1// Copyright 2023-present 650 Industries (Expo). All rights reserved. 2import { getPackageJson } from '@expo/config'; 3import { getBareExtensions } from '@expo/config/paths'; 4import * as runtimeEnv from '@expo/env'; 5import JsonFile from '@expo/json-file'; 6import chalk from 'chalk'; 7import { Reporter } from 'metro'; 8import { stableHash } from 'metro-cache'; 9import { ConfigT as MetroConfig, InputConfigT } from 'metro-config'; 10import path from 'path'; 11import resolveFrom from 'resolve-from'; 12 13import { getDefaultCustomizeFrame, INTERNAL_CALLSITES_REGEX } from './customizeFrame'; 14import { env } from './env'; 15import { getModulesPaths, getServerRoot } from './getModulesPaths'; 16import { getWatchFolders } from './getWatchFolders'; 17import { getRewriteRequestUrl } from './rewriteRequestUrl'; 18import { withExpoSerializers } from './serializer/withExpoSerializers'; 19import { getPostcssConfigHash } from './transform-worker/postcss'; 20import { importMetroConfig } from './traveling/metro-config'; 21 22const debug = require('debug')('expo:metro:config') as typeof console.log; 23 24export interface LoadOptions { 25 config?: string; 26 maxWorkers?: number; 27 port?: number; 28 reporter?: Reporter; 29 resetCache?: boolean; 30} 31 32export interface DefaultConfigOptions { 33 mode?: 'exotic'; 34 /** 35 * **Experimental:** Enable CSS support for Metro web, and shim on native. 36 * 37 * This is an experimental feature and may change in the future. The underlying implementation 38 * is subject to change, and native support for CSS Modules may be added in the future during a non-major SDK release. 39 */ 40 isCSSEnabled?: boolean; 41} 42 43function getAssetPlugins(projectRoot: string): string[] { 44 const hashAssetFilesPath = resolveFrom.silent(projectRoot, 'expo-asset/tools/hashAssetFiles'); 45 46 if (!hashAssetFilesPath) { 47 throw new Error(`The required package \`expo-asset\` cannot be found`); 48 } 49 50 return [hashAssetFilesPath]; 51} 52 53let hasWarnedAboutExotic = false; 54 55export function getDefaultConfig( 56 projectRoot: string, 57 { mode, isCSSEnabled = true }: DefaultConfigOptions = {} 58): InputConfigT { 59 const { getDefaultConfig: getDefaultMetroConfig, mergeConfig } = importMetroConfig(projectRoot); 60 61 const isExotic = mode === 'exotic' || env.EXPO_USE_EXOTIC; 62 63 if (isExotic && !hasWarnedAboutExotic) { 64 hasWarnedAboutExotic = true; 65 console.log( 66 chalk.gray( 67 `\u203A Unstable feature ${chalk.bold`EXPO_USE_EXOTIC`} is enabled. Bundling may not work as expected, and is subject to breaking changes.` 68 ) 69 ); 70 } 71 72 const reactNativePath = path.dirname(resolveFrom(projectRoot, 'react-native/package.json')); 73 74 try { 75 // Set the `EXPO_METRO_CACHE_KEY_VERSION` variable for use in the custom babel transformer. 76 // This hack is used because there doesn't appear to be anyway to resolve 77 // `babel-preset-fbjs` relative to the project root later (in `metro-expo-babel-transformer`). 78 const babelPresetFbjsPath = resolveFrom(projectRoot, 'babel-preset-fbjs/package.json'); 79 process.env.EXPO_METRO_CACHE_KEY_VERSION = String(require(babelPresetFbjsPath).version); 80 } catch { 81 // noop -- falls back to a hardcoded value. 82 } 83 84 const sourceExtsConfig = { isTS: true, isReact: true, isModern: true }; 85 const sourceExts = getBareExtensions([], sourceExtsConfig); 86 87 // Add support for cjs (without platform extensions). 88 sourceExts.push('cjs'); 89 90 const reanimatedVersion = getPkgVersion(projectRoot, 'react-native-reanimated'); 91 92 let sassVersion: string | null = null; 93 if (isCSSEnabled) { 94 sassVersion = getPkgVersion(projectRoot, 'sass'); 95 // Enable SCSS by default so we can provide a better error message 96 // when sass isn't installed. 97 sourceExts.push('scss', 'sass', 'css'); 98 } 99 100 const envFiles = runtimeEnv.getFiles(process.env.NODE_ENV, { silent: true }); 101 102 const pkg = getPackageJson(projectRoot); 103 const watchFolders = getWatchFolders(projectRoot); 104 // TODO: nodeModulesPaths does not work with the new Node.js package.json exports API, this causes packages like uuid to fail. Disabling for now. 105 const nodeModulesPaths = getModulesPaths(projectRoot); 106 if (env.EXPO_DEBUG) { 107 console.log(); 108 console.log(`Expo Metro config:`); 109 try { 110 console.log(`- Version: ${require('../package.json').version}`); 111 } catch {} 112 console.log(`- Extensions: ${sourceExts.join(', ')}`); 113 console.log(`- React Native: ${reactNativePath}`); 114 console.log(`- Watch Folders: ${watchFolders.join(', ')}`); 115 console.log(`- Node Module Paths: ${nodeModulesPaths.join(', ')}`); 116 console.log(`- Exotic: ${isExotic}`); 117 console.log(`- Env Files: ${envFiles}`); 118 console.log(`- Sass: ${sassVersion}`); 119 console.log(`- Reanimated: ${reanimatedVersion}`); 120 console.log(); 121 } 122 const { 123 // Remove the default reporter which metro always resolves to be the react-native-community/cli reporter. 124 // This prints a giant React logo which is less accessible to users on smaller terminals. 125 reporter, 126 ...metroDefaultValues 127 } = getDefaultMetroConfig.getDefaultValues(projectRoot); 128 129 // Merge in the default config from Metro here, even though loadConfig uses it as defaults. 130 // This is a convenience for getDefaultConfig use in metro.config.js, e.g. to modify assetExts. 131 const metroConfig: Partial<MetroConfig> = mergeConfig(metroDefaultValues, { 132 watchFolders, 133 resolver: { 134 // unstable_conditionsByPlatform: { web: ['browser'] }, 135 unstable_conditionNames: ['require', 'import', 'react-native'], 136 resolverMainFields: ['react-native', 'browser', 'main'], 137 platforms: ['ios', 'android'], 138 assetExts: metroDefaultValues.resolver.assetExts 139 .concat( 140 // Add default support for `expo-image` file types. 141 ['heic', 'avif'] 142 ) 143 .filter((assetExt) => !sourceExts.includes(assetExt)), 144 sourceExts, 145 nodeModulesPaths, 146 }, 147 watcher: { 148 // strip starting dot from env files 149 additionalExts: envFiles.map((file: string) => file.replace(/^\./, '')), 150 }, 151 serializer: { 152 getModulesRunBeforeMainModule: () => { 153 const preModules: string[] = [ 154 // MUST be first 155 require.resolve(path.join(reactNativePath, 'Libraries/Core/InitializeCore')), 156 ]; 157 158 // We need to shift this to be the first module so web Fast Refresh works as expected. 159 // This will only be applied if the module is installed and imported somewhere in the bundle already. 160 const metroRuntime = resolveFrom.silent(projectRoot, '@expo/metro-runtime'); 161 if (metroRuntime) { 162 preModules.push(metroRuntime); 163 } 164 165 return preModules; 166 }, 167 getPolyfills: () => require(path.join(reactNativePath, 'rn-get-polyfills'))(), 168 }, 169 server: { 170 rewriteRequestUrl: getRewriteRequestUrl(projectRoot), 171 port: Number(env.RCT_METRO_PORT) || 8081, 172 // NOTE(EvanBacon): Moves the server root down to the monorepo root. 173 // This enables proper monorepo support for web. 174 unstable_serverRoot: getServerRoot(projectRoot), 175 }, 176 symbolicator: { 177 customizeFrame: getDefaultCustomizeFrame(), 178 }, 179 transformerPath: isCSSEnabled 180 ? // Custom worker that adds CSS support for Metro web. 181 require.resolve('./transform-worker/transform-worker') 182 : metroDefaultValues.transformerPath, 183 184 transformer: { 185 // Custom: These are passed to `getCacheKey` and ensure invalidation when the version changes. 186 // @ts-expect-error: not on type. 187 postcssHash: getPostcssConfigHash(projectRoot), 188 browserslistHash: pkg.browserslist 189 ? stableHash(JSON.stringify(pkg.browserslist)).toString('hex') 190 : null, 191 sassVersion, 192 // Ensure invalidation when the version changes due to the Babel plugin. 193 reanimatedVersion, 194 195 // `require.context` support 196 unstable_allowRequireContext: true, 197 allowOptionalDependencies: true, 198 babelTransformerPath: isExotic 199 ? // TODO: Combine these into one transformer. 200 require.resolve('./transformer/metro-expo-exotic-babel-transformer') 201 : require.resolve('./babel-transformer'), 202 assetRegistryPath: 'react-native/Libraries/Image/AssetRegistry', 203 assetPlugins: getAssetPlugins(projectRoot), 204 }, 205 }); 206 207 return withExpoSerializers(metroConfig); 208} 209 210export async function loadAsync( 211 projectRoot: string, 212 { reporter, ...metroOptions }: LoadOptions = {} 213): Promise<MetroConfig> { 214 let defaultConfig = getDefaultConfig(projectRoot); 215 if (reporter) { 216 defaultConfig = { ...defaultConfig, reporter }; 217 } 218 219 const { loadConfig } = importMetroConfig(projectRoot); 220 221 return await loadConfig({ cwd: projectRoot, projectRoot, ...metroOptions }, defaultConfig); 222} 223 224// re-export for use in config files. 225export { MetroConfig, INTERNAL_CALLSITES_REGEX }; 226 227// re-export for legacy cases. 228export const EXPO_DEBUG = env.EXPO_DEBUG; 229 230function getPkgVersion(projectRoot: string, pkgName: string): string | null { 231 const targetPkg = resolveFrom.silent(projectRoot, pkgName); 232 if (!targetPkg) return null; 233 const targetPkgJson = findUpPackageJson(targetPkg); 234 if (!targetPkgJson) return null; 235 const pkg = JsonFile.read(targetPkgJson); 236 237 debug(`${pkgName} package.json:`, targetPkgJson); 238 const pkgVersion = pkg.version; 239 if (typeof pkgVersion === 'string') { 240 return pkgVersion; 241 } 242 243 return null; 244} 245 246function findUpPackageJson(cwd: string): string | null { 247 if (['.', path.sep].includes(cwd)) return null; 248 249 const found = resolveFrom.silent(cwd, './package.json'); 250 if (found) { 251 return found; 252 } 253 return findUpPackageJson(path.dirname(cwd)); 254} 255