Skip to content

Commit b2e7700

Browse files
guybedfordBethGriggs
authored andcommitted
module: path-only CJS exports extension searching
PR-URL: #32351 Reviewed-By: Geoffrey Booth <[email protected]> Reviewed-By: Bradley Farias <[email protected]> Reviewed-By: Jan Krems <[email protected]> Reviewed-By: Myles Borins <[email protected]>
1 parent 75ee5b2 commit b2e7700

File tree

9 files changed

+122
-129
lines changed

9 files changed

+122
-129
lines changed

doc/api/esm.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1635,7 +1635,7 @@ The resolver can throw the following errors:
16351635
> 1. If _exports_ contains any index property keys, as defined in ECMA-262
16361636
> [6.1.7 Array Index][], throw an _Invalid Package Configuration_ error.
16371637
> 1. For each property _p_ of _target_, in object insertion order as,
1638-
> 1. If _env_ contains an entry for _p_, then
1638+
> 1. If _p_ equals _"default"_ or _env_ contains an entry for _p_, then
16391639
> 1. Let _targetValue_ be the value of the _p_ property in _target_.
16401640
> 1. Return the result of **PACKAGE_EXPORTS_TARGET_RESOLVE**(
16411641
> _packageURL_, _targetValue_, _subpath_, _env_), continuing the

doc/api/modules.md

