WebSocket client
Anything that streams — an RPC subscription, an exchange's order feed, a Discord/Slack gateway, the mempool — used to mean cron + polling. The WebSocket client replaces that with a live connection. It is a general transport, not a blockchain feature: it lives in the standard library next to the HTTP client, and it is gated by the same net(host) capability and scope.
It is not a minimal one-connection-at-a-time client: it multiplexes thousands of feeds in a single thread (readiness-driven via epoll/kqueue/IOCP — ~0 CPU while idle), reconnects on its own with bounded backoff and resubscribe, detects half-open connections via keepalive, and bounds memory with backpressure. The two axes of scale: ws_select (vertical fan-in, N feeds in 1 thread) and parallel_map (horizontal fan-out, N workers × 1 connection).
-- Doc example: the WebSocket client is a GENERAL transport (RPC subscriptions,
-- exchange feeds, chat, mempool — anything that streams), gated by the SAME
-- net(host) capability as HTTP. A connection to an undeclared host is refused at
-- the capability check, before any socket opens. (Opening a live feed needs real
-- net, which the docs sandbox denies — so this example proves the gate; the live
-- round-trip and the WS→SSE bridge are shown in the page prose.)
intent: "doc example: WebSocket client capability gating"
require net("stream.allowed.com")
task connect_undeclared()
give ws_connect("wss://feed.evil.example.com/ws") -- host not declared → denied
task bad_scheme()
give ws_connect("https://stream.allowed.com/ws") -- WebSocket needs ws:// or wss://
test "a WebSocket to an undeclared host is blocked (same net gate as HTTP)"
assert_error(connect_undeclared)
test "the scheme must be ws:// or wss:// — an http(s) URL is a directed error"
assert_error(bad_scheme)
-- Batch 15: ws_select multiplexes many feeds in one thread (Go's `select {}` in
-- one call). Its opts are validated up front — a typo doesn't become a silent
-- misconfiguration. These are PURE checks (no live server; the multiplex, reconnect
-- and keepalive round-trips are exercised against a local mock in the test suite).
task bad_on_full()
-- on_full only accepts "block" (default), "drop_oldest", "error"
give ws_connect("wss://stream.allowed.com/ws", nothing, {"on_full": "explode"})
task unknown_opt()
give ws_connect("wss://stream.allowed.com/ws", nothing, {"reconect": {}}) -- typo
test "ws_select over an empty set is nothing (no feeds → nothing to wait for)"
assert_eq(ws_select([], 0), nothing)
test "ws_status of an unknown handle is \"closed\" (never lies, never panics)"
assert_eq(ws_status(999999), "closed")
test "connect opts are validated: a bad on_full / an unknown option error clearly"
assert_error(bad_on_full)
assert_error(unknown_opt)
The capability model (no new door)
ws_connect needs net(host) — deny-by-default, scoped by hostname, exactly like http_get. There is no WebSocket-specific permission: a WebSocket is transport, gated like any other network egress.
- Undeclared host → denied at the capability check, before any socket opens.
- Inside a
sandbox(capabilities emptied) → denied. wss://validates the server certificate against the OS root CAs, just likehttps://.
require net("stream.exchange.com")
let conn be ws_connect("wss://stream.exchange.com/ws") -- opaque handle
Send, receive, close
ws_send(conn, json_encode({"op": "subscribe", "channel": "trades"})) -- text or bytes
let msg be ws_recv(conn, 5) -- next message, or `nothing` after the 5s timeout
-- msg is {"type": "text" | "binary" | "close", "data": …}
when msg != nothing and msg["type"] == "text"
print(msg["data"])
ws_close(conn) -- clean close frame (idempotent)
ws_connect(url, headers?, opts?)→ an opaque handle (like adb_openconnection).headersis a text→text map — asecretvalue (e.g.bearer(...)) materializes only at the socket, exactly like HTTP headers.opts={"timeout", "max_message_size", "subprotocols", "max_queue", "max_queue_bytes", "on_full", "reconnect", "keepalive"}(see below).ws_recv(conn, timeout?)→ the message map, ornothingon timeout (default 30s, the same criterion aswait_for). It never blocks forever; ping/pong frames are handled transparently.ws_send(conn, data)sends text (ifdatais text) or binary (if bytes). Asecretis refused —reveal()it first if you truly must.ws_close(conn)sends a clean close frame; closing an already-closed handle is a no-op.
Multiplexing thousands of feeds: ws_select (the event loop)
A ws_recv waits on ONE connection. To watch N feeds without burning a thread per connection, ws_select waits for the first one with data — Go's select {} over channels, in a single call. It is readiness-driven (epoll/kqueue/IOCP via mio): ~0 CPU while idle (it sleeps in the kernel, never busy-spins) and scales to thousands.
require net("feed.example.com")
let feeds be {"trades": ws_connect("wss://feed.example.com/trades"),
"book": ws_connect("wss://feed.example.com/book")} -- name→handle map
let live be true
while live
let m be ws_select(feeds, 30) -- first ready of ALL feeds, or nothing on timeout
when m == nothing
set live to false -- 30s idle → stop (tune to your feed)
when m != nothing
-- m adds "conn" (WHICH handle fired) and, for a map, "name"
when m["type"] == "close"
print("feed " + m["name"] + " dropped") -- you know exactly which to resubscribe
when m["type"] == "text"
print(m["name"] + ": " + m["data"])
ws_select(conns, timeout?)→{conn, type, data, name?}of the first ready connection, ornothingon timeout.connsis a list of handles or a name→handle map (then the result carriesname). A dropped connection surfaces as{type: "close", conn}and is retired — you know WHICH to resubscribe. A fatal protocol error surfaces as a catchable error (withconn).ws_select_all(conns, timeout?)→ a list of every message ready this tick (one per connection; batch processing).ws_broadcast(conns, data)→ send the same message to many connections at once → returns the count.
Resilience: reconnection and keepalive (opt-in)
Without these options the behavior is unchanged — nothing silent. With them, a long-lived agent survives drops and detects dead sockets without userland plumbing.
let conn be ws_connect("wss://feed.example.com/ws", nothing, {
"reconnect": {"max_retries": 10, "backoff": 0.5, "on_reconnect": resubscribe},
"keepalive": {"interval": 20, "timeout": 10}})
reconnect={max_retries?, backoff?, backoff_max?, on_reconnect?}— transparent reconnection with bounded exponential backoff.on_reconnectis a task that runs after reconnecting (it receives the handle) — resubscribe there so you never lose your feed state. Every reconnect re-checksnet(host)(never escalates scope) andwssre-validates the cert. Withoutreconnect, a drop surfaces asclose.keepalive={interval, timeout?}— auto-ping everyinterval; no pong withintimeout→ the connection is dead → reconnect (if enabled) orclose. Half-open detection most libraries skip.ws_status(conn)→"open" | "reconnecting" | "closed".ws_stats(conn)→{sent, received, reconnects, queued, queued_bytes, last_pong_ago, status, subprotocol}— so a keeper of thousands of feeds can see itself (aligns with observability). Stats never lie:sentcounts only messages that actually went out (or got queued to flush);last_pong_agois seconds since the last pong (nothingif none yet). Any inbound traffic counts as liveness — a pong delayed behind a busy feed never falsely kills a live connection.
The sync-engine boundary. There is no background thread per connection (that would break CSP isolation and the "no thread-per-connection" promise). Keepalive and reconnection advance while the program is insidews_select/ws_recv/ws_status— which is why the pattern is awhileloop withws_select. A lonews_sendwith no following recv won't tick the timers.
Fan-out with parallel_map: N workers × 1 connection
ws_select is the vertical axis (many feeds, one thread). The horizontal axis is parallel_map: each worker gets its own interpreter (inheriting caps) and its own WebSocket registry — it opens its feed, processes it, returns. Handles do not cross workers (CSP isolation): never share a handle between workers.
task watch(url)
let c be ws_connect(url)
let m be ws_recv(c, 30)
ws_close(c)
give m
let results be parallel_map(watch, thousands_of_urls) -- thousands of feeds across the pool
It never hangs, can't be flooded, never busy-spins
A dead connection never hangs the agent: connect, the handshake, and every ws_recv/ws_select are bounded by the timeout — lapsed → nothing, not a stuck program. Memory is bounded under hostile input: each message is capped (default 16 MiB, ceiling 64 MiB) and the per-connection inbound queue is bounded in both dimensions — messages (max_queue, default 1024) and bytes (max_queue_bytes, default 64 MiB) — so a flood of big frames can't OOM you any more than a flood of tiny ones. The default policy when either cap is hit is real TCP backpressure: it stops reading the socket, the TCP window throttles the peer — no data loss, no OOM. The alternatives are explicit: "drop_oldest" evicts the oldest until the new one fits, and "error" drains what's already queued and then surfaces a catchable error naming the overflow — never a silent drop. Delivery across feeds is round-robin fair: a chatty connection with a backlog cannot starve the others. And an idle ws_select sleeps in the kernel (a single readiness wait), it does not spin. A soft per-interpreter connection cap (SYNSEMA_WS_MAX_CONNS, default 4096) stops a runaway loop from opening 100k sockets.
Subprotocols
opts.subprotocols (a list) negotiates the Sec-WebSocket-Protocol header; the agreed subprotocol lands in ws_stats(conn)["subprotocol"].
let c be ws_connect("wss://feed.example.com/ws", nothing, {"subprotocols": ["json", "cbor"]})
let agreed be ws_stats(c)["subprotocol"] -- "json" if the server chose it
Under serve: the WS→SSE bridge
The most common server pattern is a bridge: a route opens an outbound WebSocket to an RPC or exchange, then forwards what it receives to the browser over SSE. This is one of the few things you can't fully show in run/test (it needs a running server and a live upstream), so here is the shape:
require serve(8080)
require net("stream.exchange.com")
serve on 8080
route "GET /live"
let conn be ws_connect("wss://stream.exchange.com/ws")
ws_send(conn, json_encode({"op": "subscribe", "channel": "trades"}))
stream -- Server-Sent Events to the browser
let live be true
while live
let msg be ws_recv(conn, 30)
when msg == nothing or msg["type"] == "close"
set live to false
when msg != nothing and msg["type"] == "text"
send msg["data"] -- forward each upstream message downstream
ws_close(conn)
The browser opens one plain EventSource("/live"); the server holds the upstream WebSocket. Accepting incoming WebSocket connections (a WS server) is a separate feature — for chat and notifications, POST + SSE already ships (see Serve).
What it unlocks
Because it's a general primitive, it's not only for blockchain. Any Synsema program can now build on a live bidirectional stream: real-time dashboards, an agent reacting to a webhook gateway, collaborative tools, a market-data recorder, a chat client. Where before you wrote a polling loop, you now hold an open connection.
A concrete example: an EVM node's eth_subscribe (newHeads/logs) composes in userland today — ws_connect to the node's WS endpoint, ws_send the JSON-RPC subscribe frame, ws_recv + json_decode each notification. One-shot reads (nonce, fees, balances, receipts) go through the typed blockchain read side.