Skip to main content

Configuration

API BackendTooling

Powerful convention based finder, loader, and manager of both configuration and ignore files. Will find config files of multiple supported formats while traversing up the tree.

Installation

yarn add @boost/config

Setup

Configuration in the context of this package encompasses 2 concepts: config files and ignore files. Config files are a collection of settings (key-value pairs), while ignore files are a list of file path patterns and globs.

To utilize this functionality, we must extend the Configuration class, and define a blueprint for the structure of our config file (using optimal). This class will fulfill multiple roles: managing, finding, loading, and processing of files.

import { Blueprint, Schemas } from '@boost/common';
import { Configuration } from '@boost/config';

// Example structure
interface ConfigFile {
ast?: boolean;
cwd?: string;
debug?: boolean;
exclude?: string[];
include?: string[];
options?: object;
}

class Manager extends Configuration<ConfigFile> {
blueprint({ array, bool, string, object }: Schemas): Blueprint<ConfigFile> {
return {
ast: bool(),
cwd: string(process.cwd()),
debug: bool(),
exclude: array().of(string()),
include: array().of(string()),
options: object(),
};
}
}

This class layer is designed to be "internal only", and should not be utilized by consumers directly. Instead, consumers should interact with an instance of the class, like so.

export default new Manager('boost');

The string value passed to the constructor is the name of the config and ignore files, in camel case format. For example, boost.js and .boostignore.

Finder options

To customize the config file finding and loading layer, call Configuration#configureFinder() within #bootstrap(). This method supports all options in ConfigFinderOptions except for name.

class Manager extends Configuration<ConfigFile> {
// ...

bootstrap() {
this.configureFinder({
extendsSetting: 'extends',
includeEnv: false,
});
}
}

Processor options

To customize the config processing layer, call Configuration#configureProcessor() while within #bootstrap(). This method supports all options in ProcessorOptions except for name.

class Manager extends Configuration<ConfigFile> {
// ...

bootstrap() {
this.configureProcessor({
defaultWhenUndefined: false,
});
}
}

Processing settings

When multiple config files are merged into a single config file, this is known as processing. Processing happens automatically for each setting as we need to determine what the next setting value would be. By default, the following rules apply when the next and previous setting values are:

  • Arrays: will be merged and deduped into a new array.
  • Objects: will be shallow merged (using spread) into a new object.
  • Primitives: next value will overwrite the previous value.
  • Undefined: will reset to initial value if defaultWhenUndefined is true.

If you would like to customize this process, you can define custom process handlers per setting with Configuration#addProcessHandler(). This method requires a setting name and handler function (which is passed the previous and next values).

class Manager extends Configuration<ConfigFile> {
// ...

bootstrap() {
// Always use forward slashes
this.addProcessHandler('cwd', (prev, next) => next.replace(/\\/g, '/'));

// Deep merge options since they're dynamic
this.addProcessHandler('options', (prev, next) => deepMerge(prev, next));
}
}

Handlers may only be defined on root-level settings.

To make this process even easier, we provide a handful of pre-defined handlers (below) that can be used for common scenarios (these handlers power the default rules mentioned above).

  • mergeArray - Merges previous and next arrays into a new array while removing duplicates (using Set).
  • mergeExtends - Merges previous and next file paths (either a string or array of strings) into a new list of file paths. This is useful if utilizing config extending.
  • mergeObject - Shallow merges previous and next objects into a new object using object spread.
  • mergePlugins - Merges previous and next plugin configurations into an object. Plugin configs can either be a list of sources, or list of sources with flags/options (tuples), or a map of sources to flags/options. This is useful if utilizing the plugin package.
  • overwrite - Overwrite the previous value with the next value.
import { mergePlugins } from '@boost/config';

class Manager extends Configuration<ConfigFile> {
// ...

bootstrap() {
// Using example from @boost/plugin documentation
this.addProcessHandler('renderers', mergePlugins);
}
}

Config files

