1#!/usr/bin/env node
2/**
3 * Live variant mode server (self-contained, zero dependencies).
4 *
5 * Serves the browser script (/live.js), the detection overlay (/detect.js),
6 * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for
7 * browser→server events. Agent communicates via HTTP long-poll (/poll).
8 *
9 * Usage:
10 * node <scripts_path>/live-server.mjs # start
11 * node <scripts_path>/live-server.mjs stop # stop + remove injected live.js tag
12 * node <scripts_path>/live-server.mjs stop --keep-inject # stop only
13 * node <scripts_path>/live-server.mjs --help
14 */
15
16import http from 'node:http';
17import { randomUUID } from 'node:crypto';
18import { spawn, execFileSync } from 'node:child_process';
19import fs from 'node:fs';
20import path from 'node:path';
21import net from 'node:net';
22import { fileURLToPath } from 'node:url';
23import { parseDesignMd } from './design-parser.mjs';
24import { resolveContextDir } from './load-context.mjs';
25import { createLiveSessionStore } from './live-session-store.mjs';
26import {
27 getDesignSidecarPath,
28 getLiveAnnotationsDir,
29 readLiveServerInfo,
30 removeLiveServerInfo,
31 resolveDesignSidecarPath,
32 writeLiveServerInfo,
33} from './impeccable-paths.mjs';
34
35const __dirname = path.dirname(fileURLToPath(import.meta.url));
36// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated
37// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
38// DESIGN.json fallback for existing projects.
39const CONTEXT_DIR = resolveContextDir(process.cwd());
40const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
41const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
42
43// ---------------------------------------------------------------------------
44// Port detection
45// ---------------------------------------------------------------------------
46
47async function findOpenPort(start = 8400) {
48 return new Promise((resolve) => {
49 const srv = net.createServer();
50 srv.listen(start, '127.0.0.1', () => {
51 const port = srv.address().port;
52 srv.close(() => resolve(port));
53 });
54 srv.on('error', () => resolve(findOpenPort(start + 1)));
55 });
56}
57
58// ---------------------------------------------------------------------------
59// Session state
60// ---------------------------------------------------------------------------
61
62const state = {
63 token: null,
64 port: null,
65 sseClients: new Set(), // SSE response objects (server→browser push)
66 pendingEvents: [], // browser events waiting for agent ack ({ event, leaseUntil })
67 pendingPolls: [], // agent poll callbacks waiting for browser events
68 exitTimer: null,
69 sessionDir: null, // per-session tmp dir for annotation screenshots
70 sessionStore: null,
71 leaseTimer: null,
72};
73
74// Cap per-annotation upload size. A full 1920Ă—1080 PNG is typically <1 MB;
75// cap at 10 MB to guard against runaway writes from a misbehaving client.
76const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
77
78function enqueueEvent(event) {
79 if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
80 state.pendingEvents.push({ event, leaseUntil: 0 });
81 flushPendingPolls();
82}
83
84function restorePendingEventsFromStore() {
85 if (!state.sessionStore) return;
86 for (const snapshot of state.sessionStore.listActiveSessions()) {
87 if (snapshot.pendingEvent) enqueueEvent(snapshot.pendingEvent);
88 }
89}
90
91function findAvailablePendingEvent(now = Date.now()) {
92 return state.pendingEvents.find((entry) => !entry.leaseUntil || entry.leaseUntil <= now);
93}
94
95function leaseEvent(entry, leaseMs) {
96 if (!entry.event?.id) {
97 const idx = state.pendingEvents.indexOf(entry);
98 if (idx !== -1) state.pendingEvents.splice(idx, 1);
99 return entry.event;
100 }
101 entry.leaseUntil = Date.now() + leaseMs;
102 return entry.event;
103}
104
105function acknowledgePendingEvent(id) {
106 if (!id) return false;
107 const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
108 if (idx === -1) return false;
109 state.pendingEvents.splice(idx, 1);
110 scheduleLeaseFlush();
111 return true;
112}
113
114function scheduleLeaseFlush() {
115 if (state.leaseTimer) {
116 clearTimeout(state.leaseTimer);
117 state.leaseTimer = null;
118 }
119 if (state.pendingPolls.length === 0) return;
120 const now = Date.now();
121 const nextLeaseUntil = state.pendingEvents
122 .map((entry) => entry.leaseUntil || 0)
123 .filter((leaseUntil) => leaseUntil > now)
124 .sort((a, b) => a - b)[0];
125 if (!nextLeaseUntil) return;
126 state.leaseTimer = setTimeout(() => {
127 state.leaseTimer = null;
128 flushPendingPolls();
129 }, Math.max(0, nextLeaseUntil - now));
130}
131
132function flushPendingPolls() {
133 while (state.pendingPolls.length > 0) {
134 const entry = findAvailablePendingEvent();
135 if (!entry) {
136 scheduleLeaseFlush();
137 return;
138 }
139 const poll = state.pendingPolls.shift();
140 poll.resolve(leaseEvent(entry, poll.leaseMs));
141 }
142 scheduleLeaseFlush();
143}
144
145/** Push a message to all connected SSE clients. */
146function broadcast(msg) {
147 const data = 'data: ' + JSON.stringify(msg) + '\n\n';
148 for (const res of state.sseClients) {
149 try { res.write(data); } catch { /* client gone */ }
150 }
151}
152
153// ---------------------------------------------------------------------------
154// Load scripts
155// ---------------------------------------------------------------------------
156
157function loadBrowserScripts() {
158 // Detection script: prefer the skill-bundled detector, then fall back to
159 // source/npm package locations for local development and older installs.
160 // This one IS cached — detect.js rarely changes during a session.
161 const detectPaths = [
162 path.join(__dirname, 'detector', 'detect-antipatterns-browser.js'),
163 path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
164 path.join(__dirname, '..', '..', '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
165 path.join(process.cwd(), 'node_modules', 'impeccable', 'cli', 'engine', 'detect-antipatterns-browser.js'),
166 ];
167 let detectScript = '';
168 for (const p of detectPaths) {
169 try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
170 }
171
172 // live-browser.js: DO NOT cache. Return the path so the /live.js handler
173 // can re-read on every request. Editing the browser script during iteration
174 // should land on the next tab reload, not require a server restart.
175 const sessionPath = path.join(__dirname, 'live-browser-session.js');
176 const livePath = path.join(__dirname, 'live-browser.js');
177 for (const p of [sessionPath, livePath]) {
178 if (!fs.existsSync(p)) {
179 process.stderr.write('Error: live browser script not found at ' + p + '\n');
180 process.exit(1);
181 }
182 }
183
184 return { detectScript, sessionPath, livePath };
185}
186
187function hasProjectContext() {
188 // PRODUCT.md carries brand voice / anti-references — that's what determines
189 // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
190 // concern, surfaced by the design panel's own empty state. Legacy
191 // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs.
192 try {
193 fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
194 return true;
195 } catch { return false; }
196}
197
198function statOrNull(filePath) {
199 try { return fs.statSync(filePath); } catch { return null; }
200}
201
202// ---------------------------------------------------------------------------
203// Validation (inline — no external import needed for self-contained script)
204// ---------------------------------------------------------------------------
205
206const VISUAL_ACTIONS = [
207 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
208 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
209];
210
211// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
212// and variantIds via String(small integer). Restrict to those shapes so
213// any value that reaches a downstream child_process or DOM selector is
214// inert by construction.
215const ID_PATTERN = /^[0-9a-f]{8}$/;
216const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
217
218function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
219function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
220
221function validateEvent(msg) {
222 if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
223 switch (msg.type) {
224 case 'generate':
225 if (!isValidId(msg.id)) return 'generate: missing or malformed id';
226 if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
227 if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
228 if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
229 // Optional annotation fields (all-or-nothing: if any present, all must be well-formed).
230 if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string';
231 if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array';
232 if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
233 return null;
234 case 'accept':
235 if (!isValidId(msg.id)) return 'accept: missing or malformed id';
236 if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
237 if (msg.paramValues !== undefined) {
238 if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
239 return 'accept: paramValues must be an object';
240 }
241 }
242 return null;
243 case 'discard':
244 return isValidId(msg.id) ? null : 'discard: missing or malformed id';
245 case 'checkpoint':
246 if (!isValidId(msg.id)) return 'checkpoint: missing or malformed id';
247 if (!Number.isInteger(msg.revision) || msg.revision < 0) return 'checkpoint: revision must be a non-negative integer';
248 if (msg.paramValues !== undefined && (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues))) {
249 return 'checkpoint: paramValues must be an object';
250 }
251 return null;
252 case 'exit':
253 return null;
254 case 'prefetch':
255 if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl';
256 return null;
257 default:
258 return 'Unknown event type: ' + msg.type;
259 }
260}
261
262// ---------------------------------------------------------------------------
263// HTTP request handler
264// ---------------------------------------------------------------------------
265
266function createRequestHandler({ detectScript, sessionPath, livePath }) {
267 return (req, res) => {
268 const url = new URL(req.url, `http://localhost:${state.port}`);
269 res.setHeader('Access-Control-Allow-Origin', '*');
270 res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
271 res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
272 if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
273
274 const p = url.pathname;
275
276 // --- Scripts ---
277 if (p === '/live.js') {
278 // Re-read from disk each request so edits to live-browser.js land on
279 // the next tab reload. No-store headers prevent browser caching across
280 // sessions — during iteration, a cached old script silently breaks
281 // every subsequent session.
282 let sessionScript;
283 let liveScript;
284 try {
285 sessionScript = fs.readFileSync(sessionPath, 'utf-8');
286 liveScript = fs.readFileSync(livePath, 'utf-8');
287 } catch (err) {
288 res.writeHead(500, { 'Content-Type': 'text/plain' });
289 res.end('Error reading live browser scripts: ' + err.message);
290 return;
291 }
292 const body =
293 `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
294 `window.__IMPECCABLE_PORT__ = ${state.port};\n` +
295 sessionScript + '\n' +
296 liveScript;
297 res.writeHead(200, {
298 'Content-Type': 'application/javascript',
299 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
300 'Pragma': 'no-cache',
301 });
302 res.end(body);
303 return;
304 }
305 if (p === '/detect.js' || p === '/') {
306 if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
307 res.writeHead(200, { 'Content-Type': 'application/javascript' });
308 res.end(detectScript);
309 return;
310 }
311
312 // --- Vendored modern-screenshot (UMD build) ---
313 // Lazy-loaded by live.js when the user clicks Go; exposes
314 // window.modernScreenshot.domToBlob(...) for capture.
315 if (p === '/modern-screenshot.js') {
316 const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js');
317 try {
318 res.writeHead(200, {
319 'Content-Type': 'application/javascript',
320 'Cache-Control': 'public, max-age=31536000, immutable',
321 });
322 res.end(fs.readFileSync(vendorPath));
323 } catch {
324 res.writeHead(404); res.end('Vendor script not found');
325 }
326 return;
327 }
328
329 // --- Annotation upload (browser → server, raw PNG body) ---
330 // Client generates the eventId, POSTs the PNG, then POSTs the generate
331 // event with screenshotPath already set. Keeps bytes out of the SSE/poll
332 // bridge and preserves the "one shot from the user's POV" UX.
333 if (p === '/annotation' && req.method === 'POST') {
334 const token = url.searchParams.get('token');
335 if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
336 const eventId = url.searchParams.get('eventId');
337 if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) {
338 res.writeHead(400, { 'Content-Type': 'application/json' });
339 res.end(JSON.stringify({ error: 'Invalid eventId' }));
340 return;
341 }
342 if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') {
343 res.writeHead(415, { 'Content-Type': 'application/json' });
344 res.end(JSON.stringify({ error: 'Content-Type must be image/png' }));
345 return;
346 }
347 if (!state.sessionDir) {
348 res.writeHead(500, { 'Content-Type': 'application/json' });
349 res.end(JSON.stringify({ error: 'Session dir unavailable' }));
350 return;
351 }
352 const chunks = [];
353 let total = 0;
354 let aborted = false;
355 req.on('data', (c) => {
356 if (aborted) return;
357 total += c.length;
358 if (total > MAX_ANNOTATION_BYTES) {
359 aborted = true;
360 res.writeHead(413, { 'Content-Type': 'application/json' });
361 res.end(JSON.stringify({ error: 'Payload too large' }));
362 req.destroy();
363 return;
364 }
365 chunks.push(c);
366 });
367 req.on('end', () => {
368 if (aborted) return;
369 const absPath = path.join(state.sessionDir, eventId + '.png');
370 try {
371 fs.writeFileSync(absPath, Buffer.concat(chunks));
372 } catch (err) {
373 res.writeHead(500, { 'Content-Type': 'application/json' });
374 res.end(JSON.stringify({ error: 'Write failed: ' + err.message }));
375 return;
376 }
377 res.writeHead(200, { 'Content-Type': 'application/json' });
378 res.end(JSON.stringify({ ok: true, path: absPath }));
379 });
380 req.on('error', () => {
381 if (!aborted) {
382 res.writeHead(500, { 'Content-Type': 'application/json' });
383 res.end(JSON.stringify({ error: 'Upload failed' }));
384 }
385 });
386 return;
387 }
388
389 // --- Health ---
390 if (p === '/status') {
391 const token = url.searchParams.get('token');
392 if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
393 const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : [];
394 res.writeHead(200, { 'Content-Type': 'application/json' });
395 res.end(JSON.stringify({
396 status: 'ok',
397 port: state.port,
398 connectedClients: state.sseClients.size,
399 pendingEvents: state.pendingEvents.map((entry) => ({
400 id: entry.event?.id,
401 type: entry.event?.type,
402 leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
403 leaseUntil: entry.leaseUntil || null,
404 })),
405 activeSessions: sessions,
406 }));
407 return;
408 }
409
410 if (p === '/health') {
411 res.writeHead(200, { 'Content-Type': 'application/json' });
412 res.end(JSON.stringify({
413 status: 'ok', port: state.port, mode: 'variant',
414 hasProjectContext: hasProjectContext(),
415 connectedClients: state.sseClients.size,
416 }));
417 return;
418 }
419
420 // --- Design system (unified v2 response) + raw ---
421 // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json
422 // sidecar when present. Panel merges them:
423 // { present, parsed, sidecar, hasMd, hasSidecar,
424 // mdNewerThanJson, parseError?, sidecarError? }
425 // - parsed: output of parseDesignMd (frontmatter
426 // + six canonical sections) when DESIGN.md exists.
427 // - sidecar: .impeccable/design.json contents when present.
428 // Expected shape: schemaVersion 2, carrying
429 // extensions + components + narrative.
430 // /design-system/raw returns DESIGN.md markdown verbatim
431 if (p === '/design-system.json' || p === '/design-system/raw') {
432 const token = url.searchParams.get('token');
433 if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
434
435 const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
436 const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
437 const mdStat = statOrNull(mdPath);
438 const jsonStat = statOrNull(jsonPath);
439
440 if (p === '/design-system/raw') {
441 if (!mdStat) { res.writeHead(404); res.end('Not found'); return; }
442 res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
443 res.end(fs.readFileSync(mdPath, 'utf-8'));
444 return;
445 }
446
447 if (!mdStat && !jsonStat) {
448 res.writeHead(404, { 'Content-Type': 'application/json' });
449 res.end(JSON.stringify({ present: false }));
450 return;
451 }
452
453 const response = {
454 present: true,
455 hasMd: !!mdStat,
456 hasSidecar: !!jsonStat,
457 mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000),
458 };
459
460 if (mdStat) {
461 try {
462 response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8'));
463 } catch (err) {
464 response.parseError = err.message;
465 }
466 }
467
468 if (jsonStat) {
469 try {
470 response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
471 } catch (err) {
472 response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message;
473 }
474 }
475
476 res.writeHead(200, { 'Content-Type': 'application/json' });
477 res.end(JSON.stringify(response));
478 return;
479 }
480
481 // --- Source file (no-HMR fallback) ---
482 if (p === '/source') {
483 const token = url.searchParams.get('token');
484 if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
485 const filePath = url.searchParams.get('path');
486 if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
487 const absPath = path.resolve(process.cwd(), filePath);
488 if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
489 let content;
490 try { content = fs.readFileSync(absPath, 'utf-8'); }
491 catch { res.writeHead(404); res.end('File not found'); return; }
492 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
493 res.end(content);
494 return;
495 }
496
497 // --- SSE: server→browser push (replaces WebSocket) ---
498 if (p === '/events' && req.method === 'GET') {
499 const token = url.searchParams.get('token');
500 if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
501 res.writeHead(200, {
502 'Content-Type': 'text/event-stream',
503 'Cache-Control': 'no-cache',
504 'Connection': 'keep-alive',
505 });
506 res.write('data: ' + JSON.stringify({
507 type: 'connected',
508 hasProjectContext: hasProjectContext(),
509 }) + '\n\n');
510
511 state.sseClients.add(res);
512 clearTimeout(state.exitTimer);
513
514 // Keepalive: SSE comment every 30s prevents silent connection drops.
515 const heartbeat = setInterval(() => {
516 try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); }
517 }, SSE_HEARTBEAT_INTERVAL);
518
519 req.on('close', () => {
520 clearInterval(heartbeat);
521 state.sseClients.delete(res);
522 if (state.sseClients.size === 0) {
523 clearTimeout(state.exitTimer);
524 state.exitTimer = setTimeout(() => {
525 if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
526 }, 8000);
527 }
528 });
529 return;
530 }
531
532 // --- Browser→server events (replaces WebSocket messages) ---
533 if (p === '/events' && req.method === 'POST') {
534 let body = '';
535 req.on('data', (c) => { body += c; });
536 req.on('end', () => {
537 let msg;
538 try { msg = JSON.parse(body); } catch {
539 res.writeHead(400, { 'Content-Type': 'application/json' });
540 res.end(JSON.stringify({ error: 'Invalid JSON' }));
541 return;
542 }
543 if (msg.token !== state.token) {
544 res.writeHead(401, { 'Content-Type': 'application/json' });
545 res.end(JSON.stringify({ error: 'Unauthorized' }));
546 return;
547 }
548 const error = validateEvent(msg);
549 if (error) {
550 res.writeHead(400, { 'Content-Type': 'application/json' });
551 res.end(JSON.stringify({ error }));
552 return;
553 }
554 if (state.sessionStore && msg.id) {
555 try {
556 state.sessionStore.appendEvent(msg);
557 } catch (err) {
558 res.writeHead(500, { 'Content-Type': 'application/json' });
559 res.end(JSON.stringify({ error: 'session_store_append_failed', message: err.message }));
560 return;
561 }
562 }
563 if (msg.type !== 'checkpoint') enqueueEvent(msg);
564 res.writeHead(200, { 'Content-Type': 'application/json' });
565 res.end(JSON.stringify({ ok: true }));
566 });
567 return;
568 }
569
570 // --- Stop ---
571 if (p === '/stop') {
572 const token = url.searchParams.get('token');
573 if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
574 res.writeHead(200, { 'Content-Type': 'text/plain' });
575 res.end('stopping');
576 shutdown();
577 return;
578 }
579
580 // --- Agent poll ---
581 if (p === '/poll' && req.method === 'GET') {
582 handlePollGet(req, res, url);
583 return;
584 }
585 if (p === '/poll' && req.method === 'POST') {
586 handlePollPost(req, res);
587 return;
588 }
589
590 res.writeHead(404); res.end('Not found');
591 };
592}
593
594// ---------------------------------------------------------------------------
595// Agent poll endpoints (unchanged from WS version)
596// ---------------------------------------------------------------------------
597
598function handlePollGet(req, res, url) {
599 const token = url.searchParams.get('token');
600 if (token !== state.token) {
601 res.writeHead(401, { 'Content-Type': 'application/json' });
602 res.end(JSON.stringify({ error: 'Unauthorized' }));
603 return;
604 }
605 const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
606 const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
607 const available = findAvailablePendingEvent();
608 if (available) {
609 res.writeHead(200, { 'Content-Type': 'application/json' });
610 res.end(JSON.stringify(leaseEvent(available, leaseMs)));
611 return;
612 }
613 const poll = { resolve, leaseMs };
614 const timer = setTimeout(() => {
615 const idx = state.pendingPolls.indexOf(poll);
616 if (idx !== -1) state.pendingPolls.splice(idx, 1);
617 res.writeHead(200, { 'Content-Type': 'application/json' });
618 res.end(JSON.stringify({ type: 'timeout' }));
619 }, timeout);
620 function resolve(event) {
621 clearTimeout(timer);
622 res.writeHead(200, { 'Content-Type': 'application/json' });
623 res.end(JSON.stringify(event));
624 }
625 state.pendingPolls.push(poll);
626 scheduleLeaseFlush();
627 req.on('close', () => {
628 clearTimeout(timer);
629 const idx = state.pendingPolls.indexOf(poll);
630 if (idx !== -1) state.pendingPolls.splice(idx, 1);
631 });
632}
633
634function handlePollPost(req, res) {
635 let body = '';
636 req.on('data', (c) => { body += c; });
637 req.on('end', () => {
638 let msg;
639 try { msg = JSON.parse(body); } catch {
640 res.writeHead(400, { 'Content-Type': 'application/json' });
641 res.end(JSON.stringify({ error: 'Invalid JSON' }));
642 return;
643 }
644 if (msg.token !== state.token) {
645 res.writeHead(401, { 'Content-Type': 'application/json' });
646 res.end(JSON.stringify({ error: 'Unauthorized' }));
647 return;
648 }
649 acknowledgePendingEvent(msg.id);
650 if (state.sessionStore && msg.id) {
651 try {
652 const eventType = msg.type === 'discard' || msg.type === 'discarded'
653 ? 'discarded'
654 : msg.type === 'complete'
655 ? 'complete'
656 : msg.type === 'error'
657 ? 'agent_error'
658 : 'agent_done';
659 state.sessionStore.appendEvent({
660 type: eventType,
661 id: msg.id,
662 file: msg.file,
663 message: msg.message,
664 carbonize: msg.data?.carbonize === true,
665 });
666 } catch { /* keep reply path best-effort; browser still needs SSE */ }
667 }
668 flushPendingPolls();
669 // Forward the reply to the browser via SSE
670 broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
671 res.writeHead(200, { 'Content-Type': 'application/json' });
672 res.end(JSON.stringify({ ok: true }));
673 });
674}
675
676// ---------------------------------------------------------------------------
677// Lifecycle
678// ---------------------------------------------------------------------------
679
680let httpServer = null;
681
682function shutdown() {
683 removeLiveServerInfo(process.cwd());
684 if (state.leaseTimer) clearTimeout(state.leaseTimer);
685 state.leaseTimer = null;
686 if (state.sessionDir) {
687 try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {}
688 }
689 for (const res of state.sseClients) { try { res.end(); } catch {} }
690 state.sseClients.clear();
691 for (const poll of state.pendingPolls) poll.resolve({ type: 'exit' });
692 state.pendingPolls.length = 0;
693 if (httpServer) httpServer.close();
694 process.exit(0);
695}
696
697// ---------------------------------------------------------------------------
698// Main
699// ---------------------------------------------------------------------------
700
701const args = process.argv.slice(2);
702
703if (args.includes('--help') || args.includes('-h')) {
704 console.log(`Usage: node live-server.mjs [options]
705
706Start the live variant mode server (zero dependencies).
707
708Commands:
709 (default) Start the server (foreground)
710 stop Stop the server and remove the injected live.js script tag
711 stop --keep-inject Stop the server only (leave the script tag in the HTML entry)
712
713Options:
714 --background Start detached, print connection JSON to stdout, then exit
715 --port=PORT Use a specific port (default: auto-detect starting at 8400)
716 --keep-inject Only with stop: skip live-inject.mjs --remove
717 --help Show this help
718
719Endpoints:
720 /live.js Browser script (element picker + variant cycling)
721 /detect.js Detection overlay (backwards compatible)
722 /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js)
723 /annotation POST raw image/png to stage a variant screenshot
724 /events SSE stream (server→browser) + POST (browser→server)
725 /poll Long-poll for agent CLI
726 /source Raw source file reader (no-HMR fallback)
727 /status Durable recovery status (token-protected)
728 /health Health check`);
729 process.exit(0);
730}
731
732if (args.includes('stop')) {
733 const keepInject = args.includes('--keep-inject');
734 try {
735 const { info } = readLiveServerInfo(process.cwd()) || {};
736 const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
737 if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
738 } catch {
739 console.log('No running live server found.');
740 }
741 if (!keepInject) {
742 const injectPath = path.join(__dirname, 'live-inject.mjs');
743 try {
744 const out = execFileSync(process.execPath, [injectPath, '--remove'], {
745 encoding: 'utf-8',
746 cwd: process.cwd(),
747 });
748 const line = out.trim().split('\n').filter(Boolean).pop();
749 if (line) {
750 try {
751 const j = JSON.parse(line);
752 if (j.removed === true) {
753 console.log(`Removed live script tag from ${j.file}.`);
754 }
755 } catch {
756 /* ignore non-JSON lines */
757 }
758 }
759 } catch (err) {
760 const detail = err.stderr?.toString?.().trim?.()
761 || err.stdout?.toString?.().trim?.()
762 || err.message
763 || String(err);
764 console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`);
765 }
766 }
767 process.exit(0);
768}
769
770// --background: spawn a detached child server, wait for it to be ready,
771// print the connection JSON, then exit. This keeps the startup command
772// simple (no shell backgrounding or chained commands).
773if (args.includes('--background')) {
774 const childArgs = args.filter(a => a !== '--background');
775 const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
776 detached: true,
777 stdio: 'ignore',
778 cwd: process.cwd(),
779 });
780 child.unref();
781
782 // Poll for the PID file (the child writes it once the HTTP server is listening).
783 const deadline = Date.now() + 10_000;
784 while (Date.now() < deadline) {
785 try {
786 const { info } = readLiveServerInfo(process.cwd()) || {};
787 if (info.pid !== process.pid) {
788 // Output JSON so the agent can read port + token from stdout.
789 console.log(JSON.stringify(info));
790 process.exit(0);
791 }
792 } catch { /* not ready yet */ }
793 await new Promise(r => setTimeout(r, 200));
794 }
795 console.error('Timed out waiting for live server to start.');
796 process.exit(1);
797}
798
799// Check for existing session
800const existingRecord = readLiveServerInfo(process.cwd());
801if (existingRecord?.info) {
802 const existing = existingRecord.info;
803 try {
804 process.kill(existing.pid, 0);
805 console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
806 console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
807 process.exit(1);
808 } catch {
809 try { fs.unlinkSync(existingRecord.path); } catch {}
810 }
811}
812
813state.token = randomUUID();
814state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
815restorePendingEventsFromStore();
816const portArg = args.find(a => a.startsWith('--port='));
817state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
818// Annotation screenshots live in the project root so the agent's Read tool
819// doesn't trip a per-file permission prompt. Sessioned by token so concurrent
820// projects (or quick restarts) don't collide.
821const annotRoot = getLiveAnnotationsDir(process.cwd());
822fs.mkdirSync(annotRoot, { recursive: true });
823state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
824
825const { detectScript, sessionPath, livePath } = loadBrowserScripts();
826httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath }));
827
828httpServer.listen(state.port, '127.0.0.1', () => {
829 writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token });
830 const url = `http://localhost:${state.port}`;
831 console.log(`\nImpeccable live server running on ${url}`);
832 console.log(`Token: ${state.token}\n`);
833 console.log(`Inject: <script src="${url}/live.js"><\/script>`);
834 console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
835});
836
837process.on('SIGINT', shutdown);
838process.on('SIGTERM', shutdown);