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.
pnpm add @sigx/actors-cloudflareThe 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.
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
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:
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, notnew_classes— a one-way door.new_classescreates 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 inwrangler.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 definedon the first request.
The public mount needs an explicit
originpolicy. Workers callers are not browsers posting a form, and the mount defaults to refusing a request with noOrigin— so ordinary calls are rejected as cross-origin.origin: falseis 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-terminated | Object-terminated | |
|---|---|---|
| Upgrade path | {path} | {path}/{type}/{key} |
| Sockets | one per client | one per actor |
| Reach | every actor, multiplexed | that one actor |
Releases a departed consumer's keptAlive | no | yes |
| Survives hibernation | n/a — the Worker holds nothing | yes |
| Good for | a dashboard watching many actors | a room many clients watch |
Worker-terminated
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
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.pingMsis therefore not accepted — a session-owned ping timer would hold the object resident, which is exactly the cost hibernation removes. maxConnectionMssurvives 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.
onDeactivatenever runs, and there is no idle sweeper.An actor that flushes in
onDeactivatemustctx.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
{
"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
@sigx/actors-cloudflare— the API.- Socket sessions — the session both modes drive.
- Tasks — the checkpoint posture here.
- The app — the factory pattern.
