Feature flags

Feature flags let production apps ship changes behind typed runtime decisions without coupling use cases to a vendor SDK. Beignet owns the flag definitions and FlagsPort; providers decide where evaluation happens.

The API follows OpenFeature's core semantics — typed values, targeting context, defaults, evaluation details, and tracking — while keeping imports and tests in Beignet's port/provider model.

Define flags

Declare flags in the feature that owns the behavior:

// features/billing/flags.ts
import { defineFlag, defineFlags } from "@beignet/core/flags";

export const billingFlags = defineFlags({
  newCheckout: defineFlag.boolean("billing.new-checkout", {
    default: false,
    description: "Route checkout creation through the new billing flow.",
  }),
  pricingCopy: defineFlag.string("billing.pricing-copy", {
    default: "control",
  }),
});

String and number flags widen to string and number by default. Pass a generic when the app wants a closed set of variants, such as defineFlag.string<"control" | "treatment">(...).

Evaluate in application code

Use cases evaluate flags through ctx.ports.flags. Provider failures return the flag default instead of throwing into product workflows; call details(...) when you need the reason, variant, metadata, or error summary.

const enabled = await ctx.ports.flags.evaluate(billingFlags.newCheckout, {
  context: {
    targetingKey: requireUserId(ctx),
    subject: { type: "user", id: requireUserId(ctx) },
    tenant: ctx.tenant,
    attributes: { plan: account.plan },
    privateAttributes: ["email"],
    requestId: ctx.requestId,
    traceId: ctx.traceId,
  },
});

OpenFeature receives attributes unchanged unless Beignet has an authoritative value for the same reserved key. For example, tenant.id replaces an attributes.tenantId value, while attributes.tenantId remains available when the evaluation context has no tenant.

Plain evaluation does not record exposure. Record exposure explicitly when the user actually sees or can be affected by the flagged behavior:

await ctx.ports.flags.recordExposure(billingFlags.newCheckout, {
  context: flagContext,
  value: enabled,
});

Setup with OpenFeature

Use the OpenFeature provider to adapt LaunchDarkly, Statsig, Unleash, flagd, or any other OpenFeature-compatible provider behind Beignet's FlagsPort:

bun add @beignet/provider-flags-openfeature @openfeature/server-sdk
import { createNextServer, createNextServerLoader } from "@beignet/next";
import { createOpenFeatureFlagsProvider } from "@beignet/provider-flags-openfeature";
import { initialPorts } from "@/infra/port-wiring";
import { openFeatureProvider } from "@/infra/flags/openfeature";

export const getServer = createNextServerLoader(() =>
  createNextServer({
    ports: initialPorts,
    providers: [
      createOpenFeatureFlagsProvider({
        provider: openFeatureProvider,
        domain: "app",
      }),
    ],
    context: appContextBlueprint,
  }),
);

The provider contributes ctx.ports.flags and ctx.ports.openFeature as an escape hatch for advanced OpenFeature operations.

Testing

Use createMemoryFlags(...) or createStaticFlags(...) for tests:

import { createMemoryFlags } from "@beignet/core/flags";
import { billingFlags } from "@/features/billing/flags";

const flags = createMemoryFlags();
flags.set(billingFlags.newCheckout, true);

createTestPorts(...) includes an in-memory flags port by default, so most use-case tests only need to override the flag values they exercise.

Devtools

When devtools are installed before a flags provider, evaluations, provider errors, explicit exposures, and tracking events appear under the Feature flags watcher. Private attributes are redacted from instrumentation details.