diff --git a/openfeature-provider/js/CLAUDE.md b/openfeature-provider/js/CLAUDE.md index 59e2ab89..1c96bdf5 100644 --- a/openfeature-provider/js/CLAUDE.md +++ b/openfeature-provider/js/CLAUDE.md @@ -8,20 +8,28 @@ TypeScript OpenFeature provider using the Confidence resolver compiled to WASM. ## Entry Points & Exports -The package has 5 build targets configured in `tsdown.config.ts`: +The package has 6 build targets configured in `tsdown.config.ts` (plus 3 `pages-router/*` targets): | Export Path | Entry File | Platform | WASM Loading | | ------------------ | ---------------------- | -------- | ------------------------------- | | `"."` (default) | `src/index.inlined.ts` | neutral | WASM inlined as data URL | | `"./node"` | `src/index.node.ts` | node | `fs.readFile` | | `"./fetch"` | `src/index.fetch.ts` | neutral | `fetch()` (Deno, Bun, browsers) | +| `"./remote"` | `src/index.remote.ts` | neutral | **none** — remote resolver | | `"./react-server"` | `src/react/server.tsx` | neutral | React Server Component | | `"./react-client"` | `src/react/client.tsx` | neutral | React Client Component | -Each entry point exports a `createConfidenceServerProvider` factory function. +Every entry point except `./remote` exports a `createConfidenceServerProvider` factory function. The `./node` entry point extends options with `wasmPath?: string` and `./fetch` with `wasmUrl?: URL | string`. +`./remote` is the odd one out: it ships neither WASM nor OpenFeature (~9 kB +gzipped) and exports the standalone `ConfidenceClient` + pure `evaluate` instead +of a provider. It talks to a remote resolver (a Confidence resolver Cloudflare +Worker via service binding, or `resolver.confidence.dev`) over any +fetch-compatible function. See `plans/thin-js-client.md`. Keep the export boundary +clean — it ships no WASM and no OpenFeature, and that is the whole point. + ## ProviderOptions Defined in `src/ConfidenceServerProviderLocal.ts`: diff --git a/openfeature-provider/js/README.md b/openfeature-provider/js/README.md index 863a91c2..8697974f 100644 --- a/openfeature-provider/js/README.md +++ b/openfeature-provider/js/README.md @@ -217,6 +217,67 @@ const provider = createConfidenceServerProvider({ }); ``` +### `./remote` — Thin client against a remote resolver (no WASM) + +```ts +import { ConfidenceClient, evaluate } from '@spotify-confidence/openfeature-server-provider-local/remote'; +``` + +Ships **no WASM and no OpenFeature** (~9 kB gzipped). Instead of resolving +locally, it calls a remote resolver over any `fetch`-compatible function — a +Confidence resolver Cloudflare Worker reached via a service binding, or +`resolver.confidence.dev` over HTTP. + +This is the right choice when flag resolution runs in a *separate* process from +your application. It exists to support **resolve server-side, apply +client-side**: an exposure is recorded only when a flag actually affects what a +user sees, rather than at resolve time. + +`ConfidenceClient` is stateless — no `initialize`, no `close`, no background +timers. Constructing one is free, so it can be created per request or shared at +module level; it makes no difference. + +```ts +const confidence = new ConfidenceClient({ + flagClientSecret: env.CONFIDENCE_CLIENT_SECRET, + fetch: (input, init) => env.RESOLVER.fetch(input, init), // Cloudflare service binding +}); + +// Resolve without recording exposure. The bundle is plain JSON — forward it +// to the browser and evaluate there with no network and no secrets. +const bundle = await confidence.resolve(['promo-banner'], { targeting_key: userId }, { apply: false }); + +// Later, when the flag actually affects the rendered UI: +await confidence.apply(bundle.resolveToken, 'promo-banner'); +``` + +`evaluate` is a pure function over a resolved bundle, with a typed default. It +never throws — errors surface as the default value with an `ERROR` reason: + +```ts +const banner = evaluate(bundle, 'promo-banner', { show: false, text: '' }); +if (banner.value.show) render(banner.value.text); +``` + +Notes: + +- `apply` defaults to `true` on `resolve`, so naive usage never silently loses + exposure data. Deferred apply is the explicit opt-in shown above. +- `resolve` **never rejects**: on a transport, HTTP or decoding failure it + returns an errored bundle (`errorCode`, `errorMessage`, no flags), which + `evaluate` turns into the default value with an `ERROR` reason. Because the + bundle is still plain JSON, the failure forwards to the browser correctly + labelled. Check `bundle.errorCode` if you want to branch on it. `apply` does + reject on transport and HTTP errors. +- Skip `apply` for flags whose `shouldApply` is `false` (e.g. archived flags); + an apply is meaningless for them. +- A resolve token only permits applying the flags it was resolved for; naming + any other flag rejects the call in full. +- Pass an empty array to `resolve` to resolve every flag available to the client. +- The resolve token is encrypted by the resolver and opaque to clients, so it is + safe to round-trip through a browser. The client secret is *not* — proxy + applies through your server rather than calling the resolver from the browser. + ### `./react-server` and `./react-client` — React/Next.js Integration ```ts diff --git a/openfeature-provider/js/package.json b/openfeature-provider/js/package.json index 2b06363f..871e3396 100644 --- a/openfeature-provider/js/package.json +++ b/openfeature-provider/js/package.json @@ -24,6 +24,10 @@ "types": "./dist/index.fetch.d.ts", "default": "./dist/index.fetch.js" }, + "./remote": { + "types": "./dist/index.remote.d.ts", + "default": "./dist/index.remote.js" + }, "./react-server": { "types": "./dist/server.d.ts", "default": "./dist/server.js" diff --git a/openfeature-provider/js/src/ConfidenceClient.e2e.test.ts b/openfeature-provider/js/src/ConfidenceClient.e2e.test.ts new file mode 100644 index 00000000..6b465016 --- /dev/null +++ b/openfeature-provider/js/src/ConfidenceClient.e2e.test.ts @@ -0,0 +1,224 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { ConfidenceClient, evaluate, type FlagBundle } from './ConfidenceClient'; +import { ErrorCode } from './types'; + +/** + * End-to-end tests for the thin client against the real online resolver at + * `resolver.confidence.dev` (the client's default url, deliberately left unset + * below so the default is covered too). + * + * These cover what the mocked-transport unit tests structurally cannot: that + * the canonical protobuf JSON the client emits is what a real resolver accepts, + * that the JSON it sends back decodes into a `FlagBundle`, and that the + * `resolve(..., { apply: false })` -> `resolveToken` -> `apply()` round trip + * actually works against a server rather than against our own assumptions. + * + * They use the same `web-sdk-e2e-flag`, context and expected values as + * `ConfidenceServerProviderLocal.e2e.test.ts`, so the two are directly + * comparable: same flag, same assignment — one resolved through WASM in + * process, one over HTTP. + * + * Note that the applies below record real exposures for `web-sdk-e2e-flag`, as + * the local-provider E2E test already does. + */ + +const FLAG = 'web-sdk-e2e-flag'; +// A second flag on the same client, used to check that a resolve token only +// permits applying the flags it was minted for. +const OTHER_FLAG = 'custom-targeted-flag'; + +// Context is passed through to targeting verbatim, so the wire spelling +// `targeting_key` is what's used here — not OpenFeature's `targetingKey`. +// `sticky: false` keeps us off the flag's sticky experiment, so the control +// variant is deterministic. +const CONTEXT = { targeting_key: 'test-a', sticky: false }; + +const VARIANT = `flags/${FLAG}/variants/control`; +const ASSIGNMENT_ORIGIN = `flags/${FLAG}/rules/zphggscnfdpvcp4jy5ui`; +const CONTROL_VALUE = { + str: 'control', + bool: false, + double: 3.5, + int: 3, + obj: { str: 'obj control', bool: false, double: 3.6, int: 4, ['obj-obj']: {} }, +}; + +// Every test here makes at least one network round trip. +const TIMEOUT = 20_000; + +const client = new ConfidenceClient({ flagClientSecret: process.env.CONFIDENCE_CLIENT_SECRET! }); + +describe('ConfidenceClient E2E (resolve)', () => { + it( + 'resolves a flag into a bundle keyed by unprefixed flag name', + async () => { + const bundle = await client.resolve([FLAG], CONTEXT, { apply: false }); + + expect(bundle.resolveId).toBeTruthy(); + expect(bundle.flags[FLAG]).toEqual({ + reason: 'MATCH', + variant: VARIANT, + value: CONTROL_VALUE, + shouldApply: true, + assignmentOrigin: ASSIGNMENT_ORIGIN, + }); + }, + TIMEOUT, + ); + + it( + 'returns a resolve token when apply is deferred', + async () => { + const { resolveToken } = await client.resolve([FLAG], CONTEXT, { apply: false }); + + // Base64 of the encrypted token — opaque here, but it has to survive a + // JSON hop to the browser and come back applyable. + expect(resolveToken).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); + }, + TIMEOUT, + ); + + it( + 'returns no resolve token when the resolve itself applied', + async () => { + // apply defaults to true, and a resolve that already counted as an + // exposure has nothing left to apply — hence the empty token that + // `apply()` short-circuits on. + const { resolveToken } = await client.resolve([FLAG], CONTEXT); + + expect(resolveToken).toBe(''); + }, + TIMEOUT, + ); + + it( + 'resolves every flag available to the client for an empty flag list', + async () => { + const bundle = await client.resolve([], CONTEXT, { apply: false }); + + expect(Object.keys(bundle.flags).length).toBeGreaterThan(1); + expect(bundle.flags[FLAG]).toMatchObject({ reason: 'MATCH', variant: VARIANT }); + }, + TIMEOUT, + ); + + it( + 'returns an errored bundle carrying the resolver diagnostic for an unknown client secret', + async () => { + const bogus = new ConfidenceClient({ flagClientSecret: 'not-a-real-client-secret' }); + + const bundle = await bogus.resolve([FLAG], CONTEXT, { apply: false }); + + expect(bundle.errorMessage).toMatch(/flags:resolve failed: 4\d\d/); + expect(evaluate(bundle, FLAG, 'fallback')).toMatchObject({ reason: 'ERROR', value: 'fallback' }); + }, + TIMEOUT, + ); +}); + +describe('ConfidenceClient E2E (evaluate a forwarded bundle)', () => { + // The bundle a browser would see: resolved server-side, serialized into the + // page, parsed back out. `evaluate` is pure, so this is the whole transport. + let forwarded: FlagBundle; + + beforeAll(async () => { + const bundle = await client.resolve([FLAG], CONTEXT, { apply: false }); + forwarded = JSON.parse(JSON.stringify(bundle)); + }, TIMEOUT); + + it('survives the JSON round trip intact', () => { + expect(forwarded.flags[FLAG]?.value).toEqual(CONTROL_VALUE); + expect(forwarded.resolveToken).toBeTruthy(); + }); + + it('evaluates the whole flag against an object default', () => { + const details = evaluate(forwarded, FLAG, { str: 'default', int: 0 }); + + expect(details.value).toEqual(CONTROL_VALUE); + expect(details.reason).toBe('MATCH'); + expect(details.variant).toBe(VARIANT); + expect(details.shouldApply).toBe(true); + }); + + it('evaluates dot paths into the flag value', () => { + expect(evaluate(forwarded, `${FLAG}.bool`, true).value).toBe(false); + expect(evaluate(forwarded, `${FLAG}.int`, 10).value).toBe(3); + expect(evaluate(forwarded, `${FLAG}.double`, 10).value).toBe(3.5); + expect(evaluate(forwarded, `${FLAG}.str`, 'default').value).toBe('control'); + expect(evaluate(forwarded, `${FLAG}.obj`, {}).value).toEqual(CONTROL_VALUE.obj); + expect(evaluate(forwarded, `${FLAG}.obj.double`, 1).value).toBe(3.6); + }); + + it('returns the default with an ERROR reason on a type mismatch', () => { + const details = evaluate(forwarded, `${FLAG}.str`, 42); + + expect(details.value).toBe(42); + expect(details.reason).toBe('ERROR'); + expect(details.errorCode).toBe(ErrorCode.TYPE_MISMATCH); + }); + + it('returns the default with FLAG_NOT_FOUND for a flag outside the bundle', () => { + const details = evaluate(forwarded, 'no-such-flag', 'fallback'); + + expect(details.value).toBe('fallback'); + expect(details.reason).toBe('ERROR'); + expect(details.errorCode).toBe(ErrorCode.FLAG_NOT_FOUND); + }); +}); + +describe('ConfidenceClient E2E (apply)', () => { + let resolveToken: string; + + beforeAll(async () => { + ({ resolveToken } = await client.resolve([FLAG], CONTEXT, { apply: false })); + }, TIMEOUT); + + it( + 'records exposure for a single flag name', + async () => { + await expect(client.apply(resolveToken, FLAG)).resolves.toBeUndefined(); + }, + TIMEOUT, + ); + + it( + 'records exposure for an array of flag names', + async () => { + await expect(client.apply(resolveToken, [FLAG])).resolves.toBeUndefined(); + }, + TIMEOUT, + ); + + it( + 'rejects for a resolve token the resolver will not accept', + async () => { + await expect(client.apply('bm90LWEtdG9rZW4=', FLAG)).rejects.toThrow(/flags:apply failed: 4\d\d/); + }, + TIMEOUT, + ); + + it( + 'rejects for a flag the resolve token does not cover', + async () => { + // The token above was minted for FLAG alone. A token only permits applying + // the assignments it actually carries — which is what makes it safe to + // round-trip through the browser. + await expect(client.apply(resolveToken, OTHER_FLAG)).rejects.toThrow( + /flags:apply failed: 400.*Flag in resolve token does not match flag in request/, + ); + }, + TIMEOUT, + ); + + it( + 'rejects the whole batch when one flag is not covered by the token', + async () => { + // Rejection covers the whole call, including the flag the token does + // carry — an apply naming a flag it never resolved isn't partly right. + await expect(client.apply(resolveToken, [FLAG, OTHER_FLAG])).rejects.toThrow( + /flags:apply failed: 400.*Flag in resolve token does not match flag in request/, + ); + }, + TIMEOUT, + ); +}); diff --git a/openfeature-provider/js/src/ConfidenceClient.test.ts b/openfeature-provider/js/src/ConfidenceClient.test.ts new file mode 100644 index 00000000..45cc8958 --- /dev/null +++ b/openfeature-provider/js/src/ConfidenceClient.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ConfidenceClient, evaluate } from './ConfidenceClient'; +import { ErrorCode } from './types'; +import { bytesFromBase64 } from './util'; + +const SECRET = 'test-client-secret'; + +/** + * A canonical protobuf JSON `ResolveFlagsResponse` — the shape pbjson emits on + * the Rust side (lowerCamelCase fields, enums as proto-name strings, bytes as + * base64). Default-valued fields are omitted, as canonical JSON requires. + */ +const RESOLVE_RESPONSE = { + resolvedFlags: [ + { + flag: 'flags/promo-banner', + variant: 'flags/promo-banner/variants/treatment', + value: { show: true, text: 'Hello', nested: { count: 3 } }, + reason: 'RESOLVE_REASON_MATCH', + shouldApply: true, + assignmentOrigin: 'rule-1', + }, + { + flag: 'flags/checkout-redesign', + reason: 'RESOLVE_REASON_NO_SEGMENT_MATCH', + // shouldApply omitted — canonical JSON omits `false` + }, + ], + resolveToken: 'AQIDBP8=', // bytes [1, 2, 3, 4, 255] + resolveId: 'resolve-123', +}; + +function mockTransport( + responseBody: unknown = RESOLVE_RESPONSE, + { status = 200, statusText = 'OK', body }: { status?: number; statusText?: string; body?: string } = {}, +) { + const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + void url; + void init; + return new Response(body ?? JSON.stringify(responseBody), { + status, + statusText, + headers: { 'Content-Type': 'application/json' }, + }); + }); + return fetchImpl as unknown as typeof fetch & { mock: (typeof fetchImpl)['mock'] }; +} + +function requestBody(fetchImpl: { mock: { calls: any[][] } }, call = 0): any { + return JSON.parse(fetchImpl.mock.calls[call][1].body); +} + +function client(fetchImpl: typeof fetch, url?: string) { + return new ConfidenceClient({ flagClientSecret: SECRET, url, fetch: fetchImpl }); +} + +describe('ConfidenceClient', () => { + describe('resolve', () => { + it('posts canonical protobuf JSON to /v1/flags:resolve', async () => { + const fetchImpl = mockTransport(); + await client(fetchImpl).resolve(['promo-banner', 'checkout-redesign'], { + targeting_key: 'user-1', + country: 'SE', + }); + + const [url, init] = (fetchImpl as any).mock.calls[0]; + expect(url).toBe('https://resolver.confidence.dev/v1/flags:resolve'); + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ 'Content-Type': 'application/json' }); + + expect(requestBody(fetchImpl as any)).toEqual({ + flags: ['flags/promo-banner', 'flags/checkout-redesign'], + // Context is passed through verbatim — `targeting_key`, not `targetingKey` + evaluationContext: { targeting_key: 'user-1', country: 'SE' }, + clientSecret: SECRET, + apply: true, + sdk: { id: 'SDK_ID_JS_LOCAL_SERVER_PROVIDER', version: expect.any(String) }, + }); + }); + + it('applies by default so naive usage never loses exposure data', async () => { + const fetchImpl = mockTransport(); + await client(fetchImpl).resolve(['promo-banner'], {}); + expect(requestBody(fetchImpl as any).apply).toBe(true); + }); + + it('omits `apply` when false, which proto3 decodes back to false', async () => { + const fetchImpl = mockTransport(); + await client(fetchImpl).resolve(['promo-banner'], {}, { apply: false }); + // Canonical protobuf JSON omits default-valued fields. A missing bool + // decodes to false, so a deferred-apply resolve stays deferred. + expect(requestBody(fetchImpl as any)).not.toHaveProperty('apply'); + }); + + it('sends no `flags` for an empty array, meaning "all flags"', async () => { + const fetchImpl = mockTransport(); + await client(fetchImpl).resolve([], { targeting_key: 'user-1' }); + expect(requestBody(fetchImpl as any)).not.toHaveProperty('flags'); + }); + + it('converts the response into a FlagBundle keyed by unprefixed flag name', async () => { + const bundle = await client(mockTransport()).resolve(['promo-banner'], {}); + + expect(Object.keys(bundle.flags)).toEqual(['promo-banner', 'checkout-redesign']); + expect(bundle.resolveId).toBe('resolve-123'); + expect(bundle.flags['promo-banner']).toEqual({ + reason: 'MATCH', + variant: 'flags/promo-banner/variants/treatment', + value: { show: true, text: 'Hello', nested: { count: 3 } }, + shouldApply: true, + assignmentOrigin: 'rule-1', + }); + expect(bundle.flags['checkout-redesign']).toMatchObject({ + reason: 'NO_SEGMENT_MATCH', + value: null, + shouldApply: false, + }); + }); + + it('exposes resolveToken as a base64 string so the bundle is JSON-forwardable', async () => { + const bundle = await client(mockTransport()).resolve(['promo-banner'], {}); + expect(bundle.resolveToken).toBe('AQIDBP8='); + expect(JSON.parse(JSON.stringify(bundle)).resolveToken).toBe('AQIDBP8='); + }); + + it('returns an errored bundle on HTTP errors, surfacing the resolver diagnostic', async () => { + const fetchImpl = mockTransport(undefined, { + status: 500, + statusText: 'Internal Server Error', + body: 'client secret not found: requested=te***et, available=[ab***cd]', + }); + const bundle = await client(fetchImpl).resolve(['promo-banner'], {}); + + expect(bundle.errorCode).toBe(ErrorCode.GENERAL); + expect(bundle.errorMessage).toMatch(/500 Internal Server Error - client secret not found/); + expect(bundle.flags).toEqual({}); + }); + + it('returns an errored bundle on transport errors', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('connect ECONNREFUSED'); + }) as unknown as typeof fetch; + const bundle = await client(fetchImpl).resolve(['promo-banner'], {}); + + expect(bundle.errorCode).toBe(ErrorCode.GENERAL); + expect(bundle.errorMessage).toMatch('connect ECONNREFUSED'); + }); + + it('returns an errored bundle on a malformed response body', async () => { + const fetchImpl = vi.fn(async () => new Response('not json', { status: 200 })) as unknown as typeof fetch; + const bundle = await client(fetchImpl).resolve(['promo-banner'], {}); + + expect(bundle.errorCode).toBe(ErrorCode.GENERAL); + }); + + it('errored bundles evaluate to the default with an ERROR reason', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('connect ECONNREFUSED'); + }) as unknown as typeof fetch; + // The bundle is still plain JSON, so the failure reaches a browser + // labelled as an error rather than as a missing flag. + const forwarded = JSON.parse(JSON.stringify(await client(fetchImpl).resolve(['promo-banner'], {}))); + + expect(evaluate(forwarded, 'promo-banner.text', 'fallback')).toMatchObject({ + reason: 'ERROR', + errorCode: ErrorCode.GENERAL, + value: 'fallback', + shouldApply: false, + }); + }); + + it('normalizes trailing slashes on the base url', async () => { + const fetchImpl = mockTransport(); + await client(fetchImpl, 'https://my-resolver.example.com//').resolve(['promo-banner'], {}); + expect((fetchImpl as any).mock.calls[0][0]).toBe('https://my-resolver.example.com/v1/flags:resolve'); + }); + }); + + describe('apply', () => { + it('posts canonical protobuf JSON to /v1/flags:apply', async () => { + const fetchImpl = mockTransport({}); + await client(fetchImpl).apply('AQIDBP8=', ['promo-banner', 'checkout-redesign']); + + const [url] = (fetchImpl as any).mock.calls[0]; + expect(url).toBe('https://resolver.confidence.dev/v1/flags:apply'); + + const body = requestBody(fetchImpl as any); + expect(body.flags.map((f: any) => f.flag)).toEqual(['flags/promo-banner', 'flags/checkout-redesign']); + expect(body.clientSecret).toBe(SECRET); + expect(body.resolveToken).toBe('AQIDBP8='); + expect(bytesFromBase64(body.resolveToken)).toEqual(new Uint8Array([1, 2, 3, 4, 255])); + // Timestamps must be RFC3339 for pbjson to accept them + expect(body.sendTime).toMatch(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/); + expect(Number.isNaN(Date.parse(body.flags[0].applyTime))).toBe(false); + }); + + it('accepts a single flag name', async () => { + const fetchImpl = mockTransport({}); + await client(fetchImpl).apply('AQIDBP8=', 'promo-banner'); + expect(requestBody(fetchImpl as any).flags).toEqual([ + { flag: 'flags/promo-banner', applyTime: expect.any(String) }, + ]); + }); + + it('does not call the network without a resolve token', async () => { + // A resolve with apply=true returns an empty token; there is nothing to apply. + const fetchImpl = mockTransport({}); + await client(fetchImpl).apply('', 'promo-banner'); + expect((fetchImpl as any).mock.calls).toHaveLength(0); + }); + + it('does not call the network for an empty flag list', async () => { + const fetchImpl = mockTransport({}); + await client(fetchImpl).apply('AQIDBP8=', []); + expect((fetchImpl as any).mock.calls).toHaveLength(0); + }); + + it('tolerates an empty response body', async () => { + const fetchImpl = mockTransport(undefined, { body: '' }); + await expect(client(fetchImpl).apply('AQIDBP8=', 'promo-banner')).resolves.toBeUndefined(); + }); + + it('rejects on HTTP errors', async () => { + const fetchImpl = mockTransport(undefined, { status: 403, statusText: 'Forbidden' }); + await expect(client(fetchImpl).apply('AQIDBP8=', 'promo-banner')).rejects.toThrow(/403 Forbidden/); + }); + }); + + describe('evaluate', () => { + const bundle = async () => client(mockTransport()).resolve(['promo-banner'], {}); + + it('evaluates a whole flag against an object default', async () => { + const details = evaluate(await bundle(), 'promo-banner', { show: false, text: '' }); + expect(details.value).toEqual({ show: true, text: 'Hello', nested: { count: 3 } }); + expect(details.reason).toBe('MATCH'); + expect(details.shouldApply).toBe(true); + }); + + it('evaluates a dot path into the flag value', async () => { + expect(evaluate(await bundle(), 'promo-banner.text', 'default').value).toBe('Hello'); + expect(evaluate(await bundle(), 'promo-banner.nested.count', 0).value).toBe(3); + }); + + it('returns the default with an ERROR reason on type mismatch', async () => { + const details = evaluate(await bundle(), 'promo-banner.text', 42); + expect(details.value).toBe(42); + expect(details.reason).toBe('ERROR'); + expect(details.errorCode).toBe(ErrorCode.TYPE_MISMATCH); + }); + + it('returns the default with FLAG_NOT_FOUND for an unknown flag', async () => { + const details = evaluate(await bundle(), 'no-such-flag', false); + expect(details.value).toBe(false); + expect(details.reason).toBe('ERROR'); + expect(details.errorCode).toBe(ErrorCode.FLAG_NOT_FOUND); + }); + + it('never throws, even on a garbage bundle', async () => { + expect(() => evaluate({ flags: {}, resolveId: '', resolveToken: '' }, 'x.y.z', 'fallback')).not.toThrow(); + expect(evaluate({ flags: {}, resolveId: '', resolveToken: '' }, 'x.y.z', 'fallback').value).toBe('fallback'); + }); + + it('substitutes the default for a flag that resolved to no value', async () => { + const details = evaluate(await bundle(), 'checkout-redesign', { enabled: false }); + expect(details.value).toEqual({ enabled: false }); + expect(details.reason).toBe('NO_SEGMENT_MATCH'); + }); + }); + + describe('statelessness', () => { + it('does no work on construction', () => { + const fetchImpl = mockTransport(); + new ConfidenceClient({ flagClientSecret: SECRET, fetch: fetchImpl }); + expect((fetchImpl as any).mock.calls).toHaveLength(0); + }); + + it('has no lifecycle methods to forget to call', () => { + const instance = client(mockTransport()) as unknown as Record; + expect(instance.initialize).toBeUndefined(); + expect(instance.close).toBeUndefined(); + }); + }); +}); diff --git a/openfeature-provider/js/src/ConfidenceClient.ts b/openfeature-provider/js/src/ConfidenceClient.ts new file mode 100644 index 00000000..f0f4b79a --- /dev/null +++ b/openfeature-provider/js/src/ConfidenceClient.ts @@ -0,0 +1,143 @@ +import { ApplyFlagsRequest, ResolveFlagsRequest, ResolveFlagsResponse } from './proto/confidence/flags/resolver/v1/api'; +import { SdkId } from './proto/confidence/flags/resolver/v1/types'; +import FlagBundleType, * as FlagBundle from './flag-bundle'; +import { ErrorCode, type JsonValue, type ResolutionDetails } from './types'; +import { logger } from './logger'; +import { VERSION } from './version'; + +const DEFAULT_URL = 'https://resolver.confidence.dev'; +const FLAG_PREFIX = 'flags/'; + +// TODO: a dedicated SDK id for the thin client would make its resolve traffic +// distinguishable from the WASM-backed local provider. Additive proto change. +const SDK = { id: SdkId.SDK_ID_JS_LOCAL_SERVER_PROVIDER, version: VERSION }; + +/** Evaluation context, passed through to targeting verbatim. */ +export type Context = { targeting_key?: string; [key: string]: unknown }; + +export type { default as FlagBundle } from './flag-bundle'; + +export interface ConfidenceClientOptions { + flagClientSecret: string; + /** + * Resolver base URL. Also used for the request path when `fetch` is a + * Cloudflare service binding (bindings route by binding, not by hostname). + */ + url?: string; + /** fetch-compatible transport. Pass a Cloudflare service binding here. */ + fetch?: typeof fetch; +} + +/** + * A thin, stateless flag client for use against a remote resolver — a + * Confidence resolver Worker reached via service binding, or + * `resolver.confidence.dev` over HTTP. + * + * There is no lifecycle, no background work and no cached state: constructing + * one is free, so it can be created per request or shared at module level — + * it makes no difference. + */ +export class ConfidenceClient { + private readonly clientSecret: string; + private readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: ConfidenceClientOptions) { + this.clientSecret = options.flagClientSecret; + // Trailing slashes would produce '//v1/flags:resolve'. + this.baseUrl = (options.url ?? DEFAULT_URL).replace(/\/+$/, ''); + this.fetchImpl = options.fetch ?? globalThis.fetch; + } + + /** + * Resolve the named flags — or all flags available to the client, when the + * array is empty. + * + * `apply` defaults to true, so a resolve counts as an exposure. Pass + * `{ apply: false }` to defer exposure to an explicit {@link apply} call; + * the returned bundle then carries a resolve token to apply against. + * + * Never rejects. A transport, HTTP or decoding failure yields an errored + * bundle instead: `evaluate` then returns defaults with an `ERROR` reason, + * and because the bundle is still plain JSON the failure travels to the + * browser correctly labelled. Callers that want to branch can check + * `errorCode` on the bundle. + */ + async resolve(flagNames: string[], context: Context, options?: { apply?: boolean }): Promise { + try { + const request = ResolveFlagsRequest.create({ + flags: flagNames.map(name => FLAG_PREFIX + name), + evaluationContext: context, + apply: options?.apply ?? true, + clientSecret: this.clientSecret, + sdk: SDK, + }); + const response = await this.post('/v1/flags:resolve', ResolveFlagsRequest.toJSON(request)); + return FlagBundle.create(ResolveFlagsResponse.fromJSON(await response.json())); + } catch (err) { + // Named once here; `evaluate` would otherwise report it per flag. + logger.warn('Resolve failed, returning an errored bundle. %s', String(err)); + return FlagBundle.error(ErrorCode.GENERAL, String(err)); + } + } + + /** + * Record exposure for flags from an earlier `resolve(..., { apply: false })`. + * + * Skip flags whose `shouldApply` is false — an apply is meaningless for them. + * Rejects on transport and HTTP errors. + * + * A token only permits applying the flags it was minted for; naming any + * other flag rejects the call in full. + */ + async apply(resolveToken: string, flagNames: string | string[]): Promise { + const names = typeof flagNames === 'string' ? [flagNames] : flagNames; + // A resolve with apply=true returns no token, and there is nothing to + // apply for an empty flag list — save the round trip either way. + if (!resolveToken || names.length === 0) return; + + const now = new Date(); + const request = ApplyFlagsRequest.create({ + flags: names.map(name => ({ flag: FLAG_PREFIX + name, applyTime: now })), + clientSecret: this.clientSecret, + resolveToken: FlagBundle.decodeToken(resolveToken), + sendTime: now, + sdk: SDK, + }); + await this.post('/v1/flags:apply', ApplyFlagsRequest.toJSON(request)); + } + + private async post(path: string, body: unknown): Promise { + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) { + // The resolver returns diagnostics as the body (e.g. "client secret not + // found: requested=..., available=[...]") — worth surfacing. + const detail = await response.text().catch(() => ''); + throw new Error( + `Confidence ${path} failed: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ''}`, + ); + } + return response; + } +} + +/** + * Evaluate a flag key against a resolved bundle, with a typed default. + * + * A pure function with no I/O — it works on a bundle that was JSON-forwarded + * from the server, so a browser can evaluate without resolving again. Never + * throws: errors surface as the default value with an `ERROR` reason. + * + * @param flagKey - `'my-flag'` or a dot path into the value, `'my-flag.some.field'` + */ +export function evaluate( + bundle: FlagBundleType, + flagKey: string, + defaultValue: T, +): ResolutionDetails { + return FlagBundle.resolve(bundle, flagKey, defaultValue, logger); +} diff --git a/openfeature-provider/js/src/index.remote.ts b/openfeature-provider/js/src/index.remote.ts new file mode 100644 index 00000000..cc294892 --- /dev/null +++ b/openfeature-provider/js/src/index.remote.ts @@ -0,0 +1,4 @@ +export { ConfidenceClient, evaluate } from './ConfidenceClient'; +export type { ConfidenceClientOptions, Context, FlagBundle } from './ConfidenceClient'; +export { ErrorCode } from './types'; +export type { ResolutionDetails, ResolutionReason, FlagObject, FlagValue, JsonValue } from './types'; diff --git a/openfeature-provider/js/src/types.ts b/openfeature-provider/js/src/types.ts index 0e26412d..2d7c36ca 100644 --- a/openfeature-provider/js/src/types.ts +++ b/openfeature-provider/js/src/types.ts @@ -31,3 +31,11 @@ export type FlagObject = { [key: string]: FlagValue; }; export type FlagValue = FlagPrimitive | FlagObject; + +/** + * Structurally identical to `JsonValue` from `@openfeature/core`, restated here + * so the standalone `./remote` entry point carries no OpenFeature dependency — + * not even a type-only one, which TypeScript consumers would otherwise have to + * install to resolve our `.d.ts`. + */ +export type JsonValue = FlagPrimitive | { [key: string]: JsonValue } | JsonValue[]; diff --git a/openfeature-provider/js/tsdown.config.ts b/openfeature-provider/js/tsdown.config.ts index 381e0070..98633295 100644 --- a/openfeature-provider/js/tsdown.config.ts +++ b/openfeature-provider/js/tsdown.config.ts @@ -50,6 +50,13 @@ export default defineConfig([ copy: ['../../wasm/confidence_resolver.wasm'], ...base, }, + // ./remote: standalone ConfidenceClient against a remote resolver. + // Deliberately no `copy` of the WASM — not shipping it is the whole point. + { + entry: './src/index.remote.ts', + platform: 'neutral', + ...base, + }, // React server component { entry: './src/react/server.tsx', diff --git a/plans/thin-js-client.md b/plans/thin-js-client.md new file mode 100644 index 00000000..481b8399 --- /dev/null +++ b/plans/thin-js-client.md @@ -0,0 +1,271 @@ +# Thin Confidence client for edge resolution + +**Status:** Implemented +**Name:** `ConfidenceClient` +**Scope:** `openfeature-provider/js` only (other languages may follow the same +shape later) + +**Implementation:** `openfeature-provider/js/src/ConfidenceClient.ts` (client and +`evaluate`), `src/index.remote.ts` (entry point), `src/ConfidenceClient.test.ts` +(unit, mocked transport), `src/ConfidenceClient.e2e.test.ts` (against the live +`resolver.confidence.dev`). + +## Background + +A common deployment runs flag resolution on Cloudflare: an application Worker +calls a dedicated Confidence resolver Worker (same colo, sub-millisecond). That +resolver used to hardcode `apply = true` — every resolve immediately counted as +an exposure. + +The goal was to split that: **resolve server-side, apply client-side** — an +exposure should only be recorded when a flag actually affects what a user sees. +The resolver side landed first (`498dd2a6`: the resolver respects the `apply` +flag in the request and returns a resolve token when `apply=false`). This +document covers the application-side SDK that sends the applies. + +The existing online SDK is the wrong tool for this. It is built around a +long-lived, stateful client — caching, context mutation, apply-on-access — +which fights the per-request execution model of a Worker, and it has no good +story for carrying a resolve token from a server-side resolve to a client-side +apply. + +## Design + +A thin, stateless flag client with three operations — `resolve`, `evaluate`, +`apply` — over plain `fetch`. No lifecycle, no background work, no state: +constructing one is free, so it can be created per request or shared, it makes +no difference. + +Because the transport is just a fetch-compatible function, the same client +works: + +- in an application Worker, calling the resolver Worker via a **service binding** +- in a Worker or Node, calling the resolver (or `resolver.confidence.dev`) over HTTP + +In the browser, `evaluate` works on forwarded bundles with no network and no +secrets; exposure (`apply`) is proxied through the application Worker so the +client secret never leaves it (example below). + +## API + +```ts +interface ConfidenceClientOptions { + flagClientSecret: string; + /** Resolver base URL. Ignored path-wise when `fetch` is a service binding + * that routes by binding rather than hostname. */ + url?: string; // default: 'https://resolver.confidence.dev' + /** fetch-compatible transport. Pass a Cloudflare service binding here. */ + fetch?: typeof fetch; // default: globalThis.fetch +} + +class ConfidenceClient { + constructor(options: ConfidenceClientOptions); + + /** Resolve the named flags — or all flags, when the array is empty. apply + * defaults to true (resolve counts as exposure); pass apply: false to + * defer exposure to an explicit apply(). */ + resolve( + flagNames: string[], // [] resolves all flags + context: Context, + options?: { apply?: boolean }, + ): Promise; + + /** Record exposure for flags from an earlier resolve(..., {apply: false}). + * Every name must be covered by the token. */ + apply(resolveToken: string, flagNames: string | string[]): Promise; +} + +/** + * Evaluate a (dot-path) flag key against a resolved bundle, with a typed + * default. Pure function, no I/O — works on a bundle that was JSON-forwarded + * from the server, so the browser evaluates without another resolve. Never + * throws — errors surface as the default value with an ERROR reason. + */ +function evaluate( + bundle: FlagBundle, + flagKey: string, // 'my-flag' or 'my-flag.some.field' + defaultValue: T, +): ResolutionDetails; + +/** Plain object, passed through to targeting verbatim. */ +type Context = { targeting_key?: string; [key: string]: unknown }; + +interface FlagBundle { + flags: Record>; + resolveToken: string; // opaque, encrypted; safe to forward to the browser + resolveId: string; +} + +interface ResolutionDetails { + value: T; + variant?: string; + reason: 'MATCH' | 'NO_SEGMENT_MATCH' | 'ERROR' | /* ... */ string; + /** True when an apply is meaningful for this flag — skip applies otherwise. */ + shouldApply: boolean; + errorCode?: string; + errorMessage?: string; +} +``` + +The types deliberately match the shapes the resolver API and OpenFeature +already use (`ResolutionDetails`, resolve reasons) — no new vocabulary, and a +later OpenFeature integration consumes the same objects. + +## End to end: TanStack Start on Cloudflare + +The flow: resolve with `apply: false` in a server function, forward the +bundle to the front end (it's plain JSON), `evaluate` it there, and report +exposure back through the application Worker. The resolve token round-trips +through the browser — it is encrypted by the resolver and opaque to the +client — while the apply itself is proxied through a server function +(`createServerFn` always executes server-side), so the resolver stays +reachable only via the service binding and the client secret never leaves +the Worker. + +Because the client is stateless, it can live at module level in the server +function file: + +```ts +// src/confidence.server.ts +import { createServerFn } from '@tanstack/react-start'; +import { env } from 'cloudflare:workers'; +import { ConfidenceClient, type Context } from '...'; + +const confidence = new ConfidenceClient({ + flagClientSecret: env.CONFIDENCE_CLIENT_SECRET, + fetch: (input, init) => env.RESOLVER.fetch(input, init), // service binding +}); + +export const resolveFlags = createServerFn({ method: 'POST' }) + .validator((data: { flags: string[]; context: Context }) => data) + .handler(({ data }) => confidence.resolve(data.flags, data.context, { apply: false })); + +export const applyFlag = createServerFn({ method: 'POST' }) + .validator((data: { resolveToken: string; flagName: string }) => data) + .handler(({ data }) => confidence.apply(data.resolveToken, data.flagName)); +``` + +Bundles are plain values and `evaluate` is a pure function, so resolving +several bundles with *different contexts* (user-scoped, page-scoped, …) is +just multiple loader values — there is no assumption of one bundle per +component tree: + +```ts +// src/routes/product.$id.tsx +export const Route = createFileRoute('/product/$id')({ + loader: async ({ params }) => { + const [userFlags, pageFlags] = await Promise.all([ + resolveFlags({ data: { flags: ['checkout-redesign'], context: { targeting_key: userId } } }), + resolveFlags({ data: { + flags: ['promo-banner'], + context: { targeting_key: sessionId, page: 'product', product_id: params.id }, + } }), + ]); + return { userFlags, pageFlags }; + }, + component: ProductPage, +}); +``` + +A minimal exposure hook — dedupe repeat applies, skip flags where +`shouldApply` is false, fire-and-forget through the server function: + +```tsx +function useExposure(bundle: FlagBundle) { + const applied = useRef(new Set()); + return useCallback( + (flagName: string) => { + if (!bundle.flags[flagName]?.shouldApply || applied.current.has(flagName)) return; + applied.current.add(flagName); + applyFlag({ data: { resolveToken: bundle.resolveToken, flagName } }).catch(() => {}); + }, + [bundle], + ); +} + +function ProductPage() { + const { pageFlags } = Route.useLoaderData(); + const expose = useExposure(pageFlags); + const banner = evaluate(pageFlags, 'promo-banner', { show: false, text: '' }); + + useEffect(() => expose('promo-banner'), [expose]); // exposure when actually rendered + + return banner.value.show ? : null; +} +``` + +(Exact `createServerFn` chaining — e.g. `validator` vs newer names — tracks +the TanStack Start version in use; the shape above is illustrative.) + +## Semantics + +- **`apply` defaults to `true`** on resolve. The safe default: naive usage + never silently loses exposure data. Deferred apply is the explicit opt-in. +- **Neither `resolve` nor `evaluate` throws.** A transport/HTTP/decoding + failure makes `resolve` return an *errored bundle* (`errorCode`, + `errorMessage`, no flags) rather than reject, and `evaluate` returns the + default with an ERROR reason (standard flag-SDK behavior), so rendering code + stays branch-free. The errored bundle matters beyond convenience: it is still + plain JSON, so the failure forwards to the browser correctly labelled — a + caller catching a rejection would have to invent a fallback bundle, and the + obvious inventions (`null`, an empty bundle) mislabel the failure as + FLAG_NOT_FOUND. Callers that want to branch check `bundle.errorCode`. + `apply` still rejects: it has no bundle to carry an error, and callers + fire-and-forget it anyway. +- **Stateless by construction.** No initialize, no close, no timers. All + state (flag definitions, sticky assignments, log shipping) lives in the + resolver Worker. +- **`shouldApply`** on each flag tells the front end whether an apply is + warranted, so it doesn't send pointless applies for e.g. archived flags. +- **`apply` is scoped to its token.** A resolve token only permits applying the + flags it was minted for; naming any other flag rejects the call in full. +- **`evaluate(bundle, key, default)` is pure and tiny** — the browser bundle + for a front end that only evaluates and applies stays minimal. The + implementation already exists in the SDK (`flag-bundle.ts`); this is a + re-export with a better name. + +## Future direction: token-only applies + +The apply path doesn't truly need the client secret. The resolve token is +encrypted by the resolver and only permits applying flags whose assignments +it contains — possession of a valid token is already the credential, scoped +more tightly than the secret is. The secret's only remaining role in apply is +attributing the exposure event to a client credential. + +The token already carries the account; if the resolver also stamped the +client identity into it, `apply(resolveToken, flagNames)` could drop the +secret entirely — an additive, backward-compatible token change. At that +point browser-direct applies (the resolver Worker already serves CORS) become +clean: no secret in the browser, no proxy hop needed. Not required for v1 — +the Worker proxy above works today — but it removes the last reason the proxy +is *mandatory* rather than a choice. + +## Dependencies & sequencing + +- [x] Resolver: respects `apply` from the request and returns the resolve token + when `apply=false` (`498dd2a6`). The apply endpoint already worked — it + only short-circuited because forced-apply resolves return an empty token. +- [x] This client. Built as an entry point of the existing package rather than a + new one, so it reuses the generated protos and `flag-bundle.ts` while + shipping neither WASM nor OpenFeature (~9 kB gzipped). +- [ ] A dedicated SDK id for the thin client in resolve telemetry, so its + traffic is distinguishable from the WASM-backed local provider. It reports + `SDK_ID_JS_LOCAL_SERVER_PROVIDER` today — see the `TODO` in + `ConfidenceClient.ts`. Additive proto change. + +## Settled questions + +- **Naming and packaging** — kept `ConfidenceClient`; shipped as `./remote` on + the existing package. +- **Context spelling** — passthrough. `targeting_key`, the wire format, not + OpenFeature's `targetingKey`. + +## Still open + +- The dedicated SDK id, above. +- Token-only applies, above — would remove the need to proxy applies at all. +- An integration test against the resolver Worker under `wrangler dev`. Deferred: + it belongs beside the Worker in `confidence-cloudflare-resolver`, and running + it in CI means adding `wrangler`, `worker-build` and a pinned + `wasm-bindgen-cli` to a Docker stage. The e2e test against + `resolver.confidence.dev` covers the same wire contract meanwhile.