feat(ext/node): implement module.registerHooks() API for CommonJS#33733
Conversation
Implements the Node.js module.registerHooks() API for synchronous module loader hooks. This is the recommended replacement for the deprecated module.register() API. Supports: - Resolve hooks: intercept module resolution with custom logic - Load hooks: intercept module loading with custom source/format - LIFO hook chaining: last registered runs first, nextResolve/nextLoad delegates to previous hook - shortCircuit: skip remaining hooks in chain - Context merging: custom properties flow through the hook chain - deregister(): clean removal of registered hooks - Builtin module interception (node:* modules) - Virtual modules (resolve to URLs that don't exist on disk) Currently supports CJS require() only. ESM import() support requires changes to the Rust module loader and will follow separately.
Enable passing registerHooks() tests in the node_compat suite: - test-module-hooks-load-context-optional.js - test-module-hooks-load-short-circuit.js - test-module-hooks-resolve-short-circuit.js
- Validate that hooks returning without calling nextResolve/nextLoad must set shortCircuit: true, matching Node.js behavior. Throws ERR_INVALID_RETURN_PROPERTY_VALUE if missing. - Fix context merging: maintain a running context across the hook chain so custom properties accumulate correctly when passed through nextResolve/nextLoad calls. - Enable 7 node compat module-hooks tests (4 new): - test-module-hooks-load-context-merged.js - test-module-hooks-load-short-circuit-required-middle.js - test-module-hooks-load-short-circuit-required-start.js - test-module-hooks-resolve-short-circuit-required-middle.js
Fix the default nextResolve to pass the parent module and isMain flag to Module._resolveFilename, so relative specifiers like ../fixtures/foo resolve correctly against the requiring module's location. Enable 10 node compat module-hooks tests (3 new): - test-module-hooks-resolve-context-merged.js - test-module-hooks-resolve-context-optional.js - test-module-hooks-resolve-short-circuit-required-start.js Remaining load-chained/load-detection/load-url-change-require failures are due to ESM format source not being properly handled by loadESMFromCJS -- these will be fixed with ESM import hook support.
|
I agree with this direction. Considering these factors, I think providing a module loader API via node-compat is reasonable. |
url.fileURLToPath fails on Windows for virtual file:// URLs like file:///virtual.js because /virtual.js is not a valid Windows path. Catch the error and return the URL as-is so the load hook can handle it.
When the resolve hook returns a virtual URL (e.g. file:///virtual.js), the filename stored on the module is already a URL string. Don't pass it through pathToFileURL again when building the URL for load hooks, as that produces a nonsensical double-encoded path.
fibibot
left a comment
There was a problem hiding this comment.
Substantive new public API — module.registerHooks() is the recommended replacement for module.register() (DEP0205). The CJS-only scope is reasonable for a first cut and the design (LIFO chain, nextResolve/nextLoad delegation, shortCircuit validation, deregister) tracks Node's contract. A few items worth a look.
Possible bug — source ?? "" silently swallows file-not-found
let source;
try {
const filePath = StringPrototypeStartsWith(loadUrl, "file://")
? url.fileURLToPath(loadUrl)
: loadUrl;
source = op_require_read_file(filePath);
} catch {
source = undefined;
}
return {
source: source ?? "",
format: currentContext?.format ?? undefined,
shortCircuit: true,
};When op_require_read_file throws (file missing, permission error, EISDIR, etc.), the chain falls through to source: "". The user code then loads an empty module. Two issues:
- The user has no way to tell "the file exists but is empty" from "the file doesn't exist."
- Node's default load behavior surfaces the read error to the caller of
require(). Returning""papers over it.
Suggested: re-throw the caught error (wrap in ERR_CANNOT_LOAD_MODULE or similar) rather than coercing to empty string. The try/catch was likely there to handle "virtual modules" that have no real file — but those should already have been intercepted by an upstream hook returning a source before reaching the default branch. By the time we reach the catch, a missing file is a real error.
Misleading comment — auto-detect doesn't actually detect
} else {
// Auto-detect format: try CJS, fall back to ESM
const source = typeof result.source === "string"
? result.source
: new TextDecoder().decode(result.source);
this._compile(source, this.filename);
}The comment promises auto-detection; the code unconditionally calls _compile (CJS path) with no ESM fallback. If the hook returns {source: "import x from 'y'", format: undefined}, this hits _compile and the import keyword throws. Either:
- Implement actual detection (a regex for
import/exportat module top, or invokeloadESMFromCJSif_compilethrows a specific syntax error), or - Fix the comment to "Default to CJS when format is unspecified."
registerHooks input validation
export function registerHooks(hooks) {
const entry = {
resolve: typeof hooks.resolve === "function" ? hooks.resolve : null,
load: typeof hooks.load === "function" ? hooks.load : null,
id: nextHookId++,
};
...
}registerHooks(null)/registerHooks(undefined)throws a crypticTypeError: Cannot read properties of nullrather than Node'sERR_INVALID_ARG_TYPE. AvalidateObject(hooks, "hooks")up front would match Node behavior and give a better error.registerHooks({})(noresolve, noload) silently registers a no-op hook. Probably should throw — Node's docs say at least one of the two must be a function.
Smaller things
nextHookId is dead code
const entry = {
resolve: ...,
load: ...,
id: nextHookId++,
};The id is assigned but never read. deregister uses ArrayPrototypeIndexOf(hookEntries, entry) — direct identity, not the id. Drop nextHookId and entry.id.
O(N) "has load/resolve hook" scan on every resolve/load
if (hookEntries.length > 0 && !insideLoadHook) {
let hasLoadHook = false;
for (let i = 0; i < hookEntries.length; i++) {
if (hookEntries[i].load !== null) {
hasLoadHook = true;
break;
}
}
if (hasLoadHook) { ... }
}Repeated in _load, Module.prototype.load, and _resolveFilename (with the resolve variant). For typical short hook lists this is fine; if registerHooks usage proliferates, the scan adds up. A pair of integer counters (loadHookCount, resolveHookCount) maintained at register/deregister would make this O(1):
if (loadHookCount > 0 && !insideLoadHook) { ... }new TextDecoder() per non-string source
new TextDecoder().decode(result.source)Three call sites (commonjs, json, auto-detect). Hoist a module-level const utf8Decoder = new TextDecoder().
Reentrancy edge case worth documenting
The insideResolveHook / insideLoadHook flags prevent the default resolver/loader from re-entering the hook chain. But if a user hook calls Module._resolveFilename directly (instead of nextResolve), the flag is false and the hook chain re-fires — infinite loop.
Probably worth either:
- A doc comment in
executeResolveHookChainsaying "user hooks must usenextResolve, notModule._resolveFilename, to delegate." - Or set the flag for the entire
executeResolveHookChainbody, not just the default branch — defends against the user-_resolveFilenamefoot-gun at the cost of preventing legitimate same-realm re-entry.
Test coverage — error paths
The four spec tests are happy-path. The 10 Node compat enrollments cover shortCircuit-required-{start,middle} and context-merged/context-optional, which is good. But there's nothing exercising:
- A hook that throws synchronously (does the throw propagate to the
require()caller?). registerHooks(null)/registerHooks({})— the validation gap above.- Resolve hook returning a non-
file://, non-node:URL (e.g.,https://...) — the current_resolveFilenamereturnsresult.urlas-is, which then flows intoModule.prototype.load. Worth testing what happens.
Builtin hook is observe-only — intentional deviation?
executeLoadHookChain(filename, context);
// Always load builtins normally regardless of hook result;
// hooks can observe but not replace builtin source.Node's module.registerHooks() lets you replace builtin source (the docs explicitly call out the node:* interception case). The PR's "observe-only" choice is a real API deviation — worth either:
- Supporting source replacement for builtins (consume
result.sourceif non-null and use that to compile instead ofloadNativeModule). - Or documenting this as a known limitation in the same way the PR description calls out the ESM-side gap.
CI
134 pass, 2 skipping, 0 fail. ✓ Substance is otherwise tracking Node well.
Holding to COMMENT — this is a public CJS API addition, so the items above (especially the source ?? "" silent-fallthrough and the misleading auto-detect comment) deserve a maintainer eye before landing. Substance is mostly clean and the test coverage is reasonable for a first cut.
- Re-throw file read errors in default nextLoad instead of silently
coercing to empty string via `source ?? ""`. A missing file at the
default load stage is a real error, not a virtual module.
- Fix misleading "Auto-detect format: try CJS, fall back to ESM"
comment — the code unconditionally uses CJS, no ESM fallback exists.
- Add input validation: registerHooks(null) throws ERR_INVALID_ARG_TYPE,
registerHooks({}) throws ERR_INVALID_ARG_VALUE (at least one of
resolve/load must be a function), matching Node.js behavior.
- Remove dead `nextHookId` counter (id was assigned but never read;
deregister uses identity comparison).
- Hoist TextDecoder to a lazily-initialized module-level variable
instead of creating a new instance per decode call.
fibibot
left a comment
There was a problem hiding this comment.
Most of my prior review addressed cleanly in c0154d6. Remaining items are non-blocking. Walked the delta:
Fixed:
- File-read errors propagate instead of
source ?? ""-coercing to empty — thetry/catchand?? ""are both gone,op_require_read_file(filePath)now throws normally to the caller. Existing spec tests (main_load.cjs,main_resolve.cjs,main_chained.cjs,main_deregister.cjs) all return source from a hook rather than falling through to the default loader, so they're unaffected. nextHookIddead code dropped.new TextDecoder()per call hoisted to a module-levelutf8Decoderwith??=lazy-init across all three call sites (commonjs,json, default-CJS).- Misleading "auto-detect" comment corrected to "Default to CJS when format is unspecified."
registerHooksinput validation added:ERR_INVALID_ARG_TYPEfor non-object/null,ERR_INVALID_ARG_VALUEwhen neitherresolvenorloadis a function. No more crypticCannot read properties of nullfromhooks.resolve.
Still open from prior review (non-blocking):
- O(N) "has load/resolve hook" scan in
_load/_resolveFilename/Module.prototype.load— could be O(1) with counters maintained at register/deregister. - Reentrancy: user hook calling
Module._resolveFilenamedirectly bypassesinsideResolveHookand infinite-loops. Edge case; doc comment would suffice. - Builtin hook is observe-only — Node's
registerHooksallows replacing builtin source. The PR description scopes this to CJS-only, but the builtin observe-only choice deserves either implementation parity or an explicit "known limitation" line in the docs. - Error-path test coverage gap (hook throws, registerHooks validation, non-file/non-node URLs from resolve) — happy-path only today.
CI: 74 pass, 21 pending, 0 fail. Holding COMMENT pending CI completion. Substance is otherwise clean. cc @bartlomieju on the observe-only-builtin scope decision (you're the author so this is moot, but flagging since it's the one place this PR's behavior diverges from Node's and it's worth pinning in a review thread either way).
Match Node.js behavior where a load hook can replace a builtin module by returning a format other than "builtin" (e.g. "commonjs") with custom source. Previously hooks could only observe builtins.
fibibot
left a comment
There was a problem hiding this comment.
502eb62 addresses the v2 "builtin observe-only is a real Node deviation" concern. Walked the new code:
- Builtin-override path in
Module._load: when the hook returnsformat !== "builtin"andsource !== null, a freshModule(filename, parent)is constructed with the hook's source, cached inModule._cache[filename], compiled via_compile(orJSONParsefor"json"), and returned — bypassingloadNativeModule. ✓ - Test
main_builtin_override.cjs: registers a hook that interceptsnode:utiland replaces withmodule.exports = { customUtil: true }. Asserts both the override (util.customUtil === true) and the displaced original (util.inspect === undefined). Tight regression coverage. - Format support:
commonjs,json, and default-CJS all handled.format === "module"(ESM) falls through to default-CJS, which would syntax-error onimportkeywords — but the PR is CJS-scoped per the original description, so out of scope here. - Cache semantics: subsequent
require("node:util")after override hitsModule._cacheand returns the override. To revert, user mustdelete Module._cache["node:util"]— standard CJS pattern, matches user expectation.
CI
133 pass, 2 fail, 1 skip. The two fails are:
ci status(rollup of the wpt fail)wpt release linux-x86_64— single failing assertion is/fetch/api/abort/general.any.worker.html - Underlying connection is closed when aborting after receiving response - no-cors(5871 passed, 1 failed, 2076 expected-failure across 5872 tests). That's an unrelated fetch-abort test, nothing to do withnode:testmock implementation ornode:moduleregisterHooks. Looks like the recurring fetch-abort WPT flake.
All relevant tests for this PR (test node_compat (1/3), (2/3), (3/3) across linux/macos/windows × debug/release) pass. The WPT failure is in a different subsystem entirely.
Holding COMMENT strictly per the CI-must-be-fully-green rule rather than approving on faith. If the WPT job is the recurring fetch-abort flake (not this PR's regression), one re-run should clear it — happy to flip to APPROVE on green.
Extends `module.registerHooks()` to intercept ESM `import()` calls, not just CJS `require()`. This builds on #32432 (async module resolve) and complements #33733 (CJS-only registerHooks). - New `LoaderHookRegistry` + 5 ops connect the Rust `CliModuleLoader` to JS hook chains via async oneshot channels - `CliModuleLoader::resolve()` returns `ModuleResolveResponse::Async` when hooks are active, delegating to JS; fallthrough to `inner_resolve()` when hooks don't intercept - `CliModuleLoader::load()` delegates to JS hooks before default loading; hook-provided source is returned as `ModuleSource` - `prepare_load` skipped when hooks active so virtual (non-disk) modules work - Hook chaining in LIFO order, `nextResolve`/`nextLoad` for passthrough, `shortCircuit` validation Towards #31045, #31665, #29350, #23201
…land#33763) Extends `module.registerHooks()` to intercept ESM `import()` calls, not just CJS `require()`. This builds on denoland#32432 (async module resolve) and complements denoland#33733 (CJS-only registerHooks). - New `LoaderHookRegistry` + 5 ops connect the Rust `CliModuleLoader` to JS hook chains via async oneshot channels - `CliModuleLoader::resolve()` returns `ModuleResolveResponse::Async` when hooks are active, delegating to JS; fallthrough to `inner_resolve()` when hooks don't intercept - `CliModuleLoader::load()` delegates to JS hooks before default loading; hook-provided source is returned as `ModuleSource` - `prepare_load` skipped when hooks active so virtual (non-disk) modules work - Hook chaining in LIFO order, `nextResolve`/`nextLoad` for passthrough, `shortCircuit` validation Towards denoland#31045, denoland#31665, denoland#29350, denoland#23201
Summary
Implements the Node.js
module.registerHooks()API for synchronous moduleloader hooks. This is the recommended replacement for the deprecated
module.register()API (DEP0205).virtual modules, and passthrough via
nextResolvepassthrough via
nextLoadnextResolve/nextLoaddelegates to the previous hook in the chain
shortCircuitvalidation: hooks that don't callnext*must setshortCircuit: true, matching Node.js behavior (ERR_INVALID_RETURN_PROPERTY_VALUE)deregister(): clean removal of registered hooksnode:*modules pass through hooks with{ source: null, format: 'builtin' }Currently supports CJS
require()only. ESMimport()hook support requireschanges to the Rust module loader (
CliModuleLoader) and will follow separately.Towards #31045, #31665, #29350, #23201, #31323, #20625, #28126, #30538