server-sent-events: Server-Sent Events
| (require server-sent-events) | package: server-sent-events |
This package implements Server-Sent Events on Racket ports: the WHATWG wire format on the encoding side, and a small fan-out hub on the serving side. It is a protocol library, not a framework — the sibling websocket package covers the bidirectional case. Event routing, serialization (JSON or otherwise), and the HTTP server all stay with the caller.
1 Frames
procedure
(sse-encode event data [id]) → string?
event : sse-event-name? data : string? id : (or/c sse-line? #f) = #f
Encodes one event frame. data may contain line breaks; they are split on CRLF, CR, or LF and re-emitted as one data: line per piece, so multi-line payloads survive the trip. The optional id sets the client’s last-event ID.
> (require server-sent-events) > (sse-encode "edition" "{\"date\":\"2026-10-13\"}") "event: edition\ndata: {\"date\":\"2026-10-13\"}\n\n"
> (sse-encode "log" "line one\nline two") "event: log\ndata: line one\ndata: line two\n\n"
procedure
(sse-comment text) → string?
text : sse-line?
Encodes a comment frame — the conventional heartbeat, e.g. (sse-comment "ping").
procedure
v : any/c
procedure
(sse-event-name? v) → boolean?
v : any/c
sse-line? accepts non-empty single-line strings. sse-event-name? additionally excludes colons, which the wire parser reads as field separators. Both are flat contracts; violations raise before anything reaches the wire.
2 The hub
A hub fans frames out to attached output ports, replays the most recent frame to new subscribers, and keeps connections alive with comment heartbeats.
procedure
procedure
v : any/c
procedure
(hub-clients h) → (listof output-port?)
h : hub?
Currently attached clients, most recently attached last.
procedure
(hub-attach! h out) → void?
h : hub? out : output-port?
procedure
(hub-detach! h out) → void?
h : hub? out : output-port?
Attaching is idempotent. Detaching closes the port. When a write to a client fails (the usual signal of a disconnected client), the hub detaches it silently.
procedure
(hub-notify! h event data [id]) → void?
h : hub? event : sse-event-name? data : string? id : (or/c sse-line? #f) = #f
Broadcasts one frame to every attached client, synchronously, and remembers it as the frame to replay. Delivery is deliberately single-path: one notify produces exactly one frame per client.
procedure
(hub-stream h out [ #:retry retry #:heartbeat heartbeat]) → void? h : hub? out : output-port? retry : (and/c exact-integer? positive?) = 5000 heartbeat : (and/c real? positive?) = 15
The long-lived response body. Attaches out, sends the retry hint, replays the most recent frame if there is one, then emits a (sse-comment "ping") heartbeat every heartbeat seconds until the client goes away — a dead port makes the next write raise, which detaches the client and ends the call. Run it in the servlet’s response thread:
(define (events-h req) (response/output (λ (out) (hub-stream hub out)) #:mime-type #"text/event-stream" #:headers (list (header #"Cache-Control" #"no-cache") (header #"X-Accel-Buffering" #"no"))))
procedure
(hub-watch! h poll-seconds event thunk) → thread?
h : hub? poll-seconds : (and/c real? positive?) event : sse-event-name? thunk : (-> (or/c string? #f))
Starts a daemon-ish polling thread: every poll-seconds it calls thunk; a non-#f result different (by equal?) from the previous one is broadcast as event. The first call establishes the baseline and does not notify. Thunk errors are logged under the sse logger and treated as "nothing new". The payload is a plain string — serializing domain state (e.g. jsexpr->string) is the caller’s job, which is what keeps this package’s dependency set at base alone.
3 Semantics and limits
Replay is single-slot: the hub remembers only the most recent frame. Hubs broadcasting several independent event streams replay just the latest — give each stream its own hub.
Frames are written whole (Racket port operations do not interleave mid-call), but ordering between a notify from one thread and a heartbeat from the stream thread is not coordinated. Clients see both, in either order; heartbeats are comments, so this is invisible to the event stream semantics.
The hub assumes the caller owns connection setup — the response headers, buffering proxies (X-Accel-Buffering), and reconnect policy are all outside its scope.