@africanpilot/next-snapshot 0.1.0 → 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 +63 -3
- package/lib/bundle.mjs +122 -21
- package/lib/capture.mjs +59 -8
- package/lib/config.mjs +26 -2
- package/lib/runtime/shell.js +30 -6
- package/lib/server.mjs +4 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -86,7 +86,8 @@ A config is an ES module; relative paths resolve against it.
|
|
|
86
86
|
|---|---|---|
|
|
87
87
|
| `app.cwd`, `app.start`, `app.build`, `app.port` | — | How to run the app. `{port}` is substituted into `start`. `build` runs when there is no `.next/BUILD_ID`, or with `--build`. Omit `app` and set `url` to capture a server you started yourself. |
|
|
88
88
|
| `url` | `http://localhost:{port}` | Origin to capture. `localhost`, because Next builds redirect URLs on it and a session cookie set on `127.0.0.1` is not sent to `localhost`. |
|
|
89
|
-
| `aliases` | loopback spellings | Other origins that are the same app (a canonical host it redirects to); URLs on them are treated as the app's own. `localhost`, `127.0.0.1
|
|
89
|
+
| `aliases` | loopback spellings | Other origins that are the same app (a canonical host it redirects to); URLs on them are treated as the app's own. `localhost`, `127.0.0.1`, `[::1]` and `0.0.0.0` on the same port are always aliases of each other. |
|
|
90
|
+
| `docker` | — | `{ container, staticPath }` — the app runs in a container, so copy its build output here with `docker cp` before bundling. `staticPath` defaults to `/app/.next/static`. See [Docker](#an-app-running-in-docker). |
|
|
90
91
|
| `out` | `./<name>.html` | Output file. The capture directory, bundle report, verify report and screenshots sit next to it. |
|
|
91
92
|
| `title` | — | Title shown while the file opens. |
|
|
92
93
|
| `start` | `/` | Page to open when the file has no hash. |
|
|
@@ -96,7 +97,7 @@ A config is an ES module; relative paths resolve against it.
|
|
|
96
97
|
| `variants` | one | `[{ id, label, login({context, request, origin}) }]`. One crawl per variant; the file can switch between them. |
|
|
97
98
|
| `defaultVariant` | first | |
|
|
98
99
|
| `explore.selects` | `true` | Try each option of each visible `<select>` once per page path. |
|
|
99
|
-
| `explore.tabs` | `true` | Click each tab-like control
|
|
100
|
+
| `explore.tabs` | `true` | Click each tab-like control and capture the URL it writes (router push/replace, or a bare `history.replaceState`). Candidates: `[role=tab]`, and "button bars" — an element whose children are two or more buttons and nothing else. `true` clicks each once per page **path**; `"url"` clicks each once per captured **page**, so a route whose pages differ by query (`?programme=…`) gets every tab for every one of them. `"url"` multiplies that route's pages by the number of tabs — use it when clicking through the app moves between a tab and a query at the same time. |
|
|
100
101
|
| `explore.click` | `[]` | Extra CSS selectors to click the same way. |
|
|
101
102
|
| `explore.denyText` | sign out, delete, approve, submit, save… | Controls whose label matches are never clicked. Writes are blocked at the network anyway; this protects the session and client-side state. |
|
|
102
103
|
| `explore.custom` | — | `async ({page, key, variant, discover}) => {}` for app-specific discovery (clicking tabs that change the URL, etc). |
|
|
@@ -105,9 +106,66 @@ A config is an ES module; relative paths resolve against it.
|
|
|
105
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. |
|
|
106
107
|
| `offline.badge` | `"bottom-right"` | The "Offline snapshot" pill; `false` to hide. |
|
|
107
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. |
|
|
108
111
|
| `includeStatic` | `true` | Also embed every file under `.next/static`, so lazily-loaded chunks the crawl never triggered are present. |
|
|
109
112
|
| `viewport`, `locale`, `timezoneId`, `browser` | | Passed to Chrome. `browser.executablePath` if Chrome is not installed. |
|
|
110
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
|
+
|
|
143
|
+
## An app running in Docker
|
|
144
|
+
|
|
145
|
+
The tool drives Chrome on your machine, so it reaches the container the same way
|
|
146
|
+
your browser does. Start the app with its port published — `docker run -p
|
|
147
|
+
3000:3000 …`, or `ports: ["3000:3000"]` in compose — and point `url` at it:
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
export default {
|
|
151
|
+
name: "my-app",
|
|
152
|
+
url: "http://localhost:3000",
|
|
153
|
+
docker: { container: "my-app" }, // `docker ps` shows the name
|
|
154
|
+
seeds: ["/"],
|
|
155
|
+
};
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`docker` copies the build output out of the container with `docker cp`, so
|
|
159
|
+
chunks that only load later — a modal, a menu — are in the file too. Without it
|
|
160
|
+
the snapshot holds only what the crawl happened to load. If your image puts the
|
|
161
|
+
app somewhere other than `/app`, set `staticPath` to match. A failed copy is a
|
|
162
|
+
warning, not an error: the capture continues without it.
|
|
163
|
+
|
|
164
|
+
Redirects to `0.0.0.0` need nothing extra. Next's images set
|
|
165
|
+
`HOSTNAME=0.0.0.0`, and an app that builds absolute URLs from it redirects
|
|
166
|
+
there; `0.0.0.0` on the same port is already an alias of the origin. For any
|
|
167
|
+
other host it redirects to, add it to `aliases`.
|
|
168
|
+
|
|
111
169
|
## What it cannot do
|
|
112
170
|
|
|
113
171
|
- **Anything not captured is not there.** A URL no link, prefetch, select or tab
|
|
@@ -117,7 +175,9 @@ A config is an ES module; relative paths resolve against it.
|
|
|
117
175
|
remembered, and returning to it re-serves the page it came from at that URL.
|
|
118
176
|
- **Exploration is one option at a time.** Each select option and each tab is
|
|
119
177
|
tried once per page path, from the first URL of that path the crawl reached —
|
|
120
|
-
not every combination.
|
|
178
|
+
not every combination. `explore.tabs: "url"` covers the common case (every
|
|
179
|
+
tab of every page of a route); for anything else, seed the combinations you
|
|
180
|
+
need. A URL that was never captured shows a "not in this snapshot" page.
|
|
121
181
|
- **Writes.** Forms and fetches that POST/PUT/DELETE are refused unless an
|
|
122
182
|
`offline.post` handler emulates them. Server Actions fail the same way.
|
|
123
183
|
- **Soft navigation.** Every navigation is a full page boot, so in-memory client
|
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.
|
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
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 =
|
|
103
|
+
let id = bodyIndex.get(h);
|
|
83
104
|
if (id == null) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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}
|
|
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 (
|
|
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
|
|
182
|
-
const
|
|
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, ${
|
|
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
|
-
|
|
189
|
-
|
|
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 = {
|
|
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/capture.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// or OPTIONS is aborted before it leaves the browser. Only a variant's `login`
|
|
10
10
|
// hook, which runs before that guard is installed, can write.
|
|
11
11
|
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
12
13
|
import crypto from "node:crypto";
|
|
13
14
|
import fss from "node:fs";
|
|
14
15
|
import fs from "node:fs/promises";
|
|
@@ -146,9 +147,13 @@ export async function capture(cfg, log) {
|
|
|
146
147
|
await browser.close();
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
// The build output, so chunks the crawl never triggered are in the file too.
|
|
151
|
+
// In Docker it lives inside the container; copy it out first.
|
|
152
|
+
let staticDir = cfg.staticDir;
|
|
153
|
+
if (cfg.docker?.container) staticDir = copyFromContainer(cfg, log) ?? staticDir;
|
|
154
|
+
if (cfg.includeStatic && staticDir && fss.existsSync(staticDir)) {
|
|
155
|
+
const n = await addDir(staticDir, cfg.staticPrefix, Infinity);
|
|
156
|
+
log(`static: +${n} build files from ${path.relative(process.cwd(), staticDir)}`);
|
|
152
157
|
}
|
|
153
158
|
if (cfg.publicDir && fss.existsSync(cfg.publicDir)) {
|
|
154
159
|
const n = await addDir(cfg.publicDir, "/", cfg.maxPublicFileBytes);
|
|
@@ -181,7 +186,20 @@ export async function capture(cfg, log) {
|
|
|
181
186
|
return route.abort("blockedbyclient");
|
|
182
187
|
});
|
|
183
188
|
const page = await context.newPage();
|
|
184
|
-
const s = {
|
|
189
|
+
const s = {
|
|
190
|
+
v,
|
|
191
|
+
context,
|
|
192
|
+
page,
|
|
193
|
+
visited: new Set(),
|
|
194
|
+
rsc: new Set(),
|
|
195
|
+
files: new Set(),
|
|
196
|
+
explored: new Set(),
|
|
197
|
+
// Keys a tab click produced. In "url" mode they are not explored again:
|
|
198
|
+
// their tab strip leads back to pages already captured, and re-clicking
|
|
199
|
+
// it on every one of them is what turns this from linear into expensive.
|
|
200
|
+
fromTabs: new Set(),
|
|
201
|
+
current: null,
|
|
202
|
+
};
|
|
185
203
|
context.on("response", (res) => track(onResponse(s, res)));
|
|
186
204
|
// A click that opens a window must not leave a second crawler behind.
|
|
187
205
|
context.on("page", (p) => {
|
|
@@ -364,12 +382,19 @@ export async function capture(cfg, log) {
|
|
|
364
382
|
// a tab that never asks the server — expose views that no link names.
|
|
365
383
|
// Candidates: [role=tab], the configured `explore.click` selectors, and
|
|
366
384
|
// "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.
|
|
368
|
-
//
|
|
385
|
+
// nothing else), which is how most tab strips are built without ARIA.
|
|
386
|
+
//
|
|
387
|
+
// `explore.tabs: true` clicks each label once per page *path*: cheap, but a
|
|
388
|
+
// route whose pages differ by query (?programme=…) then holds tab views for
|
|
389
|
+
// only the first of them. `"url"` clicks each label once per captured page,
|
|
390
|
+
// so every programme gets every tab — pages, and file size, multiply by the
|
|
391
|
+
// number of tabs.
|
|
369
392
|
async function exploreClicks(s, key) {
|
|
370
393
|
const { page } = s;
|
|
371
394
|
const found = new Set();
|
|
372
|
-
const
|
|
395
|
+
const perUrl = cfg.explore.tabs === "url";
|
|
396
|
+
if (perUrl && s.fromTabs.has(key)) return found;
|
|
397
|
+
const scope = perUrl ? key : key.split("?")[0];
|
|
373
398
|
const tag = () =>
|
|
374
399
|
page
|
|
375
400
|
.evaluate(
|
|
@@ -404,7 +429,7 @@ export async function capture(cfg, log) {
|
|
|
404
429
|
const labels = (await tag()).slice(0, cfg.explore.maxClicks);
|
|
405
430
|
let dirty = false;
|
|
406
431
|
for (const label of labels) {
|
|
407
|
-
const sig = `${
|
|
432
|
+
const sig = `${scope}::click:${label}`;
|
|
408
433
|
if (s.explored.has(sig)) continue;
|
|
409
434
|
s.explored.add(sig);
|
|
410
435
|
if (dirty) {
|
|
@@ -420,6 +445,7 @@ export async function capture(cfg, log) {
|
|
|
420
445
|
const k = keyOf(page.url());
|
|
421
446
|
if (k && k !== key) {
|
|
422
447
|
found.add(k);
|
|
448
|
+
s.fromTabs.add(k);
|
|
423
449
|
dirty = true;
|
|
424
450
|
}
|
|
425
451
|
} catch {
|
|
@@ -496,6 +522,31 @@ export async function capture(cfg, log) {
|
|
|
496
522
|
}
|
|
497
523
|
}
|
|
498
524
|
|
|
525
|
+
/**
|
|
526
|
+
* `docker cp <container>:<staticPath>` into the capture directory. Returns the
|
|
527
|
+
* local path, or null with a warning — a missing build is worth saying out
|
|
528
|
+
* loud, but it does not stop a capture that is otherwise fine.
|
|
529
|
+
*/
|
|
530
|
+
function copyFromContainer(cfg, log) {
|
|
531
|
+
const { container, staticPath } = cfg.docker;
|
|
532
|
+
const dest = path.join(cfg.captureDir, "docker-static");
|
|
533
|
+
// Ours, written by the previous run: `docker cp` nests into a directory that
|
|
534
|
+
// already exists, which would bury the files a level deeper each time.
|
|
535
|
+
fss.rmSync(dest, { recursive: true, force: true });
|
|
536
|
+
const r = spawnSync("docker", ["cp", `${container}:${staticPath}`, dest], { encoding: "utf8" });
|
|
537
|
+
if (r.error?.code === "ENOENT") {
|
|
538
|
+
log(` warn: docker is not installed, so ${container}:${staticPath} could not be copied`);
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
if (r.status !== 0) {
|
|
542
|
+
log(` warn: docker cp ${container}:${staticPath} failed — ${(r.stderr || "").trim().split("\n")[0]}`);
|
|
543
|
+
log(` warn: continuing without the build output; lazily-loaded chunks may be missing offline`);
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
log(`docker: copied ${container}:${staticPath}`);
|
|
547
|
+
return dest;
|
|
548
|
+
}
|
|
549
|
+
|
|
499
550
|
async function walk(dir) {
|
|
500
551
|
const out = [];
|
|
501
552
|
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
|
package/lib/config.mjs
CHANGED
|
@@ -22,7 +22,10 @@ export async function loadConfig(file) {
|
|
|
22
22
|
// other — a sign-in that redirects across them loses its session.
|
|
23
23
|
const origin = new URL(raw.url ?? `http://localhost:${port}`).origin;
|
|
24
24
|
const o = new URL(origin);
|
|
25
|
-
|
|
25
|
+
// 0.0.0.0 is here because of Docker: Next's own images set HOSTNAME=0.0.0.0,
|
|
26
|
+
// and an app that builds absolute URLs from that redirects to 0.0.0.0:PORT.
|
|
27
|
+
// Without the alias those pages look like a different site and are dropped.
|
|
28
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]", "0.0.0.0"];
|
|
26
29
|
const aliases = [
|
|
27
30
|
...(loopback.includes(o.hostname) ? loopback.map((h) => `${o.protocol}//${h}${o.port ? ":" + o.port : ""}`) : []),
|
|
28
31
|
...(raw.aliases ?? []),
|
|
@@ -35,6 +38,9 @@ export async function loadConfig(file) {
|
|
|
35
38
|
label: v.id,
|
|
36
39
|
...v,
|
|
37
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
|
+
|
|
38
44
|
const ids = new Set();
|
|
39
45
|
for (const v of variants) {
|
|
40
46
|
if (!/^[A-Za-z0-9_.-]+$/.test(v.id)) throw new Error(`Variant id "${v.id}" must be [A-Za-z0-9_.-]+`);
|
|
@@ -70,7 +76,10 @@ export async function loadConfig(file) {
|
|
|
70
76
|
explore: {
|
|
71
77
|
selects: true,
|
|
72
78
|
maxOptions: 40,
|
|
73
|
-
// Click tab-like controls and record any URL they write.
|
|
79
|
+
// Click tab-like controls and record any URL they write. `true` clicks
|
|
80
|
+
// each once per page path; "url" clicks each once per captured page, so
|
|
81
|
+
// a route whose pages differ by query gets every tab for every one of
|
|
82
|
+
// them — at the cost of multiplying pages by the number of tabs.
|
|
74
83
|
tabs: true,
|
|
75
84
|
// Extra CSS selectors to click the same way.
|
|
76
85
|
click: [],
|
|
@@ -83,6 +92,21 @@ export async function loadConfig(file) {
|
|
|
83
92
|
},
|
|
84
93
|
variants,
|
|
85
94
|
defaultVariant: raw.defaultVariant ?? variants[0].id,
|
|
95
|
+
// The app runs in a container: its build output is not on this disk, so
|
|
96
|
+
// `docker cp` it out before bundling. `staticPath` is where the build lives
|
|
97
|
+
// inside the image (Next's own Dockerfile puts it under /app).
|
|
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,
|
|
86
110
|
includeStatic: raw.includeStatic ?? true,
|
|
87
111
|
staticDir: r(raw.staticDir) ?? defaultStatic,
|
|
88
112
|
staticPrefix: raw.staticPrefix ?? "/_next/static/",
|
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
|
}
|
package/lib/server.mjs
CHANGED
|
@@ -8,7 +8,10 @@ import path from "node:path";
|
|
|
8
8
|
export async function withServer(cfg, opts, log, fn) {
|
|
9
9
|
if (!cfg.app?.start) {
|
|
10
10
|
if (!(await isUp(cfg.origin))) {
|
|
11
|
-
throw new Error(
|
|
11
|
+
throw new Error(
|
|
12
|
+
`Nothing is answering at ${cfg.origin}. Start the app, or give the config an app.start command.\n` +
|
|
13
|
+
`If it runs in Docker, publish the port to this machine (docker run -p 3000:3000 …) and set url to that port.`,
|
|
14
|
+
);
|
|
12
15
|
}
|
|
13
16
|
log(`using the server already running at ${cfg.origin}`);
|
|
14
17
|
return fn();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@africanpilot/next-snapshot",
|
|
3
|
-
"version": "0.
|
|
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": {
|