managarten/scripts/audit-bundle.mjs
Till JS 3b85d7d3d2 chore(bundle): add bundle-size audit + snapshot inventory
scripts/audit-bundle.mjs reads `.svelte-kit/output/client/_app/immutable`
after a prod build and reports:
  - Total size + category breakdown (entry / nodes / chunks / workers /
    assets).
  - Top N JS files with content heuristics (transformers.js, zxcvbn,
    tiptap, pdf-lib, swissqrbill, rrule, suncalc, Phosphor icon paths,
    Vite __vite__mapDeps metadata, etc).
  - Route mapping for `nodes/*.js` by parsing the server manifest's
    `leaf:` entries, so node 118 is identified as /(app)/invoices/[id].
  - ⚠ flag on chunks/ ≥ 200 KB (shared, potentially eager).

Current snapshot (docs/optimizable/bundle-analysis.md):
  entry   92 KB  |  nodes   2.77 MB  |  chunks   5.59 MB
  workers 22.3 MB (ONNX WASM, lazy)  |  total   31.8 MB

Already healthy:
  - 92 KB entry (no critical-path bloat).
  - 22 MB transformers.js WASM is worker-scoped — only fetched on first
    /llm-test or memoro voice use.
  - zxcvbn (1.25 MB combined dict + keyboard graphs) correctly behind a
    dynamic import in PasswordStrength.svelte.

Follow-up opportunities logged:
  1. /invoices/[id] = 534 KB — split swissqrbill + pdf-lib via dynamic
     import.
  2. @mana/shared-icons = 317 + 149 KB SVG path chunks — migrate to
     tree-shakable per-icon imports or lazy-load.
  3. Root (app) layout = 260 KB — check for module bleed into shared
     shell.

Report-only. Run with `pnpm run audit:bundle`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:52:08 +02:00

