@geml/geml 1.4.2 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/codemap/serve.mjs CHANGED
@@ -1,578 +1,578 @@
1
- #!/usr/bin/env node
2
- // geml codemap serve — live viewer for a codemap directory.
3
- //
4
- // geml codemap serve [codemap-dir] [--port 8140] foreground
5
- // geml codemap serve [codemap-dir] [--port 8140] --background survives the session
6
- // geml codemap serve [codemap-dir] --stop stop a background server
7
- // geml codemap serve [codemap-dir] --watch editing-time sync: re-run the
8
- // recorded recipe when indexed sources change (30s quiet)
9
- //
10
- // Every *.html request is rendered FROM ITS *.geml AT REQUEST TIME, so the
11
- // pages are never stale: rebuild the codemap (or upgrade the renderer) and a
12
- // browser refresh shows the new state — no pre-render step. Pre-rendered
13
- // static .html files (from `geml codemap render`) are served only when no
14
- // .geml source exists for the path.
15
- //
16
- // --background detaches the server from the launching process (an agent
17
- // session ending must not take the viewer down): stdio goes to
18
- // _index/serve.log, the pid lands in _index/serve.pid, and the parent waits
19
- // until the port actually answers before reporting the URL.
20
- //
21
- // Local viewer by design: binds 127.0.0.1. HEAD is answered without a body —
22
- // the in-page navigation probes targets before embedding them.
23
- //
24
- // The pieces are exported (and the auto-run at the bottom is main-module
25
- // guarded) so the test suite can drive them in-process; the CLI dispatcher
26
- // always runs this file as a child's MAIN module, where nothing changes.
27
- import { createServer } from "node:http";
28
- import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, openSync, unlinkSync, readdirSync, watch, realpathSync } from "node:fs";
29
- import { join, resolve, sep, basename, dirname, relative } from "node:path";
30
- import { spawn } from "node:child_process";
31
- import { fileURLToPath, pathToFileURL } from "node:url";
32
- import { parse, renderHtml } from "../dist/geml.js";
33
- import { buildCodeGraph } from "../dist/render.js";
34
- import { isSourcePath, SKIP_DIRS } from "./detect.mjs";
35
-
36
- // Where this package's compiled ESM lives — served under /_dist/ so pages can
37
- // import the parser in the browser (live in-place navigation).
38
- const DIST_DIR = resolve(join(dirname(fileURLToPath(import.meta.url)), "..", "dist"));
39
-
40
- const USAGE = "usage: geml codemap serve [codemap-dir] [--port 8140] [--cache-mb 256] [--no-warm] [--no-open] [--watch] [--background|--stop] (dir defaults to ./.geml-code-graph)";
41
-
42
- // argv -> options, or null on a usage error (--help included: the caller
43
- // prints the usage line and exits 2 either way).
44
- export function parseServeArgs(args) {
45
- const portIdx = args.indexOf("--port");
46
- const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 8140;
47
- const background = args.includes("--background");
48
- const stop = args.includes("--stop");
49
- const noWarm = args.includes("--no-warm");
50
- const noOpen = args.includes("--no-open");
51
- const watchMode = args.includes("--watch");
52
- const cacheIdx = args.indexOf("--cache-mb");
53
- const cacheMb = cacheIdx >= 0 ? Number(args[cacheIdx + 1]) : 256;
54
- if (args.includes("--help") || args.includes("-h") || !Number.isInteger(port) || port <= 0 || !(cacheMb > 0)) return null;
55
- const dir = args.find((a, i) => !a.startsWith("--") && (portIdx < 0 || i !== portIdx + 1) && (cacheIdx < 0 || i !== cacheIdx + 1)) || ".geml-code-graph";
56
- return { dir, port, background, stop, noWarm, noOpen, watchMode, cacheMb };
57
- }
58
-
59
- // Project root for the source route: a method node's src path is
60
- // project-root-relative, so it always misses inside the codemap dir. The
61
- // recorded build recipe knows the root (_index/refresh.json "root", relative
62
- // to the codemap dir); without one, assume the codemap sits at <root>/<dir>.
63
- export function resolveSrcRoot(root) {
64
- let srcRoot = resolve(root, "..");
65
- try { srcRoot = resolve(root, JSON.parse(readFileSync(join(root, "_index", "refresh.json"), "utf8")).root ?? ".."); } catch { /* no recipe: parent */ }
66
- return srcRoot;
67
- }
68
-
69
- // --stop: end a background server via its recorded pid. Returns the exit code.
70
- export function stopServer({ pidPath }) {
71
- if (!existsSync(pidPath)) { console.error("codemap serve: no pid file — nothing to stop"); return 0; }
72
- const pid = Number(readFileSync(pidPath, "utf8").trim());
73
- let running = true;
74
- try { process.kill(pid); } catch { running = false; }
75
- // Remove the pid file BEFORE reporting — the old order announced "stale pid
76
- // file removed" and only then attempted the unlink, so the message could
77
- // outrun (or misstate) the actual removal.
78
- try { unlinkSync(pidPath); } catch { /* already gone */ }
79
- console.error(running
80
- ? `codemap serve: stopped (pid ${pid})`
81
- : `codemap serve: pid ${pid} not running (stale pid file removed)`);
82
- return 0;
83
- }
84
-
85
- // --background: start a detached copy of this script and report once the port
86
- // answers. Returns the exit code. `selfPath` defaults to this script
87
- // (argv[1]); it is a parameter so tests can substitute a stand-in child.
88
- export async function launchBackground({ dir, root, port, cacheMb, noWarm, watchMode, runDir, logPath }, selfPath = process.argv[1]) {
89
- // Already serving? Don't stack a second server on the port.
90
- try {
91
- const pre = await fetch(`http://127.0.0.1:${port}/`, { method: "HEAD" });
92
- if (pre.status > 0) {
93
- console.error(`codemap serve: port ${port} already answers — assuming it is up`);
94
- console.error(` -> http://localhost:${port}/ (stop: geml codemap serve ${dir} --stop)`);
95
- return 0;
96
- }
97
- } catch { /* nothing there: start one */ }
98
- // Detach fully: own process group, stdio to the log file — the child owes
99
- // the launching session nothing. Report only once the port answers.
100
- mkdirSync(runDir, { recursive: true });
101
- const logFd = openSync(logPath, "a");
102
- const child = spawn(process.execPath,
103
- [selfPath, root, "--port", String(port), "--cache-mb", String(cacheMb), "--no-open", ...(noWarm ? ["--no-warm"] : []), ...(watchMode ? ["--watch"] : [])],
104
- { detached: true, stdio: ["ignore", logFd, logFd] });
105
- child.unref();
106
- const deadline = Date.now() + 8000;
107
- let up = false, exited = false;
108
- child.once("exit", () => { exited = true; });
109
- while (Date.now() < deadline && !up && !exited) {
110
- try {
111
- const r = await fetch(`http://127.0.0.1:${port}/`, { method: "HEAD" });
112
- up = r.status > 0;
113
- } catch { await new Promise((r) => setTimeout(r, 250)); }
114
- }
115
- if (!up) {
116
- let tail = "";
117
- try { tail = readFileSync(logPath, "utf8").split("\n").slice(-4).join("\n "); } catch { /* no log */ }
118
- console.error(`codemap serve: failed to start on port ${port}\n ${tail}`);
119
- return 1;
120
- }
121
- console.error(`codemap serve: running in background (pid ${child.pid}) — survives this session`);
122
- console.error(` -> http://localhost:${port}/`);
123
- console.error(` stop: geml codemap serve ${dir} --stop (log: ${logPath})`);
124
- return 0;
125
- }
126
-
127
- const MIME = {
128
- ".html": "text/html; charset=utf-8",
129
- ".geml": "text/plain; charset=utf-8",
130
- ".gemlhistory": "text/plain; charset=utf-8",
131
- ".json": "application/json; charset=utf-8",
132
- ".css": "text/css; charset=utf-8",
133
- ".js": "text/javascript; charset=utf-8",
134
- ".svg": "image/svg+xml",
135
- };
136
- export const extOf = (p) => { const m = /\.[A-Za-z0-9]+$/.exec(p); return m ? m[0].toLowerCase() : ""; };
137
-
138
- // The parse cache, request handler, and http server for ONE codemap
139
- // directory. `dir` is the raw argument spelling (for the --stop hint).
140
- export function createApp({ dir, root, port, cacheMb, srcRoot }) {
141
- // Flattened [name, doc, id] rows for the /_search endpoint, loaded once from
142
- // name-lookup.json on first query (kept server-side so a huge index never
143
- // ships to the browser).
144
- let searchRows = null;
145
-
146
- // Parsed-document cache. Pages still render on every request (never stale:
147
- // entries are validated against mtime+size, so a rebuild is picked up on the
148
- // next hit), but a click-walk revisits the same multi-MB documents constantly
149
- // and re-parsing 7 MB of geml per request is pure waste. The LRU bound is a
150
- // TEXT-BYTE budget, not a document count: one page's graph slice can cross
151
- // hundreds of small documents (a count bound would thrash — evict and
152
- // re-parse the whole working set on every request), while a handful of
153
- // 7 MB documents is what actually threatens memory.
154
- const DOC_CACHE_BUDGET = cacheMb * 1024 * 1024; // --cache-mb, default 256
155
- const docCache = new Map(); // abs path -> { mtime, size, text, doc }
156
- const parsedByText = new Map(); // text (same instance as in docCache) -> doc
157
- let docCacheBytes = 0;
158
- const evict = (abs, entry) => {
159
- parsedByText.delete(entry.text);
160
- docCache.delete(abs);
161
- docCacheBytes -= entry.size;
162
- };
163
- const loadCached = (abs) => {
164
- let st;
165
- try { st = statSync(abs); } catch { return null; }
166
- const hit = docCache.get(abs);
167
- if (hit && hit.mtime === st.mtimeMs && hit.size === st.size) {
168
- docCache.delete(abs); docCache.set(abs, hit); // LRU touch
169
- return hit;
170
- }
171
- if (hit) evict(abs, hit);
172
- let text;
173
- try { text = readFileSync(abs, "utf8"); } catch { return null; }
174
- const entry = { mtime: st.mtimeMs, size: st.size, text, doc: parse(text) };
175
- docCache.set(abs, entry);
176
- parsedByText.set(text, entry.doc);
177
- docCacheBytes += entry.size;
178
- while (docCacheBytes > DOC_CACHE_BUDGET && docCache.size > 1) {
179
- const oldest = docCache.keys().next().value;
180
- evict(oldest, docCache.get(oldest));
181
- }
182
- return entry;
183
- };
184
- const loadDoc = (rel) => {
185
- const e = loadCached(join(root, rel));
186
- return e ? e.text : null;
187
- };
188
- // loadDoc hands out the cached string instance, so the by-text lookup hits
189
- // without re-hashing anything the render loop already loaded.
190
- const parseDoc = (s) => parsedByText.get(s) ?? parse(s);
191
-
192
- // Symlink-safe confinement: a lexical resolve()+startsWith() guard is
193
- // defeated by a symlink inside the served dir that resolves lexically-inside
194
- // but points at an external target. realpathSync canonicalizes through
195
- // symlinks, so comparing the REAL path against the REAL base closes that
196
- // hole (and Windows path casing/8.3 shortnames normalize the same way, since
197
- // both sides come from realpathSync). A path that does not exist makes
198
- // realpathSync throw — that is a normal miss, answered as null (never a
199
- // crash), so callers fall through to their existing 404. Bases are resolved
200
- // once; if a base itself cannot be realpath'd we fall back to its lexical
201
- // form (nothing will resolve under it, so confine still refuses).
202
- const realBase = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
203
- const realRoot = realBase(root);
204
- const realSrcRoot = realBase(srcRoot);
205
- const confine = (abs, base = realRoot) => {
206
- let real;
207
- try { real = realpathSync(abs); } catch { return null; }
208
- return (real === base || real.startsWith(base + sep)) ? real : null;
209
- };
210
-
211
- const handler = (req, res) => {
212
- const send = (status, body, type) => {
213
- // never-stale extends to the BROWSER: without this, heuristic caching
214
- // keeps serving yesterday's pages and /_dist modules across restarts.
215
- res.writeHead(status, { "content-type": type || "text/plain; charset=utf-8", "cache-control": "no-cache" });
216
- res.end(req.method === "HEAD" ? undefined : body);
217
- };
218
- let urlPath;
219
- try {
220
- urlPath = decodeURIComponent(new URL(req.url, `http://127.0.0.1:${port}`).pathname);
221
- } catch {
222
- return send(400, "bad request");
223
- }
224
- if (urlPath.endsWith("/")) urlPath += "index.html";
225
-
226
- const done = (status) => console.error(`${req.method} ${urlPath} ${status}`);
227
- // Graph payloads as a sidecar: pages carry data-graph-src instead of a
228
- // multi-MB inline attribute; the runtime fetches this route after first
229
- // paint. Computed on demand from the SAME parse cache — never stale.
230
- if (urlPath === "/_graph") {
231
- let rel = "";
232
- try { rel = new URL(req.url, `http://127.0.0.1:${port}`).searchParams.get("doc") || ""; } catch { /* fall through */ }
233
- const target = resolve(join(root, "." + ("/" + rel).replace(/\//g, sep)));
234
- // confine() both requires existence (realpathSync throws otherwise) and
235
- // rejects a symlink that escapes root; target===root (a codemap dir
236
- // itself named *.geml, reached via ../<basename>) stays allowed and the
237
- // builder reports its own clean load failure.
238
- if (!rel.endsWith(".geml") || !confine(target)) {
239
- done(404);
240
- return send(404, JSON.stringify({ error: `no such document: ${rel}` }), "application/json; charset=utf-8");
241
- }
242
- try {
243
- const r = buildCodeGraph(rel, { loadDoc, parseDoc });
244
- done(200);
245
- return send(200,
246
- JSON.stringify(r.error !== undefined ? { error: r.error } : { data: r.data, truncated: !!r.truncated }),
247
- "application/json; charset=utf-8");
248
- } catch (e) {
249
- done(500);
250
- return send(500, JSON.stringify({ error: e.message }), "application/json; charset=utf-8");
251
- }
252
- }
253
- // Name -> node search for the viewer typeahead: substring-match the build's
254
- // name-lookup and return the top matches (small), so even a 45M index stays
255
- // server-side and never ships to the browser. Static file:// pages, which
256
- // can't hit this route, load _index/search-index.js via <script> instead.
257
- if (urlPath === "/_search") {
258
- let q = "";
259
- try { q = (new URL(req.url, `http://127.0.0.1:${port}`).searchParams.get("q") || "").trim().toLowerCase(); } catch { /* fall through */ }
260
- if (q.length < 2) { done(200); return send(200, JSON.stringify({ total: 0, hits: [] }), MIME[".json"]); }
261
- if (!searchRows) {
262
- searchRows = [];
263
- try {
264
- const lk = JSON.parse(readFileSync(join(root, "_index", "name-lookup.json"), "utf8"));
265
- for (const name of Object.keys(lk)) for (const c of lk[name]) searchRows.push([name, c.doc, c.id]);
266
- } catch { /* no lookup — leave empty */ }
267
- }
268
- // Rank so the cap keeps the BEST hits, not the alphabetically first:
269
- // exact name, then prefix, then qualified-tail prefix (Cls::q / Cls.q),
270
- // then substring. The lookup also aliases bare member names to the same
271
- // node — dedupe on doc#id keeping the best-ranked row, and report the
272
- // HONEST total so the UI can say "showing K of N". (The static viewer
273
- // ranks with the same rules client-side over search-index.js.)
274
- const score = (n) => {
275
- if (n === q) return 0;
276
- if (n.startsWith(q)) return 1;
277
- const c2 = n.lastIndexOf("::"), d = n.lastIndexOf(".");
278
- const cut = Math.max(c2 >= 0 ? c2 + 2 : 0, d >= 0 ? d + 1 : 0);
279
- if (cut > 0 && n.slice(cut).startsWith(q)) return 2;
280
- return n.includes(q) ? 3 : -1;
281
- };
282
- const ranked = [];
283
- for (const [name, doc, id] of searchRows) {
284
- const s = score(name.toLowerCase());
285
- if (s >= 0) ranked.push({ s, name, doc, id });
286
- }
287
- ranked.sort((a, b) => a.s - b.s || a.name.localeCompare(b.name));
288
- const seen = new Set(), hits = [];
289
- for (const h of ranked) {
290
- const k = h.doc + "#" + h.id;
291
- if (seen.has(k)) continue;
292
- seen.add(k);
293
- hits.push(h);
294
- }
295
- done(200);
296
- return send(200, JSON.stringify({ total: hits.length, hits: hits.slice(0, 100).map(({ name, doc, id }) => ({ name, doc, id })) }), MIME[".json"]);
297
- }
298
- // The parser's own ESM dist, for the live module script the pages load —
299
- // clicks then swap views in place instead of navigating between pages.
300
- if (urlPath.startsWith("/_dist/")) {
301
- const sub = urlPath.slice("/_dist/".length);
302
- // The import map in served pages sends every node:* builtin here, so the
303
- // parser dist loads in a browser exactly like the bundled viewer does.
304
- if (sub === "_node-stub.js") {
305
- done(200);
306
- return send(200, readFileSync(join(dirname(fileURLToPath(import.meta.url)), "browser-stub.mjs")), "text/javascript; charset=utf-8");
307
- }
308
- const distFile = resolve(join(DIST_DIR, "." + sep + sub.replace(/\//g, sep)));
309
- if (!distFile.startsWith(DIST_DIR + sep) || !distFile.endsWith(".js") || !existsSync(distFile)) {
310
- done(404);
311
- return send(404, `not found: ${urlPath}`);
312
- }
313
- done(200);
314
- return send(200, readFileSync(distFile), "text/javascript; charset=utf-8");
315
- }
316
- // Stay inside the codemap directory — a viewer, not a file server.
317
- const file = resolve(join(root, "." + urlPath.replace(/\//g, sep)));
318
- if (file !== root && !file.startsWith(root + sep)) return send(403, "forbidden");
319
- // *.html: render the .geml source live when it exists. confine() gates on
320
- // the REAL path so a symlinked .geml that points outside root is refused
321
- // (a directory named *.geml still resolves in-root and the render
322
- // try/catch answers its clean 500, as before).
323
- if (urlPath.endsWith(".html")) {
324
- const geml = file.replace(/\.html$/, ".geml");
325
- if (confine(geml)) {
326
- try {
327
- const doc = loadCached(geml).doc;
328
- const html = renderHtml(doc, {
329
- source: basename(geml), loadDoc, parseDoc,
330
- liveGraph: "/_dist/", graphSidecar: "/_graph?doc=",
331
- });
332
- done(200);
333
- return send(200, html, MIME[".html"]);
334
- } catch (e) {
335
- done(500);
336
- return send(500, `render error in ${basename(geml)}: ${e.message}`);
337
- }
338
- }
339
- }
340
- const realFile = confine(file);
341
- if (realFile && statSync(realFile).isFile()) {
342
- done(200);
343
- return send(200, readFileSync(realFile), MIME[extOf(file)] || "application/octet-stream");
344
- }
345
- // Source files as a route: the graph's click-to-source fetches a method's
346
- // src path (project-root-relative), which misses inside the codemap dir.
347
- // Resolve the miss against the project root — read-only, indexed source
348
- // extensions only, traversal-guarded. Still a viewer, not a file server.
349
- if (isSourcePath(urlPath)) {
350
- const srcFile = resolve(join(srcRoot, "." + urlPath.replace(/\//g, sep)));
351
- // Same symlink-safe confinement against the (realpath'd) source root: a
352
- // symlinked source file pointing outside the project tree is refused.
353
- const realSrcFile = confine(srcFile, realSrcRoot);
354
- if (realSrcFile && statSync(realSrcFile).isFile()) {
355
- done(200);
356
- return send(200, readFileSync(realSrcFile), "text/plain; charset=utf-8");
357
- }
358
- }
359
- done(404);
360
- return send(404, `not found: ${urlPath}`);
361
- };
362
-
363
- const server = createServer(handler);
364
-
365
- server.on("error", (e) => {
366
- console.error(e && e.code === "EADDRINUSE"
367
- ? `error: port ${port} is in use — pick another with --port, or stop the old server (geml codemap serve ${dir} --stop)`
368
- : `error: ${e.message}`);
369
- process.exit(1);
370
- });
371
-
372
- // Background prewarm: the parse cache is lazy, so the FIRST click into a big
373
- // container otherwise pays its whole cross-document working set (seconds at
374
- // repo scale). Warm largest-first — the big documents are the long-tail
375
- // first-clicks — ONE document per event-loop turn so requests arriving
376
- // mid-warm are served normally (a tight synchronous loop would block them),
377
- // and stop at 80% of the byte budget: warming past it only evicts what was
378
- // just warmed. Requests still validate mtime+size, so a rebuild mid-warm is
379
- // picked up as usual.
380
- async function warmCache() {
381
- let files = [];
382
- try {
383
- files = readdirSync(root)
384
- .filter((f) => f.endsWith(".geml"))
385
- .map((f) => {
386
- const p = join(root, f);
387
- try { return { p, size: statSync(p).size }; } catch { return null; }
388
- })
389
- .filter(Boolean)
390
- .sort((a, b) => b.size - a.size);
391
- } catch { return; }
392
- const t0 = Date.now();
393
- let n = 0;
394
- // Brake on CUMULATIVE bytes pushed through the cache, not on current
395
- // occupancy — the LRU evicts as it goes, so occupancy self-limits and
396
- // would never stop the loop; past 80% of the budget every further load
397
- // only evicts something just warmed.
398
- let warmed = 0;
399
- for (const { p, size } of files) {
400
- if (warmed >= DOC_CACHE_BUDGET * 0.8) break;
401
- if (loadCached(p)) { n++; warmed += size; }
402
- await new Promise((r) => setImmediate(r));
403
- }
404
- console.error(`prewarm: ${n}/${files.length} document(s), ${(docCacheBytes / 1048576).toFixed(1)} MB cached, ${((Date.now() - t0) / 1000).toFixed(1)}s`);
405
- }
406
-
407
- return { server, handler, loadCached, loadDoc, parseDoc, warmCache, docCache, cacheBytes: () => docCacheBytes };
408
- }
409
-
410
- // Open the graph in the default browser — ONLY when serving interactively in a
411
- // real terminal (isTTY). A --background child (stdio -> log file) and piped/CI
412
- // runs are non-TTY and never open; `--no-open` opts out explicitly. A missing
413
- // opener is not an error — the URL is already printed.
414
- export const openBrowser = (url, spawnImpl = spawn) => {
415
- const argv = process.platform === "win32" ? ["cmd", "/c", "start", "", url]
416
- : process.platform === "darwin" ? ["open", url]
417
- : ["xdg-open", url];
418
- try {
419
- const child = spawnImpl(argv[0], argv.slice(1), { stdio: "ignore", detached: true });
420
- // spawn() reports a missing opener (e.g. no xdg-open on a headless Linux
421
- // box) ASYNCHRONOUSLY via an 'error' event on the ChildProcess — the
422
- // try/catch only catches a SYNCHRONOUS throw, so without this listener the
423
- // unhandled 'error' would crash the whole serve process. Swallow it: the
424
- // URL is already printed, so a failed auto-open is never fatal.
425
- child?.on?.("error", () => { /* no opener available: the printed URL is enough */ });
426
- child?.unref?.();
427
- } catch { /* synchronous spawn failure: the printed URL is enough */ }
428
- };
429
-
430
- // --watch: editing-time sync. Watch the project's indexed source files and,
431
- // after a quiet window, re-run the recorded recipe so the codemap follows the
432
- // EDIT, not just the commit (the hook covers commits). --force is required:
433
- // refresh's up-to-date check pins to git HEAD, which editing doesn't move.
434
- // Single-flight — a change arriving mid-refresh queues exactly one more run.
435
- // Pages render live from .geml, so when a run lands an F5 shows it.
436
- const WATCH_QUIET = Number(process.env.GEML_WATCH_QUIET_MS) || 30_000;
437
- export function startWatch({ root, runDir, srcRoot, logPath }) {
438
- if (!existsSync(join(runDir, "refresh.json"))) {
439
- console.error("watch: no _index/refresh.json recipe recorded — --watch disabled (build once first)");
440
- return;
441
- }
442
- // A missing source root must disable watch on EVERY platform. Windows' native
443
- // fs.watch throws on a nonexistent path (caught below), but Linux's manual
444
- // watchTree silently tolerates it and would "watch" nothing — so guard here.
445
- if (!existsSync(srcRoot)) {
446
- console.error(`watch: recursive fs.watch unavailable here (source root ${srcRoot} does not exist) — --watch disabled`);
447
- return;
448
- }
449
- let timer = null, running = false, again = false;
450
- const run = () => {
451
- if (running) { again = true; return; }
452
- running = true;
453
- console.error("watch: sources changed — refreshing the codemap…");
454
- const child = spawn(process.execPath,
455
- [join(dirname(fileURLToPath(import.meta.url)), "refresh.mjs"), root, "--force"],
456
- { stdio: ["ignore", 2, 2] });
457
- child.on("exit", (c) => {
458
- running = false;
459
- console.error(c === 0
460
- ? "watch: codemap refreshed — reload the browser to see it"
461
- : `watch: refresh failed (exit ${c}) — see ${logPath.replace(/serve\.log$/, "refresh.log")}`);
462
- if (again) { again = false; schedule(); }
463
- });
464
- };
465
- const schedule = () => { clearTimeout(timer); timer = setTimeout(run, WATCH_QUIET); };
466
- // Shared filter: `rel` is the changed path relative to srcRoot, or null when
467
- // the platform could not attribute the event. An event we cannot filter
468
- // still schedules — the quiet window and the single-flight runner absorb an
469
- // occasional false refresh, whereas dropping it deafens --watch.
470
- const onFsEvent = (rel) => {
471
- if (rel) {
472
- const parts = String(rel).split(/[\\/]/);
473
- if (parts.some((p) => SKIP_DIRS.has(p) || p.startsWith("."))) return;
474
- if (!isSourcePath(String(rel))) return;
475
- }
476
- schedule();
477
- };
478
- try {
479
- // Linux's native recursive fs.watch silently misses events inside
480
- // pre-existing subdirectories (CI proved it: zero events for 15s of edits
481
- // under src/), so there the tree is watched by hand — one plain inotify
482
- // watcher per directory, SKIP_DIRS pruned. macOS/Windows keep the native
483
- // recursive watcher. GEML_WATCH_TREE=1 forces the manual walker so tests
484
- // exercise it on every platform.
485
- if (process.platform === "linux" || process.env.GEML_WATCH_TREE === "1") {
486
- watchTree(srcRoot, onFsEvent);
487
- } else {
488
- watch(srcRoot, { recursive: true }, (_ev, rel) => onFsEvent(rel));
489
- }
490
- console.error(`watch: watching ${srcRoot} — a source change re-runs the recipe after ${WATCH_QUIET / 1000}s of quiet`);
491
- } catch (e) {
492
- console.error(`watch: recursive fs.watch unavailable here (${e.message}) — --watch disabled`);
493
- return;
494
- }
495
- return { run, schedule, onFsEvent };
496
- }
497
-
498
- // Manual recursive watcher: one non-recursive fs.watch per directory, new
499
- // directories picked up as they appear. Dead watchers on deleted directories
500
- // just fall silent — nothing to clean up for our purpose.
501
- export function watchTree(rootDir, onEvent) {
502
- const watched = new Set();
503
- const add = (dir) => {
504
- if (watched.has(dir)) return;
505
- let w;
506
- try { w = watch(dir, (_ev, name) => hit(dir, name ? String(name) : null)); } catch { return; } // vanished mid-walk
507
- watched.add(dir);
508
- w.on("error", () => watched.delete(dir));
509
- let entries = [];
510
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
511
- for (const e of entries) {
512
- if (e.isDirectory() && !SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) add(join(dir, e.name));
513
- }
514
- };
515
- const hit = (dir, name) => {
516
- if (!name) { onEvent(null); return; } // unattributed: let the caller decide
517
- const full = join(dir, name);
518
- try {
519
- if (statSync(full).isDirectory()) {
520
- if (!SKIP_DIRS.has(name) && !name.startsWith(".")) add(full); // new subtree
521
- return; // directory churn itself is not a source edit
522
- }
523
- } catch { /* deleted — an unlinked source file is still a change */ }
524
- onEvent(relative(rootDir, full));
525
- };
526
- add(rootDir);
527
- return { add, hit, watched };
528
- }
529
-
530
- // Foreground serving: create the app and listen. Returns the app so an
531
- // in-process caller can close the server; the CLI just leaves it running.
532
- export function startServing(cfg) {
533
- const { root, port } = cfg;
534
- const app = createApp(cfg);
535
- app.server.listen(port, "127.0.0.1", () => {
536
- // Record the pid so `--stop` can find us (best effort — a read-only
537
- // codemap dir just means no pid file).
538
- try { mkdirSync(cfg.runDir, { recursive: true }); writeFileSync(cfg.pidPath, String(process.pid)); } catch { /* read-only */ }
539
- console.error(`geml codemap serve: ${root}`);
540
- console.error(` -> http://localhost:${port}/ (pages render live from .geml — rebuilds show on refresh)`);
541
- if (process.stdout.isTTY && !cfg.noOpen) openBrowser(`http://localhost:${port}/`);
542
- if (!cfg.noWarm) app.warmCache();
543
- if (cfg.watchMode) startWatch(cfg);
544
- });
545
- return app;
546
- }
547
-
548
- export async function main(argv = process.argv.slice(2)) {
549
- const cfg = parseServeArgs(argv);
550
- if (!cfg) {
551
- console.error(USAGE);
552
- process.exit(2);
553
- }
554
- const root = resolve(cfg.dir);
555
- const runDir = join(root, "_index");
556
- const ctx = {
557
- ...cfg, root, runDir,
558
- pidPath: join(runDir, "serve.pid"),
559
- logPath: join(runDir, "serve.log"),
560
- srcRoot: resolveSrcRoot(root),
561
- };
562
-
563
- if (ctx.stop) process.exit(stopServer(ctx));
564
-
565
- if (!existsSync(join(root, "index.geml")) && !existsSync(join(root, "index.html"))) {
566
- console.error(`error: ${root} has no index.geml — not a codemap directory? (build one: geml codemap build)`);
567
- process.exit(1);
568
- }
569
-
570
- if (ctx.background) process.exit(await launchBackground(ctx));
571
-
572
- return startServing(ctx);
573
- }
574
-
575
- // Auto-run only as a MAIN module: the CLI dispatcher spawns this file as a
576
- // child's entry script (src/geml.ts runCodemap), and `node codemap/serve.mjs`
577
- // hits it directly — an in-process `import` (the tests) stays inert.
578
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
1
+ #!/usr/bin/env node
2
+ // geml codemap serve — live viewer for a codemap directory.
3
+ //
4
+ // geml codemap serve [codemap-dir] [--port 8140] foreground
5
+ // geml codemap serve [codemap-dir] [--port 8140] --background survives the session
6
+ // geml codemap serve [codemap-dir] --stop stop a background server
7
+ // geml codemap serve [codemap-dir] --watch editing-time sync: re-run the
8
+ // recorded recipe when indexed sources change (30s quiet)
9
+ //
10
+ // Every *.html request is rendered FROM ITS *.geml AT REQUEST TIME, so the
11
+ // pages are never stale: rebuild the codemap (or upgrade the renderer) and a
12
+ // browser refresh shows the new state — no pre-render step. Pre-rendered
13
+ // static .html files (from `geml codemap render`) are served only when no
14
+ // .geml source exists for the path.
15
+ //
16
+ // --background detaches the server from the launching process (an agent
17
+ // session ending must not take the viewer down): stdio goes to
18
+ // _index/serve.log, the pid lands in _index/serve.pid, and the parent waits
19
+ // until the port actually answers before reporting the URL.
20
+ //
21
+ // Local viewer by design: binds 127.0.0.1. HEAD is answered without a body —
22
+ // the in-page navigation probes targets before embedding them.
23
+ //
24
+ // The pieces are exported (and the auto-run at the bottom is main-module
25
+ // guarded) so the test suite can drive them in-process; the CLI dispatcher
26
+ // always runs this file as a child's MAIN module, where nothing changes.
27
+ import { createServer } from "node:http";
28
+ import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, openSync, unlinkSync, readdirSync, watch, realpathSync } from "node:fs";
29
+ import { join, resolve, sep, basename, dirname, relative } from "node:path";
30
+ import { spawn } from "node:child_process";
31
+ import { fileURLToPath, pathToFileURL } from "node:url";
32
+ import { parse, renderHtml } from "../dist/geml.js";
33
+ import { buildCodeGraph } from "../dist/render.js";
34
+ import { isSourcePath, SKIP_DIRS } from "./detect.mjs";
35
+
36
+ // Where this package's compiled ESM lives — served under /_dist/ so pages can
37
+ // import the parser in the browser (live in-place navigation).
38
+ const DIST_DIR = resolve(join(dirname(fileURLToPath(import.meta.url)), "..", "dist"));
39
+
40
+ const USAGE = "usage: geml codemap serve [codemap-dir] [--port 8140] [--cache-mb 256] [--no-warm] [--no-open] [--watch] [--background|--stop] (dir defaults to ./.geml-code-graph)";
41
+
42
+ // argv -> options, or null on a usage error (--help included: the caller
43
+ // prints the usage line and exits 2 either way).
44
+ export function parseServeArgs(args) {
45
+ const portIdx = args.indexOf("--port");
46
+ const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 8140;
47
+ const background = args.includes("--background");
48
+ const stop = args.includes("--stop");
49
+ const noWarm = args.includes("--no-warm");
50
+ const noOpen = args.includes("--no-open");
51
+ const watchMode = args.includes("--watch");
52
+ const cacheIdx = args.indexOf("--cache-mb");
53
+ const cacheMb = cacheIdx >= 0 ? Number(args[cacheIdx + 1]) : 256;
54
+ if (args.includes("--help") || args.includes("-h") || !Number.isInteger(port) || port <= 0 || !(cacheMb > 0)) return null;
55
+ const dir = args.find((a, i) => !a.startsWith("--") && (portIdx < 0 || i !== portIdx + 1) && (cacheIdx < 0 || i !== cacheIdx + 1)) || ".geml-code-graph";
56
+ return { dir, port, background, stop, noWarm, noOpen, watchMode, cacheMb };
57
+ }
58
+
59
+ // Project root for the source route: a method node's src path is
60
+ // project-root-relative, so it always misses inside the codemap dir. The
61
+ // recorded build recipe knows the root (_index/refresh.json "root", relative
62
+ // to the codemap dir); without one, assume the codemap sits at <root>/<dir>.
63
+ export function resolveSrcRoot(root) {
64
+ let srcRoot = resolve(root, "..");
65
+ try { srcRoot = resolve(root, JSON.parse(readFileSync(join(root, "_index", "refresh.json"), "utf8")).root ?? ".."); } catch { /* no recipe: parent */ }
66
+ return srcRoot;
67
+ }
68
+
69
+ // --stop: end a background server via its recorded pid. Returns the exit code.
70
+ export function stopServer({ pidPath }) {
71
+ if (!existsSync(pidPath)) { console.error("codemap serve: no pid file — nothing to stop"); return 0; }
72
+ const pid = Number(readFileSync(pidPath, "utf8").trim());
73
+ let running = true;
74
+ try { process.kill(pid); } catch { running = false; }
75
+ // Remove the pid file BEFORE reporting — the old order announced "stale pid
76
+ // file removed" and only then attempted the unlink, so the message could
77
+ // outrun (or misstate) the actual removal.
78
+ try { unlinkSync(pidPath); } catch { /* already gone */ }
79
+ console.error(running
80
+ ? `codemap serve: stopped (pid ${pid})`
81
+ : `codemap serve: pid ${pid} not running (stale pid file removed)`);
82
+ return 0;
83
+ }
84
+
85
+ // --background: start a detached copy of this script and report once the port
86
+ // answers. Returns the exit code. `selfPath` defaults to this script
87
+ // (argv[1]); it is a parameter so tests can substitute a stand-in child.
88
+ export async function launchBackground({ dir, root, port, cacheMb, noWarm, watchMode, runDir, logPath }, selfPath = process.argv[1]) {
89
+ // Already serving? Don't stack a second server on the port.
90
+ try {
91
+ const pre = await fetch(`http://127.0.0.1:${port}/`, { method: "HEAD" });
92
+ if (pre.status > 0) {
93
+ console.error(`codemap serve: port ${port} already answers — assuming it is up`);
94
+ console.error(` -> http://localhost:${port}/ (stop: geml codemap serve ${dir} --stop)`);
95
+ return 0;
96
+ }
97
+ } catch { /* nothing there: start one */ }
98
+ // Detach fully: own process group, stdio to the log file — the child owes
99
+ // the launching session nothing. Report only once the port answers.
100
+ mkdirSync(runDir, { recursive: true });
101
+ const logFd = openSync(logPath, "a");
102
+ const child = spawn(process.execPath,
103
+ [selfPath, root, "--port", String(port), "--cache-mb", String(cacheMb), "--no-open", ...(noWarm ? ["--no-warm"] : []), ...(watchMode ? ["--watch"] : [])],
104
+ { detached: true, stdio: ["ignore", logFd, logFd] });
105
+ child.unref();
106
+ const deadline = Date.now() + 8000;
107
+ let up = false, exited = false;
108
+ child.once("exit", () => { exited = true; });
109
+ while (Date.now() < deadline && !up && !exited) {
110
+ try {
111
+ const r = await fetch(`http://127.0.0.1:${port}/`, { method: "HEAD" });
112
+ up = r.status > 0;
113
+ } catch { await new Promise((r) => setTimeout(r, 250)); }
114
+ }
115
+ if (!up) {
116
+ let tail = "";
117
+ try { tail = readFileSync(logPath, "utf8").split("\n").slice(-4).join("\n "); } catch { /* no log */ }
118
+ console.error(`codemap serve: failed to start on port ${port}\n ${tail}`);
119
+ return 1;
120
+ }
121
+ console.error(`codemap serve: running in background (pid ${child.pid}) — survives this session`);
122
+ console.error(` -> http://localhost:${port}/`);
123
+ console.error(` stop: geml codemap serve ${dir} --stop (log: ${logPath})`);
124
+ return 0;
125
+ }
126
+
127
+ const MIME = {
128
+ ".html": "text/html; charset=utf-8",
129
+ ".geml": "text/plain; charset=utf-8",
130
+ ".gemlhistory": "text/plain; charset=utf-8",
131
+ ".json": "application/json; charset=utf-8",
132
+ ".css": "text/css; charset=utf-8",
133
+ ".js": "text/javascript; charset=utf-8",
134
+ ".svg": "image/svg+xml",
135
+ };
136
+ export const extOf = (p) => { const m = /\.[A-Za-z0-9]+$/.exec(p); return m ? m[0].toLowerCase() : ""; };
137
+
138
+ // The parse cache, request handler, and http server for ONE codemap
139
+ // directory. `dir` is the raw argument spelling (for the --stop hint).
140
+ export function createApp({ dir, root, port, cacheMb, srcRoot }) {
141
+ // Flattened [name, doc, id] rows for the /_search endpoint, loaded once from
142
+ // name-lookup.json on first query (kept server-side so a huge index never
143
+ // ships to the browser).
144
+ let searchRows = null;
145
+
146
+ // Parsed-document cache. Pages still render on every request (never stale:
147
+ // entries are validated against mtime+size, so a rebuild is picked up on the
148
+ // next hit), but a click-walk revisits the same multi-MB documents constantly
149
+ // and re-parsing 7 MB of geml per request is pure waste. The LRU bound is a
150
+ // TEXT-BYTE budget, not a document count: one page's graph slice can cross
151
+ // hundreds of small documents (a count bound would thrash — evict and
152
+ // re-parse the whole working set on every request), while a handful of
153
+ // 7 MB documents is what actually threatens memory.
154
+ const DOC_CACHE_BUDGET = cacheMb * 1024 * 1024; // --cache-mb, default 256
155
+ const docCache = new Map(); // abs path -> { mtime, size, text, doc }
156
+ const parsedByText = new Map(); // text (same instance as in docCache) -> doc
157
+ let docCacheBytes = 0;
158
+ const evict = (abs, entry) => {
159
+ parsedByText.delete(entry.text);
160
+ docCache.delete(abs);
161
+ docCacheBytes -= entry.size;
162
+ };
163
+ const loadCached = (abs) => {
164
+ let st;
165
+ try { st = statSync(abs); } catch { return null; }
166
+ const hit = docCache.get(abs);
167
+ if (hit && hit.mtime === st.mtimeMs && hit.size === st.size) {
168
+ docCache.delete(abs); docCache.set(abs, hit); // LRU touch
169
+ return hit;
170
+ }
171
+ if (hit) evict(abs, hit);
172
+ let text;
173
+ try { text = readFileSync(abs, "utf8"); } catch { return null; }
174
+ const entry = { mtime: st.mtimeMs, size: st.size, text, doc: parse(text) };
175
+ docCache.set(abs, entry);
176
+ parsedByText.set(text, entry.doc);
177
+ docCacheBytes += entry.size;
178
+ while (docCacheBytes > DOC_CACHE_BUDGET && docCache.size > 1) {
179
+ const oldest = docCache.keys().next().value;
180
+ evict(oldest, docCache.get(oldest));
181
+ }
182
+ return entry;
183
+ };
184
+ const loadDoc = (rel) => {
185
+ const e = loadCached(join(root, rel));
186
+ return e ? e.text : null;
187
+ };
188
+ // loadDoc hands out the cached string instance, so the by-text lookup hits
189
+ // without re-hashing anything the render loop already loaded.
190
+ const parseDoc = (s) => parsedByText.get(s) ?? parse(s);
191
+
192
+ // Symlink-safe confinement: a lexical resolve()+startsWith() guard is
193
+ // defeated by a symlink inside the served dir that resolves lexically-inside
194
+ // but points at an external target. realpathSync canonicalizes through
195
+ // symlinks, so comparing the REAL path against the REAL base closes that
196
+ // hole (and Windows path casing/8.3 shortnames normalize the same way, since
197
+ // both sides come from realpathSync). A path that does not exist makes
198
+ // realpathSync throw — that is a normal miss, answered as null (never a
199
+ // crash), so callers fall through to their existing 404. Bases are resolved
200
+ // once; if a base itself cannot be realpath'd we fall back to its lexical
201
+ // form (nothing will resolve under it, so confine still refuses).
202
+ const realBase = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
203
+ const realRoot = realBase(root);
204
+ const realSrcRoot = realBase(srcRoot);
205
+ const confine = (abs, base = realRoot) => {
206
+ let real;
207
+ try { real = realpathSync(abs); } catch { return null; }
208
+ return (real === base || real.startsWith(base + sep)) ? real : null;
209
+ };
210
+
211
+ const handler = (req, res) => {
212
+ const send = (status, body, type) => {
213
+ // never-stale extends to the BROWSER: without this, heuristic caching
214
+ // keeps serving yesterday's pages and /_dist modules across restarts.
215
+ res.writeHead(status, { "content-type": type || "text/plain; charset=utf-8", "cache-control": "no-cache" });
216
+ res.end(req.method === "HEAD" ? undefined : body);
217
+ };
218
+ let urlPath;
219
+ try {
220
+ urlPath = decodeURIComponent(new URL(req.url, `http://127.0.0.1:${port}`).pathname);
221
+ } catch {
222
+ return send(400, "bad request");
223
+ }
224
+ if (urlPath.endsWith("/")) urlPath += "index.html";
225
+
226
+ const done = (status) => console.error(`${req.method} ${urlPath} ${status}`);
227
+ // Graph payloads as a sidecar: pages carry data-graph-src instead of a
228
+ // multi-MB inline attribute; the runtime fetches this route after first
229
+ // paint. Computed on demand from the SAME parse cache — never stale.
230
+ if (urlPath === "/_graph") {
231
+ let rel = "";
232
+ try { rel = new URL(req.url, `http://127.0.0.1:${port}`).searchParams.get("doc") || ""; } catch { /* fall through */ }
233
+ const target = resolve(join(root, "." + ("/" + rel).replace(/\//g, sep)));
234
+ // confine() both requires existence (realpathSync throws otherwise) and
235
+ // rejects a symlink that escapes root; target===root (a codemap dir
236
+ // itself named *.geml, reached via ../<basename>) stays allowed and the
237
+ // builder reports its own clean load failure.
238
+ if (!rel.endsWith(".geml") || !confine(target)) {
239
+ done(404);
240
+ return send(404, JSON.stringify({ error: `no such document: ${rel}` }), "application/json; charset=utf-8");
241
+ }
242
+ try {
243
+ const r = buildCodeGraph(rel, { loadDoc, parseDoc });
244
+ done(200);
245
+ return send(200,
246
+ JSON.stringify(r.error !== undefined ? { error: r.error } : { data: r.data, truncated: !!r.truncated }),
247
+ "application/json; charset=utf-8");
248
+ } catch (e) {
249
+ done(500);
250
+ return send(500, JSON.stringify({ error: e.message }), "application/json; charset=utf-8");
251
+ }
252
+ }
253
+ // Name -> node search for the viewer typeahead: substring-match the build's
254
+ // name-lookup and return the top matches (small), so even a 45M index stays
255
+ // server-side and never ships to the browser. Static file:// pages, which
256
+ // can't hit this route, load _index/search-index.js via <script> instead.
257
+ if (urlPath === "/_search") {
258
+ let q = "";
259
+ try { q = (new URL(req.url, `http://127.0.0.1:${port}`).searchParams.get("q") || "").trim().toLowerCase(); } catch { /* fall through */ }
260
+ if (q.length < 2) { done(200); return send(200, JSON.stringify({ total: 0, hits: [] }), MIME[".json"]); }
261
+ if (!searchRows) {
262
+ searchRows = [];
263
+ try {
264
+ const lk = JSON.parse(readFileSync(join(root, "_index", "name-lookup.json"), "utf8"));
265
+ for (const name of Object.keys(lk)) for (const c of lk[name]) searchRows.push([name, c.doc, c.id]);
266
+ } catch { /* no lookup — leave empty */ }
267
+ }
268
+ // Rank so the cap keeps the BEST hits, not the alphabetically first:
269
+ // exact name, then prefix, then qualified-tail prefix (Cls::q / Cls.q),
270
+ // then substring. The lookup also aliases bare member names to the same
271
+ // node — dedupe on doc#id keeping the best-ranked row, and report the
272
+ // HONEST total so the UI can say "showing K of N". (The static viewer
273
+ // ranks with the same rules client-side over search-index.js.)
274
+ const score = (n) => {
275
+ if (n === q) return 0;
276
+ if (n.startsWith(q)) return 1;
277
+ const c2 = n.lastIndexOf("::"), d = n.lastIndexOf(".");
278
+ const cut = Math.max(c2 >= 0 ? c2 + 2 : 0, d >= 0 ? d + 1 : 0);
279
+ if (cut > 0 && n.slice(cut).startsWith(q)) return 2;
280
+ return n.includes(q) ? 3 : -1;
281
+ };
282
+ const ranked = [];
283
+ for (const [name, doc, id] of searchRows) {
284
+ const s = score(name.toLowerCase());
285
+ if (s >= 0) ranked.push({ s, name, doc, id });
286
+ }
287
+ ranked.sort((a, b) => a.s - b.s || a.name.localeCompare(b.name));
288
+ const seen = new Set(), hits = [];
289
+ for (const h of ranked) {
290
+ const k = h.doc + "#" + h.id;
291
+ if (seen.has(k)) continue;
292
+ seen.add(k);
293
+ hits.push(h);
294
+ }
295
+ done(200);
296
+ return send(200, JSON.stringify({ total: hits.length, hits: hits.slice(0, 100).map(({ name, doc, id }) => ({ name, doc, id })) }), MIME[".json"]);
297
+ }
298
+ // The parser's own ESM dist, for the live module script the pages load —
299
+ // clicks then swap views in place instead of navigating between pages.
300
+ if (urlPath.startsWith("/_dist/")) {
301
+ const sub = urlPath.slice("/_dist/".length);
302
+ // The import map in served pages sends every node:* builtin here, so the
303
+ // parser dist loads in a browser exactly like the bundled viewer does.
304
+ if (sub === "_node-stub.js") {
305
+ done(200);
306
+ return send(200, readFileSync(join(dirname(fileURLToPath(import.meta.url)), "browser-stub.mjs")), "text/javascript; charset=utf-8");
307
+ }
308
+ const distFile = resolve(join(DIST_DIR, "." + sep + sub.replace(/\//g, sep)));
309
+ if (!distFile.startsWith(DIST_DIR + sep) || !distFile.endsWith(".js") || !existsSync(distFile)) {
310
+ done(404);
311
+ return send(404, `not found: ${urlPath}`);
312
+ }
313
+ done(200);
314
+ return send(200, readFileSync(distFile), "text/javascript; charset=utf-8");
315
+ }
316
+ // Stay inside the codemap directory — a viewer, not a file server.
317
+ const file = resolve(join(root, "." + urlPath.replace(/\//g, sep)));
318
+ if (file !== root && !file.startsWith(root + sep)) return send(403, "forbidden");
319
+ // *.html: render the .geml source live when it exists. confine() gates on
320
+ // the REAL path so a symlinked .geml that points outside root is refused
321
+ // (a directory named *.geml still resolves in-root and the render
322
+ // try/catch answers its clean 500, as before).
323
+ if (urlPath.endsWith(".html")) {
324
+ const geml = file.replace(/\.html$/, ".geml");
325
+ if (confine(geml)) {
326
+ try {
327
+ const doc = loadCached(geml).doc;
328
+ const html = renderHtml(doc, {
329
+ source: basename(geml), loadDoc, parseDoc,
330
+ liveGraph: "/_dist/", graphSidecar: "/_graph?doc=",
331
+ });
332
+ done(200);
333
+ return send(200, html, MIME[".html"]);
334
+ } catch (e) {
335
+ done(500);
336
+ return send(500, `render error in ${basename(geml)}: ${e.message}`);
337
+ }
338
+ }
339
+ }
340
+ const realFile = confine(file);
341
+ if (realFile && statSync(realFile).isFile()) {
342
+ done(200);
343
+ return send(200, readFileSync(realFile), MIME[extOf(file)] || "application/octet-stream");
344
+ }
345
+ // Source files as a route: the graph's click-to-source fetches a method's
346
+ // src path (project-root-relative), which misses inside the codemap dir.
347
+ // Resolve the miss against the project root — read-only, indexed source
348
+ // extensions only, traversal-guarded. Still a viewer, not a file server.
349
+ if (isSourcePath(urlPath)) {
350
+ const srcFile = resolve(join(srcRoot, "." + urlPath.replace(/\//g, sep)));
351
+ // Same symlink-safe confinement against the (realpath'd) source root: a
352
+ // symlinked source file pointing outside the project tree is refused.
353
+ const realSrcFile = confine(srcFile, realSrcRoot);
354
+ if (realSrcFile && statSync(realSrcFile).isFile()) {
355
+ done(200);
356
+ return send(200, readFileSync(realSrcFile), "text/plain; charset=utf-8");
357
+ }
358
+ }
359
+ done(404);
360
+ return send(404, `not found: ${urlPath}`);
361
+ };
362
+
363
+ const server = createServer(handler);
364
+
365
+ server.on("error", (e) => {
366
+ console.error(e && e.code === "EADDRINUSE"
367
+ ? `error: port ${port} is in use — pick another with --port, or stop the old server (geml codemap serve ${dir} --stop)`
368
+ : `error: ${e.message}`);
369
+ process.exit(1);
370
+ });
371
+
372
+ // Background prewarm: the parse cache is lazy, so the FIRST click into a big
373
+ // container otherwise pays its whole cross-document working set (seconds at
374
+ // repo scale). Warm largest-first — the big documents are the long-tail
375
+ // first-clicks — ONE document per event-loop turn so requests arriving
376
+ // mid-warm are served normally (a tight synchronous loop would block them),
377
+ // and stop at 80% of the byte budget: warming past it only evicts what was
378
+ // just warmed. Requests still validate mtime+size, so a rebuild mid-warm is
379
+ // picked up as usual.
380
+ async function warmCache() {
381
+ let files = [];
382
+ try {
383
+ files = readdirSync(root)
384
+ .filter((f) => f.endsWith(".geml"))
385
+ .map((f) => {
386
+ const p = join(root, f);
387
+ try { return { p, size: statSync(p).size }; } catch { return null; }
388
+ })
389
+ .filter(Boolean)
390
+ .sort((a, b) => b.size - a.size);
391
+ } catch { return; }
392
+ const t0 = Date.now();
393
+ let n = 0;
394
+ // Brake on CUMULATIVE bytes pushed through the cache, not on current
395
+ // occupancy — the LRU evicts as it goes, so occupancy self-limits and
396
+ // would never stop the loop; past 80% of the budget every further load
397
+ // only evicts something just warmed.
398
+ let warmed = 0;
399
+ for (const { p, size } of files) {
400
+ if (warmed >= DOC_CACHE_BUDGET * 0.8) break;
401
+ if (loadCached(p)) { n++; warmed += size; }
402
+ await new Promise((r) => setImmediate(r));
403
+ }
404
+ console.error(`prewarm: ${n}/${files.length} document(s), ${(docCacheBytes / 1048576).toFixed(1)} MB cached, ${((Date.now() - t0) / 1000).toFixed(1)}s`);
405
+ }
406
+
407
+ return { server, handler, loadCached, loadDoc, parseDoc, warmCache, docCache, cacheBytes: () => docCacheBytes };
408
+ }
409
+
410
+ // Open the graph in the default browser — ONLY when serving interactively in a
411
+ // real terminal (isTTY). A --background child (stdio -> log file) and piped/CI
412
+ // runs are non-TTY and never open; `--no-open` opts out explicitly. A missing
413
+ // opener is not an error — the URL is already printed.
414
+ export const openBrowser = (url, spawnImpl = spawn) => {
415
+ const argv = process.platform === "win32" ? ["cmd", "/c", "start", "", url]
416
+ : process.platform === "darwin" ? ["open", url]
417
+ : ["xdg-open", url];
418
+ try {
419
+ const child = spawnImpl(argv[0], argv.slice(1), { stdio: "ignore", detached: true });
420
+ // spawn() reports a missing opener (e.g. no xdg-open on a headless Linux
421
+ // box) ASYNCHRONOUSLY via an 'error' event on the ChildProcess — the
422
+ // try/catch only catches a SYNCHRONOUS throw, so without this listener the
423
+ // unhandled 'error' would crash the whole serve process. Swallow it: the
424
+ // URL is already printed, so a failed auto-open is never fatal.
425
+ child?.on?.("error", () => { /* no opener available: the printed URL is enough */ });
426
+ child?.unref?.();
427
+ } catch { /* synchronous spawn failure: the printed URL is enough */ }
428
+ };
429
+
430
+ // --watch: editing-time sync. Watch the project's indexed source files and,
431
+ // after a quiet window, re-run the recorded recipe so the codemap follows the
432
+ // EDIT, not just the commit (the hook covers commits). --force is required:
433
+ // refresh's up-to-date check pins to git HEAD, which editing doesn't move.
434
+ // Single-flight — a change arriving mid-refresh queues exactly one more run.
435
+ // Pages render live from .geml, so when a run lands an F5 shows it.
436
+ const WATCH_QUIET = Number(process.env.GEML_WATCH_QUIET_MS) || 30_000;
437
+ export function startWatch({ root, runDir, srcRoot, logPath }) {
438
+ if (!existsSync(join(runDir, "refresh.json"))) {
439
+ console.error("watch: no _index/refresh.json recipe recorded — --watch disabled (build once first)");
440
+ return;
441
+ }
442
+ // A missing source root must disable watch on EVERY platform. Windows' native
443
+ // fs.watch throws on a nonexistent path (caught below), but Linux's manual
444
+ // watchTree silently tolerates it and would "watch" nothing — so guard here.
445
+ if (!existsSync(srcRoot)) {
446
+ console.error(`watch: recursive fs.watch unavailable here (source root ${srcRoot} does not exist) — --watch disabled`);
447
+ return;
448
+ }
449
+ let timer = null, running = false, again = false;
450
+ const run = () => {
451
+ if (running) { again = true; return; }
452
+ running = true;
453
+ console.error("watch: sources changed — refreshing the codemap…");
454
+ const child = spawn(process.execPath,
455
+ [join(dirname(fileURLToPath(import.meta.url)), "refresh.mjs"), root, "--force"],
456
+ { stdio: ["ignore", 2, 2] });
457
+ child.on("exit", (c) => {
458
+ running = false;
459
+ console.error(c === 0
460
+ ? "watch: codemap refreshed — reload the browser to see it"
461
+ : `watch: refresh failed (exit ${c}) — see ${logPath.replace(/serve\.log$/, "refresh.log")}`);
462
+ if (again) { again = false; schedule(); }
463
+ });
464
+ };
465
+ const schedule = () => { clearTimeout(timer); timer = setTimeout(run, WATCH_QUIET); };
466
+ // Shared filter: `rel` is the changed path relative to srcRoot, or null when
467
+ // the platform could not attribute the event. An event we cannot filter
468
+ // still schedules — the quiet window and the single-flight runner absorb an
469
+ // occasional false refresh, whereas dropping it deafens --watch.
470
+ const onFsEvent = (rel) => {
471
+ if (rel) {
472
+ const parts = String(rel).split(/[\\/]/);
473
+ if (parts.some((p) => SKIP_DIRS.has(p) || p.startsWith("."))) return;
474
+ if (!isSourcePath(String(rel))) return;
475
+ }
476
+ schedule();
477
+ };
478
+ try {
479
+ // Linux's native recursive fs.watch silently misses events inside
480
+ // pre-existing subdirectories (CI proved it: zero events for 15s of edits
481
+ // under src/), so there the tree is watched by hand — one plain inotify
482
+ // watcher per directory, SKIP_DIRS pruned. macOS/Windows keep the native
483
+ // recursive watcher. GEML_WATCH_TREE=1 forces the manual walker so tests
484
+ // exercise it on every platform.
485
+ if (process.platform === "linux" || process.env.GEML_WATCH_TREE === "1") {
486
+ watchTree(srcRoot, onFsEvent);
487
+ } else {
488
+ watch(srcRoot, { recursive: true }, (_ev, rel) => onFsEvent(rel));
489
+ }
490
+ console.error(`watch: watching ${srcRoot} — a source change re-runs the recipe after ${WATCH_QUIET / 1000}s of quiet`);
491
+ } catch (e) {
492
+ console.error(`watch: recursive fs.watch unavailable here (${e.message}) — --watch disabled`);
493
+ return;
494
+ }
495
+ return { run, schedule, onFsEvent };
496
+ }
497
+
498
+ // Manual recursive watcher: one non-recursive fs.watch per directory, new
499
+ // directories picked up as they appear. Dead watchers on deleted directories
500
+ // just fall silent — nothing to clean up for our purpose.
501
+ export function watchTree(rootDir, onEvent) {
502
+ const watched = new Set();
503
+ const add = (dir) => {
504
+ if (watched.has(dir)) return;
505
+ let w;
506
+ try { w = watch(dir, (_ev, name) => hit(dir, name ? String(name) : null)); } catch { return; } // vanished mid-walk
507
+ watched.add(dir);
508
+ w.on("error", () => watched.delete(dir));
509
+ let entries = [];
510
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
511
+ for (const e of entries) {
512
+ if (e.isDirectory() && !SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) add(join(dir, e.name));
513
+ }
514
+ };
515
+ const hit = (dir, name) => {
516
+ if (!name) { onEvent(null); return; } // unattributed: let the caller decide
517
+ const full = join(dir, name);
518
+ try {
519
+ if (statSync(full).isDirectory()) {
520
+ if (!SKIP_DIRS.has(name) && !name.startsWith(".")) add(full); // new subtree
521
+ return; // directory churn itself is not a source edit
522
+ }
523
+ } catch { /* deleted — an unlinked source file is still a change */ }
524
+ onEvent(relative(rootDir, full));
525
+ };
526
+ add(rootDir);
527
+ return { add, hit, watched };
528
+ }
529
+
530
+ // Foreground serving: create the app and listen. Returns the app so an
531
+ // in-process caller can close the server; the CLI just leaves it running.
532
+ export function startServing(cfg) {
533
+ const { root, port } = cfg;
534
+ const app = createApp(cfg);
535
+ app.server.listen(port, "127.0.0.1", () => {
536
+ // Record the pid so `--stop` can find us (best effort — a read-only
537
+ // codemap dir just means no pid file).
538
+ try { mkdirSync(cfg.runDir, { recursive: true }); writeFileSync(cfg.pidPath, String(process.pid)); } catch { /* read-only */ }
539
+ console.error(`geml codemap serve: ${root}`);
540
+ console.error(` -> http://localhost:${port}/ (pages render live from .geml — rebuilds show on refresh)`);
541
+ if (process.stdout.isTTY && !cfg.noOpen) openBrowser(`http://localhost:${port}/`);
542
+ if (!cfg.noWarm) app.warmCache();
543
+ if (cfg.watchMode) startWatch(cfg);
544
+ });
545
+ return app;
546
+ }
547
+
548
+ export async function main(argv = process.argv.slice(2)) {
549
+ const cfg = parseServeArgs(argv);
550
+ if (!cfg) {
551
+ console.error(USAGE);
552
+ process.exit(2);
553
+ }
554
+ const root = resolve(cfg.dir);
555
+ const runDir = join(root, "_index");
556
+ const ctx = {
557
+ ...cfg, root, runDir,
558
+ pidPath: join(runDir, "serve.pid"),
559
+ logPath: join(runDir, "serve.log"),
560
+ srcRoot: resolveSrcRoot(root),
561
+ };
562
+
563
+ if (ctx.stop) process.exit(stopServer(ctx));
564
+
565
+ if (!existsSync(join(root, "index.geml")) && !existsSync(join(root, "index.html"))) {
566
+ console.error(`error: ${root} has no index.geml — not a codemap directory? (build one: geml codemap build)`);
567
+ process.exit(1);
568
+ }
569
+
570
+ if (ctx.background) process.exit(await launchBackground(ctx));
571
+
572
+ return startServing(ctx);
573
+ }
574
+
575
+ // Auto-run only as a MAIN module: the CLI dispatcher spawns this file as a
576
+ // child's entry script (src/geml.ts runCodemap), and `node codemap/serve.mjs`
577
+ // hits it directly — an in-process `import` (the tests) stays inert.
578
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();