Skip to content

Commit e20ef73

Browse files
committed
detatched construction
1 parent e9475e7 commit e20ef73

File tree

3 files changed

+373
-62
lines changed

3 files changed

+373
-62
lines changed

src/i-client-config.ts

Lines changed: 119 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
IAssignmentLogger,
77
IAsyncStore,
88
IBanditLogger,
9+
IConfigurationStore,
910
} from '@eppo/js-client-sdk-common';
1011

1112
import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store';
@@ -103,11 +104,55 @@ export interface IPrecomputedClientConfig extends IBaseRequestConfig {
103104
precompute: IPrecompute;
104105
}
105106

106-
/**
107-
* Configuration for regular client initialization
108-
* @public
109-
*/
110-
export interface IClientConfig extends IBaseRequestConfig {
107+
export type IEventOptions = {
108+
eventIngestionConfig?: {
109+
/** Number of milliseconds to wait between each batch delivery. Defaults to 10 seconds. */
110+
deliveryIntervalMs?: number;
111+
/** Minimum amount of milliseconds to wait before retrying a failed delivery. Defaults to 5 seconds */
112+
retryIntervalMs?: number;
113+
/** Maximum amount of milliseconds to wait before retrying a failed delivery. Defaults to 30 seconds. */
114+
maxRetryDelayMs?: number;
115+
/** Maximum number of retry attempts before giving up on a batch delivery. Defaults to 3 retries. */
116+
maxRetries?: number;
117+
/** Maximum number of events to send per delivery request. Defaults to 1000 events. */
118+
batchSize?: number;
119+
/**
120+
* Maximum number of events to queue in memory before starting to drop events.
121+
* Note: This is only used if localStorage is not available.
122+
* Defaults to 10000 events.
123+
*/
124+
maxQueueSize?: number;
125+
};
126+
};
127+
128+
export type IApiOptions = {
129+
sdkKey: string;
130+
131+
initialConfiguration?: string;
132+
baseUrl?: string;
133+
134+
/**
135+
* Force reinitialize the SDK if it is already initialized.
136+
*/
137+
forceReinitialize?: boolean;
138+
139+
/**
140+
* Timeout in milliseconds for the HTTPS request for the experiment configuration. (Default: 5000)
141+
*/
142+
requestTimeoutMs?: number;
143+
144+
/**
145+
* Number of additional times the initial configuration request will be attempted if it fails.
146+
* This is the request typically synchronously waited (via await) for completion. A small wait will be
147+
* done between requests. (Default: 1)
148+
*/
149+
numInitialRequestRetries?: number;
150+
151+
/**
152+
* Skip the request for new configurations during initialization. (default: false)
153+
*/
154+
skipInitialRequest?: boolean;
155+
111156
/**
112157
* Throw an error if unable to fetch an initial configuration during initialization. (default: true)
113158
*/
@@ -133,36 +178,85 @@ export interface IClientConfig extends IBaseRequestConfig {
133178
* - empty: only use the new configuration if the current one is both expired and uninitialized/empty
134179
*/
135180
updateOnFetch?: ServingStoreUpdateStrategy;
181+
};
182+
183+
/**
184+
* Handy options class for when you want to create an offline client.
185+
*/
186+
export class OfflineApiOptions implements IApiOptions {
187+
constructor(
188+
public readonly sdkKey: string,
189+
public readonly initialConfiguration?: string,
190+
) {}
191+
public readonly offline = true;
192+
}
193+
194+
export type IStorageOptions = {
195+
flagConfigurationStore?: IConfigurationStore<Flag>;
136196

137197
/**
138198
* A custom class to use for storing flag configurations.
139199
* This is useful for cases where you want to use a different storage mechanism
140200
* than the default storage provided by the SDK.
141201
*/
142202
persistentStore?: IAsyncStore<Flag>;
203+
};
143204

205+
export type IPollingOptions = {
144206
/**
145-
* Force reinitialize the SDK if it is already initialized.
207+
* Poll for new configurations even if the initial configuration request failed. (default: false)
146208
*/
147-
forceReinitialize?: boolean;
209+
pollAfterFailedInitialization?: boolean;
148210

149-
/** Configuration settings for the event dispatcher */
150-
eventIngestionConfig?: {
151-
/** Number of milliseconds to wait between each batch delivery. Defaults to 10 seconds. */
152-
deliveryIntervalMs?: number;
153-
/** Minimum amount of milliseconds to wait before retrying a failed delivery. Defaults to 5 seconds */
154-
retryIntervalMs?: number;
155-
/** Maximum amount of milliseconds to wait before retrying a failed delivery. Defaults to 30 seconds. */
156-
maxRetryDelayMs?: number;
157-
/** Maximum number of retry attempts before giving up on a batch delivery. Defaults to 3 retries. */
158-
maxRetries?: number;
159-
/** Maximum number of events to send per delivery request. Defaults to 1000 events. */
160-
batchSize?: number;
161-
/**
162-
* Maximum number of events to queue in memory before starting to drop events.
163-
* Note: This is only used if localStorage is not available.
164-
* Defaults to 10000 events.
165-
*/
166-
maxQueueSize?: number;
211+
/**
212+
* Poll for new configurations (every `pollingIntervalMs`) after successfully requesting the initial configuration. (default: false)
213+
*/
214+
pollAfterSuccessfulInitialization?: boolean;
215+
216+
/**
217+
* Amount of time to wait between API calls to refresh configuration data. Default of 30_000 (30 seconds).
218+
*/
219+
pollingIntervalMs?: number;
220+
221+
/**
222+
* Number of additional times polling for updated configurations will be attempted before giving up.
223+
* Polling is done after a successful initial request. Subsequent attempts are done using an exponential
224+
* backoff. (Default: 7)
225+
*/
226+
numPollRequestRetries?: number;
227+
};
228+
229+
export type ILoggers = {
230+
/**
231+
* Pass a logging implementation to send variation assignments to your data warehouse.
232+
*/
233+
assignmentLogger: IAssignmentLogger;
234+
235+
/**
236+
* Pass a logging implementation to send bandit assignments to your data warehouse.
237+
*/
238+
banditLogger?: IBanditLogger;
239+
};
240+
241+
/**
242+
* Config shape for client v2.
243+
*/
244+
export type IClientOptions = IApiOptions &
245+
ILoggers &
246+
IEventOptions &
247+
IStorageOptions &
248+
IPollingOptions;
249+
250+
/**
251+
* Configuration for regular client initialization
252+
* @public
253+
*/
254+
export type IClientConfig = Omit<IClientOptions, 'sdkKey' | 'offline'> &
255+
Pick<IBaseRequestConfig, 'apiKey'>;
256+
257+
export function convertClientOptionsToClientConfig(options: IClientOptions): IClientConfig {
258+
return {
259+
...options,
260+
apiKey: options.sdkKey,
167261
};
168262
}

src/index.spec.ts

Lines changed: 178 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
EppoClient,
1212
Flag,
1313
HybridConfigurationStore,
14+
IAssignmentEvent,
1415
IAsyncStore,
1516
IPrecomputedConfigurationResponse,
1617
VariationType,
@@ -29,10 +30,18 @@ import {
2930
validateTestAssignments,
3031
} from '../test/testHelpers';
3132

32-
import { IClientConfig } from './i-client-config';
33+
import {
34+
IApiOptions,
35+
IClientConfig,
36+
IClientOptions,
37+
IPollingOptions,
38+
IStorageOptions,
39+
} from './i-client-config';
3340
import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store';
3441

3542
import {
43+
EppoJSClient,
44+
EppoJSClientV2,
3645
EppoPrecomputedJSClient,
3746
getConfigUrl,
3847
getInstance,
@@ -138,6 +147,10 @@ const mockObfuscatedUfcFlagConfig: Flag = {
138147
key: base64Encode('variant-2'),
139148
value: base64Encode('variant-2'),
140149
},
150+
[base64Encode('variant-3')]: {
151+
key: base64Encode('variant-3'),
152+
value: base64Encode('variant-3'),
153+
},
141154
},
142155
allocations: [
143156
{
@@ -382,6 +395,168 @@ describe('EppoJSClient E2E test', () => {
382395
});
383396
});
384397

398+
describe('decoupled initialization', () => {
399+
let mockLogger: IAssignmentLogger;
400+
// eslint-disable-next-line @typescript-eslint/ban-types
401+
let init: (config: IClientConfig) => Promise<EppoJSClient>;
402+
// eslint-disable-next-line @typescript-eslint/ban-types
403+
let getInstance: () => EppoJSClient;
404+
405+
beforeEach(async () => {
406+
jest.isolateModules(() => {
407+
// Isolate and re-require so that the static instance is reset to its default state
408+
// eslint-disable-next-line @typescript-eslint/no-var-requires
409+
const reloadedModule = require('./index');
410+
init = reloadedModule.init;
411+
getInstance = reloadedModule.getInstance;
412+
});
413+
});
414+
415+
describe('isolated from the singleton', () => {
416+
beforeEach(() => {
417+
mockLogger = td.object<IAssignmentLogger>();
418+
419+
global.fetch = jest.fn(() => {
420+
const ufc = { flags: { [obfuscatedFlagKey]: mockObfuscatedUfcFlagConfig } };
421+
422+
return Promise.resolve({
423+
ok: true,
424+
status: 200,
425+
json: () => Promise.resolve(ufc),
426+
});
427+
}) as jest.Mock;
428+
});
429+
430+
afterEach(() => {
431+
jest.restoreAllMocks();
432+
});
433+
434+
435+
it('should be independent of the singleton', async () => {
436+
const apiOptions: IApiOptions = { sdkKey: '<MY SDK KEY>' };
437+
const options: IClientOptions = { ...apiOptions, assignmentLogger: mockLogger };
438+
const isolatedClient = new EppoJSClientV2(options);
439+
440+
expect(isolatedClient).not.toEqual(getInstance());
441+
await isolatedClient.waitForReady();
442+
443+
expect(isolatedClient.isInitialized()).toBe(true);
444+
expect(isolatedClient.initialized).toBe(true);
445+
expect(getInstance().isInitialized()).toBe(false);
446+
expect(getInstance().initialized).toBe(false);
447+
448+
expect(getInstance().getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual(
449+
'default-value',
450+
);
451+
expect(
452+
isolatedClient.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'),
453+
).toEqual('variant-1');
454+
});
455+
it('initializes on instantiation and notifies when ready', async () => {
456+
const apiOptions: IApiOptions = { sdkKey: '<MY SDK KEY>', baseUrl };
457+
const options: IClientOptions = { ...apiOptions, assignmentLogger: mockLogger };
458+
const client = new EppoJSClientV2(options);
459+
460+
expect(client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual(
461+
'default-value',
462+
);
463+
464+
await client.waitForReady();
465+
466+
const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value');
467+
expect(assignment).toEqual('variant-1');
468+
});
469+
});
470+
471+
describe('multiple client instances', () => {
472+
const API_KEY_1 = 'my-api-key-1';
473+
const API_KEY_2 = 'my-api-key-2';
474+
const API_KEY_3 = 'my-api-key-3';
475+
476+
const commonOptions: Omit<IClientOptions, 'sdkKey'> = {
477+
baseUrl,
478+
assignmentLogger: mockLogger,
479+
};
480+
481+
let callCount = 0;
482+
483+
beforeAll(() => {
484+
global.fetch = jest.fn((url: string) => {
485+
callCount++;
486+
487+
const urlParams = new URLSearchParams(url.split('?')[1]);
488+
489+
// Get the value of the apiKey parameter and serve a specific variant.
490+
const apiKey = urlParams.get('apiKey');
491+
492+
// differentiate between the SDK keys by changing the variant that `flagKey` assigns.
493+
let variant = 'variant-1';
494+
if (apiKey === API_KEY_2) {
495+
variant = 'variant-2';
496+
} else if (apiKey === API_KEY_3) {
497+
variant = 'variant-3';
498+
}
499+
500+
const encodedVariant = base64Encode(variant);
501+
502+
// deep copy the mock data since we're going to inject a change below.
503+
const flagConfig: Flag = JSON.parse(JSON.stringify(mockObfuscatedUfcFlagConfig));
504+
// Inject the encoded variant as a single split for the flag's only allocation.
505+
flagConfig.allocations[0].splits = [
506+
{
507+
variationKey: encodedVariant,
508+
shards: [],
509+
},
510+
];
511+
512+
const ufc = { flags: { [obfuscatedFlagKey]: flagConfig } };
513+
514+
return Promise.resolve({
515+
ok: true,
516+
status: 200,
517+
json: () => Promise.resolve(ufc),
518+
});
519+
}) as jest.Mock;
520+
});
521+
afterAll(() => {
522+
jest.restoreAllMocks();
523+
});
524+
525+
it('should operate in parallel', async () => {
526+
const singleton = await init({ ...commonOptions, apiKey: API_KEY_1 });
527+
expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual(
528+
'variant-1',
529+
);
530+
expect(callCount).toBe(1);
531+
532+
const myClient2 = new EppoJSClientV2({ ...commonOptions, sdkKey: API_KEY_2 });
533+
await myClient2.waitForReady();
534+
expect(callCount).toBe(2);
535+
536+
expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual(
537+
'variant-1',
538+
);
539+
expect(myClient2.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual(
540+
'variant-2',
541+
);
542+
543+
const myClient3 = new EppoJSClientV2({ ...commonOptions, sdkKey: API_KEY_3 });
544+
await myClient3.waitForReady();
545+
546+
expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual(
547+
'variant-1',
548+
);
549+
expect(myClient2.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual(
550+
'variant-2',
551+
);
552+
553+
expect(myClient3.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual(
554+
'variant-3',
555+
);
556+
});
557+
});
558+
});
559+
385560
describe('sync init', () => {
386561
it('initializes with flags in obfuscated mode', () => {
387562
const client = offlineInit({
@@ -422,9 +597,9 @@ describe('initialization options', () => {
422597
} as unknown as Record<'flags', Record<string, Flag>>;
423598

424599
// eslint-disable-next-line @typescript-eslint/ban-types
425-
let init: (config: IClientConfig) => Promise<EppoClient>;
600+
let init: (config: IClientConfig) => Promise<EppoJSClient>;
426601
// eslint-disable-next-line @typescript-eslint/ban-types
427-
let getInstance: () => EppoClient;
602+
let getInstance: () => EppoJSClient;
428603

429604
beforeEach(async () => {
430605
jest.isolateModules(() => {

0 commit comments

Comments
 (0)