@lucerna-dev/gates-openfeature-web
v0.0.1-alpha.0
Published
[OpenFeature](https://openfeature.dev) web provider for Lucerna Gates. It plugs Gates into the vendor-neutral `@openfeature/web-sdk` — and into [`@openfeature/react-sdk`](https://openfeature.dev/docs/reference/technologies/client/web/react), which layers
Readme
@lucerna-dev/gates-openfeature-web
OpenFeature web provider for Lucerna Gates. It plugs Gates into the vendor-neutral @openfeature/web-sdk — and into @openfeature/react-sdk, which layers React hooks over any web provider. One identity-resolved decisions snapshot is fetched with the environment's publishable client key (@lucerna-dev/gates-browser underneath), then every read answers synchronously from it. Targeting rules never reach the browser — only decisions do.
Install
pnpm add @lucerna-dev/gates-openfeature-web @lucerna-dev/gates-browser @openfeature/web-sdkBoth @lucerna-dev/gates-browser and @openfeature/web-sdk are peer dependencies. For React, add @openfeature/react-sdk — no Lucerna-specific React package is needed.
Quickstart
import { OpenFeature } from "@openfeature/web-sdk";
import { LucernaWebProvider } from "@lucerna-dev/gates-openfeature-web";
await OpenFeature.setContext({ targetingKey: "u_42", plan: "pro" });
await OpenFeature.setProviderAndWait(new LucernaWebProvider({ clientKey: "ck_client_YOUR_KEY" }));
const client = OpenFeature.getClient();
const showNewBilling = client.getBooleanValue("new_billing", false); // boolean, sync
const variant = client.getStringValue("checkout_test", "control"); // variant name, syncThe key is the environment's publishable client key (ck_client_…) — safe to embed, and it pins the environment. Secret keys (ck_srv_…, ck_key_…) are rejected at construction: a secret in a browser bundle is readable by anyone.
Set the context before registering the provider so the first snapshot is already targeted; reads are synchronous from then on. When the user logs in, out, or changes, call OpenFeature.setContext() again — the provider refetches decisions for the new identity and the SDK signals ContextChanged when the swap is complete.
With React
import { OpenFeatureProvider, useBooleanFlagValue } from "@openfeature/react-sdk";
function App() {
return (
<OpenFeatureProvider>
<Billing />
</OpenFeatureProvider>
);
}
function Billing() {
const newBilling = useBooleanFlagValue("new_billing", false);
return newBilling ? <NewBilling /> : <ClassicBilling />;
}Register the provider once at startup (quickstart above); the hooks re-render on ConfigurationChanged and ContextChanged automatically.
How evaluation context maps to Gates
| OpenFeature | Gates |
| --------------------------------------------------- | ----------------------------------------------------------------- |
| targetingKey | userId — the sticky-bucketing unit for rollouts and experiments |
| string / number / boolean attribute | a stringified trait, what targeting rules match against |
| Date attribute | an ISO-8601 trait |
| null / undefined / nested structures and arrays | dropped — Gates traits are a flat string map |
Per-evaluation context does not exist in the web SDK's static-context paradigm — identity lives in the global context (OpenFeature.setContext), and every read answers for it.
Type mapping
| OpenFeature read | Gates concept | Details |
| ----------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------- |
| getBooleanValue | feature flag (kill switches folded in) | reason TARGETING_MATCH; a key in the kills snapshot answers the kill state — thrown reads false |
| getStringValue | experiment variant | reason SPLIT with variant set, or your default with reason DEFAULT when not in the experiment |
| getNumberValue / getObjectValue | — | Gates has no number/object flags: your default with TYPE_MISMATCH |
Options
| Option | Default | What it does |
| ------------------ | ---------------------------- | ------------------------------------------------------------------------------------------- |
| clientKey | — | required; the environment's publishable client key (ck_client_…) |
| baseUrl | https://api.uselucerna.app | override for self-hosted or local development |
| requestTimeoutMs | 5000 | per-request timeout |
| readyTimeoutMs | 10000 | cap on how long initialize waits for the first snapshot before coming up on safe defaults |
| onError | — | tap for bootstrap/refresh failures — resolvers never throw |
| fetch | platform fetch | override the transport (tests, custom dispatchers) |
| storage | — | optional decisions cache (e.g. localStorage) so the next page load reads instantly |
| storageKey | lucerna:gates | cache key when storage is set |
Failure semantics
Resolvers never throw. Before the first snapshot — or when bootstrap fails — boolean reads answer safe false and string reads answer your default, while the error surfaces through onError. A rejected key (401/403) fails initialize loudly (PROVIDER_ERROR); a slow or offline network does not block the app — initialize resolves at readyTimeoutMs and the provider serves safe defaults until the next context change refetches.
Wire onError: without it, a misconfigured setup serves "everything off" silently.
Decisions sent to a browser are UX hints and can be tampered with — gate what users see with them, never what users are allowed to do. Authorization stays on your backend.
With @lucerna-dev/identity
Identity stays the source of truth and feeds OpenFeature one-directionally:
import { createIdentity } from "@lucerna-dev/identity";
import { OpenFeature } from "@openfeature/web-sdk";
const identity = createIdentity({ apiKey: "ck_client_YOUR_KEY" });
identity.onChange((user) => {
void OpenFeature.setContext({ targetingKey: user.userId, ...user.traits });
});identify(), trait() and reset() (logout) all flow into a decisions refetch through that one subscription.
