diff --git a/sdk/ts/flags/ARCHITECTURE.md b/sdk/ts/flags/ARCHITECTURE.md index cdb9cd9..deb4659 100644 --- a/sdk/ts/flags/ARCHITECTURE.md +++ b/sdk/ts/flags/ARCHITECTURE.md @@ -61,10 +61,11 @@ createFlags(kv, opts) │ ▼ snapshot.start() - ├─ loadAll(): list(flags:def:*) + batchGet() → in-memory Map + ├─ loadAll(): list(flags:def:*) + batchGet() → in-memory Map; track maxRevision └─ opts.watch !== false: - kv.watch("flags:def:*") → stream deltas → apply to Map - on error: exponential backoff (1s→60s) + poll fallback + kv.watch("flags:def:*", { since: lastRevision }) → stream deltas → apply to Map + on hard error: exponential backoff (1s→60s) + poll fallback, + then re-watch from { since: lastRevision } (no gap loss) opts.watch === false (or fallback): setInterval(loadAll, refresh * 1000) [timer unref'd] │ @@ -72,6 +73,13 @@ snapshot.start() flag eval: snapshot.get(name) → O(1) → FlagDef | undefined ``` +`lastRevision` is the highest revision seen from any read or watch delta. The +first watch starts from the revision the initial `loadAll` saw, and every +reconnect resumes from the last delta applied — so writes/deletes that land while +the stream is down are replayed (the server treats `since` as exclusive) rather +than lost. Re-applying an already-seen revision is a no-op (byte-compare dedup), +so over-replay is harmless. + ### User pref mutation (CAS loop) ``` @@ -178,6 +186,77 @@ Different frameworks expose different hooks, requiring two propagation strategie **Important for Next.js edge middleware**: the scope established in `middleware.ts` does **not** propagate into App Router route handlers — Next dispatches them in a separate async context. Route handlers should use explicit `await flag(ctx)` instead. +## Vercel Flags SDK Adapter (`src/adapter.ts`) + +`@beyond.dev/flags/adapter` lets the Vercel [Flags SDK](https://flags-sdk.dev) (`flags/next`) resolve flags against Beyond KV. The host owns request plumbing, toolbar overrides, precompute, and `reportValue`; this module implements the one seam the host calls — the `Adapter` interface — by reusing the same `evaluate()` engine and KV reads as the native API. It is purely additive and shares no state with the ALS/scope machinery above (the host manages the request lifecycle). + +``` +flag({ key, adapter, identify }) ← user declares with the Vercel SDK + │ flag(request) / evaluate([...]) ← host (flags/next) drives evaluation + ▼ +host: identify({headers,cookies}) → entities ; reads override cookie + │ + ▼ adapter.decide({ key, entities, headers, cookies, defaultValue }) +beyondAdapter: entities → FlagContext (entities.id = bucket key) + │ def = DefSource.get(key) (snapshot or per-request) + │ prefs = fetchUserPrefs(id) (per-request cached) + ▼ +evaluate(key, defaultValue, ctx, def, prefs) ← same pure engine as native eval +``` + +**Mapping**: `EntitiesType = FlagContext`; the host's `entities.id` is the rollout bucket key. The declared `defaultValue` is threaded into `evaluate()` rather than baked into KV, so precedence is identical to native usage. Missing `entities`/`id` → the declared default (can't bucket). + +**Read modes** (`mode` option): + +| Mode | Source | Best for | +| -------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `snapshot` (default) | reuses the `Snapshot` class — in-memory, watch/poll, zero per-eval I/O | long-lived Node servers | +| `request` | per-request fetch cached in `WeakMap` (one `list`+`batchGet`/request) | short-lived edge/serverless (no persistent watch) | + +**Batching**: a stable per-instance `adapterId` (Symbol) + `bulkDecide` let the host's `evaluate()` resolve all flags sharing one adapter **and** one `identify` reference in a single call. Distinct `identify` closures form distinct groups (one `bulkDecide` each). + +**Discovery**: `adapter.getProviderData()` enumerates `flags:def:*` for the toolbar/`createFlagsDiscoveryEndpoint`. Definitions are thin (KV stores only `on`/`rules`/`rollout`, marked `declaredInCode: false`); merge with the host's `getProviderData(flags)` via `mergeProviderData` for code-side `options`/`description`/`defaultValue`. + +**Not bridged**: native `.set()`/`.reset()` (pref writes) and `.when()` overrides have no Vercel equivalent — the adapter still _reads_ prefs (end-user opt-in honored), but writes stay on the native API; toolbar overrides replace `.when()`. + +## OpenFeature Providers (`src/openfeature/`) + +`@beyond.dev/flags/openfeature/server` and `.../web` expose the same eval engine to [OpenFeature](https://openfeature.dev/), the CNCF vendor-neutral flag standard. OpenFeature's `Provider` is the same one-method seam as the Vercel `Adapter`; both providers are thin shells over the same `evaluate()` + `Snapshot` + `fetchUserPrefs` and share no state with the ALS machinery. + +Two entry points because OpenFeature ships server and web as **distinct packages with different `Provider` interfaces** — server `resolve*` is `async`, web `resolve*` is **synchronous** against a static context. A small SDK-agnostic `shared.ts` (imports only `@openfeature/core`) holds the mapping both reuse, so neither entry pulls the other SDK. + +``` +EvaluationContext (targetingKey + attrs) + │ toFlagContext() ← shared.ts + ▼ +FlagContext { id, ...attrs } + │ def = Snapshot.get(key) ← reuse snapshot.ts + │ prefs = fetchUserPrefs(id) ← reuse snapshot.ts + ▼ +evaluate(key, default, ctx, def, prefs) ← same pure engine as native/adapter + │ toResolution() + type-check + ▼ +ResolutionDetails { value, reason, errorCode?, flagMetadata? } +``` + +**Context mapping**: `EvaluationContext.targetingKey` → `FlagContext.id` (the rollout/pref bucket key); other attributes carry through for rule matching. A missing `targetingKey` maps to `id: ""` — rules on attributes still match, rollout/prefs are skipped (no throw, unlike native `flag.ts`). + +**Reason mapping** (`EvalReason` → OpenFeature `ResolutionReason`): `off`→`DISABLED`; `user-pref`/`override`/`rule`→`TARGETING_MATCH`; `rollout`→`SPLIT`; `no-snapshot`→`STALE` before init else `DEFAULT`; `default`→`DEFAULT`. `flagMetadata` carries `beyondReason` (+ `ruleIndex` on a rule match). + +**Type contract**: each typed resolver (`resolveBoolean/String/Number/ObjectEvaluation`) checks the resolved value's type; on mismatch it returns the declared `defaultValue` with `errorCode: TYPE_MISMATCH` (never coerces). All four delegate to one private generic `resolve`. + +**Read model**: + +| Provider | Mode | Source | +| -------- | -------------------- | ------------------------------------------------------------------------------------------------------------------ | +| server | `snapshot` (default) | in-memory `Snapshot`, watch/poll, zero def I/O per eval | +| server | `fetch` | one `kv.get` per flag per eval (no persistent watch) | +| web | snapshot only | sync resolve needs in-memory state; prefs for the active context are pre-fetched on `initialize`/`onContextChange` | + +**Events**: in snapshot mode both providers wire the new `Snapshot` `onChange` hook → `events.emit(PROVIDER_CONFIGURATION_CHANGED, { flagsChanged })`. `PROVIDER_READY`/`PROVIDER_ERROR` are emitted by the SDK from `initialize()` resolving/rejecting (no manual emit). + +**Not bridged**: like the Vercel adapter, native `.set()`/`.reset()` (pref writes) and `.when()` overrides have no OpenFeature equivalent — the providers _read_ prefs but writes stay on the native API. + ## KV Schema | Key | Value | Written by | @@ -197,8 +276,12 @@ Different frameworks expose different hooks, requiring two propagation strategie | `src/flag.ts` | `Flag` public interface and `FlagRuntime` internal interface; `makeFlag()` factory | | `src/flags.ts` | `FlagsClient`, `createFlags()`, lazy `flags` singleton; `mutateUserPrefs()` CAS loop; `Runtime` impl | | `src/eval.ts` | Pure synchronous `evaluate()` — the 7-step precedence chain | +| `src/adapter.ts` | Vercel Flags SDK adapter (`beyondAdapter()`): `decide`/`bulkDecide`/`getProviderData` over the same eval engine + KV | | `src/hash.ts` | `fnv1a32()` and `bucket(id, flagName)` for deterministic rollouts | -| `src/snapshot.ts` | `Snapshot` class: in-memory `Map`, watch+polling sync, `fetchUserPrefs()` | +| `src/snapshot.ts` | `Snapshot` class: in-memory `Map`, watch+polling sync, `onChange` change hook, `fetchUserPrefs()` | +| `src/openfeature/shared.ts` | SDK-agnostic OpenFeature glue: `toFlagContext`, `mapReason`, `toResolution` (+ type-mismatch handling) | +| `src/openfeature/server.ts` | OpenFeature **server** provider (`BeyondProvider`): async resolvers, snapshot/fetch modes, `PROVIDER_CONFIGURATION_CHANGED` | +| `src/openfeature/web.ts` | OpenFeature **web** provider (`BeyondWebProvider`): sync resolvers, per-context pref prefetch on init/context-change | | `src/middleware/hono.ts` | Hono `MiddlewareHandler` — wraps chain with `runWithScope` | | `src/middleware/express.ts` | Express `RequestHandler` — wraps chain with `runWithScope`, errors via `next(err)` | | `src/middleware/fastify.ts` | Fastify plugin — `onRequest` hook uses `enterScope` (can't wrap chain) | @@ -239,16 +322,16 @@ CLOSED ## Failure Modes -| Failure | What Actually Happens | Recovery | -| ------------------------------------------ | ------------------------------------------------------------------------ | ----------------------------------------------------- | -| Zero-arg flag call with no active scope | Throws `FlagError("no_context")` | Ensure middleware is registered before route handlers | -| `ctx.id === ""` | Throws `FlagError("missing_id")` | Middleware must supply a non-empty id | -| Snapshot not yet loaded | Returns flag's `default` with reason `"no-snapshot"` | Await `client.ready()` at startup | -| Watch stream error | `onError` called; backoff + falls back to polling | Auto-recovers; no eval interruption | -| User prefs KV fetch fails | `onError` called; prefs treated as `null` | Evals continue, user-pref branch skipped | -| `flag.set/reset` CAS conflict (≤4 retries) | Retries; emits `onError` + throws `FlagError("kv_error")` on max retries | Caller must handle | -| `BEYOND_KV_URL` not set | Default `flags` singleton throws at first call | Set env var or use `createFlags(kv)` | -| KV entry has invalid JSON | `onError` called; flag treated as absent (`"no-snapshot"`) | Fix via CLI | +| Failure | What Actually Happens | Recovery | +| ------------------------------------------ | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| Zero-arg flag call with no active scope | Throws `FlagError("no_context")` | Ensure middleware is registered before route handlers | +| `ctx.id === ""` | Throws `FlagError("missing_id")` | Middleware must supply a non-empty id | +| Snapshot not yet loaded | Returns flag's `default` with reason `"no-snapshot"` | Await `client.ready()` at startup | +| Watch stream error | `onError` called; backoff + falls back to polling; reconnect resumes from `lastRevision` | Auto-recovers; deltas during the gap are replayed, not lost | +| User prefs KV fetch fails | `onError` called; prefs treated as `null` | Evals continue, user-pref branch skipped | +| `flag.set/reset` CAS conflict (≤4 retries) | Retries; emits `onError` + throws `FlagError("kv_error")` on max retries | Caller must handle | +| `BEYOND_KV_URL` not set | Default `flags` singleton throws at first call | Set env var or use `createFlags(kv)` | +| KV entry has invalid JSON | `onError` called; flag treated as absent (`"no-snapshot"`) | Fix via CLI | ## Why It Behaves This Way @@ -271,3 +354,9 @@ Using only `id` would mean a user in the 20% cohort for flag A would also be in ### Why prefs are sparse (only non-defaults stored) Most users never opt in or out of any flag. Storing only deviations means the `flags:user:{id}` key doesn't exist for most ids, keeping KV storage proportional to actual customization rather than user count × flag count. + +### Why the watch resumes from `lastRevision` (not from "now") + +A reconnect that re-subscribes from the current moment silently drops any write or delete that landed while the stream was down — leaving a flag permanently stale until the next change or restart. Resuming from the last revision applied makes the snapshot self-healing across disconnects: the server replays the gap (since is exclusive), and byte-compare dedup makes any over-replay a no-op. This is the snapshot equivalent of the idempotent/recoverable property required of state-mutating operations elsewhere — eventual consistency that actually converges, rather than a poll-timing race. + +A periodic full reconcile on a _healthy_ stream is deliberately **not** done: its cost is `O(flags)` reads per interval × every replica, independent of change rate, and it would mask (rather than surface) a server-side watch-delivery bug. If silent same-stream drops are ever observed, that belongs fixed at the watch source, not papered over with fleet-wide polling. diff --git a/sdk/ts/flags/README.md b/sdk/ts/flags/README.md index 69004d7..b7305af 100644 --- a/sdk/ts/flags/README.md +++ b/sdk/ts/flags/README.md @@ -93,6 +93,47 @@ export default async function Page() { } ``` +## Vercel Flags SDK Adapter + +Already using the [Vercel Flags SDK](https://flags-sdk.dev) (`flags/next`)? Back its `flag()` declarations with Beyond KV via the adapter — you keep the SDK's `flag()`, toolbar overrides, precompute, and discovery endpoint, and Beyond KV supplies the values (same defs, rules, rollout, and user prefs as the native API). + +```sh +npm install @beyond.dev/flags @beyond.dev/kv flags +``` + +```ts +import { beyondAdapter } from "@beyond.dev/flags/adapter"; +import { createKvClient } from "@beyond.dev/kv"; +import { flag } from "flags/next"; + +const kv = createKvClient({ url: process.env.BEYOND_KV_URL }); +const adapter = beyondAdapter(kv); + +export const newCheckout = flag({ + key: "new-checkout", + defaultValue: false, + adapter, + // identify supplies the entities passed to evaluation; `id` is the bucket key. + identify: ({ headers }) => ({ + id: headers.get("x-user-id") ?? "anon", + plan: "free", + }), +}); + +const enabled = await newCheckout(); // resolved by Beyond KV through the host +``` + +**Read modes** — `beyondAdapter(kv, { mode })`: + +- `"snapshot"` (default) keeps an in-memory snapshot synced via KV watch/poll — zero round-trips per evaluation. Best for long-lived Node servers. +- `"request"` fetches defs per request (cached by request headers). Best for short-lived edge/serverless functions that can't hold a persistent watch. + +**Batching** — share one `adapter` instance and one `identify` reference across flags, and the host's `evaluate([...])` resolves them in a single `bulkDecide` call. + +**Toolbar discovery** — wire `adapter.getProviderData()` into the SDK's `createFlagsDiscoveryEndpoint`, merging with the host's `getProviderData(flags)` via `mergeProviderData` for code-side metadata. + +> Note: per-user writes (`.set()`/`.reset()`) and `.when()` overrides are native-only — the adapter still _reads_ user prefs, but the Vercel toolbar replaces code overrides. Use the native `createFlags` API (above) if you want those. + ## Flag Definitions Store flag definitions in KV under `flags:def:`. Definitions control behavior without deploys. @@ -180,7 +221,7 @@ const flags = createFlags(kv, { | Option | Type | Default | Description | | ------------ | ---------------------------------- | ------- | ------------------------------------------------------------- | -| `watch` | `boolean` | `false` | Stream flag definition updates via KV watch | -| `refresh` | `number` | — | Poll interval in seconds (fallback when watch is unavailable) | +| `watch` | `boolean` | `true` | Stream flag definition updates via KV watch | +| `refresh` | `number` | `30` | Poll interval in seconds (fallback when watch is unavailable) | | `onEvaluate` | `(event: FlagEvent) => void` | — | Called after each evaluation | | `onError` | `(event: FlagsErrorEvent) => void` | — | Called on KV or snapshot errors | diff --git a/sdk/ts/flags/package.json b/sdk/ts/flags/package.json index d266cf4..000ab6e 100644 --- a/sdk/ts/flags/package.json +++ b/sdk/ts/flags/package.json @@ -17,6 +17,10 @@ "import": "./dist/index.mjs", "types": "./dist/index.d.mts" }, + "./adapter": { + "import": "./dist/adapter.mjs", + "types": "./dist/adapter.d.mts" + }, "./hono": { "import": "./dist/middleware/hono.mjs", "types": "./dist/middleware/hono.d.mts" @@ -36,6 +40,14 @@ "./next/middleware": { "import": "./dist/middleware/next-middleware.mjs", "types": "./dist/middleware/next-middleware.d.mts" + }, + "./openfeature/server": { + "import": "./dist/openfeature/server.mjs", + "types": "./dist/openfeature/server.d.mts" + }, + "./openfeature/web": { + "import": "./dist/openfeature/web.mjs", + "types": "./dist/openfeature/web.d.mts" } }, "scripts": { @@ -50,13 +62,29 @@ "tslib": "^2.8.1" }, "peerDependencies": { + "@openfeature/core": ">=1.11", + "@openfeature/server-sdk": ">=1.22", + "@openfeature/web-sdk": ">=1.9", "express": ">=4", "fastify": ">=4", "fastify-plugin": ">=4", + "flags": ">=4", "hono": ">=4", "next": ">=14" }, "peerDependenciesMeta": { + "@openfeature/core": { + "optional": true + }, + "@openfeature/server-sdk": { + "optional": true + }, + "@openfeature/web-sdk": { + "optional": true + }, + "flags": { + "optional": true + }, "express": { "optional": true }, @@ -74,11 +102,15 @@ } }, "devDependencies": { + "@openfeature/core": "^1.11.0", + "@openfeature/server-sdk": "^1.22.0", + "@openfeature/web-sdk": "^1.9.0", "@types/express": "^5.0.0", "@types/node": "^22.0.0", "express": "^5.0.0", "fastify": "^5.0.0", "fastify-plugin": "^5.0.0", + "flags": "^4.2.0", "hono": "^4.0.0", "next": "^15.0.0", "tsdown": "^0.21.10", diff --git a/sdk/ts/flags/src/__tests__/adapter.test.ts b/sdk/ts/flags/src/__tests__/adapter.test.ts new file mode 100644 index 0000000..c5c8c40 --- /dev/null +++ b/sdk/ts/flags/src/__tests__/adapter.test.ts @@ -0,0 +1,310 @@ +import type { KvClient } from "@beyond.dev/kv"; +import type { ReadonlyHeaders, ReadonlyRequestCookies } from "flags"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { type BeyondAdapter, beyondAdapter } from "../adapter.js"; +import type { FlagContext } from "../types.js"; +import { kvClient, writeDef } from "./harness.js"; +import "./test-context.js"; + +// NOTE: the test KV server addresses namespaces by a small db index, so the +// harness's `uniqueNs()` does NOT isolate keys across tests — every client +// shares one keyspace (see http.ts `nsToIndex`). Tests therefore use unique +// flag keys and ids per case to avoid cross-test pollution, matching the +// pattern in integration.test.ts. +const uid = () => crypto.randomUUID(); + +// The adapter only uses `headers` as a per-request WeakMap key and never reads +// `cookies`, so plain objects suffice as stand-ins for the sealed Next types. +function reqHeaders(): ReadonlyHeaders { + return new Headers() as unknown as ReadonlyHeaders; +} +const cookies = {} as unknown as ReadonlyRequestCookies; + +async function writePrefs( + kv: KvClient, + id: string, + prefs: Record, +): Promise { + const { error } = await kv.set(`flags:user:${id}`, JSON.stringify(prefs)); + if (error) throw error; +} + +// Run the whole suite against both read strategies. snapshot mode loads defs at +// construction, so each test seeds KV first and *then* builds the adapter via +// `build()` (whose first `decide` awaits the initial load). request mode reads +// on demand, so it works the same way. +for (const mode of ["snapshot", "request"] as const) { + describe(`beyondAdapter — decide (${mode} mode)`, () => { + let kv: KvClient; + let adapter: BeyondAdapter; + + const build = (opts: { userPrefs?: boolean } = {}) => { + adapter = beyondAdapter(kv, { + mode, + watch: false, + refresh: 1, + ...opts, + }); + return adapter; + }; + + beforeEach(() => { + kv = kvClient(); + }); + + afterEach(async () => { + await adapter?.close(); + }); + + const decide = ( + key: string, + entities: FlagContext | undefined, + defaultValue: boolean, + ) => + adapter.decide({ + key, + ...(entities !== undefined ? { entities } : {}), + headers: reqHeaders(), + cookies, + defaultValue, + }); + + it("returns the rollout value for a 100% flag", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + build(); + expect(await decide(key, { id: uid() }, false)).toBe(true); + }); + + it("kill switch (on:false) returns the declared default", async () => { + const key = uid(); + await writeDef(kv, key, { + on: false, + rollout: { percent: 100 }, + rules: [{ when: {}, value: true }], + }); + build(); + expect(await decide(key, { id: uid() }, false)).toBe(false); + }); + + it("0% rollout returns the default", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + build(); + expect(await decide(key, { id: uid() }, false)).toBe(false); + }); + + it("targeting rule matches on an augmented context field", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + build(); + expect(await decide(key, { id: uid(), plan: "pro" }, false)).toBe(true); + expect(await decide(key, { id: uid(), plan: "free" }, false)).toBe(false); + }); + + it("user pref overrides rules/rollout", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + await writePrefs(kv, id, { [key]: true }); + build(); + expect(await decide(key, { id }, false)).toBe(true); + // A different id without the pref still falls through to default. + expect(await decide(key, { id: uid() }, false)).toBe(false); + }); + + it("userPrefs:false ignores stored prefs", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + await writePrefs(kv, id, { [key]: true }); + build({ userPrefs: false }); + expect(await decide(key, { id }, false)).toBe(false); + }); + + it("unknown flag returns the default", async () => { + build(); + expect(await decide(uid(), { id: uid() }, false)).toBe(false); + }); + + it("missing id returns the default (cannot bucket)", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + build(); + expect(await decide(key, undefined, false)).toBe(false); + expect(await decide(key, { id: "" }, false)).toBe(false); + }); + + it("rollout bucketing is deterministic for the same id", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 50 } }); + build(); + const a = await decide(key, { id }, false); + const b = await decide(key, { id }, false); + expect(a).toBe(b); + }); + }); + + describe(`beyondAdapter — bulkDecide (${mode} mode)`, () => { + let kv: KvClient; + let adapter: BeyondAdapter; + + beforeEach(() => { + kv = kvClient(); + }); + afterEach(async () => { + await adapter?.close(); + }); + + it("resolves many flags in one call", async () => { + const a = uid(); + const b = uid(); + const c = uid(); + const missing = uid(); + await writeDef(kv, a, { on: true, rollout: { percent: 100 } }); + await writeDef(kv, b, { on: false }); + await writeDef(kv, c, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + adapter = beyondAdapter(kv, { mode, watch: false, refresh: 1 }); + const out = await adapter.bulkDecide?.({ + flags: [ + { key: a, defaultValue: false }, + { key: b, defaultValue: false }, + { key: c, defaultValue: false }, + { key: missing, defaultValue: false }, + ], + entities: { id: uid(), plan: "pro" }, + headers: reqHeaders(), + cookies, + }); + expect(out).toEqual({ + [a]: true, + [b]: false, + [c]: true, + [missing]: false, + }); + }); + + it("missing id falls every flag back to its default", async () => { + const a = uid(); + const b = uid(); + adapter = beyondAdapter(kv, { mode, watch: false, refresh: 1 }); + const out = await adapter.bulkDecide?.({ + flags: [ + { key: a, defaultValue: false }, + { key: b, defaultValue: true }, + ], + headers: reqHeaders(), + cookies, + }); + expect(out).toEqual({ [a]: false, [b]: true }); + }); + }); +} + +describe("beyondAdapter — request mode caching", () => { + it("reads each def at most once per request (shared headers)", async () => { + const kv = kvClient(); + const a = uid(); + const b = uid(); + const id = uid(); + await writeDef(kv, a, { on: true, rollout: { percent: 100 } }); + await writeDef(kv, b, { on: true, rollout: { percent: 100 } }); + const getSpy = vi.spyOn(kv, "get"); + const batchSpy = vi.spyOn(kv, "batchGet"); + const adapter = beyondAdapter(kv, { mode: "request" }); + try { + const headers = reqHeaders(); + // Evaluate the same two flags twice within one request. + for (let i = 0; i < 2; i++) { + for (const key of [a, b]) { + await adapter.decide({ + key, + entities: { id }, + headers, + cookies, + defaultValue: false, + }); + } + } + // Two distinct def keys → at most two def reads total (get or batchGet), + // plus one prefs read for the id. Re-evaluations hit the per-request cache. + const defReads = getSpy.mock.calls.filter((c) => + String(c[0]).startsWith("flags:def:") + ) + .length + batchSpy.mock.calls.length; + const prefReads = getSpy.mock.calls.filter((c) => + String(c[0]).startsWith("flags:user:") + ).length; + expect(defReads).toBe(2); + expect(prefReads).toBe(1); + } finally { + await adapter.close(); + } + }); +}); + +describe("beyondAdapter — getProviderData", () => { + it("lists declared flags from KV", async () => { + const kv = kvClient(); + const alpha = uid(); + const beta = uid(); + await writeDef(kv, alpha, { on: true }); + await writeDef(kv, beta, { on: false }); + const adapter = beyondAdapter(kv, { mode: "request" }); + try { + const data = await adapter.getProviderData(); + // Shared keyspace: assert our defs are present (subset), not exact set. + expect(data.definitions[alpha]).toBeDefined(); + expect(data.definitions[beta]).toBeDefined(); + expect(data.definitions[alpha]?.declaredInCode).toBe(false); + expect(data.hints).toEqual([]); + } finally { + await adapter.close(); + } + }); + + it("returns a hint instead of throwing on KV failure", async () => { + const kv = kvClient(); + vi.spyOn(kv, "list").mockResolvedValue({ + data: undefined, + // biome-ignore lint/suspicious/noExplicitAny: minimal error stub + error: { message: "boom" } as any, + }); + const adapter = beyondAdapter(kv, { mode: "request" }); + try { + const data = await adapter.getProviderData(); + expect(data.definitions).toEqual({}); + expect(data.hints).toHaveLength(1); + expect(data.hints[0]?.key).toBe("beyond-kv"); + } finally { + await adapter.close(); + } + }); +}); + +describe("beyondAdapter — adapter identity", () => { + it("exposes a stable adapterId and origin resolver", async () => { + const kv = kvClient(); + const adapter = beyondAdapter(kv, { + mode: "request", + origin: (key) => `https://flags.example/${key}`, + }); + try { + expect(typeof adapter.adapterId).toBe("symbol"); + const origin = adapter.origin; + const resolved = typeof origin === "function" + ? origin("my-flag") + : origin; + expect(resolved).toBe("https://flags.example/my-flag"); + } finally { + await adapter.close(); + } + }); +}); diff --git a/sdk/ts/flags/src/__tests__/e2e-flags-sdk-surface.test.ts b/sdk/ts/flags/src/__tests__/e2e-flags-sdk-surface.test.ts new file mode 100644 index 0000000..d6c02ab --- /dev/null +++ b/sdk/ts/flags/src/__tests__/e2e-flags-sdk-surface.test.ts @@ -0,0 +1,256 @@ +/** + * Second e2e tranche: broader Vercel Flags SDK surface, all through the REAL + * host (`flags@4.2.0`). Complements e2e-flags-sdk.test.ts (core decision path) + * by covering the integration surfaces a "compatible" adapter must coexist with: + * + * 1. non-boolean values (string variants, JSON/object flags) + * 2. precompute round-trip (evaluate → serialize → getPrecomputed / flag(code)) + * 3. Vercel Toolbar override cookie (must win over KV, must skip our decide) + * 4. discovery endpoint + mergeProviderData (auth via FLAGS_SECRET access proof) + * + * As before we never call the adapter directly — the host does. Keys/ids are + * per-test UUIDs because the test KV server shares one keyspace (see + * http.ts `nsToIndex`). + */ +import type { KvClient } from "@beyond.dev/kv"; +import { createAccessProof, encryptOverrides, mergeProviderData } from "flags"; +import { + createFlagsDiscoveryEndpoint, + evaluate, + flag, + getPrecomputed, + getProviderData, + serialize, +} from "flags/next"; +import { randomBytes } from "node:crypto"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { beyondAdapter } from "../adapter.js"; +import type { FlagContext } from "../types.js"; +import { kvClient, writeDef } from "./harness.js"; +import "./test-context.js"; + +const uid = () => crypto.randomUUID(); + +// The host signs/encrypts with a base64url-encoded 256-bit key. +const SECRET = randomBytes(32).toString("base64url"); + +// biome-ignore lint/suspicious/noExplicitAny: minimal headless request stub +function request(headers: Record = {}): any { + return { headers }; +} + +describe("e2e surface: real flags/next host → beyond adapter → real KV", () => { + let kv: KvClient; + let prevSecret: string | undefined; + + beforeAll(() => { + // The override-cookie and precompute paths read process.env.FLAGS_SECRET as + // a default; set it for the duration of this file. + prevSecret = process.env["FLAGS_SECRET"]; + process.env["FLAGS_SECRET"] = SECRET; + }); + + afterAll(() => { + if (prevSecret === undefined) delete process.env["FLAGS_SECRET"]; + else process.env["FLAGS_SECRET"] = prevSecret; + }); + + beforeEach(() => { + kv = kvClient(); + }); + + // ── 1. Non-boolean values ──────────────────────────────────────────────── + + it("resolves a string-variant flag end to end", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: "v2" }], + rollout: { percent: 100, value: "v1" }, + }); + const adapter = beyondAdapter(kv, { mode: "request" }); + const variant = flag({ + key, + defaultValue: "off", + options: ["off", "v1", "v2"], + adapter, + identify: ({ headers }) => ({ + id: headers.get("x-user-id") ?? "anon", + plan: (headers.get("x-plan") as FlagContext["plan"]) ?? "free", + }), + }); + try { + // pro → rule "v2"; free → rollout "v1". + expect(await variant(request({ "x-user-id": "u", "x-plan": "pro" }))) + .toBe( + "v2", + ); + expect( + await variant(request({ "x-user-id": "u", "x-plan": "free" })), + ).toBe("v1"); + } finally { + await adapter.close(); + } + }); + + it("resolves a JSON/object flag end to end", async () => { + type Config = { theme: string; max: number }; + const key = uid(); + const def = { theme: "dark", max: 5 }; + await writeDef(kv, key, { + on: true, + rollout: { percent: 100, value: def }, + }); + const adapter = beyondAdapter(kv, { mode: "request" }); + const config = flag({ + key, + defaultValue: { theme: "light", max: 1 }, + adapter, + identify: () => ({ id: "u" }), + }); + try { + expect(await config(request())).toEqual({ theme: "dark", max: 5 }); + // Kill switch → declared default object. + await writeDef(kv, key, { on: false }); + expect(await config(request())).toEqual({ theme: "light", max: 1 }); + } finally { + await adapter.close(); + } + }); + + // ── 2. Precompute round-trip ───────────────────────────────────────────── + + it("precompute round-trip: evaluate → serialize → getPrecomputed / flag(code)", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rollout: { percent: 100, value: "v2" }, + }); + const adapter = beyondAdapter(kv, { mode: "request" }); + const variant = flag({ + key, + defaultValue: "off", + options: ["off", "v1", "v2"], + adapter, + identify: () => ({ id: "u" }), + }); + try { + // Evaluate live (headless, with a request), then serialize to a signed code. + const values = await evaluate([variant], request()); + expect(values).toEqual(["v2"]); + const code = await serialize([variant], values, SECRET); + expect(typeof code).toBe("string"); + + // Read the value back from the code two ways — both are decode-only + // (no KV, no request), proving the precompute contract holds. + expect(await getPrecomputed(variant, [variant], code, SECRET)).toBe("v2"); + // The flag's precomputed call shape: flag(code, group, secret). + // biome-ignore lint/suspicious/noExplicitAny: precomputed call shape + expect(await (variant as any)(code, [variant], SECRET)).toBe("v2"); + } finally { + await adapter.close(); + } + }); + + // ── 3. Toolbar override cookie ─────────────────────────────────────────── + + it("override cookie wins over KV and skips our decide", async () => { + const key = uid(); + // KV says true (100% rollout); the override will force false. + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + const adapter = beyondAdapter(kv, { mode: "request" }); + const decideSpy = vi.spyOn(adapter, "decide"); + const f = flag({ + key, + defaultValue: false, + adapter, + identify: () => ({ id: "u" }), + }); + try { + // Sanity: without an override, KV drives it true. + expect(await f(request())).toBe(true); + expect(decideSpy).toHaveBeenCalledTimes(1); + + // With the toolbar override cookie, the host short-circuits to the + // override value and never calls decide for that flag. + decideSpy.mockClear(); + const cookie = await encryptOverrides({ [key]: false }, SECRET); + expect( + await f(request({ cookie: `vercel-flag-overrides=${cookie}` })), + ).toBe(false); + expect(decideSpy).not.toHaveBeenCalled(); + } finally { + await adapter.close(); + } + }); + + // ── 4. Discovery endpoint + mergeProviderData ──────────────────────────── + + it("discovery endpoint serves merged provider data and enforces auth", async () => { + const kvKey = uid(); + const codeKey = uid(); + await writeDef(kv, kvKey, { on: true }); + const adapter = beyondAdapter(kv, { + mode: "request", + origin: (k) => `https://flags.example/${k}`, + }); + // A flag declared in code (description lives here, not in KV). + const declared = flag({ + key: codeKey, + defaultValue: false, + description: "Declared in code", + adapter, + identify: () => ({ id: "u" }), + }); + + const endpoint = createFlagsDiscoveryEndpoint( + async () => + mergeProviderData([ + // biome-ignore lint/suspicious/noExplicitAny: host typing friction under exactOptionalPropertyTypes + getProviderData({ [codeKey]: declared } as any), + adapter.getProviderData(), + ]), + { secret: SECRET }, + ); + + try { + // Authorized request → 200 with merged definitions + sdk-version header. + const proof = await createAccessProof(SECRET); + const ok = await endpoint( + new Request("https://test/.well-known/vercel/flags", { + headers: { Authorization: `Bearer ${proof}` }, + // biome-ignore lint/suspicious/noExplicitAny: NextRequest stand-in; endpoint only reads headers + }) as any, + ); + expect(ok.status).toBe(200); + expect(ok.headers.get("x-flags-sdk-version")).toBeTruthy(); + const body = (await ok.json()) as { + definitions: Record; + }; + // Code-declared metadata survives the merge… + expect(body.definitions[codeKey]?.description).toBe("Declared in code"); + // …and the KV-sourced flag (with adapter origin) is present too. + expect(body.definitions[kvKey]).toBeDefined(); + expect(body.definitions[kvKey]?.origin).toBe( + `https://flags.example/${kvKey}`, + ); + + // Unauthorized request → 401. + const denied = await endpoint( + // biome-ignore lint/suspicious/noExplicitAny: NextRequest stand-in + new Request("https://test/.well-known/vercel/flags") as any, + ); + expect(denied.status).toBe(401); + } finally { + await adapter.close(); + } + }); +}); diff --git a/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts b/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts new file mode 100644 index 0000000..2b48c48 --- /dev/null +++ b/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts @@ -0,0 +1,233 @@ +/** + * End-to-end proof that `@beyond.dev/flags/adapter` is a real Vercel Flags SDK + * adapter. + * + * These tests import the REAL host SDK (`flag`, `evaluate`, `getProviderData` + * from `flags/next` v4) and drive evaluation through it. We never call + * `adapter.decide`/`adapter.bulkDecide` ourselves — the host does, exactly as it + * would inside a Next.js request. The full chain is exercised: + * + * real flag() → host identify/dedupe/override → OUR adapter.decide → real beyond-kv + * + * The host's Pages-Router call shape `flag(request)` reads request data from the + * passed object instead of `next/headers`, so it runs headless under vitest + * (confirmed against flags@4.2.0 dist/next.js: `if ("headers" in args[0])`). + * + * Assertions are toggle-based: flipping the def in KV flips the value the HOST + * returns. That can only pass if the entire chain works. + */ +import type { KvClient } from "@beyond.dev/kv"; +import { evaluate, flag, getProviderData } from "flags/next"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beyondAdapter } from "../adapter.js"; +import type { FlagContext } from "../types.js"; +import { kvClient, writeDef } from "./harness.js"; +import "./test-context.js"; + +// The test KV server shares one keyspace (see http.ts `nsToIndex`), so use +// per-test UUIDs for flag keys and ids to avoid cross-test/cross-file collisions. +const uid = () => crypto.randomUUID(); + +/** + * Minimal Pages-Router request the host accepts (`"headers" in request`). The + * host only reads `.headers`, so a bare object suffices; typed `any` because it + * stands in for both `PagesRouterRequest` (flag) and `EvaluateRequest` + * (evaluate). + */ +// biome-ignore lint/suspicious/noExplicitAny: minimal headless request stub +function request(headers: Record = {}): any { + return { headers }; +} + +describe("e2e: real flags/next host → beyond adapter → real KV", () => { + let kv: KvClient; + + beforeEach(() => { + kv = kvClient(); + }); + + afterEach(async () => { + // adapters are created per-test; nothing global to tear down here. + }); + + it("host-returned value tracks live KV state (the irrefutable toggle)", async () => { + const key = uid(); + const adapter = beyondAdapter(kv, { mode: "request" }); + const newCheckout = flag({ + key, + defaultValue: false, + adapter, + identify: () => ({ id: uid() }), + }); + try { + // No def yet → host applies the declared defaultValue. + expect(await newCheckout(request())).toBe(false); + + // Turn it on at 100% → host now returns true (value came from KV via decide). + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + expect(await newCheckout(request())).toBe(true); + + // Kill switch → back to default. + await writeDef(kv, key, { on: false }); + expect(await newCheckout(request())).toBe(false); + + // Re-enable → true again. The flips prove decide reads live KV each call. + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + expect(await newCheckout(request())).toBe(true); + } finally { + await adapter.close(); + } + }); + + it("identify → entities → KV targeting rule, end to end", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + const adapter = beyondAdapter(kv, { mode: "request" }); + + // identify pulls the plan from the request headers the host sealed for us. + const aiSearch = flag({ + key, + defaultValue: false, + adapter, + identify: ({ headers }) => ({ + id: headers.get("x-user-id") ?? "anon", + plan: (headers.get("x-plan") as FlagContext["plan"]) ?? "free", + }), + }); + try { + expect(await aiSearch(request({ "x-user-id": "u1", "x-plan": "pro" }))) + .toBe( + true, + ); + expect( + await aiSearch(request({ "x-user-id": "u2", "x-plan": "free" })), + ).toBe(false); + } finally { + await adapter.close(); + } + }); + + it("per-user pref resolves end to end", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set( + `flags:user:${id}`, + JSON.stringify({ [key]: true }), + ); + if (error) throw error; + + const adapter = beyondAdapter(kv, { mode: "request" }); + const beta = flag({ + key, + defaultValue: false, + adapter, + identify: ({ headers }) => ({ id: headers.get("x-user-id") ?? "anon" }), + }); + try { + expect(await beta(request({ "x-user-id": id }))).toBe(true); // pref + expect(await beta(request({ "x-user-id": uid() }))).toBe(false); // 0% rollout + } finally { + await adapter.close(); + } + }); + + it("snapshot mode resolves through the host too", async () => { + // Write before creating the adapter; the first decide awaits initial load. + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + const adapter = beyondAdapter(kv, { + mode: "snapshot", + watch: false, + }); + const snap = flag({ + key, + defaultValue: false, + adapter, + identify: () => ({ id: uid() }), + }); + try { + expect(await snap(request())).toBe(true); + } finally { + await adapter.close(); + } + }); + + it("host evaluate() batches through our bulkDecide", async () => { + const ka = uid(); + const kb = uid(); + const kc = uid(); + await writeDef(kv, ka, { on: true, rollout: { percent: 100 } }); + await writeDef(kv, kb, { on: false }); + await writeDef(kv, kc, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + const adapter = beyondAdapter(kv, { mode: "request" }); + const bulkSpy = vi.spyOn(adapter, "bulkDecide"); + + // The host groups flags by (adapterId, identify reference). Sharing ONE + // adapter instance AND one identify function collapses all three into a + // single bulk group → a single bulkDecide call. + const id = uid(); + const identify = (): FlagContext => ({ id, plan: "pro" }); + const mk = (key: string) => + flag({ + key, + defaultValue: false, + adapter, + identify, + }); + const a = mk(ka); + const b = mk(kb); + const c = mk(kc); + try { + const result = await evaluate([a, b, c], request()); + expect(result).toEqual([true, false, true]); + // Proves the host routed through bulkDecide, not per-flag decide. + expect(bulkSpy).toHaveBeenCalledTimes(1); + expect(bulkSpy.mock.calls[0]?.[0].flags.map((f) => f.key)).toEqual([ + ka, + kb, + kc, + ]); + } finally { + await adapter.close(); + } + }); + + it("getProviderData merges adapter (KV) + host (code) definitions", async () => { + const key = uid(); + await writeDef(kv, key, { on: true }); + const adapter = beyondAdapter(kv, { + mode: "request", + origin: (k) => `https://flags.example/${k}`, + }); + const shipped = flag({ + key, + defaultValue: false, + description: "A shipped feature", + adapter, + identify: () => ({ id: uid() }), + }); + try { + // Host builds code-side definitions (description/defaultValue/options). + // biome-ignore lint/suspicious/noExplicitAny: host typing friction under exactOptionalPropertyTypes + const codeData = getProviderData({ shipped } as any); + // Adapter builds provider-side definitions (what exists in KV + origin). + const kvData = await adapter.getProviderData(); + + expect(codeData.definitions[key]?.description).toBe("A shipped feature"); + expect(kvData.definitions[key]?.declaredInCode).toBe(false); + expect(kvData.definitions[key]?.origin).toBe( + `https://flags.example/${key}`, + ); + expect(kvData.hints).toEqual([]); + } finally { + await adapter.close(); + } + }); +}); diff --git a/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts b/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts new file mode 100644 index 0000000..e2578d8 --- /dev/null +++ b/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts @@ -0,0 +1,379 @@ +/** + * End-to-end proof that `@beyond.dev/flags/openfeature/server` is a real + * OpenFeature provider. + * + * These tests import the REAL host SDK (`OpenFeature` from + * `@openfeature/server-sdk`) and drive evaluation through it — we register the + * provider with `setProviderAndWait`, get a real `Client`, and call + * `getBooleanValue`/`getBooleanDetails` exactly as an application would. We never + * call the provider's `resolve*` methods ourselves; the SDK does. The full chain + * is exercised: + * + * OpenFeature.getClient().getBooleanValue() + * → host hooks/context merge → OUR BeyondProvider.resolve* → real beyond-kv + * + * Assertions are toggle-based: flipping the def in live KV flips the value the + * HOST returns. That can only pass if the entire chain works. Flag names are + * uuid-suffixed because the test KV keyspace is shared across tests. + */ +import { createKvClient, type KvClient } from "@beyond.dev/kv"; +import { OpenFeature, ProviderEvents } from "@openfeature/server-sdk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BeyondProvider } from "../openfeature/server.js"; +import type { FlagsErrorEvent } from "../types.js"; +import { deleteDef, kvClient, sleep, writeDef } from "./harness.js"; +import "./test-context.js"; + +const uid = () => crypto.randomUUID(); + +/** + * Poll a predicate until it holds, or fail after `ms`. Waiting on observable + * state (not a single event within a fixed window) keeps these e2e tests robust + * when the shared test keyspace is large and a snapshot reload or event dispatch + * is briefly slow. + */ +async function waitUntil( + pred: () => boolean | Promise, + what: string, + ms = 20_000, +): Promise { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (await pred()) return; + await sleep(50); + } + throw new Error(`condition not met within ${ms}ms: ${what}`); +} + +/** Accumulate the flag names reported via PROVIDER_CONFIGURATION_CHANGED. */ +// biome-ignore lint/suspicious/noExplicitAny: server Client type is verbose here +function collectChanges(client: any): () => string[] { + const seen: string[] = []; + client.addHandler( + ProviderEvents.ConfigurationChanged, + (e: { flagsChanged?: string[] }) => { + seen.push(...(e?.flagsChanged ?? [])); + }, + ); + return () => seen; +} + +describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () => { + let kv: KvClient; + + beforeEach(() => { + kv = kvClient(); + }); + + afterEach(async () => { + // Closes and detaches all registered providers (calls onClose()). + await OpenFeature.clearProviders(); + }); + + it("host-returned value tracks live KV state (the irrefutable toggle)", async () => { + const key = uid(); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); + const client = OpenFeature.getClient(); + const eval_ = () => + client.getBooleanValue(key, false, { targetingKey: "u1" }); + + // No def yet → declared default. + expect(await eval_()).toBe(false); + + // Turn it on at 100% → host now returns true. + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + expect(await eval_()).toBe(true); + + // Kill switch → back to default. + await writeDef(kv, key, { on: false }); + expect(await eval_()).toBe(false); + + // Re-enable → true again. The flips prove resolve reads live KV each call. + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + expect(await eval_()).toBe(true); + }); + + it("targeting rule resolves through the host by context", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); + const client = OpenFeature.getClient(); + + expect( + await client.getBooleanValue(key, false, { + targetingKey: "u1", + plan: "pro", + }), + ).toBe(true); + expect( + await client.getBooleanValue(key, false, { + targetingKey: "u2", + plan: "free", + }), + ).toBe(false); + }); + + it("per-user pref resolves through the host", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set( + `flags:user:${id}`, + JSON.stringify({ [key]: true }), + ); + if (error) throw error; + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); + const client = OpenFeature.getClient(); + + expect(await client.getBooleanValue(key, false, { targetingKey: id })).toBe( + true, + ); + expect(await client.getBooleanValue(key, false, { targetingKey: uid() })) + .toBe(false); + }); + + it("getBooleanDetails surfaces reason and flagMetadata through the host", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); + const client = OpenFeature.getClient(); + + const details = await client.getBooleanDetails(key, false, { + targetingKey: "u1", + plan: "pro", + }); + expect(details.value).toBe(true); + expect(details.reason).toBe("TARGETING_MATCH"); + expect(details.flagMetadata?.ruleIndex).toBe(0); + expect(details.flagMetadata?.beyondReason).toBe("rule"); + }); + + it("type mismatch surfaces TYPE_MISMATCH and the default through the host", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rollout: { percent: 100, value: "not-a-bool" }, + }); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); + const client = OpenFeature.getClient(); + + const details = await client.getBooleanDetails(key, false, { + targetingKey: "u1", + }); + expect(details.value).toBe(false); + expect(details.reason).toBe("ERROR"); + expect(details.errorCode).toBe("TYPE_MISMATCH"); + }); + + it("emits PROVIDER_CONFIGURATION_CHANGED through the host on a live change", async () => { + const key = uid(); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "snapshot", watch: true, refresh: 2 }), + ); + const client = OpenFeature.getClient(); + + const changes = collectChanges(client); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + + // The host now resolves the new value from the live snapshot. + await waitUntil( + async () => + (await client.getBooleanValue(key, false, { targetingKey: "u1" })) + === true, + "value→true", + ); + await waitUntil( + () => changes().includes(key), + "config-changed event", + 10_000, + ); + }); + + it("resolves string, number, and object flags through the host", async () => { + const sKey = uid(); + const nKey = uid(); + const oKey = uid(); + await writeDef(kv, sKey, { + on: true, + rollout: { percent: 100, value: "dark" }, + }); + await writeDef(kv, nKey, { + on: true, + rollout: { percent: 100, value: 42 }, + }); + await writeDef(kv, oKey, { + on: true, + rollout: { percent: 100, value: { a: 1, b: ["x"] } }, + }); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); + const client = OpenFeature.getClient(); + const id = { targetingKey: "u1" }; + + expect(await client.getStringValue(sKey, "light", id)).toBe("dark"); + expect(await client.getNumberValue(nKey, 0, id)).toBe(42); + expect(await client.getObjectValue(oKey, {}, id)).toEqual({ + a: 1, + b: ["x"], + }); + + // Details path is typed end-to-end too. + const sd = await client.getStringDetails(sKey, "light", id); + expect(sd.value).toBe("dark"); + expect(sd.reason).toBe("SPLIT"); + const od = await client.getObjectDetails(oKey, {}, id); + expect(od.value).toEqual({ a: 1, b: ["x"] }); + }); + + it("type mismatch is enforced for string and object flags too", async () => { + const sKey = uid(); + const oKey = uid(); + // String flag resolving to a number, object flag resolving to a string. + await writeDef(kv, sKey, { on: true, rollout: { percent: 100, value: 7 } }); + await writeDef(kv, oKey, { + on: true, + rollout: { percent: 100, value: "scalar" }, + }); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); + const client = OpenFeature.getClient(); + const id = { targetingKey: "u1" }; + + const s = await client.getStringDetails(sKey, "fallback", id); + expect(s.value).toBe("fallback"); + expect(s.errorCode).toBe("TYPE_MISMATCH"); + + const o = await client.getObjectDetails(oKey, { ok: true }, id); + expect(o.value).toEqual({ ok: true }); + expect(o.errorCode).toBe("TYPE_MISMATCH"); + }); + + it("snapshot mode resolves through the host (def written before init)", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "snapshot", watch: false }), + ); + const client = OpenFeature.getClient(); + expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })) + .toBe(true); + }); + + it("flag deletion propagates back to the default", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + // fetch mode re-reads KV each eval, so deletion is observed immediately and + // deterministically. Watch-driven deletion is covered in snapshot.test.ts. + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); + const client = OpenFeature.getClient(); + expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })) + .toBe(true); + + await deleteDef(kv, key); + // With the def gone, the host falls back to the declared default. + expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })) + .toBe(false); + }); + + it("partial rollout is deterministic and roughly split through the host", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 50 } }); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); + const client = OpenFeature.getClient(); + + const ids = Array.from({ length: 200 }, (_, i) => `user-${i}`); + const first = await Promise.all( + ids.map((id) => client.getBooleanValue(key, false, { targetingKey: id })), + ); + const second = await Promise.all( + ids.map((id) => client.getBooleanValue(key, false, { targetingKey: id })), + ); + + expect(second).toEqual(first); // same id → same bucket, always + const trueCount = first.filter(Boolean).length; + expect(trueCount).toBeGreaterThan(60); // ~100 expected; wide bounds = no flake + expect(trueCount).toBeLessThan(140); + }); + + it("ignores per-user prefs when userPrefs is disabled", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set( + `flags:user:${id}`, + JSON.stringify({ [key]: true }), + ); + if (error) throw error; + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch", userPrefs: false }), + ); + const client = OpenFeature.getClient(); + // Pref says true, but userPrefs:false ignores it → 0% rollout = false. + expect(await client.getBooleanValue(key, false, { targetingKey: id })).toBe( + false, + ); + }); + + it("degrades to the default and reports onError on malformed KV data", async () => { + const key = uid(); + // Write invalid JSON directly (bypassing writeDef's JSON.stringify). + const { error } = await kv.set(`flags:def:${key}`, "{ not valid json"); + if (error) throw error; + const errors: FlagsErrorEvent[] = []; + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch", onError: (e) => errors.push(e) }), + ); + const client = OpenFeature.getClient(); + + // Malformed def is treated as absent → declared default is returned. + expect(await client.getBooleanValue(key, true, { targetingKey: "u1" })) + .toBe(true); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]?.source).toBe("snapshot"); + }); + + it("degrades to the default and reports onError when KV is unreachable", async () => { + const key = uid(); + // Nothing listens on port 1 → ECONNREFUSED. Bounded retries/timeout keep it fast. + const deadKv = createKvClient({ + url: "http://127.0.0.1:1", + namespace: "dead", + retries: 0, + timeout: 1_000, + }); + const errors: FlagsErrorEvent[] = []; + await OpenFeature.setProviderAndWait( + new BeyondProvider(deadKv, { + mode: "fetch", + onError: (e) => errors.push(e), + }), + ); + const client = OpenFeature.getClient(); + + expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })) + .toBe(false); + expect(errors.length).toBeGreaterThan(0); + }); +}); diff --git a/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts b/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts new file mode 100644 index 0000000..52a1f25 --- /dev/null +++ b/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts @@ -0,0 +1,273 @@ +/** + * End-to-end proof that `@beyond.dev/flags/openfeature/web` is a real OpenFeature + * web (client-side) provider. + * + * These tests import the REAL host SDK (`OpenFeature` from `@openfeature/web-sdk`) + * and drive evaluation through it: set a static context, register the provider + * with `setProviderAndWait`, get a real `Client`, and call the SYNCHRONOUS + * `getBooleanValue`/`getBooleanDetails`. The SDK calls our provider's sync + * `resolve*` against its in-memory snapshot. The full chain is exercised: + * + * OpenFeature.setContext() / getClient().getBooleanValue() + * → host static-context machinery → OUR BeyondWebProvider.resolve* (sync) + * → in-memory snapshot kept live by real beyond-kv watch + * + * Toggle-based: flipping the def in live KV (propagated via watch) flips the + * value the HOST returns. Flag names are uuid-suffixed (shared test keyspace). + */ +import type { KvClient } from "@beyond.dev/kv"; +import { OpenFeature, ProviderEvents } from "@openfeature/web-sdk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BeyondWebProvider } from "../openfeature/web.js"; +import { deleteDef, kvClient, sleep, writeDef } from "./harness.js"; +import "./test-context.js"; + +const uid = () => crypto.randomUUID(); + +describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () => { + let kv: KvClient; + + beforeEach(() => { + kv = kvClient(); + }); + + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.setContext({}); + }); + + it("host returns live value after a watched def change (the irrefutable toggle)", async () => { + const key = uid(); + await OpenFeature.setContext({ targetingKey: "u1" }); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, refresh: 2 }), + ); + const client = OpenFeature.getClient(); + + // No def yet → declared default (sync call, implicit static context). + expect(client.getBooleanValue(key, false)).toBe(false); + + const changes = collectChanges(client); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + // Wait on the observable value, not a single event within a fixed window. + await waitUntil( + () => client.getBooleanValue(key, false) === true, + "value→true", + ); + + expect(client.getBooleanValue(key, false)).toBe(true); + await waitUntil( + () => changes().includes(key), + "config-changed event", + 10_000, + ); + }); + + it("targeting rule resolves from the static context", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + await OpenFeature.setContext({ targetingKey: "u1", plan: "pro" }); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, refresh: 2 }), + ); + const client = OpenFeature.getClient(); + + expect(client.getBooleanValue(key, false)).toBe(true); + }); + + it("context change re-resolves with the new targeting context", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + await OpenFeature.setContext({ targetingKey: "u1", plan: "free" }); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, refresh: 2 }), + ); + const client = OpenFeature.getClient(); + + expect(client.getBooleanValue(key, false)).toBe(false); // free + + // Changing the global context reconciles the provider (onContextChange). + await OpenFeature.setContext({ targetingKey: "u2", plan: "pro" }); + expect(client.getBooleanValue(key, false)).toBe(true); // pro + }); + + it("per-user pref resolves for the active static context", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set( + `flags:user:${id}`, + JSON.stringify({ [key]: true }), + ); + if (error) throw error; + await OpenFeature.setContext({ targetingKey: id }); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, refresh: 2 }), + ); + const client = OpenFeature.getClient(); + + expect(client.getBooleanValue(key, false)).toBe(true); + + await OpenFeature.setContext({ targetingKey: uid() }); + expect(client.getBooleanValue(key, false)).toBe(false); // no pref → 0% rollout + }); + + it("getBooleanDetails surfaces reason + flagMetadata; type mismatch returns default", async () => { + const okKey = uid(); + const badKey = uid(); + await writeDef(kv, okKey, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + await writeDef(kv, badKey, { + on: true, + rollout: { percent: 100, value: "x" }, + }); + await OpenFeature.setContext({ targetingKey: "u1", plan: "pro" }); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, refresh: 2 }), + ); + const client = OpenFeature.getClient(); + + const ok = client.getBooleanDetails(okKey, false); + expect(ok.value).toBe(true); + expect(ok.reason).toBe("TARGETING_MATCH"); + expect(ok.flagMetadata?.ruleIndex).toBe(0); + + const bad = client.getBooleanDetails(badKey, false); + expect(bad.value).toBe(false); + expect(bad.errorCode).toBe("TYPE_MISMATCH"); + }); + + it("resolves string, number, and object flags synchronously through the host", async () => { + const sKey = uid(); + const nKey = uid(); + const oKey = uid(); + await writeDef(kv, sKey, { + on: true, + rollout: { percent: 100, value: "dark" }, + }); + await writeDef(kv, nKey, { + on: true, + rollout: { percent: 100, value: 42 }, + }); + await writeDef(kv, oKey, { + on: true, + rollout: { percent: 100, value: { a: 1, b: ["x"] } }, + }); + await OpenFeature.setContext({ targetingKey: "u1" }); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, refresh: 2 }), + ); + const client = OpenFeature.getClient(); + + expect(client.getStringValue(sKey, "light")).toBe("dark"); + expect(client.getNumberValue(nKey, 0)).toBe(42); + expect(client.getObjectValue(oKey, {})).toEqual({ a: 1, b: ["x"] }); + }); + + it("flag deletion propagates back to the default via poll reconcile", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + await OpenFeature.setContext({ targetingKey: "u1" }); + // Poll mode reconciles continuously (watch mode only polls during reconnect + // gaps), so a deletion is caught deterministically. Watch-driven deletion is + // covered in snapshot.test.ts. + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: false, refresh: 1 }), + ); + const client = OpenFeature.getClient(); + expect(client.getBooleanValue(key, false)).toBe(true); + + const changes = collectChanges(client); + await deleteDef(kv, key); + await waitUntil( + () => client.getBooleanValue(key, false) === false, + "value→false", + ); + + expect(client.getBooleanValue(key, false)).toBe(false); + await waitUntil( + () => changes().includes(key), + "del config-changed", + 10_000, + ); + }); + + it("polling fallback (watch:false) still picks up live changes", async () => { + const key = uid(); + await OpenFeature.setContext({ targetingKey: "u1" }); + // watch disabled → the snapshot polls every `refresh` seconds instead. + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: false, refresh: 1 }), + ); + const client = OpenFeature.getClient(); + expect(client.getBooleanValue(key, false)).toBe(false); + + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + // Arrives via the poll loop, not watch — wait on the value with headroom. + await waitUntil( + () => client.getBooleanValue(key, false) === true, + "poll→true", + ); + + expect(client.getBooleanValue(key, false)).toBe(true); + }); + + it("ignores per-user prefs when userPrefs is disabled", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set( + `flags:user:${id}`, + JSON.stringify({ [key]: true }), + ); + if (error) throw error; + await OpenFeature.setContext({ targetingKey: id }); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, userPrefs: false }), + ); + const client = OpenFeature.getClient(); + expect(client.getBooleanValue(key, false)).toBe(false); // pref ignored → 0% rollout + }); +}); + +/** + * Poll a predicate until it holds, or fail after `ms`. Waiting on observable + * state (rather than a single event within a fixed window) keeps these e2e tests + * robust when the shared test keyspace is large and a snapshot reload or event + * dispatch is briefly slow. + */ +async function waitUntil( + pred: () => boolean | Promise, + what: string, + ms = 20_000, +): Promise { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (await pred()) return; + await sleep(50); + } + throw new Error(`condition not met within ${ms}ms: ${what}`); +} + +/** Accumulate the flag names reported via PROVIDER_CONFIGURATION_CHANGED. */ +function collectChanges( + // biome-ignore lint/suspicious/noExplicitAny: web Client type is verbose here + client: any, +): () => string[] { + const seen: string[] = []; + client.addHandler( + ProviderEvents.ConfigurationChanged, + (e: { flagsChanged?: string[] }) => { + seen.push(...(e?.flagsChanged ?? [])); + }, + ); + return () => seen; +} diff --git a/sdk/ts/flags/src/__tests__/openfeature-server.test.ts b/sdk/ts/flags/src/__tests__/openfeature-server.test.ts new file mode 100644 index 0000000..5a790cb --- /dev/null +++ b/sdk/ts/flags/src/__tests__/openfeature-server.test.ts @@ -0,0 +1,285 @@ +/** + * Unit coverage for the OpenFeature **server** provider. Drives the provider's + * resolve methods directly (the SDK calls these) against real beyond-kv, and + * asserts the full {@link ResolutionDetails} — value, reason, errorCode, and + * flagMetadata — for every evaluation path. + * + * The test KV keyspace is shared across tests, so every flag/user name is + * uuid-suffixed to keep tests independent (mirrors `adapter.test.ts`). + */ +import type { KvClient } from "@beyond.dev/kv"; +import { + type EvaluationContext, + type Logger, + ProviderEvents, +} from "@openfeature/server-sdk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BeyondProvider } from "../openfeature/server.js"; +import { kvClient, writeDef } from "./harness.js"; +import "./test-context.js"; + +const uid = () => crypto.randomUUID(); + +const logger: Logger = { + error() {}, + warn() {}, + info() {}, + debug() {}, +}; + +function ctx(id: string, extra: EvaluationContext = {}): EvaluationContext { + return { targetingKey: id, ...extra }; +} + +describe("BeyondProvider (server) — fetch mode resolution", () => { + let kv: KvClient; + let provider: BeyondProvider; + + beforeEach(async () => { + kv = kvClient(); + provider = new BeyondProvider(kv, { mode: "fetch" }); + await provider.initialize(); + }); + + afterEach(async () => { + await provider.onClose(); + }); + + it("returns the default with reason DEFAULT when no def exists", async () => { + const res = await provider.resolveBooleanEvaluation( + uid(), + false, + ctx("u"), + logger, + ); + expect(res.value).toBe(false); + expect(res.reason).toBe("DEFAULT"); + expect(res.errorCode).toBeUndefined(); + }); + + it("resolves a 100% rollout to true with reason SPLIT", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx("u"), + logger, + ); + expect(res.value).toBe(true); + expect(res.reason).toBe("SPLIT"); + }); + + it("honors the kill switch with reason DISABLED", async () => { + const key = uid(); + await writeDef(kv, key, { on: false, rollout: { percent: 100 } }); + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx("u"), + logger, + ); + expect(res.value).toBe(false); + expect(res.reason).toBe("DISABLED"); + }); + + it("matches a targeting rule with reason TARGETING_MATCH + ruleIndex", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [ + { when: { plan: "free" }, value: false }, + { when: { plan: "pro" }, value: true }, + ], + }); + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx("u", { plan: "pro" }), + logger, + ); + expect(res.value).toBe(true); + expect(res.reason).toBe("TARGETING_MATCH"); + expect(res.flagMetadata?.ruleIndex).toBe(1); + expect(res.flagMetadata?.beyondReason).toBe("rule"); + }); + + it("applies a per-user pref with reason TARGETING_MATCH", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set( + `flags:user:${id}`, + JSON.stringify({ [key]: true }), + ); + if (error) throw error; + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx(id), + logger, + ); + expect(res.value).toBe(true); + expect(res.reason).toBe("TARGETING_MATCH"); + expect(res.flagMetadata?.beyondReason).toBe("user-pref"); + }); + + it("resolves string and number flags", async () => { + const sKey = uid(); + const nKey = uid(); + await writeDef(kv, sKey, { + on: true, + rollout: { percent: 100, value: "dark" }, + }); + await writeDef(kv, nKey, { + on: true, + rollout: { percent: 100, value: 42 }, + }); + const s = await provider.resolveStringEvaluation( + sKey, + "light", + ctx("u"), + logger, + ); + const n = await provider.resolveNumberEvaluation(nKey, 0, ctx("u"), logger); + expect(s.value).toBe("dark"); + expect(n.value).toBe(42); + }); + + it("resolves an object flag", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rollout: { percent: 100, value: { mode: "x", n: 3 } }, + }); + const res = await provider.resolveObjectEvaluation( + key, + {}, + ctx("u"), + logger, + ); + expect(res.value).toEqual({ mode: "x", n: 3 }); + }); + + it("returns TYPE_MISMATCH when the resolved value is the wrong type", async () => { + const key = uid(); + // Boolean flag whose KV value resolves to a string. + await writeDef(kv, key, { + on: true, + rollout: { percent: 100, value: "not-a-bool" }, + }); + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx("u"), + logger, + ); + expect(res.value).toBe(false); // declared default, not coerced + expect(res.reason).toBe("ERROR"); + expect(res.errorCode).toBe("TYPE_MISMATCH"); + }); + + it("matches attribute rules even without a targetingKey", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + const res = await provider.resolveBooleanEvaluation( + key, + false, + { plan: "pro" }, // no targetingKey + logger, + ); + expect(res.value).toBe(true); + expect(res.reason).toBe("TARGETING_MATCH"); + }); +}); + +describe("BeyondProvider (server) — snapshot mode", () => { + let kv: KvClient; + + beforeEach(() => { + kv = kvClient(); + }); + + it("resolves from the in-memory snapshot after initialize", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + const provider = new BeyondProvider(kv, { mode: "snapshot", watch: false }); + await provider.initialize(); + try { + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx("u"), + logger, + ); + expect(res.value).toBe(true); + } finally { + await provider.onClose(); + } + }); + + it("reports reason STALE when resolving an unknown flag before init completes", async () => { + // No def for this key, and initialize() is never called → not-ready + absent + // def deterministically yields STALE (vs DEFAULT once ready). + const provider = new BeyondProvider(kv, { mode: "snapshot", watch: false }); + try { + const res = await provider.resolveBooleanEvaluation( + uid(), + false, + ctx("u"), + logger, + ); + expect(res.value).toBe(false); // declared default + expect(res.reason).toBe("STALE"); + } finally { + await provider.onClose(); + } + }); + + it("emits PROVIDER_CONFIGURATION_CHANGED when a watched def changes", async () => { + const key = uid(); + const provider = new BeyondProvider(kv, { + mode: "snapshot", + watch: true, + refresh: 2, + }); + await provider.initialize(); + try { + const changed = new Promise((resolve) => { + provider.events.addHandler(ProviderEvents.ConfigurationChanged, (e) => { + resolve((e?.flagsChanged as string[]) ?? []); + }); + }); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + const flagsChanged = await withTimeout( + changed, + 10_000, + "ConfigurationChanged", + ); + expect(flagsChanged).toContain(key); + + // And the new value is now resolvable from the snapshot. + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx("u"), + logger, + ); + expect(res.value).toBe(true); + } finally { + await provider.onClose(); + } + }); +}); + +function withTimeout(p: Promise, ms: number, label: string): Promise { + return Promise.race([ + p, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), ms) + ), + ]); +} diff --git a/sdk/ts/flags/src/__tests__/openfeature-web.test.ts b/sdk/ts/flags/src/__tests__/openfeature-web.test.ts new file mode 100644 index 0000000..1141a0a --- /dev/null +++ b/sdk/ts/flags/src/__tests__/openfeature-web.test.ts @@ -0,0 +1,156 @@ +/** + * Unit coverage for the OpenFeature **web** provider. Web resolution is + * synchronous against an in-memory snapshot, with per-context prefs pre-fetched + * on initialize/onContextChange. Tests drive the provider methods directly. + * + * Flag/user names are uuid-suffixed (the test KV keyspace is shared). + */ +import type { KvClient } from "@beyond.dev/kv"; +import { + type EvaluationContext, + type Logger, + ProviderEvents, +} from "@openfeature/web-sdk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BeyondWebProvider } from "../openfeature/web.js"; +import { kvClient, writeDef } from "./harness.js"; +import "./test-context.js"; + +const uid = () => crypto.randomUUID(); + +const logger: Logger = { + error() {}, + warn() {}, + info() {}, + debug() {}, +}; + +function ctx(id: string, extra: EvaluationContext = {}): EvaluationContext { + return { targetingKey: id, ...extra }; +} + +describe("BeyondWebProvider — synchronous resolution", () => { + let kv: KvClient; + let provider: BeyondWebProvider; + + beforeEach(() => { + kv = kvClient(); + }); + + afterEach(async () => { + await provider?.onClose(); + }); + + async function start(context: EvaluationContext): Promise { + provider = new BeyondWebProvider(kv, { watch: true, refresh: 2 }); + await provider.initialize(context); + } + + it("returns the default with reason DEFAULT when no def exists", async () => { + await start(ctx("u")); + const res = provider.resolveBooleanEvaluation( + uid(), + false, + ctx("u"), + logger, + ); + expect(res.value).toBe(false); + expect(res.reason).toBe("DEFAULT"); + }); + + it("resolves a def loaded before initialize", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + await start(ctx("u")); + const res = provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + expect(res.value).toBe(true); + expect(res.reason).toBe("SPLIT"); + }); + + it("matches targeting rules from the static context", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + await start(ctx("u", { plan: "pro" })); + const res = provider.resolveBooleanEvaluation( + key, + false, + ctx("u", { plan: "pro" }), + logger, + ); + expect(res.value).toBe(true); + expect(res.reason).toBe("TARGETING_MATCH"); + }); + + it("applies pre-fetched per-user prefs for the active context", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set( + `flags:user:${id}`, + JSON.stringify({ [key]: true }), + ); + if (error) throw error; + await start(ctx(id)); // prefetches id's prefs + const res = provider.resolveBooleanEvaluation(key, false, ctx(id), logger); + expect(res.value).toBe(true); + expect(res.flagMetadata?.beyondReason).toBe("user-pref"); + }); + + it("re-fetches prefs on context change", async () => { + const key = uid(); + const id1 = uid(); + const id2 = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set( + `flags:user:${id1}`, + JSON.stringify({ [key]: true }), + ); + if (error) throw error; + await start(ctx(id1)); // id1 → pref true + expect( + provider.resolveBooleanEvaluation(key, false, ctx(id1), logger).value, + ).toBe(true); + + // Switch to id2 (no pref) → falls through to the 0% rollout = false. + await provider.onContextChange(ctx(id1), ctx(id2)); + expect( + provider.resolveBooleanEvaluation(key, false, ctx(id2), logger).value, + ).toBe(false); + }); + + it("returns TYPE_MISMATCH for a wrong-typed value", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rollout: { percent: 100, value: "nope" }, + }); + await start(ctx("u")); + const res = provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + expect(res.value).toBe(false); + expect(res.errorCode).toBe("TYPE_MISMATCH"); + }); + + it("emits PROVIDER_CONFIGURATION_CHANGED on a live def change", async () => { + const key = uid(); + await start(ctx("u")); + const changed = new Promise((resolve) => { + provider.events.addHandler(ProviderEvents.ConfigurationChanged, (e) => { + resolve((e?.flagsChanged as string[]) ?? []); + }); + }); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + const flagsChanged = await Promise.race([ + changed, + new Promise((_, reject) => + setTimeout(() => reject(new Error("timed out")), 10_000) + ), + ]); + expect(flagsChanged).toContain(key); + expect( + provider.resolveBooleanEvaluation(key, false, ctx("u"), logger).value, + ).toBe(true); + }); +}); diff --git a/sdk/ts/flags/src/__tests__/snapshot.test.ts b/sdk/ts/flags/src/__tests__/snapshot.test.ts index 65aa65a..b832d42 100644 --- a/sdk/ts/flags/src/__tests__/snapshot.test.ts +++ b/sdk/ts/flags/src/__tests__/snapshot.test.ts @@ -1,9 +1,11 @@ import { KvError } from "@beyond.dev/kv"; -import type { KvClient } from "@beyond.dev/kv"; +import type { KvClient, WatchEvent, WatchOptions } from "@beyond.dev/kv"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { fetchUserPrefs, Snapshot } from "../snapshot.js"; import { deleteDef, kvClient, sleep, writeDef } from "./harness.js"; +const encode = (v: unknown) => new TextEncoder().encode(JSON.stringify(v)); + describe("snapshot — real KV", () => { let kv: KvClient; let snap: Snapshot; @@ -68,6 +70,37 @@ describe("snapshot — real KV", () => { expect(snap.get("polled")).toEqual({ on: true }); }); + it("fires onChange once per real edit, never for unchanged re-reads (byte dedup)", async () => { + const name = `dedup-${crypto.randomUUID()}`; + await writeDef(kv, name, { on: true, rollout: { percent: 50 } }); + + const changes: string[][] = []; + const ours = () => changes.flat().filter((n) => n === name); + snap = new Snapshot(kv, { + refresh: 1, // poll every second + watch: false, + onChange: (names) => changes.push(names), + }); + snap.start(); + await snap.ready(); // initial load — onChange suppressed + + // Several poll cycles re-read the identical def. Same bytes → no events. + await sleep(2_200); + expect(ours()).toEqual([]); + + // A genuine edit is detected on the next poll — exactly once. + await writeDef(kv, name, { on: false }); + const deadline = Date.now() + 5_000; + while (Date.now() < deadline && ours().length === 0) { + await sleep(100); + } + expect(ours()).toEqual([name]); + + // Re-reading the now-stable value must not keep firing. + await sleep(2_200); + expect(ours()).toEqual([name]); + }); + it("ignores keys outside flags:def: prefix", async () => { const { error } = await kv.set("not-a-flag", JSON.stringify({ on: true })); expect(error).toBeUndefined(); @@ -224,6 +257,82 @@ describe("snapshot — real KV", () => { }); }); +describe("snapshot — watch resume after hard reconnect", () => { + it("resumes from the last revision and replays the delta missed while down", async () => { + const sinceSeen: (number | undefined)[] = []; + let watchCalls = 0; + + // Fake client: empty initial load; a watch that delivers one set then + // hard-errors (a dropped stream), and on reconnect replays a *different* + // delta — exactly what the server does for everything after `since`. + const fakeKv = { + async list() { + return { data: { keys: [] as { name: string }[] }, error: undefined }; + }, + async batchGet() { + return { data: [] as never[], error: undefined }; + }, + async *watch( + _key: string, + opts?: WatchOptions, + ): AsyncGenerator { + watchCalls++; + sinceSeen.push(opts?.since); + if (watchCalls === 1) { + yield { type: "ready" }; + yield { + type: "set", + key: "flags:def:a", + value: encode({ on: true }), + revision: 5, + }; + throw new KvError("sse_error", "stream dropped", 500); // hard disconnect + } + // Reconnect: replay the delta that landed while we were down. + yield { + type: "set", + key: "flags:def:b", + value: encode({ on: false }), + revision: 7, + }; + await new Promise((resolve) => { + opts?.signal?.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + }, + } as unknown as KvClient; + + const snap = new Snapshot(fakeKv, { refresh: 30, watch: true }); + snap.start(); + await snap.ready(); + + // First session applied A. + { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline && snap.get("a") === undefined) { + await sleep(25); + } + } + expect(snap.get("a")).toEqual({ on: true }); + + // After the hard error + backoff, the reconnect must carry since=5 and the + // replayed delta for B must land — proving no gap loss. + { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline && snap.get("b") === undefined) { + await sleep(25); + } + } + expect(snap.get("b")).toEqual({ on: false }); + + expect(sinceSeen[0]).toBeUndefined(); // first connect starts from current state + expect(sinceSeen[1]).toBe(5); // reconnect resumes exactly where it left off + + snap.close(); + }); +}); + describe("fetchUserPrefs — real KV", () => { let kv: KvClient; diff --git a/sdk/ts/flags/src/adapter.ts b/sdk/ts/flags/src/adapter.ts new file mode 100644 index 0000000..f04c0d2 --- /dev/null +++ b/sdk/ts/flags/src/adapter.ts @@ -0,0 +1,446 @@ +/** + * Vercel Flags SDK adapter for `@beyond.dev/flags`. + * + * Lets you declare flags with the Vercel [`flags`](https://flags-sdk.dev) SDK + * (`flag({ key, adapter })`) but resolve them against Beyond KV — the same + * `flags:def:*` defs, targeting rules, rollout, kill switch, and per-user prefs + * the native `@beyond.dev/flags` API uses. The host (`flags/next`) owns the + * request plumbing, toolbar overrides, precompute, and reporting; this module + * implements the one seam it calls into: the {@link Adapter} contract. + * + * @example + * ```ts + * // flags.ts + * import { flag } from 'flags/next' + * import { createKvClient } from '@beyond.dev/kv' + * import { beyondAdapter } from '@beyond.dev/flags/adapter' + * + * const kv = createKvClient({ url: process.env.BEYOND_KV_URL! }) + * const adapter = beyondAdapter(kv) + * + * export const newCheckout = flag({ + * key: 'new-checkout', + * defaultValue: false, + * adapter, + * identify: ({ headers }) => ({ id: headers.get('x-user-id') ?? 'anon', plan: 'free' }), + * }) + * ``` + * + * @packageDocumentation + */ + +import type { KvClient } from "@beyond.dev/kv"; +import type { + Adapter, + FlagDefinitionsType, + Identify, + Origin, + ProviderData, + ReadonlyHeaders, +} from "flags"; +import { evaluate } from "./eval.js"; +import { fetchUserPrefs } from "./snapshot.js"; +import { Snapshot } from "./snapshot.js"; +import type { + FlagContext, + FlagDef, + FlagsErrorEvent, + JsonValue, + UserPrefs, +} from "./types.js"; + +const DEF_PREFIX = "flags:def:"; + +/** Options for {@link beyondAdapter}. The KV client is positional, not here. */ +export interface BeyondAdapterOptions { + /** + * How defs are read from KV. + * + * - `"snapshot"` (default): keep an in-memory snapshot of all `flags:def:*`, + * refreshed via `kv.watch()` (or polling). `decide` does zero KV + * round-trips. Best for long-lived Node servers. + * - `"request"`: fetch each def on demand, cached per request (keyed by the + * request `headers` object). Survives short-lived edge/serverless functions + * that can't hold a persistent watch. One KV read per distinct flag/request. + */ + mode?: "snapshot" | "request"; + /** snapshot mode: SWR poll interval (seconds) when watch is unavailable. Default 30. */ + refresh?: number; + /** snapshot mode: use `kv.watch()` for instant invalidation. Default true. */ + watch?: boolean; + /** Honor per-user prefs (`flags:user:`) in `decide`. Default true. */ + userPrefs?: boolean; + /** + * Default `identify` for flags using this adapter. A per-flag `identify` in + * the `flag({...})` declaration overrides it. Must return an object with a + * non-empty `id` (the rollout bucket key). + */ + identify?: Identify; + /** Management URL for the toolbar — a string base or a `(key) => url` builder. */ + origin?: string | Origin | ((key: string) => string | Origin | undefined); + /** Called for snapshot/watch/KV/pref failures. */ + onError?: (event: FlagsErrorEvent) => void; +} + +/** + * The adapter object passed to `flag({ adapter })`, plus discovery/lifecycle + * helpers. It satisfies the Vercel {@link Adapter} interface; the extra members + * are ignored by the host. + */ +export interface BeyondAdapter< + T extends JsonValue = JsonValue, + E extends FlagContext = FlagContext, +> extends Adapter { + /** + * Build {@link ProviderData} for the toolbar discovery endpoint + * (`flags/next`'s `createFlagsDiscoveryEndpoint`). Enumerates `flags:def:*` + * from KV. Definitions are thin — only what KV stores; merge with the host's + * `getProviderData(flags)` (via `mergeProviderData`) for code-declared + * options/description/defaultValue. + */ + getProviderData(): Promise; + /** Stop background syncing (snapshot mode). Does not close the KV client. */ + close(): Promise; +} + +/** + * Per-request def reader. Two strategies behind one interface so `decide`, + * `bulkDecide`, and prefs caching are mode-agnostic. + */ +interface DefSource { + ready(): Promise; + get(key: string, headers: ReadonlyHeaders): Promise; + getMany( + keys: readonly string[], + headers: ReadonlyHeaders, + ): Promise>; + close(): void; +} + +/** snapshot mode — in-memory, kept live by {@link Snapshot}. Zero per-eval I/O. */ +class SnapshotDefSource implements DefSource { + private readonly snapshot: Snapshot; + + constructor(kv: KvClient, opts: BeyondAdapterOptions) { + this.snapshot = new Snapshot(kv, { + refresh: opts.refresh ?? 30, + watch: opts.watch ?? true, + ...(opts.onError ? { onError: opts.onError } : {}), + }); + this.snapshot.start(); + } + + ready(): Promise { + return this.snapshot.awaitReady(); + } + + async get(key: string): Promise { + return this.snapshot.get(key); + } + + async getMany( + keys: readonly string[], + ): Promise> { + const out = new Map(); + for (const key of keys) out.set(key, this.snapshot.get(key)); + return out; + } + + close(): void { + this.snapshot.close(); + } +} + +/** + * request mode — fetch defs on demand, cached per request via a + * `WeakMap`. The `headers` object is stable for the + * lifetime of one request (the host passes the same sealed instance to every + * flag), so it's a natural per-request cache key with no manual cleanup. + */ +class RequestDefSource implements DefSource { + private readonly kv: KvClient; + private readonly onError: ((event: FlagsErrorEvent) => void) | undefined; + private readonly cache = new WeakMap< + ReadonlyHeaders, + Map> + >(); + + constructor(kv: KvClient, opts: BeyondAdapterOptions) { + this.kv = kv; + this.onError = opts.onError; + } + + ready(): Promise { + return Promise.resolve(); + } + + private requestCache( + headers: ReadonlyHeaders, + ): Map> { + let map = this.cache.get(headers); + if (!map) { + map = new Map(); + this.cache.set(headers, map); + } + return map; + } + + get(key: string, headers: ReadonlyHeaders): Promise { + const map = this.requestCache(headers); + let pending = map.get(key); + if (!pending) { + pending = this.fetchOne(key); + map.set(key, pending); + } + return pending; + } + + private async fetchOne(key: string): Promise { + const { data, error } = await this.kv.get(DEF_PREFIX + key); + if (error) { + this.onError?.({ source: "snapshot", error, name: key }); + return undefined; + } + if (!data) return undefined; + return parseDef(data.text(), key, this.onError); + } + + async getMany( + keys: readonly string[], + headers: ReadonlyHeaders, + ): Promise> { + const map = this.requestCache(headers); + // Batch the keys not already cached into a single round-trip. + const missing = keys.filter((k) => !map.has(k)); + if (missing.length > 0) { + const fetched = this.fetchMany(missing); + for (const key of missing) { + map.set( + key, + fetched.then((m) => m.get(key)), + ); + } + } + const out = new Map(); + await Promise.all( + keys.map(async (key) => { + out.set(key, await (map.get(key) as Promise)); + }), + ); + return out; + } + + private async fetchMany( + keys: readonly string[], + ): Promise> { + const out = new Map(); + const { data, error } = await this.kv.batchGet( + keys.map((k) => DEF_PREFIX + k), + ); + if (error) { + this.onError?.({ source: "snapshot", error }); + for (const key of keys) out.set(key, undefined); + return out; + } + for (let i = 0; i < keys.length; i++) { + const key = keys[i] as string; + const entry = data[i]; + out.set( + key, + entry ? parseDef(entry.text(), key, this.onError) : undefined, + ); + } + return out; + } + + close(): void { + // Nothing to release — caches are per-request and GC'd with their headers. + } +} + +/** + * Parse a `flags:def:*` JSON payload into a {@link FlagDef}. Returns `undefined` + * (treated as "no def" → flag falls back to its declared default) on malformed + * input, reporting through `onError` rather than throwing into eval. + */ +function parseDef( + text: string, + key: string, + onError?: (event: FlagsErrorEvent) => void, +): FlagDef | undefined { + if (text.length === 0) return undefined; + try { + const parsed = JSON.parse(text) as unknown; + if ( + parsed === null + || typeof parsed !== "object" + || Array.isArray(parsed) + || typeof (parsed as Record)["on"] !== "boolean" + ) { + throw new Error("flag def must be a JSON object with a boolean `on`"); + } + return parsed as FlagDef; + } catch (err) { + onError?.({ + source: "snapshot", + error: err instanceof Error ? err : new Error(String(err)), + name: key, + }); + return undefined; + } +} + +/** + * Create a Beyond KV adapter for the Vercel Flags SDK, bound to a specific + * {@link KvClient}. Mirrors `createFlags(kv, opts)`. + * + * One adapter object owns one `adapterId`, so all flags sharing it are batched + * together by the host's `evaluate()` through a single `bulkDecide`. + */ +export function beyondAdapter< + T extends JsonValue = JsonValue, + E extends FlagContext = FlagContext, +>(kv: KvClient, opts: BeyondAdapterOptions = {}): BeyondAdapter { + const mode = opts.mode ?? "snapshot"; + const useUserPrefs = opts.userPrefs !== false; + const defSource: DefSource = mode === "request" + ? new RequestDefSource(kv, opts) + : new SnapshotDefSource(kv, opts); + + // Stable per-instance id so the host groups this adapter's flags for bulk eval. + const adapterId = Symbol("beyondAdapter"); + + // Per-request pref cache, keyed by the request headers object (stable per + // request) → one KV read per id regardless of how many flags evaluate. + const prefsCache = new WeakMap< + ReadonlyHeaders, + Map> + >(); + + function loadPrefs( + id: string, + headers: ReadonlyHeaders, + ): Promise { + let byId = prefsCache.get(headers); + if (!byId) { + byId = new Map(); + prefsCache.set(headers, byId); + } + let pending = byId.get(id); + if (!pending) { + pending = fetchUserPrefs(kv, id, opts.onError); + byId.set(id, pending); + } + return pending; + } + + function resolveOrigin( + key: string, + ): string | Origin | undefined { + const o = opts.origin; + if (o === undefined) return undefined; + return typeof o === "function" ? o(key) : o; + } + + const adapter: BeyondAdapter = { + adapterId, + + ...(opts.identify ? { identify: opts.identify as Identify } : {}), + ...(opts.origin ? { origin: resolveOrigin } : {}), + + async decide({ key, entities, headers, defaultValue }) { + const ctx = entities as FlagContext | undefined; + // No id → can't bucket or look up prefs. Fall back to the declared default. + if (!ctx?.id) return defaultValue as T; + await defSource.ready(); + const def = await defSource.get(key, headers); + const prefs = useUserPrefs ? await loadPrefs(ctx.id, headers) : null; + return evaluate( + key, + defaultValue as T, + ctx, + def as FlagDef | undefined, + prefs, + ).value; + }, + + async bulkDecide({ flags, entities, headers }) { + const ctx = entities as FlagContext | undefined; + const out: Record = {}; + if (!ctx?.id) { + for (const f of flags) out[f.key] = f.defaultValue as T; + return out; + } + await defSource.ready(); + const defs = await defSource.getMany( + flags.map((f) => f.key), + headers, + ); + const prefs = useUserPrefs ? await loadPrefs(ctx.id, headers) : null; + for (const f of flags) { + out[f.key] = evaluate( + f.key, + f.defaultValue as T, + ctx, + defs.get(f.key) as FlagDef | undefined, + prefs, + ).value; + } + return out; + }, + + async getProviderData(): Promise { + try { + const definitions = await listDefs(kv); + const out: FlagDefinitionsType = {}; + for (const name of definitions) { + const origin = opts.origin ? resolveOrigin(name) : undefined; + out[name] = { + declaredInCode: false, + ...(origin !== undefined ? { origin } : {}), + }; + } + return { definitions: out, hints: [] }; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + opts.onError?.({ source: "snapshot", error }); + return { + definitions: {}, + hints: [ + { + key: "beyond-kv", + text: + `Failed to load flag definitions from Beyond KV: ${error.message}`, + }, + ], + }; + } + }, + + async close(): Promise { + defSource.close(); + }, + }; + + return adapter; +} + +/** Alias matching the `createFlags`/`createKvClient` naming. */ +export const createBeyondAdapter = beyondAdapter; + +/** Enumerate all `flags:def:*` keys, returning the bare flag names. */ +async function listDefs(kv: KvClient): Promise { + const names: string[] = []; + let cursor: string | undefined; + do { + const { data, error } = await kv.list( + cursor === undefined + ? { prefix: DEF_PREFIX } + : { prefix: DEF_PREFIX, cursor }, + ); + if (error) throw error; + for (const k of data.keys) names.push(k.name.slice(DEF_PREFIX.length)); + cursor = data.nextCursor; + } while (cursor); + return names; +} diff --git a/sdk/ts/flags/src/openfeature/server.ts b/sdk/ts/flags/src/openfeature/server.ts new file mode 100644 index 0000000..8567db6 --- /dev/null +++ b/sdk/ts/flags/src/openfeature/server.ts @@ -0,0 +1,275 @@ +/** + * OpenFeature **server** provider for `@beyond.dev/flags`. + * + * Resolves OpenFeature flag evaluations against Beyond KV using the same + * `flags:def:*` defs, targeting rules, rollout, kill switch, and per-user prefs + * as the native `@beyond.dev/flags` API and the Vercel adapter — through the + * same pure {@link evaluate} engine. + * + * @example + * ```ts + * import { OpenFeature } from '@openfeature/server-sdk' + * import { createKvClient } from '@beyond.dev/kv' + * import { BeyondProvider } from '@beyond.dev/flags/openfeature/server' + * + * const kv = createKvClient({ url: process.env.BEYOND_KV_URL! }) + * await OpenFeature.setProviderAndWait(new BeyondProvider(kv)) + * + * const client = OpenFeature.getClient() + * const enabled = await client.getBooleanValue('new-checkout', false, { + * targetingKey: 'user-123', + * plan: 'pro', + * }) + * ``` + * + * @packageDocumentation + */ + +import type { KvClient } from "@beyond.dev/kv"; +import { + type EvaluationContext, + type JsonValue as OFJsonValue, + type Logger, + OpenFeatureEventEmitter, + type Provider, + ProviderEvents, + type ProviderMetadata, + type ResolutionDetails, +} from "@openfeature/server-sdk"; +import { evaluate } from "../eval.js"; +import { defKey, fetchUserPrefs, Snapshot } from "../snapshot.js"; +import type { + FlagDef, + FlagsErrorEvent, + JsonValue, + UserPrefs, +} from "../types.js"; +import { + type ExpectedType, + hasTargetingKey, + toFlagContext, + toResolution, +} from "./shared.js"; + +/** Options for {@link BeyondProvider}. The KV client is passed positionally. */ +export interface BeyondProviderOptions { + /** + * How defs are read from KV. + * + * - `"snapshot"` (default): keep an in-memory snapshot of all `flags:def:*`, + * refreshed via `kv.watch()` (or polling). Resolve does zero KV round-trips + * for defs and surfaces live changes as `PROVIDER_CONFIGURATION_CHANGED`. + * - `"fetch"`: read each def from KV on every evaluation. No background sync, + * no change events — for environments that can't hold a persistent watch. + */ + mode?: "snapshot" | "fetch"; + /** snapshot mode: SWR poll interval (seconds) when watch is unavailable. Default 30. */ + refresh?: number; + /** snapshot mode: use `kv.watch()` for instant invalidation + events. Default true. */ + watch?: boolean; + /** + * Honor per-user prefs (`flags:user:`) during resolution. Default true. + * Each resolution costs one KV read for the pref bundle; disable for zero + * per-eval I/O in snapshot mode. + */ + userPrefs?: boolean; + /** Called for snapshot/watch/KV/pref failures. */ + onError?: (event: FlagsErrorEvent) => void; +} + +/** Reads flag defs from KV — snapshot-backed or per-eval fetch. */ +interface DefReader { + ready(): Promise; + get(key: string): Promise; + close(): void; +} + +class SnapshotReader implements DefReader { + private readonly snapshot: Snapshot; + + constructor( + kv: KvClient, + opts: BeyondProviderOptions, + onChange: (names: string[]) => void, + ) { + this.snapshot = new Snapshot(kv, { + refresh: opts.refresh ?? 30, + watch: opts.watch ?? true, + onChange, + ...(opts.onError ? { onError: opts.onError } : {}), + }); + this.snapshot.start(); + } + + ready(): Promise { + return this.snapshot.awaitReady(); + } + + async get(key: string): Promise { + return this.snapshot.get(key); + } + + close(): void { + this.snapshot.close(); + } +} + +class FetchReader implements DefReader { + private readonly kv: KvClient; + private readonly onError: ((e: FlagsErrorEvent) => void) | undefined; + + constructor(kv: KvClient, opts: BeyondProviderOptions) { + this.kv = kv; + this.onError = opts.onError; + } + + ready(): Promise { + return Promise.resolve(); + } + + async get(key: string): Promise { + const { data, error } = await this.kv.get(defKey(key)); + if (error) { + this.onError?.({ source: "snapshot", error, name: key }); + return undefined; + } + if (!data) return undefined; + return parseDef(data.text(), key, this.onError); + } + + close(): void { + // Nothing to release. + } +} + +/** + * An OpenFeature {@link Provider} backed by Beyond KV. + * + * Long-lived: register once via `OpenFeature.setProviderAndWait(...)`. In the + * default snapshot mode it holds an in-memory, watch-synced copy of all defs and + * emits `PROVIDER_CONFIGURATION_CHANGED` when KV changes. + */ +export class BeyondProvider implements Provider { + readonly metadata: ProviderMetadata = { name: "beyond-kv" }; + readonly runsOn = "server"; + readonly events = new OpenFeatureEventEmitter(); + + private readonly reader: DefReader; + private readonly kv: KvClient; + private readonly useUserPrefs: boolean; + private readonly onError: ((e: FlagsErrorEvent) => void) | undefined; + private ready = false; + + constructor(kv: KvClient, opts: BeyondProviderOptions = {}) { + this.kv = kv; + this.useUserPrefs = opts.userPrefs !== false; + this.onError = opts.onError; + this.reader = (opts.mode ?? "snapshot") === "fetch" + ? new FetchReader(kv, opts) + : new SnapshotReader(kv, opts, (flagsChanged) => { + this.events.emit(ProviderEvents.ConfigurationChanged, { + flagsChanged, + }); + }); + } + + /** Await the initial snapshot load. The SDK emits `PROVIDER_READY` on resolve. */ + async initialize(): Promise { + await this.reader.ready(); + this.ready = true; + } + + /** Stop background syncing. Does not close the KV client (caller owns it). */ + async onClose(): Promise { + this.reader.close(); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + _logger: Logger, + ): Promise> { + return this.resolve(flagKey, defaultValue, context, "boolean"); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + _logger: Logger, + ): Promise> { + return this.resolve(flagKey, defaultValue, context, "string"); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + _logger: Logger, + ): Promise> { + return this.resolve(flagKey, defaultValue, context, "number"); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + _logger: Logger, + ): Promise> { + return this.resolve( + flagKey, + defaultValue as JsonValue, + context, + "object", + ) as Promise>; + } + + private async resolve( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + expected: ExpectedType, + ): Promise> { + const ctx = toFlagContext(context); + const def = await this.reader.get(flagKey); + const prefs: UserPrefs | null = + this.useUserPrefs && hasTargetingKey(context) + ? await fetchUserPrefs(this.kv, ctx.id, this.onError) + : null; + const result = evaluate(flagKey, defaultValue, ctx, def, prefs); + return toResolution(result, defaultValue, expected, this.ready); + } +} + +/** + * Parse a `flags:def:*` JSON payload into a {@link FlagDef}. Returns `undefined` + * (treated as "no def" → flag falls back to its declared default) on malformed + * input, reporting through `onError` rather than throwing into resolution. + */ +function parseDef( + text: string, + key: string, + onError?: (event: FlagsErrorEvent) => void, +): FlagDef | undefined { + if (text.length === 0) return undefined; + try { + const parsed = JSON.parse(text) as unknown; + if ( + parsed === null + || typeof parsed !== "object" + || Array.isArray(parsed) + || typeof (parsed as Record)["on"] !== "boolean" + ) { + throw new Error("flag def must be a JSON object with a boolean `on`"); + } + return parsed as FlagDef; + } catch (err) { + onError?.({ + source: "snapshot", + error: err instanceof Error ? err : new Error(String(err)), + name: key, + }); + return undefined; + } +} diff --git a/sdk/ts/flags/src/openfeature/shared.ts b/sdk/ts/flags/src/openfeature/shared.ts new file mode 100644 index 0000000..a208740 --- /dev/null +++ b/sdk/ts/flags/src/openfeature/shared.ts @@ -0,0 +1,132 @@ +/** + * SDK-agnostic glue shared by the OpenFeature server and web providers. + * + * This module maps between OpenFeature's evaluation model and Beyond's pure + * {@link evaluate} engine. It imports only from `@openfeature/core` (the shared + * base both the server and web SDKs depend on), so it can be bundled into either + * provider entry point without pulling the other SDK. + * + * @packageDocumentation + */ + +import { + ErrorCode, + type EvaluationContext, + type ResolutionDetails, + type ResolutionReason, + StandardResolutionReasons, +} from "@openfeature/core"; +import type { EvalResult } from "../eval.js"; +import type { EvalReason, FlagContext, JsonValue } from "../types.js"; + +/** The four flag value shapes OpenFeature resolves, used for type checking. */ +export type ExpectedType = "boolean" | "string" | "number" | "object"; + +/** + * Turn an OpenFeature {@link EvaluationContext} into Beyond's {@link FlagContext}. + * + * `targetingKey` becomes the rollout/pref bucket key `id`; every other attribute + * is carried through for targeting-rule matching. A missing `targetingKey` maps + * to an empty `id` — targeting rules still match on the other attributes, while + * rollout bucketing and per-user prefs are simply skipped (an empty id can't be + * bucketed or looked up). This mirrors the Vercel adapter's "no id → default" + * behavior without throwing. + */ +export function toFlagContext(context: EvaluationContext): FlagContext { + const { targetingKey, ...rest } = context; + return { id: targetingKey ?? "", ...rest } as unknown as FlagContext; +} + +/** Whether the context can drive rollout bucketing / pref lookup. */ +export function hasTargetingKey(context: EvaluationContext): boolean { + return typeof context.targetingKey === "string" + && context.targetingKey !== ""; +} + +/** + * Map Beyond's {@link EvalReason} to an OpenFeature {@link ResolutionReason}. + * `no-snapshot` reports `STALE` before the provider has loaded (the def may yet + * arrive) and `DEFAULT` afterward (the flag genuinely has no def in KV). + */ +export function mapReason( + reason: EvalReason, + ready: boolean, +): ResolutionReason { + switch (reason) { + case "off": + return StandardResolutionReasons.DISABLED; + case "user-pref": + case "override": + case "rule": + return StandardResolutionReasons.TARGETING_MATCH; + case "rollout": + return StandardResolutionReasons.SPLIT; + case "no-snapshot": + return ready + ? StandardResolutionReasons.DEFAULT + : StandardResolutionReasons.STALE; + case "error": + return StandardResolutionReasons.ERROR; + default: + return StandardResolutionReasons.DEFAULT; + } +} + +function typeMatches(value: JsonValue, expected: ExpectedType): boolean { + switch (expected) { + case "boolean": + return typeof value === "boolean"; + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number"; + case "object": + // JSON objects and arrays are both valid OpenFeature object flags. + return typeof value === "object" && value !== null; + } +} + +/** + * Build an OpenFeature {@link ResolutionDetails} from an {@link EvalResult}. + * + * Enforces the OpenFeature type contract: if the resolved value doesn't match + * the requested flag type, the declared `defaultValue` is returned with a + * `TYPE_MISMATCH` error code instead of coercing. `flagMetadata` carries the + * native `beyondReason` (and `ruleIndex` when a targeting rule matched) for + * debugging. + */ +export function toResolution( + result: EvalResult, + defaultValue: T, + expected: ExpectedType, + ready: boolean, +): ResolutionDetails { + if (!typeMatches(result.value, expected)) { + return { + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: `flag resolved to ${ + describe(result.value) + }, expected ${expected}`, + flagMetadata: { beyondReason: result.reason }, + }; + } + const flagMetadata: Record = { + beyondReason: result.reason, + }; + if (result.ruleIndex !== undefined) { + flagMetadata["ruleIndex"] = result.ruleIndex; + } + return { + value: result.value as T, + reason: mapReason(result.reason, ready), + flagMetadata, + }; +} + +function describe(value: JsonValue): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} diff --git a/sdk/ts/flags/src/openfeature/web.ts b/sdk/ts/flags/src/openfeature/web.ts new file mode 100644 index 0000000..3ce3cda --- /dev/null +++ b/sdk/ts/flags/src/openfeature/web.ts @@ -0,0 +1,180 @@ +/** + * OpenFeature **web** (client-side) provider for `@beyond.dev/flags`. + * + * The web SDK resolves flags **synchronously** against a single, static + * evaluation context. This provider satisfies that by keeping an in-memory, + * watch-synced {@link Snapshot} of all `flags:def:*` and pre-fetching the active + * context's per-user prefs on `initialize`/`onContextChange`. Each resolution is + * then a synchronous {@link evaluate} over in-memory state — zero I/O. + * + * Live KV changes are surfaced as `PROVIDER_CONFIGURATION_CHANGED`, prompting the + * SDK to re-resolve. + * + * @example + * ```ts + * import { OpenFeature } from '@openfeature/web-sdk' + * import { createKvClient } from '@beyond.dev/kv' + * import { BeyondWebProvider } from '@beyond.dev/flags/openfeature/web' + * + * const kv = createKvClient({ url: '...' }) + * await OpenFeature.setContext({ targetingKey: 'user-123', plan: 'pro' }) + * await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv)) + * + * const enabled = OpenFeature.getClient().getBooleanValue('new-checkout', false) + * ``` + * + * @packageDocumentation + */ + +import type { KvClient } from "@beyond.dev/kv"; +import { + type EvaluationContext, + type JsonValue as OFJsonValue, + type Logger, + OpenFeatureEventEmitter, + type Provider, + ProviderEvents, + type ProviderMetadata, + type ResolutionDetails, +} from "@openfeature/web-sdk"; +import { evaluate } from "../eval.js"; +import { fetchUserPrefs, Snapshot } from "../snapshot.js"; +import type { FlagsErrorEvent, JsonValue, UserPrefs } from "../types.js"; +import { type ExpectedType, toFlagContext, toResolution } from "./shared.js"; + +/** Options for {@link BeyondWebProvider}. The KV client is passed positionally. */ +export interface BeyondWebProviderOptions { + /** SWR poll interval (seconds) when watch is unavailable. Default 30. */ + refresh?: number; + /** Use `kv.watch()` for instant invalidation + change events. Default true. */ + watch?: boolean; + /** Honor per-user prefs (`flags:user:`) for the active context. Default true. */ + userPrefs?: boolean; + /** Called for snapshot/watch/KV/pref failures. */ + onError?: (event: FlagsErrorEvent) => void; +} + +/** + * An OpenFeature web {@link Provider} backed by Beyond KV. Resolves + * synchronously against an in-memory snapshot; prefs for the active context are + * pre-fetched on context change. + */ +export class BeyondWebProvider implements Provider { + readonly metadata: ProviderMetadata = { name: "beyond-kv" }; + readonly runsOn = "client"; + readonly events = new OpenFeatureEventEmitter(); + + private readonly snapshot: Snapshot; + private readonly kv: KvClient; + private readonly useUserPrefs: boolean; + private readonly onError: ((e: FlagsErrorEvent) => void) | undefined; + private ready = false; + private cachedPrefs: UserPrefs | null = null; + private cachedPrefsId = ""; + + constructor(kv: KvClient, opts: BeyondWebProviderOptions = {}) { + this.kv = kv; + this.useUserPrefs = opts.userPrefs !== false; + this.onError = opts.onError; + this.snapshot = new Snapshot(kv, { + refresh: opts.refresh ?? 30, + watch: opts.watch ?? true, + onChange: (flagsChanged) => { + this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged }); + }, + ...(opts.onError ? { onError: opts.onError } : {}), + }); + } + + /** Load the snapshot and the initial context's prefs. SDK emits `PROVIDER_READY`. */ + async initialize(context?: EvaluationContext): Promise { + this.snapshot.start(); + await this.snapshot.awaitReady(); + await this.loadPrefs(context?.targetingKey ?? ""); + this.ready = true; + } + + /** Re-fetch prefs for the new static context. Async → SDK reconciles + re-resolves. */ + async onContextChange( + _oldContext: EvaluationContext, + newContext: EvaluationContext, + ): Promise { + await this.loadPrefs(newContext.targetingKey ?? ""); + } + + /** Stop background syncing. Does not close the KV client (caller owns it). */ + async onClose(): Promise { + this.snapshot.close(); + } + + private async loadPrefs(id: string): Promise { + if (!this.useUserPrefs || id === "") { + this.cachedPrefs = null; + this.cachedPrefsId = id; + return; + } + this.cachedPrefs = await fetchUserPrefs(this.kv, id, this.onError); + this.cachedPrefsId = id; + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + return this.resolve(flagKey, defaultValue, context, "boolean"); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + return this.resolve(flagKey, defaultValue, context, "string"); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + return this.resolve(flagKey, defaultValue, context, "number"); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + return this.resolve( + flagKey, + defaultValue as JsonValue, + context, + "object", + ) as ResolutionDetails; + } + + private resolve( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + expected: ExpectedType, + ): ResolutionDetails { + const ctx = toFlagContext(context); + // Prefs are pre-fetched per context; use them only when they match the + // context being resolved (the SDK keeps these in lockstep). + const prefs = ctx.id === this.cachedPrefsId ? this.cachedPrefs : null; + const result = evaluate( + flagKey, + defaultValue, + ctx, + this.snapshot.get(flagKey), + prefs, + ); + return toResolution(result, defaultValue, expected, this.ready); + } +} diff --git a/sdk/ts/flags/src/snapshot.ts b/sdk/ts/flags/src/snapshot.ts index f5996e8..dd68a91 100644 --- a/sdk/ts/flags/src/snapshot.ts +++ b/sdk/ts/flags/src/snapshot.ts @@ -1,4 +1,4 @@ -import type { KvClient } from "@beyond.dev/kv"; +import type { KvClient, WatchOptions } from "@beyond.dev/kv"; import { FlagError } from "./errors.js"; import type { FlagDef, @@ -19,19 +19,32 @@ const decoder = new TextDecoder(); * Reads are O(1) Map lookups — zero KV round-trips per flag eval. */ export class Snapshot { - private state = new Map(); + /** + * Parsed def plus the exact KV bytes it was decoded from. The raw bytes are + * the source of truth for change detection (see {@link applyValue}) — keeping + * them avoids re-serializing parsed objects to compare. + */ + private state = new Map(); private readyPromise: Promise; private resolveReady!: () => void; private watchAbort: AbortController | undefined; private pollTimer: ReturnType | undefined; private closed = false; isReady = false; + /** + * Highest revision observed from any read or watch event. Used to resume the + * watch from where it left off after a hard reconnect (and to start the first + * watch from the point the initial load saw), so deltas that land while the + * stream is down are replayed rather than lost. + */ + private lastRevision = 0; readonly kv: KvClient; readonly opts: { refresh: number; watch: boolean; onError?: (e: FlagsErrorEvent) => void; + onChange?: (names: string[]) => void; }; constructor( @@ -40,6 +53,13 @@ export class Snapshot { refresh: number; watch: boolean; onError?: (e: FlagsErrorEvent) => void; + /** + * Called after the *initial* load with the names of flags whose def + * changed (added, updated, or removed) via a watch delta or a poll + * reload. Never fired during the initial load. Used to surface live + * config changes (e.g. OpenFeature `PROVIDER_CONFIGURATION_CHANGED`). + */ + onChange?: (names: string[]) => void; }, ) { this.kv = kv; @@ -61,7 +81,7 @@ export class Snapshot { /** Lookup the current state for a flag. Returns `undefined` if absent. */ get(name: string): FlagDef | undefined { - return this.state.get(name); + return this.state.get(name)?.def; } /** Start the snapshot: initial load + background sync (watch or poll). */ @@ -91,6 +111,7 @@ export class Snapshot { private async loadAll(): Promise { let cursor: string | undefined; const seen = new Set(); + const changed: string[] = []; do { const { data, error } = await this.kv.list( cursor === undefined @@ -108,7 +129,10 @@ export class Snapshot { if (!entry) continue; const flagName = fullKey.slice(DEF_PREFIX.length); seen.add(flagName); - this.applyValue(flagName, entry.value); + if (entry.revision > this.lastRevision) { + this.lastRevision = entry.revision; + } + if (this.applyValue(flagName, entry.value)) changed.push(flagName); } } cursor = data.nextCursor; @@ -116,26 +140,44 @@ export class Snapshot { // Drop any flag that's no longer in KV. for (const name of this.state.keys()) { - if (!seen.has(name)) this.state.delete(name); + if (!seen.has(name)) { + this.state.delete(name); + changed.push(name); + } } + + this.notifyChange(changed); } private async runWatch(): Promise { let attempt = 0; while (!this.closed) { this.watchAbort = new AbortController(); - const opts = { prefix: true, signal: this.watchAbort.signal }; + // Resume from the last revision we saw so deltas that arrived while the + // stream was down are replayed (the server treats `since` as exclusive). + const opts: WatchOptions = { + prefix: true, + signal: this.watchAbort.signal, + }; + if (this.lastRevision > 0) opts.since = this.lastRevision; const sessionStart = Date.now(); try { for await (const event of this.kv.watch(DEF_PREFIX, opts)) { if (this.closed) return; if (event.type === "ready") continue; + // Advance the resume point on every delta, even ones the byte-compare + // dedups, so a reconnect never re-requests already-applied revisions. + if (event.revision > this.lastRevision) { + this.lastRevision = event.revision; + } if (event.type === "set") { const flagName = event.key.slice(DEF_PREFIX.length); - this.applyValue(flagName, event.value); + if (this.applyValue(flagName, event.value)) { + this.notifyChange([flagName]); + } } else if (event.type === "del") { const flagName = event.key.slice(DEF_PREFIX.length); - this.state.delete(flagName); + if (this.state.delete(flagName)) this.notifyChange([flagName]); } } // Stream ended cleanly (server close, LB timeout, graceful restart). @@ -178,15 +220,44 @@ export class Snapshot { } } - private applyValue(flagName: string, raw: Uint8Array): void { + /** + * Decode and store a flag def. Returns `true` if the stored value actually + * changed (newly added, or its KV bytes differ from the prior value) so + * callers can fire change notifications without spurious events on no-op + * re-reads — every poll re-reads every def, so an unchanged re-read must stay + * silent. + * + * Change is decided by exact equality of the raw KV bytes, not by comparing + * parsed objects: bytes are the authoritative representation, the check + * short-circuits on length, and it needs no parse→serialize round-trip. A + * semantically-identical rewrite with reordered keys counts as a change here + * (different bytes) — that only triggers a harmless re-resolve downstream, so + * canonicalizing is deliberately not worth the cost. + */ + private applyValue(flagName: string, raw: Uint8Array): boolean { let parsed: FlagDef | undefined; try { parsed = decodeFlagDef(raw); } catch (err) { this.reportError("snapshot", err, flagName); - return; + return false; } - if (parsed) this.state.set(flagName, parsed); + if (!parsed) return false; + const prev = this.state.get(flagName); + if (prev && bytesEqual(prev.raw, raw)) return false; + // Copy the bytes: the KV client's buffer is not guaranteed to outlive this + // call unmutated, and this map retains it as the change-detection baseline. + this.state.set(flagName, { def: parsed, raw: raw.slice() }); + return true; + } + + /** + * Fire the `onChange` callback for actually-changed flags. Suppressed during + * the initial load (only live deltas/reloads notify) and for empty sets. + */ + private notifyChange(names: string[]): void { + if (!this.isReady || names.length === 0 || !this.opts.onChange) return; + this.opts.onChange(names); } private reportError( @@ -256,6 +327,16 @@ export async function fetchUserPrefs( } } +/** Exact byte equality. Short-circuits on length; no allocation. */ +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + function decodeFlagDef(value: Uint8Array): FlagDef | undefined { const text = decoder.decode(value); if (text.length === 0) return undefined; diff --git a/sdk/ts/flags/tsdown.config.ts b/sdk/ts/flags/tsdown.config.ts index b0d945a..439d6a5 100644 --- a/sdk/ts/flags/tsdown.config.ts +++ b/sdk/ts/flags/tsdown.config.ts @@ -3,17 +3,30 @@ import { defineConfig } from "tsdown"; export default defineConfig({ entry: { index: "src/index.ts", + adapter: "src/adapter.ts", "middleware/hono": "src/middleware/hono.ts", "middleware/express": "src/middleware/express.ts", "middleware/fastify": "src/middleware/fastify.ts", "middleware/next": "src/middleware/next.ts", "middleware/next-middleware": "src/middleware/next-middleware.ts", + "openfeature/server": "src/openfeature/server.ts", + "openfeature/web": "src/openfeature/web.ts", }, format: "esm", dts: true, clean: true, treeshake: true, deps: { - neverBundle: ["next", "express", "fastify", "fastify-plugin", "hono"], + neverBundle: [ + "next", + "express", + "fastify", + "fastify-plugin", + "hono", + "flags", + "@openfeature/server-sdk", + "@openfeature/web-sdk", + "@openfeature/core", + ], }, }); diff --git a/sdk/ts/package-lock.json b/sdk/ts/package-lock.json index b199e68..8815e1c 100644 --- a/sdk/ts/package-lock.json +++ b/sdk/ts/package-lock.json @@ -20,11 +20,15 @@ "tslib": "^2.8.1" }, "devDependencies": { + "@openfeature/core": "^1.11.0", + "@openfeature/server-sdk": "^1.22.0", + "@openfeature/web-sdk": "^1.9.0", "@types/express": "^5.0.0", "@types/node": "^22.0.0", "express": "^5.0.0", "fastify": "^5.0.0", "fastify-plugin": "^5.0.0", + "flags": "^4.2.0", "hono": "^4.0.0", "next": "^15.0.0", "tsdown": "^0.21.10", @@ -38,6 +42,7 @@ "express": ">=4", "fastify": ">=4", "fastify-plugin": ">=4", + "flags": ">=4", "hono": ">=4", "next": ">=14" }, @@ -51,6 +56,9 @@ "fastify-plugin": { "optional": true }, + "flags": { + "optional": true + }, "hono": { "optional": true }, @@ -412,6 +420,16 @@ "resolved": "rate-limit", "link": true }, + "node_modules/@edge-runtime/cookies": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@edge-runtime/cookies/-/cookies-5.0.2.tgz", + "integrity": "sha512-Sd8LcWpZk/SWEeKGE8LT6gMm5MGfX/wm+GPnh1eBEtCpya3vYqn37wYknwAHw92ONoyyREl1hJwxV/Qx2DWNOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1261,6 +1279,36 @@ "node": ">= 10" } }, + "node_modules/@openfeature/core": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.11.0.tgz", + "integrity": "sha512-P0u3/ht/oZCQT89fOed+laLk0kZR529a825cS02uPDglxXbE97irWYpDAeRGGVETIzKfuy+H2g8c3Ccv/tXJNQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@openfeature/server-sdk": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@openfeature/server-sdk/-/server-sdk-1.22.0.tgz", + "integrity": "sha512-YBrf6SQkn0FNB/dRAtLEs41dvFMUE8CrQTwI+iLaMFUIqWlqGNJfGnulKSneEKS+2OgKTAC6DdmKcZ6tK7kBcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@openfeature/core": "^1.11.0" + } + }, + "node_modules/@openfeature/web-sdk": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@openfeature/web-sdk/-/web-sdk-1.9.0.tgz", + "integrity": "sha512-FCrNfqvE/thHVfCNU0KKx1SD7rk+1wE2UaR5B5OPZl917QJv6AsKRwaI3N+SVgwXWI07GgtXP6hNlTVb49PGhg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@openfeature/core": "^1.11.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", @@ -3191,6 +3239,41 @@ "node": ">=20" } }, + "node_modules/flags": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/flags/-/flags-4.2.0.tgz", + "integrity": "sha512-/ahGKxe4Q3C2pi2FS7h6cNR4HWnU7/MWAtwcrTHLBQjM9vVe5NR+uRIbL+vXYYU8PYbb8VnwnD+bdLH6l69m0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@edge-runtime/cookies": "^5.0.2", + "jose": "^5.10.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0", + "@sveltejs/kit": "*", + "next": "*", + "react": "*", + "react-dom": "*" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3470,6 +3553,16 @@ "dev": true, "license": "MIT" }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",