@sdxc/flags
Feature flag evaluation implementing the OpenFeature specification
- Installs with
- @sdxc/logger@sdxc/result@sdxc/types@sdxc/validate
- Depends on
@standard-schema/spec- Used by
- uptime, auth-saas, reader
- Source
- packages/flags
Feature flag evaluation implementing the OpenFeature specification.
The API is OpenFeature v0.9.0, so a flag system is a constructor
argument rather than a name in your call sites. Nothing on the evaluation path throws: an
evaluation that cannot resolve returns the default it was handed and carries the reason on its
detailed form.
Evaluation is dynamic-context, so the subject travels with the call instead of being set
globally, and there is no singleton — createFlags() builds an instance your application owns.
Installation
npm add @sdxc/flags
Three optional peers, each needed only by the part that uses it:
remix for the router middleware and for the
remix/data-schema schemas the examples write structures with,
@sdxc/jobs for the dispatcher middleware, and
vitest for the conformance suite. Any
Standard Schema library works in place of remix/data-schema.
Usage
Evaluating a flag
An instance owns the providers, the global context, the hooks and the handlers; a client is what code evaluates through. Nothing throws, and every method is asynchronous whether or not the provider had to await anything:
import { createFlags } from "@sdxc/flags/client";
import { InMemoryProvider } from "@sdxc/flags/provider/memory";
let flags = createFlags({
provider: () =>
new InMemoryProvider({
"new-checkout": { variants: { on: true, off: false }, defaultVariant: "off" },
}),
});
let client = flags.getClient();
await client.boolean("new-checkout", false, { targetingKey: "user-1" }); // false
let details = await client.stringDetails("checkout-copy-variant", "control");
details.value; // "control" — the default it was handed
details.reason; // "ERROR", with details.errorCode "FLAG_NOT_FOUND"
Declaring a catalog
A catalog writes every key, type and default down once, so a call site restates none of them and a misspelled key is not expressible:
import { defineFlags, flag } from "@sdxc/flags/catalog";
import * as s from "remix/data-schema";
let CopySchema = s.object({ title: s.string(), cta: s.string() });
export let features = defineFlags({
newCheckout: flag.boolean("new-checkout", false),
digestBatch: flag.number("digest-batch-size", 50),
checkoutCopy: flag.object("checkout-copy", CopySchema, { title: "Checkout", cta: "Pay" }),
});
await client.get(features.newCheckout); // Promise<boolean>
await client.get(features.digestBatch, { context: { targetingKey: "team-1" } }); // Promise<number>
await client.get(features.newCheckout, { defaultValue: true }); // a safer fallback here only
Installing into a fetch router
The middleware reads the request's subject once and publishes the client as ctx.flags:
import featureFlags from "@sdxc/flags/middleware/router";
import { createRouter } from "remix/router";
import { flags } from "./flags.ts";
let router = createRouter({
middleware: [
featureFlags(flags, {
context(ctx) {
return { targetingKey: ctx.session.get("userId"), country: ctx.request.cf?.country };
},
}),
],
});
router.get("/checkout", async (ctx) => {
if (await ctx.flags.boolean("new-checkout", false)) return ctx.render(NewCheckoutView, {});
return ctx.render(CheckoutView, {});
});
Importing the middleware is what types ctx.flags, because it augments RequestContext from
its own module rather than from an ambient declaration.
Installing into a job dispatcher
import featureFlags from "@sdxc/flags/middleware/dispatcher";
import { createJobDispatcher, createJobHandler } from "@sdxc/jobs";
import { flags } from "./flags.ts";
export let dispatcher = createJobDispatcher({
logger,
middleware: [featureFlags(flags, { context: (ctx) => ({ targetingKey: ctx.get(Team).id }) })],
queue,
});
export default createJobHandler(jobs.sendWeeklyDigest, async (ctx) => {
if (await ctx.flags.boolean("weekly-digest-v2", false)) return sendV2(ctx);
return sendV1(ctx);
});
The dispatcher types ctx.flags from the effect this middleware declares, folded out of the
chain it was listed in.
API
The entry points split along the job being done, so an application that only evaluates flags
imports /client and one middleware and ships none of the authoring kit, the test provider or
the suite.
| Entry point | Contains |
|---|---|
@sdxc/flags | The shared vocabulary: context, details, error codes, reasons |
@sdxc/flags/client | createFlags, the instance, the propagator, the shipped hooks |
@sdxc/flags/catalog | defineFlags and the flag.* handles |
@sdxc/flags/provider | The Provider interface and the kit for writing one |
@sdxc/flags/provider/noop | NoopProvider |
@sdxc/flags/provider/memory | InMemoryProvider |
@sdxc/flags/conformance | The suite every provider runs |
@sdxc/flags/middleware | The Flags context key both middlewares publish to |
@sdxc/flags/middleware/router | The middleware for a remix/router fetch router |
@sdxc/flags/middleware/dispatcher | The middleware for a job dispatcher |
@sdxc/flags
The vocabulary, as types only, so importing it to write a signature emits no runtime code.
FlagValue and FlagValueType
type FlagValue = JSONValue;
type FlagValueType = "boolean" | "string" | "number" | "object";
The four types a flag evaluates to, which together are exactly what JSON holds, and the name
of whichever evaluation produced a value. JSONValue comes from
@sdxc/types.
EvaluationContext
interface EvaluationContext {
targetingKey?: string;
[field: string]: Date | JSONValue | undefined;
}
What targeting reads. targetingKey is the subject a percentage rollout hashes and a per-user
rule matches; every other field is a fact about that subject or about the request it arrived
on.
ResolutionDetails<T> and EvaluationDetails<T>
interface ResolutionDetails<T extends FlagValue> {
value: T;
variant?: string;
reason?: Reason;
errorCode?: ErrorCode;
errorMessage?: string;
flagMetadata?: FlagMetadata;
}
interface EvaluationDetails<T extends FlagValue> extends ResolutionDetails<T> {
flagKey: string;
flagMetadata: FlagMetadata;
}
A provider answers with the first and an application reads the second: the client adds the key it was asked for and fills in the metadata the provider left out, so a reader never guards against its absence.
Reason
type Reason =
| "STATIC"
| "DEFAULT"
| "TARGETING_MATCH"
| "SPLIT"
| "CACHED"
| "DISABLED"
| "UNKNOWN"
| "STALE"
| "ERROR"
| (string & {});
Why a flag resolved to the value it did, and worth branching on: DEFAULT means nothing
resolved the flag while ERROR means something tried and failed, which is the difference
between a flag that is off and a flag system that is down. The set stays open, so a provider's
own reason still type-checks while the enumerated ones complete in an editor.
ErrorCode
type ErrorCode =
| "PROVIDER_NOT_READY"
| "PROVIDER_FATAL"
| "FLAG_NOT_FOUND"
| "PARSE_ERROR"
| "TYPE_MISMATCH"
| "TARGETING_KEY_MISSING"
| "INVALID_CONTEXT"
| "GENERAL";
Why an evaluation ended in the default value. The set is closed, so a consumer branches on it exhaustively.
Client
interface Client {
readonly metadata: ClientMetadata;
readonly providerStatus: ProviderStatus;
boolean(key, defaultValue, context?, options?): Promise<boolean>;
string(key, defaultValue, context?, options?): Promise<string>;
number(key, defaultValue, context?, options?): Promise<number>;
object<T>(key, defaultValue, options: ObjectEvaluationOptions<T>, context?): Promise<T>;
booleanDetails(key, defaultValue, context?, options?): Promise<EvaluationDetails<boolean>>;
stringDetails(key, defaultValue, context?, options?): Promise<EvaluationDetails<string>>;
numberDetails(key, defaultValue, context?, options?): Promise<EvaluationDetails<number>>;
objectDetails<T>(key, defaultValue, options, context?): Promise<EvaluationDetails<T>>;
get<T extends FlagValue>(flag: Flag<T>, options?: FlagOptions<T>): Promise<T>;
details<T extends FlagValue>(
flag: Flag<T>,
options?: FlagOptions<T>,
): Promise<EvaluationDetails<T>>;
addHooks(...hooks: Hook[]): void;
addHandler(event: ProviderEvent, handler: EventHandler): void;
removeHandler(event: ProviderEvent, handler: EventHandler): void;
track(name: string, context?: EvaluationContext, details?: TrackingEventDetails): void;
}
What a handler evaluates through. Nothing here throws. The structure methods take their
required options third, ahead of the optional context, because the schema it carries is what
makes a structure checked rather than cast:
let copy = await client.object(
"checkout-copy",
{ title: "Checkout", cta: "Pay" },
{ schema: s.object({ title: s.string(), cta: s.string() }) },
);
Flag<T> and FlagOptions<T>
interface Flag<T extends FlagValue> {
readonly key: string;
readonly type: FlagValueType;
readonly defaultValue: T;
readonly schema?: StandardSchemaV1<unknown, T>;
}
interface FlagOptions<T extends FlagValue> extends EvaluationOptions {
defaultValue?: T;
context?: EvaluationContext;
}
A flag as a catalog declares it, and what one evaluation of that handle may change.
EvaluationOptions and ObjectEvaluationOptions<T>
interface EvaluationOptions {
hooks?: Hook[];
hints?: HookHints;
}
interface ObjectEvaluationOptions<T extends FlagValue> extends EvaluationOptions {
schema: StandardSchemaV1<unknown, T>;
}
Per-invocation hooks, which run after the configured ones, and the hints every hook receives. The schema is required on a structure evaluation, because an unchecked cast on a value that arrived over a network is what the check exists to prevent, and an optional check is one nobody adds.
Hook, HookContext, HookData, HookHints, HookStage
interface Hook {
before?(context: HookContext, hints: HookHints): MaybePromise<EvaluationContext | void>;
after?(
context: HookContext,
details: EvaluationDetails<FlagValue>,
hints: HookHints,
): MaybePromise<void>;
error?(context: HookContext, error: unknown, hints: HookHints): MaybePromise<void>;
finally?(
context: HookContext,
details: EvaluationDetails<FlagValue>,
hints: HookHints,
): MaybePromise<void>;
}
Arbitrary behavior around an evaluation. before runs API, then client, then invocation, then
provider hooks, and the other three run in the exact reverse; register a hook at the level it
belongs to, since an API hook covers every evaluation, a client hook covers one domain and an
invocation hook covers one call. A context returned from before merges in at the highest
precedence there is. HookContext carries the key, the value type, the default, the merged
context, both metadata structures, and a HookData map belonging to that hook alone.
ProviderStatus, ProviderEvent and the event structures
type ProviderStatus = "NOT_READY" | "READY" | "ERROR" | "STALE" | "FATAL";
type ProviderEvent =
"PROVIDER_READY" | "PROVIDER_ERROR" | "PROVIDER_CONFIGURATION_CHANGED" | "PROVIDER_STALE";
interface ProviderEventDetails {
flagsChanged?: string[];
message?: string;
errorCode?: ErrorCode;
eventMetadata?: EventMetadata;
}
interface EventDetails extends ProviderEventDetails {
providerName: string;
}
type EventHandler = (details: EventDetails) => void;
Status is read from what a provider announces, never inferred from a lifecycle call, which is
what makes an outage readable as an outage rather than as every flag being off. A provider
that implements initialize without emitting PROVIDER_READY therefore stays NOT_READY.
ERROR is recoverable and FATAL is not. A handler that throws does not stop the others.
ClientMetadata, ProviderMetadata, FlagMetadata, EventMetadata
interface ClientMetadata {
readonly domain?: string;
}
interface ProviderMetadata {
readonly name: string;
}
type FlagMetadata = Record<string, string | number | boolean>;
type EventMetadata = Record<string, string | number | boolean>;
How a client and a provider identify themselves, and the arbitrary properties a provider
attaches to a resolved flag — contextId, flagSetId and version are the three an
observability integration reads. The client passes through whatever arrives and invents
nothing.
TrackingEventDetails
interface TrackingEventDetails {
value?: number;
[field: string]: JSONValue | undefined;
}
What an application attaches to an action it wants measured against the flags a subject saw.
value is the scalar an analytics backend maps to its own numeric field.
MaybePromise<T>
type MaybePromise<T> = T | Promise<T>;
A value that may already be there, so an implementation answering from memory allocates no promise to report what it already knows.
@sdxc/flags/client
createFlags(options?: FlagsOptions): Flags
Builds an API instance. There is no global singleton, so two instances share no providers, no context and no handlers, and a test that sets a provider cannot reach the next one. Build it at module scope: an instance created inside a request would re-initialize its provider on every one, which is why the middlewares take the instance as an argument.
export let flags = createFlags({
provider: () => new ServiceProvider(),
context: { service: "checkout" },
hooks: [wideEventHook()],
handlers: { PROVIDER_ERROR: (details) => report(details.message) },
propagator: asyncLocalStoragePropagator(),
});
options.provider— builds the default provider, called the first time an evaluation needs it so startup constructs nothing it may not use.options.context— the global evaluation context, lowest of the merge levels.options.hooks— API-level hooks, which run around every evaluation this instance makes.options.handlers— event handlers by event, each one handler or an array of them.options.propagator— where the transaction level of the context merge comes from.
Flags
interface Flags {
providerMetadata(domain?: string): ProviderMetadata;
getClient(domain?: string, context?: EvaluationContext): Client;
setProvider(provider: Provider): Promise<Result<void, ProviderError>>;
setProvider(domain: string, provider: Provider): Promise<Result<void, ProviderError>>;
setContext(context: EvaluationContext): void;
addHooks(...hooks: Hook[]): void;
addHandler(event: ProviderEvent, handler: EventHandler): void;
removeHandler(event: ProviderEvent, handler: EventHandler): void;
setTransactionContextPropagator(propagator: TransactionContextPropagator): void;
setTransactionContext<T>(context: EvaluationContext, callback: () => T): T;
ready(): Promise<void>;
shutdown(): Promise<Result<void, ProviderError>>;
}
The API instance createFlags returns. getClient with a context returns a client carrying it
as the client merge level, which is how a request publishes its subject. ready() awaits the
initialization of every registered provider, which is what a middleware awaits on an isolate's
first request, and shutdown() returns the instance to how it started, which is what makes one
reusable between tests. setProvider and shutdown answer with a Result from
@sdxc/result rather than throwing. ready()
resolves to nothing, because setProvider initializes the provider it registers and hands its
caller that outcome; a later ready() awaits the same memoized answer. Nothing on the
evaluation path returns a Result either, because nothing there fails.
let outcome = await flags.setProvider("marketing", new ServiceProvider());
if (isFailure(outcome)) report(outcome.error.message);
FlagsOptions
The options createFlags takes, listed above.
wideEventHook(): Hook
A finally hook that attaches the key, reason, variant, provider and — when it failed — the
error to the wide event of the invocation the evaluation ran inside, under the attribute names
an OpenTelemetry feature-flag record is read with. The event comes from
@sdxc/logger; outside an invocation there is no
log and the hook does nothing.
let flags = createFlags({ provider, hooks: [wideEventHook()] });
redactHook(fields: string[]): Hook
A before hook that removes named fields from the context, so nothing a resolver or a later
hook sees carries them.
let flags = createFlags({ provider, hooks: [redactHook(["email", "ip"])] });
asyncLocalStoragePropagator(): TransactionContextPropagator
The propagator backed by
AsyncLocalStorage, which
is what lets a nested call see context an outer one set without threading it through every
signature in between.
flags.setTransactionContext({ targetingKey: user.id }, () => next());
TransactionContextPropagator
interface TransactionContextPropagator {
getTransactionContext(): EvaluationContext | undefined;
setTransactionContext<T>(context: EvaluationContext, callback: () => T): T;
}
The contract an API instance calls for the transaction level of the merge, for code that learns something about the subject halfway through a request.
@sdxc/flags/catalog
defineFlags(catalog)
Hands back the object of Flag<T> handles it was given, with every value type preserved, so
the application's flags are one named thing a call site reaches into.
export let features = defineFlags({ newCheckout: flag.boolean("new-checkout", false) });
flag.boolean(key, defaultValue), flag.string(key, defaultValue), flag.number(key, defaultValue)
Builds a handle carrying the key the flag management system knows the flag by, the type, and
what every evaluation falls back to. Pass it to client.get or client.details.
flag.object(key, schema, defaultValue)
Builds a structure handle, with the schema written once here instead of at every call site.
The schema comes second because it is what types the default: a structure the schema would
reject is a compile error here rather than a TYPE_MISMATCH in production.
flag.object("checkout-copy", CopySchema, { title: "Checkout", cta: "Pay" });
@sdxc/flags/provider
Provider
interface Provider {
readonly metadata: ProviderMetadata;
readonly hooks?: Hook[];
readonly events?: ProviderEvents;
readonly domainScoped?: boolean;
resolveBoolean(key, defaultValue, context): MaybePromise<ResolutionDetails<boolean>>;
resolveString(key, defaultValue, context): MaybePromise<ResolutionDetails<string>>;
resolveNumber(key, defaultValue, context): MaybePromise<ResolutionDetails<number>>;
resolveObject(key, defaultValue, context): MaybePromise<ResolutionDetails<JSONValue>>;
initialize?(context: EvaluationContext, domain?: string): Promise<void>;
shutdown?(): Promise<void>;
track?(name: string, context: EvaluationContext, details?: TrackingEventDetails): void;
}
What supplies flag values. Metadata and four resolvers is a working provider; hooks, events,
lifecycle and tracking are each opt-in. A resolver is handed the key, the default value and
the merged context, and answers with a details structure rather than throwing. domainScoped
declares that the instance keys state on the one domain it is bound to, so the API refuses to
bind it to a second.
resolved<T>(value: T, details?: ResolvedDetails<T>): ResolutionDetails<T>
Answers with a value the provider actually resolved, plus the variant, reason and flag metadata that go with it. The error fields are not accepted here at all, which is how normal execution stays free of them.
return resolved(rule.value, { variant: rule.variant, reason: "STATIC" });
failed<T>(defaultValue: T, errorCode: ErrorCode, errorMessage?: string): ResolutionDetails<T>
Answers with the default value the resolver was handed, and says why it could not do better.
The reason is always ERROR, so a caller reading reasons and one reading codes reach the same
conclusion.
return failed(defaultValue, "FLAG_NOT_FOUND", `No flag named ${key}`);
ProviderEvents
The typed emitter a provider holds as a field and announces its status transitions on, with
emit(event, details?), on(event, handler) and off(event, handler). A listener that throws
is on its own: the remaining listeners still run.
this.events.emit("PROVIDER_ERROR", { errorCode: "PROVIDER_FATAL", message });
ProviderError
An Error carrying an ErrorCode as code. Resolvers answer with a details structure
instead, so this is for initialize, which signals failure by rejecting, and for a provider
written elsewhere that throws from a resolver: the client reads code off it rather than
reporting GENERAL.
throw new ProviderError("PROVIDER_FATAL", "The flag service refused the key.");
ResolvedDetails<T> and ProviderEventHandler
type ResolvedDetails<T extends FlagValue> = Omit<
ResolutionDetails<T>,
"value" | "errorCode" | "errorMessage"
>;
type ProviderEventHandler = (details: ProviderEventDetails) => void;
What a successful resolution may say about itself beyond the value it found, and what an emitter calls when an event it declared occurs.
@sdxc/flags/provider/noop
NoopProvider
Answers every evaluation with the default value it was given, saying so with
reason: "DEFAULT". It is what an instance evaluates through before a provider is set and
after shutdown, so registering nothing is a working instance rather than a broken one, and
naming it explicitly is how an application turns evaluation off.
let flags = createFlags({ provider: () => new NoopProvider() });
@sdxc/flags/provider/memory
new InMemoryProvider(flags?: FlagSet)
A provider that answers from a flag set held in memory, so a test pins a flag the way production pins one and reads back the same variant, reason and metadata a flag management system would have sent. It runs the whole lifecycle and passes the same conformance suite as any other.
provider.putConfiguration(flags: FlagSet): void
Swaps the flag set for another one and announces it, naming every key that was in the old set or is in the new one, so a listener caching per key knows which entries to drop.
provider.putConfiguration({ "new-checkout": { variants: { on: true }, defaultVariant: "on" } });
FlagConfiguration<T> and FlagSet
interface FlagConfiguration<T extends FlagValue = FlagValue> {
variants: Record<string, T>;
defaultVariant: string;
disabled?: boolean;
contextEvaluator?: (context: EvaluationContext) => string | undefined;
flagMetadata?: FlagMetadata;
}
type FlagSet = Record<string, FlagConfiguration>;
One flag written down: every value it can take, which of them it serves, and the rule that overrides that choice for a given context. Targeting is a function rather than an expression language, so a test states the rule in TypeScript and the type checker reads it.
@sdxc/flags/conformance
conformance(name: string, create: () => Provider | Promise<Provider>, options: ConformanceOptions): void
Registers the suite every provider runs, as Vitest tests: that the metadata names the
implementation, that no resolver throws, that a missing flag is FLAG_NOT_FOUND rather than a
default, that a type mismatch is TYPE_MISMATCH rather than a coerced value, that shutdown
twice is safe, and that initialize emits before it returns. name labels the generated
describe block, create builds a fresh provider for each test, and each options entry
gates the group that needs it.
conformance("my-provider", () => new ServiceProvider(FLAGS), {
flags: FLAGS,
lifecycle: true,
failing: () => new ServiceProvider(UNLOADABLE),
});
ConformanceOptions
interface ConformanceOptions {
flags?: FlagSet;
lifecycle?: boolean;
failing?: () => Provider | Promise<Provider>;
tracking?: boolean;
}
Each entry gates a sub-suite that is skipped when it is absent, so a provider owes the suite only what it claims to implement.
flags— the flag set the provider was built against, so the suite knows which keys to ask for. Without it, only the groups that need no fixture run.lifecycle— the provider implementsinitializeandshutdown, so the suite checks that it emits beforeinitializereturns and that a secondshutdownis safe.failing— builds a provider whoseinitializerejects, so the suite checks thatPROVIDER_ERRORis emitted before the rejection.tracking— the provider implementstrack, so the suite drives it the way a client does.
@sdxc/flags/middleware
Flags
let Flags: { defaultValue?: Client };
The context key both middlewares publish the client to, for code handed a context without the
installed property. It shares its name with the Flags instance type from
@sdxc/flags/client and is a different thing: that one is the application's API instance, and
this one is a key that reads one request's or one delivery's client off a context.
import { Flags } from "@sdxc/flags/middleware";
let client = ctx.get(Flags);
@sdxc/flags/middleware/router
featureFlags(flags: Flags, options?: FeatureFlagsOptions): Middleware
The default export. Publishes the request's client as ctx.flags, and augments
RequestContext from its own module so the type follows the import. It awaits flags.ready(),
so the instance's providers are initialized on the first request an isolate serves and reused
by every request after it, leaving the per-request cost at the context callback and nothing
else.
flags— the application's API instance, built once at module scope.options.context— reads the subject and whatever else targeting is written against off the request, as the client-level context every evaluation of this request merges. It runs once per request, so per-call context is for the evaluations whose subject differs from the request's.options.domain— evaluates through the provider bound to this domain instead of the default one.
@sdxc/flags/middleware/dispatcher
featureFlags(flags: Flags, options?: FeatureFlagsOptions): JobMiddleware
The default export. Publishes the delivery's client as ctx.flags, typed on the handler
through the effect the dispatcher folds out of its chain. It awaits flags.ready() the same
way, so providers are initialized on the first delivery an isolate runs.
flags— the application's API instance, built once at module scope.options.context— reads the subject off the job's context, as the client-level context every evaluation of this delivery merges.options.domain— evaluates through the provider bound to this domain instead of the default one.
Pattern: A provider against a real service
A provider reaches the service over
fetch and parses the response
into the details structures, so the application's types never come from somebody else's SDK.
initialize is where the expensive work goes, and emitting is what makes the provider READY.
import type { EvaluationContext, ResolutionDetails } from "@sdxc/flags";
import type { Provider } from "@sdxc/flags/provider";
import { failed, ProviderEvents, ProviderError, resolved } from "@sdxc/flags/provider";
import * as s from "remix/data-schema";
let RuleSchema = s.object({ value: s.boolean(), variant: s.string() });
export class ServiceProvider implements Provider {
readonly metadata = { name: "service" };
readonly events = new ProviderEvents();
#rules = new Map<string, { value: boolean; variant: string }>();
async initialize(): Promise<void> {
let response = await fetch("https://flags.example.com/rules");
if (!response.ok) {
let message = `The flag service answered ${response.status}.`;
this.events.emit("PROVIDER_ERROR", { errorCode: "GENERAL", message });
throw new ProviderError("GENERAL", message);
}
for (let [key, rule] of Object.entries(await response.json())) {
this.#rules.set(key, s.parse(RuleSchema, rule));
}
this.events.emit("PROVIDER_READY");
}
resolveBoolean(
key: string,
defaultValue: boolean,
context: EvaluationContext,
): ResolutionDetails<boolean> {
let rule = this.#rules.get(key);
if (!rule) return failed(defaultValue, "FLAG_NOT_FOUND", `No flag named "${key}".`);
return resolved(rule.value, { variant: rule.variant, reason: "TARGETING_MATCH" });
}
}
Pattern: Pinning a flag in a test
A test builds its own instance, so nothing it sets reaches the next one, and pins the flag through the in-memory provider rather than mocking the client.
import { createFlags } from "@sdxc/flags/client";
import { InMemoryProvider } from "@sdxc/flags/provider/memory";
import { expect, test } from "vitest";
test("renders the new checkout for the enrolled team", async () => {
let flags = createFlags({
provider: () =>
new InMemoryProvider({
"new-checkout": {
variants: { on: true, off: false },
defaultVariant: "off",
contextEvaluator: (context) => (context.targetingKey === "team-1" ? "on" : undefined),
},
}),
});
await flags.ready();
let client = flags.getClient(undefined, { targetingKey: "team-1" });
expect(await client.boolean("new-checkout", false)).toBe(true);
});
Pattern: Per-call context for a job's subject
A delivery whose subject is not the one the middleware named supplies its own, since invocation context is a merge level in its own right and wins over the client level.
import { createJobHandler } from "@sdxc/jobs";
import jobs from "./jobs.ts";
export default createJobHandler(jobs.sendWeeklyDigest, async (ctx) => {
for (let member of await members(ctx.input.teamId)) {
let html = await ctx.flags.boolean("digest-html", false, { targetingKey: member.id });
await send(member, html ? renderHtml(member) : renderText(member));
}
});
Pattern: Redacting context before a third-party provider
Registering the hook at the API level covers every evaluation the instance makes, so a field the provider should never see is stripped once rather than at every call site.
import { createFlags, redactHook } from "@sdxc/flags/client";
import { ServiceProvider } from "./service-provider.ts";
export let flags = createFlags({
provider: () => new ServiceProvider(),
context: { service: "checkout" },
hooks: [redactHook(["email", "ip", "phone"])],
});
The context a resolver is handed still carries targetingKey and everything else, so targeting
on the subject keeps working while the fields that identify a person do not leave the worker.
Pattern: Reading evaluations off the wide event
Evaluation writes no log lines of its own — a flag on a hot path would produce one per request — so the record comes from the invocation's wide event instead.
import { createFlags, wideEventHook } from "@sdxc/flags/client";
import featureFlags from "@sdxc/flags/middleware/router";
import { log } from "@sdxc/logger/middleware";
import { createRouter } from "remix/router";
import { ServiceProvider } from "./service-provider.ts";
export let flags = createFlags({ provider: () => new ServiceProvider(), hooks: [wideEventHook()] });
let router = createRouter({ middleware: [log(logger), featureFlags(flags)] });
The hook's finally stage sets feature_flag.key, feature_flag.result.reason,
feature_flag.result.variant, feature_flag.provider.name, and error.type with
error.message when the evaluation failed. Listing log before the middleware is what gives
it a record to write to.