npm package: @mergedapp/feature-flags
Description: Type-safe client SDK for Merged feature flags with ES256 JWT verification
Latest version: 0.1.1

# @mergedapp/feature-flags

> **Warning**
> This SDK is not ready for public use yet.
> The package is public, but you will not be able to finish setting up feature flags until the main app is available.
> We will do a proper launch soon.

Type-safe, generic feature flag client SDK with ES256 JWT verification, code generation, and React hooks.

The SDK reads already-evaluated flag values for a specific organization and environment. Authoring concepts such as stable `codeKey`s, environment overrides, fallback values, targeting groups, percentage rollouts, and IP, country, region, or attribute targeting are configured in the dashboard and resolved by the API before values reach the client.

## Installation

```bash
npm install @mergedapp/feature-flags
```

## Quick Start

### 1. Generate Typed Flags

Run the CLI to fetch your flag definitions and generate a type-safe TypeScript file:

```bash
npx merged-ff generate --api-url=<coming_soon> --client-key=lk_pub_your_key_here --organization-id=org_123
```

This creates `./src/generated/feature-flags.ts` containing a `createClient` factory, typed hooks, and type definitions.

### 2. Create the Client

```typescript
import { createClient } from "./generated/feature-flags"

const client = createClient({
  apiUrl: "<coming_soon>",
  clientKey: "lk_pub_your_key_here",
  organizationId: "org_123",
  environmentId: "env_prod",
  evaluationContext: {
    attributes: {
      user: {
        plan: { code: "pro" },
        role: "admin",
      },
      cart: {
        items: [{ sku: "sku_123" }],
      },
    },
  },
})

await client.initialize()

// Fully typed -- flag code keys are autocompleted, return types are inferred
if (client.isEnabled("enableDarkMode")) {
  enableDarkMode()
}

const retries = client.getValue("maxRetries") // number | undefined

// Compile error -- "typo" is not a valid flag code key
client.isEnabled("typo") // TS Error: Argument of type '"typo"' is not assignable
```

The generated file uses stable `codeKey` values as the SDK-facing identifiers. You can rename a flag's display name in the dashboard without breaking typed client lookups, but changing the `codeKey` is a breaking change for generated code.

### Using a Config File

Create a `featureflags.config.json` in your project root. `featureflags.config.js`, `.mjs`, and `.cjs` are also supported:

```json
{
  "apiUrl": "<coming_soon>",
  "clientKey": "lk_pub_your_key_here",
  "organizationId": "org_123",
  "outputPath": "./src/generated/feature-flags.ts"
}
```

Then run without arguments:

```bash
npx merged-ff generate
```

Environment variables `FEATURE_FLAG_API_URL`, `FEATURE_FLAG_CLIENT_KEY`, and `FEATURE_FLAG_ORGANIZATION_ID` are also supported.

## Code Generation

The `merged-ff generate` CLI produces a TypeScript file with these exports:

### `FLAGS`

A constant object mapping stable code keys to stable UUIDs:

```typescript
export const FLAGS = {
  enableDarkMode: "550e8400-e29b-41d4-a716-446655440000",
  maxUploadSize: "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
} as const
```

### `FlagValues`

An interface mapping each flag code key to its TypeScript type:

```typescript
export interface FlagValues {
  enableDarkMode: boolean
  maxUploadSize: number
}
```

### `createClient(config)`

Creates a `MergedFeatureFlags<FlagValues>` instance with `flagIds` pre-configured from the generated `FLAGS` mapping. Provide the API origin plus the organization and environment scope used for evaluation:

```typescript
import { createClient } from "./generated/feature-flags"

const client = createClient({
  apiUrl: "<coming_soon>",
  clientKey: "lk_pub_your_key_here",
  organizationId: "org_123",
  environmentId: "env_prod",
})

await client.initialize()

client.isEnabled("enableDarkMode")       // boolean -- autocompleted
client.getValue("maxUploadSize")          // number | undefined -- inferred
client.isEnabled("nonExistent")           // Compile error
```

The client receives the final evaluated value for the configured organization and environment. If your dashboard configuration uses environment overrides, targeting groups, and percentage rollouts, the SDK sees the resolved result rather than the raw authoring definition.

