Skip to content

Cloudflare Durable Objects

Collapse the entire DO transport stack into one serveDurableObject call.

@nice-code/action/platform/cloudflare wraps up everything a Durable Object needs into one serveDurableObject call — the secure WebSocket (that survives the DO sleeping and waking), an HTTP fallback, the crypto identity stored in DO storage, and the ping/pong keepalive. Your DO just forwards its four socket events. The core library itself stays platform-neutral.

import { DurableObject } from "cloudflare:workers";
import { ActionRuntime } from "@nice-code/action";
import { serveDurableObject, type TDurableObjectChannelServer } from "@nice-code/action/platform/cloudflare";
import { appChannel, serverCoord } from "./shared"; // coordinates live in shared code
export class MyDurableObject extends DurableObject {
private _server: TDurableObjectChannelServer | null = null;
private getServer(): TDurableObjectChannelServer {
if (this._server != null) return this._server;
const serverRuntime = new ActionRuntime(serverCoord);
this._server = serveDurableObject(this.ctx, appChannel, {
runtime: serverRuntime,
keyPrefix: "ws:",
handlers: [userHandler],
});
return this._server;
}
async fetch(request: Request): Promise<Response> {
return this.getServer().fetch(request);
}
async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
this.getServer().receive(ws, msg);
}
async webSocketClose(ws: WebSocket) { this.getServer().drop(ws); }
async webSocketError(ws: WebSocket) { this.getServer().drop(ws); }
}

Build the server on the first request (and remember it), not at the top level of the file. The Workers runtime won’t let you generate random ids or do I/O in the global scope.

serveDurableObject(ctx, channel, options) accepts everything serveChannel does (handlers, channelCases, connectionState, logger, …) plus a few host-specific knobs:

  • runtime — this DO’s runtime.
  • keyPrefix — a prefix for the crypto-identity keys stored in DO storage, so they don’t clash with your other keys.
  • storage — a prebuilt StorageAdapter for identity and TOFU state. It overrides keyPrefix; use it to share an adapter or deliberately disable key tracking when whole-object deletion owns reclamation.
  • httpFallback"plain" (default), "secure" (a handshake-protected fallback that shares the WebSocket’s identity), or false (WebSocket only).
  • secure — whether the WebSocket itself runs the handshake (default true). Trusted-client verify-key pins (trust-on-first-use) default to DO-storage-backed, so they survive eviction and restarts with no configuration; pass verifyKeyResolver only to replace the trust policy itself.
  • inboundLimits — exact UTF-8/binary maxFrameBytes and an optional fixed-window message rate, enforced before handshake parsing or action dispatch. onExceeded(connection, reason) owns the product response (normally close/quarantine the socket). Fixed windows deliberately permit a boundary double-burst.
  • reliableStore — pass cloudflareReliableLog(this.ctx) so .reliable({ persist: true }) streams keep their dedup state in the DO’s SQLite and survive eviction. (On the lower-level acceptor surface this same store is the persistedReceiver option — one store, two entry points.)
  • protocols — frame protocols to ride the same sockets. This is how a realm shares the DO’s connections with the channel’s actions: protocols: [realms.protocol]. (A DO that serves only a realm doesn’t need a channel or this package at all — it hosts on serveWireDurableObject from @nice-code/wire/platform/cloudflare instead; see Serving a Realm.)

The returned server also has idempotent dispose(): it permanently quiesces the acceptors, detaches known connections, ignores late frames, answers later fetches with 503, and makes push/broadcast throw. Call it before deleting a DO’s storage so late close/error callbacks cannot recreate state.

Pass a channel array and the DO becomes a multi-channel acceptor on the same endpoint — one crypto identity, one socket surface, both carriers:

this._server = serveDurableObject(this.ctx, [coreChannel, lobbyChannel], {
runtime, keyPrefix: "ws:", httpFallback: "secure", handlers,
});

Each connection advertises the channel tags it carries in the handshake, and the acceptor composes that connection’s codec + dictionary version from the advertised subset — so a client connecting just one of the served channels (via connectChannel) is accepted with its own channel’s version, and a connectChannels client gets the union. The negotiated tags are persisted on the hibernation attachment, so a connection’s subset codec is recomposed when the DO wakes from eviction. This works on both the WebSocket and the secure HTTP-exchange fallback.

Two ambiguous shapes are rejected rather than guessed at: serving two channels that share a tag throws at setup (the registry could not route deterministically), and a handshake advertising the same tag twice is rejected (a repeat would compose the same channel twice into the positional dictionary). Tags ride the hibernation attachment, which shares Cloudflare’s per-socket attachment budget (16,384 serialized bytes) with the connection binding — keep them short.

Evolving a deployed API: versioned channel coexistence

Section titled “Evolving a deployed API: versioned channel coexistence”

Action domains have no schema hash — the channel is the unit of wire compatibility. When deployed clients update slowly (mobile wallets, embedded webviews), evolve the API by serving a new channel beside the frozen one, on the same endpoint, and let each client population pick its channel by tag:

// shared code, both ends:
const bridgeChannel = defineChannel({ toAcceptor: [bridge], tag: "bridge", dictionaryVersion: "bridge-1" });
const bridgeChannelV2 = defineChannel({ toAcceptor: [bridgeV2], tag: "bridge-v2", dictionaryVersion: "bridge-v2-1" });
// the DO serves both; released clients keep connecting only `bridgeChannel`:
serveDurableObject(this.ctx, [bridgeChannel, bridgeChannelV2], { runtime, handlers });

