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.