Skip to content
This repository has been archived by the owner on Jan 18, 2024. It is now read-only.

[config] Create dynamic config "app.config.js" #1342

Merged
merged 32 commits into from
Feb 5, 2020
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
8d8be68
Update Modules.ts
EvanBacon Dec 10, 2019
f259da9
Update Project.ts
EvanBacon Dec 10, 2019
4035f52
Created eval method
EvanBacon Dec 10, 2019
ff86c23
Added types and comments
EvanBacon Dec 10, 2019
49c9cc0
Add getConfig method
EvanBacon Dec 10, 2019
73db509
split tests
EvanBacon Dec 10, 2019
74e9422
Added tests
EvanBacon Dec 10, 2019
f74b86c
Add TS support
EvanBacon Dec 10, 2019
8db2ca4
Update Config.ts
EvanBacon Dec 10, 2019
6d26924
Disable language support for yaml, toml, and TypeScript
EvanBacon Dec 13, 2019
330c1dc
Update resolve-from.js
EvanBacon Dec 13, 2019
935e7ef
Merge branch 'master' into @evanbacon/config/basic-app.config.js
EvanBacon Jan 3, 2020
7141f69
revert auto-format changes
EvanBacon Jan 3, 2020
5228dd4
serialize config
EvanBacon Jan 4, 2020
e75c6c6
Merge branch 'master' into @evanbacon/config/basic-app.config.js
EvanBacon Jan 7, 2020
1d438de
Pass the app.json into the app.config.js method
EvanBacon Jan 7, 2020
367b9cf
Added mode option to config
EvanBacon Jan 7, 2020
537c5de
Remove generated
EvanBacon Jan 14, 2020
6823236
Move serialize
EvanBacon Jan 14, 2020
8ae9dc6
Update ConfigParsing-test.js
EvanBacon Jan 14, 2020
094a38e
Merge branch 'master' into @evanbacon/config/basic-app.config.js
EvanBacon Jan 14, 2020
1c16f65
Update Config.ts
EvanBacon Jan 14, 2020
f2ddf98
Remove support for yaml, toml, ts
EvanBacon Jan 14, 2020
65b5a90
Updated tests
EvanBacon Jan 14, 2020
09c3322
Move context resolver to another method
EvanBacon Jan 14, 2020
0456477
import ConfigContext
EvanBacon Jan 14, 2020
3420528
Merge branch 'master' into @evanbacon/config/basic-app.config.js
brentvatne Jan 22, 2020
b65a012
Merge branch 'master' into @evanbacon/config/basic-app.config.js
EvanBacon Jan 27, 2020
7e5cefd
Format error messages for syntax errors in JS
EvanBacon Jan 30, 2020
2b84c0b
Support expo object in app.json
EvanBacon Jan 30, 2020
e62f020
Update getConfig.ts
EvanBacon Jan 30, 2020
17c186d
Remove support for json5
EvanBacon Feb 5, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 17 additions & 17 deletions packages/config/__mocks__/resolve-from.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
module.exports = {
silent: (fromDirectory, request) => {
const fs = require('fs');
const path = require('path');
module.exports = require(require.resolve('resolve-from'));

try {
fromDirectory = fs.realpathSync(fromDirectory);
} catch (error) {
if (error.code === 'ENOENT') {
fromDirectory = path.resolve(fromDirectory);
} else {
return;
}
}
module.exports.silent = (fromDirectory, request) => {
const fs = require('fs');
const path = require('path');

const outputPath = path.join(fromDirectory, 'node_modules', request);
if (fs.existsSync(outputPath)) {
return outputPath;
try {
fromDirectory = fs.realpathSync(fromDirectory);
} catch (error) {
if (error.code === 'ENOENT') {
fromDirectory = path.resolve(fromDirectory);
} else {
return;
}
},
}

const outputPath = path.join(fromDirectory, 'node_modules', request);
if (fs.existsSync(outputPath)) {
return outputPath;
}
};
2 changes: 1 addition & 1 deletion packages/config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@
],
"dependencies": {
"@expo/json-file": "^8.2.4",
"@types/invariant": "^2.2.30",
"find-yarn-workspace-root": "^1.2.1",
"fs-extra": "^7.0.1",
"invariant": "^2.2.4",
Expand All @@ -54,6 +53,7 @@
},
"devDependencies": {
"@expo/babel-preset-cli": "^0.2.4",
"@types/invariant": "^2.2.30",
"memfs": "^2.15.5"
},
"publishConfig": {
Expand Down
130 changes: 111 additions & 19 deletions packages/config/src/Config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,118 @@
import JsonFile, { JSONObject } from '@expo/json-file';
import fs from 'fs-extra';
import path from 'path';
import slug from 'slugify';

import { AppJSONConfig, ExpRc, ExpoConfig, PackageJSONConfig, ProjectConfig } from './Config.types';
import {
AppJSONConfig,
ConfigContext,
ExpRc,
ExpoConfig,
PackageJSONConfig,
Platform,
ProjectConfig,
} from './Config.types';
import { ConfigError } from './Errors';
import { findAndEvalConfig } from './getConfig';
import { getRootPackageJsonPath, projectHasModule } from './Modules';
import { getExpoSDKVersion } from './Project';

/**
* Get all platforms that a project is currently capable of running.
*
* @param projectRoot
* @param exp
*/
function getSupportedPlatforms(
projectRoot: string,
exp: Pick<ExpoConfig, 'nodeModulesPath'>
): Platform[] {
const platforms: Platform[] = [];
if (projectHasModule('react-native', projectRoot, exp)) {
platforms.push('ios', 'android');
}
if (projectHasModule('react-native-web', projectRoot, exp)) {
platforms.push('web');
}
return platforms;
}

type GetConfigOptions = {
mode: 'development' | 'production';
configPath?: string;
skipSDKVersionRequirement?: boolean;
};

function getConfigContext(
projectRoot: string,
options: GetConfigOptions
): { context: ConfigContext; pkg: JSONObject } {
// TODO(Bacon): This doesn't support changing the location of the package.json
const packageJsonPath = getRootPackageJsonPath(projectRoot, {});
const pkg = JsonFile.read(packageJsonPath);

const configPath = options.configPath || customConfigPaths[projectRoot];

// If the app.json exists, we'll read it and pass it to the app.config.js for further modification
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we want to get rid of this capability, and make the JSON/JS configurations mutually exclusive, for a few reasons:

  • Having the configuration split across two files is more confusing for new users or anyone coming across a project that does this. Now you must know two ways to do the same thing. Now you need to check two files to confirm what configuration value is used.
  • When responding to issues the common question "please show us your app.config.js" becomes "please show us your app.config.js and app.json (and package.json)"
  • JS can include JSON inside it. It can also import configuration from an arbitrary JSON file, if a static format is desired for some values.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having the configuration split across two files

We support this functionality for values that are generated by Expo CLI like bundleIdentifier. I'm totally open to better ideas though. I imagine a new project's config would look something like:

module.exports = function({ config }) {
  return {
  ...config,
  icon: 'assets/icon.png',
  /* etc... */
  }
}

The spread of another config is somewhat unpleasant.

responding to issues

We can add a mechanism for printing out the serialized config after it's been returned from app.config.js.

if a static format is desired for some values.

I imagine eventually app.json will be the static location for generated values.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When responding to issues the common question "please show us your app.config.js" becomes "please show us your app.config.js and app.json (and package.json)"

maybe we could add this to expo diagnostics? and we could automatically filter out any potentially sensitive fields

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all we'd have to do to support this is switch the method used to read the config file.

const { configPath: appJsonConfigPath } = findConfigFile(projectRoot);
let rawConfig: JSONObject = {};
try {
rawConfig = JsonFile.read(appJsonConfigPath, { json5: true });
} catch (_) {}

const { exp: configFromPkg } = ensureConfigHasDefaultValues(projectRoot, rawConfig, pkg, true);

return {
pkg,
context: {
mode: options.mode,
projectRoot,
configPath,
config: configFromPkg,
},
};
}

/**
* Evaluate the config for an Expo project.
* If a function is exported from the `app.config.js` then a partial config will be passed as an argument.
* The partial config is composed from any existing app.json, and certain fields from the `package.json` like name and description.
*
* You should use the supplied `mode` option in an `app.config.js` instead of `process.env.NODE_ENV`
*
* **Example**
* ```js
* module.exports = function({ config, mode }) {
* // mutate the config before returning it.
* config.slug = 'new slug'
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved
* return config;
* }
*
* **Supports**
* - `app.config.js`
* - `app.config.json`
* - `app.json`
*
* @param projectRoot the root folder containing all of your application code
* @param options enforce criteria for a project config
*/
export function getConfig(projectRoot: string, options: GetConfigOptions): ProjectConfig {
if (!['development', 'production'].includes(options.mode)) {
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved
throw new ConfigError(
`Invalid mode "${options.mode}" was used to evaluate the project config.`,
'INVALID_MODE'
);
}

const { context, pkg } = getConfigContext(projectRoot, options);

const config = findAndEvalConfig(context) ?? context.config;

return {
...ensureConfigHasDefaultValues(projectRoot, config, pkg, options.skipSDKVersionRequirement),
rootConfig: config as AppJSONConfig,
};
}

export function readConfigJson(
projectRoot: string,
skipValidation: boolean = false,
Expand Down Expand Up @@ -115,25 +221,11 @@ function parseAndValidateRootConfig(
};
}

function getRootPackageJsonPath(projectRoot: string, exp: ExpoConfig): string {
const packageJsonPath =
'nodeModulesPath' in exp && typeof exp.nodeModulesPath === 'string'
? path.join(path.resolve(projectRoot, exp.nodeModulesPath), 'package.json')
: path.join(projectRoot, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
throw new ConfigError(
`The expected package.json path: ${packageJsonPath} does not exist`,
'MODULE_NOT_FOUND'
);
}
return packageJsonPath;
}

function ensureConfigHasDefaultValues(
projectRoot: string,
exp: ExpoConfig,
pkg: JSONObject,
skipNativeValidation: boolean = false
skipSDKVersionRequirement: boolean = false
): { exp: ExpoConfig; pkg: PackageJSONConfig } {
if (!exp) exp = {};

Expand All @@ -160,11 +252,11 @@ function ensureConfigHasDefaultValues(
try {
exp.sdkVersion = getExpoSDKVersion(projectRoot, exp);
} catch (error) {
if (!skipNativeValidation) throw error;
if (!skipSDKVersionRequirement) throw error;
}

if (!exp.platforms) {
exp.platforms = ['android', 'ios'];
exp.platforms = getSupportedPlatforms(projectRoot, exp);
}

return { exp, pkg };
Expand Down
15 changes: 14 additions & 1 deletion packages/config/src/Config.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -915,4 +915,17 @@ export type ExpoConfig = {
};
export type ExpRc = { [key: string]: any };
export type Platform = 'android' | 'ios' | 'web';
export type ConfigErrorCode = 'NO_APP_JSON' | 'NOT_OBJECT' | 'NO_EXPO' | 'MODULE_NOT_FOUND';
export type ConfigErrorCode =
| 'NO_APP_JSON'
| 'NOT_OBJECT'
| 'NO_EXPO'
| 'MODULE_NOT_FOUND'
| 'INVALID_MODE'
| 'INVALID_CONFIG';

export type ConfigContext = {
projectRoot: string;
configPath?: string;
config: Partial<ExpoConfig>;
mode: 'development' | 'production';
};
19 changes: 19 additions & 0 deletions packages/config/src/Modules.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import resolveFrom from 'resolve-from';
import { stat, statSync } from 'fs-extra';
import { join, resolve } from 'path';
import { ExpoConfig } from './Config.types';
import { ConfigError } from './Errors';

export function resolveModule(
request: string,
Expand Down Expand Up @@ -47,3 +49,20 @@ export function fileExists(file: string): boolean {
return false;
}
}

export function getRootPackageJsonPath(
projectRoot: string,
exp: Pick<ExpoConfig, 'nodeModulesPath'>
): string {
const packageJsonPath =
'nodeModulesPath' in exp && typeof exp.nodeModulesPath === 'string'
? join(resolve(projectRoot, exp.nodeModulesPath), 'package.json')
: join(projectRoot, 'package.json');
if (!fileExists(packageJsonPath)) {
throw new ConfigError(
`The expected package.json path: ${packageJsonPath} does not exist`,
'MODULE_NOT_FOUND'
);
}
return packageJsonPath;
}
5 changes: 4 additions & 1 deletion packages/config/src/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ export function isUsingYarn(projectRoot: string): boolean {
}
}

export function getExpoSDKVersion(projectRoot: string, exp: ExpoConfig): string {
export function getExpoSDKVersion(
projectRoot: string,
exp: Pick<ExpoConfig, 'sdkVersion' | 'nodeModulesPath'>
): string {
if (exp && exp.sdkVersion) {
return exp.sdkVersion;
}
Expand Down
67 changes: 9 additions & 58 deletions packages/config/src/__tests__/Config-test.js
Original file line number Diff line number Diff line change
@@ -1,67 +1,10 @@
import { vol } from 'memfs';

import { readConfigJson } from '../Config';
import { getWebOutputPath } from '../Web';
import { getConfig, readConfigJson } from '../Config';

jest.mock('fs');
jest.mock('resolve-from');

describe('getWebOutputPath', () => {
beforeAll(() => {
const packageJson = JSON.stringify(
{
name: 'testing123',
version: '0.1.0',
main: 'index.js',
},
null,
2
);

const appJson = {
name: 'testing 123',
version: '0.1.0',
slug: 'testing-123',
sdkVersion: '100.0.0',
};

vol.fromJSON({
'/standard/package.json': JSON.stringify(packageJson),
'/standard/app.json': JSON.stringify({ expo: appJson }),
'/custom/package.json': JSON.stringify(packageJson),
'/custom/app.json': JSON.stringify({
expo: { ...appJson, web: { build: { output: 'defined-in-config' } } },
}),
});
});
afterAll(() => vol.reset());

it('uses the default output build path for web', () => {
const { exp } = readConfigJson('/standard');
const outputPath = getWebOutputPath(exp);
expect(outputPath).toBe('web-build');
});

it('uses a custom output build path from the config', () => {
const { exp } = readConfigJson('/custom');
const outputPath = getWebOutputPath(exp);
expect(outputPath).toBe('defined-in-config');
});

beforeEach(() => {
delete process.env.WEBPACK_BUILD_OUTPUT_PATH;
});
it('uses an env variable for the web build path', () => {
process.env.WEBPACK_BUILD_OUTPUT_PATH = 'custom-env-path';

for (const project of ['/custom', '/standard']) {
const { exp } = readConfigJson(project);
const outputPath = getWebOutputPath(exp);
expect(outputPath).toBe('custom-env-path');
}
});
});

describe('readConfigJson', () => {
describe('sdkVersion', () => {
beforeAll(() => {
Expand Down Expand Up @@ -155,12 +98,20 @@ describe('readConfigJson', () => {

it(`will throw if the app.json is missing`, () => {
expect(() => readConfigJson('/no-config')).toThrow(/does not contain a valid app\.json/);
// No config is required for new method
expect(() => getConfig('/no-config', { mode: 'development' })).not.toThrow();
});

it(`will throw if the expo package is missing`, () => {
expect(() => readConfigJson('/no-package', true)).toThrow(
/Cannot determine which native SDK version your project uses/
);
expect(() =>
getConfig('/no-package', { mode: 'development', skipSDKVersionRequirement: false })
).toThrow(/Cannot determine which native SDK version your project uses/);
});
it(`will throw if an invalid mode is used to get the config`, () => {
expect(() => getConfig('/no-config', { mode: 'invalid' })).toThrow(/Invalid mode/);
});
});
});
Loading