@lucerna-dev/gates-node
v0.0.1-alpha.2
Published
Server-side SDK for Lucerna Gates. It downloads the environment's compiled rule runtime with a **secret server key**, keeps it fresh with a 10-second ETag poll, and answers every flag / experiment / kill-switch check locally and synchronously — evaluation
Readme
@lucerna-dev/gates-node
Server-side SDK for Lucerna Gates. It downloads the environment's compiled rule runtime with a secret server key, keeps it fresh with a 10-second ETag poll, and answers every flag / experiment / kill-switch check locally and synchronously — evaluation is in-process (@lucerna-dev/gates-core), so reads cost no network round trip. Runs on Node 18+, Bun, Deno and edge runtimes.
Prefer per-request evaluation over a background poller? The same client also offers remote evaluation: async *Async methods that ask the server per call, with mode: "remote" when you want no poller at all.
Install
pnpm add @lucerna-dev/gates-nodeQuickstart
import { createGates } from "@lucerna-dev/gates-node";
const gates = createGates({ serverKey: "ck_srv_YOUR_KEY" });
// Optional — reads are safe before it settles (they fail open).
await gates.ready();
const identity = { userId: "u_42", traits: { plan: "pro" } };
const showNewBilling = gates.flag("new_billing", identity); // boolean
const variant = gates.experiment("checkout_test", identity); // "one_page" | null
const paymentsKilled = !gates.switch("payments"); // kill switch thrown?
// Shutdown: stops the poll and flushes queued exposures.
await gates.close();This block runs verbatim in test/readme.test.ts — if it drifts from the package, the test suite fails.
The server key is a secret — it downloads your targeting rules. Keep it in server-side configuration, never in code that ships to a browser. createGates takes the server key (ck_srv_…) or a scoped key (ck_key_…) granted gates:runtime, and throws at construction on the publishable ck_client_… key — that key can never download rules, and failing here beats a 403 in production. Find the server key in Settings → API keys.
API
createGates(options) → GatesClient.
| Option | Default | What it does |
| ------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| serverKey | required | Secret ck_srv_… / ck_key_… key; pins the environment |
| baseUrl | https://api.uselucerna.app | API origin, for self-hosted or local development |
| mode | "poll" | "remote" never constructs the poller or the exposure queue — nothing to close; use the *Async methods (sync reads answer safe defaults, with a one-time onError warning) |
| remoteCacheTtlMs | 0 | Memoize *Async answers in-memory this long. Kill switches default to a 10s memo and are hard-capped at 10s — kills promise ≤10s propagation |
| refreshIntervalMs | 10000 | Runtime poll cadence, floored at 1s. The 10s default is a product guarantee — kills propagate in ≤10s — raise it only if you accept slower kill propagation |
| requestTimeoutMs | 5000 | Per-attempt request timeout |
| onError | — | Tap for refresh/auth/exposure failures — reads themselves never throw |
| fetch | global fetch | Transport override (tests, custom dispatchers) |
| trackExposures | true | Report experiment exposures to Lucerna; false opts out (tests, or when you pipe exposures yourself) |
| Method | What it does |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| flag(key, identity?) | boolean — false for unknown keys or before the runtime loads |
| experiment(key, identity?) | Assigned variant name, or null when not in the experiment |
| switch(key) | false means killed; unknown keys are not killed |
| inspect(key, identity?) | Decision plus explain-mode trace across every namespace the key matches — for debugging targeting, never branch on it |
| evaluate(identity?) | Every decision for one identity — the SSR/bootstrap projection |
| flagAsync(key, identity?) | Server-evaluated flag decision — Promise<boolean>, false on unknown keys or any failure |
| experimentAsync(key, identity?) | Server-evaluated assignment — Promise<string \| null>; the exposure is recorded server-side on the same request |
| switchAsync(key) | Server-evaluated kill state — Promise<boolean>, memoized at most 10s |
| evaluateAsync(identity?) | Every decision in one round trip — the serverless primitive; same shape as evaluate() |
| runtime() | The last-known compiled runtime; undefined before the first load |
| ready() | Resolves after the first successful runtime load; rejects on a bad key (401/403) |
| close() | Async: stops the poll and flushes queued exposures; the last-known runtime keeps answering reads |
Identity and decision types (GatesIdentity, GatesDecisions, GatesRuntime, …) are re-exported from @lucerna-dev/gates-core — no second install. @lucerna-dev/identity is not required: any { userId, traits } object works.
Remote evaluation (no polling)
Polling is one consumption style, not the only one. Every client also carries async *Async methods that evaluate on the server per call — no runtime download, no background timer, nothing to close. Pick per call site: a long-lived server can poll and still use evaluateAsync where it wants the server's answer; a process that doesn't want a poller at all constructs with mode: "remote".
The *Async methods keep the sync guarantees: they never reject — network failure, timeout, unknown key, bad response all resolve the safe default (false / null / not killed / empty decisions) and surface through onError. Remote calls make at most 2 attempts (5s timeout each) to bound request latency. In remote mode the key needs the gates:evaluate grant rather than gates:runtime; the scaffolded ck_srv_… keys carry both.
Serverless (Lambda, Vercel, Cloudflare Workers) — construct once at module scope, then one round trip per invocation:
import { createGates } from "@lucerna-dev/gates-node";
const gates = createGates({ serverKey: process.env.LUCERNA_KEY!, mode: "remote" });
export async function handler(request: Request) {
const identity = { userId: currentUserId(request), traits: { plan: "pro" } };
// Every decision in one round trip — branch on the result.
const decisions = await gates.evaluateAsync(identity);
if (decisions.kills.payments === false) return maintenanceResponse();
return render({ newBilling: decisions.flags.new_billing === true });
}
// No close() — there is nothing running.SSR / long-lived servers that don't want a poller — same construction, evaluate per request; set remoteCacheTtlMs to bound round trips on a warm process (flags change infrequently — many can afford 30–60s):
const gates = createGates({
serverKey: process.env.LUCERNA_KEY!,
mode: "remote",
remoteCacheTtlMs: 30_000, // kill switches stay capped at 10s
});
app.get("/checkout", async (req, res) => {
if (!(await gates.switchAsync("payments"))) return res.status(503).send("maintenance");
const variant = await gates.experimentAsync("checkout_test", identityOf(req));
res.render(variant === "one_page" ? "checkout-one-page" : "checkout");
});experimentAsync records the exposure server-side on the same request (unless trackExposures: false) — no queue, no flush. evaluateAsync never records exposures, mirroring sync evaluate().
Don't forget the await. gates.flagAsync(…) without await is a Promise — always truthy, so if (gates.flagAsync("x")) takes the branch for everyone. The Async suffix at the call site is the visual cue.
Ruby and Elixir SDK parity is planned.
Guarantees & semantics
- Reads never throw and fail open: flag →
false, experiment →null, switch → not killed. Before the first load — or if every refresh since has failed — they answer from the last-known runtime: stale beats default, default beats crash. - Kills propagate in ≤10s at the default poll interval. The poll is cheap: ETag /
If-None-Match, so an unchanged runtime is a 304. - Runtime swaps are atomic. Readers see either the old runtime or the new one, never a half-applied document. A runtime with a newer
schemaVersionthan this SDK understands is rejected whole (reported throughonError, last-known keeps answering) — an engine never half-evaluates a document it doesn't fully understand. - Exposure-on-read. An exposure is recorded only when your code reads a variant via
experiment()— never on refreshes,evaluate()orinspect(). Exposures are deduped locally (10-minute window; the server dedupes forever), batched (≤100 per request), and delivered fire-and-forget on the poll tick — they can never block or break a read.trackExposures: falseopts out. - Assignments are stable. Bucketing is frozen: murmur3 (x86 32-bit) over
${salt}:${unitId}, basis points (hash % 10000), stored salts — renaming a key never reshuffles users. Experiment salts are per iteration, so restarting an iteration reshuffles by design. - Server decisions still aren't entitlements. Gate UX and code paths with them; anything entitlement-shaped belongs in your authorization layer.
Errors & failure modes
- Runtime requests retry network errors and 5xx up to 3 attempts with jittered exponential backoff (250ms base, 5s timeout per attempt). 4xx are terminal for the attempt — the same request will not do better. The poll loop itself is the long-cycle retry.
ready()rejects only on 401/403 (GatesRequestErrorwithstatus), so a misconfigured key stays discoverable. The poll continues after the rejection — a key created or re-enabled later starts working without a restart.onErrorsees every refresh, auth and exposure-delivery failure. AnonErrorthat itself throws is swallowed — it never breaks the SDK.- A failed exposure batch is dropped, not retried — exposures are best-effort telemetry, and the server-side dedupe makes any resend harmless.
