Cloudflare Workers#

Cloudflare already guarantees one instance of a Durable Object globally and serializes its requests — which is the virtual-actor contract. So there is no membership, no directory and no authenticated host mount: the platform is the cluster.

Terminal
pnpm add @sigx/actors-cloudflare

The entry#

The Durable Object and the Worker are the same bundle, which is why the actor registry is a plain import and needs no build-time indirection.

TypeScript
import { createHostDurableObject, createWorkerHandler } from '@sigx/actors-cloudflare';
import { createApp } from './actors.app';
import { Counter } from './counter.actor';
import { servePage } from './page';

export interface Env { ACTORS: DurableObjectNamespace }

const actors = [Counter, Ticker];

// The object: hosts exactly the actor its id names.
export class ActorHost extends createHostDurableObject<Env>({
    actors,
    namespace: (env) => env.ACTORS,
    app: createApp,
}) {}

// The edge: hosts nothing, routes everything.
export default createWorkerHandler<Env>({
    actors,
    namespace: (env) => env.ACTORS,
    app: createApp,
    fetch: { origin: false, fallback: servePage },
});

Both need the registry: the Worker to run guards, tell a stream method from a unary one and be the 404 authority; the object to actually activate.

Pass an app factory, not an app#

TypeScript
export const createApp = (base: ActorAppOptions) => defineActorApp(base).use(metrics());
export const { defineActor } = createApp({});   // type-only binding, never started

Building the app at module scope — which is fine on Node — binds whichever Durable Object constructed it first, and every other object is then served from those seams. The factory receives the object's own storage, reminders and defaults and must pass them on.

It never receives env, so it cannot reach a namespace binding and build a placement of its own.

One placement runs on both sides#

This is the single most important thing to understand here, because the wrong version looks more natural.

Using the plain local host inside a Durable Object silently corrupts state: a cross-actor call would activate the callee inside the caller's object, writing its record into the wrong storage and violating single activation.

durableObjectPlacement() runs on both sides. Inside an object it needs isSelf:

TypeScript
durableObjectPlacement({ isSelf: (ref) => actorId(ref) === state.id.name });

In the Worker there is no isSelf — everything is remote.

There is no HMAC and no 421 retry here, deliberately. A stub is not network-reachable, so holding the binding is the capability grant, and guards run once at the public edge. And ref → object id is a pure function, so a mismatch is a config bug rather than a race.

Identity versus routing#

locationHint is a hint and safe to change. jurisdiction and objectName are part of identity, so changing either is a state migration.

Three gotchas#

new_sqlite_classes, not new_classes — a one-way door. new_classes creates the legacy key-value backed storage, which cannot be migrated to SQLite in place; the class is stuck with it forever, with a far smaller per-value limit. This is the single most consequential irreversible line in wrangler.jsonc.

__DEV__ must be defined by the bundler. The published package ships both a dev and a production dist and expects its bundler to define the flag. Without "define": { "__DEV__": "false" } the host throws __DEV__ is not defined on the first request.

The public mount needs an explicit origin policy. Workers callers are not browsers posting a form, and the mount defaults to refusing a request with no Origin — so ordinary calls are rejected as cross-origin. origin: false is right for a service; a browser front-end should pass its own origin list.

Client sockets#

Browsers can speak @sigx/actors/socket-wire over a WebSocket here, with socketTransport() from @sigx/actors-ws on the client side unchanged. There are two places the socket can terminate, and the choice is about who the connection belongs to.

Worker-terminatedObject-terminated
Upgrade path{path}{path}/{type}/{key}
Socketsone per clientone per actor
Reachevery actor, multiplexedthat one actor
Releases a departed consumer's keptAlivenoyes
Survives hibernationn/a — the Worker holds nothingyes
Good fora dashboard watching many actorsa room many clients watch

Worker-terminated#

TypeScript
export default createWorkerHandler<Env>({
    actors,
    namespace: (env) => env.ACTORS,
    app: createApp,
    socket: { path: '/_sigx/socket' },        // terminate: 'worker' is the default
});

Sugar for app.use(workerSocket(...)). The upgrade happens in the Worker — on Workers a 101 is a Response, so this is an ordinary route contribution around createActorSocketSession, not a special case.

Every call and every subscription re-dispatches through placement to its actor's Durable Object, with the stub derived fresh per dispatch. A refused construction — origin, or authentication — answers with an honest HTTP status rather than an accepted-then-closed socket. No pre-session buffer is needed either: the client end of a WebSocketPair only exists inside the returned Response, so no frame can race the session's construction.

