Heximon Logo
Realtime

WebSocket Contracts

A typed WebSocketContract class with per-kind WebSocketMessage dispatch, TypedConnection validation, broadcast transports, and a matching browser client, layered on top of a plain WebSocketHandler.

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:

src/realtime/seat-map.contract.ts
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:

src/realtime/seat-map.socket.ts
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.

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

src/realtime/seat-map.socket.ts
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

The durable: SeatRoom half of this config additionally needs new DurablePlugin() (from @heximon/durable/compiler) in heximon.config.ts — see Durable Objects.
src/realtime/realtime.module.ts
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…RoleMethod
a client transport (WsTransport.native() / .mock())clientinstance.connect(path) — open a typed connection
a broadcast transport (a WebSocketBroadcastTransport satisfier)broadcasterinstance.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.

src/chat/chat.broadcaster.ts
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:

src/chat/chat.module.ts
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.

The in-process registry is the Node / single-isolate fan-out path. On Cloudflare, bind 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 WebSocketHandler this typed layer builds on: connection lifecycle, auth at upgrade, and Durable Object hibernation.
  • Durable Objects — author the DurableObject that hosts a hibernating socket and addresses a room by name over its RPC surface.
  • Typed clients — the browser-safe WsClient for a WebSocketContract, 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 a WebSocketContract, with a WsClient driver test proving the typed round-trip and both validation failure paths.
Copyright © 2026