Node.js SDK for Quonfig — Feature Flags, Live Config, and Dynamic Log Levels.
Note: This SDK is stable (v1) and follows Semantic Versioning: breaking changes to the public API land only in a major (
x.0.0) release.
npm install @quonfig/nodeimport { Quonfig } from "@quonfig/node";
const quonfig = new Quonfig({ sdkKey: "your-sdk-key" });
await quonfig.init();
// Feature flags
if (quonfig.isEnabled("new-dashboard")) {
// show new dashboard
}
// Config values
const limit = quonfig.getNumber("rate-limit");
const regions = quonfig.getStringList("allowed-regions");
// Context-aware evaluation
const value = quonfig.get("homepage-hero", {
user: { key: "user-123", country: "US" },
});
// Bound context for repeated lookups
const userClient = quonfig.withContext({
user: { key: "user-123", plan: "pro" },
});
userClient.get("feature-x");
userClient.isEnabled("beta-feature");
// Clean up when done
quonfig.close();Migrating from earlier releases:
isFeatureEnabledis still available as a deprecated alias ofisEnabled— both behave identically. New code should preferisEnabled, which matches@quonfig/javascriptand@quonfig/react.inContextis likewise a deprecated alias forwithContext— both behave identically;withContextis the canonical name across all Quonfig SDKs andinContextwill be removed in 2.0.0.
new Quonfig({
sdkKey: "your-sdk-key", // Required (or set QUONFIG_BACKEND_SDK_KEY)
apiUrls: ["https://primary.quonfig.com", "https://secondary.quonfig.com"],
// Ordered failover list. Defaults are derived
// from QUONFIG_DOMAIN (see below).
telemetryUrl: "https://telemetry.quonfig.com",
// Default derived from QUONFIG_DOMAIN.
enableSSE: true, // Real-time updates via SSE (default: true)
fallbackPollEnabled: true, // Engage HTTP polling when SSE is unavailable (default: true)
fallbackPollIntervalMs: 60000, // Fallback poll interval in ms (default: 60000)
sseReadDeadlineMs: 90000, // Drop SSE socket if no chunk arrives within this window
// (default 90000 = 3x the 30s server heartbeat).
initTimeout: 10000, // Init timeout in ms (default: 10000)
onNoDefault: "error", // "error" | "warn" | "ignore" (default: "error")
globalContext: { ... }, // Context applied to all evaluations
datadir: "./workspace-data", // Load local workspace directories instead of API
datafile: "./config.json", // Legacy local envelope path
dataDirAutoReload: false, // Opt in to fs.watch-based re-read in datadir mode (default: false)
dataDirAutoReloadDebounceMs: 200, // Debounce window for the watcher (default: 200)
collectEvaluationSummaries: true, // Send evaluation counts (default: true)
contextUploadMode: "periodic_example", // "periodic_example" | "shapes_only" | "none"
telemetryFlushIntervalMs: 60000, // How often telemetry is sent (default: 60000)
telemetryTimeoutMs: 15000, // Overall deadline per telemetry POST (default: 15000)
telemetryMaxRetainedBatches: 5, // Failed batches kept for resend (default: 5)
telemetryMaxRetainedBytes: 2097152, // Bytes of failed batches kept (default: 2MB)
telemetryMaxRetainedAgeMs: 300000, // Discard kept batches older than this (default: 5 min)
telemetryMaxEvaluationSummaries: 10000, // Distinct flag/config keys per window (default: 10000)
telemetryMaxContextShapeFields: 10000, // Distinct context fields per window (default: 10000)
telemetryMaxExampleContexts: 10000, // Example contexts per window (default: 10000)
});See Telemetry for what these send and how failures are handled.
| Variable | Purpose |
|---|---|
QUONFIG_BACKEND_SDK_KEY |
Fallback for sdkKey when omitted from options. |
QUONFIG_DOMAIN |
Domain used to derive default apiUrls and telemetryUrl. Defaults to quonfig.com. Set to quonfig-staging.com to point everything at staging. |
QUONFIG_ENVIRONMENT |
Environment name to use in datadir mode (overridden by the environment option). |
QUONFIG_DEV_CONTEXT |
Set to false to stop injecting quonfig-user.email from ~/.quonfig/tokens.json (on by default; see below). |
Resolution order for URLs (highest wins):
- Explicit
apiUrls/telemetryUrloption. QUONFIG_DOMAINenv var (deriveshttps://primary.${DOMAIN},https://secondary.${DOMAIN},https://telemetry.${DOMAIN}).- Hardcoded default
quonfig.com.
Developer context: if qfg login has written ~/.quonfig/tokens.json on the machine
(tokens-<domain>.json for a non-production QUONFIG_DOMAIN), the SDK adds a quonfig-user
context with your email to every evaluation (and to the example contexts sent in telemetry) so you
can target yourself in development; turn it off with enableQuonfigUserContext: false or
QUONFIG_DEV_CONTEXT=false.
By default the SDK derives every hostname from QUONFIG_DOMAIN (default quonfig.com):
Role URL
------------------------- ---------------------------------------
Config fetch (primary) https://primary.quonfig.com
SSE stream (primary) https://stream.primary.quonfig.com
Config fetch (secondary) https://secondary.quonfig.com
SSE stream (secondary) https://stream.secondary.quonfig.com
Telemetry https://telemetry.quonfig.com
Set QUONFIG_DOMAIN to move all of them together (e.g. QUONFIG_DOMAIN=quonfig-staging.com).
Automatic failover and hedging between the primary and the secondary are on by default — the
secondary runs on separate infrastructure, and the SDK fails over to it if the primary is
unreachable and hedges to it if the primary is slow.
The apiUrls option replaces the derived list wholesale. To keep automatic failover with custom
URLs, pass both a primary and a secondary URL:
new Quonfig({
sdkKey: "your-sdk-key",
apiUrls: ["https://primary.your-proxy.example", "https://secondary.your-proxy.example"],
});A single apiUrls entry disables failover, and the SDK logs a warning at init. See
Reliability for the full
model.
When enableSSE: true (the default), the SDK opens a Server-Sent Events stream to
https://stream.${primary}/api/v2/sse/config and applies each pushed envelope to the in-memory
store. get* calls always read from the in-memory store, so flag reads never block on the network —
they continue returning the last-known values during a disconnect.
The SDK owns reconnection; it does not rely on the
eventsource library's built-in retry. On any stream
error (network failure, a non-200 response such as a 502 during a deploy, a server-side close, or
the read deadline below) the SDK closes the stream and opens a new one after a backoff:
- Backoff: exponential, starting at 500ms and doubling up to a 30s cap
- Jitter: each wait is a random time between half the current delay and the full delay
- Reset: a successful connection resets the delay to 500ms, so a routine server-side close reconnects quickly
- Max retries: unlimited. The stream always reconnects to the primary stream URL.
- Read deadline (Layer 1, configurable via
sseReadDeadlineMs): the SDK wraps the underlyingfetchwith anAbortControllerwhose deadline resets on every chunk. If no chunk arrives within the window (default 90s = 3x the 30s server heartbeat) the socket is dropped and the SDK reconnects. Without this, a silent server-side stall would wait on the OS TCP timeout (often 2+ hrs).
When SSE is enabled (the default) and fallbackPollEnabled: true (the default), the SDK only
polls when SSE is unavailable:
- If the initial SSE connection fails (DNS, TLS, HTTP error before any successful onopen), the fallback poller engages immediately so you keep receiving updates while the supervisor retries SSE.
- If SSE has been disconnected for >= 2x
fallbackPollIntervalMs(default 120s) without recovering, the fallback poller engages. - When SSE recovers (next successful
connectedtransition), the fallback poller stops.
This is a behavior change from earlier releases where enablePolling: true ran a parallel poller on
top of SSE (double bandwidth, no reconcile). The old options now map onto the new ones with a
deprecation warning.
Pass onSSEConnectionStateChange to surface SSE lifecycle transitions to your host application
(logging, metrics, status pages, etc.):
const quonfig = new Quonfig({
sdkKey: process.env.QUONFIG_BACKEND_SDK_KEY!,
onSSEConnectionStateChange: (state) => {
// state: "connecting" | "connected" | "error" | "disconnected"
metrics.gauge("quonfig.sse.state", state);
if (state === "error") log.warn("Quonfig SSE disconnected; reconnecting…");
},
});State semantics:
| State | When it fires |
|---|---|
connecting |
init() has started SSE; or after an error while the library retries. |
connected |
The SSE stream is open and receiving events. |
error |
The transport surfaced an error. The library will auto-reconnect. |
disconnected |
quonfig.close() was called. |
The callback is fired only on transitions — duplicate consecutive states are suppressed. During a
disconnect, get* calls keep returning the last-known config from the in-memory store.
Two getters expose aggregate health for logging, dashboards, and ad-hoc debugging:
quonfig.lastSuccessfulRefresh(); // Date | undefined — wall-clock time of the last installed envelope (any source).
quonfig.connectionState();
// "initializing" | "connected" | "disconnected" | "falling_back"| State | Meaning |
|---|---|
initializing |
init() has not yet returned. |
connected |
SSE is live, or the SDK is running from a local datadir/datafile. |
disconnected |
SSE has errored and the fallback grace timer has not elapsed, or close() has been called. |
falling_back |
The Layer 2 HTTP fallback poller is the active update channel. |
Do not wire
lastSuccessfulRefresh()orconnectionState()directly into a Kubernetes liveness probe. These signals are diagnostic, not pass/fail. A liveness probe based on SDK freshness will amplify transient network blips into restart cascades.
If you need a binary signal in your own observability stack, compose your own threshold from the two getters (e.g. "warn at 5 min stale, page at 15 min") and feed it into a metric or readiness probe — never a liveness probe.
When you initialize the SDK with datadir: "./path", configs are loaded once from disk at init()
time. Opt in to dataDirAutoReload to have the SDK watch the directory and re-read the envelope
whenever files change — an editor save, a git pull, or a build step.
import { Quonfig } from "@quonfig/node";
const quonfig = new Quonfig({
datadir: "./workspace-data",
environment: "development",
dataDirAutoReload: true, // off by default — must be opted in
onConfigUpdate: () => {
console.log("Quonfig configs reloaded from disk");
},
});
await quonfig.init();
// Edit a file under ./workspace-data and onConfigUpdate fires within ~200ms.
// On shutdown, close() stops the watcher and clears any pending debounce timer.
quonfig.close();- Local development with the datadir checked out from git.
- Self-hosted servers that
git pullthe datadir on a schedule. - CI jobs that mutate the datadir between assertions.
- Read-only / immutable filesystems (some containers, AWS Lambda, scratch images). Watch
registration may fail; the SDK degrades gracefully (logs the error and continues serving the
envelope it loaded at
init()time) but you're paying for nothing. - Build-time-embedded workflows where the datadir is bundled into the artifact and never changes at runtime. Watching wastes a file descriptor and a libuv handle.
- Production paths where reload timing matters — e.g. you'd rather pin the envelope you shipped with and roll forward through a redeploy than have it shift under traffic.
Default is false; datadir mode is silent until you opt in.
- Parse-then-swap. If the new envelope fails to parse (truncated write, mid-
git pullstate, invalid JSON), the SDK logs the error and keeps serving the previous envelope.onConfigUpdateis not fired on parse failure — only on a successful swap. - Debounced. Bursts of filesystem events (atomic-rename editor saves,
git pulltouching dozens of files) coalesce into a single re-read. Default window: 200ms — long enough to absorb the 3–5 events typical editors emit in <50ms, short enough that interactive edits feel immediate. Tune viadataDirAutoReloadDebounceMsif you need a different window. - Graceful degrade. If watch registration fails (read-only fs, immutable container, EMFILE), the
SDK logs and continues without watching — it does not throw from
init(). - Symlinks. The watcher resolves
datadirto its real path at start time. Editing the file the symlink points at is detected; atomic flips that retarget the link itself are not. - Shutdown.
quonfig.close()stops the watcher and clears any pending debounce timer. There is no separate handle to manage — the watcher lifecycle is tied to the client.
new Quonfig({
datadir: "./workspace-data",
dataDirAutoReload: true,
dataDirAutoReloadDebounceMs: 1000, // wait a full second after the last event
});The default (200ms) is tuned for interactive editing. Raise it if you have a noisy producer (continuously regenerating files) and you'd rather see one reload per second than per save. Lower it only if you've measured that 200ms is meaningfully too slow for your use case.
See the open-source / local how-to for the cross-SDK story (sdk-node, sdk-go, sdk-ruby, sdk-python, sdk-java).
winston is an optional peer dependency. Install it alongside @quonfig/node, then compose the
format:
import winston from "winston";
import { Quonfig } from "@quonfig/node";
import { createWinstonFormat } from "@quonfig/node/winston";
const quonfig = new Quonfig({
sdkKey: process.env.QUONFIG_BACKEND_SDK_KEY!,
loggerKey: "log-level.my-app",
});
await quonfig.init();
const logger = winston.createLogger({
level: "silly", // let Winston emit everything; Quonfig decides.
format: winston.format.combine(
createWinstonFormat(quonfig, "myapp.services.auth"),
winston.format.json()
),
transports: [new winston.transports.Console()],
});
logger.info("live-controlled"); // emits iff shouldLog says so.The loggerPath (second arg) is forwarded to quonfig.shouldLog verbatim — no normalization — so
rules can key on whatever identifier shape you actually log ("com.app.Auth",
"MyApp::Services::Auth", etc.).
pino is an optional peer dependency. Install it alongside @quonfig/node, then wire the hook:
import pino from "pino";
import { Quonfig } from "@quonfig/node";
import { createPinoHooks } from "@quonfig/node/pino";
const quonfig = new Quonfig({
sdkKey: process.env.QUONFIG_BACKEND_SDK_KEY!,
loggerKey: "log-level.my-app",
});
await quonfig.init();
const logger = pino({
level: "trace", // let Pino emit everything; Quonfig decides.
hooks: createPinoHooks(quonfig, "myapp.services.auth"),
});
logger.debug("live-controlled");Both adapters also ship convenience constructors — createWinstonLogger and createPinoLogger —
that return a ready-to-use logger with the Quonfig gate already attached.
The SDK sends usage telemetry to telemetryUrl so the Quonfig dashboard can show which flags and
configs are evaluated and with what contexts. Telemetry never affects flag evaluation: every failure
below is contained in the background reporter.
What is sent. Evaluation summaries (per flag/config: counts per rule and value), context shapes
(context field names and types), example contexts (up to one per context key per hour) and failover
counters. Opt out with collectEvaluationSummaries: false and contextUploadMode: "shapes_only"
(no example contexts) or "none" (no context data). With all three off, no reporter runs.
How it is sent.
- One POST every
telemetryFlushIntervalMs(60s), with at most one POST in flight. A tick that fires while a POST is still out is skipped and its data rolls into the next window. - Each POST has an overall deadline of
telemetryTimeoutMs(15s), connect and TLS included. - When a POST fails (timeout, network error, 408, 429 or 5xx), the serialized batch is kept
byte-for-byte and resent unchanged, never merged with newer data, so the server can recognize a
resend of a batch that did land. Up to 5 batches / 2MB are kept for up to 5 minutes; beyond that
the oldest is dropped, and a single batch larger than the byte cap is sent once and never kept.
Resends happen no sooner than 30s after a failure and after any
Retry-After(honored up to 10 minutes), oldest first, then the current window. - A 401, 403 or 404 means the SDK key or
telemetryUrlis wrong: the SDK logs one error and disables telemetry for the rest of the process. Any other 4xx drops that one batch with an error (the server rejected the payload) and telemetry continues.
Logging. A failed POST logs at debug only. The first batch actually dropped logs one warning
with the last POST result and queue depth; further drops log at debug with a summary warning at most
every 10 minutes; the first success after failures logs one info line. The default console logger
does not print debug lines; pass logger to receive them.
flush() and close(). flush() sends the current window now (useful in serverless handlers);
after a failure it respects the 30s floor and Retry-After. close() sends the current window once
with a 5s deadline, does not resend kept batches, and never blocks process exit.
Memory. Everything is bounded: at most 10,000 evaluation-summary keys, 10,000 context-shape
fields and 10,000 example contexts per window (see the telemetryMax* options; keys already seen
keep counting at the cap), a 100,000-entry example-context rate-limit map, and the 2MB retained
queue.
MIT