1import Debug from 'debug'; 2 3import { BaseModOptions, withBaseMod } from './withMod'; 4import { 5 ConfigPlugin, 6 ExportedConfig, 7 ExportedConfigWithProps, 8 ModPlatform, 9} from '../Plugin.types'; 10 11const debug = Debug('expo:config-plugins:base-mods'); 12 13export type ForwardedBaseModOptions = Partial< 14 Pick<BaseModOptions, 'saveToInternal' | 'skipEmptyMod'> 15>; 16 17export type BaseModProviderMethods< 18 ModType, 19 Props extends ForwardedBaseModOptions = ForwardedBaseModOptions, 20> = { 21 getFilePath: (config: ExportedConfigWithProps<ModType>, props: Props) => Promise<string> | string; 22 read: ( 23 filePath: string, 24 config: ExportedConfigWithProps<ModType>, 25 props: Props 26 ) => Promise<ModType> | ModType; 27 write: ( 28 filePath: string, 29 config: ExportedConfigWithProps<ModType>, 30 props: Props 31 ) => Promise<void> | void; 32 /** 33 * If the mod supports introspection, and avoids making any filesystem modifications during compilation. 34 * By enabling, this mod, and all of its descendants will be run in introspection mode. 35 * This should only be used for static files like JSON or XML, and not for application files that require regexes, 36 * or complex static files that require other files to be generated like Xcode `.pbxproj`. 37 */ 38 isIntrospective?: boolean; 39}; 40 41export type CreateBaseModProps< 42 ModType, 43 Props extends ForwardedBaseModOptions = ForwardedBaseModOptions, 44> = { 45 methodName: string; 46 platform: ModPlatform; 47 modName: string; 48} & BaseModProviderMethods<ModType, Props>; 49 50export function createBaseMod< 51 ModType, 52 Props extends ForwardedBaseModOptions = ForwardedBaseModOptions, 53>({ 54 methodName, 55 platform, 56 modName, 57 getFilePath, 58 read, 59 write, 60 isIntrospective, 61}: CreateBaseModProps<ModType, Props>): ConfigPlugin<Props | void> { 62 const withUnknown: ConfigPlugin<Props | void> = (config, _props) => { 63 const props = _props || ({} as Props); 64 return withBaseMod<ModType>(config, { 65 platform, 66 mod: modName, 67 skipEmptyMod: props.skipEmptyMod ?? true, 68 saveToInternal: props.saveToInternal ?? false, 69 isProvider: true, 70 isIntrospective, 71 async action({ modRequest: { nextMod, ...modRequest }, ...config }) { 72 try { 73 let results: ExportedConfigWithProps<ModType> = { 74 ...config, 75 modRequest, 76 }; 77 78 const filePath = await getFilePath(results, props); 79 debug(`mods.${platform}.${modName}: file path: ${filePath || '[skipped]'}`); 80 const modResults = await read(filePath, results, props); 81 82 results = await nextMod!({ 83 ...results, 84 modResults, 85 modRequest, 86 }); 87 88 assertModResults(results, modRequest.platform, modRequest.modName); 89 90 await write(filePath, results, props); 91 return results; 92 } catch (error: any) { 93 error.message = `[${platform}.${modName}]: ${methodName}: ${error.message}`; 94 throw error; 95 } 96 }, 97 }); 98 }; 99 100 if (methodName) { 101 Object.defineProperty(withUnknown, 'name', { 102 value: methodName, 103 }); 104 } 105 106 return withUnknown; 107} 108 109export function assertModResults(results: any, platformName: string, modName: string) { 110 // If the results came from a mod, they'd be in the form of [config, data]. 111 // Ensure the results are an array and omit the data since it should've been written by a data provider plugin. 112 const ensuredResults = results; 113 114 // Sanity check to help locate non compliant mods. 115 if (!ensuredResults || typeof ensuredResults !== 'object' || !ensuredResults?.mods) { 116 throw new Error( 117 `Mod \`mods.${platformName}.${modName}\` evaluated to an object that is not a valid project config. Instead got: ${JSON.stringify( 118 ensuredResults 119 )}` 120 ); 121 } 122 return ensuredResults; 123} 124 125function upperFirst(name: string): string { 126 return name.charAt(0).toUpperCase() + name.slice(1); 127} 128 129export function createPlatformBaseMod< 130 ModType, 131 Props extends ForwardedBaseModOptions = ForwardedBaseModOptions, 132>({ modName, ...props }: Omit<CreateBaseModProps<ModType, Props>, 'methodName'>) { 133 // Generate the function name to ensure it's uniform and also to improve stack traces. 134 const methodName = `with${upperFirst(props.platform)}${upperFirst(modName)}BaseMod`; 135 return createBaseMod<ModType, Props>({ 136 methodName, 137 modName, 138 ...props, 139 }); 140} 141 142/** A TS wrapper for creating provides */ 143export function provider<ModType, Props extends ForwardedBaseModOptions = ForwardedBaseModOptions>( 144 props: BaseModProviderMethods<ModType, Props> 145) { 146 return props; 147} 148 149/** Plugin to create and append base mods from file providers */ 150export function withGeneratedBaseMods<ModName extends string>( 151 config: ExportedConfig, 152 { 153 platform, 154 providers, 155 ...props 156 }: ForwardedBaseModOptions & { 157 /** Officially supports `'ios' | 'android'` (`ModPlatform`). Arbitrary strings are supported for adding out-of-tree platforms. */ 158 platform: ModPlatform & string; 159 providers: Partial<Record<ModName, BaseModProviderMethods<any, any>>>; 160 } 161): ExportedConfig { 162 return Object.entries(providers).reduce((config, [modName, value]) => { 163 const baseMod = createPlatformBaseMod({ platform, modName, ...(value as any) }); 164 return baseMod(config, props); 165 }, config); 166} 167