@marianmeres/sse
v1.0.0
Published
[](https://www.npmjs.com/package/@marianmeres/sse) [](https://jsr.io/@marianmeres/sse) [](LICENSE)
Readme
@marianmeres/sse
A Server-Sent Events client with namespaces, rooms and replay — reconnects pick up where they left off instead of silently losing messages — plus a mountable reference server implementing the same protocol.
Features
- Resumes, doesn't just reconnect — the server keeps a bounded window of
recent messages, the browser resends
Last-Event-IDfor free, and a reconnect inside that window loses nothing - Built on native
EventSource— no hand-rolled stream parser, and the browser's own reconnect loop does the reconnecting - Detects silent streams — the failure where a proxy drops the connection,
no error ever fires, and
EventSourcesits atOPENreceiving nothing - Namespaces and rooms — namespace isolates, rooms are channels within it
- No send queue to reason about — publishing is an ordinary HTTP request that works whether or not the stream is up
- Acknowledged publishes —
publish()resolves with a recipient count - Scales past one instance — a Redis fan-out adapter ships as
@marianmeres/sse/adapters/redis, structural over your own Redis client - Runs everywhere —
EventSourceis a global in browsers, Deno, Node 22+ and Bun, so there is no polyfill and no transport dependency - Svelte-store compatible reactive connection state
Installation
npm install @marianmeres/ssedeno add jsr:@marianmeres/ssenpm ships the client only. The reference server depends on
@marianmeres/demino, which is JSR-only, so it exists solely asjsr:@marianmeres/sse/server. The client is fully runtime-agnostic, so npm consumers lose nothing they could have used.
ws or sse?
@marianmeres/ws is the sibling package: same shape of API, different
transport, different strengths. In short —
- Reach for
ssewhen traffic is mostly server → client (notifications, live dashboards, progress, activity feeds), when losing messages across a reconnect is not acceptable, or when you want something you can debug withcurl. - Reach for
wswhen traffic is genuinely bidirectional and chatty (collaborative editing, games, chat with typing indicators), when you need presence, or when you need binary frames.
They are not drop-in replacements for one another and are not meant to be. See COMPARISON.md for the full table.
Usage
Client
import { createSSEClient } from "@marianmeres/sse";
const sse = createSSEClient({
url: "/sse",
namespace: "org-123", // required — there is deliberately no default
auth: () => session.token, // called on every (re)connect, so refresh works
});
// Subscribe and handle in one call; the returned function detaches the handler
// and unsubscribes the room when it was the last one.
const unsub = await sse.subscribe("chat", (msg) => {
console.log(msg.from, msg.payload, msg.timestamp);
});
const { recipients } = await sse.publish("chat", { text: "hello" });
unsub();
sse.dispose();connect() is optional — the first subscribe() starts the stream. Call it
explicitly when you want a readiness gate:
await sse.connect(); // resolves once connected; rejects only if retrying cannot helpNote publish() deliberately does not open a stream. It is an ordinary
HTTP request, so a publish-only client never pays for one.
Reactive state (Svelte)
<script>
import { createSSEClient } from "@marianmeres/sse";
const sse = createSSEClient({ url: "/sse", namespace: "org-123" });
// Not named `state` — that would read as `$state`, which is a Svelte 5 rune.
const connection = sse.state;
</script>
{#if $connection.connected}
<Online />
{:else if $connection.attempt > 0}
<p>Reconnecting… (attempt {$connection.attempt})</p>
{/if}Server
import { createSSEApp } from "@marianmeres/sse/server";
const { app, service } = createSSEApp("/sse", [], {
// `payload` is whatever the client's `auth()` returned — opaque, so narrow it.
verify: async (payload) => {
const { token } = (payload ?? {}) as { token?: string };
const user = await authenticate(token);
// Returning null rejects with 401, which the client treats as terminal.
return user ? { clientId: user.id, namespace: user.orgId } : null;
},
});
// Push to connected clients from anywhere in your app.
await service.publish("notifications", { text: "deploy finished" }, "org-123");
Deno.serve(app);Mounted routes, relative to the mount path:
| Method | Path | Notes |
| ------ | ----------------------------- | ----------------------------------------- |
| GET | /events | The stream |
| GET | /ping | 204, or the auth status |
| POST | /rpc | Client operations |
| GET | /stats | Guarded by httpAuth when supplied |
| POST | /publish/[namespace]/[room] | Requires httpAuth, else not mounted |
| POST | /broadcast/[room] | Requires httpAuth, else not mounted |
More than one instance
One process needs nothing. For a fleet, give each instance the Redis adapter
and they gossip over a shared pub/sub channel — bring your own client
(npm:redis v6 fits as-is; the package takes no Redis dependency):
import { createClient } from "npm:redis";
import { SSEPubSubRedis } from "@marianmeres/sse/adapters/redis";
import { createSSEApp } from "@marianmeres/sse/server";
const adapter = new SSEPubSubRedis({
// a dedicated client — the adapter takes ownership of it
client: createClient({ url: "redis://localhost:6379" }),
channel: "myapp",
});
await adapter.init();
const { app, service } = createSSEApp("/sse", [], { adapter });Replay and recipients counts stay instance-local by design — see
"Behaviour worth knowing".
Concepts
Namespace — the isolation boundary. Clients in different namespaces can subscribe to identically named rooms without ever seeing each other's messages. A client may only publish into its own namespace. It is required: a shared default would mean an app that forgets to set one has no isolation at all, and that failure is silent.
Room — a channel within a namespace. Subscribe to receive its messages.
Broadcast — the one operation that crosses namespaces. It is a separate
method rather than a flag on publish() precisely because crossing an
isolation boundary deserves its own name and its own server-side check:
allowBroadcast denies by default.
Behaviour worth knowing
Replay is instance-local. Event ids are prefixed with the server instance
that minted them. A client that reconnects to a different instance is
correctly told to start live rather than replaying someone else's sequence
numbers — so a multi-instance deployment without a shared adapter quietly
degrades to at-most-once. hello reports resumed and gap so an application
can refetch when it matters. recipients counts are instance-local too: a
publish resolves with the count on the answering instance only, whatever
happens on its peers.
The reconnect loop is mostly the browser's. EventSource retries on its
own, paced by the retry: value the server sends — jittered per connection so
a fleet does not come back in lockstep. This package only takes over when
EventSource gives up permanently, which per spec is on any non-200 response.
A non-200 is ambiguous, so we ask. EventSource hides the status, meaning
a 503 from a rolling deploy looks exactly like a 401. On a permanent
failure the client probes GET /ping to find out which it was, then either
gives up (401, 403) or resumes retrying with exponential backoff.
Terminal failures are loud. Giving up is the only non-retrying exit, so it
rejects any pending connect(), emits terminated, and logs at error level. A
silent one would be indistinguishable from a network that never recovered.
Every request is authenticated, not just the stream. There is no session to
trust — the stream and each upstream call are separate HTTP requests, and
verify() runs on all of them. Keep it cheap, and make it deterministic:
subscriptions are routed by client id, so a hook returning a different id per
call would misroute them.
Heartbeats are real events, not : comments. EventSource does not expose
comments to JavaScript, so a comment heartbeat cannot drive client-side
staleness detection. The client resets a deadline on every inbound byte and
forces a reconnect when the stream goes quiet.
disconnect() is resumable; dispose() is terminal. Handlers and rooms
survive a disconnect(), so a later connect() picks up where it left off.
Example
A complete reference app — a live ops feed with a fake CI/CD pipeline publishing into it:
deno task example # builds the client bundle, then serves on :8000Pause the stream, let the pipeline run on without you, then resume: replay fills in exactly what you missed. Then overflow the window and watch it report a gap instead. See example/README.md.
API
See API.md for complete API documentation, and docs/DESIGN.md for why the design is what it is.
