@africanpilot/next-snapshot 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -195,14 +195,18 @@ other host it redirects to, add it to `aliases`.
195
195
  ## Security
196
196
 
197
197
  A config is code: `app.build`, `app.start`, `login` hooks and `explore.custom`
198
- run with your privileges. A snapshot contains every page it captured, for every
199
- variant — treat it like access to the app. See [SECURITY.md](SECURITY.md),
200
- including how to report a vulnerability.
198
+ run with your privileges. A snapshot contains every page it captured, and the
199
+ API responses those pages fetched, for every variant — treat it like access to
200
+ the app, and check what is in it before sharing. Replayed pages run in a
201
+ sandboxed frame so that content the app never trusted cannot carry the snapshot
202
+ anywhere. See [SECURITY.md](SECURITY.md), including how to report a
203
+ vulnerability.
201
204
 
202
205
  ## Development
203
206
 
204
207
  `npm test` runs the unit and end-to-end suites; `npm run test:next` snapshots a
205
- real Next.js app. See [CONTRIBUTING.md](CONTRIBUTING.md).
208
+ real Next.js app. See [CONTRIBUTING.md](CONTRIBUTING.md). What might come next,
209
+ and what was deliberately ruled out, is in [docs/ROADMAP.md](docs/ROADMAP.md).
206
210
 
207
211
  ## License
208
212
 
package/lib/bundle.mjs CHANGED
@@ -51,7 +51,17 @@ export const CSP = [
51
51
  ].join("; ");
52
52
 
