Skip to content

Commit cb16229

Browse files
guybedfordtargos
authored andcommitted
module: pkg exports validations and fallbacks
PR-URL: #28949 Reviewed-By: Bradley Farias <[email protected]> Reviewed-By: Jan Krems <[email protected]>
1 parent b2936cf commit cb16229

File tree

10 files changed

+418
-125
lines changed

10 files changed

+418
-125
lines changed

doc/api/esm.md

+55-14
Original file line numberDiff line numberDiff line change
@@ -242,13 +242,13 @@ throw when an attempt is made to import them:
242242

243243
```js
244244
import submodule from 'es-module-package/private-module.js';
245-
// Throws - Package exports error
245+
// Throws - Module not found
246246
```
247247

248248
> Note: this is not a strong encapsulation as any private modules can still be
249249
> loaded by absolute paths.
250250
251-
Folders can also be mapped with package exports as well:
251+
Folders can also be mapped with package exports:
252252

253253
<!-- eslint-skip -->
254254
```js
@@ -268,8 +268,24 @@ import feature from 'es-module-package/features/x.js';
268268
If a package has no exports, setting `"exports": false` can be used instead of
269269
`"exports": {}` to indicate the package does not intend for submodules to be
270270
exposed.
271-
This is just a convention that works because `false`, just like `{}`, has no
272-
iterable own properties.
271+
272+
Any invalid exports entries will be ignored. This includes exports not
273+
starting with `"./"` or a missing trailing `"/"` for directory exports.
274+
275+
Array fallback support is provided for exports, similarly to import maps
276+
in order to be forward-compatible with fallback workflows in future:
277+
278+
<!-- eslint-skip -->
279+
```js
280+
{
281+
"exports": {
282+
"./submodule": ["not:valid", "./submodule.js"]
283+
}
284+
}
285+
```
286+
287+
Since `"not:valid"` is not a supported target, `"./submodule.js"` is used
288+
instead as the fallback, as if it were the only target.
273289

274290
## <code>import</code> Specifiers
275291

@@ -660,7 +676,7 @@ CommonJS loader. Additional formats such as _"addon"_ can be extended in future
660676
updates.
661677
662678
In the following algorithms, all subroutine errors are propagated as errors
663-
of these top-level routines.
679+
of these top-level routines unless stated otherwise.
664680
665681
_isMain_ is **true** when resolving the Node.js application entry point.
666682
@@ -681,6 +697,9 @@ _isMain_ is **true** when resolving the Node.js application entry point.
681697
> 1. Note: _specifier_ is now a bare specifier.
682698
> 1. Set _resolvedURL_ the result of
683699
> **PACKAGE_RESOLVE**(_specifier_, _parentURL_).
700+
> 1. If _resolvedURL_ contains any percent encodings of _"/"_ or _"\\"_ (_"%2f"_
701+
> and _"%5C"_ respectively), then
702+
> 1. Throw an _Invalid Specifier_ error.
684703
> 1. If the file at _resolvedURL_ does not exist, then
685704
> 1. Throw a _Module Not Found_ error.
686705
> 1. Set _resolvedURL_ to the real path of _resolvedURL_.
@@ -737,7 +756,7 @@ _isMain_ is **true** when resolving the Node.js application entry point.
737756
> 1. If _pjson_ is **null**, then
738757
> 1. Throw a _Module Not Found_ error.
739758
> 1. If _pjson.main_ is a String, then
740-
> 1. Let _resolvedMain_ be the concatenation of _packageURL_, "/", and
759+
> 1. Let _resolvedMain_ be the URL resolution of _packageURL_, "/", and
741760
> _pjson.main_.
742761
> 1. If the file at _resolvedMain_ exists, then
743762
> 1. Return _resolvedMain_.
@@ -746,28 +765,49 @@ _isMain_ is **true** when resolving the Node.js application entry point.
746765
> 1. Let _legacyMainURL_ be the result applying the legacy
747766
> **LOAD_AS_DIRECTORY** CommonJS resolver to _packageURL_, throwing a
748767
> _Module Not Found_ error for no resolution.
749-
> 1. If _legacyMainURL_ does not end in _".js"_ then,
750-
> 1. Throw an _Unsupported File Extension_ error.
751768
> 1. Return _legacyMainURL_.
752769
753770
**PACKAGE_EXPORTS_RESOLVE**(_packageURL_, _packagePath_, _exports_)
754771
> 1. If _exports_ is an Object, then
755772
> 1. Set _packagePath_ to _"./"_ concatenated with _packagePath_.
756773
> 1. If _packagePath_ is a key of _exports_, then
757774
> 1. Let _target_ be the value of _exports[packagePath]_.
758-
> 1. If _target_ is not a String, continue the loop.
759-
> 1. Return the URL resolution of the concatenation of _packageURL_ and
760-
> _target_.
775+
> 1. Return **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _target_,
776+
> _""_).
761777
> 1. Let _directoryKeys_ be the list of keys of _exports_ ending in
762778
> _"/"_, sorted by length descending.
763779
> 1. For each key _directory_ in _directoryKeys_, do
764780
> 1. If _packagePath_ starts with _directory_, then
765781
> 1. Let _target_ be the value of _exports[directory]_.
766-
> 1. If _target_ is not a String, continue the loop.
767782
> 1. Let _subpath_ be the substring of _target_ starting at the index
768783
> of the length of _directory_.
769-
> 1. Return the URL resolution of the concatenation of _packageURL_,
770-
> _target_ and _subpath_.
784+
> 1. Return **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _target_,
785+
> _subpath_).
786+
> 1. Throw a _Module Not Found_ error.
787+
788+
**PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _target_, _subpath_)
789+
> 1. If _target_ is a String, then
790+
> 1. If _target_ does not start with _"./"_, throw a _Module Not Found_
791+
> error.
792+
> 1. If _subpath_ has non-zero length and _target_ does not end with _"/"_,
793+
> throw a _Module Not Found_ error.
794+
> 1. If _target_ or _subpath_ contain any _"node_modules"_ segments including
795+
> _"node_modules"_ percent-encoding, throw a _Module Not Found_ error.
796+
> 1. Let _resolvedTarget_ be the URL resolution of the concatenation of
797+
> _packageURL_ and _target_.
798+
> 1. If _resolvedTarget_ is contained in _packageURL_, then
799+
> 1. Let _resolved_ be the URL resolution of the concatenation of
800+
> _subpath_ and _resolvedTarget_.
801+
> 1. If _resolved_ is contained in _resolvedTarget_, then
802+
> 1. Return _resolved_.
803+
> 1. Otherwise, if _target_ is an Array, then
804+
> 1. For each item _targetValue_ in _target_, do
805+
> 1. If _targetValue_ is not a String, continue the loop.
806+
> 1. Let _resolved_ be the result of
807+
> **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _targetValue_,
808+
> _subpath_), continuing the loop on abrupt completion.
809+
> 1. Assert: _resolved_ is a String.
810+
> 1. Return _resolved_.
771811
> 1. Throw a _Module Not Found_ error.
772812
773813
**ESM_FORMAT**(_url_, _isMain_)
@@ -790,6 +830,7 @@ _isMain_ is **true** when resolving the Node.js application entry point.
790830
**READ_PACKAGE_SCOPE**(_url_)
791831
> 1. Let _scopeURL_ be _url_.
792832
> 1. While _scopeURL_ is not the file system root,
833+
> 1. If _scopeURL_ ends in a _"node_modules"_ path segment, return **null**.
793834
> 1. Let _pjson_ be the result of **READ_PACKAGE_JSON**(_scopeURL_).
794835
> 1. If _pjson_ is not **null**, then
795836
> 1. Return _pjson_.

doc/api/modules.md

+10-9
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,12 @@ NODE_MODULES_PATHS(START)
202202
5. return DIRS
203203
```
204204

