Skip to content

Commit 0eec5cc

Browse files
joyeecheungaduh95
authored andcommitted
module: fix conditions override in synchronous resolve hooks
1. Make sure that the conditions are converted into arrays when being passed into user hooks. 2. Pass the conditions from user hooks into the ESM resolution so that it takes effect. PR-URL: #59011 Fixes: #59003 Reviewed-By: Zeyu "Alex" Yang <himself65@outlook.com> Reviewed-By: Jacob Smith <jacob@frende.me>
1 parent 4acf7cd commit 0eec5cc

File tree

10 files changed

+271
-31
lines changed

10 files changed

+271
-31
lines changed

lib/internal/modules/cjs/loader.js

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const {
5151
ReflectSet,
5252
RegExpPrototypeExec,
5353
SafeMap,
54+
SafeSet,
5455
String,
5556
StringPrototypeCharAt,
5657
StringPrototypeCharCodeAt,
@@ -154,6 +155,7 @@ const internalFsBinding = internalBinding('fs');
154155
const { safeGetenv } = internalBinding('credentials');
155156
const {
156157
getCjsConditions,
158+
getCjsConditionsArray,
157159
initializeCjsConditions,
158160
loadBuiltinModule,
159161
makeRequireFunction,
@@ -632,6 +634,8 @@ const EXPORTS_PATTERN = /^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)?$/;
632634
* Resolves the exports for a given module path and request.
633635
* @param {string} nmPath The path to the module.
634636
* @param {string} request The request for the module.
637+
* @param {Set<string>} conditions The conditions to use for resolution.
638+
* @returns {undefined|string}
635639
*/
636640
function resolveExports(nmPath, request, conditions) {
637641
// The implementation's behavior is meant to mirror resolution in ESM.
@@ -1040,17 +1044,30 @@ function resolveForCJSWithHooks(specifier, parent, isMain) {
10401044
function defaultResolve(specifier, context) {
10411045
// TODO(joyeecheung): parent and isMain should be part of context, then we
10421046
// no longer need to use a different defaultResolve for every resolution.
1047+
// In the hooks, context.conditions is passed around as an array, but internally
1048+
// the resolution helpers expect a SafeSet. Do the conversion here.
1049+
let conditionSet;
1050+
const conditions = context.conditions;
1051+
if (conditions !== undefined && conditions !== getCjsConditionsArray()) {
1052+
if (!ArrayIsArray(conditions)) {
1053+
throw new ERR_INVALID_ARG_VALUE('context.conditions', conditions,
1054+
'expected an array');
1055+
}
1056+
conditionSet = new SafeSet(conditions);
1057+
} else {
1058+
conditionSet = getCjsConditions();
1059+
}
10431060
defaultResolvedFilename = defaultResolveImpl(specifier, parent, isMain, {
10441061
__proto__: null,
1045-
conditions: context.conditions,
1062+
conditions: conditionSet,
10461063
});
10471064

10481065
defaultResolvedURL = convertCJSFilenameToURL(defaultResolvedFilename);
10491066
return { __proto__: null, url: defaultResolvedURL };
10501067
}
10511068

10521069
const resolveResult = resolveWithHooks(specifier, parentURL, /* importAttributes */ undefined,
1053-
getCjsConditions(), defaultResolve);
1070+
getCjsConditionsArray(), defaultResolve);
10541071
const { url } = resolveResult;
10551072
format = resolveResult.format;
10561073

@@ -1126,7 +1143,7 @@ function loadBuiltinWithHooks(id, url, format) {
11261143
url ??= `node:${id}`;
11271144
// TODO(joyeecheung): do we really want to invoke the load hook for the builtins?
11281145
const loadResult = loadWithHooks(url, format || 'builtin', /* importAttributes */ undefined,
1129-
getCjsConditions(), getDefaultLoad(url, id));
1146+
getCjsConditionsArray(), getDefaultLoad(url, id));
11301147
if (loadResult.format && loadResult.format !== 'builtin') {
11311148
return undefined; // Format has been overridden, return undefined for the caller to continue loading.
11321149
}
@@ -1277,7 +1294,8 @@ Module._load = function(request, parent, isMain) {
12771294
* @param {ResolveFilenameOptions} options Options object
12781295
* @typedef {object} ResolveFilenameOptions
12791296
* @property {string[]} paths Paths to search for modules in
1280-
* @property {string[]} conditions Conditions used for resolution.
1297+
* @property {Set<string>?} conditions The conditions to use for resolution.
1298+
* @returns {void|string}
12811299
*/
12821300
Module._resolveFilename = function(request, parent, isMain, options) {
12831301
if (BuiltinModule.normalizeRequirableId(request)) {
@@ -1720,7 +1738,8 @@ function loadSource(mod, filename, formatFromNode) {
17201738
mod[kURL] = convertCJSFilenameToURL(filename);
17211739
}
17221740

1723-
const loadResult = loadWithHooks(mod[kURL], mod[kFormat], /* importAttributes */ undefined, getCjsConditions(),
1741+
const loadResult = loadWithHooks(mod[kURL], mod[kFormat], /* importAttributes */ undefined,
1742+
getCjsConditionsArray(),
17241743
getDefaultLoad(mod[kURL], filename));
17251744

17261745
// Reset the module properties with load hook results.

lib/internal/modules/esm/loader.js

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -691,24 +691,30 @@ class ModuleLoader {
691691
if (this.#customizations) { // Only has module.register hooks.
692692
return this.#customizations.resolve(specifier, parentURL, importAttributes);
693693
}
694-
return this.#cachedDefaultResolve(specifier, parentURL, importAttributes);
694+
return this.#cachedDefaultResolve(specifier, {
695+
__proto__: null,
696+
conditions: this.#defaultConditions,
697+
parentURL,
698+
importAttributes,
699+
});
695700
}
696701

697702
/**
698703
* Either return a cached resolution, or perform the default resolution which is synchronous, and
699704
* cache the result.
700705
* @param {string} specifier See {@link resolve}.
701-
* @param {string} [parentURL] See {@link resolve}.
702-
* @param {ImportAttributes} importAttributes See {@link resolve}.
706+
* @param {{ parentURL?: string, importAttributes: ImportAttributes, conditions?: string[]}} context
703707
* @returns {{ format: string, url: string }}
704708
*/
705-
#cachedDefaultResolve(specifier, parentURL, importAttributes) {
709+
#cachedDefaultResolve(specifier, context) {
710+
const { parentURL, importAttributes } = context;
706711
const requestKey = this.#resolveCache.serializeKey(specifier, importAttributes);
707712
const cachedResult = this.#resolveCache.get(requestKey, parentURL);
708713
if (cachedResult != null) {
709714
return cachedResult;
710715
}
711-
const result = this.defaultResolve(specifier, parentURL, importAttributes);
716+
defaultResolve ??= require('internal/modules/esm/resolve').defaultResolve;
717+
const result = defaultResolve(specifier, context);
712718
this.#resolveCache.set(requestKey, parentURL, result);
713719
return result;
714720
}
@@ -736,14 +742,15 @@ class ModuleLoader {
736742
* This is the default resolve step for module.registerHooks(), which incorporates asynchronous hooks
737743
* from module.register() which are run in a blocking fashion for it to be synchronous.
738744
* @param {string|URL} specifier See {@link resolveSync}.
739-
* @param {{ parentURL?: string, importAttributes: ImportAttributes}} context See {@link resolveSync}.
745+
* @param {{ parentURL?: string, importAttributes: ImportAttributes, conditions?: string[]}} context
746+
* See {@link resolveSync}.
740747
* @returns {{ format: string, url: string }}
741748
*/
742749
#resolveAndMaybeBlockOnLoaderThread(specifier, context) {
743750
if (this.#customizations) {
744751
return this.#customizations.resolveSync(specifier, context.parentURL, context.importAttributes);
745752
}
746-
return this.#cachedDefaultResolve(specifier, context.parentURL, context.importAttributes);
753+
return this.#cachedDefaultResolve(specifier, context);
747754
}
748755

749756
/**
@@ -766,25 +773,12 @@ class ModuleLoader {
766773
return resolveWithHooks(specifier, parentURL, importAttributes, this.#defaultConditions,
767774
this.#resolveAndMaybeBlockOnLoaderThread.bind(this));
768775
}
769-
return this.#resolveAndMaybeBlockOnLoaderThread(specifier, { parentURL, importAttributes });
770-
}
771-
772-
/**
773-
* Our `defaultResolve` is synchronous and can be used in both
774-
* `resolve` and `resolveSync`. This function is here just to avoid
775-
* repeating the same code block twice in those functions.
776-
*/
777-
defaultResolve(originalSpecifier, parentURL, importAttributes) {
778-
defaultResolve ??= require('internal/modules/esm/resolve').defaultResolve;
779-
780-
const context = {
776+
return this.#resolveAndMaybeBlockOnLoaderThread(specifier, {
781777
__proto__: null,
782778
conditions: this.#defaultConditions,
783-
importAttributes,
784779
parentURL,
785-
};
786-
787-
return defaultResolve(originalSpecifier, context);
780+
importAttributes,
781+
});
788782
}
789783

790784
/**

lib/internal/modules/helpers.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ function toRealPath(requestPath) {
6565

6666
/** @type {Set<string>} */
6767
let cjsConditions;
68+
/** @type {string[]} */
69+
let cjsConditionsArray;
70+
6871
/**
6972
* Define the conditions that apply to the CommonJS loader.
7073
*/
@@ -73,15 +76,17 @@ function initializeCjsConditions() {
7376
const noAddons = getOptionValue('--no-addons');
7477
const addonConditions = noAddons ? [] : ['node-addons'];
7578
// TODO: Use this set when resolving pkg#exports conditions in loader.js.
76-
cjsConditions = new SafeSet([
79+
cjsConditionsArray = [
7780
'require',
7881
'node',
7982
...addonConditions,
8083
...userConditions,
81-
]);
84+
];
8285
if (getOptionValue('--experimental-require-module')) {
83-
cjsConditions.add('module-sync');
86+
cjsConditionsArray.push('module-sync');
8487
}
88+
ObjectFreeze(cjsConditionsArray);
89+
cjsConditions = new SafeSet(cjsConditionsArray);
8590
}
8691

8792
/**
@@ -94,6 +99,13 @@ function getCjsConditions() {
9499
return cjsConditions;
95100
}
96101

102+
function getCjsConditionsArray() {
103+
if (cjsConditionsArray === undefined) {
104+
initializeCjsConditions();
105+
}
106+
return cjsConditionsArray;
107+
}
108+
97109
/**
98110
* Provide one of Node.js' public modules to user code.
99111
* @param {string} id - The identifier/specifier of the builtin module to load
@@ -407,6 +419,7 @@ module.exports = {
407419
flushCompileCache,
408420
getBuiltinModule,
409421
getCjsConditions,
422+
getCjsConditionsArray,
410423
getCompileCacheDir,
411424
initializeCjsConditions,
412425
loadBuiltinModule,

test/fixtures/es-modules/custom-condition/node_modules/foo/default.cjs

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/es-modules/custom-condition/node_modules/foo/foo-esm.mjs

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/es-modules/custom-condition/node_modules/foo/foo.cjs

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/es-modules/custom-condition/node_modules/foo/package.json

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Similar to test-module-hooks-custom-conditions.mjs, but checking the
2+
// real require() instead of the re-invented one for imported CJS.
3+
'use strict';
4+
const common = require('../common');
5+
const { registerHooks } = require('node:module');
6+
const assert = require('node:assert');
7+
const { cjs, esm } = require('../fixtures/es-modules/custom-condition/load.cjs');
8+
9+
(async () => {
10+
// Without hooks, the default condition is used.
11+
assert.strictEqual(cjs('foo').result, 'default');
12+
assert.strictEqual((await esm('foo')).result, 'default');
13+
14+
// Prepending 'foo' to the conditions array in the resolve hook should
15+
// allow a CJS to be resolved with that condition.
16+
{
17+
const hooks = registerHooks({
18+
resolve(specifier, context, nextResolve) {
19+
assert(Array.isArray(context.conditions));
20+
context.conditions = ['foo', ...context.conditions];
21+
return nextResolve(specifier, context);
22+
},
23+
});
24+
assert.strictEqual(cjs('foo/second').result, 'foo');
25+
assert.strictEqual((await esm('foo/second')).result, 'foo');
26+
hooks.deregister();
27+
}
28+
29+
// Prepending 'foo-esm' to the conditions array in the resolve hook should
30+
// allow a ESM to be resolved with that condition.
31+
{
32+
const hooks = registerHooks({
33+
resolve(specifier, context, nextResolve) {
34+
assert(Array.isArray(context.conditions));
35+
context.conditions = ['foo-esm', ...context.conditions];
36+
return nextResolve(specifier, context);
37+
},
38+
});
39+
assert.strictEqual(cjs('foo/third').result, 'foo-esm');
40+
assert.strictEqual((await esm('foo/third')).result, 'foo-esm');
41+
hooks.deregister();
42+
}
43+
44+
// Duplicating the 'foo' condition in the resolve hook should not change the result.
45+
{
46+
const hooks = registerHooks({
47+
resolve(specifier, context, nextResolve) {
48+
assert(Array.isArray(context.conditions));
49+
context.conditions = ['foo', ...context.conditions, 'foo'];
50+
return nextResolve(specifier, context);
51+
},
52+
});
53+
assert.strictEqual(cjs('foo/fourth').result, 'foo');
54+
assert.strictEqual((await esm('foo/fourth')).result, 'foo');
55+
hooks.deregister();
56+
}
57+
})().then(common.mustCall());
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Check various special values of `conditions` in the context object
2+
// when using synchronous module hooks to override the loaders in a
3+
// CJS module.
4+
'use strict';
5+
const common = require('../common');
6+
const { registerHooks } = require('node:module');
7+
const assert = require('node:assert');
8+
const { cjs, esm } = require('../fixtures/es-modules/custom-condition/load.cjs');
9+
10+
(async () => {
11+
// Setting it to undefined would lead to the default conditions being used.
12+
{
13+
const hooks = registerHooks({
14+
resolve(specifier, context, nextResolve) {
15+
context.conditions = undefined;
16+
return nextResolve(specifier, context);
17+
},
18+
});
19+
assert.strictEqual(cjs('foo').result, 'default');
20+
assert.strictEqual((await esm('foo')).result, 'default');
21+
hooks.deregister();
22+
}
23+
24+
// Setting it to an empty array would lead to the default conditions being used.
25+
{
26+
const hooks = registerHooks({
27+
resolve(specifier, context, nextResolve) {
28+
context.conditions = [];
29+
return nextResolve(specifier, context);
30+
},
31+
});
32+
assert.strictEqual(cjs('foo/second').result, 'default');
33+
assert.strictEqual((await esm('foo/second')).result, 'default');
34+
hooks.deregister();
35+
}
36+
37+
// If the exports have no default export, it should error.
38+
{
39+
const hooks = registerHooks({
40+
resolve(specifier, context, nextResolve) {
41+
context.conditions = [];
42+
return nextResolve(specifier, context);
43+
},
44+
});
45+
assert.throws(() => cjs('foo/no-default'), {
46+
code: 'ERR_PACKAGE_PATH_NOT_EXPORTED',
47+
});
48+
await assert.rejects(esm('foo/no-default'), {
49+
code: 'ERR_PACKAGE_PATH_NOT_EXPORTED',
50+
});
51+
hooks.deregister();
52+
}
53+
54+
// If the exports have no default export, it should error.
55+
{
56+
const hooks = registerHooks({
57+
resolve(specifier, context, nextResolve) {
58+
context.conditions = 'invalid';
59+
return nextResolve(specifier, context);
60+
},
61+
});
62+
assert.throws(() => cjs('foo/third'), {
63+
code: 'ERR_INVALID_ARG_VALUE',
64+
});
65+
await assert.rejects(esm('foo/third'), {
66+
code: 'ERR_INVALID_ARG_VALUE',
67+
});
68+
hooks.deregister();
69+
}
70+
})().then(common.mustCall());

0 commit comments

Comments
 (0)