Skip to content

Server Sent Events

Rizky Zulkarnaen edited this page Sep 9, 2026 · 1 revision

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
      },
    }),
});

The cleanup contract (the point of the helper)

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-closeserver.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.

Writer

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.

Wire format and failure semantics

  • Field order id, event, retry, data (one data: line per source line, CRLF normalized), blank-line terminator; the serializer is exported as formatSseEvent.
  • data is a string as-is or a JSON-serializable value with the same semantics as json() (non-finite numbers → null).
  • A throwing start surfaces as the route's redacted 500 Problem Details — never a silent empty 200 (pinned: Bun surfaces mid-stream errors on zero-byte responses as 200 with 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.

Composition

  • 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.

Clone this wiki locally