@portel/photon 1.25.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,37 +1,547 @@
1
1
  /**
2
2
  * Photon MCP Worker for Cloudflare
3
3
  * Auto-generated - do not edit directly
4
+ *
5
+ * Architecture: each photon (the host plus any `@photons` siblings) lives
6
+ * in its own Durable Object class so `this.memory`, `this.emit`,
7
+ * `this.schedule`, and `this.call` work identically to the local daemon.
8
+ * One DO per `instanceName` per photon — see docs/internals/CF-DURABLE-OBJECTS.md.
4
9
  */
5
10
 
6
- // @ts-ignore - Will be replaced during build
7
- import PhotonClass from './photon';
11
+ import { DurableObject } from 'cloudflare:workers';
12
+ import { AsyncLocalStorage } from 'node:async_hooks';
13
+ import { CronExpressionParser } from 'cron-parser';
14
+ __PHOTON_IMPORTS__
8
15
 
9
16
  interface Env {
10
- // Add your environment variables here
11
- [key: string]: string | undefined;
17
+ PHOTON: DurableObjectNamespace;
18
+ [key: string]: any;
12
19
  }
13
20
 
14
- // Tool definitions extracted at build time
15
- // @ts-ignore - Will be replaced during build
16
- const TOOL_DEFINITIONS: any[] = __TOOL_DEFINITIONS__;
17
- const PHOTON_NAME = '__PHOTON_NAME__';
18
21
  const DEV_MODE = __DEV_MODE__;
22
+ const HOST_PHOTON_NAME = '__HOST_PHOTON_NAME__';
23
+
24
+ /**
25
+ * Photon name → Worker env binding name. Generated at deploy time from the
26
+ * host photon's source plus every `@photons` sibling. `this.call('foo.bar')`
27
+ * uses this to find the right DO namespace.
28
+ */
29
+ const PHOTON_BINDINGS: Record<string, string> = __PHOTON_BINDINGS_MAP__;
30
+
31
+ const CORS_HEADERS = {
32
+ 'Access-Control-Allow-Origin': '*',
33
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
34
+ 'Access-Control-Allow-Headers': 'Content-Type, Mcp-Session-Id, X-Photon-Instance',
35
+ };
19
36
 
20
- // Instantiate photon
21
- let photonInstance: InstanceType<typeof PhotonClass> | null = null;
37
+ // ════════════════════════════════════════════════════════════════════════════
38
+ // Memory proxy ctx.storage backing for this.memory
39
+ // ════════════════════════════════════════════════════════════════════════════
40
+
41
+ /**
42
+ * Build a `MemoryBackend`-shaped accessor over `ctx.storage`. Mirrors the
43
+ * `this.memory` API the local runtime exposes. Per-namespace keys are
44
+ * encoded as `${namespace}:${key}` so one DO storage table holds multiple
45
+ * namespaces while preserving the boundary the runtime relies on. The DO
46
+ * input gate already serializes calls per-DO, so `update` is just
47
+ * read-modify-write — no extra locking needed.
48
+ */
49
+ function createMemoryProxy(ctx: DurableObjectState) {
50
+ const ns = (key: string, namespace = 'default') => `${namespace}:${key}`;
51
+ return {
52
+ async get(key: string, opts?: { namespace?: string }) {
53
+ const v = await ctx.storage.get(ns(key, opts?.namespace));
54
+ return v === undefined ? null : v;
55
+ },
56
+ async set(key: string, value: unknown, opts?: { namespace?: string }) {
57
+ await ctx.storage.put(ns(key, opts?.namespace), value);
58
+ },
59
+ async delete(key: string, opts?: { namespace?: string }) {
60
+ return ctx.storage.delete(ns(key, opts?.namespace));
61
+ },
62
+ async has(key: string, opts?: { namespace?: string }) {
63
+ const v = await ctx.storage.get(ns(key, opts?.namespace));
64
+ return v !== undefined;
65
+ },
66
+ async keys(opts?: { namespace?: string }) {
67
+ const prefix = `${opts?.namespace ?? 'default'}:`;
68
+ const map = await ctx.storage.list({ prefix });
69
+ return Array.from(map.keys()).map((k) => k.slice(prefix.length));
70
+ },
71
+ async list(opts?: { namespace?: string; prefix?: string }) {
72
+ const namespacePrefix = `${opts?.namespace ?? 'default'}:`;
73
+ const prefix = namespacePrefix + (opts?.prefix ?? '');
74
+ const map = await ctx.storage.list({ prefix });
75
+ return Array.from(map.entries()).map(([k, v]) => ({
76
+ key: k.slice(namespacePrefix.length),
77
+ value: v,
78
+ }));
79
+ },
80
+ async clear(opts?: { namespace?: string }) {
81
+ const prefix = `${opts?.namespace ?? 'default'}:`;
82
+ const map = await ctx.storage.list({ prefix });
83
+ if (map.size > 0) {
84
+ await ctx.storage.delete(Array.from(map.keys()));
85
+ }
86
+ },
87
+ async update(
88
+ key: string,
89
+ updater: (current: any) => any,
90
+ opts?: { namespace?: string }
91
+ ): Promise<any> {
92
+ const fullKey = ns(key, opts?.namespace);
93
+ const current = (await ctx.storage.get(fullKey)) ?? null;
94
+ const next = await updater(current);
95
+ await ctx.storage.put(fullKey, next);
96
+ return next;
97
+ },
98
+ };
99
+ }
100
+
101
+ // ════════════════════════════════════════════════════════════════════════════
102
+ // Schedule provider — DO alarm multiplexer
103
+ // ════════════════════════════════════════════════════════════════════════════
104
+
105
+ const SCHEDULE_PREFIX = '__sched__:';
106
+
107
+ const CRON_SHORTHANDS: Record<string, string> = {
108
+ '@yearly': '0 0 1 1 *',
109
+ '@annually': '0 0 1 1 *',
110
+ '@monthly': '0 0 1 * *',
111
+ '@weekly': '0 0 * * 0',
112
+ '@daily': '0 0 * * *',
113
+ '@midnight': '0 0 * * *',
114
+ '@hourly': '0 * * * *',
115
+ };
22
116
 
