Skip to content
40 changes: 35 additions & 5 deletions libs/providers/ofrep-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,27 +100,57 @@ OpenFeature.setProvider(

### Caching

The provider supports persistent local caching via `localStorage` to reduce latency on startup and improve resilience to transient network failures. Caching is controlled with three options.
The provider supports persistent local caching via `localStorage` per [ADR-0009](https://github.com/open-feature/protocol/blob/main/service/adrs/0009-local-storage-for-static-context-providers.md). Caching reduces latency on startup and improves resilience to transient network failures.

**`cacheMode`** — controls the startup strategy:
#### Cache modes

**`cacheMode`** controls the startup strategy:

- `'local-cache-first'` _(default)_ — `initialize()` resolves immediately from the persisted cache if one exists, then refreshes from the network in the background. Evaluations served before the refresh completes will have reason `CACHED`.
- `'network-first'` — `initialize()` blocks on the network request. The persisted cache is used as a fallback only on transient failures (network unavailable, timeout, 5xx). Auth and configuration errors (400, 401, 403, 404) are always surfaced immediately and never masked by cached values.
- `'disabled'` — no persistence. `initialize()` always blocks on the network and the other cache options have no effect.

**`cacheTTL`** — maximum age in seconds of a persisted cache entry before it is treated as a miss and removed. Defaults to `2_592_000` (30 days).
**`cacheTTL`** — maximum age in seconds of a persisted cache entry before it is treated as a miss and removed. Defaults to `2_592_000` (30 days). Auth and configuration errors do not clear persisted entries; TTL governs expiry.

#### Cache key

Persisted entries are keyed by a hash of key material returned by a cache-key generator, not the full evaluation context. The default generator uses:

1. **`baseUrl`** — the configured OFREP base URL
2. **Auth credential** — serialized from known auth headers (`Authorization`, `Api-Key`, `X-Api-Key`, `X-Auth-Token`, `X-Access-Token`), taken from `headers` and `headersFactory` at read/write time
3. **`domain`** — the OpenFeature domain the provider is bound to via `OpenFeature.setProvider('domain', provider)` (passed to `initialize()` by the SDK; empty when registered as the default provider)
4. **`targetingKey`** — the evaluation context's targeting key

Use **`cacheKeyGenerator`** to customize the key material (namespace instances, drop auth for rotating tokens, or include stable context fields). The provider always hashes whatever the generator returns.

The localStorage key is `ofrep-web-provider:v2:{hash}` where `{hash}` is the first 16 hex characters of SHA-256 (or an FNV-1a fallback in non-secure contexts where `crypto.subtle` is unavailable).

#### Domain scoping

The provider declares itself `domain-scoped`, so each instance is bound to at most one OpenFeature domain via `OpenFeature.setProvider('domain', provider)`. The SDK forwards that domain to `initialize(context, domain?)`; persistence is not initialized until then, so nothing is read from or written to `localStorage` before initialization.

Bind a separate provider instance per domain in micro-frontend or multi-tenant setups:

```ts
OpenFeature.setProvider('billing', new OFREPWebProvider({ baseUrl: 'https://flags.example.com' }));
OpenFeature.setProvider('checkout', new OFREPWebProvider({ baseUrl: 'https://flags.example.com' }));
```

#### Auth headers and rotating tokens

**`cacheKeyPrefix`** — a string included in the cache key to avoid collisions when multiple provider instances share the same browser origin. A good value is the OFREP base URL or a project key.
Only the auth header names listed above participate in the cache key. Other custom headers (for example `X-My-Header`) are sent on requests but do not affect persistence.

```ts
import { OFREPWebProvider } from '@openfeature/ofrep-web-provider';

OpenFeature.setProvider(
'my-app',
new OFREPWebProvider({
baseUrl: 'https://localhost:8080',
cacheMode: 'local-cache-first',
cacheTTL: 3600, // 1 hour
cacheKeyPrefix: 'my-app',
cacheKeyGenerator: (input) => `my-app:${JSON.stringify([input.url, input.auth, input.domain, input.targetingKey])}`,
headers: [['Authorization', 'my-api-key']],
}),
);
```
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { OFREPProviderBaseOptions } from '@openfeature/ofrep-core';
import type { CacheKeyGenerator } from '../store/cache-key';

export type CacheMode = 'local-cache-first' | 'network-first' | 'disabled';

Expand Down Expand Up @@ -65,12 +66,9 @@ export type OFREPWebProviderOptions = OFREPProviderBaseOptions & {
cacheTTL?: number;

/**
* cacheKeyPrefix is included in the cache key hash to prevent collisions when multiple
* OFREP provider instances share the same storage partition (e.g. the same browser origin).
* When set, the cache key becomes `hash(cacheKeyPrefix + ":" + targetingKey)`.
*
* A sensible value is the OFREP base URL, a project key, or any other string that
* uniquely identifies this provider instance.
* cacheKeyGenerator returns the key material the provider hashes into `cacheKeyHash`.
* The default generator uses the OFREP base URL, auth credential, bound `domain`, and `targetingKey`.
* Customize to namespace instances, drop auth for rotating tokens, or include stable context fields.
*/
cacheKeyPrefix?: string;
cacheKeyGenerator?: CacheKeyGenerator;
};
98 changes: 76 additions & 22 deletions libs/providers/ofrep-web/src/lib/ofrep-web-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import TestLogger from '../../test/test-logger';
import type { FlagCache } from './model/in-memory-cache';
import type { PersistedEntry } from './store/storage';
import { Storage } from './store/storage';
import { deriveAuthCredential } from './store/cache-key';
import {
ClientProviderEvents,
ClientProviderStatus,
Expand Down Expand Up @@ -36,6 +37,58 @@ describe('OFREPWebProvider', () => {
lastname: 'Doe',
};

function createTestStorage(domain = ''): Storage {
return new Storage('local-cache-first', endpointBaseURL, () =>
deriveAuthCredential({ baseUrl: endpointBaseURL }),
domain,
);
}

it('declares itself domain-scoped', () => {
const provider = new OFREPWebProvider({ baseUrl: endpointBaseURL });
expect(provider.domainScoped).toBe(true);
});

it('does not read or write persisted storage before initialize', async () => {
const provider = new OFREPWebProvider({ baseUrl: endpointBaseURL, pollInterval: -1 }, new TestLogger());
const storeSpy = jest.spyOn(Storage.prototype, 'store');
const retrieveSpy = jest.spyOn(Storage.prototype, 'retrieve');

await provider.onContextChange?.(defaultContext, {
...defaultContext,
targetingKey: 'other-user',
});

expect(storeSpy).not.toHaveBeenCalled();
expect(retrieveSpy).not.toHaveBeenCalled();

storeSpy.mockRestore();
retrieveSpy.mockRestore();
});

it('uses the bound domain from initialize for persisted cache lookup', async () => {
const boolFlagCache: FlagCache = {
'bool-flag': {
key: 'bool-flag',
value: true,
metadata: TEST_FLAG_METADATA,
reason: StandardResolutionReasons.STATIC,
},
};
const storage = createTestStorage('billing');
await storage.store(defaultContext, boolFlagCache, null);

const provider = new OFREPWebProvider({ baseUrl: endpointBaseURL, pollInterval: -1 }, new TestLogger());
await provider.initialize(defaultContext, 'billing');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((provider as any)._isUsingCache).toBe(true);

const otherDomainProvider = new OFREPWebProvider({ baseUrl: endpointBaseURL, pollInterval: -1 }, new TestLogger());
await otherDomainProvider.initialize(defaultContext, 'checkout');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((otherDomainProvider as any)._isUsingCache).toBe(false);
});

it('should call the READY handler, when the provider is ready', async () => {
const providerName = expect.getState().currentTestName || 'test-provider';
const provider = new OFREPWebProvider({ baseUrl: endpointBaseURL }, new TestLogger());
Expand Down Expand Up @@ -403,13 +456,13 @@ describe('OFREPWebProvider', () => {

it('connects SSE after the background refresh following a local-cache-first cache hit', async () => {
// Seed the cache so initialize() hits the cache-first path.
const storage = new Storage('local-cache-first');
const key = await storage.getStorageKey(defaultContext.targetingKey);
const storage = createTestStorage();
const key = await storage.getStorageKey(defaultContext);
localStorage.setItem(
key,
JSON.stringify({
version: 1,
cacheKeyHash: key,
version: 2,
cacheKeyHash: key.split(':')[2],
etag: null,
writtenAt: new Date().toISOString(),
data: {},
Expand Down Expand Up @@ -444,13 +497,13 @@ describe('OFREPWebProvider', () => {
// start where the flags are unchanged: the background refresh sends If-None-Match
// and the server responds 304 (no body, no eventStreams), so SSE must be established
// from the persisted configuration rather than from a 200 response.
const storage = new Storage('local-cache-first');
const key = await storage.getStorageKey(defaultContext.targetingKey);
const storage = createTestStorage();
const key = await storage.getStorageKey(defaultContext);
localStorage.setItem(
key,
JSON.stringify({
version: 1,
cacheKeyHash: key,
version: 2,
cacheKeyHash: key.split(':')[2],
etag: '"cached-etag"',
writtenAt: new Date().toISOString(),
data: {},
Expand Down Expand Up @@ -830,12 +883,13 @@ describe('OFREPWebProvider', () => {
etag: string | null = null,
writtenAt: Date = new Date(),
metadata?: Record<string, unknown>,
domain = '',
): Promise<void> {
const storage = new Storage('local-cache-first');
const key = await storage.getStorageKey(targetingKey);
const storage = createTestStorage(domain);
const key = await storage.getStorageKey({ targetingKey });
const entry: PersistedEntry = {
version: 1,
cacheKeyHash: key,
version: 2,
cacheKeyHash: key.split(':')[2],
etag,
writtenAt: writtenAt.toISOString(),
data: cache,
Expand Down Expand Up @@ -892,8 +946,8 @@ describe('OFREPWebProvider', () => {

it('does not read or write localStorage when cacheMode is disabled', async () => {
const providerName = expect.getState().currentTestName || 'test-provider';
const storage = new Storage('local-cache-first');
const seededKey = await storage.getStorageKey(defaultContext.targetingKey);
const storage = createTestStorage();
const seededKey = await storage.getStorageKey(defaultContext);
await seedPersistentCache(defaultContext.targetingKey, boolFlagCache);
expect(localStorage.getItem(seededKey)).not.toBeNull();
const provider = new OFREPWebProvider(
Expand Down Expand Up @@ -950,8 +1004,8 @@ describe('OFREPWebProvider', () => {
it('keeps the persisted cache when a background fetch returns 401 (ADR 0009: TTL governs expiry, not auth errors)', async () => {
const providerName = expect.getState().currentTestName || 'test-provider';
await seedPersistentCache(defaultContext.targetingKey, boolFlagCache);
const storage = new Storage('local-cache-first');
const lsKey = await storage.getStorageKey(defaultContext.targetingKey);
const storage = createTestStorage();
const lsKey = await storage.getStorageKey(defaultContext);
expect(localStorage.getItem(lsKey)).not.toBeNull();

server.use(
Expand All @@ -971,8 +1025,8 @@ describe('OFREPWebProvider', () => {
it('keeps the persisted cache when a background fetch returns 400 (ADR 0009: TTL governs expiry, not config errors)', async () => {
const providerName = expect.getState().currentTestName || 'test-provider';
await seedPersistentCache(defaultContext.targetingKey, boolFlagCache);
const storage = new Storage('local-cache-first');
const lsKey = await storage.getStorageKey(defaultContext.targetingKey);
const storage = createTestStorage();
const lsKey = await storage.getStorageKey(defaultContext);

server.use(
http.post('https://localhost:8080/ofrep/v1/evaluate/flags', () =>
Expand All @@ -994,8 +1048,8 @@ describe('OFREPWebProvider', () => {
const expiredDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
await seedPersistentCache(defaultContext.targetingKey, boolFlagCache, null, expiredDate);

const storage = new Storage('local-cache-first');
const lsKey = await storage.getStorageKey(defaultContext.targetingKey);
const storage = createTestStorage();
const lsKey = await storage.getStorageKey(defaultContext);
expect(localStorage.getItem(lsKey)).not.toBeNull(); // Exists before init.

const provider = new OFREPWebProvider({ baseUrl: endpointBaseURL, pollInterval: -1 }, new TestLogger());
Expand Down Expand Up @@ -1048,8 +1102,8 @@ describe('OFREPWebProvider', () => {
await OpenFeature.setContext({ ...defaultContext, targetingKey: user1 });
await OpenFeature.setProviderAndWait(providerName, provider);

const storage = new Storage('local-cache-first');
const user1Key = await storage.getStorageKey(user1);
const storage = createTestStorage();
const user1Key = await storage.getStorageKey({ targetingKey: user1 });
// After init, user1's entry should have been written by the network fetch.
expect(localStorage.getItem(user1Key)).not.toBeNull();

Expand Down
31 changes: 18 additions & 13 deletions libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { BulkEvaluationStatus } from './model/evaluate-flags-response';
import type { FlagCache, MetadataCache } from './model/in-memory-cache';
import type { CacheMode, OFREPWebProviderOptions } from './model/ofrep-web-provider-options';
import { DEFAULT_CACHE_TTL_SECONDS } from './model/ofrep-web-provider-options';
import { defaultCacheKeyGenerator, deriveAuthCredential } from './store/cache-key';
import { Storage } from './store/storage';
import { SseManager } from './sse-manager';

Expand All @@ -52,6 +53,7 @@ export class OFREPWebProvider implements Provider {
name: 'OpenFeature Remote Evaluation Protocol Web Provider',
};
readonly runsOn = 'client';
readonly domainScoped = true;
readonly events = new OpenFeatureEventEmitter();
readonly hooks?: Hook[] | undefined;

Expand All @@ -66,7 +68,7 @@ export class OFREPWebProvider implements Provider {
private _isUsingCache: boolean;
private _context: EvaluationContext | undefined;
private _pollingIntervalId?: number;
private _storage: Storage;
private _storage?: Storage;
private _cacheMode: CacheMode;
private _cacheTTL: number;
private _contextRevision = 0;
Expand All @@ -85,7 +87,6 @@ export class OFREPWebProvider implements Provider {
this._pollingInterval = this._options.pollInterval ?? this.DEFAULT_POLL_INTERVAL;
this._cacheMode = this._options.cacheMode ?? 'local-cache-first';
this._cacheTTL = this._options.cacheTTL ?? DEFAULT_CACHE_TTL_SECONDS;
this._storage = new Storage(this._cacheMode, this._options.cacheKeyPrefix, logger);
this._isUsingCache = false;
}

Expand All @@ -99,9 +100,18 @@ export class OFREPWebProvider implements Provider {
/**
* Initialize the provider, it will evaluate the flags and start the polling if it is not disabled.
* @param context - the context to use for the evaluation
* @param domain - the bound OpenFeature domain, if any
*/
async initialize(context?: EvaluationContext | undefined): Promise<void> {
async initialize(context?: EvaluationContext | undefined, domain?: string): Promise<void> {
try {
this._storage = new Storage(
this._cacheMode,
this._options.baseUrl,
() => deriveAuthCredential(this._options),
domain ?? '',
this._options.cacheKeyGenerator ?? defaultCacheKeyGenerator,
this._logger,
);
this._context = context;

let result: EvaluateFlagsResponse | undefined;
Expand Down Expand Up @@ -178,7 +188,7 @@ export class OFREPWebProvider implements Provider {
try {
if (oldContext?.targetingKey !== newContext?.targetingKey) {
this._etag = null;
void this._storage.clear(oldContext?.targetingKey ?? '');
void this._storage?.clear(oldContext);
}
this._context = newContext;

Expand Down Expand Up @@ -236,6 +246,7 @@ export class OFREPWebProvider implements Provider {
}
this._sseManager?.dispose();
this._sseManager = undefined;
this._storage = undefined;
this._ofrepAPI.close();
return Promise.resolve();
}
Expand All @@ -262,7 +273,7 @@ export class OFREPWebProvider implements Provider {
throw error;
}
// Transient / server errors (5xx, network failures, timeouts) — try the persisted cache as a fallback.
const cached = await this._storage.retrieve(context?.targetingKey ?? '', this._cacheTTL);
const cached = await this._storage?.retrieve(context ?? {}, this._cacheTTL);
if (!cached) {
throw error; // No usable cache — propagate the original error.
}
Expand Down Expand Up @@ -341,13 +352,7 @@ export class OFREPWebProvider implements Provider {
this._flagSetMetadataCache = toFlagMetadata(
typeof bulkSuccessResp.metadata === 'object' ? bulkSuccessResp.metadata : {},
);
await this._storage.store(
context?.targetingKey ?? '',
newCache,
newEtag,
this._flagSetMetadataCache,
this._eventStreams,
);
await this._storage?.store(context ?? {}, newCache, newEtag, this._flagSetMetadataCache, this._eventStreams);
this._etag = newEtag;
this._isUsingCache = false;
return {
Expand Down Expand Up @@ -617,7 +622,7 @@ export class OFREPWebProvider implements Provider {
}

private async _tryLoadFlagsFromCache(context?: EvaluationContext | undefined): Promise<boolean> {
const cached = await this._storage.retrieve(context?.targetingKey ?? '', this._cacheTTL);
const cached = await this._storage?.retrieve(context ?? {}, this._cacheTTL);
if (cached) {
this._isUsingCache = true;
this._flagCache = cached.flags;
Expand Down
Loading
Loading