205-
If `--experimental-exports` is enabled,
206-
node allows packages loaded via `LOAD_NODE_MODULES` to explicitly declare
207-
which filepaths to expose and how they should be interpreted.
208-
This expands on the control packages already had using the `main` field.
209-
With this feature enabled, the `LOAD_NODE_MODULES` changes as follows:
205+
If `--experimental-exports` is enabled, Node.js allows packages loaded via
206+
`LOAD_NODE_MODULES` to explicitly declare which file paths to expose and how
207+
they should be interpreted. This expands on the control packages already had
208+
using the `main` field.
209+
210+
With this feature enabled, the `LOAD_NODE_MODULES` changes are:
210211

211212
```txt
212213
LOAD_NODE_MODULES(X, START)
@@ -224,10 +225,10 @@ RESOLVE_BARE_SPECIFIER(DIR, X)
224225
b. If "exports" is null or undefined, GOTO 3.
225226
c. Find the longest key in "exports" that the subpath starts with.
226227
d. If no such key can be found, throw "not found".
227-
e. If the key matches the subpath entirely, return DIR/name/${exports[key]}.
228-
f. If either the key or exports[key] do not end with a slash (`/`),
229-
throw "not found".
230-
g. Return DIR/name/${exports[key]}${subpath.slice(key.length)}.
228+
e. let RESOLVED_URL =
229+
PACKAGE_EXPORTS_TARGET_RESOLVE(pathToFileURL(DIR/name), exports[key],
230+
subpath.slice(key.length)), as defined in the esm resolver.
231+
f. return fileURLToPath(RESOLVED_URL)
231232
3. return DIR/X
232233
```
233234

lib/internal/modules/cjs/loader.js

