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