Skip to content

feat(ext/node): implement module.registerHooks() API for CommonJS#33733

Merged
bartlomieju merged 8 commits into
mainfrom
feat/register-hooks
May 1, 2026
Merged

feat(ext/node): implement module.registerHooks() API for CommonJS#33733
bartlomieju merged 8 commits into
mainfrom
feat/register-hooks

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Apr 30, 2026

Copy link
Copy Markdown
Member

Summary

Implements the Node.js module.registerHooks() API for synchronous module
loader hooks. This is the recommended replacement for the deprecated
module.register() API (DEP0205).

  • Resolve hooks: intercept module resolution with custom specifier mapping,
    virtual modules, and passthrough via nextResolve
  • Load hooks: intercept module loading with custom source code and format,
    passthrough via nextLoad
  • LIFO hook chaining: last registered hook runs first, nextResolve/nextLoad
    delegates to the previous hook in the chain
  • shortCircuit validation: hooks that don't call next* must set
    shortCircuit: true, matching Node.js behavior (ERR_INVALID_RETURN_PROPERTY_VALUE)
  • Context merging: custom properties on context accumulate across the hook chain
  • deregister(): clean removal of registered hooks
  • Builtin module interception: node:* modules pass through hooks with
    { source: null, format: 'builtin' }

Currently supports CJS require() only. ESM import() hook support requires
changes to the Rust module loader (CliModuleLoader) and will follow separately.

Towards #31045, #31665, #29350, #23201, #31323, #20625, #28126, #30538

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.
@bartlomieju bartlomieju changed the title feat(ext/node): implement module.registerHooks() API feat(ext/node): implement module.registerHooks() API for CommonJS Apr 30, 2026
@Hajime-san

Copy link
Copy Markdown
Contributor

I agree with this direction.
Modules (ESM) need to interact with the internal state of JS Hosts such as Realm. However, module-related specifications and implementations are constantly evolving, including the recently implemented lazy evaluation. Providing this through Deno namespaces could potentially cause breaking changes when new concepts are introduced to the ECMAScript specification and implementation.

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 fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The user has no way to tell "the file exists but is empty" from "the file doesn't exist."
  2. 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/export at module top, or invoke loadESMFromCJS if _compile throws 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 cryptic TypeError: Cannot read properties of null rather than Node's ERR_INVALID_ARG_TYPE. A validateObject(hooks, "hooks") up front would match Node behavior and give a better error.
  • registerHooks({}) (no resolve, no load) 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 executeResolveHookChain saying "user hooks must use nextResolve, not Module._resolveFilename, to delegate."
  • Or set the flag for the entire executeResolveHookChain body, not just the default branch — defends against the user-_resolveFilename foot-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 _resolveFilename returns result.url as-is, which then flows into Module.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.source if non-null and use that to compile instead of loadNativeModule).
  • 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 fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 — the try/catch and ?? "" 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.
  • nextHookId dead code dropped.
  • new TextDecoder() per call hoisted to a module-level utf8Decoder with ??= lazy-init across all three call sites (commonjs, json, default-CJS).
  • Misleading "auto-detect" comment corrected to "Default to CJS when format is unspecified."
  • registerHooks input validation added: ERR_INVALID_ARG_TYPE for non-object/null, ERR_INVALID_ARG_VALUE when neither resolve nor load is a function. No more cryptic Cannot read properties of null from hooks.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._resolveFilename directly bypasses insideResolveHook and infinite-loops. Edge case; doc comment would suffice.
  • Builtin hook is observe-only — Node's registerHooks allows 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 fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 returns format !== "builtin" and source !== null, a fresh Module(filename, parent) is constructed with the hook's source, cached in Module._cache[filename], compiled via _compile (or JSONParse for "json"), and returned — bypassing loadNativeModule. ✓
  • Test main_builtin_override.cjs: registers a hook that intercepts node:util and replaces with module.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 on import keywords — but the PR is CJS-scoped per the original description, so out of scope here.
  • Cache semantics: subsequent require("node:util") after override hits Module._cache and returns the override. To revert, user must delete 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 with node:test mock implementation or node:module registerHooks. 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.

@bartlomieju
bartlomieju merged commit 63ce783 into main May 1, 2026
268 of 270 checks passed
@bartlomieju
bartlomieju deleted the feat/register-hooks branch May 1, 2026 08:37
bartlomieju added a commit that referenced this pull request May 5, 2026
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
littledivy pushed a commit to crowlKats/deno that referenced this pull request Jun 10, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants