14 KiB
sse-session
A fetch-based Server-Sent Events client for modern browsers and Node.js runtimes that provide the standard Fetch and Streams APIs.
sse-session supports GET and POST streams, custom request hooks, connection retries, automatic reconnects, async iteration, lifecycle events, optional Page Visibility handling, and opt-in Last-Event-ID headers.
Installation
npm install sse-session
The package is authored as an ES module.
import {
SSESession,
SSEEventParser,
ExponentialBackoff,
type SSEvent,
type SSESessionOptions,
} from "sse-session";
The runtime must provide fetch, Response, ReadableStream, AbortController, FormData, and TextDecoder. A custom fetch-compatible function can be supplied through the fetch option.
Quick start
SSESession.create() constructs a session and waits until its HTTP response body is available. Stream reading then continues in the background.
import { SSESession } from "sse-session";
const session = await SSESession.create("https://api.example.com/events");
try {
for await (const event of session.messages) {
console.log(event.event, event.data);
}
} finally {
await session.disconnect();
}
Messages are also emitted as events. Construct the session directly when listeners must be installed before the connection starts:
const session = new SSESession("https://api.example.com/events");
session.on("message", (event) => {
console.log(event.data);
});
await session.connect();
Session API
Creating and connecting
const session = new SSESession(url, options);
await session.connect();
or, as a convenience:
const session = await SSESession.create(url, options);
connect() is idempotent while a transport is active. It resolves after the fetch succeeds, the response has a non-null body, onConnected runs, and the "connected" event is emitted. It does not wait for the stream to finish.
Non-2xx responses and responses without a body are treated as connection failures. The response Content-Type is not validated.
Receiving messages
Each parsed event is delivered through both of these interfaces:
const unsubscribe = session.on("message", (event) => {
handle(event);
});
for await (const event of session.messages) {
handle(event);
}
unsubscribe();
messages is an AsyncPushIterator<SSEvent>. It buffers values until they are read, and breaking out of a loop does not cancel the underlying queue. Only one async iterator may consume a messages instance at a time; use "message" listeners when multiple concurrent consumers are needed.
Do not cache session.messages across a terminal close. A later connect() replaces a closed iterator with a new one.
Stopping a session
abort() stops the active transport without ending the session:
- the active fetch is aborted;
- partial parser state is reset;
onDisconnectedand"disconnected"run;messagesstays open for a later reconnect;"closed"and"error"are not emitted.
Calling abort() with no active transport is a no-op.
disconnect() is the terminal operation for the current session lifecycle:
messagesis closed;"closed"is emitted;- an active transport is then aborted, which also produces
onDisconnectedand"disconnected".
The same SSESession object can be connected again. When that happens, it creates a fresh messages iterator.
Lifecycle and reconnect behavior
There are two separate reconnect controls:
| Situation | Option | Default | Behavior |
|---|---|---|---|
| The server ends the response body normally | persistent |
false |
Reconnect when true; otherwise close messages. |
| Reading the response body throws | attemptReconnect |
true |
Reconnect when true; otherwise emit the error and close messages. |
An intentional abort() does not trigger attemptReconnect. Automatic reconnects keep the existing messages iterator open.
Every call to connect(), including automatic reconnects, uses the configured retry policy while establishing the HTTP response. The session default is:
new ExponentialBackoff({
baseDelay: 1_000,
maxDelay: 10_000,
maxAttempts: 0, // unlimited
growthRate: 1.3,
jitter: 0.3,
});
With the default unlimited policy, create() or connect() can remain pending indefinitely when the endpoint cannot be reached. Supply a finite policy when the caller needs connection failure to reject:
const session = await SSESession.create(url, {
retry: ExponentialBackoff.from({
maxAttempts: 5,
baseDelay: 500,
}),
});
When a finite policy is exhausted, connect() closes messages, invokes the disconnection and error notifications, and rejects with the retry policy's error (AggregateError for ExponentialBackoff).
Events and callbacks
SSESession extends the exported typed EventEmitter.
| Event | Payload | Emitted when |
|---|---|---|
connected |
void |
The response stream has been opened. |
message |
SSEvent |
A complete event has been parsed. |
disconnected |
void |
An established transport ends or abort() is called. It is also emitted after a connection attempt finally fails. |
error |
Error |
A request hook, fetch, response, stream read, or onConnected callback fails. Intentional aborts are excluded. |
closed |
void |
disconnect() is called. |
The emitter provides on, once, off, emit, removeAllListeners, and waitFor. on and once return an unsubscribe function and accept an optional debounce duration as their third argument.
The corresponding option callbacks are invoked before their lifecycle event:
| Callback | Purpose |
|---|---|
onRequest(request) |
Returns the RequestInit used for each fetch. Use it for refreshed auth or other per-attempt changes. |
onConnected() |
Runs after the response body is available and before "connected". |
onDisconnected() |
Runs before "disconnected". |
onError(error) |
Runs before "error". Defaults to logging the error. |
An error thrown by onConnected is reported through onError and "error", after which the session still emits "connected" and starts reading. An error thrown by onDisconnected is passed to onError. If onError itself throws, that callback error is logged.
Options
The constructor and create() accept Partial<SSESessionOptions>.
| Option | Type | Default |
|---|---|---|
method |
"GET" | "POST" |
"GET" |
headers |
Record<string, string> |
Accept: text/event-stream and Cache-Control: no-cache |
body |
string | FormData |
Empty FormData; sent only for POST requests |
fetch |
(url: string, init: RequestInit) => Promise<Response> |
Global fetch |
onRequest |
(init: RequestInit) => Promise<RequestInit> |
Returns the input unchanged |
onConnected |
() => void |
No-op |
onDisconnected |
() => void |
No-op |
onError |
(error: Error) => void |
Logs to console.error |
retry |
Object with a run() method |
Unlimited session-specific ExponentialBackoff shown above |
attemptReconnect |
boolean |
true |
persistent |
boolean |
false |
eventParser |
Object with parseEvents() and reset() |
A new SSEEventParser per session |
Custom headers are merged over the two default headers. Each fetch also receives cache: "no-store", the session's abort signal, and a null body for GET requests.
The resolved configuration is publicly available as session.options. It can be changed between connections, although passing options to the constructor is preferable when runtime mutation is not required.
Example POST stream with refreshed authentication:
const session = await SSESession.create("https://api.example.com/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topic: "prices" }),
onRequest: async (request) => ({
...request,
headers: {
...request.headers,
Authorization: `Bearer ${await getAccessToken()}`,
},
}),
});
The custom fetch signature deliberately accepts any string URL, which allows adapters for non-standard transports:
const session = await SSESession.create("libp2p://peer/events", {
fetch: (url, init) => libp2pFetch(url, init),
});
Page Visibility integration
addBrowserVisibilityHandler() attaches a document.visibilitychange listener to an existing session:
const session = new SSESession("/events");
SSESession.addBrowserVisibilityHandler(session);
await session.connect();
When the document becomes hidden, the handler calls abort(). When it becomes visible, it calls connect(). disconnect() removes the listener; connecting the same session later reattaches it after the connection succeeds. In runtimes without document, this method is a no-op.
The helper does not inspect the document's visibility when it is attached. To avoid the initial connection while the page is already hidden, gate it explicitly:
const session = new SSESession("/events");
SSESession.addBrowserVisibilityHandler(session);
if (document.visibilityState !== "hidden") {
await session.connect();
}
There is currently no withBrowserVisibility() factory.
Last-Event-ID reconnects
Resume headers are opt-in:
const session = new SSESession("/events", { persistent: true });
await SSESession.addLastEventIdReconnect(session);
await session.connect();
The helper remembers the most recent non-undefined id received after it is attached and adds it as Last-Event-ID on subsequent fetches. The original onRequest hook runs afterward and can further modify the request. No header is sent until a non-empty ID has been observed.
Attach the helper before connect() if the first event must always be observed for later resume. The helper changes request headers only; it does not itself enable persistent or attemptReconnect.
Parsed events
interface SSEvent {
data: string;
event?: string;
id?: string;
retry?: number;
}
data is required because frames without any data field are not emitted. Multiple data lines are joined with \n. The optional event field remains undefined when the server omits it; the parser does not fill in "message".
The parser exposes a numeric retry field on the returned event, but SSESession does not apply it to its retry policy.
SSEEventParser
SSEEventParser is the incremental parser used by the session and is also exported for direct use:
const parser = new SSEEventParser();
for (const chunk of chunks) {
for (const event of parser.parseEvents(chunk)) {
console.log(event);
}
}
parser.reset();
It buffers incomplete input until a blank line completes a frame, preserves multi-byte UTF-8 characters split across chunks, ignores comments and unknown fields, removes one optional space after a field's colon, and recognizes data, event, id, and numeric retry fields. It accepts \n, \r, and \r\n line endings.
Current parser limitation: a \r\n pair split between two byte chunks is not handled correctly. Keep those two bytes in the same chunk, or use \n-delimited streams, until that edge case is fixed.
Other exports
The package currently exports all source utilities as part of its public entry point:
AsyncPushIteratorEventEmitterExponentialBackofftryAsync- the option, event-map, parser, retry, and backoff TypeScript types
These utilities are used internally by SSESession but can also be imported directly from sse-session.
Current caveat for in-progress connection retries
abort() aborts the fetch signal, but it does not cancel the configured retry runner itself. If connect() is currently retrying failed fetches under the default unlimited policy, aborting or disconnecting the session does not guarantee that the pending connect() promise will settle. Use a finite retry policy when connection attempts need a bounded lifetime.