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/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
+ }