@portel/photon 1.28.2 → 1.31.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 (183) hide show
  1. package/README.md +42 -11
  2. package/dist/asset-resolver.d.ts +44 -0
  3. package/dist/asset-resolver.d.ts.map +1 -0
  4. package/dist/asset-resolver.js +105 -0
  5. package/dist/asset-resolver.js.map +1 -0
  6. package/dist/auto-ui/beam/external-mcp-manager.d.ts +73 -0
  7. package/dist/auto-ui/beam/external-mcp-manager.d.ts.map +1 -0
  8. package/dist/auto-ui/beam/external-mcp-manager.js +65 -0
  9. package/dist/auto-ui/beam/external-mcp-manager.js.map +1 -0
  10. package/dist/auto-ui/beam/external-mcp.d.ts.map +1 -1
  11. package/dist/auto-ui/beam/external-mcp.js +25 -1
  12. package/dist/auto-ui/beam/external-mcp.js.map +1 -1
  13. package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
  14. package/dist/auto-ui/beam/photon-management.js +11 -8
  15. package/dist/auto-ui/beam/photon-management.js.map +1 -1
  16. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
  17. package/dist/auto-ui/beam/routes/api-browse.js +7 -4
  18. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
  19. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  20. package/dist/auto-ui/beam/routes/api-config.js +3 -2
  21. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  22. package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
  23. package/dist/auto-ui/beam/routes/api-marketplace.js +6 -2
  24. package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
  25. package/dist/auto-ui/beam/startup.js.map +1 -1
  26. package/dist/auto-ui/beam/types.d.ts +5 -2
  27. package/dist/auto-ui/beam/types.d.ts.map +1 -1
  28. package/dist/auto-ui/beam.d.ts.map +1 -1
  29. package/dist/auto-ui/beam.js +239 -88
  30. package/dist/auto-ui/beam.js.map +1 -1
  31. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  32. package/dist/auto-ui/bridge/index.js +11 -0
  33. package/dist/auto-ui/bridge/index.js.map +1 -1
  34. package/dist/auto-ui/bridge/types.d.ts +2 -0
  35. package/dist/auto-ui/bridge/types.d.ts.map +1 -1
  36. package/dist/auto-ui/openapi-generator.js +1 -4
  37. package/dist/auto-ui/openapi-generator.js.map +1 -1
  38. package/dist/auto-ui/photon-bridge.d.ts +4 -0
  39. package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
  40. package/dist/auto-ui/photon-bridge.js.map +1 -1
  41. package/dist/auto-ui/photon-host.js.map +1 -1
  42. package/dist/auto-ui/streamable-http-transport.d.ts +7 -0
  43. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  44. package/dist/auto-ui/streamable-http-transport.js +252 -43
  45. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  46. package/dist/auto-ui/types.d.ts +24 -2
  47. package/dist/auto-ui/types.d.ts.map +1 -1
  48. package/dist/auto-ui/types.js.map +1 -1
  49. package/dist/beam.bundle.js +202 -24
  50. package/dist/beam.bundle.js.map +3 -3
  51. package/dist/capability-negotiator.d.ts +39 -1
  52. package/dist/capability-negotiator.d.ts.map +1 -1
  53. package/dist/capability-negotiator.js +5 -0
  54. package/dist/capability-negotiator.js.map +1 -1
  55. package/dist/cf-bindings-parser.d.ts +15 -0
  56. package/dist/cf-bindings-parser.d.ts.map +1 -0
  57. package/dist/cf-bindings-parser.js +98 -0
  58. package/dist/cf-bindings-parser.js.map +1 -0
  59. package/dist/cf-usage-scanner.d.ts +76 -0
  60. package/dist/cf-usage-scanner.d.ts.map +1 -0
  61. package/dist/cf-usage-scanner.js +179 -0
  62. package/dist/cf-usage-scanner.js.map +1 -0
  63. package/dist/cli/commands/build.d.ts.map +1 -1
  64. package/dist/cli/commands/build.js +124 -16
  65. package/dist/cli/commands/build.js.map +1 -1
  66. package/dist/cli/commands/cf.d.ts +18 -0
  67. package/dist/cli/commands/cf.d.ts.map +1 -0
  68. package/dist/cli/commands/cf.js +207 -0
  69. package/dist/cli/commands/cf.js.map +1 -0
  70. package/dist/cli/commands/info.js +1 -1
  71. package/dist/cli/commands/info.js.map +1 -1
  72. package/dist/cli/commands/init.d.ts.map +1 -1
  73. package/dist/cli/commands/init.js +59 -46
  74. package/dist/cli/commands/init.js.map +1 -1
  75. package/dist/cli/commands/run.d.ts.map +1 -1
  76. package/dist/cli/commands/run.js +3 -0
  77. package/dist/cli/commands/run.js.map +1 -1
  78. package/dist/cli/index.d.ts.map +1 -1
  79. package/dist/cli/index.js +43 -6
  80. package/dist/cli/index.js.map +1 -1
  81. package/dist/daemon/client.d.ts.map +1 -1
  82. package/dist/daemon/client.js +40 -33
  83. package/dist/daemon/client.js.map +1 -1
  84. package/dist/daemon/manager.d.ts +6 -2
  85. package/dist/daemon/manager.d.ts.map +1 -1
  86. package/dist/daemon/manager.js +75 -20
  87. package/dist/daemon/manager.js.map +1 -1
  88. package/dist/daemon/server.js +69 -11
  89. package/dist/daemon/server.js.map +1 -1
  90. package/dist/daemon/worker-host.js.map +1 -1
  91. package/dist/deploy/cloudflare.d.ts +27 -0
  92. package/dist/deploy/cloudflare.d.ts.map +1 -1
  93. package/dist/deploy/cloudflare.js +210 -3
  94. package/dist/deploy/cloudflare.js.map +1 -1
  95. package/dist/editor-support/docblock-tag-catalog.d.ts.map +1 -1
  96. package/dist/editor-support/docblock-tag-catalog.js +32 -2
  97. package/dist/editor-support/docblock-tag-catalog.js.map +1 -1
  98. package/dist/embedded-runtime.js.map +1 -1
  99. package/dist/format/registry.d.ts +83 -0
  100. package/dist/format/registry.d.ts.map +1 -0
  101. package/dist/format/registry.js +139 -0
  102. package/dist/format/registry.js.map +1 -0
  103. package/dist/format/seed.d.ts +18 -0
  104. package/dist/format/seed.d.ts.map +1 -0
  105. package/dist/format/seed.js +246 -0
  106. package/dist/format/seed.js.map +1 -0
  107. package/dist/loader.d.ts +61 -66
  108. package/dist/loader.d.ts.map +1 -1
  109. package/dist/loader.js +315 -327
  110. package/dist/loader.js.map +1 -1
  111. package/dist/photon-cli-runner.d.ts.map +1 -1
  112. package/dist/photon-cli-runner.js +20 -11
  113. package/dist/photon-cli-runner.js.map +1 -1
  114. package/dist/photons/maker.photon.d.ts +2 -2
  115. package/dist/photons/maker.photon.d.ts.map +1 -1
  116. package/dist/photons/maker.photon.js +5 -6
  117. package/dist/photons/maker.photon.js.map +1 -1
  118. package/dist/photons/maker.photon.ts +5 -6
  119. package/dist/resource-server.d.ts +55 -15
  120. package/dist/resource-server.d.ts.map +1 -1
  121. package/dist/resource-server.js +205 -50
  122. package/dist/resource-server.js.map +1 -1
  123. package/dist/runtime/cf-local.d.ts +157 -0
  124. package/dist/runtime/cf-local.d.ts.map +1 -0
  125. package/dist/runtime/cf-local.js +406 -0
  126. package/dist/runtime/cf-local.js.map +1 -0
  127. package/dist/server.d.ts +117 -2
  128. package/dist/server.d.ts.map +1 -1
  129. package/dist/server.js +681 -67
  130. package/dist/server.js.map +1 -1
  131. package/dist/settings-persistence.d.ts +50 -0
  132. package/dist/settings-persistence.d.ts.map +1 -0
  133. package/dist/settings-persistence.js +188 -0
  134. package/dist/settings-persistence.js.map +1 -0
  135. package/dist/shared/asset-encoding.d.ts +30 -0
  136. package/dist/shared/asset-encoding.d.ts.map +1 -0
  137. package/dist/shared/asset-encoding.js +0 -0
  138. package/dist/shared/asset-encoding.js.map +1 -0
  139. package/dist/shared/audit-sqlite.d.ts.map +1 -1
  140. package/dist/shared/audit-sqlite.js +0 -1
  141. package/dist/shared/audit-sqlite.js.map +1 -1
  142. package/dist/shared/cross-origin-headers.d.ts +47 -0
  143. package/dist/shared/cross-origin-headers.d.ts.map +1 -0
  144. package/dist/shared/cross-origin-headers.js +61 -0
  145. package/dist/shared/cross-origin-headers.js.map +1 -0
  146. package/dist/shared/error-handler.d.ts.map +1 -1
  147. package/dist/shared/error-handler.js +3 -1
  148. package/dist/shared/error-handler.js.map +1 -1
  149. package/dist/shared/expose-route-extractor.d.ts +36 -0
  150. package/dist/shared/expose-route-extractor.d.ts.map +1 -0
  151. package/dist/shared/expose-route-extractor.js +64 -0
  152. package/dist/shared/expose-route-extractor.js.map +1 -0
  153. package/dist/shared/extract-claims.d.ts +33 -0
  154. package/dist/shared/extract-claims.d.ts.map +1 -0
  155. package/dist/shared/extract-claims.js +60 -0
  156. package/dist/shared/extract-claims.js.map +1 -0
  157. package/dist/shared/http-route-extractor.d.ts +6 -0
  158. package/dist/shared/http-route-extractor.d.ts.map +1 -1
  159. package/dist/shared/http-route-extractor.js +29 -5
  160. package/dist/shared/http-route-extractor.js.map +1 -1
  161. package/dist/shared/instance-binding.d.ts +53 -0
  162. package/dist/shared/instance-binding.d.ts.map +1 -0
  163. package/dist/shared/instance-binding.js +85 -0
  164. package/dist/shared/instance-binding.js.map +1 -0
  165. package/dist/shared/io.d.ts.map +1 -1
  166. package/dist/shared/io.js +5 -2
  167. package/dist/shared/io.js.map +1 -1
  168. package/dist/shared/logger.js.map +1 -1
  169. package/dist/shared/sqlite-runtime.d.ts.map +1 -1
  170. package/dist/shared/sqlite-runtime.js +0 -1
  171. package/dist/shared/sqlite-runtime.js.map +1 -1
  172. package/dist/task-executor.js.map +1 -1
  173. package/dist/telemetry/sdk.d.ts.map +1 -1
  174. package/dist/telemetry/sdk.js +0 -1
  175. package/dist/telemetry/sdk.js.map +1 -1
  176. package/dist/test-runner.d.ts.map +1 -1
  177. package/dist/test-runner.js.map +1 -1
  178. package/dist/types/server-types.d.ts +16 -7
  179. package/dist/types/server-types.d.ts.map +1 -1
  180. package/package.json +14 -4
  181. package/templates/cloudflare/worker.ts.template +428 -14
  182. package/templates/cloudflare/wrangler.toml.template +2 -7
  183. package/templates/photon.template.ts +13 -0
