module: fix sync resolve hooks for require with node: prefixes

Previously, when require()-ing builtins with the node: prefix,
the sync resolve hooks were not properly invoked, and load hooks
could not override the builtin's format. This fixes the
handling and enables redirecting prefixed built-ins to on-disk
files and overriding them with other module types via hooks.

PR-URL: https://github.com/nodejs/node/pull/61088
Fixes: https://github.com/nodejs/node/issues/60005
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
This commit is contained in:
Joyee Cheung authored and GitHub committed 2025-12-22 14:35:45 +00:00
1 parent 26b7fd2009
commit 28f468a615
10 files changed
+360 -39

No files matched your search

+83 -39
View File
@@ -1111,7 +1111,9 @@ function resolveForCJSWithHooks(specifier, parent, isMain) {
filename = convertURLToCJSFilename(url);
}
return { __proto__: null, url, format, filename, parentURL };
const result = { __proto__: null, url, format, filename, parentURL };
debug('resolveForCJSWithHooks', specifier, parent?.id, '->', result);
return result;
}
/**
@@ -1168,24 +1170,29 @@ function getDefaultLoad(url, filename) {
* @param {string} id The module ID (without the node: prefix)
* @param {string} url The module URL (with the node: prefix)
* @param {string} format Format from resolution.
* @returns {any} If there are no load hooks or the load hooks do not override the format of the
* builtin, load and return the exports of the builtin. Otherwise, return undefined.
* @returns {{builtinExports: any, resultFromHook: undefined|ModuleLoadResult}} If there are no load
* hooks or the load hooks do not override the format of the builtin, load and return the exports
* of the builtin module. Otherwise, return the loadResult for the caller to continue loading.
*/
function loadBuiltinWithHooks(id, url, format) {
let resultFromHook;
if (loadHooks.length) {
url ??= `node:${id}`;
debug('loadBuiltinWithHooks ', loadHooks.length, id, url, format);
// TODO(joyeecheung): do we really want to invoke the load hook for the builtins?
const loadResult = loadWithHooks(url, format || 'builtin', /* importAttributes */ undefined,
getCjsConditionsArray(), getDefaultLoad(url, id), validateLoadStrict);
if (loadResult.format && loadResult.format !== 'builtin') {
return undefined; // Format has been overridden, return undefined for the caller to continue loading.
resultFromHook = loadWithHooks(url, format || 'builtin', /* importAttributes */ undefined,
getCjsConditionsArray(), getDefaultLoad(url, id), validateLoadStrict);
if (resultFromHook.format && resultFromHook.format !== 'builtin') {
debug('loadBuiltinWithHooks overriding module', id, url, resultFromHook);
// Format has been overridden, return result for the caller to continue loading.
return { builtinExports: undefined, resultFromHook };
}
}
// No hooks or the hooks have not overridden the format. Load it as a builtin module and return the
// exports.
const mod = loadBuiltinModule(id);
return mod.exports;
return { builtinExports: mod.exports, resultFromHook: undefined };
}
/**
@@ -1223,47 +1230,64 @@ Module._load = function(request, parent, isMain) {
}
}
const { url, format, filename } = resolveForCJSWithHooks(request, parent, isMain);
const resolveResult = resolveForCJSWithHooks(request, parent, isMain);
let { format } = resolveResult;
const { url, filename } = resolveResult;
let resultFromLoadHook;
// For backwards compatibility, if the request itself starts with node:, load it before checking
// Module._cache. Otherwise, load it after the check.
if (StringPrototypeStartsWith(request, 'node:')) {
const result = loadBuiltinWithHooks(filename, url, format);
if (result) {
return result;
// TODO(joyeecheung): a more sensible handling is probably, if there are hooks, always go through the hooks
// first before checking the cache. Otherwise, check the cache first, then proceed to default loading.
if (request === url && StringPrototypeStartsWith(request, 'node:')) {
const normalized = BuiltinModule.normalizeRequirableId(request);
if (normalized) { // It's a builtin module.
const { resultFromHook, builtinExports } = loadBuiltinWithHooks(normalized, url, format);
if (builtinExports) {
return builtinExports;
}
// The format of the builtin has been overridden by user hooks. Continue loading.
resultFromLoadHook = resultFromHook;
format = resultFromLoadHook.format;
}
// The format of the builtin has been overridden by user hooks. Continue loading.
}
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (cachedModule.loaded) {
return cachedModule.exports;
// If load hooks overrides the format for a built-in, bypass the cache.
let cachedModule;
if (resultFromLoadHook === undefined) {
cachedModule = Module._cache[filename];
debug('Module._load checking cache for', filename, !!cachedModule);
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (cachedModule.loaded) {
return cachedModule.exports;
}
// If it's not cached by the ESM loader, the loading request
// comes from required CJS, and we can consider it a circular
// dependency when it's cached.
if (!cachedModule[kIsCachedByESMLoader]) {
return getExportsForCircularRequire(cachedModule);
}
// If it's cached by the ESM loader as a way to indirectly pass
// the module in to avoid creating it twice, the loading request
// came from imported CJS. In that case use the kModuleCircularVisited
// to determine if it's loading or not.
if (cachedModule[kModuleCircularVisited]) {
return getExportsForCircularRequire(cachedModule);
}
// This is an ESM loader created cache entry, mark it as visited and fallthrough to loading the module.
cachedModule[kModuleCircularVisited] = true;
}
// If it's not cached by the ESM loader, the loading request
// comes from required CJS, and we can consider it a circular
// dependency when it's cached.
if (!cachedModule[kIsCachedByESMLoader]) {
return getExportsForCircularRequire(cachedModule);
}
// If it's cached by the ESM loader as a way to indirectly pass
// the module in to avoid creating it twice, the loading request
// came from imported CJS. In that case use the kModuleCircularVisited
// to determine if it's loading or not.
if (cachedModule[kModuleCircularVisited]) {
return getExportsForCircularRequire(cachedModule);
}
// This is an ESM loader created cache entry, mark it as visited and fallthrough to loading the module.
cachedModule[kModuleCircularVisited] = true;
}
if (BuiltinModule.canBeRequiredWithoutScheme(filename)) {
const result = loadBuiltinWithHooks(filename, url, format);
if (result) {
return result;
if (resultFromLoadHook === undefined && BuiltinModule.canBeRequiredWithoutScheme(filename)) {
const { resultFromHook, builtinExports } = loadBuiltinWithHooks(filename, url, format);
if (builtinExports) {
return builtinExports;
}
// The format of the builtin has been overridden by user hooks. Continue loading.
resultFromLoadHook = resultFromHook;
format = resultFromLoadHook.format;
}
// Don't call updateChildren(), Module constructor already does.
@@ -1278,6 +1302,9 @@ Module._load = function(request, parent, isMain) {
} else {
module[kIsMainSymbol] = false;
}
if (resultFromLoadHook !== undefined) {
module[kModuleSource] = resultFromLoadHook.source;
}
reportModuleToWatchMode(filename);
Module._cache[filename] = module;
@@ -1463,6 +1490,17 @@ function createEsmNotFoundErr(request, path) {
return err;
}
function getExtensionForFormat(format) {
switch (format) {
case 'addon':
return '.node';
case 'json':
return '.json';
default:
return '.js';
}
}
/**
* Given a file name, pass it to the proper extension handler.
* @param {string} filename The `require` specifier
@@ -1475,7 +1513,13 @@ Module.prototype.load = function(filename) {
this.filename ??= filename;
this.paths ??= Module._nodeModulePaths(path.dirname(filename));
const extension = findLongestRegisteredExtension(filename);
// If the format is already overridden by hooks, convert that back to extension.
let extension;
if (this[kFormat] !== undefined) {
extension = getExtensionForFormat(this[kFormat]);
} else {
extension = findLongestRegisteredExtension(filename);
}
Module._extensions[extension](this, filename);
this.loaded = true;
+1
View File
@@ -0,0 +1 @@
export const url = import.meta.url;
@@ -0,0 +1,37 @@
'use strict';
// This tests that load hooks can override the format of builtin modules
// to 'commonjs' format.
const common = require('../common');
const assert = require('assert');
const { registerHooks } = require('module');
// Pick a builtin that's unlikely to be loaded already - like zlib.
assert(!process.moduleLoadList.includes('NativeModule zlib'));
const hook = registerHooks({
load: common.mustCall(function load(url, context, nextLoad) {
// Only intercept zlib builtin
if (url === 'node:zlib') {
// Return a different format to override the builtin
return {
source: 'exports.custom_zlib = "overridden by load hook";',
format: 'commonjs',
shortCircuit: true,
};
}
return nextLoad(url, context);
}, 2), // Called twice: once for 'zlib', once for 'node:zlib'
});
// Test: Load hook overrides builtin format to commonjs
const zlib = require('zlib');
assert.strictEqual(zlib.custom_zlib, 'overridden by load hook');
assert.strictEqual(typeof zlib.createGzip, 'undefined'); // Original zlib API should not be available
// Test with node: prefix
const zlib2 = require('node:zlib');
assert.strictEqual(zlib2.custom_zlib, 'overridden by load hook');
assert.strictEqual(typeof zlib2.createGzip, 'undefined');
hook.deregister();
@@ -0,0 +1,37 @@
'use strict';
// This tests that load hooks can override the format of builtin modules
// to 'json' format.
const common = require('../common');
const assert = require('assert');
const { registerHooks } = require('module');
// Pick a builtin that's unlikely to be loaded already - like zlib.
assert(!process.moduleLoadList.includes('NativeModule zlib'));
const hook = registerHooks({
load: common.mustCall(function load(url, context, nextLoad) {
// Only intercept zlib builtin
if (url === 'node:zlib') {
// Return JSON format to override the builtin
return {
source: JSON.stringify({ custom_zlib: 'JSON overridden zlib' }),
format: 'json',
shortCircuit: true,
};
}
return nextLoad(url, context);
}, 2), // Called twice: once for 'zlib', once for 'node:zlib'
});
// Test: Load hook overrides builtin format to json
const zlib = require('zlib');
assert.strictEqual(zlib.custom_zlib, 'JSON overridden zlib');
assert.strictEqual(typeof zlib.createGzip, 'undefined'); // Original zlib API should not be available
// Test with node: prefix
const zlib2 = require('node:zlib');
assert.strictEqual(zlib2.custom_zlib, 'JSON overridden zlib');
assert.strictEqual(typeof zlib2.createGzip, 'undefined');
hook.deregister();
@@ -0,0 +1,41 @@
'use strict';
// This tests that load hooks can override the format of builtin modules
// to 'module', and require() can load them.
const common = require('../common');
const assert = require('assert');
const { registerHooks } = require('module');
// Pick a builtin that's unlikely to be loaded already - like zlib.
assert(!process.moduleLoadList.includes('NativeModule zlib'));
const hook = registerHooks({
load: common.mustCall(function load(url, context, nextLoad) {
// Only intercept zlib builtin
if (url === 'node:zlib') {
// Return ES module format to override the builtin
// Note: For require() to work with ESM, we need to export 'module.exports'
return {
source: `const exports = { custom_zlib: "ESM overridden zlib" };
export default exports;
export { exports as 'module.exports' };`,
format: 'module',
shortCircuit: true,
};
}
return nextLoad(url, context);
}, 2), // Called twice: once for 'zlib', once for 'node:zlib'
});
// Test: Load hook overrides builtin format to module.
// With the 'module.exports' export, require() should work
const zlib = require('zlib');
assert.strictEqual(zlib.custom_zlib, 'ESM overridden zlib');
assert.strictEqual(typeof zlib.createGzip, 'undefined'); // Original zlib API should not be available
// Test with node: prefix
const zlib2 = require('node:zlib');
assert.strictEqual(zlib2.custom_zlib, 'ESM overridden zlib');
assert.strictEqual(typeof zlib2.createGzip, 'undefined');
hook.deregister();
@@ -0,0 +1,33 @@
'use strict';
// This tests that builtins can be redirected to a local file when they are prefixed
// with `node:`.
require('../common');
const assert = require('assert');
const { registerHooks } = require('module');
const fixtures = require('../common/fixtures');
// This tests that builtins can be redirected to a local file.
// Pick a builtin that's unlikely to be loaded already - like zlib.
assert(!process.moduleLoadList.includes('NativeModule zlib'));
const hook = registerHooks({
resolve(specifier, context, nextLoad) {
specifier = specifier.replaceAll('node:', '');
return {
url: fixtures.fileURL('module-hooks', `redirected-${specifier}.js`).href,
shortCircuit: true,
};
},
});
// Check assert, which is already loaded.
// eslint-disable-next-line node-core/must-call-assert
assert.strictEqual(require('node:assert').exports_for_test, 'redirected assert');
// Check zlib, which is not yet loaded.
assert.strictEqual(require('node:zlib').exports_for_test, 'redirected zlib');
// Check fs, which is redirected to an ESM
assert.strictEqual(require('node:fs').exports_for_test, 'redirected fs');
hook.deregister();
@@ -0,0 +1,36 @@
'use strict';
// This tests the interaction between resolve and load hooks for builtins with the
// `node:` prefix.
const common = require('../common');
const assert = require('assert');
const { registerHooks } = require('module');
const fixtures = require('../common/fixtures');
// Pick a builtin that's unlikely to be loaded already - like zlib.
assert(!process.moduleLoadList.includes('NativeModule zlib'));
const redirectedURL = fixtures.fileURL('module-hooks/redirected-zlib.js').href;
registerHooks({
resolve: common.mustCall(function resolve(specifier, context, nextResolve) {
assert.strictEqual(specifier, 'node:zlib');
return {
url: redirectedURL,
format: 'module',
shortCircuit: true,
};
}),
load: common.mustCall(function load(url, context, nextLoad) {
assert.strictEqual(url, redirectedURL);
return {
source: 'export const loadURL = import.meta.url;',
format: 'module',
shortCircuit: true,
};
}),
});
const zlib = require('node:zlib');
assert.strictEqual(zlib.loadURL, redirectedURL);
@@ -0,0 +1,35 @@
'use strict';
// This tests the interaction between resolve and load hooks for builtins.
const common = require('../common');
const assert = require('assert');
const { registerHooks } = require('module');
const fixtures = require('../common/fixtures');
// Pick a builtin that's unlikely to be loaded already - like zlib.
assert(!process.moduleLoadList.includes('NativeModule zlib'));
const redirectedURL = fixtures.fileURL('module-hooks/redirected-zlib.js').href;
registerHooks({
resolve: common.mustCall(function resolve(specifier, context, nextResolve) {
assert.strictEqual(specifier, 'zlib');
return {
url: redirectedURL,
format: 'module',
shortCircuit: true,
};
}),
load: common.mustCall(function load(url, context, nextLoad) {
assert.strictEqual(url, redirectedURL);
return {
source: 'export const loadURL = import.meta.url;',
format: 'module',
shortCircuit: true,
};
}),
});
const zlib = require('zlib');
assert.strictEqual(zlib.loadURL, redirectedURL);
@@ -0,0 +1,29 @@
'use strict';
// This tests the interaction between resolve and load hooks for builtins with the
// `node:` prefix.
const common = require('../common');
const assert = require('assert');
const { registerHooks } = require('module');
// Pick a builtin that's unlikely to be loaded already - like zlib or dns.
assert(!process.moduleLoadList.includes('NativeModule zlib'));
assert(!process.moduleLoadList.includes('NativeModule dns'));
registerHooks({
resolve: common.mustCall(function resolve(specifier, context, nextResolve) {
assert.strictEqual(specifier, 'node:dns');
return {
url: 'node:zlib',
shortCircuit: true,
};
}),
load: common.mustCall(function load(url, context, nextLoad) {
assert.strictEqual(url, 'node:zlib');
return nextLoad(url, context);
}),
});
const zlib = require('node:dns');
assert.strictEqual(typeof zlib.createGzip, 'function');
@@ -0,0 +1,28 @@
'use strict';
// This tests the interaction between resolve and load hooks for builtins.
const common = require('../common');
const assert = require('assert');
const { registerHooks } = require('module');
// Pick a builtin that's unlikely to be loaded already - like zlib or dns.
assert(!process.moduleLoadList.includes('NativeModule zlib'));
assert(!process.moduleLoadList.includes('NativeModule dns'));
registerHooks({
resolve: common.mustCall(function resolve(specifier, context, nextResolve) {
assert.strictEqual(specifier, 'dns');
return {
url: 'node:zlib',
shortCircuit: true,
};
}),
load: common.mustCall(function load(url, context, nextLoad) {
assert.strictEqual(url, 'node:zlib');
return nextLoad(url, context);
}),
});
const zlib = require('dns');
assert.strictEqual(typeof zlib.createGzip, 'function');