brainstorm-companion 1.0.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 +66 -0
- package/bin/brainstorm.js +2 -0
- package/package.json +39 -0
- package/skill/SKILL.md +71 -0
- package/skill/visual-companion.md +331 -0
- package/src/cli.js +441 -0
- package/src/content-detect.js +52 -0
- package/src/mcp.js +331 -0
- package/src/server.js +624 -0
- package/src/session.js +215 -0
- package/src/templates/comparison-helper.js +277 -0
- package/src/templates/comparison.html +277 -0
- package/src/templates/frame.html +283 -0
- package/src/templates/helper.js +78 -0
- package/src/templates/waiting.html +8 -0
- package/src/ws-protocol.js +69 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const http = require('node:http');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const { OPCODES, computeAcceptKey, encodeFrame, decodeFrame } = require('./ws-protocol');
|
|
8
|
+
const { detectLibraries, buildInjections } = require('./content-detect');
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Constants
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
15
|
+
const OWNER_CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
|
|
16
|
+
|
|
17
|
+
const MIME_TYPES = {
|
|
18
|
+
'.html': 'text/html',
|
|
19
|
+
'.css': 'text/css',
|
|
20
|
+
'.js': 'application/javascript',
|
|
21
|
+
'.json': 'application/json',
|
|
22
|
+
'.png': 'image/png',
|
|
23
|
+
'.jpg': 'image/jpeg',
|
|
24
|
+
'.jpeg': 'image/jpeg',
|
|
25
|
+
'.gif': 'image/gif',
|
|
26
|
+
'.svg': 'image/svg+xml',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Templates (loaded once at module load time)
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const frameTemplate = fs.readFileSync(path.join(__dirname, 'templates', 'frame.html'), 'utf-8');
|
|
34
|
+
const helperScript = fs.readFileSync(path.join(__dirname, 'templates', 'helper.js'), 'utf-8');
|
|
35
|
+
const waitingPage = fs.readFileSync(path.join(__dirname, 'templates', 'waiting.html'), 'utf-8');
|
|
36
|
+
const helperInjection = '<script>\n' + helperScript + '\n</script>';
|
|
37
|
+
|
|
38
|
+
const comparisonTemplate = fs.readFileSync(path.join(__dirname, 'templates', 'comparison.html'), 'utf-8');
|
|
39
|
+
const comparisonHelperScript = fs.readFileSync(path.join(__dirname, 'templates', 'comparison-helper.js'), 'utf-8');
|
|
40
|
+
const comparisonHelperInjection = '<script>\n' + comparisonHelperScript + '\n</script>';
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Helper functions
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function isFullDocument(html) {
|
|
47
|
+
const trimmed = html.trimStart().toLowerCase();
|
|
48
|
+
return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function wrapInFrame(content) {
|
|
52
|
+
return frameTemplate.replace('<!-- CONTENT -->', content);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getNewestScreen(screenDir) {
|
|
56
|
+
let files;
|
|
57
|
+
try {
|
|
58
|
+
files = fs.readdirSync(screenDir);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const htmlFiles = files.filter(f => f.endsWith('.html') && !f.startsWith('.'));
|
|
64
|
+
if (htmlFiles.length === 0) return null;
|
|
65
|
+
|
|
66
|
+
let newestFile = null;
|
|
67
|
+
let newestMtime = -1;
|
|
68
|
+
|
|
69
|
+
for (const file of htmlFiles) {
|
|
70
|
+
const filePath = path.join(screenDir, file);
|
|
71
|
+
try {
|
|
72
|
+
const stat = fs.statSync(filePath);
|
|
73
|
+
if (stat.mtimeMs > newestMtime) {
|
|
74
|
+
newestMtime = stat.mtimeMs;
|
|
75
|
+
newestFile = filePath;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// skip unreadable files
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return newestFile;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function injectHelper(html) {
|
|
86
|
+
if (html.includes('</body>')) {
|
|
87
|
+
return html.replace('</body>', helperInjection + '\n</body>');
|
|
88
|
+
}
|
|
89
|
+
return html + helperInjection;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getActiveSlots(screenDir) {
|
|
93
|
+
try {
|
|
94
|
+
return fs.readdirSync(screenDir, { withFileTypes: true })
|
|
95
|
+
.filter(e => e.isDirectory() && e.name.startsWith('slot-'))
|
|
96
|
+
.map(e => e.name.replace(/^slot-/, ''))
|
|
97
|
+
.filter(id => fs.existsSync(path.join(screenDir, `slot-${id}`, 'current.html')));
|
|
98
|
+
} catch { return []; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// startServer — returns synchronously with { server, url, port, broadcast, shutdown }
|
|
103
|
+
// url and port are populated once 'listening' fires (server.address()).
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function startServer(config = {}) {
|
|
107
|
+
const {
|
|
108
|
+
screenDir,
|
|
109
|
+
host = '127.0.0.1',
|
|
110
|
+
port: requestedPort = 0,
|
|
111
|
+
ownerPid = null,
|
|
112
|
+
logFn = console.log,
|
|
113
|
+
} = config;
|
|
114
|
+
|
|
115
|
+
if (!screenDir) throw new Error('startServer: screenDir is required');
|
|
116
|
+
|
|
117
|
+
// Ensure screenDir exists
|
|
118
|
+
fs.mkdirSync(screenDir, { recursive: true });
|
|
119
|
+
|
|
120
|
+
// Per-instance state
|
|
121
|
+
const clients = new Set();
|
|
122
|
+
const debounceTimers = new Map();
|
|
123
|
+
let lastActivity = Date.now(); // eslint-disable-line no-unused-vars
|
|
124
|
+
let idleTimer = null;
|
|
125
|
+
let ownerCheckTimer = null;
|
|
126
|
+
let watcher = null;
|
|
127
|
+
let isShuttingDown = false;
|
|
128
|
+
|
|
129
|
+
// Mutable result fields populated on 'listening'
|
|
130
|
+
const result = { server: null, url: null, port: null, broadcast, shutdown };
|
|
131
|
+
|
|
132
|
+
// -------------------------------------------------------------------------
|
|
133
|
+
// Activity tracking
|
|
134
|
+
// -------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function touchActivity() {
|
|
137
|
+
lastActivity = Date.now();
|
|
138
|
+
resetIdleTimer();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function resetIdleTimer() {
|
|
142
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
143
|
+
idleTimer = setTimeout(() => {
|
|
144
|
+
shutdown('idle-timeout');
|
|
145
|
+
}, IDLE_TIMEOUT_MS);
|
|
146
|
+
// Don't block the event loop
|
|
147
|
+
if (idleTimer.unref) idleTimer.unref();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// -------------------------------------------------------------------------
|
|
151
|
+
// Broadcast
|
|
152
|
+
// -------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
function broadcast(message) {
|
|
155
|
+
const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(message)));
|
|
156
|
+
for (const socket of clients) {
|
|
157
|
+
try {
|
|
158
|
+
socket.write(frame);
|
|
159
|
+
} catch {
|
|
160
|
+
clients.delete(socket);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// -------------------------------------------------------------------------
|
|
166
|
+
// Message handler (WebSocket TEXT messages)
|
|
167
|
+
// -------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
function handleMessage(raw) {
|
|
170
|
+
let event;
|
|
171
|
+
try {
|
|
172
|
+
event = JSON.parse(raw);
|
|
173
|
+
} catch {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
logFn(JSON.stringify({ type: 'ws-event', event }));
|
|
178
|
+
|
|
179
|
+
// Append to .events JSONL file if the event has a choice field
|
|
180
|
+
if (event.choice !== undefined) {
|
|
181
|
+
const eventsFile = path.join(screenDir, '.events');
|
|
182
|
+
try {
|
|
183
|
+
fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n', 'utf-8');
|
|
184
|
+
} catch {
|
|
185
|
+
// ignore write errors
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// -------------------------------------------------------------------------
|
|
191
|
+
// HTTP handler
|
|
192
|
+
// -------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
function handleRequest(req, res) {
|
|
195
|
+
touchActivity();
|
|
196
|
+
|
|
197
|
+
// Strip query string for routing
|
|
198
|
+
const urlPath = req.url.split('?')[0];
|
|
199
|
+
|
|
200
|
+
if (req.method === 'GET' && urlPath === '/') {
|
|
201
|
+
const slots = getActiveSlots(screenDir);
|
|
202
|
+
if (slots.length > 0) {
|
|
203
|
+
// Comparison mode
|
|
204
|
+
let html = comparisonTemplate;
|
|
205
|
+
if (html.includes('</body>')) {
|
|
206
|
+
html = html.replace('</body>', comparisonHelperInjection + '\n</body>');
|
|
207
|
+
} else {
|
|
208
|
+
html = html + comparisonHelperInjection;
|
|
209
|
+
}
|
|
210
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
211
|
+
res.end(html);
|
|
212
|
+
} else {
|
|
213
|
+
// Single screen mode
|
|
214
|
+
const screenFile = getNewestScreen(screenDir);
|
|
215
|
+
let html;
|
|
216
|
+
if (screenFile) {
|
|
217
|
+
const raw = fs.readFileSync(screenFile, 'utf-8');
|
|
218
|
+
html = isFullDocument(raw) ? raw : wrapInFrame(raw);
|
|
219
|
+
} else {
|
|
220
|
+
html = waitingPage;
|
|
221
|
+
}
|
|
222
|
+
const needs = detectLibraries(html);
|
|
223
|
+
const cdnTags = buildInjections(needs);
|
|
224
|
+
if (cdnTags && html.includes('</head>')) {
|
|
225
|
+
html = html.replace('</head>', cdnTags + '\n</head>');
|
|
226
|
+
}
|
|
227
|
+
html = injectHelper(html);
|
|
228
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
229
|
+
res.end(html);
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (req.method === 'GET' && urlPath.match(/^\/slot\/[a-z0-9]$/)) {
|
|
235
|
+
const slotId = urlPath.split('/')[2];
|
|
236
|
+
const slotFile = path.join(screenDir, `slot-${slotId}`, 'current.html');
|
|
237
|
+
if (!fs.existsSync(slotFile)) {
|
|
238
|
+
res.writeHead(404);
|
|
239
|
+
res.end('Slot not found');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const raw = fs.readFileSync(slotFile, 'utf-8');
|
|
243
|
+
let html = isFullDocument(raw) ? raw : wrapInFrame(raw);
|
|
244
|
+
const slotNeeds = detectLibraries(html);
|
|
245
|
+
const slotCdnTags = buildInjections(slotNeeds);
|
|
246
|
+
if (slotCdnTags && html.includes('</head>')) {
|
|
247
|
+
html = html.replace('</head>', slotCdnTags + '\n</head>');
|
|
248
|
+
}
|
|
249
|
+
html = injectHelper(html);
|
|
250
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
251
|
+
res.end(html);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (req.method === 'GET' && urlPath === '/api/status') {
|
|
256
|
+
const slots = getActiveSlots(screenDir);
|
|
257
|
+
const slotInfo = slots.map(id => {
|
|
258
|
+
const labelPath = path.join(screenDir, `slot-${id}`, '.label');
|
|
259
|
+
let label = null;
|
|
260
|
+
try { label = fs.readFileSync(labelPath, 'utf8').trim(); } catch {}
|
|
261
|
+
return { id, label };
|
|
262
|
+
});
|
|
263
|
+
const eventsFile = path.join(screenDir, '.events');
|
|
264
|
+
let eventCount = 0;
|
|
265
|
+
try {
|
|
266
|
+
const raw = fs.readFileSync(eventsFile, 'utf8');
|
|
267
|
+
eventCount = raw.split('\n').filter(l => l.trim()).length;
|
|
268
|
+
} catch {}
|
|
269
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
270
|
+
res.end(JSON.stringify({
|
|
271
|
+
mode: slots.length > 0 ? 'comparison' : 'single',
|
|
272
|
+
slots: slotInfo,
|
|
273
|
+
eventCount,
|
|
274
|
+
}));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (req.method === 'GET' && req.url.startsWith('/files/')) {
|
|
279
|
+
const fileName = req.url.slice(7).split('?')[0];
|
|
280
|
+
const filePath = path.join(screenDir, path.basename(fileName));
|
|
281
|
+
if (!fs.existsSync(filePath)) {
|
|
282
|
+
res.writeHead(404);
|
|
283
|
+
res.end('Not found');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
287
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
288
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
289
|
+
res.end(fs.readFileSync(filePath));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
res.writeHead(404);
|
|
294
|
+
res.end('Not found');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// -------------------------------------------------------------------------
|
|
298
|
+
// WebSocket upgrade handler
|
|
299
|
+
// -------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
function handleUpgrade(req, socket) {
|
|
302
|
+
const key = req.headers['sec-websocket-key'];
|
|
303
|
+
if (!key) {
|
|
304
|
+
socket.destroy();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const accept = computeAcceptKey(key);
|
|
309
|
+
socket.write(
|
|
310
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
311
|
+
'Upgrade: websocket\r\n' +
|
|
312
|
+
'Connection: Upgrade\r\n' +
|
|
313
|
+
'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n'
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
let buffer = Buffer.alloc(0);
|
|
317
|
+
clients.add(socket);
|
|
318
|
+
|
|
319
|
+
socket.on('data', (chunk) => {
|
|
320
|
+
touchActivity();
|
|
321
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
322
|
+
|
|
323
|
+
while (buffer.length > 0) {
|
|
324
|
+
let result;
|
|
325
|
+
try {
|
|
326
|
+
result = decodeFrame(buffer);
|
|
327
|
+
} catch {
|
|
328
|
+
socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
|
|
329
|
+
clients.delete(socket);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!result) break;
|
|
334
|
+
buffer = buffer.slice(result.bytesConsumed);
|
|
335
|
+
|
|
336
|
+
switch (result.opcode) {
|
|
337
|
+
case OPCODES.TEXT:
|
|
338
|
+
handleMessage(result.payload.toString());
|
|
339
|
+
break;
|
|
340
|
+
case OPCODES.CLOSE:
|
|
341
|
+
socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
|
|
342
|
+
clients.delete(socket);
|
|
343
|
+
return;
|
|
344
|
+
case OPCODES.PING:
|
|
345
|
+
socket.write(encodeFrame(OPCODES.PONG, result.payload));
|
|
346
|
+
break;
|
|
347
|
+
case OPCODES.PONG:
|
|
348
|
+
break;
|
|
349
|
+
default: {
|
|
350
|
+
const closeBuf = Buffer.alloc(2);
|
|
351
|
+
closeBuf.writeUInt16BE(1003);
|
|
352
|
+
socket.end(encodeFrame(OPCODES.CLOSE, closeBuf));
|
|
353
|
+
clients.delete(socket);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
socket.on('close', () => clients.delete(socket));
|
|
361
|
+
socket.on('error', () => clients.delete(socket));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// -------------------------------------------------------------------------
|
|
365
|
+
// Shutdown
|
|
366
|
+
// -------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
function shutdown(reason) {
|
|
369
|
+
if (isShuttingDown) return;
|
|
370
|
+
isShuttingDown = true;
|
|
371
|
+
|
|
372
|
+
logFn(JSON.stringify({ type: 'server-shutdown', reason }));
|
|
373
|
+
|
|
374
|
+
// Remove .server-info
|
|
375
|
+
const serverInfoPath = path.join(screenDir, '.server-info');
|
|
376
|
+
try {
|
|
377
|
+
if (fs.existsSync(serverInfoPath)) fs.unlinkSync(serverInfoPath);
|
|
378
|
+
} catch {
|
|
379
|
+
// ignore
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Write .server-stopped
|
|
383
|
+
const serverStoppedPath = path.join(screenDir, '.server-stopped');
|
|
384
|
+
try {
|
|
385
|
+
fs.writeFileSync(
|
|
386
|
+
serverStoppedPath,
|
|
387
|
+
JSON.stringify({ type: 'server-stopped', reason, stoppedAt: Date.now(), pid: process.pid }),
|
|
388
|
+
'utf-8'
|
|
389
|
+
);
|
|
390
|
+
} catch {
|
|
391
|
+
// ignore
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Clear timers
|
|
395
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
396
|
+
if (ownerCheckTimer) clearInterval(ownerCheckTimer);
|
|
397
|
+
if (slotPollTimer) clearInterval(slotPollTimer);
|
|
398
|
+
|
|
399
|
+
// Close watcher
|
|
400
|
+
if (watcher) {
|
|
401
|
+
try { watcher.close(); } catch { /* ignore */ }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Close slot watchers
|
|
405
|
+
for (const sw of slotWatchers.values()) {
|
|
406
|
+
try { sw.close(); } catch { /* ignore */ }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Close all client sockets
|
|
410
|
+
for (const socket of clients) {
|
|
411
|
+
try {
|
|
412
|
+
socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
|
|
413
|
+
} catch { /* ignore */ }
|
|
414
|
+
}
|
|
415
|
+
clients.clear();
|
|
416
|
+
|
|
417
|
+
// Close HTTP server (do not call process.exit — caller decides)
|
|
418
|
+
server.close();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// -------------------------------------------------------------------------
|
|
422
|
+
// File watcher
|
|
423
|
+
// -------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
const knownFiles = new Set(
|
|
426
|
+
fs.readdirSync(screenDir)
|
|
427
|
+
.filter(f => f.endsWith('.html') && !f.startsWith('.'))
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
// Track per-slot watchers so we don't double-watch
|
|
431
|
+
const slotWatchers = new Map();
|
|
432
|
+
|
|
433
|
+
function watchSlotDir(slotId) {
|
|
434
|
+
if (slotWatchers.has(slotId)) return;
|
|
435
|
+
const slotDir = path.join(screenDir, `slot-${slotId}`);
|
|
436
|
+
try {
|
|
437
|
+
const sw = fs.watch(slotDir, (eventType, filename) => {
|
|
438
|
+
if (!filename) return;
|
|
439
|
+
const key = `slot-${slotId}-${filename}`;
|
|
440
|
+
if (debounceTimers.has(key)) clearTimeout(debounceTimers.get(key));
|
|
441
|
+
debounceTimers.set(key, setTimeout(() => {
|
|
442
|
+
debounceTimers.delete(key);
|
|
443
|
+
touchActivity();
|
|
444
|
+
logFn(JSON.stringify({ type: 'slot-updated', slot: slotId, file: filename }));
|
|
445
|
+
broadcast({ type: 'slot-content', slot: slotId });
|
|
446
|
+
}, 100));
|
|
447
|
+
});
|
|
448
|
+
sw.on('error', () => { slotWatchers.delete(slotId); });
|
|
449
|
+
slotWatchers.set(slotId, sw);
|
|
450
|
+
logFn(JSON.stringify({ type: 'slot-watcher-started', slot: slotId }));
|
|
451
|
+
} catch {
|
|
452
|
+
// slot dir may not exist yet; polling will catch it
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Start watchers for any slots that already exist
|
|
457
|
+
getActiveSlots(screenDir).forEach(id => watchSlotDir(id));
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
watcher = fs.watch(screenDir, (eventType, filename) => {
|
|
461
|
+
if (!filename) return;
|
|
462
|
+
|
|
463
|
+
// Detect new slot-* directories
|
|
464
|
+
if (filename.startsWith('slot-')) {
|
|
465
|
+
const slotId = filename.replace(/^slot-/, '');
|
|
466
|
+
if (!slotWatchers.has(slotId)) {
|
|
467
|
+
// Give it a moment to settle before starting the watcher
|
|
468
|
+
setTimeout(() => {
|
|
469
|
+
const slotDir = path.join(screenDir, filename);
|
|
470
|
+
if (fs.existsSync(slotDir)) {
|
|
471
|
+
watchSlotDir(slotId);
|
|
472
|
+
const activeSlots = getActiveSlots(screenDir);
|
|
473
|
+
broadcast({ type: 'slots-update', slots: activeSlots });
|
|
474
|
+
}
|
|
475
|
+
}, 200);
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (!filename.endsWith('.html') || filename.startsWith('.')) return;
|
|
481
|
+
|
|
482
|
+
if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));
|
|
483
|
+
|
|
484
|
+
debounceTimers.set(filename, setTimeout(() => {
|
|
485
|
+
debounceTimers.delete(filename);
|
|
486
|
+
const filePath = path.join(screenDir, filename);
|
|
487
|
+
if (!fs.existsSync(filePath)) return;
|
|
488
|
+
|
|
489
|
+
touchActivity();
|
|
490
|
+
|
|
491
|
+
if (!knownFiles.has(filename)) {
|
|
492
|
+
knownFiles.add(filename);
|
|
493
|
+
// Clear .events on new screen
|
|
494
|
+
const eventsFile = path.join(screenDir, '.events');
|
|
495
|
+
try {
|
|
496
|
+
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
|
|
497
|
+
} catch { /* ignore */ }
|
|
498
|
+
logFn(JSON.stringify({ type: 'screen-added', file: filePath }));
|
|
499
|
+
} else {
|
|
500
|
+
logFn(JSON.stringify({ type: 'screen-updated', file: filePath }));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
broadcast({ type: 'reload' });
|
|
504
|
+
}, 100));
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
watcher.on('error', (err) => {
|
|
508
|
+
logFn(JSON.stringify({ type: 'watcher-error', error: String(err) }));
|
|
509
|
+
});
|
|
510
|
+
} catch (err) {
|
|
511
|
+
logFn(JSON.stringify({ type: 'watcher-start-error', error: String(err) }));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Fallback: poll root directory every 2s for new slot-* dirs (macOS fs.watch quirk)
|
|
515
|
+
const knownSlotIds = new Set(getActiveSlots(screenDir));
|
|
516
|
+
const slotPollTimer = setInterval(() => {
|
|
517
|
+
const currentSlots = getActiveSlots(screenDir);
|
|
518
|
+
let changed = false;
|
|
519
|
+
currentSlots.forEach(id => {
|
|
520
|
+
if (!knownSlotIds.has(id)) {
|
|
521
|
+
knownSlotIds.add(id);
|
|
522
|
+
watchSlotDir(id);
|
|
523
|
+
changed = true;
|
|
524
|
+
logFn(JSON.stringify({ type: 'slot-detected-poll', slot: id }));
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
if (changed) {
|
|
528
|
+
broadcast({ type: 'slots-update', slots: currentSlots });
|
|
529
|
+
}
|
|
530
|
+
}, 2000);
|
|
531
|
+
if (slotPollTimer.unref) slotPollTimer.unref();
|
|
532
|
+
|
|
533
|
+
// -------------------------------------------------------------------------
|
|
534
|
+
// Owner PID monitoring
|
|
535
|
+
// -------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
if (ownerPid) {
|
|
538
|
+
ownerCheckTimer = setInterval(() => {
|
|
539
|
+
try {
|
|
540
|
+
process.kill(ownerPid, 0);
|
|
541
|
+
} catch {
|
|
542
|
+
shutdown('owner-pid-died');
|
|
543
|
+
}
|
|
544
|
+
}, OWNER_CHECK_INTERVAL_MS);
|
|
545
|
+
if (ownerCheckTimer.unref) ownerCheckTimer.unref();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// -------------------------------------------------------------------------
|
|
549
|
+
// HTTP server — start listening
|
|
550
|
+
// -------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
const server = http.createServer(handleRequest);
|
|
553
|
+
server.on('upgrade', handleUpgrade);
|
|
554
|
+
|
|
555
|
+
// Write .server-info once we know the actual port
|
|
556
|
+
server.once('listening', () => {
|
|
557
|
+
const addr = server.address();
|
|
558
|
+
const port = addr.port;
|
|
559
|
+
const url = `http://${host}:${port}`;
|
|
560
|
+
|
|
561
|
+
result.url = url;
|
|
562
|
+
result.port = port;
|
|
563
|
+
|
|
564
|
+
const serverInfo = {
|
|
565
|
+
type: 'server-started',
|
|
566
|
+
port,
|
|
567
|
+
host,
|
|
568
|
+
url,
|
|
569
|
+
screen_dir: screenDir,
|
|
570
|
+
pid: process.pid,
|
|
571
|
+
startedAt: Date.now(),
|
|
572
|
+
};
|
|
573
|
+
try {
|
|
574
|
+
fs.writeFileSync(
|
|
575
|
+
path.join(screenDir, '.server-info'),
|
|
576
|
+
JSON.stringify(serverInfo),
|
|
577
|
+
'utf-8'
|
|
578
|
+
);
|
|
579
|
+
} catch {
|
|
580
|
+
// ignore
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
logFn(JSON.stringify({ type: 'server-started', port, host, url }));
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
resetIdleTimer();
|
|
587
|
+
|
|
588
|
+
// Use port 0 when requestedPort is 0 to get a random ephemeral port;
|
|
589
|
+
// otherwise use the requested port or generate a random one in the
|
|
590
|
+
// ephemeral range (49152-65535).
|
|
591
|
+
const listenPort = requestedPort === 0
|
|
592
|
+
? 0
|
|
593
|
+
: (requestedPort || Math.floor(Math.random() * (65535 - 49152 + 1)) + 49152);
|
|
594
|
+
|
|
595
|
+
server.listen(listenPort, host);
|
|
596
|
+
|
|
597
|
+
// Assign server reference to result before returning
|
|
598
|
+
result.server = server;
|
|
599
|
+
|
|
600
|
+
return result;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// Module exports
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
module.exports = { startServer };
|
|
608
|
+
|
|
609
|
+
// Standalone entry point
|
|
610
|
+
if (require.main === module) {
|
|
611
|
+
const screenDir = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
|
|
612
|
+
const instance = startServer({
|
|
613
|
+
screenDir,
|
|
614
|
+
host: process.env.BRAINSTORM_HOST || '127.0.0.1',
|
|
615
|
+
port: process.env.BRAINSTORM_PORT ? parseInt(process.env.BRAINSTORM_PORT, 10) : 0,
|
|
616
|
+
});
|
|
617
|
+
instance.server.on('listening', () => {
|
|
618
|
+
console.log(JSON.stringify({ type: 'server-ready', url: instance.url }));
|
|
619
|
+
});
|
|
620
|
+
instance.server.on('error', (err) => {
|
|
621
|
+
console.error(JSON.stringify({ type: 'server-error', error: String(err) }));
|
|
622
|
+
process.exit(1);
|
|
623
|
+
});
|
|
624
|
+
}
|