Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Node.js Bindings

dynamic-config-node on npm pairs this engine with the schema you already write: Rust resolves, your schema validates, JavaScript reads a cached object.

npm install dynamic-config-node
import { DynamicConfig, zodValidator } from "dynamic-config-node"
import { z } from "zod"

const Database = z.object({ host: z.string(), port: z.number().default(5432) })

const db = await new DynamicConfig({ key: "db", validate: zodValidator(Database) })
  .file("config.toml")
  .env("APP_")
  .initAndCurrent()
//    ^? { host: string; port: number }

One prebuilt binary per platform, through Node-API — which is ABI-stable, so the same binary serves Node 18, 20, 22 and whatever comes next. Nothing compiles at install time and nothing but the engine is a dependency.

What a schema is here

A function. It takes the resolved document and answers the value your program reads, or throws. That is the whole contract, and it is why no schema library is a dependency of this package:

You useYou write
Zodvalidate: zodValidator(Schema)
Ajv / TypeBoxvalidate: ajvValidator(compiled)
Neithervalidate: (document) => { … } — a function of your own
Nothingomit validate; the document is the value, read by dotted path

Schemas has each of them, and what changes between them.

What the engine does

Everything the Rust crate does with sources, unchanged: files merge in call order, the environment beats them, .env sits just below the real environment, a secrets directory beats a remote store, profiles select, discovery searches a path, and two runtime layers bracket the rest. Sources & Precedence is the chapter; the order is the same in all three languages.

await config
  .setDefault("pool.maxSize", 8)     // a fallback the program computes
  .discover("app", ["/etc/app", "."])
  .file("config.toml")
  .file("secrets.toml")              // merges over the first, key by key
  .secretsDir("/run/secrets")        // a Docker or Kubernetes mount
  .envFile(".env")
  .env("APP_")
  .init()

Reading is a property read

current() returns a cached object. Validation runs once per successful resolve, never per read, so reading configuration on every request costs what reading a field costs — which is what makes read it per request the advice rather than copy it at boot.

app.get("/", (request, response) => {
  const { rateLimit } = config.current()   // always the document in force
  …
})

The property the design is for

A document the schema refuses installs nothing. A file edited into something invalid leaves the previous document serving and reports the failure — from the watcher exactly as from an explicit reload(). That is what makes it safe to leave a watcher running in production, and it is the first thing Watching & Hooks demonstrates.

Where the parts live

Every method, every argumentAPI Reference
Zod, Ajv, plain functions, no schemaSchemas
The watcher, onReload, onChangeWatching & Hooks
Express, Fastify, NestJS, Next.js, ReactWeb Frameworks
A store written in JavaScript, and the eight Rust onesRemote Stores
What crosses the boundary, and how oftenImplementation Details
What it will not do, and whyLimitations

The engine's own behaviour — precedence, profiles, discovery, the last-known-good cache, encryption, the document shape rules — is the Rust book, because it is the same engine and describing it twice is how two descriptions drift.

API Reference

Every method, every argument, every default. The TypeScript definitions ship with the package, so an editor has all of this too — this page is what a reader wants when the editor is not the question.

new DynamicConfig<T>(options)

OptionDefaultMeaning
keyrequiredthe section this configuration reads ([db] in a TOML file). It also names the environment prefix — env("APP_") reads APP_DB_* — and every diagnostic. "" is a configuration with nothing to call itself, which goes with wholeDocument()
validatenonewhat turns a resolved object into the value the program reads. Omitted, the document is the value — see Schemas
secretsnonedotted paths whose values must never reach a diagnostic. A redacted or fingerprint cache is refused without them
fieldsnonethe keys the schema declares, for the unknown-key report. Without it check() says it compared nothing

The generic parameter is whatever validate returns, so current() is T with nothing cast.

Sources

Each returns the configuration, so they chain. All of them are refused after the first load — a source added later would take effect on the next reload and nowhere in the document that is serving.

