Skip to content

Commit cf1e37d

Browse files
committed
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: #61088 Fixes: #60005 Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com> Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
1 parent 63468af commit cf1e37d

10 files changed

+360
-39
lines changed

lib/internal/modules/cjs/loader.js

Lines changed: 83 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,7 +1086,9 @@ function resolveForCJSWithHooks(specifier, parent, isMain) {
10861086
filename = convertURLToCJSFilename(url);
10871087
}
10881088

1089-
return { __proto__: null, url, format, filename, parentURL };
1089+
const result = { __proto__: null, url, format, filename, parentURL };
1090+
debug('resolveForCJSWithHooks', specifier, parent?.id, '->', result);
1091+
return result;
10901092
}
10911093

10921094
/**
@@ -1143,24 +1145,29 @@ function getDefaultLoad(url, filename) {
11431145
* @param {string} id The module ID (without the node: prefix)
11441146
* @param {string} url The module URL (with the node: prefix)
11451147
* @param {string} format Format from resolution.
1146-
* @returns {any} If there are no load hooks or the load hooks do not override the format of the
1147-
* builtin, load and return the exports of the builtin. Otherwise, return undefined.
1148+
* @returns {{builtinExports: any, resultFromHook: undefined|ModuleLoadResult}} If there are no load
1149+
* hooks or the load hooks do not override the format of the builtin, load and return the exports
1150+
* of the builtin module. Otherwise, return the loadResult for the caller to continue loading.
11481151
*/
11491152
function loadBuiltinWithHooks(id, url, format) {
1153+
let resultFromHook;
11501154
if (loadHooks.length) {
11511155
url ??= `node:${id}`;
1156+
debug('loadBuiltinWithHooks ', loadHooks.length, id, url, format);
11521157
// TODO(joyeecheung): do we really want to invoke the load hook for the builtins?
1153-
const loadResult = loadWithHooks(url, format || 'builtin', /* importAttributes */ undefined,
1154-
getCjsConditionsArray(), getDefaultLoad(url, id), validateLoadStrict);
1155-
if (loadResult.format && loadResult.format !== 'builtin') {
1156-
return undefined; // Format has been overridden, return undefined for the caller to continue loading.
1158+
resultFromHook = loadWithHooks(url, format || 'builtin', /* importAttributes */ undefined,
1159+
getCjsConditionsArray(), getDefaultLoad(url, id), validateLoadStrict);
1160+
if (resultFromHook.format && resultFromHook.format !== 'builtin') {
1161+
debug('loadBuiltinWithHooks overriding module', id, url, resultFromHook);
1162+
// Format has been overridden, return result for the caller to continue loading.
1163+
return { builtinExports: undefined, resultFromHook };
11571164
}
11581165
}
11591166

11601167
// No hooks or the hooks have not overridden the format. Load it as a builtin module and return the
11611168
// exports.
11621169
const mod = loadBuiltinModule(id);
1163-
return mod.exports;
1170+
return { builtinExports: mod.exports, resultFromHook: undefined };
11641171
}
11651172

11661173
/**
@@ -1197,47 +1204,64 @@ Module._load = function(request, parent, isMain) {
11971204
}
11981205
}
11991206

1200-
const { url, format, filename } = resolveForCJSWithHooks(request, parent, isMain);
1207+
const resolveResult = resolveForCJSWithHooks(request, parent, isMain);
1208+
let { format } = resolveResult;
1209+
const { url, filename } = resolveResult;
12011210

1211+
let resultFromLoadHook;
12021212
// For backwards compatibility, if the request itself starts with node:, load it before checking
12031213
// Module._cache. Otherwise, load it after the check.
1204-
if (StringPrototypeStartsWith(request, 'node:')) {
1205-
const result = loadBuiltinWithHooks(filename, url, format);
1206-
if (result) {
1207-
return result;
1214+
// TODO(joyeecheung): a more sensible handling is probably, if there are hooks, always go through the hooks
1215+
// first before checking the cache. Otherwise, check the cache first, then proceed to default loading.
1216+
if (request === url && StringPrototypeStartsWith(request, 'node:')) {
1217+
const normalized = BuiltinModule.normalizeRequirableId(request);
1218+
if (normalized) { // It's a builtin module.
1219+
const { resultFromHook, builtinExports } = loadBuiltinWithHooks(normalized, url, format);
1220+
if (builtinExports) {
1221+
return builtinExports;
1222+
}
1223+
// The format of the builtin has been overridden by user hooks. Continue loading.
1224+
resultFromLoadHook = resultFromHook;
1225+
format = resultFromLoadHook.format;
12081226
}
1209-
// The format of the builtin has been overridden by user hooks. Continue loading.
12101227
}
12111228

1212-
const cachedModule = Module._cache[filename];
1213-
if (cachedModule !== undefined) {
1214-
updateChildren(parent, cachedModule, true);
1215-
if (cachedModule.loaded) {
1216-
return cachedModule.exports;
1217-
}
1218-
// If it's not cached by the ESM loader, the loading request
1219-
// comes from required CJS, and we can consider it a circular
1220-
// dependency when it's cached.
1221-
if (!cachedModule[kIsCachedByESMLoader]) {
1222-
return getExportsForCircularRequire(cachedModule);
1223-
}
1224-
// If it's cached by the ESM loader as a way to indirectly pass
1225-
// the module in to avoid creating it twice, the loading request
1226-
// came from imported CJS. In that case use the kModuleCircularVisited
1227-
// to determine if it's loading or not.
1228-
if (cachedModule[kModuleCircularVisited]) {
1229-
return getExportsForCircularRequire(cachedModule);
1229+
// If load hooks overrides the format for a built-in, bypass the cache.
1230+
let cachedModule;
1231+
if (resultFromLoadHook === undefined) {
1232+
cachedModule = Module._cache[filename];
1233+
debug('Module._load checking cache for', filename, !!cachedModule);
1234+
if (cachedModule !== undefined) {
1235+
updateChildren(parent, cachedModule, true);
1236+
if (cachedModule.loaded) {
1237+
return cachedModule.exports;
1238+
}
1239+
// If it's not cached by the ESM loader, the loading request
1240+
// comes from required CJS, and we can consider it a circular
1241+
// dependency when it's cached.
1242+
if (!cachedModule[kIsCachedByESMLoader]) {
1243+
return getExportsForCircularRequire(cachedModule);
1244+
}
1245+
// If it's cached by the ESM loader as a way to indirectly pass
1246+
// the module in to avoid creating it twice, the loading request
1247+
// came from imported CJS. In that case use the kModuleCircularVisited
1248+
// to determine if it's loading or not.
1249+
if (cachedModule[kModuleCircularVisited]) {
1250+
return getExportsForCircularRequire(cachedModule);
1251+
}
1252+
// This is an ESM loader created cache entry, mark it as visited and fallthrough to loading the module.
1253+
cachedModule[kModuleCircularVisited] = true;
12301254
}
1231-
// This is an ESM loader created cache entry, mark it as visited and fallthrough to loading the module.
1232-
cachedModule[kModuleCircularVisited] = true;
12331255
}
12341256

1235-
if (BuiltinModule.canBeRequiredWithoutScheme(filename)) {
1236-
const result = loadBuiltinWithHooks(filename, url, format);
1237-
if (result) {
1238-
return result;
1257+
if (resultFromLoadHook === undefined && BuiltinModule.canBeRequiredWithoutScheme(filename)) {
1258+
const { resultFromHook, builtinExports } = loadBuiltinWithHooks(filename, url, format);
1259+
if (builtinExports) {
1260+
return builtinExports;
12391261
}
12401262
// The format of the builtin has been overridden by user hooks. Continue loading.
1263+
resultFromLoadHook = resultFromHook;
1264+
format = resultFromLoadHook.format;
12411265
}
12421266

12431267
// Don't call updateChildren(), Module constructor already does.
@@ -1252,6 +1276,9 @@ Module._load = function(request, parent, isMain) {
12521276
} else {
12531277
module[kIsMainSymbol] = false;
12541278
}
1279+
if (resultFromLoadHook !== undefined) {
1280+
module[kModuleSource] = resultFromLoadHook.source;
1281+
}
12551282

12561283
reportModuleToWatchMode(filename);
12571284
Module._cache[filename] = module;
@@ -1436,6 +1463,17 @@ function createEsmNotFoundErr(request, path) {
14361463
return err;
14371464
}
14381465

1466+
function getExtensionForFormat(format) {
1467+
switch (format) {
1468+
case 'addon':
1469+
return '.node';
1470+
case 'json':
1471+
return '.json';
1472+
default:
1473+
return '.js';
1474+
}
1475+
}
1476+
14391477
/**
14401478
* Given a file name, pass it to the proper extension handler.
14411479
* @param {string} filename The `require` specifier
@@ -1447,7 +1485,13 @@ Module.prototype.load = function(filename) {
14471485
this.filename ??= filename;
14481486
this.paths ??= Module._nodeModulePaths(path.dirname(filename));
14491487

1450-
const extension = findLongestRegisteredExtension(filename);
1488+
// If the format is already overridden by hooks, convert that back to extension.
1489+
let extension;
1490+
if (this[kFormat] !== undefined) {
1491+
extension = getExtensionForFormat(this[kFormat]);
1492+
} else {
1493+
extension = findLongestRegisteredExtension(filename);
1494+
}
14511495

14521496
Module._extensions[extension](this, filename);
14531497
this.loaded = true;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const url = import.meta.url;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use strict';
2+
3+
// This tests that load hooks can override the format of builtin modules
4+
// to 'commonjs' format.
5+
const common = require('../common');
6+
const assert = require('assert');
7+
const { registerHooks } = require('module');
8+
9+
// Pick a builtin that's unlikely to be loaded already - like zlib.
10+
assert(!process.moduleLoadList.includes('NativeModule zlib'));
11+
12+
const hook = registerHooks({
13+
load: common.mustCall(function load(url, context, nextLoad) {
14+
// Only intercept zlib builtin
15+
if (url === 'node:zlib') {
16+
// Return a different format to override the builtin
17+
return {
18+
source: 'exports.custom_zlib = "overridden by load hook";',
19+
format: 'commonjs',
20+
shortCircuit: true,
21+
};
22+
}
23+
return nextLoad(url, context);
24+
}, 2), // Called twice: once for 'zlib', once for 'node:zlib'
25+
});
26+
27+
// Test: Load hook overrides builtin format to commonjs
28+
const zlib = require('zlib');
29+
assert.strictEqual(zlib.custom_zlib, 'overridden by load hook');
30+
assert.strictEqual(typeof zlib.createGzip, 'undefined'); // Original zlib API should not be available
31+
32+
// Test with node: prefix
33+
const zlib2 = require('node:zlib');
34+
assert.strictEqual(zlib2.custom_zlib, 'overridden by load hook');
35+
assert.strictEqual(typeof zlib2.createGzip, 'undefined');
36+
37+
hook.deregister();
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use strict';
2+
3+
// This tests that load hooks can override the format of builtin modules
4+
// to 'json' format.
5+
const common = require('../common');
6+
const assert = require('assert');
7+
const { registerHooks } = require('module');
8+
9+
// Pick a builtin that's unlikely to be loaded already - like zlib.
10+
assert(!process.moduleLoadList.includes('NativeModule zlib'));
11+
12+
const hook = registerHooks({
13+
load: common.mustCall(function load(url, context, nextLoad) {
14+
// Only intercept zlib builtin
15+
if (url === 'node:zlib') {
16+
// Return JSON format to override the builtin
17+
return {
18+
source: JSON.stringify({ custom_zlib: 'JSON overridden zlib' }),
19+
format: 'json',
20+
shortCircuit: true,
21+
};
22+
}
23+
return nextLoad(url, context);
24+
}, 2), // Called twice: once for 'zlib', once for 'node:zlib'
25+
});
26+
27+
// Test: Load hook overrides builtin format to json
28+
const zlib = require('zlib');
29+
assert.strictEqual(zlib.custom_zlib, 'JSON overridden zlib');
30+
assert.strictEqual(typeof zlib.createGzip, 'undefined'); // Original zlib API should not be available
31+
32+
// Test with node: prefix
33+
const zlib2 = require('node:zlib');
34+
assert.strictEqual(zlib2.custom_zlib, 'JSON overridden zlib');
35+
assert.strictEqual(typeof zlib2.createGzip, 'undefined');
36+
37+
hook.deregister();
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
'use strict';
2+
3+
// This tests that load hooks can override the format of builtin modules
4+
// to 'module', and require() can load them.
5+
const common = require('../common');
6+
const assert = require('assert');
7+
const { registerHooks } = require('module');
8+
9+
// Pick a builtin that's unlikely to be loaded already - like zlib.
10+
assert(!process.moduleLoadList.includes('NativeModule zlib'));
11+
12+
const hook = registerHooks({
13+
load: common.mustCall(function load(url, context, nextLoad) {
14+
// Only intercept zlib builtin
15+
if (url === 'node:zlib') {
16+
// Return ES module format to override the builtin
17+
// Note: For require() to work with ESM, we need to export 'module.exports'
18+
return {
19+
source: `const exports = { custom_zlib: "ESM overridden zlib" };
20+
export default exports;
21+
export { exports as 'module.exports' };`,
22+
format: 'module',
23+
shortCircuit: true,
24+
};
25+
}
26+
return nextLoad(url, context);
27+
}, 2), // Called twice: once for 'zlib', once for 'node:zlib'
28+
});
29+
30+
// Test: Load hook overrides builtin format to module.
31+
// With the 'module.exports' export, require() should work
32+
const zlib = require('zlib');
33+
assert.strictEqual(zlib.custom_zlib, 'ESM overridden zlib');
34+
assert.strictEqual(typeof zlib.createGzip, 'undefined'); // Original zlib API should not be available
35+
36+
// Test with node: prefix
37+
const zlib2 = require('node:zlib');
38+
assert.strictEqual(zlib2.custom_zlib, 'ESM overridden zlib');
39+
assert.strictEqual(typeof zlib2.createGzip, 'undefined');
40+
41+
hook.deregister();
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use strict';
2+
3+
// This tests that builtins can be redirected to a local file when they are prefixed
4+
// with `node:`.
5+
require('../common');
6+
7+
const assert = require('assert');
8+
const { registerHooks } = require('module');
9+
const fixtures = require('../common/fixtures');
10+
11+
// This tests that builtins can be redirected to a local file.
12+
// Pick a builtin that's unlikely to be loaded already - like zlib.
13+
assert(!process.moduleLoadList.includes('NativeModule zlib'));
14+
15+
const hook = registerHooks({
16+
resolve(specifier, context, nextLoad) {
17+
specifier = specifier.replaceAll('node:', '');
18+
return {
19+
url: fixtures.fileURL('module-hooks', `redirected-${specifier}.js`).href,
20+
shortCircuit: true,
21+
};
22+
},
23+
});
24+
25+
// Check assert, which is already loaded.
26+
// eslint-disable-next-line node-core/must-call-assert
27+
assert.strictEqual(require('node:assert').exports_for_test, 'redirected assert');
28+
// Check zlib, which is not yet loaded.
29+
assert.strictEqual(require('node:zlib').exports_for_test, 'redirected zlib');
30+
// Check fs, which is redirected to an ESM
31+
assert.strictEqual(require('node:fs').exports_for_test, 'redirected fs');
32+
33+
hook.deregister();
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use strict';
2+
3+
// This tests the interaction between resolve and load hooks for builtins with the
4+
// `node:` prefix.
5+
const common = require('../common');
6+
const assert = require('assert');
7+
const { registerHooks } = require('module');
8+
const fixtures = require('../common/fixtures');
9+
10+
// Pick a builtin that's unlikely to be loaded already - like zlib.
11+
assert(!process.moduleLoadList.includes('NativeModule zlib'));
12+
13+
const redirectedURL = fixtures.fileURL('module-hooks/redirected-zlib.js').href;
14+
15+
registerHooks({
16+
resolve: common.mustCall(function resolve(specifier, context, nextResolve) {
17+
assert.strictEqual(specifier, 'node:zlib');
18+
return {
19+
url: redirectedURL,
20+
format: 'module',
21+
shortCircuit: true,
22+
};
23+
}),
24+
25+
load: common.mustCall(function load(url, context, nextLoad) {
26+
assert.strictEqual(url, redirectedURL);
27+
return {
28+
source: 'export const loadURL = import.meta.url;',
29+
format: 'module',
30+
shortCircuit: true,
31+
};
32+
}),
33+
});
34+
35+
const zlib = require('node:zlib');
36+
assert.strictEqual(zlib.loadURL, redirectedURL);

0 commit comments

Comments
 (0)