AkurAI Build
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.