@portel/photon 1.32.5 → 1.33.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 (70) hide show
  1. package/dist/auto-ui/beam/routes/api-config.js +1 -1
  2. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  3. package/dist/auto-ui/beam/types.d.ts +1 -0
  4. package/dist/auto-ui/beam/types.d.ts.map +1 -1
  5. package/dist/auto-ui/beam.d.ts.map +1 -1
  6. package/dist/auto-ui/beam.js +58 -9
  7. package/dist/auto-ui/beam.js.map +1 -1
  8. package/dist/auto-ui/components/card.d.ts +1 -1
  9. package/dist/auto-ui/components/card.d.ts.map +1 -1
  10. package/dist/auto-ui/components/card.js +1 -1
  11. package/dist/auto-ui/components/card.js.map +1 -1
  12. package/dist/auto-ui/components/checklist.d.ts +1 -1
  13. package/dist/auto-ui/components/checklist.d.ts.map +1 -1
  14. package/dist/auto-ui/components/checklist.js +1 -1
  15. package/dist/auto-ui/components/checklist.js.map +1 -1
  16. package/dist/auto-ui/components/form.d.ts +1 -1
  17. package/dist/auto-ui/components/form.d.ts.map +1 -1
  18. package/dist/auto-ui/components/form.js +2 -2
  19. package/dist/auto-ui/components/form.js.map +1 -1
  20. package/dist/auto-ui/components/list.d.ts +1 -1
  21. package/dist/auto-ui/components/list.d.ts.map +1 -1
  22. package/dist/auto-ui/components/list.js +1 -1
  23. package/dist/auto-ui/components/list.js.map +1 -1
  24. package/dist/auto-ui/components/progress.d.ts +1 -1
  25. package/dist/auto-ui/components/progress.d.ts.map +1 -1
  26. package/dist/auto-ui/components/progress.js +1 -1
  27. package/dist/auto-ui/components/progress.js.map +1 -1
  28. package/dist/auto-ui/components/table.d.ts +1 -1
  29. package/dist/auto-ui/components/table.d.ts.map +1 -1
  30. package/dist/auto-ui/components/table.js +1 -1
  31. package/dist/auto-ui/components/table.js.map +1 -1
  32. package/dist/auto-ui/components/tree.d.ts +1 -1
  33. package/dist/auto-ui/components/tree.d.ts.map +1 -1
  34. package/dist/auto-ui/components/tree.js +1 -1
  35. package/dist/auto-ui/components/tree.js.map +1 -1
  36. package/dist/auto-ui/streamable-http-transport.d.ts +1 -0
  37. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  38. package/dist/auto-ui/streamable-http-transport.js +40 -5
  39. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  40. package/dist/auto-ui/ui-resolver.d.ts +12 -1
  41. package/dist/auto-ui/ui-resolver.d.ts.map +1 -1
  42. package/dist/auto-ui/ui-resolver.js +19 -3
  43. package/dist/auto-ui/ui-resolver.js.map +1 -1
  44. package/dist/cli/commands/build.d.ts.map +1 -1
  45. package/dist/cli/commands/build.js +13 -5
  46. package/dist/cli/commands/build.js.map +1 -1
  47. package/dist/cli/commands/ps.d.ts +4 -0
  48. package/dist/cli/commands/ps.d.ts.map +1 -1
  49. package/dist/cli/commands/ps.js +19 -5
  50. package/dist/cli/commands/ps.js.map +1 -1
  51. package/dist/daemon/manager.d.ts +8 -0
  52. package/dist/daemon/manager.d.ts.map +1 -1
  53. package/dist/daemon/manager.js +46 -9
  54. package/dist/daemon/manager.js.map +1 -1
  55. package/dist/deploy/cloudflare.d.ts.map +1 -1
  56. package/dist/deploy/cloudflare.js +55 -7
  57. package/dist/deploy/cloudflare.js.map +1 -1
  58. package/dist/resource-server.d.ts.map +1 -1
  59. package/dist/resource-server.js +5 -2
  60. package/dist/resource-server.js.map +1 -1
  61. package/dist/server.d.ts +1 -0
  62. package/dist/server.d.ts.map +1 -1
  63. package/dist/server.js +206 -14
  64. package/dist/server.js.map +1 -1
  65. package/dist/tsx-compiler.d.ts +65 -5
  66. package/dist/tsx-compiler.d.ts.map +1 -1
  67. package/dist/tsx-compiler.js +531 -52
  68. package/dist/tsx-compiler.js.map +1 -1
  69. package/package.json +3 -3
  70. package/templates/cloudflare/worker.ts.template +60 -0
