@africanpilot/next-snapshot 0.1.2 → 0.2.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.
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
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 gzipped and base64'd into its own inert <script type=text/plain>.
10
- // The runtime (runtime/shell.js) decodes assets once into blob: URLs, replaces
11
- // tokens with them, and decodes a page only when it is navigated to.
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.
@@ -41,6 +55,12 @@ export async function bundle(cfg, log) {
41
55
  const origin = M.origin;
42
56
  const readBody = (sha) => fs.readFile(path.join(cfg.captureDir, "bodies", sha));
43
57
  const t0 = Date.now();
58
+ const zstd = cfg.compress === "zstd";
59
+ if (zstd && typeof zlib.zstdCompressSync !== "function") {
60
+ throw new Error(
61
+ `compress: "zstd" needs a Node built with zstd (zlib.zstdCompressSync); this is ${process.version}. Upgrade Node, or use compress: "gzip".`,
62
+ );
63
+ }
44
64
 
45
65
  // --- asset table ---------------------------------------------------------
46
66
  const assets = [];
@@ -74,18 +94,17 @@ export async function bundle(cfg, log) {
74
94
  });
75
95
 
76
96
  // --- bodies ---------------------------------------------------------------
77
- const out = []; // id -> base64(gzip)
78
- const outIndex = new Map(); // content hash -> id
79
- const sizes = []; // {what, raw, packed}
80
- function emit(buf, what) {
97
+ // Collected first, compressed at the end: how they are packed depends on all
98
+ // of them. Identical content is stored once, whatever refers to it.
99
+ const bodies = []; // id -> { buf, what, page }
100
+ const bodyIndex = new Map(); // content hash -> id
101
+ function emit(buf, what, page = false) {
81
102
  const h = crypto.createHash("sha256").update(buf).digest("hex");
82
- let id = outIndex.get(h);
103
+ let id = bodyIndex.get(h);
83
104
  if (id == null) {
84
- const packed = zlib.gzipSync(buf, { level: 9 }).toString("base64");
85
- id = out.length;
86
- out.push(packed);
87
- outIndex.set(h, id);
88
- sizes.push({ what, raw: buf.length, packed: packed.length });
105
+ id = bodies.length;
106
+ bodies.push({ buf, what, page });
107
+ bodyIndex.set(h, id);
89
108
  }
90
109
  return id;
91
110
  }
@@ -127,12 +146,58 @@ export async function bundle(cfg, log) {
127
146
  if (i >= 0) pagesOut[v][k] = { a: i };
128
147
  } else {
129
148
  const html = (await readBody(e.body)).toString("utf8");
130
- pagesOut[v][k] = { b: emit(Buffer.from(rewriteHTML(html, k, v)), `page ${v} ${k}`), s: e.status };
149
+ pagesOut[v][k] = { b: emit(Buffer.from(rewriteHTML(html, k, v)), `page ${v} ${k}`, true), s: e.status };
131
150
  pageCount++;
132
151
  }
133
152
  }
134
153
  }
135
154
 