The rules that keep this sound:

  • New domains go in new channels; the legacy channel stays untouched. Adding an action to an existing channel moves its derived dictionaryVersion and every released client’s handshake fails.
  • Pin dictionaryVersion explicitly on channels meant to outlive deploys, and bump the pin on every semantic change — routes and payload meaning. The automatic auto:… version only tracks the ordered route dictionary; it cannot see inside payload schemas.
  • Keep each channel’s tag and route order stable. Both are wire contract.
  • A combined client gets the route-derived union version, not your pins. connectChannels([a, b]) / a multi-tag hello composes combineChannels’ derived version; constituent explicit pins are not composed into it. Pins protect single-channel clients — don’t rely on them to version the combined connection.

On a stateless Worker the same registry mechanism is serveWorkers([a, b], …) (below); the connect-side dual is connectChannel for one channel or connectChannels for a subset.

A plain Worker endpoint (no Durable Object)

Section titled “A plain Worker endpoint (no Durable Object)”

If a channel can be served by a plain Worker — no Durable Object, because secure HTTP needs no shared memory between requests — use serveWorker, the Worker version of serveDurableObject. It bundles in the crypto-identity link (created once up front to handle KV’s eventual consistency), the default trust-on-first-use store, the HTTP carrier, and the build-on-first-request pattern the Workers global scope forces on you — all over a StorageAdapter you pass in:

import { serveWorker, kvStorageAdapter } from "@nice-code/action/platform/cloudflare";
const serveCreate = serveWorker(bridgeCreateChannel, {
runtime: () => new ActionRuntime(bridgeCreatorCoord), // a factory — built lazily on first request
storage: kvStorageAdapter({ kvNamespace: env.KV, keyPrefix: "bridge-create-identity:" }),
handlers: () => [bridgeCreateHandler()], // a factory — built lazily, never at module scope
});
// route it from any framework: honoApi.on(["POST", "OPTIONS"], "/create/*", (c) => serveCreate.fetch(c.req.raw));

To serve several channels from one Worker endpoint, use serveWorkers([a, b], …) (the Worker version of serveChannels). It works out the right version for each connection from whichever channels that client actually uses — so a client that connects to just one of them (via connectChannel) is still accepted; on the connecting side, the matching helper is connectChannels. (Don’t do serveWorker(combineChannels([a, b]), …) instead — that locks in a single version for the whole set and rejects every client that only uses one channel, because the versions won’t match.)

Often each DO instance is one thing — a single bridge, room, or game — and the Worker has to pick the right one for each request. Route by the URL: the body of a secure request is unreadable to the Worker anyway, so security stays end-to-end between the client and the DO. forwardToDurableObject picks the right DO, hands off the request, and answers the CORS OPTIONS preflight right at the edge — so a specific DO never has to wake up just to reply to a preflight. It returns a { fetch }, so it fits into the optional actionRouter or any framework:

import { actionRouter } from "@nice-code/action";
import { forwardToDurableObject } from "@nice-code/action/platform/cloudflare";
const router = actionRouter()
.route("/bridge/:id/*", forwardToDurableObject(({ params }) =>
env.BRIDGE.get(env.BRIDGE.idFromString(params.id)))) // per-id, E2E client ↔ DO
.route("/app/*", forwardToDurableObject(() =>
env.APP.get(env.APP.idFromName("main")))); // singleton
export default { fetch: (request: Request) => router.fetch(request) };

forwardToDurableObject is just a Cloudflare-flavoured shortcut for the platform-neutral forwardTo(pickTarget), which forwards to any { fetch } — a DO, a service binding, or (req) => fetch(upstream, req) for an outside server. Inside the DO, make the HTTP fallback secure so the whole path is handshake-protected:

this._server = serveDurableObject(this.ctx, bridgeChannel, { runtime, httpFallback: "secure" });

Remembering who’s on each connection + broadcasting

Section titled “Remembering who’s on each connection + broadcasting”

A presence or room DO that tracks who’s on each socket adds two options: connectionState (typed state for each connection, saved alongside the routing info so both survive the DO sleeping and waking) and channelCases:

const server = serveDurableObject(this.ctx, lobbyChannel, {
runtime,
keyPrefix: "lobby-ws:",
connectionState: { schema: vs_player },
channelCases: {
join: (action, conn) => {
conn.setState(action.input);
conn.broadcast(() => act_lobby.action.player_joined.request(action.input), { exceptSelf: true });
return { players: roster() };
},
move: (action, conn) => {
const player = conn.state; // this connection's typed state (or null)
if (!player) return;
conn.broadcast(() => act_lobby.action.player_moved.request({ id: player.id, ...action.input }), { exceptSelf: true });
},
},
});
// After waking, rebuild your in-memory state from the sockets that are still open
// (the routing info is replayed for you automatically):
for (const [, player] of server.connections.entries()) players.set(player.id, player);

The WebSocket carrier saves each connection’s routing info when it’s set, and replays it when the DO is rebuilt — so even after the object wakes from sleeping, replies and pushes still reach the right socket.