@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 +8 -4
- package/lib/bundle.mjs +14 -2
- package/lib/capture.mjs +11 -2
- package/lib/runtime/frame.js +3 -2
- package/lib/runtime/shell.js +55 -4
- package/package.json +1 -1
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,
|
|
199
|
-
variant — treat it like access to
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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})`);
|
package/lib/runtime/frame.js
CHANGED
|
@@ -291,7 +291,8 @@
|
|
|
291
291
|
NO.openInNewTab(u.href);
|
|
292
292
|
return null;
|
|
293
293
|
}
|
|
294
|
-
|
|
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) {
|
|
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);
|
package/lib/runtime/shell.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/"/g, """);
|
|
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
|
-
|
|
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");
|