WebSocket Contracts
Plain handlers are fully supported — this page is the typed layer on top. When a WebSocket handler and its clients agree on a fixed set of named messages with typed payloads, declaring that agreement up front removes a whole class of bugs: the compiler sees the contract, the server validates every inbound and outbound frame, and the browser client validates before sending and after receiving.
The protocol for all typed messages is a JSON envelope {"v":1,"kind":"<messageName>","data":<payload>}.
A WebSocketContract can also own the upgrade path itself, which makes it the single declaration of the
whole protocol — address and both message directions in one class.
That, and the contract config member that binds it, is the form to reach for by default; the implements WebSocketController<C> form covered afterward remains fully supported, both as the lifecycle-typing check on
top of a contract binding and as the original, still-valid way to bind a contract on its own.
Declare the contract
WebSocketContract is a config-factory base, modeled on Contract and ServerSentEvents. Call it once with
the two schema maps — and, optionally, the path it owns — and extend the result:
import { WebSocketContract } from "@heximon/contract/websocket";
import * as v from "valibot";
export const SeatSelectionSchema = v.object({
seat: v.pipe(v.number(), v.integer(), v.minValue(1)),
sessionId: v.pipe(v.string(), v.minLength(1)),
});
export const SeatMapSchema = v.object({
held: v.array(v.number()),
remaining: v.number(),
});
export const SeatUpdateSchema = v.object({
seat: v.number(),
state: v.picklist(["held", "released"]),
remaining: v.number(),
});
export const SeatRejectedSchema = v.object({
seat: v.number(),
reason: v.string(),
});
export class SeatMapContract extends WebSocketContract({
path: "/ws/events/:eventId/seats",
clientToServer: {
"seat.hold": SeatSelectionSchema,
"seat.release": SeatSelectionSchema,
},
serverToClient: {
"seat.map": SeatMapSchema,
"seat.update": SeatUpdateSchema,
"seat.rejected": SeatRejectedSchema,
},
}) {}
SeatMapContract.clientToServer and SeatMapContract.serverToClient are the static schema maps the compiler
and runtime dispatcher read. The declared path is read the same way: the compiler registers the handler's
route from it, the typed self-client's connect(...) interpolates parameters into it, and
TypedConnection.pathParams types its parameter record off the :eventId segment.
The same class is a type and a value — TypedConnection<SeatMapContract> and TypedInbound<SeatMapContract>
project off the instance type without typeof. Omitting path keeps the original form, where the handler's
own config declares the address — useful when one contract is mounted at several paths.
Share the contract class between the server and the client from a common api-contract package (or import it
directly in a monorepo). Neither @heximon/runtime nor any server code is required in the shared package — the
@heximon/contract/websocket subpath is FE-safe.
Bind the contract to a handler
Name the contract in the handler's contract config member. Because the contract owns /ws/events/:eventId/seats,
the handler declares no path of its own — declaring both is a build error, since the address needs one source
of truth:
import { QueryBus } from "@heximon/cqrs";
import type {
TypedConnection,
WebSocketController,
WebSocketHandler,
WebSocketKeySelector,
WebSocketMessage,
} from "@heximon/websocket";
import { SeatMapContract } from "./seat-map.contract";
import { SeatRoom } from "./seat-room";
import { SeatRoomFactory } from "./seat-room.factory";
export class SeatMapSocket
implements
WebSocketHandler<{ contract: SeatMapContract; durable: SeatRoom }>,
WebSocketController<SeatMapContract>
{
private static readonly topic: string = "/ws/events/:eventId/seats";
// The selector's `request` is typed from the CONTRACT's declared path (the config names the
// contract; the contract names the path), so `request.pathParams.eventId` is a typed `string`.
public readonly key: WebSocketKeySelector<{ contract: SeatMapContract; durable: SeatRoom }> = (
request,
) => request.pathParams.eventId;
public constructor(
private readonly queryBus: QueryBus,
private readonly seatRooms: SeatRoomFactory,
) {}
public async open(connection: TypedConnection<SeatMapContract>): Promise<void> {
const eventId = connection.pathParams.eventId;
// … resolve capacity, subscribe, send the initial "seat.map" snapshot.
}
public async close(connection: TypedConnection<SeatMapContract>): Promise<void> {
connection.unsubscribe(SeatMapSocket.topic);
}
}
Typing flows from the contract member: the key selector's request.pathParams and every
lifecycle method's connection.pathParams type off the contract's declared path, exactly as pathParams
does for a handler-declared path (see The connection port) —
and it keeps working across a Cloudflare hibernation wake, since the concrete upgrade URL rides the connection
on every frame.
The implements WebSocketController<C> clause stays supported alongside contract — recommended as the
lifecycle-signature check (it requires open/close, types message's parameters, and is what the compiler
falls back to reading when no contract member is present). Naming different contracts in the config and
the implements clause is a build diagnostic (websocket.contract-binding-conflict) — one contract, one
source of truth.
path on both the contract and the handler config is websocket.path-conflict — drop the
handler's path (the contract already owns it) or drop the contract's (fall back to a handler-declared path,
useful for mounting one contract at several routes).Dispatch per message kind
message() with a switch on body.kind works, but it re-checks exhaustiveness by hand every time a message
is added. Binding one method per clientToServer kind moves that check to the compiler: annotate a method's
second parameter as WebSocketMessage<Contract, "kind">, and the dispatcher routes each validated frame of that kind
straight to it — no message() method, no manual if/switch.
public async hold(
connection: TypedConnection<SeatMapContract>,
message: WebSocketMessage<SeatMapContract, "seat.hold">,
): Promise<void> {
const capacity = SeatMapSocket.capacityFrom(connection); // stashed on `connection.data` at `open`
const eventId = connection.pathParams.eventId;
const outcome = await this.seatRooms
.getByName(eventId)
.hold(message.data.seat, message.data.sessionId, capacity);
if (!outcome.held) {
await connection.send("seat.rejected", { seat: message.data.seat, reason: "Seat is already held." });
return;
}
await connection.publish(SeatMapSocket.topic, "seat.update", {
seat: message.data.seat,
state: "held",
remaining: outcome.snapshot.remaining,
});
}
message.data is typed off "seat.hold"'s schema directly — no union to narrow, because the method itself is
the narrowing. Add a release method the same way, bound to "seat.release".
The compiler enforces coverage both ways: every clientToServer kind must be bound by exactly one method, and
a kind bound to no method (or two methods claiming the same kind) is websocket.message-binding-invalid at
build time — adding a message to the contract is a build error until some method handles it. The forms are
exclusive: a handler declares either a single message() or one method per kind, never both.
TypedInbound.body() (used internally to route the per-kind dispatch, and still what a message() method
calls directly) parses the wire envelope, looks up clientToServer[kind], validates data against the
declared schema, and returns the kind-discriminated union { kind: K; data: InferOutput<C2S[K]> }. An unknown
kind or a validation failure throws InvalidMessageError — the frame is not silently dropped.
TypedConnection.send(kind, data) and TypedConnection.publish(topic, kind, data) validate data against
serverToClient[kind] before serializing the wire envelope and calling the underlying connection. An invalid
payload throws WebSocketMessageValidationError — nothing is sent.
TypedConnection.publish on the single-isolate Cloudflare WebSocketPairConnection is a documented no-op,
exactly like the untyped connection.publish. Real fan-out across connections requires a durable: hosted
hibernation path — that is why the example above names one.Register the contract in the module
durable: SeatRoom half of this config additionally needs new DurablePlugin() (from
@heximon/durable/compiler) in heximon.config.ts — see Durable Objects.import { Module } from "@heximon/runtime";
import { SeatMapSocket } from "./seat-map.socket";
import { SeatRoom } from "./seat-room";
export class RealtimeModule extends Module({
durable: { objects: [SeatRoom] },
websocket: { handlers: [SeatMapSocket] },
}) {}
The contract instance as a typed hub
A ChatContract instance is more than a typing source — constructed over a transport it becomes directly
useful, the way a ServerSentEvents instance is a typed publisher and a Contract instance is a self-client.
WebSocket is bidirectional, so the instance carries two roles, chosen by the transport you pass:
| Construct it with… | Role | Method |
|---|---|---|
a client transport (WsTransport.native() / .mock()) | client | instance.connect(path) — open a typed connection |
a broadcast transport (a WebSocketBroadcastTransport satisfier) | broadcaster | instance.publish(topic, kind, data) — fan a typed message out |
Calling the wrong method for the supplied transport throws WebSocketContractRoleError; new ChatContract() (no
transport) stays the inert typing form. The client role is shown under Typed clients
below.
Broadcast from outside a connection
TypedConnection.publish (above) fans out from inside a lifecycle method — you already hold the connection.
To broadcast from anywhere else (a command handler, a domain-event handler, a scheduled job), inject a
broadcaster: a subclass of the contract constructed over a WebSocketBroadcastTransport, so its inherited publish
becomes the typed fan-out.
import { WebSocketBroadcastTransport } from "@heximon/websocket";
import { ChatContract } from "./chat.contract";
export class ChatBroadcaster extends ChatContract {
private static readonly room: string = "/ws/chat/room-42";
public constructor(transport: WebSocketBroadcastTransport) {
super(transport); // the transport makes the inherited `publish` the BROADCAST role
}
// A typed domain method over `publish` — validated against serverToClient["broadcast"].
public announce(text: string): Promise<number> {
return this.publish(ChatBroadcaster.room, "broadcast", { id: "system", text });
}
}
Bind the WebSocketBroadcastTransport token to a satisfier in the module. On Node, that is
InProcessWebSocketBroadcastTransport over the always-provided WebSocketBroadcastRegistry (the live-connection set the
dispatcher feeds at open/close); fan-out reaches every connection whose live topics include the target:
import { Module } from "@heximon/runtime";
import {
InProcessWebSocketBroadcastTransport,
type WebSocketBroadcastRegistry,
WebSocketBroadcastTransport,
} from "@heximon/websocket";
import { ChatBroadcaster } from "./chat.broadcaster";
import { ChatContract } from "./chat.contract";
import { ChatSocket } from "./chat.socket";
export class ChatModule extends Module({
providers: [
ChatContract,
ChatBroadcaster,
{
provide: WebSocketBroadcastTransport,
useFactory: (registry: WebSocketBroadcastRegistry): WebSocketBroadcastTransport =>
new InProcessWebSocketBroadcastTransport(registry),
},
],
websocket: { handlers: [ChatSocket] },
}) {}
Now any provider can inject ChatBroadcaster and call await this.chat.announce("…") to push a typed message
to the room with no socket in hand.
DurableWebSocketBroadcastTransport (from @heximon/websocket/cloudflare) instead — it routes the broadcast to the
topic's keyed host Durable Object, whose broadcast RPC fans out via state.getWebSockets(...) (the same
hibernation fan-out that survives eviction). The ChatBroadcaster class is unchanged; only the bound satisfier
differs.Connect from the browser
A WebSocketContract is more than a server-side declaration — the same class types a browser client. The
@heximon/client/ws subpath gives you a WsClient that validates every outbound frame before sending and
every inbound frame before dispatching, with no node: imports and no runtime dependency on
@heximon/runtime or @heximon/websocket. See Typed clients for the full guide —
connecting, sending, reconnect, and testing with a mock transport.
See also
- WebSockets — the plain
WebSocketHandlerthis typed layer builds on: connection lifecycle, auth at upgrade, and Durable Object hibernation. - Durable Objects — author the
DurableObjectthat hosts a hibernating socket and addresses a room by name over its RPC surface. - Typed clients — the browser-safe
WsClientfor aWebSocketContract, and its SSE counterpart. - The flagship Cloudflare app
— a seat-map socket hosted on a Durable Object, broadcasting across connections under
pnpm dev, bound to aWebSocketContract, with aWsClientdriver test proving the typed round-trip and both validation failure paths.
WebSockets
Bidirectional realtime over a WebSocketHandler whose upgrade path is its type argument, with inbound-frame validation, auth-at-upgrade middleware, and Durable Object hibernation for cross-isolate broadcast.
Server-Sent Events
Stream typed events server-to-client over plain HTTP with EventStream, EventChannel, heartbeats and Last-Event-ID resume, plus a durable-backed ServerSentEventsHandler for cross-isolate broadcast.