Realtime & SSE
Verve pushes live updates over Server-Sent Events — a one-way text/event-stream the server holds open and writes to. No WebSocket handshake, no extra protocol; it survives proxies and reconnects on its own. Two examples use it: chat (broadcast) and live visualization (streamed graph deltas).
The channel model
Clients subscribe to a named channel by opening GET /push?channel=<name>; the server keeps a broadcast hub per channel and writes framed events to every open subscriber. You can watch one with curl:
curl -N 'http://localhost:8080/push?channel=chat'-N disables buffering so frames arrive as they're written. (Streaming fetch in some runtimes buffers the body and looks hung — curl is the honest probe.)
Subscribing from an island
A chunk subscribes once at hydrate and registers a frame handler; each frame re-binds the DOM through the keyed reconciler, never innerHTML:
export fn hydrate(...) void {
pushSubscribe("chat"); // open the SSE channel
}
// called per inbound frame; parse + settle signals / listDiff
export fn chat_frame(ptr: [*]const u8, len: u32) void { ... }The chat island exports chat_send (post a message) and chat_frame (render an inbound broadcast). Posting goes through a server function — serverFnPost("postChat", …) — not the SSE channel; SSE is strictly server→client. The reply you care about comes back as a broadcast frame, so every connected tab updates, including the sender's.
Publishing: the chatFrameAfter hook
The server only knows how to broadcast generic frames; turning app events into channel frames is opt-in. Declare chatFrameAfter in your app and the server splices it into the push loop — it's discovered with @hasDecl(app, "chatFrameAfter"), so apps that don't define it pay nothing:
// src/app/api.zig
pub fn chatFrameAfter(last_seq: u64, buf: []u8) ?struct {
frame: []const u8,
seq: u64,
} {
// Return the next frame after `last_seq`, or null when caught up.
// `seq` lets late joiners and reconnects resync without duplicates.
}last_seq / seq are the ordering contract: every frame carries a monotonic sequence number. A client tracks the highest seq it has seen and the server replays from there — so a dropped connection resyncs from a snapshot instead of losing messages or double-counting.
Streamed deltas vs full snapshots
viz-live pushes graph deltas (diffGraphs → writeDeltaJson) every second rather than re-sending the whole graph: each frame is just the changed nodes/edges, applied on top of the SSR'd snapshot. On a sequence gap the client requests a fresh snapshot and resumes deltas — the same seq-ordered resync as chat, applied to structured data.
Wiring the publisher into the server
The hook is the only server-side change a realtime app needs; everything else (the hub, the /push route, framing) is built in. The viz publisher follows the identical shape — if you're adding a third channel, mirror chatFrameAfter: keep a monotonic seq, return the next frame or null, and let the runtime own the connection lifecycle.
Budget & gotchas
- SSE connections stay open — they never reach the browser's "network idle". That's expected; don't gate test automation on it.
- One stateful island per page still holds; a realtime island is that chunk, so compose the rest of the page declaratively.
- Posting is a server function (CSRF-protected); receiving is SSE (a plain GET). Keep the two directions separate in your head and the wiring is simple.
Next: Building a larger feature.