All docs

Docs · Reference

WebSocket

a live session in the browser

The websocket personality turns a WebSocket connection into a chassis-owned session: the upgrade is an ordinary web request a stack accepts, every message the client sends is one bounded run, and the stack answers through txco://websocket/ ops. The socket never enters processor execution; an idle connection runs nothing._

Enable it with websocket in --personalities alongside web (txco dev has it on). There is no listener of its own: sessions arrive through the web head’s Upgrade handoff.

Upgrade → accept

A request carrying Upgrade: websocket runs through the stack’s web ops like any other, with two extra chassis-stamped facts:

Envelope fieldMeaning
@websocket.upgradetrue — this request asks to upgrade
@websocket.session.idthe id the session will have if accepted (ws_…, minted, unguessable)
@websocket.subprotocolswhat the client offered in Sec-WebSocket-Protocol, if anything

Nothing upgrades until a rule EXECs txco://websocket/accept. Which paths become sessions, for whom, with which Origin policy — all of that is the stack’s WHEN clause:

WHEN @websocket.upgrade == true
  && @web.req.url.path == "/chat"
  && ._sess.email != ""
  EXEC "txco://websocket/accept"
  WITH state.email = ._sess.email,
       events = &array("close")
accept WITH
statean object (≤ 4 KiB) stamped verbatim on every message as @websocket.session.state — the already-resolved principal, a room, whatever the session should carry; null members are dropped
originshost patterns allowed cross-origin (&array("app.example")); "*" disables the check. Default: same-host, or no Origin header (a non-browser client)
subprotocolswhat the stack will speak, in preference order; the negotiated one is @websocket.session.subprotocol
subprotocol_requiredtrue refuses the upgrade (400) when the client offers none of them
eventsextra runs beyond messages: &array("close")
idle_timeoutper-session, up to --websocket-max-idle-timeout
max_message_bytesper-session, up to --websocket-max-message-bytes
intooutput key, default _websocket

Output: _websocket.accepted = true, _websocket.session.id. When the run ends the chassis completes the handshake (101). An upgrade request with no accept renders as ordinary HTTP — the stack’s 401/404/426, or the default projection — so a refused upgrade is a normal HTTP error response, never a half-open socket. The chassis refuses on its own only for the connection caps and drain (503 + Retry-After) and for a bad handshake or Origin (400/403).

A stack may accept one path, several, a regex, or all; every session of a stack enters the same <stack>/_websocket/0, and the accepting path (@websocket.req.path) plus the accept-time state are how the _websocket ops tell endpoints apart.

Message → envelope

Each complete inbound message (fragments are reassembled) is one run of <stack>/_websocket/0, strictly in order per session — the next message waits for this run to finish, so per-session state never races. The route is pre-stamped, so the boot pipeline promotes it straight to the sub-stack.

Envelope fieldFrom
@srcwebsocket
@websocket.phasemessage, or close for the opt-in close run
@websocket.session.{id, stack, subprotocol, connected_at, seq}the session; seq counts its runs
@websocket.session.statethe accept op’s state, verbatim
@websocket.req.{host, path, origin, user_agent}the upgrade request, snapshotted
@websocket.msg.typetext or binary
@websocket.msg.text / @websocket.msg.datathe payload — text inline, binary base64 (data)
@websocket.msg.bytespayload size
@websocket.close.{code, reason, initiated_by}close runs only; initiated_by is client, stack, or chassis
@websocket.tenanttenant slug
@client.ipfirst X-Forwarded-For hop, else the peer
{
  "_ts": "2026-09-04T12:00:00Z",
  "_txc": {
    "src": "websocket",
    "rid": "CcAvW7aoT26xqmjgGVZbw",
    "client": {
      "ip": "203.0.113.7"
    },
    "route": {
      "tenant": "acme",
      "stack": "counter/_websocket",
      "to": "counter/_websocket/0",
      "ingress": "host:counter.local.thanks.computer",
      "hostname_verified": true
    },
    "websocket": {
      "tenant": "acme",
      "phase": "message",
      "session": {
        "id": "ws_01K4BZ…",
        "stack": "counter",
        "subprotocol": "",
        "connected_at": "2026-09-04T11:59:00Z",
        "seq": 3,
        "state": {
          "email": "a@b.c"
        }
      },
      "req": {
        "host": "counter.local.thanks.computer",
        "path": "/ws",
        "origin": "https://counter.local.thanks.computer",
        "user_agent": "Mozilla/5.0 …"
      },
      "msg": {
        "type": "text",
        "text": "{\"type\":\"increment\"}",
        "bytes": 20
      }
    }
  }
}

Every @websocket.* fact is chassis-stamped and read-only for rules; a stack may EMIT @delete = &array("@websocket.msg.text") (or .data) once it has consumed a large payload, so it stays out of the trace and any continuation. Parse text with the ordinary builtins: EMIT .msg = &json(@websocket.msg.text).

KV namespace: these runs’ stack is <stack>/_websocket, and txco://kv/* defaults to the app stack’s namespace (<stack>) — a _-nested inlet sub-stack shares its app’s state, the same rule <stack>/_mail follows — so a web request and a session see the same keys. WITH namespace still overrides.

Stack → client

A stack talks back with ops — there is no @websocket.res. Ops work mid-run (stream a long answer as several frames), from other runs (a cron pushes to a session id it stored in KV), and after a continuation resumes.