A config file is a file that explicitly defines settings (key-value pairs) according to a defined structure.

Configuration files are designed to be serializable, so please use primitive, object, and array values only. Try to avoid non-serializable values like class instances.

File patterns

Config files are grouped into either the root or branch category. The root of a project is denoted by a root *.config.* file, or a folder with the name .config, which contains config files. Branch config files are located within folders (at any depth) below the root, and are prefixed with a leading dot (.).

RootBranch
.config/<name>.<ext>, <name>.config.<ext>.<name>.<ext>
.config/<name>.<env>.<ext>, <name>.config.<env>.<ext>.<name>.<env>.<ext>
  • <name> - Name passed to your Configuration instance (in camel case).
  • <env> - Current environment derived from NODE_ENV.
  • <ext> - File extension supported by the defined loaders and extensions.

File formats

Config files can be written in the formats below, and are listed in the order in which they're resolved (can customize with the extensions option).

  • .js - JavaScript. Will load with CommonJS or ECMAScript modules depending on the package.json type field. Defaults to CommonJS if not defined.
  • .json, .json5 - JSON. Supports JSON5 for both extensions.
  • .cjs - JavaScript using CommonJS (require()). Supported by all Node.js versions.
  • .mjs - JavaScript using ECMAScript modules (import/export). Requires Node.js v13.3+.
  • .ts - TypeScript. Requires the typescript package.
  • .yaml, .yml - YAML. Does not support multi-document.