CallWhat it adds
file(path)a file. Files merge left to right, key by key
discover(name, paths)look for name in each of paths, in order
env(prefix)PREFIX_KEY_* from the environment
nest(separator)what spells nesting in a variable name; __ by default
allowEmptyEnv()treat an empty variable as a value rather than as absent
strictEnv()refuse an ambiguous spelling — off, yes, none — instead of guessing
wholeDocument()the file has no section header: its whole document is this section
envFile(path)a .env, which sits just below the real environment
secretsDir(path)one file per key, as Docker and Kubernetes mount
profileEnv(variable)which variable names the profile to select
cache(path, mode)the last-known-good cache: "full", "redacted" or "fingerprint"

The runtime layers

CallWhere it sits
setDefault(path, value)the bottom: a fallback the program computes
setDefaults(values)a whole object at once: every leaf of it is a default
setOverride(path, value)the top: wins over everything
setAssignments(["db.port=1"])--set pairs, above the environment
bindEnv(path, variable)one path to one variable, whatever the prefix rule says
alias(from, to)accept from as another spelling of to
clearDefaults() / clearOverrides() / clearAssignments()empty one layer

All four take effect on the next load.

Lifecycle

CallAnswers
await init()the configuration, loaded, validated and installed
await initAndCurrent()…and the document, for code that wants the values
await reload()the new document. A failure installs nothing
await load()a candidate: loads and validates, installing nothing
current()the document in force. Throws before the first install
tryCurrent()that, or undefined
get(path, fallback?)one value by dotted path
replace(document)installs a document directly, without loading: the testing door. status() and snapshot() still describe the last real load
changes()an async iterator of every installed document
generationhow many documents have been installed

Watching and hooks

Call
watch({ debounceMs, pollMs })reload on a change; pollMs re-stats instead of subscribing
stopWatching()idempotent
onReload(hook)every install, on the loop. Returns a token
onChange(path, hook)one path, when it moves, with both values
removeHook(token)true if it was there

Remote stores

Call
setRemote(fetch, described?)a store: a synchronous function answering { text, format }
await refreshRemote()fetch into the remote layer
clearRemote()drop what it gave
remoteDescriptionwhat the store calls itself
remoteStatus(){ reachable, fetches, consecutiveFailures, lastFailure }

Diagnostics

CallAnswers
sourceOf(path){ kind, detail } — which layer wins the next load, and from where
isSet(path)whether anything supplies it
explain(path)every layer's answer for one path, as a table
check(){ rendered, isClean, unknown, unknownChecked, failure }
snapshot(){ generation, document, loadedAtAgoMs }
status(){ key, generation, consecutiveFailures, lastReason, lastFailure }

Testing

const answer = await config.overrides({ host: "pinned" }, async () => {
  return await somethingThatReads()
})

Pins values for the duration of the block and puts back what it found — so a nested block does not drop the outer one's pin on the way out. No filesystem and no environment involved.

DynamicConfigError

Thrown by every call that can fail.

Field
kind"io", "parse", "missing", "type", "env", "invalid", "remote", "auth", "decrypt", "backend"
paththe dotted key path, or "" when the failure is the load's
originKind"file", "env", "inline", "remote", "runtime", "unknown"
originthe file, the variable, the store — whatever originKind names

The same words the Rust ErrorKind and the Python exception hierarchy use, so the same condition is called the same thing in all three.

Module functions

zodValidator(schema)(document) => schema.parse(document)
ajvValidator(compiled)the same for a validator that answers false
packageVersion()this package's version
engineVersion()the Rust crate it was built against

Schemas

A schema here is a function: it takes the resolved document and answers the value your program reads, or throws. Everything below is that one sentence, applied.

Zod

import { DynamicConfig, zodValidator } from "dynamic-config-node"
import { z } from "zod"

const Database = z.object({
  host: z.string(),
  port: z.number().int().min(1).max(65535).default(5432),
})

const config = new DynamicConfig({
  key: "db",
  validate: zodValidator(Database),
  fields: Object.keys(Database.shape),
})

