@africanpilot/next-snapshot 0.1.2 → 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 +39 -4
- package/lib/bundle.mjs +135 -22
- package/lib/capture.mjs +11 -2
- package/lib/config.mjs +14 -0
- package/lib/runtime/frame.js +3 -2
- package/lib/runtime/shell.js +85 -10
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -106,9 +106,40 @@ A config is an ES module; relative paths resolve against it.
|
|
|
106
106
|
| `offline.missingLinks` | `"show"` | Links to pages the snapshot does not hold: `"show"` (they open a "not in snapshot" page), `"disable"` (dimmed, not clickable) or `"hide"`. With `include` narrowing the crawl, `"hide"` removes the nav entries for everything left out. |
|
|
107
107
|
| `offline.badge` | `"bottom-right"` | The "Offline snapshot" pill; `false` to hide. |
|
|
108
108
|
| `offline.switcher` | `true` | A variant `<select>` in the badge. |
|
|
109
|
+
| `compress` | `"gzip"` | How pages are packed. `"gzip"` stores each page on its own, decoded by the browser itself. `"zstd"` sorts pages by route, packs them into clusters of `clusterBytes` and compresses each cluster as one stream, inlining an 8 KB decoder — far smaller for an app with many similar pages. See [Size](#size-and-compression). |
|
|
110
|
+
| `clusterBytes` | 4 MB | Raw bytes of pages per cluster, with `compress: "zstd"`. Bigger is smaller, but the first page of each cluster takes longer to open. |
|
|
109
111
|
| `includeStatic` | `true` | Also embed every file under `.next/static`, so lazily-loaded chunks the crawl never triggered are present. |
|
|
110
112
|
| `viewport`, `locale`, `timezoneId`, `browser` | | Passed to Chrome. `browser.executablePath` if Chrome is not installed. |
|
|
111
113
|
|
|
114
|
+
## Size and compression
|
|
115
|
+
|
|
116
|
+
Pages of an app repeat each other: the same layout, nav and table shell, over
|
|
117
|
+
and over. By default each page is gzipped on its own, which cannot exploit that
|
|
118
|
+
— gzip looks only 32 KB back, and a page's near-twin is further away than that.
|
|
119
|
+
For a handful of pages this costs nothing worth fixing.
|
|
120
|
+
|
|
121
|
+
For an app with hundreds of pages, `compress: "zstd"` sorts pages by route,
|
|
122
|
+
packs them into clusters and compresses each cluster as one stream, so the
|
|
123
|
+
repetition is paid for once:
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
export default {
|
|
127
|
+
// …
|
|
128
|
+
compress: "zstd",
|
|
129
|
+
clusterBytes: 4 * 1024 * 1024, // the default
|
|
130
|
+
};
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
On a 260-page report app, measured: **12.4 MB → about 2 MB**. The cost is an
|
|
134
|
+
8 KB decoder inlined in the file, and tens of milliseconds to open the first
|
|
135
|
+
page of a cluster; pages in an already-decoded cluster are free. Assets stay
|
|
136
|
+
per-page gzip either way, since they are wanted all at once at startup and are
|
|
137
|
+
mostly already-compressed formats.
|
|
138
|
+
|
|
139
|
+
Why not brotli, which is smaller still: Chrome cannot decompress brotli from
|
|
140
|
+
JavaScript (`DecompressionStream` has no brotli there, and no zstd anywhere), so
|
|
141
|
+
it would mean inlining a 208 KB decoder to save about 10%.
|
|
142
|
+
|
|
112
143
|
## An app running in Docker
|
|
113
144
|
|
|
114
145
|
The tool drives Chrome on your machine, so it reaches the container the same way
|
|
@@ -164,14 +195,18 @@ other host it redirects to, add it to `aliases`.
|
|
|
164
195
|
## Security
|
|
165
196
|
|
|
166
197
|
A config is code: `app.build`, `app.start`, `login` hooks and `explore.custom`
|
|
167
|
-
run with your privileges. A snapshot contains every page it captured,
|
|
168
|
-
variant — treat it like access to
|
|
169
|
-
|
|
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.
|
|
170
204
|
|
|
171
205
|
## Development
|
|
172
206
|
|
|
173
207
|
`npm test` runs the unit and end-to-end suites; `npm run test:next` snapshots a
|
|
174
|
-
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).
|
|
175
210
|
|
|
176
211
|
## License
|
|
177
212
|
|
package/lib/bundle.mjs
CHANGED
|
@@ -6,12 +6,25 @@
|
|
|
6
6
|
// real URL and a placeholder for the frame shim go first in <head>.
|
|
7
7
|
// (The transformations themselves live in rewrite.mjs.)
|
|
8
8
|
//
|
|
9
|
-
// Every body is
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// Every body is then packed into the file as base64 in an inert
|
|
10
|
+
// <script type="text/plain">, one of two ways:
|
|
11
|
+
//
|
|
12
|
+
// compress: "gzip" each body gzipped on its own. The browser decodes it
|
|
13
|
+
// natively, and the file needs no decoder of its own.
|
|
14
|
+
// compress: "zstd" pages are sorted by route and packed into clusters of
|
|
15
|
+
// `clusterBytes`, each compressed as one zstd stream, with
|
|
16
|
+
// a small decoder inlined. Pages of an app repeat each
|
|
17
|
+
// other heavily and gzip's window is only 32KB, so a page
|
|
18
|
+
// cannot see its near-twin; a cluster can. Assets stay
|
|
19
|
+
// per-body gzip: they are wanted all at once at startup,
|
|
20
|
+
// and most are already-compressed formats.
|
|
21
|
+
//
|
|
22
|
+
// The runtime (runtime/shell.js) decodes an asset once into a blob: URL, and a
|
|
23
|
+
// page only when it is navigated to — a cluster at a time, cached.
|
|
12
24
|
|
|
13
25
|
import crypto from "node:crypto";
|
|
14
26
|
import fs from "node:fs/promises";
|
|
27
|
+
import { createRequire } from "node:module";
|
|
15
28
|
import path from "node:path";
|
|
16
29
|
import { fileURLToPath } from "node:url";
|
|
17
30
|
import zlib from "node:zlib";
|
|
@@ -20,6 +33,7 @@ import { urlKey } from "./key.js";
|
|
|
20
33
|
import { createRewriter, escHTML, serialisePost, virtualiseLocation } from "./rewrite.mjs";
|
|
21
34
|
|
|
22
35
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
23
37
|
|
|
24
38
|
// Nothing in the file may reach the network. Anything that tries is refused by
|
|
25
39
|
// the browser and shows up as a CSP violation, which `verify` reports.
|
|
@@ -37,10 +51,26 @@ export const CSP = [
|
|
|
37
51
|
].join("; ");
|
|
38
52
|
|
|
39
53
|
export async function bundle(cfg, log) {
|
|
40
|
-
|
|
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
|
+
}
|
|
41
65
|
const origin = M.origin;
|
|
42
66
|
const readBody = (sha) => fs.readFile(path.join(cfg.captureDir, "bodies", sha));
|
|
43
67
|
const t0 = Date.now();
|
|
68
|
+
const zstd = cfg.compress === "zstd";
|
|
69
|
+
if (zstd && typeof zlib.zstdCompressSync !== "function") {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`compress: "zstd" needs a Node built with zstd (zlib.zstdCompressSync); this is ${process.version}. Upgrade Node, or use compress: "gzip".`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
44
74
|
|
|
45
75
|
// --- asset table ---------------------------------------------------------
|
|
46
76
|
const assets = [];
|
|
@@ -74,18 +104,17 @@ export async function bundle(cfg, log) {
|
|
|
74
104
|
});
|
|
75
105
|
|
|
76
106
|
// --- bodies ---------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
107
|
+
// Collected first, compressed at the end: how they are packed depends on all
|
|
108
|
+
// of them. Identical content is stored once, whatever refers to it.
|
|
109
|
+
const bodies = []; // id -> { buf, what, page }
|
|
110
|
+
const bodyIndex = new Map(); // content hash -> id
|
|
111
|
+
function emit(buf, what, page = false) {
|
|
81
112
|
const h = crypto.createHash("sha256").update(buf).digest("hex");
|
|
82
|
-
let id =
|
|
113
|
+
let id = bodyIndex.get(h);
|
|
83
114
|
if (id == null) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
outIndex.set(h, id);
|
|
88
|
-
sizes.push({ what, raw: buf.length, packed: packed.length });
|
|
115
|
+
id = bodies.length;
|
|
116
|
+
bodies.push({ buf, what, page });
|
|
117
|
+
bodyIndex.set(h, id);
|
|
89
118
|
}
|
|
90
119
|
return id;
|
|
91
120
|
}
|
|
@@ -127,12 +156,58 @@ export async function bundle(cfg, log) {
|
|
|
127
156
|
if (i >= 0) pagesOut[v][k] = { a: i };
|
|
128
157
|
} else {
|
|
129
158
|
const html = (await readBody(e.body)).toString("utf8");
|
|
130
|
-
pagesOut[v][k] = { b: emit(Buffer.from(rewriteHTML(html, k, v)), `page ${v} ${k}
|
|
159
|
+
pagesOut[v][k] = { b: emit(Buffer.from(rewriteHTML(html, k, v)), `page ${v} ${k}`, true), s: e.status };
|
|
131
160
|
pageCount++;
|
|
132
161
|
}
|
|
133
162
|
}
|
|
134
163
|
}
|
|
135
164
|
|
|
165
|
+
// --- packing ------------------------------------------------------------------
|
|
166
|
+
const gzipB64 = (buf) => zlib.gzipSync(buf, { level: 9 }).toString("base64");
|
|
167
|
+
const blocks = new Map(); // body id -> base64, for bodies stored on their own
|
|
168
|
+
const clusters = []; // base64 of each cluster
|
|
169
|
+
const locs = {}; // body id -> [cluster, offset, length], for clustered pages
|
|
170
|
+
let clusterRawMax = 0;
|
|
171
|
+
|
|
172
|
+
if (!zstd) {
|
|
173
|
+
for (let i = 0; i < bodies.length; i++) blocks.set(i, gzipB64(bodies[i].buf));
|
|
174
|
+
} else {
|
|
175
|
+
for (let i = 0; i < bodies.length; i++) if (!bodies[i].page) blocks.set(i, gzipB64(bodies[i].buf));
|
|
176
|
+
// Sorted by route and query, so a cluster holds pages that resemble each
|
|
177
|
+
// other — which is the whole reason a cluster is smaller than its parts.
|
|
178
|
+
const pageIds = bodies.map((b, i) => i).filter((i) => bodies[i].page).sort((x, y) => (bodies[x].what < bodies[y].what ? -1 : 1));
|
|
179
|
+
// A window at least as large as a cluster: a page must be able to match
|
|
180
|
+
// against any earlier page in the same cluster.
|
|
181
|
+
const windowLog = Math.min(27, Math.max(20, Math.ceil(Math.log2(Math.max(cfg.clusterBytes, 1)))));
|
|
182
|
+
let cur = [];
|
|
183
|
+
let size = 0;
|
|
184
|
+
const flush = () => {
|
|
185
|
+
if (!cur.length) return;
|
|
186
|
+
let offset = 0;
|
|
187
|
+
for (const i of cur) {
|
|
188
|
+
locs[i] = [clusters.length, offset, bodies[i].buf.length];
|
|
189
|
+
offset += bodies[i].buf.length;
|
|
190
|
+
}
|
|
191
|
+
const raw = Buffer.concat(cur.map((i) => bodies[i].buf));
|
|
192
|
+
clusterRawMax = Math.max(clusterRawMax, raw.length);
|
|
193
|
+
clusters.push(
|
|
194
|
+
zlib
|
|
195
|
+
.zstdCompressSync(raw, {
|
|
196
|
+
params: { [zlib.constants.ZSTD_c_compressionLevel]: 19, [zlib.constants.ZSTD_c_windowLog]: windowLog },
|
|
197
|
+
})
|
|
198
|
+
.toString("base64"),
|
|
199
|
+
);
|
|
200
|
+
cur = [];
|
|
201
|
+
size = 0;
|
|
202
|
+
};
|
|
203
|
+
for (const i of pageIds) {
|
|
204
|
+
if (cur.length && size + bodies[i].buf.length > cfg.clusterBytes) flush();
|
|
205
|
+
cur.push(i);
|
|
206
|
+
size += bodies[i].buf.length;
|
|
207
|
+
}
|
|
208
|
+
flush();
|
|
209
|
+
}
|
|
210
|
+
|
|
136
211
|
// --- runtime + output ---------------------------------------------------------
|
|
137
212
|
const frameSrc = await fs.readFile(path.join(HERE, "runtime", "frame.js"), "utf8");
|
|
138
213
|
const shellSrc = (await fs.readFile(path.join(HERE, "runtime", "shell.js"), "utf8"))
|
|
@@ -152,6 +227,9 @@ export async function bundle(cfg, log) {
|
|
|
152
227
|
badge: cfg.offline.badge,
|
|
153
228
|
switcher: cfg.offline.switcher,
|
|
154
229
|
missingLinks: cfg.offline.missingLinks,
|
|
230
|
+
staticPrefix: cfg.staticPrefix,
|
|
231
|
+
codec: zstd ? "zstd" : "gzip",
|
|
232
|
+
locs: zstd ? locs : undefined,
|
|
155
233
|
assets: assets.map((a) => [a.k, a.v, a.b, a.type, a.status ?? 200]),
|
|
156
234
|
pages: pagesOut,
|
|
157
235
|
};
|
|
@@ -168,7 +246,9 @@ export async function bundle(cfg, log) {
|
|
|
168
246
|
`<div id="no-loading">Opening ${title}…</div>`,
|
|
169
247
|
`<script type="application/json" id="no-manifest">${JSON.stringify(manifestOut).replace(/</g, "\\u003c")}</script>`,
|
|
170
248
|
];
|
|
171
|
-
for (
|
|
249
|
+
for (const [id, b64] of blocks) parts.push(`<script type="text/plain" id="no-b${id}">${b64}</script>`);
|
|
250
|
+
for (let i = 0; i < clusters.length; i++) parts.push(`<script type="text/plain" id="no-c${i}">${clusters[i]}</script>`);
|
|
251
|
+
if (zstd) parts.push(`<script>${await inlineZstdDecoder()}</script>`);
|
|
172
252
|
parts.push(`<script>${shellSrc.replace(/<\/script/gi, "<\\/script").replace(/<!--/g, "<\\!--")}</script>`);
|
|
173
253
|
parts.push(`</body></html>\n`);
|
|
174
254
|
|
|
@@ -178,25 +258,58 @@ export async function bundle(cfg, log) {
|
|
|
178
258
|
|
|
179
259
|
// --- report ----------------------------------------------------------------------
|
|
180
260
|
const mb = (n) => (n / 1024 / 1024).toFixed(2) + " MB";
|
|
181
|
-
|
|
182
|
-
const assetBytes =
|
|
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);
|
|
263
|
+
const pageBytes = zstd
|
|
264
|
+
? clusters.reduce((n, c) => n + c.length, 0)
|
|
265
|
+
: bodies.reduce((n, b, i) => n + (b.page ? blocks.get(i).length : 0), 0);
|
|
183
266
|
log("");
|
|
184
267
|
log(`bundle finished in ${((Date.now() - t0) / 1000).toFixed(1)}s -> ${path.relative(process.cwd(), cfg.out)}`);
|
|
185
268
|
log(` file size ${mb(html.length)} (pages ${mb(pageBytes)}, assets ${mb(assetBytes)})`);
|
|
186
|
-
log(` pages ${pageCount} captured, ${
|
|
269
|
+
log(` pages ${pageCount} captured, ${bodies.filter((b) => b.page).length} unique bodies after dedupe`);
|
|
187
270
|
log(` assets ${assets.length} (${jsRewritten} scripts with location virtualised)`);
|
|
188
|
-
|
|
189
|
-
|
|
271
|
+
if (zstd) {
|
|
272
|
+
log(` clusters ${clusters.length} zstd, up to ${mb(clusterRawMax)} of pages each, decoded on demand`);
|
|
273
|
+
} else {
|
|
274
|
+
log(` compression gzip per page — try compress: "zstd" if this file is large`);
|
|
275
|
+
}
|
|
190
276
|
if (missing.size) {
|
|
191
277
|
const top = [...missing.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
192
278
|
log(` NOT CAPTURED ${missing.size} referenced URL(s) will fail offline:`);
|
|
193
279
|
for (const [k, n] of top) log(` ${k} (${n}x)`);
|
|
194
280
|
}
|
|
195
|
-
const report = {
|
|
281
|
+
const report = {
|
|
282
|
+
file: cfg.out,
|
|
283
|
+
bytes: html.length,
|
|
284
|
+
pageCount,
|
|
285
|
+
codec: manifestOut.codec,
|
|
286
|
+
clusters: clusters.length,
|
|
287
|
+
pageBytes,
|
|
288
|
+
assetBytes,
|
|
289
|
+
missing: Object.fromEntries(missing),
|
|
290
|
+
};
|
|
196
291
|
await fs.writeFile(cfg.out.replace(/\.html?$/, "") + ".bundle.json", JSON.stringify(report, null, 1));
|
|
197
292
|
return report;
|
|
198
293
|
}
|
|
199
294
|
|
|
295
|
+
/**
|
|
296
|
+
* fzstd's UMD build, which assigns a `fzstd` global when run as a plain script.
|
|
297
|
+
* Read by path rather than resolved: the package's exports map does not expose
|
|
298
|
+
* this build, and it is the only one that works inside the snapshot.
|
|
299
|
+
*/
|
|
300
|
+
async function inlineZstdDecoder() {
|
|
301
|
+
// Resolved through the package's main entry, then up to its directory: the
|
|
302
|
+
// exports map does not expose "./package.json" or "./umd/index.js", so
|
|
303
|
+
// neither can be resolved by subpath.
|
|
304
|
+
const pkg = path.dirname(path.dirname(require.resolve("fzstd")));
|
|
305
|
+
const umd = path.join(pkg, "umd", "index.js");
|
|
306
|
+
try {
|
|
307
|
+
return await fs.readFile(umd, "utf8");
|
|
308
|
+
} catch (e) {
|
|
309
|
+
throw new Error(`compress: "zstd" needs fzstd's UMD build at ${umd}, which is missing (${e.code}). Reinstall dependencies, or use compress: "gzip".`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
200
313
|
const SHELL_CSS = `
|
|
201
314
|
html,body{margin:0;height:100%;overflow:hidden;background:#fff}
|
|
202
315
|
.no-frame{position:fixed;inset:0;width:100%;height:100%;border:0;display:block;background:#fff}
|
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/config.mjs
CHANGED
|
@@ -38,6 +38,9 @@ export async function loadConfig(file) {
|
|
|
38
38
|
label: v.id,
|
|
39
39
|
...v,
|
|
40
40
|
}));
|
|
41
|
+
const compress = raw.compress ?? "gzip";
|
|
42
|
+
if (compress !== "gzip" && compress !== "zstd") throw new Error(`compress must be "gzip" or "zstd", not ${JSON.stringify(compress)}`);
|
|
43
|
+
|
|
41
44
|
const ids = new Set();
|
|
42
45
|
for (const v of variants) {
|
|
43
46
|
if (!/^[A-Za-z0-9_.-]+$/.test(v.id)) throw new Error(`Variant id "${v.id}" must be [A-Za-z0-9_.-]+`);
|
|
@@ -93,6 +96,17 @@ export async function loadConfig(file) {
|
|
|
93
96
|
// `docker cp` it out before bundling. `staticPath` is where the build lives
|
|
94
97
|
// inside the image (Next's own Dockerfile puts it under /app).
|
|
95
98
|
docker: raw.docker ? { staticPath: "/app/.next/static", ...raw.docker } : null,
|
|
99
|
+
// How page bodies are packed into the file.
|
|
100
|
+
//
|
|
101
|
+
// "gzip" each page gzipped on its own, decoded by the browser itself.
|
|
102
|
+
// "zstd" pages of a route packed together into clusters and compressed
|
|
103
|
+
// as one stream, with a small decoder inlined. Pages of an app
|
|
104
|
+
// mostly repeat each other, and gzip's 32KB window cannot see
|
|
105
|
+
// past one page — clustering is what makes a big app small.
|
|
106
|
+
compress,
|
|
107
|
+
// Raw bytes of pages per cluster, when clustering. Bigger is smaller but
|
|
108
|
+
// slower to open the first page of each cluster.
|
|
109
|
+
clusterBytes: raw.clusterBytes ?? 4 * 1024 * 1024,
|
|
96
110
|
includeStatic: raw.includeStatic ?? true,
|
|
97
111
|
staticDir: r(raw.staticDir) ?? defaultStatic,
|
|
98
112
|
staticPrefix: raw.staticPrefix ?? "/_next/static/",
|
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
|
@@ -41,15 +41,39 @@
|
|
|
41
41
|
for (var i = 0; i < n; i++) a[i] = bin.charCodeAt(i);
|
|
42
42
|
return a;
|
|
43
43
|
}
|
|
44
|
+
// Pages may be packed in clusters: many of them compressed as one stream,
|
|
45
|
+
// because pages of an app repeat each other and gzip cannot see past one
|
|
46
|
+
// page. A cluster is decoded on first use and the last few are kept.
|
|
47
|
+
var LOCS = M.locs || null;
|
|
48
|
+
var clusterCache = new Map();
|
|
49
|
+
function cluster(i) {
|
|
50
|
+
var hit = clusterCache.get(i);
|
|
51
|
+
if (hit) return hit;
|
|
52
|
+
var p = (async function () {
|
|
53
|
+
var el = document.getElementById("no-c" + i);
|
|
54
|
+
if (!el) throw new Error("snapshot cluster " + i + " is missing");
|
|
55
|
+
return fzstd.decompress(b64(el.textContent.trim()));
|
|
56
|
+
})();
|
|
57
|
+
clusterCache.set(i, p);
|
|
58
|
+
// Three decoded clusters is a few MB; older ones cost nothing to redo.
|
|
59
|
+
if (clusterCache.size > 3) clusterCache.delete(clusterCache.keys().next().value);
|
|
60
|
+
return p;
|
|
61
|
+
}
|
|
62
|
+
|
|
44
63
|
var kept = {};
|
|
45
64
|
function body(id, keep) {
|
|
46
65
|
if (kept[id]) return kept[id];
|
|
47
|
-
var
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
var loc = LOCS && LOCS[id];
|
|
67
|
+
var p = loc
|
|
68
|
+
? cluster(loc[0]).then(function (bytes) {
|
|
69
|
+
return bytes.subarray(loc[1], loc[1] + loc[2]);
|
|
70
|
+
})
|
|
71
|
+
: (async function () {
|
|
72
|
+
var el = document.getElementById("no-b" + id);
|
|
73
|
+
if (!el) throw new Error("snapshot body " + id + " is missing");
|
|
74
|
+
var stream = new Blob([b64(el.textContent.trim())]).stream().pipeThrough(new DecompressionStream("gzip"));
|
|
75
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
76
|
+
})();
|
|
53
77
|
if (keep) kept[id] = p;
|
|
54
78
|
return p;
|
|
55
79
|
}
|
|
@@ -63,11 +87,15 @@
|
|
|
63
87
|
idx[(a.v || "") + "\u0000" + a.k] = i;
|
|
64
88
|
});
|
|
65
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/";
|
|
66
94
|
function lookup(key, v) {
|
|
67
95
|
if (key == null) return -1;
|
|
68
96
|
var i = idx[(v || "") + "\u0000" + key];
|
|
69
97
|
if (i == null) i = idx["\u0000" + key];
|
|
70
|
-
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]];
|
|
71
99
|
return i == null ? -1 : i;
|
|
72
100
|
}
|
|
73
101
|
var bytes = await Promise.all(A.map(function (a) { return body(a.b, true); }));
|
|
@@ -87,7 +115,12 @@
|
|
|
87
115
|
return u;
|
|
88
116
|
}
|
|
89
117
|
function tokens(text) {
|
|
90
|
-
|
|
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
|
+
});
|
|
91
124
|
}
|
|
92
125
|
for (var i = 0; i < A.length; i++) assetURL(i);
|
|
93
126
|
|
|
@@ -167,8 +200,20 @@
|
|
|
167
200
|
}
|
|
168
201
|
|
|
169
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) {
|
|
170
215
|
var seq = ++NO.seq;
|
|
171
|
-
if (!M.pages
|
|
216
|
+
if (!Object.prototype.hasOwnProperty.call(M.pages, v)) v = M.defaultVariant;
|
|
172
217
|
setVariant(v);
|
|
173
218
|
var r = resolvePage(rawKey, v), e = r.entry, html, base = r.key;
|
|
174
219
|
if (!e && soft[v + "\u0000" + r.key]) {
|
|
@@ -192,6 +237,12 @@
|
|
|
192
237
|
var f = document.createElement("iframe");
|
|
193
238
|
f.className = "no-frame loading";
|
|
194
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");
|
|
195
246
|
f.addEventListener("load", function () {
|
|
196
247
|
NO.frameReady(f.contentWindow, seq);
|
|
197
248
|
if (seq === NO.seq) {
|
|
@@ -252,6 +303,17 @@
|
|
|
252
303
|
var u = new URL(href, ORIGIN);
|
|
253
304
|
window.open(location.href.split("#")[0] + hashFor(variant, NO.key(u.href), u.hash), "_blank");
|
|
254
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
|
+
};
|
|
255
317
|
NO.openAsset = function (href, name) {
|
|
256
318
|
var i = NO.assetIndex(href);
|
|
257
319
|
if (i >= 0) openAssetIndex(i, name);
|
|
@@ -394,6 +456,17 @@
|
|
|
394
456
|
function esc(s) {
|
|
395
457
|
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
|
396
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
|
+
|
|
397
470
|
function missingHTML(v, key) {
|
|
398
471
|
var P = M.pages[v] || {}, path = key.split("?")[0];
|
|
399
472
|
var pages = Object.keys(P).filter(function (k) { return P[k].b != null; }).sort();
|
|
@@ -414,7 +487,9 @@
|
|
|
414
487
|
|
|
415
488
|
// --- boot ------------------------------------------------------------------------------------
|
|
416
489
|
var start = parseHash();
|
|
417
|
-
|
|
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;
|
|
418
493
|
renderBadge();
|
|
419
494
|
NO.ready = true;
|
|
420
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.1
|
|
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",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"esbuild": "^0.28.2",
|
|
48
|
+
"fzstd": "^0.1.1",
|
|
48
49
|
"playwright-core": "~1.63.0"
|
|
49
50
|
},
|
|
50
51
|
"publishConfig": {
|