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.
A complete Durable Object
Section titled “A complete Durable Object”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.
Options
Section titled “Options”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 prebuiltStorageAdapterfor identity and TOFU state. It overrideskeyPrefix; 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), orfalse(WebSocket only).secure— whether the WebSocket itself runs the handshake (defaulttrue). Trusted-client verify-key pins (trust-on-first-use) default to DO-storage-backed, so they survive eviction and restarts with no configuration; passverifyKeyResolveronly to replace the trust policy itself.inboundLimits— exact UTF-8/binarymaxFrameBytesand 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— passcloudflareReliableLog(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 thepersistedReceiveroption — 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 onserveWireDurableObjectfrom@nice-code/wire/platform/cloudflareinstead; 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.
Serving several channels from one DO
Section titled “Serving several channels from one DO”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
dictionaryVersionand every released client’s handshake fails. - Pin
dictionaryVersionexplicitly on channels meant to outlive deploys, and bump the pin on every semantic change — routes and payload meaning. The automaticauto:…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 composescombineChannels’ 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.)
Sending HTTP to the right DO instance
Section titled “Sending HTTP to the right DO instance”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.