@bluealba/config-manager-core
v0.2.0-feature-config-manager-node8-505
Published
Framework-agnostic configuration manager: hot-reload, live change notification and fallback/degraded mode
Downloads
668
Readme
@bluealba/config-manager-core
Framework-agnostic configuration manager for JavaScript/TypeScript. Reads configuration as key–value pairs with audit metadata, refreshes it in the background (hot-reload), notifies subscribers of live changes, and keeps serving a last-known-good copy when the source fails — signalling clearly that it is running in degraded mode.
- Zero framework deps — no React/NestJS/Express in the core.
- Isomorphic — the agnostic entry works in Node and the browser; runtime-specific adapters live behind
./nodeand./browser. - Pluggable — data sources (providers), the live-change channel (transport) and the cache (fallback) are all swappable adapters.
- Dual ESM + CJS build, fully typed.
- Broad runtime support — runs on Node.js 8+ (strict minimum) and modern browsers; see Runtime support.
Installation
npm install @bluealba/config-manager-coreInternal monorepo dependency (declare with "*"):
{ "dependencies": { "@bluealba/config-manager-core": "*" } }Requirements
| | Requirement |
| --- | --- |
| Node.js | 8+ (strict minimum) — verified down to v8.17.0. No newer runtime is required. |
| Browsers | Any modern evergreen browser (ES2017). |
| Module system | ESM (import) and CommonJS (require) are both published. On Node ≥ 12 either works; on Node 8 use require (it has no ESM). |
| TypeScript | Optional. Ships its own .d.ts; no @types needed. |
| Peer deps | None. @bluealba/from-env (a bundled dependency used by envProvider) is CommonJS and Node 8-safe. |
Two adapter defaults rely on a global that older/non-browser runtimes don't have — inject an implementation there and everything else is transparent:
httpProvider→ globalfetch(Node 18+). On any earlier Node (incl. 8), passfetchImpl.localStorageFallback→ globallocalStorage(browser only). On Node, passstorageor usefileFallback/memoryFallbackinstead.
See Runtime support for how the older-runtime gaps are handled internally.
Quick start
import {
ConfigManager,
httpProvider,
memoryProvider,
pollingTransport,
} from "@bluealba/config-manager-core";
const manager = new ConfigManager({
// Providers from HIGHEST to LOWEST precedence.
providers: [
httpProvider({ url: "https://config.example/api" }),
memoryProvider({ "feature.title": "Default title" }), // fallback defaults
],
transport: pollingTransport({ intervalMs: 30_000 }),
});
await manager.init();
const title = manager.get<string>("feature.title"); // current value
const entry = manager.getEntry("feature.title"); // value + audit metadata
// React to live changes.
const unsubscribe = manager.onKeyChange<string>("feature.title", (change) => {
console.log("changed:", change.previous?.value, "→", change.current?.value);
});
// …later, on shutdown:
unsubscribe();
await manager.dispose();Core concepts
| Term | What it is |
|------|------------|
| Provider | An adapter that fetches config from a source (http, file, env, memory). Where config comes from. |
| Transport | An adapter that signals when something may have changed (polling by default). |
| Fallback | A strategy that persists the last good snapshot so it can be restored when the source fails. The safety net, not a source. |
| Snapshot | The full set of resolved entries at an instant, with its own metadata (revision, sources, degraded). |
| Config entry | A single { key, value, metadata } — the value plus its audit trail. |
| Degraded mode | The manager state while (fully or partially) serving from fallback. |
Providers are merged by precedence; the fallback is the cache of the merged result. They are different roles, hence different interfaces.
The ConfigManager API
interface ConfigManager {
init(): Promise<void>; // initial load + start watching
get<T>(key: string, defaultValue?: T): T | undefined;
getAll(): Record<string, unknown>; // flat key→value (no metadata)
getEntry<T>(key: string): ConfigEntry<T> | undefined; // value + metadata
refresh(): Promise<ConfigSnapshot>; // force an immediate re-resolve
subscribe(listener: (e: ConfigEvent) => void): () => void;
onKeyChange<T>(key: string, listener: (c: KeyChange<T>) => void): () => void;
getStatus(): ConfigStatus; // health / degraded state
dispose(): Promise<void>; // stop timers, release resources
}ConfigManagerOptions
new ConfigManager({
providers, // required — HIGHEST → LOWEST precedence
transport, // default: pollingTransport()
fallback, // default: memoryFallback()
retry, // backoff policy (see below)
debounceMs, // coalesce transport signals; default 50
validate, // (snapshot) => void; throw to reject
context, // forwarded to providers/transport (e.g. { tenant })
});Providers
Providers are ordered highest → lowest precedence. For each key, the value
from the highest-precedence provider that defines it wins; metadata.source
records which provider that was.
memoryProvider — agnostic
memoryProvider({ "feature.title": "Default" }, { name: "defaults", pin: false });Static values, ideal for declared defaults and tests. pin: true marks every
key as non-overridable so a higher-precedence provider cannot override it
(useful for local-dev overrides).
httpProvider — agnostic
httpProvider({
url: "https://config.example/api",
headers: { Authorization: "Bearer …" },
map: (payload) => ({ entries, revision }), // optional — adapt any backend
fetchImpl: customFetch, // optional — inject fetch
});Fetches over fetch with cheap change detection via ETag / If-None-Match
(a 304 Not Modified skips re-parsing). Default contract: flat JSON
{ key: value }, with source/updatedAt synthesized by the core. Provide
map() to adapt any other payload shape:
httpProvider({
url,
map: (payload) => {
const data = payload as { items: { name: string; value: unknown; rev: string }[] };
return {
entries: Object.fromEntries(
data.items.map((i) => [i.name, { value: i.value, metadata: { version: i.rev } }]),
),
};
},
});envProvider — Node (/node)
import { envProvider } from "@bluealba/config-manager-core/node";
envProvider({
mappings: {
port: { env: "PORT", type: "integer" },
enabled: { env: "FEATURE_ENABLED", type: "boolean" },
timeout: { env: ["TIMEOUT_MS", "LEGACY_TIMEOUT"], type: "integer", defaultValue: 30_000 },
dbUrl: "DATABASE_URL", // shorthand for { env: "DATABASE_URL" }
},
prefix: "APP_", // also imports APP_* vars (prefix stripped)
});Reads process.env, wrapping @bluealba/from-env for typed parsing
(integer/boolean/array/json/url/email), defaultValue, and
array-of-keys fallback.
fileProvider — Node (/node)
import { fileProvider } from "@bluealba/config-manager-core/node";
fileProvider({ path: "./config.json" }); // reads flat JSON; revision from mtimeTransports (hot-reload channel)
The transport decouples how the manager learns changes may exist. It only signals; the core decides whether to re-resolve.
pollingTransport — default, agnostic
pollingTransport({ intervalMs: 30_000 }); // default 30s; timer is unref'd in NodeSSE and WebSocket push transports are on the roadmap (v1.x). The core contract (
ConfigTransport) already supports them.
Fallback & degraded mode
This is the resilience core. When the primary source fails, the manager keeps serving the last-known-good configuration and signals that it is stale — it never throws while any fallback layer has data.
The fallback chain (preference order)
1. Current in-memory snapshot (last good resolution this session)
2. Persisted cache (FallbackStrategy.restore(): file / localStorage)
3. Lower-precedence providers (degrades per key)
4. Declared defaultsDegradation is per key: if only the remote fails, its keys degrade while the rest of the snapshot stays healthy.
Detecting "using a stale config" (the warning)
Two complementary mechanisms so a consumer cannot miss it:
// Push — react to transitions:
manager.subscribe((e) => {
if (e.type === "fallback") showBanner("Configuration stale — using last known copy");
if (e.type === "recovered") hideBanner();
});
// Pull — query at any time (health checks, logs):
const s = manager.getStatus();
if (s.degraded) {
console.warn(`stale config since ${s.lastSuccessfulSync}`, s.degradedKeys, s.lastError);
}
// Per-key:
manager.getEntry("feature.title")?.metadata.degraded; // true while staleThe fallback event fires exactly once on the healthy → degraded edge (no
spam); recovered fires when the source comes back.
Surviving a restart
memoryFallback (the default) only survives within a session. To make the
last-known-good config survive a process restart / page reload, use a
persistent fallback:
// Node:
import { fileFallback } from "@bluealba/config-manager-core/node";
new ConfigManager({ providers, fallback: fileFallback({ path: "/var/cache/app/config.json" }) });
// Browser:
import { localStorageFallback } from "@bluealba/config-manager-core/browser";
new ConfigManager({ providers, fallback: localStorageFallback({ key: "app:config" }) });| Fallback | Entry point | Survives |
|----------|-------------|----------|
| memoryFallback() | . | transient failures (same session) |
| fileFallback({ path }) | ./node | process restarts |
| localStorageFallback({ key }) | ./browser | page reloads / brief offline |
A degraded snapshot is never persisted — the cache only ever holds the last healthy snapshot, so it can't be poisoned by stale data.
Events & notifications
type ConfigEvent =
| { type: "reload"; snapshot: ConfigSnapshot } // snapshot replaced
| { type: "change"; changes: KeyChange[] } // one or more keys changed
| { type: "error"; error: Error; provider?: string } // non-fatal resolve error
| { type: "fallback"; status: ConfigStatus } // entered degraded mode
| { type: "recovered"; status: ConfigStatus }; // primary source recovered
interface KeyChange<T = unknown> {
key: string;
previous?: ConfigEntry<T>;
current?: ConfigEntry<T>; // undefined if the key was removed
}subscribe(listener)receives all events; returns an unsubscribe fn.onKeyChange(key, listener)is sugar for a single key.- Idempotent: a
refresh()that finds no real change emits nothing. - A listener that throws never breaks the hot-reload cycle or the other listeners.
Data model
interface ConfigMetadata {
source: string; // winning provider ("http" | "file" | "env" | …)
updatedAt: string; // ISO-8601
updatedBy?: string; // if the source provides it
version?: string | number; // source revision
checksum?: string; // cheap change hash
reason?: string; // audit note, if the source records it
degraded?: boolean; // served from fallback?
}
interface ConfigEntry<T = unknown> { key: string; value: T; metadata: ConfigMetadata; }
interface ConfigSnapshot {
entries: Record<string, ConfigEntry>;
metadata: { revision: string; resolvedAt: string; sources: string[]; degraded: boolean };
}
interface ConfigStatus {
healthy: boolean; // primary responded last cycle
degraded: boolean; // serving (fully/partially) from fallback
activeSource: string;
lastSuccessfulSync?: string;
degradedKeys?: string[];
lastError?: { message: string; at: string };
}Auditing: the core carries the metadata sources provide — it does not invent it. An HTTP backend with history populates
updatedBy/version/reason; an env provider only contributessource+updatedAt. Thedegradedflag is managed by the core.
Precedence, merge & pinning
providers: [
httpProvider({ url }), // wins when reachable
fileProvider({ path }), // covers http's keys if http is down (degraded)
envProvider({ prefix: "APP_" }),
memoryProvider(defaults), // last-resort defaults
]Resolution: all providers load() concurrently; per key, the highest-precedence
provider that defined it wins, unless a lower provider pinned that key
(pin: true). metadata.source records the winner. This chain is itself a
per-key fallback layer (step 3 above).
Validation
Reject an invalid resolved snapshot while keeping the previous one:
new ConfigManager({
providers,
validate: (snapshot) => {
const port = snapshot.entries.port?.value;
if (typeof port === "number" && port <= 0) {
throw new Error("port must be positive"); // snapshot rejected, previous kept, `error` emitted
}
},
});Retry & backoff
On primary-source failure the manager retries with exponential backoff before degrading; the previous snapshot keeps serving meanwhile (a transient failure does not degrade reads).
new ConfigManager({
providers,
retry: {
retries: 3, // default 0
baseDelayMs: 5_000, // first retry delay; default 5s
factor: 2, // default 2 → 5s, 10s, 20s…
maxDelayMs: 300_000, // cap; default 5min
throwOnError: false, // default: degrade instead of throwing on cold start
},
});throwOnError: true makes the first load throw if there is no cached copy
and every layer is empty. Otherwise the manager degrades and emits fallback.
Entry points
import { … } from "@bluealba/config-manager-core"; // agnostic (isomorphic)
import { … } from "@bluealba/config-manager-core/node"; // + envProvider, fileProvider, fileFallback
import { … } from "@bluealba/config-manager-core/browser"; // + localStorageFallbackThe agnostic entry pulls no node:fs / process.env — safe for browser
bundles. Node/browser entries re-export the agnostic surface for convenience.
Runtime support
Node.js 8+ is the strict minimum, and modern browsers are supported. The published bundle is transpiled and self-contained so no host polyfills are required:
- Syntax — ES2018+ (optional chaining, nullish coalescing, object spread) is
lowered to ES2017 at build time (esbuild
node8target).async/awaitis kept (native since Node 7.6). - Runtime APIs —
Promise.allSettled(Node 12.9+) ships a local implementation;globalThis(Node 12+) is resolved via a safe accessor;structuredClone(Node 17+) falls back to a JSON round-trip; the Node file adapters useutil.promisifyover the callbackfsAPI (nofs/promises, nonode:import prefix) with a manual recursivemkdir. - Subpaths on legacy Node — Node 8 ignores the
exportsmap, so physicalnode.js/browser.jsshims at the package root letrequire("@bluealba/config-manager-core/node")and.../browserresolve.
Two defaults assume a newer/host-provided API and must be supplied explicitly on Node 8, otherwise the rest degrades gracefully:
| Feature | Default source | On Node 8 |
| --- | --- | --- |
| httpProvider fetch | global fetch (Node 18+) | pass fetchImpl (e.g. node-fetch) |
| localStorageFallback | global localStorage | pass storage, or use fileFallback/memoryFallback |
The
@bluealba/from-envdependency (used byenvProvider) is itself CommonJS and Node 8-safe.
Custom adapters
All three seams are plain interfaces:
interface ConfigProvider {
readonly name: string;
load(context: LoadContext): Promise<ProviderResult>; // may throw
}
interface ConfigTransport {
readonly name: string;
watch(onSignal: (hint?: ChangeHint) => void, context: WatchContext): TransportSubscription;
}
interface FallbackStrategy {
save(snapshot: ConfigSnapshot): Promise<void>;
restore(): Promise<ConfigSnapshot | undefined>;
}Example custom provider:
const secretsProvider: ConfigProvider = {
name: "secrets",
async load() {
const data = await mySecretStore.fetch();
return { entries: { "db.password": { value: data.password } }, revision: data.version };
},
};Consuming from a framework
The core is usable directly, but two thin wrappers remove the boilerplate:
- React —
@bluealba/config-manager-react:<ConfigProvider>+useConfig/useConfigEntry/useConfigStatus/useConfigManager+<ConfigGuard>, built onuseSyncExternalStore. - NestJS —
@bluealba/config-manager-nestjs:ConfigManagerModule.forRoot/forRootAsync(global) +ConfigManagerService+@ConfigValue(key)+ a health helper.
Both build the ConfigManager internally from the options you pass and own its lifecycle. You still
supply the providers/fallback (from this package), since the wrapper can't guess your data sources.
Testing
npm test --workspace=@bluealba/config-manager-core # vitestThe core is tested without network using memoryProvider() and hand-driven
transports; providers/fallbacks are unit-tested in isolation.
License
PolyForm-Noncommercial-1.0.0 · Blue Alba
