/proload

Searches for and loads your tool's JavaScript configuration files with full support for CJS, ESM, TypeScript and more.

Primary LanguageJavaScript

@proload/core

Proload searches for and loads your tool's JavaScript configuration files. Users have complex expectations when it comes to configuration files—the goal of Proload is to offer a single, straightforward and extensible API for loading them.

import load from '@proload/core';

await load('namespace');

@proload/core can be used in node@12.20.1 and up. It relies on Node's native ESM semantics.

Motivation

Configuration files are really difficult to get right. Tool authors tend to think, "Easy solve! I'll just have everyone use one namespace.config.js!" In most cases that should work, but since node@12.17.0, plain .js files can be written in either ESM or CJS—both formats are officially supported and can be configured on a per-project basis. Additionally, node is able to load any file using a .cjs or .mjs extension, not just .js.

Many popular libraries get these semantics wrong, but maintaining and testing this resolution logic in library code can be a huge maintanence burden. As a library author, you don't need to know (or care) which module format your users choose—you just need to load the contents of the config file. @proload/core is a well-tested solution that gets these semantics right, so you can focus on more important things.

You probably have TypeScript users, too! They would definitely appreciate being able to write a .ts config file. @proload/core uses a plugin system to load non-JavaScript files. See Plugins or @proload/plugin-typescript specifically.

Resolution

Out of the box, @proload/core searches up the directory tree for the following files:

  • a [namespace].config.js, [namespace].config.cjs, or [namespace].config.mjs file
  • any of the js/cjs/mjs files inside of config/ directory
  • a package.json file with a top-level [namespace] key

Here's an overview of all the files supported by default for a tool named donut.

await load('donut');

.
├── donut.config.js        // Either ESM or CJS supported
├── donut.config.cjs
├── donut.config.mjs
├── config/                // Great for organizing many configs
│   ├── donut.config.js
│   ├── donut.config.cjs
│   └── donut.config.mjs
└── package.json           // with top-level "donut" property

resolve

resolve is an additional named export of @proload/core. It is an async function that resolves but does not load a configuration file.

  • namespace is the name of your tool. As an example, donut would search for donut.config.[ext].
  • opts configure the behavior of load. See Options.
resolve(namespace: string, opts?: ResolveOptions);

load

The default export of @proload/core is an async function to load a configuration file.

  • namespace is the name of your tool. As an example, donut would search for donut.config.[ext].
  • opts configure the behavior of load. See Options.
load(namespace: string, opts?: LoadOptions);

Options

cwd

load searches up the directory tree, beginning from this loaction. Defaults to process.cwd().

import load from '@proload/core';
await load('namespace', { cwd: '/path/to/user/project' });

filePath

If you already have the exact (absolute or relative) filePath of your user's config file, set the filePath option to disable Proload's search algorithm.

import load from '@proload/core';
await load('namespace', { cwd: '/path/to/user/project', filePath: './custom-user-config.js' });

mustExist

mustExist controls whether a configuration must be found. Defaults to true—Proload will throw an error when a configuration is not found. To customize error handling, you may check the shape of the thrown error.

Setting this option to false allows a return value of undefined when a configuration is not found.

import load, { ProloadError } from '@proload/core';

try {
    await load('namespace', { mustExist: true });
} catch (err) {
    // Proload couldn't resolve a configuration, log a custom contextual error
    if (err instanceof ProloadError && err.code === 'ERR_PROLOAD_NOT_FOUND') {
        console.error(`See the "namespace" docs for configuration info`);
    }
    throw err;
}

context

Users may want to dynamically generate a different configuration based on some contextual information passed from your tool. Any { context } passed to the load function will be forwarded to configuration "factory" functions.

// Library code
import load from '@proload/core';
await load('namespace', { context: { isDev: true }});

// namespace.config.js
export default ({ isDev }) => {
    return { featureFlag: isDev }
}

accept

If you need complete control over which file to load, the accept handler can customize resolution behavior. A return value of true marks a file to be loaded, any other return values (even truthy ones) is ignored.

See the accept interface.

Note that Plugins are able to modify similar behavior. To load non-JavaScript files, you should use a plugin instead of accept.

import load from '@proload/core';

await load('donut', {
    accept(fileName) {
        // Support alternative spelling for any European friends
        return fileName.startsWith('doughnut.config');
    }
})

The following example uses @proload/plugin-typescript to add support for loading .ts files and an accept handler to require all config files to use the .ts extension.

