Files
GeoLibre/scripts/bundle-report.mjs
Qiusheng Wu 9229c00454 build: ship h5wasm once and report bundle sizes in CI (#2870)
* build: ship h5wasm once and report bundle sizes in CI

The remote NetCDF worker is a separate Vite build, so its lazy
import("h5wasm") emitted a second 4.8 MB hdf5_hl chunk next to the main
thread's. The main build now emits h5wasm as an explicit chunk and
passes its URL to the worker with the open request; the worker build
resolves h5wasm to a shim that imports that chunk by URL. Dev is
unchanged. The build output shrinks by 4.79 MB.

Add scripts/bundle-report.mjs (npm run bundle:report): per-asset raw
and gzip sizes for every JS/WASM file, the boot set read from the built
index.html, and per-entry dist sizes, written as Markdown and JSON. The
CI build job appends it to the step summary and uploads it as an
artifact.

The boot budget guard gains a gzip limit (900 KiB; boot JS measures
~680 kB gzipped) next to the 3 MiB raw one. Both now live in
apps/geolibre-desktop/boot-budget.json, read by the guard and the
report, and are documented in docs/maintenance.md.

Part of #2858

* Address review feedback

- bundle-report: follow each boot chunk's static imports so the boot
  set stays complete even when index.html does not modulepreload the
  whole graph (CodeRabbit). Unchanged on the real build: 29 files.
- bundle-report: walk dist/assets recursively so nested JS/WASM files
  are listed and counted (CodeRabbit).
- Test that the worker's open request carries h5wasmUrl, which a
  production worker build needs to load h5wasm (Claude review).

* Address review feedback

- bundle-report: read static imports only from a chunk's leading import
  statements (after Vite's __vite__mapDeps line), so import-shaped text
  in strings or comments is not counted (CodeRabbit). With every
  modulepreload stripped from index.html, the traversal alone still
  recovers the same 29-file boot set.
- bundle-report: reject a non-numeric --top instead of silently
  rendering an empty table (Claude review).
- CI: make the bundle report step continue-on-error; it is diagnostic,
  and the build enforces the budgets (Claude review).
- vite.config: note that the worker h5wasm redirect applies to every
  worker build (Claude review).

* Address review feedback

- shared-h5wasm: fail the build unless exactly one hdf5_hl-*.js is
  emitted (worker outputs are in the main bundle by generateBundle), so
  a regression of the duplicate is caught in CI rather than only by the
  diagnostic report (Claude review). Verified: with the worker redirect
  removed, the build fails naming both old hdf5_hl chunks.
- bundle-report: note that the static-import scan is a text fallback
  that can drift with Rolldown's output shape (Claude review).

* Address review feedback

- shared-h5wasm: also fail the build when src/lib/h5wasm-chunk-url.ts
  was never replaced (for example after a move that breaks
  CHUNK_URL_MODULE). The one-chunk check alone would still pass, and
  the worker would only throw at runtime (Claude review). Verified by
  breaking the regex: the build fails with the new message.
2026-10-04 01:10:05 -04:00

331 lines
12 KiB
JavaScript

// Bundle size report for the production web build.
// Run after `npm run build`: node scripts/bundle-report.mjs (or `npm run bundle:report`)
//
// Lists every JS and WASM file under dist/assets with its raw and gzip size,
// totals the boot set (what index.html loads and modulepreloads before the
// shell can mount), and writes the result as Markdown and JSON so CI can attach
// it to a run and a reviewer can diff two builds.
//
// The boot set is read from the built index.html rather than recomputed from
// the module graph, so it is exactly what a browser fetches. The build itself
// enforces the boot budgets (bootBundleBudgetPlugin in
// apps/geolibre-desktop/vite.config.ts); this report only shows them next to
// the measured numbers. Both read apps/geolibre-desktop/boot-budget.json.
//
// Options:
// --dist <dir> build output to read (default apps/geolibre-desktop/dist)
// --out <dir> where to write bundle-report.{md,json} (default bundle-report)
// --top <n> rows in the Markdown asset table (default 40; JSON has all)
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const APP_DIR = path.join(ROOT, "apps", "geolibre-desktop");
/**
* Parse `--name value` options.
*
* @param {string[]} argv - Arguments after the script path.
* @returns {{dist: string, out: string, top: number}} The resolved options.
*/
function parseArgs(argv) {
const options = {
dist: path.join(APP_DIR, "dist"),
out: path.join(ROOT, "bundle-report"),
top: 40,
};
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i];
const value = argv[i + 1];
if (value === undefined) throw new Error(`${flag} needs a value`);
if (flag === "--dist") options.dist = path.resolve(value);
else if (flag === "--out") options.out = path.resolve(value);
else if (flag === "--top") {
if (!/^\d+$/.test(value)) throw new Error(`--top needs a whole number, got ${value}`);
options.top = Number(value);
} else throw new Error(`Unknown option ${flag}`);
i += 1;
}
return options;
}
/**
* Raw and gzip byte counts of a file.
*
* @param {string} file - Absolute path.
* @returns {{raw: number, gzip: number}} Sizes in bytes; gzip at level 9.
*/
function measure(file) {
const bytes = readFileSync(file);
return { raw: bytes.length, gzip: gzipSync(bytes, { level: 9 }).length };
}
/**
* Total size of every file under a directory.
*
* @param {string} dir - Absolute directory path.
* @returns {number} Bytes.
*/
function directoryBytes(dir) {
let total = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) total += directoryBytes(full);
else if (entry.isFile()) total += statSync(full).size;
}
return total;
}
/**
* Map a URL from index.html to a file in the build output.
*
* The URL carries the deployment base (`/`, `./`, `/geolibre/`, …), which the
* report does not know, so leading path segments are dropped until the rest
* names a file in dist.
*
* @param {string} dist - Build output directory.
* @param {string} url - A `src`/`href` attribute value.
* @returns {string | null} The relative path inside dist, or null if not found.
*/
function resolveBuiltUrl(dist, url) {
if (/^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith("//")) return null;
const segments = url
.split(/[?#]/)[0]
.split("/")
.filter((s) => s && s !== ".");
for (let start = 0; start < segments.length; start += 1) {
const relative = segments.slice(start).join("/");
if (existsSync(path.join(dist, relative))) return relative;
}
return null;
}
/**
* The files index.html fetches before the app mounts.
*
* @param {string} dist - Build output directory.
* @returns {{js: string[], css: string[]}} Paths relative to dist, in order.
*/
function bootSet(dist) {
const html = readFileSync(path.join(dist, "index.html"), "utf8");
const js = [];
const css = [];
for (const [tag] of html.matchAll(/<(?:script|link)\b[^>]*>/g)) {
const attr = (name) => tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1];
let target = null;
if (tag.startsWith("<script") && attr("type") === "module") target = js;
else if (attr("rel") === "modulepreload") target = js;
else if (attr("rel") === "stylesheet") target = css;
const url = attr("src") ?? attr("href");
if (!target || !url) continue;
const file = resolveBuiltUrl(dist, url);
if (file && !target.includes(file)) target.push(file);
}
// Vite modulepreloads the entry's whole static graph, but only while
// build.modulePreload is on; follow the static imports too so the boot set
// stays complete without it (the same graph bootBundleBudgetPlugin walks).
// This is a text scan of minified output, so a change in Rolldown's chunk
// shape could make it miss imports; the modulepreload list stays the primary
// source, and the build's own budget check uses the real module graph.
for (let i = 0; i < js.length; i += 1) {
const code = readFileSync(path.join(dist, js[i]), "utf8");
for (const specifier of staticImports(code)) {
const file = path.posix.normalize(path.posix.join(path.posix.dirname(js[i]), specifier));
if (!js.includes(file) && existsSync(path.join(dist, file))) js.push(file);
}
}
return { js, css };
}
/**
* Relative specifiers a built chunk imports statically.
*
* Matches `import … from "./x.js"` and bare `import "./x.js"` in the
* chunk's leading import statements. A dynamic `import("./x.js")` is lazy
* and deliberately not matched.
*
* @param {string} code - The chunk's source.
* @returns {string[]} Relative specifiers such as `./react-abc.js`.
*/
function staticImports(code) {
// Rolldown hoists every static import to the top of a chunk, so read only
// that leading run of import statements: matching the whole source would
// also pick up import-shaped text inside strings and comments.
const statement =
/\s*(?:\/\*[\s\S]*?\*\/\s*|\/\/[^\n]*\n\s*)*import\s*(?:[\w$*{},\s]+?\s*from\s*)?["']([^"']+)["']\s*;?/y;
// Vite prepends a one-line `const __vite__mapDeps=…;` (the dynamic-import
// preload table) to chunks that use it, ahead of the imports.
statement.lastIndex = code.startsWith("const __vite__mapDeps=") ? code.indexOf("\n") + 1 : 0;
const specifiers = [];
for (let match = statement.exec(code); match; match = statement.exec(code)) {
if (/^\.{1,2}\//.test(match[1])) specifiers.push(match[1]);
}
return specifiers;
}
/**
* Every file under a directory, recursively.
*
* @param {string} dir - Absolute directory path.
* @returns {string[]} Absolute file paths.
*/
function listFiles(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return listFiles(full);
return entry.isFile() ? [full] : [];
});
}
/**
* Format a byte count for the Markdown table.
*
* @param {number} bytes - Byte count.
* @returns {string} e.g. `1.23 MB` or `456.7 kB`.
*/
function human(bytes) {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
return `${(bytes / 1024).toFixed(1)} kB`;
}
/**
* Sum the raw and gzip sizes of a list of measured files.
*
* @param {{raw: number, gzip: number}[]} files - Measured files.
* @returns {{raw: number, gzip: number}} The totals.
*/
function sum(files) {
return files.reduce((t, f) => ({ raw: t.raw + f.raw, gzip: t.gzip + f.gzip }), {
raw: 0,
gzip: 0,
});
}
/**
* Build the report data.
*
* @param {string} dist - Build output directory.
* @returns {object} The JSON report.
*/
function buildReport(dist) {
if (!existsSync(path.join(dist, "index.html"))) {
throw new Error(`No index.html in ${dist}. Run \`npm run build\` first.`);
}
const budget = JSON.parse(readFileSync(path.join(APP_DIR, "boot-budget.json"), "utf8"));
const assetsDir = path.join(dist, "assets");
const assets = listFiles(assetsDir)
.filter((full) => /\.(?:m?js|wasm)$/.test(full))
.map((full) => ({
file: path.relative(dist, full).split(path.sep).join("/"),
kind: full.endsWith(".wasm") ? "wasm" : "js",
...measure(full),
}))
.sort((a, b) => b.raw - a.raw || a.file.localeCompare(b.file));
const boot = bootSet(dist);
const byFile = new Map(assets.map((a) => [a.file, a]));
const bootJs = boot.js.map(
(file) => byFile.get(file) ?? { file, ...measure(path.join(dist, file)) },
);
const bootCss = boot.css.map((file) => ({ file, ...measure(path.join(dist, file)) }));
for (const asset of assets) asset.boot = boot.js.includes(asset.file);
const topLevel = readdirSync(dist, { withFileTypes: true })
.map((entry) => {
const full = path.join(dist, entry.name);
return {
path: entry.name,
bytes: entry.isDirectory() ? directoryBytes(full) : statSync(full).size,
};
})
.sort((a, b) => b.bytes - a.bytes);
return {
generatedAt: new Date().toISOString(),
dist: path.relative(ROOT, dist) || ".",
budget,
boot: {
js: { ...sum(bootJs), files: bootJs.map(({ file, raw, gzip }) => ({ file, raw, gzip })) },
css: { ...sum(bootCss), files: bootCss },
},
totals: {
dist: topLevel.reduce((t, e) => t + e.bytes, 0),
js: sum(assets.filter((a) => a.kind === "js")),
wasm: sum(assets.filter((a) => a.kind === "wasm")),
},
topLevel,
assets,
};
}
/**
* Render the report as Markdown (fits a GitHub step summary).
*
* @param {object} report - The JSON report.
* @param {number} top - How many of the largest assets to list.
* @returns {string} Markdown.
*/
function toMarkdown(report, top) {
const { boot, budget, totals } = report;
const pct = (value, limit) => `${((value / limit) * 100).toFixed(0)}%`;
const lines = [
"## Bundle report",
"",
`Build output \`${report.dist}\`: ${human(totals.dist)} on disk. ` +
`JS in assets/: ${human(totals.js.raw)} raw, ${human(totals.js.gzip)} gzip. ` +
`WASM in assets/: ${human(totals.wasm.raw)} raw, ${human(totals.wasm.gzip)} gzip.`,
"",
"### Boot set",
"",
"What index.html loads and modulepreloads before the shell mounts.",
"",
"| | Files | Raw | Gzip | Budget (raw / gzip) |",
"| --- | ---: | ---: | ---: | --- |",
`| JS | ${boot.js.files.length} | ${human(boot.js.raw)} | ${human(boot.js.gzip)} | ` +
`${human(budget.rawBytes)} (${pct(boot.js.raw, budget.rawBytes)}) / ` +
`${human(budget.gzipBytes)} (${pct(boot.js.gzip, budget.gzipBytes)}) |`,
`| CSS | ${boot.css.files.length} | ${human(boot.css.raw)} | ${human(boot.css.gzip)} | |`,
"",
"<details><summary>Boot JS files</summary>",
"",
"| File | Raw | Gzip |",
"| --- | ---: | ---: |",
...[...boot.js.files]
.sort((a, b) => b.raw - a.raw)
.map((f) => `| \`${f.file}\` | ${human(f.raw)} | ${human(f.gzip)} |`),
"",
"</details>",
"",
`### Largest JS/WASM assets (${Math.min(top, report.assets.length)} of ${report.assets.length})`,
"",
"| File | Raw | Gzip | Boot |",
"| --- | ---: | ---: | :---: |",
...report.assets
.slice(0, top)
.map(
(a) => `| \`${a.file}\` | ${human(a.raw)} | ${human(a.gzip)} | ${a.boot ? "yes" : ""} |`,
),
"",
"### Build output by top-level entry",
"",
"| Path | Size |",
"| --- | ---: |",
...report.topLevel.slice(0, 15).map((e) => `| \`${e.path}\` | ${human(e.bytes)} |`),
"",
];
return lines.join("\n");
}
const options = parseArgs(process.argv.slice(2));
const report = buildReport(options.dist);
mkdirSync(options.out, { recursive: true });
writeFileSync(path.join(options.out, "bundle-report.json"), `${JSON.stringify(report, null, 2)}\n`);
writeFileSync(path.join(options.out, "bundle-report.md"), toMarkdown(report, options.top));
console.log(
`Boot JS: ${human(report.boot.js.raw)} raw, ${human(report.boot.js.gzip)} gzip ` +
`(${report.boot.js.files.length} files). Assets: ${report.assets.length} JS/WASM files. ` +
`Report written to ${path.relative(ROOT, options.out) || "."}/bundle-report.{md,json}.`,
);