@setu-ts/feature-flags-plugin
Feature flags with pluggable backends. Registers the synchronous IFeatureFlags under
CAPABILITIES.FEATURE_FLAGS ('feature-flags').
No eager imports, and the built-in providers (config, memory, database) need no npm package at
all. The launchdarkly provider lazy-loads @launchdarkly/node-server-sdk, which npm therefore
installs alongside this package; nothing imports it unless you select that provider. See
Optional npm drivers
for what that means on npm versus Deno.
Providers own a cached snapshot plus an async start()/stop() lifecycle the plugin drives, which
is what lets isEnabled stay synchronous at the call site.
Installation
import { createFlagGuard, FeatureFlagsPlugin } from '@setu-ts/feature-flags-plugin';
Usage
import { createFlagGuard, FeatureFlagsPlugin } from '@setu-ts/feature-flags-plugin';
import { CAPABILITIES, type IFeatureFlags } from '@setu-ts/common';
app.register(
FeatureFlagsPlugin({
provider: 'config',
options: {
flags: {
'new-checkout': { enabled: false, users: ['user-1', 'user-2'] },
'dark-mode': { enabled: true, percentage: 25 },
},
},
}),
);
const flags = app.services.get<IFeatureFlags>(CAPABILITIES.FEATURE_FLAGS);
if (flags.isEnabled('new-checkout', { userId: 'user-1' })) {
/* … */
}
// Route guard — short-circuits without calling next()
app.router.get('/beta', {
middleware: [createFlagGuard('new-checkout', { fallback: '/' })],
handler: betaHandler,
});
Options
FeatureFlagsPluginOptions is a union discriminated on provider, so a missing per-arm field is a
compile error rather than a startup throw:
| Option | Type | Description |
|---|---|---|
provider | 'config' | 'memory' | 'database' | 'launchdarkly' | 'custom' | Selects the arm. |
options | per-arm shape | Configuration for that arm. |
options is required for 'config' ({ flags }), 'database', 'launchdarkly' and 'custom'
({ instance }), and optional for 'memory'. See Providers for behaviour and
LaunchDarkly for that arm’s fields.
Providers
provider | Behaviour |
|---|---|
'config' | Immutable inline flags. |
'memory' | Mutable map — setFlag / removeFlag / replaceFlags. |
'database' | Polls an injected IFlagStore on one interval; keeps the last good snapshot. |
'launchdarkly' | LaunchDarkly, via the SDK’s one synchronous read — see below. |
'custom' | Any FlagProvider you supply. |
DatabaseProvider arms a single poll timer in start(). On a poll failure it retains the previous
snapshot and reports the degradation rather than flipping every flag off.
Evaluation precedence
Allowlist first, then enabled, then percentage. A users entry overrides enabled: false, so
{ enabled: false, users: ['user-1'] } turns the flag on for those users only.
The rollout bucket is a deterministic FNV-1a-32 hash of (flag, userId) % 100, so a user’s bucket
is stable across processes and restarts. A partial rollout with no userId evaluates to
false.
Guard behaviour
createFlagGuard(flag, options?) redirects with 302 when fallback is set, otherwise responds
with statusCode ?? 404. It never calls next(). An unregistered feature-flags capability is
allowed to propagate rather than silently opening the route.
LaunchDarkly
Supported through the 'launchdarkly' arm. The Node server SDK’s variation/allFlagsState are
async, which no provider can reconcile with the synchronous committed isEnabled directly — the
bridge is LDFlagsState.getFlagValue, the SDK’s one synchronous read, behind a per-context snapshot
cache.
A context whose snapshot has not loaded yet returns fallbackValue and schedules a background
refill, coalesced per key so a hot loop over uncached users does not stampede. isEnabledAsync
carries no such caveat — it awaits the SDK and is the accurate entry point when a cold read matters.
Set maxCachedContexts alongside provider and options on the 'launchdarkly' plugin arm:
FeatureFlagsPlugin({ provider: 'launchdarkly', maxCachedContexts: 1_000, options: { sdkKey } }).
The default is 10,000 snapshots. A positive integer is required; invalid limits throw a RangeError
naming maxCachedContexts at provider construction. Hits refresh recency; insertion past the limit
evicts the least recently used snapshot. An evicted user behaves like a first-time user: fallback
while a background refill runs. Direct construction accepts the same limit as the third argument:
new LaunchDarklyProvider(config, logger, maxCachedContexts).
| Option | Type | Default | Description |
|---|---|---|---|
sdkKey | string | — | Required unless client is injected. |
client | ILaunchDarklyClient | — | Prebuilt client; the SDK module is never loaded. |
fallbackValue | boolean | false | Value for an unloaded context; also the SDK default in isEnabledAsync. |
initTimeoutSeconds | number | 5 | Initial connection wait. A timeout leaves the provider degraded rather than failing startup. |
ldOptions | Record<string, unknown> | — | Forwarded verbatim as the SDK init() second argument. |
Exports
| Export | Kind |
|---|---|
adaptLaunchDarklyModule | function |
createFlagGuard | function |
createProvider | function |
FeatureFlagsPlugin | function |
loadLaunchDarklyModule | function |
toLaunchDarklyContext | function |
ConfigProvider | class |
DatabaseProvider | class |
FeatureFlagService | class |
LaunchDarklyModuleError | class |
LaunchDarklyProvider | class |
MemoryProvider | class |
ConfigProviderOptions | interface |
CustomProviderOptions | interface |
DatabaseProviderOptions | interface |
FlagContext | interface |
FlagDefinition | interface |
FlagGuardOptions | interface |
FlagProvider | interface |
FlagProviderStatus | interface |
IFeatureFlags | interface |
IFlagStore | interface |
ILaunchDarklyClient | interface |
ILaunchDarklyFlagsState | interface |
ILaunchDarklyModule | interface |
LaunchDarklyContext | interface |
LaunchDarklyProviderConfig | interface |
LaunchDarklyProviderOptions | interface |
MemoryProviderOptions | interface |
FeatureFlagsPluginOptions | type |
FlagProviderType | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
Full API
Every export and option is documented in PUBLIC_API.md.
Readiness and dependency outages (M113b)
A failed provider keeps feature-flags up with data.healthy: false and data.detail when
available. /ready stays 200 while the last good snapshot serves. Alert on data.healthy instead
of the overall health status.
API reference
Feature Flags (@setu-ts/feature-flags-plugin)
A failed provider keeps feature-flags up with data.healthy: false and data.detail when
available. /ready stays 200 while the last good snapshot serves. Alert on data.healthy instead
of the overall health status.
Provides feature flag capability.
Registration
import { FeatureFlagsPlugin } from '@setu-ts/feature-flags-plugin';
app.register(
FeatureFlagsPlugin({
provider: 'config',
options: {
flags: {
'new-dashboard': { enabled: true, percentage: 50 },
'beta-features': { enabled: false, users: ['user1', 'user2'] },
'dark-mode': { enabled: true },
},
},
}),
);
Usage
app.router.get('/dashboard', async (ctx) => {
const flags = ctx.services.get<IFeatureFlags>('feature-flags');
const user = ctx.request.user;
if (flags.isEnabled('new-dashboard', { userId: user?.id })) {
return ctx.response.json({ dashboard: 'new' });
}
return ctx.response.json({ dashboard: 'old' });
});
// Middleware (free-function guard — IFeatureFlags has no middleware method)
import { createFlagGuard } from '@setu-ts/feature-flags-plugin';
app.router.get('/beta', {
middleware: [createFlagGuard('beta-features', { fallback: '/not-found' })],
handler: async (ctx) => {
/* ... */
},
});
Options
| Option | Provider(s) | Required | Description |
|---|---|---|---|
provider | all | yes | Discriminant: 'config', 'memory', 'database', 'launchdarkly', or 'custom' |
options.flags | 'config', 'memory' | config: yes, memory: no | Static Readonly<Record<string, FlagDefinition>> |
options.store | 'database' | yes | Injected IFlagStore providing { loadFlags(): Promise<...> } |
options.refreshIntervalMs | 'database' | no | Poll cadence; defaults to 30000 |
options.sdkKey | 'launchdarkly' | unless client | LaunchDarkly SDK key; a missing key with no client throws at register() |
options.client | 'launchdarkly' | no | Prebuilt ILaunchDarklyClient; suppresses the lazy SDK import |
options.module | 'launchdarkly' | no | SDK module to adapt instead of importing — the test seam |
options.fallbackValue | 'launchdarkly' | no | Value the sync path returns on a cold context; defaults to false |
options.initTimeoutSeconds | 'launchdarkly' | no | Initial-connection budget; defaults to 5. A timeout degrades, never throws |
options.ldOptions | 'launchdarkly' | no | Forwarded verbatim as the SDK init() second argument |
maxCachedContexts | 'launchdarkly' | no | Wrapper option: LRU snapshot cap, default 10,000; positive integer or RangeError at provider construction. Hits refresh recency; evicted contexts return fallback while refilling |
options.instance | 'custom' | yes | Pre-built FlagProvider instance |
Exports
| Symbol | Kind | Description |
|---|---|---|
FeatureFlagsPlugin | function | Plugin factory — registers IFeatureFlags under CAPABILITIES.FEATURE_FLAGS |
FeatureFlagService | class | Service implementing IFeatureFlags |
createProvider | function | Provider factory dispatching on the provider discriminant |
ConfigProvider | class | Immutable config-backed provider |
MemoryProvider | class | Mutable in-process provider with setFlag/removeFlag/replaceFlags |
DatabaseProvider | class | Polled database-backed provider |
LaunchDarklyProvider | class | LaunchDarkly-backed provider bridging its async SDK to the sync contract |
toLaunchDarklyContext | function | Maps a FlagContext to the SDK’s single-kind user context |
adaptLaunchDarklyModule | function | Narrows an SDK module to ILaunchDarklyModule |
loadLaunchDarklyModule | function | Real lazy import('@launchdarkly/node-server-sdk') |
LaunchDarklyModuleError | class | Thrown when the SDK cannot be loaded or does not expose init() |
ILaunchDarklyClient | interface | Injection facade for the SDK client |
ILaunchDarklyModule | interface | Injection facade for the SDK module |
ILaunchDarklyFlagsState | interface | The snapshot facade — its getFlagValue is the synchronous read |
LaunchDarklyContext | interface | The SDK evaluation context this provider builds |
createFlagGuard | function | Free-function route guard — short-circuits to redirect/404 when flag is off |
FlagProvider | interface | Port implemented by all providers, and by the 'custom' arm’s instance |
FlagProviderStatus | interface | Health status shape ({ healthy, detail? }) |
FlagProviderType | type | 'config' | 'memory' | 'database' | 'launchdarkly' | 'custom' |
FlagDefinition | interface | { enabled, percentage?, users?, tenants? } |
IFlagStore | interface | Structural facade for DatabaseProvider |
FeatureFlagsPluginOptions | type | Discriminated union of the four provider option shapes |
ConfigProviderOptions | interface | The 'config' arm — requires options.flags |
MemoryProviderOptions | interface | The 'memory' arm — options.flags optional |
DatabaseProviderOptions | interface | The 'database' arm — requires options.store |
LaunchDarklyProviderOptions | interface | The 'launchdarkly' arm |
LaunchDarklyProviderConfig | interface | That arm’s configuration shape |
CustomProviderOptions | interface | The 'custom' arm — requires options.instance |
FlagGuardOptions | interface | createFlagGuard options (fallback, statusCode, context) |
IFeatureFlags, FlagContext | type (re-export) | From @setu-ts/common |
Notes
IFeatureFlags.isEnabledis synchronous; providers refresh their state out of band.- Tenant restriction.
FlagDefinition.tenantsis a restriction, not an allowlist: when present and non-empty, the flag isfalsefor any context whosetenantIdis not in the list — including a context with no tenant — and it is evaluated ahead of every other rule, so it cannot be overridden byusersorenabled: true. When absent, evaluation is unchanged.FlagContext.tenantIdis optional, so existing callers and implementors are source-compatible; a context without atenantIdonly matters against a flag that scopes itself.createFlagGuardderivestenantIdfromctx.request.tenant?.id(omitting it when absent) unlessoptions.contextis supplied. - The allowlist (
users) overridesenabled: false—{ enabled: false, users: ['user1'] }evaluates totrueforuserId: 'user1'. - Percentage rollout uses deterministic FNV-1a 32-bit bucketing over
`${flag}:${userId}`. FlagContext.attributesis accepted but not consumed by the built-in evaluators; the'custom'arm is its extension point.IFeatureFlags.isEnabledAsyncis optional and additive. Providers with a purely local snapshot (config, memory, database) have nothing to await, soFeatureFlagServiceresolves the synchronous evaluation for them. Both entry points funnel through one provider, so a configured option governs them identically.- LaunchDarkly. Every evaluation method on the Node server SDK is asynchronous, so it cannot
directly satisfy the synchronous
isEnabled.LaunchDarklyProviderbridges it using the one synchronous read the SDK does offer,LDFlagsState.getFlagValue: it fetches a per-context snapshot asynchronously and reads it synchronously thereafter. On a cold context — the first evaluation for a givenuserId—isEnabledreturns the configuredfallbackValueand schedules a background refill, so every later read for that user is real LaunchDarkly state.start()prewarms the anonymous context and an SDKupdateevent drops the cache. UseisEnabledAsyncwherever a wrong answer on a cold context would matter; it awaitsboolVariationand carries no such caveat. The provider is Node/Deno/Bun only (the SDK usesnode:eventsand Node HTTP), and the SDK is never a hard dependency.