@lucerna-dev/gates-browser
v0.0.1-alpha.1
Published
Browser SDK for Lucerna Gates. It bootstraps identity-resolved decisions from the server with the environment's **publishable client key**, caches them in memory, and answers every flag / experiment / kill-switch check synchronously. Targeting rules never
Readme
@lucerna-dev/gates-browser
Browser SDK for Lucerna Gates. It bootstraps identity-resolved
decisions from the server with the environment's publishable client
key, caches them in memory, and answers every flag / experiment /
kill-switch check synchronously. Targeting rules never reach the
browser — only decisions do — evaluated server-side by the same engine
(@lucerna-dev/gates-core) that powers @lucerna-dev/gates-node.
@lucerna-dev/identity is the natural identity source, but any object with
the same shape works — there is no dependency between the packages.
Install
pnpm add @lucerna-dev/gates-browserReact is an optional peer (>=18), needed only for the
@lucerna-dev/gates-browser/react subpath. The root entry never imports it.
Quickstart
import { createGates } from "@lucerna-dev/gates-browser";
import { createIdentity } from "@lucerna-dev/identity";
const identity = createIdentity({ apiKey: "ck_client_YOUR_KEY" });
const gates = createGates({ clientKey: "ck_client_YOUR_KEY", identity });
// Synchronous, never throws — before the first load reads fail open.
const before = gates.flag("new_billing"); // false until decisions load
// Await the first bootstrap when the answer matters right now.
await gates.ready();
const showNewBilling = gates.flag("new_billing"); // this user's decision
const variant = gates.experiment("checkout_test"); // "one_page" | null
const paymentsKilled = !gates.switch("payments"); // kill switch thrown?
// Sign-in: decisions clear and refetch for the new user automatically.
identity.identify({ userId: "u_42", traits: { plan: "pro" } });This block runs verbatim in test/readme.test.ts
— if it drifts from the package, the test suite fails.
Never put a secret key in browser code — anyone can read it there.
createGates takes the publishable ck_client_… key only and throws at
construction on a secret (ck_srv_… / ck_key_…). Find the client key
in Settings → API keys.
Documentation
- Getting started
- References
- Frameworks: React · Astro · Preact
React
import {
Experiment,
Feature,
GatesProvider,
KillSwitch,
Variant,
} from "@lucerna-dev/gates-browser/react";
function App() {
return (
<GatesProvider client={gates}>
<Feature name="new_billing" fallback={<OldBilling />}>
<NewBilling />
</Feature>
<KillSwitch name="payments" fallback={<PaymentsDown />}>
<Payments />
</KillSwitch>
<Experiment name="checkout_test" fallback={<Steps />}>
<Variant name="control">
<Steps />
</Variant>
<Variant name="one_page">
<OnePage />
</Variant>
</Experiment>
</GatesProvider>
);
}| Export | What it does |
| ---------------------------- | ------------------------------------------------------------------ |
| GatesProvider | Puts a client in context: <GatesProvider client={gates}> |
| <Feature name fallback> | Renders children while the flag is on |
| <KillSwitch name fallback> | Renders children while the path is alive; fallback when killed |
| <Experiment name fallback> | Renders the assigned <Variant> child; fallback when not assigned |
| <Variant name> | One variant's UI — direct child of <Experiment> |
| useFlag(key) | Flag decision; false until decisions load |
| useExperiment(key) | Assigned variant name; null when not in the experiment |
| useKillSwitch(key) | false means the guarded path is killed |
| useGates() | The client itself, for imperative calls (refresh, close) |
Everything subscribes via useSyncExternalStore — components re-render
when decisions change (first load, identity switch, refresh()). Full
semantics (unmatched-variant fallback, SSR, Preact) in the
React guide.
Devtools (dev widget)
An in-page widget for development: a clip on the screen edge (mid-right
by default; position takes ReactQueryDevtools-style corners) expanding
into a searchable, full-height drawer. Every gate row opens a detail
view explaining how it evaluated — server value, reason code and a
plain-language explanation, plus the exact identity and environment it
was computed for — next to local override toggles (flags, kill switches,
forced experiment variants) and identity impersonation, all persisted
under the devtools' own localStorage key. Overrides are a decorator
around the real client; nothing changes server-side and, like the
decisions they shadow, they are UX hints, never authorization. The
handle also registers itself at window.__LUCERNA__.gates, so the
console can drive it without an import.
import { createGates } from "@lucerna-dev/gates-browser";
import { createGatesDevtools } from "@lucerna-dev/gates-browser/devtools";
const devtools = createGatesDevtools();
const gates = devtools.wrap(
createGates({
clientKey: "ck_client_YOUR_KEY",
// Impersonation-aware: answers the impersonated identity when one
// is active, your real identity source otherwise.
identity: devtools.identity(identity),
}),
);Mount the widget from a development-only code path — a build-time branch your bundler folds away, never runtime env sniffing:
import { GatesDevtoolsWidget } from "@lucerna-dev/gates-browser/devtools/react";
{
import.meta.env.DEV ? <GatesDevtoolsWidget devtools={devtools} /> : null;
}Both subpaths ship zero bytes to production when the importing branch is
dead code ("sideEffects": false — the modules tree-shake cleanly).
Wiring nuances (reason codes vs. full traces, forced-variant metadata,
persistence) in the references.
API
createGates(options) → GatesBrowserClient.
| Option | Default | What it does |
| ------------------------ | ---------------------------- | -------------------------------------------------------------------------------------------- |
| clientKey | required | Publishable ck_client_… key; pins the environment |
| identity | — | Current-user source (IdentitySource); decisions refetch when it changes |
| baseUrl | https://api.uselucerna.app | API origin, for self-hosted or local development |
| storage / storageKey | — / "lucerna:gates" | Optional decisions cache for instant next load (DecisionsStorage) |
| onError | — | Tap for bootstrap/refresh failures — reads themselves never throw |
| requestTimeoutMs | 5000 | Per-attempt request timeout |
| fetch | global fetch | Transport override (tests, custom dispatchers) |
| Method | What it does |
| ----------------- | ------------------------------------------------------------------- |
| flag(key) | boolean — false when unknown or not loaded |
| experiment(key) | Variant name, or null when not in the experiment |
| switch(key) | false means killed; unknown keys are not killed |
| decisions() | The full snapshot; undefined before the first load |
| ready() | Resolves after the first successful bootstrap; rejects on a bad key |
| refresh() | Refetch decisions for the current identity now; never rejects |
| onChange(fn) | Fires after every decisions change; returns unsubscribe |
| close() | Unsubscribes from the identity; the last decisions keep answering |
Decision types (GatesDecisions, FlagDecision, VariantAssignment,
…) are re-exported from @lucerna-dev/gates-core — no second install.
Guarantees & semantics
- Client decisions are UX hints, never authorization. Anything
entitlement-shaped must be re-checked server-side (
@lucerna-dev/gates-nodeor your API). - Reads never throw and fail open: flag →
false, experiment →null, switch → not killed. After load, the last-known decisions keep answering — including afterclose()and through failed refreshes. - Only
userIdandtraitsare ever sent. The identity's PII fields (email, name) are deliberately stripped from the bootstrap request. - User switches can't leak decisions. A new
userIdclears decisions immediately (no flash of the previous user's variants), and a stale in-flight response can never overwrite a newer identity's decisions — the last issued request always wins. - Identity changes coalesce. An
identify()followed by a burst oftrait()calls triggers one refetch, not four. - No background poll in v1. A running page picks up config changes
on the next identity change,
refresh(), or page load. Kill switches that must cut off a page mid-session belong behind a server check. - The storage cache is per-user. A cached snapshot computed for
another
userId(or another anonymous id) is ignored, as are corrupt entries — the client just fetches fresh. - Assignments are stable. The server evaluates with frozen
bucketing — murmur3 (x86 32-bit) over
${salt}:${unitId}, basis points (hash % 10000), stored salts — so renaming a key never reshuffles users.
Errors & failure modes
- The bootstrap POST retries network errors and 5xx up to 3 attempts with jittered exponential backoff (250ms base, 5s timeout per attempt). 4xx are terminal — retrying can't fix a bad key.
ready()rejects only on 401/403 (GatesRequestErrorwithstatus), so a misconfigured key stays discoverable. Any other failure reports throughonErrorandready()stays pending until a later refetch succeeds.onErrorsees every bootstrap/refresh failure. AnonError(oronChangelistener) that itself throws is swallowed — it never breaks the SDK or its peers.- Without
storage, decisions live for the page's lifetime; every load starts fail-open until the bootstrap answers.
