Skip to content

fix(ext/node): module hook fixes for ESM nextLoad, createRequire URL, and builtin redirects - #34219

Merged
bartlomieju merged 4 commits into
mainfrom
fix/esm-load-hook-nextload-source
May 19, 2026
Merged

bartlomieju merged 4 commits into
mainfrom
fix/esm-load-hook-nextload-source

Conversation

@bartlomieju

Copy link
Copy Markdown
Member

A trio of fixes to node:module registerHooks() compat behavior, motivated
by failing tests in the bundled test-module-hooks suite.

In the ESM load hook chain, nextLoad() used to return { source: null }
when the chain fell through, breaking user hooks that wrap nextLoad to
transform real source. The terminal step now reads the file from disk for
file:// URLs (and returns the builtin marker for node:*), matching the
contract followed by the CJS path.

createRequire(url) dropped a URL's query/hash because the resolve hook
chain rebuilt context.parentURL from the Module's filename via
pathToFileURL. The original URL is now stashed on the Module under a
private symbol and preferred when populating parentURL.

When a resolve hook redirected require('zlib') to a file path,
Module._load would still hit loadNativeModule(filename, request) and
return the native builtin via the original request name. The fallback is
now skipped when the filename came from a hook (tracked in
cjsHookResolvedFilenames).

Six previously-failing module-hooks compat tests are enabled in
tests/node_compat/config.jsonc as a result.

…ough

In `executeEsmLoadHookChain`, calling `nextLoad()` from a user hook used
to return `{ source: null }` once the chain reached its end. Node's
contract is that `nextLoad` returns the actual default-loaded source so
a hook can transform it. Now read the file from disk for `file://` URLs
(and return the builtin marker for `node:*`), matching the CJS path.

Fixes module-hooks compat tests `test-module-hooks-load-esm` and
`test-module-hooks-load-esm-mock`.
…oid native-module fallback for hook-redirected builtins

Two module hook fixes:

1. createRequire(url) used to drop a URL's query/hash because the
   resolve hook chain rebuilt `context.parentURL` from the Module's
   filename via `pathToFileURL`. Preserve the original URL on the
   Module under a private symbol and prefer it when populating
   `parentURL`.

2. When a resolve hook redirects `require('zlib')` to a file path,
   `Module._load` would still fall back to `loadNativeModule(filename,
   request)` and return the native builtin via the original request
   name. Skip that fallback when the filename came from a hook
   (tracked in `cjsHookResolvedFilenames`).

Enables module-hooks compat tests for create-require-with-url,
resolve-builtin-on-disk-require, resolve-load-builtin-override-both,
load-esm, load-esm-mock, and load-url-change-import.
In compile mode (and other cases where the file isn't present on the
real filesystem) op_require_read_file throws "path not found". Catch
that and return { source: null } so the Rust default loader (which can
read from the embedded eszip) handles it.

@lunadogbot lunadogbot 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.

LGTM — three surgical fixes, each backed by an upstream test getting un-skipped. Left a few non-blocking nits inline.

One thought beyond the inline notes: kSourceURL is only set on the top-level Module from createRequireFromPath, so transitively-require()d children won't carry the original URL forward. That matches Node's intent (the URL identifies the parent of the require), but worth confirming test-module-hooks-create-require-with-url.mjs doesn't probe a deeper hop.

const source = op_require_read_file(url.fileURLToPath(loadUrl));
return {
source,
format: currentContext?.format ?? undefined,

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.

Nit: ?? undefined is redundant here — currentContext?.format already evaluates to undefined when currentContext is null/undefined or .format is missing. Could just be format: currentContext?.format,.

} catch {
// File isn't available synchronously (e.g. embedded in a compiled
// binary's eszip); fall through to Rust default loading.
return { source: null, shortCircuit: true };

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.

The bare catch swallows any failure, not just the eszip case the comment describes — e.g. EACCES, transient IO errors, or the file being deleted between resolve and load would all silently defer to Rust loading instead of surfacing at the hook. Probably fine in practice (Rust will produce a clearer error anyway), but consider widening the comment so a future reader doesn't assume eszip is the only intended path.

return mod.exports;
// A resolve hook that redirected `require('zlib')` to a file path must
// not silently fall back to the native module via the original request.
if (!SetPrototypeHas(cjsHookResolvedFilenames, filename)) {

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.

Two small things here:

  1. maybeEmitNativeModuleDeprecation(filename) on line 1484 still fires before the gate. It's a no-op when filename is a hook-resolved path (the lookup returns undefined), so harmless — but semantically it might read more clearly inside the same !SetPrototypeHas(...) branch.

  2. The cleanup of cjsHookResolvedFilenames happens in Module.prototype._compile (line 2034). If module.load(filename) throws before reaching _compile, the set entry leaks and any future load of the same filename via a non-hook path would also skip native fallback. The existing threw cleanup block (around line 1513) already deletes Module._cache[filename] — consider adding SetPrototypeDelete(cjsHookResolvedFilenames, filename) there for symmetry. Edge case, won't block.

@bartlomieju

Copy link
Copy Markdown
Member Author

I'll address these in a follow up in #34223

@bartlomieju
bartlomieju merged commit 99be4f5 into main May 19, 2026
136 checks passed
@bartlomieju
bartlomieju deleted the fix/esm-load-hook-nextload-source branch May 19, 2026 00:09
littledivy pushed a commit to crowlKats/deno that referenced this pull request Jun 10, 2026
… and builtin redirects (denoland#34219)

A trio of fixes to `node:module` registerHooks() compat behavior,
motivated by failing tests in the bundled `test-module-hooks` suite.

In the ESM load hook chain, `nextLoad()` used to return `{ source: null
}` when the chain fell through, breaking user hooks that wrap `nextLoad` to
transform real source. The terminal step now reads the file from disk
for `file://` URLs (and returns the builtin marker for `node:*`), matching
the contract followed by the CJS path.

`createRequire(url)` dropped a URL's query/hash because the resolve hook
chain rebuilt `context.parentURL` from the Module's filename via
`pathToFileURL`. The original URL is now stashed on the Module under a
private symbol and preferred when populating `parentURL`.

When a resolve hook redirected `require('zlib')` to a file path,
`Module._load` would still hit `loadNativeModule(filename, request)` and
return the native builtin via the original request name. The fallback is
now skipped when the filename came from a hook (tracked in
`cjsHookResolvedFilenames`).

Six previously-failing module-hooks compat tests are enabled in
`tests/node_compat/config.jsonc` as a result.
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.

2 participants