Skills › Compound Engineering › ce-brainstorm › light-webserver.js
light-webserver.js
1275 lines · skills/ce-brainstorm/scripts/light-webserver.js at 3449230
1#!/usr/bin/env node
2import { createHash, randomUUID } from "node:crypto"
3import { execFileSync, spawn } from "node:child_process"
4import fs from "node:fs"
5import http from "node:http"
6import path from "node:path"
7import { fileURLToPath } from "node:url"
9const scriptPath = fileURLToPath(import.meta.url)
10const assetsDir = path.join(path.dirname(scriptPath), "..", "assets")
11const DEFAULT_HOST = "127.0.0.1"
12const DEFAULT_URL_HOST = "localhost"
13const IDLE_TIMEOUT_MS = Number(process.env.CE_LIGHT_WEB_IDLE_TIMEOUT_MS) || 30 * 60 * 1000
14const LIFECYCLE_CHECK_MS = Number(process.env.CE_LIGHT_WEB_LIFECYCLE_CHECK_MS) || 60 * 1000
15const WAIT_TIMEOUT_MS = Number(process.env.CE_LIGHT_WEB_WAIT_TIMEOUT_MS) || 30 * 1000
16const SSE_GRACE_MS = Number(process.env.CE_LIGHT_WEB_SSE_GRACE_MS) || 30000
17const BODY_LIMIT = 64 * 1024
18// Reserved URL namespace for the overlay, so a screen's own /annotate.js or
19// /annotate.css under screens/ is never shadowed.
20const OVERLAY_PREFIX = "/__ce-annotate"
21const OVERLAY_FILES = {
22 [`${OVERLAY_PREFIX}/annotate.js`]: "annotate.js",
23 [`${OVERLAY_PREFIX}/annotate.css`]: "annotate.css",
24}
25// A Host header is reflected into the served document only in this shape.
26const HOST_HEADER = /^[A-Za-z0-9.\-]+(:\d{1,5})?$|^\[[0-9A-Fa-f:.]+\](:\d{1,5})?$/
28function usage() {
29 return [
30 "Usage:",
31 " node light-webserver.js start --root <dir> [--host 127.0.0.1] [--port 0] [--foreground] [--owner-pid <pid>] [--annotate]",
32 " node light-webserver.js stop --root <dir>",
33 " node light-webserver.js status --root <dir>",
34 " node light-webserver.js wait --root <dir>",
35 ].join("\n")
36}
38function parseArgs(argv) {
39 const command = argv[2]
40 const options = {
41 command,
42 host: DEFAULT_HOST,
43 port: 0,
44 foreground: false,
45 annotate: false,
46 }
48 for (let i = 3; i < argv.length; i++) {
49 const arg = argv[i]
50 if (arg === "--root") {
51 options.root = argv[++i]
52 } else if (arg === "--host") {
53 options.host = argv[++i]
54 } else if (arg === "--port") {
55 options.port = Number(argv[++i])
56 } else if (arg === "--foreground") {
57 options.foreground = true
58 } else if (arg === "--owner-pid") {
59 options.ownerPid = Number(argv[++i])
60 } else if (arg === "--annotate") {
61 options.annotate = true
62 } else {
63 throw new Error(`Unknown argument: ${arg}`)
64 }
65 }
67 if (!["start", "serve", "stop", "status", "wait"].includes(command)) {
68 throw new Error(usage())
69 }
70 if (!options.root) {
71 throw new Error("--root is required")
72 }
73 if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
74 throw new Error("--port must be an integer from 0 to 65535")
75 }
76 if (options.ownerPid !== undefined && (!Number.isInteger(options.ownerPid) || options.ownerPid <= 1)) {
77 throw new Error("--owner-pid must be an integer greater than 1")
78 }
80 options.root = path.resolve(options.root)
81 options.screensDir = path.join(options.root, "screens")
82 options.stateDir = path.join(options.root, "state")
83 options.pidFile = path.join(options.stateDir, "server.pid")
84 options.infoFile = path.join(options.stateDir, "display-info.json")
85 options.logFile = path.join(options.stateDir, "server.log")
86 return options
87}
89function ensureDirs(options) {
90 fs.mkdirSync(options.screensDir, { recursive: true })
91 fs.mkdirSync(options.stateDir, { recursive: true })
92}
94function jsonOut(value) {
95 process.stdout.write(`${JSON.stringify(value)}\n`)
96}
98function readJson(filePath) {
99 return JSON.parse(fs.readFileSync(filePath, "utf8"))
100}
102function processAlive(pid) {
103 if (!pid || !Number.isInteger(pid)) return false
104 try {
105 process.kill(pid, 0)
106 return true
107 } catch (error) {
108 return error?.code === "EPERM"
109 }
110}
112function processArgs(pid) {
113 try {
114 return execFileSync("ps", ["-p", String(pid), "-o", "args="], {
115 encoding: "utf8",
116 stdio: ["ignore", "pipe", "ignore"],
117 }).trim()
118 } catch {
119 return null
120 }
121}
123function ownsServerProcess(options, pid) {
124 const args = processArgs(pid)
125 // Process-command inspection is best-effort; when unavailable, fall back to
126 // PID-file behavior so stop still works on platforms without a compatible ps.
127 if (args === null) return true
128 if (!args.includes(scriptPath) || !args.includes(options.root)) return false
129 const tokens = args.split(/\s+/)
130 // Detached start spawns `serve`; `--foreground` keeps `start` in-process.
131 return tokens.includes("serve") || tokens.includes("start")
132}
134function resolveOwnerPid() {
135 const parentPid = process.ppid
136 if (!parentPid || parentPid <= 1) return null
137 try {
138 const grandparent = Number(execFileSync("ps", ["-o", "ppid=", "-p", String(parentPid)], {
139 encoding: "utf8",
140 stdio: ["ignore", "pipe", "ignore"],
141 }).trim())
142 if (Number.isInteger(grandparent) && grandparent > 1) return grandparent
143 } catch {
144 // Fall back to the direct parent when grandparent lookup is unavailable.
145 }
146 return parentPid
147}
149function readPid(options) {
150 if (!fs.existsSync(options.pidFile)) return null
151 const pid = Number(fs.readFileSync(options.pidFile, "utf8").trim())
152 return Number.isInteger(pid) ? pid : null
153}
155function getRunningInfo(options) {
156 const pid = readPid(options)
157 if (!processAlive(pid)) return null
158 if (!ownsServerProcess(options, pid)) return null
159 if (!fs.existsSync(options.infoFile)) return null
160 try {
161 return readJson(options.infoFile)
162 } catch {
163 return null
164 }
165}
167// The recorded end of this root's session, or null while it is live or unknown.
168function sessionEnd(options) {
169 try {
170 const info = readJson(options.infoFile)
171 return info.session_ended ? { reason: info.session_end_reason || null } : null
172 } catch {
173 return null
174 }
175}
177function exitSessionEnded({ reason }) {
178 process.exitCode = 1
179 jsonOut({ status: "session-ended", ...(reason ? { reason } : {}) })
180}
182// Containment has to survive symlinks: path.resolve is lexical, so a link
183// inside the run directory would otherwise be followed straight out of it.
184// Every route that reads a file goes through this — the screen route and the
185// asset route drifting apart is what left one of them unguarded before.
186function containedRealPath(rootDir, candidate) {
187 let root
188 let real
189 try {
190 root = fs.realpathSync(rootDir)
191 real = fs.realpathSync(candidate)
192 } catch {
193 return null
194 }
195 if (real !== root && !real.startsWith(root + path.sep)) return null
196 return real
197}
199function newestScreen(options) {
200 if (!fs.existsSync(options.screensDir)) return null
201 const files = fs.readdirSync(options.screensDir)
202 .filter((file) => file.endsWith(".html"))
203 .map((file) => containedRealPath(options.screensDir, path.join(options.screensDir, file)))
204 .filter(Boolean)
205 .map((filePath) => ({ filePath, mtimeMs: fs.statSync(filePath).mtimeMs }))
206 .sort((a, b) => b.mtimeMs - a.mtimeMs)
207 return files[0]?.filePath ?? null
208}
210function isFullDocument(html) {
211 // After ignorable prologue (whitespace, HTML comments), a doctype or <html>
212 // is a complete document; anything else is a fragment.
213 let text = html
214 for (;;) {
215 text = text.trimStart()
216 if (!text.startsWith("<!--")) break
217 const end = text.indexOf("-->")
218 if (end === -1) return false
219 text = text.slice(end + 3)
220 }
221 const trimmed = text.toLowerCase()
222 return trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")
223}
225function screenVersion(options) {
226 const screen = newestScreen(options)
227 if (!screen) return { screen: null, mtimeMs: 0 }
228 return {
229 screen: path.basename(screen),
230 mtimeMs: fs.statSync(screen).mtimeMs,
231 }
232}
234function versionKey(version) {
235 return `${version?.screen ?? ""}:${version?.mtimeMs ?? 0}`
236}
238// Annotate mode reloads the explorer's page on any change under screens/,
239// including a stylesheet or script the newest screen links, so the change key
240// covers every regular file there, not only the newest screen.
241function screensChangeKey(options) {
242 const hash = createHash("sha1")
243 const walk = (dir, prefix) => {
244 let entries
245 try {
246 entries = fs.readdirSync(dir, { withFileTypes: true })
247 } catch {
248 return
249 }
250 for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
251 const rel = `${prefix}${entry.name}`
252 if (entry.isDirectory()) {
253 walk(path.join(dir, entry.name), `${rel}/`)
254 continue
255 }
256 if (!entry.isFile()) continue
257 try {
258 const stat = fs.statSync(path.join(dir, entry.name))
259 hash.update(`${rel}:${stat.mtimeMs}:${stat.size}\n`)
260 } catch {
261 // Removed between readdir and stat: the next tick sees the settled tree.
262 }
263 }
264 }
265 walk(options.screensDir, "")
266 return hash.digest("hex")
267}
269const WAITING_HTML = "<h1>Waiting for a page...</h1><p>The agent will update this page when a screen is ready.</p>"
270const NO_STORE = { "Cache-Control": "no-store" }
272function refreshScript(options) {
273 const initialVersion = JSON.stringify(screenVersion(options))
274 return `<script>
275(function(){
276 var currentVersion = ${initialVersion};
277 function key(version) {
278 return String(version && version.screen) + ":" + String(version && version.mtimeMs);
279 }
280 async function checkForVisualProbeUpdate() {
281 try {
282 var response = await fetch("/version", { cache: "no-store" });
283 if (!response.ok) return;
284 var nextVersion = await response.json();
285 if (key(nextVersion) !== key(currentVersion)) {
286 window.location.reload();
287 }
288 } catch (error) {
289 // Keep the current sketch visible if the transient version check fails.
290 }
291 }
292 setInterval(checkForVisualProbeUpdate, 1000);
293})();
294</script>`
295}
297// Ahead of the authored document: deferred overlay. Its URL is absolute on
298// the request's own origin, so a screen's <base href> cannot redirect it.
299// data-ce-page is the path this response served, so a later History API
300// rewrite is not the screen.
301function htmlAttr(value) {
302 return String(value).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<")
303}
305function encodePagePath(page) {
306 if (typeof page !== "string" || !page.startsWith("/") || page === "/") return "/"
307 return `/${page.slice(1).split("/").map((segment) => {
308 try {
309 return encodeURIComponent(decodeURIComponent(segment))
310 } catch {
311 return encodeURIComponent(segment)
312 }
313 }).join("/")}`
314}
316function annotateBoot(origin, page = "/") {
317 const servedPage = encodePagePath(typeof page === "string" && page.startsWith("/") ? page : "/")
318 return `<script defer src="${origin}${OVERLAY_PREFIX}/annotate.js" data-ce-page="${htmlAttr(servedPage)}"></script>`
319}
321function wrapFragment(options, content) {
322 return `<!doctype html>
323<html>
324<head>
325 <meta charset="utf-8">
326 <meta name="viewport" content="width=device-width, initial-scale=1">
327 <title>CE local web</title>
328 <style>
329 body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; background: #f7f7f8; color: #1f2328; }
330 header { padding: 10px 18px; border-bottom: 1px solid #d8dee4; background: #fff; color: #57606a; font-size: 13px; }
331 main { padding: 24px; }
332 </style>
333</head>
334<body>
335 <header>CE local web - newest screen, reloads on change</header>
336 <main>${content}</main>
337 ${refreshScript(options)}
338</body>
339</html>`
340}
342function wrapAnnotateFragment(content, boot) {
343 return `<!doctype html>
344<html>
345<head>
346 <meta charset="utf-8">
347 <meta name="viewport" content="width=device-width, initial-scale=1">
348 <title>CE local web</title>
349 ${boot}
350 <style>
351 body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; background: #f7f7f8; color: #1f2328; }
352 header { padding: 10px 18px; border-bottom: 1px solid #d8dee4; background: #fff; color: #57606a; font-size: 13px; }
353 main { padding: 24px; }
354 </style>
355</head>
356<body>
357 <header>CE local web - newest screen</header>
358 <main>${content}</main>
359</body>
360</html>`
361}
363function injectRefresh(options, html) {
364 if (html.includes("</body>")) {
365 return html.replace("</body>", `${refreshScript(options)}\n</body>`)
366 }
367 return `${html}\n${refreshScript(options)}`
368}
370// The document the annotate client sees. A full document is served unchanged
371// behind our doctype and the boot script: the parser opens html/head for the
372// script, then merges the authored <html> attributes, processes the authored
373// head children in head, ignores the second doctype and <head> start tag, and
374// creates <body> with its attributes. Nothing in the authored text is located
375// or rewritten, so a "</body>" in a script string or comment cannot mislead it.
376function annotateScreen(html, origin, page = "/") {
377 const boot = annotateBoot(origin, page)
378 const text = html.replace(/^\uFEFF/, "")
379 if (!isFullDocument(text)) return wrapAnnotateFragment(text, boot)
380 return `<!doctype html>\n${boot}\n${text}`
381}
383function annotateDocument(options, origin) {
384 const screen = newestScreen(options)
385 if (!screen) return wrapAnnotateFragment(WAITING_HTML, annotateBoot(origin))
386 return annotateScreen(fs.readFileSync(screen, "utf8"), origin, pageForScreen(options, screen))
387}
389function renderPage(options, origin) {
390 if (options.annotate) return annotateDocument(options, origin)
391 const screen = newestScreen(options)
392 if (!screen) return wrapFragment(options, WAITING_HTML)
393 const html = fs.readFileSync(screen, "utf8")
394 return isFullDocument(html) ? injectRefresh(options, html) : wrapFragment(options, html)
395}
397function cookieValue(req, name) {
398 const header = req.headers.cookie
399 if (typeof header !== "string") return null
400 for (const part of header.split(";")) {
401 const eq = part.indexOf("=")
402 if (eq === -1) continue
403 if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim()
404 }
405 return null
406}
408// Every credential the request presents. A prototype may use ?token= for its
409// own purposes, so no single source may shadow another: the gate accepts the
410// request when any of these matches.
411function requestCredentials(req, cookieName) {
412 const url = new URL(req.url, "http://127.0.0.1")
413 const auth = req.headers.authorization
414 return [
415 url.searchParams.get("token"),
416 req.headers["x-session-token"],
417 typeof auth === "string" && auth.startsWith("Bearer ") ? auth.slice(7) : null,
418 cookieName ? cookieValue(req, cookieName) : null,
419 ].filter((value) => typeof value === "string" && value)
420}
422function tokenMatches(candidate, expected) {
423 return typeof candidate === "string" && candidate === expected
424}
426function sendJson(res, status, value) {
427 res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" })
428 res.end(`${JSON.stringify(value)}\n`)
429}
431function readBody(req, limit = BODY_LIMIT) {
432 return new Promise((resolve, reject) => {
433 const chunks = []
434 let size = 0
435 req.on("data", (chunk) => {
436 size += chunk.length
437 if (size > limit) {
438 req.destroy()
439 reject(new Error("payload too large"))
440 return
441 }
442 chunks.push(chunk)
443 })
444 req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
445 req.on("error", reject)
446 })
447}
449// Screens-relative URL path for a file we just served, so the overlay names
450// that file rather than "/". "/" would re-resolve to newestScreen at POST
451// time, and a newer sibling would steal the pin.
452function pageForScreen(options, filePath) {
453 let root
454 try {
455 root = fs.realpathSync(options.screensDir)
456 } catch {
457 return "/"
458 }
459 const relative = path.relative(root, filePath).split(path.sep).join("/")
460 if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return "/"
461 return `/${relative}`
462}
464// The screens/-relative HTML file the annotated page resolves to, or null.
465// "/" (also a missing page, from an older overlay) is the newest screen at
466// that moment; any other path must name an HTML file under screens/, through
467// the same containment as the route that served it. This is the file the
468// agent edits, so it is resolved here rather than trusted from the client.
469function screenForPage(options, page = "/") {
470 if (typeof page !== "string" || !page.startsWith("/")) return null
471 let filePath
472 if (page === "/") {
473 filePath = newestScreen(options)
474 } else {
475 let name
476 try {
477 name = decodeURIComponent(page)
478 } catch {
479 return null
480 }
481 filePath = containedRealPath(options.screensDir, path.resolve(options.screensDir, name.replace(/^\/+/, "")))
482 if (!filePath || contentType(filePath) !== CONTENT_TYPES[".html"]) return null
483 try {
484 if (!fs.statSync(filePath).isFile()) return null
485 } catch {
486 return null
487 }
488 }
489 if (!filePath) return null
490 let root
491 try {
492 root = fs.realpathSync(options.screensDir)
493 } catch {
494 return null
495 }
496 return path.relative(root, filePath).split(path.sep).join("/")
497}
499function parseAnnotation(raw, options) {
500 if (!raw || !raw.trim()) return null
501 let body
502 try {
503 body = JSON.parse(raw)
504 } catch {
505 return null
506 }
507 if (!body || typeof body !== "object" || Array.isArray(body)) return null
508 const comment = typeof body.comment === "string" ? body.comment.trim() : ""
509 const selector = typeof body.selector === "string" ? body.selector.trim() : ""
510 if (!comment || !selector) return null
511 const screen = screenForPage(options, body.page)
512 if (!screen) return null
513 const textSnippet = typeof body.textSnippet === "string" ? body.textSnippet : null
514 const variant =
515 Array.isArray(body.variant) && body.variant.length > 0 && body.variant.every((name) => typeof name === "string")
516 ? body.variant
517 : null
518 const plainObject = (value) => (value && typeof value === "object" && !Array.isArray(value) ? value : null)
519 const rect = plainObject(body.rect)
520 const point = plainObject(body.point)
521 return {
522 id: randomUUID(),
523 screen,
524 comment,
525 selector,
526 textSnippet,
527 variant,
528 rect,
529 point,
530 }
531}
533// Whether a request for an HTML file is the browser navigating to it, as
534// opposed to a script fetching it. Fetch metadata decides when the browser
535// sends it (frames stay raw: only a top-level document is a screen); without
536// it, a request that accepts HTML and states no fetch mode is a navigation.
537function isDocumentNavigation(req) {
538 const dest = req.headers["sec-fetch-dest"]
539 if (dest) return dest === "document"
540 return !req.headers["sec-fetch-mode"] && /\btext\/html\b/.test(req.headers.accept || "")
541}
543// The regular file under rootDir that the request names, or null after the
544// error response has been written.
545function resolveContainedFile(rootDir, req, res) {
546 let name
547 try {
548 // A malformed percent-escape throws URIError; without this the throw is
549 // uncaught in the request handler and takes the whole server down.
550 name = decodeURIComponent(req.url.split("?")[0].split("#")[0])
551 } catch {
552 res.writeHead(400)
553 res.end("Bad request")
554 return null
555 }
556 name = name.replace(/^\/+/, "")
557 // Serve nested paths so a screen can keep the asset layout it was copied
558 // from, but never resolve outside the run's screens directory.
559 const filePath = containedRealPath(rootDir, path.resolve(rootDir, name))
560 if (!filePath) {
561 res.writeHead(404)
562 res.end("Not found")
563 return null
564 }
565 let stat
566 try {
567 stat = fs.statSync(filePath)
568 } catch {
569 res.writeHead(404)
570 res.end("Not found")
571 return null
572 }
573 // `/files/%2e` resolves to the screens directory itself, which passes an
574 // existence check and then throws EISDIR on read — uncaught, killing the server.
575 if (!stat.isFile()) {
576 res.writeHead(404)
577 res.end("Not found")
578 return null
579 }
580 return filePath
581}
583function sendFile(filePath, res, headers = {}) {
584 res.writeHead(200, { "Content-Type": contentType(filePath), ...headers })
585 res.end(fs.readFileSync(filePath))
586}
588function safeFileResponse(rootDir, req, res, headers = {}) {
589 const filePath = resolveContainedFile(rootDir, req, res)
590 if (filePath) sendFile(filePath, res, headers)
591}
593// A prototype recreated from a real product brings whatever that product uses,
594// so this covers the ordinary web asset set rather than an allowlist that has
595// to grow every time a screen references a new kind of file.
596const CONTENT_TYPES = {
597 ".html": "text/html; charset=utf-8",
598 ".css": "text/css; charset=utf-8",
599 ".js": "text/javascript; charset=utf-8",
600 ".mjs": "text/javascript; charset=utf-8",
601 ".json": "application/json; charset=utf-8",
602 ".txt": "text/plain; charset=utf-8",
603 ".svg": "image/svg+xml",
604 ".png": "image/png",
605 ".jpg": "image/jpeg",
606 ".jpeg": "image/jpeg",
607 ".gif": "image/gif",
608 ".webp": "image/webp",
609 ".avif": "image/avif",
610 ".ico": "image/x-icon",
611 ".woff": "font/woff",
612 ".woff2": "font/woff2",
613 ".ttf": "font/ttf",
614 ".otf": "font/otf",
615 ".mp4": "video/mp4",
616 ".webm": "video/webm",
617 ".mp3": "audio/mpeg",
618 ".wasm": "application/wasm",
619}
621function contentType(filePath) {
622 return CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"
623}
625async function start(options) {
626 ensureDirs(options)
627 options.ownerPid = options.ownerPid ?? resolveOwnerPid()
628 const running = getRunningInfo(options)
629 if (running && Boolean(running.annotate) === options.annotate && !running.session_ended) {
630 jsonOut({ ...running, status: "running" })
631 return
632 }
633 // A server in the other mode cannot serve this start: a default server has
634 // no token for wait, and an annotate server would gate a default preview.
635 if (running) await stopServer(options)
637 fs.rmSync(options.pidFile, { force: true })
638 fs.rmSync(options.infoFile, { force: true })
640 if (options.foreground) {
641 await serve(options)
642 return
643 }
645 const logFd = fs.openSync(options.logFile, "a")
646 const child = spawn(process.execPath, [
647 scriptPath,
648 "serve",
649 "--root",
650 options.root,
651 "--host",
652 options.host,
653 "--port",
654 String(options.port),
655 ...(options.ownerPid ? ["--owner-pid", String(options.ownerPid)] : []),
656 ...(options.annotate ? ["--annotate"] : []),
657 ], {
658 detached: true,
659 stdio: ["ignore", logFd, logFd],
660 })
661 child.unref()
662 fs.closeSync(logFd)
664 const started = await waitForInfo(options, child.pid)
665 if (!started) {
666 throw new Error(`Server failed to start. See ${options.logFile}`)
667 }
668 jsonOut({ ...started, status: "started" })
669}
671async function waitForInfo(options, pid) {
672 for (let i = 0; i < 100; i++) {
673 if (fs.existsSync(options.infoFile)) {
674 try {
675 return readJson(options.infoFile)
676 } catch {
677 // Truncated write; keep polling.
678 }
679 }
680 if (pid && !processAlive(pid)) return null
681 await new Promise((resolve) => setTimeout(resolve, 50))
682 }
683 return null
684}
686// The address a local client uses to reach the bound interface: a wildcard
687// bind is reached through its loopback, anything else through itself.
688function localAddressFor(host) {
689 if (!host || host === "0.0.0.0") return DEFAULT_HOST
690 if (host === "::" || host === "[::]") return "[::1]"
691 return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host
692}
694async function wait(options) {
695 process.stdout.on("error", (error) => {
696 console.error(error.message)
697 process.exitCode = 2
698 })
699 const info = getRunningInfo(options)
700 if (!info?.port) {
701 // Idle/owner shutdown records session_ended and exits; wait must still
702 // report that terminal status rather than "not running".
703 const ended = sessionEnd(options)
704 if (ended) return exitSessionEnded(ended)
705 console.error("Server is not running")
706 process.exit(2)
707 }
708 if (!info.token) {
709 console.error("Annotation is not enabled for this server")
710 process.exit(2)
711 }
713 const url = `http://${localAddressFor(info.host)}:${info.port}/wait?token=${encodeURIComponent(info.token)}`
714 while (true) {
715 let response
716 try {
717 response = await fetch(url)
718 } catch {
719 const ended = sessionEnd(options)
720 if (ended) return exitSessionEnded(ended)
721 process.exit(2)
722 }
723 if (response.status === 200 || response.status === 410) {
724 const text = await response.text()
725 // Let pending stdout writes drain instead of truncating a piped batch.
726 process.exitCode = response.status === 200 ? 0 : 1
727 process.stdout.write(text.endsWith("\n") ? text : `${text}\n`)
728 return
729 }
730 if (response.status === 204) continue
731 process.exit(2)
732 }
733}
735async function serve(options) {
736 ensureDirs(options)
738 const sessionToken = options.annotate ? randomUUID() : null
739 const overlaySession = options.annotate ? randomUUID() : ""
740 let cookieName = null
741 const heldQueue = []
742 const annotationQueue = []
743 // id -> held | queued | working | done, in POST order. The overlay's pin
744 // status follows this, never the screen changes an annotation happens to cause.
745 const annotationStates = new Map()
746 const waiters = []
747 const sseClients = new Set()
748 let sessionEnded = false
749 let sessionEndReason = null
750 let publishedInfo = null
751 let sawSseClient = false
752 let sseGraceTimer = null
753 const pendingDocuments = new Map()
754 let lastBroadcastKey = options.annotate ? screensChangeKey(options) : null
755 let lastActivity = Date.now()
756 const touch = () => {
757 lastActivity = Date.now()
758 }
760 function endedBody() {
761 return { status: "session-ended", ...(sessionEndReason ? { reason: sessionEndReason } : {}) }
762 }
764 // reason: user-ended | tab-closed | idle | owner-exited | stopped
765 function endSession(reason) {
766 if (sessionEnded) return
767 sessionEnded = true
768 sessionEndReason = reason
769 if (publishedInfo && options.infoFile) {
770 publishedInfo = { ...publishedInfo, session_ended: true, session_end_reason: reason }
771 try {
772 fs.writeFileSync(options.infoFile, `${JSON.stringify(publishedInfo, null, 2)}\n`)
773 } catch {
774 // Reuse without this flag would report a live session that cannot wait.
775 }
776 }
777 for (const id of [...pendingDocuments.keys()]) forgetPendingDocument(id)
778 if (sseGraceTimer) {
779 clearTimeout(sseGraceTimer)
780 sseGraceTimer = null
781 }
782 for (const [id, state] of annotationStates) {
783 if (state === "working") annotationStates.set(id, "done")
784 }
785 flushHeld()
786 broadcastAnnotations()
787 fulfillWaiters()
788 const body = `${JSON.stringify(endedBody())}\n`
789 const draining = []
790 while (waiters.length > 0) {
791 const parked = waiters.shift()
792 clearTimeout(parked.timer)
793 if (!parked.res.writableEnded) {
794 parked.res.writeHead(410, { "Content-Type": "application/json; charset=utf-8" })
795 draining.push(new Promise((resolve) => parked.res.end(body, resolve)))
796 }
797 }
798 for (const client of sseClients) {
799 if (!client.writableEnded) {
800 client.write("event: session-ended\ndata: {}\n\n")
801 draining.push(new Promise((resolve) => client.end(resolve)))
802 }
803 }
804 sseClients.clear()
805 return Promise.all(draining)
806 }
808 function annotationsPayload() {
809 return JSON.stringify(Object.fromEntries(annotationStates))
810 }
812 function broadcastAnnotations() {
813 const frame = `event: annotations\ndata: ${annotationsPayload()}\n\n`
814 for (const client of sseClients) {
815 if (!client.writableEnded) client.write(frame)
816 }
817 }
819 // The agent asking for the next annotation is the completion signal for the
820 // one it was serving; the wait CLI only re-enters after a 204 while idle.
821 function completeWorking() {
822 let changed = false
823 for (const [id, state] of annotationStates) {
824 if (state === "working") {
825 annotationStates.set(id, "done")
826 changed = true
827 }
828 }
829 if (changed) broadcastAnnotations()
830 }
832 function flushHeld() {
833 if (heldQueue.length === 0) return
834 for (const item of heldQueue) {
835 annotationQueue.push(item)
836 if (annotationStates.get(item.id) === "held") annotationStates.set(item.id, "queued")
837 }
838 heldQueue.length = 0
839 }
841 function serveBatch(res, items) {
842 for (const item of items) annotationStates.set(item.id, "working")
843 res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" })
844 res.end(`${JSON.stringify(items)}\n`)
845 broadcastAnnotations()
846 }
848 function fulfillWaiters() {
849 while (waiters.length > 0 && annotationQueue.length > 0) {
850 const parked = waiters.shift()
851 clearTimeout(parked.timer)
852 if (parked.res.writableEnded) continue
853 serveBatch(parked.res, annotationQueue.splice(0, annotationQueue.length))
854 }
855 }
857 // The client reloads on this event; the browser then owns every
858 // reconciliation (head, html/body attributes, linked assets, scripts).
859 function broadcastScreenChange(key) {
860 lastBroadcastKey = key
861 const payload = JSON.stringify({ version: key })
862 for (const client of sseClients) {
863 if (!client.writableEnded) {
864 client.write(`event: screen-changed\ndata: ${payload}\n\n`)
865 }
866 }
867 }
869 function broadcastIfChanged() {
870 const key = screensChangeKey(options)
871 if (key !== lastBroadcastKey) broadcastScreenChange(key)
872 }
874 function authorized(req) {
875 return requestCredentials(req, cookieName).some((candidate) => tokenMatches(candidate, sessionToken))
876 }
878 function requireAnnotateToken(req, res) {
879 if (authorized(req)) return true
880 sendJson(res, 401, { error: "unauthorized" })
881 return false
882 }
884 function requireLiveAnnotate(req, res) {
885 if (!requireAnnotateToken(req, res)) return false
886 if (sessionEnded) {
887 sendJson(res, 410, endedBody())
888 return false
889 }
890 return true
891 }
893 function requestOrigin(req) {
894 const host = req.headers.host
895 if (typeof host === "string" && HOST_HEADER.test(host)) return `http://${host}`
896 return `http://${DEFAULT_URL_HOST}:${server.address().port}`
897 }
899 function stampOverlayDocument(html, documentId) {
900 const marker = `${OVERLAY_PREFIX}/annotate.js"`
901 const at = html.indexOf(marker)
902 if (at === -1) return html
903 const after = at + marker.length
904 return `${html.slice(0, after)} data-ce-session="${htmlAttr(overlaySession)}" data-ce-document="${htmlAttr(documentId)}"${html.slice(after)}`
905 }
907 function forgetPendingDocument(id) {
908 return pendingDocuments.delete(id)
909 }
911 function abandonPendingDocument(id) {
912 if (forgetPendingDocument(id) && sseClients.size === 0 && sawSseClient && !sessionEnded) {
913 armSseGrace()
914 }
915 }
917 function unbindPendingFromSocket(socket, exceptId) {
918 if (!socket) return
919 for (const [id, entry] of pendingDocuments) {
920 if (entry.socket !== socket || id === exceptId) continue
921 entry.socket = null
922 }
923 }
925 function retainPendingDocument(id, socket) {
926 unbindPendingFromSocket(socket, id)
927 pendingDocuments.set(id, { socket: socket || null })
928 }
930 // Every document that carries the overlay is served the same way.
931 // `renderedKey` is captured with `html`; scanning screens/ here races a rewrite.
932 function serveAnnotateDocument(req, res, html, renderedKey) {
933 // A page being served is a tab loading, not the last tab closing. The
934 // overlay is deferred and may sit behind parser-blocking work, so cancel
935 // the reconnect grace until that document's /events connects; ending on
936 // the short elapsed timeout would kill a still-loading tab. The old
937 // stream's close can arrive after this response; it must not start grace
938 // while any replacement is still pending. /events names the document it
939 // completes, so another tab's reconnect cannot consume this pending load.
940 // The handshake dies when this document can no longer open /events: the
941 // request aborted before the body was delivered, /events completed it, or
942 // the session ended. A completed response is not that; the overlay may
943 // connect on a new connection. Keep-alive reuse is not replacement:
944 // another document on the same socket leaves this pending in place.
945 // A script fetching the page is not a tab loading, so it does not create
946 // a handshake.
947 const pending = randomUUID()
948 if (isDocumentNavigation(req)) {
949 retainPendingDocument(pending, req.socket)
950 if (sseGraceTimer) {
951 clearTimeout(sseGraceTimer)
952 sseGraceTimer = null
953 }
954 req.on("close", () => {
955 if (res.writableEnded) return
956 abandonPendingDocument(pending)
957 })
958 }
959 // Sync the change key to what this page will render, so a stream that
960 // connects right after load does not reload the same screen. Any
961 // already-open stream still receives the change.
962 if (renderedKey !== lastBroadcastKey) broadcastScreenChange(renderedKey)
963 const headers = {
964 "Content-Type": CONTENT_TYPES[".html"],
965 ...NO_STORE,
966 "Referrer-Policy": "no-referrer",
967 }
968 if (cookieName && sessionToken) {
969 headers["Set-Cookie"] = `${cookieName}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`
970 }
971 res.writeHead(200, headers)
972 res.end(stampOverlayDocument(html, pending))
973 }
975 function armSseGrace() {
976 if (pendingDocuments.size > 0 || sessionEnded) return
977 if (sseGraceTimer) clearTimeout(sseGraceTimer)
978 sseGraceTimer = setTimeout(() => {
979 if (sseClients.size === 0 && pendingDocuments.size === 0) endSession("tab-closed")
980 }, SSE_GRACE_MS)
981 sseGraceTimer.unref()
982 }
984 async function handleRequest(req, res) {
985 const urlPath = req.url.split("?")[0].split("#")[0]
987 if (req.method === "GET" && urlPath === "/version") {
988 unbindPendingFromSocket(req.socket)
989 res.writeHead(200, {
990 "Content-Type": "application/json; charset=utf-8",
991 "Cache-Control": "no-store",
992 })
993 res.end(`${JSON.stringify(screenVersion(options))}\n`)
994 return
995 }
997 if (options.annotate) {
998 if (req.method === "GET" && urlPath === "/wait") {
999 unbindPendingFromSocket(req.socket)
1000 if (!requireAnnotateToken(req, res)) return
1001 // A waiting agent is attending the session; an open tab alone is not.
1002 touch()
1003 broadcastIfChanged()
1004 completeWorking()
1005 if (annotationQueue.length > 0) {
1006 serveBatch(res, annotationQueue.splice(0, annotationQueue.length))
1007 return
1008 }
1009 if (sessionEnded) {
1010 sendJson(res, 410, endedBody())
1011 return
1012 }
1013 const parked = { res, timer: null }
1014 parked.timer = setTimeout(() => {
1015 const index = waiters.indexOf(parked)
1016 if (index !== -1) waiters.splice(index, 1)
1017 if (!res.writableEnded) {
1018 res.writeHead(204)
1019 res.end()
1020 }
1021 }, WAIT_TIMEOUT_MS)
1022 waiters.push(parked)
1023 req.on("close", () => {
1024 clearTimeout(parked.timer)
1025 const index = waiters.indexOf(parked)
1026 if (index !== -1) waiters.splice(index, 1)
1027 })
1028 return
1029 }
1031 if (req.method === "POST" && urlPath === "/annotation") {
1032 unbindPendingFromSocket(req.socket)
1033 if (!requireLiveAnnotate(req, res)) return
1034 let raw
1035 try {
1036 raw = await readBody(req)
1037 } catch {
1038 sendJson(res, 400, { error: "invalid annotation" })
1039 return
1040 }
1041 // The session can end while the body is still arriving.
1042 if (sessionEnded) {
1043 sendJson(res, 410, endedBody())
1044 return
1045 }
1046 const record = parseAnnotation(raw, options)
1047 if (!record) {
1048 sendJson(res, 400, { error: "invalid annotation" })
1049 return
1050 }
1051 heldQueue.push(record)
1052 annotationStates.set(record.id, "held")
1053 touch()
1054 broadcastAnnotations()
1055 sendJson(res, 200, { ok: true, id: record.id })
1056 return
1057 }
1059 if (req.method === "POST" && urlPath === "/session/flush") {
1060 unbindPendingFromSocket(req.socket)
1061 if (!requireLiveAnnotate(req, res)) return
1062 flushHeld()
1063 broadcastAnnotations()
1064 fulfillWaiters()
1065 sendJson(res, 200, { ok: true })
1066 return
1067 }
1069 if (req.method === "POST" && urlPath === "/session/end") {
1070 unbindPendingFromSocket(req.socket)
1071 if (!requireAnnotateToken(req, res)) return
1072 endSession("user-ended")
1073 sendJson(res, 200, endedBody())
1074 return
1075 }
1077 if (req.method === "GET" && urlPath === "/events") {
1078 if (!requireLiveAnnotate(req, res)) return
1079 res.writeHead(200, {
1080 "Content-Type": "text/event-stream",
1081 "Cache-Control": "no-cache",
1082 Connection: "keep-alive",
1083 })
1084 res.write(":ok\n\n")
1085 // A client that just reloaded reconciles its pins from this frame.
1086 res.write(`event: annotations\ndata: ${annotationsPayload()}\n\n`)
1087 sawSseClient = true
1088 const documentId = new URL(req.url, "http://127.0.0.1").searchParams.get("document")
1089 if (documentId) forgetPendingDocument(documentId)
1090 unbindPendingFromSocket(req.socket, documentId)
1091 if (sseGraceTimer) {
1092 clearTimeout(sseGraceTimer)
1093 sseGraceTimer = null
1094 }
1095 sseClients.add(res)
1096 req.on("close", () => {
1097 sseClients.delete(res)
1098 if (sseClients.size === 0 && sawSseClient && !sessionEnded) armSseGrace()
1099 })
1100 return
1101 }
1103 if (req.method === "GET" && OVERLAY_FILES[urlPath]) {
1104 safeFileResponse(assetsDir, { url: `/${OVERLAY_FILES[urlPath]}` }, res, NO_STORE)
1105 return
1106 }
1108 if (req.method === "GET" && urlPath === "/") {
1109 touch()
1110 if (isDocumentNavigation(req)) {
1111 const renderedKey = screensChangeKey(options)
1112 serveAnnotateDocument(req, res, renderPage(options, requestOrigin(req)), renderedKey)
1113 return
1114 }
1115 const screen = newestScreen(options)
1116 if (screen) {
1117 sendFile(screen, res, NO_STORE)
1118 return
1119 }
1120 res.writeHead(200, { "Content-Type": CONTENT_TYPES[".html"], ...NO_STORE })
1121 res.end(WAITING_HTML)
1122 return
1123 }
1125 // A linked page under screens/ is a screen too: navigated to, it carries
1126 // the same overlay and stream, or the session would end at the first
1127 // navigation. It stays ungated like every other screen file. Fetched by
1128 // a script, the same file is a partial and is served raw.
1129 if (req.method === "GET") {
1130 touch()
1131 const filePath = resolveContainedFile(options.screensDir, req, res)
1132 if (!filePath) return
1133 if (contentType(filePath) === CONTENT_TYPES[".html"] && isDocumentNavigation(req)) {
1134 const renderedKey = screensChangeKey(options)
1135 serveAnnotateDocument(req, res, annotateScreen(fs.readFileSync(filePath, "utf8"), requestOrigin(req), urlPath), renderedKey)
1136 return
1137 }
1138 // A reload must pick up a revised stylesheet or script whose URL did
1139 // not change; a cached copy would show the old screen.
1140 sendFile(filePath, res, NO_STORE)
1141 return
1142 }
1143 }
1145 if (req.method === "GET" && urlPath === "/") {
1146 touch()
1147 res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
1148 res.end(renderPage(options))
1149 return
1150 }
1151 if (req.method === "GET") {
1152 touch()
1153 safeFileResponse(options.screensDir, req, res)
1154 return
1155 }
1156 res.writeHead(404)
1157 res.end("Not found")
1158 }
1160 const server = http.createServer((req, res) => {
1161 Promise.resolve(handleRequest(req, res)).catch(() => {
1162 if (!res.headersSent) {
1163 res.writeHead(500)
1164 res.end("Internal error")
1165 }
1166 })
1167 })
1169 server.listen(options.port, options.host, () => {
1170 const address = server.address()
1171 const port = typeof address === "object" && address ? address.port : options.port
1172 cookieName = `ce-light-web-${port}`
1173 const baseUrl = `http://${DEFAULT_URL_HOST}:${port}`
1174 const info = {
1175 status: "running",
1176 root: options.root,
1177 host: options.host,
1178 port,
1179 url: baseUrl,
1180 screen_dir: options.screensDir,
1181 state_dir: options.stateDir,
1182 pid: process.pid,
1183 owner_pid: options.ownerPid ?? null,
1184 ...(sessionToken ? { token: sessionToken, annotate: true } : {}),
1185 }
1186 publishedInfo = info
1187 fs.writeFileSync(options.pidFile, `${process.pid}\n`)
1188 fs.writeFileSync(options.infoFile, `${JSON.stringify(info, null, 2)}\n`)
1189 console.log(JSON.stringify(info))
1190 })
1192 // An open change stream or parked wait is an active connection, and
1193 // server.close waits for those forever; end the session so they drain.
1194 // CLI `stop` sends SIGTERM; without this handler the process exits before
1195 // waiters receive session-ended.
1196 function shutdown(reason) {
1197 Promise.resolve(endSession(reason)).finally(() => {
1198 server.close(() => process.exit(0))
1199 server.closeAllConnections()
1200 })
1201 }
1202 process.on("SIGTERM", () => shutdown("stopped"))
1203 process.on("SIGINT", () => shutdown("stopped"))
1205 const idleTimer = setInterval(() => {
1206 // A parked wait is ongoing activity for as long as it stays connected,
1207 // however the wait timeout compares with the idle budget.
1208 if (waiters.length > 0) touch()
1209 if (options.ownerPid && !processAlive(options.ownerPid)) {
1210 shutdown("owner-exited")
1211 } else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
1212 shutdown("idle")
1213 }
1214 }, LIFECYCLE_CHECK_MS)
1215 idleTimer.unref()
1217 if (options.annotate) {
1218 const changeTimer = setInterval(() => {
1219 if (sseClients.size > 0) broadcastIfChanged()
1220 }, 250)
1221 changeTimer.unref()
1222 }
1223}
1225async function stopServer(options) {
1226 const pid = readPid(options)
1227 if (processAlive(pid) && ownsServerProcess(options, pid)) {
1228 process.kill(pid)
1229 for (let i = 0; i < 20; i++) {
1230 if (!processAlive(pid)) break
1231 await new Promise((resolve) => setTimeout(resolve, 100))
1232 }
1233 if (processAlive(pid)) {
1234 try {
1235 process.kill(pid, "SIGKILL")
1236 } catch {
1237 // Process may have exited between the liveness check and kill.
1238 }
1239 }
1240 }
1241 fs.rmSync(options.pidFile, { force: true })
1242}
1244async function stop(options) {
1245 await stopServer(options)
1246 jsonOut({ status: "stopped", root: options.root })
1247}
1249function status(options) {
1250 const info = getRunningInfo(options)
1251 if (!info) {
1252 jsonOut({ status: "stopped", root: options.root })
1253 return
1254 }
1255 jsonOut({ ...info, status: "running" })
1256}
1258async function main() {
1259 let command
1260 try {
1261 const options = parseArgs(process.argv)
1262 command = options.command
1263 if (options.command === "start") await start(options)
1264 else if (options.command === "serve") await serve(options)
1265 else if (options.command === "stop") await stop(options)
1266 else if (options.command === "status") status(options)
1267 else if (options.command === "wait") await wait(options)
1268 } catch (error) {
1269 console.error(error.message)
1270 // Wait reserves exit 1 for session-ended; any other failure is exit 2.
1271 process.exit((command ?? process.argv[2]) === "wait" ? 2 : 1)
1272 }
1273}
1275await main()