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

[14.x backport] module: support pattern trailers #39888

Closed
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
55 changes: 41 additions & 14 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -1169,25 +1169,36 @@ The resolver can throw the following errors:
**PACKAGE_IMPORTS_EXPORTS_RESOLVE**(_matchKey_, _matchObj_, _packageURL_,
_isImports_, _conditions_)

> 1. If _matchKey_ is a key of _matchObj_, and does not end in _"*"_, then
> 1. If _matchKey_ is a key of _matchObj_ and does not end in _"/"_ or contain
> _"*"_, then
> 1. Let _target_ be the value of _matchObj_\[_matchKey_\].
> 1. Let _resolved_ be the result of **PACKAGE_TARGET_RESOLVE**(
> _packageURL_, _target_, _""_, **false**, _isImports_, _conditions_).
> 1. Return the object _{ resolved, exact: **true** }_.
> 1. Let _expansionKeys_ be the list of keys of _matchObj_ ending in _"/"_
> or _"*"_, sorted by length descending.
> 1. Let _expansionKeys_ be the list of keys of _matchObj_ either ending in
> _"/"_ or containing only a single _"*"_, sorted by the sorting function
> **PATTERN_KEY_COMPARE** which orders in descending order of specificity.
> 1. For each key _expansionKey_ in _expansionKeys_, do
> 1. If _expansionKey_ ends in _"*"_ and _matchKey_ starts with but is
> not equal to the substring of _expansionKey_ excluding the last _"*"_
> character, then
> 1. Let _target_ be the value of _matchObj_\[_expansionKey_\].
> 1. Let _subpath_ be the substring of _matchKey_ starting at the
> index of the length of _expansionKey_ minus one.
> 1. Let _resolved_ be the result of **PACKAGE_TARGET_RESOLVE**(
> _packageURL_, _target_, _subpath_, **true**, _isImports_,
> _conditions_).
> 1. Return the object _{ resolved, exact: **true** }_.
> 1. If _matchKey_ starts with _expansionKey_, then
> 1. Let _patternBase_ be **null**.
> 1. If _expansionKey_ contains _"*"_, set _patternBase_ to the substring of
> _expansionKey_ up to but excluding the first _"*"_ character.
> 1. If _patternBase_ is not **null** and _matchKey_ starts with but is not
> equal to _patternBase_, then
> 1. Let _patternTrailer_ be the substring of _expansionKey_ from the
> index after the first _"*"_ character.
> 1. If _patternTrailer_ has zero length, or if _matchKey_ ends with
> _patternTrailer_ and the length of _matchKey_ is greater than or
> equal to the length of _expansionKey_, then
> 1. Let _target_ be the value of _matchObj_\[_expansionKey_\].
> 1. Let _subpath_ be the substring of _matchKey_ starting at the
> index of the length of _patternBase_ up to the length of
> _matchKey_ minus the length of _patternTrailer_.
> 1. Let _resolved_ be the result of **PACKAGE_TARGET_RESOLVE**(
> _packageURL_, _target_, _subpath_, **true**, _isImports_,
> _conditions_).
> 1. Return the object _{ resolved, exact: **true** }_.
> 1. Otherwise if _patternBase_ is **null** and _matchKey_ starts with
> _expansionKey_, then
> 1. Let _target_ be the value of _matchObj_\[_expansionKey_\].
> 1. Let _subpath_ be the substring of _matchKey_ starting at the
> index of the length of _expansionKey_.
Expand All @@ -1197,6 +1208,22 @@ _isImports_, _conditions_)
> 1. Return the object _{ resolved, exact: **false** }_.
> 1. Return the object _{ resolved: **null**, exact: **true** }_.

**PATTERN_KEY_COMPARE**(_keyA_, _keyB_)

> 1. Assert: _keyA_ ends with _"/"_ or contains only a single _"*"_.
> 1. Assert: _keyB_ ends with _"/"_ or contains only a single _"*"_.
> 1. Let _baseLengthA_ be the index of _"*"_ in _keyA_ plus one, if _keyA_
> contains _"*"_, or the length of _keyA_ otherwise.
> 1. Let _baseLengthB_ be the index of _"*"_ in _keyB_ plus one, if _keyB_
> contains _"*"_, or the length of _keyB_ otherwise.
> 1. If _baseLengthA_ is greater than _baseLengthB_, return -1.
> 1. If _baseLengthB_ is greater than _baseLengthA_, return 1.
> 1. If _keyA_ does not contain _"*"_, return 1.
> 1. If _keyB_ does not contain _"*"_, return -1.
> 1. If the length of _keyA_ is greater than the length of _keyB_, return -1.
> 1. If the length of _keyB_ is greater than the length of _keyA_, return 1.
> 1. Return 0.

**PACKAGE_TARGET_RESOLVE**(_packageURL_, _target_, _subpath_, _pattern_,
_internal_, _conditions_)

