1837127edSEvan Bacon/**
2837127edSEvan Bacon * Copyright © 2022 650 Industries.
3837127edSEvan Bacon *
4837127edSEvan Bacon * This source code is licensed under the MIT license found in the
5837127edSEvan Bacon * LICENSE file in the root directory of this source tree.
6837127edSEvan Bacon */
72fbedb18SEvan Baconimport chalk from 'chalk';
8837127edSEvan Baconimport { ConfigT as MetroConfig } from 'metro-config';
9837127edSEvan Baconimport { ResolutionContext } from 'metro-resolver';
102fbedb18SEvan Baconimport path from 'path';
11837127edSEvan Bacon
12837127edSEvan Baconimport { isFailedToResolveNameError, isFailedToResolvePathError } from './metroErrors';
13837127edSEvan Baconimport { importMetroResolverFromProject } from './resolveFromProject';
14*8a424bebSJames Ideimport { env } from '../../../utils/env';
15837127edSEvan Bacon
16837127edSEvan Baconconst debug = require('debug')('expo:metro:withMetroResolvers') as typeof console.log;
17837127edSEvan Bacon
18837127edSEvan Baconexport type MetroResolver = NonNullable<MetroConfig['resolver']['resolveRequest']>;
19837127edSEvan Bacon
20837127edSEvan Bacon/** Expo Metro Resolvers can return `null` to skip without throwing an error. Metro Resolvers will throw either a `FailedToResolveNameError` or `FailedToResolvePathError`. */
21837127edSEvan Baconexport type ExpoCustomMetroResolver = (
22837127edSEvan Bacon  ...args: Parameters<MetroResolver>
23837127edSEvan Bacon) => ReturnType<MetroResolver> | null;
24837127edSEvan Bacon
25837127edSEvan Bacon/** @returns `MetroResolver` utilizing the upstream `resolve` method. */
26837127edSEvan Baconexport function getDefaultMetroResolver(projectRoot: string): MetroResolver {
27837127edSEvan Bacon  const { resolve } = importMetroResolverFromProject(projectRoot);
28837127edSEvan Bacon  return (context: ResolutionContext, moduleName: string, platform: string | null) => {
29837127edSEvan Bacon    return resolve(context, moduleName, platform);
30837127edSEvan Bacon  };
31837127edSEvan Bacon}
32837127edSEvan Bacon
332fbedb18SEvan Baconfunction optionsKeyForContext(context: ResolutionContext) {
342fbedb18SEvan Bacon  const canonicalize = require('metro-core/src/canonicalize');
352fbedb18SEvan Bacon
362fbedb18SEvan Bacon  // Compound key for the resolver cache
372fbedb18SEvan Bacon  return JSON.stringify(context.customResolverOptions ?? {}, canonicalize) ?? '';
382fbedb18SEvan Bacon}
392fbedb18SEvan Bacon
40837127edSEvan Bacon/**
41837127edSEvan Bacon * Extend the Metro config `resolver.resolveRequest` method with additional resolvers that can
42837127edSEvan Bacon * exit early by returning a `Resolution` or skip to the next resolver by returning `null`.
43837127edSEvan Bacon *
44837127edSEvan Bacon * @param config Metro config.
45837127edSEvan Bacon * @param projectRoot path to the project root used to resolve the default Metro resolver.
46837127edSEvan Bacon * @param resolvers custom MetroResolver to chain.
47837127edSEvan Bacon * @returns a new `MetroConfig` with the `resolver.resolveRequest` method chained.
48837127edSEvan Bacon */
49837127edSEvan Baconexport function withMetroResolvers(
50837127edSEvan Bacon  config: MetroConfig,
51837127edSEvan Bacon  projectRoot: string,
52837127edSEvan Bacon  resolvers: ExpoCustomMetroResolver[]
53837127edSEvan Bacon): MetroConfig {
54837127edSEvan Bacon  debug(
55837127edSEvan Bacon    `Appending ${
56837127edSEvan Bacon      resolvers.length
574e5f28eeSEvan Bacon    } custom resolvers to Metro config. (has custom resolver: ${!!config.resolver?.resolveRequest})`
58837127edSEvan Bacon  );
59837127edSEvan Bacon  const originalResolveRequest =
604e5f28eeSEvan Bacon    config.resolver?.resolveRequest || getDefaultMetroResolver(projectRoot);
61837127edSEvan Bacon
622fbedb18SEvan Bacon  function mutateResolutionError(
632fbedb18SEvan Bacon    error: Error,
642fbedb18SEvan Bacon    context: ResolutionContext,
652fbedb18SEvan Bacon    moduleName: string,
662fbedb18SEvan Bacon    platform: string | null
672fbedb18SEvan Bacon  ) {
682fbedb18SEvan Bacon    if (!env.EXPO_METRO_UNSTABLE_ERRORS || !platform) {
692fbedb18SEvan Bacon      debug('Cannot mutate resolution error');
702fbedb18SEvan Bacon      return error;
712fbedb18SEvan Bacon    }
722fbedb18SEvan Bacon
732fbedb18SEvan Bacon    const mapByOrigin = depGraph.get(optionsKeyForContext(context));
742fbedb18SEvan Bacon    const mapByPlatform = mapByOrigin?.get(platform);
752fbedb18SEvan Bacon
762fbedb18SEvan Bacon    if (!mapByPlatform) {
772fbedb18SEvan Bacon      return error;
782fbedb18SEvan Bacon    }
792fbedb18SEvan Bacon
802fbedb18SEvan Bacon    // collect all references inversely using some expensive lookup
812fbedb18SEvan Bacon
822fbedb18SEvan Bacon    const getReferences = (origin: string) => {
832fbedb18SEvan Bacon      const inverseOrigin: { origin: string; previous: string; request: string }[] = [];
842fbedb18SEvan Bacon
852fbedb18SEvan Bacon      if (!mapByPlatform) {
862fbedb18SEvan Bacon        return inverseOrigin;
872fbedb18SEvan Bacon      }
882fbedb18SEvan Bacon
892fbedb18SEvan Bacon      for (const [originKey, mapByTarget] of mapByPlatform) {
902fbedb18SEvan Bacon        // search comparing origin to path
912fbedb18SEvan Bacon
922fbedb18SEvan Bacon        const found = [...mapByTarget.values()].find((resolution) => resolution.path === origin);
932fbedb18SEvan Bacon        if (found) {
942fbedb18SEvan Bacon          inverseOrigin.push({
952fbedb18SEvan Bacon            origin,
962fbedb18SEvan Bacon            previous: originKey,
972fbedb18SEvan Bacon            request: found.request,
982fbedb18SEvan Bacon          });
992fbedb18SEvan Bacon        }
1002fbedb18SEvan Bacon      }
1012fbedb18SEvan Bacon
1022fbedb18SEvan Bacon      return inverseOrigin;
1032fbedb18SEvan Bacon    };
1042fbedb18SEvan Bacon
1052fbedb18SEvan Bacon    const pad = (num: number) => {
1062fbedb18SEvan Bacon      return new Array(num).fill(' ').join('');
1072fbedb18SEvan Bacon    };
1082fbedb18SEvan Bacon
1092fbedb18SEvan Bacon    const root = config.server?.unstable_serverRoot ?? config.projectRoot ?? projectRoot;
1102fbedb18SEvan Bacon
1112fbedb18SEvan Bacon    type InverseDepResult = {
1122fbedb18SEvan Bacon      origin: string;
1132fbedb18SEvan Bacon      request: string;
1142fbedb18SEvan Bacon      previous: InverseDepResult[];
1152fbedb18SEvan Bacon    };
1162fbedb18SEvan Bacon    const recurseBackWithLimit = (
1172fbedb18SEvan Bacon      req: { origin: string; request: string },
1182fbedb18SEvan Bacon      limit: number,
1192fbedb18SEvan Bacon      count: number = 0
1202fbedb18SEvan Bacon    ) => {
1212fbedb18SEvan Bacon      const results: InverseDepResult = {
1222fbedb18SEvan Bacon        origin: req.origin,
1232fbedb18SEvan Bacon        request: req.request,
1242fbedb18SEvan Bacon        previous: [],
1252fbedb18SEvan Bacon      };
1262fbedb18SEvan Bacon
1272fbedb18SEvan Bacon      if (count >= limit) {
1282fbedb18SEvan Bacon        return results;
1292fbedb18SEvan Bacon      }
1302fbedb18SEvan Bacon
1312fbedb18SEvan Bacon      const inverse = getReferences(req.origin);
1322fbedb18SEvan Bacon      for (const match of inverse) {
1332fbedb18SEvan Bacon        // Use more qualified name if possible
1342fbedb18SEvan Bacon        // results.origin = match.origin;
1352fbedb18SEvan Bacon        // Found entry point
1362fbedb18SEvan Bacon        if (req.origin === match.previous) {
1372fbedb18SEvan Bacon          continue;
1382fbedb18SEvan Bacon        }
1392fbedb18SEvan Bacon        results.previous.push(
1402fbedb18SEvan Bacon          recurseBackWithLimit({ origin: match.previous, request: match.request }, limit, count + 1)
1412fbedb18SEvan Bacon        );
1422fbedb18SEvan Bacon      }
1432fbedb18SEvan Bacon      return results;
1442fbedb18SEvan Bacon    };
1452fbedb18SEvan Bacon
1462fbedb18SEvan Bacon    const inverseTree = recurseBackWithLimit(
1472fbedb18SEvan Bacon      { origin: context.originModulePath, request: moduleName },
1482fbedb18SEvan Bacon      // TODO: Do we need to expose this?
1492fbedb18SEvan Bacon      35
1502fbedb18SEvan Bacon    );
1512fbedb18SEvan Bacon
1522fbedb18SEvan Bacon    if (inverseTree.previous.length > 0) {
1532fbedb18SEvan Bacon      debug('Found inverse graph:', JSON.stringify(inverseTree, null, 2));
1542fbedb18SEvan Bacon      let extraMessage = chalk.bold('Import stack:');
1552fbedb18SEvan Bacon      const printRecursive = (tree: InverseDepResult, depth: number = 0) => {
1562fbedb18SEvan Bacon        let filename = path.relative(root, tree.origin);
1572fbedb18SEvan Bacon        if (filename.match(/\?ctx=[\w\d]+$/)) {
1582fbedb18SEvan Bacon          filename = filename.replace(/\?ctx=[\w\d]+$/, chalk.dim(' (require.context)'));
1592fbedb18SEvan Bacon        } else {
1602fbedb18SEvan Bacon          let formattedRequest = chalk.green(`"${tree.request}"`);
1612fbedb18SEvan Bacon
1622fbedb18SEvan Bacon          if (
1632fbedb18SEvan Bacon            // If bundling for web and the import is pulling internals from outside of react-native
1642fbedb18SEvan Bacon            // then mark it as an invalid import.
1652fbedb18SEvan Bacon            platform === 'web' &&
1662fbedb18SEvan Bacon            !/^(node_modules\/)?react-native\//.test(filename) &&
1672fbedb18SEvan Bacon            tree.request.match(/^react-native\/.*/)
1682fbedb18SEvan Bacon          ) {
1692fbedb18SEvan Bacon            formattedRequest =
1702fbedb18SEvan Bacon              formattedRequest +
1712fbedb18SEvan Bacon              chalk`\n          {yellow Importing react-native internals is not supported on web.}`;
1722fbedb18SEvan Bacon          }
1732fbedb18SEvan Bacon
1742fbedb18SEvan Bacon          filename = filename + chalk`\n{gray  |} {cyan import} ${formattedRequest}\n`;
1752fbedb18SEvan Bacon        }
1762fbedb18SEvan Bacon        let line = '\n' + pad(depth) + chalk.gray(' ') + filename;
1772fbedb18SEvan Bacon        if (filename.match(/node_modules/)) {
1782fbedb18SEvan Bacon          line = chalk.gray(
1792fbedb18SEvan Bacon            // Bold the node module name
1802fbedb18SEvan Bacon            line.replace(/node_modules\/([^/]+)/, (_match, p1) => {
1812fbedb18SEvan Bacon              return 'node_modules/' + chalk.bold(p1);
1822fbedb18SEvan Bacon            })
1832fbedb18SEvan Bacon          );
1842fbedb18SEvan Bacon        }
1852fbedb18SEvan Bacon        extraMessage += line;
1862fbedb18SEvan Bacon        for (const child of tree.previous) {
1872fbedb18SEvan Bacon          printRecursive(
1882fbedb18SEvan Bacon            child,
1892fbedb18SEvan Bacon            // Only add depth if there are multiple children
1902fbedb18SEvan Bacon            tree.previous.length > 1 ? depth + 1 : depth
1912fbedb18SEvan Bacon          );
1922fbedb18SEvan Bacon        }
1932fbedb18SEvan Bacon      };
1942fbedb18SEvan Bacon      printRecursive(inverseTree);
1952fbedb18SEvan Bacon
1962fbedb18SEvan Bacon      // @ts-expect-error
1972fbedb18SEvan Bacon      error._expoImportStack = extraMessage;
1982fbedb18SEvan Bacon    } else {
1992fbedb18SEvan Bacon      debug('Found no inverse tree for:', context.originModulePath);
2002fbedb18SEvan Bacon    }
2012fbedb18SEvan Bacon
2022fbedb18SEvan Bacon    return error;
2032fbedb18SEvan Bacon  }
2042fbedb18SEvan Bacon
2052fbedb18SEvan Bacon  const depGraph: Map<
2062fbedb18SEvan Bacon    // custom options
2072fbedb18SEvan Bacon    string,
2082fbedb18SEvan Bacon    Map<
2092fbedb18SEvan Bacon      // platform
2102fbedb18SEvan Bacon      string,
2112fbedb18SEvan Bacon      Map<
2122fbedb18SEvan Bacon        // origin module name
2132fbedb18SEvan Bacon        string,
2142fbedb18SEvan Bacon        Set<{
2152fbedb18SEvan Bacon          // required module name
2162fbedb18SEvan Bacon          path: string;
2172fbedb18SEvan Bacon          // This isn't entirely accurate since a module can be imported multiple times in a file,
2182fbedb18SEvan Bacon          // and use different names. But it's good enough for now.
2192fbedb18SEvan Bacon          request: string;
2202fbedb18SEvan Bacon        }>
2212fbedb18SEvan Bacon      >
2222fbedb18SEvan Bacon    >
2232fbedb18SEvan Bacon  > = new Map();
2242fbedb18SEvan Bacon
225837127edSEvan Bacon  return {
226837127edSEvan Bacon    ...config,
227837127edSEvan Bacon    resolver: {
228837127edSEvan Bacon      ...config.resolver,
2296fb512faSEvan Bacon      resolveRequest(context, moduleName, platform) {
2302fbedb18SEvan Bacon        const storeResult = (res: NonNullable<ReturnType<ExpoCustomMetroResolver>>) => {
2312fbedb18SEvan Bacon          if (!env.EXPO_METRO_UNSTABLE_ERRORS || !platform) return;
2322fbedb18SEvan Bacon
2332fbedb18SEvan Bacon          const key = optionsKeyForContext(context);
2342fbedb18SEvan Bacon          if (!depGraph.has(key)) depGraph.set(key, new Map());
2352fbedb18SEvan Bacon          const mapByTarget = depGraph.get(key);
2362fbedb18SEvan Bacon          if (!mapByTarget!.has(platform)) mapByTarget!.set(platform, new Map());
2372fbedb18SEvan Bacon          const mapByPlatform = mapByTarget!.get(platform);
2382fbedb18SEvan Bacon          if (!mapByPlatform!.has(context.originModulePath))
2392fbedb18SEvan Bacon            mapByPlatform!.set(context.originModulePath, new Set());
2402fbedb18SEvan Bacon          const setForModule = mapByPlatform!.get(context.originModulePath)!;
2412fbedb18SEvan Bacon
2422fbedb18SEvan Bacon          const qualifiedModuleName = res?.type === 'sourceFile' ? res.filePath : moduleName;
2432fbedb18SEvan Bacon          setForModule.add({ path: qualifiedModuleName, request: moduleName });
2442fbedb18SEvan Bacon        };
2452fbedb18SEvan Bacon
2466fb512faSEvan Bacon        const universalContext = {
2476fb512faSEvan Bacon          ...context,
2486fb512faSEvan Bacon          preferNativePlatform: platform !== 'web',
2496fb512faSEvan Bacon        };
2506fb512faSEvan Bacon
2512fbedb18SEvan Bacon        try {
252837127edSEvan Bacon          for (const resolver of resolvers) {
253837127edSEvan Bacon            try {
2546fb512faSEvan Bacon              const resolution = resolver(universalContext, moduleName, platform);
255837127edSEvan Bacon              if (resolution) {
2562fbedb18SEvan Bacon                storeResult(resolution);
257837127edSEvan Bacon                return resolution;
258837127edSEvan Bacon              }
259837127edSEvan Bacon            } catch (error: any) {
2606fb512faSEvan Bacon              // If no user-defined resolver, use Expo's default behavior.
2616fb512faSEvan Bacon              // This prevents extraneous resolution attempts on failure.
2626fb512faSEvan Bacon              if (!config.resolver.resolveRequest) {
2636fb512faSEvan Bacon                throw error;
2646fb512faSEvan Bacon              }
2656fb512faSEvan Bacon
266837127edSEvan Bacon              // If the error is directly related to a resolver not being able to resolve a module, then
267837127edSEvan Bacon              // we can ignore the error and try the next resolver. Otherwise, we should throw the error.
268837127edSEvan Bacon              const isResolutionError =
269837127edSEvan Bacon                isFailedToResolveNameError(error) || isFailedToResolvePathError(error);
270837127edSEvan Bacon              if (!isResolutionError) {
271837127edSEvan Bacon                throw error;
272837127edSEvan Bacon              }
273837127edSEvan Bacon              debug(
2746fb512faSEvan Bacon                `Custom resolver threw: ${error.constructor.name}. (module: ${moduleName}, platform: ${platform})`
275837127edSEvan Bacon              );
276837127edSEvan Bacon            }
277837127edSEvan Bacon          }
278837127edSEvan Bacon          // If we haven't returned by now, use the original resolver or upstream resolver.
2792fbedb18SEvan Bacon          const res = originalResolveRequest(universalContext, moduleName, platform);
2802fbedb18SEvan Bacon          storeResult(res);
2812fbedb18SEvan Bacon          return res;
2822fbedb18SEvan Bacon        } catch (error: any) {
2832fbedb18SEvan Bacon          throw mutateResolutionError(error, universalContext, moduleName, platform);
2842fbedb18SEvan Bacon        }
285837127edSEvan Bacon      },
286837127edSEvan Bacon    },
287837127edSEvan Bacon  };
288837127edSEvan Bacon}
289