
Webpack Plugin Authoring
- 71 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
webpack-plugin-authoring is a Claude Code skill for security. It helps solo builders move faster with AI-assisted coding.
Key points
- webpack-plugin-authoring
- Security
- AI-coding skill
Webpack Plugin Authoring by the numbers
- 71 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,170 of 2,203 Security 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-authoringAdd 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 security tasks during ai-assisted development?
Helps with security tasks during AI-assisted development.
Who is it for?
Best when you're working on security and need structured help with webpack-plugin-authoring.
Skip if: Teams with no security needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with security tasks during ai-assisted development, or when webpack-plugin-authoring is a claude code skill for security. it helps solo builders move faster with ai-assisted coding.
What you get
Structured output aligned to webpack-plugin-authoring: webpack-plugin-authoring; Security; AI-coding skill.
Files
dot-skills Webpack 5 Plugins Best Practices
Comprehensive guide for writing correct, performant webpack 5 plugins. Contains 44 rules across 8 categories (8 hook + 7 asset + 5 cache + 5 life + 4 schema + 5 diag + 5 perf + 5 compat = 44), ordered by the authoring lifecycle: hook choice is the foundation, then asset manipulation, then caching/watch-mode correctness, then lifecycle hygiene, then user-facing concerns (schema validation, error reporting), then performance, then packaging.
Patterns are derived from webpack/webpack, the webpack-contrib plugin suite (mini-css-extract, terser, compression, copy, css-minimizer), and Next.js's webpack integration in vercel/next.js.
When to Apply
Reference these rules whenever:
- Writing a new plugin (defining
apply(compiler), picking which hook to tap) - Reviewing existing plugin code for correctness or performance
- Debugging "why isn't my plugin's output showing up" — usually a hook/stage mismatch
- Adding asset manipulation logic (
processAssets,emitAsset,updateAsset) - Fixing watch-mode staleness or persistent-cache poisoning
- Migrating a plugin from webpack 4 to webpack 5 (or supporting both)
- Publishing a plugin to npm (export shape, peerDependencies, schema)
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Hook Selection & Tap Patterns | CRITICAL | hook- |
| 2 | Asset Pipeline | CRITICAL | asset- |
| 3 | Caching & Watch Mode | HIGH | cache- |
| 4 | Plugin Lifecycle & State | HIGH | life- |
| 5 | Schema & Options Validation | MEDIUM-HIGH | schema- |
| 6 | Errors, Warnings & Logging | MEDIUM-HIGH | diag- |
| 7 | Performance & Parallelism | MEDIUM | perf- |
| 8 | Compatibility & Packaging | LOW-MEDIUM | compat- |
Quick Reference
1. Hook Selection & Tap Patterns (CRITICAL)
- `hook-tap-method-matches-hook-type` — Match
tap/tapAsync/tapPromiseto the hook's Sync/Async type - `hook-thiscompilation-vs-compilation` — Use
thisCompilationto skip child compilations - `hook-process-assets-stage` — Pick the right
PROCESS_ASSETS_STAGE_*for your mutation - `hook-prefer-process-assets-over-emit` — Mutate in
processAssets, notemit - `hook-bail-hook-return-semantics` — Return
undefinedfrom bail hooks unless intentionally stopping - `hook-tap-once-not-per-compilation` — Register compiler hooks once in
apply, not inside compilation hooks - `hook-name-matches-class-name` — Use a stable tap name equal to the class name
- `hook-normal-module-factory-stages` — Tap
normalModuleFactoryat the right resolution stage (beforeResolve vs resolve vs afterResolve)
2. Asset Pipeline (CRITICAL)
- `asset-emit-asset-not-direct-assignment` — Use
emitAsset/updateAsset, nevercompilation.assets[name] = ... - `asset-source-from-compiler-webpack` — Import source classes from
compiler.webpack.sources - `asset-preserve-source-maps` — Use
SourceMapSource/ReplaceSourceto keep maps attached - `asset-set-info-metadata` — Set
info.immutable,info.contenthash,info.relatedwhen emitting - `asset-content-hash-via-output-options` — Hash via
compilation.outputOptions.hashFunction, not hardcoded md5 - `asset-delete-then-emit-loses-info` — Use
renameAssetto move;deleteAsset+emitAssetsevers chunk references - `asset-buffer-not-source-for-binary` — Use
buffer()notsource()for binary assets
3. Caching & Watch Mode (HIGH)
- `cache-add-file-dependencies` — Add read files to
compilation.fileDependencies - `cache-context-dependencies-for-directories` — Use
contextDependenciesfor directory scans - `cache-missing-dependencies-for-optional-files` — Add probed-but-absent paths to
missingDependencies - `cache-build-dependencies-for-persistent-cache` — Declare
buildDependenciesfor persistent cache invalidation - `cache-use-input-file-system` — Read via
compiler.inputFileSystem, not Nodefs
4. Plugin Lifecycle & State (HIGH)
- `life-constructor-stores-options-only` — Constructor only validates and stores; side effects belong in
apply() - `life-no-mutable-state-across-builds` — Scope mutable state per-compilation via local
constorWeakMap - `life-multi-compiler-isolation` — One plugin instance per compiler; or use
WeakMap<Compiler, T> - `life-cleanup-in-shutdown-hook` — Clean up workers, watchers, fds in
compiler.hooks.shutdown - `life-defensively-copy-user-options` — Never mutate the user's options object
5. Schema & Options Validation (MEDIUM-HIGH)
- `schema-validate-with-schema-utils` — Validate via
schema-utils.validate()and a JSON Schema - `schema-name-and-base-data-path` — Set
nameandbaseDataPathfor navigable error messages - `schema-additional-properties-false` — Set
additionalProperties: falseon every object to catch typos - `schema-tap-into-validate-hook` — Defer cross-field validation to
compiler.hooks.validate(5.106+)
6. Errors, Warnings & Logging (MEDIUM-HIGH)
- `diag-push-webpack-error-not-throw` — Push
WebpackErrortocompilation.errors, don't throw - `diag-use-compilation-get-logger` — Log via
compilation.getLogger('Plugin'), not console - `diag-attach-loc-to-errors` — Attach
locandmoduleto errors for IDE click-through - `diag-warnings-vs-errors-exit-codes` — Errors fail the build; warnings don't — choose intentionally
- `diag-progress-reporting` — Report progress via
context.reportProgress(opt in withcontext: true)
7. Performance & Parallelism (MEDIUM)
- `perf-jest-worker-for-cpu-bound-work` — Offload CPU-bound work to a
jest-workerpool - `perf-cache-results-with-compilation-cache` — Cache expensive work via
compilation.getCache(name).providePromise - `perf-traverse-chunks-not-modules` — Iterate
compilation.chunksnotcompilation.moduleswhen possible - `perf-avoid-source-toString-in-hot-paths` — Avoid
source().toString()for assets you only inspect - `perf-respect-experimental-options` — Honor
experiments.cacheUnaffected/incremental
8. Compatibility & Packaging (LOW-MEDIUM)
- `compat-webpack-as-peer-dependency` — Declare
webpackaspeerDependencies, notdependencies - `compat-use-compiler-webpack-namespace` — Use
compiler.webpack.*instead ofrequire('webpack') - `compat-custom-hooks-via-weakmap` — Expose custom hooks via static
getCompilationHooks+WeakMap - `compat-feature-detection-not-version-check` — Detect APIs directly; don't parse
webpack/package.jsonversion - `compat-export-shape-and-cjs-esm` — Export the plugin class as default; provide CJS/ESM interop
How to Use
When writing or reviewing plugin code, scan AGENTS.md for the relevant category, then read the individual rule file for the full pattern and rationale.
- Start at `references/_sections.md` for category definitions and impact levels
- See `assets/templates/_template.md` for the rule template if you want to extend this skill
- Read `AGENTS.md` for a compact navigation index
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions, impact levels, descriptions |
| assets/templates/_template.md | Template for authoring new rules |
| metadata.json | Version, discipline, 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
Comprehensive guide for authoring correct, performant webpack 5 plugins, designed for AI agents and LLMs. Contains 44 rules across 8 categories (8 hook + 7 asset + 5 cache + 5 life + 4 schema + 5 diag + 5 perf + 5 compat), ordered by the plugin authoring lifecycle: hook selection (CRITICAL — wrong hook silently breaks plugins), asset pipeline (CRITICAL — bypassing emitAsset corrupts hashing and SRI), caching & watch mode (HIGH — missing dependencies cause stale builds), plugin lifecycle (HIGH — instance state leaks across rebuilds), schema validation, error/log reporting, performance & parallelism, and packaging/compatibility. Each rule includes incorrect vs correct code examples drawn from production plugins (mini-css-extract-plugin, terser-webpack-plugin, compression-webpack-plugin, copy-webpack-plugin, html-webpack-plugin, Next.js webpack plugins) and quantified impact descriptions.
---
Table of Contents
1. Hook Selection & Tap Patterns — CRITICAL
- 1.1 Match tap Method to the Hook's Async Type — CRITICAL (prevents silently dropped async work)
- 1.2 Pick the Right processAssets Stage — CRITICAL (prevents minification undoing your transform)
- 1.3 Prefer processAssets Over the emit Hook for Asset Mutation — CRITICAL (prevents bypassing real-content hashing and SRI)
- 1.4 Register Compiler Hooks Once in apply, Not Inside Compilation Hooks — CRITICAL (prevents O(n) duplicate tap registration)
- 1.5 Return Undefined From Bail Hooks Unless You Mean to Stop — CRITICAL (prevents short-circuiting other plugins)
- 1.6 Tap normalModuleFactory at the Right Resolution Stage — CRITICAL (prevents resolver re-runs and infinite recursion)
- 1.7 Use a Stable, Unique Name for Every tap — CRITICAL (prevents stats/profiling collisions and HMR breakage)
- 1.8 Use thisCompilation to Skip Child Compilations — CRITICAL (prevents firing for every child compilation)
2. Asset Pipeline — CRITICAL
- 2.1 Hash Asset Content With compilation.outputOptions.hashFunction — CRITICAL (prevents hash collisions across builds with custom hashFunction)
- 2.2 Import Source Classes From compiler.webpack.sources — CRITICAL (prevents version drift breaking persistent cache)
- 2.3 Preserve Source Maps When Transforming Assets — CRITICAL (prevents detached source maps and broken debugging)
- 2.4 Set asset.info Metadata When Emitting — CRITICAL (prevents wrong cache headers and broken SRI)
- 2.5 Use buffer() Not source() for Binary Assets — CRITICAL (prevents UTF-8 corruption of images and wasm)
- 2.6 Use emitAsset / updateAsset, Not Direct compilation.assets Mutation — CRITICAL (prevents desynced asset.info, hashes, and cache state)
- 2.7 Use renameAsset to Move Assets, Not Delete + Emit — CRITICAL (prevents losing related-asset graph and chunk linkage)
3. Caching & Watch Mode — HIGH
- 3.1 Add Code Inputs to buildDependencies for Persistent Cache — HIGH (prevents cache poisoning across plugin upgrades)
- 3.2 Add Looked-For-But-Absent Paths to missingDependencies — HIGH (prevents stale builds when an optional file appears)
- 3.3 Add Read Files to compilation.fileDependencies — HIGH (prevents stale builds in watch mode)
- 3.4 Read Files Via compiler.inputFileSystem, Not Node fs — HIGH (prevents bypassing the in-memory dev-server filesystem)
- 3.5 Use contextDependencies for Directory Scans, Not Glob Expansion — HIGH (prevents missing newly-added files in watch mode)
4. Plugin Lifecycle & State — HIGH
- 4.1 Avoid Mutable State Across Compilations — HIGH (prevents leaking partial state between rebuilds)
- 4.2 Clean Up Resources in compiler.hooks.shutdown — HIGH (prevents hanging CI processes and leaked workers)
- 4.3 Never Mutate the User's Options Object — HIGH (prevents corrupting config across compiler instances)
- 4.4 One Plugin Instance Per Compiler in MultiCompiler Setups — HIGH (prevents shared state corrupting parallel builds)
- 4.5 Store Options in the Constructor, Do the Work in apply() — HIGH (prevents side effects on plugin import)
5. Schema & Options Validation — MEDIUM-HIGH
- 5.1 Set additionalProperties: false on Every Object — MEDIUM-HIGH (catches ~90% of misconfigurations (typo-driven default activation))
- 5.2 Set name and baseDataPath on validate() — MEDIUM-HIGH (prevents anonymous "an options object" errors that don't name the plugin)
- 5.3 Use compiler.hooks.validate for Cross-Cutting Validation (5.106+) — MEDIUM-HIGH (prevents expensive validation running on every config load)
- 5.4 Validate Options With schema-utils — MEDIUM-HIGH (surfaces typos at config-load instead of mid-build)
6. Errors, Warnings & Logging — MEDIUM-HIGH
- 6.1 Attach loc and module to Errors for Source Mapping — MEDIUM-HIGH (enables IDE click-through to the offending line)
- 6.2 Choose Errors for Build Failures, Warnings for Quality Notices — MEDIUM-HIGH (prevents CI surprises (silent pass / false fail))
- 6.3 Log via compilation.getLogger, Not console — MEDIUM-HIGH (prevents log spam and integrates with stats filtering)
- 6.4 Push WebpackError to compilation.errors Instead of Throwing — MEDIUM-HIGH (prevents one bad input from killing the whole build)
- 6.5 Report Progress via context.reportProgress — MEDIUM-HIGH (prevents a frozen progress bar during 10-60s plugin work)
7. Performance & Parallelism — MEDIUM
- 7.1 Avoid source().toString() on Assets You Won't Modify — MEDIUM (skip O(asset-bytes) materialization for read-only checks)
- 7.2 Cache Expensive Work via compilation.getCache — MEDIUM (10-100x faster watch rebuilds (skips unchanged assets))
- 7.3 Honor compiler.options.experiments.cacheUnaffected and incremental — MEDIUM (5x faster incremental rebuilds (limits work to changed inputs))
- 7.4 Offload CPU-Bound Work to jest-worker — MEDIUM (2-4x build speedup on multi-core machines)
- 7.5 Traverse compilation.chunks Not compilation.modules When Possible — MEDIUM (O(modules) becomes O(chunks) — often 10-100x fewer iterations)
8. Compatibility & Packaging — LOW-MEDIUM
- 8.1 Declare webpack as peerDependency, Not dependency — LOW-MEDIUM (prevents duplicate webpack installs and version drift)
- 8.2 Detect API Presence, Don't Check Webpack Versions — LOW-MEDIUM (prevents brittle version-string parsing)
- 8.3 Export the Plugin Class Directly as module.exports (CJS) or default (ESM) — LOW-MEDIUM (prevents users seeing "X is not a constructor")
- 8.4 Expose Custom Hooks via getCompilationHooks WeakMap — LOW-MEDIUM (prevents memory leaks from per-compilation hook state)
- 8.5 Use compiler.webpack.* Instead of Importing webpack — LOW-MEDIUM (prevents class-identity mismatches in monorepos)
---
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/plugins/ 5. https://webpack.js.org/contribute/plugin-patterns/ 6. https://webpack.js.org/api/compilation-object/ 7. https://webpack.js.org/blog/2020-10-10-webpack-5-release/ 8. https://github.com/webpack/changelog-v5/blob/master/guides/persistent-caching.md 9. https://github.com/webpack/schema-utils 10. https://github.com/webpack/webpack-sources 11. https://github.com/webpack-contrib/mini-css-extract-plugin 12. https://github.com/webpack-contrib/terser-webpack-plugin 13. https://github.com/webpack-contrib/compression-webpack-plugin 14. https://github.com/webpack-contrib/copy-webpack-plugin 15. https://github.com/webpack-contrib/css-minimizer-webpack-plugin 16. https://github.com/jantimon/html-webpack-plugin 17. https://github.com/vercel/next.js/tree/canary/packages/next/src/build/webpack 18. https://github.com/jestjs/jest/tree/main/packages/jest-worker
---
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 — imperative form}
{1-3 sentences explaining WHY this matters. Focus on what goes wrong without this pattern: the cascade effect, the bug it produces in production, the cost it imposes at scale. Be specific — name the failure mode (e.g., "the dev-server overlay shows nothing meaningful", "persistent cache misses 95% of the time"). The model generalizes from understood reasoning, not from dictation — explain the mechanism, not just the rule.}
Incorrect ({what's wrong — one phrase}):
// Production-realistic code drawn from how this is actually written.
// Avoid strawman examples like `const foo = bar`.
class MyExamplePlugin {
apply(compiler) {
// Annotate the offending line with a comment explaining the cost
compiler.hooks.someHook.tap('MyExamplePlugin', /* ... */);
}
}Correct ({what's right — one phrase}):
// Minimal diff from the incorrect version — the FIX should be clearly visible.
class MyExamplePlugin {
apply(compiler) {
compiler.hooks.theRightHook.tap('MyExamplePlugin', /* ... */);
}
}{Optional sections — include only if applicable:}
Alternative ({when applicable}):
// Different valid approach for a specific scenarioWhen NOT to use this pattern:
- {Specific exception 1 — when the "wrong" thing is actually right}
- {Specific exception 2}
Benefits:
- {Concrete benefit 1}
- {Concrete benefit 2}
Decision table / API summary:
| Column 1 | Column 2 |
|---|---|
| Option A | When to use |
| Option B | When to use |
Reference: [{Authoritative source title}]({URL — webpack.js.org, github.com/webpack-contrib, vercel/next.js})
---
Authoring checklist
Before adding a new rule:
- [ ] Filename matches
{prefix}-{kebab-case-slug}.mdwhere{prefix}is one of the 8 categories fromreferences/_sections.md - [ ] First tag in frontmatter is the category prefix
- [ ] Title is in imperative form (Use, Avoid, Cache, Match, Set...)
- [ ]
impactDescriptionis quantified — Nx improvement, Nms saved, prevents <named failure>, or O(x) to O(y) - [ ] Code examples are production-realistic (drawn from real plugins or written to that bar)
- [ ] Both Incorrect AND Correct sections present with
**Incorrect ({label}):**exactly - [ ] Code blocks have a language specifier (
`js,`json,`text) - [ ] Reference link points to webpack.js.org, github.com/webpack, github.com/webpack-contrib, vercel/next.js, or another authoritative source
- [ ] After saving, run
node ${CLAUDE_PLUGIN_ROOT}/scripts/validate-skill.js /path/to/this/skillto verify - [ ] After saving, run
node ${CLAUDE_PLUGIN_ROOT}/scripts/build-agents-md.js /path/to/this/skillto refreshAGENTS.md
{
"version": "0.1.0",
"organization": "dot-skills",
"technology": "Webpack 5 Plugins",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Comprehensive guide for authoring correct, performant webpack 5 plugins, designed for AI agents and LLMs. Contains 44 rules across 8 categories (8 hook + 7 asset + 5 cache + 5 life + 4 schema + 5 diag + 5 perf + 5 compat), ordered by the plugin authoring lifecycle: hook selection (CRITICAL — wrong hook silently breaks plugins), asset pipeline (CRITICAL — bypassing emitAsset corrupts hashing and SRI), caching & watch mode (HIGH — missing dependencies cause stale builds), plugin lifecycle (HIGH — instance state leaks across rebuilds), schema validation, error/log reporting, performance & parallelism, and packaging/compatibility. Each rule includes incorrect vs correct code examples drawn from production plugins (mini-css-extract-plugin, terser-webpack-plugin, compression-webpack-plugin, copy-webpack-plugin, html-webpack-plugin, Next.js webpack plugins) and quantified impact descriptions.",
"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/plugins/",
"https://webpack.js.org/contribute/plugin-patterns/",
"https://webpack.js.org/api/compilation-object/",
"https://webpack.js.org/blog/2020-10-10-webpack-5-release/",
"https://github.com/webpack/changelog-v5/blob/master/guides/persistent-caching.md",
"https://github.com/webpack/schema-utils",
"https://github.com/webpack/webpack-sources",
"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/css-minimizer-webpack-plugin",
"https://github.com/jantimon/html-webpack-plugin",
"https://github.com/vercel/next.js/tree/canary/packages/next/src/build/webpack",
"https://github.com/jestjs/jest/tree/main/packages/jest-worker"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Hook Selection & Tap Patterns (hook)
Impact: CRITICAL Description: Picking the wrong hook or tap method silently breaks the plugin — async work fires after emit completes, taps registered on compilation fire for child compilations, and processAssets mutations land in the wrong stage. This is the foundation: every other rule assumes you tapped the right hook with the right method.
2. Asset Pipeline (asset)
Impact: CRITICAL Description: Webpack treats assets as immutable Source objects with metadata; mutating compilation.assets[name] directly bypasses emitAsset/updateAsset invariants and breaks SRI hashes, content-hashed filenames, source maps, and downstream plugins that read assets.info. The asset graph is the plugin's primary output surface.
3. Caching & Watch Mode (cache)
Impact: HIGH Description: Webpack's watch mode and persistent cache only invalidate on inputs the plugin has declared via fileDependencies, contextDependencies, missingDependencies, and buildDependencies. Forgetting any of these produces stale builds that pass tests locally but ship the wrong bytes to production.
4. Plugin Lifecycle & State (life)
Impact: HIGH Description: A plugin instance is constructed once and reused across every compilation, every --watch rebuild, and every MultiCompiler child. Mutable instance state leaks between builds; side effects in the constructor break test isolation; taps that should run once-per-compilation fire for every child compilation if registered on the wrong hook.
5. Schema & Options Validation (schema)
Impact: MEDIUM-HIGH Description: schema-utils is the contract between the plugin and its users — it produces consistent error messages, surfaces invalid options early, and integrates with webpack's experiments.futureDefaults validation toggle. Hand-rolled throw new Error('bad option') produces unfindable errors and hides typos in nested option keys.
6. Errors, Warnings & Logging (diag)
Impact: MEDIUM-HIGH Description: Throwing inside a tap kills the build with a confusing stack; the correct surface is compilation.errors.push(new WebpackError(...)) and compilation.getLogger('PluginName'), both of which integrate with webpack's stats output, IDE error overlays, dev-server overlay, and infrastructure logging filters.
7. Performance & Parallelism (perf)
Impact: MEDIUM Description: Plugin code runs inside the build hot path — synchronous filesystem reads, repeated module traversals, and unbounded CPU work serialize on the main thread and dominate build time at scale. jest-worker, chunk-level traversal, and reusing compilation.cache keep plugins viable in monorepos like Next.js and Storybook.
8. Compatibility & Packaging (compat)
Impact: LOW-MEDIUM Description: Webpack 5 made webpack a peerDependency for plugins and exposes its sub-modules via compiler.webpack.* so consumers can use any compatible webpack version with persistent caching. Direct require('webpack') and the legacy hooks.foo = bar extension pattern break in monorepos with multiple webpack versions and in webpack 5+'s sealed hook surface.
Use buffer() Not source() for Binary Assets
Source.source() returns either a string or a Buffer, depending on what was originally passed in — but webpack code that handles unknown assets generally calls .toString() on the result for convenience, which UTF-8-encodes binary content and silently corrupts PNGs, fonts, wasm modules, and source maps. Source.buffer() is the contract for "give me bytes regardless of how I was constructed." Use buffer() whenever the asset might be binary; use source() only when you've already established it's text.
Incorrect (source() coerces to string — corrupts binary content):
compilation.hooks.processAssets.tap(
{ name: 'PngOptimizerPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE },
async (assets) => {
for (const name of Object.keys(assets)) {
if (!name.endsWith('.png')) continue;
const original = compilation.getAsset(name).source;
// .source() may return string OR buffer — code below assumes buffer
const bytes = original.source(); // <-- if string, UTF-8-encoded → corrupt PNG
const compressed = await pngquant(bytes);
compilation.updateAsset(name, new sources.RawSource(compressed));
}
},
);Correct (buffer() always returns a Buffer):
compilation.hooks.processAssets.tapPromise(
{ name: 'PngOptimizerPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE },
async (assets) => {
for (const name of Object.keys(assets)) {
if (!name.endsWith('.png')) continue;
const original = compilation.getAsset(name).source;
const bytes = original.buffer(); // always a Buffer, even for OriginalSource
const compressed = await pngquant(bytes);
compilation.updateAsset(name, new sources.RawSource(compressed));
}
},
);Source method contract:
| Method | Returns | Use for |
|---|---|---|
source() | `string \ | Buffer` |
buffer() | Buffer | ANY content where bytes matter (binary OR text) |
size() | number (bytes) | Reporting, ordering — does NOT materialize content |
map(opts?) | `RawSourceMap \ | null` |
sourceAndMap(opts?) | { source, map } | Single materialization for both |
Performance note: size() returns a cached value when available; calling it doesn't decompress or read the underlying buffer. Prefer size() over buffer().length when you only need the size.
RawSource accepts both:
new sources.RawSource(stringContent); // text
new sources.RawSource(Buffer.from(bytes)); // binary — preserved as-is
new sources.RawSource(stringContent, /* convertToString */ false); // skip conversion in toString()The third argument is webpack 5.79+ — pass false for binary content stored as a string to prevent webpack from trying to re-encode it.
Reference: webpack-sources — Source API · webpack 5.79 release notes
Hash Asset Content With compilation.outputOptions.hashFunction
Webpack 5 lets users configure output.hashFunction (commonly xxhash64 for speed in Next.js builds, md4 for legacy compatibility). Hardcoding crypto.createHash('md5') in a plugin produces filenames that don't match the hash function the rest of the build uses, breaks realContentHash, and causes webpack-subresource-integrity to compute a different hash than the filename. Always derive the hash function from compilation.outputOptions.
Incorrect (hardcoded md5 — collides with user's xxhash64 config):
const crypto = require('node:crypto');
compilation.hooks.processAssets.tap(
{ name: 'EmitManifestPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL },
() => {
const content = JSON.stringify(buildManifest());
const hash = crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
compilation.emitAsset(`manifest.${hash}.json`, new sources.RawSource(content));
},
);Correct (use the compilation's hash function — matches the rest of the build):
compilation.hooks.processAssets.tap(
{ name: 'EmitManifestPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL },
() => {
const content = JSON.stringify(buildManifest());
// Pick up user config: hashFunction, hashDigest, hashDigestLength
const { hashFunction, hashDigest, hashDigestLength } = compilation.outputOptions;
const hasher = compilation.compiler.webpack.util.createHash(hashFunction);
const hash = hasher.update(content).digest(hashDigest).slice(0, hashDigestLength);
compilation.emitAsset(`manifest.${hash}.json`, new sources.RawSource(content), {
immutable: true,
contenthash: [hash],
});
},
);Why the indirection (`compiler.webpack.util.createHash`):
- Returns webpack's hash wrapper, which knows how to handle
'xxhash64'(provided by webpack viaxxhash-addon) —crypto.createHash('xxhash64')throws - Reuses the same hash provider webpack uses internally → identical bytes
- Works with custom hash functions registered via
output.hashFunction: () => MyHasher
Pattern for filename generation that mirrors webpack's own:
const { ModuleFilenameHelpers } = compiler.webpack;
const filename = compilation.getAssetPath(
compilation.outputOptions.assetModuleFilename || '[hash][ext]',
{ contentHash: hash, chunk: { name: 'manifest' } },
);This passes through user-configured filename templates ([name].[contenthash:8].js etc.) instead of hardcoding a layout.
When a fixed hash function IS appropriate:
- The asset is consumed externally and the consumer requires a specific algorithm (e.g., generating a
package-lock.json-style integrity field for a specific tool that requires SHA-512) - In that case, hash with the required algorithm but DON'T put that hash in the filename used by webpack's contenthash pipeline
Reference: Output options — hashFunction · Real Content Hash Plugin
Use renameAsset to Move Assets, Not Delete + Emit
When an asset is part of a chunk, webpack tracks the relationship in the chunk graph (chunk.files, chunk.auxiliaryFiles) and in the related-asset graph (info.related.sourceMap, info.related.gz). compilation.deleteAsset(old) followed by compilation.emitAsset(new, source) severs all of these references — chunks point to a missing file, source maps detach, and the manifest plugin emits a stale entry. compilation.renameAsset(old, new) updates every cross-reference atomically.
Incorrect (delete + emit — chunk graph still references the old name):
compilation.hooks.processAssets.tap(
{ name: 'PrefixAssetsPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_HASH },
(assets) => {
for (const oldName of Object.keys(assets)) {
const newName = `cdn/${oldName}`;
const asset = compilation.getAsset(oldName);
compilation.deleteAsset(oldName);
compilation.emitAsset(newName, asset.source, asset.info);
// chunk.files still contains oldName; sourcemap.related still points at oldName.map
}
},
);Correct (renameAsset rewrites every reference):
compilation.hooks.processAssets.tap(
{ name: 'PrefixAssetsPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_HASH },
(assets) => {
for (const oldName of Object.keys(assets)) {
const newName = `cdn/${oldName}`;
compilation.renameAsset(oldName, newName);
// chunk.files updated; related sourcemap reference rebased
}
},
);What `renameAsset` updates that delete+emit doesn't:
| Reference | delete + emit | renameAsset |
|---|---|---|
compilation.assets map key | ✓ (manually) | ✓ |
compilation.assetsInfo map | ✗ (info passed but not the original entry) | ✓ |
chunk.files Set | ✗ — points at deleted asset | ✓ |
chunk.auxiliaryFiles Set | ✗ | ✓ |
info.related.sourceMap references | ✗ | ✓ |
| Persistent cache key | ✗ — cache write is for new key, old key orphaned | ✓ |
`deleteAsset` IS the right call when:
- The asset is genuinely going away (e.g., stripping a dev-only
.LICENSE.txtcompanion in production) - You explicitly want to break the chunk reference (e.g., replacing a chunk with multiple new ones)
- You're cleaning up an asset emitted by a different plugin you're explicitly replacing
In all those cases, follow the delete with cleanup of the chunk reference yourself: chunk.files.delete(oldName) for every chunk in compilation.chunks.
Reference: Compilation API — renameAsset
Use emitAsset / updateAsset, Not Direct compilation.assets Mutation
Webpack 5 stores assets behind methods that maintain three parallel structures: the Source object itself, the info metadata (immutable, hash, related, contenthash), and the persistent-cache entry. Direct assignment via compilation.assets[name] = source updates only the first, leaving info empty and the cache stale. Tools downstream (html-webpack-plugin, webpack-subresource-integrity, compression-webpack-plugin) read getAsset(name).info and silently produce wrong output.
Incorrect (direct assignment — info metadata lost, real-content hash bypassed):
compilation.hooks.processAssets.tap(
{ name: 'StripBomPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_PRE_PROCESS },
(assets) => {
for (const name of Object.keys(assets)) {
const original = assets[name];
const cleaned = original.source().toString().replace(/^/, '');
// Lost: original info (immutable, sourceFilename, related sourcemap)
compilation.assets[name] = new sources.RawSource(cleaned);
}
},
);Correct (updateAsset preserves info, integrates with cache):
compilation.hooks.processAssets.tap(
{ name: 'StripBomPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_PRE_PROCESS },
(assets) => {
for (const name of Object.keys(assets)) {
compilation.updateAsset(
name,
(old) => new sources.RawSource(old.source().toString().replace(/^/, '')),
// Optional second arg: info updater function
(oldInfo) => ({ ...oldInfo, javascriptModule: undefined }),
);
}
},
);API summary:
| Operation | Method | Notes |
|---|---|---|
| Add a new asset | compilation.emitAsset(name, source, info?) | Throws if name exists; pair with compilation.fileDependencies.add(...) if derived from a source file |
| Replace existing | `compilation.updateAsset(name, source \ | (old) => new, info? \ |
| Remove asset | compilation.deleteAsset(name) | Removes from assets and from related child references |
| Read | compilation.getAsset(name) | Returns { name, source, info } — preferred over compilation.assets[name] |
`info` fields downstream plugins rely on:
immutable: true— long-term cacheable (filename has content hash)contenthash: string[]— list of content hashes, used by SRIrelated: { sourceMap: 'foo.js.map' }— sibling assets that should follow this onesourceFilename— the originating source pathhotModuleReplacement: true— HMR runtime asset, do not modify
Reference: Compilation API — emitAsset / updateAsset / deleteAsset
Preserve Source Maps When Transforming Assets
Wrapping a transformed asset in RawSource discards its source map — debuggers, error trackers (Sentry), and the dev-server overlay then point at minified output. The correct primitives are SourceMapSource (when you have a fresh map for the new content) and ReplaceSource (when you make text-replacement edits and want webpack to compute the rebased map). Both preserve the source-map graph so downstream consumers still get useful stack traces.
Incorrect (RawSource drops the map — production stack traces become useless):
const { sources } = compiler.webpack;
compilation.updateAsset(name, (old) => {
const code = old.source().toString();
const transformed = transformAst(code); // syntax transform, no map produced
return new sources.RawSource(transformed); // map gone
});Correct (when you produce a fresh map alongside the new code):
const { sources } = compiler.webpack;
compilation.updateAsset(name, (old) => {
const code = old.source().toString();
const oldMap = old.map(); // may be null if no map
const { code: newCode, map: newMap } = transformAstWithMap(code, oldMap);
return new sources.SourceMapSource(
newCode,
name,
newMap,
code, // original source for `original` link
oldMap, // input map to chain through
/* removeOriginalSource */ true,
);
});Alternative (pure text replacement — let ReplaceSource compute the map):
const { sources } = compiler.webpack;
compilation.updateAsset(name, (old) => {
const replacer = new sources.ReplaceSource(old, name);
// Each replace records position; final map is computed against the input map
for (const match of findEnvVarReferences(old.source().toString())) {
replacer.replace(match.start, match.end - 1, JSON.stringify(process.env[match.name]));
}
return replacer;
});Decision matrix:
| Transform shape | Use |
|---|---|
| AST/codegen produces own source map | SourceMapSource(code, name, newMap, origSrc, inputMap, true) |
| String find/replace, splicing, prepend/append | ReplaceSource (computes map automatically) |
| Concatenating multiple sources (banners, headers) | ConcatSource(...) (preserves each child's map) |
| Caching an expensive transformation | wrap result in CachedSource(inner) |
| Truly no source mapping (e.g., a binary asset) | RawSource is fine |
Don't strip the original source map asset. When you produce a new map, webpack will emit it as a related asset (name + '.map') automatically through the SourceMapDevToolPlugin pipeline if devtool is configured. Do not compilation.deleteAsset(name + '.map') manually.
Reference: webpack-sources README · SourceMapDevToolPlugin
Set asset.info Metadata When Emitting
asset.info is webpack's contract with the rest of the toolchain about what an asset is and how downstream tools should treat it. Emitting an asset without info.immutable causes Next.js's CDN headers to disable long-term caching; emitting without info.contenthash makes webpack-subresource-integrity skip the asset; emitting without info.related orphans companion files (source maps, gzipped versions). Setting info correctly is part of "correctly emitting an asset," not an optional polish step.
Incorrect (no info — looks fine in dev, breaks production caching and SRI):
const { sources } = compiler.webpack;
const hash = compilation.outputOptions.hashFunction;
const filename = `licenses.${computeHash(content)}.txt`;
compilation.emitAsset(filename, new sources.RawSource(content));
// No info — CDN won't cache, SRI skips it, source map won't followCorrect (info documents the contract):
const { sources } = compiler.webpack;
const contentHash = compilation.outputOptions.hashFunction;
const filename = `licenses.${computeHash(content)}.txt`;
compilation.emitAsset(filename, new sources.RawSource(content), {
// Filename contains content hash — safe for far-future Cache-Control
immutable: true,
// Hashes used by SRI and integrity-checking plugins
contenthash: [computeHash(content)],
// Companion files webpack should ship together
related: { sourceMap: filename + '.map' },
// Where this asset came from (some loaders use this for HMR)
sourceFilename: 'LICENSES',
// Mark generated content so other plugins know not to retransform
development: false,
// For text assets, declare encoding so compression plugin can act on it
minimized: false,
});Most-used `info` fields:
| Field | Type | Used by |
|---|---|---|
immutable | boolean | CDN cache headers (Next.js, Vercel), browser cache lifetime |
contenthash | `string \ | string[]` |
minimized | boolean | Skips re-minification; informs bundle analyzer |
related | `{ [key]: string \ | string[] }` |
sourceFilename | string | HMR module replacement, error originator display |
chunkhash | `string \ | string[]` |
fullhash | `string \ | string[]` |
hotModuleReplacement | boolean | HMR runtime — must NOT be re-emitted by other plugins |
javascriptModule | boolean | Asset is an ESM module (affects <script type=module>) |
development | boolean | Marks dev-only assets that should be stripped from prod stats |
size | number | Hint for bundle reports without forcing source materialization |
When updating an asset, merge — don't replace — the info:
compilation.updateAsset(
name,
newSource,
(oldInfo) => ({ ...oldInfo, minimized: true }), // preserve everything else
);Reference: Compilation API — assetInfo · webpack-subresource-integrity
Import Source Classes From compiler.webpack.sources
webpack-sources ships its own Source base class with an internal valueOf/buffer/map contract. When a plugin imports RawSource from a different webpack-sources version than the host webpack uses, the resulting source objects fail instanceof checks inside webpack's persistent cache serializer — the build appears to succeed but every rebuild misses cache, and source maps may detach. compiler.webpack.sources exposes the exact instance webpack itself uses, with no version drift possible.
Incorrect (direct import — version may differ from host webpack):
const { RawSource, ConcatSource } = require('webpack-sources');
class BannerPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap('BannerPlugin', (compilation) => {
compilation.hooks.processAssets.tap(/* ... */, (assets) => {
for (const name of Object.keys(assets)) {
// RawSource here may not be the SAME class webpack 5.95 uses internally
compilation.updateAsset(name, (old) => new ConcatSource('/* banner */', old));
}
});
});
}
}Correct (use the namespace webpack itself exposes):
class BannerPlugin {
apply(compiler) {
const { sources, Compilation } = compiler.webpack;
compiler.hooks.thisCompilation.tap('BannerPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{ name: 'BannerPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS },
(assets) => {
for (const name of Object.keys(assets)) {
compilation.updateAsset(
name,
(old) => new sources.ConcatSource('/* banner */', old),
);
}
},
);
});
}
}Everything `compiler.webpack` exposes (use it instead of importing `webpack`):
compiler.webpack.X | Replaces |
|---|---|
sources.RawSource | require('webpack-sources').RawSource |
sources.ConcatSource | require('webpack-sources').ConcatSource |
sources.SourceMapSource | require('webpack-sources').SourceMapSource |
sources.OriginalSource | require('webpack-sources').OriginalSource |
sources.ReplaceSource | require('webpack-sources').ReplaceSource |
sources.CachedSource | require('webpack-sources').CachedSource |
Compilation | require('webpack').Compilation (for stage constants) |
WebpackError | require('webpack').WebpackError |
ModuleFilenameHelpers | require('webpack').ModuleFilenameHelpers |
Why this is non-negotiable for persistent cache: Webpack 5's persistent cache serializes Source instances by class name lookup. A RawSource from a different module instance has a different class identity and fails the lookup, falling back to a re-serialization path that doesn't survive across processes.
Reference: Webpack 5 release — sources via compiler.webpack · GitHub: webpack/webpack-sources
Add Read Files to compilation.fileDependencies
Webpack's watcher only re-runs the compilation when something in compilation.fileDependencies changes. A plugin that reads a config file or template without registering it as a dependency produces a build that is stale until the developer manually restarts the dev server. The same applies to persistent cache: webpack snapshots fileDependencies to decide whether the cache entry is still valid.
Incorrect (reads `tailwind.config.js` but never declares it — edits don't trigger rebuild):
class TailwindConfigPlugin {
constructor({ configPath }) { this.configPath = configPath; }
apply(compiler) {
compiler.hooks.thisCompilation.tap('TailwindConfigPlugin', (compilation) => {
compilation.hooks.processAssets.tap(/* ... */, () => {
const config = require(this.configPath); // every rebuild reads, but watch never fires
emitTailwindArtifacts(config);
});
});
}
}Correct (register the file as a dependency in the SAME hook that reads it):
class TailwindConfigPlugin {
constructor({ configPath }) { this.configPath = path.resolve(configPath); }
apply(compiler) {
compiler.hooks.thisCompilation.tap('TailwindConfigPlugin', (compilation) => {
compilation.hooks.processAssets.tap(/* ... */, () => {
// Bust require's own cache so watch picks up edits
delete require.cache[this.configPath];
const config = require(this.configPath);
emitTailwindArtifacts(compilation, config);
// Tell webpack to re-run when this file changes
compilation.fileDependencies.add(this.configPath);
});
});
}
}Three dependency Sets, one rule each:
| Set | When to add | Example |
|---|---|---|
compilation.fileDependencies | Specific file you READ | tailwind.config.js, the manifest template |
compilation.contextDependencies | DIRECTORY you scanned (any file change in it triggers rebuild) | src/icons/ for an SVG sprite plugin |
compilation.missingDependencies | File path you LOOKED FOR but didn't find — rebuild if it appears | package.json in a parent dir for monorepo root detection |
Always use absolute paths. Webpack normalizes path comparisons via the dependency snapshot system; relative paths produce ambiguous matches and warning output ("dependencies should be absolute paths").
Add dependencies in the hook where you read the file — not in `apply()`. The dependency Sets exist on compilation, not on compiler, so a tap is required. Adding in make or thisCompilation synchronously is fine; adding in done is too late.
Don't forget on rebuild: compilation.fileDependencies resets each compilation. You must re-add on every run, hence the pattern above where .add() lives inside the asset-emission tap.
Reference: Compilation API — fileDependencies · Persistent caching guide
Add Code Inputs to buildDependencies for Persistent Cache
Webpack 5's persistent cache (cache.type: 'filesystem') keys cache entries on a hash of buildDependencies — the files that, if changed, would change the build OUTPUT for the same input. When a plugin reads from a config file, template, or JSON schema and that file isn't in buildDependencies, editing the file leaves the cache valid even though the build is now different. The user sees stale output that survives --watch restarts; the only fix is rm -rf node_modules/.cache.
Incorrect (plugin reads template.html but doesn't register it — cache survives edits):
class HtmlTemplatePlugin {
constructor({ template }) { this.template = template; }
apply(compiler) {
compiler.hooks.thisCompilation.tap('HtmlTemplatePlugin', (compilation) => {
compilation.hooks.processAssets.tap(/* ... */, () => {
const html = fs.readFileSync(this.template, 'utf8');
compilation.emitAsset('index.html', new sources.RawSource(html));
compilation.fileDependencies.add(this.template); // watch invalidates
// But persistent cache doesn't — template edits survive cache hit
});
});
}
}Correct (declare buildDependencies in beforeCompile so the cache keys on them):
class HtmlTemplatePlugin {
constructor({ template }) { this.template = path.resolve(template); }
apply(compiler) {
compiler.hooks.beforeCompile.tap('HtmlTemplatePlugin', (params) => {
// Tell cache: changes to these files invalidate everything
params.buildDependencies = params.buildDependencies ?? new Set();
params.buildDependencies.add(this.template);
});
compiler.hooks.thisCompilation.tap('HtmlTemplatePlugin', (compilation) => {
compilation.hooks.processAssets.tap(/* ... */, () => {
const html = fs.readFileSync(this.template, 'utf8');
compilation.emitAsset('index.html', new sources.RawSource(html));
compilation.fileDependencies.add(this.template);
});
});
}
}Even better — register your plugin's own source as a build dependency:
// Recommended Webpack docs pattern: also include the plugin source itself
module.exports = class HtmlTemplatePlugin {
/* ... */
};
// Then in webpack.config.js:
module.exports = {
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename], // invalidate cache when webpack.config.js changes
plugin: [require.resolve('./html-template-plugin')], // invalidate when plugin upgrades
},
},
};`fileDependencies` vs `buildDependencies` — they solve different problems:
| Dependency | Invalidates | Lives on |
|---|---|---|
compilation.fileDependencies | Watch mode rebuild within the same process | Compilation |
compilation.buildDependencies (via beforeCompile) | Persistent cache across processes | CompilationParams |
You need BOTH for files whose contents affect output: fileDependencies for watch, buildDependencies for persistent cache.
What counts as a buildDependency:
- Plugin source files (so plugin upgrades invalidate cache)
- Loader source files (declared via
cache.buildDependencies.loader) - Config files (
webpack.config.js,babel.config.js,tsconfig.jsonif your plugin reads it) - JSON schemas your plugin validates against
Don't include node_modules wholesale. Webpack hashes every buildDependency on every build — adding node_modules/** makes startup multi-second.
Reference: cache.buildDependencies · Persistent caching guide
Use contextDependencies for Directory Scans, Not Glob Expansion
When a plugin scans src/icons/*.svg and adds each resolved file to fileDependencies, watch mode rebuilds when an existing icon changes — but does NOT rebuild when a NEW icon is added to the directory. To watch the existence of files (additions and deletions, not just modifications), add the directory itself to compilation.contextDependencies. Webpack snapshots the directory listing and triggers a rebuild when it changes.
Incorrect (only watches files that existed at first scan):
const fg = require('fast-glob');
compilation.hooks.processAssets.tap(/* ... */, async () => {
const icons = await fg('src/icons/*.svg', { cwd: compiler.context, absolute: true });
for (const icon of icons) {
const svg = await fs.promises.readFile(icon, 'utf8');
emitSprite(compilation, icon, svg);
compilation.fileDependencies.add(icon); // catches modifications only
}
// Adding a new src/icons/foo.svg does NOT trigger a rebuild
});Correct (declare the directory so additions/deletions invalidate the build):
const fg = require('fast-glob');
compilation.hooks.processAssets.tapPromise(/* ... */, async () => {
const iconsDir = path.resolve(compiler.context, 'src/icons');
const icons = await fg('*.svg', { cwd: iconsDir, absolute: true });
for (const icon of icons) {
const svg = await fs.promises.readFile(icon, 'utf8');
emitSprite(compilation, icon, svg);
compilation.fileDependencies.add(icon); // existing files: modifications
}
compilation.contextDependencies.add(iconsDir); // directory: additions + deletions
});Decision matrix:
| What you depend on | Add to |
|---|---|
| A specific file's content | fileDependencies |
| The set of files in a directory (additions/deletions matter) | contextDependencies |
| A specific filename that DOESN'T exist yet but might appear | missingDependencies |
| Both directory contents AND each file's content | Both: directory to contextDependencies, each file to fileDependencies |
Common contextDependencies use cases:
- Icon-sprite plugins scanning
src/icons/ - i18n plugins watching
locales/for new language files - Route plugins generating from a
pages/filesystem (the Next.js pattern) - Asset plugins watching
public/for new static files
Glob patterns are NOT valid dependencies. Webpack 5 deprecated converting globs to context dependencies — pass an absolute directory path instead. If you need recursive watching of subdirectories, you must add each subdirectory you read.
Performance note: Context dependencies snapshot the directory listing on every build. Avoid declaring node_modules or your project root — it triggers expensive directory scans on every rebuild.
Reference: Compilation API — contextDependencies · Plugin Patterns — Watch Graph
Add Looked-For-But-Absent Paths to missingDependencies
When a plugin checks for an optional file (e.g., tsconfig.json in each candidate parent directory, a .env.production override) and the file doesn't exist, the absence is a real input to the build. If a developer later creates that file, the build must rebuild — but webpack has no way to know unless the path was registered in compilation.missingDependencies. Without it, the next rebuild produces stale output until something else forces a full rebuild.
Incorrect (probes for `.env.local` but never declares it — adding the file is invisible):
compilation.hooks.processAssets.tap(/* ... */, () => {
const envLocalPath = path.resolve(compiler.context, '.env.local');
let extraEnv = {};
if (fs.existsSync(envLocalPath)) {
extraEnv = parseDotenv(fs.readFileSync(envLocalPath, 'utf8'));
compilation.fileDependencies.add(envLocalPath);
}
// If .env.local DOESN'T exist now but is created later:
// existsSync still returns false on the next rebuild (require.cache),
// and webpack has no fileDependency on it. Build is permanently stale.
emitEnvManifest(compilation, extraEnv);
});Correct (declare it as missing so its appearance triggers a rebuild):
compilation.hooks.processAssets.tap(/* ... */, () => {
const envLocalPath = path.resolve(compiler.context, '.env.local');
let extraEnv = {};
if (fs.existsSync(envLocalPath)) {
extraEnv = parseDotenv(fs.readFileSync(envLocalPath, 'utf8'));
compilation.fileDependencies.add(envLocalPath);
} else {
// File didn't exist this build — re-run when it appears
compilation.missingDependencies.add(envLocalPath);
}
emitEnvManifest(compilation, extraEnv);
});Use `missingDependencies` whenever the answer "no" is part of your output:
- Module-resolution-like searches: walking up parent dirs looking for
package.json,tsconfig.json,.git - Optional config files:
.eslintrc.local,babel.config.dev.js - Conditional asset emission: "if
public/robots.txtexists, copy it; otherwise emit a default" - Resolver fallbacks: trying
./foo.ts, then./foo.tsx, then./foo.js
Don't pre-add every path that MIGHT exist. missingDependencies is for paths the plugin actually probed and didn't find — webpack snapshots each one and rechecks on every rebuild. A missingDependencies Set with thousands of speculative entries makes rebuilds noticeably slower.
Pair with proper resolution: If your plugin uses webpack's resolver (compiler.resolverFactory.get('normal')), the resolver already adds appropriate missingDependencies for unsuccessful candidates. You only need to add manually when you do the lookup yourself.
Reference: Compilation API — missingDependencies
Read Files Via compiler.inputFileSystem, Not Node fs
compiler.inputFileSystem is webpack's cached filesystem abstraction. In webpack-dev-server and many test setups (especially webpack/lib/util/MemoryFs), it's a virtual in-memory filesystem that contains generated files invisible to Node's fs. Reading via fs.readFileSync bypasses the cache (every read hits disk), misses virtual files entirely, and breaks plugins that compose on top of dev-server's asset pipeline.
Incorrect (Node fs — bypasses cache, misses virtual files in dev-server):
const fs = require('node:fs');
compilation.hooks.processAssets.tap(/* ... */, () => {
// Disk read on every call. Misses dev-server's in-memory assets entirely.
const template = fs.readFileSync(this.templatePath, 'utf8');
emit(template);
compilation.fileDependencies.add(this.templatePath);
});Correct (inputFileSystem — cached, virtualization-aware):
compilation.hooks.processAssets.tapPromise(/* ... */, async () => {
const { inputFileSystem } = compiler;
const template = await new Promise((resolve, reject) => {
inputFileSystem.readFile(this.templatePath, (err, buffer) => {
if (err) reject(err);
else resolve(buffer.toString('utf8'));
});
});
emit(template);
compilation.fileDependencies.add(this.templatePath);
});inputFileSystem method shape: All methods are CALLBACK-style (Node-fs-classic). Wrap with util.promisify or your own promise wrapper for async/await. Common methods:
| Method | Signature | Notes |
|---|---|---|
readFile(path, cb) | (Buffer) => void | Returns Buffer, not string |
readJson(path, cb) | (parsed) => void | webpack 5 — parses JSON |
readdir(path, cb) | (string[]) => void | Directory contents |
stat(path, cb) | (Stats) => void | File metadata |
purge(path?) | sync | Invalidate cache for path (or all) |
Why this matters even outside dev-server:
inputFileSystemcaches stat and content during a single build — N reads of the same file = 1 disk hitcompiler.intermediateFileSystemis the OUTPUT counterpart forcache.type: 'filesystem'writes- Plugins that work with
webpack-dev-middlewareMUST useinputFileSystem— otherwise their reads don't see the dev-middleware's in-memory output
For writes during the build, use `outputFileSystem`:
// Writing a generated config file alongside the output
await new Promise((resolve, reject) => {
compiler.outputFileSystem.mkdir(
path.dirname(targetPath),
{ recursive: true },
(err) => err ? reject(err) : resolve(),
);
});This honors output.path redirection in tests and respects dev-middleware's in-memory write surface.
Reference: Compiler API — inputFileSystem · webpack/enhanced-resolve
Expose Custom Hooks via getCompilationHooks WeakMap
Plugins that offer extension points for OTHER plugins to tap into (e.g., html-webpack-plugin's beforeEmit hook, mini-css-extract-plugin's runtimeRequirements hook) cannot attach the hooks directly to the Compilation instance — webpack 5 sealed the hooks surface and direct assignment (compilation.hooks.myHook = new SyncHook()) is no longer reliable. The canonical pattern is a static getCompilationHooks(compilation) method backed by a module-scope WeakMap<Compilation, Hooks>. The WeakMap entry is GC'd when the compilation is — no manual cleanup, no leaks across rebuilds.
Incorrect (direct assignment — sealed in webpack 5, would leak in 4):
const { SyncHook } = require('tapable');
class HtmlPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap('HtmlPlugin', (compilation) => {
// Webpack 5: TypeError (hooks is sealed)
// Webpack 4: works, but compilation never GC'd while hook handlers live
compilation.hooks.beforeEmit = new SyncHook(['data']);
});
}
}Correct (WeakMap + static getter — the webpack-contrib standard):
const { SyncHook, AsyncSeriesWaterfallHook } = require('tapable');
// Module-scope WeakMap — one entry per Compilation, GC'd with it
const compilationHooksMap = new WeakMap();
class HtmlPlugin {
static getCompilationHooks(compilation) {
let hooks = compilationHooksMap.get(compilation);
if (hooks === undefined) {
hooks = {
beforeAssetTagGeneration: new AsyncSeriesWaterfallHook(['data']),
beforeEmit: new SyncHook(['html']),
afterEmit: new AsyncSeriesWaterfallHook(['html']),
};
compilationHooksMap.set(compilation, hooks);
}
return hooks;
}
apply(compiler) {
compiler.hooks.thisCompilation.tap('HtmlPlugin', (compilation) => {
compilation.hooks.processAssets.tapPromise(/* ... */, async (assets) => {
const hooks = HtmlPlugin.getCompilationHooks(compilation);
// Fire your custom hooks at the right moment
const result = await hooks.beforeAssetTagGeneration.promise(initialData);
// ...
});
});
}
}
module.exports = HtmlPlugin;Other plugins tap into your custom hooks:
const HtmlPlugin = require('html-plugin');
class InlineRuntimePlugin {
apply(compiler) {
compiler.hooks.compilation.tap('InlineRuntimePlugin', (compilation) => {
const hooks = HtmlPlugin.getCompilationHooks(compilation);
hooks.beforeAssetTagGeneration.tapAsync(
'InlineRuntimePlugin',
(data, cb) => { /* mutate data, call cb */ },
);
});
}
}Why a static method, not an instance method:
- Other plugins can access hooks without holding a reference to the HtmlPlugin instance
- Multiple HtmlPlugin instances (one per output, in a multi-output build) share the hook surface
WeakMapis keyed byCompilation, not by plugin instance — exactly one hook bundle per compilation
Hook types to choose from:
| Hook type | When |
|---|---|
SyncHook | Notification, no return value matters |
SyncBailHook | Allow plugins to short-circuit (defined return stops) |
SyncWaterfallHook | Each tap can transform the data passed to the next |
AsyncSeriesHook | Async work, no return |
AsyncSeriesWaterfallHook | Async + transformation chain — most common for "modify this data" extension points |
AsyncParallelHook | Independent async work that can run concurrently |
Document your hooks in your README as part of your public API — once published, they're a compatibility surface.
Reference: Plugin API — Custom hooks pattern · html-webpack-plugin getHooks
Export the Plugin Class Directly as module.exports (CJS) or default (ESM)
Webpack config files run as CommonJS in most setups (webpack.config.js) and as ESM when the user opts in (webpack.config.mjs or "type": "module"). Mixing module.exports = MyPlugin with named exports (module.exports.MyPlugin = MyPlugin) confuses Babel's __esModule interop and produces new MyPlugin() → TypeError: MyPlugin is not a constructor. The convention webpack-contrib settled on: the class is THE default export, and a CommonJS file uses module.exports = MyPlugin with module.exports.default = MyPlugin for ESM-default interop.
Incorrect (mixed export shape — breaks for half of users):
class MyPlugin { /* ... */ }
class MyHelper { /* ... */ }
module.exports = { MyPlugin, MyHelper };
// CommonJS user: const MyPlugin = require('my-plugin').MyPlugin ← works
// ESM user: import MyPlugin from 'my-plugin' ← MyPlugin is the whole object, `new MyPlugin()` failsCorrect (default-class + named helpers, CJS form with ESM interop):
class MyPlugin { /* ... */ }
class MyHelper { /* ... */ }
module.exports = MyPlugin;
// Named exports as properties of the default
module.exports.MyHelper = MyHelper;
// ESM default-interop shim — `import X from 'my-plugin'` gives the class
module.exports.default = MyPlugin;// CommonJS user (webpack.config.js):
const MyPlugin = require('my-plugin');
const { MyHelper } = require('my-plugin');
// ESM user (webpack.config.mjs):
import MyPlugin, { MyHelper } from 'my-plugin';Correct (pure ESM source — for new packages):
// src/index.mjs
export default class MyPlugin { /* ... */ }
export class MyHelper { /* ... */ }{
"type": "module",
"exports": {
".": {
"import": "./src/index.mjs",
"require": "./dist/index.cjs"
}
}
}Use a build step (esbuild, tsup) to produce the .cjs for CommonJS consumers.
package.json `exports` field for dual-mode:
{
"name": "my-plugin",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
}
}TypeScript users want types — provide them:
// src/index.ts
import type { Compiler } from 'webpack';
interface MyPluginOptions { /* ... */ }
class MyPlugin {
constructor(options?: MyPluginOptions);
apply(compiler: Compiler): void;
}
export = MyPlugin; // CJS-style export for tsc → emits module.exports = MyPluginThe TypeScript export = syntax produces the right CJS shape. Combined with esModuleInterop: true in tsconfig, both require() and import default work.
Don't export an instance — always export the class. module.exports = new MyPlugin() makes the plugin instance global; users can't pass different options per build, and MultiCompiler corrupts state instantly.
Verify the export shape with a smoke test:
// tests/exports.test.js
const Plugin = require('../');
test('default export is a constructor', () => {
expect(typeof Plugin).toBe('function');
expect(typeof new Plugin().apply).toBe('function');
});
test('ESM default interop', () => {
expect(Plugin.default).toBe(Plugin);
});Reference: Node.js — Package exports field · webpack-contrib package conventions
Detect API Presence, Don't Check Webpack Versions
Webpack ships new compiler hooks, new compilation.cache methods, and new asset-info fields across minor versions. Hard-coding if (semver.gte(webpackVersion, '5.95.0')) requires importing semver, depends on a version string that vendored/forked webpacks may not set correctly, and breaks if webpack ever skips a version number. Checking for the API directly (if (compiler.hooks.validate)) is one line, never wrong, and gracefully handles forks.
Incorrect (version-string check — brittle, requires extra dep):
const semver = require('semver');
const { version: webpackVersion } = require('webpack/package.json');
apply(compiler) {
if (semver.gte(webpackVersion, '5.106.0')) {
compiler.hooks.validate.tap('Plugin', () => this.validate(compiler));
}
if (semver.gte(webpackVersion, '5.95.0')) {
// ...
}
// Breaks with Next.js's vendored webpack (no version export) and webpack forks
}Correct (feature detection — one line, always right):
apply(compiler) {
// Direct API check
if (compiler.hooks.validate) {
compiler.hooks.validate.tap('Plugin', () => this.validate(compiler));
}
// Namespace check
if (compiler.webpack?.experiments?.schemes?.data) {
// ...
}
// Method existence check
const cache = compilation.getCache('Plugin');
if (typeof cache.providePromise === 'function') {
return cache.providePromise(name, etag, factory);
} else {
// Older API fallback
return cache.getPromise(name, etag).then(v => v ?? factory());
}
}Common feature-detection patterns:
| Check | Tests for |
|---|---|
compiler.webpack | Webpack 5+ (doesn't exist in 4) |
compiler.webpack.sources | webpack-sources via namespace (always present in 5) |
compiler.hooks.validate | Webpack 5.106+ |
compilation.fileSystemInfo | Webpack 5+ snapshot API |
compilation.chunkGraph | Webpack 5+ (replaces Chunk.modulesIterable) |
Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE | Webpack 5.8.0+ |
compiler.options.experiments?.cacheUnaffected | Incremental compilation (5.95+) |
typeof asset.info.javascriptModule === 'boolean' | ESM-asset support (5.83+) |
For the webpack-4 fallback (rare, but published plugins still hit it):
function getSources(compiler) {
// Webpack 5: use the namespace
if (compiler.webpack?.sources) return compiler.webpack.sources;
// Webpack 4: fall back to the package
return require('webpack-sources');
}
apply(compiler) {
const { RawSource, ConcatSource } = getSources(compiler);
// ...
}Document your minimum webpack version in package.json's peerDependencies:
{
"peerDependencies": {
"webpack": "^5.95.0"
}
}The peer-dep version range IS your "supported version" contract — feature detection handles the cases where the range allows older APIs to be absent.
Don't try to be clever with `try/catch` around hook taps. A failed compiler.hooks.someNewHook.tap(...) throws synchronously, not in a way that compiler reaches a usable state. Use if (compiler.hooks.someNewHook) guards instead.
Reference: Webpack 5 release notes · Webpack changelog
Use compiler.webpack.* Instead of Importing webpack
require('webpack') resolves to whatever webpack copy your plugin can find — which may not be the same one the user's compiler is from, especially in pnpm monorepos, yarn workspaces, or when the plugin is symlinked. compiler.webpack is the namespace exposing the EXACT webpack instance that created the compiler. Using it guarantees instanceof checks pass, persistent cache works, and Compilation.PROCESS_ASSETS_STAGE_* constants match what the user's webpack uses.
Incorrect (direct import — different instance in monorepos):
const webpack = require('webpack');
const { sources, Compilation, WebpackError } = require('webpack');
class BannerPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap('BannerPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'BannerPlugin',
// Compilation.PROCESS_ASSETS_STAGE_ADDITIONS from "our" webpack
// may differ from the host webpack's stage numbering
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
(assets) => {
// sources.RawSource from "our" webpack — instanceof checks fail in user's webpack
compilation.updateAsset(name, new sources.RawSource(/* ... */));
},
);
});
}
}Correct (compiler.webpack — guaranteed same instance as host):
class BannerPlugin {
apply(compiler) {
// Destructure once per compiler — the namespace is stable
const { sources, Compilation, WebpackError } = compiler.webpack;
compiler.hooks.thisCompilation.tap('BannerPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'BannerPlugin',
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
(assets) => {
compilation.updateAsset(name, new sources.RawSource(/* ... */));
},
);
});
}
}Complete map of `compiler.webpack` exports:
| Namespace | Contains | Replaces |
|---|---|---|
compiler.webpack.sources | RawSource, OriginalSource, SourceMapSource, ConcatSource, ReplaceSource, CachedSource, PrefixSource | require('webpack-sources') |
compiler.webpack.Compilation | PROCESS_ASSETS_STAGE_* stage constants, type reference | require('webpack').Compilation |
compiler.webpack.WebpackError | Base error class | require('webpack').WebpackError |
compiler.webpack.ModuleFilenameHelpers | URL/path helpers (createFilename) | require('webpack').ModuleFilenameHelpers |
compiler.webpack.util.createHash | xxhash64-aware hash factory | require('crypto').createHash (doesn't know xxhash64) |
compiler.webpack.util.serialization | Persistent-cache serializers | for custom serializable types |
compiler.webpack.optimize.SplitChunksPlugin | Bundled plugins for re-use | require('webpack').optimize.X |
compiler.webpack.DefinePlugin, etc. | Top-level bundled plugins | require('webpack').DefinePlugin |
Single legitimate `require('webpack')` use case: TypeScript types only:
// type-only import — erased at runtime, no runtime dependency
import type { Compiler, Compilation } from 'webpack';
class BannerPlugin {
apply(compiler: Compiler) {
const { sources, Compilation } = compiler.webpack; // runtime: use the namespace
}
}For published TypeScript plugins, types come from webpack itself (no @types/webpack needed for webpack 5).
When does it matter in practice?
- pnpm strict mode (default): each package gets its own dependency tree — direct require resolves to a different webpack copy
- Yarn workspaces with `nohoist`: same effect
- Symlinked dev installs:
npm link my-plugin— your plugin resolveswebpackagainst ITS node_modules, not the host's - Webpack used as a library: tools like Next.js bundle their own webpack —
compiler.webpackis the only way to get the right one
Reference: Webpack 5 release — sources via compiler.webpack · Webpack 5 release notes — compiler.webpack
Declare webpack as peerDependency, Not dependency
Listing webpack in dependencies forces npm/yarn/pnpm to install a SECOND copy of webpack alongside the user's. This breaks instanceof checks (the plugin's Compilation is not the same class as the user's), corrupts persistent cache (different webpack instances serialize differently), and doubles install size. peerDependencies says "I work with whichever webpack the user installs" — the package manager warns on incompatible versions instead of silently installing a parallel one.
Incorrect (webpack as a direct dependency):
{
"name": "my-cool-plugin",
"version": "1.0.0",
"main": "src/index.js",
"dependencies": {
"webpack": "^5.0.0",
"schema-utils": "^4.0.0"
}
}npm ls webpack
my-cool-plugin@1.0.0
├── webpack@5.103.0 ← installed twice
└── my-cool-plugin@
└── webpack@5.85.0 ← the plugin's copy
# Class identity tests fail:
# plugin's `Compilation instanceof user's Compilation` → falseCorrect (webpack as peer, schema-utils as direct):
{
"name": "my-cool-plugin",
"version": "1.0.0",
"main": "src/index.js",
"peerDependencies": {
"webpack": "^5.0.0"
},
"devDependencies": {
"webpack": "^5.95.0"
},
"dependencies": {
"schema-utils": "^4.0.0"
}
}The dependency triangle:
| Package type | Where in package.json |
|---|---|
webpack itself | peerDependencies (user provides) + devDependencies (your tests need it) |
Plugin runtime deps you own (schema-utils, jest-worker) | dependencies |
Optional peers (e.g., @swc/core for an SWC plugin) | peerDependenciesMeta: { "@swc/core": { "optional": true } } |
| TypeScript types for webpack | devDependencies (@types/webpack if you need types beyond webpack's own .d.ts) |
Version range conventions (from `webpack-contrib`):
{
"peerDependencies": {
"webpack": "^5.1.0"
}
}- Use
^5.1.0not^5— names the MINIMUM webpack version your plugin works against - The minimum should be the version that introduced any APIs your plugin uses (e.g.,
^5.99.0if you usecompiler.hooks.validate) - DON'T pin to a single minor (
5.95.x) — too restrictive for users
Optional peers for sub-features:
{
"peerDependencies": {
"webpack": "^5.0.0",
"@swc/core": "^1.0.0"
},
"peerDependenciesMeta": {
"@swc/core": { "optional": true }
}
}This tells the package manager: "swc is needed if you want my SWC features, but the plugin works without it." No warning when missing.
For dual webpack 4/5 support, both as peer with OR range:
{
"peerDependencies": {
"webpack": "^4.0.0 || ^5.0.0"
}
}Combined with runtime feature-detection (compiler.webpack ? new-api : old-api).
npm 7+ auto-installs peerDependencies — declaring them no longer means "user must remember to install." But declaring them in dependencies STILL causes the double-install problem.
Reference: webpack-contrib repo conventions · npm — peerDependencies
Attach loc and module to Errors for Source Mapping
A WebpackError with no loc or module appears in stats output as a bare message. With loc and module set, webpack-cli renders a clickable source link (file:line:column), the dev-server overlay highlights the source range, and IDE webpack extensions (VS Code's webpack plugin, JetBrains' webpack integration) navigate to the exact line. The cost is two property assignments; the benefit is the difference between "search the codebase for this string" and "click here."
Incorrect (bare message — no source link):
compilation.hooks.finishModules.tap('NoDefaultExportPlugin', (modules) => {
for (const mod of modules) {
const src = mod.originalSource()?.source().toString();
if (src && /export default/.test(src)) {
compilation.warnings.push(
new WebpackError(`${mod.resource}: default exports are forbidden`),
);
// Stats: "WARNING in /abs/path/file.ts: default exports are forbidden"
// No clickable link, no overlay highlight.
}
}
});Correct (loc + module — webpack renders clickable source link):
compilation.hooks.finishModules.tap('NoDefaultExportPlugin', (modules) => {
for (const mod of modules) {
const src = mod.originalSource()?.source().toString();
if (!src) continue;
const match = /^export default/m.exec(src);
if (!match) continue;
const lineOffset = src.slice(0, match.index).split('\n').length;
const warn = new WebpackError('Default exports are forbidden — use named exports');
warn.module = mod;
warn.loc = {
start: { line: lineOffset, column: 0 },
end: { line: lineOffset, column: 'export default'.length },
};
compilation.warnings.push(warn);
// Stats: "WARNING in ./src/foo.ts:7:0-14
// Module Warning (from NoDefaultExportPlugin):
// Default exports are forbidden — use named exports"
}
});Error properties that affect stats rendering:
| Property | Type | Effect |
|---|---|---|
.module | Module | Renders as WARNING in ./relative/path (resolved via stats context) |
.file | string | Used when no .module available — absolute path shown as-is |
.loc | { start: { line, column }, end: { line, column } } | Adds :line:col-col and enables source-snippet rendering |
.dependencies | Dependency[] | For dependency-related errors, attaches to dependency chain |
.hideStack | boolean | Suppresses webpack's auto-appended stack |
.details | string | Long-form info shown with stats.errorDetails: true |
.name | string | Shown as the error category prefix |
Line numbers are 1-based, columns are 0-based. This matches the source-map convention and what webpack's stats formatter expects.
For string-replacement plugins, compute loc from the match index:
function locFromMatch(source, index) {
const before = source.slice(0, index);
const line = before.split('\n').length;
const lineStart = before.lastIndexOf('\n') + 1;
return {
start: { line, column: index - lineStart },
end: { line, column: index - lineStart },
};
}Avoid loc with the wrong source. If you're inspecting a Module's ORIGINAL source but the user has a loader chain (Babel, TS), loc should reference the loader output's line numbers — which is what you have from mod.originalSource(). The source map will translate when the overlay renders.
Reference: WebpackError fields · stats configuration
Report Progress via context.reportProgress
webpack --progress shows a progress bar that only counts events from plugins which opt into the progress API. A long-running asset processor that doesn't report progress appears as a frozen bar at 90% for 30 seconds; with progress reporting, the user sees [CompressPlugin] 142/200 files. Opt-in is a single hook option (context: true) plus calling context.reportProgress(fraction, message) at meaningful intervals.
Incorrect (no progress reporting — user sees frozen bar during slow work):
compilation.hooks.processAssets.tapPromise(
{ name: 'CompressPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER },
async (assets) => {
const files = Object.keys(assets).filter((n) => n.endsWith('.js'));
for (const name of files) {
await compressOne(name); // 200ms each × 100 files = 20s frozen
}
},
);Correct (context: true + reportProgress):
compilation.hooks.processAssets.tapPromise(
{
name: 'CompressPlugin',
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER,
context: true, // enables reportProgress
},
async (context, assets) => {
const reportProgress = context?.reportProgress;
const files = Object.keys(assets).filter((n) => n.endsWith('.js'));
for (let i = 0; i < files.length; i++) {
const name = files[i];
reportProgress?.(i / files.length, name); // fraction, message
await compressOne(name);
}
reportProgress?.(1, 'done');
},
);reportProgress signature:
reportProgress(percentage: number, message: string, ...args: string[]): voidpercentageis 0..1 within the current hook's slice of overall progressmessagebecomes the right-hand text after the progress bar- Extra args appended as detail (shown with
--profile)
Note the signature change when context: true: the handler receives (context, ...originalArgs). The context is { reportProgress } — extract reportProgress once and use it throughout.
Don't call reportProgress on every iteration in a tight loop — it forces a redraw and can dominate the work it's reporting on. Throttle to every Nth iteration or every Xms.
Standard progress message format webpack-cli renders nicely:
reportProgress(i / total, 'compress', name);
// Renders: ◜ [CompressPlugin] compress 142/200 (assets/foo.js)webpack groups progress events by plugin name and the first message keyword.
ProgressPlugin compat: webpack.ProgressPlugin (the bundled progress reporter) renders these reports. If the user has set up a custom progress handler via new webpack.ProgressPlugin(handler), they receive the same events with the percentage already mapped to global build progress.
Reference: ProgressPlugin · Plugin API — context: true
Push WebpackError to compilation.errors Instead of Throwing
Throwing inside a tap aborts the entire compilation with a stack trace that points at webpack internals, not at the user's code. The correct surface for plugin-detected errors is compilation.errors.push(new compiler.webpack.WebpackError(...)): webpack continues processing other modules and assets, then exits with the full list at done. This is how webpack --watch keeps running through type errors, how the dev-server overlay knows what to display, and how Sentry-style error trackers receive structured input.
Incorrect (throw aborts the build — one bad asset kills 999 good ones):
compilation.hooks.processAssets.tap(/* ... */, (assets) => {
for (const name of Object.keys(assets)) {
if (!name.endsWith('.json')) continue;
try {
JSON.parse(assets[name].source().toString());
} catch (e) {
// Throws here — compilation aborts, stack shows webpack internals,
// dev-server overlay shows nothing meaningful
throw new Error(`Invalid JSON in ${name}: ${e.message}`);
}
}
});Correct (collect, don't throw):
compilation.hooks.processAssets.tap(/* ... */, (assets) => {
const { WebpackError } = compiler.webpack;
for (const name of Object.keys(assets)) {
if (!name.endsWith('.json')) continue;
try {
JSON.parse(assets[name].source().toString());
} catch (e) {
const err = new WebpackError(`Invalid JSON in ${name}: ${e.message}`);
err.file = name; // dev-server overlay uses this
err.details = e.stack; // shown in `webpack --stats=detailed`
compilation.errors.push(err);
}
}
});WebpackError vs warning:
| Severity | Push to | Effect |
|---|---|---|
| Build failure (CI fails, exit code 1) | compilation.errors | Stats show errors, exit code 1 |
| Quality issue (notable but not blocking) | compilation.warnings | Stats show warnings, exit code 0, dev-server shows yellow |
Useful properties to set on a WebpackError:
| Property | Effect |
|---|---|
.file | Filename the error is "about" (shown in stats and overlay) |
.module | Reference to the Module instance (best when you have one) |
.loc | { start: { line, column }, end: { line, column } } for source highlighting |
.chunk | Reference to the Chunk for chunk-level errors |
.details | Long-form detail string shown by --stats=detailed |
.hideStack | true to suppress webpack's stack trace addition |
For module-related errors, use ModuleBuildError or ModuleParseError:
const { ModuleBuildError } = compiler.webpack;
compilation.errors.push(
new ModuleBuildError(module, new Error('Parse failed'), { from: 'MyPlugin' }),
);These integrate with webpack-cli's output, the dev-server overlay's source-link feature, and IDE error decorations via webpack-dev-server's WebSocket protocol.
When throwing IS correct: Truly unrecoverable errors that should kill the build immediately — e.g., the plugin's config file is malformed and there's no way to even start. In that case, throw in apply() or beforeRun, not deep inside a tap.
Reference: Compilation API — errors · webpack/lib/WebpackError.js
Log via compilation.getLogger, Not console
console.log from a plugin shows up unconditionally — in every CI log, in every IDE webpack output panel, even when the user passes stats: 'errors-only'. compilation.getLogger('PluginName') returns a logger that participates in webpack's stats output, respects infrastructureLogging.level, and lets users filter via stats.logging. The same plugin's logs can be silent in CI and verbose in --verbose mode without code changes.
Incorrect (console.log — always prints, breaks `stats: 'errors-only'`):
compiler.hooks.done.tap('CompressPlugin', (stats) => {
console.log(`[CompressPlugin] Compressed ${this.options.algorithm}`);
console.log(`[CompressPlugin] ${stats.compilation.assets.length} assets processed`);
// Pollutes every webpack log line, can't be suppressed without forking the plugin
});Correct (getLogger — respects user's logging level):
compiler.hooks.thisCompilation.tap('CompressPlugin', (compilation) => {
const logger = compilation.getLogger('CompressPlugin');
compilation.hooks.processAssets.tapPromise(/* ... */, async (assets) => {
logger.time('compress'); // start timing
logger.info(`Compressing with ${this.options.algorithm}`);
logger.debug(`Threshold: ${this.options.threshold} bytes`);
for (const name of Object.keys(assets)) {
logger.log(` - ${name}`); // visible only with verbose stats
}
await compressAll(assets);
logger.timeEnd('compress'); // emits time-elapsed log entry
});
});Log levels (from quietest to loudest):
| Method | Level | When user sees |
|---|---|---|
.error(msg) | error | Always (becomes compilation.errors entry in stats) |
.warn(msg) | warn | Always (becomes compilation.warnings) |
.info(msg) | info | Default and above |
.log(msg) | log | stats.logging: 'log' and above |
.debug(msg) | debug | stats.logging: 'verbose' |
.trace() | verbose | stats.logging: 'verbose' |
Time/group methods integrate with `stats.loggingDebug`:
logger.time('parse'); // logger.timeEnd('parse') logs elapsed
logger.group('Validation'); // logger.groupEnd() closes
logger.profile('build'); // logger.profileEnd() emits profiler markerInfrastructure logger vs compilation logger:
| Logger | Use for | Lives on |
|---|---|---|
compilation.getLogger(name) | Per-compilation events (asset processing, module-related logs) | Stats output, dev-server overlay |
compiler.getInfrastructureLogger(name) | Setup/teardown logs, watcher events, cross-build notes | Stdout — respects infrastructureLogging.level |
Use infrastructure logger for things that happen ONCE per compiler, not per compilation: "Worker pool initialized", "Found 5 entry points", "Cache loaded from disk".
Suppress logging by default in published plugins: Production plugins log at .debug() level — users opt in via stats: { loggingDebug: [/PluginName/] }. This is the convention mini-css-extract-plugin, terser-webpack-plugin, and compression-webpack-plugin all follow.
Reference: Logger API · stats.logging configuration
Choose Errors for Build Failures, Warnings for Quality Notices
compilation.errors makes the build fail (exit code 1, CI red); compilation.warnings does not (exit code 0, CI green). Plugins routinely get this backwards: a "missing dependency" message pushed to warnings lets a broken build pass CI silently, while a "deprecated option" notice pushed to errors fails CI for an issue users could have ignored. The decision is binary and consequential — make it explicitly per call site.
Incorrect (everything goes to warnings — broken builds pass CI):
compilation.hooks.processAssets.tap(/* ... */, (assets) => {
if (!assets['manifest.json']) {
// Missing manifest is a build failure — but we pushed it to warnings
compilation.warnings.push(new WebpackError('manifest.json was not generated'));
}
if (this.options.legacy) {
// Truly a warning, correctly placed
compilation.warnings.push(new WebpackError('legacy: true is deprecated'));
}
});
// CI runs `webpack --mode=production` — exits 0. Manifest missing in prod.Correct (errors fail the build; warnings inform):
compilation.hooks.processAssets.tap(/* ... */, (assets) => {
const { WebpackError } = compiler.webpack;
if (!assets['manifest.json']) {
// Build cannot proceed — this is an error
compilation.errors.push(new WebpackError(
'[ManifestPlugin] manifest.json was not generated. ' +
'Check that at least one entry produces an asset.',
));
}
if (this.options.legacy) {
// Build works, but user should migrate — this is a warning
compilation.warnings.push(new WebpackError(
'[ManifestPlugin] legacy: true is deprecated; will be removed in v3.',
));
}
});Decision matrix:
| Symptom | errors or warnings? |
|---|---|
| Build output is wrong / missing | errors |
| User passed an option that won't work | errors (during validate or beforeRun) |
| User passed a deprecated option that still works | warnings |
| External tool produced something we couldn't process | errors |
| Optimization opportunity skipped (e.g., file too big) | warnings |
| Plugin couldn't find an OPTIONAL file | warnings |
| Plugin couldn't find a REQUIRED file | errors |
| User's code triggered a runtime issue (e.g., circular dep) | depends on output.strictModuleErrorHandling — usually errors |
Webpack CLI flags users may set:
--no-stats-warnings— hide warnings from stats output (still exit 0)--fail-on-warnings(added in 5.78) — turn warnings into exit code 1 in CIstats: { warningsFilter: [/regex/] }— suppress specific warnings
Pushing to warnings doesn't mean the user will see it — they may filter it out. Pushing to errors always surfaces.
Don't downgrade errors to warnings to silence CI. If a user reports false-positive CI failures, the fix is to make the check more precise (better detection logic, more targeted condition), not to demote the severity. Demoted errors silently regress builds later.
Per-mode severity is fine:
const severity = compiler.options.mode === 'production' ? 'errors' : 'warnings';
compilation[severity].push(new WebpackError('External CSS not minified'));CSS not being minified is fatal in production but acceptable in dev.
Reference: stats configuration · CLI flags — fail-on-warnings
Return Undefined From Bail Hooks Unless You Mean to Stop
SyncBailHook and AsyncSeriesBailHook stop iterating taps the moment any tap returns a value that is not undefined. Returning false, null, 0, or "" from a bail hook prevents every later-registered plugin from running — and webpack uses bail hooks for shouldEmit, normalModuleFactory.beforeResolve, resolve, resolveOptions, and many others. A handler that accidentally returns the result of an assignment or filter call silently disables half the toolchain.
Incorrect (returning `false` from shouldEmit prevents any other plugin from emitting):
class GuardEmptyBuildsPlugin {
apply(compiler) {
compiler.hooks.shouldEmit.tap('GuardEmptyBuildsPlugin', (compilation) => {
// Author meant: "I don't have an opinion if there are errors."
// Actually says: "Cancel emit." — webpack writes nothing to disk.
return compilation.errors.length === 0;
});
}
}Correct (return `undefined` to mean "no opinion", explicit `false` only when intentionally bailing):
class GuardEmptyBuildsPlugin {
apply(compiler) {
compiler.hooks.shouldEmit.tap('GuardEmptyBuildsPlugin', (compilation) => {
if (compilation.errors.length > 0) {
return false; // Explicitly cancel emit when there are errors
}
return undefined; // No opinion — let other plugins decide
});
}
}Bail hook return contract:
| Return value | Effect |
|---|---|
undefined | Continue to next tap (most common) |
Any defined value (incl. false, null, 0, "") | Stop iteration, return that value as the hook's result |
Common bail hooks to be careful with:
compiler.hooks.shouldEmit—falsecancels writing all assets to diskcompiler.hooks.entryOption—truesignals "I handled the entry, skip default"normalModuleFactory.hooks.beforeResolve— defined return cancels module resolutionnormalModuleFactory.hooks.factorize— defined return uses your value as the module instance
Waterfall hooks have the inverse pitfall: SyncWaterfallHook and AsyncSeriesWaterfallHook pass the return value to the next tap. Returning undefined from a waterfall passes undefined downstream — usually a bug. Always return the (possibly modified) input.
Reference: Plugin API — Hook Types · tapable — SyncBailHook
Use a Stable, Unique Name for Every tap
The string passed as the first argument to tap()/tapAsync()/tapPromise() is the plugin's identity in webpack's stats output, the profiler trace, the dev-server overlay, and several HookMap lookups (notably HMR's JavascriptModulesPlugin.getCompilationHooks(...).renderModuleContent). Anonymous, dynamic, or duplicated tap names break stats grouping, prevent the profiler from attributing time to your plugin, and can cause HMR to silently skip module updates that depend on tap-name comparison.
Incorrect (template literal name changes per compilation — stats and profiler can't aggregate):
class WatermarkPlugin {
constructor(options) {
this.options = options;
this.runId = 0;
}
apply(compiler) {
compiler.hooks.thisCompilation.tap('WatermarkPlugin', (compilation) => {
this.runId++;
// Different name every compilation — webpack stats shows N entries instead of 1
compilation.hooks.processAssets.tap(
`WatermarkPlugin-${this.runId}`,
(assets) => { /* ... */ },
);
});
}
}Correct (stable string equal to the class name):
const PLUGIN_NAME = 'WatermarkPlugin';
class WatermarkPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.processAssets.tap(
{ name: PLUGIN_NAME, stage: /* ... */ },
(assets) => { /* ... */ },
);
});
}
}Naming conventions used by `webpack-contrib`:
- Tap name === class name (e.g.,
'MiniCssExtractPlugin','TerserPlugin') - Define once as a top-of-file
const PLUGIN_NAME = 'X'and reuse - Never include compilation IDs, timestamps, hashes, or option values in the tap name
Why this matters even when not using HMR:
webpack --profilegroups timing by tap name — dynamic names produce thousands of single-entry rows- Several plugins (notably
webpack-bundle-analyzer) attribute asset emissions to the plugin via tap name compilation.errorsand.warningsmay carry the tap name in their displayed module identifier
Reference: Writing a Plugin — Naming · webpack-contrib/mini-css-extract-plugin
Tap normalModuleFactory at the Right Resolution Stage
NormalModuleFactory exposes five sequential hooks for module resolution — beforeResolve → factorize → resolve → afterResolve → createModule — and they fire in that order for every import in the user's code. Tapping the wrong one short-circuits resolution unintentionally, mutates the request after webpack has already cached it, or causes the resolver to re-enter itself recursively. Most "why does my plugin's redirect work for half the imports?" bugs come from tapping afterResolve (too late to change the request) instead of beforeResolve (too early — no resolved path yet) instead of resolve (the right one for path rewriting).
Incorrect (rewriting in `afterResolve` — the request is already resolved and cached):
compiler.hooks.normalModuleFactory.tap('AliasPlugin', (nmf) => {
// afterResolve runs AFTER createData is computed and the module is cached.
// Rewriting `data.resource` here doesn't affect the resolved Module — webpack
// has already keyed it by the OLD path. The override silently has no effect.
nmf.hooks.afterResolve.tap('AliasPlugin', (data) => {
if (data.resource.includes('legacy-lodash')) {
data.resource = data.resource.replace('legacy-lodash', 'lodash-es');
}
});
});Correct (rewrite in `resolve` — runs before the resolved path is locked in):
compiler.hooks.normalModuleFactory.tap('AliasPlugin', (nmf) => {
// `resolve` is a SyncBailHook — return undefined to continue normal resolution
nmf.hooks.beforeResolve.tap('AliasPlugin', (data) => {
if (data.request === 'legacy-lodash') {
data.request = 'lodash-es'; // mutate the request BEFORE the resolver runs
}
return undefined; // let resolution continue with the rewritten request
});
});Stage cheatsheet (in execution order):
| Hook | Type | Receives | Use for |
|---|---|---|---|
beforeResolve | AsyncSeriesBailHook | resolveData (request, context, dependencies) | Rewriting the REQUEST string before resolution (aliases, virtual modules) |
factorize | AsyncSeriesBailHook | resolveData | Returning a pre-built Module instance to skip resolution entirely (advanced) |
resolve | AsyncSeriesBailHook | resolveData | Replacing the resolver — defined return becomes the result |
afterResolve | AsyncSeriesBailHook | resolveData (now with createData) | Mutating loader chain, parser options, generator settings on the resolved module |
createModule | AsyncSeriesBailHook | createData, resolveData | Returning a custom Module subclass instead of NormalModule |
module | SyncWaterfallHook | module, createData, resolveData | Decorating the final Module (rarely needed) |
For loader manipulation, `afterResolve` IS correct:
nmf.hooks.afterResolve.tap('InjectLoaderPlugin', (data) => {
// createData.loaders is the loader chain — modify it AFTER resolution
if (data.createData.resource?.endsWith('.tsx')) {
data.createData.loaders.unshift({
loader: require.resolve('./my-runtime-tracker-loader'),
options: { /* ... */ },
});
}
return undefined;
});This is the pattern Next.js uses to inject the React Refresh runtime, and what babel-loader-exclude-node-modules-except uses to bypass loaders for specific paths.
Bail-hook semantics matter here. All five hooks are AsyncSeriesBailHook — returning ANY defined value short-circuits resolution. The classic bug: returning data from beforeResolve (intending to "pass it through") makes webpack treat your return value as the resolved module spec, skipping every other plugin's beforeResolve. Always return undefined unless you intentionally mean to handle the resolution yourself.
Don't trigger re-resolution from inside a resolve tap. Calling compilation.params.normalModuleFactory.create(...) from inside one of these hooks recursively re-enters the factory and may produce infinite recursion if the inner request matches your plugin's condition. If you need to resolve a SECOND request as a side effect, do it from afterResolve (after the primary resolution is done) and add the resolved file to compilation.fileDependencies rather than re-creating a Module.
Reference: Module Methods — NormalModuleFactory hooks · vercel/next.js — getReactRefreshLoaderInjector
Prefer processAssets Over the emit Hook for Asset Mutation
The emit hook runs AFTER the entire processAssets pipeline, which means real-content hashing (PROCESS_ASSETS_STAGE_OPTIMIZE_HASH), Subresource Integrity, and the cache-write step have already executed. Mutating assets in emit produces filenames that don't match their content, breaks long-term caching for downstream consumers, and silently bypasses webpack 5's persistent cache. The replacement is a processAssets tap with the appropriate stage.
Incorrect (mutating in emit — content hash and SRI are wrong):
class StripSourceMapCommentsPlugin {
apply(compiler) {
// emit runs after OPTIMIZE_HASH — hashes already baked into filenames
compiler.hooks.emit.tapAsync('StripSourceMapCommentsPlugin', (compilation, cb) => {
for (const name of Object.keys(compilation.assets)) {
if (!name.endsWith('.js')) continue;
const src = compilation.assets[name].source().toString();
const cleaned = src.replace(/\/\/# sourceMappingURL=.*$/m, '');
// Direct assignment bypasses updateAsset — hash, info, and cache out of sync
compilation.assets[name] = new RawSource(cleaned);
}
cb();
});
}
}Correct (processAssets at the right stage, via updateAsset):
class StripSourceMapCommentsPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap('StripSourceMapCommentsPlugin', (compilation) => {
const { Compilation, sources } = compiler.webpack;
compilation.hooks.processAssets.tap(
{
name: 'StripSourceMapCommentsPlugin',
// Strip BEFORE size optimization and before real-content hashing
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE,
},
(assets) => {
for (const name of Object.keys(assets)) {
if (!name.endsWith('.js')) continue;
const src = assets[name].source().toString();
const cleaned = src.replace(/\/\/# sourceMappingURL=.*$/m, '');
compilation.updateAsset(name, new sources.RawSource(cleaned));
}
},
);
});
}
}Why `processAssets` won:
- Runs inside the compilation phase, BEFORE filenames and hashes are finalized
- Stage system makes ordering between plugins explicit (no fragile emit-tap ordering)
- Persistent cache participates correctly
additionalAssetsis now deprecated in favor ofprocessAssetswithSTAGE_ADDITIONAL
`emit` is still correct for:
- Side-effects that should happen exactly once after all assets are finalized (e.g., sending a notification, writing a build manifest to a path OUTSIDE webpack's output)
- Plugins that explicitly want to run AFTER all
processAssetswork (rare)
Reference: Compilation Hooks — processAssets · Webpack 5 release — processAssets
Pick the Right processAssets Stage
processAssets runs in 15 ordered stages — earlier stages run first, and a stage's name describes what it expects to be in the asset graph at that point. If you inject a banner in STAGE_OPTIMIZE_SIZE, the minifier (which runs at STAGE_OPTIMIZE_SIZE too) may strip it. If you compute a hash in STAGE_OPTIMIZE_TRANSFER, the real-content hash plugin has already run and your hash is wrong. Picking the right stage is the single most consequential decision in modern webpack 5 plugin authoring.
Incorrect (banner injected during size optimization — terser strips it):
const { Compilation, sources } = compiler.webpack;
compilation.hooks.processAssets.tap(
{
name: 'BannerPlugin',
// Wrong stage — terser also runs at OPTIMIZE_SIZE and may remove the comment
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
},
(assets) => {
for (const name of Object.keys(assets)) {
compilation.updateAsset(
name,
(old) => new sources.ConcatSource('/* my banner */\n', old),
);
}
},
);Correct (banner injected at ADDITIONS — runs before any size optimization):
const { Compilation, sources } = compiler.webpack;
compilation.hooks.processAssets.tap(
{
name: 'BannerPlugin',
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
(assets) => {
for (const name of Object.keys(assets)) {
compilation.updateAsset(
name,
(old) => new sources.ConcatSource('/* my banner */\n', old),
);
}
},
);Stage cheatsheet (in execution order):
| Stage | Use for |
|---|---|
PROCESS_ASSETS_STAGE_ADDITIONAL | Adding entirely new assets (manifests, license files) |
PROCESS_ASSETS_STAGE_PRE_PROCESS | Stripping comments, normalizing line endings |
PROCESS_ASSETS_STAGE_DERIVED | Generating from existing (split chunks, dynamic imports) |
PROCESS_ASSETS_STAGE_ADDITIONS | Banners, prepended init code, polyfills |
PROCESS_ASSETS_STAGE_OPTIMIZE | General optimizations |
PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE | Minification (terser, css-minimizer) |
PROCESS_ASSETS_STAGE_DEV_TOOLING | Source-map extraction |
PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE | Inlining small assets |
PROCESS_ASSETS_STAGE_SUMMARIZE | Reading the final asset list (read-only) |
PROCESS_ASSETS_STAGE_OPTIMIZE_HASH | Computing real content hashes |
PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER | gzip/brotli pre-compression |
PROCESS_ASSETS_STAGE_ANALYSE | Bundle analysis (read-only after hashes settled) |
PROCESS_ASSETS_STAGE_REPORT | Report files (stats.json, bundle reports) |
Reference: Compilation Hooks — processAssets stages
Match tap Method to the Hook's Async Type
tap() ignores any return value, so registering a synchronous-tap function that returns a Promise on an AsyncSeriesHook causes webpack to advance to the next phase before your work finishes — assets get emitted before your file is written, or the build closes before your worker exits. The compiler-hooks reference table tells you which hooks are SyncHook vs AsyncSeriesHook vs AsyncParallelHook; the tap method MUST match.
Incorrect (synchronous tap on AsyncSeriesHook — promise is fire-and-forget):
class WriteManifestPlugin {
apply(compiler) {
// emit is AsyncSeriesHook — tap() returns immediately, ignores the promise
compiler.hooks.emit.tap('WriteManifestPlugin', async (compilation) => {
const manifest = JSON.stringify(buildManifest(compilation));
await fs.promises.writeFile('dist/manifest.json', manifest);
// emit completes BEFORE writeFile finishes — manifest may be missing or stale
});
}
}Correct (tapPromise returns the promise to the AsyncSeriesHook):
class WriteManifestPlugin {
apply(compiler) {
compiler.hooks.emit.tapPromise('WriteManifestPlugin', async (compilation) => {
const manifest = JSON.stringify(buildManifest(compilation));
await fs.promises.writeFile('dist/manifest.json', manifest);
// emit waits for this promise to resolve before advancing to afterEmit
});
}
}Hook type → tap method:
| Hook type | Use | Notes |
|---|---|---|
SyncHook, SyncBailHook, SyncWaterfallHook | tap() | Return value matters for Bail/Waterfall |
AsyncSeriesHook, AsyncParallelHook | tapAsync() or tapPromise() | NEVER tap() — async work is dropped |
AsyncSeriesBailHook, AsyncSeriesWaterfallHook | tapPromise() (preferred) | Return value flows through |
When NOT to use `tap()` on a synchronous hook:
- The handler awaits anything (file I/O, network, child process)
- The handler returns a Promise the hook needs to wait for
Reference: Plugin API — tap, tapAsync, tapPromise
Register Compiler Hooks Once in apply, Not Inside Compilation Hooks
apply(compiler) runs exactly once. compiler.hooks.compilation (and thisCompilation) fires once per build — and in --watch, once per rebuild. Registering compiler-level hooks INSIDE a compilation tap creates a new tap on every rebuild, leaving previous tap functions still attached. After 50 rebuilds you have 50 copies of the same handler firing per event, leaking memory and making debugging impossible.
Incorrect (re-registers `compiler.hooks.done` on every compilation — leaks across rebuilds):
class TimingPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap('TimingPlugin', (compilation) => {
const start = Date.now();
// BUG: every rebuild adds another `done` tap. After 10 rebuilds,
// this logs 10 times. After 100, the build is noticeably slow.
compiler.hooks.done.tap('TimingPlugin', () => {
console.log(`Build took ${Date.now() - start}ms`);
});
});
}
}Correct (register all compiler hooks once in apply, share state via WeakMap):
class TimingPlugin {
apply(compiler) {
const startTimes = new WeakMap();
compiler.hooks.thisCompilation.tap('TimingPlugin', (compilation) => {
startTimes.set(compilation, Date.now());
});
// Registered once, fires once per build, no leak
compiler.hooks.done.tap('TimingPlugin', (stats) => {
const start = startTimes.get(stats.compilation);
if (start !== undefined) {
console.log(`Build took ${Date.now() - start}ms`);
}
});
}
}Rule of thumb:
compiler.hooks.*.tap(...)belongs inapply()— runs oncecompilation.hooks.*.tap(...)belongs in thethisCompilationcallback — runs once per compilation- Use
WeakMap<Compilation, T>to thread state from compilation to compiler-level hooks
How this typically slips in: Authors write a "complete" plugin inside one closure for readability, not realizing that nesting compiler-hook registration inside the compilation callback registers it again on every rebuild. The validate-skill linter cannot catch this; only watch-mode testing does.
Reference: Writing a Plugin — apply method
Related skills
FAQ
What does webpack-plugin-authoring do?
webpack-plugin-authoring is a Claude Code skill for security. It helps developers move faster with AI-assisted coding.
When should I use webpack-plugin-authoring?
When you need to helps with security tasks during ai-assisted development, or when webpack-plugin-authoring is a claude code skill for security. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
webpack-plugin-authoring; Security; AI-coding skill.