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: (ctx) => ctx.auth?.userId,
});

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

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({
    args: {},
    handler: 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. 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. A subscriber cannot 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.

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 — key and context may be refs or getters, 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/context.
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.