If the active targeting rules or rollout bucketing depend on application attributes, pass them through `evaluationContext.attributes`. The server evaluates nested object paths like `user.plan.code` and indexed array paths like `cart.items[0].sku`.

## Evaluation Model

Feature flag authoring happens in layers:

1. **Flag default value** -- The base value on the flag definition.
2. **Environment override** -- An environment-specific configuration with its own enabled state, fallback value, and optional rollout.
3. **Targeting groups** -- Ordered groups inside an environment override. Each group can return a different value when its matchers apply and can also have its own optional rollout.

Targeting groups can currently use four matcher types:

- **IP range targeting** -- IPv4 or IPv6 CIDR ranges.
- **Country targeting** -- ISO 3166-1 alpha-2 country codes.
- **Region targeting** -- ISO 3166-2 subdivision codes.
- **Attribute targeting** -- Caller-provided paths resolved from `evaluationContext.attributes`.

When the server evaluates a request:

1. It picks the current organization and environment scope.
2. It applies the enabled environment override, if one exists.
3. It evaluates targeting groups for that environment.
4. Matchers can combine request-derived data such as IP, country, and region with caller-provided attributes from `evaluationContext`.
5. If a matching targeting group has a percentage rollout, the server performs deterministic bucketing before returning that group's value.
6. If multiple IP groups match, the most specific CIDR wins.
7. If specificity ties, the earlier group wins.
8. If no targeting group resolves a value, the environment fallback value is considered.
9. If the environment fallback has a percentage rollout, the server performs deterministic bucketing before returning that value.
10. If no environment override applies, the base flag default value is returned.

For attribute targeting, the SDK does not infer values automatically. Your application is responsible for building the context object and updating it when the request scope changes.

The SDK never performs client-side rollout bucketing. Percentage rollout is evaluated server-side so the same logic applies across browser, server, and internal API callers.

```typescript
client.setEvaluationContext({
  attributes: {
    user: {
      plan: { code: "enterprise" },
      locale: "en-US",
    },
  },
})

await client.refresh()
```

## Durable snapshots

The SDK now persists the last successful signed evaluation automatically:

- browsers use `localStorage`
- non-browser runtimes use a local file store under the OS temp directory
- persistence is isolated by a scope key derived from `apiUrl`, org, environment, team, client key fingerprint, and a canonicalized `evaluationContext` hash

If the flag service is unavailable later, the SDK restores the last successful snapshot for the exact same scope and keeps serving it until a newer one replaces it.

You can disable or override persistence:

```typescript
import { createClient, createFileFeatureFlagSnapshotStore } from "@mergedapp/feature-flags"

const client = createClient({
  apiUrl: "<coming_soon>",
  clientKey: "lk_pub_your_key_here",
  organizationId: "org_123",
  environmentId: "env_prod",
  snapshotPersistence: {
    store: createFileFeatureFlagSnapshotStore(),
    keyPrefix: "my-app-flags",
  },
})
```

Set `snapshotPersistence: false` to disable durable snapshots entirely.

### `createTypedHooks<FlagValues>()`

Generates fully typed React hooks. The generated file exports pre-built hooks:

```typescript
export const {
  FeatureFlagProvider,
  useFeatureFlag,
  useFeatureFlags,
  useFeatureFlagClient,
  useFeatureFlagStatus,
} = createTypedHooks<FlagValues>()
```

## Turbo Pipeline

Add the generate command to your `turbo.json` so flags are regenerated before builds:

```json
{
  "tasks": {
    "generate:flags": {
      "inputs": ["featureflags.config.json"],
      "outputs": ["src/generated/feature-flags.ts"],
      "cache": false
    },
    "build": {
      "dependsOn": ["generate:flags"]
    }
  }
}
```

## React Integration

Use the generated typed hooks for full type safety:

```tsx
import { createClient } from "./generated/feature-flags"
import { FeatureFlagProvider, useFeatureFlag, useFeatureFlags } from "./generated/feature-flags"

const client = createClient({
  apiUrl: "<coming_soon>",
  clientKey: "lk_pub_your_key_here",
  organizationId: "org_123",
  environmentId: "env_prod",
  evaluationContext: {
    attributes: {
      user: {
        plan: { code: "pro" },
      },
    },
  },
})

function App() {
  return (
    <FeatureFlagProvider blockUntilReady={false} client={client}>
      <Dashboard />
    </FeatureFlagProvider>
  )
}

function Dashboard() {
  // Fully typed: "enableDarkMode" is autocompleted, value is boolean | undefined
  const { enabled, value } = useFeatureFlag("enableDarkMode")
  const allFlags = useFeatureFlags()

  if (enabled) {
    return <DarkDashboard />
  }

  return <LightDashboard />
}
```

