Skip to content

Add example/nuts-client.js, a replay-aware EventSource helper (#146) - #157

Merged
bpacholek merged 1 commit into
mainfrom
fix-146-js-client
Sep 27, 2026
Merged

bpacholek merged 1 commit into
mainfrom
fix-146-js-client

Conversation

@bpacholek

Copy link
Copy Markdown
Contributor

Summary

example/nuts-client.js is a small, dependency-free ES module that wraps
EventSource for replay-aware pages (#146). Applications copy it in.

import { subscribe } from './nuts-client.js';

const stream = subscribe('/events', {
    topics: ['orders'],
    on: { message: (data) => console.log(data.topic, data.payload) },
});

What it does:

  • The cursor. It takes the last event ID from every event that carries
    one: the connected handshake (so a page reloaded before its first
    message keeps its start position), messages, and the reset event that
    sets it to 0 when the stream was recreated or restored.
  • Storage. It keeps the cursor in sessionStorage by default, and a
    reload resumes after the last event the tab received. localStorage or
    any other storage can be passed. sessionStorage is the default, not
    localStorage as the issue suggested, because localStorage is shared
    by a site's tabs: a reloaded tab would resume where another tab is, and
    miss what arrived in between. Storage that is disabled or full leaves the
    cursor in memory.
  • Giving up. When an EventSource gives up for good (NUTS answers a
    4xx, or a proxy in front of it fails), the helper opens a new one from
    the latest cursor after restartDelay (10 s).
  • Listeners and state. Listeners are registered by event name, which
    pairs with event_type (Optional SSE event types taken from the topic or a message header #142). raw passes message data through for
    payload_format raw (Optional raw payload mode: the NATS payload as the SSE data, without the JSON envelope #143). onState reports connecting, open,
    retrying, restarting and closed.

The demo pages in example/ and example_docker/ use it; example_docker
keeps a copy, as it does of the page itself, and make test-js checks the
two copies are the same. The README's new "Replay-aware JavaScript helper"
section documents it, and a shorter one goes on the website. The README and
website snippets now keep their cursor in sessionStorage, and the
website's snippet also stores the connected and reset IDs, as the
README's already did.

The issue's open questions:

  • The reset: settled by Send the recreated-stream reset as a reset event (#133 follow-up) #147. NUTS sends event: reset with
    {"reason": …} data and id: 0.
  • Distribution: a file in the repository to copy, not an npm package.
  • Header auth: no fetch transport. Browsers cannot send headers on
    EventSource, and NUTS already takes subscriber JWTs from a cookie
    (subscriber_jwt_cookie). Elsewhere the EventSource option accepts an
    implementation that sends headers. The README shows the eventsource
    package's fetch option; checked below against a local SSE server.
  • Tests: node:test with a fake EventSource, run in CI, plus an
    end-to-end run against the example stack.

Tests

  • example/nuts-client.test.mjs, 8 tests on Node.js's built-in runner (no
    packages; passes on Node.js 20, 22 and 24). They cover URL building and
    encoding, the default key, the cursor from each kind of event, events
    without listeners, raw, restarting after a fatal close with the latest
    cursor, restartDelay, closing, the default storage, and storage that
    throws. CI runs them with make test-js on Node.js 22, which also
    compares the two copies. The new step uses actions/setup-node@v7,
    the current major; website.yml still pins v4.
  • Hand mutation of the helper: 47 mutants, all killed. The first pass left
    five alive: two gaps (a stored cursor that needs encoding, the listener's
    event argument), now tested; three equivalent (null-storage checks that
    the try already covered, clearing a timer that already fired), now
    simplified away.
  • End-to-end, with node --experimental-eventsource against the example
    stacks:
    • example/, built from this branch. A subscription closed and reopened
      with the same storage resumed from ?last-id=993. Against a control
      subscription that stayed open, it missed nothing and received nothing
      twice (57 messages). After nats stream rm and nats stream add, the
      reset arrived within 5 s and stored cursor 0. EventSource then
      replayed the new stream from its start (ids 1, 3, 5, …), and the cursor
      followed it. /example/ serves the page, and the module as
      text/javascript; the page's module script passes node --check.
    • example_docker/ with idcttech/nuts:latest (f6cc5da): the same run
      passes, the reset within 1 s. A stale local latest (v0.3.2, older
      than the reset event) failed it at first, hence the pull in
      start.sh.
  • Header auth: through the eventsource package (5.1.2) and the helper,
    a local SSE server received Authorization on every request. EventSource's
    own reconnects sent Last-Event-ID, and after a 401 the helper
    restarted with ?last-id= set to the latest cursor.

🤖 Generated with Claude Code

A dependency-free ES module that applications copy in. It keeps the cursor
of every event that carries one (the connected handshake, messages, and the
reset that sets it to 0) in sessionStorage or a storage of your choice,
resumes from it after a reload, and opens a new EventSource from it when
one gives up for good. Listeners go by event name, which pairs with
event_type; raw passes data through for payload_format raw; the
EventSource option takes an implementation that can send headers.

The demo pages use it. make test-js runs its tests with Node.js's built-in
test runner, in CI too, and checks that example_docker keeps the same copy.
The README documents it, and the README and website snippets keep their
cursor in sessionStorage, one per tab.

The source-build demo's start.sh names the page's address, /example/, and
/example redirects there so the page's relative import resolves; the image
demo pulls the latest image before starting.

Closes #146

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@bpacholek
bpacholek merged commit c1f114e into main Sep 27, 2026
5 checks passed
@bpacholek
bpacholek deleted the fix-146-js-client branch September 27, 2026 23:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant