Skip to content
DocspackagesDocumentation

@lunora/flags

OpenFeature-based feature flags for Lunora — ctx.flags, useFlag, and a first-class Cloudflare Flagship provider with any OpenFeature provider pluggable.

PackagesFlags

@lunora/flags brings feature flags to Lunora on top of OpenFeature. Configure a provider once with defineFlags in lunora/flags.ts; codegen wires a typed ctx.flags surface onto every query, mutation, and action, and the useFlag / useFlags hooks make evaluations reactive on the client. Cloudflare Flagship is the default provider, but any OpenFeature provider is pluggable.

pnpm add @lunora/flags

Scaffold the flags singleton:

vis generate lunora-flags

Configure

lunora/flags.ts is a singleton: a single defineFlags default export that configures the provider for the whole app. Cloudflare Flagship in Workers binding mode needs no auth token: bind it in wrangler and you're done.

// lunora/flags.ts
import { defineFlags } from "@lunora/flags";
import { flagshipProvider } from "@lunora/flags/providers/flagship";

export default defineFlags({
    // Cloudflare Flagship in Workers binding mode (no auth token needed).
    provider: flagshipProvider({ binding: "FLAGS" }),
    // HTTP mode instead (no binding required):
    // provider: flagshipProvider({ appId: "<app-id>", accountId: "<account-id>", authToken: (env) => env.FLAGSHIP_TOKEN }),
    // Or any OpenFeature provider — a factory that receives env:
    // provider: (env) => new SomeOpenFeatureProvider({ apiKey: env.FLAGS_API_KEY }),
    // Optional: default targetingKey for every evaluation (usually the user id).
    identify: (auth) => auth.userId ?? undefined,
});

The provider is either a Flagship config or any factory (env) => OpenFeatureProvider. identify derives the default targetingKey from each request's auth, so per-user targeting works without threading the key through every call.

HTTP mode's authToken takes either the literal token or a thunk resolved against the Worker env at construction, so the secret never has to be inlined in source. A thunk that resolves to anything but a non-empty string throws rather than sending Bearer undefined.

That throw happens at bind time, not at defineFlags time — the thunk cannot run before the Worker env exists, and the same is true of binding mode's missing-FLAGS-binding check. ctx.flags never throws, so those refusals still resolve as the caller's defaultValue; what they do not do is pass unnoticed. A failed bind is logged once — through defineFlags({ logger }) if you configured one, on console.error otherwise — because a deployment silently serving every kill-switch and rollout at its checked-in default is invisible in every other signal. The per-evaluation error is also on ctx.flags.details.*() as errorCode / errorMessage.

Its argument is a FlagsAuth{ identity, userId }, both null when the request is anonymous — not the function ctx. Return undefined (not null) for "no targeting key", which is why the ?? undefined matters: auth.userId alone is string | null, and the property is typed string | undefined.

Built-in providers

Two zero-config providers ship alongside the Flagship default, for local development, tests, and binding-driven flags. Neither adds a dependency.

Memory provider

memoryProvider wraps OpenFeature's in-memory provider with a flat key → value map. The value's runtime type is the flag's kind, so booleans, strings, numbers, and JSON objects all work. Useful for tests and local defaults.

// lunora/flags.ts
import { defineFlags } from "@lunora/flags";
import { memoryProvider } from "@lunora/flags/providers/memory";

export default defineFlags({
    provider: memoryProvider({
        "dark-mode": true,
        theme: "system",
        "page-size": 25,
        layout: { columns: 2 },
    }),
});

Env-binding provider

envProvider reads flags from the Worker's env (plain vars or Secrets Store bindings), so flags are configured the same way as the rest of your deployment. By default a flag key maps to FLAG_ + its UPPER_SNAKE_CASE name (dark-modeFLAG_DARK_MODE); customise with prefix or a full name mapper. Booleans accept 1/on/true/yes and 0/off/false/no; numbers and JSON objects are parsed from their string form, failing open to the default on a parse error.

// lunora/flags.ts
import { defineFlags } from "@lunora/flags";
import { envProvider } from "@lunora/flags/providers/env";

export default defineFlags({
    provider: envProvider(),
    // Custom prefix or key mapping:
    // provider: envProvider({ prefix: "FF_" }),
    // provider: envProvider({ name: (key) => `flags.${key}` }),
});

Server usage

Flags ride every ctx (query, mutation, and action) like ctx.kv. Each typed accessor takes a flag key and a default that is returned whenever the flag is missing, the provider errors, or evaluation is otherwise unresolved: evaluations never throw.

import { query } from "./_generated/server";

export const dashboard = query.query(async ({ ctx }) => {
    const darkMode = await ctx.flags.boolean("dark-mode", false);
    const theme = await ctx.flags.string("theme", "system");
    const limit = await ctx.flags.number("page-size", 25);
    const layout = await ctx.flags.object("layout", { columns: 2 });

    return { darkMode, theme, limit, layout };
});

For the full resolution (value plus reason, variant, and any error code) use ctx.flags.details.*:

const result = await ctx.flags.details.boolean("dark-mode", false);
// result.value, result.reason, result.variant, result.errorCode

details mirrors every accessor: details.boolean, details.string, details.number, details.object.

Client usage

@lunora/react exposes useFlag and useFlags. Evaluations are reactive and live over the WebSocket: when a flag's value changes for the current targeting context the component re-renders.

import { useFlag, useFlags } from "@lunora/react";

function Dashboard() {
    const darkMode = useFlag("dark-mode", false);
    const { theme, "page-size": pageSize } = useFlags({
        theme: "system",
        "page-size": 25,
    });

    return <Layout dark={darkMode} theme={theme} pageSize={pageSize} />;
}

useFlag(key, default) resolves a single flag; useFlags({ key: default, ... }) resolves a batch in one round trip.

The reactive WebSocket channel is server-trusted, and therefore takes no targeting context at all. Each evaluation runs under the socket's own server-verified identity: the targetingKey comes from your server-side identify(...) (and any server-set context), never from the client. There is deliberately no per-call context argument on useFlag/useFlags or any of the other adapters — a subscriber could otherwise spoof targeting attributes (plan, role, …) to unlock a flag gated on them. The channel also serves only the flag keys codegen discovered statically from your ctx.flags.<type>(...) reads; an arbitrary or unknown key resolves to the supplied default, so a subscriber can't probe internal or unreleased flags.

To evaluate under a context of your own, do it server-side: call ctx.flags.<type>(key, default, context) (or ctx.flags.details.*) inside a query, mutation, or action, build the context from data your server already trusts, and return the resolved value. That path is not reactive — the value refreshes when the query re-runs, not when the provider flips.

Other frameworks

The same reactive contract ships in every Lunora client adapter: one import, idiomatic to each framework, all live over the existing WebSocket:

FrameworkImportSingle / batchReturns
React@lunora/reactuseFlag / useFlagsthe value
Vue@lunora/vueuseFlag / useFlagsa readonly Ref
Solid@lunora/solidcreateFlag / createFlagsan accessor
Svelte@lunora/svelteflag / flagsa readable store
// Vue — the key may be a ref or a getter, so the subscription is reactive.
import { useFlag } from "@lunora/vue";
const darkMode = useFlag("dark-mode", false); // Readonly<Ref<boolean>>

// Solid — pass a plain value or an accessor for a reactive key.
import { createFlag } from "@lunora/solid";
const darkMode = createFlag("dark-mode", false); // Accessor<boolean>

// Svelte — read with the `$store` idiom (`{$darkMode}`).
import { flag } from "@lunora/svelte";
const darkMode = flag("dark-mode", false); // Readable<boolean>

Astro and Nuxt are server-rendered: read flags through ctx.flags in a server endpoint or loader and pass the resolved values to an interactive island built with one of the client adapters above.

Studio

The Lunora Studio surfaces a read-only Flags page listing every configured flag and its live evaluation under an editable targeting context. Change the targeting key (or other context attributes) and the resolved values update in place, so you can preview exactly what a given user would see.

Wrangler binding

Flagship binding mode needs a flagship binding in wrangler.jsonc. The app_id can't be auto-provisioned, so add it yourself:

{
    "flagship": [{ "binding": "FLAGS", "app_id": "<your-app-id>" }],
}

HTTP-mode Flagship and other OpenFeature providers need no binding; they reach the service over the network using the credentials you pass to the provider factory.