diff --git a/doc/api/esm.md b/doc/api/esm.md index e22072314b67ce..2e5b16f158c081 100644 --- a/doc/api/esm.md +++ b/doc/api/esm.md @@ -1050,11 +1050,22 @@ and parent URL. The module specifier is the string in an `import` statement or `import()` expression, and the parent URL is the URL of the module that imported this one, or `undefined` if this is the main entry point for the application. +The `conditions` property on the `context` is an array of conditions for +[Conditional Exports][] that apply to this resolution request. They can be used +for looking up conditional mappings elsewhere or to modify the list when calling +the default resolution logic. + +The [current set of Node.js default conditions][Conditional Exports] will always +be in the `context.conditions` list passed to the hook. If the hook wants to +ensure Node.js-compatible resolution logic, all items from this default +condition list **must** be passed through to the `defaultResolve` function. + ```js /** * @param {string} specifier * @param {object} context * @param {string} context.parentURL + * @param {string[]} context.conditions * @param {function} defaultResolve * @returns {object} response * @returns {string} response.url @@ -1069,6 +1080,14 @@ export async function resolve(specifier, context, defaultResolve) { new URL(specifier, parentURL).href : new URL(specifier).href }; } + if (anotherCondition) { + // When calling the defaultResolve, the arguments can be modified. In this + // case it's adding another value for matching conditional exports. + return defaultResolve(specifier, { + ...context, + conditions: [...context.conditions, 'another-condition'], + }); + } // Defer to Node.js for all other specifiers. return defaultResolve(specifier, context, defaultResolve); } diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 285f656fa99b11..be5868553fa8df 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -19,7 +19,10 @@ const { validateString } = require('internal/validators'); const ModuleMap = require('internal/modules/esm/module_map'); const ModuleJob = require('internal/modules/esm/module_job'); -const { defaultResolve } = require('internal/modules/esm/resolve'); +const { + defaultResolve, + DEFAULT_CONDITIONS, +} = require('internal/modules/esm/resolve'); const { defaultGetFormat } = require('internal/modules/esm/get_format'); const { defaultGetSource } = require( 'internal/modules/esm/get_source'); @@ -92,7 +95,7 @@ class Loader { validateString(parentURL, 'parentURL'); const resolveResponse = await this._resolve( - specifier, { parentURL }, defaultResolve); + specifier, { parentURL, conditions: DEFAULT_CONDITIONS }, defaultResolve); if (typeof resolveResponse !== 'object') { throw new ERR_INVALID_RETURN_VALUE( 'object', 'loader resolve', resolveResponse); diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js index 730c815b8435f0..04c6abe54269f6 100644 --- a/lib/internal/modules/esm/resolve.js +++ b/lib/internal/modules/esm/resolve.js @@ -4,9 +4,11 @@ const { ArrayIsArray, JSONParse, JSONStringify, + ObjectFreeze, ObjectGetOwnPropertyNames, ObjectPrototypeHasOwnProperty, SafeMap, + SafeSet, StringPrototypeEndsWith, StringPrototypeIncludes, StringPrototypeIndexOf, @@ -35,6 +37,7 @@ const typeFlag = getOptionValue('--input-type'); const { URL, pathToFileURL, fileURLToPath } = require('internal/url'); const { ERR_INPUT_TYPE_NOT_ALLOWED, + ERR_INVALID_ARG_VALUE, ERR_INVALID_MODULE_SPECIFIER, ERR_INVALID_PACKAGE_CONFIG, ERR_INVALID_PACKAGE_TARGET, @@ -43,6 +46,20 @@ const { ERR_UNSUPPORTED_ESM_URL_SCHEME, } = require('internal/errors').codes; +const DEFAULT_CONDITIONS = ObjectFreeze(['node', 'import']); +const DEFAULT_CONDITIONS_SET = new SafeSet(DEFAULT_CONDITIONS); + +function getConditionsSet(conditions) { + if (conditions !== undefined && conditions !== DEFAULT_CONDITIONS) { + if (!ArrayIsArray(conditions)) { + throw new ERR_INVALID_ARG_VALUE('conditions', conditions, + 'expected an array'); + } + return new SafeSet(conditions); + } + return DEFAULT_CONDITIONS_SET; +} + const realpathCache = new SafeMap(); const packageJSONCache = new SafeMap(); /* string -> PackageConfig */ @@ -310,14 +327,18 @@ function resolveExportsTargetString( return subpathResolved; } -function isArrayIndex(key /* string */) { /* -> boolean */ +/** + * @param {string} key + * @returns {boolean} + */ +function isArrayIndex(key) { const keyNum = +key; if (`${keyNum}` !== key) return false; return keyNum >= 0 && keyNum < 0xFFFF_FFFF; } function resolveExportsTarget( - packageJSONUrl, target, subpath, packageSubpath, base) { + packageJSONUrl, target, subpath, packageSubpath, base, conditions) { if (typeof target === 'string') { const resolved = resolveExportsTargetString( target, subpath, packageSubpath, packageJSONUrl, base); @@ -332,7 +353,8 @@ function resolveExportsTarget( let resolved; try { resolved = resolveExportsTarget( - packageJSONUrl, targetItem, subpath, packageSubpath, base); + packageJSONUrl, targetItem, subpath, packageSubpath, base, + conditions); } catch (e) { lastException = e; if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' || @@ -357,11 +379,12 @@ function resolveExportsTarget( } for (let i = 0; i < keys.length; i++) { const key = keys[i]; - if (key === 'node' || key === 'import' || key === 'default') { + if (key === 'default' || conditions.has(key)) { const conditionalTarget = target[key]; try { return resolveExportsTarget( - packageJSONUrl, conditionalTarget, subpath, packageSubpath, base); + packageJSONUrl, conditionalTarget, subpath, packageSubpath, base, + conditions); } catch (e) { if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') continue; throw e; @@ -397,16 +420,18 @@ function isConditionalExportsMainSugar(exports, packageJSONUrl, base) { } -function packageMainResolve(packageJSONUrl, packageConfig, base) { +function packageMainResolve(packageJSONUrl, packageConfig, base, conditions) { if (packageConfig.exists) { const exports = packageConfig.exports; if (exports !== undefined) { if (isConditionalExportsMainSugar(exports, packageJSONUrl, base)) { - return resolveExportsTarget(packageJSONUrl, exports, '', '', base); + return resolveExportsTarget(packageJSONUrl, exports, '', '', base, + conditions); } else if (typeof exports === 'object' && exports !== null) { const target = exports['.']; if (target !== undefined) - return resolveExportsTarget(packageJSONUrl, target, '', '', base); + return resolveExportsTarget(packageJSONUrl, target, '', '', base, + conditions); } throw new ERR_PACKAGE_PATH_NOT_EXPORTED(packageJSONUrl, '.'); @@ -434,9 +459,16 @@ function packageMainResolve(packageJSONUrl, packageConfig, base) { fileURLToPath(new URL('.', packageJSONUrl)), fileURLToPath(base)); } - +/** + * @param {URL} packageJSONUrl + * @param {string} packageSubpath + * @param {object} packageConfig + * @param {string} base + * @param {Set} conditions + * @returns {URL} + */ function packageExportsResolve( - packageJSONUrl, packageSubpath, packageConfig, base) /* -> URL */ { + packageJSONUrl, packageSubpath, packageConfig, base, conditions) { const exports = packageConfig.exports; if (exports === undefined || isConditionalExportsMainSugar(exports, packageJSONUrl, base)) { @@ -447,7 +479,7 @@ function packageExportsResolve( if (ObjectPrototypeHasOwnProperty(exports, packageSubpath)) { const target = exports[packageSubpath]; const resolved = resolveExportsTarget( - packageJSONUrl, target, '', packageSubpath, base); + packageJSONUrl, target, '', packageSubpath, base, conditions); return finalizeResolution(resolved, base); } @@ -466,7 +498,7 @@ function packageExportsResolve( const target = exports[bestMatch]; const subpath = StringPrototypeSubstr(packageSubpath, bestMatch.length); const resolved = resolveExportsTarget( - packageJSONUrl, target, subpath, packageSubpath, base); + packageJSONUrl, target, subpath, packageSubpath, base, conditions); return finalizeResolution(resolved, base); } @@ -478,7 +510,13 @@ function getPackageType(url) { return packageConfig.type; } -function packageResolve(specifier /* string */, base /* URL */) { /* -> URL */ +/** + * @param {string} specifier + * @param {URL} base + * @param {Set} conditions + * @returns {URL} + */ +function packageResolve(specifier, base, conditions) { let separatorIndex = StringPrototypeIndexOf(specifier, '/'); let validPackageName = true; let isScoped = false; @@ -530,10 +568,11 @@ function packageResolve(specifier /* string */, base /* URL */) { /* -> URL */ if (packageSubpath === './') { return new URL('./', packageJSONUrl); } else if (packageSubpath === '') { - return packageMainResolve(packageJSONUrl, packageConfig, base); + return packageMainResolve(packageJSONUrl, packageConfig, base, + conditions); } else { return packageExportsResolve( - packageJSONUrl, packageSubpath, packageConfig, base); + packageJSONUrl, packageSubpath, packageConfig, base, conditions); } } } @@ -559,10 +598,11 @@ function packageResolve(specifier /* string */, base /* URL */) { /* -> URL */ if (packageSubpath === './') { return new URL('./', packageJSONUrl); } else if (packageSubpath === '') { - return packageMainResolve(packageJSONUrl, packageConfig, base); + return packageMainResolve(packageJSONUrl, packageConfig, base, + conditions); } else if (packageConfig.exports !== undefined) { return packageExportsResolve( - packageJSONUrl, packageSubpath, packageConfig, base); + packageJSONUrl, packageSubpath, packageConfig, base, conditions); } else { return finalizeResolution( new URL(packageSubpath, packageJSONUrl), base); @@ -587,7 +627,13 @@ function shouldBeTreatedAsRelativeOrAbsolutePath(specifier) { return false; } -function moduleResolve(specifier /* string */, base /* URL */) { /* -> URL */ +/** + * @param {string} specifier + * @param {URL} base + * @param {Set} conditions + * @returns {URL} + */ +function moduleResolve(specifier, base, conditions) { // Order swapped from spec for minor perf gain. // Ok since relative URLs cannot parse as URLs. let resolved; @@ -597,13 +643,14 @@ function moduleResolve(specifier /* string */, base /* URL */) { /* -> URL */ try { resolved = new URL(specifier); } catch { - return packageResolve(specifier, base); + return packageResolve(specifier, base, conditions); } } return finalizeResolution(resolved, base); } -function defaultResolve(specifier, { parentURL } = {}, defaultResolveUnused) { +function defaultResolve(specifier, context = {}, defaultResolveUnused) { + let { parentURL, conditions } = context; let parsed; try { parsed = new URL(specifier); @@ -641,7 +688,8 @@ function defaultResolve(specifier, { parentURL } = {}, defaultResolveUnused) { throw new ERR_INPUT_TYPE_NOT_ALLOWED(); } - let url = moduleResolve(specifier, new URL(parentURL)); + conditions = getConditionsSet(conditions); + let url = moduleResolve(specifier, parentURL, conditions); if (isMain ? !preserveSymlinksMain : !preserveSymlinks) { const urlPath = fileURLToPath(url); @@ -658,6 +706,7 @@ function defaultResolve(specifier, { parentURL } = {}, defaultResolveUnused) { } module.exports = { + DEFAULT_CONDITIONS, defaultResolve, getPackageType }; diff --git a/test/es-module/test-esm-loader-custom-condition.mjs b/test/es-module/test-esm-loader-custom-condition.mjs new file mode 100644 index 00000000000000..26d1db4842f621 --- /dev/null +++ b/test/es-module/test-esm-loader-custom-condition.mjs @@ -0,0 +1,7 @@ +// Flags: --experimental-loader ./test/fixtures/es-module-loaders/loader-with-custom-condition.mjs +import '../common/index.mjs'; +import assert from 'assert'; + +import * as ns from '../fixtures/es-modules/conditional-exports.mjs'; + +assert.deepStrictEqual({ ...ns }, { default: 'from custom condition' }); diff --git a/test/fixtures/es-module-loaders/loader-with-custom-condition.mjs b/test/fixtures/es-module-loaders/loader-with-custom-condition.mjs new file mode 100644 index 00000000000000..78ffd75e6be27b --- /dev/null +++ b/test/fixtures/es-module-loaders/loader-with-custom-condition.mjs @@ -0,0 +1,10 @@ +import {ok, deepStrictEqual} from 'assert'; + +export async function resolve(specifier, context, defaultResolve) { + ok(Array.isArray(context.conditions), 'loader receives conditions array'); + deepStrictEqual([...context.conditions].sort(), ['import', 'node']); + return defaultResolve(specifier, { + ...context, + conditions: ['custom-condition', ...context.conditions], + }); +} diff --git a/test/fixtures/es-modules/conditional-exports.mjs b/test/fixtures/es-modules/conditional-exports.mjs new file mode 100644 index 00000000000000..cf06ac004ce883 --- /dev/null +++ b/test/fixtures/es-modules/conditional-exports.mjs @@ -0,0 +1 @@ +export { default } from 'pkgexports/condition'; diff --git a/test/fixtures/node_modules/pkgexports/custom-condition.mjs b/test/fixtures/node_modules/pkgexports/custom-condition.mjs new file mode 100644 index 00000000000000..87d8b8a8faca9a --- /dev/null +++ b/test/fixtures/node_modules/pkgexports/custom-condition.mjs @@ -0,0 +1 @@ +export default 'from custom condition'; diff --git a/test/fixtures/node_modules/pkgexports/package.json b/test/fixtures/node_modules/pkgexports/package.json index 200c028f88c9d3..f3ec20c49b2b91 100644 --- a/test/fixtures/node_modules/pkgexports/package.json +++ b/test/fixtures/node_modules/pkgexports/package.json @@ -19,6 +19,7 @@ "./nofallback2": [null, {}, "builtin:x"], "./nodemodules": "./node_modules/internalpkg/x.js", "./condition": [{ + "custom-condition": "./custom-condition.mjs", "import": "///overridden", "require": { "require": {