/** * Copyright © 2022 650 Industries. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ import chalk from 'chalk'; import fs from 'fs'; import { ConfigT } from 'metro-config'; import { Resolution, ResolutionContext } from 'metro-resolver'; import path from 'path'; import resolveFrom from 'resolve-from'; import { EXTERNAL_REQUIRE_NATIVE_POLYFILL, EXTERNAL_REQUIRE_POLYFILL, getNodeExternalModuleId, isNodeExternal, setupNodeExternals, } from './externals'; import { isFailedToResolveNameError, isFailedToResolvePathError } from './metroErrors'; import { importMetroResolverFromProject } from './resolveFromProject'; import { getAppRouterRelativeEntryPath } from './router'; import { withMetroResolvers } from './withMetroResolvers'; import { Log } from '../../../log'; import { FileNotifier } from '../../../utils/FileNotifier'; import { env } from '../../../utils/env'; import { installExitHooks } from '../../../utils/exit'; import { isInteractive } from '../../../utils/interactive'; import { learnMore } from '../../../utils/link'; import { loadTsConfigPathsAsync, TsConfigPaths } from '../../../utils/tsconfig/loadTsConfigPaths'; import { resolveWithTsConfigPaths } from '../../../utils/tsconfig/resolveWithTsConfigPaths'; import { WebSupportProjectPrerequisite } from '../../doctor/web/WebSupportProjectPrerequisite'; import { PlatformBundlers } from '../platformBundlers'; type Mutable = { -readonly [K in keyof T]: T[K] }; const debug = require('debug')('expo:start:server:metro:multi-platform') as typeof console.log; function withWebPolyfills(config: ConfigT, projectRoot: string): ConfigT { const originalGetPolyfills = config.serializer.getPolyfills ? config.serializer.getPolyfills.bind(config.serializer) : () => []; const getPolyfills = (ctx: { platform: string | null }): readonly string[] => { if (ctx.platform === 'web') { return [ // NOTE: We might need this for all platforms path.join(projectRoot, EXTERNAL_REQUIRE_POLYFILL), // TODO: runtime polyfills, i.e. Fast Refresh, error overlay, React Dev Tools... ]; } // Generally uses `rn-get-polyfills` const polyfills = originalGetPolyfills(ctx); return [...polyfills, EXTERNAL_REQUIRE_NATIVE_POLYFILL]; }; return { ...config, serializer: { ...config.serializer, getPolyfills, }, }; } function normalizeSlashes(p: string) { return p.replace(/\\/g, '/'); } export function getNodejsExtensions(srcExts: readonly string[]): string[] { const mjsExts = srcExts.filter((ext) => /mjs$/.test(ext)); const nodejsSourceExtensions = srcExts.filter((ext) => !/mjs$/.test(ext)); // find index of last `*.js` extension const jsIndex = nodejsSourceExtensions.reduce((index, ext, i) => { return /jsx?$/.test(ext) ? i : index; }, -1); // insert `*.mjs` extensions after `*.js` extensions nodejsSourceExtensions.splice(jsIndex + 1, 0, ...mjsExts); return nodejsSourceExtensions; } /** * Apply custom resolvers to do the following: * - Disable `.native.js` extensions on web. * - Alias `react-native` to `react-native-web` on web. * - Redirect `react-native-web/dist/modules/AssetRegistry/index.js` to `@react-native/assets/registry.js` on web. * - Add support for `tsconfig.json`/`jsconfig.json` aliases via `compilerOptions.paths`. */ export function withExtendedResolver( config: ConfigT, { projectRoot, tsconfig, platforms, isTsconfigPathsEnabled, }: { projectRoot: string; tsconfig: TsConfigPaths | null; platforms: string[]; isTsconfigPathsEnabled?: boolean; } ) { // Get the `transformer.assetRegistryPath` // this needs to be unified since you can't dynamically // swap out the transformer based on platform. const assetRegistryPath = fs.realpathSync( // This is the native asset registry alias for native. path.resolve(resolveFrom(projectRoot, 'react-native/Libraries/Image/AssetRegistry')) // NOTE(EvanBacon): This is the newer import but it doesn't work in the expo/expo monorepo. // path.resolve(resolveFrom(projectRoot, '@react-native/assets/registry.js')) ); let reactNativeWebAppContainer: string | null = null; try { reactNativeWebAppContainer = fs.realpathSync( // This is the native asset registry alias for native. path.resolve(resolveFrom(projectRoot, 'expo-router/build/fork/react-native-web-container')) // NOTE(EvanBacon): This is the newer import but it doesn't work in the expo/expo monorepo. // path.resolve(resolveFrom(projectRoot, '@react-native/assets/registry.js')) ); } catch {} const isWebEnabled = platforms.includes('web'); const { resolve } = importMetroResolverFromProject(projectRoot); const extraNodeModules: { [key: string]: Record } = {}; const aliases: { [key: string]: Record } = { web: { 'react-native': 'react-native-web', 'react-native/index': 'react-native-web', }, }; if (isWebEnabled) { // Allow `react-native-web` to be optional when web is not enabled but path aliases is. extraNodeModules['web'] = { 'react-native': path.resolve(require.resolve('react-native-web/package.json'), '..'), }; } const preferredMainFields: { [key: string]: string[] } = { // Defaults from Expo Webpack. Most packages using `react-native` don't support web // in the `react-native` field, so we should prefer the `browser` field. // https://github.com/expo/router/issues/37 web: ['browser', 'module', 'main'], }; let tsConfigResolve = tsconfig?.paths ? resolveWithTsConfigPaths.bind(resolveWithTsConfigPaths, { paths: tsconfig.paths ?? {}, baseUrl: tsconfig.baseUrl, }) : null; if (isTsconfigPathsEnabled && isInteractive()) { // TODO: We should track all the files that used imports and invalidate them // currently the user will need to save all the files that use imports to // use the new aliases. const configWatcher = new FileNotifier(projectRoot, ['./tsconfig.json', './jsconfig.json']); configWatcher.startObserving(() => { debug('Reloading tsconfig.json'); loadTsConfigPathsAsync(projectRoot).then((tsConfigPaths) => { if (tsConfigPaths?.paths && !!Object.keys(tsConfigPaths.paths).length) { debug('Enabling tsconfig.json paths support'); tsConfigResolve = resolveWithTsConfigPaths.bind(resolveWithTsConfigPaths, { paths: tsConfigPaths.paths ?? {}, baseUrl: tsConfigPaths.baseUrl, }); } else { debug('Disabling tsconfig.json paths support'); tsConfigResolve = null; } }); }); // TODO: This probably prevents the process from exiting. installExitHooks(() => { configWatcher.stopObserving(); }); } else { debug('Skipping tsconfig.json paths support'); } let nodejsSourceExtensions: string[] | null = null; return withMetroResolvers(config, projectRoot, [ // Add a resolver to alias the web asset resolver. (immutableContext: ResolutionContext, moduleName: string, platform: string | null) => { let context = { ...immutableContext, } as Mutable & { mainFields: string[]; customResolverOptions?: Record; }; const environment = context.customResolverOptions?.environment; const isNode = environment === 'node'; // TODO: We need to prevent the require.context from including API routes as these use externals. // Should be fine after async routes lands. if (isNode) { const moduleId = isNodeExternal(moduleName); if (moduleId) { moduleName = getNodeExternalModuleId(context.originModulePath, moduleId); debug(`Redirecting Node.js external "${moduleId}" to "${moduleName}"`); } // Adjust nodejs source extensions to sort mjs after js, including platform variants. if (nodejsSourceExtensions === null) { nodejsSourceExtensions = getNodejsExtensions(context.sourceExts); } context.sourceExts = nodejsSourceExtensions; } // Conditionally remap `react-native` to `react-native-web` on web in // a way that doesn't require Babel to resolve the alias. if (platform && platform in aliases && aliases[platform][moduleName]) { moduleName = aliases[platform][moduleName]; } // TODO: We may be able to remove this in the future, it's doing no harm // by staying here. // Conditionally remap `react-native` to `react-native-web` if (platform && platform in extraNodeModules) { context.extraNodeModules = { ...extraNodeModules[platform], ...context.extraNodeModules, }; } if (tsconfig?.baseUrl && isTsconfigPathsEnabled) { context = { ...context, nodeModulesPaths: [ ...immutableContext.nodeModulesPaths, // add last to ensure node modules are resolved first tsconfig.baseUrl, ], }; } let mainFields: string[] = context.mainFields; if (isNode) { // Node.js runtimes should only be importing main at the moment. // This is a temporary fix until we can support the package.json exports. mainFields = ['main', 'module']; } else if (env.EXPO_METRO_NO_MAIN_FIELD_OVERRIDE) { mainFields = context.mainFields; } else if (platform && platform in preferredMainFields) { mainFields = preferredMainFields[platform]; } function doResolve(moduleName: string): Resolution | null { return resolve( { ...context, resolveRequest: undefined, mainFields, // Passing `mainFields` directly won't be considered (in certain version of Metro) // we need to extend the `getPackageMainPath` directly to // use platform specific `mainFields`. // @ts-ignore getPackageMainPath(packageJsonPath) { // @ts-expect-error: mainFields is not on type const package_ = context.moduleCache.getPackage(packageJsonPath); return package_.getMain(mainFields); }, }, moduleName, platform ); } function optionalResolve(moduleName: string): Resolution | null { try { return doResolve(moduleName); } catch (error) { // If the error is directly related to a resolver not being able to resolve a module, then // we can ignore the error and try the next resolver. Otherwise, we should throw the error. const isResolutionError = isFailedToResolveNameError(error) || isFailedToResolvePathError(error); if (!isResolutionError) { throw error; } } return null; } let result: Resolution | null = null; // React Native uses `event-target-shim` incorrectly and this causes the native runtime // to fail to load. This is a temporary workaround until we can fix this upstream. // https://github.com/facebook/react-native/pull/38628 if ( moduleName.includes('event-target-shim') && context.originModulePath.includes(path.sep + 'react-native' + path.sep) ) { context.sourceExts = context.sourceExts.filter((f) => !f.includes('mjs')); debug('Skip mjs support for event-target-shim in:', context.originModulePath); } if (tsConfigResolve) { result = tsConfigResolve( { originModulePath: context.originModulePath, moduleName, }, optionalResolve ); } if ( // is web platform === 'web' && // Not server runtime !isNode && // Is Node.js built-in isNodeExternal(moduleName) ) { // Perform optional resolve first. If the module doesn't exist (no module in the node_modules) // then we can mock the file to use an empty module. result ??= optionalResolve(moduleName); if (!result) { // In this case, mock the file to use an empty module. return { type: 'empty', }; } } result ??= doResolve(moduleName); if (result) { // Replace the web resolver with the original one. // This is basically an alias for web-only. if (shouldAliasAssetRegistryForWeb(platform, result)) { // @ts-expect-error: `readonly` for some reason. result.filePath = assetRegistryPath; } // React Native Web adds a couple extra divs for no reason, these // make static rendering much harder as we expect the root element to be ``. // This resolution will alias to a simple in-out component to avoid React Native web. if ( // Only apply the transform if expo-router is present. reactNativeWebAppContainer && shouldAliasModule( { platform, result, }, { platform: 'web', output: 'react-native-web/dist/exports/AppRegistry/AppContainer.js', } ) ) { // @ts-expect-error: `readonly` for some reason. result.filePath = reactNativeWebAppContainer; } } return result; }, ]); } /** @returns `true` if the incoming resolution should be swapped on web. */ export function shouldAliasAssetRegistryForWeb( platform: string | null, result: Resolution ): boolean { return ( platform === 'web' && result?.type === 'sourceFile' && typeof result?.filePath === 'string' && normalizeSlashes(result.filePath).endsWith( 'react-native-web/dist/modules/AssetRegistry/index.js' ) ); } /** @returns `true` if the incoming resolution should be swapped. */ export function shouldAliasModule( input: { platform: string | null; result: Resolution; }, alias: { platform: string; output: string } ): boolean { return ( input.platform === alias.platform && input.result?.type === 'sourceFile' && typeof input.result?.filePath === 'string' && normalizeSlashes(input.result.filePath).endsWith(alias.output) ); } /** Add support for `react-native-web` and the Web platform. */ export async function withMetroMultiPlatformAsync( projectRoot: string, { config, platformBundlers, isTsconfigPathsEnabled, webOutput, routerDirectory, }: { config: ConfigT; isTsconfigPathsEnabled: boolean; platformBundlers: PlatformBundlers; webOutput?: 'single' | 'static' | 'server'; routerDirectory: string; } ) { // Auto pick app entry for router. process.env.EXPO_ROUTER_APP_ROOT = getAppRouterRelativeEntryPath(projectRoot, routerDirectory); // Required for @expo/metro-runtime to format paths in the web LogBox. process.env.EXPO_PUBLIC_PROJECT_ROOT = process.env.EXPO_PUBLIC_PROJECT_ROOT ?? projectRoot; if (['static', 'server'].includes(webOutput ?? '')) { // Enable static rendering in runtime space. process.env.EXPO_PUBLIC_USE_STATIC = '1'; } // Ensure the cache is invalidated if these values change. // @ts-expect-error config.transformer._expoRouterRootDirectory = process.env.EXPO_ROUTER_APP_ROOT; // @ts-expect-error config.transformer._expoRouterWebRendering = webOutput; // TODO: import mode if (platformBundlers.web === 'metro') { await new WebSupportProjectPrerequisite(projectRoot).assertAsync(); } let tsconfig: null | TsConfigPaths = null; if (isTsconfigPathsEnabled) { Log.warn( chalk.yellow`Experimental path aliases feature is enabled. ` + learnMore('https://docs.expo.dev/guides/typescript/#path-aliases') ); tsconfig = await loadTsConfigPathsAsync(projectRoot); } await setupNodeExternals(projectRoot); return withMetroMultiPlatform(projectRoot, { config, platformBundlers, tsconfig, isTsconfigPathsEnabled, }); } function withMetroMultiPlatform( projectRoot: string, { config, platformBundlers, isTsconfigPathsEnabled, tsconfig, }: { config: ConfigT; isTsconfigPathsEnabled: boolean; platformBundlers: PlatformBundlers; tsconfig: TsConfigPaths | null; } ) { let expoConfigPlatforms = Object.entries(platformBundlers) .filter(([, bundler]) => bundler === 'metro') .map(([platform]) => platform); if (Array.isArray(config.resolver.platforms)) { expoConfigPlatforms = [...new Set(expoConfigPlatforms.concat(config.resolver.platforms))]; } // @ts-expect-error: typed as `readonly`. config.resolver.platforms = expoConfigPlatforms; if (expoConfigPlatforms.includes('web')) { config = withWebPolyfills(config, projectRoot); } return withExtendedResolver(config, { projectRoot, tsconfig, isTsconfigPathsEnabled, platforms: expoConfigPlatforms, }); }