Based on the file structure in the Setup section above, the config files can be demonstrated as followed (excluding standard JavaScript since it's either CJS or MJS).

module.exports = {
ast: false,
debug: true,
exclude: ['**/node_modules/**'],
include: ['src/**', 'tests/**'],
options: { experimental: true },
};

Loading config files

Config files can be found and loaded with either the Configuration#loadConfigFromRoot() or Configuration#loadConfigFromBranchToRoot() methods -- both of which return a processed config object that abides the ProcessedConfig type.

Lookup resolution

When the finder traverses through the file system and attempts to resolve config files within each/target folder, it does so using the lookup algorithm demonstrated below. Let's assume the following:

  • The config file name is boost.
  • All file formats are supported, in their default lookup order (js, json, cjs, mjs, ts, json5, yaml, yml).
  • The current environment is development (the value of NODE_ENV).
boost.js
boost.development.js
boost.json
boost.development.json
boost.cjs
boost.development.cjs
boost.mjs
boost.development.mjs
boost.ts
boost.development.ts
boost.json5
boost.development.json5
boost.yaml
boost.development.yaml
boost.yml
boost.development.yml

For each file format, we attempt to find the base config file, and an environment config file (if includeEnv is true). This allows for higher precendence config per environment. Once a file is found, the lookup process is aborted, and the confg is returned.

Only 1 file format will be used per folder. Multiple file formats is not supported.

From root

The Configuration#loadConfigFromRoot() will load the config file found in the root, either a *.config.* or .config/*.* file. If no root path is provided, it defaults to process.cwd().

root/.config/boost.json
{
"debug": true
}
const { config } = await manager.loadConfigFromRoot('/root');
{
config: { debug: true },
files: [
{
config: { debug: true },
path: new Path('/root/.config/boost.json'),
source: 'root',
},
],
}

From branch

The Configuration#loadConfigFromBranchToRoot() method will load a config file from each folder while traversing upwards from the branch folder to the root folder. The found list is returned in reverse order so that the deepest branch can be used to overwrite the previous branch (or root).

root/modules/features/.boost.mjs
export default {
ast: true,
};
root/modules/.boost.yaml
options:
experimental: true
root/.config/boost.json
{
"debug": true
}
const { config } = await manager.loadConfigFromBranchToRoot('/root/modules/features');
{
config: {
ast: true,
debug: true,
options: {
experimental: true,
},
},
files: [
{
config: { debug: true },
path: new Path('/root/.config/boost.json'),
source: 'root',
},
{
config: {
options: {
experimental: true,
},
},
path: new Path('/root/modules/.boost.yaml'),
source: 'branch',
},
{
config: { ast: true },
path: new Path('/root/modules/features/.boost.mjs'),
source: 'branch',
},
],
};

Enable extending

Config extending enables consumers of your project to extend and merge with external config files using file system paths or Node.js modules, with the current config file taking precedence. With that being said, extending is not enabled by default and must be configured for use. To enable, define the extendsSetting option with the name of a setting in which extending would be configured.

class Manager extends Configuration<ConfigFile> {
// ...

bootstrap() {
this.configureFinder({
extendsSetting: 'extend',
});
}
}

Consumers may now extend external config files by defining a string or an array of strings for extend (name derived from the example above).

export default {
extend: ['./some/relative/path.js', 'npm-module'],
debug: false,
};

File paths are relative to the file it's configured in.

Presets

To extend from a Node.js module, we must use a preset. A preset is a JavaScript config file located in the module root, named in the format of <name>.preset.js.

npm-module/boost.preset.js
module.exports = {
exclude: ['**/node_modules'],
};

Since the preset is JavaScript, it can be written in either CommonJS or ECMAScript format, assuming the type field has been set in package.json.

Enable overrides

Config overrides enables consumers of your project to define granular settings based on file path matching; settings defined in this fashion would override their base settings. With that being said, overrides are not enabled by default and must be configured for use. To enable, define the overridesSetting option with the name of a setting in which overrides would be configured.

class Manager extends Configuration<ConfigFile> {
// ...

bootstrap() {
this.configureFinder({
overridesSetting: 'override',
});
}
}

Overrides are extracted before configurations are processed, so a process handler is not required.

Consumers may now define overrides in their config file by passing a list of items to the override setting (name derived from the example above). Each item must abide the OverridesSettingItem type.

export default {
debug: false,
override: [
{
include: '*.test.ts',
settings: {
debug: true,
},
},
],
};

Ignore files

An ignore file is a standard text file that denotes files and folders to ignore (filter/exclude/etc), within the current directory, using matching globs and patterns.

File patterns

Both root and branch level ignore files use the same file naming scheme. The file is prefixed with a leading dot (.), followed by the name passed to your Configuration instance (in camel case), and suffixed with ignore.

RootBranch
.<name>ignore.<name>ignore

The root ignore file is not located within the .config folder as ignore paths/patterns/globs must be relative to the current directory.

Loading ignore files

Ignore files can be found and loaded with either the Configuration#loadIgnoreFromRoot() or Configuration#loadIgnoreFromBranchToRoot() methods -- both of which return a list of ignore metadata that abide the IgnoreFile type.

To demonstrate this, let's assume the following file system.

root/
├── modules/
│ ├── features/
│ │ ├── index.ts
│ │ └── .boostignore
│ ├── foo.ts
│ ├── bar.ts
│ └── baz.ts
└── .boostignore
root/modules/features/.boostignore
build/
root/.boostignore
*.log
*.lock

From root

The Configuration#loadIgnoreFromRoot() will load the ignore file found in the root folder (typically 1 file). If no root path is provided, it defaults to process.cwd().

const list = await manager.loadIgnoreFromRoot('/root');
[
{
ignore: ['*.log', '*.lock'],
path: new Path('/root/.boostignore'),
source: 'root',
},
];

From branch

The Configuration#loadIgnoreFromBranchToRoot() method will load an ignore file from each folder while traversing upwards from the branch folder to the root folder. The found list is returned in reverse order so that the deepest branch can be used to overwrite the previous branch (or root).

const list = await manager.loadIgnoreFromBranchToRoot('/root/modules/features');
[
{
ignore: ['*.log', '*.lock'],
path: new Path('/root'),
source: 'root',
},
{
ignore: ['build/'],
path: new Path('/root/modules/features/.boostignore'),
source: 'branch',
},
];