Realtime: WebSocket & SSE
The framework ships two ways to push data to a connected client, both written from scratch on std with zero runtime dependencies: WebSocket (RFC 6455, bidirectional) and Server-Sent Events (the WHATWG text/event-stream spec, server→client). Both follow the standards to the letter — a browser's native WebSocket and EventSource talk to them directly, no client library needed.
How it works under the hood
A normal handler returns a Response. A realtime handler instead returns a Reply::Upgrade: the server writes the response head, then hands the raw socket to a closure that owns it for the rest of the connection. That closure runs on a dedicated thread, so a long-lived stream never ties up a request-pool worker. TLS still terminates at the edge (nginx/Caddy); the server speaks plain HTTP.
Server-Sent Events
A one-way stream of text events. Ideal for live counters, notifications, logs, progress, and live-reload — anything the server pushes and the client only reads. The browser reconnects automatically.
use akurai_http::{sse, Reply};
fn events() -> Reply {
sse(|mut sink| {
let mut n = 0;
loop {
n += 1;
sink.event("tick", &n.to_string())?; // event: tick\ndata: N\n\n
std::thread::sleep(std::time::Duration::from_secs(1));
}
})
}
The SseSink handles the wire format: data, named event, id, retry, and comment (for keep-alive heartbeats). Multi-line data is split into multiple data: lines per the spec, and every send is flushed so events arrive immediately. When the client disconnects the next write fails and the closure returns — the thread ends cleanly.
On the client, it is one line:
const es = new EventSource("/api/events");
es.addEventListener("tick", (e) => console.log("tick", e.data));
The built-in demo endpoint is GET /api/events.
WebSocket
Full-duplex messages over a single connection. Use it for chat, collaborative editing, live dashboards, and anything where the client also sends.
use akurai_ws::{upgrade, Message};
// in a handler returning `akurai_http::Reply`:
upgrade(req, |mut conn| {
while let Some(msg) = conn.recv()? {
match msg {
Message::Text(t) => conn.send_text(&t)?, // echo
Message::Binary(b) => conn.send_binary(&b)?,
Message::Close(_) => break,
_ => {}
}
}
Ok(())
})
upgrade performs the RFC 6455 handshake — it validates the Upgrade, Connection, Sec-WebSocket-Key, and Sec-WebSocket-Version headers and answers with the SHA-1 + base64 Sec-WebSocket-Accept token, or returns 400 if the request is not a valid WebSocket upgrade.
WsConn does the rest of the protocol for you:
- Framing & masking — client frames must be masked (and are rejected if not);
server frames are sent unmasked, per spec.
- Fragmentation — multi-frame messages are reassembled before you see a
Text or Binary.
- Ping/pong — pings are answered with pongs automatically.
- Close handshake — a peer close is echoed and surfaced as
Message::Close. - Validation — text is UTF-8 checked (close
1007on violation); oversized
frames close 1009; protocol errors close 1002.
On the client:
const ws = new WebSocket("ws://localhost:8090/api/ws");
ws.onmessage = (e) => console.log("echo:", e.data);
ws.onopen = () => ws.send("hello");
The built-in demo endpoint is GET /api/ws (an echo server).
Choosing between them
- SSE — server→client only, text, auto-reconnect, trivial client. Reach for
it first; most "live" features are one-directional.
- WebSocket — both directions, text or binary, lower overhead per message.
Use it when the client must send too.
Limits
Each realtime connection holds one OS thread for its lifetime — the right model at the small/medium scale the framework targets, not for tens of thousands of idle connections. WebSocket negotiates no extensions (no permessage-deflate), and a single frame or reassembled message is capped at 16 MiB to bound memory.