Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for resolving import subpaths #1302

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 17 additions & 209 deletions packages/metro-resolver/src/PackageExportsResolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,23 @@
*/

import type {
ExportMap,
ExportMapWithFallbacks,
ExportsField,
ExportsLikeMap,
FileResolution,
NormalizedExportsLikeMap,
ResolutionContext,
} from './types';

import InvalidPackageConfigurationError from './errors/InvalidPackageConfigurationError';
import PackagePathNotExportedError from './errors/PackagePathNotExportedError';
import resolveAsset from './resolveAsset';
import isAssetFile from './utils/isAssetFile';
import {isSubpathDefinedInExportsLike} from './utils/isSubpathDefinedInExportsLike';
import {matchSubpathFromExportsLike} from './utils/matchSubpathFromExportsLike';
import toPosixPath from './utils/toPosixPath';
import path from 'path';

type NormalizedExporthMap = Map<
string /* subpath */,
null | string | ExportMap,
>;

/**
* Resolve a package subpath based on the entry points defined in the package's
* "exports" field. If there is no match for the given subpath (which may be
Expand Down Expand Up @@ -70,15 +68,15 @@ export function resolvePackageTargetFromExports(
const subpath = getExportsSubpath(packageRelativePath);
const exportMap = normalizeExportsField(exportsField, createConfigError);

if (!isSubpathDefinedInExports(exportMap, subpath)) {
if (!isSubpathDefinedInExportsLike(exportMap, subpath)) {
throw new PackagePathNotExportedError(
`Attempted to import the module "${modulePath}" which is not listed ` +
`in the "exports" of "${packagePath}" under the requested subpath ` +
`"${subpath}".`,
);
}

const {target, patternMatch} = matchSubpathFromExports(
const {target, patternMatch} = matchSubpathFromExportsLike(
context,
subpath,
exportMap,
Expand Down Expand Up @@ -155,7 +153,7 @@ function getExportsSubpath(packageSubpath: string): string {
type ExcludeString<T> = T extends string ? empty : T;
const _normalizedExportsFields: WeakMap<
ExcludeString<ExportsField>,
NormalizedExporthMap,
NormalizedExportsLikeMap,
> = new WeakMap();

/**
Expand All @@ -167,7 +165,7 @@ const _normalizedExportsFields: WeakMap<
function normalizeExportsField(
exportsField: ExportsField,
createConfigError: (reason: string) => Error,
): NormalizedExporthMap {
): NormalizedExportsLikeMap {
let rootValue;

if (typeof exportsField === 'string') {
Expand Down Expand Up @@ -199,18 +197,17 @@ function normalizeExportsField(
}

if (typeof rootValue === 'string') {
const result: NormalizedExporthMap = new Map([['.', rootValue]]);
const result: NormalizedExportsLikeMap = new Map([['.', rootValue]]);
_normalizedExportsFields.set(exportsField, result);
return result;
}

const firstLevelKeys = Object.keys(rootValue);
const subpathKeys = firstLevelKeys.filter(subpathOrCondition =>
subpathOrCondition.startsWith('.'),
);
const subpathKeys = firstLevelKeys.filter(key => key.startsWith('.'));
const importKeys = firstLevelKeys.filter(key => key.startsWith('#'));

if (subpathKeys.length === firstLevelKeys.length) {
const result: NormalizedExporthMap = new Map(
if (importKeys.length + subpathKeys.length === firstLevelKeys.length) {
const result: NormalizedExportsLikeMap = new Map(
Object.entries(flattenLegacySubpathValues(rootValue, createConfigError)),
);
_normalizedExportsFields.set(exportsField, result);
Expand All @@ -224,7 +221,7 @@ function normalizeExportsField(
);
}

const result: NormalizedExporthMap = new Map([
const result: NormalizedExportsLikeMap = new Map([
['.', flattenLegacySubpathValues(rootValue, createConfigError)],
]);
_normalizedExportsFields.set(exportsField, result);
Expand All @@ -235,9 +232,9 @@ function normalizeExportsField(
* Flatten legacy Node.js <13.7 array subpath values in an exports mapping.
*/
function flattenLegacySubpathValues(
exportMap: ExportMap | ExportMapWithFallbacks,
exportMap: ExportsLikeMap | ExportMapWithFallbacks,
createConfigError: (reason: string) => Error,
): ExportMap {
): ExportsLikeMap {
return Object.entries(exportMap).reduce(
(result, [subpath, value]) => {
// We do not support empty or nested arrays (non-standard)
Expand All @@ -253,197 +250,8 @@ function flattenLegacySubpathValues(
}
return result;
},
{} as {[subpathOrCondition: string]: string | ExportMap | null},
);
}

