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 functionserverFnPost("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 (diffGraphswriteDeltaJson) 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.