mirror of
https://github.com/opengeos/GeoLibre.git
synced 2026-11-04 10:43:36 +00:00
* 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.
331 lines
12 KiB
JavaScript
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}.`,
|
|
);
|