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 use | You write |
|---|---|
| Zod | validate: zodValidator(Schema) |
| Ajv / TypeBox | validate: ajvValidator(compiled) |
| Neither | validate: (document) => { … } — a function of your own |
| Nothing | omit 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 argument | API Reference |
| Zod, Ajv, plain functions, no schema | Schemas |
The watcher, onReload, onChange | Watching & Hooks |
| Express, Fastify, NestJS, Next.js, React | Web Frameworks |
| A store written in JavaScript, and the eight Rust ones | Remote Stores |
| What crosses the boundary, and how often | Implementation Details |
| What it will not do, and why | Limitations |
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)
| Option | Default | Meaning |
|---|---|---|
key | required | the 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() |
validate | none | what turns a resolved object into the value the program reads. Omitted, the document is the value — see Schemas |
secrets | none | dotted paths whose values must never reach a diagnostic. A redacted or fingerprint cache is refused without them |
fields | none | the 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.
| Call | What 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
| Call | Where 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
| Call | Answers |
|---|---|
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 |
generation | how 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 |
remoteDescription | what the store calls itself |
remoteStatus() | { reachable, fetches, consecutiveFailures, lastFailure } |
Diagnostics
| Call | Answers |
|---|---|
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" |
path | the dotted key path, or "" when the failure is the load's |
originKind | "file", "env", "inline", "remote", "runtime", "unknown" |
origin | the 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 schema | No schema | |
|---|---|---|
check() unknown keys | compared against fields | nothing to compare — unknownChecked is false |
| Secrets | secrets: [...] says which paths | the 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 returnsDatabaseinfer it.unknownat the call site means the parameter was lost somewhere. tryCurrent()at a boundary where the configuration may not be installed yet: it isT | undefined, which is whatstrictwants.get<V>(path, fallback)names the type it expects, so a schemaless read is notanyby 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.
| Store | Constructed with |
|---|---|
Consul | address, one of key/keys/prefix, format?, token?, timeoutMs? |
Vault | address, mount, path/paths, token?, timeoutMs? |
Redis | url (the credential rides in it), key/keys/prefix, format? |
Etcd | endpoints[], key/keys/prefix, format?, username?, password? |
Nats | server, bucket, key/keys, format? |
S3 | bucket, key/keys/prefix, format? — credentials from the environment |
Firestore | project, path/paths, accessToken? |
Git | url, 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()
| Store | How it notices |
|---|---|
Consul | a blocking query — the agent holds the request open |
Redis | keyspace notifications |
Etcd | a watch stream, re-read at the event's own revision |
Nats | a 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
| Call | Cost |
|---|---|
current() | a property read on a cached object |
get("a.b") | that, plus one walk of the path |
generation | one call into the addon, one atomic load |
init / reload / refreshRemote | a 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
| Line | Status | Tested in CI | Notes |
|---|---|---|---|
| 18 | supported — the floor | ✅ every commit | engines.node is >= 18 |
| 20 | supported | ✅ every commit | |
| 22 | supported | ✅ every commit | |
| 24 | supported | ✅ every commit | |
| 26 and later | expected to work | — | Node-API is ABI-stable; a line is added to the matrix when it is released |
| 16 and older | not supported | — | End 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.
| Platform | x64 | arm64 |
|---|---|---|
| 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 suite | 41 tests across both packages, on four Node versions |
| The types | tsc --strict, with exactOptionalPropertyTypes and noUncheckedIndexedAccess, over a file written the way a caller writes one |
| Every example | the runnable ones run in CI; the TypeScript ones are typechecked there |
| The artefact | each platform's suite runs against the binary that will ship, not a debug build of the same source |
| The engine underneath | the Rust crate's own suite, property tests, loom and shuttle models for the reload path, and instruction-count gates |
| The stores | each 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()).