zodValidator is four lines in this package — (document) => schema.parse(document) — and Zod is not a dependency. A document Zod refuses installs nothing, and the message the failure carries is Zod's own: a schema library says why better than a configuration loader could.

fields is worth passing. It is what check() compares a file's keys against, so hsot = "..." is reported as an unknown key rather than silently ignored.

Ajv, TypeBox and JSON Schema

import { DynamicConfig, ajvValidator } from "dynamic-config-node"
import Ajv from "ajv"

const validate = new Ajv().compile({
  type: "object",
  properties: { host: { type: "string" }, port: { type: "integer" } },
  required: ["host"],
})

const config = new DynamicConfig({ key: "db", validate: ajvValidator(validate) })

Ajv's validator answers false and keeps the reason on itself, which is a different shape from throwing — ajvValidator is the adapter for that, and it renders every issue into the message.

A function of your own

const config = new DynamicConfig<Database>({
  key: "db",
  validate: (document): Database => {
    const record = document as Record<string, unknown>

    if (typeof record.host !== "string") {
      throw new Error("host must be a string")
    }

    return { host: record.host, port: Number(record.port ?? 5432) }
  },
})

The generic parameter is what makes current() worth having: it is Database, not unknown, with nothing cast at the call site.

Whatever the validator returns is what installs. It may coerce, fill defaults and rename — the engine stores what it answered, and every later current() hands back that.

What it may not do is return something JSON cannot carry. The answer crosses back into Rust to be stored, so a class instance arrives as a plain object with its prototype gone and a Date arrives as {}. See what a validator may not be; the short version is to return plain data and construct whatever the program wants at the read.

No schema at all

Omit validate and the document is the value, read by dotted path — the shape a plugin host, a feature-flag table or a tool reading somebody else's file wants:

const flags = new DynamicConfig({ key: "flags" })

await flags.file("flags.toml").init()

flags.get("checkout.newFlow")        // by path
flags.get("checkout.ttl", 60)        // …with a fallback
flags.current()                      // or the whole object

Two answers change, and both are reported rather than assumed:

A declared schemaNo schema
check() unknown keyscompared against fieldsnothing to compare — unknownChecked is false
Secretssecrets: [...] says which pathsthe same, and there is no other way to say it

A redacted or fingerprint cache is refused for a configuration that never said what is secret, rather than writing a file that claims a redaction it did not perform.

What a schema does not do here

It does not choose the sources, and it does not run per read. Validation happens inside the load — before anything installs — which is what makes a refusal leave the previous document serving. That ordering is the whole reason the binding is built the way Implementation Details describes.

Watching & Hooks

config.watch({ debounceMs: 250 })

Every file this configuration reads is watched. An edit reloads it — on the watcher's own thread, so the program is not structured around watching — and the new document is installed only if the schema accepts it.

A rejected edit changes nothing

This is the property the whole design is for, and it holds identically for a watcher-driven reload and an explicit one:

config.onReload((document) => console.log("installed", document))

// A file edited into something the schema refuses:
//   - installs nothing
//   - fires no hook
//   - leaves `current()` answering the last good document
//   - moves `status().consecutiveFailures`

Anything can re-read a file. What makes hot reload safe to leave running in production is that a bad edit is a failed attempt rather than a half-configured process.

Two kinds of hook

const token = config.onReload((document) => …)   // every install
config.onChange("pool.maxSize", (now, before) => …)  // one path, when it moves
config.removeHook(token)

onReload fires once per install, on the event loop — the reload happened on another thread, and the hook is queued to the loop the way any Node callback is. onChange is the same subscription with a comparison in front of it: it fires when the value at that path differs, and hands over both values.

After await config.reload(), your hooks have run. The install happens on a worker thread and the hooks are queued for the loop, so the explicit paths wait one turn of the loop before returning — otherwise await reload() would mean the document is installed but not your hook has seen it, which are two things a caller has every right to think are one.

A watcher-driven reload has no await to hang that on: its hooks fire whenever the loop next breathes, which is what a watcher is.

Polling, for filesystems that do not notify

config.watch({ debounceMs: 250, pollMs: 1_000 })

