Writing a custom Webpack plugin: hooking into the compiler lifecycle
Loaders transform files. Plugins do everything else. We build a plugin that writes build metadata to a JSON file on every compile, and map out the tapable hooks worth knowing.
If a loader's job is 'transform this one file,' a plugin's job is everything else: react to the compilation lifecycle, emit new files, modify the asset graph, fail the build on a custom condition. Plugins hook into Webpack's internals via the `tapable` event system - understanding that system is the difference between copy-pasting plugin examples and actually writing one.
A real, small plugin: build metadata emission
class BuildMetadataPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('BuildMetadataPlugin', (compilation, callback) => {
const metadata = JSON.stringify({
buildTime: new Date().toISOString(),
gitSha: process.env.GIT_SHA ?? 'unknown',
assets: Object.keys(compilation.assets),
}, null, 2);
compilation.assets['build-metadata.json'] = {
source: () => metadata,
size: () => metadata.length,
};
callback();
});
}
}
module.exports = BuildMetadataPlugin;This ships a `build-metadata.json` alongside your bundle with the commit SHA and build timestamp - genuinely useful for correlating a production error report with the exact build that shipped it. It's a real pattern we use, not a toy example.
The hooks worth knowing, in execution order
- compiler.hooks.beforeRun - fires once, before compilation starts. Good for validating environment variables or config before doing any work.
- compiler.hooks.compile - a new compilation is starting. Good for resetting any plugin-local state between watch-mode rebuilds.
- compilation.hooks.buildModule - fires per module as it's built. Good for per-file instrumentation, at the cost of firing very often.
- compiler.hooks.emit - assets are finalized but not yet written to disk. This is where you add, remove, or modify output files - our metadata plugin above uses exactly this hook.
- compiler.hooks.done - the compilation finished (success or failure). Good for reporting, notifications, or triggering a downstream task.
tapAsync vs tap vs tapPromise
Tapable hooks come in sync and async flavors, and using the wrong one either crashes at registration time or silently doesn't wait for your async work. `tap` is for synchronous callbacks. `tapAsync` expects an explicit Node-style `callback()` invocation, exactly like an async loader's `this.async()`. `tapPromise` expects your callback to return a Promise and handles the waiting for you - it's the cleanest of the three for genuinely async plugin logic.
When to write a plugin instead of a loader
If the question is 'how do I transform this file's content,' write a loader. If the question is 'how do I add a file that doesn't correspond to any import,' 'how do I fail the build on a custom rule,' or 'how do I inspect/modify the whole asset graph after everything else has run,' that's plugin territory - loaders operate on one module at a time and have no visibility into the compilation as a whole.
Written by Appesto Engineering.