Skip to content

│ feat(res): add res.sse() for Server-Sent Events #7338

Open
danelmott wants to merge 2 commits into
expressjs:masterfrom
danelmott:master
Open

│ feat(res): add res.sse() for Server-Sent Events #7338
danelmott wants to merge 2 commits into
expressjs:masterfrom
danelmott:master

Conversation

@danelmott

Copy link
Copy Markdown

Summary

Adds a res.sse() helper for Server-Sent Events, so apps no longer hand-write the
text/event-stream headers, the wire format (data:/event:/id:/retry:), a
keep-alive heartbeat and the disconnect cleanup — the same way res.json() wraps
JSON.stringify + headers.

app.get('/events', (req, res) => {
  const sse = res.sse()
  sse.send({ hello: 'world' })                 // object -> JSON
  sse.send('tick', { event: 'ping', id: 1 })   // named event
})

The client uses the standard EventSource API:

js
const es = new EventSource('/events')
es.onmessage = (e) => console.log(e.data)
es.addEventListener('ping', (e) => console.log(e.data))

API

res.sse([options]) opens the stream and returns an SSE instance:

- send(data, [options])  send one event (chainable). options: event, id, retry.
- stream(source, [options])  emit one event per chunk; resolves when the source ends.
- comment([text])  keep-alive comment, ignored by the client (chainable).
- close()  stop the heartbeat and end the response (chainable).

send() serializes data consistently with the rest of the framework:

┌─────────────────────┬─────────────────────────┐
        data                  Sent as         
├─────────────────────┼─────────────────────────┤
 string               as-is                   
├─────────────────────┼─────────────────────────┤
 object / array       JSON.stringify          
├─────────────────────┼─────────────────────────┤
 number / boolean     String(data)            
├─────────────────────┼─────────────────────────┤
 Buffer / TypedArray  UTF-8 text              
├─────────────────────┼─────────────────────────┤
 multi-line           one data: line per line 
└─────────────────────┴─────────────────────────┘

stream() accepts any web ReadableStream (e.g. a fetch() body), Node Readable,
Blob, or async iterable.

Heartbeat

A keep-alive comment is sent every 15s by default so proxies don't drop idle
connections. Configurable or disabled per stream: res.sse({ heartbeat: 10000 }) /
res.sse({ heartbeat: false }). The timer is unref()'d and cleared automatically on
disconnect via on-finished.

Design notes

- No new dependencies  reuses debug/on-finished and built-in Node APIs.
stream() unifies sources into a Node Readable via Readable.fromWeb().
- New module lib/sse.js (kept separate like lib/view.js); res.sse is a thin
method in lib/response.js.
- Broadcasting to multiple clients is intentionally left out to keep this PR small.

Tests

test/res.sse.js (mocha + supertest): headers, all data types, event/id/retry,
multi-line, comments, chaining, write-after-close, and stream() with a web
ReadableStream, Node Readable, async iterable and Blob. Full suite passes with
--check-leaks; npm run lint is clean.

Refs #7334 

Add a res.sse() helper that opens a text/event-stream response and returns a
stream with send(), stream(), comment() and close().

send() serializes data like the rest of the framework (strings as-is,
Buffer/TypedArray as UTF-8, objects as JSON) and supports event/id/retry.
stream() emits one event per chunk of any web ReadableStream, Node Readable,
Blob or async iterable, unifying them into a Node Readable (web streams and
Blobs via Readable.fromWeb(), Node streams and async iterables directly).

A keep-alive heartbeat is started by default and cleared automatically on
disconnect via on-finished. No new dependencies are added.
feat(res): add res.sse() for Server-Sent Events
@bjohansebas bjohansebas self-requested a review June 29, 2026 04:37
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