@africanpilot/next-snapshot 0.1.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.
@@ -0,0 +1,426 @@
1
+ // The outer page of the snapshot. Runs once, in the file:// document.
2
+ //
3
+ // Holds every captured body, decodes assets into blob: URLs up front, and shows
4
+ // one page at a time in a fresh srcdoc <iframe>. A fresh frame per navigation
5
+ // is the point: each page boots exactly as it did from the server, with no
6
+ // framework state carried over from the last one. The frame talks back through
7
+ // window.__NO (same origin, so plain function calls).
8
+ //
9
+ // Placeholders filled by bundle.mjs: URLKEY, FRAME, POST.
10
+ (async function () {
11
+ "use strict";
12
+ var urlKey = /*@URLKEY@*/null;
13
+ var FRAME_SRC = /*@FRAME@*/"";
14
+ var POST = /*@POST@*/{};
15
+
16
+ var M = JSON.parse(document.getElementById("no-manifest").textContent);
17
+ var ORIGIN = M.origin;
18
+ var td = new TextDecoder();
19
+ var multi = M.variants.length > 1;
20
+ var NO = (window.__NO = { origin: ORIGIN, manifest: M, reports: [], ready: false, seq: 0, loaded: null, cookies: {} });
21
+ var ALIASES = M.aliases || [];
22
+ // Another spelling of the app's own origin (localhost vs 127.0.0.1) is the app.
23
+ NO.canon = function (href) {
24
+ href = String(href);
25
+ for (var i = 0; i < ALIASES.length; i++) {
26
+ var a = ALIASES[i];
27
+ if (href === a || href.indexOf(a + "/") === 0 || href.indexOf(a + "?") === 0) return ORIGIN + href.slice(a.length);
28
+ }
29
+ return href;
30
+ };
31
+ NO.key = function (u, base) {
32
+ var abs;
33
+ try { abs = new URL(u, base || ORIGIN).href; } catch (e) { return null; }
34
+ return urlKey(NO.canon(abs), ORIGIN, ORIGIN);
35
+ };
36
+
37
+ // --- bodies -------------------------------------------------------------------
38
+ function b64(s) {
39
+ if (Uint8Array.fromBase64) return Uint8Array.fromBase64(s);
40
+ var bin = atob(s), n = bin.length, a = new Uint8Array(n);
41
+ for (var i = 0; i < n; i++) a[i] = bin.charCodeAt(i);
42
+ return a;
43
+ }
44
+ var kept = {};
45
+ function body(id, keep) {
46
+ if (kept[id]) return kept[id];
47
+ var p = (async function () {
48
+ var el = document.getElementById("no-b" + id);
49
+ if (!el) throw new Error("snapshot body " + id + " is missing");
50
+ var stream = new Blob([b64(el.textContent.trim())]).stream().pipeThrough(new DecompressionStream("gzip"));
51
+ return new Uint8Array(await new Response(stream).arrayBuffer());
52
+ })();
53
+ if (keep) kept[id] = p;
54
+ return p;
55
+ }
56
+
57
+ // --- assets -----------------------------------------------------------------------
58
+ var A = M.assets.map(function (r) {
59
+ return { k: r[0], v: r[1], b: r[2], t: r[3] || "application/octet-stream", s: r[4] || 200 };
60
+ });
61
+ var idx = {};
62
+ A.forEach(function (a, i) {
63
+ idx[(a.v || "") + "\u0000" + a.k] = i;
64
+ });
65
+ var variant = M.defaultVariant;
66
+ function lookup(key, v) {
67
+ if (key == null) return -1;
68
+ var i = idx[(v || "") + "\u0000" + key];
69
+ if (i == null) i = idx["\u0000" + key];
70
+ if (i == null && key.indexOf("?") > 0 && key.indexOf("/_next/static/") === 0) i = idx["\u0000" + key.split("?")[0]];
71
+ return i == null ? -1 : i;
72
+ }
73
+ var bytes = await Promise.all(A.map(function (a) { return body(a.b, true); }));
74
+ var urls = [], orig = {};
75
+ function assetURL(i) {
76
+ if (urls[i]) return urls[i];
77
+ var a = A[i], blob;
78
+ if (/css/i.test(a.t)) {
79
+ urls[i] = "data:,"; // cycle guard for CSS that imports itself
80
+ blob = new Blob([tokens(td.decode(bytes[i]))], { type: a.t });
81
+ } else {
82
+ blob = new Blob([bytes[i]], { type: a.t });
83
+ }
84
+ var u = URL.createObjectURL(blob);
85
+ urls[i] = u;
86
+ orig[u] = a.k;
87
+ return u;
88
+ }
89
+ function tokens(text) {
90
+ return text.replace(/__NOA(\d+)__/g, function (_, i) { return assetURL(+i); });
91
+ }
92
+ for (var i = 0; i < A.length; i++) assetURL(i);
93
+
94
+ NO.assetIndex = function (href) { return lookup(NO.key(href), variant); };
95
+ NO.assetURLFor = function (href) {
96
+ var i = NO.assetIndex(href);
97
+ return i < 0 ? null : urls[i];
98
+ };
99
+ NO.unmap = function (u) { return orig[u]; };
100
+ NO.isAsset = function (href) {
101
+ var key = NO.key(href), P = M.pages[variant] || {};
102
+ return !P[key] && lookup(key, variant) >= 0;
103
+ };
104
+ NO.fetch = async function (href) {
105
+ var key = NO.key(href);
106
+ if (key == null) return null;
107
+ var i = lookup(key, variant);
108
+ if (i >= 0) {
109
+ var a = A[i];
110
+ var b = /css/i.test(a.t) ? new TextEncoder().encode(tokens(td.decode(bytes[i]))) : bytes[i];
111
+ return { status: a.s, type: a.t, bytes: b };
112
+ }
113
+ var r = resolvePage(key, variant);
114
+ if (r.entry && r.entry.b != null) {
115
+ var html = tokens(td.decode(await body(r.entry.b)));
116
+ return { status: r.entry.s || 200, type: "text/html; charset=utf-8", bytes: new TextEncoder().encode(html) };
117
+ }
118
+ return null;
119
+ };
120
+
121
+ // --- pages & navigation ---------------------------------------------------------------
122
+ function resolvePage(key, v) {
123
+ var P = M.pages[v] || {}, hops = 0;
124
+ while (P[key] && P[key].r != null && hops++ < 20) key = P[key].r;
125
+ return { key: key, entry: P[key] || null };
126
+ }
127
+ NO.resolve = function (v, key) { return resolvePage(key, v).key; };
128
+ NO.hasPage = function (href) {
129
+ var key = NO.key(href);
130
+ if (key == null) return false;
131
+ var r = resolvePage(key, variant);
132
+ return !!(r.entry && (r.entry.b != null || r.entry.a != null)) || !!soft[variant + "\u0000" + r.key];
133
+ };
134
+ NO.listPages = function () {
135
+ var out = [];
136
+ M.variants.forEach(function (v) {
137
+ var P = M.pages[v.id] || {};
138
+ Object.keys(P).forEach(function (k) { if (P[k].b != null) out.push({ variant: v.id, key: k }); });
139
+ });
140
+ return out;
141
+ };
142
+
143
+ var current = null, frame = null, pending = null;
144
+
145
+ function hashFor(v, key, h) {
146
+ return "#" + (multi ? encodeURIComponent(v) + ":" : "") + key + (h || "");
147
+ }
148
+ function parseHash() {
149
+ var h = location.hash.slice(1);
150
+ if (!h) return null;
151
+ var v = variant, m = /^([^:\/?#]+):(\/.*)?$/.exec(h);
152
+ if (m) { v = decodeURIComponent(m[1]); h = m[2] || "/"; }
153
+ if (h.charAt(0) !== "/") return null;
154
+ var hi = h.indexOf("#"), frag = "";
155
+ if (hi >= 0) { frag = h.slice(hi); h = h.slice(0, hi); }
156
+ return { variant: v, key: NO.key(ORIGIN + h), hash: frag };
157
+ }
158
+ function writeHash(mode) {
159
+ var h = hashFor(current.variant, current.key, current.hash);
160
+ if (location.hash === h) return;
161
+ try {
162
+ if (mode === "push") history.pushState(null, "", h);
163
+ else history.replaceState(null, "", h);
164
+ } catch (e) {
165
+ /* some file:// contexts refuse; the hash is a convenience */
166
+ }
167
+ }
168
+
169
+ async function load(v, rawKey, hash, mode) {
170
+ var seq = ++NO.seq;
171
+ if (!M.pages[v]) v = M.defaultVariant;
172
+ setVariant(v);
173
+ var r = resolvePage(rawKey, v), e = r.entry, html, base = r.key;
174
+ if (!e && soft[v + "\u0000" + r.key]) {
175
+ var sr = resolvePage(soft[v + "\u0000" + r.key], v);
176
+ if (sr.entry && sr.entry.b != null) { e = sr.entry; base = sr.key; }
177
+ }
178
+ if (e && e.a != null) { openAssetIndex(e.a); return; }
179
+ if (e && e.b != null) html = tokens(td.decode(await body(e.b)));
180
+ else { html = missingHTML(v, r.key); NO.report("missing", r.key); }
181
+ if (seq !== NO.seq) return; // a later navigation won
182
+ current = { variant: v, key: r.key, hash: hash || "", base: base };
183
+ writeHash(mode);
184
+ mount(html, seq);
185
+ }
186
+
187
+ function mount(html, seq) {
188
+ html = html.replace("<script data-no-shim></script>", function () {
189
+ return "<script data-no-shim>" + FRAME_SRC + "<\/script>";
190
+ });
191
+ NO.frameState = { url: ORIGIN + current.key + current.hash, seq: seq };
192
+ var f = document.createElement("iframe");
193
+ f.className = "no-frame loading";
194
+ f.setAttribute("title", M.title || "Application");
195
+ f.addEventListener("load", function () {
196
+ NO.frameReady(f.contentWindow, seq);
197
+ if (seq === NO.seq) {
198
+ NO.loaded = { variant: current.variant, key: current.key, seq: seq };
199
+ syncTitle(f.contentWindow);
200
+ }
201
+ });
202
+ if (pending && pending !== frame) pending.remove();
203
+ pending = f;
204
+ f.srcdoc = html;
205
+ document.body.appendChild(f);
206
+ }
207
+
208
+ NO.frameReady = function (win, seq) {
209
+ if (seq !== NO.seq || !pending || pending.contentWindow !== win) return;
210
+ var f = pending;
211
+ if (frame && frame !== f) frame.remove();
212
+ frame = f;
213
+ pending = null;
214
+ f.classList.remove("loading");
215
+ var l = document.getElementById("no-loading");
216
+ if (l) l.remove();
217
+ try { win.focus(); } catch (e) {}
218
+ };
219
+
220
+ NO.navigate = function (href, opts) {
221
+ var u = new URL(href, ORIGIN + (current ? current.key : "/"));
222
+ if (u.origin !== ORIGIN) { window.open(u.href, "_blank", "noopener"); return; }
223
+ var key = NO.key(u.href), P = M.pages[variant] || {};
224
+ if (!P[key]) {
225
+ var i = lookup(key, variant);
226
+ if (i >= 0) { openAssetIndex(i); return; }
227
+ }
228
+ load(variant, key, u.hash, opts && opts.replace ? "replace" : "push");
229
+ };
230
+ NO.go = function (v, key) { load(v, key, "", "push"); };
231
+ NO.reload = function () { if (current) load(variant, current.key, current.hash, "replace"); };
232
+ // URLs the app wrote itself (a tab that calls history.replaceState) may never
233
+ // have been captured. Remember which captured page they were reached from, so
234
+ // coming back to one — reload, back/forward — serves that page at this URL
235
+ // and lets the app's own code read the parameters, instead of a dead end.
236
+ // Kept in sessionStorage (per tab, per snapshot) so a reload still knows.
237
+ var SOFT_KEY = "next-snapshot:soft:" + M.createdAt;
238
+ var soft = {};
239
+ try { soft = JSON.parse(sessionStorage.getItem(SOFT_KEY) || "{}") || {}; } catch (e) { soft = {}; }
240
+ function saveSoft() {
241
+ try { sessionStorage.setItem(SOFT_KEY, JSON.stringify(soft)); } catch (e) { /* storage refused: in-memory only */ }
242
+ }
243
+ NO.frameHistory = function (href, mode) {
244
+ var u = new URL(href), key = NO.key(u.href);
245
+ var base = current ? current.base || current.key : null;
246
+ if (base && !(M.pages[variant] || {})[key]) soft[variant + "\u0000" + key] = base;
247
+ saveSoft();
248
+ current = { variant: variant, key: key, hash: u.hash, base: base };
249
+ writeHash(mode === "push" ? "push" : "replace");
250
+ };
251
+ NO.openInNewTab = function (href) {
252
+ var u = new URL(href, ORIGIN);
253
+ window.open(location.href.split("#")[0] + hashFor(variant, NO.key(u.href), u.hash), "_blank");
254
+ };
255
+ NO.openAsset = function (href, name) {
256
+ var i = NO.assetIndex(href);
257
+ if (i >= 0) openAssetIndex(i, name);
258
+ };
259
+ function openAssetIndex(i, name) {
260
+ var a = document.createElement("a"), t = A[i].t;
261
+ a.href = urls[i];
262
+ if (name != null || !/^(text\/html|text\/plain|image\/|application\/pdf)/.test(t)) {
263
+ a.download = name || A[i].k.split("?")[0].split("/").pop() || "download";
264
+ } else {
265
+ a.target = "_blank";
266
+ }
267
+ document.body.appendChild(a);
268
+ a.click();
269
+ a.remove();
270
+ }
271
+
272
+ function onHistory() {
273
+ var p = parseHash();
274
+ if (!p) return;
275
+ if (current && p.variant === current.variant && p.key === current.key) {
276
+ if (p.hash !== current.hash) current.hash = p.hash;
277
+ return;
278
+ }
279
+ load(p.variant, p.key, p.hash, "replace");
280
+ }
281
+ window.addEventListener("popstate", onHistory);
282
+ window.addEventListener("hashchange", onHistory);
283
+
284
+ // --- writes: forms and fetches that are not GET ---------------------------------------
285
+ // A configured handler can emulate a POST in the browser (sign in as a role,
286
+ // sign out). Anything else is refused: the snapshot is read-only.
287
+ function runHandler(href, method, fields) {
288
+ var u = new URL(href, ORIGIN), h = POST[u.pathname];
289
+ if (!h) {
290
+ toast("This is a read-only offline snapshot — changes can’t be saved.");
291
+ NO.report("write", method.toUpperCase() + " " + NO.key(u.href));
292
+ return false;
293
+ }
294
+ var res;
295
+ try {
296
+ res = h(fields || {}, { variant: variant, key: current && current.key }) || {};
297
+ } catch (e) {
298
+ NO.report("error", "offline.post handler for " + u.pathname + ": " + e.message);
299
+ return false;
300
+ }
301
+ if (res.message) toast(res.message);
302
+ var changed = res.variant && M.pages[res.variant] && res.variant !== variant;
303
+ if (changed) setVariant(res.variant);
304
+ if (res.location) setTimeout(function () { load(variant, NO.key(new URL(res.location, ORIGIN).href), "", "push"); }, 0);
305
+ else if (changed) setTimeout(NO.reload, 0);
306
+ return true;
307
+ }
308
+ NO.submit = function (href, method, fields) { runHandler(href, method, fields); };
309
+ NO.write = function (href, method, fields) { return runHandler(href, method, fields); };
310
+
311
+ // --- chrome: badge, toast, title -----------------------------------------------------------
312
+ var badge = null;
313
+ function label(v) {
314
+ for (var i = 0; i < M.variants.length; i++) if (M.variants[i].id === v) return M.variants[i].label || v;
315
+ return v;
316
+ }
317
+ function setVariant(v) {
318
+ if (v === variant) return;
319
+ variant = v;
320
+ renderBadge();
321
+ }
322
+ function renderBadge() {
323
+ if (!M.badge) return;
324
+ if (!badge) {
325
+ badge = document.createElement("div");
326
+ badge.id = "no-badge";
327
+ badge.className = String(M.badge);
328
+ document.body.appendChild(badge);
329
+ }
330
+ var when = "";
331
+ try { when = new Date(M.createdAt).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); } catch (e) {}
332
+ badge.innerHTML = "";
333
+ var dot = document.createElement("span");
334
+ dot.className = "dot";
335
+ var txt = document.createElement("span");
336
+ txt.textContent = "Offline snapshot" + (when ? " · " + when : "");
337
+ txt.title = "Captured " + M.createdAt + " from " + ORIGIN + ". Read-only: nothing here reaches a server.";
338
+ badge.appendChild(dot);
339
+ badge.appendChild(txt);
340
+ if (multi && M.switcher !== false) {
341
+ var sel = document.createElement("select");
342
+ sel.setAttribute("aria-label", "View as");
343
+ M.variants.forEach(function (v) {
344
+ var o = document.createElement("option");
345
+ o.value = v.id;
346
+ o.textContent = v.label || v.id;
347
+ if (v.id === variant) o.selected = true;
348
+ sel.appendChild(o);
349
+ });
350
+ sel.addEventListener("change", function () {
351
+ setVariant(sel.value);
352
+ load(sel.value, current ? current.key : M.start, "", "push");
353
+ });
354
+ badge.appendChild(sel);
355
+ }
356
+ }
357
+ var toastEl = null, toastTimer = 0;
358
+ function toast(msg) {
359
+ if (!toastEl) {
360
+ toastEl = document.createElement("div");
361
+ toastEl.id = "no-toast";
362
+ toastEl.setAttribute("role", "status");
363
+ document.body.appendChild(toastEl);
364
+ }
365
+ toastEl.textContent = msg;
366
+ toastEl.classList.add("show");
367
+ clearTimeout(toastTimer);
368
+ toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 3500);
369
+ }
370
+ NO.toast = toast;
371
+ function syncTitle(win) {
372
+ try { document.title = win.document.title || M.title || document.title; } catch (e) {}
373
+ }
374
+ NO.title = function (win, t) { if (frame && frame.contentWindow === win && t) document.title = t; };
375
+
376
+ // --- diagnostics (read by `next-snapshot verify`) ---------------------------------------------
377
+ NO.report = function (kind, detail) {
378
+ var r = { kind: kind, detail: String(detail).slice(0, 600), key: current && current.key, variant: variant };
379
+ NO.reports.push(r);
380
+ if (NO.reports.length > 2000) NO.reports.shift();
381
+ if (kind !== "write" && kind !== "missing") console.warn("[next-snapshot] " + kind + ": " + r.detail);
382
+ };
383
+ NO.drainReports = function () { var r = NO.reports; NO.reports = []; return r; };
384
+ NO.hydrated = function () {
385
+ var d = frame && frame.contentDocument;
386
+ if (!d) return false;
387
+ var els = [d, d.documentElement, d.head, d.body].concat([].slice.call(d.body ? d.body.querySelectorAll("*") : [], 0, 40));
388
+ return els.some(function (el) {
389
+ return el && Object.keys(el).some(function (k) { return k.indexOf("__react") === 0; });
390
+ });
391
+ };
392
+ NO.frame = function () { return frame; };
393
+
394
+ function esc(s) {
395
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
396
+ }
397
+ function missingHTML(v, key) {
398
+ var P = M.pages[v] || {}, path = key.split("?")[0];
399
+ var pages = Object.keys(P).filter(function (k) { return P[k].b != null; }).sort();
400
+ var near = pages.filter(function (k) { return k.split("?")[0] === path; }).slice(0, 40);
401
+ var top = pages.filter(function (k) { return k.indexOf("?") < 0; }).slice(0, 200);
402
+ function li(k) { return '<li><a href="' + esc(k) + '">' + esc(k) + "</a></li>"; }
403
+ return (
404
+ '<!doctype html><html><head><base href="' + esc(ORIGIN + key) + '"><script data-no-shim></script>' +
405
+ '<meta charset="utf-8"><title>Not in this snapshot</title><style>body{font:14px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;margin:48px auto;padding:0 24px;color:#222;max-width:760px}' +
406
+ "code{background:#f2f2f2;padding:1px 5px;border-radius:4px}a{color:#0b57d0}li{margin:3px 0}h2{font-size:15px;margin-top:28px}</style></head><body>" +
407
+ '<h1 style="font-size:20px">This view isn’t in the offline snapshot</h1><p><code>' + esc(key) + "</code> was not captured" +
408
+ (multi ? " for <b>" + esc(label(v)) + "</b>" : "") +
409
+ ". A snapshot holds only the pages its capture visited; add this URL to the config’s <code>seeds</code> to include it.</p>" +
410
+ (near.length ? "<h2>Captured views of this page</h2><ul>" + near.map(li).join("") + "</ul>" : "") +
411
+ "<h2>Captured pages</h2><ul>" + top.map(li).join("") + "</ul></body></html>"
412
+ );
413
+ }
414
+
415
+ // --- boot ------------------------------------------------------------------------------------
416
+ var start = parseHash();
417
+ if (start && M.pages[start.variant]) variant = start.variant;
418
+ renderBadge();
419
+ NO.ready = true;
420
+ if (start) load(variant, start.key, start.hash, "replace");
421
+ else load(variant, M.start, "", "replace");
422
+ })().catch(function (e) {
423
+ var l = document.getElementById("no-loading");
424
+ if (l) l.textContent = "This snapshot could not open: " + (e && e.message ? e.message : e);
425
+ console.error(e);
426
+ });
package/lib/server.mjs ADDED
@@ -0,0 +1,86 @@
1
+ // Run the app for the length of a capture: build if asked (or if there is no
2
+ // build), start it, wait until it answers, and always stop it afterwards.
3
+
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ export async function withServer(cfg, opts, log, fn) {
9
+ if (!cfg.app?.start) {
10
+ if (!(await isUp(cfg.origin))) {
11
+ throw new Error(`Nothing is answering at ${cfg.origin}. Start the app, or give the config an app.start command.`);
12
+ }
13
+ log(`using the server already running at ${cfg.origin}`);
14
+ return fn();
15
+ }
16
+
17
+ // Capturing whatever happens to hold the port is worse than failing.
18
+ if (await isUp(cfg.origin)) {
19
+ throw new Error(`${cfg.origin} is already answering. Stop that server or change app.port in the config.`);
20
+ }
21
+
22
+ const marker = path.join(cfg.app.cwd, cfg.app.buildMarker ?? ".next/BUILD_ID");
23
+ if (cfg.app.build && (opts.build || !fs.existsSync(marker))) {
24
+ await run(cfg.app.build, cfg.app.cwd, log, "build");
25
+ }
26
+
27
+ fs.mkdirSync(cfg.captureDir, { recursive: true });
28
+ const logPath = path.join(cfg.captureDir, "server.log");
29
+ const logFile = fs.createWriteStream(logPath);
30
+ const port = new URL(cfg.origin).port || "80";
31
+ const cmd = cfg.app.start.replaceAll("{port}", port);
32
+ log(`start: ${cmd} (cwd ${path.relative(process.cwd(), cfg.app.cwd) || "."})`);
33
+ const child = spawn(cmd, {
34
+ cwd: cfg.app.cwd,
35
+ shell: true,
36
+ detached: true,
37
+ env: { ...process.env, PORT: port, ...(cfg.app.env ?? {}) },
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ });
40
+ child.stdout.pipe(logFile);
41
+ child.stderr.pipe(logFile);
42
+ let exitCode = null;
43
+ child.on("exit", (code) => (exitCode = code ?? -1));
44
+
45
+ try {
46
+ const t0 = Date.now();
47
+ while (!(await isUp(cfg.origin))) {
48
+ if (exitCode !== null) throw new Error(`The app exited (code ${exitCode}) before answering. See ${logPath}`);
49
+ if (Date.now() - t0 > (cfg.app.startTimeoutMs ?? 90_000)) throw new Error(`Timed out waiting for ${cfg.origin}. See ${logPath}`);
50
+ await new Promise((r) => setTimeout(r, 400));
51
+ }
52
+ log(`server up in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
53
+ return await fn();
54
+ } finally {
55
+ stop(child);
56
+ logFile.end();
57
+ }
58
+ }
59
+
60
+ // `next start` runs under a shell and a node child: stop the whole tree. POSIX
61
+ // has process groups (the child was spawned detached); Windows has taskkill.
62
+ function stop(child) {
63
+ try {
64
+ if (process.platform === "win32") spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
65
+ else process.kill(-child.pid, "SIGTERM");
66
+ } catch {
67
+ /* already gone */
68
+ }
69
+ }
70
+
71
+ async function isUp(origin) {
72
+ try {
73
+ await fetch(origin, { redirect: "manual", signal: AbortSignal.timeout(1500) });
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ function run(cmd, cwd, log, label) {
81
+ log(`${label}: ${cmd}`);
82
+ return new Promise((resolve, reject) => {
83
+ const c = spawn(cmd, { cwd, shell: true, stdio: "inherit" });
84
+ c.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`${label} failed (exit ${code})`))));
85
+ });
86
+ }
package/lib/verify.mjs ADDED
@@ -0,0 +1,170 @@
1
+ // Open the bundled file the way a user would — file://, network off — and walk
2
+ // every captured page. For each: did it load, did React hydrate, what did the
3
+ // console and the shim report. Then click a link on a few pages to prove
4
+ // navigation works. Anything that tried to reach the network is a leak.
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ import { launch } from "./browser.mjs";
11
+
12
+ export async function verify(cfg, opts, log) {
13
+ const url = pathToFileURL(cfg.out).href;
14
+ const browser = await launch(cfg);
15
+ const shotsDir = cfg.out.replace(/\.html?$/, "") + ".screens";
16
+ if (opts.screens) await fs.mkdir(shotsDir, { recursive: true });
17
+
18
+ try {
19
+ const context = await browser.newContext({ viewport: cfg.viewport, offline: true, serviceWorkers: "block" });
20
+ const leaks = [];
21
+ await context.route(
22
+ (u) => !/^(file|blob|data|about):/.test(u.protocol),
23
+ (r) => {
24
+ leaks.push(r.request().url());
25
+ return r.abort("internetdisconnected");
26
+ },
27
+ );
28
+ const page = await context.newPage();
29
+ let messages = [];
30
+ page.on("console", (m) => {
31
+ if (m.type() === "error" || m.type() === "warning") messages.push(`${m.type()}: ${m.text()}`);
32
+ });
33
+ page.on("pageerror", (e) => messages.push(`pageerror: ${e.message}`));
34
+
35
+ const t0 = Date.now();
36
+ await page.goto(url);
37
+ await page.waitForFunction(() => window.__NO && window.__NO.ready, null, { timeout: 120_000 });
38
+ await waitLoaded(page, 0);
39
+ const bootMs = Date.now() - t0;
40
+ log(`opened ${path.basename(cfg.out)} in ${bootMs}ms`);
41
+
42
+ const all = await page.evaluate(() => window.__NO.listPages());
43
+ let targets = all;
44
+ if (opts.variant) targets = targets.filter((t) => t.variant === opts.variant);
45
+ // By default, a sample: the first few URLs of each path per variant. Query
46
+ // variants of one route run the same code; --full walks every page.
47
+ if (!opts.full) {
48
+ const seen = new Map();
49
+ targets = targets.filter((t) => {
50
+ const k = `${t.variant} ${t.key.split("?")[0]}`;
51
+ const n = seen.get(k) ?? 0;
52
+ seen.set(k, n + 1);
53
+ return n < (opts.perPath ?? 2);
54
+ });
55
+ }
56
+ if (opts.limit) targets = targets.slice(0, opts.limit);
57
+ log(`checking ${targets.length} of ${all.length} pages${opts.full ? "" : " (a sample; --full for every page)"}`);
58
+
59
+ const results = [];
60
+ for (const t of targets) {
61
+ messages = [];
62
+ await page.evaluate(() => window.__NO.drainReports());
63
+ const before = await page.evaluate(() => window.__NO.seq);
64
+ const started = Date.now();
65
+ await page.evaluate(([v, k]) => window.__NO.go(v, k), [t.variant, t.key]);
66
+ const ok = await waitLoaded(page, before);
67
+ await page.waitForTimeout(opts.settleMs ?? 700);
68
+ const info = await page.evaluate(() => {
69
+ const f = window.__NO.frame();
70
+ const d = f && f.contentDocument;
71
+ return {
72
+ hydrated: window.__NO.hydrated(),
73
+ reports: window.__NO.drainReports(),
74
+ text: d && d.body ? d.body.innerText.trim().length : 0,
75
+ title: d ? d.title : "",
76
+ };
77
+ });
78
+ const r = { ...t, ok, ms: Date.now() - started, ...info, console: messages };
79
+ results.push(r);
80
+ const bad = r.reports.filter((x) => x.kind !== "write").length + r.console.length;
81
+ log(`${ok ? (r.hydrated ? "ok " : "dry") : "ERR"} ${String(r.ms).padStart(5)}ms ${t.variant.padEnd(20)} ${t.key}${bad ? ` (${bad} issue${bad > 1 ? "s" : ""})` : ""}`);
82
+ if (opts.screens) {
83
+ const name = `${t.variant}__${t.key.replace(/[^A-Za-z0-9]+/g, "_").slice(0, 120) || "root"}.png`;
84
+ await page.screenshot({ path: path.join(shotsDir, name) });
85
+ }
86
+ }
87
+
88
+ // Navigation by clicking, which exercises the link/Link/router paths rather
89
+ // than the shell's own loader.
90
+ const clicks = [];
91
+ const byVariant = new Map();
92
+ for (const t of targets) {
93
+ const list = byVariant.get(t.variant) ?? [];
94
+ if (list.length < (opts.clicks ?? 3)) list.push(t);
95
+ byVariant.set(t.variant, list);
96
+ }
97
+ for (const list of byVariant.values()) {
98
+ for (const t of list) {
99
+ const before0 = await page.evaluate(() => window.__NO.seq);
100
+ await page.evaluate(([v, k]) => window.__NO.go(v, k), [t.variant, t.key]);
101
+ await waitLoaded(page, before0);
102
+ await page.waitForTimeout(400);
103
+ const target = await page.evaluate(() => {
104
+ const NO = window.__NO, d = NO.frame().contentDocument, cur = NO.loaded;
105
+ for (const a of d.querySelectorAll("a[href]")) {
106
+ const href = a.getAttribute("href");
107
+ if (!href || href.startsWith("#") || !a.offsetParent) continue;
108
+ const key = NO.key(new URL(href, d.baseURI).href);
109
+ if (!key || !key.startsWith("/") || key === cur.key) continue;
110
+ const expect = NO.resolve(cur.variant, key);
111
+ if (expect === cur.key) continue;
112
+ a.setAttribute("data-no-verify", "1");
113
+ return { href, expect };
114
+ }
115
+ return null;
116
+ });
117
+ if (!target) continue;
118
+ const before = await page.evaluate(() => window.__NO.seq);
119
+ await page.frameLocator("iframe.no-frame:not(.loading)").locator('[data-no-verify="1"]').first().click({ timeout: 5000 }).catch(() => {});
120
+ const ok = await waitLoaded(page, before, 15_000);
121
+ const landed = await page.evaluate(() => window.__NO.loaded && window.__NO.loaded.key);
122
+ const pass = ok && landed === target.expect;
123
+ clicks.push({ from: t.key, variant: t.variant, href: target.href, expect: target.expect, landed, pass });
124
+ log(`${pass ? "ok " : "ERR"} click ${t.variant.padEnd(20)} ${t.key} → ${target.href}${pass ? "" : ` (landed on ${landed})`}`);
125
+ }
126
+ }
127
+
128
+ // --- summary ------------------------------------------------------------------
129
+ const issues = new Map();
130
+ const add = (kind, text) => {
131
+ const norm = `${kind}: ${text}`.replace(/blob:[^\s"')]+/g, "blob:…").replace(/\d{3,}/g, "#").slice(0, 220);
132
+ issues.set(norm, (issues.get(norm) ?? 0) + 1);
133
+ };
134
+ for (const r of results) {
135
+ for (const x of r.reports) if (x.kind !== "write") add(x.kind, x.detail);
136
+ for (const c of r.console) if (!c.includes("[next-snapshot]")) add("console", c);
137
+ }
138
+ const failed = results.filter((r) => !r.ok);
139
+ const dry = results.filter((r) => r.ok && !r.hydrated);
140
+ const empty = results.filter((r) => r.ok && r.text < 20);
141
+ log("");
142
+ log(`verify: ${results.length} pages, ${results.length - failed.length} loaded, ${results.length - failed.length - dry.length} hydrated, ${clicks.filter((c) => c.pass).length}/${clicks.length} click navigations`);
143
+ if (failed.length) log(` did not load: ${failed.slice(0, 5).map((r) => `${r.variant}:${r.key}`).join(", ")}`);
144
+ if (dry.length) log(` loaded but no React root found: ${dry.length} (fine for a static page, a failure for an app page)`);
145
+ if (empty.length) log(` nearly empty body: ${empty.slice(0, 5).map((r) => `${r.variant}:${r.key}`).join(", ")}`);
146
+ log(` network leaks: ${leaks.length}${leaks.length ? " — " + [...new Set(leaks)].slice(0, 5).join(", ") : ""}`);
147
+ if (issues.size) {
148
+ log(` distinct issues (${issues.size}):`);
149
+ for (const [k, n] of [...issues.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25)) log(` ${String(n).padStart(4)}x ${k}`);
150
+ }
151
+ const report = { file: cfg.out, bootMs, results, clicks, leaks, issues: Object.fromEntries(issues) };
152
+ const reportPath = cfg.out.replace(/\.html?$/, "") + ".verify.json";
153
+ await fs.writeFile(reportPath, JSON.stringify(report, null, 1));
154
+ log(` report: ${path.relative(process.cwd(), reportPath)}${opts.screens ? ` screenshots: ${path.relative(process.cwd(), shotsDir)}` : ""}`);
155
+ return { failed: failed.length, leaks: leaks.length, clickFailures: clicks.filter((c) => !c.pass).length };
156
+ } finally {
157
+ await browser.close();
158
+ }
159
+ }
160
+
161
+ async function waitLoaded(page, seqBefore, timeout = 30_000) {
162
+ try {
163
+ await page.waitForFunction((s) => window.__NO.loaded && window.__NO.loaded.seq > s && window.__NO.loaded.seq === window.__NO.seq, seqBefore, {
164
+ timeout,
165
+ });
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }