@portel/photon 1.11.0 → 1.13.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 +994 -34
- 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 +53 -12
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +28 -1
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js +23 -0
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +2894 -329
- package/dist/beam.bundle.js.map +4 -4
- package/dist/cli/commands/build.d.ts +3 -0
- package/dist/cli/commands/build.d.ts.map +1 -0
- package/dist/cli/commands/build.js +339 -0
- package/dist/cli/commands/build.js.map +1 -0
- 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/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +2 -0
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.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/client.d.ts.map +1 -1
- package/dist/daemon/client.js +81 -0
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/protocol.d.ts +3 -1
- package/dist/daemon/protocol.d.ts.map +1 -1
- package/dist/daemon/protocol.js +1 -1
- package/dist/daemon/protocol.js.map +1 -1
- package/dist/daemon/server.js +513 -18
- package/dist/daemon/server.js.map +1 -1
- package/dist/embedded-runtime.d.ts +38 -0
- package/dist/embedded-runtime.d.ts.map +1 -0
- package/dist/embedded-runtime.js +326 -0
- package/dist/embedded-runtime.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +38 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +455 -15
- package/dist/loader.js.map +1 -1
- package/dist/photon-cli-runner.d.ts +22 -0
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +244 -12
- 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 +30 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +155 -10
- 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/dist/version.d.ts.map +1 -1
- package/dist/version.js +10 -2
- package/dist/version.js.map +1 -1
- package/package.json +23 -6
package/dist/auto-ui/beam.js
CHANGED
|
@@ -8,11 +8,28 @@
|
|
|
8
8
|
import * as http from 'http';
|
|
9
9
|
import * as net from 'net';
|
|
10
10
|
import * as fs from 'fs/promises';
|
|
11
|
-
import { existsSync, lstatSync, mkdirSync, realpathSync, watch } from 'fs';
|
|
11
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, watch, } from 'fs';
|
|
12
12
|
import * as path from 'path';
|
|
13
|
+
import * as os from 'os';
|
|
13
14
|
import { fileURLToPath } from 'url';
|
|
14
15
|
import { createHash } from 'crypto';
|
|
15
16
|
import { setSecurityHeaders, SimpleRateLimiter } from '../shared/security.js';
|
|
17
|
+
/**
|
|
18
|
+
* Check if shell integration has been installed (photon init cli).
|
|
19
|
+
* Cached at module load since it won't change during a Beam session.
|
|
20
|
+
*/
|
|
21
|
+
const _shellIntegrationInstalled = (() => {
|
|
22
|
+
const shell = process.env.SHELL || '';
|
|
23
|
+
const rcFile = shell.includes('zsh')
|
|
24
|
+
? path.join(os.homedir(), '.zshrc')
|
|
25
|
+
: path.join(os.homedir(), '.bashrc');
|
|
26
|
+
try {
|
|
27
|
+
return readFileSync(rcFile, 'utf-8').includes('# photon shell integration');
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
16
33
|
/**
|
|
17
34
|
* Generate a unique ID for a photon based on its path.
|
|
18
35
|
* This ensures photons with the same name from different paths are distinguishable.
|
|
@@ -21,6 +38,52 @@ import { setSecurityHeaders, SimpleRateLimiter } from '../shared/security.js';
|
|
|
21
38
|
function generatePhotonId(photonPath) {
|
|
22
39
|
return createHash('sha256').update(photonPath).digest('hex').slice(0, 12);
|
|
23
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* MIME type map for icon images
|
|
43
|
+
*/
|
|
44
|
+
const ICON_MIME_TYPES = {
|
|
45
|
+
'.png': 'image/png',
|
|
46
|
+
'.jpg': 'image/jpeg',
|
|
47
|
+
'.jpeg': 'image/jpeg',
|
|
48
|
+
'.gif': 'image/gif',
|
|
49
|
+
'.svg': 'image/svg+xml',
|
|
50
|
+
'.webp': 'image/webp',
|
|
51
|
+
'.ico': 'image/x-icon',
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Resolve raw icon image paths to MCP Icon[] format (data URIs)
|
|
55
|
+
*/
|
|
56
|
+
async function resolveIconImages(iconImages, photonPath) {
|
|
57
|
+
if (!iconImages || iconImages.length === 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
const photonDir = path.dirname(photonPath);
|
|
60
|
+
const icons = [];
|
|
61
|
+
for (const entry of iconImages) {
|
|
62
|
+
try {
|
|
63
|
+
const resolvedPath = path.resolve(photonDir, entry.path);
|
|
64
|
+
const ext = path.extname(resolvedPath).toLowerCase();
|
|
65
|
+
const mimeType = ICON_MIME_TYPES[ext];
|
|
66
|
+
if (!mimeType)
|
|
67
|
+
continue;
|
|
68
|
+
const data = await fs.readFile(resolvedPath);
|
|
69
|
+
const base64 = data.toString('base64');
|
|
70
|
+
const dataUri = `data:${mimeType};base64,${base64}`;
|
|
71
|
+
const icon = {
|
|
72
|
+
src: dataUri,
|
|
73
|
+
mimeType,
|
|
74
|
+
};
|
|
75
|
+
if (entry.sizes)
|
|
76
|
+
icon.sizes = entry.sizes;
|
|
77
|
+
if (entry.theme)
|
|
78
|
+
icon.theme = entry.theme;
|
|
79
|
+
icons.push(icon);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Skip unreadable icon files silently
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return icons.length > 0 ? icons : undefined;
|
|
86
|
+
}
|
|
24
87
|
const __filename = fileURLToPath(import.meta.url);
|
|
25
88
|
const __dirname = path.dirname(__filename);
|
|
26
89
|
import { withTimeout } from '../async/index.js';
|
|
@@ -69,6 +132,318 @@ const backfillEnvDefaults = backfillEnvDefaultsFromModule;
|
|
|
69
132
|
const extractClassMetadataFromSource = extractClassMetadataFromModule;
|
|
70
133
|
const applyMethodVisibility = applyMethodVisibilityFromModule;
|
|
71
134
|
const extractCspFromSource = extractCspFromModule;
|
|
135
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
136
|
+
// NOTIFICATION SUBSCRIPTIONS
|
|
137
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
138
|
+
/**
|
|
139
|
+
* Map to store notification subscriptions per photon
|
|
140
|
+
* Key: photon name, Value: list of event types this photon cares about
|
|
141
|
+
* Example: { "chat": ["mentions", "direct-messages"], "tasks": ["deadline", "assigned-to-me"] }
|
|
142
|
+
*/
|
|
143
|
+
const photonNotificationSubscriptions = new Map();
|
|
144
|
+
/**
|
|
145
|
+
* Generate the service worker JS that validates the Beam backend
|
|
146
|
+
* on PWA launch and shows a diagnostic page if something is wrong.
|
|
147
|
+
*/
|
|
148
|
+
function generateServiceWorker(workingDir) {
|
|
149
|
+
return `
|
|
150
|
+
// Photon Beam Service Worker
|
|
151
|
+
// Validates the backend is running and healthy before serving the app.
|
|
152
|
+
const CACHE_NAME = 'photon-pwa-v1';
|
|
153
|
+
const EXPECTED_WORKING_DIR = ${JSON.stringify(workingDir)};
|
|
154
|
+
const HEALTH_ENDPOINT = '/api/diagnostics';
|
|
155
|
+
|
|
156
|
+
// Cache the boot page on install
|
|
157
|
+
self.addEventListener('install', (event) => {
|
|
158
|
+
event.waitUntil(
|
|
159
|
+
caches.open(CACHE_NAME).then((cache) => cache.put('/_pwa_boot', new Response(BOOT_PAGE, {
|
|
160
|
+
headers: { 'Content-Type': 'text/html' }
|
|
161
|
+
}))).then(() => self.skipWaiting())
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
self.addEventListener('activate', (event) => {
|
|
166
|
+
event.waitUntil(self.clients.claim());
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
self.addEventListener('fetch', (event) => {
|
|
170
|
+
const url = new URL(event.request.url);
|
|
171
|
+
|
|
172
|
+
// PWA icon PNG generation — intercept /api/pwa/icon-png requests and render
|
|
173
|
+
// the SVG icon onto OffscreenCanvas, returning a real PNG response that
|
|
174
|
+
// satisfies Chrome's installability requirement for raster icons.
|
|
175
|
+
if (url.pathname === '/api/pwa/icon-png') {
|
|
176
|
+
event.respondWith(handleIconPng(url));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Only intercept navigation requests (page loads, not API/asset fetches)
|
|
181
|
+
if (event.request.mode !== 'navigate') return;
|
|
182
|
+
|
|
183
|
+
// Skip API routes and static assets — let them pass through
|
|
184
|
+
if (url.pathname.startsWith('/api/') || url.pathname === '/sw.js' || url.pathname === '/beam.bundle.js') return;
|
|
185
|
+
|
|
186
|
+
// All navigation requests go through health check
|
|
187
|
+
event.respondWith(handlePWANavigation(event.request));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
async function handlePWANavigation(request) {
|
|
191
|
+
try {
|
|
192
|
+
// Try to reach the backend
|
|
193
|
+
const healthRes = await fetch(HEALTH_ENDPOINT, { signal: AbortSignal.timeout(3000) });
|
|
194
|
+
if (!healthRes.ok) throw new Error('Health check failed');
|
|
195
|
+
|
|
196
|
+
const health = await healthRes.json();
|
|
197
|
+
|
|
198
|
+
// Validate this is actually Beam serving the expected directory
|
|
199
|
+
if (!health.photonVersion) {
|
|
200
|
+
return serveBoot('wrong-service', JSON.stringify(health));
|
|
201
|
+
}
|
|
202
|
+
if (health.workingDir !== EXPECTED_WORKING_DIR) {
|
|
203
|
+
return serveBoot('wrong-directory', JSON.stringify({
|
|
204
|
+
expected: EXPECTED_WORKING_DIR,
|
|
205
|
+
actual: health.workingDir
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Backend is healthy and correct — serve the real page
|
|
210
|
+
return fetch(request);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
// Backend is unreachable
|
|
213
|
+
return serveBoot('not-running', err.message);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function handleIconPng(url) {
|
|
218
|
+
const photon = url.searchParams.get('photon') || '';
|
|
219
|
+
const size = parseInt(url.searchParams.get('size') || '192', 10);
|
|
220
|
+
const cacheKey = '/_pwa_icon_' + photon + '_' + size;
|
|
221
|
+
|
|
222
|
+
// Check cache first
|
|
223
|
+
const cache = await caches.open(CACHE_NAME);
|
|
224
|
+
const cached = await cache.match(cacheKey);
|
|
225
|
+
if (cached) return cached;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
// Fetch the icon from the server (may be SVG, PNG, JPEG, etc.)
|
|
229
|
+
const iconRes = await fetch('/api/pwa/icon?photon=' + encodeURIComponent(photon), { signal: AbortSignal.timeout(10000) });
|
|
230
|
+
if (!iconRes.ok) throw new Error('Icon fetch failed: ' + iconRes.status);
|
|
231
|
+
|
|
232
|
+
const contentType = (iconRes.headers.get('Content-Type') || '').toLowerCase();
|
|
233
|
+
|
|
234
|
+
// For raster images (PNG, JPEG, WebP), resize via OffscreenCanvas if needed
|
|
235
|
+
// For SVG or emoji-generated SVG, render to canvas at target size
|
|
236
|
+
let bmp;
|
|
237
|
+
if (contentType.includes('svg')) {
|
|
238
|
+
// SVG (emoji-generated or file) — parse as text, create bitmap
|
|
239
|
+
const svgText = await iconRes.text();
|
|
240
|
+
const svgBlob = new Blob([svgText], { type: 'image/svg+xml' });
|
|
241
|
+
bmp = await createImageBitmap(svgBlob, { resizeWidth: size, resizeHeight: size });
|
|
242
|
+
} else {
|
|
243
|
+
// Raster image (PNG, JPEG, WebP) — decode directly
|
|
244
|
+
const imgBlob = await iconRes.blob();
|
|
245
|
+
bmp = await createImageBitmap(imgBlob, { resizeWidth: size, resizeHeight: size });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const canvas = new OffscreenCanvas(size, size);
|
|
249
|
+
const ctx = canvas.getContext('2d');
|
|
250
|
+
|
|
251
|
+
// Dark rounded-rect background
|
|
252
|
+
ctx.fillStyle = '#1a1a1a';
|
|
253
|
+
const r = size * 0.2;
|
|
254
|
+
ctx.beginPath();
|
|
255
|
+
ctx.moveTo(r, 0);
|
|
256
|
+
ctx.lineTo(size - r, 0);
|
|
257
|
+
ctx.quadraticCurveTo(size, 0, size, r);
|
|
258
|
+
ctx.lineTo(size, size - r);
|
|
259
|
+
ctx.quadraticCurveTo(size, size, size - r, size);
|
|
260
|
+
ctx.lineTo(r, size);
|
|
261
|
+
ctx.quadraticCurveTo(0, size, 0, size - r);
|
|
262
|
+
ctx.lineTo(0, r);
|
|
263
|
+
ctx.quadraticCurveTo(0, 0, r, 0);
|
|
264
|
+
ctx.closePath();
|
|
265
|
+
ctx.fill();
|
|
266
|
+
|
|
267
|
+
// Draw the icon
|
|
268
|
+
ctx.drawImage(bmp, 0, 0, size, size);
|
|
269
|
+
|
|
270
|
+
const pngBlob = await canvas.convertToBlob({ type: 'image/png' });
|
|
271
|
+
const pngResponse = new Response(pngBlob, {
|
|
272
|
+
headers: {
|
|
273
|
+
'Content-Type': 'image/png',
|
|
274
|
+
'Cache-Control': 'public, max-age=86400'
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Cache the generated PNG
|
|
279
|
+
await cache.put(cacheKey, pngResponse.clone());
|
|
280
|
+
return pngResponse;
|
|
281
|
+
} catch (err) {
|
|
282
|
+
// Fallback: generate a simple colored square with initial letter
|
|
283
|
+
const canvas = new OffscreenCanvas(size, size);
|
|
284
|
+
const ctx = canvas.getContext('2d');
|
|
285
|
+
ctx.fillStyle = '#1a1a1a';
|
|
286
|
+
ctx.fillRect(0, 0, size, size);
|
|
287
|
+
ctx.fillStyle = '#4ade80';
|
|
288
|
+
ctx.font = (size * 0.4) + 'px sans-serif';
|
|
289
|
+
ctx.textAlign = 'center';
|
|
290
|
+
ctx.textBaseline = 'middle';
|
|
291
|
+
ctx.fillText(photon.charAt(0).toUpperCase() || 'P', size / 2, size / 2);
|
|
292
|
+
const pngBlob = await canvas.convertToBlob({ type: 'image/png' });
|
|
293
|
+
return new Response(pngBlob, {
|
|
294
|
+
headers: { 'Content-Type': 'image/png' }
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function serveBoot(reason, detail) {
|
|
300
|
+
const cache = await caches.open(CACHE_NAME);
|
|
301
|
+
const cached = await cache.match('/_pwa_boot');
|
|
302
|
+
if (cached) {
|
|
303
|
+
const html = await cached.text();
|
|
304
|
+
const injected = html
|
|
305
|
+
.replace('__BOOT_REASON__', reason)
|
|
306
|
+
.replace('__BOOT_DETAIL__', detail || '')
|
|
307
|
+
.replace('__EXPECTED_DIR__', EXPECTED_WORKING_DIR);
|
|
308
|
+
return new Response(injected, { headers: { 'Content-Type': 'text/html' } });
|
|
309
|
+
}
|
|
310
|
+
return new Response('Photon Beam is not available. Run: photon beam', {
|
|
311
|
+
status: 503, headers: { 'Content-Type': 'text/plain' }
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const BOOT_PAGE = \`<!DOCTYPE html>
|
|
316
|
+
<html lang="en">
|
|
317
|
+
<head>
|
|
318
|
+
<meta charset="UTF-8">
|
|
319
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
320
|
+
<title>Photon Beam</title>
|
|
321
|
+
<meta name="theme-color" content="#1a1a1a">
|
|
322
|
+
<style>
|
|
323
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
324
|
+
body {
|
|
325
|
+
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
326
|
+
background: #1a1a1a; color: #e5e5e5; font-family: system-ui, -apple-system, sans-serif;
|
|
327
|
+
}
|
|
328
|
+
.container { text-align: center; padding: 40px; max-width: 500px; }
|
|
329
|
+
.icon { font-size: 56px; margin-bottom: 24px; }
|
|
330
|
+
h1 { font-size: 22px; font-weight: 600; margin-bottom: 12px; color: #fff; }
|
|
331
|
+
.message { font-size: 15px; line-height: 1.6; color: #999; margin-bottom: 28px; }
|
|
332
|
+
.command {
|
|
333
|
+
display: inline-block; background: #2a2a2a; border: 1px solid #333;
|
|
334
|
+
padding: 10px 20px; border-radius: 8px; font-family: 'JetBrains Mono', monospace;
|
|
335
|
+
font-size: 14px; color: #4ade80; margin-bottom: 20px; user-select: all;
|
|
336
|
+
}
|
|
337
|
+
.detail {
|
|
338
|
+
font-size: 12px; color: #666; font-family: 'JetBrains Mono', monospace;
|
|
339
|
+
background: #222; border-radius: 6px; padding: 10px; margin-bottom: 20px;
|
|
340
|
+
word-break: break-all; display: none;
|
|
341
|
+
}
|
|
342
|
+
.detail.show { display: block; }
|
|
343
|
+
.retry {
|
|
344
|
+
padding: 10px 24px; background: #333; border: 1px solid #444; border-radius: 8px;
|
|
345
|
+
color: #fff; cursor: pointer; font-size: 14px; transition: background 0.2s;
|
|
346
|
+
}
|
|
347
|
+
.retry:hover { background: #444; }
|
|
348
|
+
.spinner {
|
|
349
|
+
display: none; width: 20px; height: 20px; border: 2px solid #444;
|
|
350
|
+
border-top-color: #4ade80; border-radius: 50%; animation: spin 0.8s linear infinite;
|
|
351
|
+
margin: 0 auto 16px;
|
|
352
|
+
}
|
|
353
|
+
.spinner.show { display: block; }
|
|
354
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
355
|
+
</style>
|
|
356
|
+
</head>
|
|
357
|
+
<body>
|
|
358
|
+
<div class="container">
|
|
359
|
+
<div class="spinner" id="spinner"></div>
|
|
360
|
+
<div id="content">
|
|
361
|
+
<div class="icon" id="icon"></div>
|
|
362
|
+
<h1 id="title"></h1>
|
|
363
|
+
<p class="message" id="message"></p>
|
|
364
|
+
<div class="detail" id="detail"></div>
|
|
365
|
+
<code class="command" id="command" style="display:none"></code>
|
|
366
|
+
<br><br>
|
|
367
|
+
<button class="retry" onclick="checkAndRetry()">Retry</button>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
<script>
|
|
371
|
+
const reason = '__BOOT_REASON__';
|
|
372
|
+
const detail = '__BOOT_DETAIL__';
|
|
373
|
+
const expectedDir = '__EXPECTED_DIR__';
|
|
374
|
+
|
|
375
|
+
const states = {
|
|
376
|
+
'not-running': {
|
|
377
|
+
icon: '\\u26a1',
|
|
378
|
+
title: 'Beam is not running',
|
|
379
|
+
message: 'Start Photon Beam to use this app:',
|
|
380
|
+
command: 'photon beam'
|
|
381
|
+
},
|
|
382
|
+
'wrong-service': {
|
|
383
|
+
icon: '\\u26a0\\ufe0f',
|
|
384
|
+
title: 'Port is in use by another service',
|
|
385
|
+
message: 'Something else is running on this port. Stop the other service or reconfigure Beam:',
|
|
386
|
+
command: 'photon beam --port <available-port>',
|
|
387
|
+
showDetail: true
|
|
388
|
+
},
|
|
389
|
+
'wrong-directory': {
|
|
390
|
+
icon: '\\ud83d\\udcc1',
|
|
391
|
+
title: 'Beam is serving a different project',
|
|
392
|
+
message: 'Beam is running but pointing to a different directory. Start it with the correct path:',
|
|
393
|
+
command: 'photon beam ' + expectedDir,
|
|
394
|
+
showDetail: true
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
function render() {
|
|
399
|
+
const s = states[reason] || states['not-running'];
|
|
400
|
+
document.getElementById('icon').textContent = s.icon;
|
|
401
|
+
document.getElementById('title').textContent = s.title;
|
|
402
|
+
document.getElementById('message').textContent = s.message;
|
|
403
|
+
const cmdEl = document.getElementById('command');
|
|
404
|
+
cmdEl.textContent = s.command;
|
|
405
|
+
cmdEl.style.display = 'inline-block';
|
|
406
|
+
if (s.showDetail && detail) {
|
|
407
|
+
const el = document.getElementById('detail');
|
|
408
|
+
el.textContent = detail;
|
|
409
|
+
el.classList.add('show');
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function checkAndRetry() {
|
|
414
|
+
document.getElementById('content').style.display = 'none';
|
|
415
|
+
document.getElementById('spinner').classList.add('show');
|
|
416
|
+
try {
|
|
417
|
+
const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(3000) });
|
|
418
|
+
if (res.ok) {
|
|
419
|
+
const h = await res.json();
|
|
420
|
+
if (h.photonVersion && h.workingDir === expectedDir) {
|
|
421
|
+
location.reload();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
} catch {}
|
|
426
|
+
document.getElementById('spinner').classList.remove('show');
|
|
427
|
+
document.getElementById('content').style.display = '';
|
|
428
|
+
render();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
render();
|
|
432
|
+
// Auto-retry every 5 seconds
|
|
433
|
+
setInterval(async () => {
|
|
434
|
+
try {
|
|
435
|
+
const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(2000) });
|
|
436
|
+
if (res.ok) {
|
|
437
|
+
const h = await res.json();
|
|
438
|
+
if (h.photonVersion && h.workingDir === expectedDir) location.reload();
|
|
439
|
+
}
|
|
440
|
+
} catch {}
|
|
441
|
+
}, 5000);
|
|
442
|
+
</script>
|
|
443
|
+
</body>
|
|
444
|
+
</html>\`;
|
|
445
|
+
`;
|
|
446
|
+
}
|
|
72
447
|
export async function startBeam(rawWorkingDir, port) {
|
|
73
448
|
const workingDir = path.resolve(rawWorkingDir);
|
|
74
449
|
const { PHOTON_VERSION } = await import('../version.js');
|
|
@@ -229,8 +604,18 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
229
604
|
backfillEnvDefaults(instance, constructorParams);
|
|
230
605
|
// Extract schema for UI — reuse source read from above
|
|
231
606
|
const schemaSource = source || (await fs.readFile(photonPath, 'utf-8'));
|
|
232
|
-
const
|
|
607
|
+
const metadata = extractor.extractAllFromSource(schemaSource);
|
|
608
|
+
const schemas = metadata.tools;
|
|
609
|
+
const templates = metadata.templates;
|
|
233
610
|
mcp.schemas = schemas;
|
|
611
|
+
// Store notification subscriptions per photon
|
|
612
|
+
if (metadata.notificationSubscriptions?.watchFor) {
|
|
613
|
+
photonNotificationSubscriptions.set(name, metadata.notificationSubscriptions.watchFor);
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
// Clear previous subscription if photon no longer has @notify-on
|
|
617
|
+
photonNotificationSubscriptions.delete(name);
|
|
618
|
+
}
|
|
234
619
|
// Get UI assets for linking
|
|
235
620
|
const uiAssets = mcp.assets?.ui || [];
|
|
236
621
|
// Filter out lifecycle methods
|
|
@@ -256,8 +641,30 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
256
641
|
? { scheduled: schema.scheduled || schema.cron }
|
|
257
642
|
: {}),
|
|
258
643
|
...(schema.locked ? { locked: schema.locked } : {}),
|
|
644
|
+
// MCP standard annotations
|
|
645
|
+
...(schema.title ? { title: schema.title } : {}),
|
|
646
|
+
...(schema.readOnlyHint ? { readOnlyHint: true } : {}),
|
|
647
|
+
...(schema.destructiveHint ? { destructiveHint: true } : {}),
|
|
648
|
+
...(schema.idempotentHint ? { idempotentHint: true } : {}),
|
|
649
|
+
...(schema.openWorldHint !== undefined ? { openWorldHint: schema.openWorldHint } : {}),
|
|
650
|
+
...(schema.audience ? { audience: schema.audience } : {}),
|
|
651
|
+
...(schema.contentPriority !== undefined
|
|
652
|
+
? { contentPriority: schema.contentPriority }
|
|
653
|
+
: {}),
|
|
654
|
+
...(schema.outputSchema ? { outputSchema: schema.outputSchema } : {}),
|
|
259
655
|
};
|
|
260
656
|
});
|
|
657
|
+
// Resolve icon images (file paths → data URIs) for methods that have them
|
|
658
|
+
for (const schema of schemas) {
|
|
659
|
+
if (!schema.iconImages)
|
|
660
|
+
continue;
|
|
661
|
+
const method = methods.find((m) => m.name === schema.name);
|
|
662
|
+
if (!method)
|
|
663
|
+
continue;
|
|
664
|
+
const resolved = await resolveIconImages(schema.iconImages, photonPath);
|
|
665
|
+
if (resolved)
|
|
666
|
+
method.icons = resolved;
|
|
667
|
+
}
|
|
261
668
|
// Add templates as methods with isTemplate flag and markdown output format
|
|
262
669
|
templates.forEach((template) => {
|
|
263
670
|
if (!lifecycleMethods.includes(template.name)) {
|
|
@@ -536,12 +943,25 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
536
943
|
if (await handleConfigRoutes(req, res, url, beamState))
|
|
537
944
|
return;
|
|
538
945
|
}
|
|
946
|
+
// Service worker for PWA support
|
|
947
|
+
if (url.pathname === '/sw.js') {
|
|
948
|
+
res.writeHead(200, {
|
|
949
|
+
'Content-Type': 'application/javascript',
|
|
950
|
+
'Service-Worker-Allowed': '/',
|
|
951
|
+
'Cache-Control': 'no-cache',
|
|
952
|
+
});
|
|
953
|
+
res.end(generateServiceWorker(beamState.workingDir));
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
539
956
|
// Serve static frontend bundle
|
|
540
957
|
if (url.pathname === '/beam.bundle.js') {
|
|
541
958
|
try {
|
|
542
959
|
const bundlePath = path.join(__dirname, '../../dist/beam.bundle.js');
|
|
543
960
|
const content = await fs.readFile(bundlePath, 'utf-8');
|
|
544
|
-
res.writeHead(200, {
|
|
961
|
+
res.writeHead(200, {
|
|
962
|
+
'Content-Type': 'text/javascript',
|
|
963
|
+
'Cache-Control': 'no-cache',
|
|
964
|
+
});
|
|
545
965
|
res.end(content);
|
|
546
966
|
}
|
|
547
967
|
catch {
|
|
@@ -550,11 +970,458 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
550
970
|
}
|
|
551
971
|
return;
|
|
552
972
|
}
|
|
973
|
+
// Standalone PWA app route: /app/{photonName}
|
|
974
|
+
// Full-featured PWA host shell with diagnostics, postMessage bridge, service worker, and install prompt
|
|
975
|
+
const appMatch = url.pathname.match(/^\/app\/([^/]+)$/);
|
|
976
|
+
if (appMatch) {
|
|
977
|
+
const photonName = appMatch[1];
|
|
978
|
+
const photon = beamState.photons.find((p) => p.name === photonName);
|
|
979
|
+
if (!photon) {
|
|
980
|
+
res.writeHead(404);
|
|
981
|
+
res.end(`Photon not found: ${photonName}`);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const label = photon?.label ||
|
|
985
|
+
photonName.charAt(0).toUpperCase() + photonName.slice(1).replace(/-/g, ' ');
|
|
986
|
+
const description = photon?.description || `${label} - Photon App`;
|
|
987
|
+
const iconValue = photon?.icon || '📦';
|
|
988
|
+
const encodedName = encodeURIComponent(photonName);
|
|
989
|
+
// Sanitize strings for safe embedding in HTML
|
|
990
|
+
const safeLabel = label.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c);
|
|
991
|
+
const safeDesc = description.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c);
|
|
992
|
+
const html = `<!DOCTYPE html>
|
|
993
|
+
<html lang="en">
|
|
994
|
+
<head>
|
|
995
|
+
<meta charset="UTF-8">
|
|
996
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
997
|
+
<title>${safeLabel}</title>
|
|
998
|
+
<meta name="description" content="${safeDesc}">
|
|
999
|
+
<meta name="theme-color" content="#1a1a1a">
|
|
1000
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
1001
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
1002
|
+
<meta name="apple-mobile-web-app-title" content="${safeLabel}">
|
|
1003
|
+
<link rel="manifest" href="/api/pwa/manifest.json?photon=${encodedName}">
|
|
1004
|
+
<link rel="apple-touch-icon" href="/api/pwa/icon?photon=${encodedName}">
|
|
1005
|
+
<style>
|
|
1006
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1007
|
+
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; font-family: system-ui, -apple-system, sans-serif; color: #e5e5e5; }
|
|
1008
|
+
#app { width: 100%; height: 100vh; }
|
|
1009
|
+
iframe { width: 100%; height: 100vh; border: none; display: block; }
|
|
1010
|
+
|
|
1011
|
+
.status-page {
|
|
1012
|
+
display: none; width: 100%; height: 100vh;
|
|
1013
|
+
flex-direction: column; align-items: center; justify-content: center;
|
|
1014
|
+
text-align: center; padding: 40px;
|
|
1015
|
+
}
|
|
1016
|
+
.status-page.show { display: flex; }
|
|
1017
|
+
.status-page .icon { font-size: 64px; margin-bottom: 24px; }
|
|
1018
|
+
.status-page h2 { font-size: 20px; font-weight: 600; margin-bottom: 12px; color: #e5e5e5; }
|
|
1019
|
+
.status-page p { font-size: 14px; color: #888; max-width: 400px; line-height: 1.6; margin-bottom: 8px; }
|
|
1020
|
+
.status-page code {
|
|
1021
|
+
display: inline-block; background: #2a2a2a; padding: 8px 16px; border-radius: 6px;
|
|
1022
|
+
font-size: 13px; color: #4ade80; font-family: 'SF Mono', Monaco, monospace; margin-top: 8px;
|
|
1023
|
+
}
|
|
1024
|
+
.status-page .spinner {
|
|
1025
|
+
width: 24px; height: 24px; border: 2px solid #333; border-top-color: #4ade80;
|
|
1026
|
+
border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 16px;
|
|
1027
|
+
}
|
|
1028
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
1029
|
+
.status-page .retry-btn {
|
|
1030
|
+
margin-top: 16px; padding: 8px 20px; background: #333; border: 1px solid #444;
|
|
1031
|
+
border-radius: 6px; color: #e5e5e5; cursor: pointer; font-size: 13px; font-family: inherit;
|
|
1032
|
+
}
|
|
1033
|
+
.status-page .retry-btn:hover { background: #444; }
|
|
1034
|
+
|
|
1035
|
+
</style>
|
|
1036
|
+
</head>
|
|
1037
|
+
<body>
|
|
1038
|
+
<!-- Status: Starting (shown while waiting for Beam) -->
|
|
1039
|
+
<div id="status-starting" class="status-page">
|
|
1040
|
+
<div class="spinner"></div>
|
|
1041
|
+
<h2>Starting ${safeLabel}...</h2>
|
|
1042
|
+
<p>Waiting for Beam server</p>
|
|
1043
|
+
</div>
|
|
1044
|
+
|
|
1045
|
+
<!-- Status: Not running (shown when Beam is down) -->
|
|
1046
|
+
<div id="status-offline" class="status-page">
|
|
1047
|
+
<div class="icon">${iconValue}</div>
|
|
1048
|
+
<h2>${safeLabel}</h2>
|
|
1049
|
+
<p>Server is not running. Start Photon to use this app:</p>
|
|
1050
|
+
<code>photon beam</code>
|
|
1051
|
+
<button class="retry-btn" onclick="checkAndLoad()">Retry</button>
|
|
1052
|
+
</div>
|
|
1053
|
+
|
|
1054
|
+
<!-- Status: Port conflict -->
|
|
1055
|
+
<div id="status-conflict" class="status-page">
|
|
1056
|
+
<div class="icon">⚠️</div>
|
|
1057
|
+
<h2>Port Conflict</h2>
|
|
1058
|
+
<p id="conflict-msg">Another process is using the required port.</p>
|
|
1059
|
+
<code id="conflict-cmd"></code>
|
|
1060
|
+
<button class="retry-btn" onclick="checkAndLoad()">Retry</button>
|
|
1061
|
+
</div>
|
|
1062
|
+
|
|
1063
|
+
<!-- App container with iframe -->
|
|
1064
|
+
<div id="app" style="display:none"></div>
|
|
1065
|
+
|
|
1066
|
+
<script>
|
|
1067
|
+
const PHOTON = ${JSON.stringify(photonName)};
|
|
1068
|
+
const appEl = document.getElementById('app');
|
|
1069
|
+
const statusStarting = document.getElementById('status-starting');
|
|
1070
|
+
const statusOffline = document.getElementById('status-offline');
|
|
1071
|
+
const statusConflict = document.getElementById('status-conflict');
|
|
1072
|
+
let retryTimer = null;
|
|
1073
|
+
|
|
1074
|
+
function hideAll() {
|
|
1075
|
+
statusStarting.classList.remove('show');
|
|
1076
|
+
statusOffline.classList.remove('show');
|
|
1077
|
+
statusConflict.classList.remove('show');
|
|
1078
|
+
appEl.style.display = 'none';
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Diagnostics-first loading: check server health before loading the app
|
|
1082
|
+
async function checkAndLoad() {
|
|
1083
|
+
if (retryTimer) { clearInterval(retryTimer); retryTimer = null; }
|
|
1084
|
+
hideAll();
|
|
1085
|
+
statusStarting.classList.add('show');
|
|
1086
|
+
|
|
1087
|
+
try {
|
|
1088
|
+
const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(5000) });
|
|
1089
|
+
if (!res.ok) throw new Error('Server error');
|
|
1090
|
+
const diag = await res.json();
|
|
1091
|
+
|
|
1092
|
+
// Check for port conflicts
|
|
1093
|
+
if (diag.portConflict) {
|
|
1094
|
+
hideAll();
|
|
1095
|
+
statusConflict.classList.add('show');
|
|
1096
|
+
const conflictMsg = document.getElementById('conflict-msg');
|
|
1097
|
+
const conflictCmd = document.getElementById('conflict-cmd');
|
|
1098
|
+
if (diag.portConflict.port) {
|
|
1099
|
+
conflictMsg.textContent = 'Port ' + diag.portConflict.port + ' is in use by another process.';
|
|
1100
|
+
}
|
|
1101
|
+
if (diag.portConflict.pid) {
|
|
1102
|
+
conflictCmd.textContent = 'kill ' + diag.portConflict.pid;
|
|
1103
|
+
}
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Server is healthy — establish MCP session then load the app
|
|
1108
|
+
hideAll();
|
|
1109
|
+
appEl.style.display = 'block';
|
|
1110
|
+
await connectSSE();
|
|
1111
|
+
await loadApp();
|
|
1112
|
+
} catch (err) {
|
|
1113
|
+
// Server unreachable — show offline state with auto-retry
|
|
1114
|
+
hideAll();
|
|
1115
|
+
statusOffline.classList.add('show');
|
|
1116
|
+
retryTimer = setInterval(async () => {
|
|
1117
|
+
try {
|
|
1118
|
+
const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(3000) });
|
|
1119
|
+
if (res.ok) {
|
|
1120
|
+
clearInterval(retryTimer);
|
|
1121
|
+
retryTimer = null;
|
|
1122
|
+
checkAndLoad();
|
|
1123
|
+
}
|
|
1124
|
+
} catch { /* still offline */ }
|
|
1125
|
+
}, 3000);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Load the @ui template into a full-viewport iframe with platform bridge.
|
|
1130
|
+
// Discovers template URL client-side: tries class-level @ui first (/api/template),
|
|
1131
|
+
// then falls back to method-level @ui by querying diagnostics for the app entry's linkedUi.
|
|
1132
|
+
async function loadApp() {
|
|
1133
|
+
try {
|
|
1134
|
+
// Step 1: Discover the template URL
|
|
1135
|
+
let templateUrl = '/api/template?photon=' + encodeURIComponent(PHOTON);
|
|
1136
|
+
let bridgeMethod = 'main';
|
|
1137
|
+
|
|
1138
|
+
// Try class-level @ui first
|
|
1139
|
+
let templateRes = await fetch(templateUrl, { signal: AbortSignal.timeout(10000) });
|
|
1140
|
+
if (!templateRes.ok) {
|
|
1141
|
+
// Fall back: query diagnostics for this photon's appEntry linkedUi
|
|
1142
|
+
const diagRes = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(10000) });
|
|
1143
|
+
if (diagRes.ok) {
|
|
1144
|
+
const diag = await diagRes.json();
|
|
1145
|
+
const photonInfo = (diag.photons || []).find(function(p) { return p.name === PHOTON; });
|
|
1146
|
+
if (photonInfo && photonInfo.appEntry && photonInfo.appEntry.linkedUi) {
|
|
1147
|
+
templateUrl = '/api/ui?photon=' + encodeURIComponent(PHOTON) + '&id=' + encodeURIComponent(photonInfo.appEntry.linkedUi);
|
|
1148
|
+
bridgeMethod = photonInfo.appEntry.name || 'main';
|
|
1149
|
+
templateRes = await fetch(templateUrl, { signal: AbortSignal.timeout(10000) });
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (!templateRes.ok) throw new Error('Template not available');
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Step 2: Fetch platform bridge script
|
|
1156
|
+
const bridgeRes = await fetch('/api/platform-bridge?photon=' + encodeURIComponent(PHOTON) + '&method=' + encodeURIComponent(bridgeMethod) + '&theme=dark', { signal: AbortSignal.timeout(10000) });
|
|
1157
|
+
|
|
1158
|
+
let templateHtml = await templateRes.text();
|
|
1159
|
+
const bridgeScript = bridgeRes.ok ? await bridgeRes.text() : '';
|
|
1160
|
+
|
|
1161
|
+
// Inject platform bridge before </head>
|
|
1162
|
+
if (templateHtml.includes('</head>')) {
|
|
1163
|
+
templateHtml = templateHtml.replace('</head>', bridgeScript + '</head>');
|
|
1164
|
+
} else {
|
|
1165
|
+
templateHtml = '<html><head>' + bridgeScript + '</head><body>' + templateHtml + '</body></html>';
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const iframe = document.createElement('iframe');
|
|
1169
|
+
iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-same-origin allow-popups allow-modals');
|
|
1170
|
+
|
|
1171
|
+
const blob = new Blob([templateHtml], { type: 'text/html' });
|
|
1172
|
+
iframe.src = URL.createObjectURL(blob);
|
|
1173
|
+
|
|
1174
|
+
appEl.innerHTML = '';
|
|
1175
|
+
appEl.appendChild(iframe);
|
|
1176
|
+
initBridge(iframe, bridgeMethod);
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
appEl.innerHTML = '<div class="status-page show"><div class="icon">⚠️</div>'
|
|
1179
|
+
+ '<h2>Failed to load</h2><p>' + err.message + '</p>'
|
|
1180
|
+
+ '<button class="retry-btn" onclick="checkAndLoad()">Retry</button></div>';
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// postMessage bridge: relays JSON-RPC from iframe through MCP for full event pipeline
|
|
1185
|
+
var mcpSessionId = null;
|
|
1186
|
+
var mcpCallId = 100;
|
|
1187
|
+
|
|
1188
|
+
function initBridge(iframe, bridgeMethod) {
|
|
1189
|
+
window.addEventListener('message', async (e) => {
|
|
1190
|
+
const msg = e.data;
|
|
1191
|
+
if (!msg || typeof msg !== 'object') return;
|
|
1192
|
+
|
|
1193
|
+
// Handle MCP Apps ui/initialize request — respond so bridge sends initialized
|
|
1194
|
+
if (msg.jsonrpc === '2.0' && msg.method === 'ui/initialize' && msg.id != null) {
|
|
1195
|
+
iframe.contentWindow.postMessage({
|
|
1196
|
+
jsonrpc: '2.0',
|
|
1197
|
+
id: msg.id,
|
|
1198
|
+
result: {
|
|
1199
|
+
protocolVersion: '2026-01-26',
|
|
1200
|
+
hostInfo: { name: 'photon-pwa', version: '1.0.0' },
|
|
1201
|
+
hostCapabilities: {},
|
|
1202
|
+
hostContext: { theme: 'dark' },
|
|
1203
|
+
},
|
|
1204
|
+
}, '*');
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Handle JSON-RPC tools/call from iframe — route through MCP for event broadcasting
|
|
1209
|
+
if (msg.jsonrpc === '2.0' && msg.method === 'tools/call' && msg.id != null) {
|
|
1210
|
+
const { name: toolName, arguments: toolArgs } = msg.params || {};
|
|
1211
|
+
// Prefix with photon name if not already prefixed (bridge sends bare method names)
|
|
1212
|
+
var fullToolName = toolName.includes('/') ? toolName : PHOTON + '/' + toolName;
|
|
1213
|
+
try {
|
|
1214
|
+
var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
|
1215
|
+
if (mcpSessionId) headers['Mcp-Session-Id'] = mcpSessionId;
|
|
1216
|
+
var mcpRes = await fetch('/mcp', {
|
|
1217
|
+
method: 'POST',
|
|
1218
|
+
headers: headers,
|
|
1219
|
+
body: JSON.stringify({
|
|
1220
|
+
jsonrpc: '2.0', id: mcpCallId++,
|
|
1221
|
+
method: 'tools/call',
|
|
1222
|
+
params: { name: fullToolName, arguments: toolArgs || {} },
|
|
1223
|
+
}),
|
|
1224
|
+
signal: AbortSignal.timeout(60000),
|
|
1225
|
+
});
|
|
1226
|
+
var mcpData = await mcpRes.json();
|
|
1227
|
+
// Extract result from MCP response (content array → structured or text)
|
|
1228
|
+
var result = undefined;
|
|
1229
|
+
var error = undefined;
|
|
1230
|
+
if (mcpData.error) {
|
|
1231
|
+
error = { code: mcpData.error.code || -32000, message: mcpData.error.message || 'Unknown error' };
|
|
1232
|
+
} else if (mcpData.result) {
|
|
1233
|
+
var content = mcpData.result.content || [];
|
|
1234
|
+
var textParts = content.filter(function(c) { return c.type === 'text'; });
|
|
1235
|
+
if (textParts.length > 0) {
|
|
1236
|
+
try { result = JSON.parse(textParts[0].text); } catch(e) { result = textParts[0].text; }
|
|
1237
|
+
}
|
|
1238
|
+
if (mcpData.result.structuredContent) result = mcpData.result.structuredContent;
|
|
1239
|
+
}
|
|
1240
|
+
iframe.contentWindow.postMessage({
|
|
1241
|
+
jsonrpc: '2.0', id: msg.id, result: result, error: error,
|
|
1242
|
+
}, '*');
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
iframe.contentWindow.postMessage({
|
|
1245
|
+
jsonrpc: '2.0', id: msg.id,
|
|
1246
|
+
error: { code: -32000, message: err.message },
|
|
1247
|
+
}, '*');
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
// Send photon:init context to iframe once loaded
|
|
1253
|
+
iframe.onload = () => {
|
|
1254
|
+
iframe.contentWindow.postMessage({
|
|
1255
|
+
type: 'photon:init',
|
|
1256
|
+
context: { photon: PHOTON, theme: 'dark', displayMode: 'standalone' }
|
|
1257
|
+
}, '*');
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
// Wait for bridge ready signal, then auto-invoke main() and deliver result
|
|
1261
|
+
var bridgeReady = false;
|
|
1262
|
+
window.addEventListener('message', async function onReady(e) {
|
|
1263
|
+
if (bridgeReady) return;
|
|
1264
|
+
var msg = e.data;
|
|
1265
|
+
if (!msg || typeof msg !== 'object') return;
|
|
1266
|
+
// Bridge sends both legacy photon:ready and MCP Apps ui/notifications/initialized
|
|
1267
|
+
var isReady = msg.type === 'photon:ready' ||
|
|
1268
|
+
(msg.jsonrpc === '2.0' && msg.method === 'ui/notifications/initialized');
|
|
1269
|
+
if (!isReady) return;
|
|
1270
|
+
bridgeReady = true;
|
|
1271
|
+
window.removeEventListener('message', onReady);
|
|
1272
|
+
|
|
1273
|
+
try {
|
|
1274
|
+
var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
|
1275
|
+
if (mcpSessionId) headers['Mcp-Session-Id'] = mcpSessionId;
|
|
1276
|
+
var invokeRes = await fetch('/mcp', {
|
|
1277
|
+
method: 'POST',
|
|
1278
|
+
headers: headers,
|
|
1279
|
+
body: JSON.stringify({
|
|
1280
|
+
jsonrpc: '2.0', id: mcpCallId++,
|
|
1281
|
+
method: 'tools/call',
|
|
1282
|
+
params: { name: PHOTON + '/' + bridgeMethod, arguments: {} },
|
|
1283
|
+
}),
|
|
1284
|
+
signal: AbortSignal.timeout(30000),
|
|
1285
|
+
});
|
|
1286
|
+
var mcpData = await invokeRes.json();
|
|
1287
|
+
if (!mcpData.error && mcpData.result) {
|
|
1288
|
+
var content = mcpData.result.content || [];
|
|
1289
|
+
var textParts = content.filter(function(c) { return c.type === 'text'; });
|
|
1290
|
+
var result = undefined;
|
|
1291
|
+
if (textParts.length > 0) {
|
|
1292
|
+
try { result = JSON.parse(textParts[0].text); } catch(e) { result = textParts[0].text; }
|
|
1293
|
+
}
|
|
1294
|
+
if (mcpData.result.structuredContent) result = mcpData.result.structuredContent;
|
|
1295
|
+
if (result !== undefined) {
|
|
1296
|
+
iframe.contentWindow.postMessage({
|
|
1297
|
+
jsonrpc: '2.0',
|
|
1298
|
+
method: 'ui/notifications/tool-result',
|
|
1299
|
+
params: { result: result },
|
|
1300
|
+
}, '*');
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
} catch (err) {
|
|
1304
|
+
console.warn('[PWA] Auto-invoke failed:', err.message);
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// --- Service Worker Registration ---
|
|
1310
|
+
if ('serviceWorker' in navigator) {
|
|
1311
|
+
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
|
1312
|
+
.then(reg => console.log('[PWA] SW registered:', reg.scope))
|
|
1313
|
+
.catch(err => console.warn('[PWA] SW registration failed:', err));
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// --- Real-time SSE subscription for cross-client sync ---
|
|
1317
|
+
async function connectSSE() {
|
|
1318
|
+
try {
|
|
1319
|
+
// Step 1: Initialize MCP session as beam client to get session ID
|
|
1320
|
+
var initRes = await fetch('/mcp', {
|
|
1321
|
+
method: 'POST',
|
|
1322
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
1323
|
+
signal: AbortSignal.timeout(10000),
|
|
1324
|
+
body: JSON.stringify({
|
|
1325
|
+
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
1326
|
+
params: {
|
|
1327
|
+
protocolVersion: '2025-03-26',
|
|
1328
|
+
clientInfo: { name: 'beam', version: '1.0.0' },
|
|
1329
|
+
capabilities: {}
|
|
1330
|
+
}
|
|
1331
|
+
})
|
|
1332
|
+
});
|
|
1333
|
+
var sessionId = initRes.headers.get('mcp-session-id');
|
|
1334
|
+
if (!sessionId) return;
|
|
1335
|
+
mcpSessionId = sessionId;
|
|
1336
|
+
|
|
1337
|
+
// Step 2: Open SSE on the same session
|
|
1338
|
+
var sseUrl = '/mcp?sessionId=' + encodeURIComponent(sessionId);
|
|
1339
|
+
var es = new EventSource(sseUrl);
|
|
1340
|
+
es.onmessage = function(event) {
|
|
1341
|
+
try {
|
|
1342
|
+
var msg = JSON.parse(event.data);
|
|
1343
|
+
if (msg.type === 'keepalive') return;
|
|
1344
|
+
var iframe = document.querySelector('iframe');
|
|
1345
|
+
if (!iframe || !iframe.contentWindow) return;
|
|
1346
|
+
|
|
1347
|
+
// Handle @stateful state-changed events — re-invoke main() to refresh UI
|
|
1348
|
+
if (msg.method === 'state-changed' && msg.params?.photon === PHOTON) {
|
|
1349
|
+
// Re-fetch data by calling main() and delivering result to iframe
|
|
1350
|
+
var refreshHeaders = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
|
1351
|
+
if (mcpSessionId) refreshHeaders['Mcp-Session-Id'] = mcpSessionId;
|
|
1352
|
+
fetch('/mcp', {
|
|
1353
|
+
method: 'POST',
|
|
1354
|
+
headers: refreshHeaders,
|
|
1355
|
+
body: JSON.stringify({
|
|
1356
|
+
jsonrpc: '2.0', id: mcpCallId++,
|
|
1357
|
+
method: 'tools/call',
|
|
1358
|
+
params: { name: PHOTON + '/' + bridgeMethod, arguments: {} },
|
|
1359
|
+
}),
|
|
1360
|
+
signal: AbortSignal.timeout(15000),
|
|
1361
|
+
}).then(function(r) { return r.json(); }).then(function(mcpData) {
|
|
1362
|
+
if (!mcpData.error && mcpData.result) {
|
|
1363
|
+
var content = mcpData.result.content || [];
|
|
1364
|
+
var textParts = content.filter(function(c) { return c.type === 'text'; });
|
|
1365
|
+
var result = undefined;
|
|
1366
|
+
if (textParts.length > 0) {
|
|
1367
|
+
try { result = JSON.parse(textParts[0].text); } catch(e) { result = textParts[0].text; }
|
|
1368
|
+
}
|
|
1369
|
+
if (mcpData.result.structuredContent) result = mcpData.result.structuredContent;
|
|
1370
|
+
if (result !== undefined) {
|
|
1371
|
+
iframe.contentWindow.postMessage({
|
|
1372
|
+
jsonrpc: '2.0',
|
|
1373
|
+
method: 'ui/notifications/tool-result',
|
|
1374
|
+
params: { result: result },
|
|
1375
|
+
}, '*');
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}).catch(function() {});
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Forward other events to iframe
|
|
1383
|
+
if (msg.method === 'photon/board-update') {
|
|
1384
|
+
iframe.contentWindow.postMessage({
|
|
1385
|
+
jsonrpc: '2.0',
|
|
1386
|
+
method: 'photon/notifications/emit',
|
|
1387
|
+
params: { emit: 'board-update', ...msg.params },
|
|
1388
|
+
}, '*');
|
|
1389
|
+
} else if (msg.method === 'photon/channel-event') {
|
|
1390
|
+
iframe.contentWindow.postMessage({
|
|
1391
|
+
jsonrpc: '2.0',
|
|
1392
|
+
method: 'photon/notifications/emit',
|
|
1393
|
+
params: msg.params,
|
|
1394
|
+
}, '*');
|
|
1395
|
+
}
|
|
1396
|
+
} catch (e) {}
|
|
1397
|
+
};
|
|
1398
|
+
es.onerror = function() {
|
|
1399
|
+
es.close();
|
|
1400
|
+
setTimeout(connectSSE, 5000);
|
|
1401
|
+
};
|
|
1402
|
+
} catch (e) {
|
|
1403
|
+
setTimeout(connectSSE, 5000);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// Start the diagnostics-first loading flow
|
|
1408
|
+
checkAndLoad();
|
|
1409
|
+
</script>
|
|
1410
|
+
</body>
|
|
1411
|
+
</html>`;
|
|
1412
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
1413
|
+
res.end(html);
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
553
1416
|
// Default route: Serve Lit App
|
|
554
1417
|
if (url.pathname === '/' || !url.pathname.startsWith('/api')) {
|
|
555
1418
|
try {
|
|
556
1419
|
const indexPath = path.join(__dirname, 'frontend/index.html');
|
|
557
|
-
|
|
1420
|
+
let content = await fs.readFile(indexPath, 'utf-8');
|
|
1421
|
+
// Inject shell integration flag so frontend can strip CLI prefix
|
|
1422
|
+
if (_shellIntegrationInstalled) {
|
|
1423
|
+
content = content.replace('</head>', '<script>window.__PHOTON_SHELL_INIT=true</script></head>');
|
|
1424
|
+
}
|
|
558
1425
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
559
1426
|
res.end(content);
|
|
560
1427
|
}
|
|
@@ -644,20 +1511,13 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
644
1511
|
// Determine which photon a file change belongs to
|
|
645
1512
|
const getPhotonForPath = (changedPath) => {
|
|
646
1513
|
const relativePath = path.relative(workingDir, changedPath);
|
|
647
|
-
|
|
648
|
-
//
|
|
649
|
-
|
|
1514
|
+
// Only react to top-level .photon.ts file changes.
|
|
1515
|
+
// Subfolders under workingDir (~/.photon/) are runtime data (auth, media, state),
|
|
1516
|
+
// NOT source assets. Asset folders live at the symlink target and are watched
|
|
1517
|
+
// separately by setupSymlinkWatcher.
|
|
1518
|
+
if (relativePath.endsWith('.photon.ts') && !relativePath.includes(path.sep)) {
|
|
650
1519
|
return path.basename(relativePath, '.photon.ts');
|
|
651
1520
|
}
|
|
652
|
-
// Asset folder change - first segment is the photon name
|
|
653
|
-
if (parts.length > 1) {
|
|
654
|
-
const folderName = parts[0];
|
|
655
|
-
// Check if corresponding .photon.ts exists
|
|
656
|
-
const photon = photons.find((p) => p.name === folderName);
|
|
657
|
-
if (photon) {
|
|
658
|
-
return folderName;
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
1521
|
return null;
|
|
662
1522
|
};
|
|
663
1523
|
// Handle file change with debounce
|
|
@@ -802,8 +1662,17 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
802
1662
|
// Re-extract schema - use extractAllFromSource to get both tools and templates
|
|
803
1663
|
const extractor = new SchemaExtractor();
|
|
804
1664
|
const reloadSource = await fs.readFile(photonPath, 'utf-8');
|
|
805
|
-
const
|
|
1665
|
+
const reloadMetadata = extractor.extractAllFromSource(reloadSource);
|
|
1666
|
+
const schemas = reloadMetadata.tools;
|
|
1667
|
+
const templates = reloadMetadata.templates;
|
|
806
1668
|
mcp.schemas = schemas; // Store schemas for result rendering
|
|
1669
|
+
// Update notification subscriptions for reloaded photon
|
|
1670
|
+
if (reloadMetadata.notificationSubscriptions?.watchFor) {
|
|
1671
|
+
photonNotificationSubscriptions.set(photonName, reloadMetadata.notificationSubscriptions.watchFor);
|
|
1672
|
+
}
|
|
1673
|
+
else {
|
|
1674
|
+
photonNotificationSubscriptions.delete(photonName);
|
|
1675
|
+
}
|
|
807
1676
|
const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
|
|
808
1677
|
const uiAssets = mcp.assets?.ui || [];
|
|
809
1678
|
const methods = schemas
|
|
@@ -821,8 +1690,32 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
821
1690
|
buttonLabel: schema.buttonLabel,
|
|
822
1691
|
icon: schema.icon,
|
|
823
1692
|
linkedUi: linkedAsset?.id,
|
|
1693
|
+
// MCP standard annotations
|
|
1694
|
+
...(schema.title ? { title: schema.title } : {}),
|
|
1695
|
+
...(schema.readOnlyHint ? { readOnlyHint: true } : {}),
|
|
1696
|
+
...(schema.destructiveHint ? { destructiveHint: true } : {}),
|
|
1697
|
+
...(schema.idempotentHint ? { idempotentHint: true } : {}),
|
|
1698
|
+
...(schema.openWorldHint !== undefined
|
|
1699
|
+
? { openWorldHint: schema.openWorldHint }
|
|
1700
|
+
: {}),
|
|
1701
|
+
...(schema.audience ? { audience: schema.audience } : {}),
|
|
1702
|
+
...(schema.contentPriority !== undefined
|
|
1703
|
+
? { contentPriority: schema.contentPriority }
|
|
1704
|
+
: {}),
|
|
1705
|
+
...(schema.outputSchema ? { outputSchema: schema.outputSchema } : {}),
|
|
824
1706
|
};
|
|
825
1707
|
});
|
|
1708
|
+
// Resolve icon images for hot-reloaded methods
|
|
1709
|
+
for (const schema of schemas) {
|
|
1710
|
+
if (!schema.iconImages)
|
|
1711
|
+
continue;
|
|
1712
|
+
const method = methods.find((m) => m.name === schema.name);
|
|
1713
|
+
if (!method)
|
|
1714
|
+
continue;
|
|
1715
|
+
const resolved = await resolveIconImages(schema.iconImages, photonPath);
|
|
1716
|
+
if (resolved)
|
|
1717
|
+
method.icons = resolved;
|
|
1718
|
+
}
|
|
826
1719
|
// Add templates as methods
|
|
827
1720
|
templates.forEach((template) => {
|
|
828
1721
|
if (!lifecycleMethods.includes(template.name)) {
|
|
@@ -1141,36 +2034,103 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
1141
2034
|
await ensureDaemon();
|
|
1142
2035
|
for (const photon of statefulPhotons) {
|
|
1143
2036
|
const photonName = photon.name;
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
2037
|
+
// Subscribe to 'default' instance + any other instances that appear
|
|
2038
|
+
const instanceNames = ['default'];
|
|
2039
|
+
for (const instanceName of instanceNames) {
|
|
2040
|
+
// Channel is now instance-specific: photon:instance:state-changed
|
|
2041
|
+
const channel = `${photonName}:${instanceName}:state-changed`;
|
|
2042
|
+
subscribeChannel(photonName, channel, (message) => {
|
|
2043
|
+
// Only broadcast if instance matches (prevents cross-instance leakage)
|
|
2044
|
+
if (message?.instance === instanceName || !message?.instance) {
|
|
2045
|
+
// Minimal transmission: include instance and patches for global sync
|
|
2046
|
+
broadcastNotification('state-changed', {
|
|
2047
|
+
photon: photonName,
|
|
2048
|
+
instance: instanceName,
|
|
2049
|
+
// JSON Patch array for client-side state sync
|
|
2050
|
+
patches: message?.patches,
|
|
2051
|
+
// Keep legacy fields for backward compatibility
|
|
2052
|
+
method: message?.method,
|
|
2053
|
+
params: message?.params,
|
|
2054
|
+
data: message?.data,
|
|
2055
|
+
// Optional fields for undo/redo support
|
|
2056
|
+
...(message?.patch && { patch: message.patch }),
|
|
2057
|
+
...(message?.inversePatch && { inversePatch: message.inversePatch }),
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
}, {
|
|
2061
|
+
reconnect: true,
|
|
2062
|
+
workingDir,
|
|
2063
|
+
onReconnect: () => logger.debug(`📡 Reconnected ${channel} subscription`),
|
|
2064
|
+
onRefreshNeeded: () => {
|
|
2065
|
+
logger.info(`📡 Refresh needed for ${channel} (events lost during daemon restart)`);
|
|
2066
|
+
// Broadcast minimal refresh signal to all clients
|
|
2067
|
+
broadcastNotification('state-changed', {
|
|
2068
|
+
photon: photonName,
|
|
2069
|
+
instance: instanceName,
|
|
2070
|
+
method: '_refresh',
|
|
2071
|
+
patches: undefined, // No patches, signal full refresh needed
|
|
2072
|
+
});
|
|
2073
|
+
},
|
|
2074
|
+
})
|
|
2075
|
+
.then(() => {
|
|
2076
|
+
logger.info(`📡 Subscribed to ${channel} for cross-client sync`);
|
|
2077
|
+
})
|
|
2078
|
+
.catch((err) => {
|
|
2079
|
+
logger.warn(`Failed to subscribe to ${channel}: ${getErrorMessage(err)}`);
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
catch (err) {
|
|
2085
|
+
logger.warn(`Failed to start daemon for stateful photons: ${getErrorMessage(err)}`);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
2089
|
+
// NOTIFICATION SUBSCRIPTIONS - Subscribe to all photon notification channels
|
|
2090
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
2091
|
+
// Unlike state-changed (active photon only), notifications are always subscribed
|
|
2092
|
+
// but filtered server-side based on each photon's @notify-on declarations
|
|
2093
|
+
if (statefulPhotons.length > 0) {
|
|
2094
|
+
try {
|
|
2095
|
+
for (const photon of statefulPhotons) {
|
|
2096
|
+
const photonName = photon.name;
|
|
2097
|
+
const instanceName = 'default'; // TODO: support multi-instance notifications
|
|
2098
|
+
// Subscribe to notifications channel (always-on, not just active)
|
|
2099
|
+
const notificationChannel = `${photonName}:${instanceName}:notifications`;
|
|
2100
|
+
// Get this photon's notification subscriptions from @notify-on tags
|
|
2101
|
+
const watchFor = photonNotificationSubscriptions.get(photonName);
|
|
2102
|
+
subscribeChannel(photonName, notificationChannel, (message) => {
|
|
2103
|
+
// Check if this photon cares about this notification type
|
|
2104
|
+
if (!watchFor || !watchFor.includes(message?.type)) {
|
|
2105
|
+
logger.debug(`📡 Notification filtered: ${photonName} doesn't care about "${message?.type}"`);
|
|
2106
|
+
return; // Don't broadcast notifications this photon doesn't care about
|
|
2107
|
+
}
|
|
2108
|
+
// Only broadcast relevant notifications
|
|
2109
|
+
logger.debug(`📡 Broadcasting notification: ${photonName} [${message?.type}]`);
|
|
2110
|
+
broadcastNotification('photon/notification', {
|
|
1147
2111
|
photon: photonName,
|
|
1148
|
-
|
|
1149
|
-
|
|
2112
|
+
type: message?.type,
|
|
2113
|
+
priority: message?.priority || 'info',
|
|
2114
|
+
message: message?.message,
|
|
2115
|
+
...(message?.action && { action: message.action }),
|
|
2116
|
+
...(message?.sound && { sound: message.sound }),
|
|
2117
|
+
...(message?.data && { data: message.data }),
|
|
1150
2118
|
});
|
|
1151
2119
|
}, {
|
|
1152
2120
|
reconnect: true,
|
|
1153
2121
|
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
|
-
},
|
|
2122
|
+
onReconnect: () => logger.debug(`📡 Reconnected ${notificationChannel} subscription`),
|
|
1163
2123
|
})
|
|
1164
2124
|
.then(() => {
|
|
1165
|
-
logger.info(`📡 Subscribed to ${
|
|
2125
|
+
logger.info(`📡 Subscribed to ${notificationChannel} for notifications${watchFor ? ` (watching: ${watchFor.join(', ')})` : ''}`);
|
|
1166
2126
|
})
|
|
1167
2127
|
.catch((err) => {
|
|
1168
|
-
logger.warn(`Failed to subscribe to ${
|
|
2128
|
+
logger.warn(`Failed to subscribe to ${notificationChannel}: ${getErrorMessage(err)}`);
|
|
1169
2129
|
});
|
|
1170
2130
|
}
|
|
1171
2131
|
}
|
|
1172
2132
|
catch (err) {
|
|
1173
|
-
logger.warn(`Failed to
|
|
2133
|
+
logger.warn(`Failed to set up notification subscriptions: ${getErrorMessage(err)}`);
|
|
1174
2134
|
}
|
|
1175
2135
|
}
|
|
1176
2136
|
// Set up file watchers for symlinked and bundled photon assets (now that photons are loaded)
|