Files
sse-session/README.md
2026-07-21 12:51:08 +10:00

315 lines
14 KiB
Markdown

# 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
```sh
npm install sse-session
```
The package is authored as an ES module.
```ts
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.
```ts
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:
```ts
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
```ts
const session = new SSESession(url, options);
await session.connect();
```
or, as a convenience:
```ts
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:
```ts
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;
- `onDisconnected` and `"disconnected"` run;
- `messages` stays 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:
- `messages` is closed;
- `"closed"` is emitted;
- an active transport is then aborted, which also produces `onDisconnected` and `"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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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
```ts
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:
```ts
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:
- `AsyncPushIterator`
- `EventEmitter`
- `ExponentialBackoff`
- `tryAsync`
- 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.