155
+ // --- packing ------------------------------------------------------------------
156
+ const gzipB64 = (buf) => zlib.gzipSync(buf, { level: 9 }).toString("base64");
157
+ const blocks = new Map(); // body id -> base64, for bodies stored on their own
158
+ const clusters = []; // base64 of each cluster
159
+ const locs = {}; // body id -> [cluster, offset, length], for clustered pages
160
+ let clusterRawMax = 0;
161
+
162
+ if (!zstd) {
163
+ for (let i = 0; i < bodies.length; i++) blocks.set(i, gzipB64(bodies[i].buf));
164
+ } else {
165
+ for (let i = 0; i < bodies.length; i++) if (!bodies[i].page) blocks.set(i, gzipB64(bodies[i].buf));
166
+ // Sorted by route and query, so a cluster holds pages that resemble each
167
+ // other — which is the whole reason a cluster is smaller than its parts.
168
+ const pageIds = bodies.map((b, i) => i).filter((i) => bodies[i].page).sort((x, y) => (bodies[x].what < bodies[y].what ? -1 : 1));
169
+ // A window at least as large as a cluster: a page must be able to match
170
+ // against any earlier page in the same cluster.
171
+ const windowLog = Math.min(27, Math.max(20, Math.ceil(Math.log2(Math.max(cfg.clusterBytes, 1)))));
172
+ let cur = [];
173
+ let size = 0;
174
+ const flush = () => {
175
+ if (!cur.length) return;
176
+ let offset = 0;
177
+ for (const i of cur) {
178
+ locs[i] = [clusters.length, offset, bodies[i].buf.length];
179
+ offset += bodies[i].buf.length;
180
+ }
181
+ const raw = Buffer.concat(cur.map((i) => bodies[i].buf));
182
+ clusterRawMax = Math.max(clusterRawMax, raw.length);
183
+ clusters.push(
184
+ zlib
185
+ .zstdCompressSync(raw, {
186
+ params: { [zlib.constants.ZSTD_c_compressionLevel]: 19, [zlib.constants.ZSTD_c_windowLog]: windowLog },
187
+ })
188
+ .toString("base64"),
189
+ );
190
+ cur = [];
191
+ size = 0;
192
+ };
193
+ for (const i of pageIds) {
194
+ if (cur.length && size + bodies[i].buf.length > cfg.clusterBytes) flush();
195
+ cur.push(i);
196
+ size += bodies[i].buf.length;
197
+ }
198
+ flush();
199
+ }
200
+
136
201
  // --- runtime + output ---------------------------------------------------------
137
202
  const frameSrc = await fs.readFile(path.join(HERE, "runtime", "frame.js"), "utf8");
138
203
  const shellSrc = (await fs.readFile(path.join(HERE, "runtime", "shell.js"), "utf8"))
@@ -152,6 +217,8 @@ export async function bundle(cfg, log) {
152
217
  badge: cfg.offline.badge,
153
218
  switcher: cfg.offline.switcher,
154
219
  missingLinks: cfg.offline.missingLinks,
220
+ codec: zstd ? "zstd" : "gzip",
221
+ locs: zstd ? locs : undefined,
155
222
  assets: assets.map((a) => [a.k, a.v, a.b, a.type, a.status ?? 200]),
156
223
  pages: pagesOut,
157
224
  };
@@ -168,7 +235,9 @@ export async function bundle(cfg, log) {
168
235
  `<div id="no-loading">Opening ${title}…</div>`,
169
236
  `<script type="application/json" id="no-manifest">${JSON.stringify(manifestOut).replace(/</g, "\\u003c")}</script>`,
170
237
  ];
171
- for (let i = 0; i < out.length; i++) parts.push(`<script type="text/plain" id="no-b${i}">${out[i]}</script>`);
238
+ for (const [id, b64] of blocks) parts.push(`<script type="text/plain" id="no-b${id}">${b64}</script>`);
239
+ for (let i = 0; i < clusters.length; i++) parts.push(`<script type="text/plain" id="no-c${i}">${clusters[i]}</script>`);
240
+ if (zstd) parts.push(`<script>${await inlineZstdDecoder()}</script>`);
172
241
  parts.push(`<script>${shellSrc.replace(/<\/script/gi, "<\\/script").replace(/<!--/g, "<\\!--")}</script>`);
173
242
  parts.push(`</body></html>\n`);
174
243
 
