@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,522 @@
1
+ // Crawl the running app in headless Chrome and record what a browser receives:
2
+ // the HTML of every page, and every asset and client-side GET it triggers.
3
+ //
4
+ // Output is a directory: manifest.json (what was seen, keyed by urlKey) and
5
+ // bodies/<sha> (content-addressed, so a chunk shared by every page and a page
6
+ // identical across variants are each stored once).
7
+ //
8
+ // The crawl is read-only by construction: every request that is not GET, HEAD
9
+ // or OPTIONS is aborted before it leaves the browser. Only a variant's `login`
10
+ // hook, which runs before that guard is installed, can write.
11
+
12
+ import crypto from "node:crypto";
13
+ import fss from "node:fs";
14
+ import fs from "node:fs/promises";
15
+ import path from "node:path";
16
+
17
+ import { launch } from "./browser.mjs";
18
+ import { urlKey } from "./key.js";
19
+
20
+ const FILE_EXT = /\.(pdf|csv|tsv|xlsx?|docx?|pptx?|zip|gz|tgz|json|txt|xml|png|jpe?g|gif|svg|webp|avif|ico|mp4|webm|mp3|wav)$/i;
21
+
22
+ const MIME = {
23
+ ".js": "application/javascript; charset=utf-8",
24
+ ".mjs": "application/javascript; charset=utf-8",
25
+ ".css": "text/css; charset=utf-8",
26
+ ".json": "application/json",
27
+ ".map": "application/json",
28
+ ".html": "text/html; charset=utf-8",
29
+ ".txt": "text/plain; charset=utf-8",
30
+ ".svg": "image/svg+xml",
31
+ ".png": "image/png",
32
+ ".jpg": "image/jpeg",
33
+ ".jpeg": "image/jpeg",
34
+ ".gif": "image/gif",
35
+ ".webp": "image/webp",
36
+ ".avif": "image/avif",
37
+ ".ico": "image/x-icon",
38
+ ".woff": "font/woff",
39
+ ".woff2": "font/woff2",
40
+ ".ttf": "font/ttf",
41
+ ".otf": "font/otf",
42
+ ".pdf": "application/pdf",
43
+ ".csv": "text/csv; charset=utf-8",
44
+ ".mp4": "video/mp4",
45
+ ".webm": "video/webm",
46
+ ".mp3": "audio/mpeg",
47
+ ".wasm": "application/wasm",
48
+ };
49
+
50
+ export function mimeFor(p) {
51
+ return MIME[path.extname(p.split("?")[0]).toLowerCase()] ?? "application/octet-stream";
52
+ }
53
+
54
+ export async function capture(cfg, log) {
55
+ const { origin } = cfg;
56
+ // Every URL the browser reports goes through here, so a redirect to another
57
+ // spelling of the same host (localhost vs 127.0.0.1) keys as the same page.
58
+ const canon = (u) => {
59
+ for (const a of cfg.aliases) if (u === a || u.startsWith(a + "/") || u.startsWith(a + "?")) return origin + u.slice(a.length);
60
+ return u;
61
+ };
62
+ const keyOf = (u, base = origin) => {
63
+ let abs;
64
+ try {
65
+ abs = new URL(String(u), base).href;
66
+ } catch {
67
+ return null;
68
+ }
69
+ return urlKey(canon(abs), origin, origin);
70
+ };
71
+ const bodiesDir = path.join(cfg.captureDir, "bodies");
72
+ await fs.mkdir(bodiesDir, { recursive: true });
73
+
74
+ async function put(buf) {
75
+ const sha = crypto.createHash("sha256").update(buf).digest("hex").slice(0, 32);
76
+ const f = path.join(bodiesDir, sha);
77
+ if (!fss.existsSync(f)) await fs.writeFile(f, buf);
78
+ return sha;
79
+ }
80
+
81
+ const M = {
82
+ version: 1,
83
+ tool: "next-snapshot",
84
+ origin,
85
+ createdAt: new Date().toISOString(),
86
+ title: cfg.title,
87
+ variants: cfg.variants.map((v) => ({ id: v.id, label: v.label })),
88
+ defaultVariant: cfg.defaultVariant,
89
+ start: keyOf(cfg.start),
90
+ pages: {}, // variant -> key -> {body,status} | {redirect} | {file}
91
+ assets: {}, // key -> {body,type,status} shared by every variant
92
+ variantAssets: {}, // variant -> key -> {body,type,status} API/data responses
93
+ blocked: [], // non-GET requests the guard stopped
94
+ failures: [], // navigations that failed outright
95
+ liveErrors: [], // errors the *live* app threw — not the tool's fault
96
+ rscSkipped: 0,
97
+ };
98
+ for (const v of cfg.variants) {
99
+ M.pages[v.id] = {};
100
+ M.variantAssets[v.id] = {};
101
+ }
102
+
103
+ const allowed = (k) =>
104
+ !!k && k.startsWith("/") && !cfg.exclude.some((re) => re.test(k)) && (!cfg.include || cfg.include.some((re) => re.test(k)));
105
+ const discovered = new Set();
106
+ const discover = (k) => {
107
+ if (allowed(k) && !discovered.has(k)) {
108
+ discovered.add(k);
109
+ return true;
110
+ }
111
+ return false;
112
+ };
113
+ for (const s of cfg.seeds) discover(keyOf(s));
114
+ discover(M.start);
115
+
116
+ const pending = new Set();
117
+ const track = (p) => {
118
+ pending.add(p);
119
+ p.finally(() => pending.delete(p));
120
+ };
121
+
122
+ const browser = await launch(cfg);
123
+ const sessions = {};
124
+ const t0 = Date.now();
125
+
126
+ try {
127
+ // Every variant must answer for every URL any variant found: a URL one role
128
+ // can open is a URL another role may be redirected away from, and that
129
+ // redirect is part of what the snapshot has to reproduce.
130
+ for (;;) {
131
+ let progressed = false;
132
+ for (const v of cfg.variants) {
133
+ const s = (sessions[v.id] ??= await openSession(v));
134
+ const todo = [...discovered].filter((k) => !s.visited.has(k));
135
+ if (!todo.length) continue;
136
+ progressed = true;
137
+ await crawl(s, todo);
138
+ }
139
+ if (!progressed) break;
140
+ }
141
+ for (const s of Object.values(sessions)) {
142
+ for (const k of s.files) if (!M.variantAssets[s.v.id][k] && !M.assets[k]) await fetchFile(s, k);
143
+ }
144
+ await Promise.allSettled([...pending]);
145
+ } finally {
146
+ await browser.close();
147
+ }
148
+
149
+ if (cfg.includeStatic && cfg.staticDir && fss.existsSync(cfg.staticDir)) {
150
+ const n = await addDir(cfg.staticDir, cfg.staticPrefix, Infinity);
151
+ log(`static: +${n} build files from ${path.relative(process.cwd(), cfg.staticDir)}`);
152
+ }
153
+ if (cfg.publicDir && fss.existsSync(cfg.publicDir)) {
154
+ const n = await addDir(cfg.publicDir, "/", cfg.maxPublicFileBytes);
155
+ if (n) log(`public: +${n} files from ${path.relative(process.cwd(), cfg.publicDir)}`);
156
+ }
157
+
158
+ await fs.writeFile(path.join(cfg.captureDir, "manifest.json"), JSON.stringify(M, null, 1));
159
+ summarise(M, log, Date.now() - t0);
160
+ return M;
161
+
162
+ // ---------------------------------------------------------------------------
163
+
164
+ async function openSession(v) {
165
+ const context = await browser.newContext({
166
+ viewport: cfg.viewport,
167
+ serviceWorkers: "block",
168
+ ignoreHTTPSErrors: true,
169
+ locale: cfg.locale,
170
+ timezoneId: cfg.timezoneId,
171
+ });
172
+ if (v.login) {
173
+ log(`[${v.id}] login`);
174
+ await v.login({ context, request: context.request, origin });
175
+ }
176
+ await context.route("**/*", (route) => {
177
+ const req = route.request();
178
+ const m = req.method();
179
+ if (m === "GET" || m === "HEAD" || m === "OPTIONS") return route.continue();
180
+ M.blocked.push({ variant: v.id, method: m, url: req.url() });
181
+ return route.abort("blockedbyclient");
182
+ });
183
+ const page = await context.newPage();
184
+ const s = { v, context, page, visited: new Set(), rsc: new Set(), files: new Set(), explored: new Set(), current: null };
185
+ context.on("response", (res) => track(onResponse(s, res)));
186
+ // A click that opens a window must not leave a second crawler behind.
187
+ context.on("page", (p) => {
188
+ if (p !== s.page) p.close().catch(() => {});
189
+ });
190
+ page.on("pageerror", (e) => {
191
+ if (M.liveErrors.length < 200) M.liveErrors.push({ variant: v.id, key: s.current, message: e.message.split("\n")[0] });
192
+ });
193
+ return s;
194
+ }
195
+
196
+ async function crawl(s, queue) {
197
+ let count = Object.keys(M.pages[s.v.id]).length;
198
+ while (queue.length) {
199
+ const key = queue.shift();
200
+ if (s.visited.has(key)) continue;
201
+ if (count >= cfg.maxPages) {
202
+ log(`[${s.v.id}] maxPages (${cfg.maxPages}) reached; ${queue.length} URL(s) left unvisited`);
203
+ for (const k of queue) s.visited.add(k);
204
+ break;
205
+ }
206
+ const found = await visit(s, key);
207
+ count = Object.keys(M.pages[s.v.id]).length;
208
+ for (const k of found) if (discover(k)) queue.push(k);
209
+ // Found by another variant earlier, but not yet visited by this one.
210
+ for (const k of found) if (discovered.has(k) && !s.visited.has(k) && !queue.includes(k)) queue.push(k);
211
+ }
212
+ }
213
+
214
+ async function visit(s, key) {
215
+ const { page, v } = s;
216
+ const P = M.pages[v.id];
217
+ s.visited.add(key);
218
+ s.current = key;
219
+ const started = Date.now();
220
+ let resp;
221
+ try {
222
+ resp = await page.goto(origin + key, { waitUntil: "load", timeout: cfg.navTimeoutMs });
223
+ } catch (e) {
224
+ if (/Download is starting|net::ERR_ABORTED/.test(e.message)) {
225
+ await fetchFile(s, key);
226
+ P[key] = { file: key };
227
+ return [];
228
+ }
229
+ M.failures.push({ variant: v.id, key, message: e.message.split("\n")[0] });
230
+ log(`[${v.id}] FAIL ${key}: ${e.message.split("\n")[0]}`);
231
+ return [];
232
+ }
233
+ if (!resp) return [];
234
+
235
+ const chain = [];
236
+ for (let r = resp.request().redirectedFrom(); r; r = r.redirectedFrom()) chain.push(keyOf(r.url()));
237
+ const finalKey = keyOf(resp.url());
238
+ const type = resp.headers()["content-type"] ?? "";
239
+ const isHTML = /html/i.test(type);
240
+
241
+ if (isHTML) {
242
+ P[finalKey] = { body: await put(await resp.body()), status: resp.status() };
243
+ } else {
244
+ const body = await resp.body().catch(() => null);
245
+ if (body) M.variantAssets[v.id][finalKey] = { body: await put(body), type, status: resp.status() };
246
+ P[finalKey] = { file: finalKey };
247
+ }
248
+ for (const k of chain) {
249
+ if (k && k !== finalKey && !P[k]?.body) P[k] = { redirect: finalKey };
250
+ if (k) s.visited.add(k);
251
+ }
252
+ if (finalKey !== key && !P[key]?.body) P[key] = { redirect: finalKey };
253
+ s.visited.add(finalKey);
254
+ const note = finalKey !== key ? ` -> ${finalKey}` : "";
255
+ log(`[${v.id}] ${resp.status()} ${key}${note} ${Date.now() - started}ms`);
256
+
257
+ if (!isHTML || !finalKey.startsWith("/")) return [];
258
+
259
+ await settle(page);
260
+ const found = new Set();
261
+ const after = keyOf(page.url());
262
+ if (after && after !== finalKey) found.add(after); // client-side redirect
263
+
264
+ const links = await page
265
+ .evaluate(() =>
266
+ [...document.querySelectorAll("a[href], area[href]")].map((a) => ({ href: a.href, download: a.hasAttribute("download") })),
267
+ )
268
+ .catch(() => []);
269
+ for (const l of links) {
270
+ const k = keyOf(l.href);
271
+ if (!k || !k.startsWith("/")) continue;
272
+ if (l.download || FILE_EXT.test(k.split("?")[0])) s.files.add(k);
273
+ else found.add(k);
274
+ }
275
+ for (const k of s.rsc) found.add(k); // Next prefetched it, so the app links to it
276
+ s.rsc.clear();
277
+
278
+ if (cfg.explore.selects) for (const k of await exploreSelects(s, finalKey)) found.add(k);
279
+ if (cfg.explore.tabs || cfg.explore.click.length) for (const k of await exploreClicks(s, finalKey)) found.add(k);
280
+ if (cfg.explore.custom) {
281
+ await cfg.explore.custom({
282
+ page,
283
+ key: finalKey,
284
+ variant: v.id,
285
+ origin,
286
+ discover: (u) => {
287
+ const k = keyOf(u, origin + finalKey);
288
+ if (k) found.add(k);
289
+ },
290
+ });
291
+ }
292
+ return [...found];
293
+ }
294
+
295
+ async function settle(page) {
296
+ await page.waitForLoadState("networkidle", { timeout: cfg.idleTimeoutMs }).catch(() => {});
297
+ if (cfg.settleMs) await page.waitForTimeout(cfg.settleMs);
298
+ }
299
+
300
+ // A <select> that drives the URL (router.push/replace on change) is the
301
+ // common way a Next page exposes views that links never mention. Try each
302
+ // option once per (page path, select); a URL change is a page to capture.
303
+ async function exploreSelects(s, key) {
304
+ const { page } = s;
305
+ const found = new Set();
306
+ const pathOnly = key.split("?")[0];
307
+ const sels = page.locator("select:visible");
308
+ const count = await sels.count().catch(() => 0);
309
+ let navigatedAway = false;
310
+
311
+ for (let i = 0; i < count; i++) {
312
+ if (navigatedAway) {
313
+ await page.goto(origin + key, { waitUntil: "load", timeout: cfg.navTimeoutMs }).catch(() => {});
314
+ await settle(page);
315
+ navigatedAway = false;
316
+ }
317
+ const sel = sels.nth(i);
318
+ let meta;
319
+ try {
320
+ meta = await sel.evaluate((el) => ({
321
+ id: el.name || el.id || el.getAttribute("aria-label") || "",
322
+ value: el.value,
323
+ options: el.disabled ? [] : [...el.options].filter((o) => !o.disabled).map((o) => o.value),
324
+ }));
325
+ } catch {
326
+ continue;
327
+ }
328
+ const sig = `${pathOnly}::${i}:${meta.id}`;
329
+ if (s.explored.has(sig)) continue;
330
+ s.explored.add(sig);
331
+
332
+ for (const val of meta.options.slice(0, cfg.explore.maxOptions)) {
333
+ if (val === meta.value) continue;
334
+ if (navigatedAway) {
335
+ await page.goto(origin + key, { waitUntil: "load", timeout: cfg.navTimeoutMs }).catch(() => {});
336
+ await settle(page);
337
+ navigatedAway = false;
338
+ }
339
+ try {
340
+ await sels.nth(i).selectOption(val, { timeout: 3000 });
341
+ await page.waitForTimeout(150);
342
+ await settle(page);
343
+ const k = keyOf(page.url());
344
+ if (k && k !== key) {
345
+ found.add(k);
346
+ navigatedAway = true;
347
+ }
348
+ } catch {
349
+ navigatedAway = true;
350
+ }
351
+ }
352
+ }
353
+ // Hand the page back where it was found: tab exploration and custom
354
+ // discovery run next, and must run on this page, not the last option's.
355
+ if (navigatedAway) {
356
+ await page.goto(origin + key, { waitUntil: "load", timeout: cfg.navTimeoutMs }).catch(() => {});
357
+ await settle(page);
358
+ }
359
+ if (found.size) log(`[${s.v.id}] selects on ${key}: ${found.size} URL(s)`);
360
+ return found;
361
+ }
362
+
363
+ // Tab strips that write the URL — router.replace, or history.replaceState for
364
+ // a tab that never asks the server — expose views that no link names.
365
+ // Candidates: [role=tab], the configured `explore.click` selectors, and
366
+ // "button bars" (an element whose children are two or more buttons and
367
+ // nothing else), which is how most tab strips are built without ARIA. Each
368
+ // (page path, label) is clicked once per variant.
369
+ async function exploreClicks(s, key) {
370
+ const { page } = s;
371
+ const found = new Set();
372
+ const pathOnly = key.split("?")[0];
373
+ const tag = () =>
374
+ page
375
+ .evaluate(
376
+ ({ tabs, extra, deny }) => {
377
+ const denyRe = new RegExp(deny, "i");
378
+ const picked = new Set();
379
+ if (tabs) {
380
+ document.querySelectorAll('[role="tab"]').forEach((el) => picked.add(el));
381
+ document.querySelectorAll("button").forEach((b) => {
382
+ const p = b.parentElement;
383
+ if (!p || b.form) return;
384
+ const kids = [...p.children];
385
+ if (kids.length >= 2 && kids.every((k) => k.tagName === "BUTTON")) picked.add(b);
386
+ });
387
+ }
388
+ for (const sel of extra) document.querySelectorAll(sel).forEach((el) => picked.add(el));
389
+ const labels = [];
390
+ for (const el of picked) {
391
+ if (!(el.offsetParent || el.getClientRects().length)) continue;
392
+ if (el.disabled || el.getAttribute("aria-disabled") === "true") continue;
393
+ const label = (el.innerText || el.getAttribute("aria-label") || "").trim().replace(/\s+/g, " ").slice(0, 80);
394
+ if (!label || denyRe.test(label)) continue;
395
+ el.setAttribute("data-no-explore", label);
396
+ labels.push(label);
397
+ }
398
+ return [...new Set(labels)];
399
+ },
400
+ { tabs: !!cfg.explore.tabs, extra: cfg.explore.click, deny: cfg.explore.denyText.source },
401
+ )
402
+ .catch(() => []);
403
+
404
+ const labels = (await tag()).slice(0, cfg.explore.maxClicks);
405
+ let dirty = false;
406
+ for (const label of labels) {
407
+ const sig = `${pathOnly}::click:${label}`;
408
+ if (s.explored.has(sig)) continue;
409
+ s.explored.add(sig);
410
+ if (dirty) {
411
+ await page.goto(origin + key, { waitUntil: "load", timeout: cfg.navTimeoutMs }).catch(() => {});
412
+ await settle(page);
413
+ dirty = false;
414
+ }
415
+ await tag(); // the page may have re-rendered since
416
+ try {
417
+ await page.locator(`[data-no-explore=${JSON.stringify(label)}]`).first().click({ timeout: 3000 });
418
+ await page.waitForTimeout(150);
419
+ await settle(page);
420
+ const k = keyOf(page.url());
421
+ if (k && k !== key) {
422
+ found.add(k);
423
+ dirty = true;
424
+ }
425
+ } catch {
426
+ dirty = true;
427
+ }
428
+ }
429
+ if (dirty) {
430
+ await page.goto(origin + key, { waitUntil: "load", timeout: cfg.navTimeoutMs }).catch(() => {});
431
+ await settle(page);
432
+ }
433
+ if (found.size) log(`[${s.v.id}] tabs on ${key}: ${found.size} URL(s)`);
434
+ return found;
435
+ }
436
+
437
+ async function onResponse(s, res) {
438
+ const req = res.request();
439
+ if (req.method() !== "GET") return;
440
+ const url = res.url();
441
+ const key = keyOf(url);
442
+ if (!key) return;
443
+ const status = res.status();
444
+ if (status >= 300 && status < 400) return;
445
+ if (req.isNavigationRequest() && req.frame() === s.page.mainFrame()) return; // pages come from visit()
446
+
447
+ const headers = res.headers();
448
+ const type = headers["content-type"] ?? "";
449
+ const rh = req.headers();
450
+ if (rh.rsc === "1" || type.startsWith("text/x-component") || /[?&]_rsc=/.test(url)) {
451
+ M.rscSkipped++;
452
+ if (key.startsWith("/")) s.rsc.add(key);
453
+ return;
454
+ }
455
+ if (/\/_next\/webpack-hmr|\/__nextjs/.test(url)) return;
456
+
457
+ let body;
458
+ try {
459
+ body = await res.body();
460
+ } catch {
461
+ return;
462
+ }
463
+ const entry = { body: await put(body), type: type || mimeFor(key), status };
464
+ const dynamic =
465
+ key.startsWith("/") &&
466
+ !key.startsWith(cfg.staticPrefix) &&
467
+ (["fetch", "xhr", "eventsource"].includes(req.resourceType()) || key.startsWith("/api/"));
468
+ if (dynamic) M.variantAssets[s.v.id][key] = entry;
469
+ else if (!M.assets[key] || M.assets[key].status >= 400) M.assets[key] = entry;
470
+ }
471
+
472
+ async function fetchFile(s, key) {
473
+ try {
474
+ const r = await s.context.request.get(origin + key, { maxRedirects: 5 });
475
+ if (!r.ok()) return;
476
+ const type = r.headers()["content-type"] ?? mimeFor(key);
477
+ M.variantAssets[s.v.id][key] = { body: await put(await r.body()), type, status: r.status() };
478
+ log(`[${s.v.id}] file ${key}`);
479
+ } catch (e) {
480
+ M.failures.push({ variant: s.v.id, key, message: `file: ${e.message.split("\n")[0]}` });
481
+ }
482
+ }
483
+
484
+ async function addDir(dir, prefix, maxBytes) {
485
+ let n = 0;
486
+ for (const f of await walk(dir)) {
487
+ const rel = path.relative(dir, f).split(path.sep).join("/");
488
+ const key = prefix + rel;
489
+ if (M.assets[key]) continue;
490
+ const st = await fs.stat(f);
491
+ if (st.size > maxBytes) continue;
492
+ M.assets[key] = { body: await put(await fs.readFile(f)), type: mimeFor(f), status: 200, fromDisk: true };
493
+ n++;
494
+ }
495
+ return n;
496
+ }
497
+ }
498
+
499
+ async function walk(dir) {
500
+ const out = [];
501
+ for (const e of await fs.readdir(dir, { withFileTypes: true })) {
502
+ const p = path.join(dir, e.name);
503
+ if (e.isDirectory()) out.push(...(await walk(p)));
504
+ else if (e.isFile() && !e.name.endsWith(".map")) out.push(p);
505
+ }
506
+ return out;
507
+ }
508
+
509
+ function summarise(M, log, ms) {
510
+ log("");
511
+ log(`capture finished in ${(ms / 1000).toFixed(1)}s`);
512
+ for (const v of M.variants) {
513
+ const P = Object.values(M.pages[v.id]);
514
+ const html = P.filter((e) => e.body).length;
515
+ const red = P.filter((e) => e.redirect).length;
516
+ log(` ${v.id.padEnd(22)} ${String(html).padStart(4)} pages ${String(red).padStart(4)} redirects ${Object.keys(M.variantAssets[v.id]).length} data responses`);
517
+ }
518
+ log(` shared assets: ${Object.keys(M.assets).length} RSC payloads skipped: ${M.rscSkipped}`);
519
+ if (M.blocked.length) log(` blocked ${M.blocked.length} non-GET request(s) — the crawl never writes`);
520
+ if (M.failures.length) log(` ${M.failures.length} navigation failure(s): ${M.failures.slice(0, 3).map((f) => f.key).join(", ")}`);
521
+ if (M.liveErrors.length) log(` the LIVE app threw ${M.liveErrors.length} error(s) during capture (first: ${M.liveErrors[0].message})`);
522
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,106 @@
1
+ // Load a config module and fill in every default, so the rest of the tool reads
2
+ // one fully-resolved object. Relative paths resolve against the config file.
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ export async function loadConfig(file) {
9
+ if (!file) throw new Error("No config given. Pass --config path/to/app.config.mjs");
10
+ const abs = path.resolve(file);
11
+ if (!fs.existsSync(abs)) throw new Error(`Config not found: ${abs}`);
12
+ const mod = await import(pathToFileURL(abs).href);
13
+ const raw = mod.default ?? mod;
14
+ const dir = path.dirname(abs);
15
+ const r = (p) => (p == null ? p : path.resolve(dir, p));
16
+
17
+ const name = raw.name ?? path.basename(abs).replace(/\.config\.m?js$/, "").replace(/\.m?js$/, "");
18
+ const app = raw.app ? { ...raw.app, cwd: r(raw.app.cwd ?? ".") } : null;
19
+ const port = app?.port ?? 3217;
20
+ // `localhost`, not 127.0.0.1: Next builds absolute redirect URLs from its own
21
+ // idea of the host, and a cookie set on one loopback name is not sent to the
22
+ // other — a sign-in that redirects across them loses its session.
23
+ const origin = new URL(raw.url ?? `http://localhost:${port}`).origin;
24
+ const o = new URL(origin);
25
+ const loopback = ["localhost", "127.0.0.1", "[::1]"];
26
+ const aliases = [
27
+ ...(loopback.includes(o.hostname) ? loopback.map((h) => `${o.protocol}//${h}${o.port ? ":" + o.port : ""}`) : []),
28
+ ...(raw.aliases ?? []),
29
+ ]
30
+ .map((a) => new URL(a).origin)
31
+ .filter((a) => a !== origin);
32
+ const out = r(raw.out ?? `./${name}.html`);
33
+
34
+ const variants = (raw.variants?.length ? raw.variants : [{ id: "default" }]).map((v) => ({
35
+ label: v.id,
36
+ ...v,
37
+ }));
38
+ const ids = new Set();
39
+ for (const v of variants) {
40
+ if (!/^[A-Za-z0-9_.-]+$/.test(v.id)) throw new Error(`Variant id "${v.id}" must be [A-Za-z0-9_.-]+`);
41
+ if (ids.has(v.id)) throw new Error(`Duplicate variant id "${v.id}"`);
42
+ ids.add(v.id);
43
+ }
44
+
45
+ const defaultStatic = app ? path.join(app.cwd, ".next", "static") : null;
46
+ const defaultPublic = app ? path.join(app.cwd, "public") : null;
47
+
48
+ return {
49
+ name,
50
+ file: abs,
51
+ dir,
52
+ app,
53
+ origin,
54
+ // Other origins that are the same app (loopback spellings, a canonical host
55
+ // the app redirects to). URLs on them are keyed as if on `origin`.
56
+ aliases,
57
+ out,
58
+ captureDir: r(raw.captureDir) ?? out.replace(/\.html?$/, "") + ".capture",
59
+ title: raw.title ?? null,
60
+ start: raw.start ?? "/",
61
+ seeds: raw.seeds ?? ["/"],
62
+ // Never crawled as pages. /_next is build output; /api is not a page, and a
63
+ // GET to it can still have side effects. Add your own with `exclude`.
64
+ exclude: [/^\/_next\//, /^\/api\//, ...(raw.exclude ?? [])],
65
+ include: raw.include ?? null,
66
+ maxPages: raw.maxPages ?? 500,
67
+ navTimeoutMs: raw.navTimeoutMs ?? 60_000,
68
+ idleTimeoutMs: raw.idleTimeoutMs ?? 8_000,
69
+ settleMs: raw.settleMs ?? 300,
70
+ explore: {
71
+ selects: true,
72
+ maxOptions: 40,
73
+ // Click tab-like controls and record any URL they write.
74
+ tabs: true,
75
+ // Extra CSS selectors to click the same way.
76
+ click: [],
77
+ maxClicks: 30,
78
+ // Never clicked, whatever they look like. Writes are blocked at the
79
+ // network regardless; this keeps client-side state (and the session) intact.
80
+ denyText: /\b(sign ?out|log ?out|delete|remove|approve|reject|submit|save|release|publish|reset|clear|discard|revoke)\b/i,
81
+ custom: null,
82
+ ...(raw.explore ?? {}),
83
+ },
84
+ variants,
85
+ defaultVariant: raw.defaultVariant ?? variants[0].id,
86
+ includeStatic: raw.includeStatic ?? true,
87
+ staticDir: r(raw.staticDir) ?? defaultStatic,
88
+ staticPrefix: raw.staticPrefix ?? "/_next/static/",
89
+ publicDir: r(raw.publicDir) ?? defaultPublic,
90
+ maxPublicFileBytes: raw.maxPublicFileBytes ?? 5 * 1024 * 1024,
91
+ offline: {
92
+ badge: "bottom-right",
93
+ switcher: true,
94
+ post: {},
95
+ // CSS added to every page, for hiding what makes no sense offline.
96
+ css: "",
97
+ // Links to pages the snapshot does not hold: "show", "disable" or "hide".
98
+ missingLinks: "show",
99
+ ...(raw.offline ?? {}),
100
+ },
101
+ viewport: raw.viewport ?? { width: 1440, height: 900 },
102
+ browser: raw.browser ?? {},
103
+ locale: raw.locale ?? "en-US",
104
+ timezoneId: raw.timezoneId,
105
+ };
106
+ }
package/lib/key.js ADDED
@@ -0,0 +1,29 @@
1
+ // URL -> lookup key, the one normalisation every part of the tool agrees on.
2
+ //
3
+ // Used by the Node side (capture, bundle) and by the browser runtime, which is
4
+ // handed this function's *source text*. Keep it a self-contained plain function:
5
+ // no imports, no closures over module scope, nothing Node-only.
6
+ //
7
+ // A same-origin URL keys as "/path?sorted=query"; anything else keys as its
8
+ // absolute URL. The fragment never matters, `_rsc` (Next's cache-buster) is
9
+ // dropped, parameters are sorted by name so `?b=2&a=1` and `?a=1&b=2` are one
10
+ // page, and a trailing slash is dropped from every path but "/".
11
+ export function urlKey(input, base, origin) {
12
+ let u;
13
+ try {
14
+ u = new URL(input, base);
15
+ } catch {
16
+ return null;
17
+ }
18
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
19
+ const own = origin || new URL(base).origin;
20
+ const params = [];
21
+ u.searchParams.forEach((v, k) => {
22
+ if (k !== "_rsc") params.push([k, v]);
23
+ });
24
+ params.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
25
+ const search = params.length ? "?" + new URLSearchParams(params).toString() : "";
26
+ let path = u.pathname;
27
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
28
+ return (u.origin === own ? "" : u.origin) + path + search;
29
+ }