+51-15
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
const {
2525
JSON,
2626
Object,
27+
ObjectPrototype,
2728
Reflect,
2829
SafeMap,
2930
StringPrototype,
@@ -372,34 +373,32 @@ function resolveExports(nmPath, request, absoluteRequest) {
372373

373374
const basePath = path.resolve(nmPath, name);
374375
const pkgExports = readExports(basePath);
376+
const mappingKey = `.${expansion}`;
375377

376-
if (pkgExports != null) {
377-
const mappingKey = `.${expansion}`;
378-
const mapping = pkgExports[mappingKey];
379-
if (typeof mapping === 'string') {
380-
return fileURLToPath(new URL(mapping, `${pathToFileURL(basePath)}/`));
378+
if (typeof pkgExports === 'object' && pkgExports !== null) {
379+
if (ObjectPrototype.hasOwnProperty(pkgExports, mappingKey)) {
380+
const mapping = pkgExports[mappingKey];
381+
return resolveExportsTarget(pathToFileURL(basePath + '/'), mapping, '',
382+
basePath, mappingKey);
381383
}
382384

383385
let dirMatch = '';
384-
for (const [candidateKey, candidateValue] of Object.entries(pkgExports)) {
386+
for (const candidateKey of Object.keys(pkgExports)) {
385387
if (candidateKey[candidateKey.length - 1] !== '/') continue;
386-
if (candidateValue[candidateValue.length - 1] !== '/') continue;
387388
if (candidateKey.length > dirMatch.length &&
388389
StringPrototype.startsWith(mappingKey, candidateKey)) {
389390
dirMatch = candidateKey;
390391
}
391392
}
392393

393394
if (dirMatch !== '') {
394-
const dirMapping = pkgExports[dirMatch];
395-
const remainder = StringPrototype.slice(mappingKey, dirMatch.length);
396-
const expectedPrefix =
397-
new URL(dirMapping, `${pathToFileURL(basePath)}/`);
398-
const resolved = new URL(remainder, expectedPrefix).href;
399-
if (StringPrototype.startsWith(resolved, expectedPrefix.href)) {
400-
return fileURLToPath(resolved);
401-
}
395+
const mapping = pkgExports[dirMatch];
396+
const subpath = StringPrototype.slice(mappingKey, dirMatch.length);
397+
return resolveExportsTarget(pathToFileURL(basePath + '/'), mapping,
398+
subpath, basePath, mappingKey);
402399
}
400+
}
401+
if (pkgExports != null) {
403402
// eslint-disable-next-line no-restricted-syntax
404403
const e = new Error(`Package exports for '${basePath}' do not define ` +
405404
`a '${mappingKey}' subpath`);
@@ -411,6 +410,43 @@ function resolveExports(nmPath, request, absoluteRequest) {
411410
return path.resolve(nmPath, request);
412411
}
413412

413+
function resolveExportsTarget(pkgPath, target, subpath, basePath, mappingKey) {
414+
if (typeof target === 'string') {
415+
if (target.startsWith('./') &&
416+
(subpath.length === 0 || target.endsWith('/'))) {
417+
const resolvedTarget = new URL(target, pkgPath);
418+
const pkgPathPath = pkgPath.pathname;
419+
const resolvedTargetPath = resolvedTarget.pathname;
420+
if (StringPrototype.startsWith(resolvedTargetPath, pkgPathPath) &&
421+
StringPrototype.indexOf(resolvedTargetPath, '/node_modules/',
422+
pkgPathPath.length - 1) === -1) {
423+
const resolved = new URL(subpath, resolvedTarget);
424+
const resolvedPath = resolved.pathname;
425+
if (StringPrototype.startsWith(resolvedPath, resolvedTargetPath) &&
426+
StringPrototype.indexOf(resolvedPath, '/node_modules/',
427+
pkgPathPath.length - 1) === -1) {
428+
return fileURLToPath(resolved);
429+
}
430+
}
431+
}
432+
} else if (Array.isArray(target)) {
433+
for (const targetValue of target) {
434+
if (typeof targetValue !== 'string') continue;
435+
try {
436+
return resolveExportsTarget(pkgPath, targetValue, subpath, basePath,
437+
mappingKey);
438+
} catch (e) {
439+
if (e.code !== 'MODULE_NOT_FOUND') throw e;
440+
}
441+
}
442+
}
443+
// eslint-disable-next-line no-restricted-syntax
444+
const e = new Error(`Package exports for '${basePath}' do not define a ` +
445+
`valid '${mappingKey}' target${subpath ? 'for ' + subpath : ''}`);
446+
e.code = 'MODULE_NOT_FOUND';
447+
throw e;
448+
}
449+
414450
Module._findPath = function(request, paths, isMain) {
415451
const absoluteRequest = path.isAbsolute(request);
416452
if (absoluteRequest) {

0 commit comments

Comments
 (0)