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