@splitsoftware/openfeature-js-split-provider 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseValidJsonObject = exports.parseValidNumber = void 0;
4
+ const server_sdk_1 = require("@openfeature/server-sdk");
5
+ function parseValidNumber(stringValue) {
6
+ if (stringValue === undefined) {
7
+ throw new server_sdk_1.ParseError(`Invalid 'undefined' value.`);
8
+ }
9
+ const result = Number.parseFloat(stringValue);
10
+ if (Number.isNaN(result)) {
11
+ throw new server_sdk_1.ParseError(`Invalid numeric value ${stringValue}`);
12
+ }
13
+ return result;
14
+ }
15
+ exports.parseValidNumber = parseValidNumber;
16
+ function parseValidJsonObject(stringValue) {
17
+ if (stringValue === undefined) {
18
+ throw new server_sdk_1.ParseError(`Invalid 'undefined' JSON value.`);
19
+ }
20
+ try {
21
+ const value = JSON.parse(stringValue);
22
+ if (typeof value !== 'object') {
23
+ throw new server_sdk_1.ParseError(`Flag value ${stringValue} had unexpected type ${typeof value}, expected "object"`);
24
+ }
25
+ return value;
26
+ }
27
+ catch (err) {
28
+ throw new server_sdk_1.ParseError(`Error parsing ${stringValue} as JSON, ${err}`);
29
+ }
30
+ }
31
+ exports.parseValidJsonObject = parseValidJsonObject;
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.emitReadyEvent = exports.waitUntilReady = exports.attachReadyEventHandlers = void 0;
4
+ const server_sdk_1 = require("@openfeature/server-sdk");
5
+ const types_1 = require("./types");
6
+ /**
7
+ * Builds OpenFeature Ready event details including Split SDK ready metadata.
8
+ * Handles Split SDK not passing metadata (e.g. in consumer/Redis mode).
9
+ */
10
+ function buildReadyEventDetails(providerName, splitMetadata, readyFromCache) {
11
+ const metadata = {
12
+ readyFromCache,
13
+ initialCacheLoad: splitMetadata?.initialCacheLoad ?? false,
14
+ };
15
+ if (splitMetadata?.lastUpdateTimestamp != null) {
16
+ metadata.lastUpdateTimestamp = splitMetadata.lastUpdateTimestamp;
17
+ }
18
+ return {
19
+ providerName: providerName || types_1.PROVIDER_NAME,
20
+ metadata,
21
+ };
22
+ }
23
+ /**
24
+ * Registers Split SDK_READY and SDK_READY_FROM_CACHE listeners and forwards them
25
+ * as OpenFeature ProviderEvents.Ready with event metadata (initialCacheLoad, lastUpdateTimestamp, readyFromCache).
26
+ * If the client is already ready when attaching (e.g. localhost or reused client), emits Ready once
27
+ * with best-effort metadata so handlers always receive at least one Ready when the client is ready.
28
+ */
29
+ function attachReadyEventHandlers(client, events, providerName = types_1.PROVIDER_NAME) {
30
+ client.on(client.Event.SDK_READY_FROM_CACHE, (splitMetadata) => {
31
+ events.emit(server_sdk_1.ProviderEvents.Ready, buildReadyEventDetails(providerName, splitMetadata, true));
32
+ });
33
+ client.on(client.Event.SDK_READY, (splitMetadata) => {
34
+ events.emit(server_sdk_1.ProviderEvents.Ready, buildReadyEventDetails(providerName, splitMetadata, false));
35
+ });
36
+ const status = client.getStatus();
37
+ if (status.isReady) {
38
+ events.emit(server_sdk_1.ProviderEvents.Ready, buildReadyEventDetails(providerName, undefined, false));
39
+ }
40
+ }
41
+ exports.attachReadyEventHandlers = attachReadyEventHandlers;
42
+ /**
43
+ * Returns a promise that resolves when the Split client is ready (SDK_READY),
44
+ * or rejects if the client has timed out (SDK_READY_TIMED_OUT).
45
+ * Used to gate evaluations until the SDK has synchronized with the backend.
46
+ */
47
+ function waitUntilReady(client, events, providerName = types_1.PROVIDER_NAME) {
48
+ return new Promise((resolve, reject) => {
49
+ const status = client.getStatus();
50
+ if (status.isReady) {
51
+ emitReadyEvent(client, events, providerName);
52
+ resolve();
53
+ return;
54
+ }
55
+ if (status.hasTimedout) {
56
+ reject();
57
+ return;
58
+ }
59
+ client.on(client.Event.SDK_READY_TIMED_OUT, reject);
60
+ client.on(client.Event.SDK_READY, () => {
61
+ emitReadyEvent(client, events, providerName);
62
+ resolve();
63
+ });
64
+ });
65
+ }
66
+ exports.waitUntilReady = waitUntilReady;
67
+ function emitReadyEvent(client, events, providerName = types_1.PROVIDER_NAME) {
68
+ events.emit(server_sdk_1.ProviderEvents.Ready, buildReadyEventDetails(providerName, undefined, false));
69
+ }
70
+ exports.emitReadyEvent = emitReadyEvent;
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PROVIDER_NAME = exports.DEFAULT_TRAFFIC_TYPE = exports.CONTROL_VALUE_ERROR_MESSAGE = exports.CONTROL_TREATMENT = void 0;
4
+ exports.CONTROL_TREATMENT = 'control';
5
+ exports.CONTROL_VALUE_ERROR_MESSAGE = 'Received the "control" value from Split.';
6
+ exports.DEFAULT_TRAFFIC_TYPE = 'user';
7
+ exports.PROVIDER_NAME = 'split';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@splitsoftware/openfeature-js-split-provider",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Split OpenFeature Provider",
5
5
  "files": [
6
6
  "README.md",
@@ -34,14 +34,16 @@
34
34
  "import": "./es/index.js"
35
35
  }
