perf(bundle): add bundle composition and duplicate dependency budgets - #102
Merged
Hallab7 merged 2 commits intoSep 1, 2026
Merged
Conversation
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
|
@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. |
|
@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! 🚀 |
This was referenced Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
scripts/bundle/bundle-analysis.jsscripts/bundle/analyze.jsscripts/bundle/budgets.jsonreasonandreviewedOn),allowedClientPackages,ownerAliases,serverOnlyPolicy.scripts/bundle/baseline.jsonbudgetsHashtying it to the budget revision it was approved against.scripts/bundle/bundle-analysis.test.jsscripts/bundle/README.mdnext.config.tsproductionBrowserSourceMaps: process.env.ANALYZE_BUNDLE === "1"..github/workflows/ci.ymlfrontendjob, uploads the report as an artifact.eslint.config.mjsscripts/bundle/**.New scripts:
Acceptance criteria
CI reports route-level client bundles and significant changes. The
frontendjob runsnpm run bundle:analyzeafternpm run buildand 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 undersignificantChanges.Duplicate versions and unexpected client-side packages fail an explicit budget.
duplicate-dependencyfails 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-packagefails when a package reaches a client chunk without being listed inallowedClientPackages— currently@stellar/freighter-api,@swc/helpers,next.Server-only crypto, secrets, and Node modules are absent from client chunks. The
server-only-leakrule fails on Node builtins (node:*or a bare builtin specifier), packages inserverOnlyPackages, paths matchingserverOnlyPathPatterns, non-NEXT_PUBLIC_process.envreads from first-party code, andsecretPatternsmatches. The current build produces zero leaks. WhensecretPatternsmatches, 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
reasonand an ISOreviewedOndate, andbaseline.jsonstores a SHA-256budgetsHashof the canonical (recursively key-sorted)budgets.json. Any edit tobudgets.jsoninvalidates it untilnpm run bundle:baselineis 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.tsopts into them only for an analysis build:A normal
next buildstill 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.jsdirectly 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 ownsourceMappingURLcomment — Turbopack hashes map filenames independently of the chunk, so the<chunk>.js.mapsibling 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-apiemitswebpack://freighterApi/..., mapped via an explicitownerAliasesprefix).Validation output
npm run bundle:analyze(passing, abridged):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:
The gate actually fails. Lowering
/verifyto 500000 B without regenerating the baseline:Both the route budget and the governance rule fire, and the failure names the owning package. Restoring the file returns exit
0.Unit tests:
Lint:
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
developtoday. Reporting exactly what I found, all of it untouched by this PR except where noted:.github/workflows/ci.ymlwas invalid YAML. A stray emptyaccessibility:job key sat between thefrontendandvisual-regressionjobs, 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.npm cifails.package.jsonlists@vitest/uias a devDependency butpackage-lock.jsondoes not contain it. I deliberately leftpackage-lock.jsonuntouched rather than committing a ~7000-line regenerated lockfile inside a bundle-budgets PR;npm install && npm run bundle:analyzereproduces everything above locally.npm run buildfails type checking, on three files unrelated to this change:playwright.config.ts— twodefineConfig()blocks concatenated by a bad merge (TS1005: ':' expectedat line 57). Commitf356924onfix/develop-ci-baselinefixes exactly this but has not been merged todevelop.tests/api/timeout-retry-cancel.test.tsandtests/components/use-api-data.test.tsx—new Promise(() {})(missing=>), five and three occurrences.tests/contracts/schema-drift.test.ts—TS7053implicit-any index access.npm testwas 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
developbaseline if that is useful.