Expand Down
5 changes: 2 additions & 3 deletions doc/api/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,9 +360,8 @@ For these use cases, subpath export patterns can be used instead:
**`*` maps expose nested subpaths as it is a string replacement syntax
only.**

The left hand matching pattern must always end in `*`. All instances of `*` on
the right hand side will then be replaced with this value, including if it
contains any `/` separators.
All instances of `*` on the right hand side will then be replaced with this
value, including if it contains any `/` separators.

```js
import featureX from 'es-module-package/features/x';
Expand Down
90 changes: 63 additions & 27 deletions lib/internal/modules/esm/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ const {
SafeSet,
String,
StringPrototypeEndsWith,
StringPrototypeIncludes,
StringPrototypeIndexOf,
StringPrototypeLastIndexOf,
StringPrototypeReplace,
StringPrototypeSlice,
StringPrototypeSplit,
StringPrototypeStartsWith,
StringPrototypeSubstr,
} = primordials;
const internalFS = require('internal/fs/utils');
const { NativeModule } = require('internal/bootstrap/loaders');
Expand Down Expand Up @@ -502,7 +503,9 @@ function packageExportsResolve(
if (isConditionalExportsMainSugar(exports, packageJSONUrl, base))
exports = { '.': exports };

if (ObjectPrototypeHasOwnProperty(exports, packageSubpath)) {
if (ObjectPrototypeHasOwnProperty(exports, packageSubpath) &&
!StringPrototypeIncludes(packageSubpath, '*') &&
!StringPrototypeEndsWith(packageSubpath, '/')) {
const target = exports[packageSubpath];
const resolved = resolvePackageTarget(
packageJSONUrl, target, '', packageSubpath, base, false, false, conditions
Expand All @@ -513,30 +516,38 @@ function packageExportsResolve(
}

let bestMatch = '';
let bestMatchSubpath;
const keys = ObjectGetOwnPropertyNames(exports);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (key[key.length - 1] === '*' &&
const patternIndex = StringPrototypeIndexOf(key, '*');
if (patternIndex !== -1 &&
StringPrototypeStartsWith(packageSubpath,
StringPrototypeSlice(key, 0, -1)) &&
packageSubpath.length >= key.length &&
key.length > bestMatch.length) {
bestMatch = key;
StringPrototypeSlice(key, 0, patternIndex))) {
const patternTrailer = StringPrototypeSlice(key, patternIndex + 1);
if (packageSubpath.length >= key.length &&
StringPrototypeEndsWith(packageSubpath, patternTrailer) &&
patternKeyCompare(bestMatch, key) === 1 &&
StringPrototypeLastIndexOf(key, '*') === patternIndex) {
bestMatch = key;
bestMatchSubpath = StringPrototypeSlice(
packageSubpath, patternIndex,
packageSubpath.length - patternTrailer.length);
}
} else if (key[key.length - 1] === '/' &&
StringPrototypeStartsWith(packageSubpath, key) &&
key.length > bestMatch.length) {
patternKeyCompare(bestMatch, key) === 1) {
bestMatch = key;
bestMatchSubpath = StringPrototypeSlice(packageSubpath, key.length);
}
}

if (bestMatch) {
const target = exports[bestMatch];
const pattern = bestMatch[bestMatch.length - 1] === '*';
const subpath = StringPrototypeSubstr(packageSubpath, bestMatch.length -
(pattern ? 1 : 0));
const resolved = resolvePackageTarget(packageJSONUrl, target, subpath,
bestMatch, base, pattern, false,
conditions);
const pattern = StringPrototypeIncludes(bestMatch, '*');
const resolved = resolvePackageTarget(packageJSONUrl, target,
bestMatchSubpath, bestMatch, base,
pattern, false, conditions);
if (resolved === null || resolved === undefined)
throwExportsNotFound(packageSubpath, packageJSONUrl, base);
return { resolved, exact: pattern };
Expand All @@ -545,6 +556,20 @@ function packageExportsResolve(
throwExportsNotFound(packageSubpath, packageJSONUrl, base);
}

function patternKeyCompare(a, b) {
const aPatternIndex = StringPrototypeIndexOf(a, '*');
const bPatternIndex = StringPrototypeIndexOf(b, '*');
const baseLenA = aPatternIndex === -1 ? a.length : aPatternIndex + 1;
const baseLenB = bPatternIndex === -1 ? b.length : bPatternIndex + 1;
if (baseLenA > baseLenB) return -1;
if (baseLenB > baseLenA) return 1;
if (aPatternIndex === -1) return 1;
if (bPatternIndex === -1) return -1;
if (a.length > b.length) return -1;
if (b.length > a.length) return 1;
return 0;
}

function packageImportsResolve(name, base, conditions) {
if (name === '#' || StringPrototypeStartsWith(name, '#/')) {
const reason = 'is not a valid internal imports specifier name';
Expand All @@ -556,38 +581,49 @@ function packageImportsResolve(name, base, conditions) {
packageJSONUrl = pathToFileURL(packageConfig.pjsonPath);
const imports = packageConfig.imports;
if (imports) {
if (ObjectPrototypeHasOwnProperty(imports, name)) {
if (ObjectPrototypeHasOwnProperty(imports, name) &&
!StringPrototypeIncludes(name, '*') &&
!StringPrototypeEndsWith(name, '/')) {
const resolved = resolvePackageTarget(
packageJSONUrl, imports[name], '', name, base, false, true, conditions
);
if (resolved !== null)
return { resolved, exact: true };
} else {
let bestMatch = '';
let bestMatchSubpath;
const keys = ObjectGetOwnPropertyNames(imports);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (key[key.length - 1] === '*' &&
const patternIndex = StringPrototypeIndexOf(key, '*');
if (patternIndex !== -1 &&
StringPrototypeStartsWith(name,
StringPrototypeSlice(key, 0, -1)) &&
name.length >= key.length &&
key.length > bestMatch.length) {
bestMatch = key;
StringPrototypeSlice(key, 0,
patternIndex))) {
const patternTrailer = StringPrototypeSlice(key, patternIndex + 1);
if (name.length >= key.length &&
StringPrototypeEndsWith(name, patternTrailer) &&
patternKeyCompare(bestMatch, key) === 1 &&
StringPrototypeLastIndexOf(key, '*') === patternIndex) {
bestMatch = key;
bestMatchSubpath = StringPrototypeSlice(
name, patternIndex, name.length - patternTrailer.length);
}
} else if (key[key.length - 1] === '/' &&
StringPrototypeStartsWith(name, key) &&
key.length > bestMatch.length) {
patternKeyCompare(bestMatch, key) === 1) {
bestMatch = key;
bestMatchSubpath = StringPrototypeSlice(name, key.length);
}
}

if (bestMatch) {
const target = imports[bestMatch];
const pattern = bestMatch[bestMatch.length - 1] === '*';
const subpath = StringPrototypeSubstr(name, bestMatch.length -
(pattern ? 1 : 0));
const resolved = resolvePackageTarget(
packageJSONUrl, target, subpath, bestMatch, base, pattern, true,
conditions);
const pattern = StringPrototypeIncludes(bestMatch, '*');
const resolved = resolvePackageTarget(packageJSONUrl, target,
bestMatchSubpath, bestMatch,
base, pattern, true,
conditions);
if (resolved !== null)
return { resolved, exact: pattern };
}
Expand Down
9 changes: 9 additions & 0 deletions test/es-module/test-esm-exports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js';
['pkgexports-sugar', { default: 'main' }],
// Path patterns
['pkgexports/subpath/sub-dir1', { default: 'main' }],
['pkgexports/subpath/sub-dir1.js', { default: 'main' }],
['pkgexports/features/dir1', { default: 'main' }],
['pkgexports/dir1/dir1/trailer', { default: 'main' }],
['pkgexports/dir2/dir2/trailer', { default: 'index' }],
['pkgexports/a/dir1/dir1', { default: 'main' }],
['pkgexports/a/b/dir1/dir1', { default: 'main' }],
]);

if (isRequire) {
Expand Down Expand Up @@ -77,6 +82,8 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js';
['pkgexports/null/subpath', './null/subpath'],
// Empty fallback
['pkgexports/nofallback1', './nofallback1'],
// Non pattern matches
['pkgexports/trailer', './trailer'],
]);

const invalidExports = new Map([
Expand Down Expand Up @@ -147,6 +154,8 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js';
['pkgexports/sub/not-a-file.js', `pkgexports${sep}not-a-file.js`],
// No extension lookups
['pkgexports/no-ext', `pkgexports${sep}asdf`],
// Pattern specificity
['pkgexports/dir2/trailer', `subpath${sep}dir2.js`],
]);

if (!isRequire) {
Expand Down
2 changes: 2 additions & 0 deletions test/es-module/test-esm-imports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const { requireImport, importImport } = importer;
['#external', { default: 'asdf' }],
// External subpath imports
['#external/subpath/asdf.js', { default: 'asdf' }],
// Trailing pattern imports
['#subpath/asdf.asdf', { default: 'test' }],
]);

for (const [validSpecifier, expected] of internalImports) {
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/es-modules/pkgimports/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"require": "./requirebranch.js"
},
"#subpath/*": "./sub/*",
"#subpath/*.asdf": "./test.js",
"#external": "pkgexports/valid-cjs",
"#external/subpath/*": "pkgexports/sub/*",
"#external/invalidsubpath/": "pkgexports/sub",
Expand Down
8 changes: 8 additions & 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.