/**
* Identifies whether the given subpath is defined in the given "exports"-like
* mapping. Does not reduce exports conditions (therefore does not identify
* whether the subpath is mapped to a value).
*/
export function isSubpathDefinedInExports(
exportMap: NormalizedExporthMap,
subpath: string,
): boolean {
if (exportMap.has(subpath)) {
return true;
}

// Attempt to match after expanding any subpath pattern keys
for (const key of exportMap.keys()) {
if (
key.split('*').length === 2 &&
matchSubpathPattern(key, subpath) != null
) {
return true;
}
}

return false;
}

/**
* Get the mapped replacement for the given subpath.
*
* Implements modern package resolution behaviour based on the [Package Entry
* Points spec](https://nodejs.org/docs/latest-v19.x/api/packages.html#package-entry-points).
*/
function matchSubpathFromExports(
context: ResolutionContext,
/**
* The package-relative subpath (beginning with '.') to match against either
* an exact subpath key or subpath pattern key in "exports".
*/
subpath: string,
exportMap: NormalizedExporthMap,
platform: string | null,
createConfigError: (reason: string) => Error,
): $ReadOnly<{
target: string | null,
patternMatch: string | null,
}> {
const conditionNames = new Set([
'default',
...context.unstable_conditionNames,
...(platform != null
? context.unstable_conditionsByPlatform[platform] ?? []
: []),
]);

const exportMapAfterConditions = reduceExportMap(
exportMap,
conditionNames,
createConfigError,
{} as {[subpathOrCondition: string]: string | ExportsLikeMap | null},
);

let target = exportMapAfterConditions.get(subpath);
let patternMatch = null;

// Attempt to match after expanding any subpath pattern keys
if (target == null) {
// Gather keys which are subpath patterns in descending order of specificity
const expansionKeys = [...exportMapAfterConditions.keys()]
.filter(key => key.includes('*'))
.sort(key => key.split('*')[0].length)
.reverse();

for (const key of expansionKeys) {
const value = exportMapAfterConditions.get(key);

// Skip invalid values (must include a single '*' or be `null`)
if (typeof value === 'string' && value.split('*').length !== 2) {
break;
}

patternMatch = matchSubpathPattern(key, subpath);

if (patternMatch != null) {
target = value;
break;
}
}
}

return {target: target ?? null, patternMatch};
}

type FlattenedExportMap = $ReadOnlyMap<string /* subpath */, string | null>;

/**
* Reduce an "exports"-like mapping to a flat subpath mapping after resolving
* conditional exports.
*/
function reduceExportMap(
exportMap: NormalizedExporthMap,
conditionNames: $ReadOnlySet<string>,
createConfigError: (reason: string) => Error,
): FlattenedExportMap {
const result = new Map<string, string | null>();

for (const [subpath, value] of exportMap) {
const subpathValue = reduceConditionalExport(value, conditionNames);

// If a subpath has no resolution for the passed `conditionNames`, do not
// include it in the result. (This includes only explicit `null` values,
// which may conditionally hide higher-specificity subpath patterns.)
if (subpathValue !== 'no-match') {
result.set(subpath, subpathValue);
}
}

for (const value of result.values()) {
if (value != null && !value.startsWith('./')) {
throw createConfigError(
'One or more mappings for subpaths defined in "exports" are invalid. ' +
'All values must begin with "./".',
);
}
}

return result;
}

/**
* Reduce an "exports"-like subpath value after asserting the passed
* `conditionNames` in any nested conditions.
*
* Returns `'no-match'` in the case that none of the asserted `conditionNames`
* are matched.
*
* See https://nodejs.org/docs/latest-v19.x/api/packages.html#conditional-exports.
*/
function reduceConditionalExport(
subpathValue: $Values<ExportMap>,
conditionNames: $ReadOnlySet<string>,
): string | null | 'no-match' {
let reducedValue = subpathValue;

while (reducedValue != null && typeof reducedValue !== 'string') {
let match: typeof subpathValue | 'no-match';

// when conditions are present and default is not specified
// the default condition is implicitly set to null, to allow
// for restricting access to unexported internals of a package.
if ('default' in reducedValue) {
match = 'no-match';
} else {
match = null;
}

for (const conditionName in reducedValue) {
if (conditionNames.has(conditionName)) {
match = reducedValue[conditionName];
break;
}
}

reducedValue = match;
}

return reducedValue;
}

/**
* If a subpath pattern expands to the passed subpath, return the subpath match
* (value to substitute for '*'). Otherwise, return `null`.
*
* See https://nodejs.org/docs/latest-v19.x/api/packages.html#subpath-patterns.
*/
function matchSubpathPattern(
subpathPattern: string,
subpath: string,
): string | null {
const [patternBase, patternTrailer] = subpathPattern.split('*');

if (subpath.startsWith(patternBase) && subpath.endsWith(patternTrailer)) {
return subpath.substring(
patternBase.length,
subpath.length - patternTrailer.length,
);
}

return null;
}

function findInvalidPathSegment(subpath: string): ?string {
Expand Down
Loading