diff --git a/packages/sdk/cloudflare/__tests__/index.test.ts b/packages/sdk/cloudflare/__tests__/index.test.ts index 941438c0a5..5b1f760fed 100644 --- a/packages/sdk/cloudflare/__tests__/index.test.ts +++ b/packages/sdk/cloudflare/__tests__/index.test.ts @@ -21,87 +21,155 @@ const namespace = 'LD_KV'; const rootEnvKey = `LD-Env-${clientSideID}`; describe('init', () => { - let kv: KVNamespace; - let ldClient: LDClient; - - beforeAll(async () => { - kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace; - await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments)); - ldClient = init(clientSideID, kv); - await ldClient.waitForInitialization(); - }); - - afterAll(() => { - ldClient.close(); - }); + describe('without caching', () => { + let kv: KVNamespace; + let ldClient: LDClient; + + beforeAll(async () => { + kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace; + await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments)); + ldClient = init(clientSideID, kv); + await ldClient.waitForInitialization(); + }); - describe('flags', () => { - test('variation default', async () => { - const value = await ldClient.variation(flagKey1, context, false); - expect(value).toBeTruthy(); + afterAll(() => { + ldClient.close(); }); - test('variation default rollout', async () => { - const contextWithEmail = { ...context, email: 'test@yahoo.com' }; - const value = await ldClient.variation(flagKey2, contextWithEmail, false); - const detail = await ldClient.variationDetail(flagKey2, contextWithEmail, false); + describe('flags', () => { + it('variation default', async () => { + const value = await ldClient.variation(flagKey1, context, false); + expect(value).toBeTruthy(); + }); - expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); - expect(value).toBeTruthy(); - }); + it('variation default rollout', async () => { + const contextWithEmail = { ...context, email: 'test@yahoo.com' }; + const value = await ldClient.variation(flagKey2, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey2, contextWithEmail, false); - test('rule match', async () => { - const contextWithEmail = { ...context, email: 'test@falsemail.com' }; - const value = await ldClient.variation(flagKey1, contextWithEmail, false); - const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); + expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); + expect(value).toBeTruthy(); + }); - expect(detail).toEqual({ - reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, - value: false, - variationIndex: 1, + it('rule match', async () => { + const contextWithEmail = { ...context, email: 'test@falsemail.com' }; + const value = await ldClient.variation(flagKey1, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); + + expect(detail).toEqual({ + reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, + value: false, + variationIndex: 1, + }); + expect(value).toBeFalsy(); }); - expect(value).toBeFalsy(); - }); - test('fallthrough', async () => { - const contextWithEmail = { ...context, email: 'test@yahoo.com' }; - const value = await ldClient.variation(flagKey1, contextWithEmail, false); - const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); + it('fallthrough', async () => { + const contextWithEmail = { ...context, email: 'test@yahoo.com' }; + const value = await ldClient.variation(flagKey1, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); - expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); - expect(value).toBeTruthy(); + expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); + expect(value).toBeTruthy(); + }); + + it('allFlags fallthrough', async () => { + const allFlags = await ldClient.allFlagsState(context); + + expect(allFlags).toBeDefined(); + expect(allFlags.toJSON()).toEqual({ + $flagsState: { + testFlag1: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + testFlag2: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + testFlag3: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + }, + $valid: true, + testFlag1: true, + testFlag2: true, + testFlag3: true, + }); + }); }); - test('allFlags fallthrough', async () => { - const allFlags = await ldClient.allFlagsState(context); - - expect(allFlags).toBeDefined(); - expect(allFlags.toJSON()).toEqual({ - $flagsState: { - testFlag1: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, - testFlag2: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, - testFlag3: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, - }, - $valid: true, - testFlag1: true, - testFlag2: true, - testFlag3: true, + describe('segments', () => { + it('segment by country', async () => { + const contextWithCountry = { ...context, country: 'australia' }; + const value = await ldClient.variation(flagKey3, contextWithCountry, false); + const detail = await ldClient.variationDetail(flagKey3, contextWithCountry, false); + + expect(detail).toEqual({ + reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, + value: false, + variationIndex: 1, + }); + expect(value).toBeFalsy(); }); }); }); - describe('segments', () => { - test('segment by country', async () => { - const contextWithCountry = { ...context, country: 'australia' }; - const value = await ldClient.variation(flagKey3, contextWithCountry, false); - const detail = await ldClient.variationDetail(flagKey3, contextWithCountry, false); + describe('with caching', () => { + it('will cache across multiple variation calls', async () => { + const kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace; + await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments)); + const ldClient = init(clientSideID, kv, { cache: { ttl: 60, checkInterval: 600 } }); - expect(detail).toEqual({ - reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, - value: false, - variationIndex: 1, - }); - expect(value).toBeFalsy(); + await ldClient.waitForInitialization(); + const spy = jest.spyOn(kv, 'get'); + await ldClient.variation(flagKey1, context, false); + await ldClient.variation(flagKey2, context, false); + ldClient.close(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('will cache across multiple allFlags calls', async () => { + const kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace; + await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments)); + const ldClient = init(clientSideID, kv, { cache: { ttl: 60, checkInterval: 600 } }); + + await ldClient.waitForInitialization(); + const spy = jest.spyOn(kv, 'get'); + await ldClient.allFlagsState(context); + await ldClient.allFlagsState(context); + ldClient.close(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('will cache between allFlags and variation', async () => { + const kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace; + await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments)); + const ldClient = init(clientSideID, kv, { cache: { ttl: 60, checkInterval: 600 } }); + + await ldClient.waitForInitialization(); + const spy = jest.spyOn(kv, 'get'); + await ldClient.variation(flagKey1, context, false); + await ldClient.allFlagsState(context); + ldClient.close(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('will eventually expire', async () => { + jest.spyOn(Date, 'now').mockImplementation(() => 0); + + const kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace; + await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments)); + const ldClient = init(clientSideID, kv, { cache: { ttl: 60, checkInterval: 600 } }); + + await ldClient.waitForInitialization(); + const spy = jest.spyOn(kv, 'get'); + await ldClient.variation(flagKey1, context, false); + await ldClient.variation(flagKey2, context, false); + + expect(spy).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockImplementation(() => 60 * 1000 + 1); + + await ldClient.variation(flagKey2, context, false); + expect(spy).toHaveBeenCalledTimes(2); + + ldClient.close(); }); }); }); diff --git a/packages/sdk/cloudflare/src/index.ts b/packages/sdk/cloudflare/src/index.ts index 064e6e969b..d4986ad1c5 100644 --- a/packages/sdk/cloudflare/src/index.ts +++ b/packages/sdk/cloudflare/src/index.ts @@ -14,14 +14,28 @@ import { BasicLogger, EdgeFeatureStore, init as initEdge, + internalServer, type LDClient, - type LDOptions, + type LDOptions as LDOptionsCommon, } from '@launchdarkly/js-server-sdk-common-edge'; import createPlatformInfo from './createPlatformInfo'; export * from '@launchdarkly/js-server-sdk-common-edge'; +export type TtlCacheOptions = internalServer.TtlCacheOptions; + +/** + * The Launchdarkly Edge SDKs configuration options. + */ +type LDOptions = { + /** + * Optional TTL cache configuration which allows for caching feature flags in + * memory. + */ + cache?: TtlCacheOptions; +} & LDOptionsCommon; + export type { LDClient }; /** @@ -41,7 +55,7 @@ export type { LDClient }; * @param kvNamespace * The Cloudflare KV configured for LaunchDarkly. * @param options - * Optional configuration settings. The only supported option is logger. + * Optional configuration settings. * @return * The new {@link LDClient} instance. */ @@ -51,9 +65,12 @@ export const init = ( options: LDOptions = {}, ): LDClient => { const logger = options.logger ?? BasicLogger.get(); + + const { cache: _cacheOptions, ...rest } = options; + const cache = options.cache ? new internalServer.TtlCache(options.cache) : undefined; return initEdge(clientSideID, createPlatformInfo(), { - featureStore: new EdgeFeatureStore(kvNamespace, clientSideID, 'Cloudflare', logger), + featureStore: new EdgeFeatureStore(kvNamespace, clientSideID, 'Cloudflare', logger, cache), logger, - ...options, + ...rest, }); }; diff --git a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts b/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts index 039ee4a300..2d62150b7c 100644 --- a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts +++ b/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts @@ -8,6 +8,8 @@ import type { } from '@launchdarkly/js-server-sdk-common'; import { deserializePoll, noop } from '@launchdarkly/js-server-sdk-common'; +import Cache from './cache'; + export interface EdgeProvider { get: (rootKey: string) => Promise; } @@ -20,6 +22,7 @@ export class EdgeFeatureStore implements LDFeatureStore { sdkKey: string, private readonly _description: string, private _logger: LDLogger, + private _cache?: Cache, ) { this._rootKey = `LD-Env-${sdkKey}`; } @@ -34,23 +37,14 @@ export class EdgeFeatureStore implements LDFeatureStore { this._logger.debug(`Requesting ${dataKey} from ${this._rootKey}.${kindKey}`); try { - const i = await this._edgeProvider.get(this._rootKey); - - if (!i) { - throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); - } - - const item = deserializePoll(i); - if (!item) { - throw new Error(`Error deserializing ${kindKey}`); - } + const storePayload = await this._getStorePayload(); switch (namespace) { case 'features': - callback(item.flags[dataKey]); + callback(storePayload.flags[dataKey]); break; case 'segments': - callback(item.segments[dataKey]); + callback(storePayload.segments[dataKey]); break; default: callback(null); @@ -66,22 +60,14 @@ export class EdgeFeatureStore implements LDFeatureStore { const kindKey = namespace === 'features' ? 'flags' : namespace; this._logger.debug(`Requesting all from ${this._rootKey}.${kindKey}`); try { - const i = await this._edgeProvider.get(this._rootKey); - if (!i) { - throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); - } - - const item = deserializePoll(i); - if (!item) { - throw new Error(`Error deserializing ${kindKey}`); - } + const storePayload = await this._getStorePayload(); switch (namespace) { case 'features': - callback(item.flags); + callback(storePayload.flags); break; case 'segments': - callback(item.segments); + callback(storePayload.segments); break; default: callback({}); @@ -92,6 +78,34 @@ export class EdgeFeatureStore implements LDFeatureStore { } } + /** + * This method is used to retrieve the environment payload from the edge + * provider. If a cache is provided, it will serve from that. + */ + private async _getStorePayload(): Promise< + Exclude, undefined> + > { + let payload = this._cache?.get(this._rootKey); + if (payload !== undefined) { + return payload; + } + + const providerData = await this._edgeProvider.get(this._rootKey); + + if (!providerData) { + throw new Error(`${this._rootKey} is not found in KV.`); + } + + payload = deserializePoll(providerData); + if (!payload) { + throw new Error(`Error deserializing ${this._rootKey}`); + } + + this._cache?.set(this._rootKey, payload); + + return payload; + } + async initialized(callback: (isInitialized: boolean) => void = noop): Promise { const config = await this._edgeProvider.get(this._rootKey); const result = config !== null; @@ -107,8 +121,11 @@ export class EdgeFeatureStore implements LDFeatureStore { return this._description; } + close(): void { + return this._cache?.close(); + } + // unused - close = noop; delete = noop; diff --git a/packages/shared/sdk-server-edge/src/api/cache.ts b/packages/shared/sdk-server-edge/src/api/cache.ts new file mode 100644 index 0000000000..e33ad61b46 --- /dev/null +++ b/packages/shared/sdk-server-edge/src/api/cache.ts @@ -0,0 +1,24 @@ +/** + * General-purpose cache interface. + * + * This is used by the SDK to cache feature flags and other data. The SDK does + * not assume any particular implementation of the cache, so you can provide + * your own. + */ +export default interface Cache { + /** + * Get a value from the cache. Returning `undefined` means the key was not found. + */ + get(key: string): any; + + /** + * Set a value in the cache. + */ + set(key: string, value: any): void; + + /** + * The close method offers a way to clean up any resources used by the cache + * on shutdown. + */ + close(): void; +} diff --git a/packages/shared/sdk-server-edge/src/api/index.ts b/packages/shared/sdk-server-edge/src/api/index.ts index c4ae612f9d..569e65c0c7 100644 --- a/packages/shared/sdk-server-edge/src/api/index.ts +++ b/packages/shared/sdk-server-edge/src/api/index.ts @@ -1,4 +1,5 @@ +import Cache from './cache'; import LDClient from './LDClient'; export * from './EdgeFeatureStore'; -export { LDClient }; +export { LDClient, Cache }; diff --git a/packages/shared/sdk-server-edge/src/index.ts b/packages/shared/sdk-server-edge/src/index.ts index 24a6c49b77..b539aaa499 100644 --- a/packages/shared/sdk-server-edge/src/index.ts +++ b/packages/shared/sdk-server-edge/src/index.ts @@ -7,12 +7,12 @@ */ import type { Info } from '@launchdarkly/js-server-sdk-common'; -import { EdgeFeatureStore, EdgeProvider, LDClient } from './api'; +import { Cache, EdgeFeatureStore, EdgeProvider, LDClient } from './api'; import validateOptions, { LDOptions, LDOptionsInternal } from './utils/validateOptions'; export * from '@launchdarkly/js-server-sdk-common'; export { EdgeFeatureStore }; -export type { LDClient, LDOptions, EdgeProvider }; +export type { LDClient, LDOptions, EdgeProvider, Cache }; /** * Do not use this function directly.