-
Notifications
You must be signed in to change notification settings - Fork 0
Server Sent Events
Source of truth:
docs/sse.md· ADR:ADR-0023
sse() builds a streaming text/event-stream response on web streams, with the wire format and producer lifecycle handled once and correctly. A primitive, not a product: no broker, no fan-out, no replay, no reconnect state — the browser's native EventSource is the client.
import { route, sse } from "lugas";
route({
handler: () =>
sse({
heartbeatMs: 15_000, // opt-in comment heartbeat; timer owned by the helper
start: (writer) => {
writer.retry(3000); // reconnect hint
writer.send({ data: "connected" }); // first byte flushes the headers
const subscription = bus.subscribe((event) => {
writer.send({ id: event.id, event: "update", data: event.payload });
});
return () => subscription.unsubscribe(); // deterministic cleanup
},
}),
});start(writer) runs synchronously inside sse() — before any wire bytes. Return a function and it runs exactly once when the stream ends by any path:
-
writer.close()(graceful, server-initiated); -
client disconnect — Bun aborts
request.signal, cancelling the response stream (pinned by probe); -
server force-close —
server.stop(true)and the drain-deadline expiry take the same path.
The heartbeat timer is cleared on the same path. This is the structural fix for the classic SSE leak: intervals and subscriptions outliving their connection.
| Method | Behavior |
|---|---|
send({ data, event?, id?, retry? }) |
Serializes one frame; returns false once ended (never throws for late sends) |
comment(text) |
Single-line comment (e.g. heartbeats) |
retry(ms) |
Standalone reconnect hint frame |
close() |
Ends the stream gracefully and runs the cleanup |
desiredSize |
Mirrors the underlying stream; null once ended |
Backpressure is explicit: send() enqueues and never blocks — poll desiredSize and pause when it drops low.
- Field order
id,event,retry,data(onedata:line per source line, CRLF normalized), blank-line terminator; the serializer is exported asformatSseEvent. -
datais a string as-is or a JSON-serializable value with the same semantics asjson()(non-finite numbers →null). - A throwing
startsurfaces as the route's redacted 500 Problem Details — never a silent empty 200 (pinned: Bun surfaces mid-stream errors on zero-byte responses as200with an empty body). - Response headers flush with the first written byte — open with an initial event,
retry, or comment. - Invalid configuration/input throw stable diagnostics
LUGAS_SSE_001/LUGAS_SSE_002.
- CORS: stream responses pass through the same compile-boundary wrappers — see CORS.
- Lifecycle: an open stream is in-flight work for the drain; close writers on shutdown for prompt exits, or rely on deadline force-close (which runs cleanups). Both paths are pinned by tests.
-
last-event-id: an ordinary request header — read it from the handler context and resume your own stream state.
Pinned by tests/sse/; evidence in docs/reports/issues/M8-002.md.
LugasJS Wiki
Server facilities
- CORS
- Server-Sent Events
- Structured Logging
- OpenAPI and Scalar
- Drizzle
- Service Lifecycle
- Assets and Body Limits
Reference
Repository