53
53
  export async function bundle(cfg, log) {
54
- const M = JSON.parse(await fs.readFile(path.join(cfg.captureDir, "manifest.json"), "utf8"));
54
+ let M;
55
+ try {
56
+ M = JSON.parse(await fs.readFile(path.join(cfg.captureDir, "manifest.json"), "utf8"));
57
+ } catch (e) {
58
+ throw new Error(
59
+ `No capture to bundle at ${cfg.captureDir} (${e.code ?? e.message}). Run \`capture\` first, or \`all\` to do both.`,
60
+ );
61
+ }
62
+ if (!M.pages || !M.assets || !M.variantAssets) {
63
+ throw new Error(`The capture at ${cfg.captureDir} is incomplete or was written by an older version. Run \`capture\` again.`);
64
+ }
55
65
  const origin = M.origin;
56
66
  const readBody = (sha) => fs.readFile(path.join(cfg.captureDir, "bodies", sha));
57
67
  const t0 = Date.now();
@@ -217,6 +227,7 @@ export async function bundle(cfg, log) {
217
227
  badge: cfg.offline.badge,
218
228
  switcher: cfg.offline.switcher,
219
229
  missingLinks: cfg.offline.missingLinks,
230
+ staticPrefix: cfg.staticPrefix,
220
231
  codec: zstd ? "zstd" : "gzip",
221
232
  locs: zstd ? locs : undefined,
222
233
  assets: assets.map((a) => [a.k, a.v, a.b, a.type, a.status ?? 200]),
@@ -247,7 +258,8 @@ export async function bundle(cfg, log) {
247
258
 
248
259
  // --- report ----------------------------------------------------------------------
249
260
  const mb = (n) => (n / 1024 / 1024).toFixed(2) + " MB";
250
- const assetBytes = assets.reduce((n, a) => n + (blocks.get(a.b)?.length ?? 0), 0);
261
+ // By body, not by asset row: one block shared by several keys is one cost.
262
+ const assetBytes = [...new Set(assets.map((a) => a.b))].reduce((n, b) => n + (blocks.get(b)?.length ?? 0), 0);
251
263
  const pageBytes = zstd
252
264
  ? clusters.reduce((n, c) => n + c.length, 0)
253
265
  : bodies.reduce((n, b, i) => n + (b.page ? blocks.get(i).length : 0), 0);
package/lib/capture.mjs CHANGED
@@ -116,8 +116,11 @@ export async function capture(cfg, log) {
116
116
 
117
117
  const pending = new Set();
118
118
  const track = (p) => {
119
- pending.add(p);
120
- p.finally(() => pending.delete(p));
119
+ // Caught here: an unhandled rejection from a background body read (a full
120
+ // disk, too many open files) would otherwise end the whole crawl silently.
121
+ const q = p.catch((e) => log(` warn: recording a response failed: ${e.message}`));
122
+ pending.add(q);
123
+ q.finally(() => pending.delete(q));
121
124
  };
122
125
 
123
126
  const browser = await launch(cfg);
@@ -567,6 +570,12 @@ function summarise(M, log, ms) {
567
570
  log(` ${v.id.padEnd(22)} ${String(html).padStart(4)} pages ${String(red).padStart(4)} redirects ${Object.keys(M.variantAssets[v.id]).length} data responses`);
568
571
  }
569
572
  log(` shared assets: ${Object.keys(M.assets).length} RSC payloads skipped: ${M.rscSkipped}`);
573
+ // An app that is answering with error pages captures perfectly happily; say
574
+ // so, or the snapshot looks complete and is a book of 500s.
575
+ const errorPages = M.variants.flatMap((v) => Object.entries(M.pages[v.id]).filter(([, e]) => e.body && e.status >= 400));
576
+ if (errorPages.length) {
577
+ log(` WARNING: ${errorPages.length} captured page(s) are error responses — e.g. ${errorPages[0][1].status} ${errorPages[0][0]}`);
578
+ }
570
579
  if (M.blocked.length) log(` blocked ${M.blocked.length} non-GET request(s) — the crawl never writes`);
571
580
  if (M.failures.length) log(` ${M.failures.length} navigation failure(s): ${M.failures.slice(0, 3).map((f) => f.key).join(", ")}`);
572
581
  if (M.liveErrors.length) log(` the LIVE app threw ${M.liveErrors.length} error(s) during capture (first: ${M.liveErrors[0].message})`);
@@ -291,7 +291,8 @@
291
291
  NO.openInNewTab(u.href);
292
292
  return null;
293
293
  }
294
- return realOpen.call(W, u.href, target || "_blank", features);
294
+ NO.external(u.href);
295
+ return null;
295
296
  };
296
297
  var jar = NO.cookies;
297
298
  try {
@@ -322,7 +323,7 @@
322
323
  if (!/^https?:$/.test(u.protocol)) return;
323
324
  e.preventDefault();
324
325
  if (a.hasAttribute("download") || NO.isAsset(u.href)) { NO.openAsset(u.href, a.getAttribute("download")); return; }
325
- if (u.origin !== cur.origin) { realOpen.call(W, u.href, "_blank", "noopener"); return; }
326
+ if (u.origin !== cur.origin) { NO.external(u.href); return; }
326
327
  var t = a.getAttribute("target");
327
328
  if (newTab || e.metaKey || e.ctrlKey || e.shiftKey || (t && !/^_(self|top|parent)$/i.test(t))) { NO.openInNewTab(u.href); return; }
328
329
  go(u.href);
@@ -87,11 +87,15 @@
87
87
  idx[(a.v || "") + "\u0000" + a.k] = i;
88
88
  });
89
89
  var variant = M.defaultVariant;
90
+ // Where the build output lives, which basePath moves: the bundler and the
91
+ // runtime must agree, or a chunk with a query is found at bundle time and
92
+ // lost at read time.
93
+ var STATIC_PREFIX = M.staticPrefix || "/_next/static/";
90
94
  function lookup(key, v) {
91
95
  if (key == null) return -1;
92
96
  var i = idx[(v || "") + "\u0000" + key];
93
97
  if (i == null) i = idx["\u0000" + key];
94
- if (i == null && key.indexOf("?") > 0 && key.indexOf("/_next/static/") === 0) i = idx["\u0000" + key.split("?")[0]];
98
+ if (i == null && key.indexOf("?") > 0 && key.indexOf(STATIC_PREFIX) === 0) i = idx["\u0000" + key.split("?")[0]];
95
99
  return i == null ? -1 : i;
96
100
  }
97
101
  var bytes = await Promise.all(A.map(function (a) { return body(a.b, true); }));
@@ -111,7 +115,12 @@
111
115
  return u;
112
116
  }
113
117
  function tokens(text) {
114
- return text.replace(/__NOA(\d+)__/g, function (_, i) { return assetURL(+i); });
118
+ // A captured page may contain the token's own shape as ordinary text. An
119
+ // index that names no asset is left as it stands: one odd string in a page
120
+ // must not take down the file.
121
+ return text.replace(/__NOA(\d+)__/g, function (whole, i) {
122
+ return +i >= 0 && +i < A.length ? assetURL(+i) : whole;
123
+ });
115
124
  }
116
125
  for (var i = 0; i < A.length; i++) assetURL(i);
117
126
 
@@ -191,8 +200,20 @@
191
200
  }
192
201
 
