WebSocket Hubs
A hub is a stateful, RPC-style WebSocket endpoint (SignalR-style). Clients open a socket, authenticate once on connect, then call typed methods and get replies correlated by request id. Weld does the RFC 6455 handshake and frame codec from scratch — no library.
type Claims = { sub: string, role: string }
hub Chat {
# Runs once, on connect. Browsers can't set WebSocket headers, so the auth
# token arrives as a query param. `fail` rejects the connection; the `injects`
# bindings are per-connection and visible to every method below.
connect (query token: string) injects (user: Claims) {
user = try jwt_verify(Claims, token, env("JWT_SECRET"))
}
method echo(text: string) -> string {
return "you ({user.sub}) said: {text}"
}
method add(a: int, b: int) -> int { return a + b }
method adminOnly() -> string {
if user.role != "admin" { fail 403 "admins only" }
return "secret for {user.sub}"
}
}The wire protocol
JSON text frames, request-id correlated:
client -> { "id": "r1", "method": "echo", "args": { "text": "hi" } }
server -> { "id": "r1", "result": "you (alice) said: hi" }
or { "id": "r1", "error": { "status": 403, "message": "admins only" } }- Connect to
ws://host/Chat?token=<jwt>. A hub shares the server's port with routes: anUpgrade: websocketrequest whose path matches a hub is handshaken (101 Switching Protocols) and handed to the hub; everything else is a normal route. - The
connectblock is exactly a decorator for the socket: it canfail(sending a final error frame and closing) and populatesinjectsbindings. - Each
methodis dispatched by name, itsargsdecoded into the declared typed params;returnbecomes{id,result}andfail STATUS "msg"becomes{id,error}.respond/emit/statusare route-only and rejected inside hubs.
Server-initiated events
The server can also push events to connected clients. Every live connection is a member of a queryable set, Hub.clients, that you filter and .send a record to; the record arrives as an unsolicited event frame ({ "event": …, "data": … }, no id):
type ChatMsg = { from: string, text: string } # any record can be an event
hub Chat {
connect (query token: string, query room: string) injects (user: Claims) {
user = try jwt_verify(Claims, token, env("JWT_SECRET"))
identify(user.sub) # this connection's identity (for targeting a user)
join(room) # add it to a listener list ("group")
}
method say(room: string, text: string) { # broadcast to a room
Chat.clients.filter(c => c.subscribed(room))
.send(ChatMsg { from: user.sub, text: text })
}
method dm(to: string, text: string) { # target one identity
Chat.clients.filter(c => c.identity == to)
.send(ChatMsg { from: user.sub, text: text })
}
}- Targeting is a filter.
.filter(c => …)is a predicate over each connection handle, which exposesc.identity,c.id,c.subscribed("group"), and the hub's injected bindings (c.user…). Chained filters compose; no filter means the whole hub.identify/join/leavemanage identity and group membership. - Counting is a filter too.
Hub.clients.count()is the number of live connections, optionally after.filter(...):Chat.clients.filter(c => c.subscribed(room)).count()is the room's occupancy. It reads the same registry as.send, and works in hub methods, thedisconnectbody, and plain route handlers. Hub.clientsis a read-only collection. Beyond.filter/.send/.count, it shares the collection method surface with lists:.map(c => …)enumerates the (optionally filtered) connections into a plain list — build a roster withChat.clients.map(c => c.identity), or a room's roster withChat.clients.filter(c => c.subscribed(room)).map(c => c.identity). Because the set is a live view of the registry, it's read-only — the mutating list ops (push,sort, …) don't apply.- Binary frames.
.sendBytes(data)pushes a raw binary WebSocket frame (astringof bytes, no JSON envelope) instead of a JSON event — for images, audio chunks, protobuf, etc. It takes the same optional.filter(...)as.send:Chat.clients.filter(c => c.subscribed(room)).sendBytes(frame). Inbound frames are the JSON method protocol above; binary is a server→client push. - Works from routes too.
Hub.clients.…send(…)is a plain global, so an ordinary HTTP route can push to connected sockets (e.g. a webhook fanning out a notification). - Concurrency is per-connection, not per-hub. The registry lock is held only for a broadcast's snapshot; writes to each socket happen under only that connection's own write lock, so a slow client never stalls the hub.
Keepalive & idle timeout
The server keeps connections healthy with WebSocket ping/pong:
- Inbound pings are answered automatically with a pong (transparent to your hub code).
- The server pings every live connection on an interval —
WS_PING_INTERVALseconds (default 25; set0to disable). A conforming client's WebSocket library replies with a pong, which counts as activity. - A silent connection is closed. If nothing arrives from a client — not even a pong — within the read timeout (~30 s), the socket is treated as dead and dropped, which fires the hub's
disconnectblock. KeepWS_PING_INTERVALbelow that window (the 25 s default leaves margin) so a healthy-but-idle client is never closed.
The ping sweeper runs on the default (threaded) server; the experimental --green runtime answers pings but does not yet sweep.
Presence: disconnect & count
A hub can also run a block when a client's socket drops. The connect-time injects bindings (e.g. user) are still in scope, so a disconnect block is the natural place to broadcast a departure and keep a presence roster correct. It has no client of its own to reply to, so respond / a return value / try / fail are all rejected there; broadcasting (.send) and .count() are what it's for.
type Claims = { sub: string, role: string }
type Notice = { text: string }
hub Chat {
connect (query token: string) injects (user: Claims) {
user = try jwt_verify(Claims, token, env("JWT_SECRET"))
}
method roster() -> int { return Chat.clients.count() } # live connection count
# Runs when the socket drops. `user` is still bound, so tell everyone who left.
disconnect {
Chat.clients.send(Notice { text: "{user.sub} left" })
}
}Pairing a join-on-connect broadcast (see above) with a disconnect broadcast is what keeps a room's membership — and its .count() — accurate on both sides.
Typed client
Every hub also generates a typed WebSocket client — see Generated Clients.