A container bind mount, an NFS share and a few overlay filesystems deliver no change events. pollMs re-stats on an interval instead, at the cost of that interval's latency — the same choice WatchMode::Poll is in Rust.

Debounce, and why there is one

An editor writing a file is several syscalls, and a naive watcher reloads in the middle of one. The debounce is how long to wait for the writes to stop; 250 ms is the default and is generous enough for every editor and every kubectl apply this has been pointed at.

Stopping

config.stopWatching()

Idempotent, and not required for a process to exit: the watcher holds no reference that keeps the event loop alive. A script that loads a configuration, starts a watcher and finishes still exits — which is the first thing anybody would notice and the last thing they would guess.

Patterns & Style

What using this well looks like from Node, and the mistakes that read fine. The Rust book's Patterns & Style covers the ones about the engine; these are the ones about an event loop.

Read current() where you use it — never at boot

app.get("/", (request, response) => {
  const { rateLimit } = config.current()    // here
  ...
})

Not app.locals.config = config.current(), not a module-level const CONFIG = config.current(). Both copy the document that was in force at startup, and every later reload lands somewhere nobody reads. current() is a property read on a cached object — cheaper than the closure you would write to avoid it.

Per framework, the same rule wearing three hats: an Express handler reads it, a Fastify decorator holds the object, a Nest provider injects the object. Web Frameworks has each.

One configuration per subsystem

const db = new DynamicConfig({ key: "db", validate: zodValidator(Database) })
const flags = new DynamicConfig({ key: "flags" })   // no schema: product keys

Three sections in one file are three objects here, and they fail independently: a flags section somebody broke leaves the database's document serving.

await init() in main, not at module scope

async function main() {
  await config.file("config.toml").env("APP_").init()

  app.listen(3000)
}

void main()

Every load is asynchronous — why — so a module that wants configuration at import time wants top-level await (ESM) or a factory that returns a promise. A Nest useFactory and a Next.js server component are both already async, which is why those examples read as they do.

changes() for work, onReload for a note

for await (const document of config.changes()) {
  await pool.resize(document.pool.maxSize)      // an await is fine here
}

config.onReload((document) => log.info({ generation: config.generation }))

A hook runs when the document installs, and anything slow in it holds the next reload. changes() yields on the loop after the install, so an await in the loop costs the caller and nobody else. Break out of the loop and the subscription is removed for you.

Validate with what the program already has

Zod if you use Zod, Ajv if you use JSON Schema, a function if you use neither, nothing at all if the keys are a product decision. The generic parameter follows whatever the validator returns, so current() is your type with nothing cast. Schemas.

Testing

const answer = await config.overrides({ rateLimit: 1 }, async () => {
  return await somethingThatReads()
})

No filesystem, no environment, and it restores what it found. Two more doors: load() resolves and validates a candidate without installing it, and replace(document) hands the configuration over directly — for the test that does not want a file at all, and for configuration that came from somewhere this library does not know about.

Health, and what to do about a failure

app.get("/healthz", (_request, response) => {
  const status = config.status()

  response.status(status.consecutiveFailures === 0 ? 200 : 503).json(status)
})

A failed reload is recorded, and recorded means somebody has to look. Two numbers matter: consecutiveFailures, and how old the serving document is (snapshot().loadedAtAgoMs). A store that is briefly unreachable should not be a startup gate — that is what the last-known-good cache is for.

TypeScript

  • Give the generic parameter: new DynamicConfig<Database>({...}), or let a validator that returns Database infer it. unknown at the call site means the parameter was lost somewhere.
  • tryCurrent() at a boundary where the configuration may not be installed yet: it is T | undefined, which is what strict wants.
  • get<V>(path, fallback) names the type it expects, so a schemaless read is not any by accident.

Shutting down

config.stopWatching()

Not required for a process to exit — nothing here keeps the event loop alive — but it is what a test wants between cases, and what a long-lived worker wants when it hands its configuration back.

Web Frameworks

