diff --git a/packages/sdk/cloudflare/example/package.json b/packages/sdk/cloudflare/example/package.json index 2af63e6b9b..0a611ef0c0 100644 --- a/packages/sdk/cloudflare/example/package.json +++ b/packages/sdk/cloudflare/example/package.json @@ -5,7 +5,7 @@ "module": "./dist/index.mjs", "packageManager": "yarn@3.4.1", "dependencies": { - "@launchdarkly/cloudflare-server-sdk": "0.0.1" + "@launchdarkly/cloudflare-server-sdk": "2.0.1" }, "devDependencies": { "@cloudflare/workers-types": "^4.20230321.0", diff --git a/packages/sdk/cloudflare/example/src/index.ts b/packages/sdk/cloudflare/example/src/index.ts index fc8804da43..9658d32c07 100644 --- a/packages/sdk/cloudflare/example/src/index.ts +++ b/packages/sdk/cloudflare/example/src/index.ts @@ -4,7 +4,7 @@ export default { async fetch(request: Request, env: Bindings): Promise { const sdkKey = 'test-sdk-key'; const flagKey = 'testFlag1'; - const context = { kind: 'user', key: 'test-user-key-1' }; + const context = { kind: 'user', key: 'test-user-key-1', email: 'test@gmail.com' }; // start using ld const client = initLD(sdkKey, env.LD_KV); @@ -13,9 +13,10 @@ export default { const flagDetail = await client.variationDetail(flagKey, context, false); const allFlags = await client.allFlagsState(context); - const resp = `${flagKey}: ${flagValue}, detail: ${JSON.stringify( - flagDetail - )}, allFlags: ${JSON.stringify(allFlags)}`; + const resp = ` + ${flagKey}: ${flagValue} + detail: ${JSON.stringify(flagDetail)} + allFlags: ${JSON.stringify(allFlags)}`; // eslint-disable-next-line console.log(`------------- ${resp}`); diff --git a/packages/sdk/cloudflare/example/src/testData.json b/packages/sdk/cloudflare/example/src/testData.json index c3498bd291..495819a24d 100644 --- a/packages/sdk/cloudflare/example/src/testData.json +++ b/packages/sdk/cloudflare/example/src/testData.json @@ -5,7 +5,26 @@ "on": true, "prerequisites": [], "targets": [], - "rules": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "contextKind": "user", + "attribute": "/email", + "op": "contains", + "values": ["gmail"], + "negate": false + } + ], + "trackEvents": false, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }] + } + } + ], "fallthrough": { "variation": 0 }, @@ -25,10 +44,52 @@ }, "testFlag2": { "key": "testFlag2", - "on": false, + "on": true, "prerequisites": [], "targets": [], "rules": [], + "fallthrough": { + "variation": 0, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }], + "contextKind:": "user", + "attribute": "/email" + } + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": null, + "version": 2, + "deleted": false + }, + "testFlag3": { + "key": "testFlag3", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "op": "segmentMatch", + "values": ["testSegment1"], + "negate": false + } + ], + "trackEvents": false + } + ], "fallthrough": { "variation": 0 }, @@ -65,7 +126,19 @@ }, "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } }, - "rules": [], + "rules": [ + { + "id": "rule-country", + "clauses": [ + { + "attribute": "country", + "op": "in", + "values": ["australia"], + "negate": false + } + ] + } + ], "version": 1, "deleted": false, "_access": { "denied": [], "allowed": [] }, diff --git a/packages/sdk/cloudflare/package.json b/packages/sdk/cloudflare/package.json index d4735de2ce..a799fc63eb 100644 --- a/packages/sdk/cloudflare/package.json +++ b/packages/sdk/cloudflare/package.json @@ -33,7 +33,8 @@ "start": "rimraf dist && yarn tsw", "lint": "eslint . --ext .ts", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", - "test": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" jest --ci --runInBand --coverage", + "test": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" jest --ci --runInBand", + "coverage": "yarn test --coverage", "check": "yarn prettier && yarn lint && yarn build && yarn test && yarn doc" }, "dependencies": { diff --git a/packages/sdk/cloudflare/src/createFeatureStore.test.ts b/packages/sdk/cloudflare/src/createFeatureStore.test.ts index cda7e2f9c8..6f1b7738a3 100644 --- a/packages/sdk/cloudflare/src/createFeatureStore.test.ts +++ b/packages/sdk/cloudflare/src/createFeatureStore.test.ts @@ -18,7 +18,7 @@ describe('createFeatureStore', () => { let asyncFeatureStore: AsyncStoreFacade; beforeEach(() => { - mockGet.mockImplementation(() => Promise.resolve(testData)); + mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData))); featureStore = createFeatureStore(mockKV, sdkKey, mockLogger); asyncFeatureStore = new AsyncStoreFacade(featureStore); }); @@ -31,8 +31,8 @@ describe('createFeatureStore', () => { test('get flag', async () => { const flag = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); - expect(mockGet).toHaveBeenCalledWith(kvKey, { type: 'json' }); - expect(flag).toEqual(testData.flags.testFlag1); + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(flag).toMatchObject(testData.flags.testFlag1); }); test('invalid flag key', async () => { @@ -44,8 +44,8 @@ describe('createFeatureStore', () => { test('get segment', async () => { const segment = await asyncFeatureStore.get({ namespace: 'segments' }, 'testSegment1'); - expect(mockGet).toHaveBeenCalledWith(kvKey, { type: 'json' }); - expect(segment).toEqual(testData.segments.testSegment1); + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(segment).toMatchObject(testData.segments.testSegment1); }); test('invalid segment key', async () => { @@ -64,23 +64,23 @@ describe('createFeatureStore', () => { describe('all', () => { test('all flags', async () => { - const flag = await asyncFeatureStore.all({ namespace: 'features' }); + const flags = await asyncFeatureStore.all({ namespace: 'features' }); - expect(mockGet).toHaveBeenCalledWith(kvKey, { type: 'json' }); - expect(flag).toEqual(testData.flags); + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(flags).toMatchObject(testData.flags); }); test('all segments', async () => { const segment = await asyncFeatureStore.all({ namespace: 'segments' }); - expect(mockGet).toHaveBeenCalledWith(kvKey, { type: 'json' }); - expect(segment).toEqual(testData.segments); + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(segment).toMatchObject(testData.segments); }); test('invalid DataKind', async () => { const flag = await asyncFeatureStore.all({ namespace: 'InvalidDataKind' }); - expect(flag).toBeUndefined(); + expect(flag).toEqual({}); }); test('invalid kv key', async () => { diff --git a/packages/sdk/cloudflare/src/createFeatureStore.ts b/packages/sdk/cloudflare/src/createFeatureStore.ts index 87b19a615a..2b0b82e2d4 100644 --- a/packages/sdk/cloudflare/src/createFeatureStore.ts +++ b/packages/sdk/cloudflare/src/createFeatureStore.ts @@ -7,6 +7,7 @@ import { LDFeatureStoreItem, LDFeatureStoreKindData, noop, + deserializePoll, } from '@launchdarkly/js-server-sdk-common-edge'; const createFeatureStore = (kvNamespace: KVNamespace, sdkKey: string, logger: LDLogger) => { @@ -14,19 +15,35 @@ const createFeatureStore = (kvNamespace: KVNamespace, sdkKey: string, logger: LD const store: LDFeatureStore = { get( kind: DataKind, - flagKey: string, + dataKey: string, callback: (res: LDFeatureStoreItem | null) => void = noop ): void { - logger.debug(`Requesting ${flagKey} from ${key}`); + const { namespace } = kind; + const kindKey = namespace === 'features' ? 'flags' : namespace; + logger.debug(`Requesting ${dataKey} from ${key}.${kindKey}`); + kvNamespace - .get(key, { type: 'json' }) + .get(key) .then((i) => { - if (i === null) { - logger.error('Feature data not found in KV.'); + if (!i) { + throw new Error(`${key}.${kindKey} is not found in KV.`); + } + + const item = deserializePoll(i); + if (!item) { + throw new Error(`Error deserializing ${kindKey}`); + } + + switch (namespace) { + case 'features': + callback(item.flags[dataKey]); + break; + case 'segments': + callback(item.segments[dataKey]); + break; + default: + throw new Error(`Unsupported DataKind: ${namespace}`); } - const kindKey = kind.namespace === 'features' ? 'flags' : kind.namespace; - const item = i as LDFeatureStoreItem; - callback(item[kindKey][flagKey]); }) .catch((err) => { logger.error(err); @@ -34,16 +51,31 @@ const createFeatureStore = (kvNamespace: KVNamespace, sdkKey: string, logger: LD }); }, all(kind: DataKind, callback: (res: LDFeatureStoreKindData) => void = noop): void { - const kindKey = kind.namespace === 'features' ? 'flags' : kind.namespace; - logger.debug(`Requesting all ${kindKey} data from KV.`); + const { namespace } = kind; + const kindKey = namespace === 'features' ? 'flags' : namespace; + logger.debug(`Requesting all from ${key}.${kindKey}`); kvNamespace - .get(key, { type: 'json' }) + .get(key) .then((i) => { - if (i === null) { - logger.error('Feature data not found in KV.'); + if (!i) { + throw new Error(`${key}.${kindKey} is not found in KV.`); + } + + const item = deserializePoll(i); + if (!item) { + throw new Error(`Error deserializing ${kindKey}`); + } + + switch (namespace) { + case 'features': + callback(item.flags); + break; + case 'segments': + callback(item.segments); + break; + default: + throw new Error(`Unsupported DataKind: ${namespace}`); } - const item = i as LDFeatureStoreItem; - callback(item[kindKey]); }) .catch((err) => { logger.error(err); diff --git a/packages/sdk/cloudflare/src/index.test.ts b/packages/sdk/cloudflare/src/index.test.ts index 4db9f790a1..7a7847ffe5 100644 --- a/packages/sdk/cloudflare/src/index.test.ts +++ b/packages/sdk/cloudflare/src/index.test.ts @@ -1,5 +1,5 @@ import type { KVNamespace } from '@cloudflare/workers-types'; -import { LDClient } from '@launchdarkly/js-server-sdk-common-edge'; +import { LDClient, LDContext } from '@launchdarkly/js-server-sdk-common-edge'; import { Miniflare } from 'miniflare'; import { init } from './index'; import * as allFlagsSegments from './utils/testData.json'; @@ -11,8 +11,10 @@ const mf = new Miniflare({ }); const sdkKey = 'test-sdk-key'; -const flagKey = 'testFlag1'; -const context = { kind: 'user', key: 'test-user-key-1' }; +const flagKey1 = 'testFlag1'; +const flagKey2 = 'testFlag2'; +const flagKey3 = 'testFlag3'; +const context: LDContext = { kind: 'user', key: 'test-user-key-1' }; const namespace = 'LD_KV'; const rootEnvKey = `LD-Env-${sdkKey}`; @@ -31,28 +33,73 @@ describe('init', () => { ldClient.close(); }); - test('variation', async () => { - const flagDetail = await ldClient.variation(flagKey, context, false); - expect(flagDetail).toBeTruthy(); - }); + describe('flags', () => { + test('variation default', async () => { + const value = await ldClient.variation(flagKey1, context, false); + expect(value).toBeTruthy(); + }); + + 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); + + expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); + expect(value).toBeTruthy(); + }); + + test('rule match', async () => { + const contextWithEmail = { ...context, email: 'test@gmail.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(); + }); + + 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); + + expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); + expect(value).toBeTruthy(); + }); - test('variationDetail', async () => { - const flagDetail = await ldClient.variationDetail(flagKey, context, false); - expect(flagDetail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); + 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, + }); + }); }); - test('allFlags', async () => { - const allFlags = await ldClient.allFlagsState(context); - - expect(allFlags).toBeDefined(); - expect(allFlags.toJSON()).toEqual({ - $flagsState: { - testFlag1: { debugEventsUntilDate: null, variation: 0, version: 2 }, - testFlag2: { debugEventsUntilDate: null, variation: 1, version: 2 }, - }, - $valid: true, - testFlag1: true, - testFlag2: false, + 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); + + expect(detail).toEqual({ + reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, + value: false, + variationIndex: 1, + }); + expect(value).toBeFalsy(); }); }); }); diff --git a/packages/sdk/cloudflare/src/utils/testData.json b/packages/sdk/cloudflare/src/utils/testData.json index c3498bd291..b9e5296c03 100644 --- a/packages/sdk/cloudflare/src/utils/testData.json +++ b/packages/sdk/cloudflare/src/utils/testData.json @@ -5,7 +5,26 @@ "on": true, "prerequisites": [], "targets": [], - "rules": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "contextKind": "user", + "attribute": "/email", + "op": "contains", + "values": ["gmail"], + "negate": false + } + ], + "trackEvents": false, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }] + } + } + ], "fallthrough": { "variation": 0 }, @@ -19,16 +38,58 @@ "salt": "aef830243d6640d0a973be89988e008d", "trackEvents": false, "trackEventsFallthrough": false, - "debugEventsUntilDate": null, + "debugEventsUntilDate": 2000, "version": 2, "deleted": false }, "testFlag2": { "key": "testFlag2", - "on": false, + "on": true, "prerequisites": [], "targets": [], "rules": [], + "fallthrough": { + "variation": 0, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }], + "contextKind:": "user", + "attribute": "/email" + } + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + }, + "testFlag3": { + "key": "testFlag3", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "op": "segmentMatch", + "values": ["testSegment1"], + "negate": false + } + ], + "trackEvents": false + } + ], "fallthrough": { "variation": 0 }, @@ -42,7 +103,7 @@ "salt": "aef830243d6640d0a973be89988e008d", "trackEvents": false, "trackEventsFallthrough": false, - "debugEventsUntilDate": null, + "debugEventsUntilDate": 2000, "version": 2, "deleted": false } @@ -65,7 +126,19 @@ }, "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } }, - "rules": [], + "rules": [ + { + "id": "rule-country", + "clauses": [ + { + "attribute": "country", + "op": "in", + "values": ["australia"], + "negate": false + } + ] + } + ], "version": 1, "deleted": false, "_access": { "denied": [], "allowed": [] }, diff --git a/packages/shared/sdk-server/src/store/index.ts b/packages/shared/sdk-server/src/store/index.ts index ff3b0c3049..c8c7c0e463 100644 --- a/packages/shared/sdk-server/src/store/index.ts +++ b/packages/shared/sdk-server/src/store/index.ts @@ -1,4 +1,4 @@ import AsyncStoreFacade from './AsyncStoreFacade'; +import { deserializePoll } from './serialization'; -// eslint-disable-next-line import/prefer-default-export -export { AsyncStoreFacade }; +export { AsyncStoreFacade, deserializePoll }; diff --git a/packages/shared/sdk-server/src/store/serialization.ts b/packages/shared/sdk-server/src/store/serialization.ts index 62cef866bb..945f3b00c3 100644 --- a/packages/shared/sdk-server/src/store/serialization.ts +++ b/packages/shared/sdk-server/src/store/serialization.ts @@ -157,6 +157,14 @@ export function deserializeAll(data: string): AllData | undefined { return parsed; } +/** + * This function is intended for usage inside LaunchDarkly SDKs. + * This function should NOT be used by customer applications. + * This function may be changed or removed without a major version. + * + * @param data String data from launchdarkly. + * @returns The parsed and processed data. + */ export function deserializePoll(data: string): FlagsAndSegments | undefined { const parsed = tryParse(data) as FlagsAndSegments;