@portable/sse
Reads, writes and reconnects server-sent event streams (text/event-stream). Runs in browsers, Cloudflare Workers, Deno, Bun and Node 24+.
Replaces eventsource-parser, the eventsource polyfill, @microsoft/fetch-event-source, and the event writers in better-sse and hono's streamSSE.
npm install @portable/sseRead events from any response, such as a streaming LLM API:
import * as Sse from '@portable/sse';
import * as Streams from '@portable/streams';
const response = await fetch('https://api.example.com/v1/chat', {
method: 'POST',
headers,
body,
});
const events = response.body!.pipeThrough(new Sse.DecoderStream());
for await (const event of Streams.toAsyncIterable(events)) {
console.log(event.data);
}Streams.toAsyncIterable from @portable/streams iterates a stream in every runtime. Where the runtime iterates streams itself, for await (const event of events) also works.
Keep a stream open the way EventSource does, with your own method, headers and body:
const events = Sse.connect(
new Request('https://example.com/feed', {
headers: { authorization },
}),
{
signal: controller.signal,
},
);Write events from a server:
const body = messages.pipeThrough(new Sse.EncoderStream());
return new Response(body, {
headers: { 'content-type': 'text/event-stream' },
});API
Sse.Event
What the decoder and connect produce, with the same values EventSource gives a MessageEvent:
| Field | Type | Value |
|---|---|---|
event | string | The block's event field, or 'message'. |
data | string | The block's data fields joined with \n. |
id | string | undefined | The last event ID. It keeps the last id field's value until another id field changes it, and is undefined until the stream sends one. |
retry | number | undefined | A reconnection time from a retry field that arrived since the previous event. |
A block without a data field is not an event, but its id and retry still apply.
new Sse.DecoderStream(options) and Sse.decode(input, options)
DecoderStream is a TransformStream<Uint8Array | string, Sse.Event>. decode reads a complete body, as a string or bytes, into an array. Both follow the WHATWG event stream rules: bytes are UTF-8, one leading BOM is skipped, CR, LF and CRLF all end a line, comments and unknown fields are ignored, an id containing NUL is ignored, retry is read only when it is all ASCII digits, and an event without its closing blank line is dropped when the input ends.
| Option | Default | Description |
|---|---|---|
maxLength | Infinity | Longest line, and longest data of one event, in UTF-16 code units. A longer one throws Sse.MaxLengthError, which stores maxLength. |
signal | DecoderStream only. Fails the stream with signal.reason and cancels its source. |
new Sse.EncoderStream(options) and Sse.encode(message)
EncoderStream is a TransformStream<Sse.Message, Uint8Array>. encode returns one message as a string. A message has optional event, data, id, retry and comment fields, so a decoded Sse.Event can be written back.
- Fields are written in the order
event,data,id,retry, followed by the blank line that dispatches the event. A comment comes first and has no blank line, so{ comment: '' }is a keep-alive. datais split on CR, LF and CRLF intodatalines, so it arrives unchanged.- A CR or LF in
eventorid, a NUL inid, or aretrythat is not a non-negative integer throwsSse.InvalidInputError. EncoderStreamtakes asignaloption, asDecoderStreamdoes.
Sse.connect(request, options)
Sends request (a Request, URL or string) with fetch and returns a ReadableStream<Sse.Event> that reconnects like EventSource:
- It adds
Accept: text/event-streamunless the request has anAcceptheader. - A response other than 200, or one whose content type is not
text/event-stream, fails the stream withSse.ResponseError. The error'sresponseis left unread so you can read the error body. A 204 closes the stream. - When a response ends or fails, it waits and sends the request again with
Last-Event-IDset to the last event ID. An emptyidfield removes the header. Events continue in the same stream. - Cancelling the stream, or aborting
signalor the request's own signal, aborts the request in flight and any wait.
Each attempt sends a clone of request, so a POST body is sent again on every reconnect. connect clones the request once when you call it, which leaves yours unread, and throws a TypeError if its body was already read. A ReadableStream body works too: the clone keeps a copy of it in memory for as long as the stream runs. Of the browsers, only Chrome can send a ReadableStream body, and only over HTTP/2 or HTTP/3. Firefox and Safari send the text [object ReadableStream] instead, so collect the stream first there with body: await Streams.toBytes(stream) from @portable/streams.
| Option | Default | Description |
|---|---|---|
attempts | Infinity | How many attempts in a row, counting the first, may fail before connect stops reconnecting, as in WebSockets.connect and Timers.retry. An accepted response resets the count, and the end of its body counts as the first failure, so attempts: 1 never reconnects. When the count is used up, the stream errors with the last error, or closes if the last response ended normally. A value below 1 throws a RangeError. |
delay | { initial: 1000, max: 30_000 } | Wait before each reconnect: milliseconds, exponential backoff { initial, max, factor?, jitter? } (full jitter by default), or ({ attempt, error }) => ms. attempt counts reconnects since the last accepted response. A retry field from the server replaces it. It is Timers.Delay from @portable/timers, and an invalid one throws a RangeError. |
fetch | globalThis.fetch | Sends each Request. A wrapper can change the request first, such as setting a fresh token. |
idleTimeout | Infinity | How long to wait, in ms, for the response headers and then for each chunk of the body. If no data arrives in that time, connect aborts the request and handles the attempt as a failed connection with a TimeoutError DOMException, so by default it reconnects. Keep-alive comment lines from the server also restart the wait. Set it a little above the server's keep-alive interval, so a stalled connection reconnects and a quiet but healthy one doesn't. |
maxLength | Infinity | As in DecoderStream. |
reconnectIf | see below | (error, { attempt }) => boolean | Promise<boolean>, called after each connection ends while attempts isn't used up. error is undefined when the response ended normally. Return false to stop reconnecting: the stream then closes if error is undefined, and errors with error otherwise. |
signal | Fails the stream with signal.reason. |
By default connect reconnects after every error except ResponseError and MaxLengthError, and after a response that ends normally. For a request you don't want repeated, such as an LLM completion, pass reconnectIf: () => false. To also retry a 5xx response:
Sse.connect(request, {
reconnectIf: (error) =>
!(error instanceof Sse.ResponseError) ||
error.response.status >= 500,
});Types
| Type | Description |
|---|---|
Sse.Event | An event read from a stream, as EventSource would dispatch it. |
Sse.Message | A message to write to an event stream. Every field is optional, and an Sse.Event fits. |
Sse.Delay | The delay option, Timers.Delay from @portable/timers. |
Sse.Backoff | { initial, max, factor?, jitter? }, exponential backoff for delay. |
Why use this over eventsource-parser, eventsource and fetch-event-source
eventsource-parser only reads. The eventsource polyfill and fetch-event-source only connect, through callbacks, and neither returns a stream you can pipe or cancel. better-sse and hono only write. This package does all three with web streams and no dependencies outside @portable/*.
- On Node 24,
DecoderStreamdecoded bytes faster thanTextDecoderStreampiped into eventsource-parser'sEventSourceParserStream: 1.4× on one LLM token event per chunk, 1.7× on 2 to 16 byte chunks, 1.6× on a 1 MB line in 128-byte chunks, and at the same speed on 16 KB chunks.connectread a mocked response 1.5× faster thanfetchwith the same pair. The exception isdecodeon one complete string, which ran at about two thirds of the speed of eventsource-parser'screateParser().feedon short events, and at the same speed on the 1 MB line. Seetest/*.bench.ts. - Events carry the values
EventSourcegives:eventdefaults to'message', andidis the last event ID, so anidfrom a block without data isn't lost. eventsource-parser reportsidonly on the block that set it. - fetch-event-source dispatches a block without
dataas an empty message, acceptstext/event-streamingas a content type, retries an HTTP error forever, and useswindowanddocument, so it fails in Workers and Node. It also ignores a signal that is already aborted. - The
eventsourcepolyfill can't send a body without a customfetch. Its issue #326 reports events lost on mixed CRLF and LF line breaks, whichtest/DecoderStream.conformance.tscovers. - better-sse writes multi-line
dataas onedata:line, so every line after the first is lost, and strips trailing line breaks. maxLengthgives the same result however the input is chunked.
test/PARITY.md maps every upstream test to ours and lists the upstream bugs this package handles differently.
| @portable/sse | eventsource-parser | eventsource | Hand-written with web APIs | |
|---|---|---|---|---|
| Decode 2,000 LLM token events, one per chunk | 365 | 252 | – | 280 |
| Decode named events in 16 KB chunks | 1,370 | 1,380 | – | 794 |
| Decode events in 2 to 16 byte chunks | 560 | 332 | – | 500 |
| Decode a 1 MB data line in 128-byte chunks | 81.3 | 52.4 | – | – |
| Decode 2,000 LLM token events from one string | 5,120 | 8,300 | – | – |
| Encode LLM token events | 12,100 | – | – | 5,400 |
Encode LLM token events through EncoderStream | 235 | – | – | 201 |
| Connect and read 2,000 events | 346 | 225 | 189 | – |
Operations per second from pnpm bench, higher is better, in Node 24.22.0, on Intel(R) Xeon(R) Processor @ 2.10GHz (4 cores). A dash means the library has no equivalent.
| @portable/sse | eventsource-parser | eventsource | Hand-written with web APIs | |
|---|---|---|---|---|
| Minified and gzipped | 1.8 kB | 1.9 kB | 3.6 kB | built in |
| Runtime dependencies | 4 | 0 | 1 | – |
| Node built-ins used | none | none | none | none |
| TypeScript types | included | included | included | – |
| Version compared | – | 4.1.2 | 5.1.3 | – |
Sizes cover the imports in the usage example, or the equivalent imports of each library, bundled for the browser by scripts/bundle-size.ts. Runtime dependencies count every package installed with the published package. A library that uses Node built-ins needs polyfills in a browser and the nodejs_compat flag in Cloudflare Workers.
- eventsource-parser's
onCommentandonErrorcallbacks have no equivalent. Comments and invalid lines are ignored, asEventSourceignores them. - An event without its closing blank line is dropped at the end of a stream, as
EventSourcedoes. Some servers omit the last blank line, and their last event is lost in both. connectreconnects after a response that ends normally, likeEventSource. fetch-event-source stops there instead. PassreconnectIf: () => falsefor the same behavior.connectdoesn't close the stream while a browser tab is hidden. fetch-event-source closes it by default.connectdoesn't setcache: 'no-store', because Cloudflare Workers needs a compatibility flag for it. Setcacheon theRequestif you need it.datais a string, so callJSON.stringifybefore writing an object. better-sse'ssession.pushserializes the data itself.- better-sse sends keep-alive comments itself. With
EncoderStream, write{ comment: '' }on an interval.