The provider calls `client.initialize()` on mount and `client.destroy()` on unmount automatically. Flags update reactively via `useSyncExternalStore`. `blockUntilReady` is required so each integration chooses whether the app should render immediately or wait for the first flag payload.

### React Subpath

The `@mergedapp/feature-flags/react` subpath is intentionally low-level. It exports `FeatureFlagProvider` and `createTypedHooks()` so generated bindings can be created, but applications should import `useFeatureFlag`, `useFeatureFlags`, `useFeatureFlagClient`, and `useFeatureFlagStatus` from their generated `./generated/feature-flags` file.

## SSR Hydration

Pass pre-evaluated flags to avoid a fetch on the server:

```tsx
function App({ serverFlags }: { serverFlags: EvaluatedFlag[] }) {
  return (
    <FeatureFlagProvider blockUntilReady={false} client={client} initialFlags={serverFlags}>
      <Dashboard />
    </FeatureFlagProvider>
  )
}
```

When `initialFlags` is provided, the provider renders with those values immediately. Once the client initializes on the client side, live flags take over seamlessly.

## Configuration Options

| Option            | Type                               | Default    | Description                                                  |
| ----------------- | ---------------------------------- | ---------- | ------------------------------------------------------------ |
| `apiUrl`          | `string`                           | (required) | Base URL for the feature flag API                            |
| `clientKey`       | `string`                           | (required) | API key with `lk_pub_*` or `lk_sec_*` prefix                |
| `organizationId`  | `string`                           | (required) | Organization scope sent to the signed evaluation endpoint    |
| `environmentId`   | `string`                           | (required) | Environment scope sent to the signed evaluation endpoint     |
| `teamId`          | `string`                           | -          | Optional team scope sent to the signed evaluation endpoint   |
| `publicKey`       | `string`                           | auto-fetch | PEM-format ES256 public key for JWT verification             |
| `refreshInterval` | `number`                           | `60000`    | Polling interval in ms. Set to `0` to disable polling.       |
| `snapshotPersistence` | `false \| { store?: FeatureFlagSnapshotStore; keyPrefix?: string }` | auto | Durable last-known-good snapshot persistence. Browser defaults to `localStorage`; non-browser defaults to a file store in the OS temp dir. |
| `evaluationContext` | `FeatureFlagEvaluationContext`   | -          | Optional caller-provided context used for attribute targeting |
| `flagIds`         | `Record<string, string>`           | -          | Mapping of stable code keys to flag IDs. Auto-configured when using generated `createClient`. |
| `onError`         | `(error: Error) => void`           | -          | Called when a refresh or verification error occurs            |
| `onFlagsChanged`  | `(flags: EvaluatedFlag[]) => void` | -          | Called when flag values change after a refresh                |

`evaluationContext` uses this shape:

```typescript
type FeatureFlagEvaluationContext = {
  attributes?: Record<string, unknown>
}
```

Recommended usage:

- Put application-owned targeting inputs in `attributes`.
- Use nested objects for stable domain structure, for example `user.plan.code`.
- Use arrays only when position matters or when you plan to target with list membership.
- Treat client-provided values as targeting inputs, not as authorization guarantees.

## Error Handling

The SDK exports three error classes:

- **`FeatureFlagError`** -- Base class for all SDK errors.
- **`FeatureFlagNetworkError`** -- Thrown when the API is unreachable or returns a non-2xx status.
- **`FeatureFlagVerificationError`** -- Thrown when JWT verification fails (expired, wrong issuer, tampered).

On refresh failure, the client applies exponential backoff starting at 5 seconds, capped at 5 minutes. The `onError` callback is invoked on every failure. Existing flags remain available during outages, including restored persisted snapshots when the evaluation scope matches exactly.

