@africanpilot/next-snapshot 0.1.0 → 0.1.2

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
@@ -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` and `[::1]` are always aliases of each other. |
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 once per page path: `[role=tab]`, and "button bars" — an element whose children are two or more buttons and nothing else. A URL the click writes (router push/replace, or a bare `history.replaceState`) is captured. |
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). |
@@ -108,6 +109,32 @@ A config is an ES module; relative paths resolve against it.
108
109
  | `includeStatic` | `true` | Also embed every file under `.next/static`, so lazily-loaded chunks the crawl never triggered are present. |
109
110
  | `viewport`, `locale`, `timezoneId`, `browser` | | Passed to Chrome. `browser.executablePath` if Chrome is not installed. |
110
111
 
112
+ ## An app running in Docker
113
+
114
+ The tool drives Chrome on your machine, so it reaches the container the same way
115
+ your browser does. Start the app with its port published — `docker run -p
116
+ 3000:3000 …`, or `ports: ["3000:3000"]` in compose — and point `url` at it:
117
+
118
+ ```js
119
+ export default {
120
+ name: "my-app",
121
+ url: "http://localhost:3000",
122
+ docker: { container: "my-app" }, // `docker ps` shows the name
123
+ seeds: ["/"],
124
+ };
125
+ ```
126
+
127
+ `docker` copies the build output out of the container with `docker cp`, so
128
+ chunks that only load later — a modal, a menu — are in the file too. Without it
129
+ the snapshot holds only what the crawl happened to load. If your image puts the
130
+ app somewhere other than `/app`, set `staticPath` to match. A failed copy is a
131
+ warning, not an error: the capture continues without it.
132
+
133
+ Redirects to `0.0.0.0` need nothing extra. Next's images set
134
+ `HOSTNAME=0.0.0.0`, and an app that builds absolute URLs from it redirects
135
+ there; `0.0.0.0` on the same port is already an alias of the origin. For any
136
+ other host it redirects to, add it to `aliases`.
137
+
111
138
  ## What it cannot do
112
139
 
113
140
  - **Anything not captured is not there.** A URL no link, prefetch, select or tab
@@ -117,7 +144,9 @@ A config is an ES module; relative paths resolve against it.
117
144
  remembered, and returning to it re-serves the page it came from at that URL.
118
145
  - **Exploration is one option at a time.** Each select option and each tab is
119
146
  tried once per page path, from the first URL of that path the crawl reached —
120
- not every combination. Seed the combinations you need.
147
+ not every combination. `explore.tabs: "url"` covers the common case (every
148
+ tab of every page of a route); for anything else, seed the combinations you
149
+ need. A URL that was never captured shows a "not in this snapshot" page.
121
150
  - **Writes.** Forms and fetches that POST/PUT/DELETE are refused unless an
122
151
  `offline.post` handler emulates them. Server Actions fail the same way.
123
152
  - **Soft navigation.** Every navigation is a full page boot, so in-memory client
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
- if (cfg.includeStatic && cfg.staticDir && fss.existsSync(cfg.staticDir)) {
150
- const n = await addDir(cfg.staticDir, cfg.staticPrefix, Infinity);
151
- log(`static: +${n} build files from ${path.relative(process.cwd(), cfg.staticDir)}`);
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 = { v, context, page, visited: new Set(), rsc: new Set(), files: new Set(), explored: new Set(), current: null };
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. Each
368
- // (page path, label) is clicked once per variant.
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 pathOnly = key.split("?")[0];
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 = `${pathOnly}::click:${label}`;
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
- const loopback = ["localhost", "127.0.0.1", "[::1]"];
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 ?? []),
@@ -70,7 +73,10 @@ export async function loadConfig(file) {
70
73
  explore: {
71
74
  selects: true,
72
75
  maxOptions: 40,
73
- // Click tab-like controls and record any URL they write.
76
+ // Click tab-like controls and record any URL they write. `true` clicks
77
+ // each once per page path; "url" clicks each once per captured page, so
78
+ // a route whose pages differ by query gets every tab for every one of
79
+ // them — at the cost of multiplying pages by the number of tabs.
74
80
  tabs: true,
75
81
  // Extra CSS selectors to click the same way.
76
82
  click: [],
@@ -83,6 +89,10 @@ export async function loadConfig(file) {
83
89
  },
84
90
  variants,
85
91
  defaultVariant: raw.defaultVariant ?? variants[0].id,
92
+ // The app runs in a container: its build output is not on this disk, so
93
+ // `docker cp` it out before bundling. `staticPath` is where the build lives
94
+ // inside the image (Next's own Dockerfile puts it under /app).
95
+ docker: raw.docker ? { staticPath: "/app/.next/static", ...raw.docker } : null,
86
96
  includeStatic: raw.includeStatic ?? true,
87
97
  staticDir: r(raw.staticDir) ?? defaultStatic,
88
98
  staticPrefix: raw.staticPrefix ?? "/_next/static/",
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(`Nothing is answering at ${cfg.origin}. Start the app, or give the config an app.start command.`);
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.1.0",
3
+ "version": "0.1.2",
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",