1import { ExpoConfig } from '@expo/config-types'; 2import { JSONObject } from '@expo/json-file'; 3import chalk from 'chalk'; 4import { boolish } from 'getenv'; 5 6import { ExportedConfig, ExportedConfigWithProps, Mod, ModPlatform } from '../Plugin.types'; 7import { PluginError } from '../utils/errors'; 8 9const EXPO_DEBUG = boolish('EXPO_DEBUG', false); 10 11export type BaseModOptions = { 12 /** Officially supports `'ios' | 'android'` (`ModPlatform`). Arbitrary strings are supported for adding out-of-tree platforms. */ 13 platform: ModPlatform & string; 14 mod: string; 15 isProvider?: boolean; 16 skipEmptyMod?: boolean; 17 saveToInternal?: boolean; 18 /** 19 * If the mod supports introspection, and avoids making any filesystem modifications during compilation. 20 * By enabling, this mod, and all of its descendants will be run in introspection mode. 21 * This should only be used for static files like JSON or XML, and not for application files that require regexes, 22 * or complex static files that require other files to be generated like Xcode `.pbxproj`. 23 */ 24 isIntrospective?: boolean; 25}; 26 27/** 28 * Plugin to intercept execution of a given `mod` with the given `action`. 29 * If an action was already set on the given `config` config for `mod`, then it 30 * will be provided to the `action` as `nextMod` when it's evaluated, otherwise 31 * `nextMod` will be an identity function. 32 * 33 * @param config exported config 34 * @param platform platform to target (ios or android) 35 * @param mod name of the platform function to intercept 36 * @param skipEmptyMod should skip running the action if there is no existing mod to intercept 37 * @param saveToInternal should save the results to `_internal.modResults`, only enable this when the results are pure JSON. 38 * @param isProvider should provide data up to the other mods. 39 * @param action method to run on the mod when the config is compiled 40 */ 41export function withBaseMod<T>( 42 config: ExportedConfig, 43 { 44 platform, 45 mod, 46 action, 47 skipEmptyMod, 48 isProvider, 49 isIntrospective, 50 saveToInternal, 51 }: BaseModOptions & { action: Mod<T> } 52): ExportedConfig { 53 if (!config.mods) { 54 config.mods = {}; 55 } 56 if (!config.mods[platform]) { 57 config.mods[platform] = {}; 58 } 59 60 let interceptedMod: Mod<T> = (config.mods[platform] as Record<string, any>)[mod]; 61 62 // No existing mod to intercept 63 if (!interceptedMod) { 64 if (skipEmptyMod) { 65 // Skip running the action 66 return config; 67 } 68 // Use a noop mod and continue 69 const noopMod: Mod<T> = (config) => config; 70 interceptedMod = noopMod; 71 } 72 73 // Create a stack trace for debugging ahead of time 74 let debugTrace: string = ''; 75 // Use the possibly user defined value. Otherwise fallback to the env variable. 76 // We support the env variable because user mods won't have _internal defined in time. 77 const isDebug = config._internal?.isDebug ?? EXPO_DEBUG; 78 if (isDebug) { 79 // Get a stack trace via the Error API 80 const stack = new Error().stack; 81 // Format the stack trace to create the debug log 82 debugTrace = getDebugPluginStackFromStackTrace(stack); 83 const modStack = chalk.bold(`${platform}.${mod}`); 84 85 debugTrace = `${modStack}: ${debugTrace}`; 86 } 87 88 // Prevent adding multiple providers to a mod. 89 // Base mods that provide files ignore any incoming modResults and therefore shouldn't have provider mods as parents. 90 if (interceptedMod.isProvider) { 91 if (isProvider) { 92 throw new PluginError( 93 `Cannot set provider mod for "${platform}.${mod}" because another is already being used.`, 94 'CONFLICTING_PROVIDER' 95 ); 96 } else { 97 throw new PluginError( 98 `Cannot add mod to "${platform}.${mod}" because the provider has already been added. Provider must be the last mod added.`, 99 'INVALID_MOD_ORDER' 100 ); 101 } 102 } 103 104 async function interceptingMod({ modRequest, ...config }: ExportedConfigWithProps<T>) { 105 if (isDebug) { 106 // In debug mod, log the plugin stack in the order which they were invoked 107 console.log(debugTrace); 108 } 109 const results = await action({ 110 ...config, 111 modRequest: { ...modRequest, nextMod: interceptedMod }, 112 }); 113 114 if (saveToInternal) { 115 saveToInternalObject(results, platform, mod, results.modResults as unknown as JSONObject); 116 } 117 return results; 118 } 119 120 // Ensure this base mod is registered as the provider. 121 interceptingMod.isProvider = isProvider; 122 123 if (isIntrospective) { 124 // Register the mode as idempotent so introspection doesn't remove it. 125 interceptingMod.isIntrospective = isIntrospective; 126 } 127 128 (config.mods[platform] as any)[mod] = interceptingMod; 129 130 return config; 131} 132 133function saveToInternalObject( 134 config: Pick<ExpoConfig, '_internal'>, 135 platformName: ModPlatform, 136 modName: string, 137 results: JSONObject 138) { 139 if (!config._internal) config._internal = {}; 140 if (!config._internal.modResults) config._internal.modResults = {}; 141 if (!config._internal.modResults[platformName]) config._internal.modResults[platformName] = {}; 142 config._internal.modResults[platformName][modName] = results; 143} 144 145function getDebugPluginStackFromStackTrace(stacktrace?: string): string { 146 if (!stacktrace) { 147 return ''; 148 } 149 150 const treeStackLines: string[] = []; 151 for (const line of stacktrace.split('\n')) { 152 const [first, second] = line.trim().split(' '); 153 if (first === 'at') { 154 treeStackLines.push(second); 155 } 156 } 157 158 const plugins = treeStackLines 159 .map((first) => { 160 // Match the first part of the stack trace against the plugin naming convention 161 // "with" followed by a capital letter. 162 return ( 163 first?.match(/^(\bwith[A-Z].*?\b)/)?.[1]?.trim() ?? 164 first?.match(/\.(\bwith[A-Z].*?\b)/)?.[1]?.trim() ?? 165 null 166 ); 167 }) 168 .filter(Boolean) 169 .filter((plugin) => { 170 // redundant as all debug logs are captured in withBaseMod 171 return !['withMod', 'withBaseMod', 'withExtendedMod'].includes(plugin!); 172 }); 173 174 const commonPlugins = ['withPlugins', 'withRunOnce', 'withStaticPlugin']; 175 176 return ( 177 (plugins as string[]) 178 .reverse() 179 .map((pluginName, index) => { 180 // Base mods indicate a logical section. 181 if (pluginName.includes('BaseMod')) { 182 pluginName = chalk.bold(pluginName); 183 } 184 // highlight dangerous mods 185 if (pluginName.toLowerCase().includes('dangerous')) { 186 pluginName = chalk.red(pluginName); 187 } 188 189 if (index === 0) { 190 return chalk.blue(pluginName); 191 } else if (commonPlugins.includes(pluginName)) { 192 // Common mod names often clutter up the logs, dim them out 193 return chalk.dim(pluginName); 194 } 195 return pluginName; 196 }) 197 // Join the results: 198 // withAndroidExpoPlugins ➜ withPlugins ➜ withIcons ➜ withDangerousMod ➜ withMod 199 .join(' ➜ ') 200 ); 201} 202 203/** 204 * Plugin to extend a mod function in the plugins config. 205 * 206 * @param config exported config 207 * @param platform platform to target (ios or android) 208 * @param mod name of the platform function to extend 209 * @param action method to run on the mod when the config is compiled 210 */ 211export function withMod<T>( 212 config: ExportedConfig, 213 { 214 platform, 215 mod, 216 action, 217 }: { 218 platform: ModPlatform; 219 mod: string; 220 action: Mod<T>; 221 } 222): ExportedConfig { 223 return withBaseMod(config, { 224 platform, 225 mod, 226 isProvider: false, 227 async action({ modRequest: { nextMod, ...modRequest }, modResults, ...config }) { 228 const results = await action({ modRequest, modResults: modResults as T, ...config }); 229 return nextMod!(results as any); 230 }, 231 }); 232} 233