@marver-design/marver 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +4 -4
  3. package/dist/{boards-BdG1TJwU.mjs → boards-BmxcT3Lc.mjs} +32 -9
  4. package/dist/{boards-6hKVW42a.mjs → boards-PuVzw5Wp.mjs} +9 -4
  5. package/dist/{build-Ct0iMoSi.mjs → build-D_g53Bp2.mjs} +12 -8
  6. package/dist/cli.mjs +27 -16
  7. package/dist/{daemon-Cb4JjpgL.mjs → daemon-Bbh_jmui.mjs} +1 -1
  8. package/dist/{dev-CdcIuhZJ.mjs → dev-LnIISva5.mjs} +9 -4
  9. package/dist/{init-BQIpIpIv.mjs → init-B7YhcN2o.mjs} +1 -1
  10. package/dist/{manifest-CpbsqQ_v.mjs → manifest-CaslQIAO.mjs} +112 -15
  11. package/dist/{plugin-BXyezwfN.mjs → plugin-D2msH1cj.mjs} +223 -43
  12. package/dist/{poster-DOY7pax8.mjs → poster-BEjUcQP3.mjs} +13 -5
  13. package/dist/shot-BzQ0PXKH.mjs +86 -0
  14. package/dist/shot-DlmTO8AF.mjs +926 -0
  15. package/docs/live-jam.md +20 -8
  16. package/docs/publish.md +2 -2
  17. package/package.json +1 -1
  18. package/src/client/shell/App.tsx +42 -14
  19. package/src/client/shell/BoardList.tsx +88 -58
  20. package/src/client/shell/LockedApp.tsx +2 -1
  21. package/src/client/shell/Play.tsx +1 -0
  22. package/src/client/shell/labels.ts +4 -4
  23. package/src/client/shell/store.ts +76 -29
  24. package/src/client/shell/styles.css +5 -1
  25. package/src/shared/board-tree.ts +104 -46
  26. package/templates/AGENTS-embedded.md +13 -3
  27. package/templates/AGENTS-studio.md +13 -3
  28. package/templates/instructions/boards.md +27 -9
  29. package/templates/instructions/discover.md +13 -1
  30. package/templates/instructions/jam.md +15 -3
  31. package/templates/instructions/shape.md +2 -1
  32. package/dist/shot-By1AItpD.mjs +0 -30
  33. package/dist/shot-CwmHO5T4.mjs +0 -528
