Skip to content

Commit bc7f819

Browse files
guybedfordtargos
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 ffdd82b commit bc7f819

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
@@ -34,6 +34,7 @@ const {
3434
ObjectKeys,
3535
ObjectPrototypeHasOwnProperty,
3636
ReflectSet,
37+
RegExpPrototypeTest,
3738
SafeMap,
3839
String,
3940
StringPrototypeIndexOf,
@@ -121,7 +122,7 @@ function enrichCJSError(err) {
121122
after a comment block and/or after a variable definition.
122123
*/
123124
if (err.message.startsWith('Unexpected token \'export\'') ||
124-
(/^\s*import(?=[ {'"*])\s*(?![ (])/).test(lineWithErr)) {
125+
(RegExpPrototypeTest(/^\s*import(?=[ {'"*])\s*(?![ (])/, lineWithErr))) {
125126
process.emitWarning(
126127
'To load an ES module, set "type": "module" in the package.json or use ' +
127128
'the .mjs extension.',
@@ -348,10 +349,11 @@ const realpathCache = new Map();
348349
// absolute realpath.
349350
function tryFile(requestPath, isMain) {
350351
const rc = stat(requestPath);
352+
if (rc !== 0) return;
351353
if (preserveSymlinks && !isMain) {
352-
return rc === 0 && path.resolve(requestPath);
354+
return path.resolve(requestPath);
353355
}
354-
return rc === 0 && toRealPath(requestPath);
356+
return toRealPath(requestPath);
355357
}
356358

357359
function toRealPath(requestPath) {
@@ -388,52 +390,7 @@ function findLongestRegisteredExtension(filename) {
388390
return '.js';
389391
}
390392

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

450-
const exts = ObjectKeys(Module._extensions);
451407
const fromExports = applyExports(basePath, expansion);
452-
// Use exports
453408
if (fromExports) {
454-
let trailingSlash = request.length > 0 &&
455-
request.charCodeAt(request.length - 1) === CHAR_FORWARD_SLASH;
456-
if (!trailingSlash) {
457-
trailingSlash = /(?:^|\/)\.?\.$/.test(request);
458-
}
459-
return resolveBasePath(fromExports, exts, isMain, trailingSlash, request);
460-
} else {
461-
// Use main field
462-
return tryPackage(basePath, exts, isMain, request);
409+
return tryFile(fromExports, false);
463410
}
411+
assert(fromExports !== false);
464412
}
465413

466414
function isConditionalDotExportSugar(exports, basePath) {
@@ -492,7 +440,7 @@ function applyExports(basePath, expansion) {
492440

493441
let pkgExports = readPackageExports(basePath);
494442
if (pkgExports === undefined || pkgExports === null)
495-
return path.resolve(basePath, mappingKey);
443+
return false;
496444

497445
if (isConditionalDotExportSugar(pkgExports, basePath))
498446
pkgExports = { '.': pkgExports };
@@ -516,8 +464,24 @@ function applyExports(basePath, expansion) {
516464
if (dirMatch !== '') {
517465
const mapping = pkgExports[dirMatch];
518466
const subpath = StringPrototypeSlice(mappingKey, dirMatch.length);
519-
return resolveExportsTarget(pathToFileURL(basePath + '/'), mapping,
520-
subpath, mappingKey);
467+
const resolved = resolveExportsTarget(pathToFileURL(basePath + '/'),
468+
mapping, subpath, mappingKey);
469+
// Extension searching for folder exports only
470+
const rc = stat(resolved);
471+
if (rc === 0) return resolved;
472+
if (!(RegExpPrototypeTest(trailingSlashRegex, resolved))) {
473+
const exts = ObjectKeys(Module._extensions);
474+
const filename = tryExtensions(resolved, exts, false);
475+
if (filename) return filename;
476+
}
477+
if (rc === 1) {
478+
const exts = ObjectKeys(Module._extensions);
479+
const filename = tryPackage(resolved, exts, false,
480+
basePath + expansion);
481+
if (filename) return filename;
482+
}
483+
// Undefined means not found
484+
return;
521485
}
522486
}
523487

@@ -528,20 +492,20 @@ function applyExports(basePath, expansion) {
528492
// 1. name/.*
529493
// 2. @scope/name/.*
530494
const EXPORTS_PATTERN = /^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)?$/;
531-
function resolveExports(nmPath, request, absoluteRequest) {
495+
function resolveExports(nmPath, request) {
532496
// The implementation's behavior is meant to mirror resolution in ESM.
533-
if (!absoluteRequest) {
534-
const [, name, expansion = ''] =
535-
StringPrototypeMatch(request, EXPORTS_PATTERN) || [];
536-
if (!name) {
537-
return path.resolve(nmPath, request);
538-
}
539-
540-
const basePath = path.resolve(nmPath, name);
541-
return applyExports(basePath, expansion);
497+
const [, name, expansion = ''] =
498+
StringPrototypeMatch(request, EXPORTS_PATTERN) || [];
499+
if (!name) {
500+
return false;
542501
}
543502

544-
return path.resolve(nmPath, request);
503+
const basePath = path.resolve(nmPath, name);
504+
const fromExports = applyExports(basePath, expansion);
505+
if (fromExports) {
506+
return tryFile(fromExports, false);
507+
}
508+
return fromExports;
545509
}
546510

547511
function isArrayIndex(p) {
@@ -632,6 +596,7 @@ function resolveExportsTarget(baseUrl, target, subpath, mappingKey) {
632596
StringPrototypeSlice(baseUrl.pathname, 0, -1), mappingKey, subpath, target);
633597
}
634598

599+
const trailingSlashRegex = /(?:^|\/)\.?\.$/;
635600
Module._findPath = function(request, paths, isMain) {
636601
const absoluteRequest = path.isAbsolute(request);
637602
if (absoluteRequest) {
@@ -650,15 +615,26 @@ Module._findPath = function(request, paths, isMain) {
650615
let trailingSlash = request.length > 0 &&
651616
request.charCodeAt(request.length - 1) === CHAR_FORWARD_SLASH;
652617
if (!trailingSlash) {
653-
trailingSlash = /(?:^|\/)\.?\.$/.test(request);
618+
trailingSlash = RegExpPrototypeTest(trailingSlashRegex, request);
654619
}
655620

656621
// For each path
657622
for (let i = 0; i < paths.length; i++) {
658623
// Don't search further if path doesn't exist
659624
const curPath = paths[i];
660625
if (curPath && stat(curPath) < 1) continue;
661-
const basePath = resolveExports(curPath, request, absoluteRequest);
626+
627+
if (!absoluteRequest) {
628+
const exportsResolved = resolveExports(curPath, request);
629+
// Undefined means not found, false means no exports
630+
if (exportsResolved === undefined)
631+
break;
632+
if (exportsResolved) {
633+
return exportsResolved;
634+
}
635+
}
636+
637+
const basePath = path.resolve(curPath, request);
662638
let filename;
663639

664640
const rc = stat(basePath);
@@ -950,7 +926,7 @@ Module._resolveFilename = function(request, parent, isMain, options) {
950926
}
951927

952928
if (parent && parent.filename) {
953-
const filename = trySelf(parent.filename, isMain, request);
929+
const filename = trySelf(parent.filename, request);
954930
if (filename) {
955931
const cacheKey = request + '\x00' +
956932
(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)