diff --git a/.gitignore b/.gitignore index 530ebe7..5fef906 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,6 @@ dist .tern-port # Build output -es/ -lib/ -types/ +/es/ +/lib/ +/types/ diff --git a/CHANGES.txt b/CHANGES.txt index c130bed..37daa13 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,7 @@ +1.3.0 (February 12, 2025) + - ConfigurationChanged event now forwards SDK_UPDATE metadata from Split (flagsChanged, metadata with type and names) + - Requires @splitsoftware/splitio ^11.10.0 for SDK_UPDATE metadata support + 1.2.0 (November 7, 2025) - Updated @openfeature/server-sdk to 1.20.0 - Updated @splitsoftware/splitio to 11.8.0 diff --git a/README.md b/README.md index 17b2ad4..390a5fb 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ const OpenFeatureSplitProvider = require('@splitsoftware/openfeature-js-split-pr const authorizationKey = 'your auth key' const provider = new OpenFeatureSplitProvider(authorizationKey); -OpenFeature.setProvider(provider); +await OpenFeature.setProviderAndWait(provider); +const client = OpenFeature.getClient('my-app'); +// safe to evaluate ``` ### Register the Split provider with OpenFeature using splitFactory @@ -42,7 +44,9 @@ const OpenFeatureSplitProvider = require('@splitsoftware/openfeature-js-split-pr const authorizationKey = 'your auth key' const splitFactory = SplitFactory({core: {authorizationKey}}); const provider = new OpenFeatureSplitProvider(splitFactory); -OpenFeature.setProvider(provider); +await OpenFeature.setProviderAndWait(provider); +const client = OpenFeature.getClient('my-app'); +// safe to evaluate ``` ### Register the Split provider with OpenFeature using splitClient @@ -54,7 +58,9 @@ const OpenFeatureSplitProvider = require('@splitsoftware/openfeature-js-split-pr const authorizationKey = 'your auth key' const splitClient = SplitFactory({core: {authorizationKey}}).client(); const provider = new OpenFeatureSplitProvider({splitClient}); -OpenFeature.setProvider(provider); +await OpenFeature.setProviderAndWait(provider); +const client = OpenFeature.getClient('my-app'); +// safe to evaluate ``` ## Use of OpenFeature with Split @@ -94,6 +100,23 @@ const booleanTreatment = await client.getBooleanDetails('boolFlag', false, conte const config = booleanTreatment.flagMetadata.config ``` +## Configuration changed event (SDK_UPDATE) + +When the Split SDK emits the `SDK_UPDATE` **event** (flags or segments changed), the provider emits OpenFeature’s `ConfigurationChanged` and forwards the event metadata. The metadata shape matches [javascript-commons SdkUpdateMetadata](https://github.com/splitio/javascript-commons): `type` is `'FLAGS_UPDATE' | 'SEGMENTS_UPDATE'` and `names` is the list of flag or segment names that were updated. Handlers receive [Provider Event Details](https://openfeature.dev/specification/types#provider-event-details): `flagsChanged` (when `type === 'FLAGS_UPDATE'`, the `names` array) and `metadata` (`type` and `names` as JSON string). + +Requires `@splitsoftware/splitio` **11.10.0 or later** (metadata was added in 11.10.0). + +```js +const { OpenFeature } = require('@openfeature/server-sdk'); +const { ProviderEvents } = require('@openfeature/server-sdk'); + +const client = OpenFeature.getClient(); +client.addHandler(ProviderEvents.ConfigurationChanged, (eventDetails) => { + console.log('Flags changed:', eventDetails.flagsChanged); + console.log('Event metadata:', eventDetails.metadata); +}); +``` + ## Tracking To use track(eventName, context, details) you must provide: diff --git a/package-lock.json b/package-lock.json index bdd786f..8c850a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "devDependencies": { "@eslint/js": "^9.35.0", "@openfeature/server-sdk": "^1.20.0", - "@splitsoftware/splitio": "^11.8.0", + "@splitsoftware/splitio": "^11.10.0", "@types/jest": "^30.0.0", "@types/node": "^24.3.1", "copyfiles": "^2.4.1", @@ -34,7 +34,7 @@ }, "peerDependencies": { "@openfeature/server-sdk": "^1.20.0", - "@splitsoftware/splitio": "^11.8.0" + "@splitsoftware/splitio": "^11.10.0" } }, "node_modules/@babel/code-frame": { @@ -682,9 +682,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -1786,16 +1786,16 @@ } }, "node_modules/@splitsoftware/splitio": { - "version": "11.8.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.8.0.tgz", - "integrity": "sha512-M9ENeH+IEmxwELeCdXgnTbLg+ZP3SRUMM6lImSbv7mD32u1v6ihnUhnhsTJzlQWMDC4H94EAW345q1cO7ovlTQ==", + "version": "11.10.1", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.10.1.tgz", + "integrity": "sha512-ZJuLAAZqe8zRgcnRNzcJQOANIQECq5BRFOFnfnp+mwPBxe9D02UFCJs5M7biLl86ephrbXY0hNYrZMtBVmRQJQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@splitsoftware/splitio-commons": "2.8.0", + "@splitsoftware/splitio-commons": "2.11.0", "bloom-filters": "^3.0.4", "ioredis": "^4.28.0", - "js-yaml": "^3.13.1", + "js-yaml": "^4.1.1", "node-fetch": "^2.7.0", "tslib": "^2.3.1", "unfetch": "^4.2.0" @@ -1805,9 +1805,9 @@ } }, "node_modules/@splitsoftware/splitio-commons": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.8.0.tgz", - "integrity": "sha512-QgHUreMOEDwf4GZzVPu4AzkZJvuaeSoHsiJc4tT3CxSIYl2bKMz1SSDlI1tW/oVbIFeWjkrIp2lCYEyUBgcvyA==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.11.0.tgz", + "integrity": "sha512-/cY9V2CHG2EnOAJp3vVWcs+ZqJ3zqEKHdKX115cK6zHKRMNDXODuPQSX7CIkuCLr6C0kQMQuBnXwcaf5C+cO1A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1823,6 +1823,26 @@ } } }, + "node_modules/@splitsoftware/splitio/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@splitsoftware/splitio/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -3007,9 +3027,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3809,9 +3829,9 @@ "license": "ISC" }, "node_modules/ioredis": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.30.1.tgz", - "integrity": "sha512-17Ed70njJ7wT7JZsdTVLb0j/cmwHwfQCFu+AP6jY7nFKd+CA7MBW7nX121mM64eT8S9ekAVtYYt8nGQPmm3euA==", + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.31.0.tgz", + "integrity": "sha512-tVrCrc4LWJwX82GD79dZ0teZQGq+5KJEGpXJRgzHOrhHtLgF9ME6rTwDV5+HN5bjnvmtrnS8ioXhflY16sy2HQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5506,9 +5526,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -5638,9 +5658,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 50dee25..a785343 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/openfeature-js-split-provider", - "version": "1.2.0", + "version": "1.3.0", "description": "Split OpenFeature Provider", "files": [ "README.md", @@ -36,12 +36,12 @@ }, "peerDependencies": { "@openfeature/server-sdk": "^1.20.0", - "@splitsoftware/splitio": "^11.8.0" + "@splitsoftware/splitio": "^11.10.0" }, "devDependencies": { "@eslint/js": "^9.35.0", "@openfeature/server-sdk": "^1.20.0", - "@splitsoftware/splitio": "^11.8.0", + "@splitsoftware/splitio": "^11.10.0", "@types/jest": "^30.0.0", "@types/node": "^24.3.1", "copyfiles": "^2.4.1", diff --git a/src/__tests__/nodeSuites/client.spec.js b/src/__tests__/nodeSuites/client.spec.js index 83b0b0d..1e778c3 100644 --- a/src/__tests__/nodeSuites/client.spec.js +++ b/src/__tests__/nodeSuites/client.spec.js @@ -1,9 +1,8 @@ /* eslint-disable jest/no-conditional-expect */ +import { OpenFeature, ProviderEvents } from '@openfeature/server-sdk'; import { OpenFeatureSplitProvider } from '../../lib/js-split-provider'; import { getLocalHostSplitClient, getSplitFactory } from '../testUtils'; -import { OpenFeature } from '@openfeature/server-sdk'; - const cases = [ [ 'openfeature client tests mode: splitClient', @@ -119,6 +118,22 @@ describe.each(cases)('%s', (label, getOptions) => { expect(client.metadata.name).toBe('test'); }); + test('Ready event includes Split metadata (readyFromCache, initialCacheLoad)', async () => { + const readyDetails = []; + const testProvider = new OpenFeatureSplitProvider(options); + testProvider.events.addHandler(ProviderEvents.Ready, (details) => readyDetails.push(details)); + await OpenFeature.setProviderAndWait(testProvider); + expect(readyDetails.length).toBeGreaterThanOrEqual(1); + const withMetadata = readyDetails.find((d) => d && d.metadata); + expect(withMetadata).toBeDefined(); + expect(withMetadata.providerName).toBe('split'); + expect(typeof withMetadata.metadata.readyFromCache).toBe('boolean'); + expect(typeof withMetadata.metadata.initialCacheLoad).toBe('boolean'); + if (withMetadata.metadata.lastUpdateTimestamp != null) { + expect(typeof withMetadata.metadata.lastUpdateTimestamp).toBe('number'); + } + }); + test('evaluate Boolean without details test', async () => { let details = await client.getBooleanDetails('some_other_feature', true); expect(details.flagKey).toBe('some_other_feature'); diff --git a/src/__tests__/nodeSuites/client_redis.spec.js b/src/__tests__/nodeSuites/client_redis.spec.js index 6496596..a887585 100644 --- a/src/__tests__/nodeSuites/client_redis.spec.js +++ b/src/__tests__/nodeSuites/client_redis.spec.js @@ -32,25 +32,24 @@ const startRedis = () => { return promise; }; -let redisServer -let splitClient +let redisServer; +let splitClient; +let client; beforeAll(async () => { redisServer = await startRedis(); + splitClient = getRedisSplitClient(redisPort); + const provider = new OpenFeatureSplitProvider({ splitClient }); + await OpenFeature.setProviderAndWait(provider); + client = OpenFeature.getClient(); }, 30000); afterAll(async () => { - await redisServer.close(); - await splitClient.destroy(); + if (redisServer) await redisServer.close(); + if (splitClient && typeof splitClient.destroy === 'function') await splitClient.destroy(); }); describe('Regular usage - DEBUG strategy', () => { - splitClient = getRedisSplitClient(redisPort); - const provider = new OpenFeatureSplitProvider({ splitClient }); - - OpenFeature.setProviderAndWait(provider); - const client = OpenFeature.getClient(); - test('Evaluate always on flag', async () => { await client.getBooleanValue('always-on', false, {targetingKey: 'emma-ss'}).then(result => { expect(result).toBe(true); diff --git a/src/__tests__/nodeSuites/provider.spec.js b/src/__tests__/nodeSuites/provider.spec.js index c9ea3d1..135e695 100644 --- a/src/__tests__/nodeSuites/provider.spec.js +++ b/src/__tests__/nodeSuites/provider.spec.js @@ -1,4 +1,5 @@ /* eslint-disable jest/no-conditional-expect */ +import { ProviderEvents } from '@openfeature/server-sdk'; import { getLocalHostSplitClient, getSplitFactory } from '../testUtils'; import { OpenFeatureSplitProvider } from '../../lib/js-split-provider'; @@ -239,3 +240,75 @@ describe.each(cases)('%s', (label, getOptions) => { expect(trackSpy).toHaveBeenCalledWith('u1', 'user', 'purchase', 9.99, { plan: 'pro', beta: true }); }); }); + +describe('provider events metadata', () => { + const SDK_UPDATE = 'state::update'; + + function createMockSplitClient() { + const listeners = {}; + const mock = { + Event: { SDK_UPDATE }, + getStatus: jest.fn().mockReturnValue({ isReady: true, hasTimedout: false }), + getTreatmentWithConfig: jest.fn().mockResolvedValue({ treatment: 'on', config: '' }), + on: jest.fn((event, cb) => { + listeners[event] = listeners[event] || []; + listeners[event].push(cb); + return mock; + }), + track: jest.fn(), + destroy: jest.fn(), + _emit(event, payload) { + (listeners[event] || []).forEach((cb) => cb(payload)); + }, + }; + return mock; + } + + test('ConfigurationChanged event includes metadata (type, names) and flagsChanged when FLAGS_UPDATE', async () => { + const mockClient = createMockSplitClient(); + const provider = new OpenFeatureSplitProvider({ splitClient: mockClient }); + const configChangedDetails = []; + provider.events.addHandler(ProviderEvents.ConfigurationChanged, (details) => configChangedDetails.push(details)); + + mockClient._emit(SDK_UPDATE, { type: 'FLAGS_UPDATE', names: ['flag1', 'flag2'] }); + + expect(configChangedDetails.length).toBe(1); + expect(configChangedDetails[0].providerName).toBe('split'); + expect(configChangedDetails[0].metadata).toEqual({ type: 'FLAGS_UPDATE', names: '["flag1","flag2"]' }); + expect(configChangedDetails[0].flagsChanged).toEqual(['flag1', 'flag2']); + + await provider.onClose(); + }); + + test('ConfigurationChanged event includes metadata without flagsChanged when SEGMENTS_UPDATE', async () => { + const mockClient = createMockSplitClient(); + const provider = new OpenFeatureSplitProvider({ splitClient: mockClient }); + const configChangedDetails = []; + provider.events.addHandler(ProviderEvents.ConfigurationChanged, (details) => configChangedDetails.push(details)); + + mockClient._emit(SDK_UPDATE, { type: 'SEGMENTS_UPDATE', names: ['seg1'] }); + + expect(configChangedDetails.length).toBe(1); + expect(configChangedDetails[0].providerName).toBe('split'); + expect(configChangedDetails[0].metadata).toEqual({ type: 'SEGMENTS_UPDATE', names: '["seg1"]' }); + expect(configChangedDetails[0].flagsChanged).toBeUndefined(); + + await provider.onClose(); + }); + + test('ConfigurationChanged event includes only providerName when SDK_UPDATE payload is undefined', async () => { + const mockClient = createMockSplitClient(); + const provider = new OpenFeatureSplitProvider({ splitClient: mockClient }); + const configChangedDetails = []; + provider.events.addHandler(ProviderEvents.ConfigurationChanged, (details) => configChangedDetails.push(details)); + + mockClient._emit(SDK_UPDATE, undefined); + + expect(configChangedDetails.length).toBe(1); + expect(configChangedDetails[0].providerName).toBe('split'); + expect(configChangedDetails[0].metadata).toBeUndefined(); + expect(configChangedDetails[0].flagsChanged).toBeUndefined(); + + await provider.onClose(); + }); +}); diff --git a/src/lib/__tests__/client-resolver.spec.ts b/src/lib/__tests__/client-resolver.spec.ts new file mode 100644 index 0000000..f6bcff0 --- /dev/null +++ b/src/lib/__tests__/client-resolver.spec.ts @@ -0,0 +1,64 @@ +import type SplitIO from '@splitsoftware/splitio/types/splitio'; +import { getSplitClient } from '../client-resolver'; +import { SplitProviderOptions } from '../types'; + +function createMockSplitClient(): SplitIO.IClient { + return { + getTreatmentWithConfig: jest.fn(), + getStatus: jest.fn(), + on: jest.fn(), + track: jest.fn(), + destroy: jest.fn(), + Event: { + SDK_READY: 'init::ready', + SDK_READY_FROM_CACHE: 'init::cache-ready', + SDK_READY_TIMED_OUT: 'init::timeout', + SDK_UPDATE: 'state::update', + }, + } as unknown as SplitIO.IClient; +} + +describe('client-resolver', () => { + describe('getSplitClient', () => { + it('returns splitClient when options is SplitProviderOptions with splitClient', () => { + const mockClient = createMockSplitClient(); + const options = { splitClient: mockClient }; + const client = getSplitClient(options); + expect(client).toBe(mockClient); + }); + + it('returns client from factory when options has client() method', () => { + const mockClient = createMockSplitClient(); + const mockFactory = { + client: jest.fn().mockReturnValue(mockClient), + }; + const client = getSplitClient(mockFactory as unknown as SplitIO.ISDK); + expect(mockFactory.client).toHaveBeenCalled(); + expect(client).toBe(mockClient); + }); + + it('falls back to splitClient when options.client() throws', () => { + const mockClient = createMockSplitClient(); + const mockFactory = { + client: jest.fn().mockImplementation(() => { + throw new Error('not a real SDK'); + }), + }; + const options = { splitClient: mockClient, ...mockFactory }; + const client = getSplitClient(options as SplitProviderOptions); + expect(client).toBe(mockClient); + }); + + it('creates client from API key when options is string', () => { + const client = getSplitClient('localhost'); + expect(client).toBeDefined(); + expect(typeof client.getTreatmentWithConfig).toBe('function'); + expect(typeof client.getStatus).toBe('function'); + expect(client.Event).toBeDefined(); + expect(client.Event.SDK_READY).toBeDefined(); + if (typeof client.destroy === 'function') { + client.destroy(); + } + }); + }); +}); diff --git a/src/lib/__tests__/context.spec.ts b/src/lib/__tests__/context.spec.ts new file mode 100644 index 0000000..4f41940 --- /dev/null +++ b/src/lib/__tests__/context.spec.ts @@ -0,0 +1,72 @@ +import { transformContext } from '../context'; +import { DEFAULT_TRAFFIC_TYPE } from '../types'; + +describe('context', () => { + describe('transformContext', () => { + it('extracts targetingKey and uses default traffic type when missing', () => { + const context = { targetingKey: 'user-123' }; + const result = transformContext(context); + expect(result.targetingKey).toBe('user-123'); + expect(result.trafficType).toBe(DEFAULT_TRAFFIC_TYPE); + expect(result.attributes).toEqual({}); + }); + + it('uses provided traffic type when present and non-empty', () => { + const context = { + targetingKey: 'user-1', + trafficType: 'account', + }; + const result = transformContext(context); + expect(result.trafficType).toBe('account'); + expect(result.targetingKey).toBe('user-1'); + expect(result.attributes).toEqual({}); + }); + + it('uses default traffic type when trafficType is empty string', () => { + const context = { + targetingKey: 'user-1', + trafficType: '', + }; + const result = transformContext(context); + expect(result.trafficType).toBe(DEFAULT_TRAFFIC_TYPE); + }); + + it('uses default traffic type when trafficType is whitespace', () => { + const context = { + targetingKey: 'user-1', + trafficType: ' ', + }; + const result = transformContext(context); + expect(result.trafficType).toBe(DEFAULT_TRAFFIC_TYPE); + }); + + it('passes remaining context as attributes', () => { + const context = { + targetingKey: 'user-1', + trafficType: 'user', + plan: 'premium', + region: 'us-east', + }; + const result = transformContext(context); + expect(result.attributes).toEqual({ plan: 'premium', region: 'us-east' }); + }); + + it('uses custom defaultTrafficType when provided', () => { + const context = { targetingKey: 'key' }; + const result = transformContext(context, 'custom'); + expect(result.trafficType).toBe('custom'); + }); + + it('handles context with only targetingKey and extra attributes', () => { + const context = { + targetingKey: 'anon', + customAttr: 'value', + count: 42, + }; + const result = transformContext(context); + expect(result.targetingKey).toBe('anon'); + expect(result.trafficType).toBe(DEFAULT_TRAFFIC_TYPE); + expect(result.attributes).toEqual({ customAttr: 'value', count: 42 }); + }); + }); +}); diff --git a/src/lib/__tests__/parsers.spec.ts b/src/lib/__tests__/parsers.spec.ts new file mode 100644 index 0000000..56d9028 --- /dev/null +++ b/src/lib/__tests__/parsers.spec.ts @@ -0,0 +1,63 @@ +import { ParseError } from '@openfeature/server-sdk'; +import { parseValidNumber, parseValidJsonObject } from '../parsers'; + +describe('parsers', () => { + describe('parseValidNumber', () => { + it('returns parsed number for valid string', () => { + expect(parseValidNumber('0')).toBe(0); + expect(parseValidNumber('42')).toBe(42); + expect(parseValidNumber('-1')).toBe(-1); + expect(parseValidNumber('3.14')).toBe(3.14); + expect(parseValidNumber('1e2')).toBe(100); + }); + + it('throws ParseError for undefined', () => { + expect(() => parseValidNumber(undefined)).toThrow(ParseError); + expect(() => parseValidNumber(undefined)).toThrow("Invalid 'undefined' value."); + }); + + it('throws ParseError for non-numeric string', () => { + expect(() => parseValidNumber('')).toThrow(ParseError); + expect(() => parseValidNumber('abc')).toThrow(ParseError); + expect(() => parseValidNumber('NaN')).toThrow(ParseError); + }); + + it('throws with message containing the invalid value', () => { + expect(() => parseValidNumber('foo')).toThrow('Invalid numeric value foo'); + }); + }); + + describe('parseValidJsonObject', () => { + it('returns parsed object for valid JSON object string', () => { + expect(parseValidJsonObject('{}')).toEqual({}); + expect(parseValidJsonObject('{"a":1}')).toEqual({ a: 1 }); + expect(parseValidJsonObject('{"nested":{"b":2}}')).toEqual({ + nested: { b: 2 }, + }); + expect(parseValidJsonObject('[]')).toEqual([]); + }); + + it('throws ParseError for undefined', () => { + expect(() => parseValidJsonObject(undefined)).toThrow(ParseError); + expect(() => parseValidJsonObject(undefined)).toThrow( + "Invalid 'undefined' JSON value." + ); + }); + + it('throws ParseError for non-object JSON (string, number, boolean)', () => { + expect(() => parseValidJsonObject('"hello"')).toThrow(ParseError); + expect(() => parseValidJsonObject('42')).toThrow(ParseError); + expect(() => parseValidJsonObject('true')).toThrow(ParseError); + }); + + it('throws with message indicating expected object type', () => { + expect(() => parseValidJsonObject('"x"')).toThrow('expected "object"'); + }); + + it('throws ParseError for invalid JSON', () => { + expect(() => parseValidJsonObject('{')).toThrow(ParseError); + expect(() => parseValidJsonObject('not json')).toThrow(ParseError); + expect(() => parseValidJsonObject('{"unclosed":')).toThrow(ParseError); + }); + }); +}); diff --git a/src/lib/__tests__/readiness.spec.ts b/src/lib/__tests__/readiness.spec.ts new file mode 100644 index 0000000..b65ae0b --- /dev/null +++ b/src/lib/__tests__/readiness.spec.ts @@ -0,0 +1,159 @@ +import { OpenFeatureEventEmitter, ProviderEvents } from '@openfeature/server-sdk'; +import type SplitIO from '@splitsoftware/splitio/types/splitio'; +import { attachReadyEventHandlers, waitUntilReady } from '../readiness'; +import { PROVIDER_NAME } from '../types'; + +interface MockSplitClient { + Event: { SDK_READY: string; SDK_READY_FROM_CACHE: string; SDK_READY_TIMED_OUT: string }; + getStatus: jest.Mock; + on: jest.Mock; + _emit: (event: string, metadata?: unknown) => void; +} + +function createMockClient(overrides: Partial<{ + isReady: boolean; + hasTimedout: boolean; + readyMetadata: { initialCacheLoad?: boolean; lastUpdateTimestamp?: number }; +}> = {}): MockSplitClient { + const { isReady = false, hasTimedout = false } = overrides; + const listeners: Record void)[]> = { + 'init::ready': [], + 'init::cache-ready': [], + 'init::timeout': [], + }; + const mock: MockSplitClient = { + Event: { + SDK_READY: 'init::ready', + SDK_READY_FROM_CACHE: 'init::cache-ready', + SDK_READY_TIMED_OUT: 'init::timeout', + }, + getStatus: jest.fn().mockReturnValue({ isReady, hasTimedout }), + on: jest.fn((event: string, cb: (...args: unknown[]) => void) => { + if (!listeners[event]) listeners[event] = []; + listeners[event].push(cb); + return mock; + }), + _emit(event: string, metadata?: unknown) { + (listeners[event] || []).forEach((cb) => cb(metadata)); + }, + }; + return mock; +} + +describe('readiness', () => { + describe('attachReadyEventHandlers', () => { + it('emits ProviderEvents.Ready with readyFromCache true when SDK_READY_FROM_CACHE fires', () => { + const client = createMockClient(); + const events = new OpenFeatureEventEmitter(); + const handler = jest.fn(); + events.addHandler(ProviderEvents.Ready, handler); + + attachReadyEventHandlers(client as unknown as SplitIO.IClient, events, 'split'); + expect(client.on).toHaveBeenCalledWith('init::cache-ready', expect.any(Function)); + expect(client.on).toHaveBeenCalledWith('init::ready', expect.any(Function)); + + client._emit('init::cache-ready', { + initialCacheLoad: true, + lastUpdateTimestamp: 12345, + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0]).toMatchObject({ + providerName: 'split', + metadata: { + readyFromCache: true, + initialCacheLoad: true, + lastUpdateTimestamp: 12345, + }, + }); + }); + + it('emits ProviderEvents.Ready with readyFromCache false when SDK_READY fires', () => { + const client = createMockClient(); + const events = new OpenFeatureEventEmitter(); + const handler = jest.fn(); + events.addHandler(ProviderEvents.Ready, handler); + + attachReadyEventHandlers(client as unknown as SplitIO.IClient, events); + client._emit('init::ready', { + initialCacheLoad: false, + lastUpdateTimestamp: 99999, + }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + providerName: PROVIDER_NAME, + metadata: expect.objectContaining({ + readyFromCache: false, + initialCacheLoad: false, + lastUpdateTimestamp: 99999, + }), + }) + ); + }); + + it('handles undefined metadata (e.g. consumer/Redis mode)', () => { + const client = createMockClient(); + const events = new OpenFeatureEventEmitter(); + const handler = jest.fn(); + events.addHandler(ProviderEvents.Ready, handler); + + attachReadyEventHandlers(client as unknown as SplitIO.IClient, events); + client._emit('init::ready', undefined); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + providerName: PROVIDER_NAME, + metadata: expect.objectContaining({ + readyFromCache: false, + initialCacheLoad: false, + }), + }) + ); + }); + + it('emits Ready immediately when client is already ready (e.g. localhost or reused client)', () => { + const client = createMockClient({ isReady: true }); + const events = new OpenFeatureEventEmitter(); + const handler = jest.fn(); + events.addHandler(ProviderEvents.Ready, handler); + + attachReadyEventHandlers(client as unknown as SplitIO.IClient, events); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0]).toMatchObject({ + providerName: PROVIDER_NAME, + metadata: { + readyFromCache: false, + initialCacheLoad: false, + }, + }); + }); + }); + + describe('waitUntilReady', () => { + it('resolves immediately when client is already ready', async () => { + const client = createMockClient({ isReady: true }); + await expect(waitUntilReady(client as unknown as SplitIO.IClient, new OpenFeatureEventEmitter(), PROVIDER_NAME)).resolves.toBeUndefined(); + }); + + it('rejects when client has timed out', async () => { + const client = createMockClient({ hasTimedout: true }); + await expect(waitUntilReady(client as unknown as SplitIO.IClient, new OpenFeatureEventEmitter(), PROVIDER_NAME)).rejects.toBeUndefined(); + }); + + it('resolves when SDK_READY fires', async () => { + const client = createMockClient({ isReady: false, hasTimedout: false }); + const promise = waitUntilReady(client as unknown as SplitIO.IClient, new OpenFeatureEventEmitter(), PROVIDER_NAME); + client._emit('init::ready'); + await expect(promise).resolves.toBeUndefined(); + }); + + it('rejects when SDK_READY_TIMED_OUT fires', async () => { + const client = createMockClient({ isReady: false, hasTimedout: false }); + const promise = waitUntilReady(client as unknown as SplitIO.IClient, new OpenFeatureEventEmitter(), PROVIDER_NAME); + client._emit('init::timeout'); + await expect(promise).rejects.toBeUndefined(); + }); + }); +}); diff --git a/src/lib/client-resolver.ts b/src/lib/client-resolver.ts new file mode 100644 index 0000000..bc6bef5 --- /dev/null +++ b/src/lib/client-resolver.ts @@ -0,0 +1,22 @@ +import { SplitFactory } from '@splitsoftware/splitio'; +import type SplitIO from '@splitsoftware/splitio/types/splitio'; +import type { SplitProviderConstructorOptions, SplitProviderOptions } from './types'; + +/** + * Resolves the Split client from the various supported constructor option shapes. + * Supports: API key (string), Split SDK/AsyncSDK (factory), or pre-built splitClient. + */ +export function getSplitClient( + options: SplitProviderConstructorOptions +): SplitIO.IClient | SplitIO.IAsyncClient { + if (typeof options === 'string') { + const splitFactory = SplitFactory({ core: { authorizationKey: options } }); + return splitFactory.client(); + } + + try { + return (options as SplitIO.ISDK | SplitIO.IAsyncSDK).client(); + } catch { + return (options as SplitProviderOptions).splitClient; + } +} diff --git a/src/lib/context.ts b/src/lib/context.ts new file mode 100644 index 0000000..141ac75 --- /dev/null +++ b/src/lib/context.ts @@ -0,0 +1,23 @@ +import type { EvaluationContext } from '@openfeature/server-sdk'; +import type { Consumer } from './types'; +import { DEFAULT_TRAFFIC_TYPE } from './types'; + +/** + * Transforms OpenFeature evaluation context into the consumer shape used by the Split API: + * targeting key, traffic type (with default), and remaining attributes. + */ +export function transformContext( + context: EvaluationContext, + defaultTrafficType: string = DEFAULT_TRAFFIC_TYPE +): Consumer { + const { targetingKey, trafficType: ttVal, ...attributes } = context; + const trafficType = + ttVal != null && typeof ttVal === 'string' && ttVal.trim() !== '' + ? ttVal + : defaultTrafficType; + return { + targetingKey, + trafficType, + attributes: JSON.parse(JSON.stringify(attributes)), + }; +} diff --git a/src/lib/js-split-provider.ts b/src/lib/js-split-provider.ts index f2e8937..ae7fa72 100644 --- a/src/lib/js-split-provider.ts +++ b/src/lib/js-split-provider.ts @@ -1,7 +1,9 @@ import { EvaluationContext, + EventDetails, FlagNotFoundError, JsonValue, + Logger, OpenFeatureEventEmitter, ParseError, Provider, @@ -11,72 +13,87 @@ import { TargetingKeyMissingError, TrackingEventDetails } from '@openfeature/server-sdk'; -import { SplitFactory } from '@splitsoftware/splitio'; import type SplitIO from '@splitsoftware/splitio/types/splitio'; - -type SplitProviderOptions = { - splitClient: SplitIO.IClient | SplitIO.IAsyncClient; -} - -type Consumer = { - targetingKey: string | undefined; - trafficType: string; - attributes: SplitIO.Attributes; -}; - -const CONTROL_VALUE_ERROR_MESSAGE = 'Received the "control" value from Split.'; -const CONTROL_TREATMENT = 'control'; +import { getSplitClient } from './client-resolver'; +import { transformContext } from './context'; +import { parseValidJsonObject, parseValidNumber } from './parsers'; +import { attachReadyEventHandlers, waitUntilReady } from './readiness'; +import { + CONTROL_TREATMENT, + CONTROL_VALUE_ERROR_MESSAGE, + DEFAULT_TRAFFIC_TYPE, + PROVIDER_NAME, + SplitProviderConstructorOptions, + type Consumer +} from './types'; export class OpenFeatureSplitProvider implements Provider { - metadata = { - name: 'split', - }; + readonly metadata = { + name: PROVIDER_NAME, + } as const; + + readonly runsOn = 'server' as const; private client: SplitIO.IClient | SplitIO.IAsyncClient; private trafficType: string; public readonly events = new OpenFeatureEventEmitter(); - private getSplitClient(options: SplitProviderOptions | string | SplitIO.ISDK | SplitIO.IAsyncSDK) { - if (typeof(options) === 'string') { - const splitFactory = SplitFactory({core: { authorizationKey: options } }); - return splitFactory.client(); - } - - let splitClient; - try { - splitClient = (options as SplitIO.ISDK | SplitIO.IAsyncSDK).client(); - } catch { - splitClient = (options as SplitProviderOptions).splitClient - } - - return splitClient; + constructor( + options: SplitProviderConstructorOptions + ) { + this.trafficType = DEFAULT_TRAFFIC_TYPE; + this.client = getSplitClient(options); + + attachReadyEventHandlers(this.client, this.events, this.metadata.name); + + this.client.on( + this.client.Event.SDK_UPDATE, + (updateMetadata: SplitIO.SdkUpdateMetadata) => { + const eventDetails: EventDetails = { + providerName: this.metadata.name, + ...(updateMetadata + ? { + metadata: { + type: updateMetadata.type, + names: JSON.stringify(updateMetadata.names), + }, + ...(updateMetadata.type === 'FLAGS_UPDATE' + ? { flagsChanged: updateMetadata.names } + : {}), + } + : {}), + }; + this.events.emit(ProviderEvents.ConfigurationChanged, eventDetails); + } + ); } - - constructor(options: SplitProviderOptions | string | SplitIO.ISDK | SplitIO.IAsyncSDK) { - // Asume 'user' as default traffic type' - this.trafficType = 'user'; - this.client = this.getSplitClient(options); - this.client.on(this.client.Event.SDK_UPDATE, () => { - this.events.emit(ProviderEvents.ConfigurationChanged); - }); + + /** + * Called by the SDK after the provider is set. Waits for the Split client to be ready. + * When this promise resolves, the SDK emits ProviderEvents.Ready. + */ + async initialize(_context?: EvaluationContext): Promise { + void _context; + await waitUntilReady(this.client, this.events, this.metadata.name); } public async resolveBooleanEvaluation( flagKey: string, _: boolean, - context: EvaluationContext + context: EvaluationContext, + logger: Logger ): Promise> { + void logger; const details = await this.evaluateTreatment( flagKey, - this.transformContext(context) + transformContext(context, this.trafficType) ); const treatment = details.value.toLowerCase(); - if ( treatment === 'on' || treatment === 'true' ) { + if (treatment === 'on' || treatment === 'true') { return { ...details, value: true }; } - - if ( treatment === 'off' || treatment === 'false' ) { + if (treatment === 'off' || treatment === 'false') { return { ...details, value: false }; } @@ -86,37 +103,42 @@ export class OpenFeatureSplitProvider implements Provider { public async resolveStringEvaluation( flagKey: string, _: string, - context: EvaluationContext + context: EvaluationContext, + logger: Logger ): Promise> { - const details = await this.evaluateTreatment( + void logger; + return this.evaluateTreatment( flagKey, - this.transformContext(context) + transformContext(context, this.trafficType) ); - return details; } public async resolveNumberEvaluation( flagKey: string, _: number, - context: EvaluationContext + context: EvaluationContext, + logger: Logger ): Promise> { + void logger; const details = await this.evaluateTreatment( flagKey, - this.transformContext(context) + transformContext(context, this.trafficType) ); - return { ...details, value: this.parseValidNumber(details.value) }; + return { ...details, value: parseValidNumber(details.value) }; } public async resolveObjectEvaluation( flagKey: string, _: U, - context: EvaluationContext + context: EvaluationContext, + logger: Logger ): Promise> { + void logger; const details = await this.evaluateTreatment( flagKey, - this.transformContext(context) + transformContext(context, this.trafficType) ); - return { ...details, value: this.parseValidJsonObject(details.value) }; + return { ...details, value: parseValidJsonObject(details.value) }; } private async evaluateTreatment( @@ -129,31 +151,26 @@ export class OpenFeatureSplitProvider implements Provider { ); } if (flagKey == null || flagKey === '') { - throw new FlagNotFoundError( - 'flagKey must be a non-empty string' - ); + throw new FlagNotFoundError('flagKey must be a non-empty string'); } - await new Promise((resolve, reject) => { - this.readinessHandler(resolve, reject); - }); - - const { treatment: value, config }: SplitIO.TreatmentWithConfig = await this.client.getTreatmentWithConfig( - consumer.targetingKey, - flagKey, - consumer.attributes - ); + await waitUntilReady(this.client, this.events, this.metadata.name); + const { treatment: value, config }: SplitIO.TreatmentWithConfig = + await this.client.getTreatmentWithConfig( + consumer.targetingKey, + flagKey, + consumer.attributes + ); if (value === CONTROL_TREATMENT) { throw new FlagNotFoundError(CONTROL_VALUE_ERROR_MESSAGE); } - const flagMetadata = { config: config ? config : '' }; - const details: ResolutionDetails = { - value: value, + const flagMetadata = Object.freeze({ config: config ?? '' }); + return { + value, variant: value, - flagMetadata: flagMetadata, + flagMetadata, reason: StandardResolutionReasons.TARGETING_MATCH, }; - return details; } async track( @@ -161,17 +178,21 @@ export class OpenFeatureSplitProvider implements Provider { context: EvaluationContext, details: TrackingEventDetails ): Promise { - - // eventName is always required - if (trackingEventName == null || trackingEventName === '') + if (trackingEventName == null || trackingEventName === '') { throw new ParseError('Missing eventName, required to track'); + } - // targetingKey is always required - const { targetingKey, trafficType } = this.transformContext(context); - if (targetingKey == null || targetingKey === '') - throw new TargetingKeyMissingError('Missing targetingKey, required to track'); + const { targetingKey, trafficType } = transformContext( + context, + this.trafficType + ); + if (targetingKey == null || targetingKey === '') { + throw new TargetingKeyMissingError( + 'Missing targetingKey, required to track' + ); + } - let value; + let value: number | undefined; let properties: SplitIO.Properties = {}; if (details != null) { if (details.value != null) { @@ -180,73 +201,18 @@ export class OpenFeatureSplitProvider implements Provider { if (details.properties != null) { properties = details.properties as SplitIO.Properties; } - } - - this.client.track(targetingKey, trafficType, trackingEventName, value, properties); - } - - public async onClose?(): Promise { - return this.client.destroy(); - } + } - //Transform the context into an object useful for the Split API, an key string with arbitrary Split 'Attributes'. - private transformContext(context: EvaluationContext): Consumer { - const { targetingKey, trafficType: ttVal, ...attributes } = context; - const trafficType = - ttVal != null && typeof ttVal === 'string' && ttVal.trim() !== '' - ? ttVal - : this.trafficType; - return { + this.client.track( targetingKey, trafficType, - // Stringify context objects include date. - attributes: JSON.parse(JSON.stringify(attributes)), - }; - } - - private parseValidNumber(stringValue: string | undefined) { - if (stringValue === undefined) { - throw new ParseError(`Invalid 'undefined' value.`); - } - const result = Number.parseFloat(stringValue); - if (Number.isNaN(result)) { - throw new ParseError(`Invalid numeric value ${stringValue}`); - } - return result; - } - - private parseValidJsonObject( - stringValue: string | undefined - ): T { - if (stringValue === undefined) { - throw new ParseError(`Invalid 'undefined' JSON value.`); - } - // we may want to allow the parsing to be customized. - try { - const value = JSON.parse(stringValue); - if (typeof value !== 'object') { - throw new ParseError( - `Flag value ${stringValue} had unexpected type ${typeof value}, expected "object"` - ); - } - return value; - } catch (err) { - throw new ParseError(`Error parsing ${stringValue} as JSON, ${err}`); - } + trackingEventName, + value, + properties + ); } - private async readinessHandler(onSdkReady: (params?: unknown) => void, onSdkTimedOut: () => void): Promise { - - const clientStatus = this.client.getStatus(); - if (clientStatus.isReady) { - onSdkReady(); - } else { - if (clientStatus.hasTimedout) { - onSdkTimedOut(); - } else { - this.client.on(this.client.Event.SDK_READY_TIMED_OUT, onSdkTimedOut); - } - this.client.on(this.client.Event.SDK_READY, onSdkReady); - } + public async onClose?(): Promise { + return this.client.destroy(); } } \ No newline at end of file diff --git a/src/lib/parsers.ts b/src/lib/parsers.ts new file mode 100644 index 0000000..0296498 --- /dev/null +++ b/src/lib/parsers.ts @@ -0,0 +1,32 @@ +import { ParseError } from '@openfeature/server-sdk'; +import type { JsonValue } from '@openfeature/server-sdk'; + +export function parseValidNumber(stringValue: string | undefined): number { + if (stringValue === undefined) { + throw new ParseError(`Invalid 'undefined' value.`); + } + const result = Number.parseFloat(stringValue); + if (Number.isNaN(result)) { + throw new ParseError(`Invalid numeric value ${stringValue}`); + } + return result; +} + +export function parseValidJsonObject( + stringValue: string | undefined +): T { + if (stringValue === undefined) { + throw new ParseError(`Invalid 'undefined' JSON value.`); + } + try { + const value = JSON.parse(stringValue); + if (typeof value !== 'object') { + throw new ParseError( + `Flag value ${stringValue} had unexpected type ${typeof value}, expected "object"` + ); + } + return value; + } catch (err) { + throw new ParseError(`Error parsing ${stringValue} as JSON, ${err}`); + } +} diff --git a/src/lib/readiness.ts b/src/lib/readiness.ts new file mode 100644 index 0000000..8ec7f53 --- /dev/null +++ b/src/lib/readiness.ts @@ -0,0 +1,103 @@ +import type { OpenFeatureEventEmitter } from '@openfeature/server-sdk'; +import { ProviderEvents } from '@openfeature/server-sdk'; +import type SplitIO from '@splitsoftware/splitio/types/splitio'; +import { PROVIDER_NAME } from './types'; + +/** + * Builds OpenFeature Ready event details including Split SDK ready metadata. + * Handles Split SDK not passing metadata (e.g. in consumer/Redis mode). + */ +function buildReadyEventDetails( + providerName: string, + splitMetadata: SplitIO.SdkReadyMetadata | undefined, + readyFromCache: boolean +) { + const metadata: Record = { + readyFromCache, + initialCacheLoad: splitMetadata?.initialCacheLoad ?? false, + }; + if (splitMetadata?.lastUpdateTimestamp != null) { + metadata.lastUpdateTimestamp = splitMetadata.lastUpdateTimestamp; + } + return { + providerName: providerName || PROVIDER_NAME, + metadata, + }; +} + +/** + * Registers Split SDK_READY and SDK_READY_FROM_CACHE listeners and forwards them + * as OpenFeature ProviderEvents.Ready with event metadata (initialCacheLoad, lastUpdateTimestamp, readyFromCache). + * If the client is already ready when attaching (e.g. localhost or reused client), emits Ready once + * with best-effort metadata so handlers always receive at least one Ready when the client is ready. + */ +export function attachReadyEventHandlers( + client: SplitIO.IClient | SplitIO.IAsyncClient, + events: OpenFeatureEventEmitter, + providerName: string = PROVIDER_NAME +): void { + client.on( + client.Event.SDK_READY_FROM_CACHE, + (splitMetadata: SplitIO.SdkReadyMetadata) => { + events.emit( + ProviderEvents.Ready, + buildReadyEventDetails(providerName, splitMetadata, true) + ); + } + ); + client.on(client.Event.SDK_READY, (splitMetadata: SplitIO.SdkReadyMetadata) => { + events.emit( + ProviderEvents.Ready, + buildReadyEventDetails(providerName, splitMetadata, false) + ); + }); + + const status = client.getStatus(); + if (status.isReady) { + events.emit( + ProviderEvents.Ready, + buildReadyEventDetails(providerName, undefined, false) + ); + } +} + +/** + * Returns a promise that resolves when the Split client is ready (SDK_READY), + * or rejects if the client has timed out (SDK_READY_TIMED_OUT). + * Used to gate evaluations until the SDK has synchronized with the backend. + */ +export function waitUntilReady( + client: SplitIO.IClient | SplitIO.IAsyncClient, + events: OpenFeatureEventEmitter, + providerName: string = PROVIDER_NAME +): Promise { + return new Promise((resolve, reject) => { + const status = client.getStatus(); + if (status.isReady) { + emitReadyEvent(client, events, providerName); + resolve(); + return; + } + if (status.hasTimedout) { + reject(); + return; + } + client.on(client.Event.SDK_READY_TIMED_OUT, reject); + client.on(client.Event.SDK_READY, () => { + emitReadyEvent(client, events, providerName); + resolve(); + }); + }); +} + +export function emitReadyEvent( + client: SplitIO.IClient | SplitIO.IAsyncClient, + events: OpenFeatureEventEmitter, + providerName: string = PROVIDER_NAME +): void { + events.emit( + ProviderEvents.Ready, + buildReadyEventDetails(providerName, undefined, false) + ); +} + diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..27553ad --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,32 @@ +import type SplitIO from '@splitsoftware/splitio/types/splitio'; + +/** + * Options when providing an existing Split client to the provider. + */ +export type SplitProviderOptions = { + splitClient: SplitIO.IClient | SplitIO.IAsyncClient; +}; + +/** + * Consumer representation used for Split API calls: + * targeting key, traffic type, and attributes. + */ +export type Consumer = { + targetingKey: string | undefined; + trafficType: string; + attributes: SplitIO.Attributes; +}; + +/** + * Union of all constructor argument types for the Split OpenFeature provider. + */ +export type SplitProviderConstructorOptions = + | SplitProviderOptions + | string + | SplitIO.ISDK + | SplitIO.IAsyncSDK; + +export const CONTROL_TREATMENT = 'control'; +export const CONTROL_VALUE_ERROR_MESSAGE = 'Received the "control" value from Split.'; +export const DEFAULT_TRAFFIC_TYPE = 'user'; +export const PROVIDER_NAME = 'split';