@@ -0,0 +1,926 @@
1
+ import { a as slideSize, i as ROUTE } from "./cli.mjs";
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { randomBytes } from "node:crypto";
5
+ import { availableParallelism, tmpdir } from "node:os";
6
+ import { execFileSync, spawn } from "node:child_process";
7
+ //#region src/server/cdp.ts
8
+ /**
9
+ * A headless Chrome driven over CDP through Chrome's own debugging PIPE - fds 3 (we write,
10
+ * Chrome reads) and 4 (Chrome writes, we read), NUL-terminated JSON. No port, no stderr
11
+ * scrape, no WebSocket - and the one property everything else here rests on: when the read
12
+ * side of that pipe hits EOF Chrome shuts itself down, and the kernel closes the pipe on ANY
13
+ * death of this process (Ctrl-C, a closed terminal, `kill -9`, OOM). A shot browser cannot
14
+ * outlive the server that started it, whatever happens to the server.
15
+ *
16
+ * One browser belongs to one operation (a shot, a batch, a poster): `launch()` → sends →
17
+ * `close()` in a finally. Nothing is shared or kept warm - a headless instance of the user's
18
+ * own Chrome registers with macOS Launch Services as that Chrome, and a stray one can swallow
19
+ * the machine's link opens; the shortest possible life is the fix.
20
+ */
21
+ const CHROMES = [
22
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
23
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
24
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
25
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
26
+ "/usr/bin/google-chrome-stable",
27
+ "/usr/bin/google-chrome",
28
+ "/usr/bin/chromium",
29
+ "/usr/bin/chromium-browser"
30
+ ];
31
+ /** The browser binary to drive, or null. MARVER_CHROME overrides; otherwise first known install. */
32
+ function findChrome() {
33
+ const env = process.env.MARVER_CHROME;
34
+ if (env) return existsSync(env) ? env : null;
35
+ return CHROMES.find((p) => existsSync(p)) ?? null;
36
+ }
37
+ /** Profile directories marver has ever created (the sweep in shot.ts looks for both). */
38
+ const PROFILE_PREFIXES = ["mv-shot-", "mv-browser-"];
39
+ const FLAGS = [
40
+ "--headless=new",
41
+ "--hide-scrollbars",
42
+ "--no-first-run",
43
+ "--no-default-browser-check",
44
+ "--disable-extensions"
45
+ ];
46
+ const HANDSHAKE_MS = 15e3;
47
+ const MAX_MESSAGE = 1 << 30;
48
+ /** The pipe's framing: NUL-terminated messages, arriving in chunks split anywhere - inside a
49
+ * message, inside a multibyte character, or several messages in one chunk. Segments are held
50
+ * and concatenated ONCE per delimiter (a screenshot answer is many chunks; re-concatenating
51
+ * the remainder per chunk would be quadratic). The size guard runs BEFORE a segment is held. */
52
+ var Frames = class {
53
+ max;
54
+ chunks = [];
55
+ held = 0;
56
+ constructor(max = MAX_MESSAGE) {
57
+ this.max = max;
58
+ }
59
+ /** Feed one chunk; returns the complete messages it finished, or throws on a runaway. */
60
+ push(d) {
61
+ const out = [];
62
+ let from = 0;
63
+ for (;;) {
64
+ const nul = d.indexOf(0, from);
65
+ if (nul < 0) break;
66
+ this.hold(d.subarray(from, nul));
67
+ out.push(Buffer.concat(this.chunks).toString("utf8"));
68
+ this.chunks = [];
69
+ this.held = 0;
70
+ from = nul + 1;
71
+ }
72
+ if (from < d.length) this.hold(d.subarray(from));
73
+ return out;
74
+ }
75
+ hold(seg) {
76
+ if (this.held + seg.length > this.max) {
77
+ this.chunks = [];
78
+ this.held = 0;
79
+ throw new Error("the browser sent a runaway message");
80
+ }
81
+ this.chunks.push(seg);
82
+ this.held += seg.length;
83
+ }
84
+ };
85
+ var Browser = class Browser {
86
+ proc;
87
+ out;
88
+ seq = 0;
89
+ pending = /* @__PURE__ */ new Map();
90
+ listeners = /* @__PURE__ */ new Set();
91
+ profile;
92
+ /** Set once the browser is gone or unusable; every send after that rejects at once. */
93
+ dead = null;
94
+ exited = Promise.resolve();
95
+ get pid() {
96
+ return this.proc.pid;
97
+ }
98
+ /** Spawn and handshake (`Browser.getVersion` within 15s), or throw with the reason. A Chrome
99
+ * that starts but never brings up CDP is killed here - it can never hold a lane. */
100
+ static async launch(prefix = "mv-shot-") {
101
+ const bin = findChrome();
102
+ if (!bin) throw new Error("no Chrome/Chromium found - install one or set MARVER_CHROME to a browser binary");
103
+ const b = new Browser();
104
+ b.profile = mkdtempSync(join(tmpdir(), prefix));
105
+ b.proc = spawn(bin, [
106
+ ...FLAGS,
107
+ `--user-data-dir=${b.profile}`,
108
+ "--remote-debugging-pipe",
109
+ "about:blank"
110
+ ], { stdio: [
111
+ "ignore",
112
+ "ignore",
113
+ "ignore",
114
+ "pipe",
115
+ "pipe"
116
+ ] });
117
+ b.out = b.proc.stdio[3];
118
+ const inp = b.proc.stdio[4];
119
+ b.exited = new Promise((res) => b.proc.once("exit", () => res()));
120
+ inp.on("data", (d) => b.feed(d));
121
+ inp.on("end", () => b.fail("the browser closed its devtools pipe"));
122
+ inp.on("error", () => b.fail("devtools pipe error"));
123
+ inp.on("close", () => b.fail("the browser closed its devtools pipe"));
124
+ b.out.on("error", () => b.fail("devtools pipe error"));
125
+ b.proc.on("error", (e) => b.fail(`could not start the browser - ${e.message}`));
126
+ b.proc.on("exit", (code, sig) => b.fail(`the browser exited (${sig ?? code})`));
127
+ b.proc.once("exit", () => {
128
+ b.rmProfile();
129
+ });
130
+ const handshake = setTimeout(() => b.fail("the browser did not answer in time"), HANDSHAKE_MS);
131
+ try {
132
+ await b.send("Browser.getVersion");
133
+ } catch (e) {
134
+ await b.close();
135
+ throw e;
136
+ } finally {
137
+ clearTimeout(handshake);
138
+ }
139
+ return b;
140
+ }
141
+ frames = new Frames();
142
+ feed(d) {
143
+ let msgs;
144
+ try {
145
+ msgs = this.frames.push(d);
146
+ } catch (e) {
147
+ return this.fail(e.message);
148
+ }
149
+ for (const text of msgs) {
150
+ let m;
151
+ try {
152
+ m = JSON.parse(text);
153
+ } catch {
154
+ continue;
155
+ }
156
+ if (m.id != null && this.pending.has(m.id)) {
157
+ const p = this.pending.get(m.id);
158
+ this.pending.delete(m.id);
159
+ m.error ? p.reject(/* @__PURE__ */ new Error(`${p.method}: ${m.error.message}`)) : p.resolve(m.result);
160
+ } else for (const l of this.listeners) l(m);
161
+ }
162
+ }
163
+ fail(why) {
164
+ if (this.dead) return;
165
+ this.dead = why;
166
+ for (const [id, p] of this.pending) {
167
+ this.pending.delete(id);
168
+ p.reject(/* @__PURE__ */ new Error(`${p.method}: ${why}`));
169
+ }
170
+ try {
171
+ this.proc.kill("SIGKILL");
172
+ } catch {}
173
+ }
174
+ /** Send one CDP command. `owner` tags the call so `abort(owner)` can settle a shot's calls
175
+ * without touching another shot's in the same browser. */
176
+ send(method, params = {}, sessionId, owner) {
177
+ return new Promise((resolve, reject) => {
178
+ if (this.dead) return reject(/* @__PURE__ */ new Error(`${method}: ${this.dead}`));
179
+ const gone = owner && this.aborted.get(owner);
180
+ if (gone) return reject(/* @__PURE__ */ new Error(`${method}: ${gone}`));
181
+ const id = ++this.seq;
182
+ this.pending.set(id, {
183
+ owner,
184
+ resolve,
185
+ reject,
186
+ method
187
+ });
188
+ try {
189
+ this.out.write(JSON.stringify({
190
+ id,
191
+ method,
192
+ params,
193
+ sessionId
194
+ }) + "\0");
195
+ } catch (e) {
196
+ this.pending.delete(id);
197
+ reject(e);
198
+ }
199
+ });
200
+ }
201
+ aborted = /* @__PURE__ */ new WeakMap();
202
+ /** Reject every pending call tagged `owner` AND every later one (a shot's watchdog fired:
203
+ * nothing of that shot may run past its deadline); the browser lives on. */
204
+ abort(owner, why) {
205
+ this.aborted.set(owner, why);
206
+ for (const [id, p] of this.pending) if (p.owner === owner) {
207
+ this.pending.delete(id);
208
+ p.reject(/* @__PURE__ */ new Error(`${p.method}: ${why}`));
209
+ }
210
+ }
211
+ /** Subscribe to CDP events (anything without an `id`); returns the unsubscribe. */
212
+ on(l) {
213
+ this.listeners.add(l);
214
+ return () => this.listeners.delete(l);
215
+ }
216
+ /** End the pipe (Chrome exits on EOF within ~60ms), SIGKILL after 1s if it is still there,
217
+ * then the profile goes once the process has actually exited. Safe to call twice. */
218
+ async close() {
219
+ try {
220
+ this.out.end();
221
+ } catch {}
222
+ const kill = setTimeout(() => {
223
+ try {
224
+ this.proc.kill("SIGKILL");
225
+ } catch {}
226
+ }, 1e3);
227
+ if (this.proc.exitCode == null && this.proc.signalCode == null) await this.exited;
228
+ clearTimeout(kill);
229
+ this.fail("the browser was closed");
230
+ await this.rmProfile();
231
+ }
232
+ rm;
233
+ rmProfile() {
234
+ return this.rm ??= (async () => {
235
+ for (let i = 0; i < 3; i++) try {
236
+ rmSync(this.profile, {
237
+ recursive: true,
238
+ force: true
239
+ });
240
+ return;
241
+ } catch {
242
+ await new Promise((r) => setTimeout(r, 200));
243
+ }
244
+ try {
245
+ rmSync(this.profile, {
246
+ recursive: true,
247
+ force: true
248
+ });
249
+ } catch {}
250
+ })();
251
+ }
252
+ };
253
+ //#endregion
254
+ //#region src/server/shot.ts
255
+ /**
256
+ * Frame screenshots without a dependency - the system's own Chrome, driven over CDP through
257
+ * its debugging pipe (cdp.ts has the transport and the lifetime story).
258
+ *
259
+ * This exists for Live Jam's verify loop: a jam agent has no shell (deliberately - the job
260
+ * packet carries untrusted text), so "look at what you built" must be a capability the dev
261
+ * server provides, not a command the agent runs. The /api/shot and /api/shots endpoints call
262
+ * this; the `marver shot` CLI and the file-drop inbox both reach those.
263
+ *
264
+ * Readiness is DETERMINISTIC, not a sleep: poll until #root (or body, for html frames) has
265
+ * children, then wait for fonts. A frame that never mounts fails with the page's own
266
+ * exception text - which is exactly what the agent needs to fix it.
267
+ *
268
+ * One browser per OPERATION (a shot, a batch, a poster), N frames at a time inside a batch,
269
+ * and the browser closed in the operation's finally - never idle, never shared, never
270
+ * outliving the server.
271
+ */
272
+ const slug = (frameId) => frameId.replace(/\//g, "--");
273
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
274
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
275
+ function planShot(frame, viewports, override = {}) {
276
+ const cw = num(frame.contentWidth);
277
+ const vpObj = frame.viewport ? viewports[frame.viewport] : void 0;
278
+ const ow = num(override.w), oh = num(override.h);
279
+ const sl = slideSize(frame);
280
+ if (sl) return {
281
+ width: sl.width,
282
+ initialHeight: sl.height,
283
+ fullHeight: false
284
+ };
285
+ const fallback = viewports.mobile ?? {
286
+ width: 390,
287
+ height: 844
288
+ };
289
+ if (cw) {
290
+ const width = clamp(ow ?? vpObj?.width ?? cw, 320, 1600);
291
+ return {
292
+ width,
293
+ initialHeight: Math.round(width * .75),
294
+ fullHeight: true
295
+ };
296
+ }
297
+ const vp = vpObj ?? fallback;
298
+ if (ow || oh) return {
299
+ width: clamp(ow ?? vp.width, 120, 3840),
300
+ initialHeight: clamp(oh ?? vp.height, 80, 2160),
301
+ fullHeight: false
302
+ };
303
+ return {
304
+ width: vp.width,
305
+ initialHeight: vp.height,
306
+ fullHeight: false
307
+ };
308
+ }
309
+ /** Resolve a frame from the manifest and screenshot it - the shared core behind BOTH
310
+ * transports (the HTTP /api/shot endpoint and the file-drop inbox), so they validate and
311
+ * name output identically. `origin` is the dev server's own base URL. */
312
+ async function shootFrame(opts) {
313
+ const { root, viewports, frameId, theme, origin, scale = 2, size, browser } = opts;
314
+ if (!/^[a-z0-9-]+$/i.test(theme)) return {
315
+ ok: false,
316
+ error: "invalid theme"
317
+ };
318
+ if (!Number.isInteger(scale) || scale < 1 || scale > 4) return {
319
+ ok: false,
320
+ error: "invalid scale"
321
+ };
322
+ let manifest = {};
323
+ try {
324
+ manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
325
+ } catch {}
326
+ const frame = (manifest.frames ?? []).find((f) => f.id === frameId);
327
+ if (!frame) return {
328
+ ok: false,
329
+ error: `unknown frame "${frameId}" - ids are in design/manifest.json`
330
+ };
331
+ const plan = planShot(frame, viewports, size);
332
+ if (frame.kind !== "html") try {
333
+ const { scanAssetRefs } = await import("./build-D_g53Bp2.mjs");
334
+ const { ensurePoster } = await import("./poster-BEjUcQP3.mjs");
335
+ const refs = scanAssetRefs(readFileSync(join(root, frame.file), "utf8"), frame.file);
336
+ for (const r of refs) if (r.endsWith(".poster.png")) await ensurePoster(join(root, "design", "assets"), r.slice(0, -11));
337
+ } catch {}
338
+ const shotsDir = join(root, "design", ".local", "shots");
339
+ mkdirSync(shotsDir, { recursive: true });
340
+ sweepTemps(shotsDir);
341
+ const explicit = opts.scale !== void 0;
342
+ const relFor = (used) => {
343
+ const tag = !explicit || scale === 2 && used === 2 ? "" : used === scale ? `@${scale}x` : `@${scale}x-as-${used}x`;
344
+ return `design/.local/shots/${slug(frameId)}--${theme}${tag}.png`;
345
+ };
346
+ const tmp = join(shotsDir, `.${slug(frameId)}--${theme}.${randomBytes(6).toString("hex")}.tmp.png`);
347
+ const req = {
348
+ url: frame.kind === "html" ? `${origin}/${frame.file}?theme=${encodeURIComponent(theme)}` : `${origin}${ROUTE}/frame/?id=${encodeURIComponent(frameId)}&theme=${encodeURIComponent(theme)}`,
349
+ width: plan.width,
350
+ height: plan.initialHeight,
351
+ out: tmp,
352
+ fullHeight: plan.fullHeight,
353
+ scale
354
+ };
355
+ const result = browser ? await captureIn(req, browser) : await capture(req);
356
+ if (!result.ok) {
357
+ rmSync(tmp, { force: true });
358
+ return result;
359
+ }
360
+ const rel = relFor(result.scale);
361
+ try {
362
+ try {
363
+ renameSync(tmp, join(root, rel));
364
+ } catch (err) {
365
+ const code = err.code;
366
+ if (code !== "EEXIST" && code !== "EPERM") throw err;
367
+ copyFileSync(tmp, join(root, rel));
368
+ }
369
+ } catch (err) {
370
+ return {
371
+ ok: false,
372
+ error: `could not write the shot - ${err.message}`
373
+ };
374
+ } finally {
375
+ rmSync(tmp, { force: true });
376
+ }
377
+ return {
378
+ ok: true,
379
+ path: rel,
380
+ width: result.width,
381
+ height: result.height,
382
+ scale: result.scale,
383
+ ...result.truncated ? { truncated: true } : {},
384
+ ...result.unsettled ? { unsettled: true } : {},
385
+ ...result.note ? { note: result.note } : {}
386
+ };
387
+ }
388
+ /** Turn a selector into the ordered list of frame ids to shoot, or an error with the HTTP
389
+ * status it deserves. Ids the manifest does not know are kept (each fails its own entry);
390
+ * duplicates, an empty or oversize list, and a scene nobody belongs to are refused whole. */
391
+ function resolveFrames(root, sel) {
392
+ const keys = [
393
+ "frames",
394
+ "scene",
395
+ "all"
396
+ ].filter((k) => sel[k] !== void 0);
397
+ if (keys.length !== 1) return {
398
+ ok: false,
399
+ status: 400,
400
+ error: "name exactly one of frames (an array of ids), scene (a name) or all (true)"
401
+ };
402
+ let manifest = {};
403
+ try {
404
+ manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
405
+ } catch {}
406
+ const entries = manifest.frames ?? [];
407
+ const listed = entries.map((f) => f.id);
408
+ let frames;
409
+ if (keys[0] === "frames") {
410
+ if (!Array.isArray(sel.frames) || !sel.frames.every((f) => typeof f === "string" && f)) return {
411
+ ok: false,
412
+ status: 400,
413
+ error: "frames must be an array of frame ids"
414
+ };
415
+ frames = sel.frames;
416
+ if (new Set(frames).size !== frames.length) return {
417
+ ok: false,
418
+ status: 400,
419
+ error: "frames lists the same id twice"
420
+ };
421
+ } else if (keys[0] === "scene") {
422
+ if (typeof sel.scene !== "string" || !sel.scene) return {
423
+ ok: false,
424
+ status: 400,
425
+ error: "scene must be a scene name"
426
+ };
427
+ frames = entries.filter((f) => f.scene === sel.scene).map((f) => f.id);
428
+ if (!frames.length) return {
429
+ ok: false,
430
+ status: 404,
431
+ error: `no frames in scene "${sel.scene}" - scenes are in design/manifest.json`
432
+ };
433
+ } else {
434
+ if (sel.all !== true) return {
435
+ ok: false,
436
+ status: 400,
437
+ error: "all must be true"
438
+ };
439
+ frames = listed;
440
+ }
441
+ if (!frames.length) return {
442
+ ok: false,
443
+ status: 400,
444
+ error: "nothing to shoot"
445
+ };
446
+ if (frames.length > 200) return {
447
+ ok: false,
448
+ status: 400,
449
+ error: `${frames.length} frames - a batch takes 200 at most`
450
+ };
451
+ return {
452
+ ok: true,
453
+ frames
454
+ };
455
+ }
456
+ /** Shoot many frames as ONE operation: one browser, `shotConcurrency()` frames at a time
457
+ * inside it, results in the order asked. Every entry answers for itself - an unknown id or a
458
+ * frame that throws fails alone; only a browser that could not start fails the whole batch
459
+ * (the thrown error, for the caller to turn into a 503). Once work has begun a browser that
460
+ * dies fails the remaining entries individually. */
461
+ async function shootBatch(opts) {
462
+ const { root, viewports, frames, theme, origin, scale } = opts;
463
+ return withBrowser("shot", (b) => pool(shotConcurrency(), frames, async (frameId) => {
464
+ return {
465
+ frame: frameId,
466
+ ...await shootFrame({
467
+ root,
468
+ viewports,
469
+ frameId,
470
+ theme,
471
+ origin,
472
+ scale,
473
+ browser: b
474
+ }).catch((e) => ({
475
+ ok: false,
476
+ error: e.message
477
+ }))
478
+ };
479
+ }));
480
+ }
481
+ /** Kill the headless browsers earlier versions left behind (they outlived their server, and
482
+ * on macOS such a ghost of the user's own Chrome can swallow every link the machine opens),
483
+ * then remove the profile directories nothing references. Runs once at `dev()` start,
484
+ * best-effort, never throws. Orphaned is the rule: a live browser of ANY running marver
485
+ * server has that server as its parent, and ours die with it - so a headless Chrome on one
486
+ * of our profiles whose parent is 1 can only be a leak. darwin and linux (`ps -o`); anywhere
487
+ * else this is a no-op. */
488
+ const BROWSER_NAMES = /* @__PURE__ */ new Set([
489
+ "Google Chrome",
490
+ "Chromium",
491
+ "Microsoft Edge",
492
+ "Brave Browser",
493
+ "chrome",
494
+ "chromium",
495
+ "chromium-browser",
496
+ "google-chrome",
497
+ "google-chrome-stable",
498
+ "msedge",
499
+ "brave",
500
+ "brave-browser"
501
+ ]);
502
+ async function sweepGhosts(log = () => {}) {
503
+ if (process.platform !== "darwin" && process.platform !== "linux") return;
504
+ const tmp = tmpdir();
505
+ const bins = /* @__PURE__ */ new Set([...CHROMES, ...process.env.MARVER_CHROME ? [process.env.MARVER_CHROME] : []]);
506
+ const profileOf = (cmd) => {
507
+ const m = /(?:^|\s)--user-data-dir=(\S+)/.exec(cmd);
508
+ if (!m) return null;
509
+ const dir = m[1];
510
+ return PROFILE_PREFIXES.some((pre) => dir.startsWith(join(tmp, pre))) ? dir : null;
511
+ };
512
+ const isBrowser = (cmd) => {
513
+ const exe = cmd.split(" --")[0];
514
+ if ([...bins].some((b) => exe === b || exe.startsWith(`${b} `))) return true;
515
+ return BROWSER_NAMES.has(exe.slice(exe.lastIndexOf("/") + 1));
516
+ };
517
+ const ps = (args) => {
518
+ try {
519
+ return execFileSync("ps", args, { encoding: "utf8" }).split("\n").map((l) => {
520
+ const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(l);
521
+ return m ? {
522
+ pid: Number(m[1]),
523
+ ppid: Number(m[2]),
524
+ cmd: m[3]
525
+ } : null;
526
+ }).filter((r) => !!r);
527
+ } catch {
528
+ return null;
529
+ }
530
+ };
531
+ const rows = ps(["-axo", "pid=,ppid=,command="]);
532
+ if (!rows) return;
533
+ const isGhost = (r) => r.ppid === 1 && r.cmd.includes("--headless") && !!profileOf(r.cmd) && isBrowser(r.cmd);
534
+ const referenced = /* @__PURE__ */ new Set();
535
+ for (const r of rows) {
536
+ const dir = profileOf(r.cmd);
537
+ if (dir) referenced.add(dir);
538
+ }
539
+ for (const g of rows.filter(isGhost)) {
540
+ const now = ps([
541
+ "-o",
542
+ "pid=,ppid=,command=",
543
+ "-p",
544
+ String(g.pid)
545
+ ])?.[0];
546
+ if (!now || now.cmd !== g.cmd || now.ppid !== g.ppid) continue;
547
+ try {
548
+ process.kill(g.pid, "SIGKILL");
549
+ } catch {
550
+ continue;
551
+ }
552
+ const by = Date.now() + 2e3;
553
+ while (Date.now() < by) try {
554
+ process.kill(g.pid, 0);
555
+ await new Promise((r) => setTimeout(r, 50));
556
+ } catch {
557
+ break;
558
+ }
559
+ log(`shot: removed a headless browser left by an earlier version (pid ${g.pid})`);
560
+ referenced.delete(profileOf(g.cmd));
561
+ }
562
+ let names = [];
563
+ try {
564
+ names = readdirSync(tmp).filter((n) => PROFILE_PREFIXES.some((pre) => n.startsWith(pre)));
565
+ } catch {
566
+ return;
567
+ }
568
+ for (const n of names) {
569
+ const dir = join(tmp, n);
570
+ if (referenced.has(dir)) continue;
571
+ try {
572
+ if (Date.now() - statSync(dir).mtimeMs < 6e5) continue;
573
+ } catch {
574
+ continue;
575
+ }
576
+ try {
577
+ rmSync(dir, {
578
+ recursive: true,
579
+ force: true
580
+ });
581
+ } catch {}
582
+ }
583
+ }
584
+ /** A crashed process can leave a `.tmp.png` behind; sweep them (older than a minute, so an in-
585
+ * flight capture in this process is never touched) at the next shot. */
586
+ const sweptAt = /* @__PURE__ */ new Map();
587
+ function sweepTemps(dir) {
588
+ if (Date.now() - (sweptAt.get(dir) ?? 0) < 6e4) return;
589
+ sweptAt.set(dir, Date.now());
590
+ try {
591
+ for (const f of readdirSync(dir)) {
592
+ if (!f.endsWith(".tmp.png")) continue;
593
+ const p = join(dir, f);
594
+ try {
595
+ if (Date.now() - statSync(p).mtimeMs > 6e4) rmSync(p, { force: true });
596
+ } catch {}
597
+ }
598
+ } catch {}
599
+ }
600
+ /** One OPERATION at a time PER LANE, and one browser per operation. An operation is a shot,
601
+ * a batch or a poster: it starts its browser, runs its frames through it (a batch runs N at
602
+ * once inside), and closes it in the finally - no browser is ever shared between operations
603
+ * or left idle. Operations on a lane queue as shots always did, so two operations never
604
+ * write the same output at the same time. Posters have their own lane - a frame being shot
605
+ * may ask for its poster mid-render, and on the shot's lane that request would wait for the
606
+ * very shot that is waiting for it. */
607
+ const chains = /* @__PURE__ */ new Map();
608
+ function withBrowser(lane, fn) {
609
+ const run = async () => {
610
+ const b = await Browser.launch();
611
+ try {
612
+ return await fn(b);
613
+ } finally {
614
+ await b.close();
615
+ }
616
+ };
617
+ const next = (chains.get(lane) ?? Promise.resolve()).then(run, run);
618
+ chains.set(lane, next);
619
+ return next;
620
+ }
621
+ /** One frame as its own operation (a poster, the shell's copy-as-image, a single `shot`). */
622
+ function capture(req, lane = "shot") {
623
+ return withBrowser(lane, (b) => captureIn(req, b)).catch((e) => ({
624
+ ok: false,
625
+ error: e.message
626
+ }));
627
+ }
628
+ /** How many frames a batch renders at once inside its browser. Six at most by default, fewer
629
+ * on a small machine: the settle and grow passes are wall-clock budgets, and past a few
630
+ * concurrent renderers a frame starts spending its budget waiting for CPU instead of for its
631
+ * own content (measured 2026-09-04: 31 frames, no unsettled capture up to 8 on 18 cores). */
632
+ function shotConcurrency() {
633
+ const n = Number(process.env.MARVER_SHOT_CONCURRENCY);
634
+ if (Number.isInteger(n) && n >= 1 && n <= 16) return n;
635
+ return Math.max(2, Math.min(6, availableParallelism() - 2));
636
+ }
637
+ /** Run `fn` over `items` with at most `n` in flight; results in input order. */
638
+ async function pool(n, items, fn) {
639
+ const results = new Array(items.length);
640
+ let next = 0;
641
+ const worker = async () => {
642
+ for (;;) {
643
+ const i = next++;
644
+ if (i >= items.length) return;
645
+ results[i] = await fn(items[i], i);
646
+ }
647
+ };
648
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
649
+ return results;
650
+ }
651
+ /** The capture budget. Chrome's surface tops out near 16384 device px per SIDE, and a bitmap is
652
+ * also bounded by its AREA: 64M device px (~256 MiB RGBA) is comfortably rendered, encoded and
653
+ * pasted, where a 3840×2160@4 (133M px) can stall the renderer or the paste target. Both limits
654
+ * are exported so the tests hold the same numbers. */
655
+ const SURFACE = 16384;
656
+ const AREA = 64e6;
657
+ const fits = (w, h, dsf) => w * dsf <= 16384 && h * dsf <= 16384 && w * h * dsf * dsf <= 64e6;
658
+ /** The tallest CSS height a full-height capture of `width` can hold at `dsf`. */
659
+ const capFor = (width, dsf) => Math.max(1, Math.min(Math.floor(SURFACE / dsf), Math.floor(AREA / (width * dsf * dsf))));
660
+ async function captureIn({ url, width, height, out, fullHeight = false, timeoutMs = 3e4, scale: wantScale = 2, clip }, b) {
661
+ let scale = Math.min(4, Math.max(1, Math.round(wantScale)));
662
+ while (scale > 1 && !fits(width, height, scale)) scale--;
663
+ const me = {};
664
+ let watchdog;
665
+ let targetId;
666
+ let sessionId;
667
+ let off = () => {};
668
+ try {
669
+ let lastException = "";
670
+ const send = (method, params = {}, sid) => b.send(method, params, sid, me);
671
+ const sendD = (method, params, ms = 1500) => {
672
+ let timer;
673
+ return Promise.race([send(method, params, sessionId), new Promise((_, rej) => {
674
+ timer = setTimeout(() => rej(/* @__PURE__ */ new Error(`${method} timed out`)), ms);
675
+ })]).finally(() => clearTimeout(timer));
676
+ };
677
+ const hardBy = Date.now() + timeoutMs + 15e3;
678
+ watchdog = setTimeout(() => b.abort(me, "the shot timed out"), timeoutMs + 15e3);
679
+ const RESERVE = 21e3;
680
+ const discretionary = () => hardBy - RESERVE - Date.now();
681
+ targetId = (await send("Target.createTarget", { url: "about:blank" })).targetId;
682
+ sessionId = (await send("Target.attachToTarget", {
683
+ targetId,
684
+ flatten: true
685
+ })).sessionId;
686
+ off = b.on((m) => {
687
+ if (m.sessionId === sessionId && m.method === "Runtime.exceptionThrown") {
688
+ const d = m.params?.exceptionDetails;
689
+ lastException = String(d?.exception?.description ?? d?.text ?? "").split("\n")[0];
690
+ }
691
+ });
692
+ await send("Emulation.setDeviceMetricsOverride", {
693
+ width,
694
+ height,
695
+ deviceScaleFactor: scale,
696
+ mobile: false
697
+ }, sessionId);
698
+ await send("Page.enable", {}, sessionId);
699
+ await send("Runtime.enable", {}, sessionId);
700
+ const nav = await send("Page.navigate", { url }, sessionId);
701
+ if (nav?.errorText && nav.errorText !== "net::ERR_ABORTED") return {
702
+ ok: false,
703
+ error: `could not load the frame (${nav.errorText}) - is the dev server reachable at ${new URL(url).origin}?`
704
+ };
705
+ const t0 = Date.now();
706
+ let ready = false;
707
+ while (Date.now() - t0 < timeoutMs) {
708
+ if ((await send("Runtime.evaluate", {
709
+ expression: `(() => { const el = document.getElementById('root') ?? document.body; return !!el && el.childElementCount > 0 && document.readyState !== 'loading' })()`,
710
+ returnByValue: true
711
+ }, sessionId).catch(() => null))?.result?.value) {
712
+ ready = true;
713
+ break;
714
+ }
715
+ await new Promise((r2) => setTimeout(r2, 150));
716
+ }
717
+ if (!ready) return {
718
+ ok: false,
719
+ error: `the frame never rendered${lastException ? ` - the page threw: ${lastException}` : " (no exception surfaced - is the dev server reachable from this machine?)"}`
720
+ };
721
+ const SETTLED = `(() => {
722
+ if (typeof window.__mvLodBusy === 'function' && window.__mvLodBusy() > 0) return false
723
+ if (typeof window.__mvPosterBusy === 'function' && window.__mvPosterBusy() > 0) return false
724
+ const H = window.innerHeight, W = window.innerWidth
725
+ for (const im of document.images) {
726
+ if (im.complete) continue
727
+ const r = im.getBoundingClientRect()
728
+ if (r.bottom < 0 || r.top > H || r.right < 0 || r.left > W) continue
729
+ return false
730
+ }
731
+ for (const c of document.querySelectorAll('.mv-chart')) if (!c.querySelector('svg, canvas')) return false
732
+ for (const d of document.querySelectorAll('.mv-diagram')) if (!d.querySelector('.mv-diagram-svg svg, .mv-diagram-err')) return false
733
+ return true
734
+ })()`;
735
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
736
+ const settle = async (budgetMs) => {
737
+ const total = Math.min(budgetMs, discretionary());
738
+ if (total <= 0) return false;
739
+ const by = Date.now() + total;
740
+ const left = () => Math.max(1, by - Date.now());
741
+ await sendD("Runtime.evaluate", {
742
+ expression: `window.postMessage({type:'sh:camera',moving:false,scale:1}, location.origin)`,
743
+ returnByValue: true
744
+ }, left()).catch(() => null);
745
+ await sendD("Runtime.evaluate", {
746
+ expression: "document.fonts.ready.then(() => true)",
747
+ awaitPromise: true,
748
+ returnByValue: true
749
+ }, left()).catch(() => null);
750
+ let ok = false;
751
+ while (Date.now() < by) {
752
+ if ((await sendD("Runtime.evaluate", {
753
+ expression: SETTLED,
754
+ returnByValue: true
755
+ }, left()).catch(() => null))?.result?.value === true) {
756
+ ok = true;
757
+ break;
758
+ }
759
+ await wait(Math.min(100, left()));
760
+ }
761
+ if (Date.now() < by) await sendD("Runtime.evaluate", {
762
+ expression: "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))",
763
+ awaitPromise: true,
764
+ returnByValue: true
765
+ }, Math.min(1e3, left())).catch(() => null);
766
+ if (Date.now() < by) await wait(Math.min(ok ? 150 : 250, left()));
767
+ return ok;
768
+ };
769
+ let settled = await settle(3e3);
770
+ const errEval = await send("Runtime.evaluate", {
771
+ expression: "window.__mvFrameError || \"\"",
772
+ returnByValue: true
773
+ }, sessionId).catch(() => null);
774
+ const frameError = typeof errEval?.result?.value === "string" ? errEval.result.value : "";
775
+ if (frameError) return {
776
+ ok: false,
777
+ error: `the frame rendered an error - ${frameError}`
778
+ };
779
+ let capW = width, capH = height, truncated = false, note = "";
780
+ if (fullHeight) {
781
+ const measureH = async () => {
782
+ const v = (await sendD("Runtime.evaluate", {
783
+ expression: `Math.ceil((document.querySelector('.mv-doc')||document.getElementById('root')||document.body).getBoundingClientRect().height)`,
784
+ returnByValue: true
785
+ }).catch(() => null))?.result?.value;
786
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
787
+ };
788
+ const lodIdle = async () => {
789
+ const v = (await sendD("Runtime.evaluate", {
790
+ expression: `(typeof window.__mvLodBusy==='function')?window.__mvLodBusy():0`,
791
+ returnByValue: true
792
+ }).catch(() => null))?.result?.value;
793
+ return typeof v === "number" ? v === 0 : true;
794
+ };
795
+ let measured = 0;
796
+ const growFit = async (dsf, capCss, deadline) => {
797
+ let h = Math.max(height, 2e3), prev = -1;
798
+ for (let i = 0; i < 8 && Date.now() < deadline; i++) {
799
+ await sendD("Emulation.setDeviceMetricsOverride", {
800
+ width,
801
+ height: Math.min(h, capCss),
802
+ deviceScaleFactor: dsf,
803
+ mobile: false
804
+ }).catch(() => {});
805
+ if (Date.now() >= deadline) break;
806
+ await sendD("Runtime.evaluate", {
807
+ expression: "document.fonts.ready.then(()=>true)",
808
+ awaitPromise: true,
809
+ returnByValue: true
810
+ }).catch(() => null);
811
+ if (Date.now() >= deadline) break;
812
+ for (let j = 0; j < 10 && Date.now() < deadline && !await lodIdle(); j++) await wait(100);
813
+ if (Date.now() >= deadline) break;
814
+ await wait(150);
815
+ if (Date.now() >= deadline) break;
816
+ const m = await measureH();
817
+ if (m > 0) measured = m;
818
+ const capped = Math.min(h, capCss);
819
+ if (capped >= capCss) break;
820
+ if (m > 0 && m <= capped && m === prev) break;
821
+ prev = m;
822
+ h = Math.max(m, capped);
823
+ }
824
+ };
825
+ const settleDeadline = Date.now() + Math.max(0, Math.min(6e3, discretionary()));
826
+ const asked = scale;
827
+ await growFit(scale, capFor(width, scale), settleDeadline);
828
+ if (measured > capFor(width, scale) && scale > 2) {
829
+ scale = 2;
830
+ await growFit(2, capFor(width, 2), settleDeadline);
831
+ }
832
+ if (measured > capFor(width, scale) && scale > 1) {
833
+ scale = 1;
834
+ await growFit(1, capFor(width, 1), settleDeadline);
835
+ }
836
+ const cap = capFor(width, scale);
837
+ capW = width;
838
+ if (measured > cap) {
839
+ capH = cap;
840
+ truncated = true;
841
+ note = `frame is ${measured}px tall; captured the top ${cap}px - split it or reduce its height`;
842
+ } else capH = clamp(measured || height, 80, cap);
843
+ if (scale < asked && !truncated) note = `frame is ${measured}px tall - too tall for ${asked}x, captured at ${scale}x`;
844
+ await sendD("Emulation.setDeviceMetricsOverride", {
845
+ width: capW,
846
+ height: capH,
847
+ deviceScaleFactor: scale,
848
+ mobile: false
849
+ }, 5e3);
850
+ await wait(Math.min(300, Math.max(0, discretionary())));
851
+ settled = await settle(Math.max(1200, settleDeadline - Date.now()));
852
+ const m2 = await measureH();
853
+ if (m2 > capH) {
854
+ if (m2 <= cap) capH = m2;
855
+ else {
856
+ capH = cap;
857
+ truncated = true;
858
+ note = `frame is ${m2}px tall; captured the top ${cap}px - split it or reduce its height`;
859
+ }
860
+ await sendD("Emulation.setDeviceMetricsOverride", {
861
+ width: capW,
862
+ height: capH,
863
+ deviceScaleFactor: scale,
864
+ mobile: false
865
+ }, 5e3);
866
+ settled = await settle(600);
867
+ }
868
+ }
869
+ let capX = 0, capY = 0;
870
+ if (clip) {
871
+ const box = (await sendD("Runtime.evaluate", {
872
+ expression: `(() => { const el = document.querySelector(${JSON.stringify(clip)}); if (!el) return null; const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height } })()`,
873
+ returnByValue: true
874
+ }, 2e3).catch(() => null))?.result?.value;
875
+ if (!box || box.w < 1 || box.h < 1) return {
876
+ ok: false,
877
+ error: `nothing to capture at "${clip}"`
878
+ };
879
+ capX = Math.max(0, Math.floor(box.x));
880
+ capY = Math.max(0, Math.floor(box.y));
881
+ capW = Math.max(1, Math.round(box.w));
882
+ capH = Math.max(1, Math.round(box.h));
883
+ }
884
+ const shot = await sendD("Page.captureScreenshot", {
885
+ format: "png",
886
+ captureBeyondViewport: true,
887
+ clip: {
888
+ x: capX,
889
+ y: capY,
890
+ width: capW,
891
+ height: capH,
892
+ scale: 1
893
+ }
894
+ }, 15e3);
895
+ writeFileSync(out, Buffer.from(String(shot.data), "base64"));
896
+ const unsettled = !settled;
897
+ if (unsettled) note = `${note ? note + "; " : ""}captured before the frame settled - images, charts or diagrams may be missing; shoot it alone or lower MARVER_SHOT_CONCURRENCY`;
898
+ return {
899
+ ok: true,
900
+ width: capW,
901
+ height: capH,
902
+ scale,
903
+ ...truncated ? { truncated: true } : {},
904
+ ...unsettled ? { unsettled: true } : {},
905
+ ...note ? { note } : {}
906
+ };
907
+ } catch (err) {
908
+ return {
909
+ ok: false,
910
+ error: err.message
911
+ };
912
+ } finally {
913
+ if (watchdog) clearTimeout(watchdog);
914
+ off();
915
+ if (targetId && !b.dead) {
916
+ let t;
917
+ await Promise.race([b.send("Target.closeTarget", { targetId }, void 0, me).catch(() => {}), new Promise((r) => {
918
+ t = setTimeout(r, 2e3);
919
+ })]);
920
+ clearTimeout(t);
921
+ }
922
+ b.abort(me, "the shot finished");
923
+ }
924
+ }
925
+ //#endregion
926
+ export { capture, resolveFrames, shootBatch, shootFrame, sweepGhosts, findChrome as t };