Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions openfeature-provider/js/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
61 changes: 61 additions & 0 deletions openfeature-provider/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions openfeature-provider/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
224 changes: 224 additions & 0 deletions openfeature-provider/js/src/ConfidenceClient.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
Loading