Skip to content

Commit bac0a34

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.
1 parent dcb9573 commit bac0a34

10 files changed

+360
-39
lines changed

lib/internal/modules/cjs/loader.js

Lines changed: 82 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,7 +1111,9 @@ function resolveForCJSWithHooks(specifier, parent, isMain) {
11111111
filename = convertURLToCJSFilename(url);
11121112
}
11131113

1114-
return { __proto__: null, url, format, filename, parentURL };
1114+
const result = { __proto__: null, url, format, filename, parentURL };
1115+
debug('resolveForCJSWithHooks', specifier, parent?.id, '->', result);
1116+
return result;
11151117
}
11161118

11171119
/**
@@ -1168,24 +1170,28 @@ function getDefaultLoad(url, filename) {
11681170
* @param {string} id The module ID (without the node: prefix)
11691171
* @param {string} url The module URL (with the node: prefix)
11701172
* @param {string} format Format from resolution.
1171-
* @returns {any} If there are no load hooks or the load hooks do not override the format of the
1172-
* builtin, load and return the exports of the builtin. Otherwise, return undefined.
1173+
* @returns {{builtinExports?: any, resultFromHook?: any}} If there are no load hooks or the load hooks do not
1174+
* override the format of the builtin, load and return the exports of the builtin module.
1175+
* Otherwise, return the loadResult for the caller to continue loading.
11731176
*/
11741177
function loadBuiltinWithHooks(id, url, format) {
1178+
let resultFromHook;
11751179
if (loadHooks.length) {
11761180
url ??= `node:${id}`;
1181+
debug('loadBuiltinWithHooks ', loadHooks.length, id, url, format);
11771182
// TODO(joyeecheung): do we really want to invoke the load hook for the builtins?
1178-
const loadResult = loadWithHooks(url, format || 'builtin', /* importAttributes */ undefined,
1179-
getCjsConditionsArray(), getDefaultLoad(url, id), validateLoadStrict);
1180-
if (loadResult.format && loadResult.format !== 'builtin') {
1181-
return undefined; // Format has been overridden, return undefined for the caller to continue loading.
1183+
resultFromHook = loadWithHooks(url, format || 'builtin', /* importAttributes */ undefined,
1184+
getCjsConditionsArray(), getDefaultLoad(url, id), validateLoadStrict);
1185+
if (resultFromHook.format && resultFromHook.format !== 'builtin') {
1186+
debug('loadBuiltinWithHooks overriding module', id, url, resultFromHook);
1187+
return { resultFromHook }; // Format has been overridden, return result for the caller to continue loading.
11821188
}
11831189
}
11841190

11851191
// No hooks or the hooks have not overridden the format. Load it as a builtin module and return the
11861192
// exports.
11871193
const mod = loadBuiltinModule(id);
1188-
return mod.exports;
1194+
return { builtinExports: mod.exports };
11891195
}
11901196

11911197
/**
@@ -1223,47 +1229,64 @@ Module._load = function(request, parent, isMain) {
12231229
}
12241230
}
12251231

1226-
const { url, format, filename } = resolveForCJSWithHooks(request, parent, isMain);
1232+
const resolveResult = resolveForCJSWithHooks(request, parent, isMain);
1233+
let { format } = resolveResult;
1234+
const { url, filename } = resolveResult;
12271235

1236+
let resultFromLoadHook;
12281237
// For backwards compatibility, if the request itself starts with node:, load it before checking
12291238
// Module._cache. Otherwise, load it after the check.
1230-
if (StringPrototypeStartsWith(request, 'node:')) {
1231-
const result = loadBuiltinWithHooks(filename, url, format);
1232-
if (result) {
1233-
return result;
1239+
// TODO(joyeecheung): a more sensible handling is probably, if there are hooks, always go through the hooks
1240+
// first before checking the cache. Otherwise, check the cache first, then proceed to default loading.
1241+
if (request === url && StringPrototypeStartsWith(request, 'node:')) {
1242+
const normalized = BuiltinModule.normalizeRequirableId(request);
1243+
if (normalized) { // It's a builtin module.
1244+
const { resultFromHook, builtinExports } = loadBuiltinWithHooks(normalized, url, format);
1245+
if (builtinExports) {
1246+
return builtinExports;
1247+
}
1248+
// The format of the builtin has been overridden by user hooks. Continue loading.
1249+
resultFromLoadHook = resultFromHook;
1250+
format = resultFromLoadHook.format;
12341251
}
1235-
// The format of the builtin has been overridden by user hooks. Continue loading.
12361252
}
12371253

1238-
const cachedModule = Module._cache[filename];
1239-
if (cachedModule !== undefined) {
1240-
updateChildren(parent, cachedModule, true);
1241-
if (cachedModule.loaded) {
1242-
return cachedModule.exports;
1243-
}
1244-
// If it's not cached by the ESM loader, the loading request
1245-
// comes from required CJS, and we can consider it a circular
1246-
// dependency when it's cached.
1247-
if (!cachedModule[kIsCachedByESMLoader]) {
1248-
return getExportsForCircularRequire(cachedModule);
1249-
}
1250-
// If it's cached by the ESM loader as a way to indirectly pass
1251-
// the module in to avoid creating it twice, the loading request
1252-
// came from imported CJS. In that case use the kModuleCircularVisited
1253-
// to determine if it's loading or not.
1254-
if (cachedModule[kModuleCircularVisited]) {
1255-
return getExportsForCircularRequire(cachedModule);
1254+
// If load hooks overrides the format for a built-in, bypass the cache.
1255+
let cachedModule;
1256+
if (resultFromLoadHook === undefined) {
1257+
cachedModule = Module._cache[filename];
1258+
debug('Module._load checking cache for', filename, !!cachedModule);
1259+
if (cachedModule !== undefined) {
1260+
updateChildren(parent, cachedModule, true);
1261+
if (cachedModule.loaded) {
1262+
return cachedModule.exports;
1263+
}
1264+
// If it's not cached by the ESM loader, the loading request
1265+
// comes from required CJS, and we can consider it a circular
1266+
// dependency when it's cached.
1267+
if (!cachedModule[kIsCachedByESMLoader]) {
1268+
return getExportsForCircularRequire(cachedModule);
1269+
}
1270+
// If it's cached by the ESM loader as a way to indirectly pass
1271+
// the module in to avoid creating it twice, the loading request
1272+
// came from imported CJS. In that case use the kModuleCircularVisited
1273+
// to determine if it's loading or not.
1274+
if (cachedModule[kModuleCircularVisited]) {
1275+
return getExportsForCircularRequire(cachedModule);
1276+
}
1277+
// This is an ESM loader created cache entry, mark it as visited and fallthrough to loading the module.
1278+
cachedModule[kModuleCircularVisited] = true;
12561279
}
1257-
// This is an ESM loader created cache entry, mark it as visited and fallthrough to loading the module.
1258-
cachedModule[kModuleCircularVisited] = true;
12591280
}
12601281

1261-
if (BuiltinModule.canBeRequiredWithoutScheme(filename)) {
1262-
const result = loadBuiltinWithHooks(filename, url, format);
1263-
if (result) {
1264-
return result;
1282+
if (resultFromLoadHook === undefined && BuiltinModule.canBeRequiredWithoutScheme(filename)) {
1283+
const { resultFromHook, builtinExports } = loadBuiltinWithHooks(filename, url, format);
1284+
if (builtinExports) {
1285+
return builtinExports;
12651286
}
12661287
// The format of the builtin has been overridden by user hooks. Continue loading.
1288+
resultFromLoadHook = resultFromHook;
1289+
format = resultFromLoadHook.format;
12671290
}
12681291

12691292
// Don't call updateChildren(), Module constructor already does.
@@ -1278,6 +1301,9 @@ Module._load = function(request, parent, isMain) {
12781301
} else {
12791302
module[kIsMainSymbol] = false;
12801303
}
1304+
if (resultFromLoadHook !== undefined) {
1305+
module[kModuleSource] = resultFromLoadHook.source;
1306+
}
12811307

12821308
reportModuleToWatchMode(filename);
12831309
Module._cache[filename] = module;
@@ -1463,6 +1489,17 @@ function createEsmNotFoundErr(request, path) {
14631489
return err;
14641490
}
14651491

1492+
function getExtensionForFormat(format) {
1493+
switch (format) {
1494+
case 'addon':
1495+
return '.node';
1496+
case 'json':
1497+
return '.json';
1498+
default:
1499+
return '.js';
1500+
}
1501+
}
1502+
14661503
/**
14671504
* Given a file name, pass it to the proper extension handler.
14681505
* @param {string} filename The `require` specifier
@@ -1475,7 +1512,13 @@ Module.prototype.load = function(filename) {
14751512
this.filename ??= filename;
14761513
this.paths ??= Module._nodeModulePaths(path.dirname(filename));
14771514

1478-
const extension = findLongestRegisteredExtension(filename);
1515+
// If the format is already overridden by hooks, convert that back to extension.
1516+
let extension;
1517+
if (this[kFormat] !== undefined) {
1518+
extension = getExtensionForFormat(this[kFormat]);
1519+
} else {
1520+
extension = findLongestRegisteredExtension(filename);
1521+
}
14791522

14801523
Module._extensions[extension](this, filename);
14811524
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)