WebSockets
WebSockets give you bidirectional realtime: the connection stays open, and either side pushes a frame whenever
it has something to say. You author a WebSocketHandler<"/path">, implement the connection lifecycle, and the
compiler mounts the upgrade onto your existing HTTP host. The path is the type argument — just like a
Controller<"/users"> prefix — so the type that names the route also types its path params.
A socket handler isn't its own server. It rides whatever fetch host already serves your HTTP routes — the
dev server, Nitro, or Workers — which is why the WebSocket plugin requires the HTTP plugin: one process, one
port, one boot.
Set up the plugin
Install the package and register WebSocketPlugin alongside the HTTP plugin. Listing the WebSocket plugin is
what attaches the dev server's upgrade adapter, so ws:// routes connect under pnpm dev.
pnpm add @heximon/websocket
npm install @heximon/websocket
yarn add @heximon/websocket
bun add @heximon/websocket
import heximon from "@heximon/build/vite";
import { defineConfig } from "vite-plus";
export default defineConfig({
plugins: [heximon()],
server: { port: 3000 },
});
import { defineHeximonConfig } from "@heximon/build";
import { HttpPlugin } from "@heximon/http/compiler";
import { WebSocketPlugin } from "@heximon/websocket/compiler";
export default defineHeximonConfig({ plugins: [new HttpPlugin(), new WebSocketPlugin()] });
Author a handler
A handler implements three lifecycle methods — open(connection), message(connection, inbound), and
close(connection) — plus an optional error(connection, error). The upgrade path is the <Path> type
argument; a duplicate path across handlers is a build error, because exactly one handler serves each route.
The WebSocketConnection you receive is the same object for the lifetime of the socket — use it to write
frames, close the connection, and subscribe to topics. Here a minimal echo socket greets on open and sends
back whatever arrives on message:
import type { Inbound, WebSocketConnection, WebSocketHandler } from "@heximon/websocket";
export class EchoSocket implements WebSocketHandler<"/ws/echo"> {
public async open(connection: WebSocketConnection): Promise<void> {
connection.send(JSON.stringify({ type: "welcome", id: connection.id }));
}
public async message(connection: WebSocketConnection, inbound: Inbound): Promise<void> {
connection.send(inbound.text());
}
public async close(_connection: WebSocketConnection): Promise<void> {}
}
WebSocketHandler is a marker: the type argument carries the config, the class body carries the lifecycle,
and with a type-only import the marker never enters your runtime module graph. extends WebSocketHandler<"/ws/echo"> works the same — use it when you want your own base class hierarchy on top.
Declare it in a module
A handler is discovered one way: by being listed under the websocket namespace in its module's config. That
list is the whole registration — the compiler constructs it through the DI graph and dispatches upgrades to it,
exactly like a controller.
import { Module } from "@heximon/runtime";
import { EchoSocket } from "./echo.socket";
export class EchoModule extends Module({
websocket: { handlers: [EchoSocket] },
}) {}
Validate inbound frames
An incoming frame is untrusted input — validate it at the boundary. Declare the message schema in the
message() parameter type as Inbound<{ body: Schema }> — a bare class reference (typeof Schema works
too, if you'd rather spell it as a type) — and inbound.body() runs the decoded JSON through it before
handing it to you.
import type { Inbound, WebSocketConnection, WebSocketHandler } from "@heximon/websocket";
import { ChatMessage } from "./chat-message"; // a SchemaObject class (any Standard Schema value works)
export class ChatSocket implements WebSocketHandler<"/ws/chat"> {
public async open(connection: WebSocketConnection): Promise<void> {}
public async message(
connection: WebSocketConnection,
inbound: Inbound<{ body: ChatMessage }>,
): Promise<void> {
const body = await inbound.body(); // typed; throws InvalidMessageError on a schema failure
connection.send(JSON.stringify({ echoed: body }));
}
public async close(connection: WebSocketConnection): Promise<void> {}
}
The validated value is fully typed with nothing to annotate. With no declared schema, inbound.body() returns
the raw decoded JSON, and inbound.text() / inbound.json<T>() give you raw access.
Authenticate at upgrade
Auth belongs at the handshake, not on every frame. Name HTTP Middleware classes in the handler's config type
argument; they run once at open over the upgrade Request. A middleware that returns a Response
without calling next aborts the upgrade, and the connection is closed with code 1008.
export class ChatSocket implements WebSocketHandler<{ path: "/ws/chat"; middlewares: [AuthMiddleware] }> {
public async open(connection: WebSocketConnection): Promise<void> {}
public async message(connection: WebSocketConnection, inbound: Inbound): Promise<void> {}
public async close(connection: WebSocketConnection): Promise<void> {}
}
The middleware is an ordinary provider, declared in the same module:
import { Module } from "@heximon/runtime";
import { AuthMiddleware } from "./auth.middleware";
import { ChatSocket } from "./chat.socket";
export class ChatModule extends Module({
providers: [AuthMiddleware],
websocket: { handlers: [ChatSocket] },
}) {}
The connection port
WebSocketConnection is an abstract class, which makes it both a uniform API and a DI token your providers can
inject. Its surface is cross-platform — the same code runs on Node and Cloudflare; only the implementation
differs.
| Member | Purpose |
|---|---|
id, request, topics | Connection identity, the upgrade Request, the topics it's subscribed to |
data: Map | A connection-stable bag, re-seeded into each frame so an upgrade-time principal is readable later |
pathParams | The :segments of the upgrade path matched against the concrete request URL |
send(data) / close(code?, reason?) | Write a frame / close the socket |
subscribe(topic) / unsubscribe(topic) / publish(topic, data) | Topic pub/sub fan-out |
pathParams is bound by the dispatcher before any lifecycle method runs, so open / message / close read
it directly instead of hand-parsing request's URL — { eventId: "evt-42" } for a /ws/events/:eventId/seats
upgrade path, empty for a static path. On a contract-bound handler, TypedConnection.pathParams narrows this
to the record of the contract's declared :segments.
It survives a Cloudflare hibernation wake too: each woken frame builds a fresh connection wrapper, and
pathParams is rebound against that frame's concrete request URL every time — so a keyed handler reads real
path parameters on any frame, not only the one that opened it.
Broadcast across isolates with Durable Objects
publish(topic, data) fans out to every connection on that topic — but only within the isolate handling the
socket. On Cloudflare a single isolate can't reach connections in another, so a plain publish never broadcasts
across them.
To fan out for real, host the socket inside a Durable Object via the
Hibernation API: the DO becomes the one
place every connection in a room lives, so publish reaches all of them. Hibernation lets the DO be evicted
between frames and rehydrated on the next, so an idle room costs nothing.
new DurablePlugin() (from
@heximon/durable/compiler) next to WebSocketPlugin in heximon.config.ts — see
Durable Objects.Name durable: SomeDO in the config type argument, and supply a key selector — which DO instance the upgrade
forwards into — as a key class field. Here it returns roomId, so each room is its own DO with isolated
state and its own pub/sub registry. The exported WebSocketKeySelector<Config> type annotates the field, so
the arrow's request is typed from the config's path literal.
import type {
Inbound,
WebSocketConnection,
WebSocketHandler,
WebSocketKeySelector,
} from "@heximon/websocket";
import { ChatRoom } from "./chat.room";
export class ChatSocket implements WebSocketHandler<{
path: "/ws/chat/:roomId";
durable: ChatRoom;
}> {
// `request.pathParams.roomId` is a typed string from the config's <path>; one DO per room.
public readonly key: WebSocketKeySelector<{ path: "/ws/chat/:roomId"; durable: ChatRoom }> = (
request,
) => request.pathParams.roomId;
private static readonly topic: string = "/ws/chat/:roomId";
public async open(connection: WebSocketConnection): Promise<void> {
connection.subscribe(ChatSocket.topic);
connection.send(JSON.stringify({ type: "welcome", id: connection.id }));
connection.publish(ChatSocket.topic, JSON.stringify({ type: "join", id: connection.id }));
}
public async message(connection: WebSocketConnection, inbound: Inbound): Promise<void> {
const text = inbound.text();
connection.send(JSON.stringify({ type: "echo", text }));
connection.publish(ChatSocket.topic, JSON.stringify({ type: "message", id: connection.id, text }));
}
public async close(connection: WebSocketConnection): Promise<void> {
connection.publish(ChatSocket.topic, JSON.stringify({ type: "leave", id: connection.id }));
}
}
The host DO declares DurableObject<{ hibernate: true }> and no webSocket* method of its own —
the compiler-emitted glue routes hibernation frames straight onto your handler. The DO is free to keep its own
state and RPC surface (here a connection count) for code that queries the room directly.
import type { DurableObject } from "@heximon/durable";
export class ChatRoom implements DurableObject<{ hibernate: true }> {
private opened = 0;
public join(): Promise<number> {
this.opened += 1;
return Promise.resolve(this.opened);
}
public roomSize(): Promise<number> {
return Promise.resolve(this.opened);
}
}
The opened counter is illustrative — a plain field resets when a hibernating DO is evicted and
rehydrated. State that must survive hibernation goes through DurableContext.storage (the pattern
Durable Objects shows).
Declare the DO under durable: { objects } and the socket under websocket: { handlers } in the same module.
The compiler sees that the socket names ChatRoom, threads the hibernation host into the room, and keys the
upgrade by roomId — and because a hibernating socket needs a hibernating host, it rejects the wiring at build
time if ChatRoom is missing hibernate: true rather than emitting a host that can't accept the socket.
The durable: target must be a discovered DurableObject<{ hibernate: true }>, checked when you compile; the
key selector field is a runtime value the durable forward reads off the resolved handler instance (on an
extends WebSocketHandler<…> handler, the same selector is passed via super((request) => …) instead —
there the arrow's request is typed with no annotation).
import { Module } from "@heximon/runtime";
import { ChatRoom } from "./chat.room";
import { ChatSocket } from "./chat.socket";
export class ChatModule extends Module({
durable: { objects: [ChatRoom] },
websocket: { handlers: [ChatSocket] },
}) {}
Platform behavior
The same handler runs on Node and Cloudflare; the realtime semantics differ only where the runtime forces them.
Node development has no Durable Objects, so a durable-backed socket degrades to an in-process connection:
open / message / close still run, with no DO round-trip.
The per-room key, the :roomId route, and the live echo all work under pnpm dev — only true cross-isolate
hibernation is Cloudflare-only, so reach for durable: exactly when a room needs to broadcast for real.
A Cloudflare app (platform: new CloudflareWorkersStrategy()) skips that degradation entirely: its vp dev
runs the worker in real workerd (Miniflare), and the dev server splices each ws:// upgrade byte-for-byte onto
the worker's own socket — a durable-backed room connects to its real Durable Object in dev, hibernation
semantics included, before any deploy.
On the Cloudflare hibernation path, connection-stable state rides the socket's platform attachment (the
one thing that survives DO eviction): connection.request carries the concrete upgrade URL on every frame
(so a keyed handler reads its real path parameters, same as on Node), and the string-keyed connection.data
entries persist across frames and eviction.
Two platform limits apply — symbol-keyed entries stay process-local (they cannot serialize), and Cloudflare caps the serialized attachment at 2 KiB, so stash small lookups (an id, a capacity, a principal), not documents.
Add a typed contract when the shape matters
A plain handler sends and receives untyped frames — every example above works exactly as shown, with no contract at all.
Once your protocol has a fixed vocabulary of message kinds worth getting wrong at compile time instead of in
production, WebSocket Contracts layers a validated, typed protocol on
top: a WebSocketContract class, per-kind method dispatch, and a browser client that validates against the
same schemas.
See also
- WebSocket Contracts — the opt-in typed layer: a
WebSocketContractclass, per-kind message dispatch, broadcast transports, and the typed browser client. - Durable Objects — author the
DurableObjectthat hosts a hibernating socket and addresses a room by name over its RPC surface. - Server-Sent Events — when you only need a one-way push channel, stream typed events instead of opening a duplex socket.
- Nitro — ship the generated upgrade routes to a Nitro host or Cloudflare Workers.
- 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.
Overview
Choosing between EventStream server-sent events and a WebSocketHandler socket, the opt-in typed layer over WebSocketContract and ServerSentEvents classes, and when Durable Objects earn their keep for cross-isolate broadcast.
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.