Skip to content

Commit 717f9c5

Browse files
guybedfordMylesBorins
authored andcommitted
module: path-only CJS exports extension searching
Backport-PR-URL: #32883 PR-URL: #32351 Reviewed-By: Geoffrey Booth <webmaster@geoffreybooth.com> Reviewed-By: Bradley Farias <bradley.meck@gmail.com> Reviewed-By: Jan Krems <jan.krems@gmail.com> Reviewed-By: Myles Borins <myles.borins@gmail.com>
1 parent 69aeaba commit 717f9c5

File tree

9 files changed

+125
-129
lines changed

9 files changed

+125
-129
lines changed

doc/api/esm.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1645,7 +1645,7 @@ The resolver can throw the following errors:
16451645
> 1. If _exports_ contains any index property keys, as defined in ECMA-262
16461646
> [6.1.7 Array Index][], throw an _Invalid Package Configuration_ error.
16471647
> 1. For each property _p_ of _target_, in object insertion order as,
1648-
> 1. If _env_ contains an entry for _p_, then
1648+
> 1. If _p_ equals _"default"_ or _env_ contains an entry for _p_, then
16491649
> 1. Let _targetValue_ be the value of the _p_ property in _target_.
16501650
> 1. Return the result of **PACKAGE_EXPORTS_TARGET_RESOLVE**(
16511651
> _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

+56-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,
@@ -122,7 +123,10 @@ function enrichCJSError(err) {
122123
after a comment block and/or after a variable definition.
123124
*/
124125
if (err.message.startsWith('Unexpected token \'export\'') ||
125-
(/^\s*import(?=[ {'"*])\s*(?![ (])/).test(lineWithErr)) {
126+
(RegExpPrototypeTest(/^\s*import(?=[ {'"*])\s*(?![ (])/, lineWithErr))) {
127+
// Emit the warning synchronously because we are in the middle of handling
128+
// a SyntaxError that will throw and likely terminate the process before an
129+
// asynchronous warning would be emitted.
126130
process.emitWarning(
127131
'To load an ES module, set "type": "module" in the package.json or use ' +
128132
'the .mjs extension.',
@@ -349,10 +353,11 @@ const realpathCache = new Map();
349353
// absolute realpath.
350354
function tryFile(requestPath, isMain) {
351355
const rc = stat(requestPath);
356+
if (rc !== 0) return;
352357
if (preserveSymlinks && !isMain) {
353-
return rc === 0 && path.resolve(requestPath);
358+
return path.resolve(requestPath);
354359
}
355-
return rc === 0 && toRealPath(requestPath);
360+
return toRealPath(requestPath);
356361
}
357362

358363
function toRealPath(requestPath) {
@@ -389,52 +394,7 @@ function findLongestRegisteredExtension(filename) {
389394
return '.js';
390395
}
391396

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

452-
const exts = ObjectKeys(Module._extensions);
453412
const fromExports = applyExports(basePath, expansion);
454-
// Use exports
455413
if (fromExports) {
456-
let trailingSlash = request.length > 0 &&
457-
request.charCodeAt(request.length - 1) === CHAR_FORWARD_SLASH;
458-
if (!trailingSlash) {
459-
trailingSlash = /(?:^|\/)\.?\.$/.test(request);
460-
}
461-
return resolveBasePath(fromExports, exts, isMain, trailingSlash, request);
462-
} else {
463-
// Use main field
464-
return tryPackage(basePath, exts, isMain, request);
414+
return tryFile(fromExports, false);
465415
}
416+
assert(fromExports !== false);
466417
}
467418

468419
function isConditionalDotExportSugar(exports, basePath) {
@@ -494,7 +445,7 @@ function applyExports(basePath, expansion) {
494445

495446
let pkgExports = readPackageExports(basePath);
496447
if (pkgExports === undefined || pkgExports === null || !experimentalModules)
497-
return path.resolve(basePath, mappingKey);
448+
return false;
498449

499450
if (isConditionalDotExportSugar(pkgExports, basePath))
500451
pkgExports = { '.': pkgExports };
@@ -518,8 +469,24 @@ function applyExports(basePath, expansion) {
518469
if (dirMatch !== '') {
519470
const mapping = pkgExports[dirMatch];
520471
const subpath = StringPrototypeSlice(mappingKey, dirMatch.length);
521-
return resolveExportsTarget(pathToFileURL(basePath + '/'), mapping,
522-
subpath, mappingKey);
472+
const resolved = resolveExportsTarget(pathToFileURL(basePath + '/'),
473+
mapping, subpath, mappingKey);
474+
// Extension searching for folder exports only
475+
const rc = stat(resolved);
476+
if (rc === 0) return resolved;
477+
if (!(RegExpPrototypeTest(trailingSlashRegex, resolved))) {
478+
const exts = ObjectKeys(Module._extensions);
479+
const filename = tryExtensions(resolved, exts, false);
480+
if (filename) return filename;
481+
}
482+
if (rc === 1) {
483+
const exts = ObjectKeys(Module._extensions);
484+
const filename = tryPackage(resolved, exts, false,
485+
basePath + expansion);
486+
if (filename) return filename;
487+
}
488+
// Undefined means not found
489+
return;
523490
}
524491
}
525492

@@ -530,20 +497,20 @@ function applyExports(basePath, expansion) {
530497
// 1. name/.*
531498
// 2. @scope/name/.*
532499
const EXPORTS_PATTERN = /^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)?$/;
533-
function resolveExports(nmPath, request, absoluteRequest) {
500+
function resolveExports(nmPath, request) {
534501
// The implementation's behavior is meant to mirror resolution in ESM.
535-
if (!absoluteRequest) {
536-
const [, name, expansion = ''] =
537-
StringPrototypeMatch(request, EXPORTS_PATTERN) || [];
538-
if (!name) {
539-
return path.resolve(nmPath, request);
540-
}
541-
542-
const basePath = path.resolve(nmPath, name);
543-
return applyExports(basePath, expansion);
502+
const [, name, expansion = ''] =
503+
StringPrototypeMatch(request, EXPORTS_PATTERN) || [];
504+
if (!name) {
505+
return false;
544506
}
545507

546-
return path.resolve(nmPath, request);
508+
const basePath = path.resolve(nmPath, name);
509+
const fromExports = applyExports(basePath, expansion);
510+
if (fromExports) {
511+
return tryFile(fromExports, false);
512+
}
513+
return fromExports;
547514
}
548515

549516
function isArrayIndex(p) {
@@ -635,6 +602,7 @@ function resolveExportsTarget(baseUrl, target, subpath, mappingKey) {
635602
StringPrototypeSlice(baseUrl.pathname, 0, -1), mappingKey, subpath, target);
636603
}
637604

605+
const trailingSlashRegex = /(?:^|\/)\.?\.$/;
638606
Module._findPath = function(request, paths, isMain) {
639607
const absoluteRequest = path.isAbsolute(request);
640608
if (absoluteRequest) {
@@ -653,15 +621,26 @@ Module._findPath = function(request, paths, isMain) {
653621
let trailingSlash = request.length > 0 &&
654622
request.charCodeAt(request.length - 1) === CHAR_FORWARD_SLASH;
655623
if (!trailingSlash) {
656-
trailingSlash = /(?:^|\/)\.?\.$/.test(request);
624+
trailingSlash = RegExpPrototypeTest(trailingSlashRegex, request);
657625
}
658626

659627
// For each path
660628
for (let i = 0; i < paths.length; i++) {
661629
// Don't search further if path doesn't exist
662630
const curPath = paths[i];
663631
if (curPath && stat(curPath) < 1) continue;
664-
const basePath = resolveExports(curPath, request, absoluteRequest);
632+
633+
if (!absoluteRequest) {
634+
const exportsResolved = resolveExports(curPath, request);
635+
// Undefined means not found, false means no exports
636+
if (exportsResolved === undefined)
637+
break;
638+
if (exportsResolved) {
639+
return exportsResolved;
640+
}
641+
}
642+
643+
const basePath = path.resolve(curPath, request);
665644
let filename;
666645

667646
const rc = stat(basePath);
@@ -953,7 +932,7 @@ Module._resolveFilename = function(request, parent, isMain, options) {
953932
}
954933

955934
if (parent && parent.filename) {
956-
const filename = trySelf(parent.filename, isMain, request);
935+
const filename = trySelf(parent.filename, request);
957936
if (filename) {
958937
emitExperimentalWarning('Package name self resolution');
959938
const cacheKey = request + '\x00' +

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

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

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

@@ -119,14 +127,28 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js';
119127
}));
120128
}
121129

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

131153
// The use of %2F escapes in paths fails loading
132154
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.

0 commit comments

Comments
 (0)