@protoboardai/cli 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,727 @@
1
+ /**
2
+ * `protoboard dev` (PB-815): Protoboard's web viewer (@protoboardai/web-viewer,
3
+ * the browser shell over the viewer stack, docs/specs/viewer-stack.md) on the
4
+ * project, with a Protoboard adapter.
5
+ *
6
+ * - Builds the project's UHD bundle (every validator that can run, so the
7
+ * bundle carries Protoboard's aggregate diagnostics) in a child process,
8
+ * and serves it with the viewer's own handler (`viewerHandler`), which
9
+ * resolves artifacts against each definition's base (the project, a package)
10
+ * and serves only files the bundle references, inside their base.
11
+ * - Serves a host page that mounts the web viewer (`mountWebViewer`) and
12
+ * passes the dev session's own findings as host diagnostics, and the views
13
+ * the project's extensions contribute as tabs.
14
+ * - Watches the project's sources, parts, configuration and display file;
15
+ * rebuilds on a change and pushes the new bundle into the open viewer, which
16
+ * keeps its view, drill level and layout.
17
+ * - Writes layout edits from the viewer (`onLayoutChange`) to the display
18
+ * file under `diagram.layouts`, and pushes a display file changed on disk
19
+ * (by hand, by git) to the viewer.
20
+ * - Relays the viewer's host messages when the page is framed (relay.js;
21
+ * parent origins: `hostOrigins`), and answers where a definition or an
22
+ * instance is written (`/__protoboard/source`) from the build's source
23
+ * index, for the viewer's openSource.
24
+ * - Lets a host's own page read the session: a GET from an allowed host
25
+ * origin (`hostOrigins`, default VS Code webviews) of the bundle, its
26
+ * artifacts, the state or the events gets CORS headers, so the VS Code
27
+ * extension's viewer tabs load them without framing the page. Nothing
28
+ * else is opened: not the page (it carries the write token), not the
29
+ * source lookup, not writes.
30
+ *
31
+ * - Records itself in `.protoboard/local/dev.json` (URL, session, pid) while
32
+ * it runs, so an editor can attach to it instead of starting another; the
33
+ * state route returns the same session, which tells a live server from a
34
+ * stale record.
35
+ *
36
+ * Bound to 127.0.0.1 by default, answering only requests addressed to a
37
+ * loopback name. Writes need the session token the page carries and a JSON
38
+ * body, so another site's page cannot post a layout.
39
+ */
40
+ import { spawn, type ChildProcess } from "node:child_process";
41
+ import { createHash, randomBytes } from "node:crypto";
42
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync, type FSWatcher } from "node:fs";
43
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
44
+ import { createRequire } from "node:module";
45
+ import type { AddressInfo } from "node:net";
46
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
47
+ import { fileURLToPath, pathToFileURL } from "node:url";
48
+ import { stableStringify } from "@protoboardai/core";
49
+ import type { BuildReport } from "./build-worker.js";
50
+ import { applyViewerLayout, formatDisplay, parseLayoutRequest, type DisplayFile } from "./layout.js";
51
+ import { DEV_ROUTES, DEV_SCRIPT, FAVICON_SVG, TOKEN_HEADER, devPage } from "./page.js";
52
+ import { SOURCES_FILE, resolveSource, type SourceIndex } from "./sources.js";
53
+ import { DEFAULT_HOST_ORIGINS, originAllowed } from "./relay.js";
54
+ import { PARTS_PREFIX, partsRoutes } from "./parts.js";
55
+
56
+ export const DEFAULT_DEV_PORT = 5180;
57
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
58
+
59
+ /**
60
+ * A write's Origin is the page's own: the host it asked (http or https: a
61
+ * proxy may terminate TLS), or the host a proxy forwarded it from. Writes
62
+ * also need the session token, which only the page carries.
63
+ */
64
+ function sameSite(origin: string, req: IncomingMessage): boolean {
65
+ let host: string;
66
+ try {
67
+ const u = new URL(origin);
68
+ if (u.protocol !== "http:" && u.protocol !== "https:") return false;
69
+ host = u.host.toLowerCase();
70
+ } catch {
71
+ return false;
72
+ }
73
+ const forwarded = String(req.headers["x-forwarded-host"] ?? "").split(",")[0].trim().toLowerCase();
74
+ return host === (req.headers.host ?? "").toLowerCase() || (!!forwarded && host === forwarded);
75
+ }
76
+ const MAX_BODY = 4 << 20;
77
+ /** The running session's record, relative to the project root. */
78
+ export const DEV_RECORD = join(".protoboard", "local", "dev.json");
79
+
80
+ /** `.protoboard/local/dev.json`: where a running `protoboard dev` serves the project. */
81
+ export interface DevRecord {
82
+ schema: "protoboard.dev/v0";
83
+ url: string;
84
+ port: number;
85
+ /** Process serving it. */
86
+ pid: number;
87
+ /** Matches `session` from the state route while this server runs. */
88
+ session: string;
89
+ root: string;
90
+ startedAt: string;
91
+ }
92
+
93
+ /** A finding from the dev session itself, shown in the viewer next to the project's (`UhdBundleDiagnostic`). */
94
+ export interface DevDiagnostic {
95
+ code: string;
96
+ severity: "error" | "warning" | "info";
97
+ message: string;
98
+ entity?: { kind: string; id: string };
99
+ source?: { file: string; line?: number };
100
+ validator: "protoboard/dev";
101
+ }
102
+
103
+ /** One structured log line. */
104
+ export interface DevLogEvent {
105
+ level: "info" | "warn" | "error";
106
+ event: "listening" | "build" | "build-failed" | "display" | "layout" | "layout-refused" | "watch" | "error" | "stopped";
107
+ message: string;
108
+ [key: string]: unknown;
109
+ }
110
+
111
+ export interface DevOptions {
112
+ /** Project root (holds protoboard.config.ts). */
113
+ root: string;
114
+ /** First port to try (default 5180; 0 picks any free port). */
115
+ port?: number;
116
+ /** Fail when `port` is taken instead of trying the next ones. */
117
+ strictPort?: boolean;
118
+ /** Interface to bind (default 127.0.0.1). Another address also accepts requests addressed to any host name. */
119
+ host?: string;
120
+ /** Watch the project and rebuild on changes (default true). */
121
+ watch?: boolean;
122
+ /** Quiet period after a change before rebuilding (default 120 ms). */
123
+ debounceMs?: number;
124
+ log?: (event: DevLogEvent) => void;
125
+ /** Where the web viewer's built files are (default: the installed `@protoboardai/web-viewer`'s dist). */
126
+ viewerLib?: string;
127
+ /**
128
+ * Origins of a window framing the page that may drive the viewer and hear
129
+ * its events (exact origins, `scheme://*`, `*`, or `self`). Default: the
130
+ * page's own origin and VS Code webviews (relay.js, DEFAULT_HOST_ORIGINS).
131
+ */
132
+ hostOrigins?: string[];
133
+ /**
134
+ * Host names answered besides the loopback ones when bound to loopback
135
+ * (`*`: any). A proxy in front of the server (a browser-hosted VS Code's
136
+ * port forwarding, code-server's `/proxy/<port>/`) may pass its own Host
137
+ * on; the server refuses other names to keep DNS rebinding out.
138
+ */
139
+ allowHosts?: string[];
140
+ /**
141
+ * Write the session's record (.protoboard/local/dev.json) for editors to find (default true).
142
+ * `protoboard views check` serves the project for itself only and leaves a person's session alone.
143
+ */
144
+ record?: boolean;
145
+ }
146
+
147
+ export interface DevServer {
148
+ url: string;
149
+ port: number;
150
+ bundleFile: string;
151
+ displayFile: string;
152
+ /** Bundle version: increases with every rebuild or display change pushed to the viewer. */
153
+ readonly version: number;
154
+ /** Rebuild now (as a change would), resolving once the result is pushed. */
155
+ rebuild(trigger?: string): Promise<BuildReport>;
156
+ /** Resolves when no build or display update is pending. */
157
+ idle(): Promise<void>;
158
+ close(): Promise<void>;
159
+ }
160
+
161
+ const require = createRequire(import.meta.url);
162
+ const digest = (b: string | Buffer) => createHash("sha256").update(b).digest("hex");
163
+
164
+ /** The web viewer's built files (the page's script and stylesheet). */
165
+ export const WEB_VIEWER_FILES = ["web-viewer.js", "web-viewer.css"] as const;
166
+
167
+ /** The installed viewers: the UHD viewer's request handler (bundle, artifacts) and the web viewer's build. */
168
+ async function loadViewer(libOverride?: string) {
169
+ const serve = (await import("@deltarobotics/uhd-viewer/serve")) as typeof import("@deltarobotics/uhd-viewer/serve");
170
+ const pkgDir = libOverride ? undefined : dirname(require.resolve("@protoboardai/web-viewer/package.json"));
171
+ const lib = libOverride ?? join(pkgDir!, "dist");
172
+ for (const f of WEB_VIEWER_FILES) {
173
+ if (!existsSync(join(lib, f))) throw new Error(`the web viewer is not built (${join(lib, f)} is missing): run \`npm run build\` in ${pkgDir ?? "@protoboardai/web-viewer"}`);
174
+ }
175
+ return { viewerHandler: serve.viewerHandler, lib };
176
+ }
177
+
178
+ function writeAtomic(file: string, data: string) {
179
+ mkdirSync(dirname(file), { recursive: true });
180
+ const tmp = join(dirname(file), `.${basename(file)}.${process.pid}.tmp`);
181
+ writeFileSync(tmp, data);
182
+ renameSync(tmp, file);
183
+ }
184
+
185
+ /** What a build says when the project's folder is not there any more (B5). */
186
+ export function folderGoneMessage(root: string): string {
187
+ return `the project folder ${root} is no longer there: it was moved or deleted`;
188
+ }
189
+
190
+ /**
191
+ * What the viewer says when the dev server cannot watch the project (B19):
192
+ * the system refused a watcher (EMFILE, ENOSPC: too many watched files on the
193
+ * machine), so changes to the files no longer reach the viewer.
194
+ */
195
+ export function unwatchedMessage(what: string, e: Error): string {
196
+ const code = (e as NodeJS.ErrnoException).code;
197
+ const why = code === "EMFILE" || code === "ENOSPC" ? `the system allows no more file watchers (${code})` : e.message;
198
+ return `Live reload is off: the dev server cannot watch ${what} (${why}). The viewer does not follow changes to the files; close programs that watch many files, then reload the window.`;
199
+ }
200
+
201
+ /** Directories to watch for a project: [dir, recursive]. Nested ones are dropped. */
202
+ export function watchPlan(root: string, w: NonNullable<BuildReport["watch"]>): [string, boolean][] {
203
+ const dirs: [string, boolean][] = [[root, false]];
204
+ const tree = (dir: string) => {
205
+ // never the whole project recursively: its node_modules would be watched too
206
+ if (dir !== root) return dirs.push([dir, true]);
207
+ if (!existsSync(root)) return;
208
+ for (const e of readdirSync(root, { withFileTypes: true })) {
209
+ if (e.isDirectory() && !ignored(e.name, "")) dirs.push([join(root, e.name), true]);
210
+ }
211
+ };
212
+ for (const g of w.sources) {
213
+ const m = g.match(/^(.*?)\/?\*\*/);
214
+ if (m) tree(resolve(root, m[1] || "."));
215
+ else dirs.push([resolve(root, dirname(g)), false]);
216
+ }
217
+ tree(resolve(root, dirname(w.catalog)));
218
+ dirs.push([resolve(root, dirname(w.display)), false]);
219
+ if (w.scenarios) dirs.push([resolve(root, dirname(w.scenarios)), false]);
220
+ const recursive = dirs.filter(([, r]) => r).map(([d]) => d);
221
+ const inside = (d: string, parent: string) => d !== parent && (d + sep).startsWith(parent.endsWith(sep) ? parent : parent + sep);
222
+ const seen = new Set<string>();
223
+ return dirs
224
+ .filter(([d, r]) => (r ? !recursive.some((p) => inside(d, p)) : !recursive.some((p) => d === p || inside(d, p))))
225
+ .filter(([d]) => (seen.has(d) ? false : (seen.add(d), true)));
226
+ }
227
+
228
+ /** Files whose changes do not matter: dependencies, build output, editor temporaries. */
229
+ function ignored(rel: string, displayRel: string): boolean {
230
+ if (rel === displayRel) return false;
231
+ const parts = rel.split(/[\\/]/);
232
+ const name = parts[parts.length - 1];
233
+ if (parts.some((p) => p === "node_modules" || p === "__pycache__" || p === ".git" || p === ".protoboard")) return true;
234
+ if (name.startsWith(".") || name.endsWith("~") || /\.(swp|swx|tmp|crdownload)$/.test(name) || name === "4913") return true;
235
+ return parts[0] === "operations";
236
+ }
237
+
238
+ export async function startDev(options: DevOptions): Promise<DevServer> {
239
+ const root = resolve(options.root);
240
+ const log = options.log ?? (() => {});
241
+ const host = options.host ?? "127.0.0.1";
242
+ const loopback = LOOPBACK.has(host);
243
+ const extra = new Set((options.allowHosts ?? []).map((h) => h.toLowerCase()));
244
+ const allowHost = !loopback || extra.has("*") ? () => true : (h: string) => LOOPBACK.has(h) || extra.has(h.toLowerCase());
245
+ const viewer = await loadViewer(options.viewerLib);
246
+ const bundleFile = join(root, ".protoboard", "build", "uhd-bundle.json");
247
+ const handleViewer = viewer.viewerHandler({ bundleFile, allowHost });
248
+ const token = randomBytes(18).toString("base64url");
249
+ const session = randomBytes(9).toString("base64url");
250
+ const tsx = pathToFileURL(require.resolve("tsx/esm")).href;
251
+ const worker = fileURLToPath(new URL("./build-worker.ts", import.meta.url));
252
+
253
+ let watchConfig: NonNullable<BuildReport["watch"]> = { display: ".protoboard/display.json", sources: ["src/**/*.uhd.ts"], catalog: "parts/catalog" };
254
+ const displayFile = () => resolve(root, watchConfig.display);
255
+ const displayRel = () => relative(root, displayFile());
256
+ let version = 0;
257
+ let devDiagnostics: DevDiagnostic[] = [];
258
+ let lastBuild: BuildReport | undefined;
259
+ /** Views the extensions of the last good build contribute (the web viewer's extra tabs). */
260
+ let lastViews: NonNullable<BuildReport["views"]> = [];
261
+ let lastPartStatus: NonNullable<BuildReport["partStatus"]> = {};
262
+ let building = false;
263
+ let closed = false;
264
+ let child: ChildProcess | undefined;
265
+ /** Digest of the display file as this server last wrote it (its own write is not news). */
266
+ let ownDisplay: string | undefined;
267
+ let message = "starting";
268
+ /** Why the project is not watched (B19): live reload is off, and the viewer says so. */
269
+ let unwatched: string | undefined;
270
+ const notWatching = (dir: string, e: Error) => {
271
+ const what = relative(root, dir) || "the project folder";
272
+ log({ level: "warn", event: "watch", message: `cannot watch ${relative(root, dir) || "."}: ${e.message}` });
273
+ // a folder deleted while watched (ENOENT) is ordinary: the next build watches what is there. The system refusing
274
+ // more watchers is what stops live reload
275
+ const code = (e as NodeJS.ErrnoException).code;
276
+ if (closed || code === "ENOENT" || code === "ENOTDIR") return;
277
+ unwatched = unwatchedMessage(what, e);
278
+ broadcast("watch", { unwatched });
279
+ };
280
+
281
+ // ---- events to open pages
282
+ const clients = new Set<ServerResponse>();
283
+ const send = (res: ServerResponse, event: string, data: unknown) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
284
+ const broadcast = (event: string, data: unknown) => {
285
+ for (const res of clients) send(res, event, data);
286
+ };
287
+ const heartbeat = setInterval(() => {
288
+ for (const res of clients) res.write(": ping\n\n");
289
+ }, 15000);
290
+ heartbeat.unref();
291
+
292
+ // ---- one task at a time: builds and display updates both rewrite the bundle
293
+ let chain: Promise<unknown> = Promise.resolve();
294
+ const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
295
+ const run = chain.then(task, task);
296
+ chain = run.catch(() => undefined);
297
+ return run;
298
+ };
299
+ /** Run a task in the background: a failure is logged, never left unhandled. */
300
+ const background = (task: Promise<unknown>) => void task.catch((e: Error) => log({ level: "error", event: "error", message: e.message }));
301
+
302
+ const runWorker = () =>
303
+ new Promise<BuildReport>((ok) => {
304
+ const t0 = Date.now();
305
+ // a project folder moved or deleted under the server (B5): the system would answer the spawn with ENOENT
306
+ if (!existsSync(root)) return ok({ ok: false, ms: 0, error: folderGoneMessage(root) });
307
+ const proc = spawn(process.execPath, ["--import", tsx, worker, root, bundleFile], { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
308
+ child = proc;
309
+ let out = "";
310
+ let err = "";
311
+ proc.stdout.on("data", (d) => (out += d));
312
+ proc.stderr.on("data", (d) => (err += d));
313
+ const timer = setTimeout(() => proc.kill(), 5 * 60 * 1000);
314
+ // a process that could not start emits "error" (and maybe no "close"): never an unhandled event that ends the server
315
+ let settled = false;
316
+ proc.on("error", (e) => {
317
+ clearTimeout(timer);
318
+ child = undefined;
319
+ if (settled) return;
320
+ settled = true;
321
+ ok({ ok: false, ms: Date.now() - t0, error: existsSync(root) ? `the build process could not start: ${e.message}` : folderGoneMessage(root) });
322
+ });
323
+ proc.on("close", (code, signal) => {
324
+ clearTimeout(timer);
325
+ child = undefined;
326
+ if (settled) return;
327
+ settled = true;
328
+ const last = out.trim().split("\n").filter((l) => l.startsWith("{")).pop();
329
+ try {
330
+ // the whole build as the session sees it, process start included
331
+ if (last) return ok({ ...(JSON.parse(last) as BuildReport), ms: Date.now() - t0 });
332
+ } catch {
333
+ // fall through: report the process failure
334
+ }
335
+ const why = signal ? `stopped by ${signal}` : `exited with ${code}`;
336
+ ok({ ok: false, ms: Date.now() - t0, error: `build process ${why}${err.trim() ? `: ${err.trim().split("\n").slice(-2).join(" ")}` : ""}` });
337
+ });
338
+ });
339
+
340
+ /** Make the bundle's display hints match the display file on disk. */
341
+ const syncDisplay = (): boolean => {
342
+ if (!existsSync(bundleFile)) return false;
343
+ const file = displayFile();
344
+ let display: unknown;
345
+ try {
346
+ display = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : undefined;
347
+ } catch (e) {
348
+ devDiagnostics = [
349
+ ...devDiagnostics.filter((d) => d.code !== "PB_DEV_DISPLAY_INVALID"),
350
+ { code: "PB_DEV_DISPLAY_INVALID", severity: "error", message: `${displayRel()} is not valid JSON (${(e as Error).message}); the viewer keeps the last valid layout`, source: { file: displayRel() }, validator: "protoboard/dev" },
351
+ ];
352
+ return true;
353
+ }
354
+ devDiagnostics = devDiagnostics.filter((d) => d.code !== "PB_DEV_DISPLAY_INVALID");
355
+ const bundle = JSON.parse(readFileSync(bundleFile, "utf8")) as Record<string, unknown>;
356
+ if (stableStringify(bundle.display ?? null) === stableStringify(display ?? null)) return false;
357
+ if (display === undefined) delete bundle.display;
358
+ else bundle.display = display;
359
+ writeAtomic(bundleFile, stableStringify(bundle));
360
+ return true;
361
+ };
362
+
363
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
364
+ const summary = (r: BuildReport) => (r.ok ? `built in ${(r.ms / 1000).toFixed(1)}s: ${plural(r.counts!.error, "error")} · ${plural(r.counts!.warning, "warning")}` : `build failed: ${r.error}`);
365
+
366
+ const build = (triggers: string[]) =>
367
+ enqueue(async () => {
368
+ building = true;
369
+ broadcast("status", { state: "busy", message: triggers.length ? `rebuilding (${triggers.slice(0, 3).join(", ")}${triggers.length > 3 ? ", …" : ""})` : "building" });
370
+ const r = await runWorker();
371
+ building = false;
372
+ if (closed) return r;
373
+ lastBuild = r;
374
+ if (r.ok) {
375
+ if (r.watch) watchConfig = r.watch;
376
+ lastViews = r.views ?? [];
377
+ lastPartStatus = r.partStatus ?? {};
378
+ devDiagnostics = devDiagnostics.filter((d) => d.code !== "PB_DEV_BUILD_FAILED");
379
+ syncDisplay(); // a layout saved while the build ran
380
+ log({ level: "info", event: "build", message: summary(r), ms: r.ms, counts: r.counts, trigger: triggers, version: version + 1 });
381
+ rewatch();
382
+ // the configuration (its libraries) or the parts may have changed
383
+ if (triggers.length) parts.changed();
384
+ } else {
385
+ devDiagnostics = [
386
+ ...devDiagnostics.filter((d) => d.code !== "PB_DEV_BUILD_FAILED"),
387
+ { code: "PB_DEV_BUILD_FAILED", severity: "error", message: `rebuild failed, showing the last good bundle: ${r.error}`, validator: "protoboard/dev" },
388
+ ];
389
+ log({ level: "error", event: "build-failed", message: summary(r), ms: r.ms, error: r.error, trigger: triggers });
390
+ }
391
+ version++;
392
+ message = summary(r);
393
+ broadcast("bundle", { version, reason: "source", diagnostics: devDiagnostics, failed: !r.ok, message, partStatus: lastPartStatus, ...(unwatched ? { unwatched } : {}) });
394
+ return r;
395
+ });
396
+
397
+ const displayChanged = () =>
398
+ enqueue(async () => {
399
+ const file = displayFile();
400
+ const bytes = existsSync(file) ? readFileSync(file) : undefined;
401
+ if (bytes && ownDisplay === digest(bytes)) return; // our own save, already pushed
402
+ ownDisplay = undefined;
403
+ if (!syncDisplay()) return;
404
+ version++;
405
+ message = devDiagnostics.some((d) => d.code === "PB_DEV_DISPLAY_INVALID") ? `${displayRel()} is not valid JSON` : `${displayRel()} changed on disk`;
406
+ log({ level: "info", event: "display", message, version });
407
+ broadcast("bundle", { version, reason: "display", diagnostics: devDiagnostics, message });
408
+ });
409
+
410
+ // ---- watching
411
+ const watchers = new Map<string, FSWatcher>();
412
+ let timer: NodeJS.Timeout | undefined;
413
+ const changed = new Set<string>();
414
+ let displayTouched = false;
415
+ const onChange = (dir: string) => (_event: string, name: string | Buffer | null) => {
416
+ if (closed || !name) return;
417
+ const rel = relative(root, join(dir, name.toString()));
418
+ if (rel.startsWith("..") || isAbsolute(rel) || ignored(rel, displayRel())) return;
419
+ if (rel === displayRel()) displayTouched = true;
420
+ else if (dir === root || !watchers.has(`${dir}|true`)) {
421
+ // a folder watched on its own: the configuration, sources named one by one, and new folders the next build watches
422
+ const named = rel === "protoboard.config.ts" || watchConfig.sources.includes(rel) || (watchConfig.scenarios !== undefined && rel === watchConfig.scenarios);
423
+ let folder = false;
424
+ try {
425
+ folder = statSync(join(root, rel)).isDirectory();
426
+ } catch {
427
+ // deleted: only a named file matters
428
+ }
429
+ if (!named && !(folder && dir === root)) return;
430
+ changed.add(rel);
431
+ } else changed.add(rel);
432
+ clearTimeout(timer);
433
+ timer = setTimeout(flush, options.debounceMs ?? 120);
434
+ };
435
+ const flush = () => {
436
+ if (displayTouched) {
437
+ displayTouched = false;
438
+ background(displayChanged());
439
+ }
440
+ if (changed.size) {
441
+ const triggers = [...changed].sort();
442
+ changed.clear();
443
+ background(build(triggers));
444
+ }
445
+ };
446
+ function rewatch() {
447
+ if (options.watch === false || closed) return;
448
+ for (const [dir, recursive] of watchPlan(root, watchConfig)) {
449
+ const key = `${dir}|${recursive}`;
450
+ if (watchers.has(key) || !existsSync(dir)) continue;
451
+ try {
452
+ const w = watch(dir, { recursive }, onChange(dir));
453
+ w.on("error", (e) => {
454
+ watchers.delete(key);
455
+ notWatching(dir, e);
456
+ });
457
+ watchers.set(key, w);
458
+ } catch (e) {
459
+ notWatching(dir, e as Error);
460
+ }
461
+ }
462
+ }
463
+
464
+ // ---- layout edits from the viewer
465
+ const saveLayout = async (req: IncomingMessage, res: ServerResponse) => {
466
+ const json = (status: number, body: unknown) => {
467
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
468
+ res.end(JSON.stringify(body));
469
+ };
470
+ const origin = req.headers.origin;
471
+ if (req.headers[TOKEN_HEADER] !== token) return json(403, { error: "missing or wrong session token" });
472
+ if (origin && !sameSite(origin, req)) return json(403, { error: `origin ${origin} not allowed` });
473
+ if (!/^application\/json\b/.test(req.headers["content-type"] ?? "")) return json(415, { error: "expected application/json" });
474
+ let size = 0;
475
+ const chunks: Buffer[] = [];
476
+ for await (const c of req) {
477
+ size += (c as Buffer).length;
478
+ if (size > MAX_BODY) return json(413, { error: "layout too large" });
479
+ chunks.push(c as Buffer);
480
+ }
481
+ let body: unknown;
482
+ try {
483
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
484
+ } catch {
485
+ return json(400, { error: "body is not JSON" });
486
+ }
487
+ const parsed = parseLayoutRequest(body);
488
+ if ("error" in parsed) {
489
+ log({ level: "warn", event: "layout-refused", message: parsed.error });
490
+ return json(400, parsed);
491
+ }
492
+ const file = displayFile();
493
+ let current: DisplayFile = {};
494
+ let previous: string | undefined;
495
+ try {
496
+ if (existsSync(file)) current = JSON.parse((previous = readFileSync(file, "utf8"))) as DisplayFile;
497
+ } catch (e) {
498
+ return json(409, { error: `${displayRel()} is not valid JSON (${(e as Error).message}); fix it before saving a layout` });
499
+ }
500
+ const merged = applyViewerLayout(current, parsed.layout, parsed.snapshot);
501
+ devDiagnostics = devDiagnostics.filter((d) => d.code !== "PB_DEV_LAYOUT_NOT_SAVED");
502
+ if (merged.skipped.length) {
503
+ devDiagnostics.push(...merged.skipped.map((s): DevDiagnostic => ({ code: "PB_DEV_LAYOUT_NOT_SAVED", severity: "warning", message: s.reason, source: { file: displayRel() }, validator: "protoboard/dev" })));
504
+ }
505
+ if (merged.levels.length) {
506
+ const data = formatDisplay(merged.display, previous);
507
+ ownDisplay = digest(data);
508
+ writeAtomic(file, data);
509
+ log({ level: "info", event: "layout", message: `saved ${merged.levels.map((l) => (l === "" ? "top level" : `level ${l}`)).join(", ")} to ${displayRel()}`, levels: merged.levels, skipped: merged.skipped });
510
+ background(enqueue(async () => {
511
+ syncDisplay();
512
+ version++;
513
+ broadcast("bundle", { version, reason: "layout", client: parsed.client, diagnostics: devDiagnostics, message: `layout saved · ${displayRel()}` });
514
+ }));
515
+ } else if (merged.skipped.length) log({ level: "warn", event: "layout", message: merged.skipped.map((s) => s.reason).join("; "), skipped: merged.skipped });
516
+ return json(200, { file: displayRel(), levels: merged.levels, skipped: merged.skipped });
517
+ };
518
+
519
+ // ---- requests
520
+ const lib = (file: string, type: string) => (res: ServerResponse) => {
521
+ res.writeHead(200, { "content-type": type, "cache-control": "no-cache" });
522
+ res.end(readFileSync(join(viewer.lib, file)));
523
+ };
524
+ const json = (res: ServerResponse, status: number, body: unknown) => {
525
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
526
+ res.end(JSON.stringify(body));
527
+ };
528
+ /** Where a definition and an instance are written, from the last good build's index. */
529
+ const source = (url: URL, res: ServerResponse) => {
530
+ const defId = url.searchParams.get("defId") ?? undefined;
531
+ const instancePath = url.searchParams.get("instancePath") ?? undefined;
532
+ if (!defId && !instancePath) return json(res, 400, { error: "give defId or instancePath" });
533
+ let index: SourceIndex;
534
+ try {
535
+ index = JSON.parse(readFileSync(join(dirname(bundleFile), SOURCES_FILE), "utf8")) as SourceIndex;
536
+ } catch {
537
+ return json(res, 503, { error: "no source index yet (the project has not built)" });
538
+ }
539
+ const found = resolveSource(index, root, { defId, instancePath });
540
+ return found ? json(res, 200, found) : json(res, 404, { error: `no source known for ${defId ?? instancePath}` });
541
+ };
542
+ const relayFile = fileURLToPath(new URL("./relay.js", import.meta.url));
543
+ // the library and bom tabs' data (./parts.ts); a write needs the page's token and origin, as a layout does
544
+ const parts = partsRoutes(root);
545
+ const routes: Record<string, (res: ServerResponse) => void> = {
546
+ [DEV_ROUTES.page]: (res) => {
547
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
548
+ res.end(devPage({ name: basename(root), token, session, ...(options.hostOrigins?.length ? { hostOrigins: options.hostOrigins } : {}) }));
549
+ },
550
+ [DEV_ROUTES.relay]: (res) => {
551
+ res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
552
+ res.end(readFileSync(relayFile));
553
+ },
554
+ "/index.html": (res) => routes[DEV_ROUTES.page](res),
555
+ // a browser that asks anyway (the page names an inline icon) gets the mark, not a 404
556
+ "/favicon.ico": (res) => {
557
+ res.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "max-age=86400" });
558
+ res.end(FAVICON_SVG);
559
+ },
560
+ [DEV_ROUTES.script]: (res) => {
561
+ res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
562
+ res.end(DEV_SCRIPT);
563
+ },
564
+ [DEV_ROUTES.viewerJs]: lib("web-viewer.js", "text/javascript; charset=utf-8"),
565
+ [DEV_ROUTES.viewerCss]: lib("web-viewer.css", "text/css; charset=utf-8"),
566
+ [DEV_ROUTES.state]: (res) => {
567
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
568
+ res.end(JSON.stringify({ session, version, diagnostics: devDiagnostics, building, failed: lastBuild ? !lastBuild.ok : false, message, ...(unwatched ? { unwatched } : {}), views: lastViews, partStatus: lastPartStatus, display: displayRel().split(sep).join("/") }));
569
+ },
570
+ [DEV_ROUTES.events]: (res) => {
571
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
572
+ res.write("retry: 1000\n\n");
573
+ send(res, "hello", { session, version });
574
+ clients.add(res);
575
+ res.on("close", () => clients.delete(res));
576
+ },
577
+ };
578
+ /** Origins of host pages that may read the session (not `self`: the dev page is same-origin already). */
579
+ const readOrigins = (options.hostOrigins?.length ? options.hostOrigins : DEFAULT_HOST_ORIGINS).filter((o) => o !== "self");
580
+ const handler = async (req: IncomingMessage, res: ServerResponse) => {
581
+ const hostname = (req.headers.host ?? "").replace(/:\d+$/, "");
582
+ if (!allowHost(hostname)) {
583
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
584
+ return res.end(`host ${hostname} not allowed`);
585
+ }
586
+ let path: string;
587
+ try {
588
+ path = new URL(req.url ?? "/", "http://x").pathname;
589
+ } catch {
590
+ res.writeHead(400);
591
+ return res.end("bad request");
592
+ }
593
+ if (path.startsWith(PARTS_PREFIX)) {
594
+ const origin = req.headers.origin;
595
+ const authorised = req.headers[TOKEN_HEADER] === token && (!origin || sameSite(origin, req)) && /^application\/json\b/.test(req.headers["content-type"] ?? "");
596
+ return parts.handle(req, res, new URL(req.url ?? "/", "http://x"), { authorised });
597
+ }
598
+ if (path === DEV_ROUTES.layout) {
599
+ if (req.method !== "POST") {
600
+ res.writeHead(405, { allow: "POST" });
601
+ return res.end("method not allowed");
602
+ }
603
+ return saveLayout(req, res).catch((e: Error) => {
604
+ if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
605
+ res.end(e.message);
606
+ });
607
+ }
608
+ if (req.method !== "GET" && req.method !== "HEAD") {
609
+ res.writeHead(405, { allow: "GET, HEAD" });
610
+ return res.end("method not allowed");
611
+ }
612
+ // a host's own page (the VS Code extension's viewer webview) reads the bundle, its artifacts,
613
+ // the state and the events from its origin; writes stay with the dev page (token, same site)
614
+ // only the routes the tab reads: never the page (it carries the write token) or the source lookup
615
+ const origin = req.headers.origin;
616
+ const shared = path === "/uhd-bundle.json" || path.startsWith("/artifacts/") || path === DEV_ROUTES.state || path === DEV_ROUTES.events;
617
+ if (shared && origin && readOrigins.length && originAllowed(origin, readOrigins)) {
618
+ res.setHeader("access-control-allow-origin", origin);
619
+ res.setHeader("vary", "Origin");
620
+ }
621
+ if (path === DEV_ROUTES.source) return source(new URL(req.url ?? "/", "http://x"), res);
622
+ const route = routes[path];
623
+ if (route) return route(res);
624
+ // the bundle and its artifacts: the viewer's handler (only referenced files, inside their base)
625
+ if (path === "/uhd-bundle.json" || path.startsWith("/artifacts/")) return handleViewer(req, res);
626
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
627
+ res.end("not found");
628
+ };
629
+
630
+ // ---- start: build first (nothing to serve without a bundle), then listen and watch
631
+ const server: Server = createServer((req, res) => void handler(req, res));
632
+ let port: number;
633
+ try {
634
+ const first = await build([]);
635
+ if (!first.ok) throw new Error(`could not build the project: ${first.error}`);
636
+ port = await listen(server, host, options.port ?? DEFAULT_DEV_PORT, options.strictPort ?? false);
637
+ } catch (e) {
638
+ closed = true;
639
+ clearInterval(heartbeat);
640
+ throw e;
641
+ }
642
+ const shown = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
643
+ const url = `http://${shown.includes(":") ? `[${shown}]` : shown}:${port}/`;
644
+ rewatch();
645
+ const recordFile = join(root, DEV_RECORD);
646
+ const record: DevRecord = { schema: "protoboard.dev/v0", url, port, pid: process.pid, session, root, startedAt: new Date().toISOString() };
647
+ try {
648
+ if (options.record !== false) writeAtomic(recordFile, JSON.stringify(record, null, 2) + "\n");
649
+ } catch (e) {
650
+ log({ level: "warn", event: "error", message: `cannot write ${DEV_RECORD}: ${(e as Error).message}; editors will not find this session` });
651
+ }
652
+ log({ level: "info", event: "listening", message: `serving ${basename(root)} at ${url}${options.watch === false ? " (not watching)" : ""}`, url, port, bundle: relative(root, bundleFile), display: displayRel() });
653
+
654
+ return {
655
+ url,
656
+ port,
657
+ bundleFile,
658
+ get displayFile() {
659
+ return displayFile();
660
+ },
661
+ get version() {
662
+ return version;
663
+ },
664
+ rebuild: (trigger = "manual") => build([trigger]),
665
+ idle: async () => {
666
+ // a change waiting for its quiet period counts as pending
667
+ while (timer && (changed.size || displayTouched)) await new Promise((r) => setTimeout(r, 20));
668
+ await chain;
669
+ },
670
+ close: async () => {
671
+ if (closed) return;
672
+ closed = true;
673
+ clearTimeout(timer);
674
+ clearInterval(heartbeat);
675
+ for (const w of watchers.values()) w.close();
676
+ child?.kill();
677
+ for (const res of clients) res.end();
678
+ clients.clear();
679
+ await new Promise<void>((ok) => {
680
+ server.close(() => ok());
681
+ server.closeAllConnections();
682
+ });
683
+ await chain;
684
+ try {
685
+ // only our own record: another session may have replaced it
686
+ if ((JSON.parse(readFileSync(recordFile, "utf8")) as DevRecord).session === session) rmSync(recordFile);
687
+ } catch {
688
+ // gone or unreadable: nothing to remove
689
+ }
690
+ log({ level: "info", event: "stopped", message: "stopped" });
691
+ },
692
+ };
693
+ }
694
+
695
+ /**
696
+ * Listen on `port`, or the next free one (up to 20 further) unless `strict`.
697
+ * When all of those are taken (a machine with many windows open, each with
698
+ * its project's dev server), any free port the system gives: a twenty-first
699
+ * project still gets its viewer.
700
+ */
701
+ async function listen(server: Server, host: string, port: number, strict: boolean): Promise<number> {
702
+ const on = (p: number) =>
703
+ new Promise<number>((ok, fail) => {
704
+ // each try takes both of its listeners away again: twenty tries leave none behind
705
+ const listening = () => {
706
+ server.off("error", failed);
707
+ ok((server.address() as AddressInfo).port);
708
+ };
709
+ const failed = (e: Error) => {
710
+ server.off("listening", listening);
711
+ fail(e);
712
+ };
713
+ server.once("listening", listening);
714
+ server.once("error", failed);
715
+ server.listen(p, host);
716
+ });
717
+ for (let p = port, tries = 0; ; p++, tries++) {
718
+ try {
719
+ return await on(p);
720
+ } catch (e) {
721
+ if ((e as NodeJS.ErrnoException).code !== "EADDRINUSE" || strict || port === 0) {
722
+ throw new Error((e as NodeJS.ErrnoException).code === "EADDRINUSE" ? `port ${p} is in use` : (e as Error).message);
723
+ }
724
+ if (tries >= 20) return on(0);
725
+ }
726
+ }
727
+ }