One rule covers all of them: hold the configuration object, read current() where you need a value. A framework that reads configuration once at boot is a framework whose configuration has stopped changing, and current() is a property read — reading it per request costs nothing.

Express

const app = express()

app.get("/", (request, response) => {
  const { greeting, rateLimit } = config.current()   // here, not at boot

  response.json({ greeting, rateLimit })
})

app.get("/healthz", (_request, response) => {
  const status = config.status()

  response.status(status.consecutiveFailures === 0 ? 200 : 503).json(status)
})

Deliberately not app.locals.config = config.current(): that copies the document that was in force at boot, and every later reload lands somewhere nobody reads. examples/07-express.mjs runs it.

Fastify

app.decorate("config", config)

app.get("/", async (request) => request.server.config.current())

The object goes on the instance, not its values. A Fastify plugin's options are read once when the plugin registers, which is exactly the mistake above wearing a different hat. examples/08-fastify.mjs.

NestJS

export const databaseConfigProvider = {
  provide: DATABASE_CONFIG,
  useFactory: async (): Promise<DynamicConfig<Database>> => {
    const config = new DynamicConfig<Database>({ key: "db", validate })

    await config.file("config.toml").env("APP_").init()
    config.watch({ debounceMs: 250 })

    return config
  },
}

useFactory is async, which is what init() is — so the application does not start until the configuration has loaded and validated, and a broken file is a startup failure with a message rather than a service that answers wrongly. Inject the configuration object; injecting config.current() would inject the document from application start and freeze it there. examples/10-nestjs/.

Next.js, and the browser

There is no configuration engine in the browser. No filesystem, no watcher, no store. What ships to a client is a snapshot the server chose to send it, and choosing which fields is a security question:

export function publicHalf(config: AppConfig): PublicConfig {
  return { siteName: config.siteName, features: { newCheckout: config.features.newCheckout } }
}

An allow-list, written out field by field — not omit(secrets). A deny-list is a list somebody forgets to add to, and the field they forget is the one that matters.

One instance per server process, parked on globalThis so a development hot reload does not start a watcher per module evaluation. examples/11-nextjs/ and examples/12-react/.

Live updates in the browser are a different feature, and this package does not pretend to have it. The shape is: the server subscribes with onChange and pushes the public half down whatever channel you already have. The engine stays where the files are.

Remote Stores

A store is a function that answers { text, format }:

config.setRemote(() => ({ text: latestJson, format: "json" }), "our config service")

await config.refreshRemote()   // fill the remote layer
await config.reload()          // resolve and validate it

Two steps rather than one, and deliberately: a fetch fills a layer, and a reload is what resolves every layer and validates the result. The same split refresh_remote() and a reload are in Rust, for the same reason — a store answering is not the same event as a configuration installing.

Where it sits

Above the files, below the environment: what a central store distributes should beat what a package shipped, and lose to a variable exported for this one run. A mounted secret (secretsDir) beats it too, for the same argument — it is a fact about this deployment.

The fetch must be synchronous

It is called from a worker thread through the event loop, and a promise cannot be awaited from there. An async source keeps its own last answer and hands that over:

let latest = { text: "{}", format: "json" as const }

setInterval(async () => {
  latest = { text: await readFromService(), format: "json" }
}, 30_000)

config.setRemote(() => latest, "our config service")

That is not a limitation being worked around: a configuration read should not block on a network call it did not schedule, which is why the Rust crate makes refresh_remote() explicit as well.

What the status says

config.remoteStatus()
// { reachable: true, fetches: 3, consecutiveFailures: 0, lastFailure: null }

reachable is three-valued, and the third is the point: null before anything has been asked of the store at all. A source that has been installed and never fetched is not down — reporting it as down is how a scrape at startup pages somebody.

A fetch that fails leaves the last good document serving and moves consecutiveFailures; nothing is torn down.

The eight Rust stores

etcd, Consul, Vault, NATS, Redis, S3, Firestore and git are not in this package — a gRPC stack, an AWS SDK and three HTTP clients in every npm install dynamic-config-node is not a default anybody asked for. They are a second one:

