@mergedapp/feature-flags 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,435 @@
1
+ # @mergedapp/feature-flags
2
+
3
+ Type-safe, generic feature flag client SDK with ES256 JWT verification, code generation, and React hooks.
4
+
5
+ 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.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @mergedapp/feature-flags
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ### 1. Generate Typed Flags
16
+
17
+ Run the CLI to fetch your flag definitions and generate a type-safe TypeScript file:
18
+
19
+ ```bash
20
+ npx merged-ff generate --api-url=https://api.merged.gg --client-key=lk_pub_your_key_here --organization-id=org_123
21
+ ```
22
+
23
+ This creates `./src/generated/feature-flags.ts` containing a `createClient` factory, typed hooks, and type definitions.
24
+
25
+ ### 2. Create the Client
26
+
27
+ ```typescript
28
+ import { createClient } from "./generated/feature-flags"
29
+
30
+ const client = createClient({
31
+ apiUrl: "https://api.merged.gg",
32
+ clientKey: "lk_pub_your_key_here",
33
+ organizationId: "org_123",
34
+ environmentId: "env_prod",
35
+ evaluationContext: {
36
+ attributes: {
37
+ user: {
38
+ plan: { code: "pro" },
39
+ role: "admin",
40
+ },
41
+ cart: {
42
+ items: [{ sku: "sku_123" }],
43
+ },
44
+ },
45
+ },
46
+ })
47
+
48
+ await client.initialize()
49
+
50
+ // Fully typed -- flag code keys are autocompleted, return types are inferred
51
+ if (client.isEnabled("enableDarkMode")) {
52
+ enableDarkMode()
53
+ }
54
+
55
+ const retries = client.getValue("maxRetries") // number | undefined
56
+
57
+ // Compile error -- "typo" is not a valid flag code key
58
+ client.isEnabled("typo") // TS Error: Argument of type '"typo"' is not assignable
59
+ ```
60
+
61
+ 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.
62
+
63
+ ### Using a Config File
64
+
65
+ Create a `featureflags.config.json` in your project root. `featureflags.config.js`, `.mjs`, and `.cjs` are also supported:
66
+
67
+ ```json
68
+ {
69
+ "apiUrl": "https://api.merged.gg",
70
+ "clientKey": "lk_pub_your_key_here",
71
+ "organizationId": "org_123",
72
+ "outputPath": "./src/generated/feature-flags.ts"
73
+ }
74
+ ```
75
+
76
+ Then run without arguments:
77
+
78
+ ```bash
79
+ npx merged-ff generate
80
+ ```
81
+
82
+ Environment variables `FEATURE_FLAG_API_URL`, `FEATURE_FLAG_CLIENT_KEY`, and `FEATURE_FLAG_ORGANIZATION_ID` are also supported.
83
+
84
+ ## Code Generation
85
+
86
+ The `merged-ff generate` CLI produces a TypeScript file with these exports:
87
+
88
+ ### `FLAGS`
89
+
90
+ A constant object mapping stable code keys to stable UUIDs:
91
+
92
+ ```typescript
93
+ export const FLAGS = {
94
+ enableDarkMode: "550e8400-e29b-41d4-a716-446655440000",
95
+ maxUploadSize: "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
96
+ } as const
97
+ ```
98
+
99
+ ### `FlagValues`
100
+
101
+ An interface mapping each flag code key to its TypeScript type:
102
+
103
+ ```typescript
104
+ export interface FlagValues {
105
+ enableDarkMode: boolean
106
+ maxUploadSize: number
107
+ }
108
+ ```
109
+
110
+ ### `createClient(config)`
111
+
112
+ 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:
113
+
114
+ ```typescript
115
+ import { createClient } from "./generated/feature-flags"
116
+
117
+ const client = createClient({
118
+ apiUrl: "https://api.merged.gg",
119
+ clientKey: "lk_pub_your_key_here",
120
+ organizationId: "org_123",
121
+ environmentId: "env_prod",
122
+ })
123
+
124
+ await client.initialize()
125
+
126
+ client.isEnabled("enableDarkMode") // boolean -- autocompleted
127
+ client.getValue("maxUploadSize") // number | undefined -- inferred
128
+ client.isEnabled("nonExistent") // Compile error
129
+ ```
130
+
131
+ 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.
132
+
133
+ 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`.
134
+
135
+ ## Evaluation Model
136
+
137
+ Feature flag authoring happens in layers:
138
+
139
+ 1. **Flag default value** -- The base value on the flag definition.
140
+ 2. **Environment override** -- An environment-specific configuration with its own enabled state, fallback value, and optional rollout.
141
+ 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.
142
+
143
+ Targeting groups can currently use four matcher types:
144
+
145
+ - **IP range targeting** -- IPv4 or IPv6 CIDR ranges.
146
+ - **Country targeting** -- ISO 3166-1 alpha-2 country codes.
147
+ - **Region targeting** -- ISO 3166-2 subdivision codes.
148
+ - **Attribute targeting** -- Caller-provided paths resolved from `evaluationContext.attributes`.
149
+
150
+ When the server evaluates a request:
151
+
152
+ 1. It picks the current organization and environment scope.
153
+ 2. It applies the enabled environment override, if one exists.
154
+ 3. It evaluates targeting groups for that environment.
155
+ 4. Matchers can combine request-derived data such as IP, country, and region with caller-provided attributes from `evaluationContext`.
156
+ 5. If a matching targeting group has a percentage rollout, the server performs deterministic bucketing before returning that group's value.
157
+ 6. If multiple IP groups match, the most specific CIDR wins.
158
+ 7. If specificity ties, the earlier group wins.
159
+ 8. If no targeting group resolves a value, the environment fallback value is considered.
160
+ 9. If the environment fallback has a percentage rollout, the server performs deterministic bucketing before returning that value.
161
+ 10. If no environment override applies, the base flag default value is returned.
162
+
163
+ 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.
164
+
165
+ 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.
166
+
167
+ ```typescript
168
+ client.setEvaluationContext({
169
+ attributes: {
170
+ user: {
171
+ plan: { code: "enterprise" },
172
+ locale: "en-US",
173
+ },
174
+ },
175
+ })
176
+
177
+ await client.refresh()
178
+ ```
179
+
180
+ ## Durable snapshots
181
+
182
+ The SDK now persists the last successful signed evaluation automatically:
183
+
184
+ - browsers use `localStorage`
185
+ - non-browser runtimes use a local file store under the OS temp directory
186
+ - persistence is isolated by a scope key derived from `apiUrl`, org, environment, team, client key fingerprint, and a canonicalized `evaluationContext` hash
187
+
188
+ 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.
189
+
190
+ You can disable or override persistence:
191
+
192
+ ```typescript
193
+ import { createClient, createFileFeatureFlagSnapshotStore } from "@mergedapp/feature-flags"
194
+
195
+ const client = createClient({
196
+ apiUrl: "https://api.merged.gg",
197
+ clientKey: "lk_pub_your_key_here",
198
+ organizationId: "org_123",
199
+ environmentId: "env_prod",
200
+ snapshotPersistence: {
201
+ store: createFileFeatureFlagSnapshotStore(),
202
+ keyPrefix: "my-app-flags",
203
+ },
204
+ })
205
+ ```
206
+
207
+ Set `snapshotPersistence: false` to disable durable snapshots entirely.
208
+
209
+ ### `createTypedHooks<FlagValues>()`
210
+
211
+ Generates fully typed React hooks. The generated file exports pre-built hooks:
212
+
213
+ ```typescript
214
+ export const {
215
+ FeatureFlagProvider,
216
+ useFeatureFlag,
217
+ useFeatureFlags,
218
+ useFeatureFlagClient,
219
+ useFeatureFlagStatus,
220
+ } = createTypedHooks<FlagValues>()
221
+ ```
222
+
223
+ ## Turbo Pipeline
224
+
225
+ Add the generate command to your `turbo.json` so flags are regenerated before builds:
226
+
227
+ ```json
228
+ {
229
+ "tasks": {
230
+ "generate:flags": {
231
+ "inputs": ["featureflags.config.json"],
232
+ "outputs": ["src/generated/feature-flags.ts"],
233
+ "cache": false
234
+ },
235
+ "build": {
236
+ "dependsOn": ["generate:flags"]
237
+ }
238
+ }
239
+ }
240
+ ```
241
+
242
+ ## React Integration
243
+
244
+ Use the generated typed hooks for full type safety:
245
+
246
+ ```tsx
247
+ import { createClient } from "./generated/feature-flags"
248
+ import { FeatureFlagProvider, useFeatureFlag, useFeatureFlags } from "./generated/feature-flags"
249
+
250
+ const client = createClient({
251
+ apiUrl: "https://api.merged.gg",
252
+ clientKey: "lk_pub_your_key_here",
253
+ organizationId: "org_123",
254
+ environmentId: "env_prod",
255
+ evaluationContext: {
256
+ attributes: {
257
+ user: {
258
+ plan: { code: "pro" },
259
+ },
260
+ },
261
+ },
262
+ })
263
+
264
+ function App() {
265
+ return (
266
+ <FeatureFlagProvider blockUntilReady={false} client={client}>
267
+ <Dashboard />
268
+ </FeatureFlagProvider>
269
+ )
270
+ }
271
+
272
+ function Dashboard() {
273
+ // Fully typed: "enableDarkMode" is autocompleted, value is boolean | undefined
274
+ const { enabled, value } = useFeatureFlag("enableDarkMode")
275
+ const allFlags = useFeatureFlags()
276
+
277
+ if (enabled) {
278
+ return <DarkDashboard />
279
+ }
280
+
281
+ return <LightDashboard />
282
+ }
283
+ ```
284
+
285
+ 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.
286
+
287
+ ### React Subpath
288
+
289
+ 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.
290
+
291
+ ## SSR Hydration
292
+
293
+ Pass pre-evaluated flags to avoid a fetch on the server:
294
+
295
+ ```tsx
296
+ function App({ serverFlags }: { serverFlags: EvaluatedFlag[] }) {
297
+ return (
298
+ <FeatureFlagProvider blockUntilReady={false} client={client} initialFlags={serverFlags}>
299
+ <Dashboard />
300
+ </FeatureFlagProvider>
301
+ )
302
+ }
303
+ ```
304
+
305
+ When `initialFlags` is provided, the provider renders with those values immediately. Once the client initializes on the client side, live flags take over seamlessly.
306
+
307
+ ## Configuration Options
308
+
309
+ | Option | Type | Default | Description |
310
+ | ----------------- | ---------------------------------- | ---------- | ------------------------------------------------------------ |
311
+ | `apiUrl` | `string` | (required) | Base URL for the feature flag API |
312
+ | `clientKey` | `string` | (required) | API key with `lk_pub_*` or `lk_sec_*` prefix |
313
+ | `organizationId` | `string` | (required) | Organization scope sent to the signed evaluation endpoint |
314
+ | `environmentId` | `string` | (required) | Environment scope sent to the signed evaluation endpoint |
315
+ | `teamId` | `string` | - | Optional team scope sent to the signed evaluation endpoint |
316
+ | `publicKey` | `string` | auto-fetch | PEM-format ES256 public key for JWT verification |
317
+ | `refreshInterval` | `number` | `60000` | Polling interval in ms. Set to `0` to disable polling. |
318
+ | `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. |
319
+ | `evaluationContext` | `FeatureFlagEvaluationContext` | - | Optional caller-provided context used for attribute targeting |
320
+ | `flagIds` | `Record<string, string>` | - | Mapping of stable code keys to flag IDs. Auto-configured when using generated `createClient`. |
321
+ | `onError` | `(error: Error) => void` | - | Called when a refresh or verification error occurs |
322
+ | `onFlagsChanged` | `(flags: EvaluatedFlag[]) => void` | - | Called when flag values change after a refresh |
323
+
324
+ `evaluationContext` uses this shape:
325
+
326
+ ```typescript
327
+ type FeatureFlagEvaluationContext = {
328
+ attributes?: Record<string, unknown>
329
+ }
330
+ ```
331
+
332
+ Recommended usage:
333
+
334
+ - Put application-owned targeting inputs in `attributes`.
335
+ - Use nested objects for stable domain structure, for example `user.plan.code`.
336
+ - Use arrays only when position matters or when you plan to target with list membership.
337
+ - Treat client-provided values as targeting inputs, not as authorization guarantees.
338
+
339
+ ## Error Handling
340
+
341
+ The SDK exports three error classes:
342
+
343
+ - **`FeatureFlagError`** -- Base class for all SDK errors.
344
+ - **`FeatureFlagNetworkError`** -- Thrown when the API is unreachable or returns a non-2xx status.
345
+ - **`FeatureFlagVerificationError`** -- Thrown when JWT verification fails (expired, wrong issuer, tampered).
346
+
347
+ 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.
348
+
349
+ **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.
350
+
351
+ ```typescript
352
+ import { FeatureFlagNetworkError, FeatureFlagVerificationError } from "@mergedapp/feature-flags"
353
+ import { createClient } from "./generated/feature-flags"
354
+
355
+ const client = createClient({
356
+ apiUrl: "https://api.merged.gg",
357
+ clientKey: "lk_pub_your_key_here",
358
+ organizationId: "org_123",
359
+ environmentId: "env_prod",
360
+ onError: (error) => {
361
+ if (error instanceof FeatureFlagNetworkError) {
362
+ console.warn("Flag service unreachable, using cached flags")
363
+ }
364
+ if (error instanceof FeatureFlagVerificationError) {
365
+ console.error("Flag token verification failed", error)
366
+ }
367
+ },
368
+ })
369
+ ```
370
+
371
+ ## Audit & Cleanup
372
+
373
+ ### Audit
374
+
375
+ Scan your codebase for unused or stale flag references:
376
+
377
+ ```bash
378
+ npx merged-ff audit --api-url=https://api.merged.gg --client-key=lk_pub_xxx --dir=./src
379
+ ```
380
+
381
+ Produces a report showing active, unused, and archived-but-still-referenced flags.
382
+
383
+ ### Cleanup
384
+
385
+ Automatically replace archived boolean flag checks with `false`:
386
+
387
+ ```bash
388
+ # Preview changes without modifying files
389
+ npx merged-ff cleanup --api-url=https://api.merged.gg --client-key=lk_pub_xxx --dry-run
390
+
391
+ # Apply changes
392
+ npx merged-ff cleanup --api-url=https://api.merged.gg --client-key=lk_pub_xxx
393
+ ```
394
+
395
+ After cleanup, run `merged-ff generate` to update the generated file.
396
+
397
+ ## API Reference
398
+
399
+ ### `MergedFeatureFlags<TFlags>`
400
+
401
+ The core client class is generic: `MergedFeatureFlags<TFlags extends FlagRegistry = FlagRegistry>`. When using generated `createClient`, the type parameter is pre-filled.
402
+
403
+ | Method | Returns | Description |
404
+ | ------------------------------------------------------------- | ------------------------ | -------------------------------------------------------- |
405
+ | `initialize()` | `Promise<void>` | Fetch public key (if needed), fetch and verify flags |
406
+ | `isEnabled<K extends string & keyof TFlags>(name: K)` | `boolean` | Check if a flag is enabled (false for unknown flags) |
407
+ | `getValue<K extends string & keyof TFlags>(name: K)` | `TFlags[K] \| undefined` | Get a flag's value with inferred return type |
408
+ | `getFlag(idOrName: string)` | `EvaluatedFlag \| undef` | Get the full evaluated flag entry |
409
+ | `getAllFlags()` | `EvaluatedFlag[]` | Get all evaluated flags |
410
+ | `refresh()` | `Promise<void>` | Manually trigger a flag refresh from the server |
411
+ | `getStatus()` | `FeatureFlagRuntimeStatus` | Read snapshot source, staleness, and last refresh metadata |
412
+ | `setEvaluationContext(context)` | `void` | Replace the caller-provided attribute context for future refreshes; the current snapshot stays active until the next refresh |
413
+ | `onChange(listener)` | `() => void` | Subscribe to flag changes; returns unsubscribe function |
414
+ | `destroy()` | `void` | Clean up timers, listeners, and cached data |
415
+
416
+ ### React Hooks (Generated)
417
+
418
+ When using hooks from the generated file (via `createTypedHooks<FlagValues>()`), all hooks are fully typed:
419
+
420
+ | Hook | Returns | Description |
421
+ | ------------------------ | ---------------------------------------------------- | ---------------------------------------- |
422
+ | `useFeatureFlag(name)` | `{ enabled: boolean, value: TFlags[K] \| undefined }` | Get a single flag's typed state |
423
+ | `useFeatureFlags()` | `EvaluatedFlag[]` | Get all flags |
424
+ | `useFeatureFlagClient()` | `MergedFeatureFlags<TFlags>` | Access the underlying typed client |
425
+ | `useFeatureFlagStatus()` | `{ status, isLoading, isReady, error, source, isStale, lastSuccessfulRefreshAt, tokenExpiresAt }` | Read provider initialization and snapshot-source state |
426
+
427
+ 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.
428
+
429
+ ### CLI Commands
430
+
431
+ | Command | Description |
432
+ | ---------- | ----------------------------------------------------- |
433
+ | `generate` | Generate typed TypeScript SDK from flag definitions |
434
+ | `audit` | Scan codebase for unused or stale flag references |
435
+ | `cleanup` | Replace archived boolean flag checks with `false` |