Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

module: expose exports conditions to loaders #31303

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding this. @nodejs/modules-active-members does this seem understandable and good UX to you?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense to me


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
jkrems marked this conversation as resolved.
Show resolved Hide resolved
jkrems marked this conversation as resolved.
Show resolved Hide resolved
guybedford marked this conversation as resolved.
Show resolved Hide resolved
* @param {function} defaultResolve
* @returns {object} response
* @returns {string} response.url
Expand All @@ -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);
}
Expand Down
7 changes: 5 additions & 2 deletions lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
jkrems marked this conversation as resolved.
Show resolved Hide resolved
} = require('internal/modules/esm/resolve');
const { defaultGetFormat } = require('internal/modules/esm/get_format');
const { defaultGetSource } = require(
'internal/modules/esm/get_source');
Expand Down Expand Up @@ -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);
Expand Down
91 changes: 70 additions & 21 deletions lib/internal/modules/esm/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ const {
ArrayIsArray,
JSONParse,
JSONStringify,
ObjectFreeze,
ObjectGetOwnPropertyNames,
ObjectPrototypeHasOwnProperty,
SafeMap,
SafeSet,
StringPrototypeEndsWith,
StringPrototypeIncludes,
StringPrototypeIndexOf,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
guybedford marked this conversation as resolved.
Show resolved Hide resolved

const realpathCache = new SafeMap();
const packageJSONCache = new SafeMap(); /* string -> PackageConfig */

Expand Down Expand Up @@ -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);
Expand All @@ -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' ||
Expand All @@ -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)) {
jkrems marked this conversation as resolved.
Show resolved Hide resolved
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;
Expand Down Expand Up @@ -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, '.');
Expand Down Expand Up @@ -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<string>} 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)) {
Expand All @@ -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);
}

Expand All @@ -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);
}

Expand All @@ -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<string>} conditions
* @returns {URL}
*/
function packageResolve(specifier, base, conditions) {
let separatorIndex = StringPrototypeIndexOf(specifier, '/');
let validPackageName = true;
let isScoped = false;
Expand Down Expand Up @@ -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);
}
}
}
Expand All @@ -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);
Expand All @@ -587,7 +627,13 @@ function shouldBeTreatedAsRelativeOrAbsolutePath(specifier) {
return false;
}

function moduleResolve(specifier /* string */, base /* URL */) { /* -> URL */
/**
* @param {string} specifier
* @param {URL} base
* @param {Set<string>} 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;
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -658,6 +706,7 @@ function defaultResolve(specifier, { parentURL } = {}, defaultResolveUnused) {
}

module.exports = {
DEFAULT_CONDITIONS,
defaultResolve,
getPackageType
};
7 changes: 7 additions & 0 deletions test/es-module/test-esm-loader-custom-condition.mjs
Original file line number Diff line number Diff line change
@@ -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' });
10 changes: 10 additions & 0 deletions test/fixtures/es-module-loaders/loader-with-custom-condition.mjs
Original file line number Diff line number Diff line change
@@ -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],
});
}
1 change: 1 addition & 0 deletions test/fixtures/es-modules/conditional-exports.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'pkgexports/condition';
1 change: 1 addition & 0 deletions test/fixtures/node_modules/pkgexports/custom-condition.mjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/fixtures/node_modules/pkgexports/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.