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