bundle-dep-auditor
v0.1.0
Published
Detects duplicate/conflicting package versions bundled across separately-built frontend apps (micro-frontends, multi-app monorepos), from webpack/Rspack stats.json, esbuild metafile.json, or Rollup/Vite visualizer JSON.
Downloads
189
Readme
bundle-dep-auditor
Finds packages that get shipped more than once, at different versions, across separately-built frontend apps (micro-frontends, Module Federation hosts/remotes, or just a few apps in one monorepo). One command reads each app's bundler output, tells you which packages are duplicated, how many bytes that's costing you, and how to fix it.
What you get
- Duplicate detection — every package, every version it's shipped at, and which apps ship it.
- A real byte count — how many gzip bytes you'd save if every app used the same version.
- A license list — for every third-party package in every app, not just the duplicated ones.
- A pass/fail check you can wire into CI, with sensible defaults and suggested fixes.
- 4 report formats — JSON, CSV, Markdown (great for PR comments), and HTML.
Install
npm install --save-dev bundle-dep-auditorQuick start
Build each app and save its bundler's stats output (see the table below for your bundler).
Describe your apps in a small JSON file, e.g.
apps.json:[ { "file": "apps/cart/dist/stats.json", "appName": "cart" }, { "file": "apps/checkout/dist/stats.json", "appName": "checkout" }, { "file": "apps/search/dist/stats.json", "appName": "search" } ]Run the audit:
npx bundle-dep-auditor audit --files apps.json --format markdown --out audit-report.md
The command exits with code 1 if a problem is found (great for failing a CI job) and 0 when
everything looks clean.
Getting a stats file from your bundler
| Bundler | How to get the file |
| --- | --- |
| webpack | Run webpack --json > stats.json |
| Rspack | Same file as webpack — add --bundler rspack when running the audit so the report labels it correctly |
| esbuild | Run your build with --metafile=metafile.json |
| Vite / Rollup | Add rollup-plugin-visualizer to your build with json: true, then point this tool at the file it writes |
| Parcel | Parcel doesn't produce a stats file on its own — see Using this with Parcel below |
| Turbopack | Not supported yet — see Turbopack below |
CLI commands
audit — check one or more apps for duplicate dependencies
Three ways to run it, depending on how many apps you have:
# One app
npx bundle-dep-auditor audit apps/cart/dist/stats.json --app-name cart
# Several apps, each with its own settings — recommended once you have more than one app
npx bundle-dep-auditor audit --files apps.json
# Several apps, none of which need any settings changed
npx bundle-dep-auditor audit apps/cart/dist/stats.json apps/checkout/dist/stats.jsonOptions:
| Option | Value | Default | What it does |
| --- | --- | --- | --- |
| --files | path to a JSON file | — | Describes multiple apps at once. See The --files file below. Can't be combined with more than one file path on the command line. |
| --app-name | text | the file's parent folder name | Label for this app in the report. Only for the single-app form above. |
| --root-dir | path | the file's own folder | Folder to look in for node_modules when resolving package versions/licenses. Only for the single-app form above. |
| --bundler | webpack, rspack, esbuild, vite-rollup, or parcel | guessed automatically | Tells the tool which bundler produced the file. Only needed to tell webpack and Rspack apart, since they produce identical files. |
| --config | path | bundle-dep-auditor.config.json | Path to your config file. See Config file below. |
| --format | json, csv, markdown, or html | markdown | Report format. |
| --out | path | — | Write the report to a file instead of printing it. |
The --files file
A JSON file listing every app you want to check. Give it either one object, or an array of them:
[
{ "file": "apps/cart/dist/stats.json", "appName": "cart" },
{ "file": "apps/checkout/dist/stats.json", "bundler": "esbuild" }
]Each entry accepts:
| Field | Required? | Value | What it does |
| --- | --- | --- | --- |
| file | Yes | path | The stats/metafile JSON for this app. |
| appName | No | text | Label for this app in the report. Defaults to the file's parent folder name. |
| rootDir | No | path | Folder to look in for node_modules. Defaults to the file's own folder. |
| bundler | No | webpack, rspack, esbuild, vite-rollup, parcel, or unknown | Which bundler produced this file. |
| sharedPackages | No | array of package names | Packages this app declares as a Module Federation shared/singleton dependency (or the equivalent for any other multi-app runtime). See Module Federation shared packages below. |
If an entry has a field that isn't in this list, or a value of the wrong type (e.g. a number where text is expected), the command stops with an error explaining exactly what's wrong — it never runs with a mistyped setting silently ignored.
Why a file instead of repeating flags? With several apps, flags like
--app-nameused to be matched to files by their position in the command — easy to get out of sync by accident. Putting each app's file and settings in one place removes that risk entirely.
Module Federation shared packages
If your apps are Module Federation remotes/hosts, some packages are declared singleton: true in
your MF config's shared block (commonly react, react-dom, and any design-system package every
remote loads into the same runtime). A version mismatch in one of these is worse than an ordinary
duplicate — the shared scope can only resolve to one version, so a mismatch is a runtime break, not
just extra bytes downloaded.
List those package names per app in sharedPackages:
[
{ "file": "apps/cart/dist/stats.json", "appName": "cart", "sharedPackages": ["react", "react-dom"] },
{ "file": "apps/checkout/dist/stats.json", "appName": "checkout", "sharedPackages": ["react", "react-dom"] }
]Any package listed in at least one app's sharedPackages is checked independently of the size
budgets below: it fails the moment more than maxDistinctVersionsPerPackage versions of it exist,
no matter how small it is. Everything not listed is judged purely on size, same as before this
option existed — this is opt-in, so leaving it out changes nothing.
diff — compare the same app at two points in time
The most common real source of version drift isn't two apps in one monorepo (a shared lockfile can't disagree with itself) — it's the same app rebuilt after its dependencies moved on: an old release vs. the current one, or one Module Federation remote's last deployed build vs. its next one, each built independently on its own schedule.
npx bundle-dep-auditor diff \
--baseline releases/v1.80.0/stats.json --baseline-root-dir releases/v1.80.0 \
--current dist/stats.json --current-root-dir . \
--format markdownThis runs the exact same aggregate-and-gate check as audit --files with just these two builds, so
a package that moved between the two shows up as an ordinary duplicate-version finding.
Options:
| Option | Value | Default | What it does |
| --- | --- | --- | --- |
| --baseline | path | — | Required. Stats/metafile JSON for the earlier build. |
| --current | path | — | Required. Stats/metafile JSON for the later build. |
| --baseline-name / --current-name | text | baseline / current | Label for each build in the report. |
| --baseline-root-dir / --current-root-dir | path | each file's own folder | Folder to look in for node_modules, per side. Deliberately separate flags, not one shared --root-dir — the two builds are usually two different checkouts with two different (or no longer existing) node_modules. |
| --baseline-bundler / --current-bundler | webpack, rspack, esbuild, vite-rollup, or parcel | guessed automatically | Which bundler produced each file. |
| --config | path | bundle-dep-auditor.config.json | Path to your config file. |
| --format | json, csv, markdown, or html | markdown | Report format. |
| --out | path | — | Write the report to a file instead of printing it. |
collect — preview how one file gets read
Useful for checking that a single stats file is recognized correctly before running a full audit.
npx bundle-dep-auditor collect apps/cart/dist/stats.json --app-name cart| Option | Value | Default | What it does |
| --- | --- | --- | --- |
| --app-name | text | the file's parent folder name | Label for this app. |
| --root-dir | path | the file's own folder | Folder to look in for node_modules. |
| --bundler | webpack, rspack, esbuild, vite-rollup, or parcel | guessed automatically | Which bundler produced the file. |
| --out | path | — | Write the result to a file instead of printing it. |
Config file
Optional — create bundle-dep-auditor.config.json in your project root to change the pass/fail
rules. If you don't create one, sensible defaults apply.
{
"gate": {
"maxDistinctVersionsPerPackage": 1,
"maxWastedGzipBytesPerPackage": 51200,
"maxWastedGzipBytesTotal": 153600,
"maxWastedRawBytesPerPackage": 153600,
"maxWastedRawBytesTotal": 460800,
"ignorePackages": ["react", "react-dom"],
"ignorePackagePatterns": ["^@internal/"]
}
}| Setting | Default | What it means |
| --- | --- | --- |
| maxDistinctVersionsPerPackage | 1 | How many different versions of one package are allowed before it's flagged. |
| maxWastedGzipBytesPerPackage | 51200 (50 KB) | How many extra gzip bytes one duplicated package can cost before it fails on its own. |
| maxWastedGzipBytesTotal | 153600 (150 KB) | How many extra gzip bytes all duplicates combined can cost before the whole check fails. |
| maxWastedRawBytesPerPackage | 153600 (150 KB) | Same as maxWastedGzipBytesPerPackage, but for a package your bundler never reported a gzip size for (see below). |
| maxWastedRawBytesTotal | 460800 (450 KB) | Same as maxWastedGzipBytesTotal, but for packages your bundler never reported a gzip size for. |
| ignorePackages | [] | Exact package names to skip entirely (e.g. one you're migrating and haven't fixed yet). |
| ignorePackagePatterns | [] | Regular expressions matching package names to skip. |
The audit also always flags a package that's listed as a devDependency in an app's own
package.json but still ends up in that app's shipped bundle — usually a sign of an accidental
import. ignorePackages/ignorePackagePatterns apply to this check too.
Like the --files file above, an unrecognized setting in this file is a validation error rather
than being silently ignored.
If your bundler doesn't report gzip sizes (see the Gzip size row in
Reading your report below), a duplicated package with no gzip data at all
can't be judged against maxWastedGzipBytes* — it would always look like it wastes 0 bytes.
For those packages only, the gate falls back to maxWastedRawBytes* instead, and the report adds
a warning telling you it did so, so you know to add a compression plugin (or just trust the raw
numbers) rather than assuming the gate is silently passing everything.
Reading your report
Whichever format you choose (JSON, CSV, Markdown, or HTML), each row is one package used by one app. Here's what each field means:
| Field | What it tells you |
| --- | --- |
| Package | The npm package name, e.g. lodash. |
| Version | The version of that package this app has bundled. |
| How the version was found | resolved-package-json = read straight from that package's own package.json on disk — the reliable case. path-embedded = the install layout named the version in the folder path itself (common with pnpm) — also reliable. unknown = neither worked, usually because the package couldn't be found on disk (see Good to know below). |
| Size | How many bytes this package added to the app's bundle. |
| Gzip size | The compressed size — closer to what actually gets downloaded. Blank/missing when your bundler didn't compute it (e.g. esbuild never reports this; webpack only does if you're using a compression plugin). |
| Duplicated? | Yes if two or more apps bundle different versions of this package. |
| Most common version | When a package is duplicated, this is the version most apps agree on — the one the "wasted bytes" number below is measured against. |
| Wasted bytes | How many gzip bytes you'd stop shipping if every app switched to the most common version. Only non-zero for duplicated packages. |
| Scope | MF shared if this package was listed in some app's sharedPackages (see Module Federation shared packages). A mismatch here fails the gate regardless of size. |
| Imported by | Which file in your app's source code pulled this package in — useful for tracking down why it's there. |
| Loads | initial = downloaded immediately on page load. async = only loaded when a certain route/feature is used. unknown = your bundler's output didn't say. |
| License | The package's license (e.g. MIT), plus where its LICENSE file lives on disk. Blank when the package couldn't be found on disk to check. |
| Dev dependency in bundle? | Yes if your app's own package.json lists this package under devDependencies, yet it still shipped in the production bundle — almost always an accidental import worth investigating. |
If a check fails, the report also includes a plain-English reason (which versions, how many
apps, how many bytes) and a short list of suggested fixes — e.g. align every app on the same
version, or move a wrongly-shipped package out of devDependencies.
Node.js API
import { audit, toMarkdown } from 'bundle-dep-auditor';
const report = audit([
{ file: 'apps/cart/dist/stats.json', appName: 'cart' },
{ file: 'apps/checkout/dist/stats.json', appName: 'checkout' },
]);
console.log(toMarkdown(report));
if (!report.gate.passed) {
process.exitCode = 1;
}Using this with Parcel
Parcel doesn't write a stats file the way other bundlers do, so this package ships a Parcel Reporter plugin that writes one during your build instead:
npm install --save-dev @parcel/plugin// .parcelrc
{
"extends": "@parcel/config-default",
"reporters": ["...", "bundle-dep-auditor/parcel-reporter"]
}By default it writes to ./parcel-bundle-report.json (override with the BUNDLE_DEP_AUDITOR_OUT
environment variable). Feed that file into audit exactly like any other app's stats file.
Turbopack
Not supported yet — its stats output isn't stable enough across Next.js releases to build a
reliable reader for it. If you're on Next.js, run next build --webpack to get a webpack stats
file this tool can already read.
Good to know
- pnpm's strict linking can leave a package unresolvable on disk in some setups — usually a package only reachable transitively under pnpm's stricter isolation. When that happens, its version and license show up as unknown (see Reading your report above) rather than a guessed wrong answer.
- Vite/Rollup support depends on the community
rollup-plugin-visualizerplugin's JSON shape, which has changed across its major versions. If your version's output isn't recognized, you'll get a clear error rather than a silently wrong report. - webpack and Rspack look identical in their stats files, so this tool can't tell them apart
automatically — use
--bundler rspackif you want the report labeled correctly.
