WebSocket
A WebSocket service holds a long-lived connection to a client. Like HTTP, cron,
and queue handlers it is declared in a service inside a context, with the
protocol bound on the service header — from WebSocket(in:, out:) — naming the
frame types the client sends (in) and the server sends (out). The upgrade
authenticates at the edge via a by actor, and the handler is handed an owned
Connection[out] it must dispose.
For the worked chat-room, see the guide
Handle a WebSocket connection. For the
Connection[F] type and its ownership (linearity) discipline, see
Type system — Connection.
Service form
Section titled “Service form”service <Name> from WebSocket(in: ClientFrame, out: ServerFrame) { on open by user: Participant (roomId: RoomId) -> Effect[()] { … } on message by user: Participant (roomId: RoomId, frame: ClientFrame) -> Effect[()] { … } on close by user: Participant (roomId: RoomId) -> Effect[()] { … }}- Frame types:
inis the inbound frame type (delivered toon message),outis the type the server sends (connection.sendand the heldConnection[out]). on open— exactly one. The upgrade handshake. It must name its actor withby(there is no anonymous upgrade — a WebSocket, like an HTTP route, has no safe default actor), and it receives a fresh, ownedconnection: Connection[out]the framework supplies. The handler must dispose of it; the canonical disposal is transfer into an agent (Room(roomId).join(…, connection)), an undisposed connection beingbynk.held.leak.on message— an inbound frame arrived (v0.106). Its parameters are the route params plus the decodedframe: <in>.on close— the connection ended (v0.106). Dispose the stored connection (here, via the owning agent).
A connection’s route params (e.g. roomId) are validated through their .of
constructors at the edge (400 on failure), exactly as HTTP route params are.
Authentication at the edge
Section titled “Authentication at the edge”The upgrade is authenticated before the connection is accepted — fail-closed,
exactly like an HTTP route. The WebSocket boundary admits None and Bearer
actors and rejects Signature: a browser WebSocket cannot set an
Authorization header, so a Bearer token is read from the first
Sec-WebSocket-Protocol subprotocol element (a browser sets it with new WebSocket(url, [token])). On Workers the Worker verifies the token with the same
audited JWT verifier HTTP uses, runs any refinement-actor authorization predicate
(403), and only on success forwards the upgrade to the Durable Object.
Sending and broadcasting
Section titled “Sending and broadcasting”The agent that owns the connection sends with connection.send(frame)
(non-consuming — the binding stays owned) and ends it with connection.close()
(consuming). To fan a frame out to many clients, hold the connections in a
store Map[K, Connection[out]] and iterate:
agent Room { key id: RoomId store members: Set[UserId] store conns: Map[UserId, Connection[ServerFrame]]
on call post(sender: UserId, text: String) -> Effect[()] { let _ <- conns.parTraverse((c: Connection[ServerFrame]) => c.send(ServerFrame { text: text })) () }}forEachbroadcasts sequentially;parTraversebroadcasts in parallel (lowering toPromise.all), so one slow or half-dead connection does not head-of-line-block the room — the production-correct form (v0.107).- The closure parameter
cis a borrowed held binding:sendis allowed, but a consuming op (closeor transfer) on it isbynk.held.consume_on_borrow. - Exclude-self filters on the sender’s key (
u != sender), not on the connection —Connectionis non-comparable by design (bynk.types.held_not_comparable).
The TestConnection model
Section titled “The TestConnection model”On the bundle target there is no Durable Object: a connection is a
TestConnection — a capture-and-inspect channel that records every frame sent — so
a WebSocket service is fully developable and testable with no Cloudflare runtime.
A TestConnection exposes .sent (the array of frames sent to it) and .closed.
A handler is driven by calling the service’s emitted handler with the connection,
the route params, and the actor identity:
const tc = new TestConnection<{ text: string }>();await ChatGateway.open(tc, roomId, { identity: alice });// tc.sent[0].text === "welcome"await ChatGateway.message(tc, roomId, { text: "hi" }, { identity: alice });This is what makes the §20 chat-room assertable: two participants join, a message fans out to both, one leaves, and the next message reaches only the other.
Platform mapping
Section titled “Platform mapping”| Target | Connection | Notes |
|---|---|---|
bundle (--target bundle) | TestConnection | capture-and-inspect; runs under Node, no Durable Object |
Workers (--target workers) | hibernatable WebSocket in a Durable Object | the Worker authenticates at the edge and accepts the socket into the addressed DO |
On Workers the connection maps onto a Durable Object via the hibernatable-WebSocket
API: a Connection stored in agent state survives hibernation and is restored
when the agent is rehydrated — a platform-supplied guarantee the language relies on
but does not implement. The hosting DO is resolved statically from the single
connection transfer the on open makes; a zero/multiple/non-routable transfer
shape is bynk.ws.open_transfer_shape.
Diagnostics
Section titled “Diagnostics”| Code | When |
|---|---|
bynk.held.leak | a connection is left undisposed when its scope exits |
bynk.held.use_after_consume | a connection is used after close/transfer |
bynk.held.branch_divergence | if/match branches dispose inconsistently |
bynk.held.consume_on_borrow | a consuming op on a borrowed connection (e.g. in a broadcast closure) |
bynk.types.held_not_comparable | a Connection compared with == |
bynk.ws.open_transfer_shape | the on open does not transfer the connection to exactly one routable agent |
bynk.target.websocket_workers_unsupported | (historical) retired once the Workers wire path landed |
Related
Section titled “Related”- Guide: Handle a WebSocket connection.
- Reference: Type system — Connection and Stream.
- Reference: HTTP — the sibling request/response protocol, including streamed responses.