144 lines
4.8 KiB
JavaScript
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;
|
|
}
|