@portel/photon 1.28.1 → 1.29.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.
Files changed (81) hide show
  1. package/README.md +1 -0
  2. package/dist/auto-ui/beam.d.ts.map +1 -1
  3. package/dist/auto-ui/beam.js +94 -47
  4. package/dist/auto-ui/beam.js.map +1 -1
  5. package/dist/auto-ui/streamable-http-transport.d.ts +7 -0
  6. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  7. package/dist/auto-ui/streamable-http-transport.js +228 -29
  8. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  9. package/dist/auto-ui/types.d.ts +9 -1
  10. package/dist/auto-ui/types.d.ts.map +1 -1
  11. package/dist/auto-ui/types.js.map +1 -1
  12. package/dist/beam.bundle.js +32 -2
  13. package/dist/beam.bundle.js.map +2 -2
  14. package/dist/cli/commands/build.d.ts.map +1 -1
  15. package/dist/cli/commands/build.js +123 -15
  16. package/dist/cli/commands/build.js.map +1 -1
  17. package/dist/daemon/manager.d.ts.map +1 -1
  18. package/dist/daemon/manager.js +45 -11
  19. package/dist/daemon/manager.js.map +1 -1
  20. package/dist/daemon/server.js +80 -0
  21. package/dist/daemon/server.js.map +1 -1
  22. package/dist/deploy/cloudflare.d.ts.map +1 -1
  23. package/dist/deploy/cloudflare.js +82 -2
  24. package/dist/deploy/cloudflare.js.map +1 -1
  25. package/dist/editor-support/docblock-tag-catalog.d.ts.map +1 -1
  26. package/dist/editor-support/docblock-tag-catalog.js +32 -2
  27. package/dist/editor-support/docblock-tag-catalog.js.map +1 -1
  28. package/dist/format/registry.d.ts +83 -0
  29. package/dist/format/registry.d.ts.map +1 -0
  30. package/dist/format/registry.js +139 -0
  31. package/dist/format/registry.js.map +1 -0
  32. package/dist/format/seed.d.ts +18 -0
  33. package/dist/format/seed.d.ts.map +1 -0
  34. package/dist/format/seed.js +246 -0
  35. package/dist/format/seed.js.map +1 -0
  36. package/dist/loader.d.ts +18 -0
  37. package/dist/loader.d.ts.map +1 -1
  38. package/dist/loader.js +130 -22
  39. package/dist/loader.js.map +1 -1
  40. package/dist/photons/maker.photon.d.ts +2 -2
  41. package/dist/photons/maker.photon.d.ts.map +1 -1
  42. package/dist/photons/maker.photon.js +5 -6
  43. package/dist/photons/maker.photon.js.map +1 -1
  44. package/dist/photons/maker.photon.ts +5 -6
  45. package/dist/resource-server.d.ts +52 -12
  46. package/dist/resource-server.d.ts.map +1 -1
  47. package/dist/resource-server.js +205 -50
  48. package/dist/resource-server.js.map +1 -1
  49. package/dist/server.d.ts +75 -0
  50. package/dist/server.d.ts.map +1 -1
  51. package/dist/server.js +515 -53
  52. package/dist/server.js.map +1 -1
  53. package/dist/shared/asset-encoding.d.ts +30 -0
  54. package/dist/shared/asset-encoding.d.ts.map +1 -0
  55. package/dist/shared/asset-encoding.js +0 -0
  56. package/dist/shared/asset-encoding.js.map +1 -0
  57. package/dist/shared/cross-origin-headers.d.ts +47 -0
  58. package/dist/shared/cross-origin-headers.d.ts.map +1 -0
  59. package/dist/shared/cross-origin-headers.js +61 -0
  60. package/dist/shared/cross-origin-headers.js.map +1 -0
  61. package/dist/shared/expose-route-extractor.d.ts +36 -0
  62. package/dist/shared/expose-route-extractor.d.ts.map +1 -0
  63. package/dist/shared/expose-route-extractor.js +64 -0
  64. package/dist/shared/expose-route-extractor.js.map +1 -0
  65. package/dist/shared/extract-claims.d.ts +33 -0
  66. package/dist/shared/extract-claims.d.ts.map +1 -0
  67. package/dist/shared/extract-claims.js +60 -0
  68. package/dist/shared/extract-claims.js.map +1 -0
  69. package/dist/shared/http-route-extractor.d.ts +6 -0
  70. package/dist/shared/http-route-extractor.d.ts.map +1 -1
  71. package/dist/shared/http-route-extractor.js +29 -5
  72. package/dist/shared/http-route-extractor.js.map +1 -1
  73. package/dist/shared/instance-binding.d.ts +53 -0
  74. package/dist/shared/instance-binding.d.ts.map +1 -0
  75. package/dist/shared/instance-binding.js +85 -0
  76. package/dist/shared/instance-binding.js.map +1 -0
  77. package/dist/types/server-types.d.ts +1 -0
  78. package/dist/types/server-types.d.ts.map +1 -1
  79. package/package.json +6 -3
  80. package/templates/cloudflare/worker.ts.template +90 -3
  81. package/templates/cloudflare/wrangler.toml.template +1 -1
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { randomUUID } from 'crypto';
20
20
  import { readdir, stat, readFile, writeFile } from 'fs/promises';
