Files
cobble/src/scripts/check-bundle-size.ts
T
tben 4f7e0a6bf0 Finish the mobile performance pass
- Cache the model catalog once per user and each conversation's setting
  separately; clear both on logout and after model saves
- Compress hashed assets asynchronously with a byte budget and set Vary
- Memoize message parsing and throttle selection reads
- Debounce draft writes to localStorage
- Keep the composer editor on the page and focused, as before
- Add a bundle budget check and run it in CI
- Show a loading block while sessions load and a workspace avatar in the
  mobile conversation header
2026-10-20 14:37:11 +01:00

143 lines
4.2 KiB
TypeScript

import { existsSync, readFileSync, readdirSync, statSync } from "fs";
import path from "path";
import { gzipSync } from "node:zlib";
/**
* Fails when the conversation page's initial JavaScript (or CSS) grows past
* its budget, or when the full Lucide icon set comes back. Run after
* `NODE_ENV=production bunx bunext build`.
*
* The editor is part of the page by design, so the budget allows Tiptap;
* the Lucide guard is what keeps the old icon bundle from returning.
*/
const MaxInitialJsBytes = 450 * 1024;
const MaxSingleFileBytes = 300 * 1024;
const MaxCssBytes = 40 * 1024;
const pages_dir = path.resolve(".bunext/public/pages");
const conversation_dir = path.join(pages_dir, "admin/bots/[bot_id]");
if (!existsSync(conversation_dir)) {
console.error(
`No conversation build at ${path.relative(process.cwd(), conversation_dir)}. Run \`NODE_ENV=production bunx bunext build\` first.`,
);
process.exit(1);
}
const entry = readdirSync(conversation_dir)
.filter((name) => name.endsWith(".js"))
.map((name) => path.join(conversation_dir, name))
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
if (!entry) {
console.error(`No page script in ${conversation_dir}.`);
process.exit(1);
}
const sizes = new Map<string, number>();
const gzipSize = (file: string) => {
const cached = sizes.get(file);
if (cached !== undefined) return cached;
const size = gzipSync(readFileSync(file)).byteLength;
sizes.set(file, size);
return size;
};
const static_files = new Set<string>();
const queue = [entry];
while (queue.length) {
const file = queue.pop()!;
if (static_files.has(file)) continue;
static_files.add(file);
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)) {
const target = resolve({ file, specifier: match[1]! });
if (target) queue.push(target);
}
}
const initial_bytes = sumBytes([...static_files]);
const largest = [...static_files].sort((a, b) => gzipSize(b) - gzipSize(a))[0];
const largest_bytes = largest ? gzipSize(largest) : 0;
const css_bytes = readdirSync(conversation_dir)
.filter((name) => name.endsWith(".css"))
.reduce((total, name) => total + gzipSize(path.join(conversation_dir, name)), 0);
/**
* Every icon imported is listed in the module comments. A handful is
* normal; the whole catalog (about 1,900) is the regression this catches.
*/
const lucide_icons = new Set<string>();
for (const file of static_files) {
for (const match of readFileSync(file, "utf8").matchAll(
/lucide-react\/dist\/esm\/icons\/([a-z0-9-]+)\.mjs/g,
)) {
lucide_icons.add(match[1]!);
}
}
report({ label: "initial JS", bytes: initial_bytes, budget: MaxInitialJsBytes });
report({ label: "largest file", bytes: largest_bytes, budget: MaxSingleFileBytes });
report({ label: "CSS", bytes: css_bytes, budget: MaxCssBytes });
let failed = false;
failed = check({
label: "initial JS",
bytes: initial_bytes,
budget: MaxInitialJsBytes,
}) || failed;
failed = check({
label: "largest file",
bytes: largest_bytes,
budget: MaxSingleFileBytes,
}) || failed;
failed = check({ label: "CSS", bytes: css_bytes, budget: MaxCssBytes }) || failed;
if (lucide_icons.size > 200) {
console.error(`The full Lucide icon set is in the page bundle (${lucide_icons.size} icons).`);
failed = true;
}
if (failed) process.exit(1);
console.log("Bundle budget holds.");
function resolve({ file, specifier }: { file: string; specifier: string }) {
if (!specifier.startsWith(".")) return null;
const target = path.resolve(path.dirname(file), specifier);
return existsSync(target) ? target : null;
}
function sumBytes(files: string[]) {
return files.reduce((total, file) => total + gzipSize(file), 0);
}
function report({ label, bytes, budget }: { label: string; bytes: number; budget: number }) {
console.log(`${label}: ${kb(bytes)} (budget ${kb(budget)})`);
}
function check({ label, bytes, budget }: { label: string; bytes: number; budget: number }) {
if (bytes <= budget) return false;
console.error(`${label} is over budget: ${kb(bytes)} > ${kb(budget)}.`);
return true;
}
function kb(bytes: number) {
return `${Math.round(bytes / 1024)} KB`;
}