Skip to content

Commit a2eed40

Browse files
committed
module: package imports targets outside package
this allows the imports field of package.json to target a location outside of the package boundary. doing so allows for imports to work in directories just altering things like the type field of package.json and to enable monorepo workspace workflows.
1 parent 78ea6ce commit a2eed40

File tree

6 files changed

+39
-15
lines changed

6 files changed

+39
-15
lines changed

doc/api/packages.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ where `import '#dep'` does not get the resolution of the external package
485485
file `./dep-polyfill.js` relative to the package in other environments.
486486

487487
Unlike the `"exports"` field, the `"imports"` field permits mapping to external
488-
packages.
488+
packages and locations.
489489

490490
The resolution rules for the imports field are otherwise analogous to the
491491
exports field.

lib/internal/modules/esm/resolve.js

+30-11
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ function emitInvalidSegmentDeprecation(target, request, match, pjsonUrl, interna
105105
}
106106
const pjsonPath = fileURLToPath(pjsonUrl);
107107
const double = RegExpPrototypeExec(doubleSlashRegEx, isTarget ? target : request) !== null;
108+
console.trace({
109+
target, request
110+
})
108111
process.emitWarning(
109112
`Use of deprecated ${double ? 'double slash' :
110113
'leading or trailing slash matching'} resolving "${target}" for module ` +
@@ -355,6 +358,7 @@ const patternRegEx = /\*/g;
355358
* @param {boolean} internal - Whether the target is internal to the package.
356359
* @param {boolean} isPathMap - Whether the target is a path map.
357360
* @param {string[]} conditions - The import conditions.
361+
* @param {boolean} mustBeInternalTarget - If target must be in the package boundary.
358362
* @returns {URL} - The resolved URL object.
359363
* @throws {ERR_INVALID_PACKAGE_TARGET} - If the target is invalid.
360364
* @throws {ERR_INVALID_SUBPATH} - If the subpath is invalid.
@@ -369,14 +373,15 @@ function resolvePackageTargetString(
369373
internal,
370374
isPathMap,
371375
conditions,
376+
mustBeInternalTarget = true,
372377
) {
373378

374379
if (subpath !== '' && !pattern && target[target.length - 1] !== '/') {
375380
throw invalidPackageTarget(match, target, packageJSONUrl, internal, base);
376381
}
377382

378-
if (!StringPrototypeStartsWith(target, './')) {
379-
if (internal && !StringPrototypeStartsWith(target, '../') &&
383+
if (!StringPrototypeStartsWith(target, './') && !StringPrototypeStartsWith(target, '../')) {
384+
if (internal &&
380385
!StringPrototypeStartsWith(target, '/')) {
381386
// No need to convert target to string, since it's already presumed to be
382387
if (!URLCanParse(target)) {
@@ -390,8 +395,17 @@ function resolvePackageTargetString(
390395
throw invalidPackageTarget(match, target, packageJSONUrl, internal, base);
391396
}
392397

393-
if (RegExpPrototypeExec(invalidSegmentRegEx, StringPrototypeSlice(target, 2)) !== null) {
394-
if (RegExpPrototypeExec(deprecatedInvalidSegmentRegEx, StringPrototypeSlice(target, 2)) === null) {
398+
// skip ../ and ./ prefixes when looking for invalid segments
399+
const skipPrefix = target.length > 1 && target[0] === '.' ?
400+
target[1] === '/' ?
401+
2 :
402+
target.length > 2 && target[1] === '.' && target[2] === '/' ?
403+
3 :
404+
0
405+
:
406+
0
407+
if (RegExpPrototypeExec(invalidSegmentRegEx, StringPrototypeSlice(target, skipPrefix)) !== null) {
408+
if (RegExpPrototypeExec(deprecatedInvalidSegmentRegEx, StringPrototypeSlice(target, skipPrefix)) === null) {
395409
if (!isPathMap) {
396410
const request = pattern ?
397411
StringPrototypeReplace(match, '*', () => subpath) :
@@ -410,7 +424,8 @@ function resolvePackageTargetString(
410424
const resolvedPath = resolved.pathname;
411425
const packagePath = new URL('.', packageJSONUrl).pathname;
412426

413-
if (!StringPrototypeStartsWith(resolvedPath, packagePath)) {
427+
// if (mustBeInternalTarget && !StringPrototypeStartsWith(resolvedPath, packagePath)) {
428+
if (mustBeInternalTarget && !StringPrototypeStartsWith(resolvedPath, packagePath)) {
414429
throw invalidPackageTarget(match, target, packageJSONUrl, internal, base);
415430
}
416431

@@ -461,14 +476,15 @@ function isArrayIndex(key) {
461476
* @param {boolean} internal - Whether the package is internal.
462477
* @param {boolean} isPathMap - Whether the package is a path map.
463478
* @param {Set<string>} conditions - The conditions to match.
479+
* @param {boolean} mustBeInternalTarget - If the target must be in the package boundary.
464480
* @returns {URL | null | undefined} - The resolved target, or null if not found, or undefined if not resolvable.
465481
*/
466482
function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath,
467-
base, pattern, internal, isPathMap, conditions) {
483+
base, pattern, internal, isPathMap, conditions, mustBeInternalTarget) {
468484
if (typeof target === 'string') {
469485
return resolvePackageTargetString(
470486
target, subpath, packageSubpath, packageJSONUrl, base, pattern, internal,
471-
isPathMap, conditions);
487+
isPathMap, conditions, mustBeInternalTarget);
472488
} else if (ArrayIsArray(target)) {
473489
if (target.length === 0) {
474490
return null;
@@ -481,7 +497,7 @@ function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath,
481497
try {
482498
resolveResult = resolvePackageTarget(
483499
packageJSONUrl, targetItem, subpath, packageSubpath, base, pattern,
484-
internal, isPathMap, conditions);
500+
internal, isPathMap, conditions, mustBeInternalTarget);
485501
} catch (e) {
486502
lastException = e;
487503
if (e.code === 'ERR_INVALID_PACKAGE_TARGET') {
@@ -518,7 +534,7 @@ function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath,
518534
const conditionalTarget = target[key];
519535
const resolveResult = resolvePackageTarget(
520536
packageJSONUrl, conditionalTarget, subpath, packageSubpath, base,
521-
pattern, internal, isPathMap, conditions);
537+
pattern, internal, isPathMap, conditions, mustBeInternalTarget);
522538
if (resolveResult === undefined) { continue; }
523539
return resolveResult;
524540
}
@@ -583,6 +599,7 @@ function packageExportsResolve(
583599
const resolveResult = resolvePackageTarget(
584600
packageJSONUrl, target, '', packageSubpath, base, false, false, false,
585601
conditions,
602+
true,
586603
);
587604

588605
if (resolveResult == null) {
@@ -635,7 +652,7 @@ function packageExportsResolve(
635652
true,
636653
false,
637654
StringPrototypeEndsWith(packageSubpath, '/'),
638-
conditions);
655+
conditions, true);
639656

640657
if (resolveResult == null) {
641658
throw exportsNotFound(packageSubpath, packageJSONUrl, base);
@@ -693,6 +710,7 @@ function packageImportsResolve(name, base, conditions) {
693710
const resolveResult = resolvePackageTarget(
694711
packageJSONUrl, imports[name], '', name, base, false, true, false,
695712
conditions,
713+
false,
696714
);
697715
if (resolveResult != null) {
698716
return resolveResult;
@@ -725,7 +743,8 @@ function packageImportsResolve(name, base, conditions) {
725743
const resolveResult = resolvePackageTarget(packageJSONUrl, target,
726744
bestMatchSubpath,
727745
bestMatch, base, true,
728-
true, false, conditions);
746+
true, false, conditions,
747+
false);
729748
if (resolveResult != null) {
730749
return resolveResult;
731750
}

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

+4-2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ const { requireImport, importImport } = importer;
2626
['#subpath//asdf.asdf', { default: 'test' }],
2727
// Double slash
2828
['#subpath/as//df.asdf', { default: 'test' }],
29+
// Target steps below the package base
30+
['#belowbase', { default: 'belowbase' }],
31+
// Target steps uses pattern below the package base
32+
['#belowbase/nested', { default: 'nested' }],
2933
]);
3034

3135
for (const [validSpecifier, expected] of internalImports) {
@@ -38,8 +42,6 @@ const { requireImport, importImport } = importer;
3842
}
3943

4044
const invalidImportTargets = new Set([
41-
// Target steps below the package base
42-
['#belowbase', '#belowbase'],
4345
// Target is a URL
4446
['#url', '#url'],
4547
]);

test/fixtures/es-modules/belowbase.js

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = 'belowbase'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = 'nested'

test/fixtures/es-modules/pkgimports/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"#external": "pkgexports/valid-cjs",
1212
"#external/subpath/*": "pkgexports/sub/*",
1313
"#external/invalidsubpath/": "pkgexports/sub",
14-
"#belowbase": "../belowbase",
14+
"#belowbase": "../belowbase.js",
15+
"#belowbase/*": "../belowbase/*.js",
1516
"#url": "some:url",
1617
"#null": null,
1718
"#nullcondition": {

0 commit comments

Comments
 (0)