21
+ import { readText } from '../shared/io.js';
21
22
  import { join, dirname, extname, resolve, normalize } from 'path';
22
23
  import { homedir } from 'os';
23
24
  import { PHOTON_VERSION } from '../version.js';
@@ -34,6 +35,7 @@ import { buildToolMetadataExtensions } from './types.js';
34
35
  import { generateServerCard } from '../server-card.js';
35
36
  import { audit } from '../shared/audit.js';
36
37
  import { writePhotonEditorDeclaration } from '../photon-editor-declarations.js';
38
+ import { isUriTemplate, matchUriTemplate, parseUriTemplateParams, SubscriptionRegistry, } from '../resource-server.js';
37
39
  import { createTask, getTask, updateTask, listTasks, registerController, unregisterController, getController, taskEvents, } from '../tasks/store.js';
38
40
  import { toWireFormat, relatedTaskMeta, TERMINAL_STATES } from '../tasks/types.js';
39
41
  import { runTaskExecution, resolveTaskInput, waitForTerminalOrInput } from '../tasks/executor.js';
@@ -76,6 +78,40 @@ const sessions = new Map();
76
78
  const pendingElicitations = new Map();
77
79
  const pendingServerRequests = new Map();
78
80
  let nextServerRequestId = 1;
81
+ /**
82
+ * Registry of `resources/subscribe` state for the streamable-HTTP transport.
83
+ * Sinks are keyed by sessionId so per-session disconnect can purge in O(1).
84
+ *
85
+ * `attachLoaderForResourceUpdates(loader)` (exported) bridges photon-author
86
+ * `this.notifyResourceUpdated(uri)` calls into `streamableSubscriptions.notify(uri)`
87
+ * so subscribers get `notifications/resources/updated` over the SSE stream.
88
+ */
89
+ const streamableSubscriptions = new SubscriptionRegistry();
90
+ const sessionSubscriptionSinks = new Map();
91
+ function getOrCreateSessionSink(sessionId) {
92
+ const existing = sessionSubscriptionSinks.get(sessionId);
93
+ if (existing)
94
+ return existing;
95
+ const sink = (uri) => {
96
+ sendToSession(sessionId, 'notifications/resources/updated', { uri });
97
+ };
98
+ sessionSubscriptionSinks.set(sessionId, sink);
99
+ return sink;
100
+ }
101
+ function disconnectSessionSubscriptions(sessionId) {
102
+ const sink = sessionSubscriptionSinks.get(sessionId);
103
+ if (!sink)
104
+ return;
105
+ streamableSubscriptions.disconnect(sink);
106
+ sessionSubscriptionSinks.delete(sessionId);
107
+ }
108
+ /**
109
+ * Wire a PhotonLoader's `notifyResourceUpdated` channel into the streamable-HTTP
110
+ * subscription registry. Call once at server start.
111
+ */
112
+ export function attachLoaderForResourceUpdates(loader) {
113
+ loader.setResourceUpdateNotifier((uri) => streamableSubscriptions.notify(uri));
114
+ }
79
115
  /**
80
116
  * Send a JSON-RPC request to a Beam session over its SSE stream and
81
117
  * await the reply. Backs photon-facing providers (sampling, future
@@ -339,6 +375,7 @@ function startSessionCleanup() {
339
375
  for (const [id, session] of sessions) {
340
376
  if (now - session.lastActivity.getTime() > SESSION_TIMEOUT_MS) {
341
377
  sessions.delete(id);
378
+ disconnectSessionSubscriptions(id);
342
379
  }
343
380
  }
344
381
  }, 60 * 1000);
@@ -556,7 +593,7 @@ const handlers = {
556
593
  capabilities: {
557
594
  tools: { listChanged: true },
558
595
  prompts: { listChanged: true },
559
- resources: { listChanged: true },
596
+ resources: { listChanged: true, subscribe: true },
560
597
  tasks: {
561
598
  list: {},
562
599
  cancel: {},
@@ -2049,23 +2086,72 @@ const handlers = {
2049
2086
  }
2050
2087
  },
2051
2088
  // ─────────────────────────────────────────────────────────────────────────────
2052
- // Resources (MCP Apps ui:// scheme)
2089
+ // Resources
2090
+ //
2091
+ // Mirrors what STDIO surfaces via ResourceServer (src/resource-server.ts):
2092
+ // - ui://<photon>/<id> UI Apps templates (assets.ui)
2093
+ // - approval://<photon>/<id> pending approval requests
2094
+ // - photon://<photon>/prompts/<id> class-level @prompt static-file
2095
+ // - photon://<photon>/resources/<id> class-level @resource static-file
2096
+ // - <author-defined-uri> method-level @resource / @Static
2097
+ // (non-templated entries from mcp.statics)
2098
+ //
2099
+ // Templated method-level URIs (`person://{slug}`) belong on
2100
+ // resources/templates/list and resolve via resources/read.
2053
2101
  // ─────────────────────────────────────────────────────────────────────────────
2054
2102
  'resources/list': async (req, session, ctx) => {
2055
2103
  const resources = [];
2056
2104
  for (const photon of ctx.photons) {
2057
- if (!photon.configured || !photon.assets?.ui)
2105
+ if (!photon.configured)
2058
2106
  continue;
2059
- for (const uiAsset of photon.assets.ui) {
2060
- const uri = uiAsset.uri || `ui://${photon.name}/${uiAsset.id}`;
2061
- resources.push({
2062
- uri,
2063
- name: uiAsset.id,
2064
- mimeType: uiAsset.mimeType || 'text/html;profile=mcp-app',
2065
- description: uiAsset.linkedTool
2066
- ? `UI template for ${photon.name}/${uiAsset.linkedTool}`
2067
- : `UI template: ${uiAsset.id}`,
2068
- });
2107
+ const mcp = ctx.photonMCPs.get(photon.name);
2108
+ if (photon.assets?.ui) {
2109
+ for (const uiAsset of photon.assets.ui) {
2110
+ const uri = uiAsset.uri || `ui://${photon.name}/${uiAsset.id}`;
2111
+ resources.push({
2112
+ uri,
2113
+ name: uiAsset.id,
2114
+ mimeType: uiAsset.mimeType || 'text/html;profile=mcp-app',
2115
+ description: uiAsset.linkedTool
2116
+ ? `UI template for ${photon.name}/${uiAsset.linkedTool}`
2117
+ : `UI template: ${uiAsset.id}`,
2118
+ });
2119
+ }
2120
+ }
2121
+ // Class-level @prompt <id> <path> static-file prompts
2122
+ if (mcp?.assets?.prompts) {
2123
+ for (const prompt of mcp.assets.prompts) {
2124
+ resources.push({
2125
+ uri: `photon://${photon.name}/prompts/${prompt.id}`,
2126
+ name: `prompt:${prompt.id}`,
2127
+ mimeType: 'text/markdown',
2128
+ description: `Prompt template: ${prompt.id}`,
2129
+ });
2130
+ }
2131
+ }
2132
+ // Class-level @resource <id> <path> static-file resources
2133
+ if (mcp?.assets?.resources) {
2134
+ for (const resource of mcp.assets.resources) {
2135
+ resources.push({
2136
+ uri: `photon://${photon.name}/resources/${resource.id}`,
2137
+ name: `resource:${resource.id}`,
2138
+ mimeType: resource.mimeType || 'application/octet-stream',
2139
+ description: `Static resource: ${resource.id}`,
2140
+ });
2141
+ }
2142
+ }
2143
+ // Method-level @resource / @Static — non-templated URIs only.
2144
+ if (mcp?.statics) {
2145
+ for (const stat of mcp.statics) {
2146
+ if (isUriTemplate(stat.uri))
2147
+ continue;
2148
+ resources.push({
2149
+ uri: stat.uri,
2150
+ name: stat.name,
2151
+ mimeType: stat.mimeType || 'text/plain',
2152
+ description: stat.description || `Resource: ${stat.name}`,
2153
+ });
2154
+ }
2069
2155
  }
2070
2156
  }
2071
2157
  // Add pending approval resources (approval:// scheme)
@@ -2081,9 +2167,30 @@ const handlers = {
2081
2167
  }
2082
2168
  return { jsonrpc: '2.0', id: req.id, result: { resources } };
2083
2169
  },
2170
+ 'resources/templates/list': async (req, _session, ctx) => {
2171
+ const resourceTemplates = [];
2172
+ for (const photon of ctx.photons) {
2173
+ if (!photon.configured)
2174
+ continue;
2175
+ const mcp = ctx.photonMCPs.get(photon.name);
2176
+ if (!mcp?.statics)
2177
+ continue;
2178
+ for (const stat of mcp.statics) {
2179
+ if (!isUriTemplate(stat.uri))
2180
+ continue;
2181
+ resourceTemplates.push({
2182
+ uriTemplate: stat.uri,
2183
+ name: stat.name,
2184
+ mimeType: stat.mimeType || 'text/plain',
2185
+ description: stat.description || `Resource template: ${stat.name}`,
2186
+ });
2187
+ }
2188
+ }
2189
+ return { jsonrpc: '2.0', id: req.id, result: { resourceTemplates } };
2190
+ },
2084
2191
  'resources/read': async (req, session, ctx) => {
2085
2192
  const { uri } = req.params;
2086
- // Parse approval:// URI
2193
+ // approval://<photon>/<id>
2087
2194
  const approvalMatch = uri.match(/^approval:\/\/([^/]+)\/(.+)$/);
2088
2195
  if (approvalMatch) {
2089
2196
  const [, photonName, approvalId] = approvalMatch;
@@ -2106,36 +2213,128 @@ const handlers = {
2106
2213
  },
2107
2214
  };
2108
2215
  }
2109
- // Parse ui:// URI
2110
- const match = uri.match(/^ui:\/\/([^/]+)\/(.+)$/);
2111
- if (!match) {
2216
+ // ui://<photon>/<id>
2217
+ const uiMatch = uri.match(/^ui:\/\/([^/]+)\/(.+)$/);
2218
+ if (uiMatch) {
2219
+ const [, photonName, uiId] = uiMatch;
2220
+ const result = await ctx.loadUIAsset(photonName, uiId);
2221
+ if (!result) {
2222
+ return {
2223
+ jsonrpc: '2.0',
2224
+ id: req.id,
2225
+ error: { code: -32602, message: `Resource not found: ${uri}` },
2226
+ };
2227
+ }
2228
+ const mimeType = result.isPhotonTemplate
2229
+ ? 'text/html;profile=mcp-app;photon-template=true'
2230
+ : 'text/html;profile=mcp-app';
2112
2231
  return {
2113
2232
  jsonrpc: '2.0',
2114
2233
  id: req.id,
2115
- error: { code: -32602, message: `Invalid URI: ${uri}` },
2234
+ result: { contents: [{ uri, mimeType, text: result.content }] },
2116
2235
  };
2117
2236
  }
2118
- const [, photonName, uiId] = match;
2119
- const result = await ctx.loadUIAsset(photonName, uiId);
2120
- if (!result) {
2237
+ // photon://<photon>/(prompts|resources)/<id> class-level static-file assets
2238
+ const assetMatch = uri.match(/^photon:\/\/([^/]+)\/(prompts|resources)\/(.+)$/);
2239
+ if (assetMatch) {
2240
+ const [, photonName, kind, assetId] = assetMatch;
2241
+ const mcp = ctx.photonMCPs.get(photonName);
2242
+ if (!mcp?.assets) {
2243
+ return {
2244
+ jsonrpc: '2.0',
2245
+ id: req.id,
2246
+ error: { code: -32602, message: `Photon assets not found: ${uri}` },
2247
+ };
2248
+ }
2249
+ const list = kind === 'prompts' ? mcp.assets.prompts : mcp.assets.resources;
2250
+ const asset = list?.find((a) => a.id === assetId);
2251
+ const resolvedPath = asset?.resolvedPath;
2252
+ if (!resolvedPath) {
2253
+ return {
2254
+ jsonrpc: '2.0',
2255
+ id: req.id,
2256
+ error: { code: -32602, message: `Asset not found: ${uri}` },
2257
+ };
2258
+ }
2259
+ const text = await readText(resolvedPath);
2260
+ const mimeType = kind === 'prompts'
2261
+ ? 'text/markdown'
2262
+ : asset?.mimeType || 'application/octet-stream';
2121
2263
  return {
2122
2264
  jsonrpc: '2.0',
2123
2265
  id: req.id,
2124
- error: { code: -32602, message: `Resource not found: ${uri}` },
2266
+ result: { contents: [{ uri, mimeType, text }] },
2125
2267
  };
2126
2268
  }
2127
- // Signal declarative mode (.photon.html) via mimeType parameter
2128
- const mimeType = result.isPhotonTemplate
2129
- ? 'text/html;profile=mcp-app;photon-template=true'
2130
- : 'text/html;profile=mcp-app';
2269
+ // Method-level @resource / @Static match against any photon's statics.
2270
+ // The URI may be exact or match a registered template; on match, dispatch
2271
+ // through the loader so middleware (auth, logging, audit) applies.
2272
+ for (const photon of ctx.photons) {
2273
+ const mcp = ctx.photonMCPs.get(photon.name);
2274
+ if (!mcp?.statics)
2275
+ continue;
2276
+ for (const stat of mcp.statics) {
2277
+ const matched = stat.uri === uri || matchUriTemplate(stat.uri, uri);
2278
+ if (!matched)
2279
+ continue;
2280
+ const params = isUriTemplate(stat.uri) ? parseUriTemplateParams(stat.uri, uri) : {};
2281
+ if (!ctx.loader) {
2282
+ return {
2283
+ jsonrpc: '2.0',
2284
+ id: req.id,
2285
+ error: {
2286
+ code: -32603,
2287
+ message: 'Loader not available for static resource resolution',
2288
+ },
2289
+ };
2290
+ }
2291
+ const result = await ctx.loader.executeTool(mcp, stat.name, params);
2292
+ const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
2293
+ return {
2294
+ jsonrpc: '2.0',
2295
+ id: req.id,
2296
+ result: {
2297
+ contents: [{ uri, mimeType: stat.mimeType || 'text/plain', text }],
2298
+ },
2299
+ };
2300
+ }
2301
+ }
2131
2302
  return {
2132
2303
  jsonrpc: '2.0',
2133
2304
  id: req.id,
2134
- result: {
2135
- contents: [{ uri, mimeType, text: result.content }],
2136
- },
2305
+ error: { code: -32602, message: `Resource not found: ${uri}` },
2137
2306
  };
2138
2307
  },
2308
+ // resources/subscribe and resources/unsubscribe — exact-URI keyed.
2309
+ // The session's sink is keyed off session.id so disconnect logic in the
2310
+ // session-cleanup path can purge subscriptions in O(1).
2311
+ 'resources/subscribe': async (req, session) => {
2312
+ const { uri } = req.params;
2313
+ if (typeof uri !== 'string' || !uri) {
2314
+ return {
2315
+ jsonrpc: '2.0',
2316
+ id: req.id,
2317
+ error: { code: -32602, message: 'resources/subscribe requires `uri`' },
2318
+ };
2319
+ }
2320
+ const sink = getOrCreateSessionSink(session.id);
2321
+ streamableSubscriptions.subscribe(sink, uri);
2322
+ return { jsonrpc: '2.0', id: req.id, result: {} };
2323
+ },
2324
+ 'resources/unsubscribe': async (req, session) => {
2325
+ const { uri } = req.params;
2326
+ if (typeof uri !== 'string' || !uri) {
2327
+ return {
2328
+ jsonrpc: '2.0',
2329
+ id: req.id,
2330
+ error: { code: -32602, message: 'resources/unsubscribe requires `uri`' },
2331
+ };
2332
+ }
2333
+ const sink = sessionSubscriptionSinks.get(session.id);
2334
+ if (sink)
2335
+ streamableSubscriptions.unsubscribe(sink, uri);
2336
+ return { jsonrpc: '2.0', id: req.id, result: {} };
2337
+ },
2139
2338
  'prompts/list': async (req, _session, ctx) => {
2140
2339
  const prompts = [];
2141
2340
  for (const photon of ctx.photons) {