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