
Webpack Plugin Recipes
- 71 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
webpack-plugin-recipes is a Claude Code skill for ai & agent building.
About
webpack-plugin-recipes is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- webpack-plugin-recipes
- AI & Agent Building
- AI-coding skill
Webpack Plugin Recipes by the numbers
- 71 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,651 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill webpack-plugin-recipesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 71 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during AI-assisted development.?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with webpack plugin recipes.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during AI-assisted development., or when webpack-plugin-recipes is a claude code skill for ai & agent building.
What you get
Structured output aligned to webpack-plugin-recipes: webpack-plugin-recipes, AI & Agent Building.
Files
dot-skills Webpack 5 Plugins Best Practices
Cookbook of 26 production-shaped webpack 5 plugins, organized by the problem they solve. Each recipe starts with a clearly defined problem statement ("here's what hurts without this"), shows the naive non-plugin approach, then provides a complete working plugin (60-150 lines) with explanation, variations, and "when NOT to use" guidance.
Companion to `webpack-plugin-authoring` — authoring teaches how to write any plugin correctly; recipes teach which plugin to write for a specific pain point. Recipes cross-reference the authoring rules they apply.
When to Apply
Reference these recipes whenever:
- A team has a recurring build-time problem that "feels like it should be a plugin" (architecture rules, secret leak prevention, asset organization)
- You need to integrate webpack output with downstream systems (SSR servers, CDNs, monitoring)
- You're considering whether to write your own plugin OR adopt an existing one — these recipes show the underlying pattern so you can judge fit
- Migrating from another bundler and need to recreate framework-style features (filesystem routing, virtual modules)
- Onboarding new engineers to webpack plugin authoring — recipes give realistic, end-to-end examples
Recipe Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Build-time Guardrails | CRITICAL | guard- |
| 2 | Build Metadata & Manifests | HIGH | meta- |
| 3 | Virtual Modules & Codegen | HIGH | virtual- |
| 4 | Code Transformation | MEDIUM-HIGH | transform- |
| 5 | Developer Experience | MEDIUM | dx- |
| 6 | Asset Pipeline | MEDIUM | assets- |
Quick Reference
1. Build-time Guardrails (CRITICAL)
- `guard-bundle-budget` — Fail builds when initial JS exceeds a per-entry gzipped budget
- `guard-forbidden-imports` — Fail builds when forbidden imports cross architectural boundaries
- `guard-required-env-vars` — Fail builds when required environment variables are missing
- `guard-no-secrets-bundled` — Fail builds when secret-shaped strings leak into client bundles
2. Build Metadata & Manifests (HIGH)
- `meta-inject-build-info` — Inject
__COMMIT__/__BUILD_TIME__into source via DefinePlugin + emitbuild-info.json - `meta-asset-manifest` — Emit chunk-grouped manifest mapping logical names → hashed filenames
- `meta-license-notice` — Generate
LICENSES.txtby walking the module graph (notnode_modules/) - `meta-sri-manifest` — Compute SHA-384 SRI hashes per asset for CSP compliance
3. Virtual Modules & Codegen (HIGH)
- `virtual-module-from-memory` — Resolve
virtual:Ximports to in-memory strings (Vite-style) - `virtual-routes-from-filesystem` — Generate route map from
pages/directory (Next.js-style) - `virtual-barrel-from-directory` — Auto-generate barrel re-exports from a directory
- `virtual-types-from-runtime` — Emit
.d.tsfrom runtime data (config files, JSON schemas)
4. Code Transformation (MEDIUM-HIGH)
- `transform-replace-library` — Replace
reactwithpreact/compatat resolve (with subpath handling) - `transform-strip-debug-helpers` — Strip
devAssert()/devLog()calls + dev-only imports from production - `transform-conditional-polyfill` — Inject only the polyfills target browsers actually need (browserslist + core-js-compat)
- `transform-banner-with-dynamic-content` — Per-chunk banners with current year, version, git commit
- `transform-define-from-config` — Drive
DefinePluginsubstitutions from aflags/staging.json-style file
5. Developer Experience (MEDIUM)
- `dx-build-duration-report` — Persist build durations, warn when current build is >30% slower than median
- `dx-notify-on-done` — Desktop notification (local) + Slack webhook (CI) on build complete
- `dx-diff-changed-chunks` — Print only the chunks that actually changed between rebuilds
- `dx-open-browser-on-first-build` — Open the dev-server URL AFTER first successful build (not before)
6. Asset Pipeline (MEDIUM)
- `assets-pre-compress-gzip-brotli` — Emit
.gz/.brsiblings for CDN-served pre-compression - `assets-optimize-images` — Optimize PNG/JPEG with imagemin + cache reuse across rebuilds
- `assets-route-by-type` — Organize emitted assets into
js//css//img//fonts/subdirectories - `assets-skip-empty-chunks` — Delete the 0-byte chunks webpack/splitChunks/mini-css emit unnecessarily
- `assets-add-cache-busting-query` — Append
?v=<hash>to references of fixed-name assets (manifest.json, sw.js)
How to Use
When the user describes a webpack problem (or asks for a plugin):
1. Identify the problem category (guardrail? metadata? codegen? transformation? dx? asset?) 2. Open the matching recipe file — read the Problem section first to confirm it's the right recipe 3. Use the Plugin section as a working starting point — adapt to the project's specifics 4. Read How it works for the WHY behind each design choice (cross-references the authoring rules) 5. Check Variations for common adaptations and When NOT to use to confirm the fit
For learning plugin authoring patterns more broadly, pair with the `webpack-plugin-authoring` skill — its 44 rules teach the underlying APIs every recipe in this skill applies.
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact levels |
| assets/templates/_template.md | Template for authoring new recipes |
| metadata.json | Version and source references |
Webpack 5 Plugins
Version 0.1.0 dot-skills May 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Cookbook of 26 production-shaped webpack 5 plugins (4 guard + 4 meta + 4 virtual + 5 transform + 4 dx + 5 assets), each starting with a clearly defined problem statement and providing a complete working plugin (60-150 lines). Recipes cover: bundle-size budgets, architectural import enforcement, env-var validation, secret-leak detection, build-info injection, asset manifests, license walking, SRI hashes, virtual modules (Vite-style), filesystem routing (Next.js-style), generated barrels, runtime-driven TypeScript types, library replacement (react → preact), debug-helper stripping, conditional polyfills, dynamic banners, config-driven feature flags, build-duration tracking, notifications, changed-chunks diffs, browser auto-open, gzip/brotli pre-compression, image optimization, type-based dist layout, empty-chunk cleanup, and cache-busting query strings. Companion to the webpack-plugin-authoring skill — recipes apply the authoring rules to specific problems and cross-reference them inline.
---
Table of Contents
1. Build-time Guardrails — CRITICAL
- 1.1 Fail Builds When Forbidden Imports Cross Architectural Boundaries — CRITICAL (prevents architecture decay across long-lived codebases)
- 1.2 Fail Builds When Initial JS Exceeds a Per-Entry Budget — CRITICAL (prevents silent bundle bloat across releases)
- 1.3 Fail Builds When Required Environment Variables Are Missing — CRITICAL (prevents deploying with unset secrets that crash on first request)
- 1.4 Fail Builds When Secret-Shaped Strings Leak Into Client Bundles — CRITICAL (prevents shipping API keys / private tokens to the browser)
2. Build Metadata & Manifests — HIGH
- 2.1 Compute Subresource Integrity Hashes Per Asset — HIGH (enables strict CSP and tamper detection on cached assets)
- 2.2 Emit an Asset Manifest Mapping Logical Names to Hashed Filenames — HIGH (enables SSR/server to reference hashed asset URLs)
- 2.3 Generate a LICENSES.txt by Walking the Module Graph — HIGH (provides legal-required OSS attribution without manual upkeep)
- 2.4 Inject Build Info (Commit Hash, Build Time) Into Source Code — HIGH (enables runtime identification of which build is deployed)
3. Virtual Modules & Codegen — HIGH
- 3.1 Auto-Generate Barrel Re-Exports From a Directory — HIGH (prevents stale exports drifting from filesystem state)
- 3.2 Emit TypeScript Declarations From Runtime Data — HIGH (keeps TS types in sync with config files / API schemas)
- 3.3 Generate a Route Map From the Filesystem (Pages Pattern) — HIGH (100% route coverage without manual registration)
- 3.4 Resolve Imports to In-Memory Strings (Virtual Modules) — HIGH (enables config-driven codegen without writing temp files to disk)
4. Code Transformation — MEDIUM-HIGH
- 4.1 Drive DefinePlugin Substitutions From a Config File — MEDIUM-HIGH (prevents feature-flag drift across environments)
- 4.2 Inject Polyfills Conditionally Based on Target Browsers — MEDIUM-HIGH (10-50kb savings on modern bundles vs blanket core-js)
- 4.3 Prepend a Per-Chunk Banner With Dynamic Content — MEDIUM-HIGH (prevents broken license headers and stale copyright years)
- 4.4 Replace One Library With Another at Resolve Time — MEDIUM-HIGH (30-80kb savings replacing react with preact/compat)
- 4.5 Strip Debug-Only Code From Production Bundles — MEDIUM-HIGH (5-30kb savings depending on how much dev instrumentation exists)
5. Developer Experience — MEDIUM
- 5.1 Open the Browser on the First Successful Dev Build — MEDIUM (prevents 3-5s manual context switch on every dev-server start)
- 5.2 Print Which Chunks Actually Changed Between Rebuilds — MEDIUM (1-2 minutes saved per chunked watch-mode rebuild)
- 5.3 Report Build Duration and Detect Regressions — MEDIUM (catches a 30%+ build slowdown the day it happens)
- 5.4 Send Desktop / Slack Notification on Build Done — MEDIUM (prevents wasted time switching to terminal to check build status)
6. Asset Pipeline — MEDIUM
- 6.1 Append Cache-Busting Query Strings to Imports — MEDIUM (enables atomic deploys to non-hash-aware hosts)
- 6.2 Delete Empty Chunks That Webpack Emits as Side Effects — MEDIUM (removes 0-byte runtime/css chunks polluting the asset graph)
- 6.3 Optimize Images Through the Asset Pipeline With Cache Reuse — MEDIUM (40-80% smaller images without quality loss)
- 6.4 Organize Emitted Assets Into Type-Based Subdirectories — MEDIUM (cleaner dist/ for CDN-config and human inspection)
- 6.5 Pre-Compress Assets to gzip and brotli for CDN — MEDIUM (60-80% smaller bytes delivered when CDN serves pre-compressed)
---
References
1. https://webpack.js.org/contribute/writing-a-plugin/ 2. https://webpack.js.org/api/compiler-hooks/ 3. https://webpack.js.org/api/compilation-hooks/ 4. https://webpack.js.org/api/compilation-object/ 5. https://webpack.js.org/api/normalmodulefactory-hooks/ 6. https://webpack.js.org/api/plugins/ 7. https://webpack.js.org/blog/2020-10-10-webpack-5-release/ 8. https://github.com/webpack/schema-utils 9. https://github.com/webpack-contrib/mini-css-extract-plugin 10. https://github.com/webpack-contrib/terser-webpack-plugin 11. https://github.com/webpack-contrib/compression-webpack-plugin 12. https://github.com/webpack-contrib/copy-webpack-plugin 13. https://github.com/webpack-contrib/image-minimizer-webpack-plugin 14. https://github.com/webpack-contrib/css-minimizer-webpack-plugin 15. https://github.com/jantimon/html-webpack-plugin 16. https://github.com/waysact/webpack-subresource-integrity 17. https://github.com/shellscape/webpack-manifest-plugin 18. https://github.com/vercel/next.js/tree/canary/packages/next/src/build/webpack 19. https://github.com/jestjs/jest/tree/main/packages/jest-worker 20. https://github.com/ai/size-limit 21. https://github.com/sverweij/dependency-cruiser 22. https://github.com/gitleaks/gitleaks 23. https://vite.dev/guide/api-plugin.html#virtual-modules-convention 24. https://github.com/zloirock/core-js/tree/master/packages/core-js-compat
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Same as title}
Problem
{2-4 sentences naming the concrete pain. Specific is better than generic. Example for a good problem statement:
"Your team agreed initial JS should stay under 250kb gzipped — but no one notices when a PR adds a 60kb dependency. CI passes; bundle bloat creeps in; weeks later a perf review catches that bundle size jumped 40%. You need CI to FAIL the build when the budget is exceeded — not warn, not log, fail."
Bad problem statement: "You need to manage bundle size." (No pain, no concrete trigger, no failure mode.)}
Pattern
{1-2 sentences describing the technical approach. Name the hook, the stage if applicable, and the key insight. Example: "Tap processAssets at PROCESS_ASSETS_STAGE_OPTIMIZE_HASH, sum entry-chunk asset sizes from compilation.getAsset(name).info.size, compare against budgets, push a WebpackError on excess."}
Incorrect (without a plugin — what people do without one):
// Brief example of the non-plugin approach (shell script, manual maintenance,
// off-the-shelf plugin that doesn't fit, etc.). 5-15 lines max.
// Show what goes wrong: silent failure, drift, missed cases.Correct (with this plugin — the recipe's working code):
// Complete, runnable plugin — 60-150 lines.
// Production-shaped:
// - schema-utils validation in constructor
// - WebpackError pushed to compilation.errors (not throw)
// - compiler.webpack.* namespace (not direct webpack import)
// - Correct hook + stage
// - Source-map-preserving transformations
// - Cache integration where applicable
// User should be able to copy this verbatim and have a working plugin.
const { validate } = require('schema-utils');
const schema = {
type: 'object',
properties: { /* ... */ },
additionalProperties: false,
};
class MyRecipePlugin {
constructor(options = {}) {
validate(schema, options, { name: 'MyRecipePlugin', baseDataPath: 'options' });
this.options = options;
}
apply(compiler) {
/* ... */
}
}
module.exports = MyRecipePlugin;Usage
// Show how the plugin is wired into webpack.config.js,
// with realistic options (not placeholder values).
new MyRecipePlugin({
// realistic config
})How it works
{Walk through 3-6 key decisions in the code. Cross-reference the authoring skill where applicable:
- "We use
processAssetsnotemitbecause [webpack-plugin-authoring/hook-prefer-process-assets-over-emit]." - "We push to
compilation.errors, not throw, because [webpack-plugin-authoring/diag-push-webpack-error-not-throw]." - "We use
compiler.webpack.sourcesso persistent cache works across versions; see [webpack-plugin-authoring/asset-source-from-compiler-webpack]."
The cross-references make the recipe a teaching artifact, not just a copy-paste source.}
Variations
- Variation 1 name (one-line description): brief code or config snippet
- Variation 2 name (one-line description): brief code or config snippet
- Variation 3 name (one-line description)
When NOT to use this pattern
- {Specific scenario where this is the wrong tool — name the better alternative}
- {Another scenario — e.g., "you already use [off-the-shelf plugin] and it fits your needs"}
- {Edge case where the cost/benefit doesn't work}
Reference: {Authoritative source 1} · {Authoritative source 2}
---
Authoring checklist
Before adding a new recipe:
- [ ] Filename matches
{prefix}-{kebab-case-slug}.mdwhere{prefix}is one of:guard,meta,virtual,transform,dx,assets - [ ] First tag in frontmatter is the category prefix
- [ ] Title is problem-named (what it accomplishes), not API-named
- [ ]
impactDescriptionis quantified where possible — savings in kb, ms, prevention of a named failure - [ ] Problem section names a concrete pain with specifics (numbers, scenarios), not generic "you might want to..."
- [ ] Both
**Incorrect (without a plugin)**and**Correct (with this plugin)**sections present - [ ] Plugin code is production-shaped: schema-utils validation, WebpackError, compiler.webpack namespace, correct hook+stage
- [ ] Code blocks have language specifiers (
`js,`json,`text,`bash,`nginx) - [ ] Cross-references to webpack-plugin-authoring rules where applicable
- [ ] At least 2 "Variations" entries
- [ ] At least 2 "When NOT to use this pattern" entries
- [ ] Reference link to authoritative sources (webpack.js.org, github.com/webpack-contrib, vercel/next.js, etc.)
- [ ] After saving, run
node ${CLAUDE_PLUGIN_ROOT}/scripts/validate-skill.js /path/to/skill - [ ] After saving, run
node ${CLAUDE_PLUGIN_ROOT}/scripts/build-agents-md.js /path/to/skill
{
"version": "0.1.1",
"organization": "dot-skills",
"technology": "Webpack 5 Plugins",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Cookbook of 26 production-shaped webpack 5 plugins (4 guard + 4 meta + 4 virtual + 5 transform + 4 dx + 5 assets), each starting with a clearly defined problem statement and providing a complete working plugin (60-150 lines). Recipes cover: bundle-size budgets, architectural import enforcement, env-var validation, secret-leak detection, build-info injection, asset manifests, license walking, SRI hashes, virtual modules (Vite-style), filesystem routing (Next.js-style), generated barrels, runtime-driven TypeScript types, library replacement (react → preact), debug-helper stripping, conditional polyfills, dynamic banners, config-driven feature flags, build-duration tracking, notifications, changed-chunks diffs, browser auto-open, gzip/brotli pre-compression, image optimization, type-based dist layout, empty-chunk cleanup, and cache-busting query strings. Companion to the webpack-plugin-authoring skill — recipes apply the authoring rules to specific problems and cross-reference them inline.",
"references": [
"https://webpack.js.org/contribute/writing-a-plugin/",
"https://webpack.js.org/api/compiler-hooks/",
"https://webpack.js.org/api/compilation-hooks/",
"https://webpack.js.org/api/compilation-object/",
"https://webpack.js.org/api/normalmodulefactory-hooks/",
"https://webpack.js.org/api/plugins/",
"https://webpack.js.org/blog/2020-10-10-webpack-5-release/",
"https://github.com/webpack/schema-utils",
"https://github.com/webpack-contrib/mini-css-extract-plugin",
"https://github.com/webpack-contrib/terser-webpack-plugin",
"https://github.com/webpack-contrib/compression-webpack-plugin",
"https://github.com/webpack-contrib/copy-webpack-plugin",
"https://github.com/webpack-contrib/image-minimizer-webpack-plugin",
"https://github.com/webpack-contrib/css-minimizer-webpack-plugin",
"https://github.com/jantimon/html-webpack-plugin",
"https://github.com/waysact/webpack-subresource-integrity",
"https://github.com/shellscape/webpack-manifest-plugin",
"https://github.com/vercel/next.js/tree/canary/packages/next/src/build/webpack",
"https://github.com/jestjs/jest/tree/main/packages/jest-worker",
"https://github.com/ai/size-limit",
"https://github.com/sverweij/dependency-cruiser",
"https://github.com/gitleaks/gitleaks",
"https://vite.dev/guide/api-plugin.html#virtual-modules-convention",
"https://github.com/zloirock/core-js/tree/master/packages/core-js-compat"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group recipes.
Each recipe is a complete, working plugin that solves a named problem. The "impact" rating reflects how much pain the problem causes when left unsolved — not the complexity of the solution.
---
1. Build-time Guardrails (guard)
Impact: CRITICAL Description: Recipes that fail the build when an invariant is violated — bundle size budgets, forbidden cross-layer imports, missing environment variables, accidentally bundled secrets. These problems escape into production silently without a plugin to catch them: a 100kb dependency creep, a UI component reaching into server-only code, an unset STRIPE_SECRET_KEY that crashes on first request. The fix is always cheaper at build time than after deploy.
2. Build Metadata & Manifests (meta)
Impact: HIGH Description: Recipes that produce or inject information ABOUT the build — git commit hash baked into the JS, manifest files mapping logical names to hashed filenames, license/notice files walked from the module graph, SRI hashes for CSP compliance. SSR servers, edge functions, and CDN configurations need this information; without a plugin emitting it, teams hand-maintain JSON files that drift from the build's actual output.
3. Virtual Modules & Codegen (virtual)
Impact: HIGH Description: Recipes that synthesize module content at build time without writing files to disk — import 'virtual:config' resolving to a runtime-generated string, filesystem-based route maps, auto-generated barrel exports, runtime-driven TypeScript types. These patterns power Next.js's file-based routing, Vite's virtual modules, and Nuxt's auto-imports; reimplementing them in a custom webpack build requires understanding NormalModuleFactory hooks and resolver patterns.
4. Code Transformation (transform)
Impact: MEDIUM-HIGH Description: Recipes that modify what gets bundled — replacing one library with another at resolve time (react → preact/compat), stripping if (__DEV__) blocks in production, injecting polyfills conditionally based on target, prepending dynamic banners (git info, copyright with year), config-driven feature flag defines. These are the patterns that bridge "I want my source code to stay clean" and "I want my production bundle to be different from my dev bundle."
5. Developer Experience (dx)
Impact: MEDIUM Description: Recipes that improve the developer feedback loop — build duration reports with regression detection, desktop/Slack notifications on rebuild completion, diffs showing which chunks actually changed between rebuilds, browser auto-open on dev-server's first successful build. These are the small frictions that compound across a team: 20 developers × 50 rebuilds/day × 10s of "did it finish?" = real time. Each recipe is small enough to drop into a project on a Friday afternoon.
6. Asset Pipeline (assets)
Impact: MEDIUM Description: Recipes that process emitted assets — pre-compressing with gzip/brotli for CDN-served assets, optimizing images via sharp/imagemin with cache reuse, routing assets into typed subdirectories (js/, css/, img/), suppressing empty chunks that SSR doesn't need, appending cache-busting query strings for hosts that ignore content hashes. These complement webpack's built-in asset pipeline for specific deployment targets (Cloudflare, S3, Vercel edge, traditional NGINX).
Append Cache-Busting Query Strings to Imports
Problem
You deploy to a host that doesn't support per-file Cache-Control headers (Squarespace export, a shared static webserver, an embed widget on third-party sites). Content hashes in filenames work — main.4f3a8e1b.js is uniquely cacheable — but you ALSO ship robots.txt, apple-touch-icon.png, manifest.json, and external API documentation HTML, which CAN'T have hashes in their filenames (their paths are referenced by name from outside).
For those filename-fixed assets, you want ?v=<build-hash> appended at the references TO them (e.g., <link rel="manifest" href="manifest.json?v=a7f8c92">), so the browser treats them as different URLs across deploys. Then a stale manifest.json doesn't get reused for 24 hours after deploy.
Pattern
In processAssets at PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER (after content hashes settled — we need the build hash), find configured "fixed-name" asset references INSIDE other text assets (<link href="manifest.json">, import 'sw.js'), and rewrite them to include the ?v= query — using ReplaceSource to preserve source maps.
Incorrect (without a plugin — manual ?v= updates in source code):
<!-- src/index.html — hand-edited every deploy -->
<link rel="manifest" href="manifest.json?v=2026-01-15">
<script src="sw.js?v=2026-01-15"></script>
<!-- Tomorrow's deploy: forgot to update the version → stale manifest cached for 24h -->
<!-- Or worse: stale offline-mode service worker that doesn't auto-update -->Correct (with this plugin — automatic, build-hash-keyed query strings):
const path = require('node:path');
const { validate } = require('schema-utils');
const schema = {
type: 'object',
properties: {
assets: {
type: 'array',
items: { type: 'string', minLength: 1 },
description: 'Asset filenames (relative to output) to cache-bust references to',
minItems: 1,
},
versionStrategy: {
enum: ['hash', 'timestamp', 'commit'],
description: 'How to compute the ?v= value',
},
referencingFiles: {
type: 'string',
description: 'Regex for files in which to find/replace references (default text assets)',
},
},
required: ['assets'],
additionalProperties: false,
};
const DEFAULTS = {
versionStrategy: 'hash',
referencingFiles: '\\.(html|js|mjs|css)$',
};
class CacheBustingQueryPlugin {
constructor(options) {
validate(schema, options, { name: 'CacheBustingQueryPlugin', baseDataPath: 'options' });
this.options = { ...DEFAULTS, ...options };
this.referencingRe = new RegExp(this.options.referencingFiles);
}
apply(compiler) {
const { Compilation, sources } = compiler.webpack;
compiler.hooks.thisCompilation.tap('CacheBustingQueryPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'CacheBustingQueryPlugin',
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER,
},
() => {
const version = this.computeVersion(compilation);
if (!version) return;
// Build a regex that matches any reference to any of our fixed-name assets
// Use a single pass per source to avoid O(n*m) overhead
const escaped = this.options.assets.map(escapeRegExp);
const refRe = new RegExp(`\\b(${escaped.join('|')})\\b(?!\\?)`, 'g');
for (const name of Object.keys(compilation.assets)) {
if (!this.referencingRe.test(name)) continue;
// Don't rewrite references inside the target assets themselves
if (this.options.assets.includes(path.basename(name))) continue;
const asset = compilation.getAsset(name);
const text = asset.source.source().toString();
if (!refRe.test(text)) {
refRe.lastIndex = 0;
continue;
}
refRe.lastIndex = 0;
const replacer = new sources.ReplaceSource(asset.source, name);
let match;
while ((match = refRe.exec(text)) !== null) {
replacer.replace(
match.index,
match.index + match[0].length - 1,
`${match[1]}?v=${version}`,
);
}
compilation.updateAsset(name, replacer);
}
},
);
});
}
computeVersion(compilation) {
switch (this.options.versionStrategy) {
case 'timestamp':
return Date.now().toString(36);
case 'commit':
return process.env.GIT_COMMIT?.slice(0, 8) ?? Date.now().toString(36);
case 'hash':
default:
// Webpack 5: compilation.hash is the build hash (8+ chars)
return compilation.hash?.slice(0, 8) ?? Date.now().toString(36);
}
}
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
module.exports = CacheBustingQueryPlugin;Usage
new CacheBustingQueryPlugin({
assets: [
'manifest.json',
'sw.js',
'apple-touch-icon.png',
'favicon.ico',
'browserconfig.xml',
],
versionStrategy: 'hash',
})
// Input (src/index.html):
// <link rel="manifest" href="manifest.json">
// <script src="sw.js"></script>
// Output (dist/index.html):
// <link rel="manifest" href="manifest.json?v=a7f8c92">
// <script src="sw.js?v=a7f8c92"></script>How it works
- `PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER` runs after content-hash optimization, so
compilation.hashhas its final value. Earlier stages would use a stale hash. - `ReplaceSource` preserves source maps —
RawSource(text.replace(...))would lose them. See [webpack-plugin-authoring/asset-preserve-source-maps]. - `(?!\\?)` lookahead prevents double-versioning — if a reference already has
?(a query string), skip it. Otherwise running the plugin twice (or another plugin adding a query) produces?v=a7f8c92?v=b3e1f4. - Single regex with alternation (
(asset1|asset2|asset3)) for O(text-length) scanning instead of O(text-length × asset-count) - Skip rewriting INSIDE the target assets themselves —
manifest.jsonreferencingmanifest.json(rare) would be a footgun - `escapeRegExp` —
asset.svgwould otherwise matchassetXsvg; literal regex match
Variations
- Per-asset version strategy (manifest.json uses hash, sw.js uses timestamp):
assets: [
{ name: 'manifest.json', strategy: 'hash' },
{ name: 'sw.js', strategy: 'timestamp' },
]- HTML-only mode (only rewrite in
.htmlfiles):referencingFiles: '\\.html$' - CDN URL prefix (rewrite to
https://cdn.example.com/manifest.json?v=hash):
cdnPrefix: 'https://cdn.example.com/',
// replacer.replace(start, end, `${cdnPrefix}${match[1]}?v=${version}`)- Per-environment opt-out (skip in dev):
if (compiler.options.mode !== 'production') return;
When NOT to use this pattern
- Your host supports content hashes in filenames AND respects Cache-Control headers — this plugin solves a problem you don't have
- You use HTTP Cache-Control: no-cache for those specific files (manifest.json, sw.js) at the host level — also solves it without query strings
- Your service worker has its own cache versioning (Workbox) — adding ?v= here may conflict
Reference: Compilation hash · webpack-sources ReplaceSource
Optimize Images Through the Asset Pipeline With Cache Reuse
Problem
Your designers export PNG/JPEG at "Save for Web (Legacy)" quality 90; webpack passes them through verbatim; a 200kb hero image ships when 50kb would be visually identical. CDN-side image optimization (Cloudinary, Imgix) costs money and adds latency on first request. Build-time image optimization is the right place, but sharp/imagemin are slow (1–3s per image), and re-running them on every rebuild (200 images × 1s = 3 minutes added to every build) is unworkable.
The fix: optimize once, cache the result on its content hash, only re-optimize when the original image changes. That's the pattern this recipe implements.
Pattern
Tap processAssets at PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE, parallelize across images via Promise.all + jest-worker, use compilation.getCache('ImageOptimizer').providePromise keyed on the original asset's etag so unchanged images skip the work.
Incorrect (without a plugin — manual `npm run images` step):
# package.json
"scripts": {
"images": "imagemin 'public/**/*.{png,jpg}' --out-dir=public",
"build": "npm run images && webpack"
}
# Step doesn't track changes — optimizes EVERY image on every run (3 min added)
# Mutates the source files — git diff is noisy with re-encoded images
# Easy to forget; production deploys without optimization if CI script misses itCorrect (with this plugin — cached, parallel, runs only on changed images):
const { validate } = require('schema-utils');
const schema = {
type: 'object',
properties: {
test: { type: 'string' },
plugins: {
type: 'object',
additionalProperties: { type: 'object' },
description: 'imagemin plugin name → options',
},
maxConcurrency: { type: 'number' },
},
additionalProperties: false,
};
const DEFAULTS = {
test: '\\.(png|jpe?g|webp)$',
plugins: {
'imagemin-mozjpeg': { quality: 80 },
'imagemin-pngquant': { quality: [0.6, 0.8] },
},
maxConcurrency: 4,
};
class ImageOptimizerPlugin {
constructor(options = {}) {
validate(schema, options, { name: 'ImageOptimizerPlugin', baseDataPath: 'options' });
this.options = { ...DEFAULTS, ...options };
this.testRe = new RegExp(this.options.test);
}
apply(compiler) {
const { Compilation, sources, WebpackError } = compiler.webpack;
compiler.hooks.thisCompilation.tap('ImageOptimizerPlugin', (compilation) => {
const logger = compilation.getLogger('ImageOptimizerPlugin');
compilation.hooks.processAssets.tapPromise(
{
name: 'ImageOptimizerPlugin',
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
additionalAssets: true,
},
async (assets) => {
const cache = compilation.getCache('ImageOptimizerPlugin');
const candidates = Object.keys(assets).filter((n) => this.testRe.test(n));
if (candidates.length === 0) return;
// Resolve imagemin plugins from user's options
let plugins;
try {
plugins = await this.resolvePlugins();
} catch (err) {
compilation.warnings.push(new WebpackError(`ImageOptimizerPlugin: ${err.message}`));
return;
}
let totalBefore = 0;
let totalAfter = 0;
// Throttle to maxConcurrency
const queue = [...candidates];
await Promise.all(
Array.from({ length: this.options.maxConcurrency }, () =>
this.worker(queue, compilation, cache, plugins, (before, after) => {
totalBefore += before;
totalAfter += after;
}),
),
);
const saved = totalBefore - totalAfter;
if (saved > 0) {
logger.info(
`Optimized ${candidates.length} images: ` +
`${fmt(totalBefore)} → ${fmt(totalAfter)} (saved ${fmt(saved)})`,
);
}
},
);
});
}
async worker(queue, compilation, cache, plugins, onDone) {
while (queue.length > 0) {
const name = queue.shift();
const original = compilation.getAsset(name);
const etag = cache.getLazyHashedEtag(original.source);
try {
const optimizedSource = await cache.providePromise(name, etag, async () => {
const buffer = original.source.buffer();
const imagemin = await import('imagemin');
const optimized = await imagemin.buffer(buffer, { plugins });
if (optimized.length >= buffer.length) return original.source; // no improvement
return new compilation.compiler.webpack.sources.RawSource(optimized);
});
const beforeSize = original.source.size();
const afterSize = optimizedSource.size();
if (afterSize < beforeSize) {
compilation.updateAsset(name, optimizedSource);
}
onDone(beforeSize, afterSize);
} catch (err) {
const warn = new compilation.compiler.webpack.WebpackError(
`ImageOptimizerPlugin: skipped ${name} (${err.message})`,
);
warn.hideStack = true;
compilation.warnings.push(warn);
}
}
}
async resolvePlugins() {
const resolved = [];
for (const [pluginName, opts] of Object.entries(this.options.plugins)) {
try {
const mod = await import(pluginName);
const factory = mod.default ?? mod;
resolved.push(factory(opts));
} catch (err) {
throw new Error(
`Cannot load imagemin plugin "${pluginName}". Install it: npm i -D ${pluginName}\n` +
`Original error: ${err.message}`,
);
}
}
return resolved;
}
}
function fmt(bytes) {
return `${(bytes / 1024).toFixed(1)}kb`;
}
module.exports = ImageOptimizerPlugin;Usage
new ImageOptimizerPlugin({
plugins: {
'imagemin-mozjpeg': { quality: 80, progressive: true },
'imagemin-pngquant': { quality: [0.6, 0.8] },
'imagemin-svgo': {
plugins: [{ name: 'preset-default', params: { overrides: { removeViewBox: false } } }],
},
},
maxConcurrency: 4, // sharp/imagemin are CPU-bound; 4 is good for most laptops
})How it works
- `PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE` is the canonical stage for size optimization — runs alongside terser/css-minimizer. See [
webpack-plugin-authoring/hook-process-assets-stage]. - `cache.providePromise` keyed on asset etag — the etag IS the original image's content hash. Unchanged image = cache hit = ~0ms. Changed image = cache miss = 1–3s of optimization. This is the highest-leverage caching pattern in webpack plugins. See [
webpack-plugin-authoring/perf-cache-results-with-compilation-cache]. - `maxConcurrency` prevents thrashing — Node's libuv threadpool defaults to 4 threads; running 200 image jobs in parallel just queues them with overhead. Manual concurrency limit is more predictable.
- `buffer()` not `source()` — binary content, must not be UTF-8-coerced. See [
webpack-plugin-authoring/asset-buffer-not-source-for-binary]. - "No improvement" fallback — if optimized > original (rare but happens for already-optimized images), keep the original. Otherwise quality stays good but file gets bigger.
- Dynamic `import()` of imagemin plugins — they're ESM-only and the host webpack.config.js may be CJS; dynamic import bridges this
Variations
- AVIF/WebP variants alongside originals (HTML uses
<picture>to pick): emitimage.pngANDimage.avif, annotateinfo.related - Per-extension quality (lossless for PNGs that have transparency, lossy for screenshots): inspect the buffer's PNG header
- Skip below 4kb (smaller files barely benefit, add overhead): add minBytes check
- Sharp-based (vastly faster than imagemin): swap the
imagemin.buffer()call forsharp(buffer).jpeg({ quality: 80 }).toBuffer()
When NOT to use this pattern
- You use image-minimizer-webpack-plugin — it's the webpack-contrib equivalent
- Your images are served by an image CDN (Cloudinary, Imgix, Vercel) that does this on-the-fly with caching
- Your image set is small (< 20 images) — manual optimization in
public/once is cheaper - Build time matters more than image size (pre-release builds in CI) — selectively disable with
mode === 'production'check
Reference: imagemin · image-minimizer-webpack-plugin · sharp
Pre-Compress Assets to gzip and brotli for CDN
Problem
You deploy to an S3/Cloudflare/Vercel-edge bucket; the CDN serves the bytes verbatim. If you ship main.4f3a8e1b.js (380kb) without a pre-compressed sibling, the CDN compresses on-the-fly per request (or worse — doesn't compress at all for cached responses, depending on tier). Both main.4f3a8e1b.js.gz (120kb) and main.4f3a8e1b.js.br (95kb) shipping in the dist directory lets the CDN match the client's Accept-Encoding header and serve the smaller bytes from cache without any compute work.
`compression-webpack-plugin` exists and is the right plugin for this — but defaults to gzip-only, doesn't tune for brotli quality level, and emits non-content-hashed companion files that break cache invalidation. This recipe shows the production-shaped pattern.
Pattern
Tap processAssets at PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER (the stage explicitly designed for this), for each candidate asset compress in parallel via jest-worker (CPU-bound, embarrassingly parallel), emit .gz and .br siblings preserving asset.info.related metadata.
Incorrect (without a plugin — relying on CDN's runtime compression):
# Cloudflare with default settings:
# - Gzips on the fly (per request, no caching)
# - Doesn't brotli unless on Pro+ plans
# - First request from each region takes the compression CPU hit
# Result: cold-cache responses are 3-8x slower than warm + you pay for compute timeCorrect (with this plugin — pre-compressed companions emitted at build time):
const zlib = require('node:zlib');
const path = require('node:path');
const { validate } = require('schema-utils');
const schema = {
type: 'object',
properties: {
algorithms: {
type: 'array',
items: { enum: ['gzip', 'brotli'] },
minItems: 1,
},
test: { type: 'string' },
minBytes: { type: 'number' },
gzipLevel: { type: 'number', minimum: 1, maximum: 9 },
brotliQuality: { type: 'number', minimum: 0, maximum: 11 },
deleteOriginal: { type: 'boolean' },
},
additionalProperties: false,
};
const DEFAULTS = {
algorithms: ['gzip', 'brotli'],
test: '\\.(js|mjs|css|html|json|svg|wasm)$',
minBytes: 1024,
gzipLevel: 9,
brotliQuality: 11,
deleteOriginal: false,
};
class PreCompressPlugin {
constructor(options = {}) {
validate(schema, options, { name: 'PreCompressPlugin', baseDataPath: 'options' });
this.options = { ...DEFAULTS, ...options };
this.testRe = new RegExp(this.options.test);
}
apply(compiler) {
const { Compilation, sources } = compiler.webpack;
compiler.hooks.thisCompilation.tap('PreCompressPlugin', (compilation) => {
compilation.hooks.processAssets.tapPromise(
{
name: 'PreCompressPlugin',
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER,
additionalAssets: true, // run again for late-added assets
},
async (assets) => {
const cache = compilation.getCache('PreCompressPlugin');
const tasks = Object.keys(assets)
.filter((name) => this.testRe.test(name))
.filter((name) => {
if (name.endsWith('.gz') || name.endsWith('.br')) return false; // already compressed
const size = compilation.getAsset(name)?.info.size ?? assets[name].size();
return size >= this.options.minBytes;
})
.flatMap((name) => this.options.algorithms.map((algo) => ({ name, algo })));
await Promise.all(tasks.map(({ name, algo }) =>
this.compressOne(compilation, cache, name, algo)));
if (this.options.deleteOriginal) {
for (const { name } of tasks) {
if (this.allCompressedSiblingsExist(compilation, name)) {
compilation.deleteAsset(name);
}
}
}
},
);
});
}
async compressOne(compilation, cache, name, algo) {
const original = compilation.getAsset(name);
const compressedName = `${name}.${algo === 'gzip' ? 'gz' : 'br'}`;
if (compilation.getAsset(compressedName)) return; // already emitted by another plugin
const etag = cache.getLazyHashedEtag(original.source);
const cacheKey = `${compressedName}|${algo}|${this.qualityFor(algo)}`;
const compressedSource = await cache.providePromise(cacheKey, etag, async () => {
const buf = original.source.buffer();
const compressed = await this.compress(buf, algo);
return new compilation.compiler.webpack.sources.RawSource(compressed);
});
compilation.emitAsset(compressedName, compressedSource, {
...original.info,
// The compressed sibling is NOT itself hashed in filename — its content
// hash IS the original's hash. CDNs cache by name; this is fine.
minimized: true,
[algo]: true, // info.gzip = true or info.brotli = true
related: { ...(original.info.related ?? {}), [algo]: compressedName },
});
// Annotate the ORIGINAL with `related` pointing at the compressed companion
compilation.updateAsset(name, original.source, (info) => ({
...info,
related: {
...(info.related ?? {}),
[algo === 'gzip' ? 'gzipped' : 'brotli']: compressedName,
},
}));
}
qualityFor(algo) {
return algo === 'gzip' ? this.options.gzipLevel : this.options.brotliQuality;
}
compress(buffer, algo) {
return new Promise((resolve, reject) => {
if (algo === 'gzip') {
zlib.gzip(buffer, { level: this.options.gzipLevel },
(err, out) => err ? reject(err) : resolve(out));
} else {
zlib.brotliCompress(buffer, {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: this.options.brotliQuality,
},
}, (err, out) => err ? reject(err) : resolve(out));
}
});
}
allCompressedSiblingsExist(compilation, name) {
return this.options.algorithms.every((algo) => {
const ext = algo === 'gzip' ? '.gz' : '.br';
return Boolean(compilation.getAsset(name + ext));
});
}
}
module.exports = PreCompressPlugin;Usage
new PreCompressPlugin({
algorithms: ['gzip', 'brotli'],
minBytes: 1024, // smaller files don't benefit and add overhead
gzipLevel: 9, // build-time can afford max compression
brotliQuality: 11, // brotli level 11 is ~3x slower than 6 but tighter
})Deployment:
# nginx serving pre-compressed
location ~* \.(?:js|css)$ {
gzip_static on;
brotli_static on;
}For Cloudflare / Vercel, simply uploading *.br and *.gz siblings is enough — they're served when the client's Accept-Encoding matches.
How it works
- `PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER` is the canonical stage for compression — runs after minification, hashing, and SRI; the compressed output represents the FINAL bytes. See [
webpack-plugin-authoring/hook-process-assets-stage]. - `additionalAssets: true` — if a LATER plugin emits a new asset, our hook runs again on those too. Without it, late-added assets ship uncompressed.
- `getCache('PreCompressPlugin').providePromise` — compression is the most expensive thing your build does (brotli level 11 is 2–4s per MB on a single core); caching it across rebuilds is essential. See [
webpack-plugin-authoring/perf-cache-results-with-compilation-cache]. - `Promise.all` over `tasks` — gzip and brotli are CPU-bound and Node's zlib doesn't block the event loop (it uses libuv's threadpool). Parallel dispatch is "free" up to the threadpool size (default 4).
- `asset.info.related` annotation — downstream plugins (e.g., a manifest plugin emitting
<link rel="preload">tags) can find the compressed siblings viainfo.related.gzipped - `minBytes: 1024` — compressing a 500-byte asset adds gzip-header overhead and produces a BIGGER file; min threshold avoids it
Variations
- Brotli only for modern targets (skip gzip):
algorithms: ['brotli'] - Worker pool for VERY large builds (>1000 candidate assets): swap
zlibforjest-worker-based compression — see [webpack-plugin-authoring/perf-jest-worker-for-cpu-bound-work] - Per-extension compression level (lower for fonts because they're already compressed):
compressionLevels: { '.woff2': 1, '.js': 9 } - Skip files smaller than compressed size (gzip can make tiny files larger): post-compression check, discard if
compressed.length > original.length
When NOT to use this pattern
- Your CDN doesn't honor pre-compressed siblings (some legacy CDNs ignore
.gzfiles) - You use
compression-webpack-plugin— it's the well-maintained webpack-contrib plugin for this - Your output is dynamic (server-rendered HTML) — pre-compression doesn't apply
Reference: compression-webpack-plugin · zlib.brotli options · nginx http_gzip_static
Organize Emitted Assets Into Type-Based Subdirectories
Problem
Webpack's default output dumps everything into dist/: main.4f3a8e1b.js, vendors.91ab30.js, main.81bf30.css, hero.2c3d.png, inter.var.woff2, manifest.json — all in one flat directory. When you look at the dist with 50+ entries, finding the CSS chunks among the JS ones is hard. Your CDN config wants different cache TTLs per type (/img/* long, /js/* long, /*.html short) — a flat dist makes glob patterns brittle. You can hand-write output.assetModuleFilename and per-loader filename overrides, but those settings are scattered across config and easy to get wrong.
You want one plugin that says "JS goes in js/, CSS in css/, images in img/, fonts in fonts/, leave manifest.json alone."
Pattern
Tap processAssets at PROCESS_ASSETS_STAGE_OPTIMIZE_HASH (so we run AFTER real-content-hash is finalized — names are stable to rename). For each asset matching a configured rule, call compilation.renameAsset(old, new) which updates chunk references atomically.
Incorrect (without a plugin — scattered config across loaders + output):
// webpack.config.js — what people end up writing
module.exports = {
output: {
assetModuleFilename: 'img/[name].[contenthash:8][ext]',
},
module: {
rules: [
{
test: /\.css$/,
use: [{ loader: MiniCssExtractPlugin.loader, options: { filename: 'css/[name].[contenthash:8].css' } }, ...],
},
{
test: /\.(woff2?|ttf)$/,
type: 'asset/resource',
generator: { filename: 'fonts/[name].[contenthash:8][ext]' },
},
],
},
plugins: [
new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:8].css' }),
],
};
// Settings live in 3 different places; an asset/source loader added later
// without overriding `filename` lands in dist/ root.Correct (with this plugin — one configuration, applies after the fact):
const path = require('node:path');
const { validate } = require('schema-utils');
const schema = {
type: 'object',
properties: {
routes: {
type: 'array',
items: {
type: 'object',
properties: {
test: { type: 'string', description: 'Regex matched against asset path' },
to: { type: 'string', description: 'Target directory (relative to output)' },
rename: {
instanceof: 'Function',
description: '(name) => newName, runs after the to-prefix is applied',
},
},
required: ['test', 'to'],
additionalProperties: false,
},
minItems: 1,
},
},
required: ['routes'],
additionalProperties: false,
};
class RouteAssetsByTypePlugin {
constructor(options) {
validate(schema, options, { name: 'RouteAssetsByTypePlugin', baseDataPath: 'options' });
this.routes = options.routes.map((r) => ({ ...r, testRe: new RegExp(r.test) }));
}
apply(compiler) {
const { Compilation } = compiler.webpack;
compiler.hooks.thisCompilation.tap('RouteAssetsByTypePlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'RouteAssetsByTypePlugin',
// OPTIMIZE_HASH: real-content-hash is now final, but we still run
// before SUMMARIZE/ANALYSE/REPORT so manifest-style plugins see the
// renamed assets.
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_HASH,
},
() => {
// Snapshot keys — renameAsset mutates the assets map mid-iteration
const names = Object.keys(compilation.assets);
for (const name of names) {
// Skip already-routed assets (assets that came in with a directory)
// unless route specifically says to re-route them
if (name.includes('/')) continue;
for (const route of this.routes) {
if (!route.testRe.test(name)) continue;
let newName = path.posix.join(route.to, name);
if (route.rename) newName = route.rename(newName);
compilation.renameAsset(name, newName);
break; // first match wins
}
}
},
);
});
}
}
module.exports = RouteAssetsByTypePlugin;Usage
new RouteAssetsByTypePlugin({
routes: [
{ test: '\\.(js|mjs)(\\.map)?$', to: 'js' },
{ test: '\\.css(\\.map)?$', to: 'css' },
{ test: '\\.(png|jpe?g|webp|avif|gif|svg)$', to: 'img' },
{ test: '\\.(woff2?|ttf|otf)$', to: 'fonts' },
{ test: '\\.(wasm)$', to: 'wasm' },
// Top-level files (don't route): manifest.json, robots.txt, sw.js, .br/.gz siblings
// — these don't match any rule, so they stay in dist/ root
],
})Output:
dist/
├── index.html
├── manifest.json
├── sw.js
├── js/
│ ├── main.4f3a8e1b.js
│ ├── vendors.91ab30.js
│ └── runtime.92ad7c.js
├── css/
│ └── main.81bf3022.css
├── img/
│ ├── hero.2c3d4e5f.png
│ └── logo.7a8b9c.svg
└── fonts/
└── inter-var.0123abc.woff2How it works
- `renameAsset` (not deleteAsset + emitAsset) — atomically updates
chunk.files,chunk.auxiliaryFiles,asset.info.relatedreferences. See [webpack-plugin-authoring/asset-delete-then-emit-loses-info]. - `PROCESS_ASSETS_STAGE_OPTIMIZE_HASH` — content hashes are final, but
SUMMARIZE(manifest plugins) hasn't run yet. So a manifest plugin emits the CORRECT new paths. - `path.posix.join` (not
path.join) — webpack asset paths use forward slashes universally;path.joinon Windows would emit\paths - Skip assets already containing `/` — they were already routed by an upstream plugin (e.g., a loader's
filename: 'img/[name]'config) or by a previous run of this plugin in an additional-assets re-run. Don't double-prefix. - First-match-wins with explicit
break— predictable rule ordering, no accidental "two routes matched" surprises
Variations
- Sourcemap to a SEPARATE directory (
.mapfiles): add{ test: '\\.map$', to: 'sourcemaps' }BEFORE the JS/CSS rules - Hash-prefix routing (
/static/v1/4f3a8e1b/main.jsfor atomic deploys): wraptoinstatic/v1/${asset.contenthash} - Excluded patterns: add an
excluderegex per rule - Preserve subdirectories from loader output (
asset.foo.bar.js→js/asset.foo.bar.jsnot justasset.js): the recipe already does this viapath.posix.join
When NOT to use this pattern
- Your CDN doesn't care about path structure (S3 + Cloudflare don't)
- You already configure
assetModuleFilenameand per-loaderfilenameprecisely — this plugin would conflict - You have <10 assets — the structure isn't necessary
- You depend on flat-dist conventions for tools (some service workers expect assets at root)
Reference: Compilation API — renameAsset · output.assetModuleFilename
Delete Empty Chunks That Webpack Emits as Side Effects
Problem
Your build produces files like runtime~main.81bf30.js (0 bytes — exports nothing), pages_admin_index.css (0 bytes — admin page has no CSS imports), or manifest~vendor.js (0 bytes — runtime artifact). These ship to your CDN, get listed in your asset manifest, and SSR servers waste network roundtrips to fetch them just to receive an empty file. They appear because of splitChunks or mini-css-extract-plugin emitting placeholder chunks when there's nothing to extract.
You can sometimes prevent these with config (optimization.splitChunks.minSize: 30000), but config gymnastics doesn't address the case where a route just happens to have no CSS that build. The cleanup belongs in a post-emit pass: detect empty chunks, delete the asset, remove the chunk's reference, and update any manifest plugins running after this stage.
Pattern
Tap processAssets at PROCESS_ASSETS_STAGE_OPTIMIZE (before SUMMARIZE so the asset manifest plugin sees a clean view), find assets whose info.size === 0 (or below a configured threshold), and remove them via compilation.deleteAsset. For chunks that become entirely empty, also remove the chunk's file reference.
Incorrect (without a plugin — empty CSS chunks ship and clutter manifest):
dist/
├── main.4f3a8e1b.js (140kb — real)
├── main.81bf3022.css (12kb — real)
├── admin.a1c2.js (45kb — real)
├── admin.0000000.css (0 bytes — empty, the admin page has no CSS imports)
├── runtime~admin.js (0 bytes — runtime chunk webpack emits unconditionally)
└── manifest.json (references all 5 files including the empties)
# Server renders <link rel="stylesheet" href="/admin.0000000.css"> → 200 OK, 0 bytes
# Wasted RTT, wasted manifest entry, wasted CDN cache slotCorrect (with this plugin — empty chunks deleted before manifest plugin runs):
const { validate } = require('schema-utils');
const schema = {
type: 'object',
properties: {
maxBytes: { type: 'number', description: 'Delete assets smaller than this (default 0 = exactly empty)' },
test: { type: 'string', description: 'Only consider assets matching this regex' },
exclude: { type: 'string', description: 'Never delete assets matching this regex' },
},
additionalProperties: false,
};
const DEFAULTS = {
maxBytes: 0,
test: '\\.(js|mjs|css)$',
exclude: '(runtime|manifest)', // be conservative — never delete runtime files even if empty
};
class SkipEmptyChunksPlugin {
constructor(options = {}) {
validate(schema, options, { name: 'SkipEmptyChunksPlugin', baseDataPath: 'options' });
this.options = { ...DEFAULTS, ...options };
this.testRe = new RegExp(this.options.test);
this.excludeRe = new RegExp(this.options.exclude);
}
apply(compiler) {
const { Compilation } = compiler.webpack;
compiler.hooks.thisCompilation.tap('SkipEmptyChunksPlugin', (compilation) => {
const logger = compilation.getLogger('SkipEmptyChunksPlugin');
compilation.hooks.processAssets.tap(
{
name: 'SkipEmptyChunksPlugin',
// Run BEFORE SUMMARIZE so a manifest plugin sees the cleaned graph
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE,
},
() => {
const deleted = [];
for (const name of Object.keys(compilation.assets)) {
if (!this.testRe.test(name)) continue;
if (this.excludeRe.test(name)) continue;
const asset = compilation.getAsset(name);
const size = asset.info.size ?? asset.source.size();
// Strict check: empty means content is literally empty,
// not just "0 bytes after gzip" or similar
if (size > this.options.maxBytes) continue;
// Cross-check: peek the source to confirm it's not whitespace-only
// that happens to size() as 0 — unlikely but defensive
const buf = asset.source.buffer();
if (buf.length > this.options.maxBytes) continue;
this.removeAsset(compilation, name);
deleted.push(name);
}
if (deleted.length > 0) {
logger.info(`Removed ${deleted.length} empty asset(s):`);
for (const name of deleted) logger.info(` - ${name}`);
}
},
);
});
}
removeAsset(compilation, name) {
// Step 1: remove from chunk.files / auxiliaryFiles
for (const chunk of compilation.chunks) {
chunk.files.delete(name);
chunk.auxiliaryFiles.delete(name);
}
// Step 2: clean any `related` references pointing at this asset
for (const otherName of Object.keys(compilation.assets)) {
const other = compilation.getAsset(otherName);
const related = other.info.related;
if (!related) continue;
let dirty = false;
const newRelated = {};
for (const [k, v] of Object.entries(related)) {
if (v === name) { dirty = true; continue; }
if (Array.isArray(v)) {
const filtered = v.filter((entry) => entry !== name);
if (filtered.length !== v.length) { dirty = true; }
newRelated[k] = filtered;
} else {
newRelated[k] = v;
}
}
if (dirty) {
compilation.updateAsset(otherName, other.source, () => ({
...other.info,
related: newRelated,
}));
}
}
// Step 3: delete the asset
compilation.deleteAsset(name);
}
}
module.exports = SkipEmptyChunksPlugin;Usage
new SkipEmptyChunksPlugin({
test: '\\.(css|js)$', // empty CSS is common with route-based splitting
exclude: '(runtime|webpack)', // never delete webpack's runtime chunks
})How it works
- `PROCESS_ASSETS_STAGE_OPTIMIZE` runs early enough that
SUMMARIZE(meta-asset-manifestrecipe) sees the cleaned set. Running later would leave empty chunks in the manifest and require the manifest plugin to filter them too. - `deleteAsset` is not enough on its own — see [
webpack-plugin-authoring/asset-delete-then-emit-loses-info]; you also need to clearchunk.files/auxiliaryFilesandrelatedreferences, which the recipe does explicitly - Double-check via `buffer().length` —
info.sizecan be cached from before transformations; the actual byte length is the source of truth - Conservative exclude pattern — runtime chunks and manifest files MAY be empty in some configurations but are required for the build to function; deleting them breaks runtime imports
- Logger output lists what was removed — gives the team visibility into which chunks were always-empty (a hint that splitChunks config might need tuning)
Variations
- Threshold-based (not strict empty) — delete chunks under 1kb that webpack emits for trivial split-chunk side effects:
maxBytes: 1024- Per-extension threshold (CSS empty often; JS empty rarely): split into two rule sets
- Warning mode (log instead of delete — useful for first run when finding what's safe):
if (this.options.warningOnly) {
compilation.warnings.push(new WebpackError(`Empty asset: ${name}`));
return;
}- Tracking which chunks routinely emit empty for splitChunks tuning: log in
${CLAUDE_PLUGIN_DATA}/empty-chunks.log
When NOT to use this pattern
- You don't have empty chunks —
ls dist/shows nothing 0-byte. The plugin would be a no-op. - Your runtime chunks are intentionally near-empty placeholder files that the runtime fetches as a sanity check (rare)
- You depend on the empty asset's PRESENCE as a deployment marker — extremely rare
Reference: Compilation API — deleteAsset · optimization.splitChunks · MiniCssExtractPlugin
Report Build Duration and Detect Regressions
Problem
Your team's webpack build was 12s in January. Now it's August and it's 35s. Each PR added 100ms here and 200ms there — no single change was big enough to notice in code review. By the time anyone investigates, dev startup time has become a meeting-blocker and you're paying for it on every push. You want each build to print not just Build finished in 35s but Build finished in 35s (1.8x slower than 7-day median of 19s) — and on a regression > 30%, print a punch list of what's slowest so the team can investigate.
speed-measure-webpack-plugin gives you per-plugin timing but adds 5–10% overhead and breaks in newer webpack versions. You want something cheaper that runs in CI without instrumentation overhead.
Pattern
In compiler.hooks.beforeRun, snapshot Date.now(). In compiler.hooks.done, compute elapsed, append to a rolling log in ${CLAUDE_PLUGIN_DATA}/build-durations.log (or a configurable path), compute median of last N entries, log with a warning when current build exceeds the median significantly.
Incorrect (without a plugin — relying on `time` command):
$ time npm run build
real 0m12.4s
# Tells you THIS build's time. Doesn't tell you anything about whether
# 12.4s is normal, fast, or a 4x regression from yesterday.
# No persistent record.Correct (with this plugin — durations logged, regression detected):
const fs = require('node:fs');
const path = require('node:path');
const { validate } = require('schema-utils');
const schema = {
type: 'object',
properties: {
logFile: { type: 'string' },
keepEntries: { type: 'number', exclusiveMinimum: 0 },
regressionThreshold: { type: 'number', exclusiveMinimum: 1 },
failOnRegression: { type: 'boolean' },
},
additionalProperties: false,
};
const DEFAULTS = {
logFile: '.webpack-build-durations.log',
keepEntries: 50,
regressionThreshold: 1.3, // 30% slower than median = regression
failOnRegression: false,
};
class BuildDurationPlugin {
constructor(options = {}) {
validate(schema, options, { name: 'BuildDurationPlugin', baseDataPath: 'options' });
this.options = { ...DEFAULTS, ...options };
this.logPath = path.resolve(this.options.logFile);
}
apply(compiler) {
let startedAt = null;
compiler.hooks.beforeRun.tap('BuildDurationPlugin', () => { startedAt = Date.now(); });
compiler.hooks.watchRun.tap('BuildDurationPlugin', () => { startedAt = Date.now(); });
compiler.hooks.done.tap('BuildDurationPlugin', (stats) => {
if (startedAt === null) return;
const elapsed = Date.now() - startedAt;
const logger = stats.compilation.getLogger('BuildDurationPlugin');
const history = this.readHistory();
const previousDurations = history.map((e) => e.ms);
const median = previousDurations.length > 4 ? medianOf(previousDurations) : null;
const newEntry = {
timestamp: new Date().toISOString(),
ms: elapsed,
commit: process.env.GIT_COMMIT?.slice(0, 7) ?? null,
hadErrors: stats.hasErrors(),
};
this.writeHistory([newEntry, ...history].slice(0, this.options.keepEntries));
const fmt = (ms) => `${(ms / 1000).toFixed(1)}s`;
if (median === null) {
logger.info(`Build duration: ${fmt(elapsed)} (warming up — need ≥5 builds for baseline)`);
return;
}
const ratio = elapsed / median;
if (ratio > this.options.regressionThreshold) {
const slowdown = `${ratio.toFixed(2)}× slower`;
const message =
`Build duration: ${fmt(elapsed)} (${slowdown} than 7-day median ${fmt(median)})\n` +
` Recent durations: ${previousDurations.slice(0, 5).map(fmt).join(', ')}\n` +
` Investigate with: webpack --profile`;
if (this.options.failOnRegression) {
const { WebpackError } = compiler.webpack;
const err = new WebpackError(`BuildDurationPlugin regression: ${message}`);
err.hideStack = true;
stats.compilation.errors.push(err);
} else {
logger.warn(message);
}
} else {
logger.info(`Build duration: ${fmt(elapsed)} (median ${fmt(median)})`);
}
});
}
readHistory() {
try {
const content = fs.readFileSync(this.logPath, 'utf8');
return content.split('\n').filter(Boolean).map((line) => JSON.parse(line));
} catch {
return [];
}
}
writeHistory(entries) {
const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n';
fs.mkdirSync(path.dirname(this.logPath), { recursive: true });
fs.writeFileSync(this.logPath, content);
}
}
function medianOf(numbers) {
const sorted = [...numbers].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
module.exports = BuildDurationPlugin;Usage
new BuildDurationPlugin({
logFile: '.webpack-build-durations.log', // gitignored
regressionThreshold: 1.3, // 30% slower than median = warn
failOnRegression: process.env.CI === 'true', // hard-fail only in CI
})Sample output:
[BuildDurationPlugin] Build duration: 18.2s (median 12.4s)
[BuildDurationPlugin] Build duration: 35.1s (2.83× slower than 7-day median 12.4s)
Recent durations: 12.4s, 12.6s, 11.9s, 13.0s, 12.3s
Investigate with: webpack --profileHow it works
- `beforeRun` AND `watchRun` —
beforeRunfires for single runs,watchRunfor watch mode rebuilds. Both reset the start time. - `done` hook receives
stats—stats.hasErrors()lets us label failed builds so they're excluded from the median (broken builds usually fail FAST and would skew the baseline) - Median, not mean — single 5-minute "first build with cold cache" doesn't poison the metric. Watch-mode rebuilds (~500ms) and full builds (~12s) coexist; median picks the typical case.
- Persistent JSONL log (one JSON per line) — append-friendly, easy to inspect with
tail, doesn't require parsing the whole file to add an entry. Use a project-relative gitignored path (.webpack-build-durations.log) so the baseline survives across team members but doesn't pollute commits. - `failOnRegression` opt-in for CI — pre-commit hooks shouldn't block on transient slowdowns; CI should
Variations
- Per-target-environment baseline (Linux CI vs dev's Mac M-series): separate log files
- Per-mode baseline (development vs production builds — wildly different):
logFile: `.webpack-durations.${process.env.NODE_ENV ?? 'dev'}.log`- Slack notification on regression (combine with
dx-notify-on-donerecipe) - Per-stage timing (where time was spent): tap
donewithstats.toJson({ all: false, timings: true })
When NOT to use this pattern
- You use speed-measure-webpack-plugin and it works for you (this recipe is intentionally cheaper — no per-plugin instrumentation overhead)
- You measure builds externally (CI dashboard, BuildKite, GitHub Actions timing) — duplicate signal
- Build duration genuinely varies wildly by inputs (codegen-heavy) — median assumption breaks
Reference: Compiler hooks — done · speed-measure-webpack-plugin
Print Which Chunks Actually Changed Between Rebuilds
Problem
Your app produces 200+ chunks (route-based code splitting + vendor chunks + async imports). On a watch-mode rebuild after touching a single file, webpack happily rebuilds and reports "compiled successfully" — but you have no idea WHICH 3 chunks actually changed bytes. Did the import you just added land in the main chunk (cache invalidation for every user) or in an async chunk (only invalidates users hitting that route)? Without this signal you can't quickly assess "does this change feel right?"
webpack --stats=detailed lists every chunk in every build whether it changed or not — the noise hides the signal. You want a one-line-per-changed-chunk diff after every rebuild.
Pattern
In apply(), keep a Map<chunkId, contentHash> of the previous build's chunk hashes. In compiler.hooks.done, walk current chunks, compare each chunk.contentHash.javascript to the previous, log a per-chunk line ONLY for those that changed (or were added/removed).
Incorrect (without a plugin — `webpack --stats`):
$ webpack --watch --stats=detailed
asset main.4f3a8e1b.js 142 KiB ... [emitted] [immutable]
asset vendors.91ab30.js 380 KiB ... [emitted] [immutable]
asset checkout.81bf30.js 67 KiB ... [emitted] [immutable]
asset blog-index.a1c290.js 22 KiB ... [emitted] [immutable]
... 196 more chunks ...
# Which one actually changed bytes from the previous build? Search the previous run's output.Correct (with this plugin — only changed chunks shown):
const { validate } = require('schema-utils');
const schema = {
type: 'object',
properties: {
minBytes: { type: 'number', description: 'Suppress chunks smaller than this (default 1024)' },
showSize: { type: 'boolean' },
showSizeDelta: { type: 'boolean' },
onFirstBuild: { enum: ['silent', 'summary', 'all'] },
},
additionalProperties: false,
};
const DEFAULTS = {
minBytes: 1024,
showSize: true,
showSizeDelta: true,
onFirstBuild: 'summary',
};
class DiffChangedChunksPlugin {
constructor(options = {}) {
validate(schema, options, { name: 'DiffChangedChunksPlugin', baseDataPath: 'options' });
this.options = { ...DEFAULTS, ...options };
this.previous = null; // Map<key, { hash, size }>
}
apply(compiler) {
compiler.hooks.done.tap('DiffChangedChunksPlugin', (stats) => {
const compilation = stats.compilation;
const logger = compilation.getLogger('DiffChangedChunksPlugin');
const current = new Map();
for (const chunk of compilation.chunks) {
const key = chunk.id ?? chunk.name ?? `<anon-${chunk.runtime}>`;
let totalSize = 0;
for (const file of chunk.files) {
totalSize += compilation.getAsset(file)?.info.size
?? compilation.getAsset(file)?.source.size()
?? 0;
}
current.set(String(key), {
hash: chunk.contentHash?.javascript ?? chunk.hash,
size: totalSize,
name: chunk.name ?? String(chunk.id),
});
}
if (this.previous === null) {
this.handleFirstBuild(logger, current);
this.previous = current;
return;
}
const changed = [];
const added = [];
const removed = [];
for (const [key, entry] of current) {
const prev = this.previous.get(key);
if (!prev) added.push(entry);
else if (prev.hash !== entry.hash) changed.push({ ...entry, prevSize: prev.size });
}
for (const [key, entry] of this.previous) {
if (!current.has(key)) removed.push(entry);
}
this.report(logger, changed, added, removed);
this.previous = current;
});
}
report(logger, changed, added, removed) {
if (changed.length === 0 && added.length === 0 && removed.length === 0) {
logger.info('No chunks changed.');
return;
}
const fmt = (b) => `${(b / 1024).toFixed(1)}kb`;
const delta = (now, then) => {
const d = now - then;
const sign = d >= 0 ? '+' : '';
return ` (${sign}${(d / 1024).toFixed(1)}kb)`;
};
for (const entry of changed) {
if (entry.size < this.options.minBytes) continue;
logger.info(
`~ ${entry.name}` +
(this.options.showSize ? ` ${fmt(entry.size)}` : '') +
(this.options.showSizeDelta ? delta(entry.size, entry.prevSize) : ''),
);
}
for (const entry of added) {
logger.info(`+ ${entry.name}${this.options.showSize ? ` ${fmt(entry.size)}` : ''}`);
}
for (const entry of removed) {
logger.info(`- ${entry.name}`);
}
}
handleFirstBuild(logger, current) {
if (this.options.onFirstBuild === 'silent') return;
if (this.options.onFirstBuild === 'all') {
for (const entry of current.values()) {
logger.info(`+ ${entry.name} ${entry.size}kb`);
}
} else {
logger.info(`Tracking ${current.size} chunks for change detection.`);
}
}
}
module.exports = DiffChangedChunksPlugin;Usage
new DiffChangedChunksPlugin({ minBytes: 1024 })Sample output across a watch-mode session:
[DiffChangedChunksPlugin] Tracking 213 chunks for change detection.
# After editing src/checkout/form.tsx
[DiffChangedChunksPlugin] ~ checkout 67.2kb (+0.3kb)
[DiffChangedChunksPlugin] ~ main 142.4kb (+0.1kb) # via runtime/module ID changes
# After upgrading lodash
[DiffChangedChunksPlugin] ~ vendors 412.8kb (+32.1kb)
[DiffChangedChunksPlugin] ~ main 142.3kb (-0.1kb)
# After deleting a page
[DiffChangedChunksPlugin] - blog-archive
[DiffChangedChunksPlugin] ~ main 141.9kb (-0.4kb)How it works
- `chunk.contentHash.javascript` is the canonical "did this chunk's content change?" indicator — not
chunk.hash, which can change for unrelated reasons. The official [webpack-plugin-authoring] companion is the Plugin Patterns guide. - `chunk.id ?? chunk.name` as the tracking key —
idis stable across builds withoptimization.chunkIds: 'deterministic'; falls back tonamefor unnamed chunks - Comparing on a SEPARATE in-memory map (
this.previous) — across rebuilds within a single watch session. Survives a single dev-server session but not server restarts (which is fine: each session has its own baseline) - `info.size` first, `source.size()` fallback — avoids materializing the source just to count bytes. See [
webpack-plugin-authoring/perf-avoid-source-toString-in-hot-paths]. - Mutable instance state EXPLICITLY for cross-build comparison is the one legitimate use of mutable instance state per [
webpack-plugin-authoring/life-no-mutable-state-across-builds] — note the "intentional cross-build state" pattern there
Variations
- Filter by chunk type (only show initial chunks, ignore async):
if (!chunk.canBeInitial()) continue;- Markdown table output for CI logs:
logger.info('| Chunk | Size | Δ |\n|---|---|---|\n' + changedRows.join('\n'));- Sort by delta magnitude (biggest changes first)
- Include module-level diff (which modules MOVED chunks): inspect
chunkGraph.getChunkModules(chunk)before/after
When NOT to use this pattern
- You only do single-shot builds (no
--watch) — every chunk is "new"; the diff is meaningless - You already use webpack-bundle-analyzer in
--mode=development— duplicative - You have chunk count under 5 —
--stats=detailedis fine
Reference: Plugin Patterns — Detecting Changed Chunks · chunk.contentHash
Send Desktop / Slack Notification on Build Done
Problem
You're in your editor, you save a file, watch-mode kicks off — and now you wait. Did it finish? Did it fail? Switching to the terminal to check breaks flow. After several rebuilds in a session, the cumulative "did it finish?" tax is 10–20 seconds × 50 rebuilds/day. Same problem in CI: a 10-minute deploy build that you forget about, then context-switch back to in 30 minutes.
You want a notification — desktop notification for local watch mode, Slack message for CI builds — that fires on build completion (success OR failure), with enough detail to act: "Build failed: 3 errors in src/checkout.ts". Both `webpack-notifier` and Slack integrations exist separately; this recipe is one plugin that does both based on environment detection.
Pattern
In compiler.hooks.done, build a result object (success/failure, error count, duration), then dispatch to one of several configured destinations. Local sends via node-notifier; CI sends via webhook POST (fetch is available without dep in Node 18+).
Incorrect (without a plugin — `npm-watch` or shell scripts):
# package.json
"scripts": {
"watch": "webpack --watch && say 'Build done'"
}
# `&&` only fires on success — failures go silent
# `say` is macOS-only
# No content — just "done" with no detailCorrect (with this plugin — desktop + Slack with full detail):
const { validate } = require('schema-utils');
const schema = {
type: 'object',
properties: {
desktop: { type: 'boolean' },
slackWebhook: { type: 'string', pattern: '^https://' },
title: { type: 'string' },
onSuccess: { enum: ['always', 'never', 'after-failure'] },
onFailure: { type: 'boolean' },
minDurationMs: { type: 'number', description: 'Skip notification for builds faster than this' },
},
additionalProperties: false,
};
const DEFAULTS = {
desktop: process.env.CI !== 'true',
title: 'webpack',
onSuccess: 'after-failure',
onFailure: true,
minDurationMs: 3000,
};
class NotifyOnDonePlugin {
constructor(options = {}) {
validate(schema, options, { name: 'NotifyOnDonePlugin', baseDataPath: 'options' });
this.options = { ...DEFAULTS, ...options };
this.previousHadErrors = false;
}
apply(compiler) {
let startedAt = null;
compiler.hooks.beforeRun.tap('NotifyOnDonePlugin', () => { startedAt = Date.now(); });
compiler.hooks.watchRun.tap('NotifyOnDonePlugin', () => { startedAt = Date.now(); });
compiler.hooks.done.tapPromise('NotifyOnDonePlugin', async (stats) => {
const elapsed = startedAt ? Date.now() - startedAt : 0;
const hadErrors = stats.hasErrors();
const hadWarnings = stats.hasWarnings();
const shouldNotify = this.shouldNotify(hadErrors, elapsed);
this.previousHadErrors = hadErrors;
if (!shouldNotify) return;
const summary = this.buildSummary(stats, elapsed, hadErrors, hadWarnings);
await Promise.all([
this.options.desktop ? this.notifyDesktop(summary, hadErrors) : null,
this.options.slackWebhook ? this.notifySlack(summary, hadErrors) : null,
].filter(Boolean));
});
}
shouldNotify(hadErrors, elapsed) {
if (elapsed < this.options.minDurationMs && !hadErrors) return false; // skip fast green builds
if (hadErrors) return this.options.onFailure;
if (this.options.onSuccess === 'never') return false;
if (this.options.onSuccess === 'after-failure') return this.previousHadErrors;
return true; // 'always'
}
buildSummary(stats, elapsed, hadErrors, hadWarnings) {
const errCount = stats.compilation.errors.length;
const warnCount = stats.compilation.warnings.length;
const fmt = (ms) => `${(ms / 1000).toFixed(1)}s`;
if (hadErrors) {
const first = stats.compilation.errors[0]?.message?.split('\n')[0] ?? 'unknown error';
return {
title: `${this.options.title}: build failed`,
body: `${errCount} error${errCount > 1 ? 's' : ''} in ${fmt(elapsed)}\n${first}`,
ok: false,
};
}
return {
title: `${this.options.title}: build succeeded`,
body: hadWarnings
? `${fmt(elapsed)} (${warnCount} warning${warnCount > 1 ? 's' : ''})`
: `${fmt(elapsed)}`,
ok: true,
};
}
async notifyDesktop(summary, isError) {
try {
const notifier = require('node-notifier');
notifier.notify({
title: summary.title,
message: summary.body,
sound: isError,
wait: false,
});
} catch {
// node-notifier not installed — fall back to terminal bell
process.stdout.write('');
}
}
async notifySlack(summary, isError) {
const color = isError ? '#dc3545' : '#28a745';
const payload = {
attachments: [{
color,
title: summary.title,
text: summary.body,
ts: Math.floor(Date.now() / 1000),
footer: process.env.GITHUB_REPOSITORY ?? 'webpack build',
}],
};
try {
await fetch(this.options.slackWebhook, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (e) {
// Don't fail the build because notification failed
console.warn(`[NotifyOnDonePlugin] Slack post failed: ${e.message}`);
}
}
}
module.exports = NotifyOnDonePlugin;Usage
new NotifyOnDonePlugin({
desktop: process.env.CI !== 'true',
slackWebhook: process.env.SLACK_BUILD_WEBHOOK, // CI only
title: 'my-app',
onSuccess: 'after-failure', // notify when build recovers; skip every-success spam
minDurationMs: 3000, // don't notify for 500ms watch rebuilds
})How it works
- `done.tapPromise` is async — Slack POST takes time we should respect, otherwise the next build can start while the notification is in-flight and skew error attribution
- `onSuccess: 'after-failure'` — the most useful default: notify on failures + notify when the build RECOVERS from a failure. Saves notification fatigue.
- `minDurationMs: 3000` — skip notifications for fast rebuilds (under 3s, you didn't have time to context-switch anyway). Failed builds always notify.
- `require('node-notifier')` in a try/catch — optional peer dep; degrades to terminal bell if not installed. Don't fail the build just because the user hasn't installed the optional dep.
- `fetch` is native in Node 18+ — no
axios/node-fetchdep needed; same API works in CI environments - Catch Slack errors silently — a flaky webhook shouldn't break the build (only log)
Variations
- Webhook formats other than Slack (Discord, Mattermost, MS Teams): take a
format: 'slack' | 'discord' | 'teams'option and emit the right payload shape - Group rapid rebuilds (debounce within 1s window): keep last notify timestamp, suppress if too recent
- Per-environment notification (only failures in CI, all builds in dev): conditional
onSuccess - Sound only on regression (combine with
dx-build-duration-report): onlysound: truewhen build was slower than median
When NOT to use this pattern
- You already have CI status notifications in Slack via GitHub/CircleCI/etc — duplicates
- Your team uses email-only notifications (corporate IT) — different transport
- Builds are so fast (under 1s consistently) the notification overhead dwarfs the build
Reference: node-notifier · Slack incoming webhooks · webpack-notifier
Open the Browser on the First Successful Dev Build
Problem
You run npm run dev, wait 8 seconds for the dev-server to boot, switch to the browser, type localhost:3000, refresh. Multiply by 30 dev-server starts per day per developer per team — significant time spent on the same context switch. webpack-dev-server --open exists but opens the browser BEFORE the build completes, so you see Cannot GET / for 5 seconds while the first compile runs. vite opens AFTER first compile, which is what you want; webpack should do the same.
Also, the developer who's actively working may NOT want a new browser tab opened on every server restart (they have their existing tab refreshing via HMR). You want "open on first build only — not on rebuilds, not on every restart unless explicitly requested."
Pattern
In compiler.hooks.done (which fires after first successful build), check a flag indicating we haven't yet opened, derive the URL from webpack-dev-server's config (or accept it as plugin option), shell out to the OS-appropriate "open URL" command. Skip on errors. After first success, set the flag so subsequent rebuilds don't reopen.
Incorrect (without a plugin — `webpack-dev-server --open`):
$ npx webpack serve --open
# Opens http://localhost:3000 IMMEDIATELY (before compile finishes)
# Browser shows "Cannot GET /" for the first 5s
# When build completes, page doesn't auto-refresh (refresh is HMR for changes,
# not for the initial 404 → success transition)
# User manually refreshes anyway.Correct (with this plugin — opens AFTER first successful build):
const { exec } = require('node:child_process');
const { validate } = require('schema-utils');
const schema = {
type: 'object',
properties: {
url: { type: 'string' },
target: {
enum: ['default', 'firefox', 'chrome', 'safari'],
description: 'Open in this browser (uses OS default if not specified)',
},
skipIfCi: { type: 'boolean' },
onlyOnFirstStart: { type: 'boolean' },
},
additionalProperties: false,
};
const DEFAULTS = { target: 'default', skipIfCi: true, onlyOnFirstStart: true };
class OpenBrowserPlugin {
constructor(options = {}) {
validate(schema, options, { name: 'OpenBrowserPlugin', baseDataPath: 'options' });
this.options = { ...DEFAULTS, ...options };
this.opened = false;
}
apply(compiler) {
if (this.options.skipIfCi && process.env.CI === 'true') return;
compiler.hooks.done.tap('OpenBrowserPlugin', (stats) => {
if (stats.hasErrors()) return; // wait until first GREEN build
if (this.options.onlyOnFirstStart && this.opened) return; // already opened this session
this.opened = true;
const url = this.resolveUrl(compiler);
if (!url) {
compiler.getInfrastructureLogger('OpenBrowserPlugin').warn(
'Could not determine dev-server URL; configure { url: "..." } explicitly.',
);
return;
}
this.openUrl(url, compiler);
});
}
resolveUrl(compiler) {
if (this.options.url) return this.options.url;
// Try to read from devServer config (works in webpack 5)
const ds = compiler.options.devServer;
if (!ds) return null;
const proto = ds.server === 'https' || ds.https ? 'https' : 'http';
const host = ds.host && ds.host !== '0.0.0.0' ? ds.host : 'localhost';
const port = ds.port ?? 8080;
const base = typeof ds.devMiddleware?.publicPath === 'string' ? ds.devMiddleware.publicPath : '';
return `${proto}://${host}:${port}${base}`;
}
openUrl(url, compiler) {
const logger = compiler.getInfrastructureLogger('OpenBrowserPlugin');
const platform = process.platform;
let command;
if (this.options.target === 'default') {
command =
platform === 'darwin' ? `open "${url}"`
: platform === 'win32' ? `start "" "${url}"`
: `xdg-open "${url}"`;
} else {
// Specific browser
const browserMap = {
darwin: { firefox: 'open -a "Firefox"', chrome: 'open -a "Google Chrome"', safari: 'open -a "Safari"' },
win32: { firefox: 'start firefox', chrome: 'start chrome' },
linux: { firefox: 'firefox', chrome: 'google-chrome' },
};
const cmd = browserMap[platform]?.[this.options.target];
if (!cmd) {
logger.warn(`OpenBrowserPlugin: target "${this.options.target}" not supported on ${platform}`);
return;
}
command = `${cmd} "${url}"`;
}
exec(command, (err) => {
if (err) {
logger.warn(`OpenBrowserPlugin: failed to open browser (${err.message})`);
} else {
logger.info(`Opened ${url}`);
}
});
}
}
module.exports = OpenBrowserPlugin;Usage
new OpenBrowserPlugin({
// optional: explicit URL if devServer config doesn't have all pieces
url: 'http://localhost:3000',
skipIfCi: true, // don't open during Docker builds
onlyOnFirstStart: true, // don't reopen on every save
})How it works
- `done` hook with `stats.hasErrors()` check — first build often has compile errors; opening the browser then shows a broken state. Wait for the first GREEN build.
- `this.opened` instance flag — survives only the current process (not desirable to persist across server restarts; users want a fresh tab when restarting deliberately). This IS legitimate cross-build instance state, per [
webpack-plugin-authoring/life-no-mutable-state-across-builds] — note the explicit single-purpose state pattern. - `compiler.getInfrastructureLogger` for "ran a setup-time action" logs — these go to the infrastructure log, not the per-compilation log. See [
webpack-plugin-authoring/diag-use-compilation-get-logger]. - `exec` with error logging — never throw from a "convenience" feature; opening the browser failing should not break the build
- Platform-specific commands —
open(macOS),start ""(Windows; the empty title is required when path is quoted),xdg-open(Linux) - Skip in CI — opening a browser in a headless Docker container hangs forever waiting for
xdg-opento find a display
Variations
- Open multiple URLs (the app + a debugging dashboard): take
urls: string[] - Wait for the server to actually be listening (not just compiled): use
await fetch(url)in a retry loop before invokingexec - Open in a SPECIFIC tab (reuse existing if open, otherwise new): use
open --new-windowflag pattern (macOS only, complex) - Profile-specific browser launch (incognito, no extensions): expand command with browser-specific flags
- Disable via env var (so terminals like JetBrains' that auto-spawn webpack-dev-server don't trigger another tab):
if (process.env.NO_OPEN) return;
When NOT to use this pattern
- You use
webpack-dev-server --openand live with the early-open behavior — works for many - You use Vite, Next.js, or Rspack — all open AFTER first compile by default
- Your workflow is "always have one terminal + one browser side by side" — opening another tab is friction
- You develop in a container where the host can't access localhost the same way (use port forwarding + manual URL)
Reference: webpack-dev-server open option · Vite open behavior
Fail Builds When Initial JS Exceeds a Per-Entry Budget
Problem
Your team agreed initial JavaScript for the main entry should stay under 250kb (gzipped) — that's the budget the perf team negotiated to hit a 3s LCP on 4G. But no one notices when a PR adds a 60kb dependency and pushes you over. CI passes (the build still succeeds), webpack-bundle-analyzer only runs when someone remembers to check it, and three weeks later the perf review catches that bundle size jumped 40%. You need CI to fail the build when the budget is exceeded — not warn, not log, fail.
Webpack ships performance.maxAssetSize / maxEntrypointSize, but they emit warnings only, don't honor compression, and apply globally (no per-entry budgets). This plugin gives you per-entry budgets with measurement against the actual on-the-wire size.
Pattern
Tap compilation.hooks.afterProcessAssets (after hashing, before emit), sum the GZIP-COMPRESSED size of each entrypoint's initial chunks, compare against the configured budget per entry, and push a WebpackError to compilation.errors when any entry exceeds its budget.
Incorrect (without a plugin — relying on webpack's built-in `performance` config):
// webpack.config.js — what people try first
module.exports = {
performance: {
maxAssetSize: 250000, // applies globally — no per-entry budgets
maxEntrypointSize: 400000, // emits a WARNING only
hints: 'error', // measures UNCOMPRESSED bytes (~3x the gzipped reality)
},
};
// Result: ships a 350kb-gzipped main bundle (~1.2MB uncompressed) and CI still passes.Correct (with this plugin — per-entry, gzipped, hard-fail):
const zlib = require('node:zlib');
const { validate } = require('schema-utils');
const schema = {
type: 'object',
properties: {
budgets: {
type: 'object',
additionalProperties: { type: 'number', exclusiveMinimum: 0 },
description: 'Map of entrypoint name → max gzipped bytes (e.g. { main: 250_000 })',
},
compression: { enum: ['gzip', 'brotli', 'none'] },
failOn: { enum: ['error', 'warning'] },
},
required: ['budgets'],
additionalProperties: false,
};
const DEFAULTS = { compression: 'gzip', failOn: 'error' };
class BundleBudgetPlugin {
constructor(options) {
validate(schema, options, { name: 'BundleBudgetPlugin', baseDataPath: 'options' });
this.options = { ...DEFAULTS, ...options };
}
apply(compiler) {
const { WebpackError } = compiler.webpack;
compiler.hooks.thisCompilation.tap('BundleBudgetPlugin', (compilation) => {
const logger = compilation.getLogger('BundleBudgetPlugin');
compilation.hooks.afterProcessAssets.tap('BundleBudgetPlugin', () => {
for (const [entryName, budget] of Object.entries(this.options.budgets)) {
const entry = compilation.entrypoints.get(entryName);
if (!entry) {
const warn = new WebpackError(
`BundleBudgetPlugin: budget set for unknown entry "${entryName}"`,
);
compilation.warnings.push(warn);
continue;
}
let totalCompressed = 0;
const breakdown = [];
for (const chunk of entry.chunks) {
for (const file of chunk.files) {
if (!/\.(js|mjs)$/.test(file)) continue;
const asset = compilation.getAsset(file);
if (!asset) continue;
const bytes = asset.source.buffer();
const compressed = this.compress(bytes).length;
totalCompressed += compressed;
breakdown.push({ file, size: compressed });
}
}
if (totalCompressed > budget) {
const message =
`BundleBudgetPlugin: entry "${entryName}" is ${fmt(totalCompressed)} ` +
`(${this.options.compression}), exceeding budget of ${fmt(budget)} by ` +
`${fmt(totalCompressed - budget)}\n` +
breakdown
.sort((a, b) => b.size - a.size)
.map((b) => ` - ${b.file}: ${fmt(b.size)}`)
.join('\n');
const err = new WebpackError(message);
err.hideStack = true;
(this.options.failOn === 'warning' ? compilation.warnings : compilation.errors)
.push(err);
} else {
logger.info(
`entry "${entryName}": ${fmt(totalCompressed)} / ${fmt(budget)} ` +
`(${Math.round((totalCompressed / budget) * 100)}%)`,
);
}
}
});
});
}
compress(buffer) {
switch (this.options.compression) {
case 'brotli': return zlib.brotliCompressSync(buffer);
case 'gzip': return zlib.gzipSync(buffer, { level: 9 });
case 'none': return buffer;
}
}
}
function fmt(bytes) {
return `${(bytes / 1024).toFixed(1)}kb`;
}
module.exports = BundleBudgetPlugin;How it works
- `afterProcessAssets` (not
afterEmit) so the check happens beforeemit, allowing the build to fail before writing wrong bytes to disk. See [webpack-plugin-authoring/hook-prefer-process-assets-over-emit]. - `entrypoints.get(name).chunks` gives the initial chunks for an entry — async chunks are excluded automatically, which matches what users actually load on first paint.
- `asset.source.buffer()` returns bytes without UTF-8-coercing binary content. See [
webpack-plugin-authoring/asset-buffer-not-source-for-binary]. - `zlib.gzipSync({ level: 9 })` measures what CDNs ship — most measure level 6, but level 9 is the conservative budget target.
- `compilation.errors.push(WebpackError)` instead of
throwlets webpack collect all budget failures before exiting. See [webpack-plugin-authoring/diag-push-webpack-error-not-throw].
Variations
- Brotli budget for modern targets:
compression: 'brotli'— typically 15–25% smaller than gzip; tighten budgets accordingly - Warn in dev, error in CI:
failOn: process.env.CI ? 'error' : 'warning' - Per-chunk (not per-entry) budget: swap
entry.chunksforcompilation.chunks.filter(c => c.canBeInitial()) - Budget against last build (regression detection): persist the previous total to
${CLAUDE_PLUGIN_DATA}/budgets-baseline.jsonand fail when current grows >5% beyond baseline - Include CSS in the budget: widen the regex to
/\.(js|mjs|css)$/
When NOT to use this pattern
- You already use
size-limitorbundlewatchin CI — they do this with richer reporting - Your app has hundreds of small dynamic-imported chunks with no clear "initial bundle" — per-chunk budgets are more useful
- You ship truly variable-size content per build (e.g., embedded translations for 30 languages) — single-number budgets are too coarse
Reference: Webpack performance budgets · size-limit · Web.dev — Performance budgets 101
Fail Builds When Forbidden Imports Cross Architectural Boundaries
Problem
You've established an architectural rule: src/ui/** may never import from src/server/** (because server code drags pg, bcrypt, and Node fs into the client bundle and bloats it by 800kb). The team agreed in design review. Three months later, someone imports getUserById from src/server/db.ts into a React component because autocomplete suggested it — the bundle silently bloats and a bcrypt runtime call ends up in the browser. ESLint's no-restricted-imports catches some cases but only at the source-text level; it misses transitive imports (import './helpers' where helpers.ts re-exports forbidden code) and barrel-file laundering.
This plugin enforces the rule against webpack's RESOLVED module graph — if forbidden code reaches a forbidden chunk, the build fails with the import chain showing how.
Pattern
Tap compilation.hooks.afterOptimizeModules (after the dependency graph is finalized), walk every module, check each module's userRequest against forbidden patterns based on which chunks the module ended up in, and push a WebpackError with the import chain on each violation.
Incorrect (without a plugin — relying on ESLint `no-restricted-imports` only):
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-imports': ['error', {
patterns: ['**/server/**'], // catches direct import in the SAME FILE
}],
},
};
// Misses: import './helpers' where helpers.ts itself imports from server/
// Misses: barrel-file laundering where ./index.ts re-exports server/db
// Misses: transitive imports through legitimate-looking utility librariesCorrect (with this plugin — checks resolved chunk membership, not source text):
const path = require('node:path');
const { validate } = require('schema-utils');
const schema = {
type: 'object',
properties: {
rules: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
chunks: { type: 'string', minLength: 1, description: 'Glob/regex for chunk names this rule applies to' },
forbidden: { type: 'string', minLength: 1, description: 'Glob/regex matched against module.userRequest' },
message: { type: 'string' },
},
required: ['name', 'chunks', 'forbidden'],
additionalProperties: false,
},
minItems: 1,
},
},
required: ['rules'],
additionalProperties: false,
};
class ForbiddenImportsPlugin {
constructor(options) {
validate(schema, options, { name: 'ForbiddenImportsPlugin', baseDataPath: 'options' });
this.rules = options.rules.map((r) => ({
...r,
chunksRegex: toRegex(r.chunks),
forbiddenRegex: toRegex(r.forbidden),
}));
}
apply(compiler) {
const { WebpackError } = compiler.webpack;
compiler.hooks.thisCompilation.tap('ForbiddenImportsPlugin', (compilation) => {
compilation.hooks.afterOptimizeModules.tap('ForbiddenImportsPlugin', (modules) => {
for (const rule of this.rules) {
for (const mod of modules) {
const request = mod.userRequest || mod.rawRequest;
if (!request || !rule.forbiddenRegex.test(request)) continue;
// Find which chunks this module landed in, filter to matching ones
const matchingChunks = [...compilation.chunkGraph.getModuleChunks(mod)]
.filter((c) => rule.chunksRegex.test(c.name ?? ''));
if (matchingChunks.length === 0) continue;
const chain = explainHowItGotHere(compilation, mod, rule);
for (const chunk of matchingChunks) {
const err = new WebpackError(
`[${rule.name}] Forbidden import in chunk "${chunk.name}": ${request}\n` +
(rule.message ? ` ${rule.message}\n` : '') +
` Import chain:\n${chain.map((c) => ` ${c}`).join('\n')}`,
);
err.hideStack = true;
err.module = mod;
compilation.errors.push(err);
}
}
}
});
});
}
}
function explainHowItGotHere(compilation, target, rule) {
// Walk reverse dependencies until we find one that DOESN'T match `forbidden`
// — that's where the architectural violation begins
const chain = [target.userRequest];
const seen = new Set([target]);
let cursor = target;
while (cursor) {
const incoming = [...compilation.moduleGraph.getIncomingConnections(cursor)]
.map((c) => c.originModule)
.filter((m) => m && !seen.has(m));
if (incoming.length === 0) break;
const next = incoming[0];
seen.add(next);
chain.unshift(next.userRequest ?? '<entry>');
if (!rule.forbiddenRegex.test(next.userRequest ?? '')) break;
cursor = next;
}
return chain;
}
function toRegex(pattern) {
if (pattern.startsWith('/') && pattern.endsWith('/')) {
return new RegExp(pattern.slice(1, -1));
}
// Glob → regex: ** matches anything, * matches single segment
const escaped = pattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '.*')
.replace(/\*/g, '[^/]*');
return new RegExp(`^${escaped}$|${escaped}`);
}
module.exports = ForbiddenImportsPlugin;Usage
new ForbiddenImportsPlugin({
rules: [
{
name: 'no-server-in-ui',
chunks: '*', // any client chunk
forbidden: '**/src/server/**',
message: 'UI code cannot import server code — use API client at src/api/ instead',
},
{
name: 'no-test-utils-in-prod',
chunks: '*',
forbidden: '/(test-utils|__mocks__|jest\\.setup)/',
message: 'Test utilities must not be bundled into production builds',
},
],
})How it works
- `afterOptimizeModules` runs after webpack has finalized which modules go in which chunks (post-split-chunks). Earlier hooks miss this picture; later hooks (
afterEmit) are too late to fail. - `compilation.chunkGraph.getModuleChunks(mod)` is webpack 5's API — replaces the deprecated
module.chunksIterable. See [webpack-plugin-authoring/perf-traverse-chunks-not-modules]. - `compilation.moduleGraph.getIncomingConnections(mod)` walks reverse dependencies — used to produce the "how did this end up here?" chain that makes errors actionable.
- `err.hideStack = true` suppresses webpack's auto-stack on the error — the import chain IS the explanation. See [
webpack-plugin-authoring/diag-attach-loc-to-errors].
Variations
- Warn-only mode for new rules (rollout):
failOn: 'warning'per rule - Allowlist exceptions (you must violate the rule for a specific file): add an
exceptions: string[]array per rule - Apply only to production builds:
if (compiler.options.mode !== 'production') return;inapply() - Check loaders/resources too (catch a CSS import that drags JS): extend the request check to include
mod.loadersas well
When NOT to use this pattern
- You already use eslint-plugin-boundaries or dependency-cruiser — those work at source-text level which is sufficient for most projects, and run in your editor too
- Your architectural rules are about WHICH FILES exist, not what imports what —
git pre-commitchecks are better
Reference: eslint-no-restricted-imports · dependency-cruiser · webpack moduleGraph API
Fail Builds When Secret-Shaped Strings Leak Into Client Bundles
Problem
A developer writes const key = process.env.STRIPE_SECRET_KEY inside a React component, intending it for an isomorphic helper that should only run on the server. DefinePlugin happily substitutes the literal sk_live_4eC39HqL... into the client bundle. The build succeeds, deploys to the CDN, and the secret is now public — Stripe will detect this within 24 hours and rotate the key, but by then your bot scrapers have logged it. The pain doesn't show up until the abuse alert. You want CI to fail when the emitted JS/CSS contains anything matching known secret patterns.
This isn't a substitute for proper code review or git-secrets pre-commit hooks — it's the last line of defense, catching the case where everyone agreed STRIPE_SECRET_KEY shouldn't be exposed but DefinePlugin substituted it anyway because someone wrote process.env.STRIPE_SECRET_KEY in client code.
Pattern
Tap processAssets at PROCESS_ASSETS_STAGE_ANALYSE (after all transformations are done, all hashes settled, just before reporting), scan each text asset's content against an extensible list of secret-shape regexes, and push a WebpackError per match with the asset name and a redacted preview.
Incorrect (without a plugin — pre-commit `git-secrets` only):
# .git/hooks/pre-commit
git secrets --scan
# Catches secrets in NEW commits. Misses:
# - DefinePlugin substituting secrets at build time (no source-text trace)
# - Secrets embedded in vendored bundles (e.g., committed third-party builds)
# - Secrets injected by other plugins (logging, debug helpers)
# Pre-commit catches the developer mistake; build-time catches the FINAL bundle.Correct (with this plugin — scans the emitted bundle, last line of defense):
const { validate } = require('schema-utils');
const schema = {
type: 'object',
properties: {
patterns: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
regex: { type: 'string', minLength: 1 },
flags: { type: 'string' },
},
required: ['name', 'regex'],
additionalProperties: false,
},
},
extensions: {
type: 'array',
items: { type: 'string', pattern: '^\\.' },
description: 'File extensions to scan (default: .js .mjs .css .html .json)',
},
allowlist: {
type: 'array',
items: { type: 'string' },
description: 'Specific matches to allow (e.g. "pk_test_TYooMQauvdEDq54NiTphI7jx")',
},
},
additionalProperties: false,
};
// Default patterns — drawn from gitleaks/truffleHog plus webpack-specific cases
const DEFAULT_PATTERNS = [
{ name: 'AWS access key', regex: 'AKIA[0-9A-Z]{16}' },
{ name: 'AWS secret key', regex: '(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])' },
{ name: 'Stripe secret key', regex: 'sk_live_[0-9a-zA-Z]{24,}' },
{ name: 'Stripe restricted key', regex: 'rk_live_[0-9a-zA-Z]{24,}' },
{ name: 'GitHub token', regex: 'gh[pousr]_[0-9a-zA-Z]{36}' },
{ name: 'Generic API key', regex: '(api[_-]?key|apikey)["\']?\\s*[:=]\\s*["\'][^"\']{20,}["\']', flags: 'i' },
{ name: 'Private key block', regex: '-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----' },
{ name: 'JWT token', regex: 'eyJ[A-Za-z0-9_-]{20,}\\.[A-Za-z0-9_-]{20,}\\.[A-Za-z0-9_-]{20,}' },
{ name: 'Slack webhook', regex: 'https://hooks\\.slack\\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[a-zA-Z0-9]+' },
];
class NoSecretsBundledPlugin {
constructor(options = {}) {
validate(schema, options, { name: 'NoSecretsBundledPlugin', baseDataPath: 'options' });
this.patterns = (options.patterns ?? DEFAULT_PATTERNS).map((p) => ({
name: p.name,
regex: new RegExp(p.regex, p.flags ?? 'g'),
}));
this.extensions = new Set(options.extensions ?? ['.js', '.mjs', '.css', '.html', '.json']);
this.allowlist = new Set(options.allowlist ?? []);
}
apply(compiler) {
const { Compilation, WebpackError } = compiler.webpack;
compiler.hooks.thisCompilation.tap('NoSecretsBundledPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'NoSecretsBundledPlugin',
// After all transformations and hashing — content is final
stage: Compilation.PROCESS_ASSETS_STAGE_ANALYSE,
},
(assets) => {
for (const name of Object.keys(assets)) {
if (![...this.extensions].some((ext) => name.endsWith(ext))) continue;
const content = compilation.getAsset(name).source.source().toString();
for (const pattern of this.patterns) {
pattern.regex.lastIndex = 0; // reset between assets
let match;
while ((match = pattern.regex.exec(content)) !== null) {
if (this.allowlist.has(match[0])) continue;
const redacted = match[0].slice(0, 6) + '…' + match[0].slice(-4);
const err = new WebpackError(
`NoSecretsBundledPlugin: ${pattern.name} detected in ${name}\n` +
` Match: ${redacted}\n` +
` Context: ${contextAround(content, match.index, 80)}\n` +
`\n Either:\n` +
` 1. Remove the secret from source (do not commit secrets)\n` +
` 2. Add to allowlist if a false positive (NoSecretsBundledPlugin.allowlist)\n` +
` 3. Confirm this code path is server-only and excluded from client bundles`,
);
err.file = name;
err.hideStack = true;
compilation.errors.push(err);
}
}
}
},
);
});
}
}
function contextAround(text, index, radius) {
const start = Math.max(0, index - radius);
const end = Math.min(text.length, index + radius);
return text.slice(start, end).replace(/\s+/g, ' ');
}
module.exports = NoSecretsBundledPlugin;How it works
- `PROCESS_ASSETS_STAGE_ANALYSE` runs after every other transformation is done — checking earlier would miss secrets injected by another plugin's transform; checking later (in
emit) is too late to fail - `source().toString()` is fine here because we filter by extension to text-only assets first; binary assets would be wasted work and could throw on the toString (see [
webpack-plugin-authoring/asset-buffer-not-source-for-binary]) - Redacted preview (
sk_liv…ENRX) shows enough to locate the leak in the source without making the error log a secret-leak itself (CI logs are often public) - The allowlist mechanism is explicit, not pattern-based — users opt in to specific known-public test keys (
pk_test_...) rather than entire pattern families
Variations
- Build-blocking vs reporting:
// Warn in dev (false positives are common), error in CI
failOn: process.env.CI ? 'error' : 'warning',- Exclude sourcemaps from scan (they include original source; will trigger on intentional server-only code if maps emitted): add
.mapto default-exclude - Per-pattern severity: some patterns (AWS secret key) ALWAYS fail; others (generic api-key regex) warn first
- Source-map decode for production builds: when a secret is detected, decode source-map back to original file:line to point at where in source the leak originated
When NOT to use this pattern
- You have a strong pre-commit hook (
gitleaks,trufflehog) — catches at commit time, not build time, which is earlier and cheaper - Your build environment has access to real secrets and you intentionally substitute them (server-side bundles for Node deployment); use
extensions: ['.js']and exclude the server entrypoint - You have many false positives — secret-shape regexes are inherently fuzzy, and a noisy guard gets ignored
Reference: gitleaks · trufflehog patterns · DefinePlugin
Related skills
FAQ
What does webpack-plugin-recipes do?
webpack-plugin-recipes is a Claude Code skill for ai & agent building.
When should I use webpack-plugin-recipes?
When you need to helps with ai & agent building tasks during AI-assisted development., or when webpack-plugin-recipes is a claude code skill for ai & agent building.
What are the main capabilities?
webpack-plugin-recipes; AI & Agent Building; AI-coding skill.