This mode does not release keptAlive for a departed live consumer. The cancellation dies at the stub.fetch boundary, so an actor whose last watcher closed its tab stays alive until it idles out. When empty-room economics matter, terminate in the object.

Object-terminated — the room pattern#

TypeScript
export class ActorHost extends createHostDurableObject<Env>({
    actors,
    namespace: (env) => env.ACTORS,
    app: createApp,
    socket: { maxConnectionMs: 3_600_000 },   // session options live HERE in this mode
}) {}

export default createWorkerHandler<Env>({
    actors,
    namespace: (env) => env.ACTORS,
    app: createApp,
    socket: { terminate: 'object' },          // only `path` is meaningful on this side
});

The Worker parses the actor out of {path}/{type}/{key} and forwards the upgrade verbatim — cookies, Origin and all — to that actor's Durable Object, which accepts it with state.acceptWebSocket under the tag sigx:socket. The 101 carrying the client end passes straight back out.

Because the session lives where the actor lives, a disconnect tears down locally: iterator.return() reaches the watch, keptAlive clears, and the empty room is released. That is the whole reason this mode exists.

The option split is enforced by the types: in 'object' mode the Worker side accepts only path, so session options cannot be configured on the side that never runs the session.

Note the arity difference means the two modes compose — mount this one and app.use(workerSocket(...)) for the other if you want both.

The hibernation contract#

Deliberately minimal, because the point of hibernation is that an idle page costs nothing:

  • Keepalive is setWebSocketAutoResponse, answered by the runtime without waking the object. pingMs is therefore not accepted — a session-owned ping timer would hold the object resident, which is exactly the cost hibernation removes.
  • maxConnectionMs survives eviction as a deadline stored in the socket attachment, checked per message. So an idle hibernated socket can nominally outlive its cap until its next frame; the alternative would be an alarm, and the object's one alarm belongs to reminders.
  • An evicted isolate loses the session — pinned principal, in-flight calls, watches. The first message after a cold wake closes 1012 'session evicted — reconnect', and the client transport redials: a fresh upgrade with the browser's current cookies, subscriptions re-seeded exactly as on any other drop.

A subclass may accept its own sockets under a different tag and compose fine; one that overrides the hibernation handlers must delegate sigx:socket sockets back through super.

Also exported#

objectSocketRoute and parseSocketActorPath for the forwarding half, and durableObjectStubResolver — the ref → stub derivation, extracted so placement and the forwarding route cannot drift apart about where an actor lives. CloudflareWebSocketLike, DurableWebSocketLike and the optional hibernation members on DurableObjectStateLike describe the runtime surface structurally.

Eviction is not deactivation#

The platform destroys the isolate, the host and the activation together. onDeactivate never runs, and there is no idle sweeper.

An actor that flushes in onDeactivate must ctx.save() in the turn instead.

The same applies to tasks: a fiber does not survive eviction, so a task here is checkpoint-and-resume with short gaps rather than one continuous run. Same at-least-once contract — checkpoint aggressively.

wrangler.jsonc#

JSONC
{
    "main": "src/worker.ts",
    // Pinned, never floating: a compatibility date is how Workers versions
    // runtime behaviour, so bumping it is a deliberate change to test.
    "compatibility_date": "2026-07-01",
    "compatibility_flags": ["nodejs_compat"],
    "durable_objects": { "bindings": [{ "name": "ACTORS", "class_name": "ActorHost" }] },
    "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ActorHost"] }],
    "define": { "__DEV__": "false" },
    "observability": { "enabled": true }
}

nodejs_compat is required — interleaving needs AsyncLocalStorage.

Reminders differ visibly#

durableObjectReminders() maps onto the DO alarm, so a reminder fires at the due time — where shardedReminders() promises only "at or after nextDue, checked every reminderTickMs".

onAlarm() runs in three phases and does not hold blockConcurrencyWhile across delivery: claim and persist (gated), deliver (ungated), re-read and re-arm (gated). Holding the gate across delivery deadlocked the object, because rescheduling from inside onReminder takes the gate itself and blockConcurrencyWhile does not nest.

Relatedly, an expected failure is raised after the gate closes, not through it — an exception escaping blockConcurrencyWhile resets the DO.

Next steps#