1837127edSEvan Bacon/** 2837127edSEvan Bacon * Copyright © 2022 650 Industries. 3837127edSEvan Bacon * 4837127edSEvan Bacon * This source code is licensed under the MIT license found in the 5837127edSEvan Bacon * LICENSE file in the root directory of this source tree. 6837127edSEvan Bacon */ 72fbedb18SEvan Baconimport chalk from 'chalk'; 8837127edSEvan Baconimport { ConfigT as MetroConfig } from 'metro-config'; 9837127edSEvan Baconimport { ResolutionContext } from 'metro-resolver'; 102fbedb18SEvan Baconimport path from 'path'; 11837127edSEvan Bacon 12837127edSEvan Baconimport { isFailedToResolveNameError, isFailedToResolvePathError } from './metroErrors'; 13837127edSEvan Baconimport { importMetroResolverFromProject } from './resolveFromProject'; 14*8a424bebSJames Ideimport { env } from '../../../utils/env'; 15837127edSEvan Bacon 16837127edSEvan Baconconst debug = require('debug')('expo:metro:withMetroResolvers') as typeof console.log; 17837127edSEvan Bacon 18837127edSEvan Baconexport type MetroResolver = NonNullable<MetroConfig['resolver']['resolveRequest']>; 19837127edSEvan Bacon 20837127edSEvan Bacon/** Expo Metro Resolvers can return `null` to skip without throwing an error. Metro Resolvers will throw either a `FailedToResolveNameError` or `FailedToResolvePathError`. */ 21837127edSEvan Baconexport type ExpoCustomMetroResolver = ( 22837127edSEvan Bacon ...args: Parameters<MetroResolver> 23837127edSEvan Bacon) => ReturnType<MetroResolver> | null; 24837127edSEvan Bacon 25837127edSEvan Bacon/** @returns `MetroResolver` utilizing the upstream `resolve` method. */ 26837127edSEvan Baconexport function getDefaultMetroResolver(projectRoot: string): MetroResolver { 27837127edSEvan Bacon const { resolve } = importMetroResolverFromProject(projectRoot); 28837127edSEvan Bacon return (context: ResolutionContext, moduleName: string, platform: string | null) => { 29837127edSEvan Bacon return resolve(context, moduleName, platform); 30837127edSEvan Bacon }; 31837127edSEvan Bacon} 32837127edSEvan Bacon 332fbedb18SEvan Baconfunction optionsKeyForContext(context: ResolutionContext) { 342fbedb18SEvan Bacon const canonicalize = require('metro-core/src/canonicalize'); 352fbedb18SEvan Bacon 362fbedb18SEvan Bacon // Compound key for the resolver cache 372fbedb18SEvan Bacon return JSON.stringify(context.customResolverOptions ?? {}, canonicalize) ?? ''; 382fbedb18SEvan Bacon} 392fbedb18SEvan Bacon 40837127edSEvan Bacon/** 41837127edSEvan Bacon * Extend the Metro config `resolver.resolveRequest` method with additional resolvers that can 42837127edSEvan Bacon * exit early by returning a `Resolution` or skip to the next resolver by returning `null`. 43837127edSEvan Bacon * 44837127edSEvan Bacon * @param config Metro config. 45837127edSEvan Bacon * @param projectRoot path to the project root used to resolve the default Metro resolver. 46837127edSEvan Bacon * @param resolvers custom MetroResolver to chain. 47837127edSEvan Bacon * @returns a new `MetroConfig` with the `resolver.resolveRequest` method chained. 48837127edSEvan Bacon */ 49837127edSEvan Baconexport function withMetroResolvers( 50837127edSEvan Bacon config: MetroConfig, 51837127edSEvan Bacon projectRoot: string, 52837127edSEvan Bacon resolvers: ExpoCustomMetroResolver[] 53837127edSEvan Bacon): MetroConfig { 54837127edSEvan Bacon debug( 55837127edSEvan Bacon `Appending ${ 56837127edSEvan Bacon resolvers.length 574e5f28eeSEvan Bacon } custom resolvers to Metro config. (has custom resolver: ${!!config.resolver?.resolveRequest})` 58837127edSEvan Bacon ); 59837127edSEvan Bacon const originalResolveRequest = 604e5f28eeSEvan Bacon config.resolver?.resolveRequest || getDefaultMetroResolver(projectRoot); 61837127edSEvan Bacon 622fbedb18SEvan Bacon function mutateResolutionError( 632fbedb18SEvan Bacon error: Error, 642fbedb18SEvan Bacon context: ResolutionContext, 652fbedb18SEvan Bacon moduleName: string, 662fbedb18SEvan Bacon platform: string | null 672fbedb18SEvan Bacon ) { 682fbedb18SEvan Bacon if (!env.EXPO_METRO_UNSTABLE_ERRORS || !platform) { 692fbedb18SEvan Bacon debug('Cannot mutate resolution error'); 702fbedb18SEvan Bacon return error; 712fbedb18SEvan Bacon } 722fbedb18SEvan Bacon 732fbedb18SEvan Bacon const mapByOrigin = depGraph.get(optionsKeyForContext(context)); 742fbedb18SEvan Bacon const mapByPlatform = mapByOrigin?.get(platform); 752fbedb18SEvan Bacon 762fbedb18SEvan Bacon if (!mapByPlatform) { 772fbedb18SEvan Bacon return error; 782fbedb18SEvan Bacon } 792fbedb18SEvan Bacon 802fbedb18SEvan Bacon // collect all references inversely using some expensive lookup 812fbedb18SEvan Bacon 822fbedb18SEvan Bacon const getReferences = (origin: string) => { 832fbedb18SEvan Bacon const inverseOrigin: { origin: string; previous: string; request: string }[] = []; 842fbedb18SEvan Bacon 852fbedb18SEvan Bacon if (!mapByPlatform) { 862fbedb18SEvan Bacon return inverseOrigin; 872fbedb18SEvan Bacon } 882fbedb18SEvan Bacon 892fbedb18SEvan Bacon for (const [originKey, mapByTarget] of mapByPlatform) { 902fbedb18SEvan Bacon // search comparing origin to path 912fbedb18SEvan Bacon 922fbedb18SEvan Bacon const found = [...mapByTarget.values()].find((resolution) => resolution.path === origin); 932fbedb18SEvan Bacon if (found) { 942fbedb18SEvan Bacon inverseOrigin.push({ 952fbedb18SEvan Bacon origin, 962fbedb18SEvan Bacon previous: originKey, 972fbedb18SEvan Bacon request: found.request, 982fbedb18SEvan Bacon }); 992fbedb18SEvan Bacon } 1002fbedb18SEvan Bacon } 1012fbedb18SEvan Bacon 1022fbedb18SEvan Bacon return inverseOrigin; 1032fbedb18SEvan Bacon }; 1042fbedb18SEvan Bacon 1052fbedb18SEvan Bacon const pad = (num: number) => { 1062fbedb18SEvan Bacon return new Array(num).fill(' ').join(''); 1072fbedb18SEvan Bacon }; 1082fbedb18SEvan Bacon 1092fbedb18SEvan Bacon const root = config.server?.unstable_serverRoot ?? config.projectRoot ?? projectRoot; 1102fbedb18SEvan Bacon 1112fbedb18SEvan Bacon type InverseDepResult = { 1122fbedb18SEvan Bacon origin: string; 1132fbedb18SEvan Bacon request: string; 1142fbedb18SEvan Bacon previous: InverseDepResult[]; 1152fbedb18SEvan Bacon }; 1162fbedb18SEvan Bacon const recurseBackWithLimit = ( 1172fbedb18SEvan Bacon req: { origin: string; request: string }, 1182fbedb18SEvan Bacon limit: number, 1192fbedb18SEvan Bacon count: number = 0 1202fbedb18SEvan Bacon ) => { 1212fbedb18SEvan Bacon const results: InverseDepResult = { 1222fbedb18SEvan Bacon origin: req.origin, 1232fbedb18SEvan Bacon request: req.request, 1242fbedb18SEvan Bacon previous: [], 1252fbedb18SEvan Bacon }; 1262fbedb18SEvan Bacon 1272fbedb18SEvan Bacon if (count >= limit) { 1282fbedb18SEvan Bacon return results; 1292fbedb18SEvan Bacon } 1302fbedb18SEvan Bacon 1312fbedb18SEvan Bacon const inverse = getReferences(req.origin); 1322fbedb18SEvan Bacon for (const match of inverse) { 1332fbedb18SEvan Bacon // Use more qualified name if possible 1342fbedb18SEvan Bacon // results.origin = match.origin; 1352fbedb18SEvan Bacon // Found entry point 1362fbedb18SEvan Bacon if (req.origin === match.previous) { 1372fbedb18SEvan Bacon continue; 1382fbedb18SEvan Bacon } 1392fbedb18SEvan Bacon results.previous.push( 1402fbedb18SEvan Bacon recurseBackWithLimit({ origin: match.previous, request: match.request }, limit, count + 1) 1412fbedb18SEvan Bacon ); 1422fbedb18SEvan Bacon } 1432fbedb18SEvan Bacon return results; 1442fbedb18SEvan Bacon }; 1452fbedb18SEvan Bacon 1462fbedb18SEvan Bacon const inverseTree = recurseBackWithLimit( 1472fbedb18SEvan Bacon { origin: context.originModulePath, request: moduleName }, 1482fbedb18SEvan Bacon // TODO: Do we need to expose this? 1492fbedb18SEvan Bacon 35 1502fbedb18SEvan Bacon ); 1512fbedb18SEvan Bacon 1522fbedb18SEvan Bacon if (inverseTree.previous.length > 0) { 1532fbedb18SEvan Bacon debug('Found inverse graph:', JSON.stringify(inverseTree, null, 2)); 1542fbedb18SEvan Bacon let extraMessage = chalk.bold('Import stack:'); 1552fbedb18SEvan Bacon const printRecursive = (tree: InverseDepResult, depth: number = 0) => { 1562fbedb18SEvan Bacon let filename = path.relative(root, tree.origin); 1572fbedb18SEvan Bacon if (filename.match(/\?ctx=[\w\d]+$/)) { 1582fbedb18SEvan Bacon filename = filename.replace(/\?ctx=[\w\d]+$/, chalk.dim(' (require.context)')); 1592fbedb18SEvan Bacon } else { 1602fbedb18SEvan Bacon let formattedRequest = chalk.green(`"${tree.request}"`); 1612fbedb18SEvan Bacon 1622fbedb18SEvan Bacon if ( 1632fbedb18SEvan Bacon // If bundling for web and the import is pulling internals from outside of react-native 1642fbedb18SEvan Bacon // then mark it as an invalid import. 1652fbedb18SEvan Bacon platform === 'web' && 1662fbedb18SEvan Bacon !/^(node_modules\/)?react-native\//.test(filename) && 1672fbedb18SEvan Bacon tree.request.match(/^react-native\/.*/) 1682fbedb18SEvan Bacon ) { 1692fbedb18SEvan Bacon formattedRequest = 1702fbedb18SEvan Bacon formattedRequest + 1712fbedb18SEvan Bacon chalk`\n {yellow Importing react-native internals is not supported on web.}`; 1722fbedb18SEvan Bacon } 1732fbedb18SEvan Bacon 1742fbedb18SEvan Bacon filename = filename + chalk`\n{gray |} {cyan import} ${formattedRequest}\n`; 1752fbedb18SEvan Bacon } 1762fbedb18SEvan Bacon let line = '\n' + pad(depth) + chalk.gray(' ') + filename; 1772fbedb18SEvan Bacon if (filename.match(/node_modules/)) { 1782fbedb18SEvan Bacon line = chalk.gray( 1792fbedb18SEvan Bacon // Bold the node module name 1802fbedb18SEvan Bacon line.replace(/node_modules\/([^/]+)/, (_match, p1) => { 1812fbedb18SEvan Bacon return 'node_modules/' + chalk.bold(p1); 1822fbedb18SEvan Bacon }) 1832fbedb18SEvan Bacon ); 1842fbedb18SEvan Bacon } 1852fbedb18SEvan Bacon extraMessage += line; 1862fbedb18SEvan Bacon for (const child of tree.previous) { 1872fbedb18SEvan Bacon printRecursive( 1882fbedb18SEvan Bacon child, 1892fbedb18SEvan Bacon // Only add depth if there are multiple children 1902fbedb18SEvan Bacon tree.previous.length > 1 ? depth + 1 : depth 1912fbedb18SEvan Bacon ); 1922fbedb18SEvan Bacon } 1932fbedb18SEvan Bacon }; 1942fbedb18SEvan Bacon printRecursive(inverseTree); 1952fbedb18SEvan Bacon 1962fbedb18SEvan Bacon // @ts-expect-error 1972fbedb18SEvan Bacon error._expoImportStack = extraMessage; 1982fbedb18SEvan Bacon } else { 1992fbedb18SEvan Bacon debug('Found no inverse tree for:', context.originModulePath); 2002fbedb18SEvan Bacon } 2012fbedb18SEvan Bacon 2022fbedb18SEvan Bacon return error; 2032fbedb18SEvan Bacon } 2042fbedb18SEvan Bacon 2052fbedb18SEvan Bacon const depGraph: Map< 2062fbedb18SEvan Bacon // custom options 2072fbedb18SEvan Bacon string, 2082fbedb18SEvan Bacon Map< 2092fbedb18SEvan Bacon // platform 2102fbedb18SEvan Bacon string, 2112fbedb18SEvan Bacon Map< 2122fbedb18SEvan Bacon // origin module name 2132fbedb18SEvan Bacon string, 2142fbedb18SEvan Bacon Set<{ 2152fbedb18SEvan Bacon // required module name 2162fbedb18SEvan Bacon path: string; 2172fbedb18SEvan Bacon // This isn't entirely accurate since a module can be imported multiple times in a file, 2182fbedb18SEvan Bacon // and use different names. But it's good enough for now. 2192fbedb18SEvan Bacon request: string; 2202fbedb18SEvan Bacon }> 2212fbedb18SEvan Bacon > 2222fbedb18SEvan Bacon > 2232fbedb18SEvan Bacon > = new Map(); 2242fbedb18SEvan Bacon 225837127edSEvan Bacon return { 226837127edSEvan Bacon ...config, 227837127edSEvan Bacon resolver: { 228837127edSEvan Bacon ...config.resolver, 2296fb512faSEvan Bacon resolveRequest(context, moduleName, platform) { 2302fbedb18SEvan Bacon const storeResult = (res: NonNullable<ReturnType<ExpoCustomMetroResolver>>) => { 2312fbedb18SEvan Bacon if (!env.EXPO_METRO_UNSTABLE_ERRORS || !platform) return; 2322fbedb18SEvan Bacon 2332fbedb18SEvan Bacon const key = optionsKeyForContext(context); 2342fbedb18SEvan Bacon if (!depGraph.has(key)) depGraph.set(key, new Map()); 2352fbedb18SEvan Bacon const mapByTarget = depGraph.get(key); 2362fbedb18SEvan Bacon if (!mapByTarget!.has(platform)) mapByTarget!.set(platform, new Map()); 2372fbedb18SEvan Bacon const mapByPlatform = mapByTarget!.get(platform); 2382fbedb18SEvan Bacon if (!mapByPlatform!.has(context.originModulePath)) 2392fbedb18SEvan Bacon mapByPlatform!.set(context.originModulePath, new Set()); 2402fbedb18SEvan Bacon const setForModule = mapByPlatform!.get(context.originModulePath)!; 2412fbedb18SEvan Bacon 2422fbedb18SEvan Bacon const qualifiedModuleName = res?.type === 'sourceFile' ? res.filePath : moduleName; 2432fbedb18SEvan Bacon setForModule.add({ path: qualifiedModuleName, request: moduleName }); 2442fbedb18SEvan Bacon }; 2452fbedb18SEvan Bacon 2466fb512faSEvan Bacon const universalContext = { 2476fb512faSEvan Bacon ...context, 2486fb512faSEvan Bacon preferNativePlatform: platform !== 'web', 2496fb512faSEvan Bacon }; 2506fb512faSEvan Bacon 2512fbedb18SEvan Bacon try { 252837127edSEvan Bacon for (const resolver of resolvers) { 253837127edSEvan Bacon try { 2546fb512faSEvan Bacon const resolution = resolver(universalContext, moduleName, platform); 255837127edSEvan Bacon if (resolution) { 2562fbedb18SEvan Bacon storeResult(resolution); 257837127edSEvan Bacon return resolution; 258837127edSEvan Bacon } 259837127edSEvan Bacon } catch (error: any) { 2606fb512faSEvan Bacon // If no user-defined resolver, use Expo's default behavior. 2616fb512faSEvan Bacon // This prevents extraneous resolution attempts on failure. 2626fb512faSEvan Bacon if (!config.resolver.resolveRequest) { 2636fb512faSEvan Bacon throw error; 2646fb512faSEvan Bacon } 2656fb512faSEvan Bacon 266837127edSEvan Bacon // If the error is directly related to a resolver not being able to resolve a module, then 267837127edSEvan Bacon // we can ignore the error and try the next resolver. Otherwise, we should throw the error. 268837127edSEvan Bacon const isResolutionError = 269837127edSEvan Bacon isFailedToResolveNameError(error) || isFailedToResolvePathError(error); 270837127edSEvan Bacon if (!isResolutionError) { 271837127edSEvan Bacon throw error; 272837127edSEvan Bacon } 273837127edSEvan Bacon debug( 2746fb512faSEvan Bacon `Custom resolver threw: ${error.constructor.name}. (module: ${moduleName}, platform: ${platform})` 275837127edSEvan Bacon ); 276837127edSEvan Bacon } 277837127edSEvan Bacon } 278837127edSEvan Bacon // If we haven't returned by now, use the original resolver or upstream resolver. 2792fbedb18SEvan Bacon const res = originalResolveRequest(universalContext, moduleName, platform); 2802fbedb18SEvan Bacon storeResult(res); 2812fbedb18SEvan Bacon return res; 2822fbedb18SEvan Bacon } catch (error: any) { 2832fbedb18SEvan Bacon throw mutateResolutionError(error, universalContext, moduleName, platform); 2842fbedb18SEvan Bacon } 285837127edSEvan Bacon }, 286837127edSEvan Bacon }, 287837127edSEvan Bacon }; 288837127edSEvan Bacon} 289