mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-02 12:05:39 +01:00
7ff4cf58e8
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
156 lines
5.6 KiB
JavaScript
156 lines
5.6 KiB
JavaScript
/* global require, process, __dirname */
|
|
// Enforce a strict size budget on the initial JS of the most critical
|
|
// entrypoints (`app` and `core`). These two are downloaded on every cold load
|
|
// before anything interactive can happen, so unintended growth here hurts
|
|
// first-load performance directly.
|
|
//
|
|
// In production rspack does not split initial chunks (splitChunks only operates
|
|
// on `!chunk.canBeInitial()`), so each entrypoint resolves to a single initial
|
|
// JS asset. We read the per-build stats written by StatsWriterPlugin and compare
|
|
// the entrypoint's initial JS size against a committed budget.
|
|
//
|
|
// Usage:
|
|
// node build-scripts/check-bundle-size.cjs # enforce, exit 1 on regression
|
|
// node build-scripts/check-bundle-size.cjs --update # rewrite budgets from current sizes
|
|
// node build-scripts/check-bundle-size.cjs --update --headroom=3 # current + 3% headroom
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const paths = require("./paths.cjs");
|
|
|
|
// Entrypoints whose initial JS we hold to a strict budget. These are all
|
|
// downloaded on a user-facing cold load before anything interactive can happen:
|
|
// `app`/`core` for the main app, plus the standalone `authorize` and
|
|
// `onboarding` pages. `custom-panel` is intentionally excluded (only loaded
|
|
// when a custom panel is opened).
|
|
const TRACKED_ENTRYPOINTS = ["app", "core", "authorize", "onboarding"];
|
|
|
|
// App build stats files, as written by StatsWriterPlugin (`${name}.json`).
|
|
const BUILDS = ["frontend-modern", "frontend-legacy"];
|
|
|
|
const BUDGET_FILE = path.join(__dirname, "bundle-budget.json");
|
|
const STATS_DIR = path.join(paths.build_dir, "stats");
|
|
|
|
const readStats = (build) => {
|
|
const file = path.join(STATS_DIR, `${build}.json`);
|
|
if (!fs.existsSync(file)) {
|
|
throw new Error(
|
|
`Missing stats file: ${path.relative(process.cwd(), file)}.\n` +
|
|
`Run a production build first (e.g. \`gulp build-app\`), then re-run this check.`
|
|
);
|
|
}
|
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
};
|
|
|
|
// Initial JS bytes for an entrypoint = sum of the .js asset sizes of its initial
|
|
// entry chunk(s). Sizes are raw (uncompressed) bytes, matching the stats output.
|
|
const entrypointInitialJS = (stats, entrypoint) => {
|
|
const assetSize = new Map(stats.assets.map((a) => [a.name, a.size]));
|
|
let total = 0;
|
|
let found = false;
|
|
for (const chunk of stats.chunks) {
|
|
if (!chunk.entry || !chunk.initial) {
|
|
continue;
|
|
}
|
|
if (!(chunk.names || []).includes(entrypoint)) {
|
|
continue;
|
|
}
|
|
found = true;
|
|
for (const file of chunk.files || []) {
|
|
if (file.endsWith(".js") && assetSize.has(file)) {
|
|
total += assetSize.get(file);
|
|
}
|
|
}
|
|
}
|
|
if (!found) {
|
|
throw new Error(`Entrypoint "${entrypoint}" not found in bundle stats.`);
|
|
}
|
|
return total;
|
|
};
|
|
|
|
const kib = (bytes) => `${(bytes / 1024).toFixed(1)} KiB`;
|
|
|
|
const main = () => {
|
|
const update = process.argv.includes("--update");
|
|
const headroomArg = process.argv.find((a) => a.startsWith("--headroom="));
|
|
const headroom = headroomArg ? Number(headroomArg.split("=")[1]) : 0;
|
|
|
|
const current = {};
|
|
for (const build of BUILDS) {
|
|
const stats = readStats(build);
|
|
current[build] = {};
|
|
for (const entrypoint of TRACKED_ENTRYPOINTS) {
|
|
current[build][entrypoint] = entrypointInitialJS(stats, entrypoint);
|
|
}
|
|
}
|
|
|
|
if (update) {
|
|
const budget = { _comment: BUDGET_COMMENT };
|
|
for (const build of BUILDS) {
|
|
budget[build] = {};
|
|
for (const entrypoint of TRACKED_ENTRYPOINTS) {
|
|
budget[build][entrypoint] = Math.ceil(
|
|
current[build][entrypoint] * (1 + headroom / 100)
|
|
);
|
|
}
|
|
}
|
|
fs.writeFileSync(BUDGET_FILE, `${JSON.stringify(budget, null, 2)}\n`);
|
|
console.log(
|
|
`Updated ${path.relative(process.cwd(), BUDGET_FILE)} from current sizes` +
|
|
(headroom ? ` (+${headroom}% headroom).` : ".")
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!fs.existsSync(BUDGET_FILE)) {
|
|
throw new Error(
|
|
`Missing budget file ${path.relative(process.cwd(), BUDGET_FILE)}.\n` +
|
|
`Seed it from a production build with: node build-scripts/check-bundle-size.cjs --update --headroom=3`
|
|
);
|
|
}
|
|
const budget = JSON.parse(fs.readFileSync(BUDGET_FILE, "utf8"));
|
|
|
|
let failed = false;
|
|
console.log("Initial JS budget (entry chunks, raw bytes):\n");
|
|
for (const build of BUILDS) {
|
|
for (const entrypoint of TRACKED_ENTRYPOINTS) {
|
|
const actual = current[build][entrypoint];
|
|
const limit = budget[build] && budget[build][entrypoint];
|
|
if (typeof limit !== "number") {
|
|
failed = true;
|
|
console.log(
|
|
` ✗ ${build} / ${entrypoint}: no budget set (current ${kib(actual)})`
|
|
);
|
|
continue;
|
|
}
|
|
const ok = actual <= limit;
|
|
const delta = (((actual - limit) / limit) * 100).toFixed(1);
|
|
console.log(
|
|
` ${ok ? "✓" : "✗"} ${build} / ${entrypoint}: ` +
|
|
`${kib(actual)} / ${kib(limit)}${ok ? "" : ` (+${delta}% over budget)`}`
|
|
);
|
|
if (!ok) {
|
|
failed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (failed) {
|
|
console.error(
|
|
"\nInitial JS budget exceeded for a critical entrypoint.\n" +
|
|
"Investigate what was pulled into the entry chunk (a static import that should be lazy?).\n" +
|
|
"If the growth is intentional, re-seed the budget:\n" +
|
|
" node build-scripts/check-bundle-size.cjs --update --headroom=3"
|
|
);
|
|
process.exit(1);
|
|
}
|
|
console.log("\nAll tracked entrypoints within budget.");
|
|
};
|
|
|
|
const BUDGET_COMMENT =
|
|
"Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. " +
|
|
"Enforced by build-scripts/check-bundle-size.cjs in CI. " +
|
|
"Re-seed after an intentional change with `--update --headroom=<percent>`.";
|
|
|
|
main();
|