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