Menu
Public repository / fee5b6c2cde89fa6bf8dc51068af7336b56a88b8
AkurAI-Framework / site/content/docs/realtime.md
Latest change a0501370bec01f30f21f48fbebd95284b81aca77 - akurai-ws: WebSocket client — connect/connect_url, client handshake, masked frames, Role on WsConn (OLI-437) by Ólafur Búi Ólafsson
# 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.
```rust
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:
```js
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.
```rust
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 `1007` on violation); oversized
frames close `1009`; protocol errors close `1002`.
On the client:
```js
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).
### WebSocket client
The same crate dials *out*: a service subscribing to another service's socket
(a home hub to Home Assistant, a worker to a job feed). `connect_url` performs
the RFC 6455 §4.1 client handshake — random `Sec-WebSocket-Key`, `101`
required, `Sec-WebSocket-Accept` verified — and returns the same `WsConn`, in
the client role: every frame it sends is masked, as the spec demands of
clients, and incoming server frames are read unmasked.
```rust
use akurai_ws::{connect_url, Message};
let mut conn = connect_url(
"ws://127.0.0.1:8123/api/websocket",
&[("Authorization", "Bearer …")], // extra request headers, if any
)?;
conn.send_text(r#"{"type":"ping"}"#)?;
if let Some(Message::Text(reply)) = conn.recv()? {
println!("{reply}");
}
conn.close(akurai_ws::close::NORMAL, "done")?;
```
`connect(addr, host, path, headers)` is the lower-level form when the socket
address and the `Host` header differ. Only `ws://` is supported — TLS
terminates at the edge, as everywhere in the framework. The handshake has a
15-second read timeout; the data phase has none. Reconnect policy (backoff,
resubscribe) belongs to the caller, which knows what the stream means.
## Live collection changes
Every running project streams its record changes at
**`GET /api/collections/events`** — no handler code. Each create, update and
delete through the [auto-API](/docs/auto-api) (or the
[image API](/docs/images)) arrives as a named event:
```
event: update
data: {"collection":"jobs","record":{"id":4,"created":1750000000,"status":"running","step":12}}
```
A delete carries only `{"id": …}` as its `record`. Narrow the stream with
`?collection=jobs,images`. A `: ping` comment every 15 s keeps idle
connections open. Every open browser sees the same changes, so two tabs (or two
people) watching one page stay mirrored:
```js
const es = new EventSource("/api/collections/events?collection=jobs");
es.addEventListener("update", (e) => render(JSON.parse(e.data).record));
```
`events` is therefore a reserved collection name.
## 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.