Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 44 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Use the `loadConfig` function once, to load and merge configurations from specif
With parameters you can define:
- `configDirs`: (Default: `['${current-working-directory}/config']`) An array of directories to load configurations from
- `environment`: (Default: `process.env.NODE_ENV`) The environment to load specific configurations for
- `variant`: (Default: `undefined`) Allows to define 2nd dimension of configs above environment. Will load configs matching `*-{variant}` pattern
- `fileExtensions`: (Default: `['ts', 'js']`) An array of file extensions to load configurations from
- `throwOnUndefinedProp`: (Default: `true`) If true, throws an error when accessing undefined properties
- `freezeConfig`: (Default: `true`) If true, freezes the configuration object to make it immutable
Expand Down Expand Up @@ -153,14 +154,52 @@ Add this to `.gitignore` to prevent committing local configuration files:
**/config/local*
```

### Second layer of variant configurations

Some applications may need another layer of overrides besides environments (for example: regions, brands, customers etc).
Pass the `variant` option to `loadConfig` and create files that follow the `*-{variant}` naming convention to scope those overrides.

When `variant` is set, Configate looks for these optional files on top of the usual ones:
- `default-{variant}.ext`
- `{environment}-{variant}.ext`
- `local-{variant}.ext`
- `local-{environment}-{variant}.ext`

Example setup:

```text
config/
default.ts
default-customerA.ts
default-customerB.ts
production.ts
production-customerA.ts
```

```ts
// src/config.ts
import { loadConfig } from 'configate';

export const { config } = await loadConfig<AppConfig>({
environment: 'production',
variant: 'customerA',
});
```

This keeps the base config reusable while still letting you target per-variant overrides without duplicating entire files.

### Order of loading configuration files

```
default.ext
{environment}.ext
local.ext
local-{environment}.ext
custom-environment-variables.ext
1. default.ext
2. default-{variant}.ext
3. {environment}.ext
4. {environment}-{variant}.ext
5. local.ext
6. local-{variant}.ext
7. local-{environment}.ext
8. local-{environment}-{variant}.ext
9. custom-environment-variables.ext
```

### Using unsecure config
Expand Down Expand Up @@ -221,4 +260,3 @@ export const { config } = await loadConfig<TestConfig>({
],
});
```

43 changes: 43 additions & 0 deletions src/importConfigFiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,49 @@ describe('importConfigFiles', () => {

process.env.SHALLOW_PROPERTY = undefined;
});

it('loads variant-specific overrides on top of defaults', async () => {
const config = await importConfigFiles<TestConfig>({
configDir: `${import.meta.dirname}/testConfigDirs/variant`,
variant: 'customerA',
fileExtensions: ['ts'],
});

assert.equal(config.shallowProperty, 'default');
assert.equal(
config.nestedObject.nestedProperty1,
'default-customerA-n1',
);
assert.equal(config.nestedObject.nestedProperty2, 'local-customerA-n2');

const configB = await importConfigFiles<TestConfig>({
configDir: `${import.meta.dirname}/testConfigDirs/variant`,
variant: 'customerB',
fileExtensions: ['ts'],
});

assert.equal(configB.shallowProperty, 'default');
assert.equal(
configB.nestedObject.nestedProperty1,
'default-customerB-n1',
);
});

it('prioritizes environment+variant specific files and locals', async () => {
const config = await importConfigFiles<TestConfig>({
configDir: `${import.meta.dirname}/testConfigDirs/variant`,
environment: 'production',
variant: 'customerA',
fileExtensions: ['ts'],
});

assert.equal(config.shallowProperty, 'local-production');
assert.equal(
config.nestedObject.nestedProperty1,
'default-customerA-n1',
);
assert.equal(config.nestedObject.nestedProperty2, 'local-customerA-n2');
});
});

