diff --git a/.eslintrc.js b/.eslintrc.js index 86eea84..cd06049 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,6 +24,7 @@ module.exports = { ], 'import/named': 'off', 'import/no-unresolved': 'off', + '@typescript-eslint/no-explicit-any': 'off', 'import/order': [ 'warn', { @@ -33,7 +34,7 @@ module.exports = { group: 'parent', position: 'before', }, - ], + ], groups: ['builtin', 'external', 'parent', 'sibling', 'index'], 'newlines-between': 'always', alphabetize: { diff --git a/package.json b/package.json index 1005c46..87d3c96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk-common", - "version": "4.3.0", + "version": "4.4.0", "description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)", "main": "dist/index.js", "files": [ @@ -46,6 +46,7 @@ "@types/lodash": "^4.17.5", "@types/md5": "^2.3.2", "@types/semver": "^7.5.6", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^5.13.0", "@typescript-eslint/parser": "^5.13.0", "eslint": "^8.17.0", @@ -71,7 +72,8 @@ "js-base64": "^3.7.7", "md5": "^2.3.0", "pino": "^8.19.0", - "semver": "^7.5.4" + "semver": "^7.5.4", + "uuid": "^8.3.2" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 5c6e326..b9340bd 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -1,3 +1,5 @@ +import { v4 as randomUUID } from 'uuid'; + import ApiEndpoints from '../api-endpoints'; import { logger } from '../application-logger'; import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; @@ -21,7 +23,8 @@ import { EppoValue } from '../eppo_value'; import { Evaluator, FlagEvaluation, noneResult } from '../evaluator'; import ArrayBackedNamedEventQueue from '../events/array-backed-named-event-queue'; import { BoundedEventQueue } from '../events/bounded-event-queue'; -import NamedEventQueue from '../events/named-event-queue'; +import EventDispatcher from '../events/event-dispatcher'; +import NoOpEventDispatcher from '../events/no-op-event-dispatcher'; import { FlagEvaluationDetailsBuilder, IFlagEvaluationDetails, @@ -77,8 +80,17 @@ export interface IContainerExperiment { treatmentVariationEntries: Array; } +const DEFAULT_EVENT_DISPATCHER_CONFIG = { + // TODO: Replace with actual ingestion URL + ingestionUrl: 'https://example.com/events', + batchSize: 10, + flushIntervalMs: 10_000, + retryIntervalMs: 5_000, + maxRetries: 3, +}; + export default class EppoClient { - private readonly eventQueue: NamedEventQueue; + private readonly eventDispatcher: EventDispatcher; private readonly assignmentEventsQueue: BoundedEventQueue = newBoundedArrayEventQueue('assignments'); private readonly banditEventsQueue: BoundedEventQueue = @@ -99,23 +111,23 @@ export default class EppoClient { private readonly evaluator = new Evaluator(); constructor({ - eventQueue = new ArrayBackedNamedEventQueue('events'), + eventDispatcher = new NoOpEventDispatcher(), isObfuscated = false, flagConfigurationStore, banditVariationConfigurationStore, banditModelConfigurationStore, configurationRequestParameters, }: { - // Queue for arbitrary, application-level events (not to be confused with Eppo specific assignment + // 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. - eventQueue?: NamedEventQueue; + eventDispatcher?: EventDispatcher; flagConfigurationStore: IConfigurationStore; banditVariationConfigurationStore?: IConfigurationStore; banditModelConfigurationStore?: IConfigurationStore; configurationRequestParameters?: FlagConfigurationRequestParameters; isObfuscated?: boolean; }) { - this.eventQueue = eventQueue; + this.eventDispatcher = eventDispatcher; this.flagConfigurationStore = flagConfigurationStore; this.banditVariationConfigurationStore = banditVariationConfigurationStore; this.banditModelConfigurationStore = banditModelConfigurationStore; @@ -909,9 +921,10 @@ export default class EppoClient { return result; } + /** TODO */ // noinspection JSUnusedGlobalSymbols track(event: unknown, params: Record) { - this.eventQueue.push({ event, params }); + this.eventDispatcher.dispatch({ id: randomUUID(), data: event, params }); } private newFlagEvaluationDetailsBuilder(flagKey: string): FlagEvaluationDetailsBuilder { @@ -929,7 +942,9 @@ export default class EppoClient { return { configFetchedAt: this.flagConfigurationStore.getConfigFetchedAt() ?? '', configPublishedAt: this.flagConfigurationStore.getConfigPublishedAt() ?? '', - configEnvironment: this.flagConfigurationStore.getEnvironment() ?? { name: '' }, + configEnvironment: this.flagConfigurationStore.getEnvironment() ?? { + name: '', + }, }; } @@ -1128,6 +1143,6 @@ export function checkValueTypeMatch( } } -export function newBoundedArrayEventQueue(name: string): BoundedEventQueue { +function newBoundedArrayEventQueue(name: string): BoundedEventQueue { return new BoundedEventQueue(new ArrayBackedNamedEventQueue(name)); } diff --git a/src/evaluator.ts b/src/evaluator.ts index 54ee80a..f685ef8 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -159,7 +159,10 @@ export class Evaluator { split: Split, subjectKey: string, expectedVariationType: VariationType | undefined, - ): { flagEvaluationCode: FlagEvaluationCode; flagEvaluationDescription: string } => { + ): { + flagEvaluationCode: FlagEvaluationCode; + flagEvaluationDescription: string; + } => { if (!checkValueTypeMatch(expectedVariationType, variation.value)) { const { key: vKey, value: vValue } = variation; return { diff --git a/src/events/array-backed-named-event-queue.ts b/src/events/array-backed-named-event-queue.ts index 9a0dec3..15d5682 100644 --- a/src/events/array-backed-named-event-queue.ts +++ b/src/events/array-backed-named-event-queue.ts @@ -1,6 +1,11 @@ import NamedEventQueue from './named-event-queue'; -/** A named event queue backed by an array. */ +/** + * @internal + * A named event queue backed by an **unbounded** array. + * This class probably should NOT be used directly, but only as a backing store for + * {@link BoundedEventQueue}. + */ export default class ArrayBackedNamedEventQueue implements NamedEventQueue { private readonly events: T[] = []; @@ -22,7 +27,11 @@ export default class ArrayBackedNamedEventQueue implements NamedEventQueue return this.events[Symbol.iterator](); } - shift(): T | undefined { - return this.events.shift(); + splice(count: number): T[] { + return this.events.splice(0, count); + } + + isEmpty(): boolean { + return this.events.length === 0; } } diff --git a/src/events/batch-event-processor.spec.ts b/src/events/batch-event-processor.spec.ts new file mode 100644 index 0000000..b7df548 --- /dev/null +++ b/src/events/batch-event-processor.spec.ts @@ -0,0 +1,24 @@ +import ArrayBackedNamedEventQueue from './array-backed-named-event-queue'; +import BatchEventProcessor from './batch-event-processor'; + +describe('BatchEventProcessor', () => { + describe('nextBatch', () => { + it('should return a batch and remove items from the queue', () => { + const eventQueue = new ArrayBackedNamedEventQueue('test-queue'); + const processor = new BatchEventProcessor(eventQueue, 2); + expect(processor.isEmpty()).toBeTruthy(); + expect(processor.nextBatch()).toHaveLength(0); + processor.push({ id: 'foo-1', data: 'event1', params: {} }); + processor.push({ id: 'foo-2', data: 'event2', params: {} }); + processor.push({ id: 'foo-3', data: 'event3', params: {} }); + expect(processor.isEmpty()).toBeFalsy(); + const batch = processor.nextBatch(); + expect(batch).toEqual([ + { id: 'foo-1', data: 'event1', params: {} }, + { id: 'foo-2', data: 'event2', params: {} }, + ]); + expect(processor.nextBatch()).toEqual([{ id: 'foo-3', data: 'event3', params: {} }]); + expect(processor.isEmpty()).toBeTruthy(); + }); + }); +}); diff --git a/src/events/batch-event-processor.ts b/src/events/batch-event-processor.ts new file mode 100644 index 0000000..7a4dd64 --- /dev/null +++ b/src/events/batch-event-processor.ts @@ -0,0 +1,20 @@ +import NamedEventQueue from './named-event-queue'; + +export default class BatchEventProcessor { + constructor( + private readonly eventQueue: NamedEventQueue, + private readonly batchSize: number, + ) {} + + nextBatch(): unknown[] { + return this.eventQueue.splice(this.batchSize); + } + + push(event: unknown): void { + this.eventQueue.push(event); + } + + isEmpty(): boolean { + return this.eventQueue.isEmpty(); + } +} diff --git a/src/events/batch-retry-manager.ts b/src/events/batch-retry-manager.ts new file mode 100644 index 0000000..3ff4c3b --- /dev/null +++ b/src/events/batch-retry-manager.ts @@ -0,0 +1,44 @@ +import { logger } from '../application-logger'; + +import EventDelivery from './event-delivery'; + +/** + * Attempts to retry delivering a batch of events to the ingestionUrl up to `maxRetries` times + * using exponential backoff. + */ +export default class BatchRetryManager { + /** + * @param config.retryInterval - The minimum retry interval in milliseconds + * @param config.maxRetryDelayMs - The maximum retry delay in milliseconds + * @param config.maxRetries - The maximum number of retries + */ + constructor( + private readonly delivery: EventDelivery, + private readonly config: { + retryIntervalMs: number; + maxRetryDelayMs: number; + maxRetries: number; + }, + ) {} + + async retry(batch: unknown[], attempt = 0): Promise { + const { retryIntervalMs, maxRetryDelayMs, maxRetries } = this.config; + const delay = Math.min(retryIntervalMs * Math.pow(2, attempt), maxRetryDelayMs); + logger.info(`[BatchRetryManager] Retrying batch delivery in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + + const success = await this.delivery.deliver(batch); + if (success) { + logger.info(`[BatchRetryManager] Batch delivery successfully after ${attempt} retries.`); + return; + } + if (attempt < maxRetries) { + return this.retry(batch, attempt + 1); + } else { + // TODO: Persist batch to avoid data loss + logger.warn( + `[BatchRetryManager] Failed to deliver batch after ${maxRetries} retries, bailing`, + ); + } + } +} diff --git a/src/events/bounded-event-queue.ts b/src/events/bounded-event-queue.ts index 39bfad6..700fe3b 100644 --- a/src/events/bounded-event-queue.ts +++ b/src/events/bounded-event-queue.ts @@ -24,9 +24,4 @@ export class BoundedEventQueue { this.queue.length = 0; return events; } - - /** Returns the first event in the queue and removes it. */ - shift(): T | undefined { - return this.queue.shift(); - } } diff --git a/src/events/default-event-dispatcher.spec.ts b/src/events/default-event-dispatcher.spec.ts new file mode 100644 index 0000000..a773b33 --- /dev/null +++ b/src/events/default-event-dispatcher.spec.ts @@ -0,0 +1,201 @@ +import { resolve } from 'eslint-import-resolver-typescript'; + +import ArrayBackedNamedEventQueue from './array-backed-named-event-queue'; +import BatchEventProcessor from './batch-event-processor'; +import DefaultEventDispatcher, { EventDispatcherConfig } from './default-event-dispatcher'; +import { Event } from './event-dispatcher'; +import NetworkStatusListener from './network-status-listener'; + +global.fetch = jest.fn(); + +const mockNetworkStatusListener = { + isOffline: () => false, + onNetworkStatusChange: (_: (_: boolean) => void) => null as unknown as void, +}; + +const createDispatcher = ( + configOverrides: Partial< + EventDispatcherConfig & { networkStatusListener: NetworkStatusListener } + > = {}, +) => { + const batchSize = 2; + const defaultConfig: EventDispatcherConfig = { + ingestionUrl: 'http://example.com', + deliveryIntervalMs: 100, + retryIntervalMs: 300, + maxRetryDelayMs: 5000, + maxRetries: 3, + }; + const config = { ...defaultConfig, ...configOverrides }; + const eventQueue = new ArrayBackedNamedEventQueue('test-queue'); + const batchProcessor = new BatchEventProcessor(eventQueue, batchSize); + const dispatcher = new DefaultEventDispatcher( + batchProcessor, + configOverrides.networkStatusListener || mockNetworkStatusListener, + config, + ); + return { dispatcher, batchProcessor }; +}; + +describe('DefaultEventDispatcher', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('BatchEventProcessor', () => { + it('processes events in batches of the configured size', () => { + const { dispatcher, batchProcessor } = createDispatcher(); + + // Add three events to the queue + dispatcher.dispatch({ id: 'foo-1', data: 'event1', params: {} }); + dispatcher.dispatch({ id: 'foo-2', data: 'event2', params: {} }); + dispatcher.dispatch({ id: 'foo-3', data: 'event3', params: {} }); + + const batch1 = batchProcessor.nextBatch(); + expect(batch1).toHaveLength(2); + const batch2 = batchProcessor.nextBatch(); + expect(batch2).toHaveLength(1); + const batch3 = batchProcessor.nextBatch(); + expect(batch3).toHaveLength(0); + expect(batchProcessor.isEmpty()).toBe(true); + }); + }); + + describe('deliverNextBatch', () => { + it('delivers the next batch of events using fetch', async () => { + const { dispatcher } = createDispatcher(); + dispatcher.dispatch({ id: 'foo-1', data: 'event1', params: {} }); + dispatcher.dispatch({ id: 'foo-2', data: 'event2', params: {} }); + dispatcher.dispatch({ id: 'foo-3', data: 'event3', params: {} }); + + const fetch = global.fetch as jest.Mock; + fetch.mockResolvedValue({ ok: true }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }), + ); + + let fetchOptions = fetch.mock.calls[0][1]; + let payload = JSON.parse(fetchOptions.body); + expect(payload).toEqual([ + expect.objectContaining({ data: 'event1' }), + expect.objectContaining({ data: 'event2' }), + ]); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }), + ); + + fetchOptions = fetch.mock.calls[1][1]; + payload = JSON.parse(fetchOptions.body); + expect(payload).toEqual([expect.objectContaining({ data: 'event3' })]); + }); + + it('does not schedule delivery if the queue is empty', async () => { + createDispatcher(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + }); + + describe('retry logic', () => { + it('retries failed deliveries after the retry interval', async () => { + const { dispatcher } = createDispatcher(); + dispatcher.dispatch({ id: 'foo', data: 'event1' }); + + // Simulate fetch failure on the first attempt + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ ok: false }) // First attempt fails + .mockResolvedValueOnce({ ok: true }); // Second attempt succeeds + + // Fast-forward to trigger the first attempt + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(global.fetch).toHaveBeenCalledTimes(1); + + // Fast-forward to trigger the retry + await new Promise((resolve) => setTimeout(resolve, 100)); + // no retries yet since retry interval is 300ms + expect(global.fetch).toHaveBeenCalledTimes(1); + + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('offline handling', () => { + it('skips delivery when offline', async () => { + let isOffline = false; + let cb = (_: boolean) => null as unknown as void; + const networkStatusListener = { + isOffline: () => isOffline, + onNetworkStatusChange: (callback: (isOffline: boolean) => void) => { + cb = callback; + }, + triggerNetworkStatusChange: () => cb(isOffline), + }; + const { dispatcher } = createDispatcher({ networkStatusListener }); + dispatcher.dispatch({ id: '1', data: 'event1', params: {} }); + dispatcher.dispatch({ id: '2', data: 'event2', params: {} }); + + isOffline = true; + // simulate the network going offline + networkStatusListener.triggerNetworkStatusChange(); + + // Fast-forward, should not attempt delivery + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('resumes delivery when back online', async () => { + let isOffline = true; + let cb = (_: boolean) => null as unknown as void; + const networkStatusListener = { + isOffline: () => isOffline, + onNetworkStatusChange: (callback: (isOffline: boolean) => void) => { + cb = callback; + }, + triggerNetworkStatusChange: () => cb(isOffline), + }; + const { dispatcher } = createDispatcher({ networkStatusListener }); + dispatcher.dispatch({ id: '1', data: 'event1', params: {} }); + dispatcher.dispatch({ id: '2', data: 'event2', params: {} }); + + const fetch = global.fetch as jest.Mock; + fetch.mockResolvedValue({ ok: true }); + + isOffline = true; + // simulate the network going offline + networkStatusListener.triggerNetworkStatusChange(); + + // Fast-forward, should not attempt delivery + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(global.fetch).not.toHaveBeenCalled(); + + isOffline = false; + // simulate the network going back online + networkStatusListener.triggerNetworkStatusChange(); + + // Fast-forward, should attempt delivery + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(global.fetch).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/events/default-event-dispatcher.ts b/src/events/default-event-dispatcher.ts new file mode 100644 index 0000000..e6addb3 --- /dev/null +++ b/src/events/default-event-dispatcher.ts @@ -0,0 +1,97 @@ +import { logger } from '../application-logger'; + +import BatchEventProcessor from './batch-event-processor'; +import BatchRetryManager from './batch-retry-manager'; +import EventDelivery from './event-delivery'; +import EventDispatcher, { Event } from './event-dispatcher'; +import NetworkStatusListener from './network-status-listener'; + +export type EventDispatcherConfig = { + // target url to deliver events to + ingestionUrl: string; + // number of milliseconds to wait between each batch delivery + deliveryIntervalMs: number; + // minimum amount of milliseconds to wait before retrying a failed delivery + retryIntervalMs: number; + // maximum amount of milliseconds to wait before retrying a failed delivery + maxRetryDelayMs: number; + // maximum number of retry attempts before giving up on a batch delivery + maxRetries?: number; +}; + +/** + * @internal + * An {@link EventDispatcher} that, given the provided config settings, delivers events in batches + * to the ingestionUrl and retries failed deliveries. Also reacts to network status changes to + * determine when to deliver events. + */ +export default class DefaultEventDispatcher implements EventDispatcher { + private readonly eventDelivery: EventDelivery; + private readonly retryManager: BatchRetryManager; + private readonly deliveryIntervalMs: number; + private dispatchTimer: NodeJS.Timeout | null = null; + private isOffline = false; + + constructor( + private readonly batchProcessor: BatchEventProcessor, + private readonly networkStatusListener: NetworkStatusListener, + config: EventDispatcherConfig, + ) { + this.eventDelivery = new EventDelivery(config.ingestionUrl); + this.retryManager = new BatchRetryManager(this.eventDelivery, { + retryIntervalMs: config.retryIntervalMs, + maxRetryDelayMs: config.maxRetryDelayMs, + maxRetries: config.maxRetries || 3, + }); + this.deliveryIntervalMs = config.deliveryIntervalMs; + this.networkStatusListener.onNetworkStatusChange((isOffline) => { + logger.info(`[EventDispatcher] Network status change, isOffline=${isOffline}.`); + this.isOffline = isOffline; + if (isOffline) { + this.dispatchTimer = null; + } else { + this.maybeScheduleNextDelivery(); + } + }); + } + + dispatch(event: Event) { + this.batchProcessor.push(event); + this.maybeScheduleNextDelivery(); + } + + private async deliverNextBatch() { + if (this.isOffline) { + logger.warn('[EventDispatcher] Skipping delivery; network status is offline.'); + return; + } + + const batch = this.batchProcessor.nextBatch(); + if (batch.length === 0) { + // nothing to deliver + this.dispatchTimer = null; + return; + } + + logger.info(`[EventDispatcher] Delivering batch of ${batch.length} events...`); + const success = await this.eventDelivery.deliver(batch); + if (!success) { + logger.warn('[EventDispatcher] Failed to deliver batch, retrying...'); + await this.retryManager.retry(batch); + } + logger.debug(`[EventDispatcher] Delivered batch of ${batch.length} events.`); + this.dispatchTimer = null; + this.maybeScheduleNextDelivery(); + } + + private maybeScheduleNextDelivery() { + // schedule next event delivery when: + // 1. we're not offline + // 2. there are enqueued events + // 3. there isn't already a scheduled delivery + if (!this.isOffline && !this.batchProcessor.isEmpty() && !this.dispatchTimer) { + logger.info(`[EventDispatcher] Scheduling next delivery in ${this.deliveryIntervalMs}ms.`); + this.dispatchTimer = setTimeout(() => this.deliverNextBatch(), this.deliveryIntervalMs); + } + } +} diff --git a/src/events/event-delivery.ts b/src/events/event-delivery.ts new file mode 100644 index 0000000..c21831e --- /dev/null +++ b/src/events/event-delivery.ts @@ -0,0 +1,20 @@ +import { logger } from '../application-logger'; + +export default class EventDelivery { + constructor(private ingestionUrl: string) {} + + async deliver(batch: unknown[]): Promise { + try { + const response = await fetch(this.ingestionUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + // TODO: Figure out proper request body encoding format for batch, using JSON for now + body: JSON.stringify(batch), + }); + return response.ok; + } catch { + logger.warn('Failed to upload event batch'); + return false; + } + } +} diff --git a/src/events/event-dispatcher.ts b/src/events/event-dispatcher.ts new file mode 100644 index 0000000..25a85cf --- /dev/null +++ b/src/events/event-dispatcher.ts @@ -0,0 +1,10 @@ +export type Event = { + id: string; + data: unknown; + params?: Record; +}; + +export default interface EventDispatcher { + /** Dispatches (enqueues) an event for eventual delivery. */ + dispatch(event: Event): void; +} diff --git a/src/events/named-event-queue.ts b/src/events/named-event-queue.ts index 1c98056..c0f4a38 100644 --- a/src/events/named-event-queue.ts +++ b/src/events/named-event-queue.ts @@ -4,9 +4,14 @@ export default interface NamedEventQueue { name: string; + /** Add an element to the end of the array */ push(event: T): void; [Symbol.iterator](): IterableIterator; - shift(): T | undefined; + /** changes the contents of an array by removing count elements from the start of the queue */ + splice(count: number): T[]; + + /** Returns true if the queue is empty */ + isEmpty(): boolean; } diff --git a/src/events/network-status-listener.ts b/src/events/network-status-listener.ts new file mode 100644 index 0000000..2cf9fb5 --- /dev/null +++ b/src/events/network-status-listener.ts @@ -0,0 +1,8 @@ +/** Listener interface for network status changes */ +export default interface NetworkStatusListener { + /** Returns true if the network is offline */ + isOffline(): boolean; + + /** Calls the provided callback when the network status changes */ + onNetworkStatusChange(callback: (isOffline: boolean) => void): void; +} diff --git a/src/events/no-op-event-dispatcher.ts b/src/events/no-op-event-dispatcher.ts new file mode 100644 index 0000000..17bfaa2 --- /dev/null +++ b/src/events/no-op-event-dispatcher.ts @@ -0,0 +1,7 @@ +import EventDispatcher, { Event } from './event-dispatcher'; + +export default class NoOpEventDispatcher implements EventDispatcher { + dispatch(_: Event): void { + // Do nothing + } +} diff --git a/src/index.ts b/src/index.ts index a4b0eec..d855930 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,12 @@ import { import { HybridConfigurationStore } from './configuration-store/hybrid.store'; import { MemoryStore, MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; import * as constants from './constants'; +import ArrayBackedNamedEventQueue from './events/array-backed-named-event-queue'; +import BatchEventProcessor from './events/batch-event-processor'; +import DefaultEventDispatcher from './events/default-event-dispatcher'; +import EventDispatcher from './events/event-dispatcher'; +import NamedEventQueue from './events/named-event-queue'; +import NetworkStatusListener from './events/network-status-listener'; import HttpClient from './http-client'; import { Flag, ObfuscatedFlag, VariationType } from './interfaces'; import { @@ -86,4 +92,11 @@ export { ContextAttributes, BanditSubjectAttributes, BanditActions, + + // event queue types + NamedEventQueue, + EventDispatcher, + BatchEventProcessor, + NetworkStatusListener, + DefaultEventDispatcher, }; diff --git a/tsconfig.json b/tsconfig.json index 583833b..6e8b718 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "declarationMap": true, "sourceMap": true, "allowSyntheticDefaultImports": true, - "target": "es2017", + "target": "es2020", "outDir": "dist", "noImplicitAny": true, "strict": true, diff --git a/yarn.lock b/yarn.lock index d063b08..1a3d002 100644 --- a/yarn.lock +++ b/yarn.lock @@ -811,6 +811,11 @@ resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz" integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw== +"@types/uuid@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d" + integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz" @@ -4161,6 +4166,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.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz"