Skip to content

perf(bundle): add bundle composition and duplicate dependency budgets - #102

Merged
Hallab7 merged 2 commits into
veridatum-labs:developfrom
noevidence1017:perf/bundle-composition-budgets
Sep 1, 2026
Merged

perf(bundle): add bundle composition and duplicate dependency budgets#102
Hallab7 merged 2 commits into
veridatum-labs:developfrom
noevidence1017:perf/bundle-composition-budgets

Conversation

@noevidence1017

Copy link
Copy Markdown
Contributor

closes #82

Why

scripts/performance/ already answers "is this route too big?". It cannot answer "what is actually in it?" — a route can sit comfortably inside a total-size budget while quietly picking up a second copy of a library at a different version, pulling in a package nobody approved for the browser, or dragging server-only code across the server/client boundary.

This adds a reproducible bundle composition gate with route budgets, allowlisted dependency ownership, and a governance rule that makes changing a budget a reviewed act.

What this adds

File Role
scripts/bundle/bundle-analysis.js Pure, filesystem-free rules: owner attribution, duplicate versions, client dependency ownership, server-only leaks, baseline drift, budget governance.
scripts/bundle/analyze.js CLI. Runs after (or performs) a production build, attributes every route's client chunks to their owning package, writes the machine-readable report.
scripts/bundle/budgets.json Per-route client JS budgets (each with a reason and reviewedOn), allowedClientPackages, ownerAliases, serverOnlyPolicy.
scripts/bundle/baseline.json Reviewed baseline: measured per-route composition plus a budgetsHash tying it to the budget revision it was approved against.
scripts/bundle/bundle-analysis.test.js 36 unit tests covering every rule and every failure path.
scripts/bundle/README.md What is measured, how attribution works, and the documented process for changing a budget.
next.config.ts productionBrowserSourceMaps: process.env.ANALYZE_BUNDLE === "1".
.github/workflows/ci.yml Runs the gate in the frontend job, uploads the report as an artifact.
eslint.config.mjs Extends the existing CommonJS-tooling exemption to scripts/bundle/**.

New scripts:

npm run bundle:analyze    # build with ANALYZE_BUNDLE=1, then analyze (what CI runs)
npm run bundle:check      # analyze the build already in .next
npm run bundle:baseline   # rebuild and rewrite the reviewed baseline

Acceptance criteria

CI reports route-level client bundles and significant changes. The frontend job runs npm run bundle:analyze after npm run build and prints a per-route owner breakdown, then uploads .next/analyze/bundle-report.json. Any route whose client JS moves by more than 5 KB or 2% versus the reviewed baseline is printed and recorded under significantChanges.

Duplicate versions and unexpected client-side packages fail an explicit budget. duplicate-dependency fails when any package resolves to more than one version across client chunks (a hoisted and a nested copy of the same version correctly count as one). unexpected-client-package fails when a package reaches a client chunk without being listed in allowedClientPackages — currently @stellar/freighter-api, @swc/helpers, next.

Server-only crypto, secrets, and Node modules are absent from client chunks. The server-only-leak rule fails on Node builtins (node:* or a bare builtin specifier), packages in serverOnlyPackages, paths matching serverOnlyPathPatterns, non-NEXT_PUBLIC_ process.env reads from first-party code, and secretPatterns matches. The current build produces zero leaks. When secretPatterns matches, the report records the matching rule, not the matched text — echoing a suspected secret into a CI log would be the very leak the check exists to prevent.

Budget updates require a documented reason and reviewed baseline change. Two mechanical rules: every route budget must carry a non-empty reason and an ISO reviewedOn date, and baseline.json stores a SHA-256 budgetsHash of the canonical (recursively key-sorted) budgets.json. Any edit to budgets.json invalidates it until npm run bundle:baseline is re-run, so a budget change can never land without the reviewed baseline change that goes with it — and a reviewer sees the new limit and its real measured composition in the same diff.

How attribution works

Minified Turbopack chunks contain no module paths, so the only accurate way to say which package owns which bytes is to read the client source maps. next.config.ts opts into them only for an analysis build:

productionBrowserSourceMaps: process.env.ANALYZE_BUNDLE === "1"

A normal next build still emits no browser source maps. That is the one deliberate trade-off here: shipping them would publish the app's original sources to anyone who opens devtools, so the analyzer runs its own build instead of reusing the deploy build.

Route asset resolution imports scripts/performance/budget-check.js directly rather than duplicating it, so the two gates can never disagree about what a route loads. Chunks are matched to maps through each chunk's own sourceMappingURL comment — Turbopack hashes map filenames independently of the chunk, so the <chunk>.js.map sibling convention does not hold. Chunk bytes are split across owners in proportion to original-source size: an estimate, but a deterministic one.

Two things are attributed by name because they have no usable map data, both documented in the README: Next's polyfill chunk (identified from build-manifest.json#polyfillFiles) and dependencies shipping their own pre-bundled source map (@stellar/freighter-api emits webpack://freighterApi/..., mapped via an explicit ownerAliases prefix).

Validation output

npm run bundle:analyze (passing, abridged):

Route client bundle composition
  /  584.7 KB client JS across 8 chunk(s)
      next                         462.1 KB
      next (polyfills)             110.0 KB
      [turbopack-runtime]          9.5 KB
      lib                          1.8 KB
      components                   0.8 KB
  /proofs/create  601.2 KB client JS across 9 chunk(s)
      next                         462.1 KB
      next (polyfills)             110.0 KB
      components                   11.5 KB
      [turbopack-runtime]          9.5 KB
      lib                          7.2 KB
  /verify  599.1 KB client JS across 9 chunk(s)
      next                         462.1 KB
      next (polyfills)             110.0 KB
      lib                          10.2 KB
      [turbopack-runtime]          9.5 KB
      components                   6.4 KB

Client dependency ownership
  @stellar/freighter-api@6.0.1
  @swc/helpers@0.5.15
  next@16.3.0

No significant change vs the reviewed baseline.

Bundle composition budgets: PASS

Exit code 0.

Stable machine-readable output. The report carries no timestamps, no content hashes and no absolute paths, and every collection is sorted. Two full analysis builds of the same source produce byte-identical JSON:

$ cp .next/analyze/bundle-report.json /tmp/report-a.json
$ node scripts/bundle/analyze.js --build   # full rebuild
$ diff /tmp/report-a.json .next/analyze/bundle-report.json && echo IDENTICAL
IDENTICAL

The gate actually fails. Lowering /verify to 500000 B without regenerating the baseline:

Bundle composition budgets: FAIL (2 violation(s))
  [governance] budgets.json changed without a matching reviewed baseline. Re-run
    `npm run bundle:baseline` so the budget change and the baseline it was reviewed
    against land in the same diff (expected budgetsHash 3af43d34e0df...).
  [route-budget] /verify: client JS 613454 B exceeds budget 500000 B by 113454 B
    - largest owner: next (473172 B)

analyzer exit=1

Both the route budget and the governance rule fire, and the failure names the owning package. Restoring the file returns exit 0.

Unit tests:

$ npx jest scripts/bundle --runInBand
Test Suites: 1 passed, 1 total
Tests:       36 passed, 36 total

Lint:

$ npx eslint scripts/bundle
(no output — clean)

Pre-existing CI state on develop (not caused by this PR)

I could not confirm "existing CI checks remain green" because they are not green on develop today. Reporting exactly what I found, all of it untouched by this PR except where noted:

  1. .github/workflows/ci.yml was invalid YAML. A stray empty accessibility: job key sat between the frontend and visual-regression jobs, which makes GitHub reject the whole workflow. This PR removes it, because a bundle gate that cannot be parsed by Actions is not a gate. The accessibility scans already run as steps of the job below it, so nothing else changes.
  2. npm ci fails. package.json lists @vitest/ui as a devDependency but package-lock.json does not contain it. I deliberately left package-lock.json untouched rather than committing a ~7000-line regenerated lockfile inside a bundle-budgets PR; npm install && npm run bundle:analyze reproduces everything above locally.
  3. npm run build fails type checking, on three files unrelated to this change:
    • playwright.config.ts — two defineConfig() blocks concatenated by a bad merge (TS1005: ':' expected at line 57). Commit f356924 on fix/develop-ci-baseline fixes exactly this but has not been merged to develop.
    • tests/api/timeout-retry-cancel.test.ts and tests/components/use-api-data.test.tsxnew Promise(() {}) (missing =>), five and three occurrences.
    • tests/contracts/schema-drift.test.tsTS7053 implicit-any index access.
  4. npm test was already red: 6 failed suites / 18 failed tests before this change; 6 failed suites / 18 failed tests after, with 36 additional passing tests from this PR (82 → 118 passing). No test regressed.

To produce the build evidence above I locally patched (1) and (3) without committing any of it — the tree in this PR contains none of those edits. Happy to open a separate PR for the develop baseline if that is useful.

Route size budgets alone cannot see a second copy of a library, a package
nobody approved for the browser, or server-only code crossing the
server/client boundary. Add a reproducible bundle composition gate that
does.

- scripts/bundle/bundle-analysis.js: pure, filesystem-free rules for owner
  attribution, duplicate versions, client dependency ownership, server-only
  leaks, baseline drift and budget governance.
- scripts/bundle/analyze.js: CLI that runs after (or performs) a production
  build, attributes every route's client chunks to their owning package via
  the client source maps, and writes a stable, machine-readable report to
  .next/analyze/bundle-report.json.
- next.config.ts: emit client source maps only when ANALYZE_BUNDLE=1, so a
  normal production build never ships them.
- budgets.json / baseline.json: per-route client JS budgets with a required
  reason and review date, an allowlist of packages permitted in the browser,
  and a budgetsHash that ties the reviewed baseline to the budget revision -
  editing a budget without regenerating the baseline fails the check.
- CI runs the gate in the frontend job and uploads the report; the stray
  empty accessibility job key that made the workflow invalid is removed.
- bundle-analysis.test.js covers every rule, including each failure path.

Closes veridatum-labs#82
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

@noevidence1017 is attempting to deploy a commit to the hallab's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Aug 28, 2026

Copy link
Copy Markdown

@noevidence1017 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Hallab7
Hallab7 merged commit ef5cd97 into veridatum-labs:develop Sep 1, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add bundle composition and duplicate dependency budgets

2 participants