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