import load from '@proload/core';
import typescript from '@proload/plugin-typescript';

load.use([typescript]);
await load('namespace', {
    accept(fileName) {
        // Only accept `.ts` config files
        return fileName.endsWith('.ts');
    }
})

merge

To customize extends behavior, you may pass a custom merge function to the load function. By default, deepmerge is used.

// Library code
import load from '@proload/core';

const shallowMerge = (a, b) => ({ ...a, ...b })
await load('namespace', { merge: shallowMerge });

// namespace.config.js
export default {
    extends: ['./a.js', './b.js']
}

// a.js
export default {
    a: true
}

// b.js
export default {
    b: true
}

// result
{
    a: true,
    b: true
}

Automatic extends

Tools like typescript and babel have popularized the ability to share configuration presets through a top-level extends clause. extends also allows you to share a local base configuration with other packages, which is extremely useful for monorepo users.

Custom implementation of this behavior can be difficult, so @proload/core automatically recognizes top-level extends clauses (string[]) for you. It recursively resolves and merges all dependent configurations.

// namespace.config.js
export default {
    extends: ['@namespace/preset', '../namespace.base.config.js']
}

Extending local configuration files

In many cases, particularly in monorepos, it's useful to have a base configuration file and use extends in any sub-packages to inherit the base configuration. @proload/core resolves paths in extends relative to the configuration file itself.

.
├── namespace.base.config.js
└── packages/
    ├── package-a/
    │   └── namespace.config.js
    └── package-b/
        └── namespace.config.js

Extending configuration presets

@proload/core uses the same strategy to resolve a configuration file from project dependencies as it does for user configurations. When publishing a configuration preset, use the same file naming strategy as you would for local configuration.

.
├── node_modules/
│   └── @namespace/
│       └── preset-env/
│           ├── package.json
│           └── namespace.config.js
├── package.json
└── namespace.config.js

Assuming @namespace/preset-env is a project dependency, the top-level namespace.config.js file can use extends to reference the dependency.

export default {
    extends: ['@namespace/preset-env']
}

Plugins

In order to support as many use cases as possible, @proload/core uses a plugin system. Plugins build on each other and are designed to be combined. For example, to support a namespacerc.json file, you could use both @proload/plugin-json and @proload/plugin-rc.

import load from '@proload/core';
import rc from '@proload/plugin-rc';
import json from '@proload/plugin-json';

load.use([rc, json]);
await load('namespace');

TypeScript

In order to load a [namespace].config.ts file, use @proload/plugin-typescript.

import load from '@proload/core';
import typescript from '@proload/plugin-typescript';

load.use([typescript]);
await load('namespace');

JSON

In order to load a [namespace].config.json file, use @proload/plugin-json.

import load from '@proload/core';
import json from '@proload/plugin-json';

load.use([json]);
await load('namespace');

YAML

In order to load a [namespace].config.yaml or [namespace].config.yml file, use @proload/plugin-yaml.

import load from '@proload/core';
import yaml from '@proload/plugin-yaml';

load.use([yaml]);
await load('namespace');

RC files

In order to load a [namespace]rc file with any extension, use @proload/plugin-rc.

import load from '@proload/core';
import rc from '@proload/plugin-rc';

load.use([rc]);
await load('namespace');

All Plugins

For illustrative purposes (please don't do this), combining all of these plugins would support the following resolution logic:

.
├── namespace.config.js
├── namespace.config.cjs
├── namespace.config.mjs
├── namespace.config.ts
├── namespace.config.json
├── namespace.config.yaml
├── namespace.config.yml
├── namespacerc.js
├── namespacerc.cjs
├── namespacerc.mjs
├── namespacerc.ts
├── namespacerc.json
├── namespacerc.yaml
├── namespacerc.yml
├── config/
│   ├── namespace.config.js
│   ├── namespace.config.cjs
│   ├── namespace.config.mjs
│   ├── namespace.config.ts
│   ├── namespace.config.json
│   ├── namespace.config.yaml
│   ├── namespace.config.yml
│   ├── namespacerc.js
│   ├── namespacerc.cjs
│   ├── namespacerc.mjs
│   ├── namespacerc.ts
│   ├── namespacerc.json
│   ├── namespacerc.yaml
│   └── namespacerc.yml
└── package.json

Credits

Proload is heavily inspired by tools like cosmiconfig and rc.

Proload would not be possible without @lukeed's amazing work on escalade and uvu.