OpWITHOutput
txco://websocket/sendsession_id; text (a string as-is, any other value as JSON text) or data (base64 → binary frame)_websocket.sent.{session_id, bytes, type}
txco://websocket/replyas send without session_id — the session this envelope came from; only inside a websocket run (or its resumed continuation)same
txco://websocket/closesession_id (implicit inside a session run), code (default 1000; 1000, 1001, 1003, 1007–1011, 3000–4999), reason (≤ 123 bytes)_websocket.closed.{session_id, code}
WHEN .msg.type == "increment"
  EXEC "txco://websocket/reply"
  WITH text.count = ._kv          # sent as {"count": 42}

Errors land at <into>.error.{code, message} and the run continues: txco_websocket_session_not_found (no live session with that id for this tenant on this node — a closed session, a wrong id, or another tenant’s; the three are indistinguishable), _session_closed, _write_timeout (the client did not read within --websocket-write-timeout; the session was closed), _message_too_large, _not_session_run (reply outside a session run), _not_upgrade (accept outside an upgrade run), _invalid_close_code, _bad_argument, _state_too_large, _disabled (the personality is off on this node — every op is registered regardless, so a stack can branch).

A WITH path that resolves to nothing arrives as null: text = .missing is txco_websocket_bad_argument, never an empty frame.

Sending to a closed session errors; nothing reconnects on the stack’s behalf. A reconnecting client is a new session with a new id — continuity across reconnects is the application’s, through the authenticated identity in state or a key the client presents (doc: “WebSocket is live transport; durability is the continuation store / pub-sub”).

Limits and timeouts

FlagDefault
--websocket-max-conns8192open sessions per node; past it an upgrade is 503 + Retry-After. A memory guard (an idle session is ~50 KB and no processor time), not a throughput limit; keep it under the process’s file-descriptor limit and any front proxy’s connection cap
--websocket-max-conns-per-tenant2048per tenant per node, same refusal; stops one tenant taking a node’s whole budget
--websocket-max-message-bytes256 KiBone complete message, in or out; a larger inbound one closes the session with 1009
--websocket-inbound-queue16messages waiting behind the running one; a full queue closes with 1013 rather than growing memory
--websocket-run-timeout60sthe run one message makes; a late result is discarded, the session stays
--websocket-idle-timeout5mno application message either way → 1000 idle timeout (ping/pong does not count); per-session up to --websocket-max-idle-timeout (1h)
--websocket-ping-interval25schassis liveness pings; a missing pong closes with 1011. Under fly-proxy’s ~60s HTTP idle on the Flycast hop
--websocket-write-timeout10sone send/reply write; a peer that will not read is closed (1011) — messages are never silently dropped
--websocket-drain-timeout5sshutdown: every session gets 1001 going away; this bounds the wait
--websocket-relay(none)cross-node delivery: names a registered relay backend (the hosted build: nats); needs a shared --kvstore (redis), where the chassis records which node owns each session. Empty = a session is reachable only on its own node

Admission runs per message like any run (the _sys → tenant pin fires once per run): a 429 (rate limit, concurrency) drops that message and keeps the socket; 402/403 close with 1008; 503 closes with 1013. A suspended tenant’s upgrade request is denied before its accept op can run.

Backpressure, as built: inbound is the bounded queue above; outbound is one bounded write per send — the library serializes concurrent writers, so a slow client stalls senders for at most the write timeout, then loses the session. No outbound queue in v1, by choice: correctness over silent drops with far less machinery.

Fuel: connection lifetime is not processor lifetime. An open, idle socket meters nothing; each message run meters as a run, each op as an op; ping/pong never reaches a stack. Outbound bytes are not metered separately in v1.

Continuations: a message run that suspends is “finished” from the session’s point of view; when it resumes (on whichever node the callback or poll landed), txco://websocket/reply still reaches the session (the resume re-pins the source, and with --websocket-relay the socket’s node is found through the directory), and send with session_id = @websocket.session.id is the belt-and-braces spelling.

Streaming: @web.res.body written from a websocket run is drained and ignored — the socket is the transport; use reply, as many times as needed.

Deployment

  • Cross-node delivery is --websocket-relay. A session lives where its socket is; a send, reply or close that runs on another node looks the owner up in the shared KV (namespace _txc.websocket, one lease per session, written on open, refreshed while it lives, deleted on close, expired if the node dies) and hands the request to that node over the relay, which answers with the same errors a local write would. A node that is gone answers nothing, which reads as session_not_found; a bus or directory outage is txco_websocket_relay_unavailable. Without the flag a session is reachable only on its own node, and a send from another answers session_not_found. The chassis refuses to boot with the flag set but no shared KV or no working relay: a node that silently stayed local would hide every cross-node send as an application error.
  • Fronting proxies must pass Upgrade and keep the stream: on the hosted edge, Caddy’s reverse_proxy does by default, with stream_close_delay set so an edge reload drains sessions instead of severing them; keep stream_timeout unset. Fly’s connection-based concurrency counts each session as a connection — set the limits accordingly.
  • Hostname classes behind Cloudflare’s proxy inherit its ~100s idle window; *.stacks.thanks.computer and custom domains are gray-cloud (direct to the edge), so the chassis pings alone hold the connection.
  • A chassis terminating TLS itself (--web-tls-addr) negotiates HTTP/2; browsers open an HTTP/1.1 connection for wss:// so this works, but an h2 client cannot upgrade (RFC 8441 is not implemented).
  • The access log records an upgrade as status=101 size=0; every session logs one websocket open and one websocket close line (counts, duration, code, who closed) and never a payload. Metrics: chassis.websocket.{upgrades,messages,closes,connections}.

See examples/websocket-counter for the whole shape in eight small ops.

Edit this page · View as markdown