From 41af927efbbf88bd208c52bb15740bf4f48faf74 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 11 Aug 2020 18:40:05 -0700 Subject: [PATCH] module: exports pattern support Backport-PR-URL: https://github.com/nodejs/node/pull/35757 PR-URL: https://github.com/nodejs/node/pull/34718 Reviewed-By: Jan Krems Reviewed-By: Matteo Collina --- doc/api/esm.md | 49 ++++++++++---- doc/api/packages.md | 43 +++++++++---- lib/internal/modules/esm/resolve.js | 64 +++++++++++++------ test/es-module/test-esm-exports.mjs | 3 + .../es-modules/pkgimports/package.json | 4 +- .../node_modules/pkgexports/package.json | 4 +- 6 files changed, 118 insertions(+), 49 deletions(-) diff --git a/doc/api/esm.md b/doc/api/esm.md index 219c45d06ecafd..4b5679b3c3363d 100644 --- a/doc/api/esm.md +++ b/doc/api/esm.md @@ -1038,7 +1038,8 @@ The resolver can throw the following errors: > 1. Set _mainExport_ to _exports_\[_"."_\]. > 1. If _mainExport_ is not **undefined**, then > 1. Let _resolved_ be the result of **PACKAGE_TARGET_RESOLVE**( -> _packageURL_, _mainExport_, _""_, **false**, _conditions_). +> _packageURL_, _mainExport_, _""_, **false**, **false**, +> _conditions_). > 1. If _resolved_ is not **null** or **undefined**, then > 1. Return _resolved_. > 1. Otherwise, if _exports_ is an Object and all keys of _exports_ start with @@ -1072,29 +1073,43 @@ _isImports_, _conditions_) > 1. If _matchKey_ is a key of _matchObj_, and does not end in _"*"_, then > 1. Let _target_ be the value of _matchObj_\[_matchKey_\]. > 1. Let _resolved_ be the result of **PACKAGE_TARGET_RESOLVE**( -> _packageURL_, _target_, _""_, _isImports_, _conditions_). +> _packageURL_, _target_, _""_, **false**, _isImports_, _conditions_). > 1. Return the object _{ resolved, exact: **true** }_. -> 1. Let _expansionKeys_ be the list of keys of _matchObj_ ending in _"/"_, -> sorted by length descending. +> 1. Let _expansionKeys_ be the list of keys of _matchObj_ ending in _"/"_ +> or _"*"_, sorted by length descending. > 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 _target_ be the value of _matchObj_\[_expansionKey_\]. > 1. Let _subpath_ be the substring of _matchKey_ starting at the > index of the length of _expansionKey_. > 1. Let _resolved_ be the result of **PACKAGE_TARGET_RESOLVE**( -> _packageURL_, _target_, _subpath_, _isImports_, _conditions_). +> _packageURL_, _target_, _subpath_, **false**, _isImports_, +> _conditions_). > 1. Return the object _{ resolved, exact: **false** }_. > 1. Return the object _{ resolved: **null**, exact: **true** }_. -**PACKAGE_TARGET_RESOLVE**(_packageURL_, _target_, _subpath_, _internal_, -_conditions_) +**PACKAGE_TARGET_RESOLVE**(_packageURL_, _target_, _subpath_, _pattern_, +_internal_, _conditions_) > 1. If _target_ is a String, then -> 1. If _subpath_ has non-zero length and _target_ does not end with _"/"_, -> throw an _Invalid Module Specifier_ error. +> 1. If _pattern_ is **false**, _subpath_ has non-zero length and _target_ +> does not end with _"/"_, throw an _Invalid Module Specifier_ error. > 1. If _target_ does not start with _"./"_, then > 1. If _internal_ is **true** and _target_ does not start with _"../"_ or > _"/"_ and is not a valid URL, then +> 1. If _pattern_ is **true**, then +> 1. Return **PACKAGE_RESOLVE**(_target_ with every instance of +> _"*"_ replaced by _subpath_, _packageURL_ + _"/"_)_. > 1. Return **PACKAGE_RESOLVE**(_target_ + _subpath_, > _packageURL_ + _"/"_)_. > 1. Otherwise, throw an _Invalid Package Target_ error. @@ -1106,8 +1121,12 @@ _conditions_) > 1. Assert: _resolvedTarget_ is contained in _packageURL_. > 1. If _subpath_ split on _"/"_ or _"\\"_ contains any _"."_, _".."_ or > _"node_modules"_ segments, throw an _Invalid Module Specifier_ error. -> 1. Return the URL resolution of the concatenation of _subpath_ and -> _resolvedTarget_. +> 1. If _pattern_ is **true**, then +> 1. Return the URL resolution of _resolvedTarget_ with every instance of +> _"*"_ replaced with _subpath_. +> 1. Otherwise, +> 1. Return the URL resolution of the concatenation of _subpath_ and +> _resolvedTarget_. > 1. Otherwise, if _target_ is a non-null Object, then > 1. If _exports_ contains any index property keys, as defined in ECMA-262 > [6.1.7 Array Index][], throw an _Invalid Package Configuration_ error. @@ -1116,7 +1135,8 @@ _conditions_) > then > 1. Let _targetValue_ be the value of the _p_ property in _target_. > 1. Let _resolved_ be the result of **PACKAGE_TARGET_RESOLVE**( -> _packageURL_, _targetValue_, _subpath_, _internal_, _conditions_). +> _packageURL_, _targetValue_, _subpath_, _pattern_, _internal_, +> _conditions_). > 1. If _resolved_ is equal to **undefined**, continue the loop. > 1. Return _resolved_. > 1. Return **undefined**. @@ -1124,8 +1144,9 @@ _conditions_) > 1. If _target.length is zero, return **null**. > 1. For each item _targetValue_ in _target_, do > 1. Let _resolved_ be the result of **PACKAGE_TARGET_RESOLVE**( -> _packageURL_, _targetValue_, _subpath_, _internal_, _conditions_), -> continuing the loop on any _Invalid Package Target_ error. +> _packageURL_, _targetValue_, _subpath_, _pattern_, _internal_, +> _conditions_), continuing the loop on any _Invalid Package Target_ +> error. > 1. If _resolved_ is **undefined**, continue the loop. > 1. Return _resolved_. > 1. Return or throw the last fallback resolution **null** return or error. diff --git a/doc/api/packages.md b/doc/api/packages.md index 84da766d956340..a67343585a55c1 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -181,17 +181,17 @@ Alternatively a project could choose to export entire folders: "exports": { ".": "./lib/index.js", "./lib": "./lib/index.js", - "./lib/": "./lib/", + "./lib/*": "./lib/*.js", "./feature": "./feature/index.js", - "./feature/": "./feature/", + "./feature/*": "./feature/*.js", "./package.json": "./package.json" } } ``` As a last resort, package encapsulation can be disabled entirely by creating an -export for the root of the package `"./": "./"`. This will expose every file in -the package at the cost of disabling the encapsulation and potential tooling +export for the root of the package `"./*": "./*"`. This will expose every file +in the package at the cost of disabling the encapsulation and potential tooling benefits this provides. As the ES Module loader in Node.js enforces the use of [the full specifier path][], exporting the root rather than being explicit about entry is less expressive than either of the prior examples. Not only @@ -254,29 +254,46 @@ import submodule from 'es-module-package/private-module.js'; // Throws ERR_PACKAGE_PATH_NOT_EXPORTED ``` -Entire folders can also be mapped with package exports: +### Subpath export patterns + +> Stability: 1 - Experimental + +Explicitly listing each exports subpath entry is recommended for packages with +a small number of exports. But for packages that have very large numbers of +subpaths this can start to cause package.json bloat and maintenance issues. + +For these use cases, subpath export patterns can be used instead: ```json // ./node_modules/es-module-package/package.json { "exports": { - "./features/": "./src/features/" + "./features/*": "./src/features/*.js" } } ``` -With the preceding, all modules within the `./src/features/` folder -are exposed deeply to `import` and `require`: +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. ```js -import feature from 'es-module-package/features/x.js'; +import featureX from 'es-module-package/features/x'; // Loads ./node_modules/es-module-package/src/features/x.js + +import featureY from 'es-module-package/features/y/y'; +// Loads ./node_modules/es-module-package/src/features/y/y.js ``` -When using folder mappings, ensure that you do want to expose every -module inside the subfolder. Any modules which are not public -should be moved to another folder to retain the encapsulation -benefits of exports. +This is a direct static replacement without any special handling for file +extensions. In the previous example, `pkg/features/x.json` would be resolved to +`./src/features/x.json.js` in the mapping. + +The property of exports being statically enumerable is maintained with exports +patterns since the individual exports for a package can be determined by +treating the right hand side target pattern as a `**` glob against the list of +files within the package. Because `node_modules` paths are forbidden in exports +targets, this expansion is dependent on only the files of the package itself. ### Package exports fallbacks diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js index dd24019351a72e..92760d201beb4c 100644 --- a/lib/internal/modules/esm/resolve.js +++ b/lib/internal/modules/esm/resolve.js @@ -307,10 +307,11 @@ function throwInvalidPackageTarget( } const invalidSegmentRegEx = /(^|\\|\/)(\.\.?|node_modules)(\\|\/|$)/; +const patternRegEx = /\*/g; function resolvePackageTargetString( - target, subpath, match, packageJSONUrl, base, internal, conditions) { - if (subpath !== '' && target[target.length - 1] !== '/') + target, subpath, match, packageJSONUrl, base, pattern, internal, conditions) { + if (subpath !== '' && !pattern && target[target.length - 1] !== '/') throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); if (!StringPrototypeStartsWith(target, './')) { @@ -321,8 +322,12 @@ function resolvePackageTargetString( new URL(target); isURL = true; } catch {} - if (!isURL) - return packageResolve(target + subpath, packageJSONUrl, conditions); + if (!isURL) { + const exportTarget = pattern ? + StringPrototypeReplace(target, patternRegEx, subpath) : + target + subpath; + return packageResolve(exportTarget, packageJSONUrl, conditions); + } } throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); } @@ -342,6 +347,9 @@ function resolvePackageTargetString( if (RegExpPrototypeTest(invalidSegmentRegEx, subpath)) throwInvalidSubpath(match + subpath, packageJSONUrl, internal, base); + if (pattern) + return new URL(StringPrototypeReplace(resolved.href, patternRegEx, + subpath)); return new URL(subpath, resolved); } @@ -356,10 +364,10 @@ function isArrayIndex(key) { } function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath, - base, internal, conditions) { + base, pattern, internal, conditions) { if (typeof target === 'string') { return resolvePackageTargetString( - target, subpath, packageSubpath, packageJSONUrl, base, internal, + target, subpath, packageSubpath, packageJSONUrl, base, pattern, internal, conditions); } else if (ArrayIsArray(target)) { if (target.length === 0) @@ -371,8 +379,8 @@ function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath, let resolved; try { resolved = resolvePackageTarget( - packageJSONUrl, targetItem, subpath, packageSubpath, base, internal, - conditions); + packageJSONUrl, targetItem, subpath, packageSubpath, base, pattern, + internal, conditions); } catch (e) { lastException = e; if (e.code === 'ERR_INVALID_PACKAGE_TARGET') @@ -406,7 +414,7 @@ function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath, const conditionalTarget = target[key]; const resolved = resolvePackageTarget( packageJSONUrl, conditionalTarget, subpath, packageSubpath, base, - internal, conditions); + pattern, internal, conditions); if (resolved === undefined) continue; return resolved; @@ -460,7 +468,7 @@ function packageExportsResolve( if (ObjectPrototypeHasOwnProperty(exports, packageSubpath)) { const target = exports[packageSubpath]; const resolved = resolvePackageTarget( - packageJSONUrl, target, '', packageSubpath, base, false, conditions + packageJSONUrl, target, '', packageSubpath, base, false, false, conditions ); if (resolved === null || resolved === undefined) throwExportsNotFound(packageSubpath, packageJSONUrl, base); @@ -471,7 +479,13 @@ function packageExportsResolve( const keys = ObjectGetOwnPropertyNames(exports); for (let i = 0; i < keys.length; i++) { const key = keys[i]; - if (key[key.length - 1] === '/' && + if (key[key.length - 1] === '*' && + StringPrototypeStartsWith(packageSubpath, + StringPrototypeSlice(key, 0, -1)) && + packageSubpath.length >= key.length && + key.length > bestMatch.length) { + bestMatch = key; + } else if (key[key.length - 1] === '/' && StringPrototypeStartsWith(packageSubpath, key) && key.length > bestMatch.length) { bestMatch = key; @@ -480,12 +494,15 @@ function packageExportsResolve( if (bestMatch) { const target = exports[bestMatch]; - const subpath = StringPrototypeSubstr(packageSubpath, bestMatch.length); + const pattern = bestMatch[bestMatch.length - 1] === '*'; + const subpath = StringPrototypeSubstr(packageSubpath, bestMatch.length - + (pattern ? 1 : 0)); const resolved = resolvePackageTarget(packageJSONUrl, target, subpath, - bestMatch, base, false, conditions); + bestMatch, base, pattern, false, + conditions); if (resolved === null || resolved === undefined) throwExportsNotFound(packageSubpath, packageJSONUrl, base); - return { resolved, exact: false }; + return { resolved, exact: pattern }; } throwExportsNotFound(packageSubpath, packageJSONUrl, base); @@ -504,7 +521,7 @@ function packageImportsResolve(name, base, conditions) { if (imports) { if (ObjectPrototypeHasOwnProperty(imports, name)) { const resolved = resolvePackageTarget( - packageJSONUrl, imports[name], '', name, base, true, conditions + packageJSONUrl, imports[name], '', name, base, false, true, conditions ); if (resolved !== null) return { resolved, exact: true }; @@ -513,7 +530,13 @@ function packageImportsResolve(name, base, conditions) { const keys = ObjectGetOwnPropertyNames(imports); for (let i = 0; i < keys.length; i++) { const key = keys[i]; - if (key[key.length - 1] === '/' && + if (key[key.length - 1] === '*' && + StringPrototypeStartsWith(name, + StringPrototypeSlice(key, 0, -1)) && + name.length >= key.length && + key.length > bestMatch.length) { + bestMatch = key; + } else if (key[key.length - 1] === '/' && StringPrototypeStartsWith(name, key) && key.length > bestMatch.length) { bestMatch = key; @@ -522,11 +545,14 @@ function packageImportsResolve(name, base, conditions) { if (bestMatch) { const target = imports[bestMatch]; - const subpath = StringPrototypeSubstr(name, bestMatch.length); + const pattern = bestMatch[bestMatch.length - 1] === '*'; + const subpath = StringPrototypeSubstr(name, bestMatch.length - + (pattern ? 1 : 0)); const resolved = resolvePackageTarget( - packageJSONUrl, target, subpath, bestMatch, base, true, conditions); + packageJSONUrl, target, subpath, bestMatch, base, pattern, true, + conditions); if (resolved !== null) - return { resolved, exact: false }; + return { resolved, exact: pattern }; } } } diff --git a/test/es-module/test-esm-exports.mjs b/test/es-module/test-esm-exports.mjs index a4cced41f897b0..d234099732e3aa 100644 --- a/test/es-module/test-esm-exports.mjs +++ b/test/es-module/test-esm-exports.mjs @@ -33,6 +33,9 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js'; { default: 'self-cjs' } : { default: 'self-mjs' }], // Resolve self sugar ['pkgexports-sugar', { default: 'main' }], + // Path patterns + ['pkgexports/subpath/sub-dir1', { default: 'main' }], + ['pkgexports/features/dir1', { default: 'main' }] ]); if (isRequire) { diff --git a/test/fixtures/es-modules/pkgimports/package.json b/test/fixtures/es-modules/pkgimports/package.json index 7cd179631fa618..a2224b39ddd2ac 100644 --- a/test/fixtures/es-modules/pkgimports/package.json +++ b/test/fixtures/es-modules/pkgimports/package.json @@ -5,9 +5,9 @@ "import": "./importbranch.js", "require": "./requirebranch.js" }, - "#subpath/": "./sub/", + "#subpath/*": "./sub/*", "#external": "pkgexports/valid-cjs", - "#external/subpath/": "pkgexports/sub/", + "#external/subpath/*": "pkgexports/sub/*", "#external/invalidsubpath/": "pkgexports/sub", "#belowbase": "../belowbase", "#url": "some:url", diff --git a/test/fixtures/node_modules/pkgexports/package.json b/test/fixtures/node_modules/pkgexports/package.json index 71406a407c453d..240122d4aaec95 100644 --- a/test/fixtures/node_modules/pkgexports/package.json +++ b/test/fixtures/node_modules/pkgexports/package.json @@ -47,6 +47,8 @@ "require": "./resolve-self-invalid.js", "import": "./resolve-self-invalid.mjs" }, - "./subpath/": "./subpath/" + "./subpath/": "./subpath/", + "./subpath/sub-*": "./subpath/dir1/*.js", + "./features/*": "./subpath/*/*.js" } }