+28-42
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ require(X) from module at path Y
165165
6. THROW "not found"
166166
167167
LOAD_AS_FILE(X)
168-
1. If X is a file, load X as JavaScript text. STOP
168+
1. If X is a file, load X as its file extension format. STOP
169169
2. If X.js is a file, load X.js as JavaScript text. STOP
170170
3. If X.json is a file, parse X.json to a JavaScript Object. STOP
171171
4. If X.node is a file, load X.node as binary addon. STOP
@@ -189,8 +189,9 @@ LOAD_AS_DIRECTORY(X)
189189
LOAD_NODE_MODULES(X, START)
190190
1. let DIRS = NODE_MODULES_PATHS(START)
191191
2. for each DIR in DIRS:
192-
a. LOAD_AS_FILE(DIR/X)
193-
b. LOAD_AS_DIRECTORY(DIR/X)
192+
a. LOAD_PACKAGE_EXPORTS(DIR, X)
193+
b. LOAD_AS_FILE(DIR/X)
194+
c. LOAD_AS_DIRECTORY(DIR/X)
194195
195196
NODE_MODULES_PATHS(START)
196197
1. let PARTS = path split(START)
@@ -208,50 +209,35 @@ LOAD_SELF_REFERENCE(X, START)
208209
2. If no scope was found, return.
209210
3. If the `package.json` has no "exports", return.
210211
4. If the name in `package.json` isn't a prefix of X, throw "not found".
211-
5. Otherwise, resolve the remainder of X relative to this package as if it
212-
was loaded via `LOAD_NODE_MODULES` with a name in `package.json`.
213-
```
214-
215-
Node.js allows packages loaded via
216-
`LOAD_NODE_MODULES` to explicitly declare which file paths to expose and how
217-
they should be interpreted. This expands on the control packages already had
218-
using the `main` field.
219-
220-
With this feature enabled, the `LOAD_NODE_MODULES` changes are:
212+
5. Otherwise, load the remainder of X relative to this package as if it
213+
was loaded via `LOAD_NODE_MODULES` with a name in `package.json`.
221214
222-
```txt
223-
LOAD_NODE_MODULES(X, START)
224-
1. let DIRS = NODE_MODULES_PATHS(START)
225-
2. for each DIR in DIRS:
226-
a. let FILE_PATH = RESOLVE_BARE_SPECIFIER(DIR, X)
227-
b. LOAD_AS_FILE(FILE_PATH)
228-
c. LOAD_AS_DIRECTORY(FILE_PATH)
229-
230-
RESOLVE_BARE_SPECIFIER(DIR, X)
215+
LOAD_PACKAGE_EXPORTS(DIR, X)
231216
1. Try to interpret X as a combination of name and subpath where the name
232217
may have a @scope/ prefix and the subpath begins with a slash (`/`).
233-
2. If X matches this pattern and DIR/name/package.json is a file:
234-
a. Parse DIR/name/package.json, and look for "exports" field.
235-
b. If "exports" is null or undefined, GOTO 3.
236-
c. If "exports" is an object with some keys starting with "." and some keys
237-
not starting with ".", throw "invalid config".
238-
d. If "exports" is a string, or object with no keys starting with ".", treat
239-
it as having that value as its "." object property.
240-
e. If subpath is "." and "exports" does not have a "." entry, GOTO 3.
241-
f. Find the longest key in "exports" that the subpath starts with.
242-
g. If no such key can be found, throw "not found".
243-
h. let RESOLVED_URL =
244-
PACKAGE_EXPORTS_TARGET_RESOLVE(pathToFileURL(DIR/name), exports[key],
245-
subpath.slice(key.length), ["node", "require"]), as defined in the ESM
246-
resolver.
247-
i. return fileURLToPath(RESOLVED_URL)
248-
3. return DIR/X
218+
2. If X does not match this pattern or DIR/name/package.json is not a file,
219+
return.
220+
3. Parse DIR/name/package.json, and look for "exports" field.
221+
4. If "exports" is null or undefined, return.
222+
5. If "exports" is an object with some keys starting with "." and some keys
223+
not starting with ".", throw "invalid config".
224+
6. If "exports" is a string, or object with no keys starting with ".", treat
225+
it as having that value as its "." object property.
226+
7. If subpath is "." and "exports" does not have a "." entry, return.
227+
8. Find the longest key in "exports" that the subpath starts with.
228+
9. If no such key can be found, throw "not found".
229+
10. let RESOLVED =
230+
fileURLToPath(PACKAGE_EXPORTS_TARGET_RESOLVE(pathToFileURL(DIR/name),
231+
exports[key], subpath.slice(key.length), ["node", "require"])), as defined
232+
in the ESM resolver.
233+
11. If key ends with "/":
234+
a. LOAD_AS_FILE(RESOLVED)
235+
b. LOAD_AS_DIRECTORY(RESOLVED)
236+
12. Otherwise
237+
a. If RESOLVED is a file, load it as its file extension format. STOP
238+
13. Throw "not found"
249239
```
250240

251-
`"exports"` is only honored when loading a package "name" as defined above. Any
252-
`"exports"` values within nested directories and packages must be declared by
253-
the `package.json` responsible for the "name".
254-
255241
## Caching
256242

257243
<!--type=misc-->

lib/internal/modules/cjs/loader.js

+53-77
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const {
3737
ObjectPrototypeHasOwnProperty,
3838
ObjectSetPrototypeOf,
3939
ReflectSet,
40+
RegExpPrototypeTest,
4041
SafeMap,
4142
String,
4243
StringPrototypeIndexOf,
@@ -125,7 +126,7 @@ function enrichCJSError(err) {
125126
after a comment block and/or after a variable definition.
126127
*/
127128
if (err.message.startsWith('Unexpected token \'export\'') ||
128-
(/^\s*import(?=[ {'"*])\s*(?![ (])/).test(lineWithErr)) {
129+
(RegExpPrototypeTest(/^\s*import(?=[ {'"*])\s*(?![ (])/, lineWithErr))) {
129130
// Emit the warning synchronously because we are in the middle of handling
130131
// a SyntaxError that will throw and likely terminate the process before an
131132
// asynchronous warning would be emitted.
@@ -352,10 +353,11 @@ const realpathCache = new Map();
352353
// absolute realpath.
353354
function tryFile(requestPath, isMain) {
354355
const rc = stat(requestPath);
356+
if (rc !== 0) return;
355357
if (preserveSymlinks && !isMain) {
356-
return rc === 0 && path.resolve(requestPath);
358+
return path.resolve(requestPath);
357359
}
358-
return rc === 0 && toRealPath(requestPath);
360+
return toRealPath(requestPath);
359361
}
360362

361363
function toRealPath(requestPath) {
@@ -392,52 +394,7 @@ function findLongestRegisteredExtension(filename) {
392394
return '.js';
393395
}
394396

395-
function resolveBasePath(basePath, exts, isMain, trailingSlash, request) {
396-
let filename;
397-
398-
const rc = stat(basePath);
399-
if (!trailingSlash) {
400-
if (rc === 0) { // File.
401-
if (!isMain) {
402-
if (preserveSymlinks) {
403-
filename = path.resolve(basePath);
404-
} else {
405-
filename = toRealPath(basePath);
406-
}
407-
} else if (preserveSymlinksMain) {
408-
// For the main module, we use the preserveSymlinksMain flag instead
409-
// mainly for backward compatibility, as the preserveSymlinks flag
410-
// historically has not applied to the main module. Most likely this
411-
// was intended to keep .bin/ binaries working, as following those
412-
// symlinks is usually required for the imports in the corresponding
413-
// files to resolve; that said, in some use cases following symlinks
414-
// causes bigger problems which is why the preserveSymlinksMain option
415-
// is needed.
416-
filename = path.resolve(basePath);
417-
} else {
418-
filename = toRealPath(basePath);
419-
}
420-
}
421-
422-
if (!filename) {
423-
// Try it with each of the extensions
424-
if (exts === undefined)
425-
exts = ObjectKeys(Module._extensions);
426-
filename = tryExtensions(basePath, exts, isMain);
427-
}
428-
}
429-
430-
if (!filename && rc === 1) { // Directory.
431-
// try it with each of the extensions at "index"
432-
if (exts === undefined)
433-
exts = ObjectKeys(Module._extensions);
434-
filename = tryPackage(basePath, exts, isMain, request);
435-
}
436-
437-
return filename;
438-
}
439-
440-
function trySelf(parentPath, isMain, request) {
397+
function trySelf(parentPath, request) {
441398
const { data: pkg, path: basePath } = readPackageScope(parentPath) || {};
442399
if (!pkg || pkg.exports === undefined) return false;
443400
if (typeof pkg.name !== 'string') return false;
@@ -451,20 +408,11 @@ function trySelf(parentPath, isMain, request) {
451408
return false;
452409
}
453410

454-
const exts = ObjectKeys(Module._extensions);
455411
const fromExports = applyExports(basePath, expansion);
456-
// Use exports
457412
if (fromExports) {
458-
let trailingSlash = request.length > 0 &&
459-
request.charCodeAt(request.length - 1) === CHAR_FORWARD_SLASH;
460-
if (!trailingSlash) {
461-
trailingSlash = /(?:^|\/)\.?\.$/.test(request);
462-
}
463-
return resolveBasePath(fromExports, exts, isMain, trailingSlash, request);
464-
} else {
465-
// Use main field
466-
return tryPackage(basePath, exts, isMain, request);
413+
return tryFile(fromExports, false);
467414
}
415+
assert(fromExports !== false);
468416
}
469417

470418
function isConditionalDotExportSugar(exports, basePath) {
@@ -496,7 +444,7 @@ function applyExports(basePath, expansion) {
496444

497445
let pkgExports = readPackageExports(basePath);
498446
if (pkgExports === undefined || pkgExports === null)
499-
return path.resolve(basePath, mappingKey);
447+
return false;
500448

501449
if (isConditionalDotExportSugar(pkgExports, basePath))
502450
pkgExports = { '.': pkgExports };
@@ -520,8 +468,24 @@ function applyExports(basePath, expansion) {
520468
if (dirMatch !== '') {
521469
const mapping = pkgExports[dirMatch];
522470
const subpath = StringPrototypeSlice(mappingKey, dirMatch.length);
523-
return resolveExportsTarget(pathToFileURL(basePath + '/'), mapping,
524-
subpath, mappingKey);
471+
const resolved = resolveExportsTarget(pathToFileURL(basePath + '/'),
472+
mapping, subpath, mappingKey);
473+
// Extension searching for folder exports only
474+
const rc = stat(resolved);
475+
if (rc === 0) return resolved;
476+
if (!(RegExpPrototypeTest(trailingSlashRegex, resolved))) {
477+
const exts = ObjectKeys(Module._extensions);
478+
const filename = tryExtensions(resolved, exts, false);
479+
if (filename) return filename;
480+
}
481+
if (rc === 1) {
482+
const exts = ObjectKeys(Module._extensions);
483+
const filename = tryPackage(resolved, exts, false,
484+
basePath + expansion);
485+
if (filename) return filename;
486+
}
487+
// Undefined means not found
488+
return;
525489
}
526490
}
527491

@@ -532,20 +496,20 @@ function applyExports(basePath, expansion) {
532496
// 1. name/.*
533497
// 2. @scope/name/.*
534498
const EXPORTS_PATTERN = /^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)?$/;
535-
function resolveExports(nmPath, request, absoluteRequest) {
499+
function resolveExports(nmPath, request) {
536500
// The implementation's behavior is meant to mirror resolution in ESM.
537-
if (!absoluteRequest) {
538-
const [, name, expansion = ''] =
539-
StringPrototypeMatch(request, EXPORTS_PATTERN) || [];
540-
if (!name) {
541-
return path.resolve(nmPath, request);
542-
}
543-
544-
const basePath = path.resolve(nmPath, name);
545-
return applyExports(basePath, expansion);
501+
const [, name, expansion = ''] =
502+
StringPrototypeMatch(request, EXPORTS_PATTERN) || [];
503+
if (!name) {
504+
return false;
546505
}
547506

548-
return path.resolve(nmPath, request);
507+
const basePath = path.resolve(nmPath, name);
508+
const fromExports = applyExports(basePath, expansion);
509+
if (fromExports) {
510+
return tryFile(fromExports, false);
511+
}
512+
return fromExports;
549513
}
550514

551515
function isArrayIndex(p) {
@@ -636,6 +600,7 @@ function resolveExportsTarget(baseUrl, target, subpath, mappingKey) {
636600
StringPrototypeSlice(baseUrl.pathname, 0, -1), mappingKey, subpath, target);
637601
}
638602

603+
const trailingSlashRegex = /(?:^|\/)\.?\.$/;
639604
Module._findPath = function(request, paths, isMain) {
640605
const absoluteRequest = path.isAbsolute(request);
641606
if (absoluteRequest) {
@@ -654,15 +619,26 @@ Module._findPath = function(request, paths, isMain) {
654619
let trailingSlash = request.length > 0 &&
655620
request.charCodeAt(request.length - 1) === CHAR_FORWARD_SLASH;
656621
if (!trailingSlash) {
657-
trailingSlash = /(?:^|\/)\.?\.$/.test(request);
622+
trailingSlash = RegExpPrototypeTest(trailingSlashRegex, request);
658623
}
659624

660625
// For each path
661626
for (let i = 0; i < paths.length; i++) {
662627
// Don't search further if path doesn't exist
663628
const curPath = paths[i];
664629
if (curPath && stat(curPath) < 1) continue;
665-
const basePath = resolveExports(curPath, request, absoluteRequest);
630+
631+
if (!absoluteRequest) {
632+
const exportsResolved = resolveExports(curPath, request);
633+
// Undefined means not found, false means no exports
634+
if (exportsResolved === undefined)
635+
break;
636+
if (exportsResolved) {
637+
return exportsResolved;
638+
}
639+
}
640+
641+
const basePath = path.resolve(curPath, request);
666642
let filename;
667643

668644
const rc = stat(basePath);
@@ -1005,7 +981,7 @@ Module._resolveFilename = function(request, parent, isMain, options) {
1005981
}
1006982

1007983
if (parent && parent.filename) {
1008-
const filename = trySelf(parent.filename, isMain, request);
984+
const filename = trySelf(parent.filename, request);
1009985
if (filename) {
1010986
const cacheKey = request + '\x00' +
1011987
(paths.length === 1 ? paths[0] : paths.join('\x00'));

test/es-module/test-esm-exports.mjs

+30-8
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js';
3535
['pkgexports-sugar', { default: 'main' }],
3636
]);
3737

38+
if (isRequire) {
39+
validSpecifiers.set('pkgexports/subpath/file', { default: 'file' });
40+
validSpecifiers.set('pkgexports/subpath/dir1', { default: 'main' });
41+
validSpecifiers.set('pkgexports/subpath/dir1/', { default: 'main' });
42+
validSpecifiers.set('pkgexports/subpath/dir2', { default: 'index' });
43+
validSpecifiers.set('pkgexports/subpath/dir2/', { default: 'index' });
44+
}
45+
3846
for (const [validSpecifier, expected] of validSpecifiers) {
3947
if (validSpecifier === null) continue;
4048

@@ -118,14 +126,28 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js';
118126
}));
119127
}
120128

121-
// Covering out bases - not a file is still not a file after dir mapping.
122-
loadFixture('pkgexports/sub/not-a-file.js').catch(mustCall((err) => {
123-
strictEqual(err.code, (isRequire ? '' : 'ERR_') + 'MODULE_NOT_FOUND');
124-
// ESM returns a full file path
125-
assertStartsWith(err.message, isRequire ?
126-
'Cannot find module \'pkgexports/sub/not-a-file.js\'' :
127-
'Cannot find module');
128-
}));
129+
const notFoundExports = new Map([
130+
// Non-existing file
131+
['pkgexports/sub/not-a-file.js', 'pkgexports/sub/not-a-file.js'],
132+
// No extension lookups
133+
['pkgexports/no-ext', 'pkgexports/no-ext'],
134+
]);
135+
136+
if (!isRequire) {
137+
notFoundExports.set('pkgexports/subpath/file', 'pkgexports/subpath/file');
138+
notFoundExports.set('pkgexports/subpath/dir1', 'pkgexports/subpath/dir1');
139+
notFoundExports.set('pkgexports/subpath/dir2', 'pkgexports/subpath/dir2');
140+
}
141+
142+
for (const [specifier, request] of notFoundExports) {
143+
loadFixture(specifier).catch(mustCall((err) => {
144+
strictEqual(err.code, (isRequire ? '' : 'ERR_') + 'MODULE_NOT_FOUND');
145+
// ESM returns a full file path
146+
assertStartsWith(err.message, isRequire ?
147+
`Cannot find module '${request}'` :
148+
'Cannot find module');
149+
}));
150+
}
129151

130152
// The use of %2F escapes in paths fails loading
131153
loadFixture('pkgexports/sub/..%2F..%2Fbar.js').catch(mustCall((err) => {

test/fixtures/node_modules/pkgexports/package.json

+3-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/node_modules/pkgexports/subpath/dir1/dir1.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)