diff --git a/packages/sdk/vercel/package.json b/packages/sdk/vercel/package.json index a7e1ac96cc..fdc62bcefe 100644 --- a/packages/sdk/vercel/package.json +++ b/packages/sdk/vercel/package.json @@ -20,21 +20,25 @@ "import": "./dist/esm/src/index.js" }, "main": "./dist/cjs/src/index.js", + "types": "./dist/cjs/src/index.d.ts", "files": [ "dist" ], "scripts": { "doc": "../../../scripts/build-doc.sh .", "build": "../../../scripts/build-package.sh", + "clean": "rimraf dist", "tsw": "yarn tsc --watch", "start": "rimraf dist && yarn tsw", "lint": "eslint . --ext .ts", - "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)'", - "test": "jest --ci --runInBand --coverage", + "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", + "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": { "@launchdarkly/js-server-sdk-common-edge": "1.0.1", + "@vercel/edge-config": "^0.1.8", "crypto-js": "^4.1.1" }, "devDependencies": { @@ -42,7 +46,6 @@ "@types/jest": "^29.5.0", "@typescript-eslint/eslint-plugin": "^5.57.0", "@typescript-eslint/parser": "^5.57.0", - "@vercel/edge-config": "^0.1.7", "eslint": "^8.37.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-typescript": "^17.0.0", @@ -52,6 +55,7 @@ "jest": "^29.5.0", "launchdarkly-js-test-helpers": "^2.2.0", "prettier": "^2.8.7", + "rimraf": "^5.0.0", "ts-jest": "^29.1.0", "typedoc": "0.23.26", "typescript": "^5.0.3" diff --git a/packages/sdk/vercel/src/index.test.ts b/packages/sdk/vercel/src/index.test.ts index 76706116fc..342b25a3b8 100644 --- a/packages/sdk/vercel/src/index.test.ts +++ b/packages/sdk/vercel/src/index.test.ts @@ -1,22 +1,21 @@ -import type { EdgeConfigValue } from '@vercel/edge-config'; -import { LDClient } from '@launchdarkly/js-server-sdk-common-edge'; +import { LDClient, LDContext } from '@launchdarkly/js-server-sdk-common-edge'; import { init } from './index'; import * as testData from './utils/testData.json'; -import mockEdge from './utils/mockEdge'; +import mockEdgeConfigClient from './utils/mockEdgeConfigClient'; 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' }; describe('init', () => { let ldClient: LDClient; + const mockGet = mockEdgeConfigClient.get as jest.Mock; beforeAll(async () => { - // I can't figure out a way around the generic types used in @vercel/edge-config causing us type issues here. - // The tests work as expected - // @ts-ignore - mockEdge.get = jest.fn(async () => testData as EdgeConfigValue); - ldClient = init(sdkKey, mockEdge); + mockGet.mockImplementation(() => Promise.resolve(testData)); + ldClient = init(sdkKey, mockEdgeConfigClient); await ldClient.waitForInitialization(); }); @@ -24,28 +23,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/vercel/src/index.ts b/packages/sdk/vercel/src/index.ts index 54056a6be7..1536f7e577 100644 --- a/packages/sdk/vercel/src/index.ts +++ b/packages/sdk/vercel/src/index.ts @@ -11,11 +11,12 @@ import type { EdgeConfigClient } from '@vercel/edge-config'; import { BasicLogger, + EdgeFeatureStore, + EdgeProvider, init as initEdge, LDClient, LDOptions, } from '@launchdarkly/js-server-sdk-common-edge'; -import VercelFeatureStore from './vercelFeatureStore'; import createPlatformInfo from './createPlatformInfo'; export * from '@launchdarkly/js-server-sdk-common-edge'; @@ -33,11 +34,11 @@ export type { LDClient }; * (`new LDClient()/new LDClientImpl()/new LDClient()`); the SDK does not currently support * this. * - * @param edgeConfig - * The Vercel Edge Config client configured for LaunchDarkly. * @param sdkKey * The client side SDK key. This is only used to query the edgeConfig above, * not to connect with LaunchDarkly servers. + * @param edgeConfig + * The Vercel Edge Config client configured for LaunchDarkly. * @param options * Optional configuration settings. The only supported option is logger. * @return @@ -45,8 +46,17 @@ export type { LDClient }; */ export const init = (sdkKey: string, edgeConfig: EdgeConfigClient, options: LDOptions = {}) => { const logger = options.logger ?? BasicLogger.get(); + + // vercel does not support string gets so we have to patch it + const edgeProvider: EdgeProvider = { + get: async (rootKey: string) => { + const json = await edgeConfig.get(rootKey); + return json ? JSON.stringify(json) : null; + }, + }; + return initEdge(sdkKey, createPlatformInfo(), { - featureStore: new VercelFeatureStore(edgeConfig, sdkKey, logger), + featureStore: new EdgeFeatureStore(edgeProvider, sdkKey, 'Vercel', logger), logger, ...options, }); diff --git a/packages/sdk/vercel/src/utils/mockEdge.ts b/packages/sdk/vercel/src/utils/mockEdgeConfigClient.ts similarity index 61% rename from packages/sdk/vercel/src/utils/mockEdge.ts rename to packages/sdk/vercel/src/utils/mockEdgeConfigClient.ts index 1d6d0b3e96..6a62ba0066 100644 --- a/packages/sdk/vercel/src/utils/mockEdge.ts +++ b/packages/sdk/vercel/src/utils/mockEdgeConfigClient.ts @@ -1,10 +1,10 @@ import { EdgeConfigClient } from '@vercel/edge-config'; -const mockEdge: EdgeConfigClient = { +const mockEdgeConfigClient: EdgeConfigClient = { get: jest.fn(), getAll: jest.fn(), digest: jest.fn(), has: jest.fn(), }; -export default mockEdge; +export default mockEdgeConfigClient; diff --git a/packages/sdk/vercel/src/utils/testData.json b/packages/sdk/vercel/src/utils/testData.json index e5ebbdb071..b9e5296c03 100644 --- a/packages/sdk/vercel/src/utils/testData.json +++ b/packages/sdk/vercel/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,9 +103,69 @@ "salt": "aef830243d6640d0a973be89988e008d", "trackEvents": false, "trackEventsFallthrough": false, - "debugEventsUntilDate": null, + "debugEventsUntilDate": 2000, "version": 2, "deleted": false } + }, + "segments": { + "testSegment1": { + "name": "testSegment1", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment1", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [ + { + "id": "rule-country", + "clauses": [ + { + "attribute": "country", + "op": "in", + "values": ["australia"], + "negate": false + } + ] + } + ], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + }, + "testSegment2": { + "name": "testSegment2", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment2", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + } } } diff --git a/packages/sdk/vercel/src/vercelFeatureStore.test.ts b/packages/sdk/vercel/src/vercelFeatureStore.test.ts deleted file mode 100644 index 956f41c70e..0000000000 --- a/packages/sdk/vercel/src/vercelFeatureStore.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { AsyncStoreFacade, LDFeatureStore } from '@launchdarkly/js-server-sdk-common-edge'; -import VercelFeatureStore from './vercelFeatureStore'; - -import mockEdge from './utils/mockEdge'; -import * as testData from './utils/testData.json'; - -describe('VercelFeatureStore', () => { - const sdkKey = 'sdkKey'; - const configKey = `LD-Env-${sdkKey}`; - const mockLogger = { - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - debug: jest.fn(), - }; - const mockGet = mockEdge.get as jest.Mock; - let featureStore: LDFeatureStore; - let asyncFeatureStore: AsyncStoreFacade; - - beforeEach(() => { - mockGet.mockImplementation(() => Promise.resolve(testData)); - featureStore = new VercelFeatureStore(mockEdge, sdkKey, mockLogger); - asyncFeatureStore = new AsyncStoreFacade(featureStore); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('get', () => { - test('get flag', async () => { - const flag = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); - - expect(mockGet).toHaveBeenCalledWith(configKey); - expect(flag).toEqual(testData.flags.testFlag1); - }); - - test('invalid flag key', async () => { - const flag = await asyncFeatureStore.get({ namespace: 'features' }, 'invalid'); - - expect(flag).toBeUndefined(); - }); - - test('invalid namespace key', async () => { - const flag = await asyncFeatureStore.get({ namespace: 'invalid' }, 'testFlag1'); - - expect(flag).toBe(null); - }); - - test('invalid edge config key', async () => { - mockGet.mockImplementation(() => Promise.resolve(null)); - const flag = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); - - expect(flag).toBeNull(); - }); - }); - - describe('all', () => { - test('all flags', async () => { - const flag = await asyncFeatureStore.all({ namespace: 'features' }); - - expect(mockGet).toHaveBeenCalledWith(configKey); - expect(flag).toEqual(testData.flags); - }); - - test('invalid DataKind', async () => { - const flag = await asyncFeatureStore.all({ namespace: 'InvalidDataKind' }); - - expect(flag).toBeUndefined(); - }); - - test('invalid edge config key', async () => { - mockGet.mockImplementation(() => Promise.resolve(null)); - const flag = await asyncFeatureStore.all({ namespace: 'flags11' }); - - expect(flag).toEqual({}); - }); - }); - - describe('initialized', () => { - test('is initialized', async () => { - const isInitialized = await asyncFeatureStore.initialized(); - - expect(mockGet).toHaveBeenCalledWith(configKey); - expect(isInitialized).toBeTruthy(); - }); - - test('not initialized', async () => { - mockGet.mockImplementation(() => Promise.resolve(null)); - const isInitialized = await asyncFeatureStore.initialized(); - - expect(mockGet).toHaveBeenCalledWith(configKey); - expect(isInitialized).toBeFalsy(); - }); - }); - - describe('init & getDescription', () => { - test('init', (done) => { - const cb = jest.fn(() => { - done(); - }); - featureStore.init(testData, cb); - }); - - test('getDescription', async () => { - const description = featureStore.getDescription?.(); - - expect(description).toEqual('Vercel Edge Config'); - }); - }); -}); diff --git a/packages/sdk/vercel/src/vercelFeatureStore.ts b/packages/sdk/vercel/src/vercelFeatureStore.ts deleted file mode 100644 index 851b8ba31a..0000000000 --- a/packages/sdk/vercel/src/vercelFeatureStore.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { EdgeConfigClient } from '@vercel/edge-config'; -import type { - DataKind, - LDLogger, - LDFeatureStore, - LDFeatureStoreDataStorage, - LDFeatureStoreItem, - LDFeatureStoreKindData, -} from '@launchdarkly/js-server-sdk-common-edge'; -import { noop } from '@launchdarkly/js-server-sdk-common-edge'; - -class VercelFeatureStore implements LDFeatureStore { - private edgeConfig: EdgeConfigClient; - - private configKey: string; - - private logger: LDLogger; - - constructor(edgeConfig: EdgeConfigClient, sdkKey: string, logger: LDLogger) { - this.edgeConfig = edgeConfig; - this.configKey = `LD-Env-${sdkKey}`; - this.logger = logger; - } - - async get( - kind: DataKind, - flagKey: string, - callback: (res: LDFeatureStoreItem | null) => void - ): Promise { - this.logger.debug(`Requesting ${flagKey} from ${this.configKey}`); - try { - const config = await this.edgeConfig.get(this.configKey); - if (config === null) { - this.logger.error('Feature data not found in Edge Config.'); - } - const kindKey = kind.namespace === 'features' ? 'flags' : kind.namespace; - const item = config as LDFeatureStoreItem; - callback(item[kindKey][flagKey]); - } catch (err) { - this.logger.error(err); - callback(null); - } - } - - async all(kind: DataKind, callback: (res: LDFeatureStoreKindData) => void = noop): Promise { - const kindKey = kind.namespace === 'features' ? 'flags' : kind.namespace; - this.logger.debug(`Requesting all ${kindKey} data from Edge Config.`); - try { - const config = await this.edgeConfig.get(this.configKey); - if (config === null) { - this.logger.error('Feature data not found in Edge Config.'); - } - const item = config as LDFeatureStoreItem; - callback(item[kindKey]); - } catch (err) { - this.logger.error(err); - callback({}); - } - } - - async initialized(callback: (isInitialized: boolean) => void = noop): Promise { - const config = await this.edgeConfig.get(this.configKey); - const result = config !== null; - this.logger.debug(`Is ${this.configKey} initialized? ${result}`); - callback(result); - } - - init(allData: LDFeatureStoreDataStorage, callback: () => void): void { - callback(); - } - - getDescription(): string { - return 'Vercel Edge Config'; - } - - // unused - close = noop; - - delete = noop; - - upsert = noop; -} - -export default VercelFeatureStore; diff --git a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts b/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts index a01650d0c4..ea20675261 100644 --- a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts +++ b/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts @@ -9,7 +9,7 @@ import type { import { deserializePoll, noop } from '@launchdarkly/js-server-sdk-common'; export interface EdgeProvider { - get: (rootKey: string) => Promise; + get: (rootKey: string) => Promise; } export class EdgeFeatureStore implements LDFeatureStore {