@portel/photon 1.10.0 → 1.12.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.
- package/README.md +81 -72
- package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
- package/dist/auto-ui/beam/photon-management.js +5 -0
- package/dist/auto-ui/beam/photon-management.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-browse.d.ts +1 -2
- package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-browse.js +140 -191
- package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.js +44 -1
- package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +874 -20
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/frontend/index.html +83 -60
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +16 -2
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +1 -1
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +2836 -357
- package/dist/beam.bundle.js.map +4 -4
- package/dist/cli/commands/package-app.d.ts.map +1 -1
- package/dist/cli/commands/package-app.js +116 -35
- package/dist/cli/commands/package-app.js.map +1 -1
- package/dist/context-store.d.ts +5 -0
- package/dist/context-store.d.ts.map +1 -1
- package/dist/context-store.js +9 -0
- package/dist/context-store.js.map +1 -1
- package/dist/daemon/server.js +303 -6
- package/dist/daemon/server.js.map +1 -1
- package/dist/loader.d.ts +21 -0
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +277 -0
- package/dist/loader.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +21 -1
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/photon-doc-extractor.d.ts +6 -0
- package/dist/photon-doc-extractor.d.ts.map +1 -1
- package/dist/photon-doc-extractor.js +22 -0
- package/dist/photon-doc-extractor.js.map +1 -1
- package/dist/photons/tunnel.photon.d.ts +5 -9
- package/dist/photons/tunnel.photon.d.ts.map +1 -1
- package/dist/photons/tunnel.photon.js +36 -96
- package/dist/photons/tunnel.photon.js.map +1 -1
- package/dist/photons/tunnel.photon.ts +40 -112
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +27 -2
- package/dist/server.js.map +1 -1
- package/dist/test-runner.d.ts +13 -1
- package/dist/test-runner.d.ts.map +1 -1
- package/dist/test-runner.js +529 -122
- package/dist/test-runner.js.map +1 -1
- package/package.json +22 -6
package/dist/auto-ui/beam.js
CHANGED
|
@@ -69,6 +69,318 @@ const backfillEnvDefaults = backfillEnvDefaultsFromModule;
|
|
|
69
69
|
const extractClassMetadataFromSource = extractClassMetadataFromModule;
|
|
70
70
|
const applyMethodVisibility = applyMethodVisibilityFromModule;
|
|
71
71
|
const extractCspFromSource = extractCspFromModule;
|
|
72
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
73
|
+
// NOTIFICATION SUBSCRIPTIONS
|
|
74
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
75
|
+
/**
|
|
76
|
+
* Map to store notification subscriptions per photon
|
|
77
|
+
* Key: photon name, Value: list of event types this photon cares about
|
|
78
|
+
* Example: { "chat": ["mentions", "direct-messages"], "tasks": ["deadline", "assigned-to-me"] }
|
|
79
|
+
*/
|
|
80
|
+
const photonNotificationSubscriptions = new Map();
|
|
81
|
+
/**
|
|
82
|
+
* Generate the service worker JS that validates the Beam backend
|
|
83
|
+
* on PWA launch and shows a diagnostic page if something is wrong.
|
|
84
|
+
*/
|
|
85
|
+
function generateServiceWorker(workingDir) {
|
|
86
|
+
return `
|
|
87
|
+
// Photon Beam Service Worker
|
|
88
|
+
// Validates the backend is running and healthy before serving the app.
|
|
89
|
+
const CACHE_NAME = 'photon-pwa-v1';
|
|
90
|
+
const EXPECTED_WORKING_DIR = ${JSON.stringify(workingDir)};
|
|
91
|
+
const HEALTH_ENDPOINT = '/api/diagnostics';
|
|
92
|
+
|
|
93
|
+
// Cache the boot page on install
|
|
94
|
+
self.addEventListener('install', (event) => {
|
|
95
|
+
event.waitUntil(
|
|
96
|
+
caches.open(CACHE_NAME).then((cache) => cache.put('/_pwa_boot', new Response(BOOT_PAGE, {
|
|
97
|
+
headers: { 'Content-Type': 'text/html' }
|
|
98
|
+
}))).then(() => self.skipWaiting())
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
self.addEventListener('activate', (event) => {
|
|
103
|
+
event.waitUntil(self.clients.claim());
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
self.addEventListener('fetch', (event) => {
|
|
107
|
+
const url = new URL(event.request.url);
|
|
108
|
+
|
|
109
|
+
// PWA icon PNG generation — intercept /api/pwa/icon-png requests and render
|
|
110
|
+
// the SVG icon onto OffscreenCanvas, returning a real PNG response that
|
|
111
|
+
// satisfies Chrome's installability requirement for raster icons.
|
|
112
|
+
if (url.pathname === '/api/pwa/icon-png') {
|
|
113
|
+
event.respondWith(handleIconPng(url));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Only intercept navigation requests (page loads, not API/asset fetches)
|
|
118
|
+
if (event.request.mode !== 'navigate') return;
|
|
119
|
+
|
|
120
|
+
// Skip API routes and static assets — let them pass through
|
|
121
|
+
if (url.pathname.startsWith('/api/') || url.pathname === '/sw.js' || url.pathname === '/beam.bundle.js') return;
|
|
122
|
+
|
|
123
|
+
// All navigation requests go through health check
|
|
124
|
+
event.respondWith(handlePWANavigation(event.request));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
async function handlePWANavigation(request) {
|
|
128
|
+
try {
|
|
129
|
+
// Try to reach the backend
|
|
130
|
+
const healthRes = await fetch(HEALTH_ENDPOINT, { signal: AbortSignal.timeout(3000) });
|
|
131
|
+
if (!healthRes.ok) throw new Error('Health check failed');
|
|
132
|
+
|
|
133
|
+
const health = await healthRes.json();
|
|
134
|
+
|
|
135
|
+
// Validate this is actually Beam serving the expected directory
|
|
136
|
+
if (!health.photonVersion) {
|
|
137
|
+
return serveBoot('wrong-service', JSON.stringify(health));
|
|
138
|
+
}
|
|
139
|
+
if (health.workingDir !== EXPECTED_WORKING_DIR) {
|
|
140
|
+
return serveBoot('wrong-directory', JSON.stringify({
|
|
141
|
+
expected: EXPECTED_WORKING_DIR,
|
|
142
|
+
actual: health.workingDir
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Backend is healthy and correct — serve the real page
|
|
147
|
+
return fetch(request);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
// Backend is unreachable
|
|
150
|
+
return serveBoot('not-running', err.message);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function handleIconPng(url) {
|
|
155
|
+
const photon = url.searchParams.get('photon') || '';
|
|
156
|
+
const size = parseInt(url.searchParams.get('size') || '192', 10);
|
|
157
|
+
const cacheKey = '/_pwa_icon_' + photon + '_' + size;
|
|
158
|
+
|
|
159
|
+
// Check cache first
|
|
160
|
+
const cache = await caches.open(CACHE_NAME);
|
|
161
|
+
const cached = await cache.match(cacheKey);
|
|
162
|
+
if (cached) return cached;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
// Fetch the icon from the server (may be SVG, PNG, JPEG, etc.)
|
|
166
|
+
const iconRes = await fetch('/api/pwa/icon?photon=' + encodeURIComponent(photon), { signal: AbortSignal.timeout(10000) });
|
|
167
|
+
if (!iconRes.ok) throw new Error('Icon fetch failed: ' + iconRes.status);
|
|
168
|
+
|
|
169
|
+
const contentType = (iconRes.headers.get('Content-Type') || '').toLowerCase();
|
|
170
|
+
|
|
171
|
+
// For raster images (PNG, JPEG, WebP), resize via OffscreenCanvas if needed
|
|
172
|
+
// For SVG or emoji-generated SVG, render to canvas at target size
|
|
173
|
+
let bmp;
|
|
174
|
+
if (contentType.includes('svg')) {
|
|
175
|
+
// SVG (emoji-generated or file) — parse as text, create bitmap
|
|
176
|
+
const svgText = await iconRes.text();
|
|
177
|
+
const svgBlob = new Blob([svgText], { type: 'image/svg+xml' });
|
|
178
|
+
bmp = await createImageBitmap(svgBlob, { resizeWidth: size, resizeHeight: size });
|
|
179
|
+
} else {
|
|
180
|
+
// Raster image (PNG, JPEG, WebP) — decode directly
|
|
181
|
+
const imgBlob = await iconRes.blob();
|
|
182
|
+
bmp = await createImageBitmap(imgBlob, { resizeWidth: size, resizeHeight: size });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const canvas = new OffscreenCanvas(size, size);
|
|
186
|
+
const ctx = canvas.getContext('2d');
|
|
187
|
+
|
|
188
|
+
// Dark rounded-rect background
|
|
189
|
+
ctx.fillStyle = '#1a1a1a';
|
|
190
|
+
const r = size * 0.2;
|
|
191
|
+
ctx.beginPath();
|
|
192
|
+
ctx.moveTo(r, 0);
|
|
193
|
+
ctx.lineTo(size - r, 0);
|
|
194
|
+
ctx.quadraticCurveTo(size, 0, size, r);
|
|
195
|
+
ctx.lineTo(size, size - r);
|
|
196
|
+
ctx.quadraticCurveTo(size, size, size - r, size);
|
|
197
|
+
ctx.lineTo(r, size);
|
|
198
|
+
ctx.quadraticCurveTo(0, size, 0, size - r);
|
|
199
|
+
ctx.lineTo(0, r);
|
|
200
|
+
ctx.quadraticCurveTo(0, 0, r, 0);
|
|
201
|
+
ctx.closePath();
|
|
202
|
+
ctx.fill();
|
|
203
|
+
|
|
204
|
+
// Draw the icon
|
|
205
|
+
ctx.drawImage(bmp, 0, 0, size, size);
|
|
206
|
+
|
|
207
|
+
const pngBlob = await canvas.convertToBlob({ type: 'image/png' });
|
|
208
|
+
const pngResponse = new Response(pngBlob, {
|
|
209
|
+
headers: {
|
|
210
|
+
'Content-Type': 'image/png',
|
|
211
|
+
'Cache-Control': 'public, max-age=86400'
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Cache the generated PNG
|
|
216
|
+
await cache.put(cacheKey, pngResponse.clone());
|
|
217
|
+
return pngResponse;
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// Fallback: generate a simple colored square with initial letter
|
|
220
|
+
const canvas = new OffscreenCanvas(size, size);
|
|
221
|
+
const ctx = canvas.getContext('2d');
|
|
222
|
+
ctx.fillStyle = '#1a1a1a';
|
|
223
|
+
ctx.fillRect(0, 0, size, size);
|
|
224
|
+
ctx.fillStyle = '#4ade80';
|
|
225
|
+
ctx.font = (size * 0.4) + 'px sans-serif';
|
|
226
|
+
ctx.textAlign = 'center';
|
|
227
|
+
ctx.textBaseline = 'middle';
|
|
228
|
+
ctx.fillText(photon.charAt(0).toUpperCase() || 'P', size / 2, size / 2);
|
|
229
|
+
const pngBlob = await canvas.convertToBlob({ type: 'image/png' });
|
|
230
|
+
return new Response(pngBlob, {
|
|
231
|
+
headers: { 'Content-Type': 'image/png' }
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function serveBoot(reason, detail) {
|
|
237
|
+
const cache = await caches.open(CACHE_NAME);
|
|
238
|
+
const cached = await cache.match('/_pwa_boot');
|
|
239
|
+
if (cached) {
|
|
240
|
+
const html = await cached.text();
|
|
241
|
+
const injected = html
|
|
242
|
+
.replace('__BOOT_REASON__', reason)
|
|
243
|
+
.replace('__BOOT_DETAIL__', detail || '')
|
|
244
|
+
.replace('__EXPECTED_DIR__', EXPECTED_WORKING_DIR);
|
|
245
|
+
return new Response(injected, { headers: { 'Content-Type': 'text/html' } });
|
|
246
|
+
}
|
|
247
|
+
return new Response('Photon Beam is not available. Run: photon beam', {
|
|
248
|
+
status: 503, headers: { 'Content-Type': 'text/plain' }
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const BOOT_PAGE = \`<!DOCTYPE html>
|
|
253
|
+
<html lang="en">
|
|
254
|
+
<head>
|
|
255
|
+
<meta charset="UTF-8">
|
|
256
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
257
|
+
<title>Photon Beam</title>
|
|
258
|
+
<meta name="theme-color" content="#1a1a1a">
|
|
259
|
+
<style>
|
|
260
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
261
|
+
body {
|
|
262
|
+
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
263
|
+
background: #1a1a1a; color: #e5e5e5; font-family: system-ui, -apple-system, sans-serif;
|
|
264
|
+
}
|
|
265
|
+
.container { text-align: center; padding: 40px; max-width: 500px; }
|
|
266
|
+
.icon { font-size: 56px; margin-bottom: 24px; }
|
|
267
|
+
h1 { font-size: 22px; font-weight: 600; margin-bottom: 12px; color: #fff; }
|
|
268
|
+
.message { font-size: 15px; line-height: 1.6; color: #999; margin-bottom: 28px; }
|
|
269
|
+
.command {
|
|
270
|
+
display: inline-block; background: #2a2a2a; border: 1px solid #333;
|
|
271
|
+
padding: 10px 20px; border-radius: 8px; font-family: 'JetBrains Mono', monospace;
|
|
272
|
+
font-size: 14px; color: #4ade80; margin-bottom: 20px; user-select: all;
|
|
273
|
+
}
|
|
274
|
+
.detail {
|
|
275
|
+
font-size: 12px; color: #666; font-family: 'JetBrains Mono', monospace;
|
|
276
|
+
background: #222; border-radius: 6px; padding: 10px; margin-bottom: 20px;
|
|
277
|
+
word-break: break-all; display: none;
|
|
278
|
+
}
|
|
279
|
+
.detail.show { display: block; }
|
|
280
|
+
.retry {
|
|
281
|
+
padding: 10px 24px; background: #333; border: 1px solid #444; border-radius: 8px;
|
|
282
|
+
color: #fff; cursor: pointer; font-size: 14px; transition: background 0.2s;
|
|
283
|
+
}
|
|
284
|
+
.retry:hover { background: #444; }
|
|
285
|
+
.spinner {
|
|
286
|
+
display: none; width: 20px; height: 20px; border: 2px solid #444;
|
|
287
|
+
border-top-color: #4ade80; border-radius: 50%; animation: spin 0.8s linear infinite;
|
|
288
|
+
margin: 0 auto 16px;
|
|
289
|
+
}
|
|
290
|
+
.spinner.show { display: block; }
|
|
291
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
292
|
+
</style>
|
|
293
|
+
</head>
|
|
294
|
+
<body>
|
|
295
|
+
<div class="container">
|
|
296
|
+
<div class="spinner" id="spinner"></div>
|
|
297
|
+
<div id="content">
|
|
298
|
+
<div class="icon" id="icon"></div>
|
|
299
|
+
<h1 id="title"></h1>
|
|
300
|
+
<p class="message" id="message"></p>
|
|
301
|
+
<div class="detail" id="detail"></div>
|
|
302
|
+
<code class="command" id="command" style="display:none"></code>
|
|
303
|
+
<br><br>
|
|
304
|
+
<button class="retry" onclick="checkAndRetry()">Retry</button>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
<script>
|
|
308
|
+
const reason = '__BOOT_REASON__';
|
|
309
|
+
const detail = '__BOOT_DETAIL__';
|
|
310
|
+
const expectedDir = '__EXPECTED_DIR__';
|
|
311
|
+
|
|
312
|
+
const states = {
|
|
313
|
+
'not-running': {
|
|
314
|
+
icon: '\\u26a1',
|
|
315
|
+
title: 'Beam is not running',
|
|
316
|
+
message: 'Start Photon Beam to use this app:',
|
|
317
|
+
command: 'photon beam'
|
|
318
|
+
},
|
|
319
|
+
'wrong-service': {
|
|
320
|
+
icon: '\\u26a0\\ufe0f',
|
|
321
|
+
title: 'Port is in use by another service',
|
|
322
|
+
message: 'Something else is running on this port. Stop the other service or reconfigure Beam:',
|
|
323
|
+
command: 'photon beam --port <available-port>',
|
|
324
|
+
showDetail: true
|
|
325
|
+
},
|
|
326
|
+
'wrong-directory': {
|
|
327
|
+
icon: '\\ud83d\\udcc1',
|
|
328
|
+
title: 'Beam is serving a different project',
|
|
329
|
+
message: 'Beam is running but pointing to a different directory. Start it with the correct path:',
|
|
330
|
+
command: 'photon beam ' + expectedDir,
|
|
331
|
+
showDetail: true
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
function render() {
|
|
336
|
+
const s = states[reason] || states['not-running'];
|
|
337
|
+
document.getElementById('icon').textContent = s.icon;
|
|
338
|
+
document.getElementById('title').textContent = s.title;
|
|
339
|
+
document.getElementById('message').textContent = s.message;
|
|
340
|
+
const cmdEl = document.getElementById('command');
|
|
341
|
+
cmdEl.textContent = s.command;
|
|
342
|
+
cmdEl.style.display = 'inline-block';
|
|
343
|
+
if (s.showDetail && detail) {
|
|
344
|
+
const el = document.getElementById('detail');
|
|
345
|
+
el.textContent = detail;
|
|
346
|
+
el.classList.add('show');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function checkAndRetry() {
|
|
351
|
+
document.getElementById('content').style.display = 'none';
|
|
352
|
+
document.getElementById('spinner').classList.add('show');
|
|
353
|
+
try {
|
|
354
|
+
const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(3000) });
|
|
355
|
+
if (res.ok) {
|
|
356
|
+
const h = await res.json();
|
|
357
|
+
if (h.photonVersion && h.workingDir === expectedDir) {
|
|
358
|
+
location.reload();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} catch {}
|
|
363
|
+
document.getElementById('spinner').classList.remove('show');
|
|
364
|
+
document.getElementById('content').style.display = '';
|
|
365
|
+
render();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
render();
|
|
369
|
+
// Auto-retry every 5 seconds
|
|
370
|
+
setInterval(async () => {
|
|
371
|
+
try {
|
|
372
|
+
const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(2000) });
|
|
373
|
+
if (res.ok) {
|
|
374
|
+
const h = await res.json();
|
|
375
|
+
if (h.photonVersion && h.workingDir === expectedDir) location.reload();
|
|
376
|
+
}
|
|
377
|
+
} catch {}
|
|
378
|
+
}, 5000);
|
|
379
|
+
</script>
|
|
380
|
+
</body>
|
|
381
|
+
</html>\`;
|
|
382
|
+
`;
|
|
383
|
+
}
|
|
72
384
|
export async function startBeam(rawWorkingDir, port) {
|
|
73
385
|
const workingDir = path.resolve(rawWorkingDir);
|
|
74
386
|
const { PHOTON_VERSION } = await import('../version.js');
|
|
@@ -229,8 +541,18 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
229
541
|
backfillEnvDefaults(instance, constructorParams);
|
|
230
542
|
// Extract schema for UI — reuse source read from above
|
|
231
543
|
const schemaSource = source || (await fs.readFile(photonPath, 'utf-8'));
|
|
232
|
-
const
|
|
544
|
+
const metadata = extractor.extractAllFromSource(schemaSource);
|
|
545
|
+
const schemas = metadata.tools;
|
|
546
|
+
const templates = metadata.templates;
|
|
233
547
|
mcp.schemas = schemas;
|
|
548
|
+
// Store notification subscriptions per photon
|
|
549
|
+
if (metadata.notificationSubscriptions?.watchFor) {
|
|
550
|
+
photonNotificationSubscriptions.set(name, metadata.notificationSubscriptions.watchFor);
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
// Clear previous subscription if photon no longer has @notify-on
|
|
554
|
+
photonNotificationSubscriptions.delete(name);
|
|
555
|
+
}
|
|
234
556
|
// Get UI assets for linking
|
|
235
557
|
const uiAssets = mcp.assets?.ui || [];
|
|
236
558
|
// Filter out lifecycle methods
|
|
@@ -536,12 +858,25 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
536
858
|
if (await handleConfigRoutes(req, res, url, beamState))
|
|
537
859
|
return;
|
|
538
860
|
}
|
|
861
|
+
// Service worker for PWA support
|
|
862
|
+
if (url.pathname === '/sw.js') {
|
|
863
|
+
res.writeHead(200, {
|
|
864
|
+
'Content-Type': 'application/javascript',
|
|
865
|
+
'Service-Worker-Allowed': '/',
|
|
866
|
+
'Cache-Control': 'no-cache',
|
|
867
|
+
});
|
|
868
|
+
res.end(generateServiceWorker(beamState.workingDir));
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
539
871
|
// Serve static frontend bundle
|
|
540
872
|
if (url.pathname === '/beam.bundle.js') {
|
|
541
873
|
try {
|
|
542
874
|
const bundlePath = path.join(__dirname, '../../dist/beam.bundle.js');
|
|
543
875
|
const content = await fs.readFile(bundlePath, 'utf-8');
|
|
544
|
-
res.writeHead(200, {
|
|
876
|
+
res.writeHead(200, {
|
|
877
|
+
'Content-Type': 'text/javascript',
|
|
878
|
+
'Cache-Control': 'no-cache',
|
|
879
|
+
});
|
|
545
880
|
res.end(content);
|
|
546
881
|
}
|
|
547
882
|
catch {
|
|
@@ -550,6 +885,449 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
550
885
|
}
|
|
551
886
|
return;
|
|
552
887
|
}
|
|
888
|
+
// Standalone PWA app route: /app/{photonName}
|
|
889
|
+
// Full-featured PWA host shell with diagnostics, postMessage bridge, service worker, and install prompt
|
|
890
|
+
const appMatch = url.pathname.match(/^\/app\/([^/]+)$/);
|
|
891
|
+
if (appMatch) {
|
|
892
|
+
const photonName = appMatch[1];
|
|
893
|
+
const photon = beamState.photons.find((p) => p.name === photonName);
|
|
894
|
+
if (!photon) {
|
|
895
|
+
res.writeHead(404);
|
|
896
|
+
res.end(`Photon not found: ${photonName}`);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const label = photon?.label ||
|
|
900
|
+
photonName.charAt(0).toUpperCase() + photonName.slice(1).replace(/-/g, ' ');
|
|
901
|
+
const description = photon?.description || `${label} - Photon App`;
|
|
902
|
+
const iconValue = photon?.icon || '📦';
|
|
903
|
+
const encodedName = encodeURIComponent(photonName);
|
|
904
|
+
// Sanitize strings for safe embedding in HTML
|
|
905
|
+
const safeLabel = label.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c);
|
|
906
|
+
const safeDesc = description.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c);
|
|
907
|
+
const html = `<!DOCTYPE html>
|
|
908
|
+
<html lang="en">
|
|
909
|
+
<head>
|
|
910
|
+
<meta charset="UTF-8">
|
|
911
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
912
|
+
<title>${safeLabel}</title>
|
|
913
|
+
<meta name="description" content="${safeDesc}">
|
|
914
|
+
<meta name="theme-color" content="#1a1a1a">
|
|
915
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
916
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
917
|
+
<meta name="apple-mobile-web-app-title" content="${safeLabel}">
|
|
918
|
+
<link rel="manifest" href="/api/pwa/manifest.json?photon=${encodedName}">
|
|
919
|
+
<link rel="apple-touch-icon" href="/api/pwa/icon?photon=${encodedName}">
|
|
920
|
+
<style>
|
|
921
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
922
|
+
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; font-family: system-ui, -apple-system, sans-serif; color: #e5e5e5; }
|
|
923
|
+
#app { width: 100%; height: 100vh; }
|
|
924
|
+
iframe { width: 100%; height: 100vh; border: none; display: block; }
|
|
925
|
+
|
|
926
|
+
.status-page {
|
|
927
|
+
display: none; width: 100%; height: 100vh;
|
|
928
|
+
flex-direction: column; align-items: center; justify-content: center;
|
|
929
|
+
text-align: center; padding: 40px;
|
|
930
|
+
}
|
|
931
|
+
.status-page.show { display: flex; }
|
|
932
|
+
.status-page .icon { font-size: 64px; margin-bottom: 24px; }
|
|
933
|
+
.status-page h2 { font-size: 20px; font-weight: 600; margin-bottom: 12px; color: #e5e5e5; }
|
|
934
|
+
.status-page p { font-size: 14px; color: #888; max-width: 400px; line-height: 1.6; margin-bottom: 8px; }
|
|
935
|
+
.status-page code {
|
|
936
|
+
display: inline-block; background: #2a2a2a; padding: 8px 16px; border-radius: 6px;
|
|
937
|
+
font-size: 13px; color: #4ade80; font-family: 'SF Mono', Monaco, monospace; margin-top: 8px;
|
|
938
|
+
}
|
|
939
|
+
.status-page .spinner {
|
|
940
|
+
width: 24px; height: 24px; border: 2px solid #333; border-top-color: #4ade80;
|
|
941
|
+
border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 16px;
|
|
942
|
+
}
|
|
943
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
944
|
+
.status-page .retry-btn {
|
|
945
|
+
margin-top: 16px; padding: 8px 20px; background: #333; border: 1px solid #444;
|
|
946
|
+
border-radius: 6px; color: #e5e5e5; cursor: pointer; font-size: 13px; font-family: inherit;
|
|
947
|
+
}
|
|
948
|
+
.status-page .retry-btn:hover { background: #444; }
|
|
949
|
+
|
|
950
|
+
</style>
|
|
951
|
+
</head>
|
|
952
|
+
<body>
|
|
953
|
+
<!-- Status: Starting (shown while waiting for Beam) -->
|
|
954
|
+
<div id="status-starting" class="status-page">
|
|
955
|
+
<div class="spinner"></div>
|
|
956
|
+
<h2>Starting ${safeLabel}...</h2>
|
|
957
|
+
<p>Waiting for Beam server</p>
|
|
958
|
+
</div>
|
|
959
|
+
|
|
960
|
+
<!-- Status: Not running (shown when Beam is down) -->
|
|
961
|
+
<div id="status-offline" class="status-page">
|
|
962
|
+
<div class="icon">${iconValue}</div>
|
|
963
|
+
<h2>${safeLabel}</h2>
|
|
964
|
+
<p>Server is not running. Start Photon to use this app:</p>
|
|
965
|
+
<code>photon beam</code>
|
|
966
|
+
<button class="retry-btn" onclick="checkAndLoad()">Retry</button>
|
|
967
|
+
</div>
|
|
968
|
+
|
|
969
|
+
<!-- Status: Port conflict -->
|
|
970
|
+
<div id="status-conflict" class="status-page">
|
|
971
|
+
<div class="icon">⚠️</div>
|
|
972
|
+
<h2>Port Conflict</h2>
|
|
973
|
+
<p id="conflict-msg">Another process is using the required port.</p>
|
|
974
|
+
<code id="conflict-cmd"></code>
|
|
975
|
+
<button class="retry-btn" onclick="checkAndLoad()">Retry</button>
|
|
976
|
+
</div>
|
|
977
|
+
|
|
978
|
+
<!-- App container with iframe -->
|
|
979
|
+
<div id="app" style="display:none"></div>
|
|
980
|
+
|
|
981
|
+
<script>
|
|
982
|
+
const PHOTON = ${JSON.stringify(photonName)};
|
|
983
|
+
const appEl = document.getElementById('app');
|
|
984
|
+
const statusStarting = document.getElementById('status-starting');
|
|
985
|
+
const statusOffline = document.getElementById('status-offline');
|
|
986
|
+
const statusConflict = document.getElementById('status-conflict');
|
|
987
|
+
let retryTimer = null;
|
|
988
|
+
|
|
989
|
+
function hideAll() {
|
|
990
|
+
statusStarting.classList.remove('show');
|
|
991
|
+
statusOffline.classList.remove('show');
|
|
992
|
+
statusConflict.classList.remove('show');
|
|
993
|
+
appEl.style.display = 'none';
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Diagnostics-first loading: check server health before loading the app
|
|
997
|
+
async function checkAndLoad() {
|
|
998
|
+
if (retryTimer) { clearInterval(retryTimer); retryTimer = null; }
|
|
999
|
+
hideAll();
|
|
1000
|
+
statusStarting.classList.add('show');
|
|
1001
|
+
|
|
1002
|
+
try {
|
|
1003
|
+
const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(5000) });
|
|
1004
|
+
if (!res.ok) throw new Error('Server error');
|
|
1005
|
+
const diag = await res.json();
|
|
1006
|
+
|
|
1007
|
+
// Check for port conflicts
|
|
1008
|
+
if (diag.portConflict) {
|
|
1009
|
+
hideAll();
|
|
1010
|
+
statusConflict.classList.add('show');
|
|
1011
|
+
const conflictMsg = document.getElementById('conflict-msg');
|
|
1012
|
+
const conflictCmd = document.getElementById('conflict-cmd');
|
|
1013
|
+
if (diag.portConflict.port) {
|
|
1014
|
+
conflictMsg.textContent = 'Port ' + diag.portConflict.port + ' is in use by another process.';
|
|
1015
|
+
}
|
|
1016
|
+
if (diag.portConflict.pid) {
|
|
1017
|
+
conflictCmd.textContent = 'kill ' + diag.portConflict.pid;
|
|
1018
|
+
}
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Server is healthy — establish MCP session then load the app
|
|
1023
|
+
hideAll();
|
|
1024
|
+
appEl.style.display = 'block';
|
|
1025
|
+
await connectSSE();
|
|
1026
|
+
await loadApp();
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
// Server unreachable — show offline state with auto-retry
|
|
1029
|
+
hideAll();
|
|
1030
|
+
statusOffline.classList.add('show');
|
|
1031
|
+
retryTimer = setInterval(async () => {
|
|
1032
|
+
try {
|
|
1033
|
+
const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(3000) });
|
|
1034
|
+
if (res.ok) {
|
|
1035
|
+
clearInterval(retryTimer);
|
|
1036
|
+
retryTimer = null;
|
|
1037
|
+
checkAndLoad();
|
|
1038
|
+
}
|
|
1039
|
+
} catch { /* still offline */ }
|
|
1040
|
+
}, 3000);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Load the @ui template into a full-viewport iframe with platform bridge.
|
|
1045
|
+
// Discovers template URL client-side: tries class-level @ui first (/api/template),
|
|
1046
|
+
// then falls back to method-level @ui by querying diagnostics for the app entry's linkedUi.
|
|
1047
|
+
async function loadApp() {
|
|
1048
|
+
try {
|
|
1049
|
+
// Step 1: Discover the template URL
|
|
1050
|
+
let templateUrl = '/api/template?photon=' + encodeURIComponent(PHOTON);
|
|
1051
|
+
let bridgeMethod = 'main';
|
|
1052
|
+
|
|
1053
|
+
// Try class-level @ui first
|
|
1054
|
+
let templateRes = await fetch(templateUrl, { signal: AbortSignal.timeout(10000) });
|
|
1055
|
+
if (!templateRes.ok) {
|
|
1056
|
+
// Fall back: query diagnostics for this photon's appEntry linkedUi
|
|
1057
|
+
const diagRes = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(10000) });
|
|
1058
|
+
if (diagRes.ok) {
|
|
1059
|
+
const diag = await diagRes.json();
|
|
1060
|
+
const photonInfo = (diag.photons || []).find(function(p) { return p.name === PHOTON; });
|
|
1061
|
+
if (photonInfo && photonInfo.appEntry && photonInfo.appEntry.linkedUi) {
|
|
1062
|
+
templateUrl = '/api/ui?photon=' + encodeURIComponent(PHOTON) + '&id=' + encodeURIComponent(photonInfo.appEntry.linkedUi);
|
|
1063
|
+
bridgeMethod = photonInfo.appEntry.name || 'main';
|
|
1064
|
+
templateRes = await fetch(templateUrl, { signal: AbortSignal.timeout(10000) });
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (!templateRes.ok) throw new Error('Template not available');
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Step 2: Fetch platform bridge script
|
|
1071
|
+
const bridgeRes = await fetch('/api/platform-bridge?photon=' + encodeURIComponent(PHOTON) + '&method=' + encodeURIComponent(bridgeMethod) + '&theme=dark', { signal: AbortSignal.timeout(10000) });
|
|
1072
|
+
|
|
1073
|
+
let templateHtml = await templateRes.text();
|
|
1074
|
+
const bridgeScript = bridgeRes.ok ? await bridgeRes.text() : '';
|
|
1075
|
+
|
|
1076
|
+
// Inject platform bridge before </head>
|
|
1077
|
+
if (templateHtml.includes('</head>')) {
|
|
1078
|
+
templateHtml = templateHtml.replace('</head>', bridgeScript + '</head>');
|
|
1079
|
+
} else {
|
|
1080
|
+
templateHtml = '<html><head>' + bridgeScript + '</head><body>' + templateHtml + '</body></html>';
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const iframe = document.createElement('iframe');
|
|
1084
|
+
iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-same-origin allow-popups allow-modals');
|
|
1085
|
+
|
|
1086
|
+
const blob = new Blob([templateHtml], { type: 'text/html' });
|
|
1087
|
+
iframe.src = URL.createObjectURL(blob);
|
|
1088
|
+
|
|
1089
|
+
appEl.innerHTML = '';
|
|
1090
|
+
appEl.appendChild(iframe);
|
|
1091
|
+
initBridge(iframe, bridgeMethod);
|
|
1092
|
+
} catch (err) {
|
|
1093
|
+
appEl.innerHTML = '<div class="status-page show"><div class="icon">⚠️</div>'
|
|
1094
|
+
+ '<h2>Failed to load</h2><p>' + err.message + '</p>'
|
|
1095
|
+
+ '<button class="retry-btn" onclick="checkAndLoad()">Retry</button></div>';
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// postMessage bridge: relays JSON-RPC from iframe through MCP for full event pipeline
|
|
1100
|
+
var mcpSessionId = null;
|
|
1101
|
+
var mcpCallId = 100;
|
|
1102
|
+
|
|
1103
|
+
function initBridge(iframe, bridgeMethod) {
|
|
1104
|
+
window.addEventListener('message', async (e) => {
|
|
1105
|
+
const msg = e.data;
|
|
1106
|
+
if (!msg || typeof msg !== 'object') return;
|
|
1107
|
+
|
|
1108
|
+
// Handle MCP Apps ui/initialize request — respond so bridge sends initialized
|
|
1109
|
+
if (msg.jsonrpc === '2.0' && msg.method === 'ui/initialize' && msg.id != null) {
|
|
1110
|
+
iframe.contentWindow.postMessage({
|
|
1111
|
+
jsonrpc: '2.0',
|
|
1112
|
+
id: msg.id,
|
|
1113
|
+
result: {
|
|
1114
|
+
protocolVersion: '2026-01-26',
|
|
1115
|
+
hostInfo: { name: 'photon-pwa', version: '1.0.0' },
|
|
1116
|
+
hostCapabilities: {},
|
|
1117
|
+
hostContext: { theme: 'dark' },
|
|
1118
|
+
},
|
|
1119
|
+
}, '*');
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Handle JSON-RPC tools/call from iframe — route through MCP for event broadcasting
|
|
1124
|
+
if (msg.jsonrpc === '2.0' && msg.method === 'tools/call' && msg.id != null) {
|
|
1125
|
+
const { name: toolName, arguments: toolArgs } = msg.params || {};
|
|
1126
|
+
// Prefix with photon name if not already prefixed (bridge sends bare method names)
|
|
1127
|
+
var fullToolName = toolName.includes('/') ? toolName : PHOTON + '/' + toolName;
|
|
1128
|
+
try {
|
|
1129
|
+
var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
|
1130
|
+
if (mcpSessionId) headers['Mcp-Session-Id'] = mcpSessionId;
|
|
1131
|
+
var mcpRes = await fetch('/mcp', {
|
|
1132
|
+
method: 'POST',
|
|
1133
|
+
headers: headers,
|
|
1134
|
+
body: JSON.stringify({
|
|
1135
|
+
jsonrpc: '2.0', id: mcpCallId++,
|
|
1136
|
+
method: 'tools/call',
|
|
1137
|
+
params: { name: fullToolName, arguments: toolArgs || {} },
|
|
1138
|
+
}),
|
|
1139
|
+
signal: AbortSignal.timeout(60000),
|
|
1140
|
+
});
|
|
1141
|
+
var mcpData = await mcpRes.json();
|
|
1142
|
+
// Extract result from MCP response (content array → structured or text)
|
|
1143
|
+
var result = undefined;
|
|
1144
|
+
var error = undefined;
|
|
1145
|
+
if (mcpData.error) {
|
|
1146
|
+
error = { code: mcpData.error.code || -32000, message: mcpData.error.message || 'Unknown error' };
|
|
1147
|
+
} else if (mcpData.result) {
|
|
1148
|
+
var content = mcpData.result.content || [];
|
|
1149
|
+
var textParts = content.filter(function(c) { return c.type === 'text'; });
|
|
1150
|
+
if (textParts.length > 0) {
|
|
1151
|
+
try { result = JSON.parse(textParts[0].text); } catch(e) { result = textParts[0].text; }
|
|
1152
|
+
}
|
|
1153
|
+
if (mcpData.result.structuredContent) result = mcpData.result.structuredContent;
|
|
1154
|
+
}
|
|
1155
|
+
iframe.contentWindow.postMessage({
|
|
1156
|
+
jsonrpc: '2.0', id: msg.id, result: result, error: error,
|
|
1157
|
+
}, '*');
|
|
1158
|
+
} catch (err) {
|
|
1159
|
+
iframe.contentWindow.postMessage({
|
|
1160
|
+
jsonrpc: '2.0', id: msg.id,
|
|
1161
|
+
error: { code: -32000, message: err.message },
|
|
1162
|
+
}, '*');
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
// Send photon:init context to iframe once loaded
|
|
1168
|
+
iframe.onload = () => {
|
|
1169
|
+
iframe.contentWindow.postMessage({
|
|
1170
|
+
type: 'photon:init',
|
|
1171
|
+
context: { photon: PHOTON, theme: 'dark', displayMode: 'standalone' }
|
|
1172
|
+
}, '*');
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
// Wait for bridge ready signal, then auto-invoke main() and deliver result
|
|
1176
|
+
var bridgeReady = false;
|
|
1177
|
+
window.addEventListener('message', async function onReady(e) {
|
|
1178
|
+
if (bridgeReady) return;
|
|
1179
|
+
var msg = e.data;
|
|
1180
|
+
if (!msg || typeof msg !== 'object') return;
|
|
1181
|
+
// Bridge sends both legacy photon:ready and MCP Apps ui/notifications/initialized
|
|
1182
|
+
var isReady = msg.type === 'photon:ready' ||
|
|
1183
|
+
(msg.jsonrpc === '2.0' && msg.method === 'ui/notifications/initialized');
|
|
1184
|
+
if (!isReady) return;
|
|
1185
|
+
bridgeReady = true;
|
|
1186
|
+
window.removeEventListener('message', onReady);
|
|
1187
|
+
|
|
1188
|
+
try {
|
|
1189
|
+
var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
|
1190
|
+
if (mcpSessionId) headers['Mcp-Session-Id'] = mcpSessionId;
|
|
1191
|
+
var invokeRes = await fetch('/mcp', {
|
|
1192
|
+
method: 'POST',
|
|
1193
|
+
headers: headers,
|
|
1194
|
+
body: JSON.stringify({
|
|
1195
|
+
jsonrpc: '2.0', id: mcpCallId++,
|
|
1196
|
+
method: 'tools/call',
|
|
1197
|
+
params: { name: PHOTON + '/' + bridgeMethod, arguments: {} },
|
|
1198
|
+
}),
|
|
1199
|
+
signal: AbortSignal.timeout(30000),
|
|
1200
|
+
});
|
|
1201
|
+
var mcpData = await invokeRes.json();
|
|
1202
|
+
if (!mcpData.error && mcpData.result) {
|
|
1203
|
+
var content = mcpData.result.content || [];
|
|
1204
|
+
var textParts = content.filter(function(c) { return c.type === 'text'; });
|
|
1205
|
+
var result = undefined;
|
|
1206
|
+
if (textParts.length > 0) {
|
|
1207
|
+
try { result = JSON.parse(textParts[0].text); } catch(e) { result = textParts[0].text; }
|
|
1208
|
+
}
|
|
1209
|
+
if (mcpData.result.structuredContent) result = mcpData.result.structuredContent;
|
|
1210
|
+
if (result !== undefined) {
|
|
1211
|
+
iframe.contentWindow.postMessage({
|
|
1212
|
+
jsonrpc: '2.0',
|
|
1213
|
+
method: 'ui/notifications/tool-result',
|
|
1214
|
+
params: { result: result },
|
|
1215
|
+
}, '*');
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
console.warn('[PWA] Auto-invoke failed:', err.message);
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// --- Service Worker Registration ---
|
|
1225
|
+
if ('serviceWorker' in navigator) {
|
|
1226
|
+
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
|
1227
|
+
.then(reg => console.log('[PWA] SW registered:', reg.scope))
|
|
1228
|
+
.catch(err => console.warn('[PWA] SW registration failed:', err));
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// --- Real-time SSE subscription for cross-client sync ---
|
|
1232
|
+
async function connectSSE() {
|
|
1233
|
+
try {
|
|
1234
|
+
// Step 1: Initialize MCP session as beam client to get session ID
|
|
1235
|
+
var initRes = await fetch('/mcp', {
|
|
1236
|
+
method: 'POST',
|
|
1237
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
1238
|
+
signal: AbortSignal.timeout(10000),
|
|
1239
|
+
body: JSON.stringify({
|
|
1240
|
+
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
1241
|
+
params: {
|
|
1242
|
+
protocolVersion: '2025-03-26',
|
|
1243
|
+
clientInfo: { name: 'beam', version: '1.0.0' },
|
|
1244
|
+
capabilities: {}
|
|
1245
|
+
}
|
|
1246
|
+
})
|
|
1247
|
+
});
|
|
1248
|
+
var sessionId = initRes.headers.get('mcp-session-id');
|
|
1249
|
+
if (!sessionId) return;
|
|
1250
|
+
mcpSessionId = sessionId;
|
|
1251
|
+
|
|
1252
|
+
// Step 2: Open SSE on the same session
|
|
1253
|
+
var sseUrl = '/mcp?sessionId=' + encodeURIComponent(sessionId);
|
|
1254
|
+
var es = new EventSource(sseUrl);
|
|
1255
|
+
es.onmessage = function(event) {
|
|
1256
|
+
try {
|
|
1257
|
+
var msg = JSON.parse(event.data);
|
|
1258
|
+
if (msg.type === 'keepalive') return;
|
|
1259
|
+
var iframe = document.querySelector('iframe');
|
|
1260
|
+
if (!iframe || !iframe.contentWindow) return;
|
|
1261
|
+
|
|
1262
|
+
// Handle @stateful state-changed events — re-invoke main() to refresh UI
|
|
1263
|
+
if (msg.method === 'state-changed' && msg.params?.photon === PHOTON) {
|
|
1264
|
+
// Re-fetch data by calling main() and delivering result to iframe
|
|
1265
|
+
var refreshHeaders = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
|
1266
|
+
if (mcpSessionId) refreshHeaders['Mcp-Session-Id'] = mcpSessionId;
|
|
1267
|
+
fetch('/mcp', {
|
|
1268
|
+
method: 'POST',
|
|
1269
|
+
headers: refreshHeaders,
|
|
1270
|
+
body: JSON.stringify({
|
|
1271
|
+
jsonrpc: '2.0', id: mcpCallId++,
|
|
1272
|
+
method: 'tools/call',
|
|
1273
|
+
params: { name: PHOTON + '/' + bridgeMethod, arguments: {} },
|
|
1274
|
+
}),
|
|
1275
|
+
signal: AbortSignal.timeout(15000),
|
|
1276
|
+
}).then(function(r) { return r.json(); }).then(function(mcpData) {
|
|
1277
|
+
if (!mcpData.error && mcpData.result) {
|
|
1278
|
+
var content = mcpData.result.content || [];
|
|
1279
|
+
var textParts = content.filter(function(c) { return c.type === 'text'; });
|
|
1280
|
+
var result = undefined;
|
|
1281
|
+
if (textParts.length > 0) {
|
|
1282
|
+
try { result = JSON.parse(textParts[0].text); } catch(e) { result = textParts[0].text; }
|
|
1283
|
+
}
|
|
1284
|
+
if (mcpData.result.structuredContent) result = mcpData.result.structuredContent;
|
|
1285
|
+
if (result !== undefined) {
|
|
1286
|
+
iframe.contentWindow.postMessage({
|
|
1287
|
+
jsonrpc: '2.0',
|
|
1288
|
+
method: 'ui/notifications/tool-result',
|
|
1289
|
+
params: { result: result },
|
|
1290
|
+
}, '*');
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}).catch(function() {});
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Forward other events to iframe
|
|
1298
|
+
if (msg.method === 'photon/board-update') {
|
|
1299
|
+
iframe.contentWindow.postMessage({
|
|
1300
|
+
jsonrpc: '2.0',
|
|
1301
|
+
method: 'photon/notifications/emit',
|
|
1302
|
+
params: { emit: 'board-update', ...msg.params },
|
|
1303
|
+
}, '*');
|
|
1304
|
+
} else if (msg.method === 'photon/channel-event') {
|
|
1305
|
+
iframe.contentWindow.postMessage({
|
|
1306
|
+
jsonrpc: '2.0',
|
|
1307
|
+
method: 'photon/notifications/emit',
|
|
1308
|
+
params: msg.params,
|
|
1309
|
+
}, '*');
|
|
1310
|
+
}
|
|
1311
|
+
} catch (e) {}
|
|
1312
|
+
};
|
|
1313
|
+
es.onerror = function() {
|
|
1314
|
+
es.close();
|
|
1315
|
+
setTimeout(connectSSE, 5000);
|
|
1316
|
+
};
|
|
1317
|
+
} catch (e) {
|
|
1318
|
+
setTimeout(connectSSE, 5000);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// Start the diagnostics-first loading flow
|
|
1323
|
+
checkAndLoad();
|
|
1324
|
+
</script>
|
|
1325
|
+
</body>
|
|
1326
|
+
</html>`;
|
|
1327
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
1328
|
+
res.end(html);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
553
1331
|
// Default route: Serve Lit App
|
|
554
1332
|
if (url.pathname === '/' || !url.pathname.startsWith('/api')) {
|
|
555
1333
|
try {
|
|
@@ -802,8 +1580,17 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
802
1580
|
// Re-extract schema - use extractAllFromSource to get both tools and templates
|
|
803
1581
|
const extractor = new SchemaExtractor();
|
|
804
1582
|
const reloadSource = await fs.readFile(photonPath, 'utf-8');
|
|
805
|
-
const
|
|
1583
|
+
const reloadMetadata = extractor.extractAllFromSource(reloadSource);
|
|
1584
|
+
const schemas = reloadMetadata.tools;
|
|
1585
|
+
const templates = reloadMetadata.templates;
|
|
806
1586
|
mcp.schemas = schemas; // Store schemas for result rendering
|
|
1587
|
+
// Update notification subscriptions for reloaded photon
|
|
1588
|
+
if (reloadMetadata.notificationSubscriptions?.watchFor) {
|
|
1589
|
+
photonNotificationSubscriptions.set(photonName, reloadMetadata.notificationSubscriptions.watchFor);
|
|
1590
|
+
}
|
|
1591
|
+
else {
|
|
1592
|
+
photonNotificationSubscriptions.delete(photonName);
|
|
1593
|
+
}
|
|
807
1594
|
const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
|
|
808
1595
|
const uiAssets = mcp.assets?.ui || [];
|
|
809
1596
|
const methods = schemas
|
|
@@ -1141,36 +1928,103 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
1141
1928
|
await ensureDaemon();
|
|
1142
1929
|
for (const photon of statefulPhotons) {
|
|
1143
1930
|
const photonName = photon.name;
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1931
|
+
// Subscribe to 'default' instance + any other instances that appear
|
|
1932
|
+
const instanceNames = ['default'];
|
|
1933
|
+
for (const instanceName of instanceNames) {
|
|
1934
|
+
// Channel is now instance-specific: photon:instance:state-changed
|
|
1935
|
+
const channel = `${photonName}:${instanceName}:state-changed`;
|
|
1936
|
+
subscribeChannel(photonName, channel, (message) => {
|
|
1937
|
+
// Only broadcast if instance matches (prevents cross-instance leakage)
|
|
1938
|
+
if (message?.instance === instanceName || !message?.instance) {
|
|
1939
|
+
// Minimal transmission: include instance and patches for global sync
|
|
1940
|
+
broadcastNotification('state-changed', {
|
|
1941
|
+
photon: photonName,
|
|
1942
|
+
instance: instanceName,
|
|
1943
|
+
// JSON Patch array for client-side state sync
|
|
1944
|
+
patches: message?.patches,
|
|
1945
|
+
// Keep legacy fields for backward compatibility
|
|
1946
|
+
method: message?.method,
|
|
1947
|
+
params: message?.params,
|
|
1948
|
+
data: message?.data,
|
|
1949
|
+
// Optional fields for undo/redo support
|
|
1950
|
+
...(message?.patch && { patch: message.patch }),
|
|
1951
|
+
...(message?.inversePatch && { inversePatch: message.inversePatch }),
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
}, {
|
|
1955
|
+
reconnect: true,
|
|
1956
|
+
workingDir,
|
|
1957
|
+
onReconnect: () => logger.debug(`📡 Reconnected ${channel} subscription`),
|
|
1958
|
+
onRefreshNeeded: () => {
|
|
1959
|
+
logger.info(`📡 Refresh needed for ${channel} (events lost during daemon restart)`);
|
|
1960
|
+
// Broadcast minimal refresh signal to all clients
|
|
1961
|
+
broadcastNotification('state-changed', {
|
|
1962
|
+
photon: photonName,
|
|
1963
|
+
instance: instanceName,
|
|
1964
|
+
method: '_refresh',
|
|
1965
|
+
patches: undefined, // No patches, signal full refresh needed
|
|
1966
|
+
});
|
|
1967
|
+
},
|
|
1968
|
+
})
|
|
1969
|
+
.then(() => {
|
|
1970
|
+
logger.info(`📡 Subscribed to ${channel} for cross-client sync`);
|
|
1971
|
+
})
|
|
1972
|
+
.catch((err) => {
|
|
1973
|
+
logger.warn(`Failed to subscribe to ${channel}: ${getErrorMessage(err)}`);
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
catch (err) {
|
|
1979
|
+
logger.warn(`Failed to start daemon for stateful photons: ${getErrorMessage(err)}`);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1983
|
+
// NOTIFICATION SUBSCRIPTIONS - Subscribe to all photon notification channels
|
|
1984
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1985
|
+
// Unlike state-changed (active photon only), notifications are always subscribed
|
|
1986
|
+
// but filtered server-side based on each photon's @notify-on declarations
|
|
1987
|
+
if (statefulPhotons.length > 0) {
|
|
1988
|
+
try {
|
|
1989
|
+
for (const photon of statefulPhotons) {
|
|
1990
|
+
const photonName = photon.name;
|
|
1991
|
+
const instanceName = 'default'; // TODO: support multi-instance notifications
|
|
1992
|
+
// Subscribe to notifications channel (always-on, not just active)
|
|
1993
|
+
const notificationChannel = `${photonName}:${instanceName}:notifications`;
|
|
1994
|
+
// Get this photon's notification subscriptions from @notify-on tags
|
|
1995
|
+
const watchFor = photonNotificationSubscriptions.get(photonName);
|
|
1996
|
+
subscribeChannel(photonName, notificationChannel, (message) => {
|
|
1997
|
+
// Check if this photon cares about this notification type
|
|
1998
|
+
if (!watchFor || !watchFor.includes(message?.type)) {
|
|
1999
|
+
logger.debug(`📡 Notification filtered: ${photonName} doesn't care about "${message?.type}"`);
|
|
2000
|
+
return; // Don't broadcast notifications this photon doesn't care about
|
|
2001
|
+
}
|
|
2002
|
+
// Only broadcast relevant notifications
|
|
2003
|
+
logger.debug(`📡 Broadcasting notification: ${photonName} [${message?.type}]`);
|
|
2004
|
+
broadcastNotification('photon/notification', {
|
|
1147
2005
|
photon: photonName,
|
|
1148
|
-
|
|
1149
|
-
|
|
2006
|
+
type: message?.type,
|
|
2007
|
+
priority: message?.priority || 'info',
|
|
2008
|
+
message: message?.message,
|
|
2009
|
+
...(message?.action && { action: message.action }),
|
|
2010
|
+
...(message?.sound && { sound: message.sound }),
|
|
2011
|
+
...(message?.data && { data: message.data }),
|
|
1150
2012
|
});
|
|
1151
2013
|
}, {
|
|
1152
2014
|
reconnect: true,
|
|
1153
2015
|
workingDir,
|
|
1154
|
-
onReconnect: () => logger.debug(`📡 Reconnected ${
|
|
1155
|
-
onRefreshNeeded: () => {
|
|
1156
|
-
logger.info(`📡 Refresh needed for ${channel} (events lost during daemon restart)`);
|
|
1157
|
-
broadcastToBeam('photon/state-changed', {
|
|
1158
|
-
photon: photonName,
|
|
1159
|
-
method: '_refresh',
|
|
1160
|
-
data: {},
|
|
1161
|
-
});
|
|
1162
|
-
},
|
|
2016
|
+
onReconnect: () => logger.debug(`📡 Reconnected ${notificationChannel} subscription`),
|
|
1163
2017
|
})
|
|
1164
2018
|
.then(() => {
|
|
1165
|
-
logger.info(`📡 Subscribed to ${
|
|
2019
|
+
logger.info(`📡 Subscribed to ${notificationChannel} for notifications${watchFor ? ` (watching: ${watchFor.join(', ')})` : ''}`);
|
|
1166
2020
|
})
|
|
1167
2021
|
.catch((err) => {
|
|
1168
|
-
logger.warn(`Failed to subscribe to ${
|
|
2022
|
+
logger.warn(`Failed to subscribe to ${notificationChannel}: ${getErrorMessage(err)}`);
|
|
1169
2023
|
});
|
|
1170
2024
|
}
|
|
1171
2025
|
}
|
|
1172
2026
|
catch (err) {
|
|
1173
|
-
logger.warn(`Failed to
|
|
2027
|
+
logger.warn(`Failed to set up notification subscriptions: ${getErrorMessage(err)}`);
|
|
1174
2028
|
}
|
|
1175
2029
|
}
|
|
1176
2030
|
// Set up file watchers for symlinked and bundled photon assets (now that photons are loaded)
|