@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:

OptionTypeDescription
provider'config' | 'memory' | 'database' | 'launchdarkly' | 'custom'Selects the arm.
optionsper-arm shapeConfiguration 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

providerBehaviour
'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).

OptionTypeDefaultDescription
sdkKeystring—Required unless client is injected.
clientILaunchDarklyClient—Prebuilt client; the SDK module is never loaded.
fallbackValuebooleanfalseValue for an unloaded context; also the SDK default in isEnabledAsync.
initTimeoutSecondsnumber5Initial connection wait. A timeout leaves the provider degraded rather than failing startup.
ldOptionsRecord<string, unknown>—Forwarded verbatim as the SDK init() second argument.

Exports

ExportKind
adaptLaunchDarklyModulefunction
createFlagGuardfunction
createProviderfunction
FeatureFlagsPluginfunction
loadLaunchDarklyModulefunction
toLaunchDarklyContextfunction
ConfigProviderclass
DatabaseProviderclass
FeatureFlagServiceclass
LaunchDarklyModuleErrorclass
LaunchDarklyProviderclass
MemoryProviderclass
ConfigProviderOptionsinterface
CustomProviderOptionsinterface
DatabaseProviderOptionsinterface
FlagContextinterface
FlagDefinitioninterface
FlagGuardOptionsinterface
FlagProviderinterface
FlagProviderStatusinterface
IFeatureFlagsinterface
IFlagStoreinterface
ILaunchDarklyClientinterface
ILaunchDarklyFlagsStateinterface
ILaunchDarklyModuleinterface
LaunchDarklyContextinterface
LaunchDarklyProviderConfiginterface
LaunchDarklyProviderOptionsinterface
MemoryProviderOptionsinterface
FeatureFlagsPluginOptionstype
FlagProviderTypetype

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

OptionProvider(s)RequiredDescription
providerallyesDiscriminant: 'config', 'memory', 'database', 'launchdarkly', or 'custom'
options.flags'config', 'memory'config: yes, memory: noStatic Readonly<Record<string, FlagDefinition>>
options.store'database'yesInjected IFlagStore providing { loadFlags(): Promise<...> }
options.refreshIntervalMs'database'noPoll cadence; defaults to 30000
options.sdkKey'launchdarkly'unless clientLaunchDarkly SDK key; a missing key with no client throws at register()
options.client'launchdarkly'noPrebuilt ILaunchDarklyClient; suppresses the lazy SDK import
options.module'launchdarkly'noSDK module to adapt instead of importing — the test seam
options.fallbackValue'launchdarkly'noValue the sync path returns on a cold context; defaults to false
options.initTimeoutSeconds'launchdarkly'noInitial-connection budget; defaults to 5. A timeout degrades, never throws
options.ldOptions'launchdarkly'noForwarded verbatim as the SDK init() second argument
maxCachedContexts'launchdarkly'noWrapper 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'yesPre-built FlagProvider instance

Exports

SymbolKindDescription
FeatureFlagsPluginfunctionPlugin factory — registers IFeatureFlags under CAPABILITIES.FEATURE_FLAGS
FeatureFlagServiceclassService implementing IFeatureFlags
createProviderfunctionProvider factory dispatching on the provider discriminant
ConfigProviderclassImmutable config-backed provider
MemoryProviderclassMutable in-process provider with setFlag/removeFlag/replaceFlags
DatabaseProviderclassPolled database-backed provider
LaunchDarklyProviderclassLaunchDarkly-backed provider bridging its async SDK to the sync contract
toLaunchDarklyContextfunctionMaps a FlagContext to the SDK’s single-kind user context
adaptLaunchDarklyModulefunctionNarrows an SDK module to ILaunchDarklyModule
loadLaunchDarklyModulefunctionReal lazy import('@launchdarkly/node-server-sdk')
LaunchDarklyModuleErrorclassThrown when the SDK cannot be loaded or does not expose init()
ILaunchDarklyClientinterfaceInjection facade for the SDK client
ILaunchDarklyModuleinterfaceInjection facade for the SDK module
ILaunchDarklyFlagsStateinterfaceThe snapshot facade — its getFlagValue is the synchronous read
LaunchDarklyContextinterfaceThe SDK evaluation context this provider builds
createFlagGuardfunctionFree-function route guard — short-circuits to redirect/404 when flag is off
FlagProviderinterfacePort implemented by all providers, and by the 'custom' arm’s instance
FlagProviderStatusinterfaceHealth status shape ({ healthy, detail? })
FlagProviderTypetype'config' | 'memory' | 'database' | 'launchdarkly' | 'custom'
FlagDefinitioninterface{ enabled, percentage?, users?, tenants? }
IFlagStoreinterfaceStructural facade for DatabaseProvider
FeatureFlagsPluginOptionstypeDiscriminated union of the four provider option shapes
ConfigProviderOptionsinterfaceThe 'config' arm — requires options.flags
MemoryProviderOptionsinterfaceThe 'memory' arm — options.flags optional
DatabaseProviderOptionsinterfaceThe 'database' arm — requires options.store
LaunchDarklyProviderOptionsinterfaceThe 'launchdarkly' arm
LaunchDarklyProviderConfiginterfaceThat arm’s configuration shape
CustomProviderOptionsinterfaceThe 'custom' arm — requires options.instance
FlagGuardOptionsinterfacecreateFlagGuard options (fallback, statusCode, context)
IFeatureFlags, FlagContexttype (re-export)From @setu-ts/common

Notes

  • IFeatureFlags.isEnabled is synchronous; providers refresh their state out of band.
  • Tenant restriction. FlagDefinition.tenants is a restriction, not an allowlist: when present and non-empty, the flag is false for any context whose tenantId is not in the list — including a context with no tenant — and it is evaluated ahead of every other rule, so it cannot be overridden by users or enabled: true. When absent, evaluation is unchanged. FlagContext.tenantId is optional, so existing callers and implementors are source-compatible; a context without a tenantId only matters against a flag that scopes itself. createFlagGuard derives tenantId from ctx.request.tenant?.id (omitting it when absent) unless options.context is supplied.
  • The allowlist (users) overrides enabled: false — { enabled: false, users: ['user1'] } evaluates to true for userId: 'user1'.
  • Percentage rollout uses deterministic FNV-1a 32-bit bucketing over `${flag}:${userId}`.
  • FlagContext.attributes is accepted but not consumed by the built-in evaluators; the 'custom' arm is its extension point.
  • IFeatureFlags.isEnabledAsync is optional and additive. Providers with a purely local snapshot (config, memory, database) have nothing to await, so FeatureFlagService resolves 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. LaunchDarklyProvider bridges 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 given userId — isEnabled returns the configured fallbackValue and schedules a background refill, so every later read for that user is real LaunchDarkly state. start() prewarms the anonymous context and an SDK update event drops the cache. Use isEnabledAsync wherever a wrong answer on a cold context would matter; it awaits boolVariation and carries no such caveat. The provider is Node/Deno/Bun only (the SDK uses node:events and Node HTTP), and the SDK is never a hard dependency.

Agent card · TypeDoc API reference