package/dist/server.js CHANGED
@@ -121,6 +121,83 @@ function uiSiblingMime(ext) {
121
121
  return 'application/octet-stream';
122
122
  }
123
123
  }
124
+ function splitServerRoutePath(pathname) {
125
+ const normalized = pathname === '/' ? '/' : pathname.replace(/\/+$/, '');
126
+ if (normalized === '/')
127
+ return [];
128
+ return normalized.split('/').filter(Boolean);
129
+ }
130
+ function serverWebRouteMatches(routePath, requestPath) {
131
+ if (routePath === requestPath)
132
+ return true;
133
+ const routeParts = splitServerRoutePath(routePath);
134
+ const requestParts = splitServerRoutePath(requestPath);
135
+ if (routeParts.length !== requestParts.length)
136
+ return false;
137
+ for (let i = 0; i < routeParts.length; i++) {
138
+ const routePart = routeParts[i];
139
+ const requestPart = requestParts[i];
140
+ if (routePart.startsWith(':')) {
141
+ if (!requestPart)
142
+ return false;
143
+ continue;
144
+ }
145
+ if (routePart !== requestPart)
146
+ return false;
147
+ }
148
+ return true;
149
+ }
150
+ function serverWebRouteScore(routePath) {
151
+ return splitServerRoutePath(routePath).reduce((score, part) => score + (part.startsWith(':') ? 1 : 10), routePath === '/' ? 100 : 0);
152
+ }
153
+ function findServerWebRoute(routes, method, requestPath) {
154
+ if (!routes?.length || !method)
155
+ return undefined;
156
+ const wantedMethod = method.toUpperCase();
157
+ return routes
158
+ .filter((route) => route.method.toUpperCase() === wantedMethod &&
159
+ serverWebRouteMatches(route.path, requestPath))
160
+ .sort((a, b) => serverWebRouteScore(b.path) - serverWebRouteScore(a.path))[0];
161
+ }
162
+ function uiAssetPath(asset) {
163
+ return asset.resolvedPath || asset.path || '';
164
+ }
165
+ function isTsxUiAsset(asset) {
166
+ return uiAssetPath(asset).endsWith('.tsx');
167
+ }
168
+ function selectServerClientAppUi(photon) {
169
+ const uiAssets = photon?.assets?.ui || [];
170
+ const linkedUi = photon?.appEntry?.linkedUi;
171
+ if (linkedUi) {
172
+ const linkedAsset = uiAssets.find((ui) => ui.id === linkedUi);
173
+ if (!linkedAsset || isTsxUiAsset(linkedAsset))
174
+ return linkedUi;
175
+ }
176
+ const namedApp = uiAssets.find((ui) => ui.id === 'app' && isTsxUiAsset(ui));
177
+ if (namedApp)
178
+ return namedApp.id;
179
+ const tsxAssets = uiAssets.filter(isTsxUiAsset);
180
+ if (tsxAssets.length === 1)
181
+ return tsxAssets[0].id;
182
+ return undefined;
183
+ }
184
+ function selectServerWebAppUrl(photon) {
185
+ if (!photon?.name)
186
+ return undefined;
187
+ const hasWebRoot = photon._httpRoutes?.some((route) => route.method === 'GET' && route.path === '/');
188
+ if (!hasWebRoot && !selectServerClientAppUi(photon))
189
+ return undefined;
190
+ return `/web/${photon.name}/`;
191
+ }
192
+ function shouldFallbackToServerClientApp(pathname, searchParams, route) {
193
+ if (route)
194
+ return false;
195
+ if (searchParams.get('legacy') === '1')
196
+ return false;
197
+ if (pathname === '/mcp' || pathname.startsWith('/mcp/'))
198
+ return false;
199
+ return true;
200
+ }
124
201
  function findFreePort(preferred = 0) {
125
202
  return new Promise((resolve, reject) => {
126
203
  const srv = createServer();
@@ -171,6 +248,12 @@ class BeamCompatTransport {
171
248
  'x-photon-icon': this.photonMeta.icon || '⚡',
172
249
  'x-photon-stateful': this.photonMeta.stateful || false,
173
250
  'x-photon-has-settings': this.photonMeta.hasSettings || false,
251
+ ...(this.photonMeta.webUrl
252
+ ? {
253
+ 'x-web-url': this.photonMeta.webUrl,
254
+ 'x-web-description': this.photonMeta.webDescription || this.photonMeta.description || '',
255
+ }
256
+ : {}),
174
257
  }));
175
258
  // Append sub-photon tools (each with their own x-photon-* metadata)
176
259
  for (const sub of this.subPhotons) {
@@ -183,6 +266,12 @@ class BeamCompatTransport {
183
266
  'x-photon-icon': sub.icon,
184
267
  'x-photon-stateful': sub.stateful,
185
268
  'x-photon-has-settings': sub.hasSettings,
269
+ ...(sub.webUrl
270
+ ? {
271
+ 'x-web-url': sub.webUrl,
272
+ 'x-web-description': sub.webDescription || sub.description,
273
+ }
274
+ : {}),
186
275
  };
187
276
  // Add UI linking metadata if this tool has a linked UI
188
277
  const meta = buildToolMCPMeta(tool, {
@@ -2140,6 +2229,62 @@ export class PhotonServer {
2140
2229
  }
2141
2230
  res.writeHead(404).end('Not Found');
2142
2231
  }
2232
+ async serveTopLevelUiAsset(req, res, uiId, corsOrigin) {
2233
+ const ui = this.mcp?.assets?.ui.find((asset) => asset.id === uiId);
2234
+ if (!ui?.resolvedPath)
2235
+ return false;
2236
+ try {
2237
+ // Non-.tsx assets: serve the file as-is (unchanged behaviour).
2238
+ if (!ui.resolvedPath.endsWith('.tsx')) {
2239
+ const content = await readText(ui.resolvedPath);
2240
+ const uiHeaders = { 'Content-Type': 'text/html' };
2241
+ if (corsOrigin)
2242
+ uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2243
+ if (detectIsolationMode(req) === 'standalone') {
2244
+ uiHeaders['Cross-Origin-Opener-Policy'] = 'same-origin';
2245
+ uiHeaders['Cross-Origin-Embedder-Policy'] = 'require-corp';
2246
+ }
2247
+ res.writeHead(200, uiHeaders);
2248
+ res.end(content);
2249
+ return true;
2250
+ }
2251
+ const { compileTsxCached, tsxHttpResponse } = await import('./tsx-compiler.js');
2252
+ const compiled = await compileTsxCached(ui.resolvedPath);
2253
+ // This mount doubles as the SPA fallback (any unmatched GET), so the
2254
+ // browser's relative `./<hash>.js` request may arrive at an arbitrary
2255
+ // depth. The hashed filename is unique, so match it by basename to
2256
+ // serve the immutable bundle; everything else gets the shell.
2257
+ const reqPath = (req.url ?? '').split('?')[0];
2258
+ const lastSeg = decodeURIComponent(reqPath.slice(reqPath.lastIndexOf('/') + 1));
2259
+ const restPath = compiled.jsFileName && lastSeg === compiled.jsFileName ? lastSeg : '';
2260
+ const r = tsxHttpResponse(compiled, restPath);
2261
+ // Cheap revalidation: 304 when the shell hash is unchanged.
2262
+ const inm = req.headers['if-none-match'];
2263
+ if (r.headers['ETag'] && inm && inm === r.headers['ETag']) {
2264
+ const notMod = { ETag: r.headers['ETag'] };
2265
+ if (corsOrigin)
2266
+ notMod['Access-Control-Allow-Origin'] = corsOrigin;
2267
+ res.writeHead(304, notMod);
2268
+ res.end();
2269
+ return true;
2270
+ }
2271
+ const uiHeaders = { ...r.headers };
2272
+ if (corsOrigin)
2273
+ uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2274
+ if (!restPath && detectIsolationMode(req) === 'standalone') {
2275
+ uiHeaders['Cross-Origin-Opener-Policy'] = 'same-origin';
2276
+ uiHeaders['Cross-Origin-Embedder-Policy'] = 'require-corp';
2277
+ }
2278
+ if (restPath)
2279
+ uiHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
2280
+ res.writeHead(r.status, uiHeaders);
2281
+ res.end(r.body);
2282
+ return true;
2283
+ }
2284
+ catch {
2285
+ return false;
2286
+ }
2287
+ }
2143
2288
  /**
2144
2289
  * Start server with SSE transport (HTTP)
2145
2290
  */
@@ -2156,11 +2301,15 @@ export class PhotonServer {
2156
2301
  let beamTransport = null;
2157
2302
  {
2158
2303
  const photonName = this.mcp?.name || 'photon';
2304
+ const photonWebUrl = selectServerWebAppUrl(this.mcp);
2159
2305
  beamTransport = new BeamCompatTransport(photonName, {
2160
2306
  description: this.mcp?.description,
2161
2307
  icon: this.mcp?.icon,
2162
2308
  stateful: !!this.mcp?.stateful,
2163
2309
  hasSettings: !!this.mcp?.hasSettings,
2310
+ ...(photonWebUrl
2311
+ ? { webUrl: photonWebUrl, webDescription: this.mcp?.description || `${photonName} MCP` }
2312
+ : {}),
2164
2313
  });
2165
2314
  this.capabilityNegotiator.interceptTransportForRawCapabilities(beamTransport, this.server, (msg) => this.channelManager.interceptPermissionRequest(msg));
2166
2315
  await this.server.connect(beamTransport);
@@ -2176,6 +2325,7 @@ export class PhotonServer {
2176
2325
  // these two callsites still need the narrower runtime cast until
2177
2326
  // the loader's return type widens to PhotonClassWithMeta.
2178
2327
  const hasSettings = !!loaded.settingsSchema?.hasSettings;
2328
+ const webUrl = selectServerWebAppUrl(loaded);
2179
2329
  // Convert PhotonTool[] to MCP tool format with UI linking
2180
2330
  const uiAssets = loaded.assets?.ui || [];
2181
2331
  const tools = loaded.tools
@@ -2195,6 +2345,7 @@ export class PhotonServer {
2195
2345
  icon,
2196
2346
  stateful,
2197
2347
  hasSettings,
2348
+ ...(webUrl ? { webUrl, webDescription: loaded.description || `${loaded.name} MCP` } : {}),
2198
2349
  tools,
2199
2350
  });
2200
2351
  }
@@ -2225,6 +2376,11 @@ export class PhotonServer {
2225
2376
  }
2226
2377
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
2227
2378
  const corsOrigin = getCorsOrigin(req);
2379
+ let matchedRoute = null;
2380
+ const route = findServerWebRoute(this.mcp?._httpRoutes, req.method, url.pathname);
2381
+ if (route)
2382
+ matchedRoute = { handler: route.handler, format: route.format };
2383
+ const clientAppUi = selectServerClientAppUi(this.mcp);
2228
2384
  // Handle CORS preflight
2229
2385
  if (req.method === 'OPTIONS') {
2230
2386
  const preflightHeaders = {
@@ -2262,7 +2418,7 @@ export class PhotonServer {
2262
2418
  return;
2263
2419
  }
2264
2420
  // Health check / info endpoint
2265
- if (req.method === 'GET' && url.pathname === '/') {
2421
+ if (req.method === 'GET' && url.pathname === '/' && !matchedRoute && !clientAppUi) {
2266
2422
  res.writeHead(200, { 'Content-Type': 'application/json' });
2267
2423
  const endpoints = {
2268
2424
  sse: `http://localhost:${port}${ssePath}`,
@@ -2529,14 +2685,31 @@ export class PhotonServer {
2529
2685
  }
2530
2686
  if (ui?.resolvedPath) {
2531
2687
  try {
2532
- let content;
2533
2688
  if (ui.resolvedPath.endsWith('.tsx')) {
2534
- const { compileTsxCached } = await import('./tsx-compiler.js');
2535
- content = await compileTsxCached(ui.resolvedPath);
2536
- }
2537
- else {
2538
- content = await readText(ui.resolvedPath);
2689
+ const { compileTsxCached, tsxHttpResponse } = await import('./tsx-compiler.js');
2690
+ const compiled = await compileTsxCached(ui.resolvedPath);
2691
+ const r = tsxHttpResponse(compiled, '');
2692
+ const inm = req.headers['if-none-match'];
2693
+ if (r.headers['ETag'] && inm && inm === r.headers['ETag']) {
2694
+ const notMod = { ETag: r.headers['ETag'] };
2695
+ if (corsOrigin)
2696
+ notMod['Access-Control-Allow-Origin'] = corsOrigin;
2697
+ res.writeHead(304, notMod);
2698
+ res.end();
2699
+ return;
2700
+ }
2701
+ const tsxHeaders = { ...r.headers };
2702
+ if (corsOrigin)
2703
+ tsxHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2704
+ if (detectIsolationMode(req) === 'standalone') {
2705
+ tsxHeaders['Cross-Origin-Opener-Policy'] = 'same-origin';
2706
+ tsxHeaders['Cross-Origin-Embedder-Policy'] = 'require-corp';
2707
+ }
2708
+ res.writeHead(r.status, tsxHeaders);
2709
+ res.end(r.body);
2710
+ return;
2539
2711
  }
2712
+ const content = await readText(ui.resolvedPath);
2540
2713
  const uiHeaders = { 'Content-Type': 'text/html' };
2541
2714
  if (corsOrigin)
2542
2715
  uiHeaders['Access-Control-Allow-Origin'] = corsOrigin;
@@ -2560,6 +2733,25 @@ export class PhotonServer {
2560
2733
  res.writeHead(404).end('UI not found');
2561
2734
  return;
2562
2735
  }
2736
+ // Compiled .tsx bundle: the shell references `./<base>.<hash>.js`,
2737
+ // which lands here as a sub-path. Serve it from the compile cache
2738
+ // with an immutable cache policy (the hash is the cache key).
2739
+ if (ui?.resolvedPath?.endsWith('.tsx')) {
2740
+ const { compileTsxCached, tsxHttpResponse } = await import('./tsx-compiler.js');
2741
+ const compiled = await compileTsxCached(ui.resolvedPath);
2742
+ const r = tsxHttpResponse(compiled, restPath);
2743
+ if (r.status === 200) {
2744
+ const jsHeaders = { ...r.headers };
2745
+ if (corsOrigin)
2746
+ jsHeaders['Access-Control-Allow-Origin'] = corsOrigin;
2747
+ jsHeaders['Cross-Origin-Resource-Policy'] = 'same-origin';
2748
+ res.writeHead(200, jsHeaders);
2749
+ res.end(r.body);
2750
+ return;
2751
+ }
2752
+ // Not the bundle (e.g. a static sibling shipped beside the .tsx) —
2753
+ // fall through to filesystem resolution below.
2754
+ }
2563
2755
  // Sub-path: directory-style sibling resolution. Try the filesystem
2564
2756
  // first (dev mode), then the embedded asset tree (compiled binary).
2565
2757
  if (ui?.resolvedPath) {
@@ -2733,13 +2925,6 @@ export class PhotonServer {
2733
2925
  // @get / @post HTTP routes — dispatch to photon method, public (no auth).
2734
2926
  // Track C: when none match, fall through to the auto-RPC table built
2735
2927
  // from @expose tags below.
2736
- const httpRoutes = this.mcp?._httpRoutes;
2737
- let matchedRoute = null;
2738
- if (httpRoutes?.length && req.method) {
2739
- const route = httpRoutes.find((r) => r.method === req.method && r.path === url.pathname);
2740
- if (route)
2741
- matchedRoute = { handler: route.handler, format: route.format };
2742
- }
2743
2928
  // Track C: auto-RPC. POST /api/<kebab-method> dispatches to @expose'd
2744
2929
  // methods. Explicit @get/@post takes precedence (matchedRoute already
2745
2930
  // set above) so a user can override path/verb for any @expose'd
@@ -2871,6 +3056,13 @@ export class PhotonServer {
2871
3056
  return;
2872
3057
  }
2873
3058
  }
3059
+ if (req.method === 'GET' &&
3060
+ clientAppUi &&
3061
+ shouldFallbackToServerClientApp(url.pathname, url.searchParams, matchedRoute ?? undefined)) {
3062
+ const served = await this.serveTopLevelUiAsset(req, res, clientAppUi, corsOrigin);
3063
+ if (served)
3064
+ return;
3065
+ }
2874
3066
  res.writeHead(404).end('Not Found');
2875
3067
  })();
2876
3068
  });