ci: report per-route JavaScript budgets from Docker build
CI / check (push) Failing after 54s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-11 08:41:31 +02:00
1 parent 445ef13846
commit cc714b427a
9 files changed
+744 -1

No files matched your search

+338
View File
@@ -0,0 +1,338 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
const LIMIT_KEYS = ["route", "maxInitialRawBytes", "maxInitialGzipBytes"];
export function validateBudgets(config) {
if (
config?.schemaVersion !== 1 ||
config.mode !== "informational" ||
!Array.isArray(config.routes) ||
config.routes.length === 0
)
throw new Error(
"Budget config requires schemaVersion 1, mode informational and nonempty routes.",
);
if (
Object.keys(config).some(
(key) => !["schemaVersion", "mode", "routes"].includes(key),
)
)
throw new Error("Unknown budget config field.");
const seen = new Set();
for (const entry of config.routes) {
if (
!entry ||
typeof entry.route !== "string" ||
!/^\/(?:[^?#\s]*)$/.test(entry.route) ||
seen.has(entry.route) ||
Object.keys(entry).some((key) => !LIMIT_KEYS.includes(key))
)
throw new Error("Invalid or duplicate budget route.");
seen.add(entry.route);
for (const key of LIMIT_KEYS.slice(1))
if (
entry[key] !== null &&
(!Number.isSafeInteger(entry[key]) || entry[key] <= 0)
)
throw new Error(
`Invalid ${key} for ${entry.route}: use positive integer bytes or null.`,
);
}
return config;
}
/** Parse Next's generated JSON assignment, never execute the manifest as JavaScript. */
export function parseClientManifest(source, appPath) {
const assignment =
/globalThis\.__RSC_MANIFEST\[("(?:[^"\\]|\\.)*")\]\s*=\s*/g;
const match = assignment.exec(source);
if (!match || JSON.parse(match[1]) !== appPath)
throw new Error("Unsupported client-reference manifest assignment.");
const value = source
.slice(match.index + match[0].length)
.trim()
.replace(/;$/, "");
return JSON.parse(value);
}
export function normalizeAsset(value) {
if (typeof value !== "string")
throw new Error("Non-string JavaScript asset.");
const clean = value.split(/[?#]/, 1)[0].replace(/^\/_next\//, "");
if (
!clean.startsWith("static/") ||
clean.includes("\\") ||
clean
.split("/")
.some((part) => part === ".." || part === "." || part === "") ||
!clean.endsWith(".js")
)
throw new Error(`Unsupported JavaScript asset: ${value}`);
return clean;
}
function filesFrom(values) {
if (!Array.isArray(values))
throw new Error("Unsupported JavaScript chunk list.");
return values.map(normalizeAsset);
}
export function measureRoute({
budget,
appPath,
buildManifest,
clientManifest,
readAsset,
}) {
const entries = clientManifest?.entryJSFiles;
if (!entries || typeof entries !== "object" || Array.isArray(entries))
throw new Error(
"entryJSFiles is unavailable; this manifest layout is not supported.",
);
const sourceEntries = Object.keys(entries);
if (
!sourceEntries.some((key) =>
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
)
)
throw new Error("Route page entry is absent from entryJSFiles.");
const bootstrap = filesFrom(
buildManifest.rootMainFilesTree?.[appPath] ?? buildManifest.rootMainFiles,
);
if (bootstrap.length === 0)
throw new Error("Initial bootstrap chunks are unavailable.");
const origins = new Map();
const add = (file, origin) => {
const sources = origins.get(file) ?? new Set();
sources.add(origin);
origins.set(file, sources);
};
for (const file of bootstrap) add(file, "bootstrap");
for (const [entry, values] of Object.entries(entries))
for (const file of filesFrom(values)) add(file, entry);
const size = (file, sources) => {
const bytes = readAsset(file);
return {
path: file,
rawBytes: bytes.length,
gzipBytes: gzipSync(bytes, { level: 9 }).length,
sources: [...sources].sort(),
};
};
const chunks = [...origins]
.sort(([a], [b]) => a.localeCompare(b))
.map(([file, sources]) => size(file, sources));
const polyfills = [...new Set(filesFrom(buildManifest.polyfillFiles ?? []))]
.filter((file) => !origins.has(file) && !file.endsWith(".module.js"))
.sort()
.map((file) => size(file, ["legacy-nomodule-polyfill"]));
const rawBytes = chunks.reduce((total, file) => total + file.rawBytes, 0);
const gzipBytes = chunks.reduce((total, file) => total + file.gzipBytes, 0);
const exceeded = [];
if (
budget.maxInitialRawBytes !== null &&
rawBytes > budget.maxInitialRawBytes
)
exceeded.push("raw");
if (
budget.maxInitialGzipBytes !== null &&
gzipBytes > budget.maxInitialGzipBytes
)
exceeded.push("gzip");
return {
route: budget.route,
appPath,
status: exceeded.length ? "over-budget" : "measured",
initial: { chunkCount: chunks.length, rawBytes, gzipBytes, chunks },
legacyPolyfills: {
rawBytes: polyfills.reduce((n, f) => n + f.rawBytes, 0),
gzipBytes: polyfills.reduce((n, f) => n + f.gzipBytes, 0),
chunks: polyfills,
},
budget: {
maxInitialRawBytes: budget.maxInitialRawBytes,
maxInitialGzipBytes: budget.maxInitialGzipBytes,
},
exceeded,
};
}
function jsonFile(file) {
return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
}
function within(root, relative) {
const resolved = path.resolve(root, relative);
if (!resolved.startsWith(path.resolve(root) + path.sep))
throw new Error("Manifest path escapes the build directory.");
return resolved;
}
export function collectReport(nextDir, config, metadata = {}) {
validateBudgets(config);
let routeMap, appMap, manifestError;
let buildId = null;
try {
buildId = readFileSync(path.join(nextDir, "BUILD_ID"), "utf8").trim();
if (!buildId)
throw new Error("A completed production BUILD_ID is required.");
routeMap = jsonFile(path.join(nextDir, "app-path-routes-manifest.json"));
appMap = jsonFile(path.join(nextDir, "server/app-paths-manifest.json"));
} catch (error) {
manifestError = error.message;
}
const routes = config.routes.map((budget) => {
try {
if (manifestError) throw new Error(manifestError);
const matches = Object.entries(routeMap)
.filter(([, route]) => route === budget.route)
.map(([appPath]) => appPath);
if (matches.length !== 1)
throw new Error(
`Expected one build entry for ${budget.route}, found ${matches.length}.`,
);
const appPath = matches[0],
serverEntry = appMap[appPath];
if (typeof serverEntry !== "string" || !serverEntry.endsWith("/page.js"))
throw new Error("Unsupported server app entry.");
const clientFile = within(
path.join(nextDir, "server"),
serverEntry.replace(/\.js$/, "_client-reference-manifest.js"),
);
const clientManifest = parseClientManifest(
readFileSync(clientFile, "utf8"),
appPath,
);
let buildManifest,
buildManifestSource = `server/${serverEntry.replace(/\.js$/, "/build-manifest.json")}`;
try {
buildManifest = jsonFile(within(nextDir, buildManifestSource));
} catch (error) {
if (error.code !== "ENOENT") throw error;
buildManifestSource = "build-manifest.json";
buildManifest = jsonFile(path.join(nextDir, buildManifestSource));
}
return {
...measureRoute({
budget,
appPath,
buildManifest,
clientManifest,
readAsset: (file) => readFileSync(within(nextDir, file)),
}),
manifests: {
client: path.relative(nextDir, clientFile).split(path.sep).join("/"),
build: buildManifestSource,
},
};
} catch (error) {
return {
route: budget.route,
status: "unavailable",
reason: error.message,
initial: null,
budget: {
maxInitialRawBytes: budget.maxInitialRawBytes,
maxInitialGzipBytes: budget.maxInitialGzipBytes,
},
};
}
});
return {
schemaVersion: 1,
mode: "informational",
generatedAt: new Date().toISOString(),
buildId,
commitSha: metadata.commitSha ?? null,
commitProvenance:
"Optional PERFORMANCE_COMMIT_SHA supplied by the build caller; not inferred from current checkout.",
nodeVersion: process.version,
zlibVersion: process.versions.zlib,
manifestFormat: "Next App Router client-reference entryJSFiles",
definition:
"Initial entry envelope: deduplicated rootMainFiles bootstrap plus all entryJSFiles in this route's client-reference manifest, including boundary/loading entries. This is emitted file size, not measured browser traffic or a load-time benchmark.",
gzip: "Sum of each unique JavaScript file independently compressed with Node gzip level 9. Excludes HTTP headers and shared-cache reuse.",
excluded:
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from entryJSFiles; legacy nomodule polyfills are reported separately.",
routes,
};
}
export function markdownReport(report) {
const lines = [
"# Route JavaScript size report",
"",
report.definition,
"",
report.gzip,
"",
`Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: informational; exceeded budgets and unavailable metrics do not fail CI.`,
"",
"| Route | State | Unique JS | Raw bytes | Gzip bytes | Raw budget | Gzip budget |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: |",
];
for (const row of report.routes)
lines.push(
`| ${row.route} | ${row.status} | ${row.initial?.chunkCount ?? "—"} | ${row.initial?.rawBytes ?? "—"} | ${row.initial?.gzipBytes ?? "—"} | ${row.budget.maxInitialRawBytes ?? "observe"} | ${row.budget.maxInitialGzipBytes ?? "observe"} |`,
);
lines.push("", report.excluded, "");
for (const row of report.routes)
if (row.reason)
lines.push(
`- ${row.route}: unavailable — ${row.reason.replaceAll("\n", " ")}`,
);
lines.push(
"",
"Exact file lists, provenance and legacy polyfill sizes are in report.json.",
"",
);
return lines.join("\n");
}
export function main(argv = process.argv.slice(2)) {
const options = {
"--next-dir": ".next",
"--config": "scripts/performance-budgets.json",
"--output-dir": ".next/performance-report",
};
for (let i = 0; i < argv.length; i += 2) {
if (!Object.hasOwn(options, argv[i]) || !argv[i + 1])
throw new Error(
"Usage: node scripts/performance-report.mjs [--next-dir DIR] [--config FILE] [--output-dir DIR]",
);
options[argv[i]] = argv[i + 1];
}
const config = validateBudgets(jsonFile(options["--config"]));
const report = collectReport(options["--next-dir"], config, {
commitSha: process.env.PERFORMANCE_COMMIT_SHA || null,
});
mkdirSync(options["--output-dir"], { recursive: true });
writeFileSync(
path.join(options["--output-dir"], "report.json"),
`${JSON.stringify(report, null, 2)}\n`,
);
writeFileSync(
path.join(options["--output-dir"], "report.md"),
markdownReport(report),
);
console.log(markdownReport(report));
for (const row of report.routes)
if (row.status !== "measured")
console.warn(
`Performance warning: ${row.route} ${row.status}${row.reason ? `: ${row.reason}` : ""}`,
);
return report;
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
main();
} catch (error) {
console.error(
`Performance report configuration/output error: ${error.message}`,
);
process.exitCode = 1;
}
}