npm install dynamic-config-node-remote
import { Etcd, useStore } from "dynamic-config-node-remote"

const store = new Etcd(["http://etcd:2379"], "myapp/db.json")
const installed = await useStore(config, store)

await installed.refresh()   // later: a timer, a signal, a webhook

Each store is a class with the same two methods this chapter started with — an async fetch() answering { text, format }, and describe() — so a store from that package is indistinguishable from one you wrote. What useStore adds is the bridge: fetch() is async because a round trip must not sit on the loop, and the engine's remote layer is filled from a worker thread and must be handed a synchronous answer, so the last one is kept.

StoreConstructed with
Consuladdress, one of key/keys/prefix, format?, token?, timeoutMs?
Vaultaddress, mount, path/paths, token?, timeoutMs?
Redisurl (the credential rides in it), key/keys/prefix, format?
Etcdendpoints[], key/keys/prefix, format?, username?, password?
Natsserver, bucket, key/keys, format?
S3bucket, key/keys/prefix, format? — credentials from the environment
Firestoreproject, path/paths, accessToken?
Giturl, path/paths/prefix, one of branch/tag/commit, format?, token?

A description never carries a credential: a Redis URL with a password in it and a git URL with a token are both redacted by the store crates' own rule, so an error message is safe to log.

A credential may be a function