204 lines
7.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Audit the unified Mana web-app production bundle.
*
* Reads `.svelte-kit/output/client/_app/immutable/*` after `pnpm build`
* and reports size distribution by category + the biggest chunks, with
* hints about what's likely in each chunk.
*
* No extra deps: pure Node ESM walking the disk. Vite + SvelteKit have
* already done the code-splitting work by the time we read the output;
* this script just surfaces it.
*
* Usage:
* pnpm --filter @mana/web build # prerequisite
* node scripts/audit-bundle.mjs
* node scripts/audit-bundle.mjs --top 30 # show top N chunks
* node scripts/audit-bundle.mjs --summary # category totals only
*
* Bucketing:
* - entry : first JS a browser loads on cold visit (app + start).
* - nodes : per-route layout/page bundles (lazy by route).
* - chunks : shared code-split modules (loaded on demand by import).
* - workers : Web Worker bundles + their assets (incl. WASM).
* - assets : static assets (CSS, fonts, images).
*
* Flags: chunks > 200KB that aren't inside `workers/` or `nodes/` are
* worth inspecting — they're eagerly reachable from the shared runtime
* and contribute to every cold load's cost (unless behind a dynamic
* import, which appears as a separate chunk the browser only fetches
* when triggered).
*/
import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..');
const OUT = join(REPO_ROOT, 'apps/mana/apps/web/.svelte-kit/output/client/_app/immutable');
const SERVER_MANIFEST = join(REPO_ROOT, 'apps/mana/apps/web/.svelte-kit/output/server/manifest.js');
/** Parse `/(app)/invoices/[id]` route → node-index pairs from the server
* manifest so we can map bundle filenames back to SvelteKit routes. */
function loadRouteMap() {
const map = new Map(); // nodeIndex → routeId
if (!existsSync(SERVER_MANIFEST)) return map;
const src = readFileSync(SERVER_MANIFEST, 'utf8');
// Matches the routes block entries like:
// id: "/(app)/invoices/[id]",
// ...
// page: { ..., leaf: 118 }
const entryRe = /id:\s*"([^"]+)"[\s\S]*?leaf:\s*(\d+)/g;
let m;
while ((m = entryRe.exec(src)) !== null) {
map.set(Number(m[2]), m[1]);
}
return map;
}
const args = process.argv.slice(2);
const SUMMARY = args.includes('--summary');
const TOP_IDX = args.indexOf('--top');
const TOP_N = TOP_IDX >= 0 ? Number(args[TOP_IDX + 1] || 15) : 15;
function walk(dir, rel = '') {
const out = [];
for (const ent of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, ent.name);
const r = rel ? `${rel}/${ent.name}` : ent.name;
if (ent.isDirectory()) out.push(...walk(p, r));
else if (ent.isFile()) {
const size = statSync(p).size;
out.push({ abs: p, rel: r, size });
}
}
return out;
}
function classify(rel) {
if (rel.startsWith('entry/')) return 'entry';
if (rel.startsWith('nodes/')) return 'nodes';
if (rel.startsWith('workers/')) return 'workers';
if (rel.startsWith('assets/')) return 'assets';
if (rel.startsWith('chunks/')) return 'chunks';
return 'other';
}
function fmtKB(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
/** Heuristic hint at what a chunk contains, based on a longer read. */
function contentHint(abs) {
try {
const buf = readFileSync(abs, 'utf8').slice(0, 2000);
const pairs = [
[/@huggingface\/transformers|transformers\.|TransformersJS/, 'transformers.js'],
[/adjacency.?graphs?|azerty:\{0:\["/i, 'zxcvbn keyboard graphs'],
[/ich,sie,das,ist,du,nicht|dictionaryRaw|frequencyLists/, 'zxcvbn language dictionary'],
[/tiptap|ProseMirror|prosemirror/i, 'tiptap / prosemirror'],
[/pdfLib|PDFDocument|%PDF-/i, 'pdf-lib'],
[/swissqrbill|swissqr|IBAN|QR-IBAN/i, 'swissqrbill'],
[/RRule|\bBYSETPOS\b|FREQ=DAILY/, 'rrule'],
[/suncalc|solarTimes|sunrise/, 'suncalc'],
[/dexie|_dbSchema|IDBObjectStore/, 'dexie'],
[/marked|lexer\.Token|parseInline/, 'marked'],
[/\bpako_inflate\b|\bpako\.deflate\b|\bZLIB\b/, 'pako'],
[/date-fns|format\w+\(|parseISO/, 'date-fns'],
[/zod|ZodError|z\.object/, 'zod'],
[/svelte-dnd-action|dndzone/, 'svelte-dnd-action'],
[/svelte-i18n|addMessages|register\(/, 'svelte-i18n'],
[/wasm|WebAssembly/i, 'WASM module'],
// Phosphor icon sprites — the chunk literally contains `<path d="..."`.
[/<path d="M[^"]{40,}/, '@mana/shared-icons (SVG paths)'],
// Vite's dynamic-import dep graph — a large `__vite__mapDeps` is
// just import-map metadata, not real code.
[/^const __vite__mapDeps=\(i,m=__vite__mapDeps/, 'Vite __vite__mapDeps graph'],
];
for (const [re, label] of pairs) if (re.test(buf)) return label;
} catch {
// binary / unreadable
}
return '—';
}
function audit() {
if (!existsSync(OUT)) {
console.error('✗ No build output found. Run: pnpm --filter @mana/web build');
process.exit(2);
}
const files = walk(OUT);
const byCategory = new Map();
let total = 0;
for (const f of files) {
const cat = classify(f.rel);
byCategory.set(cat, (byCategory.get(cat) ?? 0) + f.size);
total += f.size;
}
console.log(`\n── Bundle audit ───────────────────────────────────────\n`);
console.log(`Total immutable output: ${fmtKB(total)} (${files.length} files)`);
console.log('');
console.log(`By category:`);
const catOrder = ['entry', 'nodes', 'chunks', 'workers', 'assets', 'other'];
for (const cat of catOrder) {
const bytes = byCategory.get(cat) ?? 0;
if (bytes === 0) continue;
const pct = ((bytes / total) * 100).toFixed(0).padStart(3);
console.log(` ${cat.padEnd(8)} ${fmtKB(bytes).padStart(10)} ${pct}%`);
}
console.log('');
if (SUMMARY) return;
const routeMap = loadRouteMap();
// Top N files by size, excluding assets (static = noise) and workers/assets (WASM).
const topFiles = files
.filter((f) => f.rel.endsWith('.js'))
.sort((a, b) => b.size - a.size)
.slice(0, TOP_N);
console.log(`Top ${topFiles.length} JS files by size (category + heuristic hint):\n`);
for (const f of topFiles) {
const cat = classify(f.rel);
let hint = '—';
if (cat === 'nodes') {
const nodeIdx = Number(f.rel.match(/nodes\/(\d+)\./)?.[1]);
const route = routeMap.get(nodeIdx);
hint = route ? `route ${route}` : 'route bundle';
} else if (cat !== 'assets') {
hint = contentHint(f.abs);
}
const flag = cat === 'chunks' && f.size > 200 * 1024 ? ' ⚠ eager?' : '';
console.log(
` ${fmtKB(f.size).padStart(9)} ${cat.padEnd(7)} ${hint.padEnd(36)} ${f.rel}${flag}`
);
}
console.log('');
const topAssets = files
.filter((f) => !f.rel.endsWith('.js') && !f.rel.endsWith('.css'))
.sort((a, b) => b.size - a.size)
.slice(0, 5);
if (topAssets.length > 0) {
console.log(`Largest non-JS/CSS assets:\n`);
for (const f of topAssets) {
console.log(` ${fmtKB(f.size).padStart(9)} ${f.rel}`);
}
console.log('');
}
console.log(
`Hint: chunks marked ⚠ are ≥200 KB and in the shared 'chunks/' dir. They may be\n` +
`reachable from the shared runtime (loaded eagerly on any cold visit) rather than\n` +
`behind a dynamic import. Inspect the producing module and wrap with await import()\n` +
`if appropriate. Chunks in nodes/ are route-scoped (lazy by route); workers/ are\n` +
`Web-Worker-scoped (lazy until the worker is instantiated).\n`
);
}
audit();