@setu-ts/config-plugin
Configuration management plugin for Setu-TS with strict .env parsing, variable expansion, and
structurally compatible schema validation.
Installation
npm install @setu-ts/config-plugin
# Deno: deno add npm:@setu-ts/config-plugin
Quick Start
import { createApplication } from '@setu-ts/kernel';
import { ConfigPlugin } from '@setu-ts/config-plugin';
import { RuntimePlugin } from '@setu-ts/runtime';
import { CAPABILITIES } from '@setu-ts/common';
import type { IConfig } from '@setu-ts/common';
const app = createApplication();
app.register(RuntimePlugin());
app.register(
ConfigPlugin({
envFilePath: ['.env.local', '.env'],
}),
);
await app.start();
// Access configuration
const config = app.services.get<IConfig>(CAPABILITIES.CONFIG);
const port = config.get('PORT', { default: '3000' });
Configuration a plugin needs at construction
Factory arguments are evaluated before app.start() registers ConfigPlugin and loads .env. Load
a snapshot first when another plugin needs those values at construction, then register that same
snapshot with instance; it is used verbatim without loading again:
import { createApplication } from '@setu-ts/kernel';
import { createRuntimeServices, RuntimePlugin } from '@setu-ts/runtime';
import { ConfigPlugin, loadConfig } from '@setu-ts/config-plugin';
import { AuthPlugin } from '@setu-ts/auth-plugin';
const runtime = createRuntimeServices();
const config = await loadConfig(runtime, { envFilePath: '.env', envFileOptional: true });
const app = createApplication({
plugins: [
RuntimePlugin(),
ConfigPlugin({ instance: config }),
AuthPlugin({ jwt: { secret: config.getOrThrow<string>('JWT_SECRET') } }),
],
});
await app.start();
Options
| Option | Type | Default | Description |
|---|---|---|---|
envFilePath | string | readonly string[] | undefined | Path(s) to .env files. No file loading when absent. |
envFileOptional | boolean | false | Skip an ABSENT configured path instead of throwing (unreadable still throws). |
validationSchema | StructuralSchema<T> | undefined | Zod-compatible whole-snapshot schema for startup validation. |
sections | readonly ConfigSection<unknown>[] | undefined | Typed, declared-key sections to validate and cache at startup. |
expandVariables | boolean | true | Expand ${NAME} references in values. |
instance | IConfig | undefined | An already-loaded snapshot to register verbatim; nothing is read. |
diagnostics | ConfigDiagnosticsOptions | undefined | Opt-in value-free provenance; absent registers a disabled source. |
Configuration Precedence
Values are merged in the following order (highest precedence first):
- Environment variables (
runtime.env) - Earlier file paths (
.env.localoverrides.env) - Later file paths
undefined entries in runtime.env are filtered out.
Dotenv Parsing
Configured files are parsed strictly at startup. Blank lines and comments are ignored; export
prefixes, quoted values, common double-quoted escapes, empty values, and whitespace-delimited inline
comments are supported. Malformed entries, invalid keys, and unterminated quotes fail startup with a
line number. Error messages never include the rejected value.
Variable Expansion
When expandVariables is true (default), ${NAME} references in values are resolved against the
final merged configuration:
# .env
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_URL=postgresql://${DATABASE_HOST}:${DATABASE_PORT}/mydb
- Supports recursive references.
- Resolves references once, after every file and
runtime.envhave been merged. - Detects cycles spanning any combination of sources and throws with a clear error.
- Fails for missing variable references.
- Never uses
evalorFunction.
Validation with Zod
Pass a Zod schema to validationSchema for type coercion and validation at startup:
import { z } from 'zod';
const AppConfigSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
DEBUG: z.coerce.boolean().default(false),
});
app.register(
ConfigPlugin({
envFilePath: ['.env.local', '.env'],
validationSchema: AppConfigSchema,
}),
);
- Coercions and defaults are preserved in the stored configuration.
- The schema’s parsed output must be a non-null, non-array object.
- Validation errors do not disclose secret values.
Typed Configuration Sections
Use a section when related settings should be returned with the type that its schema validated.
Sections are validated and cached during startup; getConfigSection never parses or asserts a value
at read time.
import { CAPABILITIES, type IConfig } from '@setu-ts/common';
import { ConfigPlugin, defineConfigSection, getConfigSection } from '@setu-ts/config-plugin';
import { z } from 'zod';
const database = defineConfigSection({
prefix: 'DATABASE_',
keys: ['URL', 'POOL_SIZE'],
schema: z.object({
URL: z.string().url(),
POOL_SIZE: z.coerce.number().int().positive(),
}),
});
app.register(ConfigPlugin({ sections: [database] }));
const config = app.services.get<IConfig>(CAPABILITIES.CONFIG);
const settings = getConfigSection(config, database);
// settings.POOL_SIZE is a number, validated before the application started.
keys contains prefix-stripped names: prefix: 'DATABASE_' plus keys: ['URL'] reads the flat
DATABASE_URL key and supplies { URL } to the schema. The list is explicit because IConfig
supports named reads but deliberately has no key-enumeration method; this also lets sections
validate an arbitrary instance snapshot. A missing key is omitted, so the schema decides whether
it is optional. A section schema is not interchangeable with validationSchema: the latter receives
the whole flat snapshot, while the former receives only its declared, prefix-stripped keys.
Configuration Provenance
diagnostics is the explicit opt-in to value-free provenance for the approved keys only:
app.register(
ConfigPlugin({
envFilePath: ['.env.local', '.env'],
diagnostics: {
enabled: true, // the literal true: an acknowledgement, not a toggle
keys: { PORT: 'port', DATABASE_URL: 'database' },
files: { '.env.local': 'dotenv-local', '.env': 'dotenv' },
},
}),
);
During the ONE load already performed — never a second pass — the loader records where each approved
key’s final value was observed to come from: the source category and approved source aliases,
evidenced precedence displacement (overriddenSourceAliases, lowest first), ${NAME} expansion
references (expanded + approved referenceAliases), and a schema effect derived only from
input/output property presence. introduced reports that a key appeared only after schema parsing;
it never names a mechanism, because a default and a transform are indistinguishable by presence. No
value, hash, length, raw key name, or file path is ever retained, and unapproved keys are never
observed at all.
loadConfig(runtime, { diagnostics }) builds the record with the snapshot;
ConfigPlugin({ instance, diagnostics }) adopts that exact instance’s record — keeping real origins
— or reports an opaque injected instance as unknown with no presence flag and no read of it. An
adopted record is served as the loader built it, under the loadConfig call’s approvals; the
plugin’s own keys and files then only enable the source and govern the opaque case, so pass the
same policy to both calls. Provenance adds no read to any configuration object. The plugin always
registers the source under CAPABILITIES.CONFIG_DIAGNOSTICS: absent diagnostics answers
disabled. The Diagnostics Connector serves it at GET /v1/config — see
docs/diagnostics-protocol.md.
Hot Reload
Deferred. The current runtime contract has no file-watching abstraction. Configuration is an immutable application-startup snapshot.
Edge Runtimes
On edge platforms (Cloudflare Workers, etc.) where runtime.fs is undefined, envFilePath must
not be set. Attempting to do so throws a clear startup error:
ConfigPlugin: envFilePath requires a runtime with filesystem support.
API Reference
ConfigPlugin(options?)
Creates the configuration plugin. Consumes CAPABILITIES.RUNTIME, provides CAPABILITIES.CONFIG.
ConfigPluginOptions
import type { IConfig } from '@setu-ts/common';
import type { ConfigSection, StructuralSchema } from '@setu-ts/config-plugin';
interface ConfigPluginOptions {
readonly envFilePath?: string | readonly string[];
readonly envFileOptional?: boolean;
readonly validationSchema?: StructuralSchema<unknown>;
readonly sections?: readonly ConfigSection<unknown>[];
readonly expandVariables?: boolean;
readonly instance?: IConfig;
}
StructuralSchema<T>
Minimal schema interface compatible with Zod’s parse(unknown) API.
ConfigSection<T>
The typed section declaration returned by defineConfigSection. It carries prefix,
prefix-stripped keys, and a StructuralSchema<T>. Pass it in ConfigPluginOptions.sections, then
retrieve its validated output with getConfigSection(config, section).
IConfig (from @setu-ts/common)
interface IConfig {
get<T>(key: string): T | undefined;
get<T>(key: string, options: { readonly default: T }): T;
getOrThrow<T>(key: string): T;
has(key: string): boolean;
}
Without Schema
When validationSchema is not provided, all values remain as strings from the environment and
.env files.
Dependencies
@setu-ts/common(workspace)- Consumer-supplied structural schema such as Zod (optional; not a package dependency)
Exports
| Export | Kind |
|---|---|
ConfigPlugin | function |
defineConfigSection | function |
getConfigSection | function |
loadConfig | function |
ConfigDiagnosticsOptions | interface |
ConfigPluginOptions | interface |
ConfigSection | interface |
StructuralSchema | interface |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
Full API
Every export and option is documented in PUBLIC_API.md.
API reference
ConfigPlugin() (@setu-ts/config-plugin)
Provides strongly-typed configuration with environment validation and .env file loading.
Configuration is an immutable application-startup snapshot — values are loaded once at startup and
never mutated. Hot reload is deferred (the runtime contract has no file-watching abstraction).
Registration
import { ConfigPlugin } from '@setu-ts/config-plugin';
import { CAPABILITIES } from '@setu-ts/common';
import { z } from 'zod';
const AppConfigSchema = z.object({
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
REDIS_URL: z.string().url().optional(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
app.register(
ConfigPlugin({
envFilePath: ['.env.local', '.env'],
validationSchema: AppConfigSchema,
expandVariables: true,
}),
);
Usage
app.router.get('/config', (ctx) => {
const config = ctx.services.get<IConfig>(CAPABILITIES.CONFIG);
return ctx.response.json({
port: config.get<number>('PORT'),
env: config.get<string>('NODE_ENV'),
hasRedis: config.has('REDIS_URL'),
redisUrl: config.get<string>('REDIS_URL', { default: 'redis://localhost:6379' }),
});
});
Config Interface
interface IConfig {
get<T>(key: string): T | undefined;
get<T>(key: string, options: { readonly default: T }): T;
getOrThrow<T>(key: string): T;
has(key: string): boolean;
}
ConfigPluginOptions
interface ConfigPluginOptions {
readonly envFilePath?: string | readonly string[];
readonly envFileOptional?: boolean;
readonly validationSchema?: StructuralSchema<unknown>;
readonly sections?: readonly ConfigSection<unknown>[];
readonly expandVariables?: boolean;
readonly instance?: IConfig;
readonly diagnostics?: ConfigDiagnosticsOptions;
}
interface ConfigDiagnosticsOptions {
readonly enabled: true;
readonly keys: Readonly<Record<string, string>>;
readonly files?: Readonly<Record<string, string>>;
}
envFilePath— Path or paths to.envfiles. Defaults to no file loading. When supplied, the runtime must providefs(absent on edge platforms).envFileOptional— Whentrue, a path inenvFilePaththat does not exist is skipped instead of throwing. Defaults tofalse, which is the behaviour released in0.1.0. Only ABSENCE is tolerated: a file that exists and cannot be read still throws. This is what a scaffolded project uses, because the dotenv file the CLI emits is gitignored and therefore absent on every fresh clone, in CI, and in a container built from the repository.validationSchema— A Zod-compatible schema for startup validation. The schema’sparse()is called once after merging and expansion; the parsed output is stored as the configuration snapshot, preserving Zod coercions and defaults.sections— Typed sections validated aftervalidationSchemahas parsed the whole snapshot. Each section reads only its declared prefix-plus-key entries, parses them once at startup, and caches the output forgetConfigSection. Sections validate even wheninstanceis supplied.expandVariables— Whentrue(default), expand${NAME}references in values using the final merged configuration.instance— An already-loaded snapshot to register verbatim. Present → nothing is read from the environment or from disk andenvFilePath,envFileOptional,validationSchema, andexpandVariablesare ignored; declaredsectionsstill validate it through named reads. Absent → configuration loads normally. This exists so an application can resolve configuration before its plugins are constructed and then hand the plugin that same object, rather than letting it load a second snapshot a moment later that the composition never saw.diagnostics— The opt-in value-free provenance policy, below.
loadConfig()
import { loadConfig } from '@setu-ts/config-plugin';
import { createRuntimeServices } from '@setu-ts/runtime';
const config = await loadConfig(createRuntimeServices(), {
envFilePath: ['.env.local', '.env'],
});
const port = config.get<number>('PORT', { default: 3000 });
(runtime: IRuntimeServices, options?: ConfigPluginOptions) => Promise<IConfig> — the same
implementation ConfigPlugin.register uses, reachable without an application. Use it when
configuration must be read before any plugin exists (choosing which plugins to register, for
instance); inside an application, resolve CAPABILITIES.CONFIG from the registry instead.
Merging, expansion, and validation behave identically on both paths because there is only one: the
plugin delegates to this function and registers what it returns. Pass the result back as
ConfigPlugin({ instance: config }) so the application holds exactly one snapshot —
createFullStackAppFromConfig does precisely that.
StructuralSchema<T>
interface StructuralSchema<T> {
parse(input: unknown): T;
}
Minimal schema interface compatible with Zod’s parse(unknown) API. Consumers supply a Zod schema
without config-plugin depending on Zod.
Typed Configuration Sections
import { CAPABILITIES, type IConfig } from '@setu-ts/common';
import { ConfigPlugin, defineConfigSection, getConfigSection } from '@setu-ts/config-plugin';
import { z } from 'zod';
const database = defineConfigSection({
prefix: 'DATABASE_',
keys: ['URL', 'POOL_SIZE'],
schema: z.object({
URL: z.string().url(),
POOL_SIZE: z.coerce.number().int().positive(),
}),
});
app.register(ConfigPlugin({ sections: [database] }));
const config = app.services.get<IConfig>(CAPABILITIES.CONFIG);
const settings = getConfigSection(config, database);
// settings.POOL_SIZE is a validated number.
ConfigSection<T> is the typed declaration returned by defineConfigSection. prefix is prepended
to every prefix-stripped entry in keys, so the example reads DATABASE_URL and
DATABASE_POOL_SIZE while the schema receives { URL, POOL_SIZE }. The explicit key list is
required because IConfig intentionally supports named reads but no key enumeration; this also
makes an arbitrary ConfigPluginOptions.instance validatable. Missing keys are omitted and the
schema decides whether they are optional. A section schema is not reusable as validationSchema:
the latter receives the entire flat snapshot and always runs first.
getConfigSection(config, section) returns the exact schema output cached during startup. It throws
when that configuration snapshot did not validate the given definition; it never parses a value at
read time.
Configuration Precedence
Values are merged in the following order (highest precedence first):
- Environment variables (
runtime.env) - Earlier file paths (
.env.localoverrides.env) - Later file paths
undefined entries in runtime.env are filtered out.
Variable references are expanded only after all sources have been merged, so file values may
reference runtime.env, runtime values may reference files, and references may cross file
boundaries. Cycles or missing references fail startup unless expansion is disabled.
Dotenv Parsing
Configured files use strict parsing. Blank lines, comments, optional export prefixes, quoted and
unquoted values, common double-quoted escapes, empty values, and inline comments are supported.
Malformed entries, invalid keys, and unterminated quotes fail startup with a line number but do not
include rejected values in the error message.
Schema validation failures use a stable, value-free error message so validator diagnostics cannot leak configuration secrets.
Edge Runtimes
On edge platforms where runtime.fs is undefined, envFilePath must not be set. Attempting to do
so throws a clear startup error.
Hot Reload
Deferred. Configuration is an immutable application-startup snapshot.
Configuration Provenance (0.8.0)
ConfigDiagnosticsOptions is the explicit opt-in: enabled is the LITERAL true (an absent option
is the disabled path; any other value is refused, with fixed messages that never echo a value),
keys is the exact configuration-key to display-alias allowlist (at most 128 entries, each alias
unique, 1–64 UTF-8 bytes, no control characters), and files optionally allows exact configured
.env paths as source aliases (at most eight, same alias rules). When present, the loader records —
during the ONE load it already performs — where each approved key’s final value was observed to come
from: the source category and approved source aliases, the evidenced precedence displacement
(overriddenSourceAliases, lowest first), the ${NAME} expansion evidence (expanded plus the
approved referenceAliases, at most 16 per key), and the schema effect derived only from
input/output property presence (validated / introduced / removed / not-configured;
introduced reports the appearance and never names a mechanism, since a schema default and a
transform are indistinguishable by presence).
No configuration value, value hash, value length, raw key name, or file path is ever retained or
served. Unapproved keys are never observed — no entry, and no counter disclosing that they exist; an
unapproved file path contributes only the category file. loadConfig(runtime, { diagnostics })
builds the record with the snapshot; ConfigPlugin({ instance, diagnostics }) adopts that exact
instance’s record (keeping each entry’s real environment/file origin — injection is how the
snapshot reached the application, not where its values came from) or, for an opaque injected
instance, reports every approved alias with origin and schema effect unknown and NO presence flag,
without a single read of it. An adopted record is served exactly as the loader built it, under the
loadConfig call’s approvals — the plugin’s own keys and files only enable the source and
govern the opaque case, so the same policy belongs on both calls. Provenance adds no read to any
configuration object: configured sections perform exactly the same IConfig.get calls with
diagnostics disabled and enabled.
The plugin ALWAYS registers the source under the eager CAPABILITIES.CONFIG_DIAGNOSTICS token:
absent diagnostics answers disabled; enabled with no approved resolved entries answers
no-data; otherwise ready. The Diagnostics Connector serves it at GET /v1/config (see
Diagnostics Connector and
docs/diagnostics-protocol.md); snapshots are deeply frozen and bounded by the connector’s 256 KiB
response ceiling.
Exports
| Export | Kind |
|---|---|
ConfigPlugin | function |
ConfigPluginOptions | interface |
loadConfig | function |
StructuralSchema | interface |
defineConfigSection | function |
getConfigSection | function |
ConfigSection | interface |
ConfigDiagnosticsOptions | interface |