36
36
  },
37
+ "dependencies": {
38
+ "@splitsoftware/splitio": "^11.10.0"
39
+ },
37
40
  "peerDependencies": {
38
- "@openfeature/server-sdk": "^1.19.0",
39
- "@splitsoftware/splitio": "^11.4.1"
41
+ "@openfeature/server-sdk": "^1.20.0"
40
42
  },
41
43
  "devDependencies": {
42
44
  "@eslint/js": "^9.35.0",
43
- "@openfeature/server-sdk": "^1.19.0",
44
- "@splitsoftware/splitio": "^11.4.1",
45
+ "@openfeature/server-sdk": "^1.20.0",
46
+ "@splitsoftware/splitio": "^11.10.0",
45
47
  "@types/jest": "^30.0.0",
46
48
  "@types/node": "^24.3.1",
47
49
  "copyfiles": "^2.4.1",
@@ -1,9 +1,8 @@
1
1
  /* eslint-disable jest/no-conditional-expect */
2
+ import { OpenFeature, ProviderEvents } from '@openfeature/server-sdk';
2
3
  import { OpenFeatureSplitProvider } from '../../lib/js-split-provider';
3
4
  import { getLocalHostSplitClient, getSplitFactory } from '../testUtils';
4
5
 
5
- import { OpenFeature } from '@openfeature/server-sdk';
6
-
7
6
  const cases = [
8
7
  [
9
8
  'openfeature client tests mode: splitClient',
@@ -119,6 +118,22 @@ describe.each(cases)('%s', (label, getOptions) => {
119
118
  expect(client.metadata.name).toBe('test');
120
119
  });
121
120
 
121
+ test('Ready event includes Split metadata (readyFromCache, initialCacheLoad)', async () => {
122
+ const readyDetails = [];
123
+ const testProvider = new OpenFeatureSplitProvider(options);
124
+ testProvider.events.addHandler(ProviderEvents.Ready, (details) => readyDetails.push(details));
125
+ await OpenFeature.setProviderAndWait(testProvider);
126
+ expect(readyDetails.length).toBeGreaterThanOrEqual(1);
127
+ const withMetadata = readyDetails.find((d) => d && d.metadata);
128
+ expect(withMetadata).toBeDefined();
129
+ expect(withMetadata.providerName).toBe('split');
130
+ expect(typeof withMetadata.metadata.readyFromCache).toBe('boolean');
131
+ expect(typeof withMetadata.metadata.initialCacheLoad).toBe('boolean');
132
+ if (withMetadata.metadata.lastUpdateTimestamp != null) {
133
+ expect(typeof withMetadata.metadata.lastUpdateTimestamp).toBe('number');
134
+ }
135
+ });
136
+
122
137
  test('evaluate Boolean without details test', async () => {
123
138
  let details = await client.getBooleanDetails('some_other_feature', true);
124
139
  expect(details.flagKey).toBe('some_other_feature');
@@ -32,25 +32,24 @@ const startRedis = () => {
32
32
  return promise;
33
33
  };
34
34
 
35
- let redisServer
36
- let splitClient
35
+ let redisServer;
36
+ let splitClient;
37
+ let client;
37
38
 
38
39
  beforeAll(async () => {
39
40
  redisServer = await startRedis();
41
+ splitClient = getRedisSplitClient(redisPort);
42
+ const provider = new OpenFeatureSplitProvider({ splitClient });
43
+ await OpenFeature.setProviderAndWait(provider);
44
+ client = OpenFeature.getClient();
40
45
  }, 30000);
41
46
 
42
47
  afterAll(async () => {
43
- await redisServer.close();
44
- await splitClient.destroy();
48
+ if (redisServer) await redisServer.close();
49
+ if (splitClient && typeof splitClient.destroy === 'function') await splitClient.destroy();
45
50
  });
46
51
 
47
52
  describe('Regular usage - DEBUG strategy', () => {
48
- splitClient = getRedisSplitClient(redisPort);
49
- const provider = new OpenFeatureSplitProvider({ splitClient });
50
-
51
- OpenFeature.setProviderAndWait(provider);
52
- const client = OpenFeature.getClient();
53
-
54
53
  test('Evaluate always on flag', async () => {
55
54
  await client.getBooleanValue('always-on', false, {targetingKey: 'emma-ss'}).then(result => {
56
55
  expect(result).toBe(true);
@@ -1,4 +1,5 @@
1
1
  /* eslint-disable jest/no-conditional-expect */
2
+ import { ProviderEvents } from '@openfeature/server-sdk';
2
3
  import { getLocalHostSplitClient, getSplitFactory } from '../testUtils';
3
4
  import { OpenFeatureSplitProvider } from '../../lib/js-split-provider';
4
5
 
@@ -239,3 +240,75 @@ describe.each(cases)('%s', (label, getOptions) => {
239
240
  expect(trackSpy).toHaveBeenCalledWith('u1', 'user', 'purchase', 9.99, { plan: 'pro', beta: true });
240
241
  });
241
242
  });
243
+
244
+ describe('provider events metadata', () => {
245
+ const SDK_UPDATE = 'state::update';
246
+
247
+ function createMockSplitClient() {
248
+ const listeners = {};
249
+ const mock = {
250
+ Event: { SDK_UPDATE },
251
+ getStatus: jest.fn().mockReturnValue({ isReady: true, hasTimedout: false }),
252
+ getTreatmentWithConfig: jest.fn().mockResolvedValue({ treatment: 'on', config: '' }),
253
+ on: jest.fn((event, cb) => {
254
+ listeners[event] = listeners[event] || [];
255
+ listeners[event].push(cb);
256
+ return mock;
257
+ }),
258
+ track: jest.fn(),
259
+ destroy: jest.fn(),
260
+ _emit(event, payload) {
261
+ (listeners[event] || []).forEach((cb) => cb(payload));
262
+ },
263
+ };
264
+ return mock;
265
+ }
266
+
267
+ test('ConfigurationChanged event includes metadata (type) and flagsChanged when FLAGS_UPDATE', async () => {
268
+ const mockClient = createMockSplitClient();
269
+ const provider = new OpenFeatureSplitProvider({ splitClient: mockClient });
270
+ const configChangedDetails = [];
271
+ provider.events.addHandler(ProviderEvents.ConfigurationChanged, (details) => configChangedDetails.push(details));
272
+
273
+ mockClient._emit(SDK_UPDATE, { type: 'FLAGS_UPDATE', names: ['flag1', 'flag2'] });
274
+
275
+ expect(configChangedDetails.length).toBe(1);
276
+ expect(configChangedDetails[0].providerName).toBe('split');
277
+ expect(configChangedDetails[0].metadata).toEqual({ type: 'FLAGS_UPDATE' });
278
+ expect(configChangedDetails[0].flagsChanged).toEqual(['flag1', 'flag2']);
279
+
280
+ await provider.onClose();
281
+ });
282
+
283
+ test('ConfigurationChanged event includes metadata without flagsChanged when SEGMENTS_UPDATE', async () => {
284
+ const mockClient = createMockSplitClient();
285
+ const provider = new OpenFeatureSplitProvider({ splitClient: mockClient });
286
+ const configChangedDetails = [];
287
+ provider.events.addHandler(ProviderEvents.ConfigurationChanged, (details) => configChangedDetails.push(details));
288
+
289
+ mockClient._emit(SDK_UPDATE, { type: 'SEGMENTS_UPDATE' });
290
+
291
+ expect(configChangedDetails.length).toBe(1);
292
+ expect(configChangedDetails[0].providerName).toBe('split');
293
+ expect(configChangedDetails[0].metadata).toEqual({ type: 'SEGMENTS_UPDATE' });
294
+ expect(configChangedDetails[0].flagsChanged).toEqual([]);
295
+
296
+ await provider.onClose();
297
+ });
298
+
299
+ test('ConfigurationChanged event includes only providerName when SDK_UPDATE payload is undefined', async () => {
300
+ const mockClient = createMockSplitClient();
301
+ const provider = new OpenFeatureSplitProvider({ splitClient: mockClient });
302
+ const configChangedDetails = [];
303
+ provider.events.addHandler(ProviderEvents.ConfigurationChanged, (details) => configChangedDetails.push(details));
304
+
305
+ mockClient._emit(SDK_UPDATE, undefined);
306
+
307
+ expect(configChangedDetails.length).toBe(1);
308
+ expect(configChangedDetails[0].providerName).toBe('split');
309
+ expect(configChangedDetails[0].metadata).toBeUndefined();
310
+ expect(configChangedDetails[0].flagsChanged).toBeUndefined();
311
+
312
+ await provider.onClose();
313
+ });
314
+ });
@@ -0,0 +1,64 @@
1
+ import type SplitIO from '@splitsoftware/splitio/types/splitio';
2
+ import { getSplitClient } from '../client-resolver';
3
+ import { SplitProviderOptions } from '../types';
4
+
5
+ function createMockSplitClient(): SplitIO.IClient {
6
+ return {
7
+ getTreatmentWithConfig: jest.fn(),
8
+ getStatus: jest.fn(),
9
+ on: jest.fn(),
10
+ track: jest.fn(),
11
+ destroy: jest.fn(),
12
+ Event: {
13
+ SDK_READY: 'init::ready',
14
+ SDK_READY_FROM_CACHE: 'init::cache-ready',
15
+ SDK_READY_TIMED_OUT: 'init::timeout',
16
+ SDK_UPDATE: 'state::update',
17
+ },
18
+ } as unknown as SplitIO.IClient;
19
+ }
20
+
21
+ describe('client-resolver', () => {
22
+ describe('getSplitClient', () => {
23
+ it('returns splitClient when options is SplitProviderOptions with splitClient', () => {
24
+ const mockClient = createMockSplitClient();
25
+ const options = { splitClient: mockClient };
26
+ const client = getSplitClient(options);
27
+ expect(client).toBe(mockClient);
28
+ });
29
+
30
+ it('returns client from factory when options has client() method', () => {
31
+ const mockClient = createMockSplitClient();
32
+ const mockFactory = {
33
+ client: jest.fn().mockReturnValue(mockClient),
34
+ };
35
+ const client = getSplitClient(mockFactory as unknown as SplitIO.ISDK);
36
+ expect(mockFactory.client).toHaveBeenCalled();
37
+ expect(client).toBe(mockClient);
38
+ });
39
+
40
+ it('falls back to splitClient when options.client() throws', () => {
41
+ const mockClient = createMockSplitClient();
42
+ const mockFactory = {
43
+ client: jest.fn().mockImplementation(() => {
44
+ throw new Error('not a real SDK');
45
+ }),
46
+ };
47
+ const options = { splitClient: mockClient, ...mockFactory };
48
+ const client = getSplitClient(options as SplitProviderOptions);
49
+ expect(client).toBe(mockClient);
50
+ });
51
+
52
+ it('creates client from API key when options is string', () => {
53
+ const client = getSplitClient('localhost');
54
+ expect(client).toBeDefined();
55
+ expect(typeof client.getTreatmentWithConfig).toBe('function');
56
+ expect(typeof client.getStatus).toBe('function');
57
+ expect(client.Event).toBeDefined();
58
+ expect(client.Event.SDK_READY).toBeDefined();
59
+ if (typeof client.destroy === 'function') {
60
+ client.destroy();
61
+ }
62
+ });
63
+ });
64
+ });
@@ -0,0 +1,72 @@
1
+ import { transformContext } from '../context';
2
+ import { DEFAULT_TRAFFIC_TYPE } from '../types';
3
+
4
+ describe('context', () => {
5
+ describe('transformContext', () => {
6
+ it('extracts targetingKey and uses default traffic type when missing', () => {
7
+ const context = { targetingKey: 'user-123' };
8
+ const result = transformContext(context);
9
+ expect(result.targetingKey).toBe('user-123');
10
+ expect(result.trafficType).toBe(DEFAULT_TRAFFIC_TYPE);
11
+ expect(result.attributes).toEqual({});
12
+ });
13
+
14
+ it('uses provided traffic type when present and non-empty', () => {
15
+ const context = {
16
+ targetingKey: 'user-1',
17
+ trafficType: 'account',
18
+ };
19
+ const result = transformContext(context);
20
+ expect(result.trafficType).toBe('account');
21
+ expect(result.targetingKey).toBe('user-1');
22
+ expect(result.attributes).toEqual({});
23
+ });
24
+
25
+ it('uses default traffic type when trafficType is empty string', () => {
26
+ const context = {
27
+ targetingKey: 'user-1',
28
+ trafficType: '',
29
+ };
30
+ const result = transformContext(context);
31
+ expect(result.trafficType).toBe(DEFAULT_TRAFFIC_TYPE);
32
+ });
33
+
34
+ it('uses default traffic type when trafficType is whitespace', () => {
35
+ const context = {
36
+ targetingKey: 'user-1',
37
+ trafficType: ' ',
38
+ };
39
+ const result = transformContext(context);
40
+ expect(result.trafficType).toBe(DEFAULT_TRAFFIC_TYPE);
41
+ });
42
+
43
+ it('passes remaining context as attributes', () => {
44
+ const context = {
45
+ targetingKey: 'user-1',
46
+ trafficType: 'user',
47
+ plan: 'premium',
48
+ region: 'us-east',
49
+ };
50
+ const result = transformContext(context);
51
+ expect(result.attributes).toEqual({ plan: 'premium', region: 'us-east' });
52
+ });
53
+
54
+ it('uses custom defaultTrafficType when provided', () => {
55
+ const context = { targetingKey: 'key' };
56
+ const result = transformContext(context, 'custom');
57
+ expect(result.trafficType).toBe('custom');
58
+ });
59
+
60
+ it('handles context with only targetingKey and extra attributes', () => {
61
+ const context = {
62
+ targetingKey: 'anon',
63
+ customAttr: 'value',
64
+ count: 42,
65
+ };
66
+ const result = transformContext(context);
67
+ expect(result.targetingKey).toBe('anon');
68
+ expect(result.trafficType).toBe(DEFAULT_TRAFFIC_TYPE);
69
+ expect(result.attributes).toEqual({ customAttr: 'value', count: 42 });
70
+ });
71
+ });
72
+ });
@@ -0,0 +1,63 @@
1
+ import { ParseError } from '@openfeature/server-sdk';
2
+ import { parseValidNumber, parseValidJsonObject } from '../parsers';
3
+
4
+ describe('parsers', () => {
5
+ describe('parseValidNumber', () => {
6
+ it('returns parsed number for valid string', () => {
7
+ expect(parseValidNumber('0')).toBe(0);
8
+ expect(parseValidNumber('42')).toBe(42);
9
+ expect(parseValidNumber('-1')).toBe(-1);
10
+ expect(parseValidNumber('3.14')).toBe(3.14);
11
+ expect(parseValidNumber('1e2')).toBe(100);
12
+ });
13
+
14
+ it('throws ParseError for undefined', () => {
15
+ expect(() => parseValidNumber(undefined)).toThrow(ParseError);
16
+ expect(() => parseValidNumber(undefined)).toThrow("Invalid 'undefined' value.");
17
+ });
18
+
19
+ it('throws ParseError for non-numeric string', () => {
20
+ expect(() => parseValidNumber('')).toThrow(ParseError);
21
+ expect(() => parseValidNumber('abc')).toThrow(ParseError);
22
+ expect(() => parseValidNumber('NaN')).toThrow(ParseError);
23
+ });
24
+
25
+ it('throws with message containing the invalid value', () => {
26
+ expect(() => parseValidNumber('foo')).toThrow('Invalid numeric value foo');
27
+ });
28
+ });
29
+
30
+ describe('parseValidJsonObject', () => {
31
+ it('returns parsed object for valid JSON object string', () => {
32
+ expect(parseValidJsonObject('{}')).toEqual({});
33
+ expect(parseValidJsonObject('{"a":1}')).toEqual({ a: 1 });
34
+ expect(parseValidJsonObject('{"nested":{"b":2}}')).toEqual({
35
+ nested: { b: 2 },
36
+ });
37
+ expect(parseValidJsonObject('[]')).toEqual([]);
38
+ });
39
+
40
+ it('throws ParseError for undefined', () => {
41
+ expect(() => parseValidJsonObject(undefined)).toThrow(ParseError);
42
+ expect(() => parseValidJsonObject(undefined)).toThrow(
43
+ "Invalid 'undefined' JSON value."
44
+ );
45
+ });
46
+
47
+ it('throws ParseError for non-object JSON (string, number, boolean)', () => {
48
+ expect(() => parseValidJsonObject('"hello"')).toThrow(ParseError);
49
+ expect(() => parseValidJsonObject('42')).toThrow(ParseError);
50
+ expect(() => parseValidJsonObject('true')).toThrow(ParseError);
51
+ });
52
+
53
+ it('throws with message indicating expected object type', () => {
54
+ expect(() => parseValidJsonObject('"x"')).toThrow('expected "object"');
55
+ });
56
+
57
+ it('throws ParseError for invalid JSON', () => {
58
+ expect(() => parseValidJsonObject('{')).toThrow(ParseError);
59
+ expect(() => parseValidJsonObject('not json')).toThrow(ParseError);
60
+ expect(() => parseValidJsonObject('{"unclosed":')).toThrow(ParseError);
61
+ });
62
+ });
63
+ });
@@ -0,0 +1,159 @@
1
+ import { OpenFeatureEventEmitter, ProviderEvents } from '@openfeature/server-sdk';
2
+ import type SplitIO from '@splitsoftware/splitio/types/splitio';
3
+ import { attachReadyEventHandlers, waitUntilReady } from '../readiness';
4
+ import { PROVIDER_NAME } from '../types';
5
+
6
+ interface MockSplitClient {
7
+ Event: { SDK_READY: string; SDK_READY_FROM_CACHE: string; SDK_READY_TIMED_OUT: string };
8
+ getStatus: jest.Mock;
9
+ on: jest.Mock;
10
+ _emit: (event: string, metadata?: unknown) => void;
11
+ }
12
+
13
+ function createMockClient(overrides: Partial<{
14
+ isReady: boolean;
15
+ hasTimedout: boolean;
16
+ readyMetadata: { initialCacheLoad?: boolean; lastUpdateTimestamp?: number };
17
+ }> = {}): MockSplitClient {
18
+ const { isReady = false, hasTimedout = false } = overrides;
19
+ const listeners: Record<string, ((...args: unknown[]) => void)[]> = {
20
+ 'init::ready': [],
21
+ 'init::cache-ready': [],
22
+ 'init::timeout': [],
23
+ };
24
+ const mock: MockSplitClient = {
25
+ Event: {
26
+ SDK_READY: 'init::ready',
27
+ SDK_READY_FROM_CACHE: 'init::cache-ready',
28
+ SDK_READY_TIMED_OUT: 'init::timeout',
29
+ },
30
+ getStatus: jest.fn().mockReturnValue({ isReady, hasTimedout }),
31
+ on: jest.fn((event: string, cb: (...args: unknown[]) => void) => {
32
+ if (!listeners[event]) listeners[event] = [];
33
+ listeners[event].push(cb);
34
+ return mock;
35
+ }),
36
+ _emit(event: string, metadata?: unknown) {
37
+ (listeners[event] || []).forEach((cb) => cb(metadata));
38
+ },
39
+ };
40
+ return mock;
41
+ }
42
+
43
+ describe('readiness', () => {
44
+ describe('attachReadyEventHandlers', () => {
45
+ it('emits ProviderEvents.Ready with readyFromCache true when SDK_READY_FROM_CACHE fires', () => {
46
+ const client = createMockClient();
47
+ const events = new OpenFeatureEventEmitter();
48
+ const handler = jest.fn();
49
+ events.addHandler(ProviderEvents.Ready, handler);
50
+
51
+ attachReadyEventHandlers(client as unknown as SplitIO.IClient, events, 'split');
52
+ expect(client.on).toHaveBeenCalledWith('init::cache-ready', expect.any(Function));
53
+ expect(client.on).toHaveBeenCalledWith('init::ready', expect.any(Function));
54
+
55
+ client._emit('init::cache-ready', {
56
+ initialCacheLoad: true,
57
+ lastUpdateTimestamp: 12345,
58
+ });
59
+
60
+ expect(handler).toHaveBeenCalledTimes(1);
61
+ expect(handler.mock.calls[0][0]).toMatchObject({
62
+ providerName: 'split',
63
+ metadata: {
64
+ readyFromCache: true,
65
+ initialCacheLoad: true,
66
+ lastUpdateTimestamp: 12345,
67
+ },
68
+ });
69
+ });
70
+
71
+ it('emits ProviderEvents.Ready with readyFromCache false when SDK_READY fires', () => {
72
+ const client = createMockClient();
73
+ const events = new OpenFeatureEventEmitter();
74
+ const handler = jest.fn();
75
+ events.addHandler(ProviderEvents.Ready, handler);
76
+
77
+ attachReadyEventHandlers(client as unknown as SplitIO.IClient, events);
78
+ client._emit('init::ready', {
79
+ initialCacheLoad: false,
80
+ lastUpdateTimestamp: 99999,
81
+ });
82
+
83
+ expect(handler).toHaveBeenCalledWith(
84
+ expect.objectContaining({
85
+ providerName: PROVIDER_NAME,
86
+ metadata: expect.objectContaining({
87
+ readyFromCache: false,
88
+ initialCacheLoad: false,
89
+ lastUpdateTimestamp: 99999,
90
+ }),
91
+ })
92
+ );
93
+ });
94
+
95
+ it('handles undefined metadata (e.g. consumer/Redis mode)', () => {
96
+ const client = createMockClient();
97
+ const events = new OpenFeatureEventEmitter();
98
+ const handler = jest.fn();
99
+ events.addHandler(ProviderEvents.Ready, handler);
100
+
101
+ attachReadyEventHandlers(client as unknown as SplitIO.IClient, events);
102
+ client._emit('init::ready', undefined);
103
+
104
+ expect(handler).toHaveBeenCalledWith(
105
+ expect.objectContaining({
106
+ providerName: PROVIDER_NAME,
107
+ metadata: expect.objectContaining({
108
+ readyFromCache: false,
109
+ initialCacheLoad: false,
110
+ }),
111
+ })
112
+ );
113
+ });
114
+
115
+ it('emits Ready immediately when client is already ready (e.g. localhost or reused client)', () => {
116
+ const client = createMockClient({ isReady: true });
117
+ const events = new OpenFeatureEventEmitter();
118
+ const handler = jest.fn();
119
+ events.addHandler(ProviderEvents.Ready, handler);
120
+
121
+ attachReadyEventHandlers(client as unknown as SplitIO.IClient, events);
122
+
123
+ expect(handler).toHaveBeenCalledTimes(1);
124
+ expect(handler.mock.calls[0][0]).toMatchObject({
125
+ providerName: PROVIDER_NAME,
126
+ metadata: {
127
+ readyFromCache: false,
128
+ initialCacheLoad: false,
129
+ },
130
+ });
131
+ });
132
+ });
133
+
134
+ describe('waitUntilReady', () => {
135
+ it('resolves immediately when client is already ready', async () => {
136
+ const client = createMockClient({ isReady: true });
137
+ await expect(waitUntilReady(client as unknown as SplitIO.IClient, new OpenFeatureEventEmitter(), PROVIDER_NAME)).resolves.toBeUndefined();
138
+ });
139
+
140
+ it('rejects when client has timed out', async () => {
141
+ const client = createMockClient({ hasTimedout: true });
142
+ await expect(waitUntilReady(client as unknown as SplitIO.IClient, new OpenFeatureEventEmitter(), PROVIDER_NAME)).rejects.toBeUndefined();
143
+ });
144
+
145
+ it('resolves when SDK_READY fires', async () => {
146
+ const client = createMockClient({ isReady: false, hasTimedout: false });
147
+ const promise = waitUntilReady(client as unknown as SplitIO.IClient, new OpenFeatureEventEmitter(), PROVIDER_NAME);
148
+ client._emit('init::ready');
149
+ await expect(promise).resolves.toBeUndefined();
150
+ });
151
+
152
+ it('rejects when SDK_READY_TIMED_OUT fires', async () => {
153
+ const client = createMockClient({ isReady: false, hasTimedout: false });
154
+ const promise = waitUntilReady(client as unknown as SplitIO.IClient, new OpenFeatureEventEmitter(), PROVIDER_NAME);
155
+ client._emit('init::timeout');
156
+ await expect(promise).rejects.toBeUndefined();
157
+ });
158
+ });
159
+ });
@@ -0,0 +1,22 @@
1
+ import { SplitFactory } from '@splitsoftware/splitio';
2
+ import type SplitIO from '@splitsoftware/splitio/types/splitio';
3
+ import type { SplitProviderConstructorOptions, SplitProviderOptions } from './types';
4
+
5
+ /**
6
+ * Resolves the Split client from the various supported constructor option shapes.
7
+ * Supports: API key (string), Split SDK/AsyncSDK (factory), or pre-built splitClient.
8
+ */
9
+ export function getSplitClient(
10
+ options: SplitProviderConstructorOptions
11
+ ): SplitIO.IClient | SplitIO.IAsyncClient {
12
+ if (typeof options === 'string') {
13
+ const splitFactory = SplitFactory({ core: { authorizationKey: options } });
14
+ return splitFactory.client();
15
+ }
16
+
17
+ try {
18
+ return (options as SplitIO.ISDK | SplitIO.IAsyncSDK).client();
19
+ } catch {
20
+ return (options as SplitProviderOptions).splitClient;
21
+ }
22
+ }