Module Customization Hooks

Customization hooks let you intervene at the two points the resolver exposes: resolve, which turns a specifier into a URL, and load, which turns a URL into source text and a format. This is the machinery behind TypeScript runners such as tsx 12,162 , coverage instrumenters and test doubles that replace a module without touching its importers.

Node 26 has picked a winner between the two registration APIs. module.registerHooks() (23.5.0, still a release candidate) installs synchronous hooks that run on the main thread. The older module.register() runs asynchronous hooks on a separate loader thread; it is deprecated as of Node 25.9.0 and warns at runtime (DEP0205) in Node 26. Write new hooks with registerHooks().

env-hooks.js -- a virtual module and a source transformJavaScript
import { registerHooks } from 'node:module';
registerHooks({
  resolve(specifier, context, nextResolve) {
    if (specifier.startsWith('config:')) {
      const value = process.env[specifier.slice(7)] ?? 'unset';
      const url = `data:text/javascript,export default ${JSON.stringify(value)}`;
      return { url, shortCircuit: true };
    }
    return nextResolve(specifier, context);
  },
  load(url, context, nextLoad) {
    const result = nextLoad(url, context);
    if (!url.endsWith('.timed.js')) return result;
    const body = result.source.toString();
    const stamp = "console.time('module');\n";
    return { ...result, source: stamp + body + "console.timeEnd('module');\n" };
  },
});

app.js then does two ordinary imports -- const { default: port } = await import('config:PORT') and await import('./report.timed.js') -- and logs the port and the row count. Hooks must be installed before the modules they affect are loaded, so preload them with --import. Running PORT=8080 node --import ./env-hooks.js app.js gives:

Output of 16
module: 3.221ms
port=8080 rows=50000

Three rules keep hooks maintainable. Always call nextResolve or nextLoad for anything you do not handle: hooks form a chain and yours is not the only one. Return shortCircuit: true only when you deliberately stop that chain, as the config: specifier does. And keep the work cheap -- a synchronous hook runs for every module in the graph, so a slow regular expression lands straight in your startup time.