Heximon Logo
Realtime

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.

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
vite.config.ts
import heximon from "@heximon/build/vite";
import { defineConfig } from "vite-plus";

export default defineConfig({
  plugins: [heximon()],
  server: { port: 3000 },
});
heximon.config.ts
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:

src/echo/echo.socket.ts
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.

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

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

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

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

MemberPurpose
id, request, topicsConnection identity, the upgrade Request, the topics it's subscribed to
data: MapA connection-stable bag, re-seeded into each frame so an upgrade-time principal is readable later
pathParamsThe :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.

A durable-hosted handler needs one more plugin: add 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.

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

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

src/chat/chat.module.ts
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 WebSocketContract class, per-kind message dispatch, broadcast transports, and the typed browser client.
  • Durable Objects — author the DurableObject that 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 a WebSocketContract, with a WsClient driver test proving the typed round-trip and both validation failure paths.
Copyright © 2026