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 fs from 'fs'; 9import { ConfigT } from 'metro-config'; 10import { Resolution, ResolutionContext } from 'metro-resolver'; 11import path from 'path'; 12import resolveFrom from 'resolve-from'; 13 14import { 15 EXTERNAL_REQUIRE_NATIVE_POLYFILL, 16 EXTERNAL_REQUIRE_POLYFILL, 17 getNodeExternalModuleId, 18 isNodeExternal, 19 setupNodeExternals, 20} from './externals'; 21import { isFailedToResolveNameError, isFailedToResolvePathError } from './metroErrors'; 22import { importMetroResolverFromProject } from './resolveFromProject'; 23import { getAppRouterRelativeEntryPath } from './router'; 24import { withMetroResolvers } from './withMetroResolvers'; 25import { Log } from '../../../log'; 26import { FileNotifier } from '../../../utils/FileNotifier'; 27import { env } from '../../../utils/env'; 28import { installExitHooks } from '../../../utils/exit'; 29import { isInteractive } from '../../../utils/interactive'; 30import { learnMore } from '../../../utils/link'; 31import { loadTsConfigPathsAsync, TsConfigPaths } from '../../../utils/tsconfig/loadTsConfigPaths'; 32import { resolveWithTsConfigPaths } from '../../../utils/tsconfig/resolveWithTsConfigPaths'; 33import { WebSupportProjectPrerequisite } from '../../doctor/web/WebSupportProjectPrerequisite'; 34import { PlatformBundlers } from '../platformBundlers'; 35 36type Mutable<T> = { -readonly [K in keyof T]: T[K] }; 37 38const debug = require('debug')('expo:start:server:metro:multi-platform') as typeof console.log; 39 40function withWebPolyfills(config: ConfigT, projectRoot: string): ConfigT { 41 const originalGetPolyfills = config.serializer.getPolyfills 42 ? config.serializer.getPolyfills.bind(config.serializer) 43 : () => []; 44 45 const getPolyfills = (ctx: { platform: string | null }): readonly string[] => { 46 if (ctx.platform === 'web') { 47 return [ 48 // NOTE: We might need this for all platforms 49 path.join(projectRoot, EXTERNAL_REQUIRE_POLYFILL), 50 // TODO: runtime polyfills, i.e. Fast Refresh, error overlay, React Dev Tools... 51 ]; 52 } 53 // Generally uses `rn-get-polyfills` 54 const polyfills = originalGetPolyfills(ctx); 55 56 return [...polyfills, EXTERNAL_REQUIRE_NATIVE_POLYFILL]; 57 }; 58 59 return { 60 ...config, 61 serializer: { 62 ...config.serializer, 63 getPolyfills, 64 }, 65 }; 66} 67 68function normalizeSlashes(p: string) { 69 return p.replace(/\\/g, '/'); 70} 71 72export function getNodejsExtensions(srcExts: readonly string[]): string[] { 73 const mjsExts = srcExts.filter((ext) => /mjs$/.test(ext)); 74 const nodejsSourceExtensions = srcExts.filter((ext) => !/mjs$/.test(ext)); 75 // find index of last `*.js` extension 76 const jsIndex = nodejsSourceExtensions.reduce((index, ext, i) => { 77 return /jsx?$/.test(ext) ? i : index; 78 }, -1); 79 80 // insert `*.mjs` extensions after `*.js` extensions 81 nodejsSourceExtensions.splice(jsIndex + 1, 0, ...mjsExts); 82 83 return nodejsSourceExtensions; 84} 85 86/** 87 * Apply custom resolvers to do the following: 88 * - Disable `.native.js` extensions on web. 89 * - Alias `react-native` to `react-native-web` on web. 90 * - Redirect `react-native-web/dist/modules/AssetRegistry/index.js` to `@react-native/assets/registry.js` on web. 91 * - Add support for `tsconfig.json`/`jsconfig.json` aliases via `compilerOptions.paths`. 92 */ 93export function withExtendedResolver( 94 config: ConfigT, 95 { 96 projectRoot, 97 tsconfig, 98 platforms, 99 isTsconfigPathsEnabled, 100 }: { 101 projectRoot: string; 102 tsconfig: TsConfigPaths | null; 103 platforms: string[]; 104 isTsconfigPathsEnabled?: boolean; 105 } 106) { 107 // Get the `transformer.assetRegistryPath` 108 // this needs to be unified since you can't dynamically 109 // swap out the transformer based on platform. 110 const assetRegistryPath = fs.realpathSync( 111 // This is the native asset registry alias for native. 112 path.resolve(resolveFrom(projectRoot, 'react-native/Libraries/Image/AssetRegistry')) 113 // NOTE(EvanBacon): This is the newer import but it doesn't work in the expo/expo monorepo. 114 // path.resolve(resolveFrom(projectRoot, '@react-native/assets/registry.js')) 115 ); 116 117 let reactNativeWebAppContainer: string | null = null; 118 try { 119 reactNativeWebAppContainer = fs.realpathSync( 120 // This is the native asset registry alias for native. 121 path.resolve(resolveFrom(projectRoot, 'expo-router/build/fork/react-native-web-container')) 122 // NOTE(EvanBacon): This is the newer import but it doesn't work in the expo/expo monorepo. 123 // path.resolve(resolveFrom(projectRoot, '@react-native/assets/registry.js')) 124 ); 125 } catch {} 126 127 const isWebEnabled = platforms.includes('web'); 128 129 const { resolve } = importMetroResolverFromProject(projectRoot); 130 131 const extraNodeModules: { [key: string]: Record<string, string> } = {}; 132 133 const aliases: { [key: string]: Record<string, string> } = { 134 web: { 135 'react-native': 'react-native-web', 136 'react-native/index': 'react-native-web', 137 }, 138 }; 139 140 if (isWebEnabled) { 141 // Allow `react-native-web` to be optional when web is not enabled but path aliases is. 142 extraNodeModules['web'] = { 143 'react-native': path.resolve(require.resolve('react-native-web/package.json'), '..'), 144 }; 145 } 146 147 const preferredMainFields: { [key: string]: string[] } = { 148 // Defaults from Expo Webpack. Most packages using `react-native` don't support web 149 // in the `react-native` field, so we should prefer the `browser` field. 150 // https://github.com/expo/router/issues/37 151 web: ['browser', 'module', 'main'], 152 }; 153 154 let tsConfigResolve = tsconfig?.paths 155 ? resolveWithTsConfigPaths.bind(resolveWithTsConfigPaths, { 156 paths: tsconfig.paths ?? {}, 157 baseUrl: tsconfig.baseUrl, 158 }) 159 : null; 160 161 if (isTsconfigPathsEnabled && isInteractive()) { 162 // TODO: We should track all the files that used imports and invalidate them 163 // currently the user will need to save all the files that use imports to 164 // use the new aliases. 165 const configWatcher = new FileNotifier(projectRoot, ['./tsconfig.json', './jsconfig.json']); 166 configWatcher.startObserving(() => { 167 debug('Reloading tsconfig.json'); 168 loadTsConfigPathsAsync(projectRoot).then((tsConfigPaths) => { 169 if (tsConfigPaths?.paths && !!Object.keys(tsConfigPaths.paths).length) { 170 debug('Enabling tsconfig.json paths support'); 171 tsConfigResolve = resolveWithTsConfigPaths.bind(resolveWithTsConfigPaths, { 172 paths: tsConfigPaths.paths ?? {}, 173 baseUrl: tsConfigPaths.baseUrl, 174 }); 175 } else { 176 debug('Disabling tsconfig.json paths support'); 177 tsConfigResolve = null; 178 } 179 }); 180 }); 181 182 // TODO: This probably prevents the process from exiting. 183 installExitHooks(() => { 184 configWatcher.stopObserving(); 185 }); 186 } else { 187 debug('Skipping tsconfig.json paths support'); 188 } 189 190 let nodejsSourceExtensions: string[] | null = null; 191 192 return withMetroResolvers(config, projectRoot, [ 193 // Add a resolver to alias the web asset resolver. 194 (immutableContext: ResolutionContext, moduleName: string, platform: string | null) => { 195 let context = { 196 ...immutableContext, 197 } as Mutable<ResolutionContext> & { 198 mainFields: string[]; 199 customResolverOptions?: Record<string, string>; 200 }; 201 202 const environment = context.customResolverOptions?.environment; 203 const isNode = environment === 'node'; 204 205 // TODO: We need to prevent the require.context from including API routes as these use externals. 206 // Should be fine after async routes lands. 207 if (isNode) { 208 const moduleId = isNodeExternal(moduleName); 209 if (moduleId) { 210 moduleName = getNodeExternalModuleId(context.originModulePath, moduleId); 211 debug(`Redirecting Node.js external "${moduleId}" to "${moduleName}"`); 212 } 213 214 // Adjust nodejs source extensions to sort mjs after js, including platform variants. 215 if (nodejsSourceExtensions === null) { 216 nodejsSourceExtensions = getNodejsExtensions(context.sourceExts); 217 } 218 context.sourceExts = nodejsSourceExtensions; 219 } 220 221 // Conditionally remap `react-native` to `react-native-web` on web in 222 // a way that doesn't require Babel to resolve the alias. 223 if (platform && platform in aliases && aliases[platform][moduleName]) { 224 moduleName = aliases[platform][moduleName]; 225 } 226 227 // TODO: We may be able to remove this in the future, it's doing no harm 228 // by staying here. 229 // Conditionally remap `react-native` to `react-native-web` 230 if (platform && platform in extraNodeModules) { 231 context.extraNodeModules = { 232 ...extraNodeModules[platform], 233 ...context.extraNodeModules, 234 }; 235 } 236 237 if (tsconfig?.baseUrl && isTsconfigPathsEnabled) { 238 context = { 239 ...context, 240 nodeModulesPaths: [ 241 ...immutableContext.nodeModulesPaths, 242 // add last to ensure node modules are resolved first 243 tsconfig.baseUrl, 244 ], 245 }; 246 } 247 248 let mainFields: string[] = context.mainFields; 249 250 if (isNode) { 251 // Node.js runtimes should only be importing main at the moment. 252 // This is a temporary fix until we can support the package.json exports. 253 mainFields = ['main', 'module']; 254 } else if (env.EXPO_METRO_NO_MAIN_FIELD_OVERRIDE) { 255 mainFields = context.mainFields; 256 } else if (platform && platform in preferredMainFields) { 257 mainFields = preferredMainFields[platform]; 258 } 259 function doResolve(moduleName: string): Resolution | null { 260 return resolve( 261 { 262 ...context, 263 resolveRequest: undefined, 264 mainFields, 265 266 // Passing `mainFields` directly won't be considered (in certain version of Metro) 267 // we need to extend the `getPackageMainPath` directly to 268 // use platform specific `mainFields`. 269 // @ts-ignore 270 getPackageMainPath(packageJsonPath) { 271 // @ts-expect-error: mainFields is not on type 272 const package_ = context.moduleCache.getPackage(packageJsonPath); 273 return package_.getMain(mainFields); 274 }, 275 }, 276 moduleName, 277 platform 278 ); 279 } 280 281 function optionalResolve(moduleName: string): Resolution | null { 282 try { 283 return doResolve(moduleName); 284 } catch (error) { 285 // If the error is directly related to a resolver not being able to resolve a module, then 286 // we can ignore the error and try the next resolver. Otherwise, we should throw the error. 287 const isResolutionError = 288 isFailedToResolveNameError(error) || isFailedToResolvePathError(error); 289 if (!isResolutionError) { 290 throw error; 291 } 292 } 293 return null; 294 } 295 296 let result: Resolution | null = null; 297 298 // React Native uses `event-target-shim` incorrectly and this causes the native runtime 299 // to fail to load. This is a temporary workaround until we can fix this upstream. 300 // https://github.com/facebook/react-native/pull/38628 301 if ( 302 moduleName.includes('event-target-shim') && 303 context.originModulePath.includes(path.sep + 'react-native' + path.sep) 304 ) { 305 context.sourceExts = context.sourceExts.filter((f) => !f.includes('mjs')); 306 debug('Skip mjs support for event-target-shim in:', context.originModulePath); 307 } 308 309 if (tsConfigResolve) { 310 result = tsConfigResolve( 311 { 312 originModulePath: context.originModulePath, 313 moduleName, 314 }, 315 optionalResolve 316 ); 317 } 318 319 if ( 320 // is web 321 platform === 'web' && 322 // Not server runtime 323 !isNode && 324 // Is Node.js built-in 325 isNodeExternal(moduleName) 326 ) { 327 // Perform optional resolve first. If the module doesn't exist (no module in the node_modules) 328 // then we can mock the file to use an empty module. 329 result ??= optionalResolve(moduleName); 330 331 if (!result) { 332 // In this case, mock the file to use an empty module. 333 return { 334 type: 'empty', 335 }; 336 } 337 } 338 339 result ??= doResolve(moduleName); 340 341 if (result) { 342 // Replace the web resolver with the original one. 343 // This is basically an alias for web-only. 344 if (shouldAliasAssetRegistryForWeb(platform, result)) { 345 // @ts-expect-error: `readonly` for some reason. 346 result.filePath = assetRegistryPath; 347 } 348 349 // React Native Web adds a couple extra divs for no reason, these 350 // make static rendering much harder as we expect the root element to be `<html>`. 351 // This resolution will alias to a simple in-out component to avoid React Native web. 352 if ( 353 // Only apply the transform if expo-router is present. 354 reactNativeWebAppContainer && 355 shouldAliasModule( 356 { 357 platform, 358 result, 359 }, 360 { 361 platform: 'web', 362 output: 'react-native-web/dist/exports/AppRegistry/AppContainer.js', 363 } 364 ) 365 ) { 366 // @ts-expect-error: `readonly` for some reason. 367 result.filePath = reactNativeWebAppContainer; 368 } 369 } 370 return result; 371 }, 372 ]); 373} 374 375/** @returns `true` if the incoming resolution should be swapped on web. */ 376export function shouldAliasAssetRegistryForWeb( 377 platform: string | null, 378 result: Resolution 379): boolean { 380 return ( 381 platform === 'web' && 382 result?.type === 'sourceFile' && 383 typeof result?.filePath === 'string' && 384 normalizeSlashes(result.filePath).endsWith( 385 'react-native-web/dist/modules/AssetRegistry/index.js' 386 ) 387 ); 388} 389/** @returns `true` if the incoming resolution should be swapped. */ 390export function shouldAliasModule( 391 input: { 392 platform: string | null; 393 result: Resolution; 394 }, 395 alias: { platform: string; output: string } 396): boolean { 397 return ( 398 input.platform === alias.platform && 399 input.result?.type === 'sourceFile' && 400 typeof input.result?.filePath === 'string' && 401 normalizeSlashes(input.result.filePath).endsWith(alias.output) 402 ); 403} 404 405/** Add support for `react-native-web` and the Web platform. */ 406export async function withMetroMultiPlatformAsync( 407 projectRoot: string, 408 { 409 config, 410 platformBundlers, 411 isTsconfigPathsEnabled, 412 webOutput, 413 routerDirectory, 414 }: { 415 config: ConfigT; 416 isTsconfigPathsEnabled: boolean; 417 platformBundlers: PlatformBundlers; 418 webOutput?: 'single' | 'static' | 'server'; 419 routerDirectory: string; 420 } 421) { 422 // Auto pick app entry for router. 423 process.env.EXPO_ROUTER_APP_ROOT = getAppRouterRelativeEntryPath(projectRoot, routerDirectory); 424 425 // Required for @expo/metro-runtime to format paths in the web LogBox. 426 process.env.EXPO_PUBLIC_PROJECT_ROOT = process.env.EXPO_PUBLIC_PROJECT_ROOT ?? projectRoot; 427 428 if (['static', 'server'].includes(webOutput ?? '')) { 429 // Enable static rendering in runtime space. 430 process.env.EXPO_PUBLIC_USE_STATIC = '1'; 431 } 432 433 // Ensure the cache is invalidated if these values change. 434 // @ts-expect-error 435 config.transformer._expoRouterRootDirectory = process.env.EXPO_ROUTER_APP_ROOT; 436 // @ts-expect-error 437 config.transformer._expoRouterWebRendering = webOutput; 438 // TODO: import mode 439 440 if (platformBundlers.web === 'metro') { 441 await new WebSupportProjectPrerequisite(projectRoot).assertAsync(); 442 } 443 444 let tsconfig: null | TsConfigPaths = null; 445 446 if (isTsconfigPathsEnabled) { 447 Log.warn( 448 chalk.yellow`Experimental path aliases feature is enabled. ` + 449 learnMore('https://docs.expo.dev/guides/typescript/#path-aliases') 450 ); 451 tsconfig = await loadTsConfigPathsAsync(projectRoot); 452 } 453 454 await setupNodeExternals(projectRoot); 455 456 return withMetroMultiPlatform(projectRoot, { 457 config, 458 platformBundlers, 459 tsconfig, 460 isTsconfigPathsEnabled, 461 }); 462} 463 464function withMetroMultiPlatform( 465 projectRoot: string, 466 { 467 config, 468 platformBundlers, 469 isTsconfigPathsEnabled, 470 tsconfig, 471 }: { 472 config: ConfigT; 473 isTsconfigPathsEnabled: boolean; 474 platformBundlers: PlatformBundlers; 475 tsconfig: TsConfigPaths | null; 476 } 477) { 478 let expoConfigPlatforms = Object.entries(platformBundlers) 479 .filter(([, bundler]) => bundler === 'metro') 480 .map(([platform]) => platform); 481 482 if (Array.isArray(config.resolver.platforms)) { 483 expoConfigPlatforms = [...new Set(expoConfigPlatforms.concat(config.resolver.platforms))]; 484 } 485 486 // @ts-expect-error: typed as `readonly`. 487 config.resolver.platforms = expoConfigPlatforms; 488 489 if (expoConfigPlatforms.includes('web')) { 490 config = withWebPolyfills(config, projectRoot); 491 } 492 493 return withExtendedResolver(config, { 494 projectRoot, 495 tsconfig, 496 isTsconfigPathsEnabled, 497 platforms: expoConfigPlatforms, 498 }); 499} 500