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>
This commit is contained in:
Till JS 2026-04-22 17:52:08 +02:00
parent 72a5995fa5
commit 3b85d7d3d2
6 changed files with 425 additions and 13 deletions

View file

@ -20,6 +20,7 @@ import { createManaLlmClient } from './llm-client';
import { runDueMissions, type MissionRunnerDeps } from './runner'; import { runDueMissions, type MissionRunnerDeps } from './runner';
import { registerDefaultInputResolvers } from './default-resolvers'; import { registerDefaultInputResolvers } from './default-resolvers';
import { runAgentsBootstrap } from '../agents/bootstrap'; import { runAgentsBootstrap } from '../agents/bootstrap';
import { onActiveSpaceChanged } from '../../scope/active-space.svelte';
/** /**
* Populate the seed-handler registry. Each import pulls the module's * Populate the seed-handler registry. Each import pulls the module's
@ -68,11 +69,21 @@ export function startMissionTick(intervalMs: number = DEFAULT_TICK_INTERVAL_MS):
// and the template applicator itself awaits the registry. // and the template applicator itself awaits the registry.
void ensureSeedsRegistered(); void ensureSeedsRegistered();
// Multi-Agent Workbench: ensure a default "Mana" agent exists and // Multi-Agent Workbench: ensure a default agent exists (space-aware
// backfill agentId on legacy missions. Fire-and-forget — the runner // since Phase 2d.3 — "Mana" in Personal, "Familien-Helfer" in
// itself tolerates missions without an agentId during the migration // Family, etc.) and backfill agentId on legacy missions.
// window. See docs/plans/multi-agent-workbench.md §Phase 2d. // Fire-and-forget — the runner itself tolerates missions without an
// agentId during the migration window. See
// docs/plans/multi-agent-workbench.md §Phase 2d and
// docs/plans/space-scoped-data-model.md §2d.4.
void runAgentsBootstrap(); void runAgentsBootstrap();
// And re-run when the active Space flips so every Space the user
// visits lands its own default agent the first time they land there.
// Replay-on-register also fires once immediately if the Space was
// already loaded before setup() ran.
onActiveSpaceChanged(() => {
void runAgentsBootstrap();
});
const tickOnce = async () => { const tickOnce = async () => {
// Guard against overlap — a slow LLM run could pile up multiple ticks. // Guard against overlap — a slow LLM run could pile up multiple ticks.

View file

@ -28,6 +28,48 @@ let active = $state<ActiveSpace | null>(null);
let status = $state<ActiveSpaceStatus>('idle'); let status = $state<ActiveSpaceStatus>('idle');
let lastError = $state<string | null>(null); let lastError = $state<string | null>(null);
// ─── Change-handler subscribers ───────────────────────────────────
//
// Other stores (workbench scenes, AI agents bootstrap, future Space-
// aware caches) register here and get notified whenever the active
// Space flips. Handlers are fire-and-forget: they can be async, but
// the space-switch flow does not wait for them. This keeps the
// primary path (user clicks a Space in the switcher) responsive, and
// lets each registered module own its own error handling.
//
// Newly-registered handlers are immediately replayed with the current
// active Space (if any) so they don't miss the first activation when
// the registration happens after loadActiveSpace already resolved.
export type ActiveSpaceChangedHandler = (space: ActiveSpace | null) => void | Promise<void>;
const handlers: ActiveSpaceChangedHandler[] = [];
export function onActiveSpaceChanged(h: ActiveSpaceChangedHandler): () => void {
handlers.push(h);
if (active && status === 'ready') {
try {
void h(active);
} catch (err) {
console.error('[active-space] handler replay failed:', err);
}
}
return () => {
const i = handlers.indexOf(h);
if (i >= 0) handlers.splice(i, 1);
};
}
function notifyHandlers(space: ActiveSpace | null): void {
for (const h of handlers) {
try {
void h(space);
} catch (err) {
console.error('[active-space] handler failed:', err);
}
}
}
export function getActiveSpace(): ActiveSpace | null { export function getActiveSpace(): ActiveSpace | null {
return active; return active;
} }
@ -45,9 +87,11 @@ export function getActiveSpaceError(): string | null {
} }
export function setActiveSpace(space: ActiveSpace | null): void { export function setActiveSpace(space: ActiveSpace | null): void {
const prevId = active?.id;
active = space; active = space;
status = space ? 'ready' : 'idle'; status = space ? 'ready' : 'idle';
lastError = null; lastError = null;
if (space?.id !== prevId) notifyHandlers(space);
} }
/** /**
@ -113,12 +157,14 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise<A
status = 'loading'; status = 'loading';
lastError = null; lastError = null;
const prevId = active?.id;
try { try {
const member = await fetchActiveMember(); const member = await fetchActiveMember();
if (member) { if (member) {
active = member; active = member;
status = 'ready'; status = 'ready';
writeActiveSpaceHint(member.id); writeActiveSpaceHint(member.id);
if (member.id !== prevId) notifyHandlers(member);
return member; return member;
} }
@ -140,6 +186,7 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise<A
active = { ...chosen, role: hinted ? hinted.role : 'owner' }; active = { ...chosen, role: hinted ? hinted.role : 'owner' };
status = 'ready'; status = 'ready';
writeActiveSpaceHint(chosen.id); writeActiveSpaceHint(chosen.id);
if (active.id !== prevId) notifyHandlers(active);
return active; return active;
} catch (err) { } catch (err) {
lastError = err instanceof Error ? err.message : String(err); lastError = err instanceof Error ? err.message : String(err);

View file

@ -22,12 +22,28 @@ import type {
WorkbenchSceneApp, WorkbenchSceneApp,
} from '$lib/types/workbench-scenes'; } from '$lib/types/workbench-scenes';
import { setSceneScopeTagIds } from './scene-scope.svelte'; import { setSceneScopeTagIds } from './scene-scope.svelte';
import { getActiveSpaceId, onActiveSpaceChanged } from '$lib/data/scope/active-space.svelte';
import { getInScopeSpaceIds } from '$lib/data/scope/scoped-db';
const TABLE = 'workbenchScenes'; const TABLE = 'workbenchScenes';
const ACTIVE_SCENE_LS_KEY = 'mana:workbench:activeSceneId';
const MRU_LS_KEY = 'mana:workbench:sceneMru'; // Per-Space localStorage keys. Each Space remembers its own last-active
// scene + MRU list on this device, so flipping between Spaces restores
// the right workbench without cross-pollution. A null spaceId (pre-
// bootstrap / guest) still works via the legacy bare keys so the first-
// paint path doesn't lose the active-scene hint during boot.
const ACTIVE_SCENE_LS_KEY_BASE = 'mana:workbench:activeSceneId';
const MRU_LS_KEY_BASE = 'mana:workbench:sceneMru';
const MRU_CAP = 5; const MRU_CAP = 5;
function activeSceneKey(spaceId: string | null): string {
return spaceId ? `${ACTIVE_SCENE_LS_KEY_BASE}:${spaceId}` : ACTIVE_SCENE_LS_KEY_BASE;
}
function mruKey(spaceId: string | null): string {
return spaceId ? `${MRU_LS_KEY_BASE}:${spaceId}` : MRU_LS_KEY_BASE;
}
const DEFAULT_HOME_APPS: WorkbenchSceneApp[] = [ const DEFAULT_HOME_APPS: WorkbenchSceneApp[] = [
{ appId: 'todo' }, { appId: 'todo' },
{ appId: 'calendar' }, { appId: 'calendar' },
@ -47,7 +63,7 @@ const MAX_SUBSCRIBE_RETRIES = 3;
function readActiveIdFromStorage(): string | null { function readActiveIdFromStorage(): string | null {
if (!browser) return null; if (!browser) return null;
try { try {
return localStorage.getItem(ACTIVE_SCENE_LS_KEY); return localStorage.getItem(activeSceneKey(getActiveSpaceId()));
} catch { } catch {
return null; return null;
} }
@ -56,8 +72,9 @@ function readActiveIdFromStorage(): string | null {
function writeActiveIdToStorage(id: string | null) { function writeActiveIdToStorage(id: string | null) {
if (!browser) return; if (!browser) return;
try { try {
if (id) localStorage.setItem(ACTIVE_SCENE_LS_KEY, id); const key = activeSceneKey(getActiveSpaceId());
else localStorage.removeItem(ACTIVE_SCENE_LS_KEY); if (id) localStorage.setItem(key, id);
else localStorage.removeItem(key);
} catch { } catch {
/* storage quota / disabled — ignore */ /* storage quota / disabled — ignore */
} }
@ -66,7 +83,7 @@ function writeActiveIdToStorage(id: string | null) {
function readMruFromStorage(): string[] { function readMruFromStorage(): string[] {
if (!browser) return []; if (!browser) return [];
try { try {
const raw = localStorage.getItem(MRU_LS_KEY); const raw = localStorage.getItem(mruKey(getActiveSpaceId()));
if (!raw) return []; if (!raw) return [];
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []; return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
@ -75,13 +92,13 @@ function readMruFromStorage(): string[] {
} }
} }
/** Push `id` to the front of the MRU list, dedup, cap. Per-device only. */ /** Push `id` to the front of the MRU list, dedup, cap. Per-device-per-Space. */
function bumpMru(id: string) { function bumpMru(id: string) {
if (!browser) return; if (!browser) return;
try { try {
const current = readMruFromStorage(); const current = readMruFromStorage();
const next = [id, ...current.filter((x) => x !== id)].slice(0, MRU_CAP); const next = [id, ...current.filter((x) => x !== id)].slice(0, MRU_CAP);
localStorage.setItem(MRU_LS_KEY, JSON.stringify(next)); localStorage.setItem(mruKey(getActiveSpaceId()), JSON.stringify(next));
} catch { } catch {
/* storage quota / disabled — ignore */ /* storage quota / disabled — ignore */
} }
@ -195,8 +212,18 @@ function openSubscription(): void {
subscription = liveQuery(() => db.table<LocalWorkbenchScene>(TABLE).toArray()).subscribe({ subscription = liveQuery(() => db.table<LocalWorkbenchScene>(TABLE).toArray()).subscribe({
next: (rows) => { next: (rows) => {
try { try {
// Filter by active Space before any UI sees the rows — scenes
// stamped against a different spaceId live in the same Dexie
// table but aren't part of this Space's workbench. Pre-boot
// (no active-space yet) returns an empty set; the replay
// after loadActiveSpace fires the right scenes.
const inScopeIds = getInScopeSpaceIds();
const visible = rows const visible = rows
.filter((r) => !r.deletedAt) .filter((r) => {
if (r.deletedAt) return false;
const spaceId = (r as { spaceId?: unknown }).spaceId;
return typeof spaceId === 'string' && inScopeIds.includes(spaceId);
})
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map(toScene); .map(toScene);
scenesState = visible; scenesState = visible;
@ -259,6 +286,10 @@ export const workbenchScenesStore = {
if (!browser || initializedState) return; if (!browser || initializedState) return;
// Seed a Home scene on first run so the UI never has zero scenes. // Seed a Home scene on first run so the UI never has zero scenes.
// We can't safely check "none in this Space" until the active-
// space handler replays — the guard is "no scenes anywhere", same
// as before. Per-Space seeding happens inside onSpaceChanged
// below.
const count = await db.table(TABLE).count(); const count = await db.table(TABLE).count();
if (count === 0) { if (count === 0) {
await ensureSeedScene(); await ensureSeedScene();
@ -266,6 +297,33 @@ export const workbenchScenesStore = {
activeSceneIdState = readActiveIdFromStorage(); activeSceneIdState = readActiveIdFromStorage();
openSubscription(); openSubscription();
// Register a handler that refreshes per-Space state whenever the
// active Space flips. Replay-on-register fires once immediately if
// a Space is already loaded, so the initial state is correct
// regardless of which store finishes initializing first.
onActiveSpaceChanged(async (space) => {
// Update activeSceneIdState from the new Space's LS key. The
// liveQuery is already re-running because getInScopeSpaceIds()
// returns a different set, but the local activeSceneIdState
// needs an explicit re-read.
activeSceneIdState = readActiveIdFromStorage();
// Seed a default scene for this Space if none exists yet. Runs
// on the first visit to every Shared/Brand/Family/Team Space a
// user joins, so the workbench never shows empty.
if (space) {
const anyInSpace = await db
.table<LocalWorkbenchScene>(TABLE)
.filter((r) => {
if (r.deletedAt) return false;
const spaceId = (r as { spaceId?: unknown }).spaceId;
return spaceId === space.id;
})
.first();
if (!anyInSpace) await ensureSeedScene();
}
});
}, },
dispose() { dispose() {

View file

@ -0,0 +1,91 @@
# Bundle Analysis
**Snapshot 2026-04-22.** Run `pnpm run audit:bundle` after any `pnpm --filter @mana/web build` for live numbers.
## Snapshot
| Category | Size | % | Notes |
|---|---|---|---|
| entry | 92 KB | 0% | app.js + start.js — first JS a cold browser loads |
| nodes | 2.77 MB | 9% | per-route layout/page bundles (230 files) |
| chunks | 5.59 MB | 18% | shared code-split modules (711 files) |
| workers | 22.31 MB | 70% | transformers.js ONNX WASM — lazy until LLM/STT opened |
| assets | 1.04 MB | 3% | CSS, fonts, images |
| **total** | **31.80 MB** | | 1147 files |
Entry is tiny (92 KB). That's healthy. The 22 MB in `workers/` is
`ort-wasm-simd-threaded.asyncify-*.wasm` — the ONNX Runtime for
transformers.js. It's only fetched when the browser actually
instantiates the Web Worker (first use of `@mana/local-llm` or
`@mana/local-stt`). Most users never hit it.
## Biggest shared chunks
Top entries in `chunks/` ≥ 200 KB, with what's actually inside:
| Size | File | Content | Verdict |
|---|---|---|---|
| 797 KB | SDMVbHi1 | `@zxcvbn-ts/language-de` German dictionary | ✓ **lazy** — dynamic import in `PasswordStrength.svelte`, only on register / recovery |
| 454 KB | bdamX4EN | `@zxcvbn-ts/language-common` keyboard adjacency graphs | ✓ **lazy** — same import path |
| 317 KB | DtX-t1si | `@mana/shared-icons` (Phosphor SVG paths) | ⚠ **partly eager** — imported by many routes; see notes |
| 220 KB | BbeX9yAb | Vite `__vite__mapDeps` import-graph | ✓ **metadata only**, not real code |
| 162 KB | Bqmpszdn | (unknown) | below threshold |
## Biggest route bundles
Routes loaded per navigation (not eagerly):
| Size | Route | Note |
|---|---|---|
| 534 KB | `/(app)/invoices/[id]` | **heaviest route.** Likely swissqrbill + pdf-lib. Investigate — could split the QR-bill generator behind `await import()` so preview/list pages stay small. |
| 380 KB | `/(app)/broadcasts/[id]/edit` | Tiptap editor — unavoidable, tiptap is ~250 KB baseline |
| 260 KB | node 2 (root `(app)` layout) | All app-wide chrome: shell, stores, auth guard |
| 95 KB | `/(app)/calendar` | Acceptable; rrule is shared |
| 85 KB | `/(app)/todo` | Acceptable |
## Priority improvements
1. **`/invoices/[id]` code-split** — 534 KB is large. `swissqrbill`
and `pdf-lib` are probably both eagerly imported. Wrap the PDF/QR
generation path with `await import('swissqrbill')` so the route
bundle drops to ~150 KB for the list/display case. Real win for
Swiss-bill users on 3G / slow laptops.
2. **`@mana/shared-icons` chunking** — 317 + 149 KB of Phosphor SVG
paths across two chunks. Phosphor doesn't tree-shake well because
its icons are named exports of a single module. Either:
- migrate to tree-shakable per-icon imports, OR
- lazy-load rarely-used icons in the module that imports them
instead of the shared chunk.
3. **Root `(app)` layout (260 KB)** — on the high side for "just the
shell". Investigate whether any module-specific stores / AI pipeline
bits are leaking into the shared layout when they could be
route-scoped.
## What's already good
- Entry bundle is 92 KB — no bloat in the critical path.
- 22 MB of WASM (transformers.js) is correctly behind Web Workers —
cold visitors don't pay for it.
- zxcvbn (1.25 MB German dict + keyboard graphs) is correctly behind a
dynamic import in PasswordStrength.svelte — only register / recovery
/ password-change surfaces load it.
- Route-level code-splitting is working: 230 separate route bundles,
median ~12 KB.
## Usage
```bash
pnpm --filter @mana/web build # prerequisite
pnpm run audit:bundle # full report
pnpm run audit:bundle --top 30 # show top N chunks
pnpm run audit:bundle --summary # category totals only
```
Heuristic rules: chunks in `chunks/` with ≥ 200 KB get a ⚠ flag.
Route bundles (`nodes/`) and worker bundles (`workers/`) are
always-lazy and don't get flagged. The content-hint regex knows about
transformers.js, zxcvbn, tiptap, pdf-lib, swissqrbill, rrule, suncalc,
dexie, marked, pako, date-fns, zod, svelte-dnd-action, svelte-i18n,
WASM, Phosphor icons, and Vite's own mapDeps metadata.

View file

@ -35,6 +35,7 @@
"audit:i18n-coverage": "node scripts/audit-i18n-coverage.mjs", "audit:i18n-coverage": "node scripts/audit-i18n-coverage.mjs",
"audit:port-drift": "node scripts/audit-port-drift.mjs", "audit:port-drift": "node scripts/audit-port-drift.mjs",
"audit:test-coverage": "node scripts/audit-test-coverage.mjs", "audit:test-coverage": "node scripts/audit-test-coverage.mjs",
"audit:bundle": "node scripts/audit-bundle.mjs",
"audit:all": "pnpm run audit:port-drift && pnpm run audit:i18n-coverage --summary && pnpm run audit:test-coverage --summary", "audit:all": "pnpm run audit:port-drift && pnpm run audit:i18n-coverage --summary && pnpm run audit:test-coverage --summary",
"generate:dockerfiles": "node scripts/generate-dockerfiles.mjs", "generate:dockerfiles": "node scripts/generate-dockerfiles.mjs",
"setup:env": "node scripts/generate-env.mjs", "setup:env": "node scripts/generate-env.mjs",

204
scripts/audit-bundle.mjs Normal file
View file

@ -0,0 +1,204 @@
#!/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();