export type TestConfig = {
Expand Down
40 changes: 37 additions & 3 deletions src/importConfigFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { importConfigFile } from './importConfigFile.ts';
export async function importConfigFiles<Config extends DefaultConfig>({
configDir,
environment,
variant,
fileExtensions,
}: ImportConfigFilesOptions): Promise<DefaultConfig> {
/** default.ext **/
Expand All @@ -22,11 +23,21 @@ export async function importConfigFiles<Config extends DefaultConfig>({
shouldThrowError: true,
});

/** default-{variant}.ext **/
let defaultVariantConfig: DeepPartial<Config> | undefined;
if (variant) {
defaultVariantConfig = await importConfigFile<DeepPartial<Config>>({
filePath: `${configDir}/default-${variant}`,
fileExtensions,
});
}

/** {environment}.ext **/
let envConfig: DeepPartial<Config> | undefined;
let environmentConfig: DeepPartial<Config> | undefined;
let environmentVariantConfig: DeepPartial<Config> | undefined;
if (environment) {
try {
envConfig = await importConfigFile<DeepPartial<Config>>({
environmentConfig = await importConfigFile<DeepPartial<Config>>({
filePath: `${configDir}/${environment}`,
fileExtensions,
shouldThrowError: true,
Expand All @@ -36,6 +47,16 @@ export async function importConfigFiles<Config extends DefaultConfig>({
`Environment ${environment} defined but no config file found`,
);
}

/** {environment}-{variant}.ext **/
if (variant) {
environmentVariantConfig = await importConfigFile<
DeepPartial<Config>
>({
filePath: `${configDir}/${environment}-${variant}`,
fileExtensions,
});
}
}

/** local.ext **/
Expand All @@ -44,6 +65,15 @@ export async function importConfigFiles<Config extends DefaultConfig>({
fileExtensions,
});

/** local-{variant}.ext **/
let localVariantConfig: DeepPartial<Config> | undefined;
if (variant) {
localVariantConfig = await importConfigFile<DeepPartial<Config>>({
filePath: `${configDir}/local-${variant}`,
fileExtensions,
});
}

/** local-{environment}.ext **/
const localEnvConfig = environment
? await importConfigFile<Config>({
Expand All @@ -60,8 +90,11 @@ export async function importConfigFiles<Config extends DefaultConfig>({

return deepMerge<Config>(
structuredClone(defaultConfig),
structuredClone(envConfig ?? {}),
structuredClone(defaultVariantConfig ?? {}),
structuredClone(environmentConfig ?? {}),
structuredClone(environmentVariantConfig ?? {}),
structuredClone(localConfig),
structuredClone(localVariantConfig ?? {}),
structuredClone(localEnvConfig),
structuredClone(customEnvVarsConfig),
) as Config;
Expand All @@ -70,5 +103,6 @@ export async function importConfigFiles<Config extends DefaultConfig>({
type ImportConfigFilesOptions = {
configDir: string;
environment?: Environment;
variant?: string;
fileExtensions: FileExtension[];
};
16 changes: 12 additions & 4 deletions src/loadConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import { makeSecureDeepProxy } from './makeSecureDeepProxy.ts';
* Features:
* - Load config files in order:
* 1. default.ext
* 2. {environment}.ext
* 4. local.ext
* 3. local-{environment}.ext
* 5. custom-environment-variables.ext (only for .ts and .js files)
* 2. default-{variant}.ext
* 3. {environment}.ext
* 4. {environment}-{variant}.ext
* 5. local.ext
* 6. local-{variant}.ext
* 7. local-{environment}.ext
* 8. local-{environment}-{variant}.ext
* 9. custom-environment-variables.ext (only for .ts and .js files)
* - Throw an error if a property is accessed that is not defined in the config
* - Freeze the config object to prevent modifications
* - Supported config file formats: TypeScript (.ts, .mts), JavaScript (.js, .mjs), JSON (.json)
Expand All @@ -22,6 +26,7 @@ import { makeSecureDeepProxy } from './makeSecureDeepProxy.ts';
*
* @param configDirs - paths to directories where the config files are located. Relative or absolute. Directories are merged in the array order. Default: ['${current-working-directory}/config']
* @param environment - the environment to load the config for. Default: process.env.NODE_ENV
* @param variant - allows to define 2nd dimension of configs above environment. Default: undefined
* @param fileExtensions - an array of file extensions to use when looking for config files. Configs are loaded in this order. Default: ['ts', 'js']
* @param throwOnUndefinedProp - throw an error if a property is accessed that is not defined in the config. Default: true
* @param freezeConfig - freeze the config object to prevent modifications. Default: true
Expand All @@ -33,6 +38,7 @@ import { makeSecureDeepProxy } from './makeSecureDeepProxy.ts';
export async function loadConfig<Config extends DefaultConfig>({
configDirs = [`${process.cwd()}/config`],
environment = process.env.NODE_ENV,
variant,
fileExtensions = ['ts', 'js'],
throwOnUndefinedProp = true,
freezeConfig = true,
Expand All @@ -46,6 +52,7 @@ export async function loadConfig<Config extends DefaultConfig>({
const config = await importConfigFiles<Config>({
configDir,
environment,
variant,
fileExtensions,
});

Expand All @@ -71,6 +78,7 @@ export async function loadConfig<Config extends DefaultConfig>({
type LoadConfigOptions = {
configDirs?: string[];
environment?: Environment;
variant?: string;
fileExtensions?: FileExtension[];
throwOnUndefinedProp?: boolean;
freezeConfig?: boolean;
Expand Down
8 changes: 8 additions & 0 deletions src/testConfigDirs/variant/default-customerA.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { DeepPartial } from '../../common.ts';
import type { TestConfig } from '../../importConfigFiles.test.js';

export const config: DeepPartial<TestConfig> = {
nestedObject: {
nestedProperty1: 'default-customerA-n1',
},
};
8 changes: 8 additions & 0 deletions src/testConfigDirs/variant/default-customerB.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { DeepPartial } from '../../common.ts';
import type { TestConfig } from '../../importConfigFiles.test.js';

export const config: DeepPartial<TestConfig> = {
nestedObject: {
nestedProperty1: 'default-customerB-n1',
},
};
9 changes: 9 additions & 0 deletions src/testConfigDirs/variant/default.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { TestConfig } from '../../importConfigFiles.test.js';

export const config: TestConfig = {
shallowProperty: 'default',
nestedObject: {
nestedProperty1: 'default-n1',
nestedProperty2: 'default-n2',
},
};
8 changes: 8 additions & 0 deletions src/testConfigDirs/variant/local-customerA.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { DeepPartial } from '../../common.ts';
import type { TestConfig } from '../../importConfigFiles.test.js';

export const config: DeepPartial<TestConfig> = {
nestedObject: {
nestedProperty2: 'local-customerA-n2',
},
};
8 changes: 8 additions & 0 deletions src/testConfigDirs/variant/local-production-customerA.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { DeepPartial } from '../../common.ts';
import type { TestConfig } from '../../importConfigFiles.test.js';

export const config: DeepPartial<TestConfig> = {
nestedObject: {
nestedProperty1: 'local-production-customerA-n1',
},
};
6 changes: 6 additions & 0 deletions src/testConfigDirs/variant/local-production.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { DeepPartial } from '../../common.ts';
import type { TestConfig } from '../../importConfigFiles.test.js';

export const config: DeepPartial<TestConfig> = {
shallowProperty: 'local-production',
};
8 changes: 8 additions & 0 deletions src/testConfigDirs/variant/local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { DeepPartial } from '../../common.ts';
import type { TestConfig } from '../../importConfigFiles.test.js';

export const config: DeepPartial<TestConfig> = {
nestedObject: {
nestedProperty2: 'local-n2',
},
};
6 changes: 6 additions & 0 deletions src/testConfigDirs/variant/production-customerA.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { DeepPartial } from '../../common.ts';
import type { TestConfig } from '../../importConfigFiles.test.js';

export const config: DeepPartial<TestConfig> = {
shallowProperty: 'production-customerA',
};
9 changes: 9 additions & 0 deletions src/testConfigDirs/variant/production.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { DeepPartial } from '../../common.ts';
import type { TestConfig } from '../../importConfigFiles.test.js';

export const config: DeepPartial<TestConfig> = {
shallowProperty: 'production',
nestedObject: {
nestedProperty2: 'production-n2',
},
};