npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 ./node and ./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-core

Internal 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 → global fetch (Node 18+). On any earlier Node (incl. 8), pass fetchImpl.
  • localStorageFallback → global localStorage (browser only). On Node, pass storage or use fileFallback / memoryFallback instead.

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 mtime

Transports (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 Node

SSE 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 defaults

Degradation 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 stale

The 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 contributes source + updatedAt. The degraded flag 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";  // + localStorageFallback

The 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 node8 target). async/await is kept (native since Node 7.6).
  • Runtime APIsPromise.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 use util.promisify over the callback fs API (no fs/promises, no node: import prefix) with a manual recursive mkdir.
  • Subpaths on legacy Node — Node 8 ignores the exports map, so physical node.js / browser.js shims at the package root let require("@bluealba/config-manager-core/node") and .../browser resolve.

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-env dependency (used by envProvider) 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 on useSyncExternalStore.
  • 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   # vitest

The 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