Skip to content

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.

weld
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: an Upgrade: websocket request whose path matches a hub is handshaken (101 Switching Protocols) and handed to the hub; everything else is a normal route.
  • The connect block is exactly a decorator for the socket: it can fail (sending a final error frame and closing) and populates injects bindings.
  • Each method is dispatched by name, its args decoded into the declared typed params; return becomes {id,result} and fail STATUS "msg" becomes {id,error}. respond / emit / status are 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):

weld
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 exposes c.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 / leave manage 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, the disconnect body, and plain route handlers.
  • Hub.clients is 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 with Chat.clients.map(c => c.identity), or a room's roster with Chat.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 (a string of 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_INTERVAL seconds (default 25; set 0 to 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 disconnect block. Keep WS_PING_INTERVAL below 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.

weld
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.