@@ -178,25 +247,57 @@ export async function bundle(cfg, log) {
178
247
 
179
248
  // --- report ----------------------------------------------------------------------
180
249
  const mb = (n) => (n / 1024 / 1024).toFixed(2) + " MB";
181
- const pageBytes = sizes.filter((s) => s.what.startsWith("page")).reduce((n, s) => n + s.packed, 0);
182
- const assetBytes = sizes.filter((s) => s.what.startsWith("asset")).reduce((n, s) => n + s.packed, 0);
250
+ const assetBytes = assets.reduce((n, a) => n + (blocks.get(a.b)?.length ?? 0), 0);
251
+ const pageBytes = zstd
252
+ ? clusters.reduce((n, c) => n + c.length, 0)
253
+ : bodies.reduce((n, b, i) => n + (b.page ? blocks.get(i).length : 0), 0);
183
254
  log("");
184
255
  log(`bundle finished in ${((Date.now() - t0) / 1000).toFixed(1)}s -> ${path.relative(process.cwd(), cfg.out)}`);
185
256
  log(` file size ${mb(html.length)} (pages ${mb(pageBytes)}, assets ${mb(assetBytes)})`);
186
- log(` pages ${pageCount} captured, ${out.length} unique bodies after dedupe`);
257
+ log(` pages ${pageCount} captured, ${bodies.filter((b) => b.page).length} unique bodies after dedupe`);
187
258
  log(` assets ${assets.length} (${jsRewritten} scripts with location virtualised)`);
188
- const biggest = [...sizes].sort((a, b) => b.packed - a.packed).slice(0, 6);
189
- log(` largest bodies ${biggest.map((s) => `${s.what.slice(0, 70)} ${mb(s.packed)}`).join("\n ")}`);
259
+ if (zstd) {
260
+ log(` clusters ${clusters.length} zstd, up to ${mb(clusterRawMax)} of pages each, decoded on demand`);
261
+ } else {
262
+ log(` compression gzip per page — try compress: "zstd" if this file is large`);
263
+ }
190
264
  if (missing.size) {
191
265
  const top = [...missing.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
192
266
  log(` NOT CAPTURED ${missing.size} referenced URL(s) will fail offline:`);
193
267
  for (const [k, n] of top) log(` ${k} (${n}x)`);
194
268
  }
195
- const report = { file: cfg.out, bytes: html.length, pageCount, bodies: out.length, missing: Object.fromEntries(missing), sizes };
269
+ const report = {
270
+ file: cfg.out,
271
+ bytes: html.length,
272
+ pageCount,
273
+ codec: manifestOut.codec,
274
+ clusters: clusters.length,
275
+ pageBytes,
276
+ assetBytes,
277
+ missing: Object.fromEntries(missing),
278
+ };
196
279
  await fs.writeFile(cfg.out.replace(/\.html?$/, "") + ".bundle.json", JSON.stringify(report, null, 1));
197
280
  return report;
198
281
  }
199
282
 
283
+ /**
284
+ * fzstd's UMD build, which assigns a `fzstd` global when run as a plain script.
285
+ * Read by path rather than resolved: the package's exports map does not expose
286
+ * this build, and it is the only one that works inside the snapshot.
287
+ */
288
+ async function inlineZstdDecoder() {
289
+ // Resolved through the package's main entry, then up to its directory: the
290
+ // exports map does not expose "./package.json" or "./umd/index.js", so
291
+ // neither can be resolved by subpath.
292
+ const pkg = path.dirname(path.dirname(require.resolve("fzstd")));
293
+ const umd = path.join(pkg, "umd", "index.js");
294
+ try {
295
+ return await fs.readFile(umd, "utf8");
296
+ } catch (e) {
297
+ throw new Error(`compress: "zstd" needs fzstd's UMD build at ${umd}, which is missing (${e.code}). Reinstall dependencies, or use compress: "gzip".`);
298
+ }
299
+ }
300
+
200
301
  const SHELL_CSS = `
201
302
  html,body{margin:0;height:100%;overflow:hidden;background:#fff}
202
303
  .no-frame{position:fixed;inset:0;width:100%;height:100%;border:0;display:block;background:#fff}
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/",
@@ -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 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
- })();
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@africanpilot/next-snapshot",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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": {