1/**
2 * Copyright © 2022 650 Industries.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7import chalk from 'chalk';
8import fs from 'fs';
9import { ConfigT } from 'metro-config';
10import { Resolution, ResolutionContext } from 'metro-resolver';
11import path from 'path';
12import resolveFrom from 'resolve-from';
13
14import {
15  EXTERNAL_REQUIRE_NATIVE_POLYFILL,
16  EXTERNAL_REQUIRE_POLYFILL,
17  getNodeExternalModuleId,
18  isNodeExternal,
19  setupNodeExternals,
20} from './externals';
21import { isFailedToResolveNameError, isFailedToResolvePathError } from './metroErrors';
22import { importMetroResolverFromProject } from './resolveFromProject';
23import { getAppRouterRelativeEntryPath } from './router';
24import { withMetroResolvers } from './withMetroResolvers';
25import { Log } from '../../../log';
26import { FileNotifier } from '../../../utils/FileNotifier';
27import { env } from '../../../utils/env';
28import { installExitHooks } from '../../../utils/exit';
29import { isInteractive } from '../../../utils/interactive';
30import { learnMore } from '../../../utils/link';
31import { loadTsConfigPathsAsync, TsConfigPaths } from '../../../utils/tsconfig/loadTsConfigPaths';
32import { resolveWithTsConfigPaths } from '../../../utils/tsconfig/resolveWithTsConfigPaths';
33import { WebSupportProjectPrerequisite } from '../../doctor/web/WebSupportProjectPrerequisite';
34import { PlatformBundlers } from '../platformBundlers';
35
36type Mutable<T> = { -readonly [K in keyof T]: T[K] };
37
38const debug = require('debug')('expo:start:server:metro:multi-platform') as typeof console.log;
39
40function withWebPolyfills(config: ConfigT, projectRoot: string): ConfigT {
41  const originalGetPolyfills = config.serializer.getPolyfills
42    ? config.serializer.getPolyfills.bind(config.serializer)
43    : () => [];
44
45  const getPolyfills = (ctx: { platform: string | null }): readonly string[] => {
46    if (ctx.platform === 'web') {
47      return [
48        // NOTE: We might need this for all platforms
49        path.join(projectRoot, EXTERNAL_REQUIRE_POLYFILL),
50        // TODO: runtime polyfills, i.e. Fast Refresh, error overlay, React Dev Tools...
51      ];
52    }
53    // Generally uses `rn-get-polyfills`
54    const polyfills = originalGetPolyfills(ctx);
55
56    return [...polyfills, EXTERNAL_REQUIRE_NATIVE_POLYFILL];
57  };
58
59  return {
60    ...config,
61    serializer: {
62      ...config.serializer,
63      getPolyfills,
64    },
65  };
66}
67
68function normalizeSlashes(p: string) {
69  return p.replace(/\\/g, '/');
70}
71
72export function getNodejsExtensions(srcExts: readonly string[]): string[] {
73  const mjsExts = srcExts.filter((ext) => /mjs$/.test(ext));
74  const nodejsSourceExtensions = srcExts.filter((ext) => !/mjs$/.test(ext));
75  // find index of last `*.js` extension
76  const jsIndex = nodejsSourceExtensions.reduce((index, ext, i) => {
77    return /jsx?$/.test(ext) ? i : index;
78  }, -1);
79
80  // insert `*.mjs` extensions after `*.js` extensions
81  nodejsSourceExtensions.splice(jsIndex + 1, 0, ...mjsExts);
82
83  return nodejsSourceExtensions;
84}
85
86/**
87 * Apply custom resolvers to do the following:
88 * - Disable `.native.js` extensions on web.
89 * - Alias `react-native` to `react-native-web` on web.
90 * - Redirect `react-native-web/dist/modules/AssetRegistry/index.js` to `@react-native/assets/registry.js` on web.
91 * - Add support for `tsconfig.json`/`jsconfig.json` aliases via `compilerOptions.paths`.
92 */
93export function withExtendedResolver(
94  config: ConfigT,
95  {
96    projectRoot,
97    tsconfig,
98    platforms,
99    isTsconfigPathsEnabled,
100  }: {
101    projectRoot: string;
102    tsconfig: TsConfigPaths | null;
103    platforms: string[];
104    isTsconfigPathsEnabled?: boolean;
105  }
106) {
107  // Get the `transformer.assetRegistryPath`
108  // this needs to be unified since you can't dynamically
109  // swap out the transformer based on platform.
110  const assetRegistryPath = fs.realpathSync(
111    // This is the native asset registry alias for native.
112    path.resolve(resolveFrom(projectRoot, 'react-native/Libraries/Image/AssetRegistry'))
113    // NOTE(EvanBacon): This is the newer import but it doesn't work in the expo/expo monorepo.
114    // path.resolve(resolveFrom(projectRoot, '@react-native/assets/registry.js'))
115  );
116
117  let reactNativeWebAppContainer: string | null = null;
118  try {
119    reactNativeWebAppContainer = fs.realpathSync(
120      // This is the native asset registry alias for native.
121      path.resolve(resolveFrom(projectRoot, 'expo-router/build/fork/react-native-web-container'))
122      // NOTE(EvanBacon): This is the newer import but it doesn't work in the expo/expo monorepo.
123      // path.resolve(resolveFrom(projectRoot, '@react-native/assets/registry.js'))
124    );
125  } catch {}
126
127  const isWebEnabled = platforms.includes('web');
128
129  const { resolve } = importMetroResolverFromProject(projectRoot);
130
131  const extraNodeModules: { [key: string]: Record<string, string> } = {};
132
133  const aliases: { [key: string]: Record<string, string> } = {
134    web: {
135      'react-native': 'react-native-web',
136      'react-native/index': 'react-native-web',
137    },
138  };
139
140  if (isWebEnabled) {
141    // Allow `react-native-web` to be optional when web is not enabled but path aliases is.
142    extraNodeModules['web'] = {
143      'react-native': path.resolve(require.resolve('react-native-web/package.json'), '..'),
144    };
145  }
146
147  const preferredMainFields: { [key: string]: string[] } = {
148    // Defaults from Expo Webpack. Most packages using `react-native` don't support web
149    // in the `react-native` field, so we should prefer the `browser` field.
150    // https://github.com/expo/router/issues/37
151    web: ['browser', 'module', 'main'],
152  };
153
154  let tsConfigResolve = tsconfig?.paths
155    ? resolveWithTsConfigPaths.bind(resolveWithTsConfigPaths, {
156        paths: tsconfig.paths ?? {},
157        baseUrl: tsconfig.baseUrl,
158      })
159    : null;
160
161  if (isTsconfigPathsEnabled && isInteractive()) {
162    // TODO: We should track all the files that used imports and invalidate them
163    // currently the user will need to save all the files that use imports to
164    // use the new aliases.
165    const configWatcher = new FileNotifier(projectRoot, ['./tsconfig.json', './jsconfig.json']);
166    configWatcher.startObserving(() => {
167      debug('Reloading tsconfig.json');
168      loadTsConfigPathsAsync(projectRoot).then((tsConfigPaths) => {
169        if (tsConfigPaths?.paths && !!Object.keys(tsConfigPaths.paths).length) {
170          debug('Enabling tsconfig.json paths support');
171          tsConfigResolve = resolveWithTsConfigPaths.bind(resolveWithTsConfigPaths, {
172            paths: tsConfigPaths.paths ?? {},
173            baseUrl: tsConfigPaths.baseUrl,
174          });
175        } else {
176          debug('Disabling tsconfig.json paths support');
177          tsConfigResolve = null;
178        }
179      });
180    });
181
182    // TODO: This probably prevents the process from exiting.
183    installExitHooks(() => {
184      configWatcher.stopObserving();
185    });
186  } else {
187    debug('Skipping tsconfig.json paths support');
188  }
189
190  let nodejsSourceExtensions: string[] | null = null;
191
192  return withMetroResolvers(config, projectRoot, [
193    // Add a resolver to alias the web asset resolver.
194    (immutableContext: ResolutionContext, moduleName: string, platform: string | null) => {
195      let context = {
196        ...immutableContext,
197      } as Mutable<ResolutionContext> & {
198        mainFields: string[];
199        customResolverOptions?: Record<string, string>;
200      };
201
202      const environment = context.customResolverOptions?.environment;
203      const isNode = environment === 'node';
204
205      // TODO: We need to prevent the require.context from including API routes as these use externals.
206      // Should be fine after async routes lands.
207      if (isNode) {
208        const moduleId = isNodeExternal(moduleName);
209        if (moduleId) {
210          moduleName = getNodeExternalModuleId(context.originModulePath, moduleId);
211          debug(`Redirecting Node.js external "${moduleId}" to "${moduleName}"`);
212        }
213
214        // Adjust nodejs source extensions to sort mjs after js, including platform variants.
215        if (nodejsSourceExtensions === null) {
216          nodejsSourceExtensions = getNodejsExtensions(context.sourceExts);
217        }
218        context.sourceExts = nodejsSourceExtensions;
219      }
220
221      // Conditionally remap `react-native` to `react-native-web` on web in
222      // a way that doesn't require Babel to resolve the alias.
223      if (platform && platform in aliases && aliases[platform][moduleName]) {
224        moduleName = aliases[platform][moduleName];
225      }
226
227      // TODO: We may be able to remove this in the future, it's doing no harm
228      // by staying here.
229      // Conditionally remap `react-native` to `react-native-web`
230      if (platform && platform in extraNodeModules) {
231        context.extraNodeModules = {
232          ...extraNodeModules[platform],
233          ...context.extraNodeModules,
234        };
235      }
236
237      if (tsconfig?.baseUrl && isTsconfigPathsEnabled) {
238        context = {
239          ...context,
240          nodeModulesPaths: [
241            ...immutableContext.nodeModulesPaths,
242            // add last to ensure node modules are resolved first
243            tsconfig.baseUrl,
244          ],
245        };
246      }
247
248      let mainFields: string[] = context.mainFields;
249
250      if (isNode) {
251        // Node.js runtimes should only be importing main at the moment.
252        // This is a temporary fix until we can support the package.json exports.
253        mainFields = ['main', 'module'];
254      } else if (env.EXPO_METRO_NO_MAIN_FIELD_OVERRIDE) {
255        mainFields = context.mainFields;
256      } else if (platform && platform in preferredMainFields) {
257        mainFields = preferredMainFields[platform];
258      }
259      function doResolve(moduleName: string): Resolution | null {
260        return resolve(
261          {
262            ...context,
263            resolveRequest: undefined,
264            mainFields,
265
266            // Passing `mainFields` directly won't be considered (in certain version of Metro)
267            // we need to extend the `getPackageMainPath` directly to
268            // use platform specific `mainFields`.
269            // @ts-ignore
270            getPackageMainPath(packageJsonPath) {
271              // @ts-expect-error: mainFields is not on type
272              const package_ = context.moduleCache.getPackage(packageJsonPath);
273              return package_.getMain(mainFields);
274            },
275          },
276          moduleName,
277          platform
278        );
279      }
280
281      function optionalResolve(moduleName: string): Resolution | null {
282        try {
283          return doResolve(moduleName);
284        } catch (error) {
285          // If the error is directly related to a resolver not being able to resolve a module, then
286          // we can ignore the error and try the next resolver. Otherwise, we should throw the error.
287          const isResolutionError =
288            isFailedToResolveNameError(error) || isFailedToResolvePathError(error);
289          if (!isResolutionError) {
290            throw error;
291          }
292        }
293        return null;
294      }
295
296      let result: Resolution | null = null;
297
298      // React Native uses `event-target-shim` incorrectly and this causes the native runtime
299      // to fail to load. This is a temporary workaround until we can fix this upstream.
300      // https://github.com/facebook/react-native/pull/38628
301      if (
302        moduleName.includes('event-target-shim') &&
303        context.originModulePath.includes(path.sep + 'react-native' + path.sep)
304      ) {
305        context.sourceExts = context.sourceExts.filter((f) => !f.includes('mjs'));
306        debug('Skip mjs support for event-target-shim in:', context.originModulePath);
307      }
308
309      if (tsConfigResolve) {
310        result = tsConfigResolve(
311          {
312            originModulePath: context.originModulePath,
313            moduleName,
314          },
315          optionalResolve
316        );
317      }
318
319      if (
320        // is web
321        platform === 'web' &&
322        // Not server runtime
323        !isNode &&
324        // Is Node.js built-in
325        isNodeExternal(moduleName)
326      ) {
327        // Perform optional resolve first. If the module doesn't exist (no module in the node_modules)
328        // then we can mock the file to use an empty module.
329        result ??= optionalResolve(moduleName);
330
331        if (!result) {
332          // In this case, mock the file to use an empty module.
333          return {
334            type: 'empty',
335          };
336        }
337      }
338
339      result ??= doResolve(moduleName);
340
341      if (result) {
342        // Replace the web resolver with the original one.
343        // This is basically an alias for web-only.
344        if (shouldAliasAssetRegistryForWeb(platform, result)) {
345          // @ts-expect-error: `readonly` for some reason.
346          result.filePath = assetRegistryPath;
347        }
348
349        // React Native Web adds a couple extra divs for no reason, these
350        // make static rendering much harder as we expect the root element to be `<html>`.
351        // This resolution will alias to a simple in-out component to avoid React Native web.
352        if (
353          // Only apply the transform if expo-router is present.
354          reactNativeWebAppContainer &&
355          shouldAliasModule(
356            {
357              platform,
358              result,
359            },
360            {
361              platform: 'web',
362              output: 'react-native-web/dist/exports/AppRegistry/AppContainer.js',
363            }
364          )
365        ) {
366          // @ts-expect-error: `readonly` for some reason.
367          result.filePath = reactNativeWebAppContainer;
368        }
369      }
370      return result;
371    },
372  ]);
373}
374
375/** @returns `true` if the incoming resolution should be swapped on web. */
376export function shouldAliasAssetRegistryForWeb(
377  platform: string | null,
378  result: Resolution
379): boolean {
380  return (
381    platform === 'web' &&
382    result?.type === 'sourceFile' &&
383    typeof result?.filePath === 'string' &&
384    normalizeSlashes(result.filePath).endsWith(
385      'react-native-web/dist/modules/AssetRegistry/index.js'
386    )
387  );
388}
389/** @returns `true` if the incoming resolution should be swapped. */
390export function shouldAliasModule(
391  input: {
392    platform: string | null;
393    result: Resolution;
394  },
395  alias: { platform: string; output: string }
396): boolean {
397  return (
398    input.platform === alias.platform &&
399    input.result?.type === 'sourceFile' &&
400    typeof input.result?.filePath === 'string' &&
401    normalizeSlashes(input.result.filePath).endsWith(alias.output)
402  );
403}
404
405/** Add support for `react-native-web` and the Web platform. */
406export async function withMetroMultiPlatformAsync(
407  projectRoot: string,
408  {
409    config,
410    platformBundlers,
411    isTsconfigPathsEnabled,
412    webOutput,
413    routerDirectory,
414  }: {
415    config: ConfigT;
416    isTsconfigPathsEnabled: boolean;
417    platformBundlers: PlatformBundlers;
418    webOutput?: 'single' | 'static' | 'server';
419    routerDirectory: string;
420  }
421) {
422  // Auto pick app entry for router.
423  process.env.EXPO_ROUTER_APP_ROOT = getAppRouterRelativeEntryPath(projectRoot, routerDirectory);
424
425  // Required for @expo/metro-runtime to format paths in the web LogBox.
426  process.env.EXPO_PUBLIC_PROJECT_ROOT = process.env.EXPO_PUBLIC_PROJECT_ROOT ?? projectRoot;
427
428  if (['static', 'server'].includes(webOutput ?? '')) {
429    // Enable static rendering in runtime space.
430    process.env.EXPO_PUBLIC_USE_STATIC = '1';
431  }
432
433  // Ensure the cache is invalidated if these values change.
434  // @ts-expect-error
435  config.transformer._expoRouterRootDirectory = process.env.EXPO_ROUTER_APP_ROOT;
436  // @ts-expect-error
437  config.transformer._expoRouterWebRendering = webOutput;
438  // TODO: import mode
439
440  if (platformBundlers.web === 'metro') {
441    await new WebSupportProjectPrerequisite(projectRoot).assertAsync();
442  }
443
444  let tsconfig: null | TsConfigPaths = null;
445
446  if (isTsconfigPathsEnabled) {
447    Log.warn(
448      chalk.yellow`Experimental path aliases feature is enabled. ` +
449        learnMore('https://docs.expo.dev/guides/typescript/#path-aliases')
450    );
451    tsconfig = await loadTsConfigPathsAsync(projectRoot);
452  }
453
454  await setupNodeExternals(projectRoot);
455
456  return withMetroMultiPlatform(projectRoot, {
457    config,
458    platformBundlers,
459    tsconfig,
460    isTsconfigPathsEnabled,
461  });
462}
463
464function withMetroMultiPlatform(
465  projectRoot: string,
466  {
467    config,
468    platformBundlers,
469    isTsconfigPathsEnabled,
470    tsconfig,
471  }: {
472    config: ConfigT;
473    isTsconfigPathsEnabled: boolean;
474    platformBundlers: PlatformBundlers;
475    tsconfig: TsConfigPaths | null;
476  }
477) {
478  let expoConfigPlatforms = Object.entries(platformBundlers)
479    .filter(([, bundler]) => bundler === 'metro')
480    .map(([platform]) => platform);
481
482  if (Array.isArray(config.resolver.platforms)) {
483    expoConfigPlatforms = [...new Set(expoConfigPlatforms.concat(config.resolver.platforms))];
484  }
485
486  // @ts-expect-error: typed as `readonly`.
487  config.resolver.platforms = expoConfigPlatforms;
488
489  if (expoConfigPlatforms.includes('web')) {
490    config = withWebPolyfills(config, projectRoot);
491  }
492
493  return withExtendedResolver(config, {
494    projectRoot,
495    tsconfig,
496    isTsconfigPathsEnabled,
497    platforms: expoConfigPlatforms,
498  });
499}
500