# Server-Sent Events (SSE)
Server-Sent Events stream events one way — server to client — over plain
HTTP. A route handler opens a `text/event-stream` response with
`http/sse`, the headers are written and flushed immediately, and the
connection leaves the normal request/response lifecycle: events are
appended as HTTP/1.1 chunked-transfer frames until the stream is closed
or the client disconnects. Browsers reconnect automatically, so there is
no polling loop and no WebSocket handshake.
## `http/sse*` Stdlib Functions
| Function | Returns | Purpose |
|---|---|---|
| `[http/sse $res ?opts]` | `%SseStream{}` | Open a `text/event-stream` response |
| `[http/sse-send $stream $data ?opts]` | `true` / `%Err` | Send one event (`:event` name, `:id` id) |
| `[http/sse-comment $stream $text]` | `true` / `%Err` | Send a comment line — manual keep-alive |
| `[http/sse-close $stream]` | `true` / `false` | Queue the terminal chunk and close |
## Opening a Stream
def events {|req res| set stream [http/sse $res %{ :retry 3000 :keep-alive 15000 }] [http/sse-send $stream "hello"] [http/sse-close $stream] nil }
`http/sse` options:
| Option | Meaning |
|---|---|
| `:retry` | Initial reconnect delay (ms), sent as the `retry:` field |
| `:keep-alive` | Idle `: ping` comment interval (ms) on a native timer |
| `:status` | Status-code override (default 200) |
| `:headers` | Extra response headers; `[http/header ...]` wins |
| `:on-close` | `{|stream| ...}` fired on teardown (see below) |
The response carries `Content-Type: text/event-stream`,
`Cache-Control: no-cache, no-store`, `Connection: keep-alive`, and
`Transfer-Encoding: chunked`; `X-Accel-Buffering: no` keeps reverse
proxies such as nginx from buffering the stream. `Content-Length` is
never emitted. Headers set with `[http/header $res ...]` replace the
matching default; the `:headers` opt only adds lines, so use
`[http/header ...]` when overriding a default such as `Content-Type`.
`http/sse` returns `%Err` when `$res` is not a `%Response{}`, the
response has already been written (`http/respond`, `http/json`,
`http/html`, `http/redirect`), is already an SSE stream, or the
connection is already streaming. It also returns `%Err` when the client
disconnects on the first flush.
## Sending Events
`http/sse-send` writes and flushes one event. Data only:
[http/sse-send $stream "hello"]
With a named event and an id:
[http/sse-send $stream "payload" %{ :event "tick" :id "42" }]
Multi-line data is split into one `data:` line per line. An `:id` lets
the browser resume with a `Last-Event-ID` header after a reconnect.
`http/sse-send` returns `true`, or `%Err` once the stream is closed
(client gone, slow-consumer teardown, explicit close).
`http/sse-comment` sends a comment line (`: text`), useful as an
application-level keep-alive. `http/sse-close` queues the terminal
chunk, drains it, and closes the connection; it returns `false` when the
stream is already closed.
## Handler Suspension
Handlers run as coroutines, so an SSE handler may suspend between
events. `[sleep]` and `[await ...]` yield only this handler — other
connections keep running:
def ticks {|req res| set stream [http/sse $res] for i in @{ 1 2 3 } { [http/sse-send $stream [str "tick-" $i] %{ :event "tick" }] [sleep 1000] } [http/sse-close $stream] nil }
Events written after a resume arrive on the same stream.
## Fan-out Pattern (pub/sub, cluster-safe)
The usual shape keeps live streams in an app-level map and pushes from a
named pub/sub handler. `:on-close` removes the entry when a client goes
away:
def clients [ref %{}] # request id -> %SseStream{} def on-tick {|ev| [values [deref $clients]] |> each {|s| [http/sse-send $s $ev~data] } } def events {|req res| set id $req~id set stream [http/sse $res %{ :keep-alive 15000 :on-close {|s| [swap! $clients {|m| [dissoc $m $id]}]} }] [swap! $clients {|m| [assoc $m $id $stream]}] [pub/subscribe "ticks" "on-tick"] nil }
`$req~id` (the request id) is a convenient stable key for the client
map. In cluster mode every worker subscribes; publishes from any runtime
reach the others through the shared broker, and each worker writes only
its own sockets.
## Client Side
Browsers consume the stream with `EventSource`:
```javascript
const es = new EventSource("/events");
es.onmessage = (e) => console.log(e.data);
es.addEventListener("tick", (e) => console.log("tick:", e.data));
es.onerror = () => {
// readyState === EventSource.CLOSED means the server closed the stream
};
````
Named events (`:event`) dispatch to `addEventListener`; unnamed events
fire `onmessage`. Reconnects are automatic after the `retry` interval.
For resumability, read the `last-event-id` header server-side
(`[get $req~headers "last-event-id"]`) and replay from your own event
log.
From the shell, `curl -N http://localhost:8080/events` prints frames as
they arrive.
## Semantics to Respect
- Chunked, one-way, close-delimited. `http/sse-close` writes the
`0\r\n\r\n` terminal chunk and closes the socket; SSE connections are
never returned to keep-alive.
- The stream is a native resource. Dropping the last reference lets
the GC finalizer tear the connection down (fd closed, pending buffer
freed). `:on-close` is not fired on GC teardown — only explicit
close, client disconnect, and handler failure call back into the VM.
- Slow clients are cut off. Pending output is capped at 1 MiB per
connection; exceeding it closes the stream (and `http/sse-send`
returns `%Err`) rather than buffering without bound.
- Reconnects are the client's job. `EventSource` retries
automatically; use `:id` + `Last-Event-ID` if you need replay.
- Cluster reloads drop live streams, like any in-flight request.
Clients reconnect and land on the new generation.
Integration coverage lives in `test/test_sse.dft` (framing, incremental
delivery, heartbeat, disconnect/`:on-close`, GC teardown, validation).
## Quick Reference
| Form | Purpose |
|---|---|
| `[http/sse $res ?opts]` | Open a stream (`:retry`, `:keep-alive`, `:status`, `:headers`, `:on-close`) |
| `[http/sse-send $stream $data ?opts]` | Send an event (`:event`, `:id`); true or `%Err` |
| `[http/sse-comment $stream $text]` | Comment line / manual keep-alive |
| `[http/sse-close $stream]` | Terminal chunk + close |
See also HTTP Server and App Server.