A string is right for a token an operator pasted into a deployment, and wrong for one that rotates — a projected service-account token, a Vault lease, a Google access token that lives an hour. So tokenFn (and Firestore's accessTokenFn) is called on the event loop before each fetch, which is where your readFileSync, your cloud SDK and your own cache live:

new Vault("https://vault:8200", "secret", "myapp/db", null, null, null,
  () => readFileSync("/var/run/secrets/vault-token", "utf8"))

TLS, as files or as bytes

new Consul(address, key, null, null, null, null, null, {
  caCertificateFile: "/etc/ssl/private-ca.pem",
  clientCertificateFile: "/etc/ssl/app.crt",
  clientKeyFile: "/etc/ssl/app.key",
})

Both shapes, because both are real: a Kubernetes secret is a mounted file and a certificate fetched at startup is bytes. Saying nothing means the platform's trust store, not no TLS.

Watching a store

Four of them push, and those can be watched:

const handle = store.watch(
  (document) => console.log("the store moved", document),
  (failure) => console.error("the watch ended", failure.error),
)

handle.stop()
StoreHow it notices
Consula blocking query — the agent holds the request open
Rediskeyspace notifications
Etcda watch stream, re-read at the event's own revision
Natsa JetStream watch

The loop is a thread of its own and reaches the event loop only to deliver, exactly as the file watcher does.

Vault, S3, Firestore and git have no watch, and that is not a gap: their Rust watch loops poll — a version counter, an ETag, an update time, a commit — so setInterval(() => installed.refresh(), 30_000) is the same thing with one fewer thread, and it is a line you can read.

A store watch hands you a document; useStore is what installs one. Keeping those apart is what lets a caller log a change, or refuse it, before the engine has acted on it.

Implementation Details

What crosses the boundary, how often, and the three decisions that shaped it. None of this is needed to use the binding; it is here because the answers are unusual enough to be worth writing down.

The thread rule, and why every load is async

Node's rule is that only the event loop may touch a JavaScript value. The engine's rule is that validation happens inside the load, before anything installs — which is what makes a rejected edit change nothing.

The two meet like this: the load runs on a worker thread (libuv's pool, or the file watcher's own), and when it reaches the validate hook it hands the resolved document to the loop through a ThreadsafeFunction and blocks until the answer comes back.

Blocking a worker on the loop is safe in exactly one direction. It is why there is no initSync: a synchronous init() would be the loop thread waiting for itself, which is a deadlock at startup — the worst place to put one.

Nothing is thrown across the boundary

Node-API cannot attach fields to a rejection raised on a worker thread: the Env a rich error object needs does not exist there. So the compiled half never throws. Every fallible call answers

{ ok: true, value }   |   { ok: false, error: { kind, path, originKind, origin, message } }

and the JavaScript facade turns the second into a DynamicConfigError with those fields on it. The union never reaches a caller — it is the wire between two halves of one package, and the alternative is the bare Error whose only structured part is its message that Node libraries usually ship.

No JavaScript reference is held by Rust

A Python validator returns an instance, and the Python binding holds it. A JavaScript validator returns a plain object, so what is held here is a serde_json::Value — and nothing in the compiled half owns a JavaScript reference past a call.

That is what lets the watcher thread install a document while the loop is asleep: there is no handle for it to have taken. The facade caches the converted object, so current() is a property read rather than a conversion.

What a read costs

CallCost
current()a property read on a cached object
get("a.b")that, plus one walk of the path
generationone call into the addon, one atomic load
init / reload / refreshRemotea worker thread, and one call into the loop per validation

A configuration is read on every request and reloaded rarely, which is why the split falls where it does.

The two versions

packageVersion()   // this npm package
engineVersion()    // the Rust crate it was built against

They move on two schedules: the package embeds the engine rather than depending on a published version of it, so a Rust-only release has nothing in it for a Node user.

Node-API, not a per-version build

The addon is compiled against Node-API, which is ABI-stable: one prebuilt binary per platform serves Node 18, 20, 22 and whatever comes next — the way an abi3 wheel serves CPython 3.9 upwards. Nothing compiles at install time. CI still runs the suite on every version the package claims, because "ABI-stable" is a claim about the addon and the JavaScript half is ordinary code that a version can break.

Stability & Production Use

Beta, and the surface is finished for 0.x.

dynamic-config-node and dynamic-config-node-remote are Beta, like every crate and package in this repository. Between here and 1.0, only security fixes and hotfixes land: no new sources, no new schema doors, no new methods on the settled types. What still ships is a defect that produces a wrong answer, a security advisory, and documentation — each as a patch.

That is a change of intent rather than of policy, and it is worth saying plainly because the two look identical from outside: a project that publishes weekly because it is growing and one that publishes rarely because it is finished are both quiet. This is the second.

What that means for your program

Pin the minor version and take patches automatically.

{ "dependencies": { "dynamic-config-node": "~0.0.1" } }

A patch will not break you. Pre-1.0 a break bumps the minor, is called out in the changelog, and comes with what to change on your side.

The two packages version together. dynamic-config-node-remote declares the base package as a peer dependency and hands documents to it; a gap between them is a combination nobody has tested.

The engine's version is a separate number. packageVersion() is this package's; engineVersion() is the Rust crate it was built against.

Node versions

LineStatusTested in CINotes
18supported — the floor✅ every commitengines.node is >= 18
20supported✅ every commit
22supported✅ every commit
24supported✅ every commit
26 and laterexpected to workNode-API is ABI-stable; a line is added to the matrix when it is released
16 and oldernot supportedEnd of life; engines.node refuses

The addon is compiled against Node-API, which is ABI-stable — the same prebuilt binary serves every line above and the ones after them, the way an abi3 wheel serves CPython 3.9 upwards. Nothing compiles at install time.

The matrix exists anyway, because "ABI-stable" is a claim about the addon: the JavaScript half is ordinary code that a version can break, and node --test, AsyncGenerator and setImmediate ordering are all things a release has changed before.

Raising the floor is a breaking change, treated exactly as an API break. It will not happen before 1.0.

Platformx64arm64
Linux (glibc)
macOS
Windows

One prebuilt binary per row, installed as an optional dependency — so an install downloads one, not five. musl (Alpine) is not among them: the addon links glibc, and an Alpine image needs gcompat or a glibc-based base. Saying so beats an install that resolves and then crashes on first import.

TypeScript: the definitions are hand-written and checked under strict, exactOptionalPropertyTypes and noUncheckedIndexedAccess. TypeScript 5.0 and later; nothing in them needs a newer feature.

What is tested, and where you can see it

The suite41 tests across both packages, on four Node versions
The typestsc --strict, with exactOptionalPropertyTypes and noUncheckedIndexedAccess, over a file written the way a caller writes one
Every examplethe runnable ones run in CI; the TypeScript ones are typechecked there
The artefacteach platform's suite runs against the binary that will ship, not a debug build of the same source
The engine underneaththe Rust crate's own suite, property tests, loom and shuttle models for the reload path, and instruction-count gates
The storeseach against a real server in a container, and three unplugged mid-watch by a proxy

What running this in production actually asks of you

Decide what a failed reload should do. The default is right for most services — the previous document keeps serving and the failure is recorded — but recorded means somebody has to look. status() in a health endpoint is two lines:

app.get("/healthz", (_request, response) => {
  const status = config.status()

  response.status(status.consecutiveFailures === 0 ? 200 : 503).json(status)
})

Give the last-known-good cache a path that survives a restart, so a broken source at startup is a warning rather than an outage. A redacted cache refuses to write at all unless the configuration has said what is secret.

Watch the watcher. A container bind mount and some network filesystems deliver no change events; pollMs is the answer there rather than a mystery.

Read current() where you need a value, not at boot. It is a property read on a cached object. A configuration copied into app.locals at startup is a configuration that has stopped reloading — the one mistake this library cannot stop you making.

Nothing here needs a sidecar, an agent or a server. The engine is in your process; the only thing that leaves is what a store you configured goes to fetch.

Limitations

What this binding does not do, and why each one is a decision rather than a gap.

No initSync

Validation happens inside the load, so the load runs on a worker thread and calls back into the event loop. A synchronous init() would be the loop waiting for itself.

Use top-level await in an ESM module, or await config.init() in your main. A configuration that must exist before anything else — a Nest provider, a Next.js module — has an async door already: useFactory is async, and a server component is.

No configuration engine in the browser

No filesystem, no watcher, no store, and a bundler cannot polyfill any of them. What ships to a client is a snapshot the server chose to send. Web Frameworks draws the line and examples/12-react/README.md writes it out.

The eight Rust stores are a second package

etcd, Consul, Vault, NATS, Redis, S3, Firestore and git each carry a client — gRPC, an AWS SDK, three HTTP stacks — and putting them in every npm install dynamic-config-node is not a default anybody asked for. Same reasoning as the second wheel in Python.

A store this package does not ship is still a function away: Remote Stores.

A remote fetch is synchronous

setRemote takes a function that answers { text, format }, not a promise. It is called from a worker thread through the loop, and awaiting from there is not possible. An async source keeps its own last answer; the pattern is three lines and it is in the chapter.

Encrypted files are not exposed

Decryption needs a Decryptor, which is a Rust trait. Decrypt with the CLI and point this at the result. The Python binding draws the same line for the same reason.

save and JSON Schema export are not exposed

The Rust crate can write a configuration back and export a JSON Schema from a type. Neither has an obvious Node shape — a schema here is a function, so there is nothing to export from — and both are one CLI invocation away.

What a validator may not be

Asynchronous. A validator is called inside the load, on a worker thread, and a promise cannot be awaited there. Every schema library's synchronous door — Zod's parse, Ajv's compiled validator — is what this takes. If a check genuinely needs I/O, it is not validation: do it after init() and refuse to start.

A class instance, or anything else JSON cannot carry. What a validator returns crosses back into Rust to be stored, so it is serialised: the document current() hands back is a plain object with the same data and none of the identity. A class loses its prototype — instanceof is false, methods and getters are gone. A Date is worse than a string: it serialises to {}, because it has no fields.

class Database { constructor(host) { this.host = host } get shouty() { … } }

validate: (document) => new Database(document.host)

config.current() instanceof Database   // false
config.current().shouty                // undefined

Return plain data — objects, arrays, strings, numbers, booleans, null — and keep the behaviour outside the configuration, where a reload does not have to rebuild it. Zod is fine as long as the schema is: z.date() and z.map() produce values with the same problem, and z.coerce.string() or an ISO string in the document is the shape that survives. A wrapper the program wants is one line at the read: new Database(config.current()).