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 field | Meaning |
|---|---|
@websocket.upgrade | true — this request asks to upgrade |
@websocket.session.id | the id the session will have if accepted (ws_…, minted, unguessable) |
@websocket.subprotocols | what 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 | |
|---|---|
state | an 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 |
origins | host patterns allowed cross-origin (&array("app.example")); "*" disables the check. Default: same-host, or no Origin header (a non-browser client) |
subprotocols | what the stack will speak, in preference order; the negotiated one is @websocket.session.subprotocol |
subprotocol_required | true refuses the upgrade (400) when the client offers none of them |
events | extra runs beyond messages: &array("close") |
idle_timeout | per-session, up to --websocket-max-idle-timeout |
max_message_bytes | per-session, up to --websocket-max-message-bytes |
into | output 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 field | From |
|---|---|
@src | websocket |
@websocket.phase | message, or close for the opt-in close run |
@websocket.session.{id, stack, subprotocol, connected_at, seq} | the session; seq counts its runs |
@websocket.session.state | the accept op’s state, verbatim |
@websocket.req.{host, path, origin, user_agent} | the upgrade request, snapshotted |
@websocket.msg.type | text or binary |
@websocket.msg.text / @websocket.msg.data | the payload — text inline, binary base64 (data) |
@websocket.msg.bytes | payload size |
@websocket.close.{code, reason, initiated_by} | close runs only; initiated_by is client, stack, or chassis |
@websocket.tenant | tenant slug |
@client.ip | the client’s address: the socket peer, or behind --web-trusted-proxies the client the proxies recorded — never a header the client chose |
{
"_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.
| Op | WITH | Output |
|---|---|---|
txco://websocket/send | session_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/reply | as send without session_id — the session this envelope came from; only inside a websocket run (or its resumed continuation) | same |
txco://websocket/close | session_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
| Flag | Default | |
|---|---|---|
--websocket-max-conns | 8192 | open 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-tenant | 2048 | per tenant per node, same refusal; stops one tenant taking a node’s whole budget |
--websocket-max-message-bytes | 256 KiB | one complete message, in or out; a larger inbound one closes the session with 1009 |
--websocket-inbound-queue | 16 | messages waiting behind the running one; a full queue closes with 1013 rather than growing memory |
--websocket-run-timeout | 60s | the run one message makes; a late result is discarded, the session stays |
--websocket-idle-timeout | 5m | no application message either way → 1000 idle timeout (ping/pong does not count); per-session up to --websocket-max-idle-timeout (1h) |
--websocket-ping-interval | 25s | chassis liveness pings; a missing pong closes with 1011. Under fly-proxy’s ~60s HTTP idle on the Flycast hop |
--websocket-write-timeout | 10s | one send/reply write; a peer that will not read is closed (1011) — messages are never silently dropped |
--websocket-drain-timeout | 5s | shutdown: 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.
Attached transports (a PTY or a service on a session)
A session can bind a live resource in a workspace to itself and stop
running the stack per frame. Two kinds exist. workspace://<name>/attach,
run inside a message, hands the socket a real pseudo-terminal (see workspaces and examples/workspace-terminal). workspace://<name>/connect hands it a service the workspace is already
running — named from a chassis-owned table, never addressed by port — such
as its VNC display (see workspaces and examples/workspace-connect).
The personality cannot tell the two apart: the binding lives in its own
registry rather than in the message path, and everything below applies to
both.
The model is the chassis’s own: the stack evaluates the upgrade and the attach message with everything it normally has and grants the binding, once. From then on the bytes flow over the capability it granted — the same way possession of a continuation’s rcid is authorization, and the stack does not re-run to move each byte. An attachment is a granted capability, not a bypass.
Framing. The chassis sends one {"type":"attached"} status frame the
moment the binding is live, before any resource byte — a client that does
not greet first (VNC is the exception) waits for it before sending. After
it, the reader routes frames straight to the binding instead of the
per-message queue:
| Direction | Binary frame | Text frame |
|---|---|---|
| client → process | stdin bytes, verbatim | control: {"type":"resize","cols","rows"}, {"type":"signal","signal":"INT"}, {"type":"detach"} |
| process → client | stdout bytes (a TTY merges stderr in) | status: {"type":"attached"} (once, first), {"type":"exit","code":N}, {"type":"expired"}, {"type":"error","error":"…"} |
Keeping terminal traffic as bytes avoids both base64 and UTF-8 boundary
problems. Unknown control types get one error frame and the session stays —
as do resize and signal on a service binding, which has nothing to
resize or signal.
Lifecycle. The socket closing (tab closed, idle, write timeout) cancels
the process, exactly as a timeout does. The process exiting flushes the last
output, sends exit, and closes 1000. {"type":"detach"} cancels the
process and the lease and returns the socket to ordinary per-message runs. max_duration sends expired then exit. A service closing its end of a connect binding reads as a process exit with code 0. Both directions count
as activity, so the idle timeout never fires on a live terminal — max_duration, not idle, is what bounds it.
Flow control copies streaming’s shape: the pump does a bounded, blocking
write per read whose deadline is --websocket-write-timeout, so a client
too slow to drain is closed rather than buffered without limit. No credit
protocol.
Node affinity. The session runs on the node holding the socket, and the
lease records that node id. txco://websocket/send to an attached session
is refused with txco_websocket_attached (cross-node too) rather than
racing the pump. Persistence, scrollback and reattach are the workspace’s
job (run tmux new -A -s main), never the chassis’s. Secrets are refused on
an attach: an interactive user can type a value back out.
Deployment
- Cross-node delivery is
--websocket-relay. A session lives where its socket is; asend,replyorclosethat 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 assession_not_found; a bus or directory outage istxco_websocket_relay_unavailable. Without the flag a session is reachable only on its own node, and asendfrom another answerssession_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_proxydoes by default, withstream_close_delayset so an edge reload drains sessions instead of severing them; keepstream_timeoutunset. 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.computerand 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 forwss://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 onewebsocket openand onewebsocket closeline (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.