1import chalk from 'chalk';
2import glob from 'fast-glob';
3import fs from 'fs-extra';
4import { createRequire } from 'module';
5import path from 'path';
6
7import { mergeLinkingOptionsAsync, projectPackageJsonPath } from './mergeLinkingOptions';
8import { requireAndResolveExpoModuleConfig } from '../ExpoModuleConfig';
9import { PackageRevision, SearchOptions, SearchResults } from '../types';
10
11// Names of the config files. From lowest to highest priority.
12const EXPO_MODULE_CONFIG_FILENAMES = ['unimodule.json', 'expo-module.config.json'];
13
14/**
15 * Searches for modules to link based on given config.
16 */
17export async function findModulesAsync(providedOptions: SearchOptions): Promise<SearchResults> {
18  const options = await mergeLinkingOptionsAsync(providedOptions);
19  const results: Map<string, PackageRevision> = new Map();
20
21  const nativeModuleNames = new Set<string>();
22
23  // custom native modules should be resolved first so that they can override other modules
24  const searchPaths =
25    options.nativeModulesDir && fs.existsSync(options.nativeModulesDir)
26      ? [options.nativeModulesDir, ...options.searchPaths]
27      : options.searchPaths;
28
29  for (const searchPath of searchPaths) {
30    const isNativeModulesDir = searchPath === options.nativeModulesDir;
31
32    const packageConfigPaths = await findPackagesConfigPathsAsync(searchPath);
33
34    for (const packageConfigPath of packageConfigPaths) {
35      const packagePath = await fs.realpath(path.join(searchPath, path.dirname(packageConfigPath)));
36      const expoModuleConfig = requireAndResolveExpoModuleConfig(
37        path.join(packagePath, path.basename(packageConfigPath))
38      );
39
40      const { name, version } = resolvePackageNameAndVersion(packagePath, {
41        fallbackToDirName: isNativeModulesDir,
42      });
43
44      // we ignore the `exclude` option for custom native modules
45      if (
46        (!isNativeModulesDir && options.exclude?.includes(name)) ||
47        !expoModuleConfig.supportsPlatform(options.platform)
48      ) {
49        continue;
50      }
51
52      // add the current revision to the results
53      const currentRevision: PackageRevision = {
54        path: packagePath,
55        version,
56        config: expoModuleConfig,
57      };
58      addRevisionToResults(results, name, currentRevision);
59
60      // if the module is a native module, we need to add it to the nativeModuleNames set
61      if (isNativeModulesDir && !nativeModuleNames.has(name)) {
62        nativeModuleNames.add(name);
63      }
64    }
65  }
66
67  const searchResults: SearchResults = Object.fromEntries(results.entries());
68
69  // It doesn't make much sense to strip modules if there is only one search path.
70  // (excluding custom native modules path)
71  // Workspace root usually doesn't specify all its dependencies (see Expo Go),
72  // so in this case we should link everything.
73  if (options.searchPaths.length <= 1) {
74    return searchResults;
75  }
76
77  return filterToProjectDependencies(searchResults, {
78    ...providedOptions,
79    // Custom native modules are not filtered out
80    // when they're not specified in package.json dependencies.
81    alwaysIncludedPackagesNames: nativeModuleNames,
82  });
83}
84
85/**
86 * Returns the priority of the config at given path. Higher number means higher priority.
87 */
88function configPriority(fullpath: string): number {
89  return EXPO_MODULE_CONFIG_FILENAMES.indexOf(path.basename(fullpath));
90}
91
92/**
93 * Adds {@link revision} to the {@link results} map
94 * or to package duplicates if it already exists.
95 * @param results [mutable] yet resolved packages map
96 * @param name resolved package name
97 * @param revision resolved package revision
98 */
99function addRevisionToResults(
100  results: Map<string, PackageRevision>,
101  name: string,
102  revision: PackageRevision
103): void {
104  if (!results.has(name)) {
105    // The revision that was found first will be the main one.
106    // An array of duplicates and the config are needed only here.
107    results.set(name, {
108      ...revision,
109      duplicates: [],
110    });
111  } else if (
112    results.get(name)?.path !== revision.path &&
113    results.get(name)?.duplicates?.every(({ path }) => path !== revision.path)
114  ) {
115    const { config, duplicates, ...duplicateEntry } = revision;
116    results.get(name)?.duplicates?.push(duplicateEntry);
117  }
118}
119
120/**
121 * Returns paths to the highest priority config files, relative to the {@link searchPath}.
122 * @example
123 * ```
124 * // Given the following file exists: /foo/myapp/modules/mymodule/expo-module.config.json
125 * await findPackagesConfigPathsAsync('/foo/myapp/modules');
126 * // returns ['mymodule/expo-module.config.json']
127 *
128 * await findPackagesConfigPathsAsync('/foo/myapp/modules/mymodule');
129 * // returns ['expo-module.config.json']
130 * ```
131 */
132async function findPackagesConfigPathsAsync(searchPath: string): Promise<string[]> {
133  const bracedFilenames = '{' + EXPO_MODULE_CONFIG_FILENAMES.join(',') + '}';
134  const paths = await glob(
135    [
136      `*/${bracedFilenames}`,
137      `@*/*/${bracedFilenames}`,
138      `./${bracedFilenames}`,
139      `.pnpm/*/node_modules/*/${bracedFilenames}`,
140      `.pnpm/*/node_modules/@*/*/${bracedFilenames}`,
141    ],
142    {
143      cwd: searchPath,
144    }
145  );
146
147  // If the package has multiple configs (e.g. `unimodule.json` and `expo-module.config.json` during the transition time)
148  // then we want to give `expo-module.config.json` the priority.
149  return Object.values(
150    paths.reduce<Record<string, string>>((acc, configPath) => {
151      const dirname = path.dirname(configPath);
152
153      if (!acc[dirname] || configPriority(configPath) > configPriority(acc[dirname])) {
154        acc[dirname] = configPath;
155      }
156      return acc;
157    }, {})
158  );
159}
160
161/**
162 * Resolves package name and version for the given {@link packagePath} from its `package.json`.
163 * if {@link fallbackToDirName} is true, it returns the dir name when `package.json` doesn't exist.
164 * @returns object with `name` and `version` properties. `version` falls back to `UNVERSIONED` if cannot be resolved.
165 */
166function resolvePackageNameAndVersion(
167  packagePath: string,
168  { fallbackToDirName }: { fallbackToDirName?: boolean } = {}
169): { name: string; version: string } {
170  try {
171    const { name, version } = require(path.join(packagePath, 'package.json'));
172    return { name, version: version || 'UNVERSIONED' };
173  } catch (e) {
174    if (fallbackToDirName) {
175      // we don't have the package.json name, so we'll use the directory name
176      return {
177        name: path.basename(packagePath),
178        version: 'UNVERSIONED',
179      };
180    } else {
181      throw e;
182    }
183  }
184}
185
186/**
187 * Filters out packages that are not the dependencies of the project.
188 */
189function filterToProjectDependencies(
190  results: SearchResults,
191  options: Pick<SearchOptions, 'silent'> & { alwaysIncludedPackagesNames?: Set<string> } = {}
192) {
193  const filteredResults: SearchResults = {};
194  const visitedPackages = new Set<string>();
195
196  // iterate through always included package names and add them to the visited packages
197  // if the results contains them
198  for (const name of options.alwaysIncludedPackagesNames ?? []) {
199    if (results[name] && !visitedPackages.has(name)) {
200      filteredResults[name] = results[name];
201      visitedPackages.add(name);
202    }
203  }
204
205  // Helper for traversing the dependency hierarchy.
206  function visitPackage(packageJsonPath: string) {
207    const packageJson = require(packageJsonPath);
208
209    // Prevent getting into the recursive loop.
210    if (visitedPackages.has(packageJson.name)) {
211      return;
212    }
213    visitedPackages.add(packageJson.name);
214
215    // Iterate over the dependencies to find transitive modules.
216    for (const dependencyName in packageJson.dependencies) {
217      const dependencyResult = results[dependencyName];
218
219      if (!filteredResults[dependencyName]) {
220        let dependencyPackageJsonPath: string;
221
222        if (dependencyResult) {
223          filteredResults[dependencyName] = dependencyResult;
224          dependencyPackageJsonPath = path.join(dependencyResult.path, 'package.json');
225        } else {
226          try {
227            /**
228             * Custom `require` that resolves from the current working dir instead of this script path.
229             * **Requires Node v12.2.0**
230             */
231            const projectRequire = createRequire(packageJsonPath);
232            dependencyPackageJsonPath = projectRequire.resolve(`${dependencyName}/package.json`);
233          } catch (error: any) {
234            // Some packages don't include package.json in its `exports` field,
235            // but none of our packages do that, so it seems fine to just ignore that type of error.
236            // Related issue: https://github.com/react-native-community/cli/issues/1168
237            if (!options.silent && error.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
238              console.warn(
239                chalk.yellow(`⚠️  Cannot resolve the path to "${dependencyName}" package.`)
240              );
241            }
242            continue;
243          }
244        }
245
246        // Visit the dependency package.
247        visitPackage(dependencyPackageJsonPath);
248      }
249    }
250  }
251
252  // Visit project's package.
253  visitPackage(projectPackageJsonPath);
254
255  return filteredResults;
256}
257