Skip to content

Bundle Budget PluginΒ #1015

Open
Enhancement
@BioPhoton

Description

@BioPhoton

πŸ“¦ Bundle Budget Plugin

Bundle size tracking for your build artifacts
Track, compare, and prevent bundle size regressions to maintain web performance (e.g. LCP) across product areas.


πŸ§ͺ Reference PR

πŸ‘‰ #??? – BundleBudget Plugin PoC Implementation


Metric

Bundle size over time across key dimensions.
Parsed from --stats-json output and grouped by logical artifact buckets.

Property Value Description
value 132341 Total size of all chunks.
displayValue 13.4 MB Delta compared to base (cached or previous release).
score 1 1 if within budget or unchanged, 0 if regression is detected.

User story

As a developer, I want to track bundle size regressions per product area and route,
so that we can avoid performance regressions and optimize LCP over time.

The plugin should:

  • Analyze stats.json output from Angular/Esbuild builds.
  • Identify and compare main, initial, and lazy chunks.
  • Use chunk input fingerprinting to map renamed chunk files.
  • Group chunk sizes by route/product (e.g., /route-1, /route-2).
  • Store and compare bundle stats across versions/releases.

Integration Requirements

TODO:

  • Scoring (compare against budget/trashhold)
  • identify hashed files
  • unified data

The plugin can be implemented in 2 ways:

  1. Using stats files
  2. Crawling the filesystem

As stats file serve significantly more details and are state of the art when debugging bundle size this issue favours this approach.

Using stats files

stats.json

The stats.json follows the following types

/**
 * Describes the kind of an import. This is a string literal union
 * for better type safety and autocompletion.
 */
export type ImportKind =
  | 'entry-point'
  // An import statement, e.g., `import "foo"`
  | 'import-statement'
  // A require call, e.g., `require("foo")`
  | 'require-call'
  // A dynamic import, e.g., `import("foo")`
  | 'dynamic-import'
  // A require.resolve call, e.g., `require.resolve("foo")`
  | 'require-resolve'
  // An @import rule in CSS
  | 'import-rule'
  // A url() token in CSS
  | 'url-token';

/**
 * Represents a single import record within a file.
 */
export interface MetafileImport {
  /**
   * The path of the imported file, relative to the working directory.
   */
  path: string;
  /**
   * The kind of the import.
   */
  kind: ImportKind;
  /**
   * If true, this dependency is external and was not included in the bundle.
   */
  external?: boolean;
  /**
   * The original path string as it appeared in the source code.
   * Useful for context, especially with aliases or tsconfig paths.
   */
  original?: string;
}

/**
 * Details about a single input file that contributed to the bundle.
 */
export interface MetafileInput {
  /**
   * The size of the file in bytes.
   */
  bytes: number;
  /**
   * A list of all imports within this file.
   */
  imports: MetafileImport[];
}

/**
 * Details about the contribution of a single input file to a specific output file.
 */
export interface MetafileOutputInput {
  /**
   * The number of bytes from this input file that are part of this specific output file.
   */
  bytesInOutput: number;
}

/**
 * Represents a single output file (a "chunk") from the build process.
 */
export interface MetafileOutput {
  /**
   * The total size of this output file in bytes.
   */
  bytes: number;
  /**
   * A map of all input files that were bundled into this output file.
   * The key is the input file path, and the value provides details about its contribution.
   */
  inputs: {
    [path: string]: MetafileOutputInput;
  };
  /**
   * A list of imports that were not bundled and remain as import/require statements in the output.
   * This is common for external packages.
   */
  imports: MetafileImport[];
  /**
   * A list of all exported symbols from this output file.
   */
  exports: string[];
  /**
   * The entry point that corresponds to this output file. This is only present if the
   * output file is an entry point.
   */
  entryPoint?: string;
}

/**
 * The root structure of the metafile JSON generated by esbuild.
 */
export interface EsbuildMetafile {
  /**
   * A map of all input files involved in the build.
   * The key is the file path relative to the working directory.
   */
  inputs: {
    [path: string]: MetafileInput;
  };
  /**
   * A map of all output files generated by the build.
   * The key is the file path relative to the `outdir`.
   */
  outputs: {
    [path: string]: MetafileOutput;
  };
}

Example stats.json

