1082815dcSEvan Baconimport { ExpoConfig } from '@expo/config-types'; 2082815dcSEvan Baconimport { JSONObject } from '@expo/json-file'; 3082815dcSEvan Baconimport chalk from 'chalk'; 4082815dcSEvan Baconimport { boolish } from 'getenv'; 5082815dcSEvan Bacon 6082815dcSEvan Baconimport { ExportedConfig, ExportedConfigWithProps, Mod, ModPlatform } from '../Plugin.types'; 7082815dcSEvan Baconimport { PluginError } from '../utils/errors'; 8082815dcSEvan Bacon 9082815dcSEvan Baconconst EXPO_DEBUG = boolish('EXPO_DEBUG', false); 10082815dcSEvan Bacon 11082815dcSEvan Baconexport type BaseModOptions = { 12*edc75823SEvan Bacon /** Officially supports `'ios' | 'android'` (`ModPlatform`). Arbitrary strings are supported for adding out-of-tree platforms. */ 13*edc75823SEvan Bacon platform: ModPlatform & string; 14082815dcSEvan Bacon mod: string; 15082815dcSEvan Bacon isProvider?: boolean; 16082815dcSEvan Bacon skipEmptyMod?: boolean; 17082815dcSEvan Bacon saveToInternal?: boolean; 18082815dcSEvan Bacon /** 19082815dcSEvan Bacon * If the mod supports introspection, and avoids making any filesystem modifications during compilation. 20082815dcSEvan Bacon * By enabling, this mod, and all of its descendants will be run in introspection mode. 21082815dcSEvan Bacon * This should only be used for static files like JSON or XML, and not for application files that require regexes, 22082815dcSEvan Bacon * or complex static files that require other files to be generated like Xcode `.pbxproj`. 23082815dcSEvan Bacon */ 24082815dcSEvan Bacon isIntrospective?: boolean; 25082815dcSEvan Bacon}; 26082815dcSEvan Bacon 27082815dcSEvan Bacon/** 28082815dcSEvan Bacon * Plugin to intercept execution of a given `mod` with the given `action`. 29082815dcSEvan Bacon * If an action was already set on the given `config` config for `mod`, then it 30082815dcSEvan Bacon * will be provided to the `action` as `nextMod` when it's evaluated, otherwise 31082815dcSEvan Bacon * `nextMod` will be an identity function. 32082815dcSEvan Bacon * 33082815dcSEvan Bacon * @param config exported config 34082815dcSEvan Bacon * @param platform platform to target (ios or android) 35082815dcSEvan Bacon * @param mod name of the platform function to intercept 36082815dcSEvan Bacon * @param skipEmptyMod should skip running the action if there is no existing mod to intercept 37082815dcSEvan Bacon * @param saveToInternal should save the results to `_internal.modResults`, only enable this when the results are pure JSON. 38082815dcSEvan Bacon * @param isProvider should provide data up to the other mods. 39082815dcSEvan Bacon * @param action method to run on the mod when the config is compiled 40082815dcSEvan Bacon */ 41082815dcSEvan Baconexport function withBaseMod<T>( 42082815dcSEvan Bacon config: ExportedConfig, 43082815dcSEvan Bacon { 44082815dcSEvan Bacon platform, 45082815dcSEvan Bacon mod, 46082815dcSEvan Bacon action, 47082815dcSEvan Bacon skipEmptyMod, 48082815dcSEvan Bacon isProvider, 49082815dcSEvan Bacon isIntrospective, 50082815dcSEvan Bacon saveToInternal, 51082815dcSEvan Bacon }: BaseModOptions & { action: Mod<T> } 52082815dcSEvan Bacon): ExportedConfig { 53082815dcSEvan Bacon if (!config.mods) { 54082815dcSEvan Bacon config.mods = {}; 55082815dcSEvan Bacon } 56082815dcSEvan Bacon if (!config.mods[platform]) { 57082815dcSEvan Bacon config.mods[platform] = {}; 58082815dcSEvan Bacon } 59082815dcSEvan Bacon 60082815dcSEvan Bacon let interceptedMod: Mod<T> = (config.mods[platform] as Record<string, any>)[mod]; 61082815dcSEvan Bacon 62082815dcSEvan Bacon // No existing mod to intercept 63082815dcSEvan Bacon if (!interceptedMod) { 64082815dcSEvan Bacon if (skipEmptyMod) { 65082815dcSEvan Bacon // Skip running the action 66082815dcSEvan Bacon return config; 67082815dcSEvan Bacon } 68082815dcSEvan Bacon // Use a noop mod and continue 69082815dcSEvan Bacon const noopMod: Mod<T> = (config) => config; 70082815dcSEvan Bacon interceptedMod = noopMod; 71082815dcSEvan Bacon } 72082815dcSEvan Bacon 73082815dcSEvan Bacon // Create a stack trace for debugging ahead of time 74082815dcSEvan Bacon let debugTrace: string = ''; 75082815dcSEvan Bacon // Use the possibly user defined value. Otherwise fallback to the env variable. 76082815dcSEvan Bacon // We support the env variable because user mods won't have _internal defined in time. 77082815dcSEvan Bacon const isDebug = config._internal?.isDebug ?? EXPO_DEBUG; 78082815dcSEvan Bacon if (isDebug) { 79082815dcSEvan Bacon // Get a stack trace via the Error API 80082815dcSEvan Bacon const stack = new Error().stack; 81082815dcSEvan Bacon // Format the stack trace to create the debug log 82082815dcSEvan Bacon debugTrace = getDebugPluginStackFromStackTrace(stack); 83082815dcSEvan Bacon const modStack = chalk.bold(`${platform}.${mod}`); 84082815dcSEvan Bacon 85082815dcSEvan Bacon debugTrace = `${modStack}: ${debugTrace}`; 86082815dcSEvan Bacon } 87082815dcSEvan Bacon 88082815dcSEvan Bacon // Prevent adding multiple providers to a mod. 89082815dcSEvan Bacon // Base mods that provide files ignore any incoming modResults and therefore shouldn't have provider mods as parents. 90082815dcSEvan Bacon if (interceptedMod.isProvider) { 91082815dcSEvan Bacon if (isProvider) { 92082815dcSEvan Bacon throw new PluginError( 93082815dcSEvan Bacon `Cannot set provider mod for "${platform}.${mod}" because another is already being used.`, 94082815dcSEvan Bacon 'CONFLICTING_PROVIDER' 95082815dcSEvan Bacon ); 96082815dcSEvan Bacon } else { 97082815dcSEvan Bacon throw new PluginError( 98082815dcSEvan Bacon `Cannot add mod to "${platform}.${mod}" because the provider has already been added. Provider must be the last mod added.`, 99082815dcSEvan Bacon 'INVALID_MOD_ORDER' 100082815dcSEvan Bacon ); 101082815dcSEvan Bacon } 102082815dcSEvan Bacon } 103082815dcSEvan Bacon 104082815dcSEvan Bacon async function interceptingMod({ modRequest, ...config }: ExportedConfigWithProps<T>) { 105082815dcSEvan Bacon if (isDebug) { 106082815dcSEvan Bacon // In debug mod, log the plugin stack in the order which they were invoked 107082815dcSEvan Bacon console.log(debugTrace); 108082815dcSEvan Bacon } 109082815dcSEvan Bacon const results = await action({ 110082815dcSEvan Bacon ...config, 111082815dcSEvan Bacon modRequest: { ...modRequest, nextMod: interceptedMod }, 112082815dcSEvan Bacon }); 113082815dcSEvan Bacon 114082815dcSEvan Bacon if (saveToInternal) { 115082815dcSEvan Bacon saveToInternalObject(results, platform, mod, results.modResults as unknown as JSONObject); 116082815dcSEvan Bacon } 117082815dcSEvan Bacon return results; 118082815dcSEvan Bacon } 119082815dcSEvan Bacon 120082815dcSEvan Bacon // Ensure this base mod is registered as the provider. 121082815dcSEvan Bacon interceptingMod.isProvider = isProvider; 122082815dcSEvan Bacon 123082815dcSEvan Bacon if (isIntrospective) { 124082815dcSEvan Bacon // Register the mode as idempotent so introspection doesn't remove it. 125082815dcSEvan Bacon interceptingMod.isIntrospective = isIntrospective; 126082815dcSEvan Bacon } 127082815dcSEvan Bacon 128082815dcSEvan Bacon (config.mods[platform] as any)[mod] = interceptingMod; 129082815dcSEvan Bacon 130082815dcSEvan Bacon return config; 131082815dcSEvan Bacon} 132082815dcSEvan Bacon 133082815dcSEvan Baconfunction saveToInternalObject( 134082815dcSEvan Bacon config: Pick<ExpoConfig, '_internal'>, 135082815dcSEvan Bacon platformName: ModPlatform, 136082815dcSEvan Bacon modName: string, 137082815dcSEvan Bacon results: JSONObject 138082815dcSEvan Bacon) { 139082815dcSEvan Bacon if (!config._internal) config._internal = {}; 140082815dcSEvan Bacon if (!config._internal.modResults) config._internal.modResults = {}; 141082815dcSEvan Bacon if (!config._internal.modResults[platformName]) config._internal.modResults[platformName] = {}; 142082815dcSEvan Bacon config._internal.modResults[platformName][modName] = results; 143082815dcSEvan Bacon} 144082815dcSEvan Bacon 145082815dcSEvan Baconfunction getDebugPluginStackFromStackTrace(stacktrace?: string): string { 146082815dcSEvan Bacon if (!stacktrace) { 147082815dcSEvan Bacon return ''; 148082815dcSEvan Bacon } 149082815dcSEvan Bacon 150082815dcSEvan Bacon const treeStackLines: string[] = []; 151082815dcSEvan Bacon for (const line of stacktrace.split('\n')) { 152082815dcSEvan Bacon const [first, second] = line.trim().split(' '); 153082815dcSEvan Bacon if (first === 'at') { 154082815dcSEvan Bacon treeStackLines.push(second); 155082815dcSEvan Bacon } 156082815dcSEvan Bacon } 157082815dcSEvan Bacon 158082815dcSEvan Bacon const plugins = treeStackLines 159082815dcSEvan Bacon .map((first) => { 160082815dcSEvan Bacon // Match the first part of the stack trace against the plugin naming convention 161082815dcSEvan Bacon // "with" followed by a capital letter. 162082815dcSEvan Bacon return ( 163082815dcSEvan Bacon first?.match(/^(\bwith[A-Z].*?\b)/)?.[1]?.trim() ?? 164082815dcSEvan Bacon first?.match(/\.(\bwith[A-Z].*?\b)/)?.[1]?.trim() ?? 165082815dcSEvan Bacon null 166082815dcSEvan Bacon ); 167082815dcSEvan Bacon }) 168082815dcSEvan Bacon .filter(Boolean) 169082815dcSEvan Bacon .filter((plugin) => { 170082815dcSEvan Bacon // redundant as all debug logs are captured in withBaseMod 171082815dcSEvan Bacon return !['withMod', 'withBaseMod', 'withExtendedMod'].includes(plugin!); 172082815dcSEvan Bacon }); 173082815dcSEvan Bacon 174082815dcSEvan Bacon const commonPlugins = ['withPlugins', 'withRunOnce', 'withStaticPlugin']; 175082815dcSEvan Bacon 176082815dcSEvan Bacon return ( 177082815dcSEvan Bacon (plugins as string[]) 178082815dcSEvan Bacon .reverse() 179082815dcSEvan Bacon .map((pluginName, index) => { 180082815dcSEvan Bacon // Base mods indicate a logical section. 181082815dcSEvan Bacon if (pluginName.includes('BaseMod')) { 182082815dcSEvan Bacon pluginName = chalk.bold(pluginName); 183082815dcSEvan Bacon } 184082815dcSEvan Bacon // highlight dangerous mods 185082815dcSEvan Bacon if (pluginName.toLowerCase().includes('dangerous')) { 186082815dcSEvan Bacon pluginName = chalk.red(pluginName); 187082815dcSEvan Bacon } 188082815dcSEvan Bacon 189082815dcSEvan Bacon if (index === 0) { 190082815dcSEvan Bacon return chalk.blue(pluginName); 191082815dcSEvan Bacon } else if (commonPlugins.includes(pluginName)) { 192082815dcSEvan Bacon // Common mod names often clutter up the logs, dim them out 193082815dcSEvan Bacon return chalk.dim(pluginName); 194082815dcSEvan Bacon } 195082815dcSEvan Bacon return pluginName; 196082815dcSEvan Bacon }) 197082815dcSEvan Bacon // Join the results: 198082815dcSEvan Bacon // withAndroidExpoPlugins ➜ withPlugins ➜ withIcons ➜ withDangerousMod ➜ withMod 199082815dcSEvan Bacon .join(' ➜ ') 200082815dcSEvan Bacon ); 201082815dcSEvan Bacon} 202082815dcSEvan Bacon 203082815dcSEvan Bacon/** 204082815dcSEvan Bacon * Plugin to extend a mod function in the plugins config. 205082815dcSEvan Bacon * 206082815dcSEvan Bacon * @param config exported config 207082815dcSEvan Bacon * @param platform platform to target (ios or android) 208082815dcSEvan Bacon * @param mod name of the platform function to extend 209082815dcSEvan Bacon * @param action method to run on the mod when the config is compiled 210082815dcSEvan Bacon */ 211082815dcSEvan Baconexport function withMod<T>( 212082815dcSEvan Bacon config: ExportedConfig, 213082815dcSEvan Bacon { 214082815dcSEvan Bacon platform, 215082815dcSEvan Bacon mod, 216082815dcSEvan Bacon action, 217082815dcSEvan Bacon }: { 218082815dcSEvan Bacon platform: ModPlatform; 219082815dcSEvan Bacon mod: string; 220082815dcSEvan Bacon action: Mod<T>; 221082815dcSEvan Bacon } 222082815dcSEvan Bacon): ExportedConfig { 223082815dcSEvan Bacon return withBaseMod(config, { 224082815dcSEvan Bacon platform, 225082815dcSEvan Bacon mod, 226082815dcSEvan Bacon isProvider: false, 227082815dcSEvan Bacon async action({ modRequest: { nextMod, ...modRequest }, modResults, ...config }) { 228082815dcSEvan Bacon const results = await action({ modRequest, modResults: modResults as T, ...config }); 229082815dcSEvan Bacon return nextMod!(results as any); 230082815dcSEvan Bacon }, 231082815dcSEvan Bacon }); 232082815dcSEvan Bacon} 233