Hooks
Presets are the only place you can register hooks — extension points that
run a JavaScript module through netpack’s Node bridge at a specific point in the
build. They live under the hooks key of a preset:
// netpack.json
{
"presets": ["@myorg/base"],
"hooks": {
"afterBundling": ["./transform.mjs", "@myorg/tools/stamp.js"]
}
}
Each hook name maps to an array of module references, so several callbacks can
attach, and the arrays merge across the whole preset chain. Hook modules are
resolved with the same mechanism as presets (a path, or a package reference
through node_modules).
Entry shape
An entry can be a bare string (the module to run) or an object that also scopes and parameterizes it:
{
"hooks": {
"afterBundling": [
"./transform.mjs", // shorthand
{
"source": "./stamp.mjs", // the module (required)
"test": "\\.js$", // only .js files/modules
"exclude": "\\.min\\.js$", // …but not minified ones
"mode": "prod", // only in optimized builds
"order": -1, // run before default hooks
"name": "stamp", // label for diagnostics
"options": { "year": 2026 } // passed to the hook
}
]
}
}
source— the hook module (path or package reference). A plain string entry is exactly{ "source": "…" }.test— a regular expression matched against the name. For asset hooks it filters which files the hook receives (and may rewrite); for per-module hooks it skips modules whose path doesn’t match. Omitted means “everything”. A filtered asset hook with no matching files isn’t invoked at all — the Node bridge only spins up when there’s work to do. An invalid regex is reported and ignored.exclude— a regular expression for names to skip, applied aftertest.mode—dev(dev server only),prod(optimized builds only), orboth(default). A hook that doesn’t apply to the current build is never invoked.order— an integer that shifts the hook earlier (negative) or later (positive) among the hooks for the same phase. The default (0) keeps the base-first order presets already give; useorderonly to override it.name— a label surfaced in diagnostics and passed to the hook aspayload.name.options— any JSON value, handed to the hook function aspayload.options(default{}). Use it to reuse one hook module with different settings.
The two forms mix freely in the same array, and dedup is by the whole entry
(module plus its test/exclude/mode/order/name/options), so the same
module with different settings runs more than once by design.
Behaviour
- Merged, not overridden. Every preset’s hooks contribute; nothing shadows anything.
- Base-first order. Hooks run in the reverse of option precedence — the deepest referenced (base) presets execute first, the entry preset last — so a base can set things up before a more specific preset finishes.
- Deduplicated. The same module reached through two presets runs once, at its earliest position.
- You only pay when you use them. Resolution happens up front in native code; the Node bridge is engaged only for hooks that actually have modules registered. A build with no hooks is exactly as fast as one with no config at all.
The module contract
A hook module default-exports (or module.exports) an async function. It receives
{ hook, root, dev, options, name } — plus module for the per-module hooks, and
files for the asset hooks — and may return a value the bundler applies. options
is the entry’s options value (or {}), and name its label (if set). Unknown
hook names are ignored with a warning.
// transform.mjs — strip // line comments from every JS bundle
export default async ({ files }) => ({
files: files
.filter((f) => f.name.endsWith(".js"))
.map((f) => ({ name: f.name, text: f.text.replace(/^\s*\/\/.*$/gm, "") })),
});
Modules run over the Node bridge, so @babel/core, terser, or any npm package
they import must be installed in the project.
Two kinds carry extra payload and can return a value:
- Asset hooks (
additionalAssets,processAssets,afterProcessAssets,afterEmit/ theafterBundlingalias) receivefiles: [{ name, text }]— text outputs (.js,.css,.html,.json,.map, …) astext, binary assets bynameonly. Return afilesarray to replace an asset’s contents (or add a new one). This is your post-transformation slot. shouldEmitmay return{ emit: false }to skip writing entirely.
The per-module hooks additionally receive the module’s path as module.
Lifecycle points
Every point in netpack’s build maps to a hook name, mirroring the webpack/rspack lifecycle. The build runs left to right; your post-transformation hooks most commonly tap the Emit phase:
| Phase | Hooks (in order) |
|---|---|
| Compiler start | initialize, beforeRun, run / watchRun, beforeCompile (alias beforeCompilation), compile, thisCompilation, compilation, make |
| Per module | buildModule, stillValidModule, succeedModule, failedModule |
| After the graph | finishMake, finishModules |
| Optimize (optimized builds) | optimize, optimizeDependencies, afterOptimizeDependencies, optimizeModules, afterOptimizeModules, optimizeChunks, afterOptimizeChunks, optimizeTree, optimizeChunkModules |
| Ids & seal | moduleIds, chunkIds, seal, contentHash, afterCodeGeneration |
| Emit | shouldEmit, emit, additionalAssets, processAssets, afterProcessAssets, afterEmit (alias afterBundling) |
| Finish | afterSeal, afterCompile, done |
The run-level hooks invalid, watchClose, shutdown and failed are
recognized (you can register them) but reserved — they aren’t fired yet.
.NET
Hooks map onto the CompilerHooks / CompilationHooks tap system in
NetPack.Core, which .NET plugins can tap directly (no Node bridge). See
.NET libraries.