{
  "inputs": {
    "node_modules/@angular/core/fesm2022/untracked-BKcld_ew.mjs": {
      "bytes": 20949,
      "imports": [
        {
          "path": "<runtime>",
          "kind": "import-statement",
          "external": true
        }
      ],
      "format": "esm"
    },
    "node_modules/@angular/core/fesm2022/primitives/di.mjs": {
      "bytes": 1158,
      "imports": [],
      "format": "esm"
    },
    "node_modules/@angular/core/fesm2022/primitives/signals.mjs": {
      "bytes": 3167,
      "imports": [
        {
          "path": "node_modules/@angular/core/fesm2022/untracked-BKcld_ew.mjs",
          "kind": "import-statement",
          "original": "../untracked-BKcld_ew.mjs"
        },
        {
          "path": "node_modules/@angular/core/fesm2022/untracked-BKcld_ew.mjs",
          "kind": "import-statement",
          "original": "../untracked-BKcld_ew.mjs"
        },
        {
          "path": "<runtime>",
          "kind": "import-statement",
          "external": true
        }
      ],
      "format": "esm"
    }
  },
  "outputs": {
    "chunk-MXYP3LXH.js.map": {
      "imports": [],
      "exports": [],
      "inputs": {},
      "bytes": 15799
    },
    "chunk-MXYP3LXH.js": {
      "imports": [
        {
          "path": "chunk-PH2Q42UX.js",
          "kind": "import-statement"
        },
        {
          "path": "chunk-A3IGLJVE.js",
          "kind": "import-statement"
        },
        {
          "path": "chunk-MMWVBYNW.js",
          "kind": "import-statement"
        },
        {
          "path": "chunk-KTVYRR64.js",
          "kind": "import-statement"
        }
      ],
      "exports": [
        "a",
        "b"
      ],
      "inputs": {
        "lib/re-captcha.ts": {
          "bytesInOutput": 165
        },
        "lib/re-captcha.service.ts": {
          "bytesInOutput": 2408
        },
        "lib/re-captcha-noop.service.ts": {
          "bytesInOutput": 342
        },
        "lib/recaptcha.module.ts": {
          "bytesInOutput": 120
        },
        "index.ts": {
          "bytesInOutput": 0
        }
      },
      "bytes": 3345
    },
    "chunk-5HVHEFQE.js.map": {
      "imports": [],
      "exports": [],
      "inputs": {},
      "bytes": 3942
    },
    "chunk-5HVHEFQE.js": {
      "imports": [
        {
          "path": "chunk-J5C35BAI.js",
          "kind": "import-statement"
        },
        {
          "path": "chunk-PUHQEZG3.js",
          "kind": "import-statement"
        },
        {
          "path": "chunk-ZQTK7NVT.js",
          "kind": "import-statement"
        },
        {
          "path": "chunk-KTVYRR64.js",
          "kind": "import-statement"
        }
      ],
      "exports": [
        "a"
      ],
      "inputs": {
        "auth-state.service.ts": {
          "bytesInOutput": 732
        }
      },
      "bytes": 946
    }
  }
}

Crawling the filesystem

TBD

Setup and Requirements

πŸ“¦ Package Dependencies

  • Dev Dependencies:
    • None required, optional CLI runner for local debugging.
  • Optional Dependencies:
    • esbuild – or any tool that emits --metafile/stats.json.

πŸ“ Configuration Files

  • angular.json / vite.config.ts or equivalent – for custom build config.
  • No required config file for the plugin itself.

Implementation details

const pluginConfig = {
generateStats: "nx run app-1:build --stats";
statsPath: "./stats.json"
customAudits: [
  {
    name: 'Main App Core',
    include: ['src/main.ts', 'src/app/**'],
    thresholds: {
      percent: 0.05
    }
  },
  {
    name: 'Auth Module',
    include: ['src/app/auth/**'],
    thresholds: {
      bytes: 102400
    }
  },
  {
    name: 'Lazy Products',
    include: ['src/app/products/**', '!src/app/products/shared/**'],
    thresholds: {
      percent: 0.02
    }
  }
]
}

MD Report

Code PushUp Report

🏷 Category ⭐ Score πŸ›‘ Audits
Bundle Size 🟑 54 13

🏷 Categories

Performance

Bundle size metrics πŸ“– Docs

🟒 Score: 92

πŸ›‘οΈ Audits

Bundle size changes InitialChunk (CodePushUp)

πŸŸ₯ 3 info (score: 0)

Issues

Severity Message Source file Line(s)
ℹ️ info 200 KB src/components/CreateTodo.jsx
ℹ️ info 200 KB src/components/TodoFilter.jsx
ℹ️ info 200 KB src/components/TodoFilter.jsx

Alternative tree

└── index.ts                                  0.0 KB
    └── lib/recaptcha.module.ts               0.1 KB
        β”œβ”€β”€ lib/re-captcha.service.ts         2.4 KB
        β”‚   └── lib/re-captcha.ts             0.2 KB
        └── lib/re-captcha-noop.service.ts    0.3 KB

CI Comment

πŸ”Œ Plugin πŸ›‘οΈ Audit πŸ“ Previous value πŸ“ Current value πŸ”„ Value change
Bundle size Total 🟨 12.0 MB πŸŸ₯ 13.1 MB ↑ +9.2 %
Bundle size Initial Chunks 🟩 6.0 MB 🟩 6.2 MB ↑ +3.3 %
Bundle size Lazy Chunks πŸŸ₯ 6.0 MB πŸŸ₯ 6.9 MB ↑ +15.0 %
Bundle size Main Bundle 🟨 5.0 MB πŸŸ₯ 5.2 MB ↑ +4.0 %
Bundle size Auth Module 🟨 1.1 MB πŸŸ₯ 1.3 MB ↑ +18.2 %
Bundle size Lazy Products 🟩 3.0 MB 🟩 3.1 MB ↑ +3.3 %

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions