# Bundle Budgets **Status:** active **Owner:** web platform **Related:** [`scripts/bundle-budgets.json`](../../scripts/bundle-budgets.json), [`scripts/verify-bundle-size.mjs`](../../scripts/verify-bundle-size.mjs) ## Why Web bundle growth is silent unless we measure it. The boilerplate tracks two complementary signals: 1. **Group budgets** (`BUDGETS` constant in `verify-bundle-size.mjs`): coarse caps for `root` / `initial_payload_js` / `vendor` / `route` / `other` / `lazy_runtime` / `application_js` / `total_js` / `css` / `manifest_js`. Hard CI gate — failure exits 1. 2. **Per-route, per-vendor and per-lazy-runtime budgets** (`scripts/bundle-budgets.json`): fine-grained gzip limits for routes, vendor chunks and self-hosted feature runtimes/workers. **Non-blocking** — surfaces as a PR-comment with a Δ-from-main column. Reviewers decide whether growth is justified. The per-route layer was added in task #71 (Phase 10) to make day-to-day creep visible without blocking legitimate feature work. `initial_payload_js` recursively follows the static imports of the React Router entry and root modules. Dynamic imports stay outside that boot signal. Self-hosted feature runtimes need a separate signal. `lazy_runtime` contains Monaco, PDF.js and emitted Web Worker entry points: these bytes replaced executable CDN downloads and are loaded only when the feature or worker is activated. `application_js` preserves the pre-self-hosting cap for the app itself, while `total_js` still caps every emitted JavaScript byte including lazy runtimes. This keeps CDN removal from weakening either critical-path or overall artifact controls. ## Config schema `scripts/bundle-budgets.json` validated by `scripts/bundle-budgets.schema.json`: ```jsonc { "default": { "limitKb": 200, "warningKb": 180 }, "routes": { "app/$tenant/dashboard": { "limitKb": 250, "warningKb": 225 }, "auth/magic-link": { "limitKb": 80, "warningKb": 75 }, }, "vendorChunks": { "vendor-react": { "limitKb": 150, "warningKb": 135 }, }, "lazyRuntimeChunks": { "MonacoEditor.client": { "limitKb": 1000, "warningKb": 950 }, "ts.worker": { "limitKb": 1550, "warningKb": 1520 }, }, } ``` - All sizes are **gzip kilobytes** (the transport-relevant number). - `limitKb` is the hard cap; assets above this report `[EXCEED]` in CI output and the PR comment. - `warningKb` is the soft threshold; between `warningKb` and `limitKb` assets report `[WARN]`. - Routes use React Router v7 route ids in either dot- or slash-form (`auth/magic-link` and `auth.magic-link` both match). - Unmatched routes fall back to `default`. - Unmatched vendor chunks (anything starting with `vendor-` or `chunk-`) are reported under the chunk basename and also fall back to `default`. - Monaco/PDF.js chunks and `*.worker-*` entry points use `lazyRuntimeChunks`; these budgets make formerly hidden CDN payloads reviewable without treating them as route modules. ## Local workflow ```bash # Build apps/web normally and print the per-route table: pnpm --filter @hivegrid/web build node scripts/verify-bundle-size.mjs # Only the per-route table (skips group checks): node scripts/verify-bundle-size.mjs --per-route-only # Machine-readable JSON (used by the PR-comment workflow): node scripts/verify-bundle-size.mjs --json > pr-bundle-report.json ``` `--json` emits only the non-blocking per-route/per-vendor report and exits with code 0 when the report was produced. Keep the hard group-budget gate on the plain `node scripts/verify-bundle-size.mjs` path so redirected JSON never mixes machine-readable output with human diagnostics. ### Visualizer (treemap) To inspect why a chunk is big: ```bash pnpm --filter @hivegrid/web run bundle:visualize open apps/web/build/stats.html ``` The visualizer is gated on `ANALYZE_BUNDLE=true` so the regular production build keeps its current footprint. It uses `rollup-plugin-visualizer` (treemap, gzip + brotli sizes per chunk). **Security note:** `stats.html` contains source-map references and should never be deployed publicly. Treat it as a developer-only artifact. ## Gitea workflow `.gitea/workflows/bundle-budget.yml` runs on every relevant Gitea pull request. The dedicated workflow keeps the expensive web build outside the default CI lane while still enforcing the aggregate budget. The workflow: 1. Build `@hivegrid/web`. 2. Run `verify-bundle-size.mjs --json`. 3. Download the latest `bundle-baseline-main` artifact (captured by `bundle-budget-baseline.yml` on `main` pushes). 4. Render a markdown report via `scripts/render-bundle-budget-comment.mjs`. 5. Upload the JSON and Markdown reports as Gitea Actions artifacts. The comment renders rows in severity order (`[EXCEED]` → `[WARN]` → unbudgeted → ok), with columns: status / kind / route or chunk key / current gzip size / configured limit / Δ from main. Δ is `—` if no baseline is available. ### When the baseline is missing The first PR after a fresh checkout (or after a baseline-artifact expiry) sees `Δ from main = —` for every row. This is expected — the next push to `main` re-captures the baseline. ## When to raise a limit 1. Confirm the growth is intentional (a new feature shipped, a dependency landed) — not a regression from accidental imports. 2. Open the pull request; the bundle-budget artifact shows the Δ. Reviewers and the author should agree the new floor is acceptable. 3. Edit `scripts/bundle-budgets.json` in the **same PR** that ships the growth. Bump `warningKb` to ≈ (current gzip + 5 KB) and `limitKb` to ≈ (current gzip + 20 KB). 4. Call the change out in the PR description so the new floor is reviewable. ## Anti-patterns - **Don't disable a budget by raising it to 9999 KB.** The point is to surface drift. If a route is legitimately huge, raise the limit to a tight new floor, not infinity. - **Don't lazy-load critical-path code.** Bundle splits help non-critical routes, but the recursively measured entry/root static graph still has its own `initial_payload_js` hard group budget that must stay tight. - **Don't bundle-split shared deps.** Splitting `react` into many chunks just shifts cost into chunk boot-up. Keep `vendor-react`, `vendor-core-utils` etc. as single chunks; the manualChunks mapping in `apps/web/app/utils/build/manualChunks.ts` is the source of truth. ## Troubleshooting - **Report artifact fehlt:** check the `Bundle Budget` workflow run — the `Build web` step may have failed (e.g. server-only import in a client export). - **A worker appears as a route:** worker filenames must match `*.worker-*` (or PDF.js's `*.worker.min-*`) so the verifier puts them in `lazy_runtime`; update the narrow matcher when adding a differently named worker entry. - **CI run is much slower than `pnpm typecheck`:** the workflow does a full `pnpm --filter @hivegrid/web build`. That's the expected cost. If it ever exceeds ~30 minutes, profile with `ANALYZE_BUNDLE=true` locally first. ## Rollback - If the per-route budget surfaces too many false alarms, lower the warning thresholds in `scripts/bundle-budgets.json` and ship a follow-up. - If the entire PR-comment flow is broken, set `if: false` on the `measure` job in `bundle-budget.yml` — no other CI stage depends on it.