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