diff --git a/jest.config.ts b/jest.config.ts index d0b7d3f..41da99e 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -5,6 +5,7 @@ const jestConfig = { '^src/(.*)': ['/src/$1'], '^test/(.*)': ['/test/$1'], '@eppo(.*)': '/node_modules/@eppo/$1', + '^uuid$': '/node_modules/uuid/dist/index.js', }, testRegex: '.*\\..*spec\\.ts$', transform: { diff --git a/package.json b/package.json index 4a5959e..66ee672 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "webpack-cli": "^4.10.0" }, "dependencies": { - "@eppo/js-client-sdk-common": "4.3.0" + "@eppo/js-client-sdk-common": "^4.5.0" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/events/browser-network-status-listener.spec.ts b/src/events/browser-network-status-listener.spec.ts new file mode 100644 index 0000000..d39d654 --- /dev/null +++ b/src/events/browser-network-status-listener.spec.ts @@ -0,0 +1,140 @@ +import BrowserNetworkStatusListener from './browser-network-status-listener'; + +describe('BrowserNetworkStatusListener', () => { + let originalNavigator: Navigator; + let originalWindow: Window; + + beforeEach(() => { + // Save original references + originalNavigator = global.navigator; + originalWindow = global.window; + + // Mock `navigator.onLine` + Object.defineProperty(global, 'navigator', { + value: { onLine: true }, + writable: true, + }); + + const listeners: Map void> = new Map(); + Object.defineProperty(global, 'window', { + value: { + addEventListener: (evt: string, fn: () => void) => { + listeners.set(evt, fn); + }, + removeEventListener: () => null, + dispatchEvent: (event: Event) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const listener = listeners.get(event.type)!; + listener(event.type === 'offline'); + }, + }, + writable: true, + }); + }); + + afterEach(() => { + // Restore original references + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore test code + // noinspection JSConstantReassignment + global.navigator = originalNavigator; + // noinspection JSConstantReassignment + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore test code + // noinspection JSConstantReassignment + global.window = originalWindow; + + jest.clearAllMocks(); + }); + + test('throws an error if instantiated outside a browser environment', () => { + Object.defineProperty(global, 'window', { value: undefined }); + + expect(() => new BrowserNetworkStatusListener()).toThrow( + 'BrowserNetworkStatusListener can only be used in a browser environment', + ); + }); + + test('correctly initializes offline state based on navigator.onLine', () => { + // Online state + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore test code + // noinspection JSConstantReassignment + navigator.onLine = true; + const listener = new BrowserNetworkStatusListener(); + expect(listener.isOffline()).toBe(false); + + // Offline state + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // noinspection JSConstantReassignment + navigator.onLine = false; + const offlineListener = new BrowserNetworkStatusListener(); + expect(offlineListener.isOffline()).toBe(true); + }); + + test('notifies listeners when offline event is triggered', async () => { + const listener = new BrowserNetworkStatusListener(); + const mockCallback = jest.fn(); + + listener.onNetworkStatusChange(mockCallback); + + // Simulate offline event + const offlineEvent = new Event('offline'); + window.dispatchEvent(offlineEvent); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(mockCallback).toHaveBeenCalledWith(true); + }); + + test('notifies listeners when online event is triggered', async () => { + const listener = new BrowserNetworkStatusListener(); + const mockCallback = jest.fn(); + + listener.onNetworkStatusChange(mockCallback); + + // Simulate online event + const onlineEvent = new Event('online'); + window.dispatchEvent(onlineEvent); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(mockCallback).toHaveBeenCalledWith(false); + }); + + test('removes listeners and does not notify them after removal', () => { + const listener = new BrowserNetworkStatusListener(); + const mockCallback = jest.fn(); + + listener.onNetworkStatusChange(mockCallback); + listener.removeNetworkStatusChange(mockCallback); + + // Simulate offline event + const offlineEvent = new Event('offline'); + window.dispatchEvent(offlineEvent); + + expect(mockCallback).not.toHaveBeenCalled(); + }); + + test('debounces notifications for rapid online/offline changes', () => { + jest.useFakeTimers(); + const listener = new BrowserNetworkStatusListener(); + const mockCallback = jest.fn(); + + listener.onNetworkStatusChange(mockCallback); + + // Simulate rapid online/offline changes + const offlineEvent = new Event('offline'); + const onlineEvent = new Event('online'); + window.dispatchEvent(offlineEvent); + window.dispatchEvent(onlineEvent); + + // Fast-forward time by less than debounce duration + jest.advanceTimersByTime(100); + + expect(mockCallback).not.toHaveBeenCalled(); + + // Fast-forward time past debounce duration + jest.advanceTimersByTime(200); + + expect(mockCallback).toHaveBeenCalledWith(false); // Online state + jest.useRealTimers(); + }); +}); diff --git a/src/events/browser-network-status-listener.ts b/src/events/browser-network-status-listener.ts new file mode 100644 index 0000000..f985603 --- /dev/null +++ b/src/events/browser-network-status-listener.ts @@ -0,0 +1,46 @@ +import { NetworkStatusListener } from '@eppo/js-client-sdk-common'; + +const debounceDurationMs = 200; + +/** A NetworkStatusListener that listens for online/offline events in the browser. */ +export default class BrowserNetworkStatusListener implements NetworkStatusListener { + private readonly listeners: ((isOffline: boolean) => void)[] = []; + private _isOffline: boolean; + private debounceTimer: NodeJS.Timeout | null = null; + + constructor() { + if (typeof window === 'undefined') { + throw new Error('BrowserNetworkStatusListener can only be used in a browser environment'); + } + // guard against navigator API not being available (oder browsers) + // noinspection SuspiciousTypeOfGuard + this._isOffline = typeof navigator.onLine === 'boolean' ? !navigator.onLine : false; + window.addEventListener('offline', () => this.notifyListeners(true)); + window.addEventListener('online', () => this.notifyListeners(false)); + } + + isOffline(): boolean { + return this._isOffline; + } + + onNetworkStatusChange(callback: (isOffline: boolean) => void): void { + this.listeners.push(callback); + } + + removeNetworkStatusChange(callback: (isOffline: boolean) => void): void { + const index = this.listeners.indexOf(callback); + if (index !== -1) { + this.listeners.splice(index, 1); + } + } + + private notifyListeners(isOffline: boolean): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => { + this._isOffline = isOffline; + [...this.listeners].forEach((listener) => listener(isOffline)); + }, debounceDurationMs); + } +} diff --git a/src/events/local-storage-backed-named-event-queue.spec.ts b/src/events/local-storage-backed-named-event-queue.spec.ts new file mode 100644 index 0000000..07609a5 --- /dev/null +++ b/src/events/local-storage-backed-named-event-queue.spec.ts @@ -0,0 +1,86 @@ +/** + * @jest-environment jsdom + */ + +import LocalStorageBackedNamedEventQueue from './local-storage-backed-named-event-queue'; + +describe('LocalStorageBackedNamedEventQueue', () => { + const queueName = 'testQueue'; + let queue: LocalStorageBackedNamedEventQueue; + + beforeEach(() => { + localStorage.clear(); + queue = new LocalStorageBackedNamedEventQueue(queueName); + }); + + it('should initialize with an empty queue', () => { + expect(queue.length).toBe(0); + }); + + it('should persist and retrieve events correctly via push and iterator', () => { + queue.push('event1'); + queue.push('event2'); + + expect(queue.length).toBe(2); + + const events = Array.from(queue); + expect(events).toEqual(['event1', 'event2']); + }); + + it('should persist and retrieve events correctly via push and shift', () => { + queue.push('event1'); + queue.push('event2'); + + const firstEvent = queue.shift(); + expect(firstEvent).toBe('event1'); + expect(queue.length).toBe(1); + + const secondEvent = queue.shift(); + expect(secondEvent).toBe('event2'); + expect(queue.length).toBe(0); + }); + + it('should remove events from localStorage after shift', () => { + queue.push('event1'); + const eventKey = Object.keys(localStorage).find( + (key) => key.includes(queueName) && localStorage.getItem(key)?.includes('event1'), + ); + + expect(eventKey).toBeDefined(); + queue.shift(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(localStorage.getItem(eventKey!)).toBeNull(); + }); + + it('should reconstruct the queue from localStorage', () => { + queue.push('event1'); + queue.push('event2'); + + const newQueueInstance = new LocalStorageBackedNamedEventQueue(queueName); + expect(newQueueInstance.length).toBe(2); + + const events = Array.from(newQueueInstance); + expect(events).toEqual(['event1', 'event2']); + }); + + it('should handle empty shift gracefully', () => { + expect(queue.shift()).toBeUndefined(); + }); + + it('should not fail if localStorage state is corrupted', () => { + localStorage.setItem(`eventQueue:${queueName}`, '{ corrupted state }'); + + const newQueueInstance = new LocalStorageBackedNamedEventQueue(queueName); + expect(newQueueInstance.length).toBe(0); + }); + + it('should handle events with the same content correctly using consistent hashing', () => { + queue.push('event1'); + queue.push('event1'); // Push the same event content twice + + expect(queue.length).toBe(2); + + const events = Array.from(queue); + expect(events).toEqual(['event1', 'event1']); + }); +}); diff --git a/src/events/local-storage-backed-named-event-queue.ts b/src/events/local-storage-backed-named-event-queue.ts new file mode 100644 index 0000000..253bafc --- /dev/null +++ b/src/events/local-storage-backed-named-event-queue.ts @@ -0,0 +1,93 @@ +import { applicationLogger } from '@eppo/js-client-sdk-common'; +import NamedEventQueue from '@eppo/js-client-sdk-common/dist/events/named-event-queue'; + +import { takeWhile } from '../util'; + +/** A localStorage-backed NamedEventQueue. */ +export default class LocalStorageBackedNamedEventQueue implements NamedEventQueue { + private readonly localStorageKey: string; + private eventKeys: string[] = []; + + constructor(public readonly name: string) { + this.localStorageKey = `eventQueue:${this.name}`; + this.loadStateFromLocalStorage(); + } + + splice(count: number): T[] { + const arr = Array.from({ length: count }, () => this.shift()); + return takeWhile(arr, (item) => item !== undefined) as T[]; + } + + isEmpty(): boolean { + return this.length === 0; + } + + get length(): number { + return this.eventKeys.length; + } + + push(event: T): void { + const eventKey = this.generateEventKey(event); + const serializedEvent = JSON.stringify(event); + localStorage.setItem(eventKey, serializedEvent); + this.eventKeys.push(eventKey); + this.saveStateToLocalStorage(); + } + + *[Symbol.iterator](): IterableIterator { + for (const key of this.eventKeys) { + const eventData = localStorage.getItem(key); + if (eventData) { + yield JSON.parse(eventData); + } + } + } + + shift(): T | undefined { + if (this.eventKeys.length === 0) { + return undefined; + } + const eventKey = this.eventKeys.shift()!; + const eventData = localStorage.getItem(eventKey); + if (eventData) { + localStorage.removeItem(eventKey); + this.saveStateToLocalStorage(); + return JSON.parse(eventData); + } + return undefined; + } + + private loadStateFromLocalStorage(): void { + const serializedState = localStorage.getItem(this.localStorageKey); + if (serializedState) { + try { + this.eventKeys = JSON.parse(serializedState); + } catch { + applicationLogger.error( + `Failed to parse event queue ${this.name} state. Initializing empty queue.`, + ); + this.eventKeys = []; + } + } + } + + private saveStateToLocalStorage(): void { + const serializedState = JSON.stringify(this.eventKeys); + localStorage.setItem(this.localStorageKey, serializedState); + } + + private generateEventKey(event: T): string { + const hash = this.hashEvent(event); + return `eventQueue:${this.name}:${hash}`; + } + + private hashEvent(event: T): string { + const serializedEvent = JSON.stringify(event); + let hash = 0; + for (let i = 0; i < serializedEvent.length; i++) { + hash = (hash << 5) - hash + serializedEvent.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } + return hash.toString(36); + } +} diff --git a/src/i-client-config.ts b/src/i-client-config.ts new file mode 100644 index 0000000..d21a408 --- /dev/null +++ b/src/i-client-config.ts @@ -0,0 +1,102 @@ +import { Flag, IAssignmentLogger, IAsyncStore } from '@eppo/js-client-sdk-common'; + +import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store'; + +/** + * Configuration used for initializing the Eppo client + * @public + */ +export interface IClientConfig { + /** + * Eppo API key + */ + apiKey: string; + + /** + * Base URL of the Eppo API. + * Clients should use the default setting in most cases. + */ + baseUrl?: string; + + /** + * Pass a logging implementation to send variation assignments to your data warehouse. + */ + assignmentLogger: IAssignmentLogger; + + /*** + * 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; + + /** + * Throw an error if unable to fetch an initial configuration during initialization. (default: true) + */ + throwOnFailedInitialization?: boolean; + + /** + * Poll for new configurations even if the initial configuration request failed. (default: false) + */ + pollAfterFailedInitialization?: boolean; + + /** + * 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; + + /** + * Skip the request for new configurations during initialization. (default: false) + */ + skipInitialRequest?: boolean; + + /** + * Maximum age, in seconds, previously cached values are considered valid until new values will be + * fetched (default: 0) + */ + maxCacheAgeSeconds?: number; + + /** + * 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) + */ + useExpiredCache?: boolean; + + /** + * 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 + */ + updateOnFetch?: ServingStoreUpdateStrategy; + + /** + * 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. + */ + persistentStore?: IAsyncStore; + + /** + * Force reinitialize the SDK if it is already initialized. + */ + forceReinitialize?: boolean; +} diff --git a/src/index.spec.ts b/src/index.spec.ts index a757247..c548689 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -5,38 +5,32 @@ import { createHash } from 'crypto'; import { - Flag, - VariationType, + AssignmentCache, constants, + EppoClient, + Flag, HybridConfigurationStore, IAsyncStore, - AssignmentCache, - EppoClient, + VariationType, } from '@eppo/js-client-sdk-common'; import * as td from 'testdouble'; -const { DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } = constants; - import { + getTestAssignments, IAssignmentTestCase, - readAssignmentTestData, - readMockUfcResponse, MOCK_UFC_RESPONSE_FILE, OBFUSCATED_MOCK_UFC_RESPONSE_FILE, - getTestAssignments, + readAssignmentTestData, + readMockUfcResponse, validateTestAssignments, } from '../test/testHelpers'; +import { IClientConfig } from './i-client-config'; import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store'; -import { - offlineInit, - IAssignmentLogger, - getInstance, - init, - IClientConfig, - getConfigUrl, -} from './index'; +import { getConfigUrl, getInstance, IAssignmentLogger, init, offlineInit } from './index'; + +const { DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } = constants; function md5Hash(input: string): string { return createHash('md5').update(input).digest('hex'); @@ -47,7 +41,7 @@ function base64Encode(input: string): string { } // Configuration for a single flag within the UFC. -const apiKey = 'dummy'; +const apiKey = 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk'; const baseUrl = 'http://127.0.0.1:4000'; const flagKey = 'mock-experiment'; diff --git a/src/index.ts b/src/index.ts index 60a2d76..8ff50f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,20 @@ import { - IAssignmentLogger, - validation, - EppoClient, - FlagConfigurationRequestParameters, - Flag, - IAsyncStore, - AttributeType, - ObfuscatedFlag, ApiEndpoints, applicationLogger, - IAssignmentDetails, + AttributeType, BanditActions, BanditSubjectAttributes, + EppoClient, + EventDispatcher, + Flag, + FlagConfigurationRequestParameters, + IAssignmentDetails, + IAssignmentLogger, IContainerExperiment, + newDefaultEventDispatcher, + ObfuscatedFlag, + BoundedEventQueue, + validation, } from '@eppo/js-client-sdk-common'; import { assignmentCacheFactory } from './cache/assignment-cache-factory'; @@ -24,108 +26,11 @@ import { hasWindowLocalStorage, localStorageIfAvailable, } from './configuration-factory'; -import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store'; +import BrowserNetworkStatusListener from './events/browser-network-status-listener'; +import LocalStorageBackedNamedEventQueue from './events/local-storage-backed-named-event-queue'; +import { IClientConfig } from './i-client-config'; import { sdkName, sdkVersion } from './sdk-data'; -/** - * Configuration used for initializing the Eppo client - * @public - */ -export interface IClientConfig { - /** - * Eppo API key - */ - apiKey: string; - - /** - * Base URL of the Eppo API. - * Clients should use the default setting in most cases. - */ - baseUrl?: string; - - /** - * Pass a logging implementation to send variation assignments to your data warehouse. - */ - assignmentLogger: IAssignmentLogger; - - /*** - * 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; - - /** - * Throw an error if unable to fetch an initial configuration during initialization. (default: true) - */ - throwOnFailedInitialization?: boolean; - - /** - * Poll for new configurations even if the initial configuration request failed. (default: false) - */ - pollAfterFailedInitialization?: boolean; - - /** - * 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; - - /** - * Skip the request for new configurations during initialization. (default: false) - */ - skipInitialRequest?: boolean; - - /** - * Maximum age, in seconds, previously cached values are considered valid until new values will be - * fetched (default: 0) - */ - maxCacheAgeSeconds?: number; - - /** - * 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) - */ - useExpiredCache?: boolean; - - /** - * 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 - */ - updateOnFetch?: ServingStoreUpdateStrategy; - - /** - * 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. - */ - persistentStore?: IAsyncStore; - - /** - * Force reinitialize the SDK if it is already initialized. - */ - forceReinitialize?: boolean; -} - export interface IClientConfigSync { flagsConfiguration: Record; @@ -136,6 +41,8 @@ export interface IClientConfigSync { throwOnFailedInitialization?: boolean; } +export { IClientConfig }; + // Export the common types and classes from the SDK. export { IAssignmentDetails, @@ -160,13 +67,10 @@ 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: EppoJSClient = new EppoJSClient( + public static instance = new EppoJSClient({ flagConfigurationStore, - undefined, - undefined, - undefined, - true, - ); + isObfuscated: true, + }); public static initialized = false; public getStringAssignment( @@ -175,7 +79,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: string, ): string { - EppoJSClient.getAssignmentInitializationCheck(); + EppoJSClient.ensureInitialized(); return super.getStringAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -185,7 +89,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: string, ): IAssignmentDetails { - EppoJSClient.getAssignmentInitializationCheck(); + EppoJSClient.ensureInitialized(); return super.getStringAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -207,7 +111,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: boolean, ): boolean { - EppoJSClient.getAssignmentInitializationCheck(); + EppoJSClient.ensureInitialized(); return super.getBooleanAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -217,7 +121,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: boolean, ): IAssignmentDetails { - EppoJSClient.getAssignmentInitializationCheck(); + EppoJSClient.ensureInitialized(); return super.getBooleanAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -227,7 +131,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: number, ): number { - EppoJSClient.getAssignmentInitializationCheck(); + EppoJSClient.ensureInitialized(); return super.getIntegerAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -237,7 +141,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: number, ): IAssignmentDetails { - EppoJSClient.getAssignmentInitializationCheck(); + EppoJSClient.ensureInitialized(); return super.getIntegerAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -247,7 +151,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: number, ): number { - EppoJSClient.getAssignmentInitializationCheck(); + EppoJSClient.ensureInitialized(); return super.getNumericAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -257,7 +161,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: number, ): IAssignmentDetails { - EppoJSClient.getAssignmentInitializationCheck(); + EppoJSClient.ensureInitialized(); return super.getNumericAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -267,7 +171,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: object, ): object { - EppoJSClient.getAssignmentInitializationCheck(); + EppoJSClient.ensureInitialized(); return super.getJSONAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -277,7 +181,7 @@ export class EppoJSClient extends EppoClient { subjectAttributes: Record, defaultValue: object, ): IAssignmentDetails { - EppoJSClient.getAssignmentInitializationCheck(); + EppoJSClient.ensureInitialized(); return super.getJSONAssignmentDetails(flagKey, subjectKey, subjectAttributes, defaultValue); } @@ -288,7 +192,7 @@ export class EppoJSClient extends EppoClient { actions: BanditActions, defaultValue: string, ): Omit, 'evaluationDetails'> { - EppoJSClient.getAssignmentInitializationCheck(); + EppoJSClient.ensureInitialized(); return super.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, defaultValue); } @@ -299,7 +203,7 @@ export class EppoJSClient extends EppoClient { actions: BanditActions, defaultValue: string, ): IAssignmentDetails { - EppoJSClient.getAssignmentInitializationCheck(); + EppoJSClient.ensureInitialized(); return super.getBanditActionDetails( flagKey, subjectKey, @@ -314,11 +218,11 @@ export class EppoJSClient extends EppoClient { subjectKey: string, subjectAttributes: Record, ): T { - EppoJSClient.getAssignmentInitializationCheck(); + EppoJSClient.ensureInitialized(); return super.getExperimentContainerEntry(flagExperiment, subjectKey, subjectAttributes); } - private static getAssignmentInitializationCheck() { + private static ensureInitialized() { if (!EppoJSClient.initialized) { applicationLogger.warn('Eppo SDK assignment requested before init() completed'); } @@ -484,6 +388,7 @@ export async function init(config: IClientConfig): Promise { skipInitialPoll: skipInitialRequest, }; instance.setConfigurationRequestParameters(requestConfiguration); + instance.setEventDispatcher(newEventDispatcher(apiKey)); // 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 @@ -581,3 +486,15 @@ export function getConfigUrl(apiKey: string, baseUrl?: string): URL { const queryParams = { sdkName, sdkVersion, apiKey }; return new ApiEndpoints({ baseUrl, queryParams }).ufcEndpoint(); } + +function newEventDispatcher(sdkKey: string): EventDispatcher { + const eventQueue = hasWindowLocalStorage() + ? new LocalStorageBackedNamedEventQueue('events') + : new BoundedEventQueue('events'); + const emptyNetworkStatusListener = + // eslint-disable-next-line @typescript-eslint/no-empty-function + { isOffline: () => false, onNetworkStatusChange: () => {} }; + const networkStatusListener = + typeof window !== 'undefined' ? new BrowserNetworkStatusListener() : emptyNetworkStatusListener; + return newDefaultEventDispatcher(eventQueue, networkStatusListener, sdkKey); +} diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..b138281 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,11 @@ +/* Returns elements from arr until the predicate returns false. */ +export function takeWhile(arr: T[], predicate: (item: T) => boolean): T[] { + const result = []; + for (const item of arr) { + if (!predicate(item)) { + break; + } + result.push(item); + } + return result; +} diff --git a/tsconfig.json b/tsconfig.json index 50b3822..5ac87b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "target": "es2017", "outDir": "dist", "noImplicitAny": true, - "strict": true + "strict": true, + "skipLibCheck": true }, "include": [ "src/**/*.ts" diff --git a/yarn.lock b/yarn.lock index 7cc5446..23fc088 100644 --- a/yarn.lock +++ b/yarn.lock @@ -380,15 +380,16 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== -"@eppo/js-client-sdk-common@4.3.0": - version "4.3.0" - resolved "https://registry.yarnpkg.com/@eppo/js-client-sdk-common/-/js-client-sdk-common-4.3.0.tgz#66c0e5904091ac1a9c2bc3bf4017637b13404ce8" - integrity sha512-ur270vCZjUuKuEohF1vH7yh1MBxtDbVcduCJzJmJ6m7kjoyvqNPzG/+lYPEol6Bpr9wV42ciIB+A1cYeNZ7gSA== +"@eppo/js-client-sdk-common@^4.5.0": + version "4.5.0" + resolved "https://registry.yarnpkg.com/@eppo/js-client-sdk-common/-/js-client-sdk-common-4.5.0.tgz#8a1745c3d162b882fc57b4bb3428ce3f9c6755fc" + integrity sha512-G9WBt+fz8SIwhIQ8fz4fPL6d1UMCCC0uPR99TQGG9NEREEcyfML+Di1/5cCIeNvTSlxsMLtSa1C+I0tMQAg21g== dependencies: js-base64 "^3.7.7" md5 "^2.3.0" pino "^8.19.0" semver "^7.5.4" + uuid "^8.3.2" "@eslint/eslintrc@^1.3.0": version "1.3.0" @@ -4710,6 +4711,11 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"