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 { ConfigT as MetroConfig } from 'metro-config';
9import { ResolutionContext } from 'metro-resolver';
10import path from 'path';
11
12import { isFailedToResolveNameError, isFailedToResolvePathError } from './metroErrors';
13import { importMetroResolverFromProject } from './resolveFromProject';
14import { env } from '../../../utils/env';
15
16const debug = require('debug')('expo:metro:withMetroResolvers') as typeof console.log;
17
18export type MetroResolver = NonNullable<MetroConfig['resolver']['resolveRequest']>;
19
20/** Expo Metro Resolvers can return `null` to skip without throwing an error. Metro Resolvers will throw either a `FailedToResolveNameError` or `FailedToResolvePathError`. */
21export type ExpoCustomMetroResolver = (
22  ...args: Parameters<MetroResolver>
23) => ReturnType<MetroResolver> | null;
24
25/** @returns `MetroResolver` utilizing the upstream `resolve` method. */
26export function getDefaultMetroResolver(projectRoot: string): MetroResolver {
27  const { resolve } = importMetroResolverFromProject(projectRoot);
28  return (context: ResolutionContext, moduleName: string, platform: string | null) => {
29    return resolve(context, moduleName, platform);
30  };
31}
32
33function optionsKeyForContext(context: ResolutionContext) {
34  const canonicalize = require('metro-core/src/canonicalize');
35
36  // Compound key for the resolver cache
37  return JSON.stringify(context.customResolverOptions ?? {}, canonicalize) ?? '';
38}
39
40/**
41 * Extend the Metro config `resolver.resolveRequest` method with additional resolvers that can
42 * exit early by returning a `Resolution` or skip to the next resolver by returning `null`.
43 *
44 * @param config Metro config.
45 * @param projectRoot path to the project root used to resolve the default Metro resolver.
46 * @param resolvers custom MetroResolver to chain.
47 * @returns a new `MetroConfig` with the `resolver.resolveRequest` method chained.
48 */
49export function withMetroResolvers(
50  config: MetroConfig,
51  projectRoot: string,
52  resolvers: ExpoCustomMetroResolver[]
53): MetroConfig {
54  debug(
55    `Appending ${
56      resolvers.length
57    } custom resolvers to Metro config. (has custom resolver: ${!!config.resolver?.resolveRequest})`
58  );
59  const originalResolveRequest =
60    config.resolver?.resolveRequest || getDefaultMetroResolver(projectRoot);
61
62  function mutateResolutionError(
63    error: Error,
64    context: ResolutionContext,
65    moduleName: string,
66    platform: string | null
67  ) {
68    if (!env.EXPO_METRO_UNSTABLE_ERRORS || !platform) {
69      debug('Cannot mutate resolution error');
70      return error;
71    }
72
73    const mapByOrigin = depGraph.get(optionsKeyForContext(context));
74    const mapByPlatform = mapByOrigin?.get(platform);
75
76    if (!mapByPlatform) {
77      return error;
78    }
79
80    // collect all references inversely using some expensive lookup
81
82    const getReferences = (origin: string) => {
83      const inverseOrigin: { origin: string; previous: string; request: string }[] = [];
84
85      if (!mapByPlatform) {
86        return inverseOrigin;
87      }
88
89      for (const [originKey, mapByTarget] of mapByPlatform) {
90        // search comparing origin to path
91
92        const found = [...mapByTarget.values()].find((resolution) => resolution.path === origin);
93        if (found) {
94          inverseOrigin.push({
95            origin,
96            previous: originKey,
97            request: found.request,
98          });
99        }
100      }
101
102      return inverseOrigin;
103    };
104
105    const pad = (num: number) => {
106      return new Array(num).fill(' ').join('');
107    };
108
109    const root = config.server?.unstable_serverRoot ?? config.projectRoot ?? projectRoot;
110
111    type InverseDepResult = {
112      origin: string;
113      request: string;
114      previous: InverseDepResult[];
115    };
116    const recurseBackWithLimit = (
117      req: { origin: string; request: string },
118      limit: number,
119      count: number = 0
120    ) => {
121      const results: InverseDepResult = {
122        origin: req.origin,
123        request: req.request,
124        previous: [],
125      };
126
127      if (count >= limit) {
128        return results;
129      }
130
131      const inverse = getReferences(req.origin);
132      for (const match of inverse) {
133        // Use more qualified name if possible
134        // results.origin = match.origin;
135        // Found entry point
136        if (req.origin === match.previous) {
137          continue;
138        }
139        results.previous.push(
140          recurseBackWithLimit({ origin: match.previous, request: match.request }, limit, count + 1)
141        );
142      }
143      return results;
144    };
145
146    const inverseTree = recurseBackWithLimit(
147      { origin: context.originModulePath, request: moduleName },
148      // TODO: Do we need to expose this?
149      35
150    );
151
152    if (inverseTree.previous.length > 0) {
153      debug('Found inverse graph:', JSON.stringify(inverseTree, null, 2));
154      let extraMessage = chalk.bold('Import stack:');
155      const printRecursive = (tree: InverseDepResult, depth: number = 0) => {
156        let filename = path.relative(root, tree.origin);
157        if (filename.match(/\?ctx=[\w\d]+$/)) {
158          filename = filename.replace(/\?ctx=[\w\d]+$/, chalk.dim(' (require.context)'));
159        } else {
160          let formattedRequest = chalk.green(`"${tree.request}"`);
161
162          if (
163            // If bundling for web and the import is pulling internals from outside of react-native
164            // then mark it as an invalid import.
165            platform === 'web' &&
166            !/^(node_modules\/)?react-native\//.test(filename) &&
167            tree.request.match(/^react-native\/.*/)
168          ) {
169            formattedRequest =
170              formattedRequest +
171              chalk`\n          {yellow Importing react-native internals is not supported on web.}`;
172          }
173
174          filename = filename + chalk`\n{gray  |} {cyan import} ${formattedRequest}\n`;
175        }
176        let line = '\n' + pad(depth) + chalk.gray(' ') + filename;
177        if (filename.match(/node_modules/)) {
178          line = chalk.gray(
179            // Bold the node module name
180            line.replace(/node_modules\/([^/]+)/, (_match, p1) => {
181              return 'node_modules/' + chalk.bold(p1);
182            })
183          );
184        }
185        extraMessage += line;
186        for (const child of tree.previous) {
187          printRecursive(
188            child,
189            // Only add depth if there are multiple children
190            tree.previous.length > 1 ? depth + 1 : depth
191          );
192        }
193      };
194      printRecursive(inverseTree);
195
196      // @ts-expect-error
197      error._expoImportStack = extraMessage;
198    } else {
199      debug('Found no inverse tree for:', context.originModulePath);
200    }
201
202    return error;
203  }
204
205  const depGraph: Map<
206    // custom options
207    string,
208    Map<
209      // platform
210      string,
211      Map<
212        // origin module name
213        string,
214        Set<{
215          // required module name
216          path: string;
217          // This isn't entirely accurate since a module can be imported multiple times in a file,
218          // and use different names. But it's good enough for now.
219          request: string;
220        }>
221      >
222    >
223  > = new Map();
224
225  return {
226    ...config,
227    resolver: {
228      ...config.resolver,
229      resolveRequest(context, moduleName, platform) {
230        const storeResult = (res: NonNullable<ReturnType<ExpoCustomMetroResolver>>) => {
231          if (!env.EXPO_METRO_UNSTABLE_ERRORS || !platform) return;
232
233          const key = optionsKeyForContext(context);
234          if (!depGraph.has(key)) depGraph.set(key, new Map());
235          const mapByTarget = depGraph.get(key);
236          if (!mapByTarget!.has(platform)) mapByTarget!.set(platform, new Map());
237          const mapByPlatform = mapByTarget!.get(platform);
238          if (!mapByPlatform!.has(context.originModulePath))
239            mapByPlatform!.set(context.originModulePath, new Set());
240          const setForModule = mapByPlatform!.get(context.originModulePath)!;
241
242          const qualifiedModuleName = res?.type === 'sourceFile' ? res.filePath : moduleName;
243          setForModule.add({ path: qualifiedModuleName, request: moduleName });
244        };
245
246        const universalContext = {
247          ...context,
248          preferNativePlatform: platform !== 'web',
249        };
250
251        try {
252          for (const resolver of resolvers) {
253            try {
254              const resolution = resolver(universalContext, moduleName, platform);
255              if (resolution) {
256                storeResult(resolution);
257                return resolution;
258              }
259            } catch (error: any) {
260              // If no user-defined resolver, use Expo's default behavior.
261              // This prevents extraneous resolution attempts on failure.
262              if (!config.resolver.resolveRequest) {
263                throw error;
264              }
265
266              // If the error is directly related to a resolver not being able to resolve a module, then
267              // we can ignore the error and try the next resolver. Otherwise, we should throw the error.
268              const isResolutionError =
269                isFailedToResolveNameError(error) || isFailedToResolvePathError(error);
270              if (!isResolutionError) {
271                throw error;
272              }
273              debug(
274                `Custom resolver threw: ${error.constructor.name}. (module: ${moduleName}, platform: ${platform})`
275              );
276            }
277          }
278          // If we haven't returned by now, use the original resolver or upstream resolver.
279          const res = originalResolveRequest(universalContext, moduleName, platform);
280          storeResult(res);
281          return res;
282        } catch (error: any) {
283          throw mutateResolutionError(error, universalContext, moduleName, platform);
284        }
285      },
286    },
287  };
288}
289