Cloudflare Durable Object Bridge
How a stateful photon runs on Cloudflare Workers. Companion to docs/internals/ARCHITECTURE.md; specific to the CF deploy target.
Problem
The local daemon gives every photon this.memory (persistent KV), this.emit (pub/sub broadcast to subscribers), and a per-instanceName isolation boundary. Workers are stateless request/response — none of those primitives work without a backing store and a long-lived session.
Cloudflare Durable Objects fill the gap: each DO ID is a single-threaded, addressable, persistent unit with its own KV/SQLite storage and hibernatable WebSockets. One DO per photon-instance maps cleanly to one photon instance.
Mapping
| Photon concept | CF target |
|---|---|
| Photon class | DO class generated by the deploy adapter |
instanceName | env.PHOTON.idFromName(instanceName) (default: 'default') |
this.memory.{get,set,delete,has,keys,update,list,clear} | ctx.storage (SQLite-backed) |
this.emit({channel, ...}) | Broadcast to hibernated WebSockets attached to this DO |
| Subscriber (Beam SSE in local) | WebSocket client connected to /events?channel=... |
this.schedule.{create,get,getByName,list,cancel,update} | DO alarm multiplexer over ctx.storage (entries persisted under __sched__:<id>; alarm always set to next-firing) |
this.call('sibling.method', params, {instance?}) | env.PHOTON_<SIBLING>.idFromName(instance).fetch('/__call', {method, args}) |
this.sample(params) | MCP sampling/createMessage request pushed on the active SSE response, awaited by request-id correlation |
this.confirm(question) | MCP elicitation/create with a boolean schema, same SSE round-trip |
this.elicit(params) | MCP elicitation/create, same SSE round-trip |
this.callerCwd | undefined (no cwd on a Worker; falls through to the photon's own fallback) |
Generated worker shape
import { DurableObject } from 'cloudflare:workers';
import PhotonClass from './photon';
import { withCfCapabilities } from './cf-runtime';
export class PhotonDO extends DurableObject<Env> {
#photon: any;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.#photon = withCfCapabilities(new PhotonClass(), { ctx, env });
}
// Tool calls + WS upgrades route in here from the outer Worker
async fetch(req: Request): Promise<Response> { /* MCP + /events */ }
// Hibernation hooks
async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) { /* … */ }
async webSocketClose(ws: WebSocket) { /* … */ }
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const instance = extractInstance(req) ?? 'default';
const id = env.PHOTON.idFromName(instance);
return env.PHOTON.get(id).fetch(req);
},
};Storage backend
withCfCapabilities installs a MemoryBackend-shaped object on the photon instance that delegates to ctx.storage. The backend interface (@portel/photon-core/src/memory.ts:46) takes (namespace, key) pairs; on CF we encode as ${namespace}:${key} so all keys live in one DO storage table while preserving the namespace boundary the runtime relies on.
SQLite-backed DOs are the default — same KV API, lower cost, and ctx.storage.sql.exec(...) is available for photons that want to opt into queries later.
Emit transport
this.emit({channel, ...payload}) writes to in-memory subscribers attached to this DO. Subscribers are hibernatable WebSockets opened at GET /events?channel=<name> (or * for all). The DO uses ctx.acceptWebSocket(ws, [channel]) so sockets survive eviction; tags identify channels for fan-out.
The handler walks ctx.getWebSockets(channel) and calls ws.send(JSON.stringify(payload)). Cleanup runs in webSocketClose.
Instance routing
extractInstance(req) picks the instance name from (in priority order):
?instance=<name>query paramX-Photon-Instanceheader- JSON-RPC params
_meta.photonInstance(mirrors_meta.callerCwd) - Falls back to
'default'
A request without any of these lands on the singleton instance — the right default for shared-room photons where every user lives in one matchmaking queue.
Schedule multiplexer
A Durable Object has a single alarm slot. The runtime shim sidesteps that by persisting every schedule entry under __sched__:<id> in ctx.storage and always setting the alarm to the next-firing entry across all of them. On alarm() it walks every entry, dispatches anything due (with a 1s jitter tolerance), updates lastExecutionAt/executionCount/status, and recomputes the next alarm. Cron parsing uses cron-parser (auto-injected into every generated worker's dependencies).
Behavior matches the local ScheduleProvider: create rejects duplicate names, fireOnce and maxExecutions move tasks to 'completed', and a scheduled call that throws moves the task to 'error' with errorMessage set without blocking other entries.
Cross-photon calls
The deploy adapter parses @photons foo, bar from the host photon's JSDoc, resolves each name to a sibling .photon.ts file, and bundles every sibling as its own DO class in the same Worker. Wrangler binds the host photon as PHOTON and each sibling as PHOTON_<UPPER_SNAKE_NAME>. The runtime shim's this.call(target, params, opts?) splits the target on the first ., looks up the binding name in a deploy-time-generated PHOTON_BINDINGS map, and posts to the sibling DO's internal /__call endpoint. The internal endpoint dispatches the named method (with the same simpleParams spreading rule the public MCP surface uses) and returns the result inline.
Single-level resolution only in v1: a sibling photon's own @photons are not recursively bundled; the deploy emits a warning and the host author is expected to declare the full set on the entry photon.
Server-initiated MCP requests (sample / confirm / elicit)
this.sample, this.confirm, and this.elicit use the standard MCP Streamable HTTP server-initiated request pattern:
- Client POSTs
tools/callto/mcpwithAccept: text/event-stream. - The DO opens an SSE response (
text/event-stream) instead of the plain JSON path. The original tool call's response stream stays open for the duration of the call. - Inside the tool, when the photon awaits
this.sample({...}), the runtime generates a request id, writes the corresponding JSON-RPC request (sampling/createMessage/elicitation/create) to the SSE stream, and parks a Promise keyed by that id in the DO'spendingRequestsmap. - The client receives, runs its LLM (or surfaces the prompt to the user for elicit/confirm), and POSTs the response back to
/mcpas a separate request — body is a JSON-RPC reply with the matching id. - The DO's POST handler distinguishes a JSON-RPC response from a request (no
method, id is in the pending map), resolves the parked Promise with the result, and acks with 204. - The original tool's
awaitresumes, the tool returns, and the DO writes the final tool result on the SSE stream and closes it.
The per-request state (SSE writer, pending map reference) lives on an AsyncLocalStorage context so concurrent tool calls don't collide. A client that POSTs without Accept: text/event-stream gets the legacy plain JSON response path; calling this.sample etc. inside a tool invoked that way throws with a clear message pointing at the Accept requirement.
Hibernation isn't a concern: the DO can't hibernate while a fetch handler holds an open response stream. Long-pending sampling requests keep the DO active until the client responds (or the outer Worker's max_duration is hit, configurable in wrangler.toml).
Other follow-ups
@statefulevent auto-emission — rides on the same emit pipe; ship after end-to-end emit is verified in production.
Verification
templates/cloudflare/__tests__/counter-photon/ ships a tiny increment/get/reset photon used as the runtime smoke test:
- deploy succeeds
this.memorysurvives invocations + a deploy bouncethis.emitreaches a WS subscriber- two
instanceNames have isolated state