23
- function getPhoton(env: Env): InstanceType<typeof PhotonClass> {
24
- if (!photonInstance) {
25
- // Pass env vars as constructor params if needed
26
- photonInstance = new PhotonClass();
117
+ function resolveCron(schedule: string): string {
118
+ const trimmed = schedule.trim();
119
+ const shorthand = CRON_SHORTHANDS[trimmed.toLowerCase()];
120
+ if (shorthand) return shorthand;
121
+ if (trimmed.split(/\s+/).length !== 5) {
122
+ throw new Error(
123
+ `Invalid cron expression: '${schedule}'. Expected 5 fields or a shorthand (@hourly, @daily, @weekly, @monthly, @yearly).`
124
+ );
27
125
  }
28
- return photonInstance;
126
+ return trimmed;
127
+ }
128
+
129
+ function nextFireMs(cron: string, from: Date = new Date()): number {
130
+ return CronExpressionParser.parse(cron, { currentDate: from }).next().getTime();
131
+ }
132
+
133
+ interface ScheduledTask {
134
+ id: string;
135
+ name: string;
136
+ description?: string;
137
+ cron: string;
138
+ method: string;
139
+ params: Record<string, unknown>;
140
+ fireOnce: boolean;
141
+ maxExecutions: number;
142
+ status: 'active' | 'paused' | 'completed' | 'error';
143
+ createdAt: string;
144
+ lastExecutionAt?: string;
145
+ executionCount: number;
146
+ errorMessage?: string;
147
+ photonId: string;
148
+ }
149
+
150
+ async function listSchedules(ctx: DurableObjectState): Promise<ScheduledTask[]> {
151
+ const map = await ctx.storage.list<ScheduledTask>({ prefix: SCHEDULE_PREFIX });
152
+ return Array.from(map.values());
153
+ }
154
+
155
+ async function rescheduleAlarm(ctx: DurableObjectState): Promise<void> {
156
+ const tasks = await listSchedules(ctx);
157
+ let earliest = Infinity;
158
+ const now = Date.now();
159
+ for (const task of tasks) {
160
+ if (task.status !== 'active') continue;
161
+ const fromTs = Math.max(
162
+ task.lastExecutionAt ? Date.parse(task.lastExecutionAt) : 0,
163
+ now - 1000
164
+ );
165
+ const t = nextFireMs(task.cron, new Date(fromTs));
166
+ if (t < earliest) earliest = t;
167
+ }
168
+ if (earliest === Infinity) {
169
+ await ctx.storage.deleteAlarm();
170
+ } else {
171
+ await ctx.storage.setAlarm(earliest);
172
+ }
173
+ }
174
+
175
+ function createScheduleProvider(ctx: DurableObjectState, photonId: string) {
176
+ return {
177
+ async create(opts: {
178
+ name: string;
179
+ schedule: string;
180
+ method: string;
181
+ params?: Record<string, unknown>;
182
+ description?: string;
183
+ fireOnce?: boolean;
184
+ maxExecutions?: number;
185
+ }): Promise<ScheduledTask> {
186
+ const cron = resolveCron(opts.schedule);
187
+ const all = await listSchedules(ctx);
188
+ if (all.find((t) => t.name === opts.name)) {
189
+ throw new Error(`Schedule '${opts.name}' already exists. Use update() to modify it.`);
190
+ }
191
+ const task: ScheduledTask = {
192
+ id: crypto.randomUUID(),
193
+ name: opts.name,
194
+ description: opts.description,
195
+ cron,
196
+ method: opts.method,
197
+ params: opts.params ?? {},
198
+ fireOnce: opts.fireOnce ?? false,
199
+ maxExecutions: opts.maxExecutions ?? 0,
200
+ status: 'active',
201
+ createdAt: new Date().toISOString(),
202
+ executionCount: 0,
203
+ photonId,
204
+ };
205
+ await ctx.storage.put(SCHEDULE_PREFIX + task.id, task);
206
+ await rescheduleAlarm(ctx);
207
+ return task;
208
+ },
209
+ async get(id: string): Promise<ScheduledTask | null> {
210
+ return (await ctx.storage.get<ScheduledTask>(SCHEDULE_PREFIX + id)) ?? null;
211
+ },
212
+ async getByName(name: string): Promise<ScheduledTask | null> {
213
+ const all = await listSchedules(ctx);
214
+ return all.find((t) => t.name === name) ?? null;
215
+ },
216
+ async list(status?: ScheduledTask['status']): Promise<ScheduledTask[]> {
217
+ const all = await listSchedules(ctx);
218
+ return status ? all.filter((t) => t.status === status) : all;
219
+ },
220
+ async cancel(id: string): Promise<boolean> {
221
+ const ok = await ctx.storage.delete(SCHEDULE_PREFIX + id);
222
+ if (ok) await rescheduleAlarm(ctx);
223
+ return ok;
224
+ },
225
+ async update(
226
+ id: string,
227
+ updates: Partial<
228
+ Pick<ScheduledTask, 'method' | 'params' | 'description' | 'fireOnce' | 'maxExecutions'>
229
+ > & { schedule?: string }
230
+ ): Promise<ScheduledTask> {
231
+ const cur = await ctx.storage.get<ScheduledTask>(SCHEDULE_PREFIX + id);
232
+ if (!cur) throw new Error(`Schedule ${id} not found`);
233
+ const next: ScheduledTask = {
234
+ ...cur,
235
+ ...updates,
236
+ cron: updates.schedule ? resolveCron(updates.schedule) : cur.cron,
237
+ };
238
+ await ctx.storage.put(SCHEDULE_PREFIX + id, next);
239
+ await rescheduleAlarm(ctx);
240
+ return next;
241
+ },
242
+ };
29
243
  }
30
244
 
31
- // MCP JSON-RPC handler
32
- async function handleMCPRequest(request: any, env: Env): Promise<any> {
245
+ // ════════════════════════════════════════════════════════════════════════════
246
+ // Cross-photon call env.PHOTON_<NAME> stub routing
247
+ // ════════════════════════════════════════════════════════════════════════════
248
+
249
+ /**
250
+ * `this.call('sibling.method', params, {instance?})` — hops to a sibling
251
+ * photon's DO via the env binding generated at deploy time. The sibling DO
252
+ * exposes an internal /__call endpoint that dispatches the request to the
253
+ * named method (with the same simpleParams spreading rule the public MCP
254
+ * surface uses) and returns the result inline.
255
+ */
256
+ function createCallProvider(env: Env, callerName: string) {
257
+ return async (
258
+ target: string,
259
+ params: Record<string, unknown> = {},
260
+ options?: { instance?: string }
261
+ ): Promise<unknown> => {
262
+ const dotIndex = target.indexOf('.');
263
+ if (dotIndex === -1) {
264
+ throw new Error(
265
+ `Invalid call target: '${target}'. Expected format: 'photonName.methodName'.`
266
+ );
267
+ }
268
+ const photonName = target.slice(0, dotIndex);
269
+ const methodName = target.slice(dotIndex + 1);
270
+ if (photonName === callerName) {
271
+ throw new Error(
272
+ `this.call('${target}') points at the caller's own photon. Call the method directly via this.${methodName}(...) instead.`
273
+ );
274
+ }
275
+ const bindingName = PHOTON_BINDINGS[photonName];
276
+ if (!bindingName) {
277
+ throw new Error(
278
+ `Unknown photon '${photonName}' in this.call('${target}'). Add it to the host photon's @photons docblock so it gets bundled.`
279
+ );
280
+ }
281
+ const ns = env[bindingName] as DurableObjectNamespace | undefined;
282
+ if (!ns) {
283
+ throw new Error(
284
+ `Worker is missing binding '${bindingName}'. The deploy adapter should have generated it; rerun \`photon host deploy cf\`.`
285
+ );
286
+ }
287
+ const stub = ns.get(ns.idFromName(options?.instance ?? 'default'));
288
+ const res = await stub.fetch('http://photon.internal/__call', {
289
+ method: 'POST',
290
+ headers: { 'Content-Type': 'application/json' },
291
+ body: JSON.stringify({ method: methodName, args: params }),
292
+ });
293
+ const payload = (await res.json()) as { ok: boolean; result?: unknown; error?: string };
294
+ if (!payload.ok) {
295
+ throw new Error(payload.error ?? `Cross-photon call to ${target} failed`);
296
+ }
297
+ return payload.result;
298
+ };
299
+ }
300
+
301
+ // ════════════════════════════════════════════════════════════════════════════
302
+ // Capability shim — wires this.* on the photon instance
303
+ // ════════════════════════════════════════════════════════════════════════════
304
+
305
+ function withCfCapabilities(
306
+ instance: any,
307
+ ctx: DurableObjectState,
308
+ env: Env,
309
+ photonName: string
310
+ ): any {
311
+ Object.defineProperty(instance, 'memory', {
312
+ value: createMemoryProxy(ctx),
313
+ writable: false,
314
+ enumerable: false,
315
+ configurable: false,
316
+ });
317
+
318
+ Object.defineProperty(instance, 'emit', {
319
+ value: (event: { channel?: string; [k: string]: unknown }) => {
320
+ const channel = (event && typeof event === 'object' && event.channel) || 'default';
321
+ const payload = JSON.stringify(event);
322
+ for (const ws of ctx.getWebSockets(channel)) {
323
+ try {
324
+ ws.send(payload);
325
+ } catch {
326
+ // Socket closing; webSocketClose hook will reap.
327
+ }
328
+ }
329
+ },
330
+ writable: false,
331
+ enumerable: false,
332
+ configurable: false,
333
+ });
334
+
335
+ Object.defineProperty(instance, 'schedule', {
336
+ value: createScheduleProvider(ctx, photonName),
337
+ writable: false,
338
+ enumerable: false,
339
+ configurable: false,
340
+ });
341
+
342
+ Object.defineProperty(instance, 'call', {
343
+ value: createCallProvider(env, photonName),
344
+ writable: false,
345
+ enumerable: false,
346
+ configurable: false,
347
+ });
348
+
349
+ Object.defineProperty(instance, 'callerCwd', {
350
+ get() {
351
+ return undefined;
352
+ },
353
+ enumerable: false,
354
+ configurable: false,
355
+ });
356
+
357
+ // Worker env exposed to the photon for direct binding access — Workers AI
358
+ // (`env.AI.run('@cf/...', ...)`), KV, R2, queues, secrets. Photons that
359
+ // want to stay CF-portable should branch on `(this as any).env?.AI` and
360
+ // fall back to a non-CF path on the local daemon (where `env` is
361
+ // undefined). Non-enumerable so it doesn't leak into JSON output.
362
+ Object.defineProperty(instance, 'env', {
363
+ value: env,
364
+ writable: false,
365
+ enumerable: false,
366
+ configurable: false,
367
+ });
368
+
369
+ // Human/LLM-in-the-loop primitives. Each one wraps an MCP server-initiated
370
+ // request (sampling/createMessage, elicitation/create), pushed over the
371
+ // active tool call's SSE response stream and awaited on a Promise keyed by
372
+ // request id. The pending map and the SSE writer live on the per-request
373
+ // AsyncLocalStorage context so concurrent tool calls don't collide.
374
+ Object.defineProperty(instance, 'sample', {
375
+ value: cfSample,
376
+ writable: false,
377
+ enumerable: false,
378
+ configurable: false,
379
+ });
380
+ Object.defineProperty(instance, 'confirm', {
381
+ value: cfConfirm,
382
+ writable: false,
383
+ enumerable: false,
384
+ configurable: false,
385
+ });
386
+ Object.defineProperty(instance, 'elicit', {
387
+ value: cfElicit,
388
+ writable: false,
389
+ enumerable: false,
390
+ configurable: false,
391
+ });
392
+
393
+ return instance;
394
+ }
395
+
396
+ // ════════════════════════════════════════════════════════════════════════════
397
+ // Server-initiated MCP requests — sample / confirm / elicit
398
+ // ════════════════════════════════════════════════════════════════════════════
399
+
400
+ interface PendingRequest {
401
+ resolve: (value: any) => void;
402
+ reject: (error: Error) => void;
403
+ }
404
+
405
+ interface RequestContext {
406
+ /** Push one JSON-RPC message to the client over the active SSE response. */
407
+ send: (msg: unknown) => Promise<void>;
408
+ /** Shared pending map; the DO's POST /mcp handler resolves entries here. */
409
+ pendingRequests: Map<string, PendingRequest>;
410
+ }
411
+
412
+ /**
413
+ * Per-tool-call context. Set when the DO begins streaming a tool call
414
+ * response and read by `this.sample` / `this.confirm` / `this.elicit` to
415
+ * find the right SSE writer + pending map. AsyncLocalStorage propagates
416
+ * the context across awaits so a tool that calls `await this.sample()`
417
+ * deep in its async tree still hits the right context.
418
+ */
419
+ const requestContext = new AsyncLocalStorage<RequestContext>();
420
+
421
+ function requireRequestContext(which: string): RequestContext {
422
+ const ctx = requestContext.getStore();
423
+ if (!ctx) {
424
+ throw new Error(
425
+ `this.${which}() requires the calling client to use SSE (Accept: text/event-stream). ` +
426
+ `Stateless JSON requests can't carry server-initiated MCP messages back to the client. ` +
427
+ `If you control the client, set the Accept header. If not, the photon should not call ` +
428
+ `this.${which}() in tools invoked by JSON-only callers.`
429
+ );
430
+ }
431
+ return ctx;
432
+ }
433
+
434
+ /**
435
+ * Send a server-initiated JSON-RPC request to the client over the active
436
+ * SSE stream and await the matching response. The DO's POST /mcp handler
437
+ * resolves the pending Promise when the client posts back a response with
438
+ * the same id.
439
+ */
440
+ async function sendServerRequest(method: string, params: unknown, which: string): Promise<unknown> {
441
+ const ctx = requireRequestContext(which);
442
+ const id = crypto.randomUUID();
443
+ const promise = new Promise<unknown>((resolve, reject) => {
444
+ ctx.pendingRequests.set(id, { resolve, reject });
445
+ });
446
+ await ctx.send({ jsonrpc: '2.0', id, method, params });
447
+ return promise;
448
+ }
449
+
450
+ async function cfSample(params: {
451
+ prompt?: string;
452
+ messages?: Array<{ role: string; content: { type: string; text?: string } }>;
453
+ maxTokens?: number;
454
+ systemPrompt?: string;
455
+ temperature?: number;
456
+ }): Promise<string> {
457
+ if (!params.prompt && !params.messages?.length) {
458
+ throw new Error('this.sample() requires either `prompt` or `messages`.');
459
+ }
460
+ const messages =
461
+ params.messages ??
462
+ [{ role: 'user' as const, content: { type: 'text' as const, text: params.prompt! } }];
463
+ const result = (await sendServerRequest(
464
+ 'sampling/createMessage',
465
+ {
466
+ messages,
467
+ maxTokens: params.maxTokens ?? 1024,
468
+ ...(params.systemPrompt ? { systemPrompt: params.systemPrompt } : {}),
469
+ ...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
470
+ },
471
+ 'sample'
472
+ )) as { content?: { type?: string; text?: string } } | string | undefined;
473
+ if (typeof result === 'string') return result;
474
+ if (result?.content?.type === 'text' && typeof result.content.text === 'string') {
475
+ return result.content.text;
476
+ }
477
+ return JSON.stringify(result);
478
+ }
479
+
480
+ async function cfElicit<T = unknown>(params: {
481
+ message?: string;
482
+ question?: string;
483
+ requestedSchema?: unknown;
484
+ [k: string]: unknown;
485
+ }): Promise<T> {
486
+ const message = params.message ?? params.question ?? 'Input requested';
487
+ const result = await sendServerRequest(
488
+ 'elicitation/create',
489
+ {
490
+ message,
491
+ ...(params.requestedSchema ? { requestedSchema: params.requestedSchema } : {}),
492
+ },
493
+ 'elicit'
494
+ );
495
+ // Per MCP elicitation spec the response carries `content` with the user's
496
+ // answer. Some clients return the answer at the top level for simple
497
+ // schemas; accept either shape so photon code stays compact.
498
+ const obj = result as { content?: T } | T;
499
+ return ((obj as { content?: T })?.content ?? (obj as T));
500
+ }
501
+
502
+ async function cfConfirm(question: string): Promise<boolean> {
503
+ const result = await cfElicit<{ confirmed?: boolean } | boolean>({
504
+ message: question,
505
+ requestedSchema: {
506
+ type: 'object',
507
+ properties: {
508
+ confirmed: {
509
+ type: 'boolean',
510
+ title: 'Confirm',
511
+ description: question,
512
+ },
513
+ },
514
+ required: ['confirmed'],
515
+ },
516
+ });
517
+ if (typeof result === 'boolean') return result;
518
+ return Boolean(result?.confirmed);
519
+ }
520
+
521
+ // ════════════════════════════════════════════════════════════════════════════
522
+ // Tool dispatch
523
+ // ════════════════════════════════════════════════════════════════════════════
524
+
525
+ /**
526
+ * Convert the JSON-RPC `arguments` object into the positional-call arg list
527
+ * the photon method expects. Mirrors the local loader's logic so a photon
528
+ * behaves identically whether it runs on the daemon or as a Cloudflare Worker.
529
+ */
530
+ function spreadArgs(toolDef: any, args: Record<string, unknown>): unknown[] {
531
+ if (toolDef?.simpleParams && args && typeof args === 'object') {
532
+ const paramNames = Object.keys(toolDef.inputSchema?.properties || {});
533
+ return paramNames.map((name) => args[name]);
534
+ }
535
+ return [args];
536
+ }
537
+
538
+ async function handleMCPRequest(
539
+ request: any,
540
+ photon: any,
541
+ photonName: string,
542
+ toolDefinitions: any[]
543
+ ): Promise<any> {
33
544
  const { method, params, id } = request;
34
- const photon = getPhoton(env);
35
545
 
36
546
  switch (method) {
37
547
  case 'initialize':
@@ -41,7 +551,7 @@ async function handleMCPRequest(request: any, env: Env): Promise<any> {
41
551
  result: {
42
552
  protocolVersion: '2024-11-05',
43
553
  capabilities: { tools: {} },
44
- serverInfo: { name: PHOTON_NAME, version: '1.0.0' },
554
+ serverInfo: { name: photonName, version: '1.0.0' },
45
555
  },
46
556
  };
47
557
 
@@ -49,21 +559,19 @@ async function handleMCPRequest(request: any, env: Env): Promise<any> {
49
559
  return {
50
560
  jsonrpc: '2.0',
51
561
  id,
52
- result: { tools: TOOL_DEFINITIONS },
562
+ result: { tools: toolDefinitions },
53
563
  };
54
564
 
55
565
  case 'tools/call': {
56
566
  const { name, arguments: args } = params;
57
567
  try {
58
- const method = (photon as any)[name];
59
- if (typeof method !== 'function') {
568
+ const fn = (photon as any)[name];
569
+ if (typeof fn !== 'function') {
60
570
  throw new Error(`Unknown tool: ${name}`);
61
571
  }
62
- const result = await method.call(photon, args || {});
63
- // String returns (e.g. markdown from `read`) flow through as-is so
64
- // clients receive renderable text. Other shapes are JSON-stringified
65
- // for legibility. Structured shapes are also surfaced via
66
- // `structuredContent` per MCP — clients that prefer JSON pick it up.
572
+ const toolDef = toolDefinitions.find((t: any) => t.name === name);
573
+ const callArgs = spreadArgs(toolDef, args || {});
574
+ const result = await fn.call(photon, ...callArgs);
67
575
  const isString = typeof result === 'string';
68
576
  const text = isString ? result : JSON.stringify(result, null, 2);
69
577
  const isObject = result !== null && typeof result === 'object';
@@ -100,43 +608,414 @@ async function handleMCPRequest(request: any, env: Env): Promise<any> {
100
608
  }
101
609
  }
102
610
 
103
- // SSE stream handler
104
- /**
105
- * Stateless MCP Streamable HTTP handler.
106
- *
107
- * Each POST /mcp is a complete JSON-RPC exchange: body in, result out, no
108
- * session state carried across requests. This matches the MCP Streamable
109
- * HTTP spec's single-endpoint pattern and stays within the Cloudflare
110
- * Workers free tier (no Durable Objects needed for session storage).
111
- */
112
- async function handleStreamableMCP(request: Request, env: Env): Promise<Response> {
611
+ async function handleStreamableMCP(
612
+ request: Request,
613
+ photon: any,
614
+ photonName: string,
615
+ toolDefinitions: any[]
616
+ ): Promise<Response> {
113
617
  let body: unknown;
114
618
  try {
115
619
  body = await request.json();
116
620
  } catch (error: any) {
117
621
  return Response.json(
118
- { jsonrpc: '2.0', id: null, error: { code: -32700, message: `Parse error: ${error?.message ?? String(error)}` } },
622
+ {
623
+ jsonrpc: '2.0',
624
+ id: null,
625
+ error: { code: -32700, message: `Parse error: ${error?.message ?? String(error)}` },
626
+ },
119
627
  { status: 400, headers: CORS_HEADERS }
120
628
  );
121
629
  }
122
- const result = await handleMCPRequest(body, env);
630
+ const result = await handleMCPRequest(body, photon, photonName, toolDefinitions);
123
631
  return Response.json(result, { headers: CORS_HEADERS });
124
632
  }
125
633
 
126
- const CORS_HEADERS = {
127
- 'Access-Control-Allow-Origin': '*',
128
- 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
129
- 'Access-Control-Allow-Headers': 'Content-Type, Mcp-Session-Id',
634
+ // ════════════════════════════════════════════════════════════════════════════
635
+ // BasePhotonDO — shared logic for every per-photon DO class
636
+ // ════════════════════════════════════════════════════════════════════════════
637
+
638
+ abstract class BasePhotonDO extends DurableObject<Env> {
639
+ protected abstract readonly photonName: string;
640
+ protected abstract readonly toolDefinitions: any[];
641
+ protected readonly httpRoutes: { method: string; path: string; handler: string }[] = [];
642
+ protected abstract createPhoton(): any;
643
+
644
+ protected photon: any;
645
+ protected instanceName: string;
646
+
647
+ /**
648
+ * In-flight server-initiated MCP requests, keyed by request id. The active
649
+ * tool call's `this.sample` / `this.confirm` / `this.elicit` add entries
650
+ * here; the POST /mcp handler resolves them when the client sends the
651
+ * response. Lives on the DO instance so it persists across the multiple
652
+ * fetch handlers (one for the original tool call, one per client response).
653
+ */
654
+ protected pendingRequests = new Map<string, PendingRequest>();
655
+
656
+ constructor(ctx: DurableObjectState, env: Env) {
657
+ super(ctx, env);
658
+ this.instanceName = ctx.id.name ?? 'default';
659
+ this.photon = withCfCapabilities(this.createPhoton(), ctx, env, this.photonName);
660
+ }
661
+
662
+ async fetch(request: Request): Promise<Response> {
663
+ const url = new URL(request.url);
664
+
665
+ // Internal cross-photon call — invoked by sibling DOs via this.call.
666
+ // Not exposed externally (outer Worker doesn't route to this path).
667
+ if (url.pathname === '/__call' && request.method === 'POST') {
668
+ try {
669
+ const { method, args } = (await request.json()) as {
670
+ method: string;
671
+ args: Record<string, unknown>;
672
+ };
673
+ const fn = (this.photon as any)[method];
674
+ if (typeof fn !== 'function') {
675
+ return Response.json({ ok: false, error: `Unknown method: ${method}` });
676
+ }
677
+ const toolDef = this.toolDefinitions.find((t: any) => t.name === method);
678
+ const callArgs = spreadArgs(toolDef, args || {});
679
+ const result = await fn.call(this.photon, ...callArgs);
680
+ return Response.json({ ok: true, result });
681
+ } catch (error: any) {
682
+ return Response.json({ ok: false, error: error?.message ?? String(error) });
683
+ }
684
+ }
685
+
686
+ // Hibernatable WebSocket upgrade for emit subscribers, tagged by channel.
687
+ if (url.pathname === '/events' && request.headers.get('Upgrade') === 'websocket') {
688
+ const channel = url.searchParams.get('channel') ?? 'default';
689
+ const pair = new WebSocketPair();
690
+ const client = pair[0];
691
+ const server = pair[1];
692
+ this.ctx.acceptWebSocket(server, [channel]);
693
+ return new Response(null, { status: 101, webSocket: client });
694
+ }
695
+
696
+ // MCP Streamable HTTP. Three flavors of POST /mcp body:
697
+ // - JSON-RPC request (has `method`): tools/call, initialize, etc.
698
+ // Tool calls may produce server-initiated requests during execution
699
+ // (sample/confirm/elicit), so when the client signals SSE support
700
+ // (`Accept: text/event-stream`) we stream the response. Plain JSON
701
+ // is the default for clients that don't.
702
+ // - JSON-RPC response (has `result` or `error`, no `method`): the
703
+ // client answering a server-initiated request. We resolve the
704
+ // matching pending entry and ack with 204.
705
+ if (url.pathname === '/mcp' && request.method === 'POST') {
706
+ return this._handleMcpPost(request);
707
+ }
708
+ if (url.pathname === '/mcp' && request.method === 'GET') {
709
+ return new Response(': streamable-http\n\n', {
710
+ headers: {
711
+ 'Content-Type': 'text/event-stream',
712
+ 'Cache-Control': 'no-cache',
713
+ ...CORS_HEADERS,
714
+ },
715
+ });
716
+ }
717
+ if (url.pathname === '/mcp' && request.method === 'DELETE') {
718
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
719
+ }
720
+
721
+ // Info endpoint
722
+ if (url.pathname === '/' && request.method === 'GET') {
723
+ return Response.json(
724
+ {
725
+ name: this.photonName,
726
+ instance: this.instanceName,
727
+ transport: 'streamable-http',
728
+ runtime: 'cloudflare-workers',
729
+ endpoints: {
730
+ mcp: '/mcp',
731
+ events: '/events?channel=<name>',
732
+ ...(DEV_MODE ? { playground: '/playground' } : {}),
733
+ },
734
+ tools: this.toolDefinitions.length,
735
+ },
736
+ { headers: CORS_HEADERS }
737
+ );
738
+ }
739
+
740
+ // Dev-only endpoints
741
+ if (DEV_MODE) {
742
+ if (url.pathname === '/playground') {
743
+ return new Response(getPlaygroundHTML(this.photonName, this.toolDefinitions), {
744
+ headers: { 'Content-Type': 'text/html' },
745
+ });
746
+ }
747
+ if (url.pathname === '/api/tools') {
748
+ return Response.json({ tools: this.toolDefinitions }, { headers: CORS_HEADERS });
749
+ }
750
+ if (url.pathname === '/api/call' && request.method === 'POST') {
751
+ const { tool, args } = (await request.json()) as { tool: string; args: any };
752
+ try {
753
+ const fn = (this.photon as any)[tool];
754
+ if (typeof fn !== 'function') {
755
+ throw new Error(`Unknown tool: ${tool}`);
756
+ }
757
+ const toolDef = this.toolDefinitions.find((t: any) => t.name === tool);
758
+ const callArgs = spreadArgs(toolDef, args || {});
759
+ const result = await fn.call(this.photon, ...callArgs);
760
+ return Response.json({ success: true, data: result }, { headers: CORS_HEADERS });
761
+ } catch (error: any) {
762
+ return Response.json(
763
+ { success: false, error: error.message },
764
+ { status: 500, headers: CORS_HEADERS }
765
+ );
766
+ }
767
+ }
768
+ }
769
+
770
+ // @get / @post HTTP routes — dispatch to photon method, bypass MCP
771
+ const httpRoute = this.httpRoutes.find(
772
+ (r) => r.method === request.method && r.path === url.pathname
773
+ );
774
+ if (httpRoute) {
775
+ const fn = (this.photon as any)[httpRoute.handler];
776
+ if (typeof fn === 'function') {
777
+ try {
778
+ const response = await fn.call(this.photon, request);
779
+ if (response instanceof Response) return response;
780
+ return Response.json(response, { headers: CORS_HEADERS });
781
+ } catch (error: any) {
782
+ return new Response(error?.message ?? 'Internal Server Error', { status: 500 });
783
+ }
784
+ }
785
+ }
786
+
787
+ return new Response('Not Found', { status: 404, headers: CORS_HEADERS });
788
+ }
789
+
790
+ /**
791
+ * Handle a single POST /mcp body. Distinguishes JSON-RPC requests from
792
+ * JSON-RPC responses to server-initiated requests, and chooses between a
793
+ * plain JSON or SSE-streamed reply for tool calls based on the client's
794
+ * Accept header.
795
+ */
796
+ private async _handleMcpPost(request: Request): Promise<Response> {
797
+ let body: any;
798
+ try {
799
+ body = await request.json();
800
+ } catch (err: any) {
801
+ return Response.json(
802
+ {
803
+ jsonrpc: '2.0',
804
+ id: null,
805
+ error: { code: -32700, message: `Parse error: ${err?.message ?? String(err)}` },
806
+ },
807
+ { status: 400, headers: CORS_HEADERS }
808
+ );
809
+ }
810
+
811
+ // JSON-RPC RESPONSE coming back to a server-initiated request: route to
812
+ // the pending Promise, ack with 204. Distinguished by the absence of
813
+ // `method` plus a known id in the pending map.
814
+ if (
815
+ body &&
816
+ typeof body === 'object' &&
817
+ body.method === undefined &&
818
+ body.id !== undefined &&
819
+ typeof body.id === 'string' &&
820
+ this.pendingRequests.has(body.id)
821
+ ) {
822
+ const pending = this.pendingRequests.get(body.id)!;
823
+ this.pendingRequests.delete(body.id);
824
+ if (body.error) {
825
+ pending.reject(new Error(body.error.message ?? 'Server-initiated request rejected'));
826
+ } else {
827
+ pending.resolve(body.result);
828
+ }
829
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
830
+ }
831
+
832
+ // Tool calls from clients that signal SSE support get a streamed
833
+ // response so this.sample / this.confirm / this.elicit can push
834
+ // server-initiated requests inline. All other JSON-RPC methods (and
835
+ // tool calls from JSON-only clients) use the plain JSON path.
836
+ const accept = request.headers.get('Accept') ?? '';
837
+ const wantsSse = accept.includes('text/event-stream');
838
+ if (body?.method === 'tools/call' && wantsSse) {
839
+ return this._streamToolCall(body);
840
+ }
841
+
842
+ const result = await handleMCPRequest(body, this.photon, this.photonName, this.toolDefinitions);
843
+ return Response.json(result, { headers: CORS_HEADERS });
844
+ }
845
+
846
+ /**
847
+ * SSE-stream a tool call response. Sets up the per-request context so
848
+ * `this.sample` / `this.confirm` / `this.elicit` can push server-initiated
849
+ * MCP requests over the same stream and await the client's responses
850
+ * (which arrive as separate POST /mcp requests routed by `_handleMcpPost`).
851
+ */
852
+ private _streamToolCall(rpcRequest: any): Response {
853
+ const { readable, writable } = new TransformStream();
854
+ const writer = writable.getWriter();
855
+ const encoder = new TextEncoder();
856
+ const send = async (msg: unknown): Promise<void> => {
857
+ await writer.write(encoder.encode(`data: ${JSON.stringify(msg)}\n\n`));
858
+ };
859
+
860
+ const ctx: RequestContext = {
861
+ send,
862
+ pendingRequests: this.pendingRequests,
863
+ };
864
+
865
+ // Fire-and-forget: the response stream stays open as long as the writer
866
+ // is open, which keeps the DO active until the tool finishes and we
867
+ // close the writer in `finally`.
868
+ requestContext
869
+ .run(ctx, async () => {
870
+ try {
871
+ const result = await handleMCPRequest(
872
+ rpcRequest,
873
+ this.photon,
874
+ this.photonName,
875
+ this.toolDefinitions
876
+ );
877
+ await send(result);
878
+ } catch (err: any) {
879
+ await send({
880
+ jsonrpc: '2.0',
881
+ id: rpcRequest?.id ?? null,
882
+ error: { code: -32603, message: err?.message ?? String(err) },
883
+ });
884
+ }
885
+ })
886
+ .finally(() => {
887
+ writer.close().catch(() => {
888
+ // Stream already torn down on the client side; nothing to do.
889
+ });
890
+ });
891
+
892
+ return new Response(readable, {
893
+ headers: {
894
+ 'Content-Type': 'text/event-stream',
895
+ 'Cache-Control': 'no-cache',
896
+ ...CORS_HEADERS,
897
+ },
898
+ });
899
+ }
900
+
901
+ async webSocketMessage(_ws: WebSocket, _msg: string | ArrayBuffer): Promise<void> {
902
+ // Subscribers don't send messages in v1; ignore.
903
+ }
904
+
905
+ async webSocketClose(_ws: WebSocket, _code: number, _reason: string): Promise<void> {
906
+ // No-op — ctx.getWebSockets() prunes closed sockets.
907
+ }
908
+
909
+ /**
910
+ * Alarm fired by the DO scheduler. Walk every persisted schedule, dispatch
911
+ * those that are due, advance bookkeeping, and reschedule the next alarm.
912
+ * Per-task errors move that task to status='error' without blocking others.
913
+ */
914
+ async alarm(): Promise<void> {
915
+ const tasks = await listSchedules(this.ctx);
916
+ const now = Date.now();
917
+ for (const task of tasks) {
918
+ if (task.status !== 'active') continue;
919
+ const fromTs = task.lastExecutionAt
920
+ ? Date.parse(task.lastExecutionAt)
921
+ : Date.parse(task.createdAt);
922
+ const due = nextFireMs(task.cron, new Date(fromTs));
923
+ if (due > now + 1000) continue;
924
+ try {
925
+ const fn = (this.photon as any)[task.method];
926
+ if (typeof fn !== 'function') {
927
+ throw new Error(`Scheduled method '${task.method}' not found on photon`);
928
+ }
929
+ const toolDef = this.toolDefinitions.find((t: any) => t.name === task.method);
930
+ const callArgs = spreadArgs(toolDef, task.params || {});
931
+ await fn.call(this.photon, ...callArgs);
932
+ task.lastExecutionAt = new Date(now).toISOString();
933
+ task.executionCount += 1;
934
+ if (
935
+ task.fireOnce ||
936
+ (task.maxExecutions > 0 && task.executionCount >= task.maxExecutions)
937
+ ) {
938
+ task.status = 'completed';
939
+ }
940
+ } catch (err) {
941
+ task.errorMessage = err instanceof Error ? err.message : String(err);
942
+ task.status = 'error';
943
+ }
944
+ await this.ctx.storage.put(SCHEDULE_PREFIX + task.id, task);
945
+ }
946
+ await rescheduleAlarm(this.ctx);
947
+ }
948
+ }
949
+
950
+ // ════════════════════════════════════════════════════════════════════════════
951
+ // Generated DO classes — one per photon (host + every @photons sibling)
952
+ // ════════════════════════════════════════════════════════════════════════════
953
+
954
+ __PHOTON_DO_CLASSES__
955
+
956
+ // ════════════════════════════════════════════════════════════════════════════
957
+ // Outer Worker — routes every external request to the host photon DO
958
+ // ════════════════════════════════════════════════════════════════════════════
959
+
960
+ const CF_ACCESS_ENABLED = __CF_ACCESS_ENABLED__;
961
+
962
+ /**
963
+ * Pick the photon instance name from (in priority order):
964
+ * 1. CF Access JWT email — when @auth cf-access is set on the photon
965
+ * 2. ?instance=<name> query param
966
+ * 3. X-Photon-Instance header
967
+ * 4. 'default' singleton
968
+ *
969
+ * CF Access verifies the JWT at the edge before the request reaches this
970
+ * Worker, so we trust the claim without re-verifying the signature here.
971
+ */
972
+ function extractInstance(request: Request): string {
973
+ if (CF_ACCESS_ENABLED) {
974
+ const jwt = request.headers.get('Cf-Access-Jwt-Assertion');
975
+ if (jwt) {
976
+ try {
977
+ const payload = JSON.parse(atob(jwt.split('.')[1]));
978
+ if (payload?.email) return payload.email as string;
979
+ } catch {
980
+ // malformed JWT — fall through to default resolution
981
+ }
982
+ }
983
+ }
984
+ const url = new URL(request.url);
985
+ return (
986
+ url.searchParams.get('instance') ?? request.headers.get('X-Photon-Instance') ?? 'default'
987
+ );
988
+ }
989
+
990
+ export default {
991
+ async fetch(request: Request, env: Env): Promise<Response> {
992
+ if (request.method === 'OPTIONS') {
993
+ return new Response(null, {
994
+ headers: {
995
+ 'Access-Control-Allow-Origin': '*',
996
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
997
+ 'Access-Control-Allow-Headers':
998
+ 'Content-Type, Mcp-Session-Id, X-Photon-Instance, Upgrade',
999
+ },
1000
+ });
1001
+ }
1002
+ const instance = extractInstance(request);
1003
+ const id = env.__HOST_BINDING__.idFromName(instance);
1004
+ return env.__HOST_BINDING__.get(id).fetch(request);
1005
+ },
130
1006
  };
131
1007
 
132
- // Playground HTML
133
- function getPlaygroundHTML(): string {
1008
+ // ════════════════════════════════════════════════════════════════════════════
1009
+ // Dev playground UI
1010
+ // ════════════════════════════════════════════════════════════════════════════
1011
+
1012
+ function getPlaygroundHTML(photonName: string, toolDefinitions: any[]): string {
134
1013
  return `<!DOCTYPE html>
135
1014
  <html lang="en">
136
1015
  <head>
137
1016
  <meta charset="UTF-8">
138
1017
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
139
- <title>${PHOTON_NAME} - Playground</title>
1018
+ <title>${photonName} - Playground</title>
140
1019
  <style>
141
1020
  * { margin: 0; padding: 0; box-sizing: border-box; }
142
1021
  :root { --bg: #0a0a0f; --card: #12121a; --border: #1e1e2e; --text: #e4e4e7; --muted: #71717a; --accent: #6366f1; --green: #22c55e; }
@@ -168,7 +1047,7 @@ function getPlaygroundHTML(): string {
168
1047
  </head>
169
1048
  <body>
170
1049
  <div class="header">
171
- <h1>${PHOTON_NAME}</h1>
1050
+ <h1>${photonName}</h1>
172
1051
  <span class="badge">Cloudflare Workers</span>
173
1052
  </div>
174
1053
  <div class="container">
@@ -188,7 +1067,7 @@ function getPlaygroundHTML(): string {
188
1067
  </div>
189
1068
  </div>
190
1069
  <script>
191
- const tools = ${JSON.stringify(TOOL_DEFINITIONS)};
1070
+ const tools = ${JSON.stringify(toolDefinitions)};
192
1071
  let selectedTool = null;
193
1072
 
194
1073
  function renderTools() {
@@ -272,89 +1151,3 @@ function getPlaygroundHTML(): string {
272
1151
  </body>
273
1152
  </html>`;
274
1153
  }
275
-
276
- // Main fetch handler
277
- export default {
278
- async fetch(request: Request, env: Env): Promise<Response> {
279
- const url = new URL(request.url);
280
-
281
- // CORS preflight
282
- if (request.method === 'OPTIONS') {
283
- return new Response(null, {
284
- headers: {
285
- 'Access-Control-Allow-Origin': '*',
286
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
287
- 'Access-Control-Allow-Headers': 'Content-Type',
288
- },
289
- });
290
- }
291
-
292
- // Info endpoint
293
- if (url.pathname === '/' && request.method === 'GET') {
294
- return Response.json({
295
- name: PHOTON_NAME,
296
- transport: 'streamable-http',
297
- runtime: 'cloudflare-workers',
298
- endpoints: {
299
- mcp: '/mcp',
300
- ...(DEV_MODE ? { playground: '/playground' } : {}),
301
- },
302
- tools: TOOL_DEFINITIONS.length,
303
- });
304
- }
305
-
306
- // MCP Streamable HTTP: one endpoint, stateless per request.
307
- if (url.pathname === '/mcp' && request.method === 'POST') {
308
- return handleStreamableMCP(request, env);
309
- }
310
- if (url.pathname === '/mcp' && request.method === 'GET') {
311
- // Spec-compliant no-op: clients that open an SSE stream for server
312
- // notifications get a valid empty stream. We do not push anything
313
- // server-initiated from a stateless Worker.
314
- return new Response(': streamable-http\n\n', {
315
- headers: {
316
- 'Content-Type': 'text/event-stream',
317
- 'Cache-Control': 'no-cache',
318
- ...CORS_HEADERS,
319
- },
320
- });
321
- }
322
- if (url.pathname === '/mcp' && request.method === 'DELETE') {
323
- // Session termination: stateless, nothing to clean up.
324
- return new Response(null, { status: 204, headers: CORS_HEADERS });
325
- }
326
-
327
- // Dev-only endpoints
328
- if (DEV_MODE) {
329
- // Playground
330
- if (url.pathname === '/playground') {
331
- return new Response(getPlaygroundHTML(), {
332
- headers: { 'Content-Type': 'text/html' },
333
- });
334
- }
335
-
336
- // API: List tools
337
- if (url.pathname === '/api/tools') {
338
- return Response.json({ tools: TOOL_DEFINITIONS });
339
- }
340
-
341
- // API: Call tool
342
- if (url.pathname === '/api/call' && request.method === 'POST') {
343
- const { tool, args } = await request.json();
344
- const photon = getPhoton(env);
345
- try {
346
- const method = (photon as any)[tool];
347
- if (typeof method !== 'function') {
348
- throw new Error(`Unknown tool: ${tool}`);
349
- }
350
- const result = await method.call(photon, args || {});
351
- return Response.json({ success: true, data: result });
352
- } catch (error: any) {
353
- return Response.json({ success: false, error: error.message }, { status: 500 });
354
- }
355
- }
356
- }
357
-
358
- return new Response('Not Found', { status: 404 });
359
- },
360
- };