**Browser tab visibility:** Polling is automatically paused when the tab is hidden and resumed with an immediate refresh when it becomes visible again. This prevents unnecessary server requests from background tabs.

```typescript
import { FeatureFlagNetworkError, FeatureFlagVerificationError } from "@mergedapp/feature-flags"
import { createClient } from "./generated/feature-flags"

const client = createClient({
  apiUrl: "<coming_soon>",
  clientKey: "lk_pub_your_key_here",
  organizationId: "org_123",
  environmentId: "env_prod",
  onError: (error) => {
    if (error instanceof FeatureFlagNetworkError) {
      console.warn("Flag service unreachable, using cached flags")
    }
    if (error instanceof FeatureFlagVerificationError) {
      console.error("Flag token verification failed", error)
    }
  },
})
```

## Audit & Cleanup

### Audit

Scan your codebase for unused or stale flag references:

```bash
npx merged-ff audit --api-url=<coming_soon> --client-key=lk_pub_xxx --dir=./src
```

Produces a report showing active, unused, and archived-but-still-referenced flags.

### Cleanup

Automatically replace archived boolean flag checks with `false`:

```bash
# Preview changes without modifying files
npx merged-ff cleanup --api-url=<coming_soon> --client-key=lk_pub_xxx --dry-run

# Apply changes
npx merged-ff cleanup --api-url=<coming_soon> --client-key=lk_pub_xxx
```

After cleanup, run `merged-ff generate` to update the generated file.

## API Reference

### `MergedFeatureFlags<TFlags>`

The core client class is generic: `MergedFeatureFlags<TFlags extends FlagRegistry = FlagRegistry>`. When using generated `createClient`, the type parameter is pre-filled.

| Method                                                        | Returns                  | Description                                              |
| ------------------------------------------------------------- | ------------------------ | -------------------------------------------------------- |
| `initialize()`                                                | `Promise<void>`          | Fetch public key (if needed), fetch and verify flags     |
| `isEnabled<K extends string & keyof TFlags>(name: K)`         | `boolean`                | Check if a flag is enabled (false for unknown flags)     |
| `getValue<K extends string & keyof TFlags>(name: K)`          | `TFlags[K] \| undefined` | Get a flag's value with inferred return type            |
| `getFlag(idOrName: string)`                                   | `EvaluatedFlag \| undef` | Get the full evaluated flag entry                        |
| `getAllFlags()`                                                | `EvaluatedFlag[]`        | Get all evaluated flags                                  |
| `refresh()`                                                   | `Promise<void>`          | Manually trigger a flag refresh from the server          |
| `getStatus()`                                                 | `FeatureFlagRuntimeStatus` | Read snapshot source, staleness, and last refresh metadata |
| `setEvaluationContext(context)`                               | `void`                   | Replace the caller-provided attribute context for future refreshes; the current snapshot stays active until the next refresh |
| `onChange(listener)`                                           | `() => void`             | Subscribe to flag changes; returns unsubscribe function  |
| `destroy()`                                                   | `void`                   | Clean up timers, listeners, and cached data              |

### React Hooks (Generated)

When using hooks from the generated file (via `createTypedHooks<FlagValues>()`), all hooks are fully typed:

| Hook                     | Returns                                              | Description                              |
| ------------------------ | ---------------------------------------------------- | ---------------------------------------- |
| `useFeatureFlag(name)`   | `{ enabled: boolean, value: TFlags[K] \| undefined }` | Get a single flag's typed state         |
| `useFeatureFlags()`      | `EvaluatedFlag[]`                                    | Get all flags                            |
| `useFeatureFlagClient()` | `MergedFeatureFlags<TFlags>`                         | Access the underlying typed client       |
| `useFeatureFlagStatus()` | `{ status, isLoading, isReady, error, source, isStale, lastSuccessfulRefreshAt, tokenExpiresAt }` | Read provider initialization and snapshot-source state |

There is no public untyped React hook entrypoint. React applications are expected to consume the generated bindings so invalid flag code keys fail at compile time.

### CLI Commands

| Command    | Description                                           |
| ---------- | ----------------------------------------------------- |
| `generate` | Generate typed TypeScript SDK from flag definitions    |
| `audit`    | Scan codebase for unused or stale flag references      |
| `cleanup`  | Replace archived boolean flag checks with `false`      |