This repository has been archived on 2022-03-12. You can view files and clone it, but cannot push or open issues or pull requests.
2021-04-02 02:24:13 +03:00

470 lines
19 KiB
Markdown

# Expo Config Plugins
The Expo config is a powerful tool for generating native app code from a unified JavaScript interface. Most basic functionality can be controlled by using the the [static Expo config](https://docs.expo.io/versions/latest/config/app/), but some features require manipulation of the native project files. To support complex behavior we've created config plugins, and mods (short for modifiers).
> 💡 **Hands-on Learners**: Use [this sandbox][sandbox] to play with the core functionality of Expo config plugins. For more complex tests, use a local Expo project, with `expo eject --no-install` to apply changes.
- [Usage](#usage)
- [What are plugins](#what-are-plugins)
- [Creating a plugin](#creating-a-plugin)
- [Importing plugins](#importing-plugins)
- [Chaining plugins](#chaining-plugins)
- [What are mods](#what-are-mods)
- [How mods works](#how-mods-works)
- [Default mods](#default-mods)
- [Mod plugins](#mod-plugins)
- [Creating a mod](#creating-a-mod)
- [Experimental functionality](#experimental-functionality)
- [Plugin module resolution](#plugin-module-resolution)
- [Project file](#project-file)
- [app.plugin.js](#apppluginjs)
- [Node module default file](#node-module-default-file)
- [Project folder](#project-folder)
- [Module internals](#module-internals)
- [Raw functions](#raw-functions)
- [Why app.plugin.js for plugins](#why-apppluginjs-for-plugins)
**Quick facts**
- Plugins are functions that can change values on your Expo config.
- Plugins are mostly meant to be used with [`expo eject`][cli-eject] or `eas build` commands.
- We recommend you use plugins with `app.config.json` or `app.config.js` instead of `app.json` (no top-level `expo` object is required).
- `mods` are async functions that modify native project files, such as source code or configuration (plist, xml) files.
- Changes performed with `mods` will require rebuilding the affected native projects.
- `mods` are removed from the public app manifest.
- 💡 Everything in the Expo config must be able to be converted to JSON (with the exception of the `mods` field). So no async functions outside of `mods` in your config plugins!
## Usage
Here is a basic config that uses the `expo-splash-screen` plugin:
```json
{
"name": "my app",
"plugins": ["expo-splash-screen"]
}
```
Some plugins can be customized by passing an array, where the second argument is the options:
```json
{
"name": "my app",
"plugins": [
[
"expo-splash-screen",
{
/* Values passed to the plugin */
}
]
]
}
```
If you run `expo eject`, the `mods` will be compiled, and the native files be changed! The changes won't be fully shown until you rebuild the native project with `eas build -p ios` or locally with `npx react-native run-ios` or `npx react-native run-android`.
For instance, if you add a plugin that adds permission messages to your app, the app will need to be rebuilt.
And that's it! Now you're using Config plugins. No more having to interact with the native projects!
> 💡 Check out all the different ways you can import `plugins`: [plugin module resolution](#Plugin-module-resolution)
## What are plugins
Plugins are **synchronous** functions that accept an [`ExpoConfig`][config-docs] and return a modified [`ExpoConfig`][config-docs].
- Plugins should be named using the following convention: `with<Plugin Functionality>` i.e. `withFacebook`.
- Plugins should be synchronous and their return value should be serializable, except for any `mods` that are added.
- Optionally, a second argument can be passed to the plugin to configure it.
- `plugins` are always invoked when the config is read by `@expo/config`s `getConfig` method. However, the `mods` are only invoked during the "syncing" phase of `expo eject`.
## Creating a plugin
> 💡 Hands-on learners: Try this [sandbox](https://codesandbox.io/s/expo-config-plugins-basic-example-xopto?file=/src/project/app.config.js) (check the terminal logs).
Here is an example of the most basic config plugin:
```ts
const withNothing = config => config;
```
Say you wanted to create a plugin which added custom values to the native iOS Info.plist:
```ts
const withMySDK = (config, { apiKey }) => {
// Ensure the objects exist
if (!config.ios) {
config.ios = {};
}
if (!config.ios.infoPlist) {
config.ios.infoPlist = {};
}
// Append the apiKey
config.ios.infoPlist['MY_CUSTOM_NATIVE_IOS_API_KEY'] = apiKey;
return config;
};
// 💡 Usage:
/// Create a config
const config = {
name: 'my app',
};
/// Use the plugin
export default withMySDK(config, { apiKey: 'X-XXX-XXX' });
```
### Importing plugins
You may want to create a plugin in a different file, here's how:
- The root file can be any JS file or a file named `app.plugin.js` in the [root of a Node module](#root-app.plugin.js).
- The file should export a function that satisfies the [`ConfigPlugin`][configplugin] type.
- Plugins should be transpiled for Node environments ahead of time!
- They should support the versions of Node that [Expo supports](https://docs.expo.io/get-started/installation/#requirements) (LTS).
- No `import/export` keywords, use `module.exports` in the shipped plugin file.
- Expo only transpiles the user's initial `app.config` file, anything more would require a bundler which would add too many "opinions" for a config file.
Consider the following example that changes the config name:
```
╭── app.config.js ➡️ Expo Config
╰── my-plugin.js ➡️ Our custom plugin file
```
`my-plugin.js`
```js
module.exports = function withCustomName(config, name) {
// Modify the config
config.name = 'custom-' + name;
// Return the results
return config;
};
```
`app.config.json`
```json
{
"name": "my-app",
"plugins": ["./my-plugin", "app"]
}
```
↓ ↓ ↓
**Evaluated config JSON**
```json
{
"name": "custom-app",
"plugins": ["./my-plugin", "app"]
}
```
### Chaining plugins
Once you add a few plugins, your `app.config.js` code can become difficult to read and manipulate. To combat this, `@expo/config-plugins` provides a `withPlugins` function which can be used to chain plugins together and execute them in order.
```js
/// Create a config
const config = {
name: 'my app',
};
// ❌ Hard to read
withDelta(withFoo(withBar(config, 'input 1'), 'input 2'), 'input 3');
// ✅ Easy to read
import { withPlugins } from '@expo/config-plugins';
withPlugins(config, [
[withBar, 'input 1'],
[withFoo, 'input 2'],
// When no input is required, you can just pass the method...
withDelta,
]);
```
To support JSON configs, we also added the `plugins` array which just uses `withPlugins` under the hood.
Here is the same config as above, but even simpler:
```js
export default {
name: 'my app',
plugins: [
[withBar, 'input 1'],
[withFoo, 'input 2'],
[withDelta, 'input 3'],
],
};
```
## What are mods
An async function which accepts a config and a data object, then manipulates and returns both as an object.
Modifiers (mods for short) are added to the `mods` object of the Expo config. The `mods` object is different to the rest of the Expo config because it doesn't get serialized after the initial reading, this means you can use it to perform actions _during_ code generation. If possible, you should attempt to use basic plugins instead of mods as they're simpler to work with.
- `mods` are omitted from the manifest and **cannot** be accessed via `Updates.manifest`. mods exist for the sole purpose of modifying native files during code generation!
- `mods` can be used to read and write files safely during the `expo eject` command. This is how Expo CLI modifies the Info.plist, entitlements, xcproj, etc...
- `mods` are platform specific and should always be added to a platform specific object:
`app.config.js`
```js
module.exports = {
name: 'my-app',
mods: {
ios: {
/* iOS mods... */
},
android: {
/* Android mods... */
},
},
};
```
## How mods work
- The config is read using `getConfig` from `@expo/config`
- All of the core functionality supported by Expo is added via plugins in `withExpoIOSPlugins`. This is stuff like name, version, icons, locales, etc.
- The config is passed to the compiler `compileModifiersAsync`
- The compiler adds base mods which are responsible for reading data (like `Info.plist`), executing a named mod (like `mods.ios.infoPlist`), then writing the results to the file system.
- The compiler iterates over all of the mods and asynchronously evaluates them, providing some base props like the `projectRoot`.
- After each mod, error handling asserts if the mod chain was corrupted by an invalid mod.
<!-- TODO: Move to a section about mod compiler -->
> 💡 Here is a [colorful chart](https://whimsical.com/UjytoYXT2RN43LywvWExfK) of the mod compiler for visual learners.
### Default mods
The following default mods are provided by the mod compiler for common file manipulation:
- `mods.ios.appDelegate` -- Modify the `ios/<name>/AppDelegate.m` as a string.
- `mods.ios.infoPlist` -- Modify the `ios/<name>/Info.plist` as JSON (parsed with [`@expo/plist`](https://www.npmjs.com/package/@expo/plist)).
- `mods.ios.entitlements` -- Modify the `ios/<name>/<product-name>.entitlements` as JSON (parsed with [`@expo/plist`](https://www.npmjs.com/package/@expo/plist)).
- `mods.ios.expoPlist` -- Modify the `ios/<name>/Expo.plist` as JSON (Expo updates config for iOS) (parsed with [`@expo/plist`](https://www.npmjs.com/package/@expo/plist)).
- `mods.ios.xcodeproj` -- Modify the `ios/<name>.xcodeproj` as an `XcodeProject` object (parsed with [`xcode`](https://www.npmjs.com/package/xcode)).
- `mods.android.manifest` -- Modify the `android/app/src/main/AndroidManifest.xml` as JSON (parsed with [`xml2js`](https://www.npmjs.com/package/xml2js)).
- `mods.android.strings` -- Modify the `android/app/src/main/res/values/strings.xml` as JSON (parsed with [`xml2js`](https://www.npmjs.com/package/xml2js)).
- `mods.android.mainActivity` -- Modify the `android/app/src/main/<package>/MainActivity.java` as a string.
- `mods.android.appBuildGradle` -- Modify the `android/app/build.gradle` as a string.
- `mods.android.projectBuildGradle` -- Modify the `android/build.gradle` as a string.
- `mods.android.settingsGradle` -- Modify the `android/settings.gradle` as a string.
- `mods.android.gradleProperties` -- Modify the `android/gradle.properties` as a `Properties.PropertiesItem[]`.
After the mods are resolved, the contents of each mod will be written to disk. Custom default mods can be added to support new native files.
For example, you can create a mod to support the `GoogleServices-Info.plist`, and pass it to other mods.
### Mod plugins
Mods are responsible for a lot of things, so they can be pretty difficult to understand at first.
If you're developing a feature that requires mods, it's best not to interact with them directly.
Instead you should use the helper mods provided by `@expo/config-plugins`:
- iOS
- `withAppDelegate`
- `withInfoPlist`
- `withEntitlementsPlist`
- `withExpoPlist`
- `withXcodeProject`
- Android
- `withAndroidManifest`
- `withStringsXml`
- `withMainActivity`
- `withProjectBuildGradle`
- `withAppBuildGradle`
- `withSettingsGradle`
- `withGradleProperties`
A mod plugin gets passed a `config` object with additional properties `modResults` and `modRequest` added to it.
- `modResults`: The object to modify and return. The type depends on the mod that's being used.
- `modRequest`: Additional properties supplied by the mod compiler.
- `projectRoot: string`: Project root directory for the universal app.
- `platformProjectRoot: string`: Project root for the specific platform.
- `modName: string`: Name of the mod.
- `platform: ModPlatform`: Name of the platform used in the mods config.
- `projectName?: string`: iOS only: The path component used for querying project files. ex. `projectRoot/ios/[projectName]/`
## Creating a mod
Say you wanted to write a mod to update the Xcode Project's "product name":
```ts
import { ConfigPlugin, withXcodeProject } from '@expo/config-plugins';
const withCustomProductName: ConfigPlugin = (config, customName) => {
return withXcodeProject(config, async config => {
// config = { modResults, modRequest, ...expoConfig }
const xcodeProject = config.modResults;
xcodeProject.productName = customName;
return config;
});
};
// 💡 Usage:
/// Create a config
const config = {
name: 'my app',
};
/// Use the plugin
export default withCustomProductName(config, 'new_name');
```
### Experimental functionality
Some parts of the mod system aren't fully flushed out, these parts use `withDangerousModifier` to read/write data without a base mod. These methods essentially act as their own base mod and cannot be extended. Icons for example, currently use the dangerous mod to perform a single generation step with no ability to customize the results.
```ts
export const withIcons: ConfigPlugin = config => {
return withDangerousModifier(config, async config => {
// No modifications are made to the config
await setIconsAsync(config, config.modRequest.projectRoot);
return config;
});
};
```
Be careful using `withDangerousModifier` as it is subject to change in the future.
The order with which it gets executed is not reliable either.
Currently dangerous mods run first before all other modifiers, this is because we use dangerous mods internally for large file system refactoring like when the package name changes.
## Plugin module resolution
The strings passed to the `plugins` array can be resolved in a few different ways.
> Any resolution pattern that isn't specified below is unexpected behavior, and subject to breaking changes.
### Project file
You can quickly create a plugin in your project and use it in your config.
-`'./my-config-plugin'`
-`'./my-config-plugin.js'`
```
╭── app.config.js ➡️ Expo Config
╰── my-config-plugin.js ➡️ ✅ `module.exports = (config) => config`
```
### app.plugin.js
Sometimes you want your package to export React components and also support a plugin, to do this, multiple entry points need to be used (because the transpilation (Babel preset) may be different).
If a `app.plugin.js` file is present in the root of a Node module's folder, it'll be used instead of the package's `main` file.
-`'expo-splash-screen'`
-`'expo-splash-screen/app.plugin.js'`
```
╭── app.config.js ➡️ Expo Config
╰── node_modules/expo-splash-screen/ ➡️ Module installed from NPM (works with Yarn workspaces as well).
├── package.json ➡️ The `main` file will be used if `app.plugin.js` doesn't exist.
├── app.plugin.js ➡️ ✅ `module.exports = (config) => config` -- must export a function.
╰── build/index.js ➡️ ❌ Ignored because `app.plugin.js` exists. This could be used with `expo-splash-screen/build/index.js`
```
### Node module default file
A config plugin in a node module (without an `app.plugin.js`) will use the `main` file defined in the `package.json`.
-`'expo-splash-screen'`
-`'expo-splash-screen/build/index'`
```
╭── app.config.js ➡️ Expo Config
╰── node_modules/expo-splash-screen/ ➡️ Module installed from NPM (works with Yarn workspaces as well).
├── package.json ➡️ The `main` file points to `build/index.js`
╰── build/index.js ➡️ ✅ Node resolves to this module.
```
### Project folder
-`'./my-config-plugin'`
-`'./my-config-plugin.js'`
This is different to how Node modules work because `app.plugin.js` won't be resolved by default in a directory. You'll have to manually specify `./my-config-plugin/app.plugin.js` to use it, otherwise `index.js` in the directory will be used.
```
╭── app.config.js ➡️ Expo Config
╰── my-config-plugin/ ➡️ Folder containing plugin code
╰── index.js ➡️ ✅ By default, Node resolves a folder's index.js file as the main file.
```
### Module internals
If a file inside a Node module is specified, then the module's root `app.plugin.js` resolution will be skipped. This is referred to as "reaching inside a package" and is considered **bad form**.
We support this to make testing, and plugin authoring easier, but we don't expect library authors to expose their plugins like this as a public API.
-`'expo-splash-screen/build/index.js'`
-`'expo-splash-screen/build'`
```
╭── app.config.js ➡️ Expo Config
╰── node_modules/expo-splash-screen/ ➡️ Module installed from npm (works with Yarn workspaces as well).
├── package.json ➡️ The `main` file will be used if `app.plugin.js` doesn't exist.
├── app.plugin.js ➡️ ❌ Ignored because the reference reaches into the package internals.
╰── build/index.js ➡️ ✅ `module.exports = (config) => config`
```
### Raw functions
You can also just pass in a config plugin.
```js
const withCustom = (config, props) => config;
const config = {
plugins: [
[
withCustom,
{
/* props */
},
],
// Without props
withCustom,
],
};
```
One caveat to using functions instead of strings is that serialization will replace the function with the function's name. This keeps **manifests** (kinda like the `index.html` for your app) working as expected.
Here is what the serialized config would look like:
```json
{
"plugins": [["withCustom", {}], "withCustom"]
}
```
## Why app.plugin.js for plugins
Config resolution searches for a `app.plugin.js` first when a Node module name is provided.
This is because Node environments are often different to iOS, Android, or web JS environments and therefore require different transpilation presets (ex: `module.exports` instead of `import/export`).
Because of this reasoning, the root of a Node module is searched instead of right next to the `index.js`. Imagine you had a TypeScript Node module where the transpiled main file was located at `build/index.js`, if Expo config plugin resolution searched for `build/app.plugin.js` you'd lose the ability to transpile the file differently.
[config-docs]: https://docs.expo.io/versions/latest/config/app/
[cli-eject]: https://docs.expo.io/workflow/expo-cli/#eject
[sandbox]: https://codesandbox.io/s/expo-config-plugins-8qhof?file=/src/project/app.config.js
[configplugin]: ./src/Plugin.types.ts
## Debugging
You can debug config plugins by running `expo prebuild`. If `EXPO_DEBUG` is enabled, the plugin stack logs will be printed, these are useful for viewing which mods ran, and in what order they ran in. To view all static plugin resolution errors, enable `EXPO_CONFIG_PLUGIN_VERBOSE_ERRORS`, this should only be needed for plugin authors.
By default some automatic plugin errors are hidden because they're usually related to versioning issues and aren't very helpful (i.e. legacy package doesn't have a config plugin yet).