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 { ConfigT as MetroConfig } from 'metro-config';
8import { ResolutionContext } from 'metro-resolver';
9
10import { isFailedToResolveNameError, isFailedToResolvePathError } from './metroErrors';
11import { importMetroResolverFromProject } from './resolveFromProject';
12
13const debug = require('debug')('expo:metro:withMetroResolvers') as typeof console.log;
14
15export type MetroResolver = NonNullable<MetroConfig['resolver']['resolveRequest']>;
16
17/** Expo Metro Resolvers can return `null` to skip without throwing an error. Metro Resolvers will throw either a `FailedToResolveNameError` or `FailedToResolvePathError`. */
18export type ExpoCustomMetroResolver = (
19  ...args: Parameters<MetroResolver>
20) => ReturnType<MetroResolver> | null;
21
22/** @returns `MetroResolver` utilizing the upstream `resolve` method. */
23export function getDefaultMetroResolver(projectRoot: string): MetroResolver {
24  const { resolve } = importMetroResolverFromProject(projectRoot);
25  return (context: ResolutionContext, moduleName: string, platform: string | null) => {
26    return resolve(context, moduleName, platform);
27  };
28}
29
30/**
31 * Extend the Metro config `resolver.resolveRequest` method with additional resolvers that can
32 * exit early by returning a `Resolution` or skip to the next resolver by returning `null`.
33 *
34 * @param config Metro config.
35 * @param projectRoot path to the project root used to resolve the default Metro resolver.
36 * @param resolvers custom MetroResolver to chain.
37 * @returns a new `MetroConfig` with the `resolver.resolveRequest` method chained.
38 */
39export function withMetroResolvers(
40  config: MetroConfig,
41  projectRoot: string,
42  resolvers: ExpoCustomMetroResolver[]
43): MetroConfig {
44  debug(
45    `Appending ${
46      resolvers.length
47    } custom resolvers to Metro config. (has custom resolver: ${!!config.resolver.resolveRequest})`
48  );
49  const originalResolveRequest =
50    config.resolver.resolveRequest || getDefaultMetroResolver(projectRoot);
51
52  return {
53    ...config,
54    resolver: {
55      ...config.resolver,
56      resolveRequest(context, moduleName, platform) {
57        const universalContext = {
58          ...context,
59          preferNativePlatform: platform !== 'web',
60        };
61
62        for (const resolver of resolvers) {
63          try {
64            const resolution = resolver(universalContext, moduleName, platform);
65            if (resolution) {
66              return resolution;
67            }
68          } catch (error: any) {
69            // If no user-defined resolver, use Expo's default behavior.
70            // This prevents extraneous resolution attempts on failure.
71            if (!config.resolver.resolveRequest) {
72              throw error;
73            }
74
75            // If the error is directly related to a resolver not being able to resolve a module, then
76            // we can ignore the error and try the next resolver. Otherwise, we should throw the error.
77            const isResolutionError =
78              isFailedToResolveNameError(error) || isFailedToResolvePathError(error);
79            if (!isResolutionError) {
80              throw error;
81            }
82            debug(
83              `Custom resolver threw: ${error.constructor.name}. (module: ${moduleName}, platform: ${platform})`
84            );
85          }
86        }
87        // If we haven't returned by now, use the original resolver or upstream resolver.
88        return originalResolveRequest(universalContext, moduleName, platform);
89      },
90    },
91  };
92}
93