Skip to content

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 conceptCF target
Photon classDO class generated by the deploy adapter
instanceNameenv.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.callerCwdundefined (no cwd on a Worker; falls through to the photon's own fallback)

Generated worker shape

ts
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=&lt;name&gt; (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):

  1. ?instance=&lt;name&gt; query param
  2. X-Photon-Instance header
  3. JSON-RPC params _meta.photonInstance (mirrors _meta.callerCwd)
  4. 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__:&lt;id&gt; 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_&lt;UPPER_SNAKE_NAME&gt;. 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:

  1. Client POSTs tools/call to /mcp with Accept: text/event-stream.
  2. 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.
  3. 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's pendingRequests map.
  4. The client receives, runs its LLM (or surfaces the prompt to the user for elicit/confirm), and POSTs the response back to /mcp as a separate request — body is a JSON-RPC reply with the matching id.
  5. 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.
  6. The original tool's await resumes, 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

  • @stateful event 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.memory survives invocations + a deploy bounce
  • this.emit reaches a WS subscriber
  • two instanceNames have isolated state

Released under the MIT License.