A user-land ponyfill for module.register() built on top of module.registerHooks() + worker threads + Atomics.
This provides a drop-in replacement for Node.js's module.register() that works entirely in user-land, allowing existing consumers of the async hooks API to migrate to the synchronous module.registerHooks() infrastructure without changing their hook modules.
- Node.js >= 22.15.0 (for
module.registerHooks())
npm install module-register-ponyfillImport register directly from the package instead of node:module. This is
the simplest approach when you control the call sites:
// Before:
import { register } from 'node:module';
register('./hooks.mjs', import.meta.url);
// After:
import { register } from 'module-register-ponyfill';
register('./hooks.mjs', import.meta.url);Use the polyfill entry point to monkey-patch module.register so that
existing code works without changing import sites.
Important: The polyfill must be loaded before any register() calls.
The recommended approach is --import, which guarantees the polyfill runs
before any application code is evaluated:
node --import module-register-ponyfill/polyfill your-app.jsAlternatively, use a static import placed before any module that calls
register():
// Must come first -- patches module.register before other code runs.
import 'module-register-ponyfill/polyfill';
import { register } from 'node:module';
register('./hooks.mjs', import.meta.url);Both calling conventions are supported:
// 3-arg form
register(specifier, parentURL, { data, transferList });
// 2-arg form
register(specifier, { parentURL, data, transferList });Hook modules work similarly to the ones accepted by the native module.register() API. They can export:
initialize(data)-- called when the hook is registered, receives thedataoptionresolve(specifier, context, nextResolve)-- customize module resolutionload(url, context, nextLoad)-- customize module loading
// my-hooks.mjs
export function initialize(data) {
console.log('Hook initialized with:', data);
}
export function resolve(specifier, context, nextResolve) {
if (specifier === 'virtual:foo') {
return { url: 'file:///path/to/foo.js', shortCircuit: true };
}
return nextResolve(specifier, context);
}
export function load(url, context, nextLoad) {
return nextLoad(url, context);
}Pass initialization data and transferable objects (e.g. MessagePort) to hooks:
import { register } from 'module-register-ponyfill';
import { MessageChannel } from 'node:worker_threads';
const { port1, port2 } = new MessageChannel();
register('./hooks.mjs', import.meta.url, {
data: { port: port2, config: { debug: true } },
transferList: [port2],
});
port1.on('message', (msg) => console.log('From hook:', msg));Multiple register() calls are supported. Hooks chain in LIFO order (last registered runs first), matching the native behavior:
register('./hook-a.mjs', import.meta.url);
register('./hook-b.mjs', import.meta.url);
// hook-b's resolve/load runs first. When it calls nextResolve/nextLoad,
// hook-a runs. When hook-a calls next, the Node.js default runs.
// Chain: hook-b -> hook-a -> default- On the first
register()call, a single worker thread is spawned (withexecArgv: []so--require/--importpreloads are not re-executed) - A pair of synchronous hooks is registered on the main thread via
module.registerHooks() - When a module is imported, the sync hooks proxy the request to the worker via
MessagePort+Atomics.wait/Atomics.notify - The worker runs the async hook chain (all registered hook modules)
- If the hook chain calls
nextResolve()/nextLoad()all the way to the default, the worker delegates back to the main thread'snextResolve/nextLoadvia bidirectional communication- Note: this is different from the native
module.register(), which delegates to the loader thread's default resolver/loader for the final step. It's currently an open question which behavior is more desirable and whether this new behavior is worth keeping or made configurable.
- Note: this is different from the native
- Results flow back to the main thread, which unblocks and returns them
All Atomics.wait() calls use a 60-second timeout by default. If a hook
hangs or the worker crashes silently, the caller receives a descriptive
timeout error instead of deadlocking.
The timeout is configurable via the MODULE_REGISTER_TIMEOUT_MS environment
variable:
# Increase to 120 seconds for slow CI environments
MODULE_REGISTER_TIMEOUT_MS=120000 node your-app.js-
Cross-hook loading effects: Earlier
register()calls do not affect the loading of later hook modules in the worker. In native Node.js, previously registered async hooks can affect how subsequent hook modules are loaded (e.g., a TypeScript hook enabling loading of a.tshook module). This requires special handling internally in Node.js that is on the way of removal as it's very race-prone. This user-land ponyfill does not provide this behavior. If the loading of an asynchronous hook module needs to be customized, it's recommended to migrate tomodule.registerHooks()instead. -
globalPreload: The deprecatedglobalPreloadhook export is not recognized. Useinitializeinstead. -
Never-settling hooks: when a hook returns a promise that never settles, in the native
module.register()implementation, the process exits with code 13 or throws anERR_ASYNC_LOADER_REQUEST_NEVER_SETTLEDerror. This ponyfill throws a timeout error after 60 seconds instead (configurable viaMODULE_REGISTER_TIMEOUT_MS).
Unlike the native module.register(), this ponyfill returns a handle with a deregister() method:
const handle = register('./hooks.mjs', import.meta.url);
// Later, remove the hook:
handle.deregister();This is possible because we control the hook chain in the worker. The native API has no equivalent.
All Atomics.wait() calls use a 60-second timeout by default. If a hook hangs or the worker crashes silently, the caller receives a descriptive error instead of deadlocking forever. The timeout is configurable via MODULE_REGISTER_TIMEOUT_MS (see above). Native module.register() has no such safeguard.
- Explore support for cross-hook loading effects on the loader worker.
- Investigate whether delegating to the main thread's default resolver/loader (instead of the worker's) is desirable or whether it should be made configurable.
- Smarter handling of
--import/--requireinheritance in the loader worker. - Test
require.resolve()(depends on nodejs/node#62028)
Type definitions are included. Both the main export and the polyfill entry point are typed:
import { register } from 'module-register-ponyfill';
import type { RegisterOptions } from 'module-register-ponyfill';MIT