1/** 2 * Copyright © 2022 650 Industries. 3 * 4 * This source code is licensed under the MIT license found in the 5 * LICENSE file in the root directory of this source tree. 6 */ 7import chalk from 'chalk'; 8import { ConfigT as MetroConfig } from 'metro-config'; 9import { ResolutionContext } from 'metro-resolver'; 10import path from 'path'; 11 12import { isFailedToResolveNameError, isFailedToResolvePathError } from './metroErrors'; 13import { importMetroResolverFromProject } from './resolveFromProject'; 14import { env } from '../../../utils/env'; 15 16const debug = require('debug')('expo:metro:withMetroResolvers') as typeof console.log; 17 18export type MetroResolver = NonNullable<MetroConfig['resolver']['resolveRequest']>; 19 20/** Expo Metro Resolvers can return `null` to skip without throwing an error. Metro Resolvers will throw either a `FailedToResolveNameError` or `FailedToResolvePathError`. */ 21export type ExpoCustomMetroResolver = ( 22 ...args: Parameters<MetroResolver> 23) => ReturnType<MetroResolver> | null; 24 25/** @returns `MetroResolver` utilizing the upstream `resolve` method. */ 26export function getDefaultMetroResolver(projectRoot: string): MetroResolver { 27 const { resolve } = importMetroResolverFromProject(projectRoot); 28 return (context: ResolutionContext, moduleName: string, platform: string | null) => { 29 return resolve(context, moduleName, platform); 30 }; 31} 32 33function optionsKeyForContext(context: ResolutionContext) { 34 const canonicalize = require('metro-core/src/canonicalize'); 35 36 // Compound key for the resolver cache 37 return JSON.stringify(context.customResolverOptions ?? {}, canonicalize) ?? ''; 38} 39 40/** 41 * Extend the Metro config `resolver.resolveRequest` method with additional resolvers that can 42 * exit early by returning a `Resolution` or skip to the next resolver by returning `null`. 43 * 44 * @param config Metro config. 45 * @param projectRoot path to the project root used to resolve the default Metro resolver. 46 * @param resolvers custom MetroResolver to chain. 47 * @returns a new `MetroConfig` with the `resolver.resolveRequest` method chained. 48 */ 49export function withMetroResolvers( 50 config: MetroConfig, 51 projectRoot: string, 52 resolvers: ExpoCustomMetroResolver[] 53): MetroConfig { 54 debug( 55 `Appending ${ 56 resolvers.length 57 } custom resolvers to Metro config. (has custom resolver: ${!!config.resolver?.resolveRequest})` 58 ); 59 const originalResolveRequest = 60 config.resolver?.resolveRequest || getDefaultMetroResolver(projectRoot); 61 62 function mutateResolutionError( 63 error: Error, 64 context: ResolutionContext, 65 moduleName: string, 66 platform: string | null 67 ) { 68 if (!env.EXPO_METRO_UNSTABLE_ERRORS || !platform) { 69 debug('Cannot mutate resolution error'); 70 return error; 71 } 72 73 const mapByOrigin = depGraph.get(optionsKeyForContext(context)); 74 const mapByPlatform = mapByOrigin?.get(platform); 75 76 if (!mapByPlatform) { 77 return error; 78 } 79 80 // collect all references inversely using some expensive lookup 81 82 const getReferences = (origin: string) => { 83 const inverseOrigin: { origin: string; previous: string; request: string }[] = []; 84 85 if (!mapByPlatform) { 86 return inverseOrigin; 87 } 88 89 for (const [originKey, mapByTarget] of mapByPlatform) { 90 // search comparing origin to path 91 92 const found = [...mapByTarget.values()].find((resolution) => resolution.path === origin); 93 if (found) { 94 inverseOrigin.push({ 95 origin, 96 previous: originKey, 97 request: found.request, 98 }); 99 } 100 } 101 102 return inverseOrigin; 103 }; 104 105 const pad = (num: number) => { 106 return new Array(num).fill(' ').join(''); 107 }; 108 109 const root = config.server?.unstable_serverRoot ?? config.projectRoot ?? projectRoot; 110 111 type InverseDepResult = { 112 origin: string; 113 request: string; 114 previous: InverseDepResult[]; 115 }; 116 const recurseBackWithLimit = ( 117 req: { origin: string; request: string }, 118 limit: number, 119 count: number = 0 120 ) => { 121 const results: InverseDepResult = { 122 origin: req.origin, 123 request: req.request, 124 previous: [], 125 }; 126 127 if (count >= limit) { 128 return results; 129 } 130 131 const inverse = getReferences(req.origin); 132 for (const match of inverse) { 133 // Use more qualified name if possible 134 // results.origin = match.origin; 135 // Found entry point 136 if (req.origin === match.previous) { 137 continue; 138 } 139 results.previous.push( 140 recurseBackWithLimit({ origin: match.previous, request: match.request }, limit, count + 1) 141 ); 142 } 143 return results; 144 }; 145 146 const inverseTree = recurseBackWithLimit( 147 { origin: context.originModulePath, request: moduleName }, 148 // TODO: Do we need to expose this? 149 35 150 ); 151 152 if (inverseTree.previous.length > 0) { 153 debug('Found inverse graph:', JSON.stringify(inverseTree, null, 2)); 154 let extraMessage = chalk.bold('Import stack:'); 155 const printRecursive = (tree: InverseDepResult, depth: number = 0) => { 156 let filename = path.relative(root, tree.origin); 157 if (filename.match(/\?ctx=[\w\d]+$/)) { 158 filename = filename.replace(/\?ctx=[\w\d]+$/, chalk.dim(' (require.context)')); 159 } else { 160 let formattedRequest = chalk.green(`"${tree.request}"`); 161 162 if ( 163 // If bundling for web and the import is pulling internals from outside of react-native 164 // then mark it as an invalid import. 165 platform === 'web' && 166 !/^(node_modules\/)?react-native\//.test(filename) && 167 tree.request.match(/^react-native\/.*/) 168 ) { 169 formattedRequest = 170 formattedRequest + 171 chalk`\n {yellow Importing react-native internals is not supported on web.}`; 172 } 173 174 filename = filename + chalk`\n{gray |} {cyan import} ${formattedRequest}\n`; 175 } 176 let line = '\n' + pad(depth) + chalk.gray(' ') + filename; 177 if (filename.match(/node_modules/)) { 178 line = chalk.gray( 179 // Bold the node module name 180 line.replace(/node_modules\/([^/]+)/, (_match, p1) => { 181 return 'node_modules/' + chalk.bold(p1); 182 }) 183 ); 184 } 185 extraMessage += line; 186 for (const child of tree.previous) { 187 printRecursive( 188 child, 189 // Only add depth if there are multiple children 190 tree.previous.length > 1 ? depth + 1 : depth 191 ); 192 } 193 }; 194 printRecursive(inverseTree); 195 196 // @ts-expect-error 197 error._expoImportStack = extraMessage; 198 } else { 199 debug('Found no inverse tree for:', context.originModulePath); 200 } 201 202 return error; 203 } 204 205 const depGraph: Map< 206 // custom options 207 string, 208 Map< 209 // platform 210 string, 211 Map< 212 // origin module name 213 string, 214 Set<{ 215 // required module name 216 path: string; 217 // This isn't entirely accurate since a module can be imported multiple times in a file, 218 // and use different names. But it's good enough for now. 219 request: string; 220 }> 221 > 222 > 223 > = new Map(); 224 225 return { 226 ...config, 227 resolver: { 228 ...config.resolver, 229 resolveRequest(context, moduleName, platform) { 230 const storeResult = (res: NonNullable<ReturnType<ExpoCustomMetroResolver>>) => { 231 if (!env.EXPO_METRO_UNSTABLE_ERRORS || !platform) return; 232 233 const key = optionsKeyForContext(context); 234 if (!depGraph.has(key)) depGraph.set(key, new Map()); 235 const mapByTarget = depGraph.get(key); 236 if (!mapByTarget!.has(platform)) mapByTarget!.set(platform, new Map()); 237 const mapByPlatform = mapByTarget!.get(platform); 238 if (!mapByPlatform!.has(context.originModulePath)) 239 mapByPlatform!.set(context.originModulePath, new Set()); 240 const setForModule = mapByPlatform!.get(context.originModulePath)!; 241 242 const qualifiedModuleName = res?.type === 'sourceFile' ? res.filePath : moduleName; 243 setForModule.add({ path: qualifiedModuleName, request: moduleName }); 244 }; 245 246 const universalContext = { 247 ...context, 248 preferNativePlatform: platform !== 'web', 249 }; 250 251 try { 252 for (const resolver of resolvers) { 253 try { 254 const resolution = resolver(universalContext, moduleName, platform); 255 if (resolution) { 256 storeResult(resolution); 257 return resolution; 258 } 259 } catch (error: any) { 260 // If no user-defined resolver, use Expo's default behavior. 261 // This prevents extraneous resolution attempts on failure. 262 if (!config.resolver.resolveRequest) { 263 throw error; 264 } 265 266 // If the error is directly related to a resolver not being able to resolve a module, then 267 // we can ignore the error and try the next resolver. Otherwise, we should throw the error. 268 const isResolutionError = 269 isFailedToResolveNameError(error) || isFailedToResolvePathError(error); 270 if (!isResolutionError) { 271 throw error; 272 } 273 debug( 274 `Custom resolver threw: ${error.constructor.name}. (module: ${moduleName}, platform: ${platform})` 275 ); 276 } 277 } 278 // If we haven't returned by now, use the original resolver or upstream resolver. 279 const res = originalResolveRequest(universalContext, moduleName, platform); 280 storeResult(res); 281 return res; 282 } catch (error: any) { 283 throw mutateResolutionError(error, universalContext, moduleName, platform); 284 } 285 }, 286 }, 287 }; 288} 289