Typed SSE
August 6, 2026 ยท View on GitHub
basic-webserver supports first-class server-sent event responses through a
typed functional source. Roc owns application state and event construction;
the host owns admission, timers, transport backpressure, HTTP framing, Brotli,
cancellation, and shutdown.
Application API
Return Server.stream with a source created by Sse.unfold!:
respond! = |request, context| {
user_id = authorize!(request, context)?
initial = { user_id, revision: 0 }
source = Sse.unfold!(initial, |state| {
# The transition is an ordinary Roc closure, so it can use immutable
# Context fields without putting them in the changing state record.
view = load_view!(context.db, state.user_id)?
Ok(Emit({
event: Sse.Event.keyed("view-update", "html", render(view)),
state: { ..state, revision: view.revision },
wake: After(500),
}))
})
Ok(Server.stream(source))
}
One transition returns:
Emit({ event, state, wake }): send one completely framed event, then retain the next state;Wait({ state, wake }): retain the next state without emitting;End: finish the response cleanly; orErr(err): fail the source.
Immediately makes the next transition ready after the current event has
drained into host-owned frames. After(milliseconds) parks it on a host timer;
the maximum wait is 24 hours, and a larger value fails the source.
The application does not receive the host's wake-generation counter, a socket,
a writer, a task, or a cancellation callback.
The transition closure may retain ordinary immutable Roc values and
server-lifetime capabilities from Context. Request-scoped capabilities are
not valid retained state: in particular, a request Server.Body expires when
respond! returns, before the precommit transition runs. Prefer small IDs,
versions, and durable cursors as changing state, then query SQLite or another
context capability on each transition. The host bounds each admitted source
slot but does not trace or byte-quota the transitive Roc heap captured by
trusted application code.
Commitment and failures
The host reserves the stream and selected Brotli capacity and executes exactly
one transition before committing 200 text/event-stream.
- Initial admission failure or queue timeout returns
503. - If neither identity nor Brotli is acceptable, the host returns
406. - An initial Roc panic, application error, or oversized event is logged and
returns the ordinary bounded
500response. - A successful initial
Emit,Wait, orEndcommits the SSE response.
After commitment, HTTP cannot change the response status. A later transition, framing, or compression failure is logged and terminates that stream. It does not affect other requests.
Exactly one transition runs for a stream at a time. The next transition cannot begin until the current event has copied into identity frames or completely flushed into Brotli frames owned by the host. This is bounded host acceptance, not proof that the peer displayed or even received the event. HTTP/1.1 socket backpressure and HTTP/2 flow control naturally stop the source from running ahead of a slow reader.
Disconnect cancels parked and queued transitions immediately. A synchronous
transition that is already running cannot be safely preempted: it remains
request- and handler-accounted until it returns, and its result is then dropped.
Graceful shutdown uses the same rule before invoking shutdown!.
Limits and operations
The defaults admit 256 SSE responses and allow one framed event up to 1 MiB. They are independent from the 32 active Roc handlers because parked streams do not occupy handler workers:
config = Server.default_config
.with_sse_limits({
max_streams: 1024,
max_event_bytes: 256 * 1024,
})
max_streams and max_event_bytes must be non-zero; the event limit cannot
exceed 16 MiB. Stream saturation returns 503 before commitment. Ordinary
handlers and ready SSE transitions share the configured fixed Roc worker pool
and FIFO queue, including its queue timeout.
An SSE request remains active for metrics, access logging, response-idle
timeout, and graceful drain until its body ends, fails, or is cancelled. Parked
time is therefore part of request duration. Active-handler metrics include only
the initial handler or a currently executing transition, never a parked source.
Every transition contributes to the bounded Roc-handler duration histogram;
the access-log handler fields describe the initial respond! call, while its
overall request duration includes the complete stream lifetime.
The OpenMetrics endpoint also exports current and high-water gauges for
admitted SSE streams, leased Brotli lanes, queued Brotli operations, and
running Brotli operations under the basic_webserver_sse_* namespace. These
distinguish stream-slot pressure from compression pressure when a request is
rejected before commitment.
Content coding
The response always uses host-owned canonical headers:
Content-Type: text/event-stream
Cache-Control: no-cache
Vary: Accept-Encoding
The host negotiates identity or streaming Brotli from Accept-Encoding; Brotli
uses the bounded scale profile and is transparent to Roc. Every logical event
is flushed. Normal end writes a valid Brotli finish; disconnect abandons the
encoder without pretending the response ended cleanly. The same contract is
used over HTTP/1.1 and HTTP/2.
Event constructors
Sse.Event.data constructs a generic data event. Sse.Event.named constructs
a named event from data values. Use Sse.Event.named_with with
Sse.default_event_options for reconnect metadata. Sse.event_id validates
that an ID contains no NUL or line ending, Sse.clear_event_id emits an empty
id:, and Sse.retry_after supplies the retry delay.
Sse.Event.keyed is a convenience for protocols that encode a key and value
inside a named event. These constructors only implement SSE framing; event
names, data-field conventions, and client behavior remain application or
package policy.
Deliberately deferred optimizations
The current hot transition transfers one complete Roc-framed event allocation into host-owned frames without additional host allocations per event. Two possible optimizations are deliberately outside the accepted implementation:
- sharing response-frame capacity only among currently active streams; and
- replacing the complete Roc event with a structured host-framing ABI.
Neither changes the application API or is required for bounded, correct SSE. They should be reconsidered only with evidence that parked-stream memory or the remaining Roc event allocation matters for a target workload.
The maintained SSE capacity, fairness, timing, memory, and allocation scenarios are described in the benchmarking guide. The earlier Roc/Go comparison remains available in repository history.