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