@fluojs/config
v2.0.2
Published
Configuration loading, merging, validation, and typed runtime access for Fluo applications.
Maintainers
Readme
@fluojs/config
Configuration loading, merging, validation, and typed runtime access for fluo applications.
Preparing for the coordinated Node 24 release? Follow the consumer migration guide before upgrading packages.
Table of Contents
Installation
npm install @fluojs/configThe package intentionally declares no package-wide engines.node. ConfigService, merge/validation/clone behavior, and ConfigModule.load({ defaults, processEnv, runtimeOverrides }) are portable and never resolve process.cwd(), a default .env path, or Node filesystem/path/crypto builtins. Env-file loading, default .env loading, and watch mode are Node-only features: they lazily resolve builtins through process.getBuiltinModule(...) (supported on Node.js >=24.0.0 <27) and throw CONFIG_RUNTIME_UNAVAILABLE with guidance to use in-memory options or run that feature on Node.js when the host cannot provide the boundary. This guard checks feature capabilities; it does not compare Node versions or reject portable root imports.
When to Use
Use this package when you need to:
- Load configuration from
.envfiles plus an explicitprocessEnvsnapshot. - Merge multiple configuration sources with strict precedence rules.
- Validate your application configuration at startup.
- Access configuration values through a typed
ConfigService.
Quick Start
Use ConfigModule.load(...) to create the one validated snapshot at the application boundary, then register that snapshot for injection.
import { Module } from '@fluojs/core';
import { ConfigModule } from '@fluojs/config';
import { z } from 'zod';
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
});
const config = ConfigModule.load({
envFilePaths: ['.env'],
processEnv: {
DATABASE_URL: process.env.DATABASE_URL,
},
defaults: { PORT: '3000' },
schema: EnvSchema,
});
@Module({
imports: [ConfigModule.forRoot({
envFilePaths: [],
runtimeOverrides: config,
})],
})
class AppModule {}envFilePaths is the only file input: use a one-entry list for one file or an ordered list for layered files. Use parse when you need a custom flat-file parser instead of the default dotenv parser.
Once registered, you can inject ConfigService to access your values:
import { Inject } from '@fluojs/core';
import { ConfigService } from '@fluojs/config';
@Inject(ConfigService)
class MyService {
constructor(private readonly config: ConfigService) {
const port = this.config.get('PORT');
const dbUrl = this.config.getOrThrow('DATABASE_URL');
}
}Key Capabilities
NestJS Registration Migration
ConfigModule exposes only synchronous forRoot(...) registration and has no ConfigModule.forRootAsync(...) counterpart. When migrating from @nestjs/config:
- Resolve remote secrets and other asynchronous sources at the application-owned bootstrap boundary before defining the module graph, then pass their final values to the synchronous registration call.
- Replace NestJS
loadfactories with nested plain objects indefaultsorruntimeOverrides; keep the nesting intact for deep merging and dot-pathConfigServiceaccess. - Pass an explicit
processEnvsnapshot because@fluojs/configdoes not scan ambient environment variables. - Replace NestJS
validatecallbacks with a synchronous Standard Schema passed asschema; asynchronous schema results are rejected. - Use
global, not NestJSisGlobal. Visibility is global by default, andglobal: falseopts into module-local visibility. - Rewrite call sites to the single-key shape:
ConfigService.get(key)andgetOrThrow(key)take one key and expose no NestJS default-value or options overload. Own defaults indefaultsor theschemaoutput, or apply an explicit??fallback to theget(key)result.
See the canonical NestJS configuration migration guide for the shared validated-snapshot bootstrap pattern and HTTP adapter boundary.
Source Precedence
Configuration is merged in the following order (highest precedence wins):
- Runtime Overrides: Values passed explicitly via
runtimeOverrides. - Process Environment Snapshot: Values passed via the
processEnvoption. - Environment File: Values from the
.envfile (or custom path). - Defaults: Values provided in the
defaultsoption.
@fluojs/config does not scan ambient environment variables automatically. Pass an explicit processEnv snapshot at the bootstrap boundary when process-backed values should participate in precedence.
envFilePaths is the only file input: use a one-entry list for one file and an ordered list for layered files. Relative entries resolve from cwd. Omitting the option selects the default <cwd>/.env only for a file-capable load; explicit in-memory defaults, processEnv, or runtimeOverrides sources suppress that default. envFilePaths: [] disables file loading. envFile and envFilePath are removed and fail with INVALID_CONFIG so JavaScript consumers receive the same migration guidance as TypeScript consumers. parse lets callers replace dotenv parsing with a custom parser for flat key/value files. Missing env files are treated as empty input during load; watch mode also observes the parent directory so creating the file later can trigger a reload.
Ordered Multi-File Env Loading
envFilePaths accepts one explicit, ordered list of env files. The list is merged from lowest to highest precedence into the single env-file tier, so it still sits above defaults and below processEnv and runtimeOverrides.
const config = ConfigModule.load({
envFilePaths: ['.env', '.env.production', '.env.production.local'],
processEnv: {
DATABASE_URL: process.env.DATABASE_URL,
},
schema: EnvSchema,
});Contract:
- Later entries win over earlier entries; plain objects still deep-merge and arrays still replace.
- Relative entries resolve against
cwd(defaulting toprocess.cwd()); absolute entries are used as-is. - Missing files contribute nothing and never fail the load.
envFilePaths: []explicitly opts out of env-file loading, including the default<cwd>/.envfallback.envFilePathsis the only file-input option. MigrateenvFile: '.env.local'orenvFilePath: '.env.local'toenvFilePaths: ['.env.local']; duplicate resolved paths and blank entries still fail withINVALID_CONFIG.- The schema validates the fully merged result once, not each file individually.
- In watch mode every distinct parent directory is watched once, any listed-file change recomputes the entire list, and deleting a higher-precedence file falls back to the remaining files. Validation failures keep the last valid snapshot.
- Automatic profile discovery stays outside the package: the caller decides the exact list and order.
The package does not derive env-file names from NODE_ENV. Build the list at the bootstrap boundary when a deployment needs environment-specific layering.
For standalone reloads, create one manager with the same explicit file list:
import { ConfigReloadManager } from '@fluojs/config';
const reloader = ConfigReloadManager.create({
envFilePaths: ['.env', '.env.local'],
watch: true,
schema: EnvSchema,
});ConfigModule.forRoot({ watch: true }) is the module-registration path for automatic injected-service reloads. That registration exposes one CONFIG_RELOADER instance with manual reload(), success/error subscriptions, rollback on listener failure, and terminal close() behavior; do not register a second reload manager for the same files.
Importing the root @fluojs/config package is safe for in-memory consumers that only need ConfigService, option types, or ConfigModule.load(...) with explicit in-memory inputs. Non-Node runtimes are supported for those portable paths. Env-file, default .env, and watch execution remain Node-only and require a host with process.getBuiltinModule(...); unsupported hosts receive the documented CONFIG_RUNTIME_UNAVAILABLE error instead of an eager import failure.
Deep Merging
Plain objects are deep-merged by key. Arrays and primitive values from higher-precedence sources completely replace lower-precedence ones.
Validation
The schema option accepts a synchronous Standard Schema-compatible validator such as Zod, Valibot, or ArkType. The schema runs after all sources are merged but before the application starts. Its validated value becomes the final config snapshot, and reported issues fail bootstrap/load/reload with INVALID_CONFIG.
@fluojs/config keeps loading and reload APIs synchronous. Async Standard Schema results are rejected with INVALID_CONFIG; use a synchronous schema for config validation.
Runtime Access and Reload Cost Model
ConfigService.get('a.b.c') resolves dot-path keys by walking each path segment, so lookup cost is proportional to path depth. When get(), getOrThrow(), or snapshot() returns an object-like value, the returned value is a detached clone; clone cost is proportional to the returned subtree size so caller mutations cannot affect the active config snapshot.
ConfigReloadManager.reload() serializes reload work. If another reload is requested while the current reload is notifying listeners, the follow-up reload is queued and applied after the active notification finishes; if the active notification fails, the previous snapshot is restored and the queued reload is discarded. ConfigReloadManager.create(...) provides the same standalone serialization and rollback contract, including manual reloads queued during watch-triggered notifications.
Module registration and standalone reloader creation snapshot caller-owned options before storing them, including nested Standard Schema validator objects supplied through schema. This capture happens synchronously when ConfigModule.forRoot(...) or ConfigReloadManager.create(...) is called, before provider resolution or application bootstrap. Config dictionaries, processEnv, and the Standard Schema descriptor are detached, while callable values such as parse, onReloadError, and the schema validator remain the references captured at that call. Later mutations to the option object or its snapshotted nested objects do not affect bootstrap, manual reloads, or watch reloads. When ConfigModule.forRoot({ watch: true, ... }) is used, the module starts its one env-file watcher during application bootstrap, first aligns the injected ConfigService with the watch reloader baseline, and then updates the same injected ConfigService instance after successful watch reloads. Pass onReloadError when the application needs ownership of automatic watch reload failures from ConfigModule. In watch mode, each listed file's parent directory is watched once, so creating or atomically replacing a listed env file can trigger reload. Watch reloads compare the final ordered env-file content with the last committed watch baseline before reloading, so unchanged saves and change-then-revert bursts do not replace the in-process config snapshot.
ConfigModule is the only module registration path. Its public CONFIG_RELOADER token has one stable same-realm identity across compatible duplicate package copies, allowing a consumer loaded from one copy to resolve the application-owned reloader registered by another. It always exports the shared CONFIG_RELOADER contract for manual reloads and subscriptions; it creates a Node watcher only when watch: true, and closes that watcher during module shutdown. ConfigReloadManager shutdown is terminal: after close() or onModuleDestroy(), the manager never creates another reloader or watcher, reload(), subscribe(), and subscribeError() throw an InvariantError, and current() keeps returning the last committed snapshot. Inject CONFIG_RELOADER from the same ConfigModule.forRoot(...) registration; no second reload registration is needed.
Public API
| Class/Helper | Description |
|---|---|
| ConfigModule | The sole module registration path for configuration, ConfigService, and the shared CONFIG_RELOADER contract; ConfigModule.load(options) is the standalone validated loader. |
| ConfigReloadManager | Coordinates reloads for the injected ConfigService, preserving service identity while replacing snapshots through the reload path; ConfigReloadManager.create(options) creates a standalone terminal manager. |
| CONFIG_RELOADER | Injection token for the shared config reloader contract. |
| ConfigService | Read-only service for typed access to configuration values. Snapshot replacement stays inside the config reload path. |
The package also exports option and subscription types such as ConfigModuleOptions, ConfigLoadOptions, ConfigReloadSubscription, and ConfigReloadReason.
ConfigReloadManager.reload() updates the existing ConfigService instance so consumers keep their injected service identity while observing the new snapshot. If a reload listener throws, the manager restores the previous snapshot and rethrows the listener error. ConfigReloadManager.create(...).reload() follows the same listener serialization and rollback behavior for its standalone reloader snapshot.
Related Packages
@fluojs/runtime: Does not provide configuration loading transitively. Applications that useConfigModule.forRoot(...)or injectConfigServicemust declare@fluojs/configas a direct dependency.- Standard Schema validators: Zod, Valibot, ArkType, and other compatible schema libraries can be passed through the
schemaoption.
Example Sources
packages/config/src/load.tspackages/config/src/options.tspackages/config/src/module.tspackages/config/src/service.tspackages/config/src/load.test.ts- Config and Environments
- Dev Reload Architecture
Compatible service copies
ConfigService and ConfigReloadManager support ordinary class injection from compatible same-realm @fluojs/config copies. CONFIG_RELOADER remains a name-isolated symbol contract.
