Overview
Some responses can't be a single JSON payload — the server has more to say after it's already replied, or the client needs to talk back on the same connection. Heximon covers both with a handler class and one config key: no separate server to run, no Durable Object required to get started.
Start with plain Node — no Durable Objects needed
A server-sent-events stream is a value you return from a controller, and a WebSocket handler is a class with
open/message/close methods bound to a path. Both run on the same fetch host your HTTP routes already
use, and both work fully in-process on Node: one server, one port, real bidirectional or push traffic, zero
platform-specific concepts.
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> {}
}
That's the entire mental model for a single-server app: pick a transport, author the handler, list it in your module. Everything past this point is opt-in, and each tier only matters once your app actually needs it.
SSE or WebSockets?
The two transports solve different shapes of "the response isn't one message." Pick by whether the client ever needs to talk back on the same connection.
| Server-Sent Events | WebSockets | |
|---|---|---|
| Direction | Server → client only | Bidirectional |
| Protocol | Plain HTTP (text/event-stream) | Upgraded connection |
| Browser API | Built-in EventSource, auto-reconnect | WebSocket, you handle reconnect |
| Resume after a drop | Last-Event-ID, built in | Nothing built in — reconnect and re-sync yourself |
| Reach for it when | Progress updates, notifications, live feeds | Chat, presence, collaborative editing, anything the client sends often |
If the client only ever receives, SSE is less to build and gives you resume for free. The moment the client needs to send more than an occasional request, reach for a socket instead.
See Server-Sent Events and WebSockets for the full handler walkthrough — validation, auth at upgrade, and the module wiring.
Add a typed contract when the shape matters
A plain socket sends and receives untyped frames; a plain stream sends untyped events. Once your protocol has
a fixed vocabulary of message kinds worth getting wrong at compile time instead of in production, declare it
as a class — a WebSocketContract({ clientToServer, serverToClient }) for a socket, or a
ServerSentEvents({ events }) map for a stream — and both your server and a matching frontend client
validate against the same schemas.
This is a layer on top, not a prerequisite: every example above works with no contract at all.
See WebSocket Contracts for binding a WebSocketContract to a socket
handler, and Typed clients for the frontend-safe WsClient / SseClient pair.
Durable Objects: the Cloudflare scale-out story
Everything so far runs in one process. That's fine until clients of the same room or topic land on
different isolates on Cloudflare — at that point no single process holds every connection, so a plain
publish or broadcast can't reach them all. Durable Objects solve that by
giving one room a single, addressable, stateful home: a DurableObject holds every connection for a topic,
regardless of which isolate accepted them.
You may never need this. It's a Cloudflare-specific mechanism for cross-isolate fan-out, not a requirement for realtime in general — a single Node server never has this problem, because one process already holds every connection. When you don't need cross-isolate broadcast, skip this tier entirely.
When you do, pnpm dev still runs a faithful in-process shim, so you build and test the whole flow before
you ever touch real Cloudflare infrastructure.
Documenting it: AsyncAPI
Once you've described your realtime API as a WebSocketContract or a ServerSentEvents map, that
description doubles as documentation: AsyncAPI generates a standards-compliant
document straight from the same classes, so the docs can't drift from what the wire actually validates.
Next steps
- WebSockets — author a bidirectional handler, validate inbound frames, and authenticate at upgrade.
- WebSocket Contracts — the opt-in typed layer: a
WebSocketContractclass with per-kind message dispatch and broadcast transports. - Server-Sent Events — stream typed events with heartbeats and
Last-Event-IDresume. - Typed clients — the frontend-safe
WsClient/SseClientpair. - Durable Objects — the cross-isolate broadcast tier, and its Node dev shim.
- AsyncAPI — generate a spec document from your existing contracts.
Durable Workflows
Workflow, WorkflowFactory, WorkflowStep, NodeWorkflowEngine — a linear imperative procedure that memoizes each step and survives restarts, with step.do durable checkpoints and step.sleep durable suspend/resume.
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.