mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:01:10 +02:00
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:
parent
72a5995fa5
commit
3b85d7d3d2
6 changed files with 425 additions and 13 deletions
|
|
@ -20,6 +20,7 @@ import { createManaLlmClient } from './llm-client';
|
|||
import { runDueMissions, type MissionRunnerDeps } from './runner';
|
||||
import { registerDefaultInputResolvers } from './default-resolvers';
|
||||
import { runAgentsBootstrap } from '../agents/bootstrap';
|
||||
import { onActiveSpaceChanged } from '../../scope/active-space.svelte';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
void ensureSeedsRegistered();
|
||||
|
||||
// Multi-Agent Workbench: ensure a default "Mana" agent exists and
|
||||
// backfill agentId on legacy missions. Fire-and-forget — the runner
|
||||
// itself tolerates missions without an agentId during the migration
|
||||
// window. See docs/plans/multi-agent-workbench.md §Phase 2d.
|
||||
// Multi-Agent Workbench: ensure a default agent exists (space-aware
|
||||
// since Phase 2d.3 — "Mana" in Personal, "Familien-Helfer" in
|
||||
// Family, etc.) and backfill agentId on legacy missions.
|
||||
// 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();
|
||||
// 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 () => {
|
||||
// Guard against overlap — a slow LLM run could pile up multiple ticks.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,48 @@ let active = $state<ActiveSpace | null>(null);
|
|||
let status = $state<ActiveSpaceStatus>('idle');
|
||||
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 {
|
||||
return active;
|
||||
}
|
||||
|
|
@ -45,9 +87,11 @@ export function getActiveSpaceError(): string | null {
|
|||
}
|
||||
|
||||
export function setActiveSpace(space: ActiveSpace | null): void {
|
||||
const prevId = active?.id;
|
||||
active = space;
|
||||
status = space ? 'ready' : 'idle';
|
||||
lastError = null;
|
||||
if (space?.id !== prevId) notifyHandlers(space);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -113,12 +157,14 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise<A
|
|||
status = 'loading';
|
||||
lastError = null;
|
||||
|
||||
const prevId = active?.id;
|
||||
try {
|
||||
const member = await fetchActiveMember();
|
||||
if (member) {
|
||||
active = member;
|
||||
status = 'ready';
|
||||
writeActiveSpaceHint(member.id);
|
||||
if (member.id !== prevId) notifyHandlers(member);
|
||||
return member;
|
||||
}
|
||||
|
||||
|
|
@ -140,6 +186,7 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise<A
|
|||
active = { ...chosen, role: hinted ? hinted.role : 'owner' };
|
||||
status = 'ready';
|
||||
writeActiveSpaceHint(chosen.id);
|
||||
if (active.id !== prevId) notifyHandlers(active);
|
||||
return active;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
|
|
|
|||
|
|
@ -22,12 +22,28 @@ import type {
|
|||
WorkbenchSceneApp,
|
||||
} from '$lib/types/workbench-scenes';
|
||||
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 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;
|
||||
|
||||
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[] = [
|
||||
{ appId: 'todo' },
|
||||
{ appId: 'calendar' },
|
||||
|
|
@ -47,7 +63,7 @@ const MAX_SUBSCRIBE_RETRIES = 3;
|
|||
function readActiveIdFromStorage(): string | null {
|
||||
if (!browser) return null;
|
||||
try {
|
||||
return localStorage.getItem(ACTIVE_SCENE_LS_KEY);
|
||||
return localStorage.getItem(activeSceneKey(getActiveSpaceId()));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -56,8 +72,9 @@ function readActiveIdFromStorage(): string | null {
|
|||
function writeActiveIdToStorage(id: string | null) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
if (id) localStorage.setItem(ACTIVE_SCENE_LS_KEY, id);
|
||||
else localStorage.removeItem(ACTIVE_SCENE_LS_KEY);
|
||||
const key = activeSceneKey(getActiveSpaceId());
|
||||
if (id) localStorage.setItem(key, id);
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* storage quota / disabled — ignore */
|
||||
}
|
||||
|
|
@ -66,7 +83,7 @@ function writeActiveIdToStorage(id: string | null) {
|
|||
function readMruFromStorage(): string[] {
|
||||
if (!browser) return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(MRU_LS_KEY);
|
||||
const raw = localStorage.getItem(mruKey(getActiveSpaceId()));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
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) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
const current = readMruFromStorage();
|
||||
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 {
|
||||
/* storage quota / disabled — ignore */
|
||||
}
|
||||
|
|
@ -195,8 +212,18 @@ function openSubscription(): void {
|
|||
subscription = liveQuery(() => db.table<LocalWorkbenchScene>(TABLE).toArray()).subscribe({
|
||||
next: (rows) => {
|
||||
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
|
||||
.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)
|
||||
.map(toScene);
|
||||
scenesState = visible;
|
||||
|
|
@ -259,6 +286,10 @@ export const workbenchScenesStore = {
|
|||
if (!browser || initializedState) return;
|
||||
|
||||
// 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();
|
||||
if (count === 0) {
|
||||
await ensureSeedScene();
|
||||
|
|
@ -266,6 +297,33 @@ export const workbenchScenesStore = {
|
|||
|
||||
activeSceneIdState = readActiveIdFromStorage();
|
||||
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() {
|
||||
|
|
|
|||
91
docs/optimizable/bundle-analysis.md
Normal file
91
docs/optimizable/bundle-analysis.md
Normal 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.
|
||||
|
|
@ -35,6 +35,7 @@
|
|||
"audit:i18n-coverage": "node scripts/audit-i18n-coverage.mjs",
|
||||
"audit:port-drift": "node scripts/audit-port-drift.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",
|
||||
"generate:dockerfiles": "node scripts/generate-dockerfiles.mjs",
|
||||
"setup:env": "node scripts/generate-env.mjs",
|
||||
|
|
|
|||
204
scripts/audit-bundle.mjs
Normal file
204
scripts/audit-bundle.mjs
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue