documentation
Supported Formats
Webpack, Rspack, esbuild, source-map-explorer, rollup-visualizer, webpack-bundle-analyzer, Parcel, and the generic JSON format
dendrobundle auto-detects the format by inspecting the top-level structure of the JSON you POST. No format flag needed.
At a glance
| Format | Producer | gzip/brotli | Initial-chunk flag | Size basis |
|---|---|---|---|---|
webpack-stats |
webpack --json |
— | ✓ (chunks[].initial) |
emitted |
rspack-stats |
rspack --json / Rsbuild |
— | ✓ (chunks[].initial) |
emitted |
esbuild-metafile |
esbuild --metafile |
— | ✓ (outputs[].entryPoint) |
emitted |
source-map-explorer |
source-map-explorer --json |
— | — | source-attributed |
rollup-stats |
rollup-plugin-visualizer (Rollup + Vite) | ✓ | ✓ (nodeMetas[].isEntry, when present) |
rendered |
webpack-bundle-analyzer |
wba JSON report | gzip ✓ | ✓ (isInitialByEntrypoint) |
emitted (or source-attributed when only statSize present) |
parcel-metrics |
@parcel/reporter-build-metrics |
— | — | emitted |
generic-json |
anything — your own script | ✓ (optional fields) | ✓ (optional field) | producer-declared |
Size basis records what the byte counts mean: emitted = on-disk output bytes; rendered = rollup-visualizer's rendered module lengths (excludes externals/virtual modules); source-attributed = bytes attributed back to source files (undercounts shipped size). dendrobundle stores the basis per snapshot and only computes deltas/alerts between snapshots of the same format, so a format switch never reads as a size regression.
Budgets with initialOnly need a format that reports entry/initial chunks — webpack, rspack, esbuild, webpack-bundle-analyzer, or generic. source-map-explorer cannot express it; those rules never match on SME-only projects.
Compressed sizes are only as good as the producer: rollup-visualizer (with gzipSize/brotliSize), webpack-bundle-analyzer (gzip), and the generic format carry them; webpack/rspack stats, esbuild metafiles, and SME output do not — the UI shows gzip — for those snapshots. If you want gzip tracking on a webpack/esbuild project, also push a rollup-visualizer/wba report or emit the generic format.
webpack-stats
Generated by running webpack with --json:
npx webpack --profile --json > dist/webpack-stats.json
Or via webpack-bundle-analyzer with generateStatsFile: true:
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'disabled',
generateStatsFile: true,
statsFilename: 'webpack-stats.json',
}),
],
};
Fields used:
| Field | Purpose |
|---|---|
assets[].name |
Asset file name |
assets[].size |
Asset size in bytes |
assets[].chunkNames |
Which named chunks the asset belongs to |
chunks[].initial |
Whether this is an initial (non-lazy) chunk |
chunks[].files |
Asset file names this chunk maps to |
modules[].name |
Source module path |
modules[].size |
Rendered size in bytes after tree-shaking |
Minimal example:
{
"assets": [
{ "name": "main.js", "size": 412680, "chunkNames": ["main"] },
{ "name": "vendor.js", "size": 1154020, "chunkNames": ["vendor"] }
],
"chunks": [
{ "id": 0, "names": ["main"], "initial": true, "files": ["main.js"] },
{ "id": 1, "names": ["vendor"], "initial": true, "files": ["vendor.js"] }
],
"modules": [
{ "name": "src/app/app.ts", "size": 4812, "chunkNames": ["main"] },
{ "name": "node_modules/react/index.js", "size": 7240, "chunkNames": ["vendor"] }
]
}
rspack-stats
Rspack (and Rsbuild) emit webpack-compatible stats plus an rspackVersion marker, which dendrobundle uses to record the snapshot as rspack-stats:
npx rspack build --json > dist/rspack-stats.json
// rsbuild: emit stats via the underlying rspack config
export default {
tools: {
rspack: (config, { env }) => {
// then run: rsbuild build --env-mode production && node scripts/emit-stats.mjs
},
},
};
Fields are identical to webpack-stats above — assets[], chunks[] (incl. initial), and modules[] all work the same, including scope-hoisted module.modules flattening. A stats file with the marker stripped still pushes fine; it is simply recorded as webpack-stats.
esbuild-metafile
Generated with the --metafile CLI flag:
npx esbuild src/index.ts --bundle --metafile=dist/meta.json --outfile=dist/bundle.js
Or via the JavaScript API:
import * as esbuild from 'esbuild';
const result = await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
metafile: true,
});
require('fs').writeFileSync('dist/meta.json', JSON.stringify(result.metafile));
Fields used:
| Field | Purpose |
|---|---|
outputs[key].bytes |
Total output file size |
outputs[key].inputs |
Map of input file → bytes contributed |
outputs[key].entryPoint |
Entry point that produced this output |
Minimal example:
{
"inputs": {},
"outputs": {
"dist/bundle.js": {
"bytes": 412680,
"entryPoint": "src/index.ts",
"inputs": {
"src/index.ts": { "bytesInOutput": 4812 },
"src/app/router.ts": { "bytesInOutput": 2140 },
"node_modules/react/index.js": { "bytesInOutput": 7240 }
}
},
"dist/vendor.js": {
"bytes": 1154020,
"inputs": {
"node_modules/react-dom/index.js": { "bytesInOutput": 580000 }
}
}
}
}
source-map-explorer
Generated by running source-map-explorer with --json:
# Build with source maps first
npm run build -- --sourcemap
# Then extract module breakdown
npx source-map-explorer --json 'dist/*.js' > dist/sme.json
Fields used:
| Field | Purpose |
|---|---|
results[].bundleName |
Output file name |
results[].totalBytes |
Total size of the bundle |
results[].mappedBytes |
Bytes covered by source maps |
results[].files |
Map of source file → bytes |
Minimal example:
{
"results": [
{
"bundleName": "dist/main.js",
"totalBytes": 412680,
"mappedBytes": 398420,
"unmappedBytes": 14260,
"files": {
"src/app/app.ts": { "size": 4812 },
"src/app/router.ts": { "size": 2140 },
"node_modules/react/index.js": { "size": 7240 },
"[unmapped]": { "size": 14260 }
}
}
]
}
Note: source-map-explorer data is only as accurate as your source maps. Production builds with --sourcemap=hidden or --sourcemap=nosources will show reduced coverage.
rollup-stats
Generated by rollup-plugin-visualizer with the raw-data template. Because Vite builds on Rollup, the same plugin covers both Rollup and Vite projects.
// rollup.config.js or vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({
template: 'raw-data',
filename: 'dist/rollup-stats.json',
gzipSize: true,
brotliSize: true,
}),
],
};
Fields used:
| Field | Purpose |
|---|---|
tree.children[] |
Output chunk files (each top-level node is one asset) |
tree…children[].uid |
Leaf module → its part in nodeParts |
nodeParts[uid].renderedLength |
Module size contributed to the chunk |
nodeParts[uid].gzipLength / brotliLength |
Compressed sizes (when gzipSize/brotliSize enabled) |
nodeMetas[metaUid].id |
Real module path; isExternal modules are excluded |
Minimal example:
{
"version": 2,
"tree": {
"name": "root",
"children": [{ "name": "main-abc123.js", "children": [{ "name": "src/app.ts", "uid": "u1" }] }]
},
"nodeParts": {
"u1": { "metaUid": "u1", "renderedLength": 4812, "gzipLength": 1900, "brotliLength": 1700 }
},
"nodeMetas": {
"u1": { "id": "src/app.ts" }
},
"options": { "gzip": true, "brotli": true, "sourcemap": false }
}
webpack-bundle-analyzer
The analyzer's own JSON report (distinct from webpack's stats.json — see webpack-stats for that). A root array of per-asset chart nodes with statSize / parsedSize / gzipSize and nested groups:
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'json', // writes report.json instead of opening the UI
reportFilename: 'wba-report.json',
}),
],
};
Fields used:
| Field | Purpose |
|---|---|
[].label |
Asset file name |
[].parsedSize |
Minified on-disk size (preferred) |
[].statSize |
Pre-minification source size — stored alongside as the "stat" size |
[].gzipSize |
Gzip size |
[].isInitialByEntrypoint |
Initial-chunk flag (any entrypoint true) |
[].groups[] (recursive) |
Directory tree down to leaf modules |
Both sizes are kept: the treemap sizes tiles by parsedSize and the tooltip shows the stat→parsed minification win. Reports generated without access to the output files carry only statSize; those snapshots are recorded with a source-attributed size basis so comparisons stay honest.
parcel-metrics
Parcel via the official @parcel/reporter-build-metrics reporter, which writes parcel-metrics.json:
npm i -D @parcel/reporter-build-metrics
npx parcel build src/index.html --reporter @parcel/reporter-build-metrics
Fields used:
| Field | Purpose |
|---|---|
bundles[].filePath |
Output bundle (asset name = basename) |
bundles[].size |
Bundle size in bytes |
bundles[].largestAssets[] |
Top source modules per bundle (name, size) |
Caveat — partial module data: the reporter only reports each bundle's ~10 largest source assets, so the module view covers the heavy hitters, not every module. Bundle (asset) sizes are complete.
Why not @parcel/reporter-bundle-buddy? Its output is an import-graph edge list ([{source, target}, …]) with no size data — there is nothing to measure. Pushing it returns a 400 pointing you at build-metrics or the generic format.
generic-json
dendrobundle's own documented format — the adapter of last resort. If your tool isn't listed above, emit this shape from a few lines of script and push it. Unlike the sniffed formats, this payload is schema-validated: malformed entries are rejected with a 400 (so your emitter gets fixed, instead of zeros being stored silently).
{
"dendrobundle": 1,
"sizeBasis": "emitted",
"assets": [
{
"name": "main.js",
"size": 412680,
"gzip": 132000,
"brotli": 118000,
"type": "js",
"chunk": "main",
"initial": true
},
{ "name": "logo.svg", "size": 4200 }
],
"modules": [
{ "name": "src/app.ts", "size": 4812, "chunk": "main" },
{ "name": "node_modules/react/index.js", "size": 7240, "chunk": "main" }
]
}
Field reference:
| Field | Required | Notes |
|---|---|---|
dendrobundle |
✓ | Format marker + schema version. Currently 1. |
sizeBasis |
— | emitted (default) | rendered | source-attributed — what your byte counts mean |
assets[].name |
✓ | ≤1024 chars |
assets[].size |
✓ | Bytes; non-negative integer |
assets[].gzip, assets[].brotli |
— | Compressed sizes in bytes |
assets[].type |
— | js | css | img | other; inferred from the extension when omitted |
assets[].chunk |
— | Logical chunk name (stable across builds — used for cross-build identity) |
assets[].initial |
— | true when loaded on first paint (feeds initialOnly budgets) |
modules[] |
— | Same name/size rules; chunk attributes the module to an asset |
Unknown extra keys are ignored (forward compatible). Caps match the push API: ≤50,000 assets, ≤200,000 modules.
Static sites / no JS bundle
Static-site generators and content sites (Astro content sites, Hugo, Eleventy, Jekyll, a plain dist/ of HTML) often ship little or no client JavaScript, so there is no module graph for a bundler analyzer to measure — rollup-plugin-visualizer and friends emit an essentially empty report.
You can still track these. Don't graph a bundle that isn't there — measure what you actually ship: walk your build's output directory and total every file. This node script (built-ins only, no dependencies) emits the generic-json format above:
// scripts/dist-stats.mjs — usage: node scripts/dist-stats.mjs <distDir> <outFile>
import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { gzipSync } from 'node:zlib';
const [distDir, outFile] = process.argv.slice(2);
if (!distDir || !outFile) {
console.error('usage: node scripts/dist-stats.mjs <distDir> <outFile>');
process.exit(2);
}
const walk = (dir) =>
readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const abs = join(dir, e.name);
return e.isDirectory() ? walk(abs) : e.isFile() ? [abs] : [];
});
const assets = walk(distDir)
.filter((abs) => !abs.endsWith('.map')) // source maps aren't shipped
.map((abs) => {
const buf = readFileSync(abs);
return {
name: relative(distDir, abs).split(sep).join('/'),
size: buf.length,
gzip: gzipSync(buf).length,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
if (assets.length === 0) {
console.error(`no shippable files under "${distDir}" — build missing?`);
process.exit(1);
}
writeFileSync(outFile, JSON.stringify({ dendrobundle: 1, sizeBasis: 'emitted', assets }));
Run it after your build, then push the result like any other stats file:
node scripts/dist-stats.mjs dist stats.json
curl -sS "https://dendrobundle.com/api/push?branch=$(git branch --show-current)&commit=$(git rev-parse HEAD)" \
-H "Authorization: Bearer $DENDROBUNDLE_TOKEN" \
-H "Content-Type: application/json" \
-d @stats.json
This counts total shipped weight — HTML, CSS, JS, images, and fonts — the honest "what a visitor downloads" number, with sizeBasis: "emitted". Point it at the directory a browser is actually served from (for an SSR adapter that's the client output dir, e.g. dist/client, not the server runtime). Projects whose latest snapshot ships (near) no JavaScript are flagged with a no-JS badge on your dashboard.