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 options: DefaultConfigOptions = {} 58): InputConfigT { 59 const { getDefaultConfig: getDefaultMetroConfig, mergeConfig } = importMetroConfig(projectRoot); 60 61 const isExotic = options.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 let sassVersion: string | null = null; 91 if (options.isCSSEnabled) { 92 sassVersion = getSassVersion(projectRoot); 93 // Enable SCSS by default so we can provide a better error message 94 // when sass isn't installed. 95 sourceExts.push('scss', 'sass', 'css'); 96 } 97 98 const envFiles = runtimeEnv.getFiles(process.env.NODE_ENV, { silent: true }); 99 100 const pkg = getPackageJson(projectRoot); 101 const watchFolders = getWatchFolders(projectRoot); 102 // TODO: nodeModulesPaths does not work with the new Node.js package.json exports API, this causes packages like uuid to fail. Disabling for now. 103 const nodeModulesPaths = getModulesPaths(projectRoot); 104 if (env.EXPO_DEBUG) { 105 console.log(); 106 console.log(`Expo Metro config:`); 107 try { 108 console.log(`- Version: ${require('../package.json').version}`); 109 } catch {} 110 console.log(`- Extensions: ${sourceExts.join(', ')}`); 111 console.log(`- React Native: ${reactNativePath}`); 112 console.log(`- Watch Folders: ${watchFolders.join(', ')}`); 113 console.log(`- Node Module Paths: ${nodeModulesPaths.join(', ')}`); 114 console.log(`- Exotic: ${isExotic}`); 115 console.log(`- Env Files: ${envFiles}`); 116 console.log(`- Sass: ${sassVersion}`); 117 console.log(); 118 } 119 const { 120 // Remove the default reporter which metro always resolves to be the react-native-community/cli reporter. 121 // This prints a giant React logo which is less accessible to users on smaller terminals. 122 reporter, 123 ...metroDefaultValues 124 } = getDefaultMetroConfig.getDefaultValues(projectRoot); 125 126 // Merge in the default config from Metro here, even though loadConfig uses it as defaults. 127 // This is a convenience for getDefaultConfig use in metro.config.js, e.g. to modify assetExts. 128 const metroConfig: Partial<MetroConfig> = mergeConfig(metroDefaultValues, { 129 watchFolders, 130 resolver: { 131 // unstable_conditionsByPlatform: { web: ['browser'] }, 132 unstable_conditionNames: ['require', 'import', 'react-native'], 133 resolverMainFields: ['react-native', 'browser', 'main'], 134 platforms: ['ios', 'android'], 135 assetExts: metroDefaultValues.resolver.assetExts 136 .concat( 137 // Add default support for `expo-image` file types. 138 ['heic', 'avif'] 139 ) 140 .filter((assetExt) => !sourceExts.includes(assetExt)), 141 sourceExts, 142 nodeModulesPaths, 143 }, 144 watcher: { 145 // strip starting dot from env files 146 additionalExts: envFiles.map((file: string) => file.replace(/^\./, '')), 147 }, 148 serializer: { 149 getModulesRunBeforeMainModule: () => { 150 const preModules: string[] = [ 151 // MUST be first 152 require.resolve(path.join(reactNativePath, 'Libraries/Core/InitializeCore')), 153 ]; 154 155 // We need to shift this to be the first module so web Fast Refresh works as expected. 156 // This will only be applied if the module is installed and imported somewhere in the bundle already. 157 const metroRuntime = resolveFrom.silent(projectRoot, '@expo/metro-runtime'); 158 if (metroRuntime) { 159 preModules.push(metroRuntime); 160 } 161 162 return preModules; 163 }, 164 getPolyfills: () => require(path.join(reactNativePath, 'rn-get-polyfills'))(), 165 }, 166 server: { 167 rewriteRequestUrl: getRewriteRequestUrl(projectRoot), 168 port: Number(env.RCT_METRO_PORT) || 8081, 169 // NOTE(EvanBacon): Moves the server root down to the monorepo root. 170 // This enables proper monorepo support for web. 171 unstable_serverRoot: getServerRoot(projectRoot), 172 }, 173 symbolicator: { 174 customizeFrame: getDefaultCustomizeFrame(), 175 }, 176 transformerPath: options.isCSSEnabled 177 ? // Custom worker that adds CSS support for Metro web. 178 require.resolve('./transform-worker/transform-worker') 179 : metroDefaultValues.transformerPath, 180 181 transformer: { 182 // Custom: These are passed to `getCacheKey` and ensure invalidation when the version changes. 183 // @ts-expect-error: not on type. 184 postcssHash: getPostcssConfigHash(projectRoot), 185 browserslistHash: pkg.browserslist 186 ? stableHash(JSON.stringify(pkg.browserslist)).toString('hex') 187 : null, 188 sassVersion, 189 190 // `require.context` support 191 unstable_allowRequireContext: true, 192 allowOptionalDependencies: true, 193 babelTransformerPath: isExotic 194 ? // TODO: Combine these into one transformer. 195 require.resolve('./transformer/metro-expo-exotic-babel-transformer') 196 : require.resolve('./babel-transformer'), 197 assetRegistryPath: 'react-native/Libraries/Image/AssetRegistry', 198 assetPlugins: getAssetPlugins(projectRoot), 199 }, 200 }); 201 202 return withExpoSerializers(metroConfig); 203} 204 205export async function loadAsync( 206 projectRoot: string, 207 { reporter, ...metroOptions }: LoadOptions = {} 208): Promise<MetroConfig> { 209 let defaultConfig = getDefaultConfig(projectRoot); 210 if (reporter) { 211 defaultConfig = { ...defaultConfig, reporter }; 212 } 213 214 const { loadConfig } = importMetroConfig(projectRoot); 215 216 return await loadConfig({ cwd: projectRoot, projectRoot, ...metroOptions }, defaultConfig); 217} 218 219// re-export for use in config files. 220export { MetroConfig, INTERNAL_CALLSITES_REGEX }; 221 222// re-export for legacy cases. 223export const EXPO_DEBUG = env.EXPO_DEBUG; 224 225function getSassVersion(projectRoot: string): string | null { 226 const sassPkg = resolveFrom.silent(projectRoot, 'sass'); 227 if (!sassPkg) return null; 228 const sassPkgJson = findUpPackageJson(sassPkg); 229 if (!sassPkgJson) return null; 230 const pkg = JsonFile.read(sassPkgJson); 231 232 debug('sass package.json:', sassPkgJson); 233 const sassVersion = pkg.version; 234 if (typeof sassVersion === 'string') { 235 return sassVersion; 236 } 237 238 return null; 239} 240 241function findUpPackageJson(cwd: string): string | null { 242 if (['.', path.sep].includes(cwd)) return null; 243 244 const found = resolveFrom.silent(cwd, './package.json'); 245 if (found) { 246 return found; 247 } 248 return findUpPackageJson(path.dirname(cwd)); 249} 250