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