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; }