Skip to content
Merged
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
198 changes: 133 additions & 65 deletions packages/sdk/cloudflare/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend hiding whitespace.

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();
});
});
});
25 changes: 21 additions & 4 deletions packages/sdk/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

/**
Expand All @@ -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.
*/
Expand All @@ -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,
});
};
65 changes: 41 additions & 24 deletions packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null | undefined>;
}
Expand All @@ -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}`;
}
Expand All @@ -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);
Expand All @@ -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({});
Expand All @@ -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<ReturnType<typeof deserializePoll>, 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<void> {
const config = await this._edgeProvider.get(this._rootKey);
const result = config !== null;
Expand All @@ -107,8 +121,11 @@ export class EdgeFeatureStore implements LDFeatureStore {
return this._description;
}

close(): void {
return this._cache?.close();
}

// unused
close = noop;

delete = noop;

Expand Down
Loading