@@ -102,7 +102,7 @@ import { ensureDaemon } from '../daemon/manager.js';
102
102
  import { SchemaExtractor } from '@portel/photon-core';
103
103
  import { generateServerCard } from '../server-card.js';
104
104
  import { resolveUIAssetPath, readUIContent } from './ui-resolver.js';
105
- import { handleStreamableHTTP, broadcastNotification, broadcastToBeam, stopSessionCleanup, } from './streamable-http-transport.js';
105
+ import { handleStreamableHTTP, broadcastNotification, broadcastToBeam, stopSessionCleanup, attachLoaderForResourceUpdates, } from './streamable-http-transport.js';
106
106
  import { getBundledPhotonPath, BEAM_BUNDLED_PHOTONS } from '../shared-utils.js';
107
107
  // BUNDLED_PHOTONS and getBundledPhotonPath are imported from shared-utils.js
108
108
  // Extracted modules (Phase 5)
@@ -110,6 +110,7 @@ import { loadConfig as loadConfigFromModule, saveConfig as saveConfigFromModule,
110
110
  import { extractClassMetadataFromSource as extractClassMetadataFromModule, applyMethodVisibility as applyMethodVisibilityFromModule, extractCspFromSource as extractCspFromModule, prettifyName as prettifyNameFromModule, backfillEnvDefaults as backfillEnvDefaultsFromModule, } from './beam/class-metadata.js';
111
111
  import { StartupSequencer } from './beam/startup.js';
112
112
  import { SubscriptionManager } from './beam/subscription.js';
113
+ import { ExternalMCPManager } from './beam/external-mcp-manager.js';
113
114
  import { handleMarketplaceRoutes } from './beam/routes/api-marketplace.js';
114
115
  import { handleBrowseRoutes } from './beam/routes/api-browse.js';
115
116
  import { handleConfigRoutes } from './beam/routes/api-config.js';
@@ -123,12 +124,8 @@ const getConfigFilePath = getConfigFilePathFromModule;
123
124
  // BEAM CONTEXT — all module-level mutable state lives here
124
125
  // ═══════════════════════════════════════════════════════════════════════════════
125
126
  class BeamContext {
126
- /** External MCP server metadata */
127
- externalMCPs = [];
128
- /** Transport-level clients for external MCPs */
129
- externalMCPClients = new Map();
130
- /** SDK Client instances for tool calls with structuredContent */
131
- externalMCPSDKClients = new Map();
127
+ /** External MCP lifecycle: list, transport clients, SDK clients, add/remove. */
128
+ mcp = new ExternalMCPManager();
132
129
  /**
133
130
  * Notification subscriptions per photon.
134
131
  * Key: photon name, Value: list of event types this photon cares about
@@ -140,12 +137,24 @@ class BeamContext {
140
137
  * so dynamically discovered photons can be subscribed without duplicates.
141
138
  */
142
139
  subscribedStateChannels = new Set();
140
+ // Backward-compat field-name accessors. The 24 callsites that read
141
+ // ctx.externalMCPs / ctx.externalMCPClients / ctx.externalMCPSDKClients
142
+ // continue to work; they now flow through the manager.
143
+ get externalMCPs() {
144
+ return this.mcp.externalMCPs;
145
+ }
146
+ get externalMCPClients() {
147
+ return this.mcp.externalMCPClients;
148
+ }
149
+ get externalMCPSDKClients() {
150
+ return this.mcp.externalMCPSDKClients;
151
+ }
143
152
  /** Convenience accessor matching the shape expected by external-mcp module */
144
153
  get externalMCPState() {
145
154
  return {
146
- externalMCPs: this.externalMCPs,
147
- externalMCPClients: this.externalMCPClients,
148
- externalMCPSDKClients: this.externalMCPSDKClients,
155
+ externalMCPs: this.mcp.externalMCPs,
156
+ externalMCPClients: this.mcp.externalMCPClients,
157
+ externalMCPSDKClients: this.mcp.externalMCPSDKClients,
149
158
  };
150
159
  }
151
160
  }
@@ -598,6 +607,9 @@ export async function startBeam(rawWorkingDir, port) {
598
607
  // Beam handles config errors gracefully via UI forms, but we still want to see actual errors
599
608
  const errorOnlyLogger = createLogger({ level: 'error' });
600
609
  const loader = new PhotonLoader(false, errorOnlyLogger, workingDir);
610
+ // Bridge `this.notifyResourceUpdated(uri)` from photon authors into the
611
+ // streamable-HTTP subscription registry so SSE clients get fanout.
612
+ attachLoaderForResourceUpdates(loader);
601
613
  // Check for placeholder defaults or localhost URLs (which need local services running)
602
614
  const isPlaceholderOrLocalDefault = (value) => {
603
615
  if (value.includes('<') || value.includes('your-'))
@@ -750,7 +762,10 @@ export async function startBeam(rawWorkingDir, port) {
750
762
  // Check if method has @ui tag matching this id
751
763
  const methodSource = schemaSource.match(new RegExp(`@ui\\s+${uiId}[\\s\\n]*\\*/[\\s\\n]*(?:async\\s+)?${schema.name}\\s*\\(`, 'm'));
752
764
  if (methodSource) {
753
- uiAssets.push({ id: uiId, linkedTool: schema.name });
765
+ // Synthetic record for sidebar linking — `path` isn't available
766
+ // here (source-only inference), but downstream readers only
767
+ // touch id/linkedTool. Empty string keeps the UIAsset contract.
768
+ uiAssets.push({ id: uiId, path: '', linkedTool: schema.name });
754
769
  }
755
770
  });
756
771
  }
@@ -833,7 +848,6 @@ export async function startBeam(rawWorkingDir, port) {
833
848
  const mainMethod = methods.find((m) => m.name === 'main');
834
849
  // Extract class-level metadata — reuse source already read
835
850
  const classMetadata = extractClassMetadataFromSource(schemaSource);
836
- // Extract class-level @csp metadata and apply to all UI assets
837
851
  const cspData = extractCspFromSource(schemaSource);
838
852
  if (cspData['__class__'] && mcp.assets?.ui) {
839
853
  for (const uiAsset of mcp.assets.ui) {
@@ -934,7 +948,8 @@ export async function startBeam(rawWorkingDir, port) {
934
948
  return null;
935
949
  const photonDir = path.dirname(photon.path);
936
950
  const photonBaseName = path.basename(photon.path, '.photon.ts');
937
- const asset = photon.assets?.ui?.find((u) => u.id === uiId);
951
+ // assets only live on configured photons
952
+ const asset = photon.configured ? photon.assets?.ui?.find((u) => u.id === uiId) : undefined;
938
953
  let resolved;
939
954
  if (asset?.resolvedPath) {
940
955
  resolved = {
@@ -1194,54 +1209,85 @@ export async function startBeam(rawWorkingDir, port) {
1194
1209
  res.end('Photon not found');
1195
1210
  return;
1196
1211
  }
1197
- // Resolve asset path using the same convention as Photon.assets():
1198
- // preferred {photonDir}/{photonBaseName}/{assetPath}, legacy fallback
1199
- // {photonDir}/{photonBaseName}/assets/{assetPath}.
1212
+ // Dual-layout asset resolution (v1.29 Track E):
1213
+ // 1. {photonDir}/{photonBaseName}/assets/{assetPath} canonical
1214
+ // 2. {photonDir}/{photonBaseName}/{assetPath} — legacy
1215
+ // The new convention wins; legacy only fires when nothing matches
1216
+ // under assets/ (preserves byte-compat for fixtures without an
1217
+ // assets/ wrapper).
1200
1218
  const realPath = realpathSync(photon.path);
1201
1219
  const photonDir = path.dirname(realPath);
1202
1220
  const baseName = path.basename(realPath).replace(/\.photon\.(ts|js)$/, '');
1203
- const assetsRoot = path.join(photonDir, baseName);
1204
- const legacyAssetsRoot = path.join(assetsRoot, 'assets');
1205
- const activeRoot = existsSync(assetsRoot) ? assetsRoot : legacyAssetsRoot;
1206
- const fullPath = path.join(activeRoot, assetPath);
1207
- // Security: ensure resolved path is within the assets directory
1208
- if (!fullPath.startsWith(activeRoot)) {
1209
- res.writeHead(403);
1210
- res.end('Forbidden');
1211
- return;
1212
- }
1213
- try {
1214
- const data = await fs.readFile(fullPath);
1215
- const ext = path.extname(fullPath).toLowerCase();
1216
- const mimeTypes = {
1217
- '.png': 'image/png',
1218
- '.jpg': 'image/jpeg',
1219
- '.jpeg': 'image/jpeg',
1220
- '.gif': 'image/gif',
1221
- '.svg': 'image/svg+xml',
1222
- '.webp': 'image/webp',
1223
- '.ico': 'image/x-icon',
1224
- '.mp4': 'video/mp4',
1225
- '.webm': 'video/webm',
1226
- '.pdf': 'application/pdf',
1227
- '.woff2': 'font/woff2',
1228
- '.woff': 'font/woff',
1229
- '.ttf': 'font/ttf',
1230
- '.css': 'text/css',
1231
- '.js': 'text/javascript',
1232
- '.json': 'application/json',
1233
- '.txt': 'text/plain',
1234
- };
1235
- res.writeHead(200, {
1236
- 'Content-Type': mimeTypes[ext] || 'application/octet-stream',
1237
- 'Cache-Control': 'public, max-age=3600',
1238
- });
1239
- res.end(data);
1240
- }
1241
- catch {
1221
+ const legacyRoot = path.join(photonDir, baseName);
1222
+ const canonicalRoot = path.join(legacyRoot, 'assets');
1223
+ const candidateRoots = [canonicalRoot, legacyRoot];
1224
+ const tryRoots = async (subPath) => {
1225
+ for (const root of candidateRoots) {
1226
+ const candidate = path.join(root, subPath);
1227
+ const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
1228
+ if (!candidate.startsWith(rootWithSep) && candidate !== root) {
1229
+ continue;
1230
+ }
1231
+ try {
1232
+ const stat = await fs.stat(candidate);
1233
+ if (stat.isDirectory()) {
1234
+ // Directory-style serve: if the dir contains index.html,
1235
+ // serve it. Otherwise fall through to the next root.
1236
+ const indexCandidate = path.join(candidate, 'index.html');
1237
+ try {
1238
+ const data = await fs.readFile(indexCandidate);
1239
+ return { data, resolved: indexCandidate };
1240
+ }
1241
+ catch {
1242
+ continue;
1243
+ }
1244
+ }
1245
+ const data = await fs.readFile(candidate);
1246
+ return { data, resolved: candidate };
1247
+ }
1248
+ catch {
1249
+ continue;
1250
+ }
1251
+ }
1252
+ return null;
1253
+ };
1254
+ const found = await tryRoots(assetPath);
1255
+ if (!found) {
1242
1256
  res.writeHead(404);
1243
1257
  res.end('Asset not found');
1258
+ return;
1244
1259
  }
1260
+ const ext = path.extname(found.resolved).toLowerCase();
1261
+ const mimeTypes = {
1262
+ '.html': 'text/html; charset=utf-8',
1263
+ '.png': 'image/png',
1264
+ '.jpg': 'image/jpeg',
1265
+ '.jpeg': 'image/jpeg',
1266
+ '.gif': 'image/gif',
1267
+ '.svg': 'image/svg+xml',
1268
+ '.webp': 'image/webp',
1269
+ '.ico': 'image/x-icon',
1270
+ '.mp4': 'video/mp4',
1271
+ '.webm': 'video/webm',
1272
+ '.pdf': 'application/pdf',
1273
+ '.woff2': 'font/woff2',
1274
+ '.woff': 'font/woff',
1275
+ '.ttf': 'font/ttf',
1276
+ '.css': 'text/css',
1277
+ '.js': 'text/javascript',
1278
+ '.mjs': 'text/javascript',
1279
+ '.json': 'application/json',
1280
+ '.txt': 'text/plain',
1281
+ '.wasm': 'application/wasm',
1282
+ };
1283
+ res.writeHead(200, {
1284
+ 'Content-Type': mimeTypes[ext] || 'application/octet-stream',
1285
+ 'Cache-Control': 'public, max-age=3600',
1286
+ // Track D2: CORP same-origin keeps assets fetchable from a
1287
+ // standalone parent page that asserts COEP `require-corp`.
1288
+ 'Cross-Origin-Resource-Policy': 'same-origin',
1289
+ });
1290
+ res.end(found.data);
1245
1291
  return;
1246
1292
  }
1247
1293
  // ══════════════════════════════════════════════════════════════════════════
@@ -1353,10 +1399,12 @@ export async function startBeam(rawWorkingDir, port) {
1353
1399
  res.end(`Photon not found: ${photonName}`);
1354
1400
  return;
1355
1401
  }
1356
- const label = photon?.label ||
1402
+ const label = photon.label ||
1357
1403
  photonName.charAt(0).toUpperCase() + photonName.slice(1).replace(/-/g, ' ');
1358
- const description = photon?.description || `${label} - Photon App`;
1359
- const iconValue = photon?.icon || '📦';
1404
+ // `description` and `icon` only live on configured photons; an unconfigured
1405
+ // entry falls back to the auto-built defaults below.
1406
+ const description = (photon.configured && photon.description) || `${label} - Photon App`;
1407
+ const iconValue = (photon.configured && photon.icon) || '📦';
1360
1408
  const encodedName = encodeURIComponent(photonName);
1361
1409
  // Sanitize strings for safe embedding in HTML
1362
1410
  const safeLabel = label.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] || c);
@@ -1840,6 +1888,94 @@ export async function startBeam(rawWorkingDir, port) {
1840
1888
  }
1841
1889
  return;
1842
1890
  }
1891
+ // Web route proxy: /web/{photonName}/{...path} dispatches to the photon's
1892
+ // @get/@post handlers. The prefix is stripped before dispatch so that the
1893
+ // photon's routes look like they're running at the root. HTML responses
1894
+ // get a fetch interceptor injected so that absolute fetch('/api/foo')
1895
+ // calls inside the photon's UI are transparently rewritten to
1896
+ // /web/{photonName}/api/foo by the browser.
1897
+ if (url.pathname.startsWith('/web/')) {
1898
+ const [, , photonName, ...pathParts] = url.pathname.split('/');
1899
+ const photonPath = '/' + pathParts.join('/') || '/';
1900
+ const photonClass = photonName ? photonMCPs.get(photonName) : undefined;
1901
+ const httpRoutes = photonClass?._httpRoutes;
1902
+ const route = httpRoutes?.find((r) => r.method === (req.method || 'GET') && r.path === photonPath);
1903
+ if (route && photonClass?.instance) {
1904
+ const fn = photonClass.instance[route.handler];
1905
+ if (typeof fn === 'function') {
1906
+ try {
1907
+ let bodyBuffer = Buffer.alloc(0);
1908
+ await new Promise((resolve) => {
1909
+ req.on('data', (chunk) => {
1910
+ bodyBuffer = Buffer.concat([bodyBuffer, chunk]);
1911
+ });
1912
+ req.on('end', resolve);
1913
+ });
1914
+ const internalUrl = new URL(photonPath + (url.search || ''), `http://${req.headers.host || 'localhost'}`);
1915
+ const webReq = new Request(internalUrl.toString(), {
1916
+ method: req.method,
1917
+ headers: req.headers,
1918
+ ...(req.method !== 'GET' && bodyBuffer.length > 0 ? { body: bodyBuffer } : {}),
1919
+ });
1920
+ const result = await fn.call(photonClass.instance, webReq);
1921
+ if (result instanceof Response) {
1922
+ const contentType = result.headers.get('content-type') || '';
1923
+ const responseHeaders = {};
1924
+ result.headers.forEach((value, key) => {
1925
+ responseHeaders[key] = value;
1926
+ });
1927
+ let body = Buffer.from(await result.arrayBuffer());
1928
+ // Inject fetch interceptor into HTML responses so relative API
1929
+ // calls inside the photon UI resolve via the /web/ prefix.
1930
+ if (contentType.includes('text/html')) {
1931
+ const prefix = `/web/${photonName}`;
1932
+ const interceptor = `<script>
1933
+ (function(){
1934
+ const _prefix="${prefix}";
1935
+ const _origFetch=window.fetch;
1936
+ window.fetch=function(input,init){
1937
+ if(typeof input==='string'&&input.startsWith('/')&&!input.startsWith(_prefix))
1938
+ input=_prefix+input;
1939
+ return _origFetch(input,init);
1940
+ };
1941
+ const _origOpen=XMLHttpRequest.prototype.open;
1942
+ XMLHttpRequest.prototype.open=function(m,u,...a){
1943
+ if(typeof u==='string'&&u.startsWith('/')&&!u.startsWith(_prefix))u=_prefix+u;
1944
+ return _origOpen.call(this,m,u,...a);
1945
+ };
1946
+ })();
1947
+ </script>`;
1948
+ const bodyStr = body.toString('utf-8').replace('<head>', '<head>' + interceptor);
1949
+ body = Buffer.from(bodyStr, 'utf-8');
1950
+ responseHeaders['content-length'] = String(body.length);
1951
+ }
1952
+ res.writeHead(result.status, responseHeaders);
1953
+ res.end(body);
1954
+ return;
1955
+ }
1956
+ // Plain value — negotiate content type and render
1957
+ const { negotiateAccept } = await import('../format/registry.js');
1958
+ const { getDefaultRegistry } = await import('../format/seed.js');
1959
+ const acceptHeader = req.headers['accept'];
1960
+ const rendered = negotiateAccept({
1961
+ accept: typeof acceptHeader === 'string' ? acceptHeader : undefined,
1962
+ declaredFormat: route.format,
1963
+ value: result,
1964
+ registry: getDefaultRegistry(),
1965
+ });
1966
+ res.writeHead(200, { 'Content-Type': rendered.mime });
1967
+ res.end(typeof rendered.body === 'string' ? rendered.body : Buffer.from(rendered.body));
1968
+ return;
1969
+ }
1970
+ catch (err) {
1971
+ res.writeHead(500).end(err?.message ?? 'Internal Server Error');
1972
+ return;
1973
+ }
1974
+ }
1975
+ }
1976
+ res.writeHead(404).end('Not Found');
1977
+ return;
1978
+ }
1843
1979
  // Default route: Serve Lit App
1844
1980
  if (url.pathname === '/' || !url.pathname.startsWith('/api')) {
1845
1981
  try {
@@ -1877,6 +2013,7 @@ export async function startBeam(rawWorkingDir, port) {
1877
2013
  const activeLoads = new Set(); // Photons currently being loaded (prevents concurrent duplicate loads)
1878
2014
  const pendingAfterLoad = new Set(); // File changes that arrived while a load was active; re-triggered after
1879
2015
  const symlinkWatchedDirs = new Set(); // Track which source dirs already have watchers (prevents duplicates on re-setup)
2016
+ const autoRetried = new Set(); // Photons that have already had one auto-retry after a load failure
1880
2017
  // Set up file watchers for a symlinked photon's real source directory and asset folder.
1881
2018
  // Called both at startup and after a previously-errored symlinked photon recovers.
1882
2019
  const setupSymlinkWatcher = (photonName, photonPath) => {
@@ -2135,12 +2272,13 @@ export async function startBeam(rawWorkingDir, port) {
2135
2272
  }
2136
2273
  try {
2137
2274
  // Load or reload the photon
2138
- const mcp = isNewPhoton
2275
+ const mcp = (isNewPhoton
2139
2276
  ? await loader.loadFile(photonPath)
2140
- : await loader.reloadFile(photonPath);
2277
+ : await loader.reloadFile(photonPath));
2141
2278
  if (!mcp.instance)
2142
2279
  throw new Error('Failed to create instance');
2143
2280
  photonMCPs.set(photonName, mcp);
2281
+ autoRetried.delete(photonName); // Clear retry flag on success
2144
2282
  // Re-extract schema - use extractAllFromSource to get both tools and templates
2145
2283
  const extractor = new SchemaExtractor();
2146
2284
  const reloadSource = await readText(photonPath);
@@ -2339,6 +2477,40 @@ export async function startBeam(rawWorkingDir, port) {
2339
2477
  }
2340
2478
  return;
2341
2479
  }
2480
+ // esbuild subprocess crash: the in-process service is permanently
2481
+ // dead — cache clears don't help. Respawn Beam so the next
2482
+ // file-save gets a fresh compiler. The browser's SSE reconnect
2483
+ // loop handles the brief disconnect transparently.
2484
+ const isServiceCrash = errorMsg.includes('The service was stopped') ||
2485
+ errorMsg.includes('The service is no longer running');
2486
+ if (isServiceCrash) {
2487
+ logger.warn(`Compiler service crashed — restarting Beam (${process.argv.slice(1).join(' ')})`);
2488
+ const { spawn } = await import('child_process');
2489
+ spawn(process.execPath, process.argv.slice(1), {
2490
+ stdio: 'inherit',
2491
+ env: process.env,
2492
+ detached: false,
2493
+ });
2494
+ process.exit(0);
2495
+ return;
2496
+ }
2497
+ // On the first failure, clear the build cache and retry automatically.
2498
+ // This recovers from stale artifacts or dependency issues without
2499
+ // requiring the user to touch the file or restart Beam.
2500
+ if (!autoRetried.has(photonName)) {
2501
+ autoRetried.add(photonName);
2502
+ logger.info(`🔄 ${photonName} failed to load, clearing cache and retrying...`);
2503
+ try {
2504
+ const retryPath = photons.find((p) => p.name === photonName)?.path || photonPath;
2505
+ await loader.clearCacheForFile(retryPath);
2506
+ }
2507
+ catch {
2508
+ // best-effort
2509
+ }
2510
+ setTimeout(() => void handleFileChange(photonName), 500);
2511
+ return;
2512
+ }
2513
+ // Second failure — give up and surface the error
2342
2514
  logger.error(`Hot reload failed for ${photonName}: ${errorMsg}`);
2343
2515
  broadcastToBeam('beam/error', {
2344
2516
  type: 'hot-reload-error',
@@ -2499,7 +2671,7 @@ export async function startBeam(rawWorkingDir, port) {
2499
2671
  }
2500
2672
  // Load external MCPs from config
2501
2673
  const externalMCPList = await loadExternalMCPs(savedConfig);
2502
- ctx.externalMCPs.push(...externalMCPList);
2674
+ ctx.mcp.addAll(externalMCPList);
2503
2675
  // Mark startup complete — flushes queued output and restores console
2504
2676
  startup.ready();
2505
2677
  // Notify connected clients that photon list is now available
@@ -2789,14 +2961,9 @@ export async function startBeam(rawWorkingDir, port) {
2789
2961
  // Remove MCPs — do all synchronous Map mutations first, then close async
2790
2962
  const removedSdkClients = [];
2791
2963
  for (const name of removed) {
2792
- const idx = ctx.externalMCPs.findIndex((m) => m.name === name);
2793
- if (idx !== -1)
2794
- ctx.externalMCPs.splice(idx, 1);
2795
- const sdkClient = ctx.externalMCPSDKClients.get(name);
2964
+ const { sdkClient } = ctx.mcp.removeByName(name);
2796
2965
  if (sdkClient)
2797
2966
  removedSdkClients.push({ name, client: sdkClient });
2798
- ctx.externalMCPSDKClients.delete(name);
2799
- ctx.externalMCPClients.delete(name);
2800
2967
  logger.info(`🔌 Removed external MCP: ${name}`);
2801
2968
  }
2802
2969
  // Close SDK clients after all Maps are consistent
@@ -2815,7 +2982,7 @@ export async function startBeam(rawWorkingDir, port) {
2815
2982
  mcpServers: Object.fromEntries(added.map((k) => [k, newServers[k]])),
2816
2983
  };
2817
2984
  const newMCPs = await loadExternalMCPs(addConfig);
2818
- ctx.externalMCPs.push(...newMCPs);
2985
+ ctx.mcp.addAll(newMCPs);
2819
2986
  for (const m of newMCPs) {
2820
2987
  logger.info(`🔌 Added external MCP: ${m.name} (${m.connected ? m.methods.length + ' tools' : 'failed'})`);
2821
2988
  }
@@ -2823,14 +2990,9 @@ export async function startBeam(rawWorkingDir, port) {
2823
2990
  // Reconnect modified MCPs — synchronous cleanup first, then async reconnect
2824
2991
  const modifiedSdkClients = [];
2825
2992
  for (const name of modified) {
2826
- const idx = ctx.externalMCPs.findIndex((m) => m.name === name);
2827
- if (idx !== -1)
2828
- ctx.externalMCPs.splice(idx, 1);
2829
- const sdkClient = ctx.externalMCPSDKClients.get(name);
2993
+ const { sdkClient } = ctx.mcp.removeByName(name);
2830
2994
  if (sdkClient)
2831
2995
  modifiedSdkClients.push({ name, client: sdkClient });
2832
- ctx.externalMCPSDKClients.delete(name);
2833
- ctx.externalMCPClients.delete(name);
2834
2996
  }
2835
2997
  // Close old SDK clients
2836
2998
  for (const { client } of modifiedSdkClients) {
@@ -2848,7 +3010,7 @@ export async function startBeam(rawWorkingDir, port) {
2848
3010
  mcpServers: { [name]: newServers[name] },
2849
3011
  };
2850
3012
  const reconnected = await loadExternalMCPs(modConfig);
2851
- ctx.externalMCPs.push(...reconnected);
3013
+ ctx.mcp.addAll(reconnected);
2852
3014
  logger.info(`🔌 Reconnected external MCP: ${name}`);
2853
3015
  }
2854
3016
  // Update savedConfig
@@ -2878,18 +3040,7 @@ export async function startBeam(rawWorkingDir, port) {
2878
3040
  export async function stopBeam() {
2879
3041
  // Stop session cleanup timer
2880
3042
  stopSessionCleanup();
2881
- // Close all SDK clients gracefully
2882
- const closePromises = [];
2883
- for (const [, client] of ctx.externalMCPSDKClients) {
2884
- closePromises.push(client.close().catch(() => {
2885
- // Ignore close errors - process is exiting anyway
2886
- }));
2887
- }
2888
- // Wait for all clients to close (with timeout)
2889
- if (closePromises.length > 0) {
2890
- await withTimeout(Promise.all(closePromises), 1000, 'MCP client close timeout').catch(() => { }); // Timeout during shutdown is expected
2891
- }
2892
- ctx.externalMCPSDKClients.clear();
2893
- ctx.externalMCPClients.clear();
3043
+ // Close every external MCP SDK client gracefully and clear the maps.
3044
+ await ctx.mcp.closeAllSDKClients();
2894
3045
  }
2895
3046
  //# sourceMappingURL=beam.js.map