Cloudflare Durable Objects
Collapse the entire DO transport stack into one serveDurableObject call.
@nice-code/action/platform/cloudflare collapses the entire Durable Object transport stack — the hibernatable secure WebSocket, an HTTP fallback, the DO-storage crypto identity, and the ping/pong keepalive — into a single serveDurableObject call. The DO just forwards its four socket lifecycle methods. The core library stays platform-agnostic.
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";
export class MyDurableObject extends DurableObject { private _server: TDurableObjectChannelServer | null = null;
private getServer(): TDurableObjectChannelServer { if (this._server != null) return this._server; const runtime = new ActionRuntime(serverCoord);
this._server = serveDurableObject(this.ctx, appChannel, { runtime, clientEnv: RuntimeCoordinate.env("frontend"), 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 lazily on first request (memoized), not at module scope — the Workers runtime forbids generating random ids and doing I/O in the global scope.
Options
Section titled “Options”serveDurableObject(ctx, channel, options) takes the same serveChannel surface (clientEnv, handlers, channelCases, connectionState, …) plus host knobs:
runtime— this DO’s runtime.keyPrefix— namespace for the DO-storage crypto-identity keys.httpFallback—"plain"(default),"secure"(handshake-protected exchange sharing the WS identity), orfalse(WebSocket only).secure— whether the WebSocket itself runs the handshake (defaulttrue).
A stateless Worker endpoint
Section titled “A stateless Worker endpoint”For a channel a stateless Worker serves itself (no Durable Object — the secure exchange is stateless),
serveWorker is the stateless dual of serveDurableObject. It folds in the crypto-identity link (with
one-time provisioning for KV’s eventual consistency), the in-memory TOFU default, the HTTP exchange
carrier, and the lazy build the Workers global scope forces — 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 clientEnv: RuntimeCoordinate.env("frontend"), 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));Serve several channels on one stateless endpoint with serveWorkers([a, b], …) — the stateless dual of
serveChannels. It composes the matching dictionary version per connection from each client’s advertised
subset, so a client connecting just one of the channels via connectChannel is accepted; the connect-side
dual is connectChannels. (Avoid serveWorker(combineChannels([a, b]), …): it pins one fixed version over
the whole union and rejects every single-channel client with a dictionary-version mismatch.)
Routing HTTP to a per-id DO
Section titled “Routing HTTP to a per-id DO”When each DO instance is one thing (a bridge, a room, a game) and the Worker must pick the right one per request, route by URL — a secure exchange body is opaque to the Worker, so security stays end-to-end between the client and the DO. forwardToDurableObject picks the stub, hands off the request, and answers the CORS OPTIONS preflight at the edge so a per-id DO is never woken just to reply to a preflight. It returns a { fetch }, so it drops into the optional actionRouter or any framework:
import { actionRouter, 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 CF sugar over the generic, platform-neutral forwardTo(pickTarget) — which
forwards to any { fetch } (a DO stub, a service binding, or (req) => fetch(upstream, req) for an
external 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" });
forwardToDurableObjectwas previouslyforwardExchangeToDurableObject(kept as a deprecated alias).
Per-connection state + broadcast
Section titled “Per-connection state + broadcast”A presence/room DO that tracks who’s on each socket adds two knobs — connectionState (typed per-socket app state, co-stored with the routing binding so both survive a wake) and channelCases:
const server = serveDurableObject(this.ctx, lobbyChannel, { runtime, clientEnv: RuntimeCoordinate.env("web"), keyPrefix: "lobby-ws:", connectionState: { schema: vs_player }, channelCases: { join: (action, conn) => { conn.setState(action.input); conn.broadcast(() => lobbyPush.action.player_joined.request(action.input), { exceptSelf: true }); return { players: roster() }; }, move: (action, conn) => { const player = conn.state; // this socket's typed state (or null) if (!player) return; conn.broadcast(() => lobbyPush.action.player_moved.request({ id: player.id, ...action.input }), { exceptSelf: true }); }, },});
// After a wake, rebuild in-memory state from surviving sockets (binding replay is automatic):for (const [, player] of server.connections.entries()) players.set(player.id, player);Because the WS carrier persists each connection’s binding on bind and replays it on construction, results and pushes still route to the right socket after the object wakes from eviction.
Next: React Query & Devtools →