learning_ai_notes/web/scripts/analyze-build.mjs

144 lines
4.8 KiB
JavaScript

import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { gzipSync } from "node:zlib";
const root = process.cwd();
const nextDir = path.join(root, ".next");
const appManifestPath = path.join(nextDir, "app-build-manifest.json");
const appPathsManifestPath = path.join(nextDir, "server", "app-paths-manifest.json");
const buildManifestPath = path.join(nextDir, "build-manifest.json");
const routeLimitKb = Number(process.env.NOTELETT_ROUTE_GZIP_LIMIT_KB ?? "650");
const sharedLimitKb = Number(process.env.NOTELETT_SHARED_GZIP_LIMIT_KB ?? "1500");
if (!existsSync(appManifestPath) && !existsSync(appPathsManifestPath)) {
throw new Error("Missing .next/app-build-manifest.json. Run `pnpm --filter @notelett/web run build` first.");
}
const manifest = existsSync(appManifestPath)
? JSON.parse(readFileSync(appManifestPath, "utf8"))
: appPathManifest();
const pages = manifest.pages ?? {};
const buildManifest = existsSync(buildManifestPath)
? JSON.parse(readFileSync(buildManifestPath, "utf8"))
: {};
const sharedLayoutFiles = findStaticChunks("app/layout")
.concat(findStaticChunks("app/(app)/layout"), findStaticChunks("app/(auth)/layout"));
const routeRows = Object.entries(pages)
.map(([route, files]) => routeSummary(route, Array.isArray(files) ? files : []))
.sort((a, b) => b.gzipKb - a.gzipKb);
const sharedFiles = new Set([...(buildManifest.rootMainFiles ?? []), ...sharedLayoutFiles]);
for (const row of routeRows) {
for (const file of row.files) {
if (file.includes("static/chunks/") && !file.includes("app/")) {
sharedFiles.add(file);
}
}
}
const sharedGzipKb = roundKb([...sharedFiles].reduce((total, file) => total + gzipBytes(file), 0));
const oversizedRoutes = routeRows.filter((row) => row.gzipKb > routeLimitKb);
const oversizedShared = sharedGzipKb > sharedLimitKb;
const summary = {
generatedAt: new Date().toISOString(),
limits: {
routeGzipKb: routeLimitKb,
sharedGzipKb: sharedLimitKb,
},
shared: {
gzipKb: sharedGzipKb,
files: [...sharedFiles].sort(),
},
routes: routeRows,
};
const outDir = path.join(nextDir, "analyze");
mkdirSync(outDir, { recursive: true });
writeFileSync(path.join(outDir, "notelett-bundle-summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
console.log("NoteLett bundle summary (gzip KB)");
console.table(routeRows.slice(0, 20).map((row) => ({
route: row.route,
gzipKb: row.gzipKb,
jsKb: row.jsKb,
files: row.files.length,
})));
console.log(`Shared chunks: ${sharedGzipKb} KB gzip across ${sharedFiles.size} files`);
if (oversizedRoutes.length > 0 || oversizedShared) {
const routeText = oversizedRoutes.map((row) => `${row.route}: ${row.gzipKb} KB`).join(", ");
const sharedText = oversizedShared ? `shared chunks: ${sharedGzipKb} KB` : "";
throw new Error(`Bundle budget exceeded. ${[routeText, sharedText].filter(Boolean).join("; ")}`);
}
function routeSummary(route, files) {
const jsFiles = files.filter((file) => file.endsWith(".js"));
const jsBytes = jsFiles.reduce((total, file) => total + rawBytes(file), 0);
const gzBytes = jsFiles.reduce((total, file) => total + gzipBytes(file), 0);
return {
route,
files: jsFiles,
jsKb: roundKb(jsBytes),
gzipKb: roundKb(gzBytes),
};
}
function appPathManifest() {
const appPaths = JSON.parse(readFileSync(appPathsManifestPath, "utf8"));
const pages = {};
for (const [appRoute, serverFile] of Object.entries(appPaths)) {
const route = normalizeAppRoute(appRoute);
pages[route] = [
`server/${serverFile}`,
...findStaticChunks(String(serverFile).replace(/\.js$/, "")),
];
}
return { pages };
}
function normalizeAppRoute(appRoute) {
const route = appRoute
.replace(/\/page$/, "")
.replace(/^\/\((app|auth)\)/, "")
.replace(/^$/, "/");
return route === "" ? "/" : route;
}
function findStaticChunks(prefix) {
const chunkRoot = path.join(nextDir, "static", "chunks");
const relativePrefix = `static/chunks/${prefix}`;
return walk(chunkRoot)
.map((file) => path.relative(nextDir, file).replaceAll(path.sep, "/"))
.filter((file) => file.startsWith(relativePrefix) && file.endsWith(".js"));
}
function walk(dir) {
if (!existsSync(dir)) return [];
const entries = [];
for (const name of readdirSync(dir)) {
const full = path.join(dir, name);
if (statSync(full).isDirectory()) {
entries.push(...walk(full));
} else {
entries.push(full);
}
}
return entries;
}
function rawBytes(file) {
const fullPath = path.join(nextDir, file);
if (!existsSync(fullPath)) return 0;
return statSync(fullPath).size;
}
function gzipBytes(file) {
const fullPath = path.join(nextDir, file);
if (!existsSync(fullPath)) return 0;
return gzipSync(readFileSync(fullPath)).length;
}
function roundKb(bytes) {
return Math.round((bytes / 1024) * 10) / 10;
}