Skip to content

Cloudflare Durable Objects

Serve actions from a Durable Object with one serveDurableObject call.

serveDurableObject, from @nice-code/action/platform/cloudflare, sets up everything a Durable Object (DO) needs to serve your actions in one call: a 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 only forwards four events: fetch and the three WebSocket callbacks. 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;
// A Durable Object needs a stable identity across hibernation and process restarts.
// `insId` remains the runtime's fresh, per-boot discriminator.
const serverRuntime = new ActionRuntime(
serverCoord.withPersistentId(this.ctx.id.toString()),
{ scope: "instance" },
);
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 options:

  • runtime — this DO’s runtime. It must use { scope: "instance" }; the host borrows it and server.dispose() does not dispose it.
  • 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 with other code. The default adapter also keeps an index of the keys it wrote, so they can be cleared later. If you clean up by deleting the whole DO’s storage, you don’t need that index: pass an adapter built with trackKeysForClearing: false.
  • httpFallback — the HTTP endpoint that sits beside the WebSocket. "plain" is the default: no handshake, the counterpart of the client’s httpCarrier(url, { secure: false }), and the usual fallback for a public client. "secure" is the full handshake-protected exchange, sharing the WebSocket’s identity. false means WebSocket only. The plain endpoint accepts both the exchange envelope a plain httpCarrier sends and a bare action wire, so curl and older peers keep working. An enveloped request is always answered with a 200 envelope, refusals included. Requires ≥ 0.90.0; see the note below.
  • secure — whether the WebSocket itself runs the handshake (default true). Trusted-client verify-key pins (trust-on-first-use) are kept in DO storage by default, 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. A frame over a limit is dropped, and that is all the library does. onExceeded(connection, reason) is where you decide what happens next, normally closing or quarantining the socket. reason is "frame_bytes" or "message_rate". Fixed windows deliberately permit a double burst at a window boundary.
  • 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 API this same store is the persistedReceiver option: one store, two entry points.
  • protocols — frame protocols that share 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.

Note that a plain client works against a secure acceptor. secure: false on the connector produces the tokenless envelope, which httpAcceptorCarrier() accepts at level none. So a read-only public client needs no identity of its own while the server keeps one. The server still needs storage for its own identity.

The returned server also has an idempotent dispose(), which 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 accepts several channels on the same endpoint, with one crypto identity, one socket surface and 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 and dictionary version from the advertised subset. So a client connecting only 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, not guessed at. Serving two channels that share a tag throws at setup, because the registry could not route deterministically. A handshake advertising the same tag twice is rejected, because a repeat would compose the same channel twice into the positional dictionary. Keep tags short: they are stored on the hibernation attachment, which shares Cloudflare’s per-socket attachment budget (16,384 serialized bytes) with the connection binding.

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. Each client population picks 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], { identity, 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 changes 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, to routes and to 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]) or a multi-tag hello composes combineChannels’ derived version. Constituent explicit pins are not composed into it. Pins protect single-channel clients, so 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 counterpart 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 plain Worker can serve a channel, use serveWorker, the Worker version of serveDurableObject. No Durable Object is needed, because secure HTTP needs no shared memory between requests. It bundles 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. It all runs over a StorageAdapter you pass in:

import { serveWorker, kvStorageAdapter } from "@nice-code/action/platform/cloudflare";
const serveCreate = serveWorker(bridgeCreateChannel, {
identity: bridgeCreatorCoord, // runtime built lazily on first request
storage: kvStorageAdapter({ kvNamespace: env.KV, keyPrefix: "bridge-create-identity:" }),
handlers: [bridgeCreateHandler],
});
// 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 the channels that client actually uses, so a client that connects to only one of them (via connectChannel) is still accepted. On the connecting side, the matching helper is connectChannels. Don’t use 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 must pick the right one per request. Route by the URL. The Worker can’t read the body of a secure request 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 at the edge, so a DO never has to wake for one. It returns a { fetch }, so it fits 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 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, { identity: bridgeCoord, 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: channelCases, and connectionState, which is typed state for each connection. It is saved alongside the routing info, so both survive the DO’s sleep and wake:

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 == null) 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, replies and pushes still reach the right socket.