diff --git a/packages/metro-resolver/src/PackageExportsResolve.js b/packages/metro-resolver/src/PackageExportsResolve.js index 10cd13991c..a4e1bccd50 100644 --- a/packages/metro-resolver/src/PackageExportsResolve.js +++ b/packages/metro-resolver/src/PackageExportsResolve.js @@ -10,10 +10,11 @@ */ import type { - ExportMap, ExportMapWithFallbacks, ExportsField, + ExportsLikeMap, FileResolution, + NormalizedExportsLikeMap, ResolutionContext, } from './types'; @@ -21,14 +22,11 @@ import InvalidPackageConfigurationError from './errors/InvalidPackageConfigurati 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 @@ -70,7 +68,7 @@ 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 ` + @@ -78,7 +76,7 @@ export function resolvePackageTargetFromExports( ); } - const {target, patternMatch} = matchSubpathFromExports( + const {target, patternMatch} = matchSubpathFromExportsLike( context, subpath, exportMap, @@ -155,7 +153,7 @@ function getExportsSubpath(packageSubpath: string): string { type ExcludeString = T extends string ? empty : T; const _normalizedExportsFields: WeakMap< ExcludeString, - NormalizedExporthMap, + NormalizedExportsLikeMap, > = new WeakMap(); /** @@ -167,7 +165,7 @@ const _normalizedExportsFields: WeakMap< function normalizeExportsField( exportsField: ExportsField, createConfigError: (reason: string) => Error, -): NormalizedExporthMap { +): NormalizedExportsLikeMap { let rootValue; if (typeof exportsField === 'string') { @@ -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); @@ -224,7 +221,7 @@ function normalizeExportsField( ); } - const result: NormalizedExporthMap = new Map([ + const result: NormalizedExportsLikeMap = new Map([ ['.', flattenLegacySubpathValues(rootValue, createConfigError)], ]); _normalizedExportsFields.set(exportsField, result); @@ -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) @@ -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; - -/** - * Reduce an "exports"-like mapping to a flat subpath mapping after resolving - * conditional exports. - */ -function reduceExportMap( - exportMap: NormalizedExporthMap, - conditionNames: $ReadOnlySet, - createConfigError: (reason: string) => Error, -): FlattenedExportMap { - const result = new Map(); - - 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, - conditionNames: $ReadOnlySet, -): 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 { diff --git a/packages/metro-resolver/src/PackageImportsResolve.js b/packages/metro-resolver/src/PackageImportsResolve.js new file mode 100644 index 0000000000..9ef053b208 --- /dev/null +++ b/packages/metro-resolver/src/PackageImportsResolve.js @@ -0,0 +1,110 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {ExportsLikeMap, FileResolution, ResolutionContext} from './types'; + +import InvalidPackageConfigurationError from './errors/InvalidPackageConfigurationError'; +import PackageImportNotResolvedError from './errors/PackageImportNotResolvedError'; +import resolveAsset from './resolveAsset'; +import isAssetFile from './utils/isAssetFile'; +import {isSubpathDefinedInExportsLike} from './utils/isSubpathDefinedInExportsLike'; +import {matchSubpathFromExportsLike} from './utils/matchSubpathFromExportsLike'; +import path from 'path'; + +/** + * Resolve a package subpath based on the entry points defined in the package's + * "imports" field. If there is no match for the given subpath (which may be + * augmented by resolution of conditional exports for the passed `context`), + * throws a `PackagePathNotExportedError`. + * + * Implementation of PACKAGE_IMPORTS_RESOLVE described in https://nodejs.org/api/esm.html + * + * @throws {InvalidPackageConfigurationError} Raised if configuration specified + * by `importsMap` is invalid. + */ +export function resolvePackageTargetFromImports( + context: ResolutionContext, + /** + * The absolute path to the package.json + */ + packagePath: string, + importPath: string, + importsMap: ExportsLikeMap, + platform: string | null, +): FileResolution { + const createConfigError = (reason: string) => { + return new InvalidPackageConfigurationError({ + reason, + packagePath, + }); + }; + + const firstLevelKeys = Object.keys(importsMap); + const keysWithoutPrefix = firstLevelKeys.filter(key => !key.startsWith('#')); + if (firstLevelKeys.length === 0) { + throw createConfigError('The "imports" field cannot be empty'); + } else if (keysWithoutPrefix.length !== 0) { + throw createConfigError( + 'The "imports" field cannot have keys which do not start with #', + ); + } + + const normalizedMap = new Map(Object.entries(importsMap)); + if (!isSubpathDefinedInExportsLike(normalizedMap, importPath)) { + throw new PackageImportNotResolvedError({ + importSpecifier: importPath, + reason: `"${importPath}" could not be matched using "imports" of ${packagePath}`, + }); + } + + const {target, patternMatch} = matchSubpathFromExportsLike( + context, + importPath, + normalizedMap, + platform, + createConfigError, + ); + + if (target == null) { + throw new PackageImportNotResolvedError({ + importSpecifier: importPath, + reason: + `"${importPath}" which matches a subpath "imports" in ${packagePath}` + + `however no match was resolved for this request (platform = ${platform ?? 'null'}).`, + }); + } + + const filePath = path.join( + packagePath, + patternMatch != null ? target.replace('*', patternMatch) : target, + ); + + if (isAssetFile(filePath, context.assetExts)) { + const assetResult = resolveAsset(context, filePath); + + if (assetResult != null) { + return assetResult; + } + } + + const lookupResult = context.fileSystemLookup(filePath); + if (lookupResult.exists && lookupResult.type === 'f') { + return { + type: 'sourceFile', + filePath: lookupResult.realPath, + }; + } + + throw createConfigError( + `The resolved path for "${importPath}" defined in "imports" is ${filePath}, ` + + 'however this file does not exist.', + ); +} diff --git a/packages/metro-resolver/src/__tests__/package-imports-test.js b/packages/metro-resolver/src/__tests__/package-imports-test.js index 457066f4e0..453fa202b9 100644 --- a/packages/metro-resolver/src/__tests__/package-imports-test.js +++ b/packages/metro-resolver/src/__tests__/package-imports-test.js @@ -9,57 +9,124 @@ * @oncall react_native */ +import Resolver from '../index'; import {createResolutionContext} from './utils'; +import {createPackageAccessors} from './utils'; // Implementation of PACKAGE_IMPORTS_RESOLVE described in https://nodejs.org/api/esm.html describe('subpath imports resolution support', () => { - let Resolver; - const mockRedirectModulePath = jest.fn(); + const baseContext = { + ...createResolutionContext({ + '/root/src/main.js': '', + '/root/node_modules/test-pkg/package.json': '', + '/root/node_modules/test-pkg/index.js': '', + '/root/node_modules/test-pkg/index-main.js': '', + '/root/node_modules/test-pkg/symlink.js': { + realPath: '/root/node_modules/test-pkg/symlink-target.js', + }, + }), + originModulePath: '/root/src/main.js', + }; - beforeEach(() => { - jest.resetModules(); - jest.mock('../PackageResolve', () => ({ - ...jest.requireActual('../PackageResolve'), - redirectModulePath: mockRedirectModulePath, - })); - Resolver = require('../index'); + test('"imports" subpath that maps directly to a file', () => { + const context = { + ...baseContext, + ...createPackageAccessors({ + '/root/node_modules/test-pkg/package.json': { + main: 'index-main.js', + imports: { + '#foo': './index.js', + }, + }, + }), + originModulePath: '/root/node_modules/test-pkg/lib/foo.js', + }; + + expect(Resolver.resolve(context, '#foo', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/test-pkg/index.js', + }); }); +}); - test('specifiers beginning # are reserved for future package imports support', () => { - const mockNeverCalledFn = jest.fn(); - const mockCustomResolver = jest - .fn() - .mockImplementation((ctx, ...args) => ctx.resolveRequest(ctx, ...args)); +describe('import subpath patterns resolution support', () => { + const baseContext = { + ...createResolutionContext({ + '/root/src/main.js': '', + '/root/node_modules/test-pkg/package.json': JSON.stringify({ + name: 'test-pkg', + main: 'index.js', + imports: { + '#features/*': './src/features/*.js', + }, + }), + '/root/node_modules/test-pkg/src/index.js': '', + '/root/node_modules/test-pkg/src/features/foo.js': '', + '/root/node_modules/test-pkg/src/features/foo.js.js': '', + '/root/node_modules/test-pkg/src/features/bar/Bar.js': '', + '/root/node_modules/test-pkg/src/features/baz.native.js': '', + }), + originModulePath: '/root/node_modules/test-pkg/src/index.js', + }; - const context = { - ...createResolutionContext({}), - originModulePath: '/root/src/main.js', - doesFileExist: mockNeverCalledFn, - fileSystemLookup: mockNeverCalledFn, - redirectModulePath: mockNeverCalledFn, - resolveHasteModule: mockNeverCalledFn, - resolveHastePackage: mockNeverCalledFn, - resolveRequest: mockCustomResolver, - }; + test('resolving subpath patterns in "imports" matching import specifier', () => { + expect(Resolver.resolve(baseContext, '#features/foo', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/test-pkg/src/features/foo.js', + }); - expect(() => Resolver.resolve(context, '#foo', null)).toThrow( - new Resolver.FailedToResolveUnsupportedError( - 'Specifier starts with "#" but subpath imports are not currently supported.', - ), - ); + expect(Resolver.resolve(baseContext, '#features/foo.js', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/test-pkg/src/features/foo.js.js', + }); + }); +}); - // Ensure any custom resolver *is* still called first. - expect(mockCustomResolver).toBeCalledTimes(1); - expect(mockCustomResolver).toBeCalledWith( - expect.objectContaining({ - originModulePath: '/root/src/main.js', +describe('import subpath conditional imports resolution', () => { + const baseContext = { + ...createResolutionContext({ + '/root/src/main.js': '', + '/root/node_modules/test-pkg/package.json': JSON.stringify({ + name: 'test-pkg', + main: 'index.js', + imports: { + '#foo': { + import: './lib/foo-module.mjs', + development: './lib/foo-dev.js', + 'react-native': { + import: './lib/foo-react-native.mjs', + require: './lib/foo-react-native.cjs', + default: './lib/foo-react-native.js', + }, + browser: './lib/foo-browser.js', + require: './lib/foo-require.cjs', + default: './lib/foo.js', + }, + }, }), - '#foo', - null, - ); + '/root/node_modules/test-pkg/index.js': '', + '/root/node_modules/test-pkg/lib/foo.js': '', + '/root/node_modules/test-pkg/lib/foo-require.cjs': '', + '/root/node_modules/test-pkg/lib/foo-module.mjs': '', + '/root/node_modules/test-pkg/lib/foo-dev.js': '', + '/root/node_modules/test-pkg/lib/foo-browser.js': '', + '/root/node_modules/test-pkg/lib/foo-react-native.cjs': '', + '/root/node_modules/test-pkg/lib/foo-react-native.mjs': '', + '/root/node_modules/test-pkg/lib/foo-react-native.js': '', + '/root/node_modules/test-pkg/lib/foo.web.js': '', + }), + originModulePath: '/root/node_modules/test-pkg/src/index.js', + }; + + test('resolving imports subpath with conditions', () => { + const context = { + ...baseContext, + unstable_conditionNames: ['require', 'react-native'], + }; - // Ensure package imports precedes any other attempt at resolution for a '#' specifier. - expect(mockNeverCalledFn).not.toHaveBeenCalled(); - expect(mockRedirectModulePath).not.toHaveBeenCalled(); + expect(Resolver.resolve(context, '#foo', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/test-pkg/lib/foo-react-native.cjs', + }); }); }); diff --git a/packages/metro-resolver/src/errors/PackageImportNotResolvedError.js b/packages/metro-resolver/src/errors/PackageImportNotResolvedError.js new file mode 100644 index 0000000000..b112ab8cc9 --- /dev/null +++ b/packages/metro-resolver/src/errors/PackageImportNotResolvedError.js @@ -0,0 +1,41 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + * @oncall react_native + */ + +/** + * Raised when package imports do not define or permit a target subpath in the + * package for the given import specifier. + */ +export default class PackageImportNotResolvedError extends Error { + /** + * Either the import specifier read, or the absolute path of the module being + * resolved (used when import specifier is externally remapped). + */ + +importSpecifier: string; + + /** + * The description of the error cause. + */ + +reason: string; + + constructor( + opts: $ReadOnly<{ + importSpecifier: string, + reason: string, + }>, + ) { + super( + `The path for ${opts.importSpecifier} could not be resolved.\nReason: ` + + opts.reason, + ); + this.importSpecifier = opts.importSpecifier; + this.reason = opts.reason; + } +} diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index ab75aeffbe..abf131332e 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -21,12 +21,13 @@ import type { import FailedToResolveNameError from './errors/FailedToResolveNameError'; import FailedToResolvePathError from './errors/FailedToResolvePathError'; -import FailedToResolveUnsupportedError from './errors/FailedToResolveUnsupportedError'; import formatFileCandidates from './errors/formatFileCandidates'; import InvalidPackageConfigurationError from './errors/InvalidPackageConfigurationError'; import InvalidPackageError from './errors/InvalidPackageError'; +import PackageImportNotResolvedError from './errors/PackageImportNotResolvedError'; import PackagePathNotExportedError from './errors/PackagePathNotExportedError'; import {resolvePackageTargetFromExports} from './PackageExportsResolve'; +import {resolvePackageTargetFromImports} from './PackageImportsResolve'; import {getPackageEntryPoint, redirectModulePath} from './PackageResolve'; import resolveAsset from './resolveAsset'; import isAssetFile from './utils/isAssetFile'; @@ -65,12 +66,49 @@ function resolve( throw new FailedToResolvePathError(result.candidates); } return result.resolution; - } + } else if (isSubpathImport(moduleName)) { + const pkg = context.getPackageForModule(context.originModulePath); + const importsField = pkg?.packageJson.imports; + + if (pkg == null) { + throw new PackageImportNotResolvedError({ + importSpecifier: moduleName, + reason: `Could not find a package.json file relative to module ${context.originModulePath}`, + }); + } else if (importsField == null) { + throw new PackageImportNotResolvedError({ + importSpecifier: moduleName, + reason: `Missing field "imports" in package.json. Check package.json at: ${pkg.rootPath}`, + }); + } else { + try { + const packageImportsResult = resolvePackageTargetFromImports( + context, + pkg.rootPath, + moduleName, + importsField, + platform, + ); - if (moduleName.startsWith('#')) { - throw new FailedToResolveUnsupportedError( - 'Specifier starts with "#" but subpath imports are not currently supported.', - ); + if (packageImportsResult != null) { + return packageImportsResult; + } + } catch (e) { + if (e instanceof PackageImportNotResolvedError) { + context.unstable_logWarning( + e.message + + ' Falling back to file-based resolution. Consider updating the ' + + 'call site or checking there is a matching subpath inside "imports" of package.json.', + ); + } else if (e instanceof InvalidPackageConfigurationError) { + context.unstable_logWarning( + e.message + ' Falling back to file-based resolution.', + ); + } else { + throw e; + } + } + } } const realModuleName = redirectModulePath(context, moduleName); @@ -612,6 +650,10 @@ function isRelativeImport(filePath: string) { return /^[.][.]?(?:[/]|$)/.test(filePath); } +function isSubpathImport(filePath: string) { + return filePath.startsWith('#'); +} + function resolvedAs( resolution: TResolution, ): Result { diff --git a/packages/metro-resolver/src/types.js b/packages/metro-resolver/src/types.js index e092e255f2..9efd2ce5ac 100644 --- a/packages/metro-resolver/src/types.js +++ b/packages/metro-resolver/src/types.js @@ -52,18 +52,18 @@ export type FileCandidates = +candidateExts: $ReadOnlyArray, }; -export type ExportMap = $ReadOnly<{ - [subpathOrCondition: string]: string | ExportMap | null, +export type ExportsLikeMap = $ReadOnly<{ + [subpathOrCondition: string]: string | ExportsLikeMap | null, }>; /** "exports" mapping where values may be legacy Node.js <13.7 array format. */ export type ExportMapWithFallbacks = $ReadOnly<{ - [subpath: string]: $Values | ExportValueWithFallback, + [subpath: string]: $Values | ExportValueWithFallback, }>; /** "exports" subpath value when in legacy Node.js <13.7 array format. */ export type ExportValueWithFallback = - | $ReadOnlyArray + | $ReadOnlyArray // JSON can also contain exotic nested array structure, which will not be parsed | $ReadOnlyArray<$ReadOnlyArray>; @@ -71,13 +71,24 @@ export type ExportsField = | string | $ReadOnlyArray | ExportValueWithFallback - | ExportMap + | ExportsLikeMap | ExportMapWithFallbacks; +export type FlattenedExportMap = $ReadOnlyMap< + string /* subpath */, + string | null, +>; + +export type NormalizedExportsLikeMap = Map< + string /* subpath */, + null | string | ExportsLikeMap, +>; + export type PackageJson = $ReadOnly<{ name?: string, main?: string, exports?: ExportsField, + imports?: ExportsLikeMap, ... }>; diff --git a/packages/metro-resolver/src/utils/isSubpathDefinedInExportsLike.js b/packages/metro-resolver/src/utils/isSubpathDefinedInExportsLike.js new file mode 100644 index 0000000000..a0f7086ecf --- /dev/null +++ b/packages/metro-resolver/src/utils/isSubpathDefinedInExportsLike.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +/** + * 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). + */ +import type {NormalizedExportsLikeMap} from '../types'; + +import {matchSubpathPattern} from './matchSubpathPattern'; + +export function isSubpathDefinedInExportsLike( + exportsLikeMap: NormalizedExportsLikeMap, + subpath: string, +): boolean { + if (exportsLikeMap.has(subpath)) { + return true; + } + + // Attempt to match after expanding any subpath pattern keys + for (const key of exportsLikeMap.keys()) { + if ( + key.split('*').length === 2 && + matchSubpathPattern(key, subpath) != null + ) { + return true; + } + } + + return false; +} diff --git a/packages/metro-resolver/src/utils/matchSubpathFromExportsLike.js b/packages/metro-resolver/src/utils/matchSubpathFromExportsLike.js new file mode 100644 index 0000000000..624e307d87 --- /dev/null +++ b/packages/metro-resolver/src/utils/matchSubpathFromExportsLike.js @@ -0,0 +1,80 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {NormalizedExportsLikeMap, ResolutionContext} from '../types'; + +import {matchSubpathPattern} from './matchSubpathPattern'; +import {reduceExportsLikeMap} from './reduceExportsLikeMap'; + +/** + * 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). + */ +export function matchSubpathFromExportsLike( + context: ResolutionContext, + /** + * The package-relative subpath (beginning with '.') to match against either + * an exact subpath key or subpath pattern key in "exports". + */ + subpath: string, + exportsLikeMap: NormalizedExportsLikeMap, + 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 exportsLikeMapAfterConditions = reduceExportsLikeMap( + exportsLikeMap, + conditionNames, + createConfigError, + ); + + let target = exportsLikeMapAfterConditions.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 = [...exportsLikeMapAfterConditions.keys()] + .filter(key => key.includes('*')) + .sort(key => key.split('*')[0].length) + .reverse(); + + for (const key of expansionKeys) { + const value = exportsLikeMapAfterConditions.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}; +} diff --git a/packages/metro-resolver/src/utils/matchSubpathPattern.js b/packages/metro-resolver/src/utils/matchSubpathPattern.js new file mode 100644 index 0000000000..4c738ff584 --- /dev/null +++ b/packages/metro-resolver/src/utils/matchSubpathPattern.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + * @oncall react_native + */ + +/** + * 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. + */ +export 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; +} diff --git a/packages/metro-resolver/src/utils/reduceExportsLikeMap.js b/packages/metro-resolver/src/utils/reduceExportsLikeMap.js new file mode 100644 index 0000000000..b4592258af --- /dev/null +++ b/packages/metro-resolver/src/utils/reduceExportsLikeMap.js @@ -0,0 +1,90 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +/** + * Reduce an "exports"-like mapping to a flat subpath mapping after resolving + * conditional exports. + */ +import type { + ExportsLikeMap, + FlattenedExportMap, + NormalizedExportsLikeMap, +} from '../types'; + +export function reduceExportsLikeMap( + exportsLikeMap: NormalizedExportsLikeMap, + conditionNames: $ReadOnlySet, + createConfigError: (reason: string) => Error, +): FlattenedExportMap { + const result = new Map(); + + for (const [subpath, value] of exportsLikeMap) { + 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, + conditionNames: $ReadOnlySet, +): 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; +}