From e20ef7347747e542830ee9f0904f7641cd3a4844 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 30 Jan 2025 12:13:06 -0700 Subject: [PATCH 01/29] detatched construction --- src/i-client-config.ts | 144 ++++++++++++++++++++++++++------ src/index.spec.ts | 181 ++++++++++++++++++++++++++++++++++++++++- src/index.ts | 110 +++++++++++++++++-------- 3 files changed, 373 insertions(+), 62 deletions(-) diff --git a/src/i-client-config.ts b/src/i-client-config.ts index 5b7d8f0..fc0b098 100644 --- a/src/i-client-config.ts +++ b/src/i-client-config.ts @@ -6,6 +6,7 @@ import { IAssignmentLogger, IAsyncStore, IBanditLogger, + IConfigurationStore, } from '@eppo/js-client-sdk-common'; import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store'; @@ -103,11 +104,55 @@ export interface IPrecomputedClientConfig extends IBaseRequestConfig { precompute: IPrecompute; } -/** - * Configuration for regular client initialization - * @public - */ -export interface IClientConfig extends IBaseRequestConfig { +export type IEventOptions = { + eventIngestionConfig?: { + /** Number of milliseconds to wait between each batch delivery. Defaults to 10 seconds. */ + deliveryIntervalMs?: number; + /** Minimum amount of milliseconds to wait before retrying a failed delivery. Defaults to 5 seconds */ + retryIntervalMs?: number; + /** Maximum amount of milliseconds to wait before retrying a failed delivery. Defaults to 30 seconds. */ + maxRetryDelayMs?: number; + /** Maximum number of retry attempts before giving up on a batch delivery. Defaults to 3 retries. */ + maxRetries?: number; + /** Maximum number of events to send per delivery request. Defaults to 1000 events. */ + batchSize?: number; + /** + * Maximum number of events to queue in memory before starting to drop events. + * Note: This is only used if localStorage is not available. + * Defaults to 10000 events. + */ + maxQueueSize?: number; + }; +}; + +export type IApiOptions = { + sdkKey: string; + + initialConfiguration?: string; + baseUrl?: string; + + /** + * Force reinitialize the SDK if it is already initialized. + */ + forceReinitialize?: boolean; + + /** + * Timeout in milliseconds for the HTTPS request for the experiment configuration. (Default: 5000) + */ + requestTimeoutMs?: number; + + /** + * Number of additional times the initial configuration request will be attempted if it fails. + * This is the request typically synchronously waited (via await) for completion. A small wait will be + * done between requests. (Default: 1) + */ + numInitialRequestRetries?: number; + + /** + * Skip the request for new configurations during initialization. (default: false) + */ + skipInitialRequest?: boolean; + /** * Throw an error if unable to fetch an initial configuration during initialization. (default: true) */ @@ -133,6 +178,21 @@ export interface IClientConfig extends IBaseRequestConfig { * - empty: only use the new configuration if the current one is both expired and uninitialized/empty */ updateOnFetch?: ServingStoreUpdateStrategy; +}; + +/** + * Handy options class for when you want to create an offline client. + */ +export class OfflineApiOptions implements IApiOptions { + constructor( + public readonly sdkKey: string, + public readonly initialConfiguration?: string, + ) {} + public readonly offline = true; +} + +export type IStorageOptions = { + flagConfigurationStore?: IConfigurationStore; /** * A custom class to use for storing flag configurations. @@ -140,29 +200,63 @@ export interface IClientConfig extends IBaseRequestConfig { * than the default storage provided by the SDK. */ persistentStore?: IAsyncStore; +}; +export type IPollingOptions = { /** - * Force reinitialize the SDK if it is already initialized. + * Poll for new configurations even if the initial configuration request failed. (default: false) */ - forceReinitialize?: boolean; + pollAfterFailedInitialization?: boolean; - /** Configuration settings for the event dispatcher */ - eventIngestionConfig?: { - /** Number of milliseconds to wait between each batch delivery. Defaults to 10 seconds. */ - deliveryIntervalMs?: number; - /** Minimum amount of milliseconds to wait before retrying a failed delivery. Defaults to 5 seconds */ - retryIntervalMs?: number; - /** Maximum amount of milliseconds to wait before retrying a failed delivery. Defaults to 30 seconds. */ - maxRetryDelayMs?: number; - /** Maximum number of retry attempts before giving up on a batch delivery. Defaults to 3 retries. */ - maxRetries?: number; - /** Maximum number of events to send per delivery request. Defaults to 1000 events. */ - batchSize?: number; - /** - * Maximum number of events to queue in memory before starting to drop events. - * Note: This is only used if localStorage is not available. - * Defaults to 10000 events. - */ - maxQueueSize?: number; + /** + * Poll for new configurations (every `pollingIntervalMs`) after successfully requesting the initial configuration. (default: false) + */ + pollAfterSuccessfulInitialization?: boolean; + + /** + * Amount of time to wait between API calls to refresh configuration data. Default of 30_000 (30 seconds). + */ + pollingIntervalMs?: number; + + /** + * Number of additional times polling for updated configurations will be attempted before giving up. + * Polling is done after a successful initial request. Subsequent attempts are done using an exponential + * backoff. (Default: 7) + */ + numPollRequestRetries?: number; +}; + +export type ILoggers = { + /** + * Pass a logging implementation to send variation assignments to your data warehouse. + */ + assignmentLogger: IAssignmentLogger; + + /** + * Pass a logging implementation to send bandit assignments to your data warehouse. + */ + banditLogger?: IBanditLogger; +}; + +/** + * Config shape for client v2. + */ +export type IClientOptions = IApiOptions & + ILoggers & + IEventOptions & + IStorageOptions & + IPollingOptions; + +/** + * Configuration for regular client initialization + * @public + */ +export type IClientConfig = Omit & + Pick; + +export function convertClientOptionsToClientConfig(options: IClientOptions): IClientConfig { + return { + ...options, + apiKey: options.sdkKey, }; } diff --git a/src/index.spec.ts b/src/index.spec.ts index b443fbd..bca3da3 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -11,6 +11,7 @@ import { EppoClient, Flag, HybridConfigurationStore, + IAssignmentEvent, IAsyncStore, IPrecomputedConfigurationResponse, VariationType, @@ -29,10 +30,18 @@ import { validateTestAssignments, } from '../test/testHelpers'; -import { IClientConfig } from './i-client-config'; +import { + IApiOptions, + IClientConfig, + IClientOptions, + IPollingOptions, + IStorageOptions, +} from './i-client-config'; import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store'; import { + EppoJSClient, + EppoJSClientV2, EppoPrecomputedJSClient, getConfigUrl, getInstance, @@ -138,6 +147,10 @@ const mockObfuscatedUfcFlagConfig: Flag = { key: base64Encode('variant-2'), value: base64Encode('variant-2'), }, + [base64Encode('variant-3')]: { + key: base64Encode('variant-3'), + value: base64Encode('variant-3'), + }, }, allocations: [ { @@ -382,6 +395,168 @@ describe('EppoJSClient E2E test', () => { }); }); +describe('decoupled initialization', () => { + let mockLogger: IAssignmentLogger; + // eslint-disable-next-line @typescript-eslint/ban-types + let init: (config: IClientConfig) => Promise; + // eslint-disable-next-line @typescript-eslint/ban-types + let getInstance: () => EppoJSClient; + + beforeEach(async () => { + jest.isolateModules(() => { + // Isolate and re-require so that the static instance is reset to its default state + // eslint-disable-next-line @typescript-eslint/no-var-requires + const reloadedModule = require('./index'); + init = reloadedModule.init; + getInstance = reloadedModule.getInstance; + }); + }); + + describe('isolated from the singleton', () => { + beforeEach(() => { + mockLogger = td.object(); + + global.fetch = jest.fn(() => { + const ufc = { flags: { [obfuscatedFlagKey]: mockObfuscatedUfcFlagConfig } }; + + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(ufc), + }); + }) as jest.Mock; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + + it('should be independent of the singleton', async () => { + const apiOptions: IApiOptions = { sdkKey: '' }; + const options: IClientOptions = { ...apiOptions, assignmentLogger: mockLogger }; + const isolatedClient = new EppoJSClientV2(options); + + expect(isolatedClient).not.toEqual(getInstance()); + await isolatedClient.waitForReady(); + + expect(isolatedClient.isInitialized()).toBe(true); + expect(isolatedClient.initialized).toBe(true); + expect(getInstance().isInitialized()).toBe(false); + expect(getInstance().initialized).toBe(false); + + expect(getInstance().getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( + 'default-value', + ); + expect( + isolatedClient.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'), + ).toEqual('variant-1'); + }); + it('initializes on instantiation and notifies when ready', async () => { + const apiOptions: IApiOptions = { sdkKey: '', baseUrl }; + const options: IClientOptions = { ...apiOptions, assignmentLogger: mockLogger }; + const client = new EppoJSClientV2(options); + + expect(client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( + 'default-value', + ); + + await client.waitForReady(); + + const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'); + expect(assignment).toEqual('variant-1'); + }); + }); + + describe('multiple client instances', () => { + const API_KEY_1 = 'my-api-key-1'; + const API_KEY_2 = 'my-api-key-2'; + const API_KEY_3 = 'my-api-key-3'; + + const commonOptions: Omit = { + baseUrl, + assignmentLogger: mockLogger, + }; + + let callCount = 0; + + beforeAll(() => { + global.fetch = jest.fn((url: string) => { + callCount++; + + const urlParams = new URLSearchParams(url.split('?')[1]); + + // Get the value of the apiKey parameter and serve a specific variant. + const apiKey = urlParams.get('apiKey'); + + // differentiate between the SDK keys by changing the variant that `flagKey` assigns. + let variant = 'variant-1'; + if (apiKey === API_KEY_2) { + variant = 'variant-2'; + } else if (apiKey === API_KEY_3) { + variant = 'variant-3'; + } + + const encodedVariant = base64Encode(variant); + + // deep copy the mock data since we're going to inject a change below. + const flagConfig: Flag = JSON.parse(JSON.stringify(mockObfuscatedUfcFlagConfig)); + // Inject the encoded variant as a single split for the flag's only allocation. + flagConfig.allocations[0].splits = [ + { + variationKey: encodedVariant, + shards: [], + }, + ]; + + const ufc = { flags: { [obfuscatedFlagKey]: flagConfig } }; + + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(ufc), + }); + }) as jest.Mock; + }); + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('should operate in parallel', async () => { + const singleton = await init({ ...commonOptions, apiKey: API_KEY_1 }); + expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( + 'variant-1', + ); + expect(callCount).toBe(1); + + const myClient2 = new EppoJSClientV2({ ...commonOptions, sdkKey: API_KEY_2 }); + await myClient2.waitForReady(); + expect(callCount).toBe(2); + + expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( + 'variant-1', + ); + expect(myClient2.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( + 'variant-2', + ); + + const myClient3 = new EppoJSClientV2({ ...commonOptions, sdkKey: API_KEY_3 }); + await myClient3.waitForReady(); + + expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( + 'variant-1', + ); + expect(myClient2.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( + 'variant-2', + ); + + expect(myClient3.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( + 'variant-3', + ); + }); + }); +}); + describe('sync init', () => { it('initializes with flags in obfuscated mode', () => { const client = offlineInit({ @@ -422,9 +597,9 @@ describe('initialization options', () => { } as unknown as Record<'flags', Record>; // eslint-disable-next-line @typescript-eslint/ban-types - let init: (config: IClientConfig) => Promise; + let init: (config: IClientConfig) => Promise; // eslint-disable-next-line @typescript-eslint/ban-types - let getInstance: () => EppoClient; + let getInstance: () => EppoJSClient; beforeEach(async () => { jest.isolateModules(() => { diff --git a/src/index.ts b/src/index.ts index 3c0ce25..abe7989 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,9 @@ import { Subject, IBanditLogger, IObfuscatedPrecomputedConfigurationResponse, + IAssignmentEvent, } from '@eppo/js-client-sdk-common'; +import { getMD5Hash } from '@eppo/js-client-sdk-common/dist/obfuscation'; import { assignmentCacheFactory } from './cache/assignment-cache-factory'; import HybridAssignmentCache from './cache/hybrid-assignment-cache'; @@ -43,7 +45,12 @@ import { } from './configuration-factory'; import BrowserNetworkStatusListener from './events/browser-network-status-listener'; import LocalStorageBackedNamedEventQueue from './events/local-storage-backed-named-event-queue'; -import { IClientConfig, IPrecomputedClientConfig } from './i-client-config'; +import { + convertClientOptionsToClientConfig, + IClientConfig, + IClientOptions, + IPrecomputedClientConfig, +} from './i-client-config'; import { sdkName, sdkVersion } from './sdk-data'; /** @@ -105,7 +112,7 @@ export class EppoJSClient extends EppoClient { flagConfigurationStore, isObfuscated: true, }); - public static initialized = false; + initialized = false; public getStringAssignment( flagKey: string, @@ -113,7 +120,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: string, ): string { - EppoJSClient.ensureInitialized(); + this.ensureInitialized(); return super.getStringAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -123,7 +130,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: string, ): IAssignmentDetails { - EppoJSClient.ensureInitialized(); + this.ensureInitialized(); return super.getStringAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -145,7 +152,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: boolean, ): boolean { - EppoJSClient.ensureInitialized(); + this.ensureInitialized(); return super.getBooleanAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -155,7 +162,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: boolean, ): IAssignmentDetails { - EppoJSClient.ensureInitialized(); + this.ensureInitialized(); return super.getBooleanAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -165,7 +172,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: number, ): number { - EppoJSClient.ensureInitialized(); + this.ensureInitialized(); return super.getIntegerAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -175,7 +182,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: number, ): IAssignmentDetails { - EppoJSClient.ensureInitialized(); + this.ensureInitialized(); return super.getIntegerAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -185,7 +192,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: number, ): number { - EppoJSClient.ensureInitialized(); + this.ensureInitialized(); return super.getNumericAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -195,7 +202,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: number, ): IAssignmentDetails { - EppoJSClient.ensureInitialized(); + this.ensureInitialized(); return super.getNumericAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -205,7 +212,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: object, ): object { - EppoJSClient.ensureInitialized(); + this.ensureInitialized(); return super.getJSONAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -215,7 +222,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: object, ): IAssignmentDetails { - EppoJSClient.ensureInitialized(); + this.ensureInitialized(); return super.getJSONAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -226,7 +233,7 @@ export class EppoJSClient extends EppoClient { actions: BanditActions, defaultValue: string, ): Omit, 'evaluationDetails'> { - EppoJSClient.ensureInitialized(); + this.ensureInitialized(); return super.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, defaultValue); } @@ -237,7 +244,7 @@ export class EppoJSClient extends EppoClient { actions: BanditActions, defaultValue: string, ): IAssignmentDetails { - EppoJSClient.ensureInitialized(); + this.ensureInitialized(); return super.getBanditActionDetails( flagKey, subjectKey, @@ -252,17 +259,47 @@ export class EppoJSClient extends EppoClient { subjectKey: string, subjectAttributes: Record, ): T { - EppoJSClient.ensureInitialized(); + this.ensureInitialized(); return super.getExperimentContainerEntry(flagExperiment, subjectKey, subjectAttributes); } - private static ensureInitialized() { - if (!EppoJSClient.initialized) { + private ensureInitialized() { + if (!this.initialized) { + // TODO: check super.isinitialized? applicationLogger.warn('Eppo SDK assignment requested before init() completed'); } } } +/** + * Non-managed implementation of the Eppo JS Client. + * + * Users of this class will need to manage their instance of `EppoJSClient` with their favourite + * flavour of dependency injections instead of using the exported function `getInstance()`. + */ +export class EppoJSClientV2 extends EppoJSClient { + private readonly readyPromise: Promise; + + constructor(options: IClientOptions) { + const flagConfigurationStore = configurationStorageFactory({ + forceMemoryOnly: true, + }); + + super({ + flagConfigurationStore, + isObfuscated: true, + }); + + this.readyPromise = explicitInit(convertClientOptionsToClientConfig(options), this).then(() => { + return; + }); + } + + public waitForReady(): Promise { + return this.readyPromise; + } +} + /** * Builds a storage key suffix from an API key. * @param apiKey - The API key to build the suffix from @@ -270,8 +307,8 @@ export class EppoJSClient extends EppoClient { * @public */ export function buildStorageKeySuffix(apiKey: string): string { - // Note that we use the first 8 characters of the API key to create per-API key persistent storages and caches - return apiKey.replace(/\W/g, '').substring(0, 8); + // Note that we use the last 8 characters of hashed API key to create per-API key persistent storages and caches + return getMD5Hash(apiKey).slice(-8); } /** @@ -283,10 +320,13 @@ export function buildStorageKeySuffix(apiKey: string): string { * This method should be called once on application startup. * * @param config - client configuration + * @param instance an EppoJSClient instance to bootstrap. * @returns a singleton client instance * @public */ -export function offlineInit(config: IClientConfigSync): EppoClient { +export function offlineInit(config: IClientConfigSync, instance?: EppoJSClient): EppoClient { + instance = instance ?? getInstance(); + const isObfuscated = config.isObfuscated ?? false; const throwOnFailedInitialization = config.throwOnFailedInitialization ?? true; @@ -299,19 +339,19 @@ export function offlineInit(config: IClientConfigSync): EppoClient { .catch((err) => applicationLogger.warn('Error setting flags for memory-only configuration store', err), ); - EppoJSClient.instance.setFlagConfigurationStore(memoryOnlyConfigurationStore); + instance.setFlagConfigurationStore(memoryOnlyConfigurationStore); // Allow the caller to override the default obfuscated mode, which is false // since the purpose of this method is to bootstrap the SDK from an external source, // which is likely a server that has not-obfuscated flag values. - EppoJSClient.instance.setIsObfuscated(isObfuscated); + instance.setIsObfuscated(isObfuscated); if (config.assignmentLogger) { - EppoJSClient.instance.setAssignmentLogger(config.assignmentLogger); + instance.setAssignmentLogger(config.assignmentLogger); } if (config.banditLogger) { - EppoJSClient.instance.setBanditLogger(config.banditLogger); + instance.setBanditLogger(config.banditLogger); } // There is no SDK key in the offline context. @@ -325,7 +365,7 @@ export function offlineInit(config: IClientConfigSync): EppoClient { storageKeySuffix, forceMemoryOnly: true, }); - EppoJSClient.instance.useCustomAssignmentCache(assignmentCache); + instance.useCustomAssignmentCache(assignmentCache); } catch (error) { applicationLogger.warn( 'Eppo SDK encountered an error initializing, assignment calls will return the default value and not be logged', @@ -335,8 +375,8 @@ export function offlineInit(config: IClientConfigSync): EppoClient { } } - EppoJSClient.initialized = true; - return EppoJSClient.instance; + instance.initialized = true; + return instance; } /** @@ -366,10 +406,10 @@ export async function init(config: IClientConfig): Promise { return client; } -async function explicitInit(config: IClientConfig): Promise { +async function explicitInit(config: IClientConfig, instance?: EppoJSClient): Promise { validation.validateNotBlank(config.apiKey, 'API key required'); + instance = instance ?? getInstance(); let initializationError: Error | undefined; - const instance = EppoJSClient.instance; const { apiKey, persistentStore, @@ -386,13 +426,15 @@ async function explicitInit(config: IClientConfig): Promise { skipInitialRequest = false, eventIngestionConfig, } = config; + try { - if (EppoJSClient.initialized) { + if (instance.initialized) { + // TODO: check super.isInitialized. if (forceReinitialize) { applicationLogger.warn( 'Eppo SDK is already initialized, reinitializing since forceReinitialize is true.', ); - EppoJSClient.initialized = false; + instance.initialized = false; } else { applicationLogger.warn( 'Eppo SDK is already initialized, skipping reinitialization since forceReinitialize is false.', @@ -587,7 +629,7 @@ async function explicitInit(config: IClientConfig): Promise { } } - EppoJSClient.initialized = true; + instance.initialized = true; return instance; } @@ -597,7 +639,7 @@ async function explicitInit(config: IClientConfig): Promise { * @returns a singleton client instance * @public */ -export function getInstance(): EppoClient { +export function getInstance(): EppoJSClient { return EppoJSClient.instance; } @@ -659,7 +701,7 @@ export class EppoPrecomputedJSClient extends EppoPrecomputedClient { } private static getAssignmentInitializationCheck() { - if (!EppoJSClient.initialized) { + if (!EppoPrecomputedJSClient.initialized) { applicationLogger.warn('Eppo SDK assignment requested before init() completed'); } } From 50e66aada63cd87be25adfa847c74019e4908ce7 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 30 Jan 2025 14:19:20 -0700 Subject: [PATCH 02/29] combine new and old constructors --- src/client-options-converter.spec.ts | 134 +++++++++++++++++++++++++ src/client-options-converter.ts | 58 +++++++++++ src/index.spec.ts | 10 +- src/index.ts | 141 ++++++++++++++++++--------- 4 files changed, 290 insertions(+), 53 deletions(-) create mode 100644 src/client-options-converter.spec.ts create mode 100644 src/client-options-converter.ts diff --git a/src/client-options-converter.spec.ts b/src/client-options-converter.spec.ts new file mode 100644 index 0000000..21e67e3 --- /dev/null +++ b/src/client-options-converter.spec.ts @@ -0,0 +1,134 @@ +import { + IConfigurationStore, + ObfuscatedFlag, + Flag, + EventDispatcher, +} from '@eppo/js-client-sdk-common'; +import * as td from 'testdouble'; + +import { clientOptionsToParameters } from './client-options-converter'; +import { IClientOptions } from './i-client-config'; +import { sdkName, sdkVersion } from './sdk-data'; + +describe('clientOptionsToParameters', () => { + const mockStore = td.object>(); + + it('converts basic client options', () => { + const options: IClientOptions = { + sdkKey: 'test-key', + baseUrl: 'https://test.eppo.cloud', + assignmentLogger: { logAssignment: jest.fn() }, + }; + + const result = clientOptionsToParameters(options, mockStore); + + expect(result.isObfuscated).toBe(true); + expect(result.flagConfigurationStore).toBeDefined(); + expect(result.configurationRequestParameters).toEqual({ + apiKey: 'test-key', + baseUrl: 'https://test.eppo.cloud', + sdkName, + sdkVersion, + numInitialRequestRetries: undefined, + numPollRequestRetries: undefined, + pollingIntervalMs: undefined, + requestTimeoutMs: undefined, + pollAfterFailedInitialization: undefined, + pollAfterSuccessfulInitialization: undefined, + throwOnFailedInitialization: undefined, + skipInitialPoll: undefined, + }); + }); + + it('uses provided flag configuration store', () => { + const options: IClientOptions = { + sdkKey: 'test-key', + assignmentLogger: { logAssignment: jest.fn() }, + }; + + const result = clientOptionsToParameters(options, mockStore); + + expect(result.flagConfigurationStore).toBe(mockStore); + }); + + it('converts client options with event ingestion config', () => { + const options: IClientOptions = { + sdkKey: 'test-key', + assignmentLogger: { logAssignment: jest.fn() }, + }; + const mockDispatcher: EventDispatcher = td.object(); + + const result = clientOptionsToParameters(options, mockStore, mockDispatcher); + + expect(result.eventDispatcher).toBeDefined(); + }); + + it('converts client options with polling configuration', () => { + const options: IClientOptions = { + sdkKey: 'test-key', + assignmentLogger: { logAssignment: jest.fn() }, + pollingIntervalMs: 30000, + pollAfterSuccessfulInitialization: true, + pollAfterFailedInitialization: true, + skipInitialRequest: true, + }; + + const result = clientOptionsToParameters(options, mockStore); + + expect(result.configurationRequestParameters).toMatchObject({ + pollingIntervalMs: 30000, + pollAfterSuccessfulInitialization: true, + pollAfterFailedInitialization: true, + skipInitialPoll: true, + }); + }); + + it('converts client options with retry configuration', () => { + const options: IClientOptions = { + sdkKey: 'test-key', + assignmentLogger: { logAssignment: jest.fn() }, + requestTimeoutMs: 5000, + numInitialRequestRetries: 3, + numPollRequestRetries: 2, + }; + + const result = clientOptionsToParameters(options, mockStore); + + expect(result.configurationRequestParameters).toMatchObject({ + requestTimeoutMs: 5000, + numInitialRequestRetries: 3, + numPollRequestRetries: 2, + }); + }); + + it('handles undefined optional parameters', () => { + const options: IClientOptions = { + sdkKey: 'test-key', + assignmentLogger: { logAssignment: jest.fn() }, + }; + + const result = clientOptionsToParameters(options, mockStore); + + expect(result.configurationRequestParameters).toMatchObject({ + baseUrl: undefined, + pollingIntervalMs: undefined, + requestTimeoutMs: undefined, + numInitialRequestRetries: undefined, + numPollRequestRetries: undefined, + }); + }); + + it('includes sdk metadata', () => { + const options: IClientOptions = { + sdkKey: 'test-key', + assignmentLogger: { logAssignment: jest.fn() }, + }; + + const result = clientOptionsToParameters(options, mockStore); + + expect(result.configurationRequestParameters).toMatchObject({ + sdkName, + sdkVersion, + }); + }); +}); diff --git a/src/client-options-converter.ts b/src/client-options-converter.ts new file mode 100644 index 0000000..37d73b7 --- /dev/null +++ b/src/client-options-converter.ts @@ -0,0 +1,58 @@ +import { + BanditParameters, + BanditVariation, + EventDispatcher, + Flag, + FlagConfigurationRequestParameters, + IConfigurationStore, + ObfuscatedFlag, +} from '@eppo/js-client-sdk-common'; + +import { IClientOptions } from './i-client-config'; +import { sdkName, sdkVersion } from './sdk-data'; + +export type EppoClientParameters = { + // Dispatcher for arbitrary, application-level events (not to be confused with Eppo specific assignment + // or bandit events). These events are application-specific and captures by EppoClient#track API. + eventDispatcher?: EventDispatcher; + flagConfigurationStore: IConfigurationStore; + banditVariationConfigurationStore?: IConfigurationStore; + banditModelConfigurationStore?: IConfigurationStore; + configurationRequestParameters?: FlagConfigurationRequestParameters; + isObfuscated?: boolean; +}; + +/** + * Converts IClientOptions to EppoClientParameters + * @internal + */ +export function clientOptionsToParameters( + options: IClientOptions, + flagConfigurationStore: IConfigurationStore, + eventDispatcher?: EventDispatcher, +): EppoClientParameters { + const parameters: EppoClientParameters = { + flagConfigurationStore, + isObfuscated: true, + }; + + parameters.eventDispatcher = eventDispatcher; + + // Always include configuration request parameters + parameters.configurationRequestParameters = { + apiKey: options.sdkKey, + sdkVersion, // dynamically picks up version. + sdkName, // Hardcoded to `js-client-sdk` + baseUrl: options.baseUrl, + requestTimeoutMs: options.requestTimeoutMs, + numInitialRequestRetries: options.numInitialRequestRetries, + numPollRequestRetries: options.numPollRequestRetries, + pollAfterSuccessfulInitialization: options.pollAfterSuccessfulInitialization, + pollAfterFailedInitialization: options.pollAfterFailedInitialization, + pollingIntervalMs: options.pollingIntervalMs, + throwOnFailedInitialization: options.throwOnFailedInitialization, + skipInitialPoll: options.skipInitialRequest, + }; + + return parameters; +} diff --git a/src/index.spec.ts b/src/index.spec.ts index bca3da3..433142a 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -41,7 +41,6 @@ import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store'; import { EppoJSClient, - EppoJSClientV2, EppoPrecomputedJSClient, getConfigUrl, getInstance, @@ -431,11 +430,10 @@ describe('decoupled initialization', () => { jest.restoreAllMocks(); }); - it('should be independent of the singleton', async () => { const apiOptions: IApiOptions = { sdkKey: '' }; const options: IClientOptions = { ...apiOptions, assignmentLogger: mockLogger }; - const isolatedClient = new EppoJSClientV2(options); + const isolatedClient = new EppoJSClient(options); expect(isolatedClient).not.toEqual(getInstance()); await isolatedClient.waitForReady(); @@ -455,7 +453,7 @@ describe('decoupled initialization', () => { it('initializes on instantiation and notifies when ready', async () => { const apiOptions: IApiOptions = { sdkKey: '', baseUrl }; const options: IClientOptions = { ...apiOptions, assignmentLogger: mockLogger }; - const client = new EppoJSClientV2(options); + const client = new EppoJSClient(options); expect(client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( 'default-value', @@ -529,7 +527,7 @@ describe('decoupled initialization', () => { ); expect(callCount).toBe(1); - const myClient2 = new EppoJSClientV2({ ...commonOptions, sdkKey: API_KEY_2 }); + const myClient2 = new EppoJSClient({ ...commonOptions, sdkKey: API_KEY_2 }); await myClient2.waitForReady(); expect(callCount).toBe(2); @@ -540,7 +538,7 @@ describe('decoupled initialization', () => { 'variant-2', ); - const myClient3 = new EppoJSClientV2({ ...commonOptions, sdkKey: API_KEY_3 }); + const myClient3 = new EppoJSClient({ ...commonOptions, sdkKey: API_KEY_3 }); await myClient3.waitForReady(); expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( diff --git a/src/index.ts b/src/index.ts index abe7989..228f7ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,12 +22,12 @@ import { Subject, IBanditLogger, IObfuscatedPrecomputedConfigurationResponse, - IAssignmentEvent, } from '@eppo/js-client-sdk-common'; import { getMD5Hash } from '@eppo/js-client-sdk-common/dist/obfuscation'; import { assignmentCacheFactory } from './cache/assignment-cache-factory'; import HybridAssignmentCache from './cache/hybrid-assignment-cache'; +import { clientOptionsToParameters, EppoClientParameters } from './client-options-converter'; import { ConfigLoaderStatus, ConfigLoadResult, @@ -92,6 +92,7 @@ export { export { ChromeStorageEngine } from './chrome-storage-engine'; // Instantiate the configuration store with memory-only implementation. +// For use only with the singleton instance. const flagConfigurationStore = configurationStorageFactory({ forceMemoryOnly: true, }); @@ -105,15 +106,58 @@ const memoryOnlyPrecomputedBanditsStore = precomputedBanditStoreFactory(); * @public */ export class EppoJSClient extends EppoClient { - // Ensure that the client is instantiated during class loading. - // Use an empty memory-only configuration store until the `init` method is called, - // to avoid serving stale data to the user. + private readonly readyPromise: Promise; + private readyPromiseResolver: (() => void) | null = null; + public static instance = new EppoJSClient({ flagConfigurationStore, isObfuscated: true, }); initialized = false; + constructor(optionsOrConfig: EppoClientParameters | IClientOptions) { + const v2Constructor = 'sdkKey' in optionsOrConfig; + + super( + v2Constructor + ? clientOptionsToParameters( + optionsOrConfig, // The IClientOptions wrapper + optionsOrConfig.flagConfigurationStore ?? // create a new, memory only FCS if none was provided + configurationStorageFactory({ + forceMemoryOnly: true, + }), + optionsOrConfig.eventIngestionConfig // Create an Event Dispatcher if Event Ingestion config was provided. + ? newEventDispatcher(optionsOrConfig.sdkKey, optionsOrConfig.eventIngestionConfig) + : undefined, + ) // "New" construction technique; isolated instance. + : (optionsOrConfig as EppoClientParameters), // Legacy instantiation of singleton. + ); + + if (!v2Constructor) { + // Original constructor path; passthrough to super is above. + // `waitForReady` is a new API we need to graft onto the old way of constructing first and initializing later. + + // Create a promise that will be resolved when EppoJSClient.initializeClient() is called + this.readyPromise = new Promise((resolve) => { + this.readyPromiseResolver = resolve; + }); + } else { + this.readyPromise = explicitInit( + convertClientOptionsToClientConfig(optionsOrConfig), + this, + ).then(() => { + return; + }); + } + } + + public waitForReady(): Promise { + if (!this.readyPromise) { + return Promise.resolve(); + } + return this.readyPromise; + } + public getStringAssignment( flagKey: string, subjectKey: string, @@ -265,38 +309,51 @@ export class EppoJSClient extends EppoClient { private ensureInitialized() { if (!this.initialized) { - // TODO: check super.isinitialized? + // TODO: check super.isInitialized? applicationLogger.warn('Eppo SDK assignment requested before init() completed'); } } -} -/** - * Non-managed implementation of the Eppo JS Client. - * - * Users of this class will need to manage their instance of `EppoJSClient` with their favourite - * flavour of dependency injections instead of using the exported function `getInstance()`. - */ -export class EppoJSClientV2 extends EppoJSClient { - private readonly readyPromise: Promise; - - constructor(options: IClientOptions) { - const flagConfigurationStore = configurationStorageFactory({ - forceMemoryOnly: true, - }); + /** + * Tracks pending initialization. After an initialization completes, the value is removed from the map. + */ + private static initializationPromise: Promise | null = null; - super({ - flagConfigurationStore, - isObfuscated: true, - }); + /** + * This method is part of a bridge from using a singleton to independent instances. More specifically, it exists so + * that the init method can access the private field, `readyResolver`. It should not be called by any + * methods other than the `init` method. There are limited guards in place; the behaviour if called inappropriately + * is undefined. + * + * It also keeps code that relies on internal details of EppoJSClient colocated in the class. + * + * @internal + * + * @param client + * @param config + */ + static async initializeClient( + client: EppoJSClient, + config: IClientConfig, + ): Promise { + validation.validateNotBlank(config.apiKey, 'API key required'); + + // If there is already an init in progress for this apiKey, return that. + if (!EppoJSClient.initializationPromise) { + EppoJSClient.initializationPromise = explicitInit(config, client).then((client) => { + // Resolve the ready promise if it exists + if (client.readyPromiseResolver) { + client.readyPromiseResolver(); + client.readyPromiseResolver = null; + } + return client; + }); + } - this.readyPromise = explicitInit(convertClientOptionsToClientConfig(options), this).then(() => { - return; - }); - } + const readyClient = await EppoJSClient.initializationPromise; + EppoJSClient.initializationPromise = null; - public waitForReady(): Promise { - return this.readyPromise; + return readyClient; } } @@ -379,36 +436,26 @@ export function offlineInit(config: IClientConfigSync, instance?: EppoJSClient): return instance; } -/** - * Tracks pending initialization. After an initialization completes, the value is removed from the map. - */ -let initializationPromise: Promise | null = null; - /** * Initializes the Eppo client with configuration parameters. * This method should be called once on application startup. * If an initialization is in process, calling `init` will return the in-progress * `Promise`. Once the initialization completes, calling `init` again will kick off the * initialization routine (if `forceReinitialization` is `true`). + * + * + * @deprecated + * Use `new EppoJSClient(options)` instead of `init` or `initializeClient`. These will be removed in v4 + * * @param config - client configuration * @public */ export async function init(config: IClientConfig): Promise { - validation.validateNotBlank(config.apiKey, 'API key required'); - - // If there is already an init in progress for this apiKey, return that. - if (!initializationPromise) { - initializationPromise = explicitInit(config); - } - - const client = await initializationPromise; - initializationPromise = null; - return client; + return EppoJSClient.initializeClient(getInstance(), config); } -async function explicitInit(config: IClientConfig, instance?: EppoJSClient): Promise { +async function explicitInit(config: IClientConfig, instance: EppoJSClient): Promise { validation.validateNotBlank(config.apiKey, 'API key required'); - instance = instance ?? getInstance(); let initializationError: Error | undefined; const { apiKey, @@ -889,7 +936,7 @@ export function getPrecomputedInstance(): EppoPrecomputedClient { return EppoPrecomputedJSClient.instance; } -function newEventDispatcher( +export function newEventDispatcher( sdkKey: string, config: IClientConfig['eventIngestionConfig'] = {}, ): EventDispatcher { From de9f65314375dba0794e68ba5fa147f2df3080e8 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 31 Jan 2025 09:48:24 -0700 Subject: [PATCH 03/29] use spark for md5 instead of non-exported member from common --- package.json | 3 ++- src/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 73c284c..884137a 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,8 @@ "webpack-cli": "^6.0.1" }, "dependencies": { - "@eppo/js-client-sdk-common": "4.8.4" + "@eppo/js-client-sdk-common": "4.8.4", + "spark-md5": "^3.0.2" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/index.ts b/src/index.ts index 228f7ea..6d46a02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,7 @@ import { IBanditLogger, IObfuscatedPrecomputedConfigurationResponse, } from '@eppo/js-client-sdk-common'; -import { getMD5Hash } from '@eppo/js-client-sdk-common/dist/obfuscation'; +import SparkMD5 from 'spark-md5'; import { assignmentCacheFactory } from './cache/assignment-cache-factory'; import HybridAssignmentCache from './cache/hybrid-assignment-cache'; @@ -365,7 +365,7 @@ export class EppoJSClient extends EppoClient { */ export function buildStorageKeySuffix(apiKey: string): string { // Note that we use the last 8 characters of hashed API key to create per-API key persistent storages and caches - return getMD5Hash(apiKey).slice(-8); + return new SparkMD5().append(apiKey).end().slice(-8); } /** From fa5baab7455115a610c74b4b1056d958a61b8ab9 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 31 Jan 2025 12:13:45 -0700 Subject: [PATCH 04/29] update to latest common, use exported members --- package.json | 3 +-- src/client-options-converter.ts | 13 +------------ src/index.ts | 16 ++-------------- yarn.lock | 8 ++++---- 4 files changed, 8 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index 884137a..3346459 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,7 @@ "webpack-cli": "^6.0.1" }, "dependencies": { - "@eppo/js-client-sdk-common": "4.8.4", - "spark-md5": "^3.0.2" + "@eppo/js-client-sdk-common": "4.9.0" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/client-options-converter.ts b/src/client-options-converter.ts index 37d73b7..78d9e0e 100644 --- a/src/client-options-converter.ts +++ b/src/client-options-converter.ts @@ -1,6 +1,6 @@ import { BanditParameters, - BanditVariation, + BanditVariation, EppoClientParameters, EventDispatcher, Flag, FlagConfigurationRequestParameters, @@ -11,17 +11,6 @@ import { import { IClientOptions } from './i-client-config'; import { sdkName, sdkVersion } from './sdk-data'; -export type EppoClientParameters = { - // Dispatcher for arbitrary, application-level events (not to be confused with Eppo specific assignment - // or bandit events). These events are application-specific and captures by EppoClient#track API. - eventDispatcher?: EventDispatcher; - flagConfigurationStore: IConfigurationStore; - banditVariationConfigurationStore?: IConfigurationStore; - banditModelConfigurationStore?: IConfigurationStore; - configurationRequestParameters?: FlagConfigurationRequestParameters; - isObfuscated?: boolean; -}; - /** * Converts IClientOptions to EppoClientParameters * @internal diff --git a/src/index.ts b/src/index.ts index 6d46a02..cbe73b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,13 +21,12 @@ import { IConfigurationWire, Subject, IBanditLogger, - IObfuscatedPrecomputedConfigurationResponse, + IObfuscatedPrecomputedConfigurationResponse, buildStorageKeySuffix, EppoClientParameters, } from '@eppo/js-client-sdk-common'; -import SparkMD5 from 'spark-md5'; import { assignmentCacheFactory } from './cache/assignment-cache-factory'; import HybridAssignmentCache from './cache/hybrid-assignment-cache'; -import { clientOptionsToParameters, EppoClientParameters } from './client-options-converter'; +import { clientOptionsToParameters } from './client-options-converter'; import { ConfigLoaderStatus, ConfigLoadResult, @@ -357,17 +356,6 @@ export class EppoJSClient extends EppoClient { } } -/** - * Builds a storage key suffix from an API key. - * @param apiKey - The API key to build the suffix from - * @returns A string suffix for storage keys - * @public - */ -export function buildStorageKeySuffix(apiKey: string): string { - // Note that we use the last 8 characters of hashed API key to create per-API key persistent storages and caches - return new SparkMD5().append(apiKey).end().slice(-8); -} - /** * Initializes the Eppo client with configuration parameters. * diff --git a/yarn.lock b/yarn.lock index f193f71..3a81272 100644 --- a/yarn.lock +++ b/yarn.lock @@ -380,10 +380,10 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz#f13c7c205915eb91ae54c557f5e92bddd8be0e83" integrity sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ== -"@eppo/js-client-sdk-common@4.8.4": - version "4.8.4" - resolved "https://registry.yarnpkg.com/@eppo/js-client-sdk-common/-/js-client-sdk-common-4.8.4.tgz#a1919233fa52399b86ce75b9eebed1c6d2bac16e" - integrity sha512-cDxOOHjGU0kJLp2zXWGXaH2xcEd/oxsAT4e78jbbhktb+e5vkU3+SCFTvijFykr1h/hQ2a3O1PPP0M8HFfdrZA== +"@eppo/js-client-sdk-common@4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@eppo/js-client-sdk-common/-/js-client-sdk-common-4.9.0.tgz#6af8dfc7ee0ca9d36ec7fd4ca9ac8852e25c355e" + integrity sha512-DW1C7dTmBzKd96lUUZo/00178ZXoIUiEsbPn2E5myB3WMCFO38HBpdHmDtJS6Qv5SvSGm9c9FdGgIq1k+fElrw== dependencies: buffer "npm:@eppo/buffer@6.2.0" js-base64 "^3.7.7" From 953c4e4ae1983f0cdf27c453ca107e841012f112 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 31 Jan 2025 12:19:26 -0700 Subject: [PATCH 05/29] doc comments --- src/i-client-config.ts | 70 +++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/src/i-client-config.ts b/src/i-client-config.ts index fc0b098..5fc2fbc 100644 --- a/src/i-client-config.ts +++ b/src/i-client-config.ts @@ -104,31 +104,18 @@ export interface IPrecomputedClientConfig extends IBaseRequestConfig { precompute: IPrecompute; } -export type IEventOptions = { - eventIngestionConfig?: { - /** Number of milliseconds to wait between each batch delivery. Defaults to 10 seconds. */ - deliveryIntervalMs?: number; - /** Minimum amount of milliseconds to wait before retrying a failed delivery. Defaults to 5 seconds */ - retryIntervalMs?: number; - /** Maximum amount of milliseconds to wait before retrying a failed delivery. Defaults to 30 seconds. */ - maxRetryDelayMs?: number; - /** Maximum number of retry attempts before giving up on a batch delivery. Defaults to 3 retries. */ - maxRetries?: number; - /** Maximum number of events to send per delivery request. Defaults to 1000 events. */ - batchSize?: number; - /** - * Maximum number of events to queue in memory before starting to drop events. - * Note: This is only used if localStorage is not available. - * Defaults to 10000 events. - */ - maxQueueSize?: number; - }; -}; - +/** + * Base options for the EppoClient SDK + */ export type IApiOptions = { + /** + * Your key for accessing Eppo through the Eppo SDK. + */ sdkKey: string; - initialConfiguration?: string; + /** + * Override the endpoint the SDK uses to load configuration. + */ baseUrl?: string; /** @@ -178,20 +165,39 @@ export type IApiOptions = { * - empty: only use the new configuration if the current one is both expired and uninitialized/empty */ updateOnFetch?: ServingStoreUpdateStrategy; + + /** + * A configuration string to bootstrap the client without having to make a network fetch. + */ + initialConfiguration: string; }; -/** - * Handy options class for when you want to create an offline client. - */ -export class OfflineApiOptions implements IApiOptions { - constructor( - public readonly sdkKey: string, - public readonly initialConfiguration?: string, - ) {} - public readonly offline = true; -} + +export type IEventOptions = { + eventIngestionConfig?: { + /** Number of milliseconds to wait between each batch delivery. Defaults to 10 seconds. */ + deliveryIntervalMs?: number; + /** Minimum amount of milliseconds to wait before retrying a failed delivery. Defaults to 5 seconds */ + retryIntervalMs?: number; + /** Maximum amount of milliseconds to wait before retrying a failed delivery. Defaults to 30 seconds. */ + maxRetryDelayMs?: number; + /** Maximum number of retry attempts before giving up on a batch delivery. Defaults to 3 retries. */ + maxRetries?: number; + /** Maximum number of events to send per delivery request. Defaults to 1000 events. */ + batchSize?: number; + /** + * Maximum number of events to queue in memory before starting to drop events. + * Note: This is only used if localStorage is not available. + * Defaults to 10000 events. + */ + maxQueueSize?: number; + }; +}; export type IStorageOptions = { + /** + * Custom implementation of the flag configuration store for advanced use-cases. + */ flagConfigurationStore?: IConfigurationStore; /** From 159df112441913433a866c66e60dcd97e50db6f7 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 31 Jan 2025 12:33:15 -0700 Subject: [PATCH 06/29] wip --- src/client-options-converter.spec.ts | 16 ++++++++-------- src/client-options-converter.ts | 7 ++----- src/i-client-config.ts | 2 +- src/index.ts | 18 +++++++++++++++--- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/client-options-converter.spec.ts b/src/client-options-converter.spec.ts index 21e67e3..3edc50b 100644 --- a/src/client-options-converter.spec.ts +++ b/src/client-options-converter.spec.ts @@ -6,7 +6,7 @@ import { } from '@eppo/js-client-sdk-common'; import * as td from 'testdouble'; -import { clientOptionsToParameters } from './client-options-converter'; +import { clientOptionsToEppoClientParameters } from './client-options-converter'; import { IClientOptions } from './i-client-config'; import { sdkName, sdkVersion } from './sdk-data'; @@ -20,7 +20,7 @@ describe('clientOptionsToParameters', () => { assignmentLogger: { logAssignment: jest.fn() }, }; - const result = clientOptionsToParameters(options, mockStore); + const result = clientOptionsToEppoClientParameters(options, mockStore); expect(result.isObfuscated).toBe(true); expect(result.flagConfigurationStore).toBeDefined(); @@ -46,7 +46,7 @@ describe('clientOptionsToParameters', () => { assignmentLogger: { logAssignment: jest.fn() }, }; - const result = clientOptionsToParameters(options, mockStore); + const result = clientOptionsToEppoClientParameters(options, mockStore); expect(result.flagConfigurationStore).toBe(mockStore); }); @@ -58,7 +58,7 @@ describe('clientOptionsToParameters', () => { }; const mockDispatcher: EventDispatcher = td.object(); - const result = clientOptionsToParameters(options, mockStore, mockDispatcher); + const result = clientOptionsToEppoClientParameters(options, mockStore, mockDispatcher); expect(result.eventDispatcher).toBeDefined(); }); @@ -73,7 +73,7 @@ describe('clientOptionsToParameters', () => { skipInitialRequest: true, }; - const result = clientOptionsToParameters(options, mockStore); + const result = clientOptionsToEppoClientParameters(options, mockStore); expect(result.configurationRequestParameters).toMatchObject({ pollingIntervalMs: 30000, @@ -92,7 +92,7 @@ describe('clientOptionsToParameters', () => { numPollRequestRetries: 2, }; - const result = clientOptionsToParameters(options, mockStore); + const result = clientOptionsToEppoClientParameters(options, mockStore); expect(result.configurationRequestParameters).toMatchObject({ requestTimeoutMs: 5000, @@ -107,7 +107,7 @@ describe('clientOptionsToParameters', () => { assignmentLogger: { logAssignment: jest.fn() }, }; - const result = clientOptionsToParameters(options, mockStore); + const result = clientOptionsToEppoClientParameters(options, mockStore); expect(result.configurationRequestParameters).toMatchObject({ baseUrl: undefined, @@ -124,7 +124,7 @@ describe('clientOptionsToParameters', () => { assignmentLogger: { logAssignment: jest.fn() }, }; - const result = clientOptionsToParameters(options, mockStore); + const result = clientOptionsToEppoClientParameters(options, mockStore); expect(result.configurationRequestParameters).toMatchObject({ sdkName, diff --git a/src/client-options-converter.ts b/src/client-options-converter.ts index 78d9e0e..c32db42 100644 --- a/src/client-options-converter.ts +++ b/src/client-options-converter.ts @@ -1,11 +1,8 @@ import { - BanditParameters, - BanditVariation, EppoClientParameters, + EppoClientParameters, EventDispatcher, Flag, - FlagConfigurationRequestParameters, IConfigurationStore, - ObfuscatedFlag, } from '@eppo/js-client-sdk-common'; import { IClientOptions } from './i-client-config'; @@ -15,7 +12,7 @@ import { sdkName, sdkVersion } from './sdk-data'; * Converts IClientOptions to EppoClientParameters * @internal */ -export function clientOptionsToParameters( +export function clientOptionsToEppoClientParameters( options: IClientOptions, flagConfigurationStore: IConfigurationStore, eventDispatcher?: EventDispatcher, diff --git a/src/i-client-config.ts b/src/i-client-config.ts index 5fc2fbc..6314348 100644 --- a/src/i-client-config.ts +++ b/src/i-client-config.ts @@ -258,7 +258,7 @@ export type IClientOptions = IApiOptions & * @public */ export type IClientConfig = Omit & - Pick; + Pick; // Could also just use `& IBaseRequestConfig` here instead of picking just `apiKey`. export function convertClientOptionsToClientConfig(options: IClientOptions): IClientConfig { return { diff --git a/src/index.ts b/src/index.ts index cbe73b3..18dd529 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,12 +21,14 @@ import { IConfigurationWire, Subject, IBanditLogger, - IObfuscatedPrecomputedConfigurationResponse, buildStorageKeySuffix, EppoClientParameters, + IObfuscatedPrecomputedConfigurationResponse, + buildStorageKeySuffix, + EppoClientParameters, } from '@eppo/js-client-sdk-common'; import { assignmentCacheFactory } from './cache/assignment-cache-factory'; import HybridAssignmentCache from './cache/hybrid-assignment-cache'; -import { clientOptionsToParameters } from './client-options-converter'; +import { clientOptionsToEppoClientParameters } from './client-options-converter'; import { ConfigLoaderStatus, ConfigLoadResult, @@ -105,7 +107,17 @@ const memoryOnlyPrecomputedBanditsStore = precomputedBanditStoreFactory(); * @public */ export class EppoJSClient extends EppoClient { + /** + * Resolved when the client is initialized + * @private + */ private readonly readyPromise: Promise; + + /** + * Used when the client is initialized from outside the constructor, namely, from the `init` or + * `EppoJSClient.initializeClient` methods. + * @private + */ private readyPromiseResolver: (() => void) | null = null; public static instance = new EppoJSClient({ @@ -119,7 +131,7 @@ export class EppoJSClient extends EppoClient { super( v2Constructor - ? clientOptionsToParameters( + ? clientOptionsToEppoClientParameters( optionsOrConfig, // The IClientOptions wrapper optionsOrConfig.flagConfigurationStore ?? // create a new, memory only FCS if none was provided configurationStorageFactory({ From a73bf0c450f1f2f9419eda0f8c13ad0f165f19cd Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 31 Jan 2025 12:35:07 -0700 Subject: [PATCH 07/29] skip initial config for now --- src/i-client-config.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/i-client-config.ts b/src/i-client-config.ts index 6314348..b58f266 100644 --- a/src/i-client-config.ts +++ b/src/i-client-config.ts @@ -166,10 +166,7 @@ export type IApiOptions = { */ updateOnFetch?: ServingStoreUpdateStrategy; - /** - * A configuration string to bootstrap the client without having to make a network fetch. - */ - initialConfiguration: string; + // TODO: Add initial config (stringified IConfigurationWire) here. }; From 7e5abe193b8c0555df63a7550ee8520fe27bd97d Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 31 Jan 2025 12:39:12 -0700 Subject: [PATCH 08/29] exports --- src/index.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 18dd529..f101fac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,9 +48,14 @@ import BrowserNetworkStatusListener from './events/browser-network-status-listen import LocalStorageBackedNamedEventQueue from './events/local-storage-backed-named-event-queue'; import { convertClientOptionsToClientConfig, + IApiOptions, IClientConfig, IClientOptions, + IEventOptions, + ILoggers, + IPollingOptions, IPrecomputedClientConfig, + IStorageOptions, } from './i-client-config'; import { sdkName, sdkVersion } from './sdk-data'; @@ -70,7 +75,16 @@ export interface IClientConfigSync { throwOnFailedInitialization?: boolean; } -export { IClientConfig, IPrecomputedClientConfig }; +export { + IClientConfig, + IPrecomputedClientConfig, + IClientOptions, + IApiOptions, + ILoggers, + IEventOptions, + IStorageOptions, + IPollingOptions, +}; // Export the common types and classes from the SDK. export { From 92cdd1d9933546a883a9b8c6a43eda846a969d50 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 31 Jan 2025 12:41:12 -0700 Subject: [PATCH 09/29] waitForReady --- src/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index f101fac..30da711 100644 --- a/src/index.ts +++ b/src/index.ts @@ -177,9 +177,6 @@ export class EppoJSClient extends EppoClient { } public waitForReady(): Promise { - if (!this.readyPromise) { - return Promise.resolve(); - } return this.readyPromise; } From c4b271717e61db1dbc44d834882b849b08b6c1c0 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 31 Jan 2025 12:44:58 -0700 Subject: [PATCH 10/29] clean up offline init method --- src/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 30da711..5688507 100644 --- a/src/index.ts +++ b/src/index.ts @@ -388,12 +388,11 @@ export class EppoJSClient extends EppoClient { * This method should be called once on application startup. * * @param config - client configuration - * @param instance an EppoJSClient instance to bootstrap. * @returns a singleton client instance * @public */ -export function offlineInit(config: IClientConfigSync, instance?: EppoJSClient): EppoClient { - instance = instance ?? getInstance(); +export function offlineInit(config: IClientConfigSync): EppoClient { + const instance = getInstance(); const isObfuscated = config.isObfuscated ?? false; const throwOnFailedInitialization = config.throwOnFailedInitialization ?? true; From ddab6c069a47979edbe27bc46e35c6030271c571 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 31 Jan 2025 12:49:11 -0700 Subject: [PATCH 11/29] no need to export --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 5688507..572e4b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -946,7 +946,7 @@ export function getPrecomputedInstance(): EppoPrecomputedClient { return EppoPrecomputedJSClient.instance; } -export function newEventDispatcher( +function newEventDispatcher( sdkKey: string, config: IClientConfig['eventIngestionConfig'] = {}, ): EventDispatcher { From d55d23d59eb9469e31b8b21b57c1fa837163a27e Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 31 Jan 2025 12:49:42 -0700 Subject: [PATCH 12/29] DOCS --- ...s-client-sdk.eppojsclient._constructor_.md | 47 +++++ .../js-client-sdk.eppojsclient.initialized.md | 2 +- docs/js-client-sdk.eppojsclient.md | 48 +++++- ...js-client-sdk.eppojsclient.waitforready.md | 15 ++ docs/js-client-sdk.getinstance.md | 4 +- ...-sdk.iclientconfig.eventingestionconfig.md | 20 --- ...ent-sdk.iclientconfig.forcereinitialize.md | 13 -- ...nt-sdk.iclientconfig.maxcacheageseconds.md | 13 -- docs/js-client-sdk.iclientconfig.md | 163 +----------------- ...lient-sdk.iclientconfig.persistentstore.md | 13 -- ...lientconfig.throwonfailedinitialization.md | 13 -- ...-client-sdk.iclientconfig.updateonfetch.md | 13 -- ...lient-sdk.iclientconfig.useexpiredcache.md | 13 -- docs/js-client-sdk.init.md | 7 +- docs/js-client-sdk.md | 50 ++++-- docs/js-client-sdk.neweventdispatcher.md | 65 +++++++ docs/js-client-sdk.offlineinit.md | 18 +- 17 files changed, 238 insertions(+), 279 deletions(-) create mode 100644 docs/js-client-sdk.eppojsclient._constructor_.md create mode 100644 docs/js-client-sdk.eppojsclient.waitforready.md delete mode 100644 docs/js-client-sdk.iclientconfig.eventingestionconfig.md delete mode 100644 docs/js-client-sdk.iclientconfig.forcereinitialize.md delete mode 100644 docs/js-client-sdk.iclientconfig.maxcacheageseconds.md delete mode 100644 docs/js-client-sdk.iclientconfig.persistentstore.md delete mode 100644 docs/js-client-sdk.iclientconfig.throwonfailedinitialization.md delete mode 100644 docs/js-client-sdk.iclientconfig.updateonfetch.md delete mode 100644 docs/js-client-sdk.iclientconfig.useexpiredcache.md create mode 100644 docs/js-client-sdk.neweventdispatcher.md diff --git a/docs/js-client-sdk.eppojsclient._constructor_.md b/docs/js-client-sdk.eppojsclient._constructor_.md new file mode 100644 index 0000000..821de62 --- /dev/null +++ b/docs/js-client-sdk.eppojsclient._constructor_.md @@ -0,0 +1,47 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [EppoJSClient](./js-client-sdk.eppojsclient.md) > [(constructor)](./js-client-sdk.eppojsclient._constructor_.md) + +## EppoJSClient.(constructor) + +Constructs a new instance of the `EppoJSClient` class + +**Signature:** + +```typescript +constructor(optionsOrConfig: EppoClientParameters | IClientOptions); +``` + +## Parameters + + + +
+ +Parameter + + + + +Type + + + + +Description + + +
+ +optionsOrConfig + + + + +EppoClientParameters \| IClientOptions + + + + + +
diff --git a/docs/js-client-sdk.eppojsclient.initialized.md b/docs/js-client-sdk.eppojsclient.initialized.md index 74ed212..4c301f8 100644 --- a/docs/js-client-sdk.eppojsclient.initialized.md +++ b/docs/js-client-sdk.eppojsclient.initialized.md @@ -7,5 +7,5 @@ **Signature:** ```typescript -static initialized: boolean; +initialized: boolean; ``` diff --git a/docs/js-client-sdk.eppojsclient.md b/docs/js-client-sdk.eppojsclient.md index f466039..6af41df 100644 --- a/docs/js-client-sdk.eppojsclient.md +++ b/docs/js-client-sdk.eppojsclient.md @@ -13,6 +13,40 @@ export declare class EppoJSClient extends EppoClient ``` **Extends:** EppoClient +## Constructors + + + +
+ +Constructor + + + + +Modifiers + + + + +Description + + +
+ +[(constructor)(optionsOrConfig)](./js-client-sdk.eppojsclient._constructor_.md) + + + + + + + +Constructs a new instance of the `EppoJSClient` class + + +
+ ## Properties +
@@ -43,8 +77,6 @@ Description -`static` - @@ -261,5 +293,17 @@ Description +
+ +[waitForReady()](./js-client-sdk.eppojsclient.waitforready.md) + + + + + + + +
diff --git a/docs/js-client-sdk.eppojsclient.waitforready.md b/docs/js-client-sdk.eppojsclient.waitforready.md new file mode 100644 index 0000000..64b2e00 --- /dev/null +++ b/docs/js-client-sdk.eppojsclient.waitforready.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [EppoJSClient](./js-client-sdk.eppojsclient.md) > [waitForReady](./js-client-sdk.eppojsclient.waitforready.md) + +## EppoJSClient.waitForReady() method + +**Signature:** + +```typescript +waitForReady(): Promise; +``` +**Returns:** + +Promise<void> + diff --git a/docs/js-client-sdk.getinstance.md b/docs/js-client-sdk.getinstance.md index e147f9f..3bc021a 100644 --- a/docs/js-client-sdk.getinstance.md +++ b/docs/js-client-sdk.getinstance.md @@ -9,11 +9,11 @@ Used to access a singleton SDK client instance. Use the method after calling ini **Signature:** ```typescript -export declare function getInstance(): EppoClient; +export declare function getInstance(): EppoJSClient; ``` **Returns:** -EppoClient +[EppoJSClient](./js-client-sdk.eppojsclient.md) a singleton client instance diff --git a/docs/js-client-sdk.iclientconfig.eventingestionconfig.md b/docs/js-client-sdk.iclientconfig.eventingestionconfig.md deleted file mode 100644 index f7bb096..0000000 --- a/docs/js-client-sdk.iclientconfig.eventingestionconfig.md +++ /dev/null @@ -1,20 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [eventIngestionConfig](./js-client-sdk.iclientconfig.eventingestionconfig.md) - -## IClientConfig.eventIngestionConfig property - -Configuration settings for the event dispatcher - -**Signature:** - -```typescript -eventIngestionConfig?: { - deliveryIntervalMs?: number; - retryIntervalMs?: number; - maxRetryDelayMs?: number; - maxRetries?: number; - batchSize?: number; - maxQueueSize?: number; - }; -``` diff --git a/docs/js-client-sdk.iclientconfig.forcereinitialize.md b/docs/js-client-sdk.iclientconfig.forcereinitialize.md deleted file mode 100644 index 837c284..0000000 --- a/docs/js-client-sdk.iclientconfig.forcereinitialize.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [forceReinitialize](./js-client-sdk.iclientconfig.forcereinitialize.md) - -## IClientConfig.forceReinitialize property - -Force reinitialize the SDK if it is already initialized. - -**Signature:** - -```typescript -forceReinitialize?: boolean; -``` diff --git a/docs/js-client-sdk.iclientconfig.maxcacheageseconds.md b/docs/js-client-sdk.iclientconfig.maxcacheageseconds.md deleted file mode 100644 index b942b01..0000000 --- a/docs/js-client-sdk.iclientconfig.maxcacheageseconds.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [maxCacheAgeSeconds](./js-client-sdk.iclientconfig.maxcacheageseconds.md) - -## IClientConfig.maxCacheAgeSeconds property - -Maximum age, in seconds, previously cached values are considered valid until new values will be fetched (default: 0) - -**Signature:** - -```typescript -maxCacheAgeSeconds?: number; -``` diff --git a/docs/js-client-sdk.iclientconfig.md b/docs/js-client-sdk.iclientconfig.md index 367b89f..0c315a0 100644 --- a/docs/js-client-sdk.iclientconfig.md +++ b/docs/js-client-sdk.iclientconfig.md @@ -2,171 +2,12 @@ [Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) -## IClientConfig interface +## IClientConfig type Configuration for regular client initialization **Signature:** ```typescript -export interface IClientConfig extends IBaseRequestConfig +export declare type IClientConfig = Omit & Pick; ``` -**Extends:** IBaseRequestConfig - -## Properties - - - - - - - - - -
- -Property - - - - -Modifiers - - - - -Type - - - - -Description - - -
- -[eventIngestionConfig?](./js-client-sdk.iclientconfig.eventingestionconfig.md) - - - - - - - -{ deliveryIntervalMs?: number; retryIntervalMs?: number; maxRetryDelayMs?: number; maxRetries?: number; batchSize?: number; maxQueueSize?: number; } - - - - -_(Optional)_ Configuration settings for the event dispatcher - - -
- -[forceReinitialize?](./js-client-sdk.iclientconfig.forcereinitialize.md) - - - - - - - -boolean - - - - -_(Optional)_ Force reinitialize the SDK if it is already initialized. - - -
- -[maxCacheAgeSeconds?](./js-client-sdk.iclientconfig.maxcacheageseconds.md) - - - - - - - -number - - - - -_(Optional)_ Maximum age, in seconds, previously cached values are considered valid until new values will be fetched (default: 0) - - -
- -[persistentStore?](./js-client-sdk.iclientconfig.persistentstore.md) - - - - - - - -IAsyncStore<Flag> - - - - -_(Optional)_ A custom class to use for storing flag configurations. This is useful for cases where you want to use a different storage mechanism than the default storage provided by the SDK. - - -
- -[throwOnFailedInitialization?](./js-client-sdk.iclientconfig.throwonfailedinitialization.md) - - - - - - - -boolean - - - - -_(Optional)_ Throw an error if unable to fetch an initial configuration during initialization. (default: true) - - -
- -[updateOnFetch?](./js-client-sdk.iclientconfig.updateonfetch.md) - - - - - - - -ServingStoreUpdateStrategy - - - - -_(Optional)_ Sets how the configuration is updated after a successful fetch - always: immediately start using the new configuration - expired: immediately start using the new configuration only if the current one has expired - empty: only use the new configuration if the current one is both expired and uninitialized/empty - - -
- -[useExpiredCache?](./js-client-sdk.iclientconfig.useexpiredcache.md) - - - - - - - -boolean - - - - -_(Optional)_ Whether initialization will be considered successfully complete if expired cache values are loaded. If false, initialization will always wait for a fetch if cached values are expired. (default: false) - - -
diff --git a/docs/js-client-sdk.iclientconfig.persistentstore.md b/docs/js-client-sdk.iclientconfig.persistentstore.md deleted file mode 100644 index 08a89a2..0000000 --- a/docs/js-client-sdk.iclientconfig.persistentstore.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [persistentStore](./js-client-sdk.iclientconfig.persistentstore.md) - -## IClientConfig.persistentStore property - -A custom class to use for storing flag configurations. This is useful for cases where you want to use a different storage mechanism than the default storage provided by the SDK. - -**Signature:** - -```typescript -persistentStore?: IAsyncStore; -``` diff --git a/docs/js-client-sdk.iclientconfig.throwonfailedinitialization.md b/docs/js-client-sdk.iclientconfig.throwonfailedinitialization.md deleted file mode 100644 index 35dfe23..0000000 --- a/docs/js-client-sdk.iclientconfig.throwonfailedinitialization.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [throwOnFailedInitialization](./js-client-sdk.iclientconfig.throwonfailedinitialization.md) - -## IClientConfig.throwOnFailedInitialization property - -Throw an error if unable to fetch an initial configuration during initialization. (default: true) - -**Signature:** - -```typescript -throwOnFailedInitialization?: boolean; -``` diff --git a/docs/js-client-sdk.iclientconfig.updateonfetch.md b/docs/js-client-sdk.iclientconfig.updateonfetch.md deleted file mode 100644 index dfb03a9..0000000 --- a/docs/js-client-sdk.iclientconfig.updateonfetch.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [updateOnFetch](./js-client-sdk.iclientconfig.updateonfetch.md) - -## IClientConfig.updateOnFetch property - -Sets how the configuration is updated after a successful fetch - always: immediately start using the new configuration - expired: immediately start using the new configuration only if the current one has expired - empty: only use the new configuration if the current one is both expired and uninitialized/empty - -**Signature:** - -```typescript -updateOnFetch?: ServingStoreUpdateStrategy; -``` diff --git a/docs/js-client-sdk.iclientconfig.useexpiredcache.md b/docs/js-client-sdk.iclientconfig.useexpiredcache.md deleted file mode 100644 index 2183b4e..0000000 --- a/docs/js-client-sdk.iclientconfig.useexpiredcache.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [useExpiredCache](./js-client-sdk.iclientconfig.useexpiredcache.md) - -## IClientConfig.useExpiredCache property - -Whether initialization will be considered successfully complete if expired cache values are loaded. If false, initialization will always wait for a fetch if cached values are expired. (default: false) - -**Signature:** - -```typescript -useExpiredCache?: boolean; -``` diff --git a/docs/js-client-sdk.init.md b/docs/js-client-sdk.init.md index ba3dafd..a08566d 100644 --- a/docs/js-client-sdk.init.md +++ b/docs/js-client-sdk.init.md @@ -4,7 +4,12 @@ ## init() function -Initializes the Eppo client with configuration parameters. This method should be called once on application startup. +> Warning: This API is now obsolete. +> +> Use `new EppoJSClient(options)` instead of `init` or `initializeClient`. These will be removed in v4 +> + +Initializes the Eppo client with configuration parameters. This method should be called once on application startup. If an initialization is in process, calling `init` will return the in-progress `Promise`. Once the initialization completes, calling `init` again will kick off the initialization routine (if `forceReinitialization` is `true`). **Signature:** diff --git a/docs/js-client-sdk.md b/docs/js-client-sdk.md index 58dc4e6..fa4e94a 100644 --- a/docs/js-client-sdk.md +++ b/docs/js-client-sdk.md @@ -120,13 +120,22 @@ Used to access a singleton SDK precomputed client instance. Use the method after -Initializes the Eppo client with configuration parameters. This method should be called once on application startup. +Initializes the Eppo client with configuration parameters. This method should be called once on application startup. If an initialization is in process, calling `init` will return the in-progress `Promise`. Once the initialization completes, calling `init` again will kick off the initialization routine (if `forceReinitialization` is `true`). -[offlineInit(config)](./js-client-sdk.offlineinit.md) +[newEventDispatcher(sdkKey, config)](./js-client-sdk.neweventdispatcher.md) + + + + + + + + +[offlineInit(config, instance)](./js-client-sdk.offlineinit.md) @@ -182,17 +191,6 @@ Description -[IClientConfig](./js-client-sdk.iclientconfig.md) - - - - -Configuration for regular client initialization - - - - - [IClientConfigSync](./js-client-sdk.iclientconfigsync.md) @@ -225,5 +223,31 @@ Configuration parameters for initializing the Eppo precomputed client. This interface is used for cases where precomputed assignments are available from an external process that can bootstrap the SDK client. + + + +## Type Aliases + + +
+ +Type Alias + + + + +Description + + +
+ +[IClientConfig](./js-client-sdk.iclientconfig.md) + + + + +Configuration for regular client initialization + +
diff --git a/docs/js-client-sdk.neweventdispatcher.md b/docs/js-client-sdk.neweventdispatcher.md new file mode 100644 index 0000000..a831cb3 --- /dev/null +++ b/docs/js-client-sdk.neweventdispatcher.md @@ -0,0 +1,65 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [newEventDispatcher](./js-client-sdk.neweventdispatcher.md) + +## newEventDispatcher() function + +**Signature:** + +```typescript +export declare function newEventDispatcher(sdkKey: string, config?: IClientConfig['eventIngestionConfig']): EventDispatcher; +``` + +## Parameters + + + + +
+ +Parameter + + + + +Type + + + + +Description + + +
+ +sdkKey + + + + +string + + + + + +
+ +config + + + + +[IClientConfig](./js-client-sdk.iclientconfig.md)\['eventIngestionConfig'\] + + + + +_(Optional)_ + + +
+**Returns:** + +EventDispatcher + diff --git a/docs/js-client-sdk.offlineinit.md b/docs/js-client-sdk.offlineinit.md index 505eb5d..2365c78 100644 --- a/docs/js-client-sdk.offlineinit.md +++ b/docs/js-client-sdk.offlineinit.md @@ -13,7 +13,7 @@ This method should be called once on application startup. **Signature:** ```typescript -export declare function offlineInit(config: IClientConfigSync): EppoClient; +export declare function offlineInit(config: IClientConfigSync, instance?: EppoJSClient): EppoClient; ``` ## Parameters @@ -49,6 +49,22 @@ config client configuration + + + +instance + + + + +[EppoJSClient](./js-client-sdk.eppojsclient.md) + + + + +_(Optional)_ an EppoJSClient instance to bootstrap. + + **Returns:** From 3c18bc9d97d08e45c7f3ab663f02911ae731bad1 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Tue, 4 Feb 2025 15:35:44 -0700 Subject: [PATCH 13/29] builder method --- src/client-options-converter.spec.ts | 134 ------ src/client-options-converter.ts | 44 -- src/i-client-config.ts | 24 +- src/index.spec.ts | 37 +- src/index.ts | 601 ++++++++++++--------------- 5 files changed, 295 insertions(+), 545 deletions(-) delete mode 100644 src/client-options-converter.spec.ts delete mode 100644 src/client-options-converter.ts diff --git a/src/client-options-converter.spec.ts b/src/client-options-converter.spec.ts deleted file mode 100644 index 3edc50b..0000000 --- a/src/client-options-converter.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { - IConfigurationStore, - ObfuscatedFlag, - Flag, - EventDispatcher, -} from '@eppo/js-client-sdk-common'; -import * as td from 'testdouble'; - -import { clientOptionsToEppoClientParameters } from './client-options-converter'; -import { IClientOptions } from './i-client-config'; -import { sdkName, sdkVersion } from './sdk-data'; - -describe('clientOptionsToParameters', () => { - const mockStore = td.object>(); - - it('converts basic client options', () => { - const options: IClientOptions = { - sdkKey: 'test-key', - baseUrl: 'https://test.eppo.cloud', - assignmentLogger: { logAssignment: jest.fn() }, - }; - - const result = clientOptionsToEppoClientParameters(options, mockStore); - - expect(result.isObfuscated).toBe(true); - expect(result.flagConfigurationStore).toBeDefined(); - expect(result.configurationRequestParameters).toEqual({ - apiKey: 'test-key', - baseUrl: 'https://test.eppo.cloud', - sdkName, - sdkVersion, - numInitialRequestRetries: undefined, - numPollRequestRetries: undefined, - pollingIntervalMs: undefined, - requestTimeoutMs: undefined, - pollAfterFailedInitialization: undefined, - pollAfterSuccessfulInitialization: undefined, - throwOnFailedInitialization: undefined, - skipInitialPoll: undefined, - }); - }); - - it('uses provided flag configuration store', () => { - const options: IClientOptions = { - sdkKey: 'test-key', - assignmentLogger: { logAssignment: jest.fn() }, - }; - - const result = clientOptionsToEppoClientParameters(options, mockStore); - - expect(result.flagConfigurationStore).toBe(mockStore); - }); - - it('converts client options with event ingestion config', () => { - const options: IClientOptions = { - sdkKey: 'test-key', - assignmentLogger: { logAssignment: jest.fn() }, - }; - const mockDispatcher: EventDispatcher = td.object(); - - const result = clientOptionsToEppoClientParameters(options, mockStore, mockDispatcher); - - expect(result.eventDispatcher).toBeDefined(); - }); - - it('converts client options with polling configuration', () => { - const options: IClientOptions = { - sdkKey: 'test-key', - assignmentLogger: { logAssignment: jest.fn() }, - pollingIntervalMs: 30000, - pollAfterSuccessfulInitialization: true, - pollAfterFailedInitialization: true, - skipInitialRequest: true, - }; - - const result = clientOptionsToEppoClientParameters(options, mockStore); - - expect(result.configurationRequestParameters).toMatchObject({ - pollingIntervalMs: 30000, - pollAfterSuccessfulInitialization: true, - pollAfterFailedInitialization: true, - skipInitialPoll: true, - }); - }); - - it('converts client options with retry configuration', () => { - const options: IClientOptions = { - sdkKey: 'test-key', - assignmentLogger: { logAssignment: jest.fn() }, - requestTimeoutMs: 5000, - numInitialRequestRetries: 3, - numPollRequestRetries: 2, - }; - - const result = clientOptionsToEppoClientParameters(options, mockStore); - - expect(result.configurationRequestParameters).toMatchObject({ - requestTimeoutMs: 5000, - numInitialRequestRetries: 3, - numPollRequestRetries: 2, - }); - }); - - it('handles undefined optional parameters', () => { - const options: IClientOptions = { - sdkKey: 'test-key', - assignmentLogger: { logAssignment: jest.fn() }, - }; - - const result = clientOptionsToEppoClientParameters(options, mockStore); - - expect(result.configurationRequestParameters).toMatchObject({ - baseUrl: undefined, - pollingIntervalMs: undefined, - requestTimeoutMs: undefined, - numInitialRequestRetries: undefined, - numPollRequestRetries: undefined, - }); - }); - - it('includes sdk metadata', () => { - const options: IClientOptions = { - sdkKey: 'test-key', - assignmentLogger: { logAssignment: jest.fn() }, - }; - - const result = clientOptionsToEppoClientParameters(options, mockStore); - - expect(result.configurationRequestParameters).toMatchObject({ - sdkName, - sdkVersion, - }); - }); -}); diff --git a/src/client-options-converter.ts b/src/client-options-converter.ts deleted file mode 100644 index c32db42..0000000 --- a/src/client-options-converter.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - EppoClientParameters, - EventDispatcher, - Flag, - IConfigurationStore, -} from '@eppo/js-client-sdk-common'; - -import { IClientOptions } from './i-client-config'; -import { sdkName, sdkVersion } from './sdk-data'; - -/** - * Converts IClientOptions to EppoClientParameters - * @internal - */ -export function clientOptionsToEppoClientParameters( - options: IClientOptions, - flagConfigurationStore: IConfigurationStore, - eventDispatcher?: EventDispatcher, -): EppoClientParameters { - const parameters: EppoClientParameters = { - flagConfigurationStore, - isObfuscated: true, - }; - - parameters.eventDispatcher = eventDispatcher; - - // Always include configuration request parameters - parameters.configurationRequestParameters = { - apiKey: options.sdkKey, - sdkVersion, // dynamically picks up version. - sdkName, // Hardcoded to `js-client-sdk` - baseUrl: options.baseUrl, - requestTimeoutMs: options.requestTimeoutMs, - numInitialRequestRetries: options.numInitialRequestRetries, - numPollRequestRetries: options.numPollRequestRetries, - pollAfterSuccessfulInitialization: options.pollAfterSuccessfulInitialization, - pollAfterFailedInitialization: options.pollAfterFailedInitialization, - pollingIntervalMs: options.pollingIntervalMs, - throwOnFailedInitialization: options.throwOnFailedInitialization, - skipInitialPoll: options.skipInitialRequest, - }; - - return parameters; -} diff --git a/src/i-client-config.ts b/src/i-client-config.ts index b58f266..deb05ed 100644 --- a/src/i-client-config.ts +++ b/src/i-client-config.ts @@ -111,7 +111,7 @@ export type IApiOptions = { /** * Your key for accessing Eppo through the Eppo SDK. */ - sdkKey: string; + apiKey: string; /** * Override the endpoint the SDK uses to load configuration. @@ -165,11 +165,8 @@ export type IApiOptions = { * - empty: only use the new configuration if the current one is both expired and uninitialized/empty */ updateOnFetch?: ServingStoreUpdateStrategy; - - // TODO: Add initial config (stringified IConfigurationWire) here. }; - export type IEventOptions = { eventIngestionConfig?: { /** Number of milliseconds to wait between each batch delivery. Defaults to 10 seconds. */ @@ -242,24 +239,11 @@ export type ILoggers = { }; /** - * Config shape for client v2. + * Configuration for regular client initialization + * @public */ -export type IClientOptions = IApiOptions & +export type IClientConfig = IApiOptions & ILoggers & IEventOptions & IStorageOptions & IPollingOptions; - -/** - * Configuration for regular client initialization - * @public - */ -export type IClientConfig = Omit & - Pick; // Could also just use `& IBaseRequestConfig` here instead of picking just `apiKey`. - -export function convertClientOptionsToClientConfig(options: IClientOptions): IClientConfig { - return { - ...options, - apiKey: options.sdkKey, - }; -} diff --git a/src/index.spec.ts b/src/index.spec.ts index 433142a..af3c278 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -11,7 +11,6 @@ import { EppoClient, Flag, HybridConfigurationStore, - IAssignmentEvent, IAsyncStore, IPrecomputedConfigurationResponse, VariationType, @@ -30,13 +29,7 @@ import { validateTestAssignments, } from '../test/testHelpers'; -import { - IApiOptions, - IClientConfig, - IClientOptions, - IPollingOptions, - IStorageOptions, -} from './i-client-config'; +import { IApiOptions, IClientConfig } from './i-client-config'; import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store'; import { @@ -431,12 +424,12 @@ describe('decoupled initialization', () => { }); it('should be independent of the singleton', async () => { - const apiOptions: IApiOptions = { sdkKey: '' }; - const options: IClientOptions = { ...apiOptions, assignmentLogger: mockLogger }; - const isolatedClient = new EppoJSClient(options); + const apiOptions: IApiOptions = { apiKey: '' }; + const options: IClientConfig = { ...apiOptions, assignmentLogger: mockLogger }; + const isolatedClient = EppoJSClient.buildAndInit(options); expect(isolatedClient).not.toEqual(getInstance()); - await isolatedClient.waitForReady(); + await isolatedClient.waitForInitialized(); expect(isolatedClient.isInitialized()).toBe(true); expect(isolatedClient.initialized).toBe(true); @@ -451,15 +444,15 @@ describe('decoupled initialization', () => { ).toEqual('variant-1'); }); it('initializes on instantiation and notifies when ready', async () => { - const apiOptions: IApiOptions = { sdkKey: '', baseUrl }; - const options: IClientOptions = { ...apiOptions, assignmentLogger: mockLogger }; - const client = new EppoJSClient(options); + const apiOptions: IApiOptions = { apiKey: '', baseUrl }; + const options: IClientConfig = { ...apiOptions, assignmentLogger: mockLogger }; + const client = EppoJSClient.buildAndInit(options); expect(client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( 'default-value', ); - await client.waitForReady(); + await client.waitForInitialized(); const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'); expect(assignment).toEqual('variant-1'); @@ -471,7 +464,7 @@ describe('decoupled initialization', () => { const API_KEY_2 = 'my-api-key-2'; const API_KEY_3 = 'my-api-key-3'; - const commonOptions: Omit = { + const commonOptions: Omit = { baseUrl, assignmentLogger: mockLogger, }; @@ -527,8 +520,8 @@ describe('decoupled initialization', () => { ); expect(callCount).toBe(1); - const myClient2 = new EppoJSClient({ ...commonOptions, sdkKey: API_KEY_2 }); - await myClient2.waitForReady(); + const myClient2 = EppoJSClient.buildAndInit({ ...commonOptions, apiKey: API_KEY_2 }); + await myClient2.waitForInitialized(); expect(callCount).toBe(2); expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( @@ -538,8 +531,8 @@ describe('decoupled initialization', () => { 'variant-2', ); - const myClient3 = new EppoJSClient({ ...commonOptions, sdkKey: API_KEY_3 }); - await myClient3.waitForReady(); + const myClient3 = EppoJSClient.buildAndInit({ ...commonOptions, apiKey: API_KEY_3 }); + await myClient3.waitForInitialized(); expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( 'variant-1', @@ -1338,7 +1331,7 @@ describe('initialization options', () => { async entries() { return entriesPromise.promise; }, - async setEntries(entries) { + async setEntries() { // pass }, }; diff --git a/src/index.ts b/src/index.ts index 572e4b4..90aa4bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,7 +28,6 @@ import { import { assignmentCacheFactory } from './cache/assignment-cache-factory'; import HybridAssignmentCache from './cache/hybrid-assignment-cache'; -import { clientOptionsToEppoClientParameters } from './client-options-converter'; import { ConfigLoaderStatus, ConfigLoadResult, @@ -47,10 +46,8 @@ import { import BrowserNetworkStatusListener from './events/browser-network-status-listener'; import LocalStorageBackedNamedEventQueue from './events/local-storage-backed-named-event-queue'; import { - convertClientOptionsToClientConfig, IApiOptions, IClientConfig, - IClientOptions, IEventOptions, ILoggers, IPollingOptions, @@ -78,7 +75,6 @@ export interface IClientConfigSync { export { IClientConfig, IPrecomputedClientConfig, - IClientOptions, IApiOptions, ILoggers, IEventOptions, @@ -106,12 +102,6 @@ export { } from '@eppo/js-client-sdk-common'; export { ChromeStorageEngine } from './chrome-storage-engine'; -// Instantiate the configuration store with memory-only implementation. -// For use only with the singleton instance. -const flagConfigurationStore = configurationStorageFactory({ - forceMemoryOnly: true, -}); - // Instantiate the precomputed flags and bandits stores with memory-only implementation. const memoryOnlyPrecomputedFlagsStore = precomputedFlagsStorageFactory(); const memoryOnlyPrecomputedBanditsStore = precomputedBanditStoreFactory(); @@ -121,63 +111,281 @@ const memoryOnlyPrecomputedBanditsStore = precomputedBanditStoreFactory(); * @public */ export class EppoJSClient extends EppoClient { - /** - * Resolved when the client is initialized - * @private - */ - private readonly readyPromise: Promise; - - /** - * Used when the client is initialized from outside the constructor, namely, from the `init` or - * `EppoJSClient.initializeClient` methods. - * @private - */ - private readyPromiseResolver: (() => void) | null = null; - public static instance = new EppoJSClient({ - flagConfigurationStore, + flagConfigurationStore: configurationStorageFactory({ + forceMemoryOnly: true, + }), isObfuscated: true, }); - initialized = false; - constructor(optionsOrConfig: EppoClientParameters | IClientOptions) { - const v2Constructor = 'sdkKey' in optionsOrConfig; - - super( - v2Constructor - ? clientOptionsToEppoClientParameters( - optionsOrConfig, // The IClientOptions wrapper - optionsOrConfig.flagConfigurationStore ?? // create a new, memory only FCS if none was provided - configurationStorageFactory({ - forceMemoryOnly: true, - }), - optionsOrConfig.eventIngestionConfig // Create an Event Dispatcher if Event Ingestion config was provided. - ? newEventDispatcher(optionsOrConfig.sdkKey, optionsOrConfig.eventIngestionConfig) - : undefined, - ) // "New" construction technique; isolated instance. - : (optionsOrConfig as EppoClientParameters), // Legacy instantiation of singleton. - ); + constructor(optionsOrConfig: EppoClientParameters) { + super(optionsOrConfig); - if (!v2Constructor) { - // Original constructor path; passthrough to super is above. - // `waitForReady` is a new API we need to graft onto the old way of constructing first and initializing later. + // Create a promise that will be resolved when initialization is complete. + this.initializedPromise = new Promise((resolve) => { + this.initializedPromiseResolver = resolve; + }); + } - // Create a promise that will be resolved when EppoJSClient.initializeClient() is called - this.readyPromise = new Promise((resolve) => { - this.readyPromiseResolver = resolve; + public static buildAndInit(config: IClientConfig): EppoJSClient { + const flagConfigurationStore = + config.flagConfigurationStore ?? + configurationStorageFactory({ + forceMemoryOnly: true, }); - } else { - this.readyPromise = explicitInit( - convertClientOptionsToClientConfig(optionsOrConfig), - this, - ).then(() => { - return; + const client = new EppoJSClient({ flagConfigurationStore }); + client.init(config); + return client; + } + + async init(config: IClientConfig): Promise { + validation.validateNotBlank(config.apiKey, 'API key required'); + let initializationError: Error | undefined; + const { + apiKey, + persistentStore, + baseUrl, + maxCacheAgeSeconds, + updateOnFetch, + forceReinitialize, + requestTimeoutMs, + numInitialRequestRetries, + numPollRequestRetries, + pollingIntervalMs, + pollAfterSuccessfulInitialization = false, + pollAfterFailedInitialization = false, + skipInitialRequest = false, + eventIngestionConfig, + } = config; + + try { + if (this.initialized) { + // TODO: check super.isInitialized. + if (forceReinitialize) { + applicationLogger.warn( + 'Eppo SDK is already initialized, reinitializing since forceReinitialize is true.', + ); + this.initialized = false; + } else { + applicationLogger.warn( + 'Eppo SDK is already initialized, skipping reinitialization since forceReinitialize is false.', + ); + return this; + } + } + + // If the instance was polling, stop. + this.stopPolling(); + // Set up assignment logger and cache + this.setAssignmentLogger(config.assignmentLogger); + if (config.banditLogger) { + this.setBanditLogger(config.banditLogger); + } + // Default to obfuscated mode when requesting configuration from the server. + this.setIsObfuscated(true); + + const storageKeySuffix = buildStorageKeySuffix(apiKey); + + // Set the configuration store to the desired persistent store, if provided. + // Otherwise, the factory method will detect the current environment and instantiate the correct store. + const configurationStore = configurationStorageFactory( + { + maxAgeSeconds: maxCacheAgeSeconds, + servingStoreUpdateStrategy: updateOnFetch, + persistentStore, + hasChromeStorage: hasChromeStorage(), + hasWindowLocalStorage: hasWindowLocalStorage(), + }, + { + chromeStorage: chromeStorageIfAvailable(), + windowLocalStorage: localStorageIfAvailable(), + storageKeySuffix, + }, + ); + this.setFlagConfigurationStore(configurationStore); + + // instantiate and init assignment cache + const assignmentCache = assignmentCacheFactory({ + chromeStorage: chromeStorageIfAvailable(), + storageKeySuffix, }); + if (assignmentCache instanceof HybridAssignmentCache) { + await assignmentCache.init(); + } + this.useCustomAssignmentCache(assignmentCache); + + // Set up parameters for requesting updated configurations + const requestConfiguration: FlagConfigurationRequestParameters = { + apiKey, + sdkName, + sdkVersion, + baseUrl, + requestTimeoutMs, + numInitialRequestRetries, + numPollRequestRetries, + pollAfterSuccessfulInitialization, + pollAfterFailedInitialization, + pollingIntervalMs, + throwOnFailedInitialization: true, // always use true here as underlying instance fetch is surrounded by try/catch + skipInitialPoll: skipInitialRequest, + }; + this.setConfigurationRequestParameters(requestConfiguration); + this.setEventDispatcher(newEventDispatcher(apiKey, eventIngestionConfig)); + + // We have two at-bats for initialization: from the configuration store and from fetching + // We can resolve the initialization promise as soon as either one succeeds + // If there is no persistent store underlying the ConfigurationStore, then there is no race to begin with, as + // there is not an async local load to wait for. + + // The definition of a successful configuration load differs for a cached config vs a fetched config. + // Specifically, a cached config with no entries is not considered a completed load and will result in + // `ConfigLoaderStatus.DID_NOT_PRODUCE` while and empty config from the fetch is considered a valid, `COMPLETED` + // configuration load. + let initFromConfigStoreError = undefined; + let initFromFetchError = undefined; + + const attemptInitFromConfigStore: ConfigurationLoadAttempt = configurationStore + .init() + .then(async () => { + if (!configurationStore.getKeys().length) { + // Consider empty configuration stores invalid + applicationLogger.warn('Eppo SDK cached configuration is empty'); + initFromConfigStoreError = new Error('Configuration store was empty'); + return ConfigLoaderStatus.DID_NOT_PRODUCE; + } + + const cacheIsExpired = await configurationStore.isExpired(); + if (cacheIsExpired && !config.useExpiredCache) { + applicationLogger.warn('Eppo SDK set not to use expired cached configuration'); + initFromConfigStoreError = new Error('Configuration store was expired'); + return ConfigLoaderStatus.DID_NOT_PRODUCE; + } else if (cacheIsExpired && config.useExpiredCache) { + applicationLogger.warn('Eppo SDK config.useExpiredCache is true; using expired cache'); + } + + return ConfigLoaderStatus.COMPLETED; + }) + .catch((e) => { + applicationLogger.warn( + 'Eppo SDK encountered an error initializing from the configuration store', + e, + ); + initFromConfigStoreError = e; + return ConfigLoaderStatus.FAILED; + }) + .then((status) => { + return { source: ConfigSource.CONFIG_STORE, result: status }; + }); + + // Kick off a remote fetch (and start the poller, if configured to do so). + // Before a network fetch is attempted, the `ConfiguratonRequestor` instance will check to see whether the + // locally-stored config data is expired. If it is not expired, the network fetch is skipped, and the promise chain + // will quickly return. **When data not successfully fetched due to an error, invalid data, or giving up the fetch + // because the cache is not expired, the configuration store will not be set as initialized.** + const attemptInitFromFetch: ConfigurationLoadAttempt = this.fetchFlagConfigurations() + .then(() => { + if (configurationStore.isInitialized()) { + // If fetch has won the race and the configStore is initialized, that means we have a successful load and the + // fetched data was used to init. + return ConfigLoaderStatus.COMPLETED; + } else { + // If the config store is not initialized and the fetch says it is done, that means the fetch did not set any + // values into the config store (ex:the cache is not expired or invalid config data was received). + // It's important not to set an error here as this state does not mean that an error was encountered. + // If the initial cache had not expired, the fetch would be skipped and the config store would report as + // uninitialized because only a successful fetch setting the data entries will set the persistent store as + // initialized. + return ConfigLoaderStatus.DID_NOT_PRODUCE; + } + }) + .catch((e) => { + applicationLogger.warn('Eppo SDK encountered an error initializing from fetching', e); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + initFromFetchError = e; + return ConfigLoaderStatus.FAILED; + }) + .then((status) => { + return { source: ConfigSource.FETCH, result: status }; + }); + + const racers: ConfigurationLoadAttempt[] = [attemptInitFromConfigStore]; + + // attemptInitFromFetch will return early and not attempt to fetch if `skipInitialRequest` is `true`. + // We could still race it against a load from configuration as the handling logic will see that no config was loaded + // into the config store from the remote fetch and return `DID_NOT_PRODUCE`. + if (!config.skipInitialRequest) { + racers.push(attemptInitFromFetch); + } + let initializationSource: ConfigSource; + const { source: firstSource, result: firstConfigResult } = await Promise.race(racers); + + if (firstConfigResult === ConfigLoaderStatus.COMPLETED) { + // First config load successfully produced a configuration + initializationSource = firstSource; + } else { + // First attempt failed or did not produce configuration, but we have a second at bat that will be executed in the scope of the top-level try-catch + + const secondAttempt = + firstSource === ConfigSource.FETCH ? attemptInitFromConfigStore : attemptInitFromFetch; + + initializationSource = await secondAttempt.then((resultPair: ConfigLoadResult) => { + const { source, result } = resultPair; + return result === ConfigLoaderStatus.COMPLETED ? source : ConfigSource.NONE; + }); + } + + if (initializationSource === ConfigSource.NONE) { + // Neither config loader produced useful config. Return error, if exists, in order from first to last: fetch, config store, generic. + initializationError = initFromFetchError + ? initFromFetchError + : initFromConfigStoreError + ? initFromConfigStoreError + : new Error('Eppo SDK: No configuration source produced a valid configuration'); + } + applicationLogger.debug('Initialization source', initializationSource); + } catch (error: unknown) { + initializationError = error instanceof Error ? error : new Error(String(error)); + } + + if (initializationError) { + applicationLogger.warn( + 'Eppo SDK was unable to initialize with a configuration, assignment calls will return the default value and not be logged' + + (config.pollAfterFailedInitialization + ? ' until an experiment configuration is successfully retrieved' + : ''), + ); + if (config.throwOnFailedInitialization ?? true) { + throw initializationError; + } } + + this.initialized = true; + this.initializedPromiseResolver(); + return this; } - public waitForReady(): Promise { - return this.readyPromise; + /** + * Resolved when the client is initialized + * @private + */ + private readonly initializedPromise: Promise; + + initialized = false; + + /** + * Resolves the `initializedPromise` when initialization is complete + * + * Initialization happens outside the constructor, so we can't assign `initializedPromise` to the result + * of initialization. Instead, we call the resolver when `init` is complete. + * @private + */ + private initializedPromiseResolver: () => void = () => null; + + /** + * Resolves when the EppoClient has completed its initialization. + */ + public waitForInitialized(): Promise { + return this.initializedPromise; } public getStringAssignment( @@ -335,48 +543,6 @@ export class EppoJSClient extends EppoClient { applicationLogger.warn('Eppo SDK assignment requested before init() completed'); } } - - /** - * Tracks pending initialization. After an initialization completes, the value is removed from the map. - */ - private static initializationPromise: Promise | null = null; - - /** - * This method is part of a bridge from using a singleton to independent instances. More specifically, it exists so - * that the init method can access the private field, `readyResolver`. It should not be called by any - * methods other than the `init` method. There are limited guards in place; the behaviour if called inappropriately - * is undefined. - * - * It also keeps code that relies on internal details of EppoJSClient colocated in the class. - * - * @internal - * - * @param client - * @param config - */ - static async initializeClient( - client: EppoJSClient, - config: IClientConfig, - ): Promise { - validation.validateNotBlank(config.apiKey, 'API key required'); - - // If there is already an init in progress for this apiKey, return that. - if (!EppoJSClient.initializationPromise) { - EppoJSClient.initializationPromise = explicitInit(config, client).then((client) => { - // Resolve the ready promise if it exists - if (client.readyPromiseResolver) { - client.readyPromiseResolver(); - client.readyPromiseResolver = null; - } - return client; - }); - } - - const readyClient = await EppoJSClient.initializationPromise; - EppoJSClient.initializationPromise = null; - - return readyClient; - } } /** @@ -446,6 +612,11 @@ export function offlineInit(config: IClientConfigSync): EppoClient { return instance; } +/** + * Tracks pending initialization. After an initialization completes, this value is nulled + */ +let initializationPromise: Promise | null = null; + /** * Initializes the Eppo client with configuration parameters. * This method should be called once on application startup. @@ -455,239 +626,18 @@ export function offlineInit(config: IClientConfigSync): EppoClient { * * * @deprecated - * Use `new EppoJSClient(options)` instead of `init` or `initializeClient`. These will be removed in v4 + * Use `EppoJSClient.createAndInit` instead of `init` and `getInstance`. These will be removed in v4 * * @param config - client configuration * @public */ export async function init(config: IClientConfig): Promise { - return EppoJSClient.initializeClient(getInstance(), config); -} - -async function explicitInit(config: IClientConfig, instance: EppoJSClient): Promise { - validation.validateNotBlank(config.apiKey, 'API key required'); - let initializationError: Error | undefined; - const { - apiKey, - persistentStore, - baseUrl, - maxCacheAgeSeconds, - updateOnFetch, - forceReinitialize, - requestTimeoutMs, - numInitialRequestRetries, - numPollRequestRetries, - pollingIntervalMs, - pollAfterSuccessfulInitialization = false, - pollAfterFailedInitialization = false, - skipInitialRequest = false, - eventIngestionConfig, - } = config; - - try { - if (instance.initialized) { - // TODO: check super.isInitialized. - if (forceReinitialize) { - applicationLogger.warn( - 'Eppo SDK is already initialized, reinitializing since forceReinitialize is true.', - ); - instance.initialized = false; - } else { - applicationLogger.warn( - 'Eppo SDK is already initialized, skipping reinitialization since forceReinitialize is false.', - ); - return instance; - } - } - - // If any existing instances; ensure they are not polling - instance.stopPolling(); - // Set up assignment logger and cache - instance.setAssignmentLogger(config.assignmentLogger); - if (config.banditLogger) { - instance.setBanditLogger(config.banditLogger); - } - // Default to obfuscated mode when requesting configuration from the server. - instance.setIsObfuscated(true); - - const storageKeySuffix = buildStorageKeySuffix(apiKey); - - // Set the configuration store to the desired persistent store, if provided. - // Otherwise, the factory method will detect the current environment and instantiate the correct store. - const configurationStore = configurationStorageFactory( - { - maxAgeSeconds: maxCacheAgeSeconds, - servingStoreUpdateStrategy: updateOnFetch, - persistentStore, - hasChromeStorage: hasChromeStorage(), - hasWindowLocalStorage: hasWindowLocalStorage(), - }, - { - chromeStorage: chromeStorageIfAvailable(), - windowLocalStorage: localStorageIfAvailable(), - storageKeySuffix, - }, - ); - instance.setFlagConfigurationStore(configurationStore); - - // instantiate and init assignment cache - const assignmentCache = assignmentCacheFactory({ - chromeStorage: chromeStorageIfAvailable(), - storageKeySuffix, - }); - if (assignmentCache instanceof HybridAssignmentCache) { - await assignmentCache.init(); - } - instance.useCustomAssignmentCache(assignmentCache); - - // Set up parameters for requesting updated configurations - const requestConfiguration: FlagConfigurationRequestParameters = { - apiKey, - sdkName, - sdkVersion, - baseUrl, - requestTimeoutMs, - numInitialRequestRetries, - numPollRequestRetries, - pollAfterSuccessfulInitialization, - pollAfterFailedInitialization, - pollingIntervalMs, - throwOnFailedInitialization: true, // always use true here as underlying instance fetch is surrounded by try/catch - skipInitialPoll: skipInitialRequest, - }; - instance.setConfigurationRequestParameters(requestConfiguration); - instance.setEventDispatcher(newEventDispatcher(apiKey, eventIngestionConfig)); - - // We have two at-bats for initialization: from the configuration store and from fetching - // We can resolve the initialization promise as soon as either one succeeds - // If there is no persistent store underlying the ConfigurationStore, then there is no race to begin with, as - // there is not an async local load to wait for. - - // The definition of a successful configuration load differs for a cached config vs a fetched config. - // Specifically, a cached config with no entries is not considered a completed load and will result in - // `ConfigLoaderStatus.DID_NOT_PRODUCE` while and empty config from the fetch is considered a valid, `COMPLETED` - // configuration load. - let initFromConfigStoreError = undefined; - let initFromFetchError = undefined; - - const attemptInitFromConfigStore: ConfigurationLoadAttempt = configurationStore - .init() - .then(async () => { - if (!configurationStore.getKeys().length) { - // Consider empty configuration stores invalid - applicationLogger.warn('Eppo SDK cached configuration is empty'); - initFromConfigStoreError = new Error('Configuration store was empty'); - return ConfigLoaderStatus.DID_NOT_PRODUCE; - } - - const cacheIsExpired = await configurationStore.isExpired(); - if (cacheIsExpired && !config.useExpiredCache) { - applicationLogger.warn('Eppo SDK set not to use expired cached configuration'); - initFromConfigStoreError = new Error('Configuration store was expired'); - return ConfigLoaderStatus.DID_NOT_PRODUCE; - } else if (cacheIsExpired && config.useExpiredCache) { - applicationLogger.warn('Eppo SDK config.useExpiredCache is true; using expired cache'); - } - - return ConfigLoaderStatus.COMPLETED; - }) - .catch((e) => { - applicationLogger.warn( - 'Eppo SDK encountered an error initializing from the configuration store', - e, - ); - initFromConfigStoreError = e; - return ConfigLoaderStatus.FAILED; - }) - .then((status) => { - return { source: ConfigSource.CONFIG_STORE, result: status }; - }); - - // Kick off a remote fetch (and start the poller, if configured to do so). - // Before a network fetch is attempted, the `ConfiguratonRequestor` instance will check to see whether the - // locally-stored config data is expired. If it is not expired, the network fetch is skipped, and the promise chain - // will quickly return. **When data not successfully fetched due to an error, invalid data, or giving up the fetch - // because the cache is not expired, the configuration store will not be set as initialized.** - const attemptInitFromFetch: ConfigurationLoadAttempt = instance - .fetchFlagConfigurations() - .then(() => { - if (configurationStore.isInitialized()) { - // If fetch has won the race and the configStore is initialized, that means we have a successful load and the - // fetched data was used to init. - return ConfigLoaderStatus.COMPLETED; - } else { - // If the config store is not initialized and the fetch says it is done, that means the fetch did not set any - // values into the config store (ex:the cache is not expired or invalid config data was received). - // It's important not to set an error here as this state does not mean that an error was encountered. - // If the initial cache had not expired, the fetch would be skipped and the config store would report as - // uninitialized because only a successful fetch setting the data entries will set the persistent store as - // initialized. - return ConfigLoaderStatus.DID_NOT_PRODUCE; - } - }) - .catch((e) => { - applicationLogger.warn('Eppo SDK encountered an error initializing from fetching', e); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - initFromFetchError = e; - return ConfigLoaderStatus.FAILED; - }) - .then((status) => { - return { source: ConfigSource.FETCH, result: status }; - }); - - const racers: ConfigurationLoadAttempt[] = [attemptInitFromConfigStore]; - - // attemptInitFromFetch will return early and not attempt to fetch if `skipInitialRequest` is `true`. - // We could still race it against a load from configuration as the handling logic will see that no config was loaded - // into the config store from the remote fetch and return `DID_NOT_PRODUCE`. - if (!config.skipInitialRequest) { - racers.push(attemptInitFromFetch); - } - let initializationSource: ConfigSource; - const { source: firstSource, result: firstConfigResult } = await Promise.race(racers); - - if (firstConfigResult === ConfigLoaderStatus.COMPLETED) { - // First config load successfully produced a configuration - initializationSource = firstSource; - } else { - // First attempt failed or did not produce configuration, but we have a second at bat that will be executed in the scope of the top-level try-catch - - const secondAttempt = - firstSource === ConfigSource.FETCH ? attemptInitFromConfigStore : attemptInitFromFetch; - - initializationSource = await secondAttempt.then((resultPair: ConfigLoadResult) => { - const { source, result } = resultPair; - return result === ConfigLoaderStatus.COMPLETED ? source : ConfigSource.NONE; - }); - } - - if (initializationSource === ConfigSource.NONE) { - // Neither config loader produced useful config. Return error, if exists, in order from first to last: fetch, config store, generic. - initializationError = initFromFetchError - ? initFromFetchError - : initFromConfigStoreError - ? initFromConfigStoreError - : new Error('Eppo SDK: No configuration source produced a valid configuration'); - } - applicationLogger.debug('Initialization source', initializationSource); - } catch (error: unknown) { - initializationError = error instanceof Error ? error : new Error(String(error)); + if (initializationPromise === null) { + initializationPromise = getInstance().init(config); } - - if (initializationError) { - applicationLogger.warn( - 'Eppo SDK was unable to initialize with a configuration, assignment calls will return the default value and not be logged' + - (config.pollAfterFailedInitialization - ? ' until an experiment configuration is successfully retrieved' - : ''), - ); - if (config.throwOnFailedInitialization ?? true) { - throw initializationError; - } - } - - instance.initialized = true; - return instance; + const client = await initializationPromise; + initializationPromise = null; + return client; } /** @@ -695,6 +645,7 @@ async function explicitInit(config: IClientConfig, instance: EppoJSClient): Prom * Use the method after calling init() to initialize the client. * @returns a singleton client instance * @public + * @deprecated */ export function getInstance(): EppoJSClient { return EppoJSClient.instance; From 4b8077fdbae9a9806306043bce9e3e4252a84ca6 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Tue, 4 Feb 2025 21:56:29 -0700 Subject: [PATCH 14/29] polish --- src/i-client-config.ts | 1 + src/index.spec.ts | 8 ++++---- src/index.ts | 31 +++++++++++++++++-------------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/i-client-config.ts b/src/i-client-config.ts index deb05ed..ab9bb08 100644 --- a/src/i-client-config.ts +++ b/src/i-client-config.ts @@ -167,6 +167,7 @@ export type IApiOptions = { updateOnFetch?: ServingStoreUpdateStrategy; }; +/** Configuration settings for the event dispatcher */ export type IEventOptions = { eventIngestionConfig?: { /** Number of milliseconds to wait between each batch delivery. Defaults to 10 seconds. */ diff --git a/src/index.spec.ts b/src/index.spec.ts index af3c278..5cd051e 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -429,7 +429,7 @@ describe('decoupled initialization', () => { const isolatedClient = EppoJSClient.buildAndInit(options); expect(isolatedClient).not.toEqual(getInstance()); - await isolatedClient.waitForInitialized(); + await isolatedClient.waitForInitialization(); expect(isolatedClient.isInitialized()).toBe(true); expect(isolatedClient.initialized).toBe(true); @@ -452,7 +452,7 @@ describe('decoupled initialization', () => { 'default-value', ); - await client.waitForInitialized(); + await client.waitForInitialization(); const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'); expect(assignment).toEqual('variant-1'); @@ -521,7 +521,7 @@ describe('decoupled initialization', () => { expect(callCount).toBe(1); const myClient2 = EppoJSClient.buildAndInit({ ...commonOptions, apiKey: API_KEY_2 }); - await myClient2.waitForInitialized(); + await myClient2.waitForInitialization(); expect(callCount).toBe(2); expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( @@ -532,7 +532,7 @@ describe('decoupled initialization', () => { ); const myClient3 = EppoJSClient.buildAndInit({ ...commonOptions, apiKey: API_KEY_3 }); - await myClient3.waitForInitialized(); + await myClient3.waitForInitialization(); expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( 'variant-1', diff --git a/src/index.ts b/src/index.ts index 90aa4bd..e06ce6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,6 +111,9 @@ const memoryOnlyPrecomputedBanditsStore = precomputedBanditStoreFactory(); * @public */ export class EppoJSClient extends EppoClient { + // Ensure that the client is instantiated during class loading. + // Use an empty memory-only configuration store until the `init` method is called, + // to avoid serving stale data to the user. public static instance = new EppoJSClient({ flagConfigurationStore: configurationStorageFactory({ forceMemoryOnly: true, @@ -118,8 +121,8 @@ export class EppoJSClient extends EppoClient { isObfuscated: true, }); - constructor(optionsOrConfig: EppoClientParameters) { - super(optionsOrConfig); + constructor(options: EppoClientParameters) { + super(options); // Create a promise that will be resolved when initialization is complete. this.initializedPromise = new Promise((resolve) => { @@ -134,10 +137,20 @@ export class EppoJSClient extends EppoClient { forceMemoryOnly: true, }); const client = new EppoJSClient({ flagConfigurationStore }); + + // init will resolve the promise that client.waitForInitialized returns. client.init(config); return client; } + /** + * Resolved when the client is initialized + * @private + */ + private readonly initializedPromise: Promise; + + initialized = false; + async init(config: IClientConfig): Promise { validation.validateNotBlank(config.apiKey, 'API key required'); let initializationError: Error | undefined; @@ -160,7 +173,6 @@ export class EppoJSClient extends EppoClient { try { if (this.initialized) { - // TODO: check super.isInitialized. if (forceReinitialize) { applicationLogger.warn( 'Eppo SDK is already initialized, reinitializing since forceReinitialize is true.', @@ -364,14 +376,6 @@ export class EppoJSClient extends EppoClient { return this; } - /** - * Resolved when the client is initialized - * @private - */ - private readonly initializedPromise: Promise; - - initialized = false; - /** * Resolves the `initializedPromise` when initialization is complete * @@ -384,7 +388,7 @@ export class EppoJSClient extends EppoClient { /** * Resolves when the EppoClient has completed its initialization. */ - public waitForInitialized(): Promise { + public waitForInitialization(): Promise { return this.initializedPromise; } @@ -539,7 +543,6 @@ export class EppoJSClient extends EppoClient { private ensureInitialized() { if (!this.initialized) { - // TODO: check super.isInitialized? applicationLogger.warn('Eppo SDK assignment requested before init() completed'); } } @@ -622,7 +625,7 @@ let initializationPromise: Promise | null = null; * This method should be called once on application startup. * If an initialization is in process, calling `init` will return the in-progress * `Promise`. Once the initialization completes, calling `init` again will kick off the - * initialization routine (if `forceReinitialization` is `true`). + * initialization routine (if `forceReinitialize` is `true`). * * * @deprecated From 6141581d2c529b4d3c753c943e15248bc277ddc3 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Tue, 4 Feb 2025 21:58:20 -0700 Subject: [PATCH 15/29] polish --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index e06ce6a..33e04bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -138,7 +138,7 @@ export class EppoJSClient extends EppoClient { }); const client = new EppoJSClient({ flagConfigurationStore }); - // init will resolve the promise that client.waitForInitialized returns. + // init will resolve the promise that client.waitForConfiguration returns. client.init(config); return client; } From 82ec342389ce55e04a188607532e30a5ce6088dc Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Tue, 4 Feb 2025 22:24:06 -0700 Subject: [PATCH 16/29] test name --- src/index.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 5cd051e..898f655 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -513,7 +513,7 @@ describe('decoupled initialization', () => { jest.restoreAllMocks(); }); - it('should operate in parallel', async () => { + it('should evaluate separate UFCs for each SDK key', async () => { const singleton = await init({ ...commonOptions, apiKey: API_KEY_1 }); expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( 'variant-1', From 81ff3b16cb9b707241a9b861e158a27aba390e68 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Tue, 11 Feb 2025 11:13:10 -0700 Subject: [PATCH 17/29] move comment --- src/i-client-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i-client-config.ts b/src/i-client-config.ts index ab9bb08..985ccf9 100644 --- a/src/i-client-config.ts +++ b/src/i-client-config.ts @@ -167,8 +167,8 @@ export type IApiOptions = { updateOnFetch?: ServingStoreUpdateStrategy; }; -/** Configuration settings for the event dispatcher */ export type IEventOptions = { + /** Configuration settings for the event dispatcher */ eventIngestionConfig?: { /** Number of milliseconds to wait between each batch delivery. Defaults to 10 seconds. */ deliveryIntervalMs?: number; From fad43d320389f838f00c8f65c4a6cb42072e2be1 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 13 Feb 2025 09:44:35 -0700 Subject: [PATCH 18/29] move forceReinit --- js-client-sdk.api.md | 91 ++++++++++++++++++++++++++++++------------ src/i-client-config.ts | 1 + src/index.ts | 39 +++++++++--------- 3 files changed, 86 insertions(+), 45 deletions(-) diff --git a/js-client-sdk.api.md b/js-client-sdk.api.md index f7ef16d..c8864a2 100644 --- a/js-client-sdk.api.md +++ b/js-client-sdk.api.md @@ -13,6 +13,7 @@ import { BanditActions } from '@eppo/js-client-sdk-common'; import { BanditSubjectAttributes } from '@eppo/js-client-sdk-common'; import { ContextAttributes } from '@eppo/js-client-sdk-common'; import { EppoClient } from '@eppo/js-client-sdk-common'; +import { EppoClientParameters } from '@eppo/js-client-sdk-common'; import { EppoPrecomputedClient } from '@eppo/js-client-sdk-common'; import { Flag } from '@eppo/js-client-sdk-common'; import { FlagKey } from '@eppo/js-client-sdk-common'; @@ -22,6 +23,7 @@ import { IAssignmentLogger } from '@eppo/js-client-sdk-common'; import { IAsyncStore } from '@eppo/js-client-sdk-common'; import { IBanditEvent } from '@eppo/js-client-sdk-common'; import { IBanditLogger } from '@eppo/js-client-sdk-common'; +import { IConfigurationStore } from '@eppo/js-client-sdk-common'; import { IContainerExperiment } from '@eppo/js-client-sdk-common'; import { ObfuscatedFlag } from '@eppo/js-client-sdk-common'; @@ -33,9 +35,6 @@ export { BanditActions } export { BanditSubjectAttributes } -// @public -export function buildStorageKeySuffix(apiKey: string): string; - // Warning: (ae-forgotten-export) The symbol "IStringStorageEngine" needs to be exported by the entry point index.d.ts // // @public @@ -56,6 +55,9 @@ export { ContextAttributes } // @public export class EppoJSClient extends EppoClient { + constructor(options: EppoClientParameters); + // (undocumented) + static buildAndInit(config: IClientConfig): EppoJSClient; // (undocumented) getBanditAction(flagKey: string, subjectKey: string, subjectAttributes: BanditSubjectAttributes, actions: BanditActions, defaultValue: string): Omit, 'evaluationDetails'>; // (undocumented) @@ -85,9 +87,12 @@ export class EppoJSClient extends EppoClient { // (undocumented) getStringAssignmentDetails(flagKey: string, subjectKey: string, subjectAttributes: Record, defaultValue: string): IAssignmentDetails; // (undocumented) - static initialized: boolean; + init(config: IClientConfig): Promise; + // (undocumented) + initialized: boolean; // (undocumented) static instance: EppoJSClient; + waitForInitialization(): Promise; } // @public @@ -115,12 +120,26 @@ export { Flag } // @public export function getConfigUrl(apiKey: string, baseUrl?: string): URL; -// @public -export function getInstance(): EppoClient; +// @public @deprecated +export function getInstance(): EppoJSClient; // @public export function getPrecomputedInstance(): EppoPrecomputedClient; +// @public +export type IApiOptions = { + apiKey: string; + baseUrl?: string; + forceReinitialize?: boolean; + requestTimeoutMs?: number; + numInitialRequestRetries?: number; + skipInitialRequest?: boolean; + throwOnFailedInitialization?: boolean; + maxCacheAgeSeconds?: number; + useExpiredCache?: boolean; + updateOnFetch?: ServingStoreUpdateStrategy; +}; + export { IAssignmentDetails } export { IAssignmentEvent } @@ -133,26 +152,8 @@ export { IBanditEvent } export { IBanditLogger } -// Warning: (ae-forgotten-export) The symbol "IBaseRequestConfig" needs to be exported by the entry point index.d.ts -// // @public -export interface IClientConfig extends IBaseRequestConfig { - eventIngestionConfig?: { - deliveryIntervalMs?: number; - retryIntervalMs?: number; - maxRetryDelayMs?: number; - maxRetries?: number; - batchSize?: number; - maxQueueSize?: number; - }; - forceReinitialize?: boolean; - maxCacheAgeSeconds?: number; - persistentStore?: IAsyncStore; - throwOnFailedInitialization?: boolean; - // Warning: (ae-forgotten-export) The symbol "ServingStoreUpdateStrategy" needs to be exported by the entry point index.d.ts - updateOnFetch?: ServingStoreUpdateStrategy; - useExpiredCache?: boolean; -} +export type IClientConfig = IApiOptions & ILoggers & IEventOptions & IStorageOptions & IPollingOptions; // @public export interface IClientConfigSync { @@ -168,9 +169,37 @@ export interface IClientConfigSync { throwOnFailedInitialization?: boolean; } -// @public +// @public (undocumented) +export type IEventOptions = { + eventIngestionConfig?: { + deliveryIntervalMs?: number; + retryIntervalMs?: number; + maxRetryDelayMs?: number; + maxRetries?: number; + batchSize?: number; + maxQueueSize?: number; + }; +}; + +// @public (undocumented) +export type ILoggers = { + assignmentLogger: IAssignmentLogger; + banditLogger?: IBanditLogger; +}; + +// @public @deprecated export function init(config: IClientConfig): Promise; +// @public (undocumented) +export type IPollingOptions = { + pollAfterFailedInitialization?: boolean; + pollAfterSuccessfulInitialization?: boolean; + pollingIntervalMs?: number; + numPollRequestRetries?: number; +}; + +// Warning: (ae-forgotten-export) The symbol "IBaseRequestConfig" needs to be exported by the entry point index.d.ts +// // @public export interface IPrecomputedClientConfig extends IBaseRequestConfig { // Warning: (ae-forgotten-export) The symbol "IPrecompute" needs to be exported by the entry point index.d.ts @@ -191,6 +220,12 @@ export interface IPrecomputedClientConfigSync { throwOnFailedInitialization?: boolean; } +// @public (undocumented) +export type IStorageOptions = { + flagConfigurationStore?: IConfigurationStore; + persistentStore?: IAsyncStore; +}; + export { ObfuscatedFlag } // @public @@ -202,6 +237,10 @@ export function offlinePrecomputedInit(config: IPrecomputedClientConfigSync): Ep // @public export function precomputedInit(config: IPrecomputedClientConfig): Promise; +// Warnings were encountered during analysis: +// +// src/i-client-config.ts:167:3 - (ae-forgotten-export) The symbol "ServingStoreUpdateStrategy" needs to be exported by the entry point index.d.ts + // (No @packageDocumentation comment for this package) ``` diff --git a/src/i-client-config.ts b/src/i-client-config.ts index 985ccf9..c8fcdc1 100644 --- a/src/i-client-config.ts +++ b/src/i-client-config.ts @@ -120,6 +120,7 @@ export type IApiOptions = { /** * Force reinitialize the SDK if it is already initialized. + * @deprecated use `buildAndInit` to create a fresh client. */ forceReinitialize?: boolean; diff --git a/src/index.ts b/src/index.ts index 33e04bb..a379940 100644 --- a/src/index.ts +++ b/src/index.ts @@ -121,7 +121,7 @@ export class EppoJSClient extends EppoClient { isObfuscated: true, }); - constructor(options: EppoClientParameters) { + private constructor(options: EppoClientParameters) { super(options); // Create a promise that will be resolved when initialization is complete. @@ -151,7 +151,7 @@ export class EppoJSClient extends EppoClient { initialized = false; - async init(config: IClientConfig): Promise { + async init(config: Omit): Promise { validation.validateNotBlank(config.apiKey, 'API key required'); let initializationError: Error | undefined; const { @@ -160,7 +160,6 @@ export class EppoJSClient extends EppoClient { baseUrl, maxCacheAgeSeconds, updateOnFetch, - forceReinitialize, requestTimeoutMs, numInitialRequestRetries, numPollRequestRetries, @@ -172,20 +171,6 @@ export class EppoJSClient extends EppoClient { } = config; try { - if (this.initialized) { - if (forceReinitialize) { - applicationLogger.warn( - 'Eppo SDK is already initialized, reinitializing since forceReinitialize is true.', - ); - this.initialized = false; - } else { - applicationLogger.warn( - 'Eppo SDK is already initialized, skipping reinitialization since forceReinitialize is false.', - ); - return this; - } - } - // If the instance was polling, stop. this.stopPolling(); // Set up assignment logger and cache @@ -634,10 +619,26 @@ let initializationPromise: Promise | null = null; * @param config - client configuration * @public */ -export async function init(config: IClientConfig): Promise { +export async function init(config: IClientConfig): Promise { if (initializationPromise === null) { - initializationPromise = getInstance().init(config); + const singleton = getInstance(); + if (singleton.initialized) { + if (config.forceReinitialize) { + applicationLogger.warn( + 'Eppo SDK is already initialized, reinitializing since forceReinitialize is true.', + ); + singleton.initialized = false; + } else { + applicationLogger.warn( + 'Eppo SDK is already initialized, skipping reinitialization since forceReinitialize is false.', + ); + return singleton; + } + } + + initializationPromise = singleton.init(config); } + const client = await initializationPromise; initializationPromise = null; return client; From febb3f8286f4de294fd1e1a333d5648118187ecc Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 13 Feb 2025 09:44:56 -0700 Subject: [PATCH 19/29] docs --- ...s-client-sdk.eppojsclient._constructor_.md | 6 +- ...s-client-sdk.eppojsclient.buildandinit.md} | 18 ++--- docs/js-client-sdk.eppojsclient.init.md | 49 +++++++++++++ docs/js-client-sdk.eppojsclient.md | 32 ++++++++- ...sdk.eppojsclient.waitforinitialization.md} | 8 ++- docs/js-client-sdk.getinstance.md | 4 ++ docs/js-client-sdk.iapioptions.md | 24 +++++++ docs/js-client-sdk.iclientconfig.md | 4 +- docs/js-client-sdk.ieventoptions.md | 20 ++++++ docs/js-client-sdk.iloggers.md | 14 ++++ docs/js-client-sdk.init.md | 4 +- docs/js-client-sdk.ipollingoptions.md | 16 +++++ docs/js-client-sdk.istorageoptions.md | 14 ++++ docs/js-client-sdk.md | 71 +++++++++++++------ docs/js-client-sdk.neweventdispatcher.md | 65 ----------------- docs/js-client-sdk.offlineinit.md | 18 +---- 16 files changed, 240 insertions(+), 127 deletions(-) rename docs/{js-client-sdk.buildstoragekeysuffix.md => js-client-sdk.eppojsclient.buildandinit.md} (50%) create mode 100644 docs/js-client-sdk.eppojsclient.init.md rename docs/{js-client-sdk.eppojsclient.waitforready.md => js-client-sdk.eppojsclient.waitforinitialization.md} (53%) create mode 100644 docs/js-client-sdk.iapioptions.md create mode 100644 docs/js-client-sdk.ieventoptions.md create mode 100644 docs/js-client-sdk.iloggers.md create mode 100644 docs/js-client-sdk.ipollingoptions.md create mode 100644 docs/js-client-sdk.istorageoptions.md delete mode 100644 docs/js-client-sdk.neweventdispatcher.md diff --git a/docs/js-client-sdk.eppojsclient._constructor_.md b/docs/js-client-sdk.eppojsclient._constructor_.md index 821de62..9659231 100644 --- a/docs/js-client-sdk.eppojsclient._constructor_.md +++ b/docs/js-client-sdk.eppojsclient._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `EppoJSClient` class **Signature:** ```typescript -constructor(optionsOrConfig: EppoClientParameters | IClientOptions); +constructor(options: EppoClientParameters); ``` ## Parameters @@ -32,12 +32,12 @@ Description -optionsOrConfig +options -EppoClientParameters \| IClientOptions +EppoClientParameters diff --git a/docs/js-client-sdk.buildstoragekeysuffix.md b/docs/js-client-sdk.eppojsclient.buildandinit.md similarity index 50% rename from docs/js-client-sdk.buildstoragekeysuffix.md rename to docs/js-client-sdk.eppojsclient.buildandinit.md index 160bb3c..51a91ed 100644 --- a/docs/js-client-sdk.buildstoragekeysuffix.md +++ b/docs/js-client-sdk.eppojsclient.buildandinit.md @@ -1,15 +1,13 @@ -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [buildStorageKeySuffix](./js-client-sdk.buildstoragekeysuffix.md) +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [EppoJSClient](./js-client-sdk.eppojsclient.md) > [buildAndInit](./js-client-sdk.eppojsclient.buildandinit.md) -## buildStorageKeySuffix() function - -Builds a storage key suffix from an API key. +## EppoJSClient.buildAndInit() method **Signature:** ```typescript -export declare function buildStorageKeySuffix(apiKey: string): string; +static buildAndInit(config: IClientConfig): EppoJSClient; ``` ## Parameters @@ -32,24 +30,20 @@ Description -apiKey +config -string +[IClientConfig](./js-client-sdk.iclientconfig.md) -The API key to build the suffix from - **Returns:** -string - -A string suffix for storage keys +[EppoJSClient](./js-client-sdk.eppojsclient.md) diff --git a/docs/js-client-sdk.eppojsclient.init.md b/docs/js-client-sdk.eppojsclient.init.md new file mode 100644 index 0000000..96eaa06 --- /dev/null +++ b/docs/js-client-sdk.eppojsclient.init.md @@ -0,0 +1,49 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [EppoJSClient](./js-client-sdk.eppojsclient.md) > [init](./js-client-sdk.eppojsclient.init.md) + +## EppoJSClient.init() method + +**Signature:** + +```typescript +init(config: IClientConfig): Promise; +``` + +## Parameters + + + +
+ +Parameter + + + + +Type + + + + +Description + + +
+ +config + + + + +[IClientConfig](./js-client-sdk.iclientconfig.md) + + + + + +
+**Returns:** + +Promise<[EppoJSClient](./js-client-sdk.eppojsclient.md)> + diff --git a/docs/js-client-sdk.eppojsclient.md b/docs/js-client-sdk.eppojsclient.md index 6af41df..c571b4b 100644 --- a/docs/js-client-sdk.eppojsclient.md +++ b/docs/js-client-sdk.eppojsclient.md @@ -33,7 +33,7 @@ Description -[(constructor)(optionsOrConfig)](./js-client-sdk.eppojsclient._constructor_.md) +[(constructor)(options)](./js-client-sdk.eppojsclient._constructor_.md) @@ -128,6 +128,20 @@ Description +[buildAndInit(config)](./js-client-sdk.eppojsclient.buildandinit.md) + + + + +`static` + + + + + + + + [getBanditAction(flagKey, subjectKey, subjectAttributes, actions, defaultValue)](./js-client-sdk.eppojsclient.getbanditaction.md) @@ -296,7 +310,7 @@ Description -[waitForReady()](./js-client-sdk.eppojsclient.waitforready.md) +[init(config)](./js-client-sdk.eppojsclient.init.md) @@ -305,5 +319,19 @@ Description + + + +[waitForInitialization()](./js-client-sdk.eppojsclient.waitforinitialization.md) + + + + + + + +Resolves when the EppoClient has completed its initialization. + + diff --git a/docs/js-client-sdk.eppojsclient.waitforready.md b/docs/js-client-sdk.eppojsclient.waitforinitialization.md similarity index 53% rename from docs/js-client-sdk.eppojsclient.waitforready.md rename to docs/js-client-sdk.eppojsclient.waitforinitialization.md index 64b2e00..9c1e66a 100644 --- a/docs/js-client-sdk.eppojsclient.waitforready.md +++ b/docs/js-client-sdk.eppojsclient.waitforinitialization.md @@ -1,13 +1,15 @@ -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [EppoJSClient](./js-client-sdk.eppojsclient.md) > [waitForReady](./js-client-sdk.eppojsclient.waitforready.md) +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [EppoJSClient](./js-client-sdk.eppojsclient.md) > [waitForInitialization](./js-client-sdk.eppojsclient.waitforinitialization.md) -## EppoJSClient.waitForReady() method +## EppoJSClient.waitForInitialization() method + +Resolves when the EppoClient has completed its initialization. **Signature:** ```typescript -waitForReady(): Promise; +waitForInitialization(): Promise; ``` **Returns:** diff --git a/docs/js-client-sdk.getinstance.md b/docs/js-client-sdk.getinstance.md index 3bc021a..42bcc2e 100644 --- a/docs/js-client-sdk.getinstance.md +++ b/docs/js-client-sdk.getinstance.md @@ -4,6 +4,10 @@ ## getInstance() function +> Warning: This API is now obsolete. +> +> + Used to access a singleton SDK client instance. Use the method after calling init() to initialize the client. **Signature:** diff --git a/docs/js-client-sdk.iapioptions.md b/docs/js-client-sdk.iapioptions.md new file mode 100644 index 0000000..4086587 --- /dev/null +++ b/docs/js-client-sdk.iapioptions.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IApiOptions](./js-client-sdk.iapioptions.md) + +## IApiOptions type + +Base options for the EppoClient SDK + +**Signature:** + +```typescript +export declare type IApiOptions = { + apiKey: string; + baseUrl?: string; + forceReinitialize?: boolean; + requestTimeoutMs?: number; + numInitialRequestRetries?: number; + skipInitialRequest?: boolean; + throwOnFailedInitialization?: boolean; + maxCacheAgeSeconds?: number; + useExpiredCache?: boolean; + updateOnFetch?: ServingStoreUpdateStrategy; +}; +``` diff --git a/docs/js-client-sdk.iclientconfig.md b/docs/js-client-sdk.iclientconfig.md index 0c315a0..d408187 100644 --- a/docs/js-client-sdk.iclientconfig.md +++ b/docs/js-client-sdk.iclientconfig.md @@ -9,5 +9,7 @@ Configuration for regular client initialization **Signature:** ```typescript -export declare type IClientConfig = Omit & Pick; +export declare type IClientConfig = IApiOptions & ILoggers & IEventOptions & IStorageOptions & IPollingOptions; ``` +**References:** [IApiOptions](./js-client-sdk.iapioptions.md), [ILoggers](./js-client-sdk.iloggers.md), [IEventOptions](./js-client-sdk.ieventoptions.md), [IStorageOptions](./js-client-sdk.istorageoptions.md), [IPollingOptions](./js-client-sdk.ipollingoptions.md) + diff --git a/docs/js-client-sdk.ieventoptions.md b/docs/js-client-sdk.ieventoptions.md new file mode 100644 index 0000000..6e8b10b --- /dev/null +++ b/docs/js-client-sdk.ieventoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IEventOptions](./js-client-sdk.ieventoptions.md) + +## IEventOptions type + +**Signature:** + +```typescript +export declare type IEventOptions = { + eventIngestionConfig?: { + deliveryIntervalMs?: number; + retryIntervalMs?: number; + maxRetryDelayMs?: number; + maxRetries?: number; + batchSize?: number; + maxQueueSize?: number; + }; +}; +``` diff --git a/docs/js-client-sdk.iloggers.md b/docs/js-client-sdk.iloggers.md new file mode 100644 index 0000000..e750093 --- /dev/null +++ b/docs/js-client-sdk.iloggers.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [ILoggers](./js-client-sdk.iloggers.md) + +## ILoggers type + +**Signature:** + +```typescript +export declare type ILoggers = { + assignmentLogger: IAssignmentLogger; + banditLogger?: IBanditLogger; +}; +``` diff --git a/docs/js-client-sdk.init.md b/docs/js-client-sdk.init.md index a08566d..1184923 100644 --- a/docs/js-client-sdk.init.md +++ b/docs/js-client-sdk.init.md @@ -6,10 +6,10 @@ > Warning: This API is now obsolete. > -> Use `new EppoJSClient(options)` instead of `init` or `initializeClient`. These will be removed in v4 +> Use `EppoJSClient.createAndInit` instead of `init` and `getInstance`. These will be removed in v4 > -Initializes the Eppo client with configuration parameters. This method should be called once on application startup. If an initialization is in process, calling `init` will return the in-progress `Promise`. Once the initialization completes, calling `init` again will kick off the initialization routine (if `forceReinitialization` is `true`). +Initializes the Eppo client with configuration parameters. This method should be called once on application startup. If an initialization is in process, calling `init` will return the in-progress `Promise`. Once the initialization completes, calling `init` again will kick off the initialization routine (if `forceReinitialize` is `true`). **Signature:** diff --git a/docs/js-client-sdk.ipollingoptions.md b/docs/js-client-sdk.ipollingoptions.md new file mode 100644 index 0000000..21a0b26 --- /dev/null +++ b/docs/js-client-sdk.ipollingoptions.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IPollingOptions](./js-client-sdk.ipollingoptions.md) + +## IPollingOptions type + +**Signature:** + +```typescript +export declare type IPollingOptions = { + pollAfterFailedInitialization?: boolean; + pollAfterSuccessfulInitialization?: boolean; + pollingIntervalMs?: number; + numPollRequestRetries?: number; +}; +``` diff --git a/docs/js-client-sdk.istorageoptions.md b/docs/js-client-sdk.istorageoptions.md new file mode 100644 index 0000000..26b6836 --- /dev/null +++ b/docs/js-client-sdk.istorageoptions.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IStorageOptions](./js-client-sdk.istorageoptions.md) + +## IStorageOptions type + +**Signature:** + +```typescript +export declare type IStorageOptions = { + flagConfigurationStore?: IConfigurationStore; + persistentStore?: IAsyncStore; +}; +``` diff --git a/docs/js-client-sdk.md b/docs/js-client-sdk.md index fa4e94a..52be57f 100644 --- a/docs/js-client-sdk.md +++ b/docs/js-client-sdk.md @@ -71,17 +71,6 @@ Description -[buildStorageKeySuffix(apiKey)](./js-client-sdk.buildstoragekeysuffix.md) - - - - -Builds a storage key suffix from an API key. - - - - - [getConfigUrl(apiKey, baseUrl)](./js-client-sdk.getconfigurl.md) @@ -120,22 +109,13 @@ Used to access a singleton SDK precomputed client instance. Use the method after -Initializes the Eppo client with configuration parameters. This method should be called once on application startup. If an initialization is in process, calling `init` will return the in-progress `Promise`. Once the initialization completes, calling `init` again will kick off the initialization routine (if `forceReinitialization` is `true`). +Initializes the Eppo client with configuration parameters. This method should be called once on application startup. If an initialization is in process, calling `init` will return the in-progress `Promise`. Once the initialization completes, calling `init` again will kick off the initialization routine (if `forceReinitialize` is `true`). -[newEventDispatcher(sdkKey, config)](./js-client-sdk.neweventdispatcher.md) - - - - - - - - -[offlineInit(config, instance)](./js-client-sdk.offlineinit.md) +[offlineInit(config)](./js-client-sdk.offlineinit.md) @@ -241,6 +221,17 @@ Description +[IApiOptions](./js-client-sdk.iapioptions.md) + + + + +Base options for the EppoClient SDK + + + + + [IClientConfig](./js-client-sdk.iclientconfig.md) @@ -249,5 +240,41 @@ Description Configuration for regular client initialization + + + +[IEventOptions](./js-client-sdk.ieventoptions.md) + + + + + + + + +[ILoggers](./js-client-sdk.iloggers.md) + + + + + + + + +[IPollingOptions](./js-client-sdk.ipollingoptions.md) + + + + + + + + +[IStorageOptions](./js-client-sdk.istorageoptions.md) + + + + + diff --git a/docs/js-client-sdk.neweventdispatcher.md b/docs/js-client-sdk.neweventdispatcher.md deleted file mode 100644 index a831cb3..0000000 --- a/docs/js-client-sdk.neweventdispatcher.md +++ /dev/null @@ -1,65 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [newEventDispatcher](./js-client-sdk.neweventdispatcher.md) - -## newEventDispatcher() function - -**Signature:** - -```typescript -export declare function newEventDispatcher(sdkKey: string, config?: IClientConfig['eventIngestionConfig']): EventDispatcher; -``` - -## Parameters - - - - -
- -Parameter - - - - -Type - - - - -Description - - -
- -sdkKey - - - - -string - - - - - -
- -config - - - - -[IClientConfig](./js-client-sdk.iclientconfig.md)\['eventIngestionConfig'\] - - - - -_(Optional)_ - - -
-**Returns:** - -EventDispatcher - diff --git a/docs/js-client-sdk.offlineinit.md b/docs/js-client-sdk.offlineinit.md index 2365c78..505eb5d 100644 --- a/docs/js-client-sdk.offlineinit.md +++ b/docs/js-client-sdk.offlineinit.md @@ -13,7 +13,7 @@ This method should be called once on application startup. **Signature:** ```typescript -export declare function offlineInit(config: IClientConfigSync, instance?: EppoJSClient): EppoClient; +export declare function offlineInit(config: IClientConfigSync): EppoClient; ``` ## Parameters @@ -49,22 +49,6 @@ config client configuration - - - -instance - - - - -[EppoJSClient](./js-client-sdk.eppojsclient.md) - - - - -_(Optional)_ an EppoJSClient instance to bootstrap. - - **Returns:** From 212e26eccd05b149e5e2d3e83cbdad5e2f9702ef Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 13 Feb 2025 12:14:38 -0700 Subject: [PATCH 20/29] move members --- src/index.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index a379940..8ab51de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -121,6 +121,14 @@ export class EppoJSClient extends EppoClient { isObfuscated: true, }); + /** + * Resolved when the client is initialized + * @private + */ + private readonly initializedPromise: Promise; + + initialized = false; + private constructor(options: EppoClientParameters) { super(options); @@ -143,14 +151,6 @@ export class EppoJSClient extends EppoClient { return client; } - /** - * Resolved when the client is initialized - * @private - */ - private readonly initializedPromise: Promise; - - initialized = false; - async init(config: Omit): Promise { validation.validateNotBlank(config.apiKey, 'API key required'); let initializationError: Error | undefined; From a943e90d035ec32edd3bad11351d1f14f7492a5a Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 14 Feb 2025 08:53:16 -0700 Subject: [PATCH 21/29] refactor wip --- js-client-sdk.api.md | 9 ++++---- src/i-client-config.ts | 21 ++++++++++++------ src/index.spec.ts | 5 +++-- src/index.ts | 49 ++++++++++++++++++++++++++---------------- 4 files changed, 52 insertions(+), 32 deletions(-) diff --git a/js-client-sdk.api.md b/js-client-sdk.api.md index c8864a2..257176c 100644 --- a/js-client-sdk.api.md +++ b/js-client-sdk.api.md @@ -13,7 +13,6 @@ import { BanditActions } from '@eppo/js-client-sdk-common'; import { BanditSubjectAttributes } from '@eppo/js-client-sdk-common'; import { ContextAttributes } from '@eppo/js-client-sdk-common'; import { EppoClient } from '@eppo/js-client-sdk-common'; -import { EppoClientParameters } from '@eppo/js-client-sdk-common'; import { EppoPrecomputedClient } from '@eppo/js-client-sdk-common'; import { Flag } from '@eppo/js-client-sdk-common'; import { FlagKey } from '@eppo/js-client-sdk-common'; @@ -55,7 +54,6 @@ export { ContextAttributes } // @public export class EppoJSClient extends EppoClient { - constructor(options: EppoClientParameters); // (undocumented) static buildAndInit(config: IClientConfig): EppoJSClient; // (undocumented) @@ -130,7 +128,6 @@ export function getPrecomputedInstance(): EppoPrecomputedClient; export type IApiOptions = { apiKey: string; baseUrl?: string; - forceReinitialize?: boolean; requestTimeoutMs?: number; numInitialRequestRetries?: number; skipInitialRequest?: boolean; @@ -187,8 +184,10 @@ export type ILoggers = { banditLogger?: IBanditLogger; }; +// Warning: (ae-forgotten-export) The symbol "ICompatibilityOptions" needs to be exported by the entry point index.d.ts +// // @public @deprecated -export function init(config: IClientConfig): Promise; +export function init(config: IClientConfig & ICompatibilityOptions): Promise; // @public (undocumented) export type IPollingOptions = { @@ -239,7 +238,7 @@ export function precomputedInit(config: IPrecomputedClientConfig): Promise { }); }) as jest.Mock; }); + afterAll(() => { jest.restoreAllMocks(); }); @@ -588,7 +589,7 @@ describe('initialization options', () => { } as unknown as Record<'flags', Record>; // eslint-disable-next-line @typescript-eslint/ban-types - let init: (config: IClientConfig) => Promise; + let init: (config: IClientConfig & ICompatibilityOptions) => Promise; // eslint-disable-next-line @typescript-eslint/ban-types let getInstance: () => EppoJSClient; diff --git a/src/index.ts b/src/index.ts index 8ab51de..9653961 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,7 @@ import LocalStorageBackedNamedEventQueue from './events/local-storage-backed-nam import { IApiOptions, IClientConfig, + ICompatibilityOptions, IEventOptions, ILoggers, IPollingOptions, @@ -127,7 +128,8 @@ export class EppoJSClient extends EppoClient { */ private readonly initializedPromise: Promise; - initialized = false; + public static initialized = false; + private initialized = false; private constructor(options: EppoClientParameters) { super(options); @@ -151,7 +153,7 @@ export class EppoJSClient extends EppoClient { return client; } - async init(config: Omit): Promise { + async init(config: IClientConfig): Promise { validation.validateNotBlank(config.apiKey, 'API key required'); let initializationError: Error | undefined; const { @@ -596,7 +598,7 @@ export function offlineInit(config: IClientConfigSync): EppoClient { } } - instance.initialized = true; + // instance.initialized = true; return instance; } @@ -619,28 +621,37 @@ let initializationPromise: Promise | null = null; * @param config - client configuration * @public */ -export async function init(config: IClientConfig): Promise { - if (initializationPromise === null) { - const singleton = getInstance(); - if (singleton.initialized) { - if (config.forceReinitialize) { - applicationLogger.warn( - 'Eppo SDK is already initialized, reinitializing since forceReinitialize is true.', - ); - singleton.initialized = false; - } else { - applicationLogger.warn( - 'Eppo SDK is already initialized, skipping reinitialization since forceReinitialize is false.', - ); - return singleton; - } +export async function init(config: IClientConfig & ICompatibilityOptions): Promise { + validation.validateNotBlank(config.apiKey, 'API key required'); + const instance = getInstance(); + + if (EppoJSClient.initialized) { + if (config.forceReinitialize) { + applicationLogger.info( + 'Eppo SDK is already initialized, reinitializing since forceReinitialize is true.', + ); + } else { + applicationLogger.warn( + 'Eppo SDK is already initialized, skipping reinitialization since forceReinitialize is false.', + ); + return instance; } + } - initializationPromise = singleton.init(config); + if (initializationPromise === null) { + initializationPromise = instance.init(config); + } else { + applicationLogger.warn( + 'Initialization is already in progress. init should be called only once at application startup.', + ); } const client = await initializationPromise; initializationPromise = null; + + // For backwards compatibility. + EppoJSClient.initialized = true; + return client; } From ba219f2ec464a26c57fa1528826e9f98df57276c Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Tue, 11 Feb 2025 16:08:50 -0700 Subject: [PATCH 22/29] chore: Move initialization code from static to the instance --- src/index.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9653961..755e3dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,8 +22,6 @@ import { Subject, IBanditLogger, IObfuscatedPrecomputedConfigurationResponse, - buildStorageKeySuffix, - EppoClientParameters, } from '@eppo/js-client-sdk-common'; import { assignmentCacheFactory } from './cache/assignment-cache-factory'; @@ -115,6 +113,9 @@ export class EppoJSClient extends EppoClient { // Ensure that the client is instantiated during class loading. // Use an empty memory-only configuration store until the `init` method is called, // to avoid serving stale data to the user. + /** + * @deprecated. use `getInstance()` instead. + */ public static instance = new EppoJSClient({ flagConfigurationStore: configurationStorageFactory({ forceMemoryOnly: true, @@ -128,7 +129,12 @@ export class EppoJSClient extends EppoClient { */ private readonly initializedPromise: Promise; + + /** + * @deprecated + */ public static initialized = false; + private initialized = false; private constructor(options: EppoClientParameters) { @@ -724,7 +730,7 @@ export class EppoPrecomputedJSClient extends EppoPrecomputedClient { } private static getAssignmentInitializationCheck() { - if (!EppoPrecomputedJSClient.initialized) { + if (!EppoJSClient.initialized) { applicationLogger.warn('Eppo SDK assignment requested before init() completed'); } } From 53335745560de1478c86dba5890a7de14d581d19 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Tue, 11 Feb 2025 16:17:44 -0700 Subject: [PATCH 23/29] docs --- src/index.ts | 259 +++++++++++---------------------------------------- 1 file changed, 53 insertions(+), 206 deletions(-) diff --git a/src/index.ts b/src/index.ts index 755e3dc..7e753fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -159,6 +159,12 @@ export class EppoJSClient extends EppoClient { return client; } + /** + * Initialize the Eppo Client. + * + * @internal + * @param config + */ async init(config: IClientConfig): Promise { validation.validateNotBlank(config.apiKey, 'API key required'); let initializationError: Error | undefined; @@ -370,194 +376,14 @@ export class EppoJSClient extends EppoClient { } /** - * Resolves the `initializedPromise` when initialization is complete + * Initialize the client synchronously, for offline use. * - * Initialization happens outside the constructor, so we can't assign `initializedPromise` to the result - * of initialization. Instead, we call the resolver when `init` is complete. - * @private - */ - private initializedPromiseResolver: () => void = () => null; - - /** - * Resolves when the EppoClient has completed its initialization. - */ - public waitForInitialization(): Promise { - return this.initializedPromise; - } - - public getStringAssignment( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: string, - ): string { - this.ensureInitialized(); - return super.getStringAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); - } - - public getStringAssignmentDetails( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: string, - ): IAssignmentDetails { - this.ensureInitialized(); - return super.getStringAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); - } - - /** - * @deprecated Use getBooleanAssignment instead + * @internal + * @param config */ - public getBoolAssignment( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: boolean, - ): boolean { - return this.getBooleanAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); - } - - public getBooleanAssignment( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: boolean, - ): boolean { - this.ensureInitialized(); - return super.getBooleanAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); - } - - public getBooleanAssignmentDetails( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: boolean, - ): IAssignmentDetails { - this.ensureInitialized(); - return super.getBooleanAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); - } - - public getIntegerAssignment( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: number, - ): number { - this.ensureInitialized(); - return super.getIntegerAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); - } - - public getIntegerAssignmentDetails( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: number, - ): IAssignmentDetails { - this.ensureInitialized(); - return super.getIntegerAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); - } - - public getNumericAssignment( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: number, - ): number { - this.ensureInitialized(); - return super.getNumericAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); - } - - public getNumericAssignmentDetails( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: number, - ): IAssignmentDetails { - this.ensureInitialized(); - return super.getNumericAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); - } - - public getJSONAssignment( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: object, - ): object { - this.ensureInitialized(); - return super.getJSONAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); - } - - public getJSONAssignmentDetails( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: object, - ): IAssignmentDetails { - this.ensureInitialized(); - return super.getJSONAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); - } - - public getBanditAction( - flagKey: string, - subjectKey: string, - subjectAttributes: BanditSubjectAttributes, - actions: BanditActions, - defaultValue: string, - ): Omit, 'evaluationDetails'> { - this.ensureInitialized(); - return super.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, defaultValue); - } - - public getBanditActionDetails( - flagKey: string, - subjectKey: string, - subjectAttributes: BanditSubjectAttributes, - actions: BanditActions, - defaultValue: string, - ): IAssignmentDetails { - this.ensureInitialized(); - return super.getBanditActionDetails( - flagKey, - subjectKey, - subjectAttributes, - actions, - defaultValue, - ); - } - - public getExperimentContainerEntry( - flagExperiment: IContainerExperiment, - subjectKey: string, - subjectAttributes: Record, - ): T { - this.ensureInitialized(); - return super.getExperimentContainerEntry(flagExperiment, subjectKey, subjectAttributes); - } - - private ensureInitialized() { - if (!this.initialized) { - applicationLogger.warn('Eppo SDK assignment requested before init() completed'); - } - } -} - -/** - * Initializes the Eppo client with configuration parameters. - * - * The purpose is for use-cases where the configuration is available from an external process - * that can bootstrap the SDK. - * - * This method should be called once on application startup. - * - * @param config - client configuration - * @returns a singleton client instance - * @public - */ -export function offlineInit(config: IClientConfigSync): EppoClient { - const instance = getInstance(); - - const isObfuscated = config.isObfuscated ?? false; - const throwOnFailedInitialization = config.throwOnFailedInitialization ?? true; + offlineInit(config: IClientConfigSync) { + const isObfuscated = config.isObfuscated ?? false; + const throwOnFailedInitialization = config.throwOnFailedInitialization ?? true; try { const memoryOnlyConfigurationStore = configurationStorageFactory({ @@ -568,43 +394,64 @@ export function offlineInit(config: IClientConfigSync): EppoClient { .catch((err) => applicationLogger.warn('Error setting flags for memory-only configuration store', err), ); - instance.setFlagConfigurationStore(memoryOnlyConfigurationStore); + this.setFlagConfigurationStore(memoryOnlyConfigurationStore); // Allow the caller to override the default obfuscated mode, which is false // since the purpose of this method is to bootstrap the SDK from an external source, // which is likely a server that has not-obfuscated flag values. - instance.setIsObfuscated(isObfuscated); + this.setIsObfuscated(isObfuscated); if (config.assignmentLogger) { - instance.setAssignmentLogger(config.assignmentLogger); + this.setAssignmentLogger(config.assignmentLogger); } if (config.banditLogger) { - instance.setBanditLogger(config.banditLogger); + this.setBanditLogger(config.banditLogger); } // There is no SDK key in the offline context. const storageKeySuffix = 'offline'; - // As this is a synchronous initialization, - // we are unable to call the async `init` method on the assignment cache - // which loads the assignment cache from the browser's storage. - // Therefore, there is no purpose trying to use a persistent assignment cache. - const assignmentCache = assignmentCacheFactory({ - storageKeySuffix, - forceMemoryOnly: true, - }); - instance.useCustomAssignmentCache(assignmentCache); - } catch (error) { - applicationLogger.warn( - 'Eppo SDK encountered an error initializing, assignment calls will return the default value and not be logged', - ); - if (throwOnFailedInitialization) { - throw error; + // As this is a synchronous initialization, + // we are unable to call the async `init` method on the assignment cache + // which loads the assignment cache from the browser's storage. + // Therefore, there is no purpose trying to use a persistent assignment cache. + const assignmentCache = assignmentCacheFactory({ + storageKeySuffix, + forceMemoryOnly: true, + }); + this.useCustomAssignmentCache(assignmentCache); + } catch (error) { + applicationLogger.warn( + 'Eppo SDK encountered an error initializing, assignment calls will return the default value and not be logged', + ); + if (throwOnFailedInitialization) { + throw error; + } } + + this.initialized = true; } +} - // instance.initialized = true; +/** + * Initializes the Eppo client with configuration parameters. + * + * The purpose is for use-cases where the configuration is available from an external process + * that can bootstrap the SDK. + * + * This method should be called once on application startup. + * + * @param config - client configuration + * @returns a singleton client instance + * @public + */ +export function offlineInit(config: IClientConfigSync): EppoClient { + const instance = getInstance(); + instance.offlineInit(config); + + // For backwards compatibility. + EppoJSClient.initialized = true; return instance; } From aab1009c5debc0fcc4913e98b3e7f077ae2a8d4c Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Tue, 11 Feb 2025 20:57:19 -0700 Subject: [PATCH 24/29] lint --- src/index.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7e753fe..a4d9661 100644 --- a/src/index.ts +++ b/src/index.ts @@ -160,10 +160,7 @@ export class EppoJSClient extends EppoClient { } /** - * Initialize the Eppo Client. - * * @internal - * @param config */ async init(config: IClientConfig): Promise { validation.validateNotBlank(config.apiKey, 'API key required'); @@ -376,10 +373,7 @@ export class EppoJSClient extends EppoClient { } /** - * Initialize the client synchronously, for offline use. - * * @internal - * @param config */ offlineInit(config: IClientConfigSync) { const isObfuscated = config.isObfuscated ?? false; @@ -452,6 +446,7 @@ export function offlineInit(config: IClientConfigSync): EppoClient { // For backwards compatibility. EppoJSClient.initialized = true; + return instance; } From f93fa619d35fb11dbc5da3bbd518830b238f4643 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Thu, 13 Feb 2025 12:32:18 -0700 Subject: [PATCH 25/29] docs --- docs/js-client-sdk.eppojsclient.initialized.md | 4 ++++ docs/js-client-sdk.eppojsclient.instance.md | 2 ++ docs/js-client-sdk.eppojsclient.md | 2 ++ 3 files changed, 8 insertions(+) diff --git a/docs/js-client-sdk.eppojsclient.initialized.md b/docs/js-client-sdk.eppojsclient.initialized.md index 4c301f8..c638ca5 100644 --- a/docs/js-client-sdk.eppojsclient.initialized.md +++ b/docs/js-client-sdk.eppojsclient.initialized.md @@ -4,6 +4,10 @@ ## EppoJSClient.initialized property +> Warning: This API is now obsolete. +> +> + **Signature:** ```typescript diff --git a/docs/js-client-sdk.eppojsclient.instance.md b/docs/js-client-sdk.eppojsclient.instance.md index 6d8e963..2c9196e 100644 --- a/docs/js-client-sdk.eppojsclient.instance.md +++ b/docs/js-client-sdk.eppojsclient.instance.md @@ -4,6 +4,8 @@ ## EppoJSClient.instance property +@deprecated. use `getInstance()` instead. + **Signature:** ```typescript diff --git a/docs/js-client-sdk.eppojsclient.md b/docs/js-client-sdk.eppojsclient.md index c571b4b..e7bacd4 100644 --- a/docs/js-client-sdk.eppojsclient.md +++ b/docs/js-client-sdk.eppojsclient.md @@ -104,6 +104,8 @@ boolean +@deprecated. use `getInstance()` instead. + From 030aa332251cfe9644bfdd38da414adfe78cdfac Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 14 Feb 2025 09:08:30 -0700 Subject: [PATCH 26/29] docs --- ...s-client-sdk.eppojsclient._constructor_.md | 47 ------------------- .../js-client-sdk.eppojsclient.initialized.md | 4 -- docs/js-client-sdk.eppojsclient.instance.md | 2 - docs/js-client-sdk.eppojsclient.md | 36 -------------- docs/js-client-sdk.iapioptions.md | 1 - 5 files changed, 90 deletions(-) delete mode 100644 docs/js-client-sdk.eppojsclient._constructor_.md diff --git a/docs/js-client-sdk.eppojsclient._constructor_.md b/docs/js-client-sdk.eppojsclient._constructor_.md deleted file mode 100644 index 9659231..0000000 --- a/docs/js-client-sdk.eppojsclient._constructor_.md +++ /dev/null @@ -1,47 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [EppoJSClient](./js-client-sdk.eppojsclient.md) > [(constructor)](./js-client-sdk.eppojsclient._constructor_.md) - -## EppoJSClient.(constructor) - -Constructs a new instance of the `EppoJSClient` class - -**Signature:** - -```typescript -constructor(options: EppoClientParameters); -``` - -## Parameters - - - -
- -Parameter - - - - -Type - - - - -Description - - -
- -options - - - - -EppoClientParameters - - - - - -
diff --git a/docs/js-client-sdk.eppojsclient.initialized.md b/docs/js-client-sdk.eppojsclient.initialized.md index c638ca5..4c301f8 100644 --- a/docs/js-client-sdk.eppojsclient.initialized.md +++ b/docs/js-client-sdk.eppojsclient.initialized.md @@ -4,10 +4,6 @@ ## EppoJSClient.initialized property -> Warning: This API is now obsolete. -> -> - **Signature:** ```typescript diff --git a/docs/js-client-sdk.eppojsclient.instance.md b/docs/js-client-sdk.eppojsclient.instance.md index 2c9196e..6d8e963 100644 --- a/docs/js-client-sdk.eppojsclient.instance.md +++ b/docs/js-client-sdk.eppojsclient.instance.md @@ -4,8 +4,6 @@ ## EppoJSClient.instance property -@deprecated. use `getInstance()` instead. - **Signature:** ```typescript diff --git a/docs/js-client-sdk.eppojsclient.md b/docs/js-client-sdk.eppojsclient.md index e7bacd4..c4bfaac 100644 --- a/docs/js-client-sdk.eppojsclient.md +++ b/docs/js-client-sdk.eppojsclient.md @@ -13,40 +13,6 @@ export declare class EppoJSClient extends EppoClient ``` **Extends:** EppoClient -## Constructors - - - -
- -Constructor - - - - -Modifiers - - - - -Description - - -
- -[(constructor)(options)](./js-client-sdk.eppojsclient._constructor_.md) - - - - - - - -Constructs a new instance of the `EppoJSClient` class - - -
- ## Properties
@@ -104,8 +70,6 @@ boolean -@deprecated. use `getInstance()` instead. -
diff --git a/docs/js-client-sdk.iapioptions.md b/docs/js-client-sdk.iapioptions.md index 4086587..c2d1921 100644 --- a/docs/js-client-sdk.iapioptions.md +++ b/docs/js-client-sdk.iapioptions.md @@ -12,7 +12,6 @@ Base options for the EppoClient SDK export declare type IApiOptions = { apiKey: string; baseUrl?: string; - forceReinitialize?: boolean; requestTimeoutMs?: number; numInitialRequestRetries?: number; skipInitialRequest?: boolean; From e61ec6cfdb5ec11f05c3bd43c5ca3fe5f4bd9afb Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 14 Feb 2025 09:37:47 -0700 Subject: [PATCH 27/29] merge fixes --- src/index.spec.ts | 10 +++---- src/index.ts | 69 ++++++++++++++++++++++++++++------------------- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 50070f4..073cc61 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -429,12 +429,10 @@ describe('decoupled initialization', () => { const isolatedClient = EppoJSClient.buildAndInit(options); expect(isolatedClient).not.toEqual(getInstance()); - await isolatedClient.waitForInitialization(); + await isolatedClient.waitForConfiguration(); expect(isolatedClient.isInitialized()).toBe(true); - expect(isolatedClient.initialized).toBe(true); expect(getInstance().isInitialized()).toBe(false); - expect(getInstance().initialized).toBe(false); expect(getInstance().getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( 'default-value', @@ -452,7 +450,7 @@ describe('decoupled initialization', () => { 'default-value', ); - await client.waitForInitialization(); + await client.waitForConfiguration(); const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'); expect(assignment).toEqual('variant-1'); @@ -522,7 +520,7 @@ describe('decoupled initialization', () => { expect(callCount).toBe(1); const myClient2 = EppoJSClient.buildAndInit({ ...commonOptions, apiKey: API_KEY_2 }); - await myClient2.waitForInitialization(); + await myClient2.waitForConfiguration(); expect(callCount).toBe(2); expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( @@ -533,7 +531,7 @@ describe('decoupled initialization', () => { ); const myClient3 = EppoJSClient.buildAndInit({ ...commonOptions, apiKey: API_KEY_3 }); - await myClient3.waitForInitialization(); + await myClient3.waitForConfiguration(); expect(singleton.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual( 'variant-1', diff --git a/src/index.ts b/src/index.ts index a4d9661..fbd99eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,12 @@ import { ApiEndpoints, applicationLogger, - AttributeType, - BanditActions, - BanditSubjectAttributes, EppoClient, EventDispatcher, Flag, FlagConfigurationRequestParameters, IAssignmentDetails, IAssignmentLogger, - IContainerExperiment, EppoPrecomputedClient, PrecomputedFlagsRequestParameters, newDefaultEventDispatcher, @@ -22,6 +18,8 @@ import { Subject, IBanditLogger, IObfuscatedPrecomputedConfigurationResponse, + EppoClientParameters, + buildStorageKeySuffix, } from '@eppo/js-client-sdk-common'; import { assignmentCacheFactory } from './cache/assignment-cache-factory'; @@ -129,6 +127,14 @@ export class EppoJSClient extends EppoClient { */ private readonly initializedPromise: Promise; + /** + * Resolves the `initializedPromise` when initialization is complete + * + * Initialization happens outside the constructor, so we can't assign `initializedPromise` to the result + * of initialization. Instead, we call the resolver when `init` is complete. + * @private + */ + private initializedPromiseResolver: () => void = () => null; /** * @deprecated @@ -146,6 +152,13 @@ export class EppoJSClient extends EppoClient { }); } + /** + * Resolves when the EppoClient has completed its initialization. + */ + public waitForConfiguration(): Promise { + return this.initializedPromise; + } + public static buildAndInit(config: IClientConfig): EppoJSClient { const flagConfigurationStore = config.flagConfigurationStore ?? @@ -379,32 +392,32 @@ export class EppoJSClient extends EppoClient { const isObfuscated = config.isObfuscated ?? false; const throwOnFailedInitialization = config.throwOnFailedInitialization ?? true; - try { - const memoryOnlyConfigurationStore = configurationStorageFactory({ - forceMemoryOnly: true, - }); - memoryOnlyConfigurationStore - .setEntries(config.flagsConfiguration) - .catch((err) => - applicationLogger.warn('Error setting flags for memory-only configuration store', err), - ); - this.setFlagConfigurationStore(memoryOnlyConfigurationStore); - - // Allow the caller to override the default obfuscated mode, which is false - // since the purpose of this method is to bootstrap the SDK from an external source, - // which is likely a server that has not-obfuscated flag values. - this.setIsObfuscated(isObfuscated); - - if (config.assignmentLogger) { - this.setAssignmentLogger(config.assignmentLogger); - } + try { + const memoryOnlyConfigurationStore = configurationStorageFactory({ + forceMemoryOnly: true, + }); + memoryOnlyConfigurationStore + .setEntries(config.flagsConfiguration) + .catch((err) => + applicationLogger.warn('Error setting flags for memory-only configuration store', err), + ); + this.setFlagConfigurationStore(memoryOnlyConfigurationStore); + + // Allow the caller to override the default obfuscated mode, which is false + // since the purpose of this method is to bootstrap the SDK from an external source, + // which is likely a server that has not-obfuscated flag values. + this.setIsObfuscated(isObfuscated); + + if (config.assignmentLogger) { + this.setAssignmentLogger(config.assignmentLogger); + } - if (config.banditLogger) { - this.setBanditLogger(config.banditLogger); - } + if (config.banditLogger) { + this.setBanditLogger(config.banditLogger); + } - // There is no SDK key in the offline context. - const storageKeySuffix = 'offline'; + // There is no SDK key in the offline context. + const storageKeySuffix = 'offline'; // As this is a synchronous initialization, // we are unable to call the async `init` method on the assignment cache From a0002051854b5d0c4c4b49d49615f33a862b2623 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 14 Feb 2025 10:03:44 -0700 Subject: [PATCH 28/29] fix the horrible diff and merge artifacts --- src/index.ts | 248 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 197 insertions(+), 51 deletions(-) diff --git a/src/index.ts b/src/index.ts index fbd99eb..24e9eab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,16 @@ import { ApiEndpoints, applicationLogger, + AttributeType, + BanditActions, + BanditSubjectAttributes, EppoClient, EventDispatcher, Flag, FlagConfigurationRequestParameters, IAssignmentDetails, IAssignmentLogger, + IContainerExperiment, EppoPrecomputedClient, PrecomputedFlagsRequestParameters, newDefaultEventDispatcher, @@ -19,7 +23,6 @@ import { IBanditLogger, IObfuscatedPrecomputedConfigurationResponse, EppoClientParameters, - buildStorageKeySuffix, } from '@eppo/js-client-sdk-common'; import { assignmentCacheFactory } from './cache/assignment-cache-factory'; @@ -41,16 +44,7 @@ import { } from './configuration-factory'; import BrowserNetworkStatusListener from './events/browser-network-status-listener'; import LocalStorageBackedNamedEventQueue from './events/local-storage-backed-named-event-queue'; -import { - IApiOptions, - IClientConfig, - ICompatibilityOptions, - IEventOptions, - ILoggers, - IPollingOptions, - IPrecomputedClientConfig, - IStorageOptions, -} from './i-client-config'; +import { IClientConfig, ICompatibilityOptions, IPrecomputedClientConfig } from './i-client-config'; import { sdkName, sdkVersion } from './sdk-data'; /** @@ -69,15 +63,7 @@ export interface IClientConfigSync { throwOnFailedInitialization?: boolean; } -export { - IClientConfig, - IPrecomputedClientConfig, - IApiOptions, - ILoggers, - IEventOptions, - IStorageOptions, - IPollingOptions, -}; +export { IClientConfig, IPrecomputedClientConfig }; // Export the common types and classes from the SDK. export { @@ -99,6 +85,11 @@ export { } from '@eppo/js-client-sdk-common'; export { ChromeStorageEngine } from './chrome-storage-engine'; +// Instantiate the configuration store with memory-only implementation. +const flagConfigurationStore = configurationStorageFactory({ + forceMemoryOnly: true, +}); + // Instantiate the precomputed flags and bandits stores with memory-only implementation. const memoryOnlyPrecomputedFlagsStore = precomputedFlagsStorageFactory(); const memoryOnlyPrecomputedBanditsStore = precomputedBanditStoreFactory(); @@ -115,12 +106,28 @@ export class EppoJSClient extends EppoClient { * @deprecated. use `getInstance()` instead. */ public static instance = new EppoJSClient({ - flagConfigurationStore: configurationStorageFactory({ - forceMemoryOnly: true, - }), + flagConfigurationStore, isObfuscated: true, }); + /** + * @deprecated + */ + public static initialized = false; + + public static buildAndInit(config: IClientConfig): EppoJSClient { + const flagConfigurationStore = + config.flagConfigurationStore ?? + configurationStorageFactory({ + forceMemoryOnly: true, + }); + const client = new EppoJSClient({ flagConfigurationStore }); + + // init will resolve the promise that client.waitForConfiguration returns. + client.init(config); + return client; + } + /** * Resolved when the client is initialized * @private @@ -136,11 +143,6 @@ export class EppoJSClient extends EppoClient { */ private initializedPromiseResolver: () => void = () => null; - /** - * @deprecated - */ - public static initialized = false; - private initialized = false; private constructor(options: EppoClientParameters) { @@ -159,25 +161,168 @@ export class EppoJSClient extends EppoClient { return this.initializedPromise; } - public static buildAndInit(config: IClientConfig): EppoJSClient { - const flagConfigurationStore = - config.flagConfigurationStore ?? - configurationStorageFactory({ - forceMemoryOnly: true, - }); - const client = new EppoJSClient({ flagConfigurationStore }); + public getStringAssignment( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: string, + ): string { + this.ensureInitialized(); + return super.getStringAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); + } - // init will resolve the promise that client.waitForConfiguration returns. - client.init(config); - return client; + public getStringAssignmentDetails( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: string, + ): IAssignmentDetails { + this.ensureInitialized(); + return super.getStringAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); + } + + /** + * @deprecated Use getBooleanAssignment instead + */ + public getBoolAssignment( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: boolean, + ): boolean { + return this.getBooleanAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); + } + + public getBooleanAssignment( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: boolean, + ): boolean { + this.ensureInitialized(); + return super.getBooleanAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); + } + + public getBooleanAssignmentDetails( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: boolean, + ): IAssignmentDetails { + this.ensureInitialized(); + return super.getBooleanAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); + } + + public getIntegerAssignment( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: number, + ): number { + this.ensureInitialized(); + return super.getIntegerAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); + } + + public getIntegerAssignmentDetails( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: number, + ): IAssignmentDetails { + this.ensureInitialized(); + return super.getIntegerAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); + } + + public getNumericAssignment( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: number, + ): number { + this.ensureInitialized(); + return super.getNumericAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); + } + + public getNumericAssignmentDetails( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: number, + ): IAssignmentDetails { + this.ensureInitialized(); + return super.getNumericAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); + } + + public getJSONAssignment( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: object, + ): object { + this.ensureInitialized(); + return super.getJSONAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); + } + + public getJSONAssignmentDetails( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: object, + ): IAssignmentDetails { + this.ensureInitialized(); + return super.getJSONAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); + } + + public getBanditAction( + flagKey: string, + subjectKey: string, + subjectAttributes: BanditSubjectAttributes, + actions: BanditActions, + defaultValue: string, + ): Omit, 'evaluationDetails'> { + this.ensureInitialized(); + return super.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, defaultValue); + } + + public getBanditActionDetails( + flagKey: string, + subjectKey: string, + subjectAttributes: BanditSubjectAttributes, + actions: BanditActions, + defaultValue: string, + ): IAssignmentDetails { + this.ensureInitialized(); + return super.getBanditActionDetails( + flagKey, + subjectKey, + subjectAttributes, + actions, + defaultValue, + ); + } + + public getExperimentContainerEntry( + flagExperiment: IContainerExperiment, + subjectKey: string, + subjectAttributes: Record, + ): T { + this.ensureInitialized(); + return super.getExperimentContainerEntry(flagExperiment, subjectKey, subjectAttributes); + } + + private ensureInitialized() { + if (!this.initialized) { + applicationLogger.warn('Eppo SDK assignment requested before init() completed'); + } } /** * @internal */ - async init(config: IClientConfig): Promise { + async init(config: Omit): Promise { validation.validateNotBlank(config.apiKey, 'API key required'); let initializationError: Error | undefined; + const { apiKey, persistentStore, @@ -193,9 +338,8 @@ export class EppoJSClient extends EppoClient { skipInitialRequest = false, eventIngestionConfig, } = config; - try { - // If the instance was polling, stop. + // If any existing instances; ensure they are not polling this.stopPolling(); // Set up assignment logger and cache this.setAssignmentLogger(config.assignmentLogger); @@ -441,6 +585,17 @@ export class EppoJSClient extends EppoClient { } } +/** + * Builds a storage key suffix from an API key. + * @param apiKey - The API key to build the suffix from + * @returns A string suffix for storage keys + * @public + */ +export function buildStorageKeySuffix(apiKey: string): string { + // Note that we use the first 8 characters of the API key to create per-API key persistent storages and caches + return apiKey.replace(/\W/g, '').substring(0, 8); +} + /** * Initializes the Eppo client with configuration parameters. * @@ -464,21 +619,13 @@ export function offlineInit(config: IClientConfigSync): EppoClient { } /** - * Tracks pending initialization. After an initialization completes, this value is nulled + * Tracks pending initialization. After an initialization completes, this value is set to null */ let initializationPromise: Promise | null = null; /** * Initializes the Eppo client with configuration parameters. * This method should be called once on application startup. - * If an initialization is in process, calling `init` will return the in-progress - * `Promise`. Once the initialization completes, calling `init` again will kick off the - * initialization routine (if `forceReinitialize` is `true`). - * - * - * @deprecated - * Use `EppoJSClient.createAndInit` instead of `init` and `getInstance`. These will be removed in v4 - * * @param config - client configuration * @public */ @@ -521,7 +668,6 @@ export async function init(config: IClientConfig & ICompatibilityOptions): Promi * Use the method after calling init() to initialize the client. * @returns a singleton client instance * @public - * @deprecated */ export function getInstance(): EppoJSClient { return EppoJSClient.instance; From 6f1a68e7c4596f1c45a6135f6303961b05e8f090 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 14 Feb 2025 10:03:51 -0700 Subject: [PATCH 29/29] docs --- docs/js-client-sdk.init.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/js-client-sdk.init.md b/docs/js-client-sdk.init.md index 1184923..4f11349 100644 --- a/docs/js-client-sdk.init.md +++ b/docs/js-client-sdk.init.md @@ -14,7 +14,7 @@ Initializes the Eppo client with configuration parameters. This method should be **Signature:** ```typescript -export declare function init(config: IClientConfig): Promise; +export declare function init(config: IClientConfig & ICompatibilityOptions): Promise; ``` ## Parameters @@ -42,7 +42,7 @@ config -[IClientConfig](./js-client-sdk.iclientconfig.md) +[IClientConfig](./js-client-sdk.iclientconfig.md) & ICompatibilityOptions @@ -54,5 +54,5 @@ client configuration **Returns:** -Promise<EppoClient> +Promise<[EppoJSClient](./js-client-sdk.eppojsclient.md)>