193
202
  async function load(v, rawKey, hash, mode) {
203
+ try {
204
+ await loadPage(v, rawKey, hash, mode);
205
+ } catch (err) {
206
+ // A body that will not decode — a truncated file, a half-finished copy.
207
+ // Saying so beats leaving the reader on "Opening…" for ever.
208
+ NO.report("error", "could not open " + rawKey + ": " + ((err && err.message) || err));
209
+ current = { variant: variant, key: rawKey, hash: hash || "", base: rawKey };
210
+ mount(brokenHTML(rawKey, (err && err.message) || String(err)), NO.seq);
211
+ }
212
+ }
213
+
214
+ async function loadPage(v, rawKey, hash, mode) {
194
215
  var seq = ++NO.seq;
195
- if (!M.pages[v]) v = M.defaultVariant;
216
+ if (!Object.prototype.hasOwnProperty.call(M.pages, v)) v = M.defaultVariant;
196
217
  setVariant(v);
197
218
  var r = resolvePage(rawKey, v), e = r.entry, html, base = r.key;
198
219
  if (!e && soft[v + "\u0000" + r.key]) {
@@ -216,6 +237,12 @@
216
237
  var f = document.createElement("iframe");
217
238
  f.className = "no-frame loading";
218
239
  f.setAttribute("title", M.title || "Application");
240
+ // A captured page may contain content its app never trusted — a comment
241
+ // field, a hostile API value — and its inline scripts run here. Same-origin
242
+ // is kept because the shim needs `parent.__NO`, but without top navigation
243
+ // or popups that content cannot carry the snapshot off to a server. Links
244
+ // that genuinely lead outside go through the shell instead (NO.external).
245
+ f.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-modals");
219
246
  f.addEventListener("load", function () {
220
247
  NO.frameReady(f.contentWindow, seq);
221
248
  if (seq === NO.seq) {
@@ -276,6 +303,17 @@
276
303
  var u = new URL(href, ORIGIN);
277
304
  window.open(location.href.split("#")[0] + hashFor(variant, NO.key(u.href), u.hash), "_blank");
278
305
  };
306
+ // A link out of the app. The frame cannot open one itself — that is the point
307
+ // of its sandbox — so the shell asks, and the reader decides. Anything the
308
+ // page does without a click never gets here.
309
+ NO.external = function (href) {
310
+ var u;
311
+ try { u = new URL(href); } catch (e) { return; }
312
+ if (!/^https?:$/.test(u.protocol)) return;
313
+ if (window.confirm("This link leaves the offline snapshot and connects to the internet:\n\n" + u.href + "\n\nOpen it?")) {
314
+ window.open(u.href, "_blank", "noopener");
315
+ }
316
+ };
279
317
  NO.openAsset = function (href, name) {
280
318
  var i = NO.assetIndex(href);
281
319
  if (i >= 0) openAssetIndex(i, name);
@@ -418,6 +456,17 @@
418
456
  function esc(s) {
419
457
  return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
420
458
  }
459
+ function brokenHTML(key, why) {
460
+ return (
461
+ '<!doctype html><html><head><base href="' + esc(ORIGIN + key) + '"><script data-no-shim></script>' +
462
+ '<meta charset="utf-8"><title>This page could not be opened</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:720px}' +
463
+ "code{background:#f2f2f2;padding:1px 5px;border-radius:4px}</style></head><body>" +
464
+ '<h1 style="font-size:20px">This page could not be opened</h1><p><code>' + esc(key) + "</code> is in this snapshot, but its content would not decode:</p>" +
465
+ "<p><code>" + esc(why) + "</code></p>" +
466
+ "<p>The file is probably incomplete — a copy or download that did not finish. Try the original file again.</p></body></html>"
467
+ );
468
+ }
469
+
421
470
  function missingHTML(v, key) {
422
471
  var P = M.pages[v] || {}, path = key.split("?")[0];
423
472
  var pages = Object.keys(P).filter(function (k) { return P[k].b != null; }).sort();
@@ -438,7 +487,9 @@
438
487
 
439
488
  // --- boot ------------------------------------------------------------------------------------
440
489
  var start = parseHash();
441
- if (start && M.pages[start.variant]) variant = start.variant;
490
+ // hasOwnProperty: a hash of "#__proto__:/" would otherwise name a variant
491
+ // that exists only on Object.prototype.
492
+ if (start && Object.prototype.hasOwnProperty.call(M.pages, start.variant)) variant = start.variant;
442
493
  renderBadge();
443
494
  NO.ready = true;
444
495
  if (start) load(variant, start.key, start.hash, "replace");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@africanpilot/next-snapshot",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Capture a running Next.js app and bundle it into one self-contained, offline HTML file.",
5
5
  "keywords": [
6
6
  "nextjs",