# 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

```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=<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):
1. `?instance=<name>` 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__:<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:

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 `instanceName`s have isolated state
