diff --git a/.gitignore b/.gitignore index 7c12012a..ba3f0be8 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ types *.sln *.sw? *.tsbuildinfo +/refs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..6ca9a434 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# CLAUDE.md + +Guidance for working in the Bucketeer **JavaScript/TypeScript client SDK** +(`@bucketeer/js-client-sdk`). A feature-flag SDK that fetches user evaluations from +the Bucketeer backend and serves variations to the app. + +## Commands + +- Install: `pnpm install` (pnpm workspace; Node version pinned in `.node-version`). +- Build: `pnpm build` (uses `unbuild`, config in `build.config.ts`). +- Unit tests: `pnpm test` (runs browser + node). Single env: `pnpm test:browser` / + `pnpm test:node`. Tests live in `test/`, mirror `src/` layout, use **Vitest**. +- E2E: `pnpm test:e2e` (browser + node; copies `e2e/module..ts` → `e2e/module.ts`). +- Typecheck: `pnpm typecheck:lib` (source) / `pnpm typecheck:test` (tests). +- Lint: `pnpm lint` / `pnpm lint:fix`. There is a custom ESLint rule + (`eslint-rules/no-spread-after-defaults`) — test it via `pnpm test:custom-eslint-rules`. + +## Code style + +- **No semicolons, single quotes, 2-space indent** (`.prettierrc`). Match existing code. +- Custom lint rule `no-spread-after-defaults`: in `defineBKTConfig`, do **not** spread + a source object over already-applied defaults (it would re-introduce `undefined`). + Advanced/optional config keys are assigned conditionally (`if (x !== undefined)`), + not spread — follow that pattern. + +## Platform builds (important) + +The SDK ships **three platform entry points**, selected by `package.json` `exports`: + +- `src/main.ts` → **Node** (`NodePlatformModule`) +- `src/main.browser.ts` → **Browser** / `default` (`BrowserPlatformModule`) +- `src/main.native.ts` → **React Native** (`BasePlatformModule`, requires injected `idGenerator`) + +Platform-specific implementations use the `*.browser.ts` / `*.node.ts` filename +convention (e.g. `IdGenerator.browser.ts` / `IdGenerator.node.ts`, +`PlatformModule.browser.ts` / `PlatformModule.node.ts`). When adding a platform +capability, add it to the `PlatformModule` interface and each variant. Each +`initializeBKTClient` builds a `DefaultComponent` from the platform module + +`DataModule` + `InteractorModule`, then calls `initializeBKTClientInternal`. + +## Architecture (DI graph) + +Hand-rolled DI; everything hangs off `Component` (`src/internal/di/Component.ts`): + +- `Component`: `config()`, `userHolder()`, `evaluationInteractor()`, `eventInteractor()`. + `DefaultComponent` lazily memoizes interactors. +- `DataModule` (`src/internal/di/DataModule.ts`): owns `InternalConfig`, `UserHolder`, + `Clock`, `ApiClient`, `EvaluationStorage`, `EventStorage` (all lazy-memoized). +- `InteractorModule`: factory for `EvaluationInteractor` / `EventInteractor`. +- `PlatformModule`: platform abstractions (currently `idGenerator()`). + +## Core data flow + +1. **Config** — `src/BKTConfig.ts`. `RawBKTConfig` (user-facing, mostly optional) → + `defineBKTConfig()` applies `??` defaults, then validates (throws + `IllegalArgumentException`), then returns `InternalConfig` (adds `sourceId`, + `sdkVersion` via `resolveSourceId`/`resolveSDKVersion`). Key defaults: + `pollingInterval` 600_000ms (min 60_000), `eventsFlushInterval` 10_000ms, + `eventsMaxQueueSize` 50, `fetch ?? globalThis.fetch`. `SourceId` enum in + `src/internal/model/SourceId.ts` (JAVASCRIPT=7, NODE_SERVER=6, REACT_NATIVE=10, …). + +2. **Remote** — `src/internal/remote/ApiClient.ts`. `ApiClientImpl.getEvaluations` + does `POST ${endpoint}/get_evaluations` with headers + `{ 'Content-Type': 'application/json', Authorization: }` (auth is the + **Authorization header**, not a query param) and a `GetEvaluationsRequest` body. + Returns a tagged `GetEvaluationsResult` (`{type:'success'|'failure'}`). `FetchLike` + (`remote/fetch.ts`) is the injectable fetch abstraction; `post.ts` adds retry on + 499/`ClientClosedRequestException`. + +3. **Evaluation** — `src/internal/evaluation/EvaluationInteractor.ts`. + - `fetch(user)`: sends current `userEvaluationsId` + `evaluatedAt` for incremental + updates; on success, `forceUpdate ? storage.deleteAllAndInsert(...) : + storage.update(...)`, then `clearUserAttributesUpdated()`, then notifies all + `updateListeners` **iff** something changed. + - `updateListeners` registered via `addUpdateListener` (id from `idGenerator`); + exposed to apps as `BKTClient.addEvaluationUpdateListener`. + - `EvaluationStorage` (`EvaluationStorage.ts`) is an in-memory cache (`Mutex`-guarded) + backed by `BKTStorage`; `update()` returns `boolean` (changed?). + - Models: `Evaluation`, `UserEvaluations` (`{id, evaluations?, createdAt, + archivedFeatureIds, forceUpdate}`), `GetEvaluationsResponse` (`{evaluations, + userEvaluationsId}`). + +4. **Scheduling** — `src/internal/scheduler/`. `TaskScheduler`'s main task is + `StreamingTask` (`src/internal/streaming/`) when `enableStreaming` is on, else + `EvaluationTask`; either way it pairs that with `EventTask`, and `start()/stop()`s + both. `ScheduledTask` interface = `isRunning()/start()/stop()`. `EvaluationTask` + polls via `setTimeout` (`BKTClientImpl.fetchEvaluationsInternal`), with retry + (max 5 @ 60s) only when `pollingInterval > 60s`. `StreamingTask` opens an SSE + connection and internally falls back to polling (an `EvaluationTask` it owns) on + error when `streamingFallbackToPolling` is enabled. `EventTask` flushes queued + events. + +5. **Client lifecycle** — `src/BKTClient.ts`. `initializeBKTClientInternal` creates the + singleton (`internal/instance.ts`); `initializeInternal` then awaits + `evaluationInteractor().initialize()` **before** `scheduleTasks()` (`new + TaskScheduler().start()`) — `StreamingTask.start()` reads the evaluation cache + synchronously, so the cache must already be loaded — followed by the first fetch. + `resetTasks()` stops the scheduler; `destroyBKTClient()` calls it and clears the + instance + page lifecycle listeners. Browser build also wires + `setupPageLifecycleListeners` for flush-on-pagehide when + `enableAutoPageLifecycleFlush`. + diff --git a/eslint.config.cjs b/eslint.config.cjs index 0695d351..e589f3b6 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -17,6 +17,7 @@ module.exports = [ '**/*.d.ts', '.github', 'eslint-rules/', + 'refs/', ], }, ...tseslint.configs.recommended, diff --git a/src/BKTClient.ts b/src/BKTClient.ts index b8337dff..491e47be 100644 --- a/src/BKTClient.ts +++ b/src/BKTClient.ts @@ -77,9 +77,34 @@ export class BKTClientImpl implements BKTClient { constructor(public component: Component) {} - async initializeInternal(timeoutMillis: number): Promise { - this.scheduleTasks() + /** + * Fatal init phase: load the evaluation cache. This MUST resolve before + * scheduleAndFetch() runs. scheduleAndFetch() -> scheduleTasks() starts + * StreamingTask (when enableStreaming), whose start() synchronously opens + * the first connection and reads the evaluation cache via + * EvaluationStorage.getCurrentEvaluationsCondition() — which throws if the + * cache isn't loaded yet, same contract as getCurrentEvaluationsId() / + * getEvaluatedAt(). Scheduling before this resolves reintroduces a + * synchronous crash on every app startup with enableStreaming: true. + * (Polling's EvaluationTask never had this constraint: EvaluationTask.start() + * only arms a timer, its real first cache read is the explicit + * fetchEvaluations() call in scheduleAndFetch(), already sequenced after + * this.) initializeBKTClientInternal() owns that ordering — it awaits + * initializeCache() before calling scheduleAndFetch(). + */ + async initializeCache(): Promise { await this.component.evaluationInteractor().initialize() + } + + /** + * Normal init phase: start the background tasks, then run the first fetch. + * Only call after initializeCache() has resolved (see its ordering note). + * A first-fetch timeout rejecting here is normal and must NOT tear the + * singleton down — that is why initializeBKTClientInternal() only clears the + * singleton on an initializeCache() failure, never on this. + */ + scheduleAndFetch(timeoutMillis: number): Promise { + this.scheduleTasks() return this.fetchEvaluations(timeoutMillis) } @@ -179,6 +204,7 @@ export class BKTClientImpl implements BKTClient { ): Promise { this.component.userHolder().updateAttributes((_prev) => ({ ...attributes })) await this.component.evaluationInteractor().setUserAttributesUpdated() + this.taskScheduler?.reconnectStreaming() } async fetchEvaluations(timeoutMillis?: number): Promise { @@ -308,6 +334,11 @@ export class BKTClientImpl implements BKTClient { } resetTasks(): void { + // A destroy racing a pending initializeBKTClientInternal() is handled there + // by an identity check after the initializeCache() await (getInstance() !== + // client), since destroyBKTClient() clears the singleton synchronously right + // after this call — so nothing extra is needed here when taskScheduler is + // still null. if (this.taskScheduler) { this.taskScheduler.stop() this.taskScheduler = null @@ -362,18 +393,41 @@ export const getBKTClient = (): BKTClient | null => { return getInstance() } -export const initializeBKTClientInternal = ( +export const initializeBKTClientInternal = async ( component: Component, timeoutMillis = 5_000, ): Promise => { if (getInstance()) { - return Promise.resolve() + return } const client = new BKTClientImpl(component) setInstance(client) - return client.initializeInternal(timeoutMillis) + try { + await client.initializeCache() + } catch (err) { + // Fatal: without a loaded evaluation cache the client can never work. + // Remove the singleton so a retry of initializeBKTClient() actually + // re-initializes instead of silently no-oping on the dead instance — but + // only if this client is still the registered one (a destroy + re-init may + // have replaced it while initializeCache() was pending, in which case this + // rejection belongs to an already-orphaned attempt and must not clear the + // new client). + if (getInstance() === client) { + clearInstance() + } + throw err + } + + // destroyBKTClient() may have run while initializeCache() was pending (e.g. + // React 18 StrictMode mount/unmount). It synchronously clears the singleton, + // so if we are no longer the registered instance, a destroy (or a destroy + + // re-init) happened while we awaited — stop here, or scheduling now would + // leak an unstoppable stream and timers. Same identity check the catch block + // above uses. + if (getInstance() !== client) return + return client.scheduleAndFetch(timeoutMillis) } export const destroyBKTClient = (): void => { diff --git a/src/BKTConfig.ts b/src/BKTConfig.ts index 44d26ad3..5c3f37de 100644 --- a/src/BKTConfig.ts +++ b/src/BKTConfig.ts @@ -7,6 +7,7 @@ import { } from './internal/InternalConfig' import { IdGenerator } from './internal/IdGenerator' import { FetchLike } from './internal/remote/fetch' +import { EventSourceLike } from './internal/streaming/EventSourceLike' import { SDK_VERSION } from './internal/version' const MINIMUM_FLUSH_INTERVAL_MILLIS = 10_000 // 10 seconds @@ -58,6 +59,18 @@ export interface RawBKTConfig { // The sourceID is used to identify the origin of the request. wrapperSdkSourceId?: number idGenerator?: IdGenerator + + // Optional custom EventSource implementation for SSE streaming. + // If omitted, the SDK uses its built-in FetchEventSource (fetch + ReadableStream). + // Provide this only if you have a POST-capable SSE library you prefer to use. + eventSource?: EventSourceLike + + // Enable SSE streaming as the evaluation update mechanism (default: false). + // When true, StreamingTask replaces EvaluationTask as the main scheduler. + enableStreaming?: boolean + + // When streaming fails or is unavailable, fall back to polling (default: true). + streamingFallbackToPolling?: boolean } export interface BKTConfig extends RawBKTConfig { @@ -69,6 +82,8 @@ export interface BKTConfig extends RawBKTConfig { fetch: FetchLike storageFactory: (key: string) => BKTStorage enableAutoPageLifecycleFlush: boolean + enableStreaming: boolean + streamingFallbackToPolling: boolean } const defaultUserAgent = () => { @@ -100,6 +115,8 @@ export const defineBKTConfig = (config: RawBKTConfig): BKTConfig => { fetch: config.fetch ?? globalThis.fetch, storageFactory: config.storageFactory ?? createBKTStorage, enableAutoPageLifecycleFlush: config.enableAutoPageLifecycleFlush ?? true, + enableStreaming: config.enableStreaming ?? false, + streamingFallbackToPolling: config.streamingFallbackToPolling ?? true, } // Advanced properties: only included when explicitly set (not undefined) @@ -113,6 +130,9 @@ export const defineBKTConfig = (config: RawBKTConfig): BKTConfig => { if (config.idGenerator !== undefined) { result.idGenerator = config.idGenerator } + if (config.eventSource !== undefined) { + result.eventSource = config.eventSource + } // Validate required properties if (!result.apiKey) throw new IllegalArgumentException('apiKey is required') diff --git a/src/internal/evaluation/EvaluationInteractor.ts b/src/internal/evaluation/EvaluationInteractor.ts index 25efc095..5a959d37 100644 --- a/src/internal/evaluation/EvaluationInteractor.ts +++ b/src/internal/evaluation/EvaluationInteractor.ts @@ -1,9 +1,10 @@ import { IdGenerator } from '../IdGenerator' import { Evaluation } from '../model/Evaluation' import { User } from '../model/User' +import { GetEvaluationsResponse } from '../model/response/GetEvaluationsResponse' import { ApiClient } from '../remote/ApiClient' import { GetEvaluationsResult } from '../remote/GetEvaluationsResult' -import { EvaluationStorage } from './EvaluationStorage' +import { EvaluationStorage, UserAttributesState } from './EvaluationStorage' export class EvaluationInteractor { constructor( @@ -30,18 +31,26 @@ export class EvaluationInteractor { user: User, timeoutMillis?: number, ): Promise { + // Captured synchronously as the FIRST statement, before any await: the + // caller reads `user` in the same synchronous expression that invokes + // fetch(), so capturing here makes the user snapshot and this state + // snapshot atomic. Captured after an await instead, a + // setUserAttributesUpdated() landing in that gap would stamp this request + // with a sequence for attributes the request's user object doesn't carry — + // and the success-path clear below would then wipe a flag whose + // attributes were never sent. See EvaluationStorage. + // clearUserAttributesUpdated(). + const attributesStateAtStart = this.evaluationStorage.getUserAttributesState() const currentEvaluationsId = await this.evaluationStorage.getCurrentEvaluationsId() ?? '' const evaluatedAt = await this.evaluationStorage.getEvaluatedAt() ?? '0' - const userAttributesUpdated = await - this.evaluationStorage.getUserAttributesUpdated() const result = await this.apiClient.getEvaluations( { user, userEvaluationsId: currentEvaluationsId, userEvaluationCondition: { evaluatedAt: evaluatedAt, - userAttributesUpdated: userAttributesUpdated, + userAttributesUpdated: attributesStateAtStart.userAttributesUpdated, }, tag: this.featureTag, }, @@ -49,48 +58,96 @@ export class EvaluationInteractor { ) if (result.type === 'success') { - const response = result.value - - let shouldNotify: boolean - if (response.evaluations.forceUpdate) { - // 1- Delete all the evaluations from local storage, and save the latest evaluations from the response into the local storage - // 2- Save the UserEvaluations.CreatedAt in the response as evaluatedAt in the localStorage - await this.evaluationStorage.deleteAllAndInsert( - response.userEvaluationsId, - response.evaluations.evaluations ?? [], - response.evaluations.createdAt, - ) - shouldNotify = true - } else { - // 1- Check the evaluation list in the response and upsert them in the localStorage if the list is not empty - // 2- Check the archivedFeatureIds list and delete them from the localStorage if is not empty - // 3- Save the UserEvaluations.CreatedAt in the response as evaluatedAt in the localStorage - shouldNotify = await this.evaluationStorage.update( - response.userEvaluationsId, - response.evaluations.evaluations ?? [], - response.evaluations.archivedFeatureIds ?? [], - response.evaluations.createdAt, - ) + // Ordering carries two invariants. Write BEFORE clear: the response to + // a userAttributesUpdated:true request carries the re-evaluation the + // flag asked for, so a rejected write must skip the clear — the flag + // survives and the next poll retries. Clear BEFORE notify: a listener + // that triggers a nested fetch (refresh-on-change pattern) must + // observe the flag already cleared — this request already carried it — + // or the nested call re-sends userAttributesUpdated:true and gets back + // a redundant forceUpdate snapshot. Streamed data must never clear the + // flag (race) — only this, the polling/fetch path, does. + const changed = await this.writeEvaluations(result.value) + await this.evaluationStorage.clearUserAttributesUpdated( + attributesStateAtStart, + ) + if (changed) { + this.notifyListeners() } + } - await this.evaluationStorage.clearUserAttributesUpdated() + return result + } - if (shouldNotify) { - Object.values(this.updateListeners).forEach((listener) => listener()) - } + async applyEvaluationsResponse( + response: GetEvaluationsResponse, + // shouldNotify is re-checked AFTER the storage write completes: the write + // is awaited, so a stop()/destroy racing it must be able to suppress the + // listener callbacks (which may run app code against a torn-down client). + // The write itself is allowed to land — it's just unused cached data. + shouldNotify: () => boolean = () => true, + ): Promise { + const changed = await this.writeEvaluations(response) + if (changed && shouldNotify()) { + this.notifyListeners() } + } - return result + // @returns whether anything changed. A skipped stale write (see + // EvaluationStorage's staleness guard) returns false — callers must not + // notify in that case. + private async writeEvaluations( + response: GetEvaluationsResponse, + ): Promise { + if (response.evaluations.forceUpdate) { + return this.evaluationStorage.deleteAllAndInsert( + response.userEvaluationsId, + response.evaluations.evaluations ?? [], + response.evaluations.createdAt, + ) + } + return this.evaluationStorage.update( + response.userEvaluationsId, + response.evaluations.evaluations ?? [], + response.evaluations.archivedFeatureIds ?? [], + response.evaluations.createdAt, + ) + } + + private notifyListeners(): void { + Object.values(this.updateListeners).forEach((listener) => listener()) } getLatest(featureId: string): Evaluation | null { return this.evaluationStorage.getByFeatureId(featureId) } + // Used by StreamingTask.buildRequest() to send the last-known state on + // every (re)connect, so the backend can reply with a diff instead of a + // full snapshot. Throws before initialize() — see the comment on + // EvaluationStorage.getCurrentEvaluationsCondition(). + getCurrentEvaluationsCondition(): { + currentEvaluationsId: string | null + evaluatedAt: string | null + } { + return this.evaluationStorage.getCurrentEvaluationsCondition() + } + async setUserAttributesUpdated(): Promise { return this.evaluationStorage.setUserAttributesUpdated() } + // Used by StreamingTask.buildRequest()/onOpen to capture the flag's state + // at request-build time and clear it once the connection this request + // built actually opens — see EvaluationStorage.clearUserAttributesUpdated(). + getUserAttributesState(): UserAttributesState { + return this.evaluationStorage.getUserAttributesState() + } + + async clearUserAttributesUpdated(state: UserAttributesState): Promise { + return this.evaluationStorage.clearUserAttributesUpdated(state) + } + addUpdateListener(listener: () => void): string { const id = this.idGenerator.newId() this.updateListeners[id] = listener diff --git a/src/internal/evaluation/EvaluationStorage.ts b/src/internal/evaluation/EvaluationStorage.ts index 3016b071..e7a7e0b5 100644 --- a/src/internal/evaluation/EvaluationStorage.ts +++ b/src/internal/evaluation/EvaluationStorage.ts @@ -12,6 +12,34 @@ export interface EvaluationEntity { userAttributesUpdated: boolean } +// Guards against a stale concurrent writer (e.g. the initial REST fetch +// racing the stream's first snapshot) rewinding state. Strictly older only — +// an equal evaluatedAt still applies, since two patches computed in the same +// clock tick are not stale relative to each other, and dropping an +// equal-timestamp write would be a worse failure than the race this guards +// against. `Number()` is safe for the observed decimal-millisecond-string +// format, far below 2^53. +function isStale(entity: EvaluationEntity, evaluatedAt: string): boolean { + return ( + entity.evaluatedAt !== null && + Number(evaluatedAt) < Number(entity.evaluatedAt) + ) +} + +/** + * Snapshot of the userAttributesUpdated flag paired with the sequence number + * it was read at. Mirrors the Android/iOS SDKs' UserAttributesState (same + * field names, incl. updateSequence) — bundled into one value so callers + * can't accidentally read the flag and sequence at inconsistent points. Pass + * the whole snapshot back to clearUserAttributesUpdated() so a request built + * from a stale snapshot can't clear a flag set by a later + * setUserAttributesUpdated() call. + */ +export interface UserAttributesState { + userAttributesUpdated: boolean + updateSequence: number +} + export interface EvaluationStorage { getByFeatureId(featureId: string): Evaluation | null @@ -21,11 +49,19 @@ export interface EvaluationStorage { */ initialize(): Promise + /** + * @returns false (a no-op) if the incoming write is stale — see isStale() + * for the full rule and rationale — otherwise true. + */ deleteAllAndInsert( evaluationsId: string, evaluations: Evaluation[], evaluatedAt: string, - ): Promise + ): Promise + /** + * @returns false if the incoming write is stale — see isStale() — otherwise + * true iff something changed. + */ update( evaluationsId: string, evaluations: Evaluation[], @@ -37,14 +73,51 @@ export interface EvaluationStorage { getEvaluatedAt(): Promise + /** + * Synchronous cache read for the streaming request builder. Throws before + * initialize(), same contract as getCurrentEvaluationsId() / getEvaluatedAt() + * above — safe because initializeBKTClientInternal() guarantees initialize() + * always resolves before any task (including StreamingTask's first connect) + * can reach this storage. See the ordering comment on + * BKTClientImpl.initializeCache() in BKTClient.ts before changing that + * guarantee. + */ + getCurrentEvaluationsCondition(): { + currentEvaluationsId: string | null + evaluatedAt: string | null + } + /** * @returns true if featureTag has been updated */ updateFeatureTag(featureTag: string): Promise setUserAttributesUpdated(): Promise - getUserAttributesUpdated(): Promise - clearUserAttributesUpdated(): Promise + + /** + * Synchronous cache read, same contract as getCurrentEvaluationsCondition() + * above — deliberately not mutex-guarded. Two reasons: (1) + * StreamingTask.buildRequest() must call this synchronously, with no + * `await` anywhere in that call chain, so this can't become async; (2) a + * synchronous read with no `await` inside it can't be interrupted by a + * concurrent write in a single-threaded runtime, so no mutex is needed for + * the read itself to be internally consistent. The tradeoff: it only + * reflects a setUserAttributesUpdated() call once that call has been + * awaited by its caller (not the instant it's called) — true today, since + * the only real caller, BKTClient.updateUserAttributes(), always awaits it. + * Capture the returned snapshot before starting a request and pass it back + * to clearUserAttributesUpdated() so a stale in-flight request can't clear + * a flag set by a later setUserAttributesUpdated() call. + */ + getUserAttributesState(): UserAttributesState + + /** + * No-ops if state.updateSequence no longer matches the current sequence, + * i.e. a setUserAttributesUpdated() call happened after state was captured + * — that means the caller's request didn't carry the latest attributes, so + * the flag must survive for the next request to pick up. + */ + clearUserAttributesUpdated(state: UserAttributesState): Promise clear(): Promise } @@ -64,6 +137,12 @@ export class EvaluationStorageImpl implements EvaluationStorage { */ public cacheEvaluationEntity: EvaluationEntity | null = null + /** + * In-memory only (no persistence/migration needed) — bumped by every + * setUserAttributesUpdated() call. See clearUserAttributesUpdated(). + */ + private updateSequence = 0 + async initialize(): Promise { if (this.cacheEvaluationEntity) { throw new Error( @@ -100,9 +179,10 @@ export class EvaluationStorageImpl implements EvaluationStorage { evaluationsId: string, evaluations: Evaluation[], evaluatedAt: string, - ): Promise { - await runWithMutex(this.mutex, async () => { + ): Promise { + return await runWithMutex(this.mutex, async () => { const entity = this.getCachedEvaluationEntity() + if (isStale(entity, evaluatedAt)) return false const updated: EvaluationEntity = { ...entity, userId: this.userId, @@ -116,6 +196,7 @@ export class EvaluationStorageImpl implements EvaluationStorage { evaluatedAt, } await this.saveAsync(updated) + return true }) } @@ -127,6 +208,7 @@ export class EvaluationStorageImpl implements EvaluationStorage { ): Promise { return await runWithMutex(this.mutex, async () => { const entity = this.getCachedEvaluationEntity() + if (isStale(entity, evaluatedAt)) return false // remove archived evaluations const activeEvaluations = Object.fromEntries( @@ -163,6 +245,17 @@ export class EvaluationStorageImpl implements EvaluationStorage { return this.getCachedEvaluationEntity().evaluatedAt } + getCurrentEvaluationsCondition(): { + currentEvaluationsId: string | null + evaluatedAt: string | null + } { + const entity = this.getCachedEvaluationEntity() + return { + currentEvaluationsId: entity.currentEvaluationsId, + evaluatedAt: entity.evaluatedAt, + } + } + async updateFeatureTag(featureTag: string): Promise { return await runWithMutex(this.mutex, async () => { const entity = this.getCachedEvaluationEntity() @@ -183,6 +276,7 @@ export class EvaluationStorageImpl implements EvaluationStorage { async setUserAttributesUpdated(): Promise { await runWithMutex(this.mutex, async () => { const entity = this.getCachedEvaluationEntity() + this.updateSequence++ await this.saveAsync({ ...entity, userAttributesUpdated: true, @@ -190,16 +284,29 @@ export class EvaluationStorageImpl implements EvaluationStorage { }) } - async getUserAttributesUpdated(): Promise { - return await runWithMutex(this.mutex, async () => { - const entity = this.getCachedEvaluationEntity() - return entity.userAttributesUpdated - }) + getUserAttributesState(): UserAttributesState { + const entity = this.getCachedEvaluationEntity() + return { + userAttributesUpdated: entity.userAttributesUpdated, + updateSequence: this.updateSequence, + } } - async clearUserAttributesUpdated(): Promise { + async clearUserAttributesUpdated(state: UserAttributesState): Promise { await runWithMutex(this.mutex, async () => { const entity = this.getCachedEvaluationEntity() + if (this.updateSequence !== state.updateSequence) { + // A setUserAttributesUpdated() call landed after state was captured + // — this clear belongs to a now-stale request that didn't carry the + // latest attributes. Leave the flag set for the next request. + return + } + if (!entity.userAttributesUpdated) { + // Already false — the common case on every (re)connect/poll. Skip the + // write instead of re-serializing the whole evaluations map to set + // false → false. + return + } await this.saveAsync({ ...entity, userAttributesUpdated: false, diff --git a/src/internal/model/request/StreamEvaluationsRequest.ts b/src/internal/model/request/StreamEvaluationsRequest.ts new file mode 100644 index 00000000..c4b3e88a --- /dev/null +++ b/src/internal/model/request/StreamEvaluationsRequest.ts @@ -0,0 +1,14 @@ +import { SourceId } from '../SourceId' + +export interface StreamEvaluationsRequest { + tag: string + user: { id: string; data?: Record } + sourceId: SourceId + sdkVersion: string + // Last-known state, so the backend can send a diff instead of a full + // snapshot on (re)connect — same mechanism as the polling path's + // UserEvaluationCondition. Empty string / '0' (the proto3 zero values) on + // the first connect, before any state has been cached. + userEvaluationsId: string + evaluatedAt: string +} diff --git a/src/internal/remote/fetch.ts b/src/internal/remote/fetch.ts index 79e2011c..471d0297 100644 --- a/src/internal/remote/fetch.ts +++ b/src/internal/remote/fetch.ts @@ -17,6 +17,7 @@ export type FetchResponseLike = { // eslint-disable-next-line @typescript-eslint/no-explicit-any json: () => Promise text: () => Promise + body?: ReadableStream | null } export type FetchLike = ( diff --git a/src/internal/scheduler/EvaluationTask.ts b/src/internal/scheduler/EvaluationTask.ts index 00b3e66d..be18ae45 100644 --- a/src/internal/scheduler/EvaluationTask.ts +++ b/src/internal/scheduler/EvaluationTask.ts @@ -28,11 +28,14 @@ export class EvaluationTask implements ScheduledTask { async fetchEvaluations() { try { await BKTClientImpl.fetchEvaluationsInternal(this.component) + if (!this.running) return // guard: stop() may have run while this was in flight // success this.retryCount = 0 this.reschedule(this.component.config().pollingInterval) } catch { + if (!this.running) return // guard: stop() may have run while this was in flight + // error const pollingInterval = this.component.config().pollingInterval const isLongInterval = pollingInterval > this.retryPollingInterval @@ -54,9 +57,16 @@ export class EvaluationTask implements ScheduledTask { return this.running } - start(): void { + // immediate = true fetches right away instead of waiting a full + // pollingInterval. A flag, not a second method — only one caller needs it + // (StreamingTask's polling fallback). + start(immediate = false): void { this.running = true - this.reschedule(this.component.config().pollingInterval) + if (immediate) { + this.fetchEvaluations() + } else { + this.reschedule(this.component.config().pollingInterval) + } } stop(): void { clearTimeout(this.timerId) diff --git a/src/internal/scheduler/TaskScheduler.ts b/src/internal/scheduler/TaskScheduler.ts index 4e6b4e90..108d0cb9 100644 --- a/src/internal/scheduler/TaskScheduler.ts +++ b/src/internal/scheduler/TaskScheduler.ts @@ -1,16 +1,22 @@ import { Component } from '../di/Component' +import { StreamingTask } from '../streaming/StreamingTask' import { EvaluationTask } from './EvaluationTask' import { EventTask } from './EventTask' import { ScheduledTask } from './ScheduledTask' +// Coalesces a burst of updateUserAttributes() calls (e.g. several attributes +// set at login) into a single reconnect() instead of one per call. +const RECONNECT_STREAMING_DEBOUNCE_MILLIS = 200 + export class TaskScheduler { private schedulers: ScheduledTask[] + private reconnectStreamingTimer: ReturnType | undefined constructor(private component: Component) { - this.schedulers = [ - new EvaluationTask(this.component), - new EventTask(this.component), - ] + const mainTask = this.component.config().enableStreaming + ? new StreamingTask(this.component) + : new EvaluationTask(this.component) + this.schedulers = [mainTask, new EventTask(this.component)] } start() { @@ -19,5 +25,22 @@ export class TaskScheduler { stop() { this.schedulers.forEach((scheduler) => scheduler.stop()) + clearTimeout(this.reconnectStreamingTimer) + this.reconnectStreamingTimer = undefined + } + + // Called by BKTClientImpl.updateUserAttributes when streaming is active. + // No-op when polling (the find returns nothing). Debounced: harmless if the + // timer fires after stop() (reconnect() no-ops when not running), but + // stop() clears it anyway so destroy doesn't leave a timer behind. + reconnectStreaming(): void { + const task = this.schedulers.find((s) => s instanceof StreamingTask) as + | StreamingTask + | undefined + if (!task) return + clearTimeout(this.reconnectStreamingTimer) + this.reconnectStreamingTimer = setTimeout(() => { + task.reconnect() + }, RECONNECT_STREAMING_DEBOUNCE_MILLIS) } } diff --git a/src/internal/streaming/Backoff.ts b/src/internal/streaming/Backoff.ts new file mode 100644 index 00000000..e1498fd3 --- /dev/null +++ b/src/internal/streaming/Backoff.ts @@ -0,0 +1,26 @@ +const DEFAULT_INITIAL_DELAY_MILLIS = 1_000 +const MAX_DELAY_MILLIS = 30_000 +const JITTER_RATIO = 0.5 + +export class Backoff { + private attempt = 0 + + constructor( + private readonly initialDelayMillis = DEFAULT_INITIAL_DELAY_MILLIS, + private readonly maxDelayMillis = MAX_DELAY_MILLIS, + ) {} + + // Call to get the delay before the next reconnect attempt. + nextDelayMillis(): number { + const base = Math.min( + this.initialDelayMillis * 2 ** this.attempt, + this.maxDelayMillis, + ) + this.attempt++ + return base - Math.trunc(Math.random() * JITTER_RATIO * base) + } + + reset(): void { + this.attempt = 0 + } +} diff --git a/src/internal/streaming/EventSourceLike.ts b/src/internal/streaming/EventSourceLike.ts new file mode 100644 index 00000000..ecb4e26d --- /dev/null +++ b/src/internal/streaming/EventSourceLike.ts @@ -0,0 +1,54 @@ +// Injectable transport contract for SSE streaming. +// +// 'message' / onmessage is not an event name the backend chooses — it's the +// SSE/EventSource web standard's reserved default event type for a block with +// no `event:` line (WHATWG HTML spec, "Server-sent events"). Native browser +// EventSource dispatches such blocks via `.onmessage` / +// `addEventListener('message', ...)`; any injected implementation should do +// the same. +// +// Injection note: the message/onmessage channel always counts as proof of +// liveness, data or not — the built-in FetchEventSource emits a bare +// onmessage({ data: undefined }) tick for every received chunk, so even SSE +// comment heartbeats (": ping") keep the stream alive. Any other (named) event +// counts as liveness only if it carries data — a dataless named event (e.g. a +// connection-error `error` event with no payload) is a failure signal, not +// proof of life. Native-style EventSource implementations do NOT surface +// comment lines — if you inject one and the backend heartbeats with comments +// only, the watchdog will false-trip. Injected implementations should either +// emit named/unnamed events for heartbeats or provide their own liveness +// signalling. The init.headers an injected implementation receives already +// carry the complete request profile (Authorization, Content-Type: +// application/json, Accept: text/event-stream) — it should send them as-is and +// must not depend on the SDK's built-in transport to add anything. +export interface EventSourceLike { + new (url: string, init?: EventSourceLikeInit): EventSourceInstance +} + +export interface EventSourceLikeInit { + method?: string + headers?: Record + body?: string | null +} + +export interface EventSourceErrorLike { + status?: number // HTTP status when known + terminal?: boolean // true = retrying can never succeed (e.g. streaming unsupported) +} + +export interface EventSourceInstance { + readonly readyState: number + onopen: ((ev: unknown) => void) | null + onmessage: ((ev: MessageEventLike) => void) | null + onerror: ((ev: EventSourceErrorLike | unknown) => void) | null + addEventListener(type: string, listener: (ev: MessageEventLike) => void): void + removeEventListener( + type: string, + listener: (ev: MessageEventLike) => void, + ): void + close(): void +} + +export interface MessageEventLike { + data?: string +} diff --git a/src/internal/streaming/FetchEventSource.ts b/src/internal/streaming/FetchEventSource.ts new file mode 100644 index 00000000..68eae328 --- /dev/null +++ b/src/internal/streaming/FetchEventSource.ts @@ -0,0 +1,240 @@ +import { FetchLike } from '../remote/fetch' +import { + EventSourceInstance, + EventSourceLikeInit, + MessageEventLike, +} from './EventSourceLike' + +const READY_STATE_CONNECTING = 0 +const READY_STATE_OPEN = 1 +const READY_STATE_CLOSED = 2 + +// SSE lines may end with \r\n, \n, or \r (WHATWG spec). Normalize to \n so the +// parser only deals with one framing. Idempotent on already-normalized text. +const normalizeLineEndings = (text: string): string => + text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + +export class FetchEventSource implements EventSourceInstance { + readyState: number = READY_STATE_CONNECTING + onopen: ((ev: unknown) => void) | null = null + onmessage: ((ev: MessageEventLike) => void) | null = null + onerror: + | ((ev: { status?: number; terminal?: boolean } | unknown) => void) + | null = null + + private readonly listeners = new Map< + string, + Array<(ev: MessageEventLike) => void> + >() + private abortController: AbortController | null = null + + constructor( + private readonly url: string, + private readonly init: EventSourceLikeInit = {}, + private readonly fetchImpl: FetchLike, + ) { + this.connect() + } + + addEventListener( + type: string, + listener: (ev: MessageEventLike) => void, + ): void { + if (!this.listeners.has(type)) this.listeners.set(type, []) + this.listeners.get(type)!.push(listener) + } + + removeEventListener( + type: string, + listener: (ev: MessageEventLike) => void, + ): void { + const arr = this.listeners.get(type) + if (!arr) return + const i = arr.indexOf(listener) + if (i !== -1) arr.splice(i, 1) + } + + close(): void { + this.readyState = READY_STATE_CLOSED + this.abortController?.abort() + this.abortController = null + } + + // private + + private connect(): void { + if (this.readyState === READY_STATE_CLOSED) return + const ac = new AbortController() + this.abortController = ac + + // Object.assign, not spread: caller headers (e.g. Authorization) must win + // over the defaults, and the no-spread-after-defaults lint rule forbids + // writing that as a spread after default properties. + const headers = Object.assign( + { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }, + this.init.headers, + ) + + // Call the injected fetch receiver-free: `this.fetchImpl(...)` would pass + // this FetchEventSource instance as `this`, and the default unbound + // `globalThis.fetch` brand-checks its receiver in browsers ("Illegal + // invocation"). A bare call leaves `this` undefined → global fallback. + const doFetch = this.fetchImpl + doFetch(this.url, { + method: this.init.method ?? 'POST', + headers, + body: this.init.body ?? '', + signal: ac.signal, + }) + .then((response) => { + if (this.readyState === READY_STATE_CLOSED) return + if (!response.ok) { + this.readyState = READY_STATE_CLOSED + this.onerror?.({ status: response.status }) + return + } + if (!response.body || typeof response.body.getReader !== 'function') { + // No WHATWG ReadableStream: the runtime's fetch cannot stream (e.g. + // React Native without a polyfill), or an injected fetch returns a + // non-WHATWG body (e.g. node-fetch → Node.js Readable, no + // getReader()). Retrying can never succeed with this fetch + // implementation → terminal. + this.readyState = READY_STATE_CLOSED + this.onerror?.({ terminal: true }) + return + } + this.readyState = READY_STATE_OPEN + this.onopen?.({}) + return this.readStream(response.body) + }) + .then(() => { + // Stream ended naturally (server closed) — signal a recoverable error + // so StreamConnection can decide whether to reconnect. + if (this.readyState !== READY_STATE_CLOSED) { + this.readyState = READY_STATE_CLOSED + this.onerror?.({}) + } + }) + .catch((err: unknown) => { + if (this.readyState === READY_STATE_CLOSED) return + if (err instanceof Error && err.name === 'AbortError') return + this.readyState = READY_STATE_CLOSED + this.onerror?.(err) + }) + } + + private async readStream(body: ReadableStream): Promise { + const reader = body.getReader() + const decoder = new TextDecoder() + // Retained remainder, already normalized — never re-normalized (that was + // the quadratic cost: every chunk re-normalizing everything received so + // far). Only the newly-decoded chunk gets normalized before appending. + let buffer = '' + // A trailing \r held back from the previous chunk may be half of a \r\n + // split across the chunk boundary — prepended to the next chunk's raw + // text before normalizing it (see the CRLF-split test). + let pendingCR = '' + // Resume point for the \n\n scan in parseBuffer(): the portion of buffer + // before this offset has already been searched and found clean, so it's + // never rescanned — avoids re-scanning a large, still-growing block on + // every chunk. + let searchOffset = 0 + try { + while (this.readyState === READY_STATE_OPEN) { + const { done, value } = await reader.read() + if (done) { + // End of stream: flush the decoder (the stream may end mid + // multi-byte character). The output is deliberately discarded — it + // can only belong to an unterminated block, and SSE dispatches + // events only on a blank-line terminator, so nothing parseable is + // ever lost here. + decoder.decode() + break + } + // Liveness tick: fire a bare onmessage on every chunk so the caller's + // watchdog resets on any received bytes, including SSE comment + // heartbeats (": ping"). The tick carries no data. NOTE: StreamConnection + // wires es.onmessage unconditionally (see its openConnection()), so this + // tick always reaches it regardless of what the caller registered. + this.onmessage?.({ data: undefined }) + let chunkText = pendingCR + decoder.decode(value, { stream: true }) + pendingCR = '' + if (chunkText.endsWith('\r')) { + pendingCR = '\r' + chunkText = chunkText.slice(0, -1) + } + buffer += normalizeLineEndings(chunkText) + buffer = this.parseBuffer(buffer, searchOffset) + // Resume next scan from one char before the end, so a '\n\n' split + // across this chunk and the next (one '\n' at the very end, the other + // at the start of the next chunk) is still caught. + searchOffset = Math.max(0, buffer.length - 1) + } + } finally { + try { + await reader.cancel() + } catch { + // cancel() rejects if the stream already errored — nothing to clean up + } + } + } + + // Dispatches complete SSE events out of the (LF-normalized) buffer, + // scanning for the '\n\n' block separator with indexOf from searchFrom + // instead of splitting the whole buffer — the portion before searchFrom + // already went through a previous call and contained no separator, so it + // never needs rescanning. Returns the unconsumed remainder; the caller + // derives the next searchFrom from its length. + private parseBuffer(buffer: string, searchFrom: number): string { + let offset = searchFrom + while (true) { + const sepIndex = buffer.indexOf('\n\n', offset) + if (sepIndex === -1) { + // No complete block beyond what's already been scanned. + return buffer + } + this.dispatchBlock(buffer.slice(0, sepIndex)) + buffer = buffer.slice(sepIndex + 2) + offset = 0 + } + } + + private dispatchBlock(block: string): void { + if (!block.trim()) return + // 'message' is not a name the backend chooses — it's the SSE/EventSource + // web standard's reserved default event type for a block with no + // `event:` line (WHATWG HTML spec, "Server-sent events"). A native + // browser EventSource dispatches such blocks via `.onmessage` / + // `addEventListener('message', ...)`; this mirrors that rule. + let eventName = 'message' + const dataLines: string[] = [] + for (const line of block.split('\n')) { + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trimStart()) + } else if (line.startsWith('event:')) { + // An empty event type buffer leaves the type as the default + // 'message' (WHATWG), so an empty `event:` line behaves like none. + const name = line.slice(6).trim() + eventName = name === '' ? 'message' : name + } + // Comment lines (':') count as liveness via the per-chunk tick above + } + if (dataLines.length === 0) return + const ev: MessageEventLike = { data: dataLines.join('\n') } + const handlers = this.listeners.get(eventName) + if (handlers && handlers.length > 0) { + handlers.forEach((h) => h(ev)) + } else if (eventName === 'message') { + // The default 'message' type — whether from no `event:` line, an empty + // one, or an explicit `event: message` — falls back to onmessage, per + // the SSE/EventSource standard. A NAMED event with no registered + // listener is dropped silently instead (its name is never 'message'), + // so an unknown event name can't be misrouted into onmessage and + // misinterpreted as evaluation data. + this.onmessage?.(ev) + } + } +} diff --git a/src/internal/streaming/StreamConnection.ts b/src/internal/streaming/StreamConnection.ts new file mode 100644 index 00000000..5a319043 --- /dev/null +++ b/src/internal/streaming/StreamConnection.ts @@ -0,0 +1,243 @@ +import { Backoff } from './Backoff' +import { + EventSourceErrorLike, + EventSourceInstance, + EventSourceLike, + EventSourceLikeInit, + MessageEventLike, +} from './EventSourceLike' +import { isRecoverableStatus, isTerminalStatus } from './httpStatus' + +// Must be > the backend heartbeat interval so a healthy stream never false-trips. +const WATCHDOG_TIMEOUT_MILLIS = 70_000 +// Max duration of an unhealthy period before giving up and letting the caller +// fall back to polling. Applies whether or not the stream ever opened. +const UNHEALTHY_FALLBACK_TIMEOUT_MILLIS = 120_000 +// A connection open this long counts as proven-stable: forgive prior failures so +// the next drop backs off from scratch instead of continuing to escalate. +const RESET_INTERVAL_MILLIS = 60_000 + +export interface StreamConnectionErrorInfo { + // true → retrying can never succeed (auth failure, streaming unsupported); + // the caller must not schedule streaming recovery. + terminal: boolean +} + +export interface StreamConnectionCallbacks { + onOpen: () => void + // Called only when this connection gives up: terminal error, non-recoverable + // status, or unhealthy for > UNHEALTHY_FALLBACK_TIMEOUT_MILLIS. + // Brief transient drops self-heal internally and do NOT call this. + onError: (info: StreamConnectionErrorInfo) => void +} + +export interface StreamConnectionOptions { + eventSource: EventSourceLike + // Re-invoked on every (re)connect so reconnect() picks up fresh URL/headers/body. + requestBuilder: () => { url: string; init?: EventSourceLikeInit } + // Named backend events only (e.g. 'put', 'patch', 'error'), wired via + // addEventListener. A 'message' key is ignored — that channel is owned by + // this class (see openConnection()). A named event proves liveness only + // when it carries data. + events: Record void> + // Optional: receives data that matched no named handler above — a genuinely + // unnamed SSE event ('message' is the SSE standard's default event type for + // a block with no `event:` line — see EventSourceLike.ts) or, with the + // built-in FetchEventSource, a named event nobody registered. Liveness + // tracking on the message channel does NOT depend on this being provided. + onUnhandledMessage?: (data: string) => void + callbacks: StreamConnectionCallbacks +} + +export class StreamConnection { + private es: EventSourceInstance | null = null + private watchdog: ReturnType | undefined + private reconnectTimer: ReturnType | undefined + private backoffResetTimer: ReturnType | undefined + private readonly backoff = new Backoff() + private unhealthySince = 0 // 0 = healthy + private active = false + + constructor(private readonly options: StreamConnectionOptions) {} + + start(): void { + this.active = true + this.openConnection() + } + + // External reconnect (e.g. attribute change) — fresh request, reset backoff. + // Deliberately does NOT clear unhealthySince: an already-unhealthy connection + // must keep its UNHEALTHY_FALLBACK_TIMEOUT_MILLIS give-up deadline, or an app + // calling updateUserAttributes() more often than that window would postpone + // the polling fallback forever while the stream endpoint stays down. A + // healthy connection already has unhealthySince === 0, and markHealthy() + // keeps clearing it on real liveness, so this only affects the unhealthy case. + reconnect(): void { + if (!this.active) return + this.backoff.reset() + this.openConnection() + } + + stop(): void { + this.active = false + this.clearReconnectTimer() + this.closeEventSource() + } + + // private + + private openConnection(): void { + // Single-connection invariant: kill any pending retry and any live + // EventSource before opening a new one (an external reconnect() racing a + // scheduled backoff retry must not produce two streams). + this.clearReconnectTimer() + this.closeEventSource() + + let es: EventSourceInstance + try { + const { url, init } = this.options.requestBuilder() + es = new this.options.eventSource(url, init) + } catch { + // requestBuilder() or the eventSource constructor threw synchronously + // (e.g. a misbehaving injected EventSourceLike). openConnection() runs + // from a setTimeout callback (backoff/watchdog/recovery) as well as + // from start()/reconnect() — an uncaught throw there would crash a + // Node process. Same give-up path as a runtime onerror: caller decides + // (StreamingTask starts the polling fallback + schedules recovery). + this.closeEventSource() + this.options.callbacks.onError({ terminal: false }) + return + } + this.es = es + + es.onopen = () => { + if (this.es !== es) return // stale instance — already replaced + this.armBackoffReset() + this.resetWatchdog() + this.options.callbacks.onOpen() + } + + // 'message' is not a name the backend chooses — it's the SSE/EventSource web + // standard's reserved event type for a block with no `event:` line (see + // EventSourceLike.ts). It is wired here UNCONDITIONALLY — not gated behind + // the caller supplying onUnhandledMessage — because FetchEventSource's + // per-chunk liveness tick always fires through onmessage, with or without + // data, even for a chunk containing nothing but the backend's heartbeat + // comment. That tick alone proves the connection is delivering bytes, so it + // always counts as healthy, data or not. This unconditional wiring is what + // makes liveness tracking a structural guarantee instead of something a + // caller can opt out of by editing a map. + es.onmessage = (ev) => { + if (this.es !== es) return // stale instance — already replaced + this.markHealthy() + if (ev?.data !== undefined) this.options.onUnhandledMessage?.(ev.data) + } + + // Wire every caller-named event (e.g. 'put', 'patch', 'error') — never + // 'message', which is reserved for the unconditional channel above. Unlike + // 'message', a named event only proves liveness if it actually carries data: + // + // named event WITH data → mark HEALTHY, deliver to the handler + // (e.g. the backend sends `event: error` + a data payload — real bytes + // arrived, so it counts, even though the payload itself reports a + // failure) + // named event with NO data → do NOT mark healthy + // (e.g. a native EventSource's connection-error 'error' event carries no + // data — that's a failure signal, not proof the stream is working; + // counting it would let a repeatedly failing connection mask itself as + // healthy forever) + Object.entries(this.options.events).forEach(([name, handler]) => { + if (name === 'message') return // reserved for the channel above + const wrapped = (ev: MessageEventLike) => { + if (this.es !== es) return // stale instance — already replaced + if (ev?.data !== undefined) { + this.markHealthy() + handler(ev.data) + } + } + es.addEventListener(name, wrapped) + }) + + es.onerror = (ev) => { + if (this.es !== es) return // stale instance — already replaced + const info = (ev ?? {}) as EventSourceErrorLike + if (info.terminal === true || isTerminalStatus(info.status)) { + this.closeEventSource() + this.options.callbacks.onError({ terminal: true }) + return + } + if (isRecoverableStatus(info.status)) { + // Self-heal with backoff, bounded by the unhealthy window — whether or + // not the stream ever opened (UNHEALTHY_FALLBACK_TIMEOUT_MILLIS's + // "applies whether or not the stream ever opened" comment above). + this.scheduleReconnect() + return + } + // A non-recoverable status → give up; caller decides. + this.closeEventSource() + this.options.callbacks.onError({ terminal: false }) + } + + // Also acts as the connect timeout: if the request hangs without opening, + // the watchdog trips and scheduleReconnect() bounds the retries. + this.resetWatchdog() + } + + private scheduleReconnect(): void { + this.closeEventSource() + if (!this.active) return + const now = Date.now() + if (this.unhealthySince === 0) { + this.unhealthySince = now + } + if (now - this.unhealthySince > UNHEALTHY_FALLBACK_TIMEOUT_MILLIS) { + this.options.callbacks.onError({ terminal: false }) + return + } + this.clearReconnectTimer() + const delay = this.backoff.nextDelayMillis() + this.reconnectTimer = setTimeout(() => this.openConnection(), delay) + } + + // A liveness signal: the message channel (any tick, data or not), or a + // named event that actually carried data. + private markHealthy(): void { + this.unhealthySince = 0 + this.resetWatchdog() + } + + // Connection has been open — schedule forgiving prior failures once it's + // stayed stable for RESET_INTERVAL_MILLIS. closeEventSource() cancels this if + // the connection drops before then, so flapping connections keep escalating. + private armBackoffReset(): void { + clearTimeout(this.backoffResetTimer) + this.backoffResetTimer = setTimeout(() => { + this.backoff.reset() + }, RESET_INTERVAL_MILLIS) + } + + private resetWatchdog(): void { + this.cancelWatchdog() + this.watchdog = setTimeout(() => { + this.scheduleReconnect() + }, WATCHDOG_TIMEOUT_MILLIS) + } + + private cancelWatchdog(): void { + clearTimeout(this.watchdog) + this.watchdog = undefined + } + + private clearReconnectTimer(): void { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = undefined + } + + private closeEventSource(): void { + this.cancelWatchdog() + clearTimeout(this.backoffResetTimer) + this.backoffResetTimer = undefined + this.es?.close() + this.es = null + } +} diff --git a/src/internal/streaming/StreamingTask.ts b/src/internal/streaming/StreamingTask.ts new file mode 100644 index 00000000..0b55719c --- /dev/null +++ b/src/internal/streaming/StreamingTask.ts @@ -0,0 +1,307 @@ +import { Component } from '../di/Component' +import { UserAttributesState } from '../evaluation/EvaluationStorage' +import { requiredInternalConfig } from '../InternalConfig' +import { StreamEvaluationsRequest } from '../model/request/StreamEvaluationsRequest' +import { GetEvaluationsResponse } from '../model/response/GetEvaluationsResponse' +import { EvaluationTask } from '../scheduler/EvaluationTask' +import { ScheduledTask } from '../scheduler/ScheduledTask' +import { FetchEventSource } from './FetchEventSource' +import { EventSourceLike, EventSourceLikeInit } from './EventSourceLike' +import { StreamConnection, StreamConnectionErrorInfo } from './StreamConnection' + +const STREAM_EVALUATIONS_PATH = '/stream_evaluations' +const RECOVERY_INTERVAL_MILLIS = 5 * 60_000 + +// Minimal structural check, not full validation: just enough to stop a +// misrouted/malformed payload from being blind-cast and handed to +// EvaluationStorage's writes. See handleData()'s defense-in-depth comment. +// forceUpdate is allowed to be absent: a protobuf-JSON marshaler that omits +// zero-valued fields sends no key for forceUpdate=false, and the write path +// treats a missing forceUpdate as false — same tolerance as the REST path. +function isGetEvaluationsResponseShape( + value: unknown, +): value is GetEvaluationsResponse { + if (typeof value !== 'object' || value === null) return false + const response = value as Record + if (typeof response.userEvaluationsId !== 'string') return false + const evaluations = response.evaluations + if (typeof evaluations !== 'object' || evaluations === null) return false + const forceUpdate = (evaluations as Record).forceUpdate + return forceUpdate === undefined || typeof forceUpdate === 'boolean' +} + +export class StreamingTask implements ScheduledTask { + private connection: StreamConnection | null = null + private fallbackTask: EvaluationTask | null = null + private recoveryTimer: ReturnType | undefined + private running = false + // Snapshot captured by buildRequest(); onOpen clears the flag with it, so + // only a request that actually carried these attributes can clear the flag + // they belong to. See EvaluationStorage.clearUserAttributesUpdated(). + private lastRequestAttributesState: UserAttributesState | undefined + // Set by handleError() on a terminal failure (bad API key, streaming + // unsupported) — reconnect() must not retry streaming in that case, only + // the polling fallback remains viable. See reconnect() and handleError(). + // + // WARNING — never reset, by design: today every start() runs on a freshly + // constructed StreamingTask (TaskScheduler builds a new one on every + // scheduleTasks() call — see BKTClient.ts), so this field starting false + // per instance is enough. If a start()/stop() REUSE pattern is ever added + // to this class, start() MUST reset this to false there, or a restarted + // task will stay permanently stuck on the polling fallback because of a + // previous instance's terminal error. + private terminalFailure = false + + constructor(private readonly component: Component) {} + + isRunning(): boolean { + return this.running + } + + start(): void { + this.running = true + this.openStream() + } + + // Called by TaskScheduler.reconnectStreaming() on user attribute change. + reconnect(): void { + if (!this.running) return + // Stream is permanently dead (bad API key, streaming unsupported) — the + // polling fallback keeps running untouched, exact parity with pure + // polling mode where updateUserAttributes() doesn't force an immediate + // fetch either. + if (this.terminalFailure) return + if (this.connection) { + // Transport re-invokes requestBuilder → picks up fresh attributes. + this.connection.reconnect() + } else { + // Currently on polling fallback (or idle after a terminal error) — jump + // straight back to streaming. No stopFallback() here: the poller keeps + // running until onOpen proves the new stream actually works (onOpen + // calls stopFallback() itself) — otherwise there'd be a polling gap for + // however long this attempt takes to open or fail. + this.openStream() + } + } + + stop(): void { + this.running = false + this.connection?.stop() + this.connection = null + this.stopFallback() + } + + // private + + private openStream(): void { + // Cancel any pending streaming-recovery timer at entry. reconnect()'s + // fallback branch and the recovery callback both open a stream without + // calling stopFallback() first, so a recovery timer armed earlier must be + // cleared here — otherwise it could fire after this opened a fresh + // connection and create a second, leaked one. (This clear is also what + // keeps every caller's precondition true: they all reach openStream() + // with this.connection === null, since no recovery timer can outlive it.) + clearTimeout(this.recoveryTimer) + + const config = requiredInternalConfig(this.component.config()) + // Prefer the user-injected EventSource; fall back to our FetchEventSource. + const eventSource: EventSourceLike = + config.eventSource ?? this.makeFetchEventSourceClass() + + this.connection = new StreamConnection({ + eventSource, + requestBuilder: () => this.buildRequest(), + events: { + // handleData is async; the events map requires void-returning handlers, + // so the call is explicitly caught here — otherwise a rejection (e.g. a + // storage failure) would surface as an unhandled promise rejection. + // Backend event names (evaluations.go): 'put' is the full snapshot sent + // once after connecting, 'patch' is a per-change diff. + put: (data) => { + this.handleData(data).catch((e) => { + console.error('StreamingTask: failed to handle put event', e) + }) + }, + patch: (data) => { + this.handleData(data).catch((e) => { + console.error('StreamingTask: failed to handle patch event', e) + }) + }, + // The backend sends this right before closing the stream to report an + // internal error. Handle it distinctly instead of letting it fall + // through to handleData, which would JSON-parse it fine and then throw + // on the missing GetEvaluationsResponse shape. + error: (data) => { + console.error('StreamingTask: server reported a stream error', data) + }, + }, + // Defensive fallback for a message that matched none of the named + // handlers above — a genuinely unnamed SSE event (the SSE/EventSource + // standard's 'message' default — see EventSourceLike.ts). This backend + // always names its events (put/patch/error), so this normally never + // fires with data. StreamConnection tracks liveness on this channel + // unconditionally, whether or not this callback is provided. + onUnhandledMessage: (data) => { + this.handleData(data).catch((e) => { + console.error('StreamingTask: failed to handle message event', e) + }) + }, + callbacks: { + onOpen: () => { + this.stopFallback() + this.clearUserAttributesUpdated() + }, + onError: (info) => this.handleError(info), + }, + }) + this.connection.start() + } + + // Returns an EventSourceLike constructor that closes over config.fetch, so the + // standard StreamConnection interface can call `new eventSource(url, init)`. + private makeFetchEventSourceClass(): EventSourceLike { + const fetch = requiredInternalConfig(this.component.config()).fetch + return class extends FetchEventSource { + constructor(url: string, init?: EventSourceLikeInit) { + super(url, init ?? {}, fetch) + } + } + } + + // Single unified POST body profile — identical on every platform. + // Credentials go in the Authorization header; user identification in the body. + // The FULL header profile lives here so injected EventSourceLike + // implementations receive a complete request; the built-in FetchEventSource + // merely re-asserts the same Content-Type/Accept defaults defensively. + private buildRequest(): { url: string; init: EventSourceLikeInit } { + const config = requiredInternalConfig(this.component.config()) + const user = this.component.userHolder().get() + // Re-read on every (re)connect (this method is re-invoked by + // StreamConnection's requestBuilder), so a reconnect always carries the + // latest cached state — the backend can then reply with a diff instead of + // a full snapshot. Same '' / '0' defaults as the polling path + // (EvaluationInteractor.fetch()) for a fresh install that has never + // cached any evaluations yet (getCurrentEvaluationsCondition() is always + // called after initialize() — see its own comment — so these defaults + // handle "initialized but empty," not "not yet initialized"). + const condition = this.component + .evaluationInteractor() + .getCurrentEvaluationsCondition() + // Captured (not sent — the backend re-evaluates from user.data on every + // reconnect, no wire field needed) so onOpen can clear the flag only if + // it's still the latest snapshot. See EvaluationStorage. + // clearUserAttributesUpdated() and the onOpen callback below. + this.lastRequestAttributesState = this.component + .evaluationInteractor() + .getUserAttributesState() + const body: StreamEvaluationsRequest = { + tag: config.featureTag, + user: { id: user.id, data: user.data }, + sourceId: config.sourceId, + sdkVersion: config.sdkVersion, + userEvaluationsId: condition.currentEvaluationsId ?? '', + evaluatedAt: condition.evaluatedAt ?? '0', + } + return { + url: `${config.apiEndpoint}${STREAM_EVALUATIONS_PATH}`, + init: { + method: 'POST', + headers: { + Authorization: config.apiKey, + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }, + body: JSON.stringify(body), + }, + } + } + + // Called from onOpen: this connection's request carried the attributes in + // lastRequestAttributesState, so it's safe to clear — but only if no newer + // setUserAttributesUpdated() call has landed since (guarded by + // EvaluationStorage.clearUserAttributesUpdated()'s sequence check). A + // failed connect never calls this, so the flag survives for the polling + // fallback to send. + private clearUserAttributesUpdated(): void { + const state = this.lastRequestAttributesState + if (!state) return + this.component + .evaluationInteractor() + .clearUserAttributesUpdated(state) + .catch((e) => { + console.error( + 'StreamingTask: failed to clear userAttributesUpdated flag', + e, + ) + }) + } + + private handleError(info: StreamConnectionErrorInfo): void { + if (!this.running) return + this.connection?.stop() + this.connection = null + this.startFallback() + if (info.terminal) { + // Remembered so reconnect() (e.g. a later updateUserAttributes() call) + // doesn't retry a permanently dead stream — see reconnect(). + this.terminalFailure = true + } else { + // Terminal failures (bad API key, streaming unsupported) are not retried; + // everything else gets a streaming retry after the recovery interval. + this.scheduleRecovery() + } + } + + private async handleData(data: string): Promise { + if (!this.running) return // guard: data may arrive after stop() + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + return + } + // Defense in depth: even with FetchEventSource routing named events with + // no listener away from onmessage, a shape check here stops any other + // misrouted/malformed payload (not just an unknown event name) from + // reaching deleteAllAndInsert()/update() with garbage. + if (!isGetEvaluationsResponseShape(parsed)) return + const response = parsed + // shouldNotify is re-checked after the awaited storage write: a stop()/ + // destroy racing that write must not fire update listeners into + // torn-down app code (the write itself may land — unused cached data). + await this.component + .evaluationInteractor() + .applyEvaluationsResponse(response, () => this.running) + } + + private startFallback(): void { + if (!this.component.config().streamingFallbackToPolling) return + if (!this.fallbackTask) { + this.fallbackTask = new EvaluationTask(this.component) + // Named instead of passed as a bare literal so the call site reads on + // its own, without needing to check start()'s signature. + const immediately = true + this.fallbackTask.start(immediately) // fetch immediately instead of waiting a full pollingInterval + } + } + + private scheduleRecovery(): void { + clearTimeout(this.recoveryTimer) + this.recoveryTimer = setTimeout(() => { + if (!this.running) return + // No stopFallback() here: the poller keeps running until onOpen proves + // the reopened stream actually works — otherwise every 5-minute + // recovery attempt would open a polling gap for as long as it takes to + // open or fail (see openStream()'s onOpen callback and its own + // defensive clearTimeout(this.recoveryTimer) at entry). + this.openStream() + }, RECOVERY_INTERVAL_MILLIS) + } + + private stopFallback(): void { + this.fallbackTask?.stop() + this.fallbackTask = null + clearTimeout(this.recoveryTimer) + this.recoveryTimer = undefined + } +} diff --git a/src/internal/streaming/httpStatus.ts b/src/internal/streaming/httpStatus.ts new file mode 100644 index 00000000..e412663b --- /dev/null +++ b/src/internal/streaming/httpStatus.ts @@ -0,0 +1,40 @@ +export function isRecoverableStatus(status: number | undefined): boolean { + if (status === undefined) { + return true + } + if (status >= 400 && status < 500) { + // 499 ("client closed request") is a deployment-related status post.ts + // already retries for the polling API (ClientClosedRequestException) — a + // backend rollout that polling survives must not kill the stream instead. + return ( + status === 400 || status === 408 || status === 429 || status === 499 + ) + } + return true +} + +// Statuses where retrying the exact same request can never produce a different +// outcome: auth failures (401/403), and 4xx responses tied to the fixed shape of +// the request itself (method/headers/body) rather than transient server state. +// 404 is included deliberately: unlike the deployment-related 499 that `post.ts` +// retries for the polling API, this codebase has no established convention that +// treats 404 as a signal of an in-progress backend rollout, so it gets no benefit +// of the doubt here. +const TERMINAL_STATUSES = new Set([ + 401, // Unauthorized — bad API key + 403, // Forbidden — bad API key + 404, // Not Found — same URL, will not appear on its own + 405, // Method Not Allowed — same method every request + 406, // Not Acceptable — same fixed Accept header every request + 410, // Gone — permanent by definition + 413, // Payload Too Large — same body every request + 414, // URI Too Long — malformed URL, won't self-resolve + 415, // Unsupported Media Type — same fixed Content-Type every request + 422, // Unprocessable Entity — same body fails validation every request + 431, // Request Header Fields Too Large — same headers every request + 451, // Unavailable For Legal Reasons — permanent +]) + +export function isTerminalStatus(status: number | undefined): boolean { + return status !== undefined && TERMINAL_STATUSES.has(status) +} diff --git a/src/main.browser.ts b/src/main.browser.ts index 0811f69d..829ac832 100644 --- a/src/main.browser.ts +++ b/src/main.browser.ts @@ -29,6 +29,13 @@ export type { BKTJsonPrimitive, } from './BKTValue' export type { BKTEvaluationDetails } from './BKTEvaluationDetails' +export type { + EventSourceLike, + EventSourceLikeInit, + EventSourceInstance, + EventSourceErrorLike, + MessageEventLike, +} from './internal/streaming/EventSourceLike' export { setupPageLifecycleListeners, supportsSendBeacon, diff --git a/src/main.native.ts b/src/main.native.ts index 25576c3e..ea564c55 100644 --- a/src/main.native.ts +++ b/src/main.native.ts @@ -29,6 +29,13 @@ export type { BKTJsonPrimitive, } from './BKTValue' export type { BKTEvaluationDetails } from './BKTEvaluationDetails' +export type { + EventSourceLike, + EventSourceLikeInit, + EventSourceInstance, + EventSourceErrorLike, + MessageEventLike, +} from './internal/streaming/EventSourceLike' // This endpoint is intended for use in React Native - Expo environments. const createComponent = (config: BKTConfig, user: User): Component => { diff --git a/src/main.ts b/src/main.ts index 4c82ac1c..15fbc00d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,6 +28,13 @@ export type { BKTJsonPrimitive, } from './BKTValue' export type { BKTEvaluationDetails } from './BKTEvaluationDetails' +export type { + EventSourceLike, + EventSourceLikeInit, + EventSourceInstance, + EventSourceErrorLike, + MessageEventLike, +} from './internal/streaming/EventSourceLike' const createNodeComponent = (config: BKTConfig, user: User): Component => { return new DefaultComponent( diff --git a/test/BKTClient.spec.ts b/test/BKTClient.spec.ts index f440b49b..9f3c7d3c 100644 --- a/test/BKTClient.spec.ts +++ b/test/BKTClient.spec.ts @@ -12,6 +12,7 @@ import { vi, } from 'vitest' import { + BKTClientImpl, defaultStringToTypeConverter, destroyBKTClient, getBKTClient, @@ -50,6 +51,13 @@ import { InteractorModule } from '../src/internal/di/InteractorModule' import { BKTEvaluationDetails } from '../src/BKTEvaluationDetails' import { requiredInternalConfig } from '../src/internal/InternalConfig' import { SourceId } from '../src/internal/model/SourceId' +import { + EventSourceErrorLike, + EventSourceInstance, + EventSourceLike, + EventSourceLikeInit, + MessageEventLike, +} from '../src/internal/streaming/EventSourceLike' suite('BKTClient', () => { let server: SetupServer @@ -179,6 +187,173 @@ suite('BKTClient', () => { await initializeBKTClientInternal(component, 1000) }) + + test('streaming: first connect does not throw even though StreamingTask starts before the fetchEvaluations() call (regression guard for the init-ordering bug)', async () => { + // StreamingTask.start() synchronously reads the evaluation cache via + // buildRequest() -> getCurrentEvaluationsCondition(), which throws if + // called before evaluationInteractor().initialize() has resolved. A + // StreamingTask-only unit test can't catch a regression here — the bug + // lives in initializeBKTClientInternal()'s ordering, external to + // StreamingTask itself — so this goes through the real + // initializeBKTClientInternal() entry point instead. + class FakeEventSourceForInit implements EventSourceInstance { + static instances: FakeEventSourceForInit[] = [] + readyState = 0 + onopen: ((ev: unknown) => void) | null = null + onmessage: ((ev: MessageEventLike) => void) | null = null + onerror: ((ev: EventSourceErrorLike | unknown) => void) | null = null + constructor( + public readonly url: string, + public readonly init?: EventSourceLikeInit, + ) { + FakeEventSourceForInit.instances.push(this) + } + addEventListener(): void {} + removeEventListener(): void {} + close(): void {} + } + + server.use( + http.post< + Record, + GetEvaluationsRequest, + GetEvaluationsResponse + >(`${config.apiEndpoint}/get_evaluations`, () => { + return HttpResponse.json({ + evaluations: user1Evaluations, + userEvaluationsId: 'user_evaluation_id_value', + }) + }), + ) + + const streamingConfig = defineBKTConfig({ + apiKey: 'api_key_value', + apiEndpoint: 'https://api.bucketeer.io', + featureTag: 'feature_tag_value', + appVersion: '1.2.3', + enableStreaming: true, + eventSource: FakeEventSourceForInit as unknown as EventSourceLike, + fetch, + }) + const streamingComponent = new DefaultComponent( + new TestPlatformModule(), + new DataModule(user1, requiredInternalConfig(streamingConfig)), + new InteractorModule(), + ) + + await initializeBKTClientInternal(streamingComponent, 1000) + + expect(FakeEventSourceForInit.instances).toHaveLength(1) + const body = JSON.parse( + FakeEventSourceForInit.instances[0].init?.body ?? '', + ) + // Fresh in-memory storage, no evaluations cached yet — proto3 zero + // values. The point isn't the exact values, it's that buildRequest()'s + // synchronous cache read succeeded instead of throwing. + expect(body.userEvaluationsId).toBe('') + expect(body.evaluatedAt).toBe('0') + }) + + test('destroyBKTClient() while evaluationInteractor().initialize() is still pending never schedules tasks (regression: React 18 StrictMode mount/unmount leak)', async () => { + let resolveInitialize: () => void = () => {} + const initializePromise = new Promise((resolve) => { + resolveInitialize = resolve + }) + vi.spyOn(component.evaluationInteractor(), 'initialize').mockReturnValue( + initializePromise, + ) + const fetchSpy = vi.spyOn(component.evaluationInteractor(), 'fetch') + + // Don't await yet: setInstance() runs synchronously before the pending + // initialize() await, so getBKTClient() is already populated here. + const initPromise = initializeBKTClientInternal(component, 1000) + const client = getBKTClient() as unknown as BKTClientImpl + expect(client).not.toBeNull() + + // destroyBKTClient() races the pending initialize(): with the old + // ordering this had nothing to stop yet (taskScheduler was still null) + // and the resumed continuation would schedule tasks anyway. + destroyBKTClient() + expect(getBKTClient()).toBeNull() + + resolveInitialize() + await initPromise + + expect(client.taskScheduler).toBeNull() + expect(fetchSpy).not.toHaveBeenCalled() + }) + + test('evaluationInteractor().initialize() rejecting clears the singleton so a retry actually re-initializes (regression: silent no-op on retry)', async () => { + server.use( + http.post< + Record, + GetEvaluationsRequest, + GetEvaluationsResponse + >(`${config.apiEndpoint}/get_evaluations`, () => { + return HttpResponse.json({ + evaluations: user1Evaluations, + userEvaluationsId: 'user_evaluation_id_value', + }) + }), + ) + + const initSpy = vi + .spyOn(component.evaluationInteractor(), 'initialize') + .mockRejectedValueOnce(new Error('storage corrupted')) + + await expect( + initializeBKTClientInternal(component, 1000), + ).rejects.toThrow('storage corrupted') + // Without the fix, the singleton stays registered here and the retry + // below silently no-ops via `if (getInstance()) return Promise.resolve()`. + expect(getBKTClient()).toBeNull() + + await initializeBKTClientInternal(component, 1000) + expect(getBKTClient()).not.toBeNull() + expect(initSpy).toHaveBeenCalledTimes(2) + }) + + test('evaluationInteractor().initialize() rejecting after a destroy + re-init replaced the instance does not clear the new instance', async () => { + // First attempt: initialize() left pending so it can be orphaned. + let rejectFirstInit: (err: Error) => void = () => {} + const firstInitPromise = new Promise((_resolve, reject) => { + rejectFirstInit = reject + }) + vi.spyOn(component.evaluationInteractor(), 'initialize').mockReturnValue( + firstInitPromise, + ) + const firstInitCall = initializeBKTClientInternal(component, 1000) + + // Orphan the first attempt, then start a second one on a fresh component. + destroyBKTClient() + const secondComponent = new DefaultComponent( + new TestPlatformModule(), + new DataModule(user1, requiredInternalConfig(config)), + new InteractorModule(), + ) + server.use( + http.post< + Record, + GetEvaluationsRequest, + GetEvaluationsResponse + >(`${config.apiEndpoint}/get_evaluations`, () => { + return HttpResponse.json({ + evaluations: user1Evaluations, + userEvaluationsId: 'user_evaluation_id_value', + }) + }), + ) + await initializeBKTClientInternal(secondComponent, 1000) + const secondClient = getBKTClient() + expect(secondClient).not.toBeNull() + + // The orphaned first attempt now rejects — it must not clear the + // second (current) client's singleton registration. + rejectFirstInit(new Error('stale storage error')) + await expect(firstInitCall).rejects.toThrow('stale storage error') + + expect(getBKTClient()).toBe(secondClient) + }) }) suite('getBKTClient', () => { @@ -735,17 +910,17 @@ suite('BKTClient', () => { ) // 1. Update user attributes - // Important: should unawaited - client.updateUserAttributes({ key: 'value' }) + await client.updateUserAttributes({ key: 'value' }) expect(userHolder.get().data).toStrictEqual({ key: 'value' }) expect(await storage.getCurrentEvaluationsId()).toBe( 'user_evaluation_id_value', ) - // 2. Even if we update user attributes without awaiting, - // the storage is still updated, so getUserAttributesUpdated should return true. - // because we are using mutex lock in setUserAttributesUpdated - expect(await storage.getUserAttributesUpdated()).toBeTruthy() + // 2. getUserAttributesState() is a synchronous cache read, so it only + // reflects the update once updateUserAttributes() has been awaited (as + // above) — updateUserAttributes() itself awaits setUserAttributesUpdated() + // internally, so callers never need to await anything beyond that. + expect(storage.getUserAttributesState().userAttributesUpdated).toBeTruthy() }) suite('fetchEvaluations', async () => { diff --git a/test/BKTConfig.spec.ts b/test/BKTConfig.spec.ts index 77869786..a65580e4 100644 --- a/test/BKTConfig.spec.ts +++ b/test/BKTConfig.spec.ts @@ -29,6 +29,8 @@ suite('defineBKTConfig', () => { fetch, storageFactory: createBKTStorage, enableAutoPageLifecycleFlush: true, + enableStreaming: false, + streamingFallbackToPolling: true, sdkVersion: SDK_VERSION, sourceId: SourceId.JAVASCRIPT, }) @@ -50,6 +52,8 @@ suite('defineBKTConfig', () => { fetch, storageFactory: createBKTStorage, enableAutoPageLifecycleFlush: true, + enableStreaming: false, + streamingFallbackToPolling: true, wrapperSdkSourceId: SourceId.REACT, wrapperSdkVersion: '1.2.5', sdkVersion: '1.2.5', @@ -57,6 +61,30 @@ suite('defineBKTConfig', () => { }) }) + test('enableStreaming without eventSource resolves (built-in transport is always available)', () => { + const result = defineBKTConfig({ + ...defaultConfig, + enableStreaming: true, + }) + + expect(result.enableStreaming).toBe(true) + expect(result.streamingFallbackToPolling).toBe(true) + expect(result.eventSource).toBeUndefined() + }) + + test('an injected eventSource is preserved', () => { + class CustomEventSource {} + const result = defineBKTConfig({ + ...defaultConfig, + enableStreaming: true, + eventSource: CustomEventSource as unknown as NonNullable< + RawBKTConfig['eventSource'] + >, + }) + + expect(result.eventSource).toBe(CustomEventSource) + }) + test('empty apiKey throws', () => { expect(() => { defineBKTConfig({ diff --git a/test/InternalConfig.test.ts b/test/InternalConfig.test.ts index 27f9168d..2b3c283a 100644 --- a/test/InternalConfig.test.ts +++ b/test/InternalConfig.test.ts @@ -17,6 +17,8 @@ const inputConfig = { eventsMaxQueueSize: 0, pollingInterval: 0, enableAutoPageLifecycleFlush: false, + enableStreaming: false, + streamingFallbackToPolling: true, fetch: function (_url: string, _request: FetchRequestLike): Promise { throw new Error('Just a stub for testing') }, diff --git a/test/internal/evaluation/EvaluationInteractor.spec.ts b/test/internal/evaluation/EvaluationInteractor.spec.ts index 4f28e83a..05511bb1 100644 --- a/test/internal/evaluation/EvaluationInteractor.spec.ts +++ b/test/internal/evaluation/EvaluationInteractor.spec.ts @@ -260,6 +260,141 @@ suite('internal/evaluation/EvaluationInteractor', () => { expect(mockListener).toBeCalledTimes(2) }) + + test('a listener fired during fetch() observes userAttributesUpdated already cleared (ordering regression: clear must run before notify)', async () => { + // main's ordering: clear the flag, THEN apply/notify. If a listener + // (e.g. a refresh-on-change pattern) synchronously triggers a nested + // read of the flag, it must see it already cleared — otherwise the + // nested caller re-sends userAttributesUpdated:true and gets a + // redundant forceUpdate snapshot back. + server.use( + http.post< + Record, + GetEvaluationsRequest, + GetEvaluationsResponse + >(`${config.apiEndpoint}/get_evaluations`, async () => { + return HttpResponse.json({ + evaluations: { + ...user1Evaluations, + createdAt: clock.currentTimeMillis().toString(), + }, + userEvaluationsId: 'user_evaluation_id_value', + }) + }), + ) + + await interactor.initialize() + await interactor.setUserAttributesUpdated() + + let capturedFlag: boolean | undefined + interactor.addUpdateListener(() => { + // getUserAttributesState() is a synchronous cache read: capturing it + // here, from inside the listener, reflects the flag's state as of + // this exact point in the clear/notify ordering, not just "eventually". + capturedFlag = evaluationStorage.getUserAttributesState().userAttributesUpdated + }) + + const result = await interactor.fetch(user1) + assert(result.type === 'success') + + expect(capturedFlag).toBe(false) + }) + + test('an updateUserAttributes() landing after fetch() starts must not have its flag cleared by that fetch', async () => { + // fetch()'s caller snapshots the user synchronously at the call site, so + // any attribute change that lands after fetch() begins was NOT carried + // by this request. The attributes-state snapshot must therefore be + // captured before fetch()'s first await — otherwise a set landing + // inside that window gets the request's sequence stamp and the + // success-path clear wipes a flag whose attributes were never sent. + server.use( + http.post< + Record, + GetEvaluationsRequest, + GetEvaluationsResponse + >(`${config.apiEndpoint}/get_evaluations`, async () => { + return HttpResponse.json({ + evaluations: { + ...user1Evaluations, + createdAt: clock.currentTimeMillis().toString(), + }, + userEvaluationsId: 'user_evaluation_id_value', + }) + }), + ) + + await interactor.initialize() + + // Hold fetch()'s first awaited storage read open so a + // setUserAttributesUpdated() call can land inside the pre-request window. + let releaseRead: () => void = () => {} + const realGetCurrentEvaluationsId = + evaluationStorage.getCurrentEvaluationsId.bind(evaluationStorage) + vi.spyOn(evaluationStorage, 'getCurrentEvaluationsId').mockImplementation( + async () => { + await new Promise((resolve) => { + releaseRead = resolve + }) + return realGetCurrentEvaluationsId() + }, + ) + + const fetchPromise = interactor.fetch(user1) + // fetch() is parked inside its first await; this update lands after the + // request's user snapshot, so the request does not carry it. + await interactor.setUserAttributesUpdated() + releaseRead() + + const result = await fetchPromise + assert(result.type === 'success') + + // The flag belongs to attributes this request never sent — it must + // survive for the next request to pick up. + expect( + evaluationStorage.getUserAttributesState().userAttributesUpdated, + ).toBe(true) + }) + + test('a failed storage write keeps userAttributesUpdated set, so the next poll retries the attribute-driven refresh', async () => { + // The server's response to a userAttributesUpdated:true request carries + // the re-evaluation the flag asked for. If persisting it fails (e.g. + // browser storage quota), the flag must NOT have been cleared yet — + // otherwise the next poll sends userAttributesUpdated:false and the + // re-evaluation is silently lost. Clear must come after the write. + server.use( + http.post< + Record, + GetEvaluationsRequest, + GetEvaluationsResponse + >(`${config.apiEndpoint}/get_evaluations`, async () => { + return HttpResponse.json({ + evaluations: { + id: '17388826713971171773', + evaluations: [evaluation2], + createdAt: clock.currentTimeMillis().toString(), + forceUpdate: true, + archivedFeatureIds: [], + }, + userEvaluationsId: 'new_user_evaluation_id', + }) + }), + ) + + await interactor.initialize() + await interactor.setUserAttributesUpdated() + + vi.spyOn(evaluationStorage, 'deleteAllAndInsert').mockRejectedValue( + new Error('QuotaExceededError'), + ) + + await expect(interactor.fetch(user1)).rejects.toThrow( + 'QuotaExceededError', + ) + + expect( + evaluationStorage.getUserAttributesState().userAttributesUpdated, + ).toBe(true) + }) }) suite('getLatest', () => { @@ -513,4 +648,224 @@ suite('internal/evaluation/EvaluationInteractor', () => { expect(mockListener).toBeCalledTimes(1) }) }) + + suite('applyEvaluationsResponse', () => { + const seedStorage = async (userAttributesUpdated: boolean) => { + await evaluationStorage.storage.set({ + userId: user1.id, + currentEvaluationsId: 'user_evaluation_id_value', + evaluations: { + [evaluation1.featureId]: evaluation1, + }, + currentFeatureTag: 'feature_tag_value', + evaluatedAt: clock.currentTimeMillis().toString(), + userAttributesUpdated, + }) + await interactor.initialize() + } + + test('forceUpdate=true deletes all and inserts, then notifies listeners', async () => { + await seedStorage(false) + const mockListener = vi.fn() + interactor.addUpdateListener(mockListener) + + const createdAt = clock.currentTimeMillis().toString() + await interactor.applyEvaluationsResponse({ + evaluations: { + id: '17388826713971171773', + evaluations: [evaluation2], + createdAt, + forceUpdate: true, + archivedFeatureIds: [], + }, + userEvaluationsId: 'new_user_evaluation_id', + }) + + // evaluation1 is gone — the response replaced the whole cache + expect(await evaluationStorage.storage.get()).toStrictEqual({ + userId: user1.id, + currentEvaluationsId: 'new_user_evaluation_id', + evaluations: { + [evaluation2.featureId]: evaluation2, + }, + currentFeatureTag: 'feature_tag_value', + evaluatedAt: createdAt, + userAttributesUpdated: false, + }) + expect(mockListener).toBeCalledTimes(1) + }) + + test('stale forceUpdate (createdAt strictly older than the stored evaluatedAt) is skipped: no notify, no overwrite', async () => { + await evaluationStorage.storage.set({ + userId: user1.id, + currentEvaluationsId: 'user_evaluation_id_value', + evaluations: { + [evaluation1.featureId]: evaluation1, + }, + currentFeatureTag: 'feature_tag_value', + evaluatedAt: '1700000000', + userAttributesUpdated: false, + }) + await interactor.initialize() + const mockListener = vi.fn() + interactor.addUpdateListener(mockListener) + + await interactor.applyEvaluationsResponse({ + evaluations: { + id: '17388826713971171773', + evaluations: [evaluation2], + createdAt: '1699999999', // strictly older than the stored evaluatedAt + forceUpdate: true, + archivedFeatureIds: [], + }, + userEvaluationsId: 'new_user_evaluation_id', + }) + + expect(mockListener).not.toHaveBeenCalled() + expect(await evaluationStorage.storage.get()).toStrictEqual({ + userId: user1.id, + currentEvaluationsId: 'user_evaluation_id_value', + evaluations: { + [evaluation1.featureId]: evaluation1, + }, + currentFeatureTag: 'feature_tag_value', + evaluatedAt: '1700000000', + userAttributesUpdated: false, + }) + }) + + test('upsert notifies listeners only when something changed', async () => { + await seedStorage(false) + const mockListener = vi.fn() + interactor.addUpdateListener(mockListener) + + // Same evaluationsId, no evaluations, no archived ids → no change. + await interactor.applyEvaluationsResponse({ + evaluations: { + id: '17388826713971171773', + evaluations: [], + createdAt: clock.currentTimeMillis().toString(), + forceUpdate: false, + archivedFeatureIds: [], + }, + userEvaluationsId: 'user_evaluation_id_value', + }) + expect(mockListener).toBeCalledTimes(0) + + // An upserted evaluation is a change → notify. + await interactor.applyEvaluationsResponse({ + evaluations: { + id: '17388826713971171773', + evaluations: [evaluation2], + createdAt: clock.currentTimeMillis().toString(), + forceUpdate: false, + archivedFeatureIds: [], + }, + userEvaluationsId: 'user_evaluation_id_value', + }) + expect(mockListener).toBeCalledTimes(1) + }) + + test('shouldNotify=() => false suppresses the listener but the storage write still lands', async () => { + // Regression for a destroy racing an in-flight apply (StreamingTask + // passes shouldNotify: () => this.running): the write must not be lost, + // only the listener callback — which could run app code against a + // torn-down client — is suppressed. + await seedStorage(false) + const mockListener = vi.fn() + interactor.addUpdateListener(mockListener) + + const createdAt = clock.currentTimeMillis().toString() + await interactor.applyEvaluationsResponse( + { + evaluations: { + id: '17388826713971171773', + evaluations: [evaluation2], + createdAt, + forceUpdate: true, + archivedFeatureIds: [], + }, + userEvaluationsId: 'new_user_evaluation_id', + }, + () => false, + ) + + expect(mockListener).not.toHaveBeenCalled() + const stored = await evaluationStorage.storage.get() + expect(stored?.currentEvaluationsId).toBe('new_user_evaluation_id') + }) + + test('shouldNotify=() => true behaves the same as omitting the argument', async () => { + await seedStorage(false) + const mockListener = vi.fn() + interactor.addUpdateListener(mockListener) + + await interactor.applyEvaluationsResponse( + { + evaluations: { + id: '17388826713971171773', + evaluations: [evaluation2], + createdAt: clock.currentTimeMillis().toString(), + forceUpdate: true, + archivedFeatureIds: [], + }, + userEvaluationsId: 'new_user_evaluation_id', + }, + () => true, + ) + + expect(mockListener).toHaveBeenCalledTimes(1) + }) + + test('does NOT clear userAttributesUpdated (streamed data must not clear the flag)', async () => { + // A streamed message can race a concurrent updateUserAttributes() — it may + // have been produced before the new attributes existed, so it must never + // clear the flag. Only fetch(), which sent the attributes, may clear it. + await seedStorage(true) + + await interactor.applyEvaluationsResponse({ + evaluations: { + id: '17388826713971171773', + evaluations: [evaluation2], + createdAt: clock.currentTimeMillis().toString(), + forceUpdate: false, + archivedFeatureIds: [], + }, + userEvaluationsId: 'new_user_evaluation_id', + }) + + const stored = await evaluationStorage.storage.get() + expect(stored?.userAttributesUpdated).toBe(true) + }) + + test('fetch() success applies the response AND clears userAttributesUpdated', async () => { + await seedStorage(true) + + server.use( + http.post< + Record, + GetEvaluationsRequest, + GetEvaluationsResponse + >(`${config.apiEndpoint}/get_evaluations`, async () => { + return HttpResponse.json({ + evaluations: { + id: '17388826713971171773', + evaluations: [evaluation2], + createdAt: clock.currentTimeMillis().toString(), + forceUpdate: false, + archivedFeatureIds: [], + }, + userEvaluationsId: 'new_user_evaluation_id', + }) + }), + ) + + const result = await interactor.fetch(user1) + assert(result.type === 'success') + + const stored = await evaluationStorage.storage.get() + expect(stored?.evaluations[evaluation2.featureId]).toStrictEqual(evaluation2) + expect(stored?.userAttributesUpdated).toBe(false) + }) + }) }) diff --git a/test/internal/evaluation/EvaluationStorage.spec.ts b/test/internal/evaluation/EvaluationStorage.spec.ts index d95a89d3..ec4b8dce 100644 --- a/test/internal/evaluation/EvaluationStorage.spec.ts +++ b/test/internal/evaluation/EvaluationStorage.spec.ts @@ -1,4 +1,4 @@ -import { expect, suite, test, beforeEach, afterEach } from 'vitest' +import { expect, suite, test, beforeEach, afterEach, vi } from 'vitest' import { EvaluationEntity, EvaluationStorage, @@ -41,7 +41,7 @@ suite('internal/evaluation/EvaluationStorage', () => { expect(await evaluationStorage.getCurrentEvaluationsId()).toBe('evaluations_id_1') expect(evaluationStorage.getByFeatureId(evaluation1.featureId)).toStrictEqual(evaluation1) - expect(await evaluationStorage.getUserAttributesUpdated()).toBe(true) + expect(evaluationStorage.getUserAttributesState().userAttributesUpdated).toBe(true) }) test('should initialize with default data when storage is empty', async () => { @@ -49,7 +49,7 @@ suite('internal/evaluation/EvaluationStorage', () => { expect(await evaluationStorage.getCurrentEvaluationsId()).toBeNull() expect(await evaluationStorage.getEvaluatedAt()).toBeNull() - expect(await evaluationStorage.getUserAttributesUpdated()).toBe(false) + expect(evaluationStorage.getUserAttributesState().userAttributesUpdated).toBe(false) expect(evaluationStorage.getByFeatureId('any_feature')).toBeNull() }) @@ -69,7 +69,7 @@ suite('internal/evaluation/EvaluationStorage', () => { expect(await evaluationStorage.getCurrentEvaluationsId()).toBeNull() expect(await evaluationStorage.getEvaluatedAt()).toBeNull() - expect(await evaluationStorage.getUserAttributesUpdated()).toBe(false) + expect(evaluationStorage.getUserAttributesState().userAttributesUpdated).toBe(false) expect(evaluationStorage.getByFeatureId(evaluation1.featureId)).toBeNull() }) @@ -124,7 +124,10 @@ suite('internal/evaluation/EvaluationStorage', () => { expect(() => evaluationStorage.getByFeatureId('any_feature')).toThrow( 'Cache Evaluation entity is not loaded. Call initialize() first.' ) - await expect(evaluationStorage.getUserAttributesUpdated()).rejects.toThrow( + expect(() => evaluationStorage.getUserAttributesState()).toThrow( + 'Cache Evaluation entity is not loaded. Call initialize() first.' + ) + expect(() => evaluationStorage.getCurrentEvaluationsCondition()).toThrow( 'Cache Evaluation entity is not loaded. Call initialize() first.' ) }) @@ -202,6 +205,143 @@ suite('internal/evaluation/EvaluationStorage', () => { }) }) + suite('staleness guard (concurrent writers must not rewind state)', () => { + const seed = async (evaluatedAt: string) => { + await storage.set({ + userId: 'user_id_1', + currentEvaluationsId: 'evaluations_id_1', + evaluations: { + [evaluation1.featureId]: evaluation1, + }, + currentFeatureTag: 'feature_tag_1', + evaluatedAt, + userAttributesUpdated: false, + }) + await evaluationStorage.initialize() + } + + suite('update', () => { + test('strictly-older evaluatedAt is a no-op: returns false, storage unchanged', async () => { + await seed('1700000000') + + const result = await evaluationStorage.update( + 'evaluations_id_2', + [evaluation2], + [], + '1699999999', + ) + + expect(result).toBe(false) + expect(await storage.get()).toStrictEqual({ + userId: 'user_id_1', + currentEvaluationsId: 'evaluations_id_1', + evaluations: { + [evaluation1.featureId]: evaluation1, + }, + currentFeatureTag: 'feature_tag_1', + evaluatedAt: '1700000000', + userAttributesUpdated: false, + }) + }) + + test('equal evaluatedAt still applies (same-tick patches must not be dropped)', async () => { + await seed('1700000000') + + const result = await evaluationStorage.update( + 'evaluations_id_2', + [evaluation2], + [], + '1700000000', + ) + + expect(result).toBe(true) + expect((await storage.get())?.currentEvaluationsId).toBe( + 'evaluations_id_2', + ) + }) + + test('newer evaluatedAt applies normally', async () => { + await seed('1700000000') + + const result = await evaluationStorage.update( + 'evaluations_id_2', + [evaluation2], + [], + '1700000001', + ) + + expect(result).toBe(true) + expect((await storage.get())?.evaluatedAt).toBe('1700000001') + }) + + test('a fresh install (evaluatedAt null) is never treated as stale', async () => { + await evaluationStorage.initialize() + + const result = await evaluationStorage.update( + 'evaluations_id_1', + [evaluation1], + [], + '0', + ) + + expect(result).toBe(true) + expect((await storage.get())?.evaluatedAt).toBe('0') + }) + }) + + suite('deleteAllAndInsert', () => { + test('strictly-older evaluatedAt is a no-op: returns false, storage unchanged', async () => { + await seed('1700000000') + + const result = await evaluationStorage.deleteAllAndInsert( + 'evaluations_id_2', + [evaluation2], + '1699999999', + ) + + expect(result).toBe(false) + expect(await storage.get()).toStrictEqual({ + userId: 'user_id_1', + currentEvaluationsId: 'evaluations_id_1', + evaluations: { + [evaluation1.featureId]: evaluation1, + }, + currentFeatureTag: 'feature_tag_1', + evaluatedAt: '1700000000', + userAttributesUpdated: false, + }) + }) + + test('equal evaluatedAt still applies', async () => { + await seed('1700000000') + + const result = await evaluationStorage.deleteAllAndInsert( + 'evaluations_id_2', + [evaluation2], + '1700000000', + ) + + expect(result).toBe(true) + expect((await storage.get())?.currentEvaluationsId).toBe( + 'evaluations_id_2', + ) + }) + + test('newer evaluatedAt applies normally', async () => { + await seed('1700000000') + + const result = await evaluationStorage.deleteAllAndInsert( + 'evaluations_id_2', + [evaluation2], + '1700000001', + ) + + expect(result).toBe(true) + expect((await storage.get())?.evaluatedAt).toBe('1700000001') + }) + }) + }) + suite('getCurrentEvaluationsId', () => { test('return currentEvaluationsId if saved data is present', async () => { await storage.set({ @@ -270,6 +410,59 @@ suite('internal/evaluation/EvaluationStorage', () => { }) }) + suite('getCurrentEvaluationsCondition', () => { + test('throws before initialize() — same contract as the other getters', () => { + // initializeBKTClientInternal() guarantees initialize() always resolves + // before any task (including StreamingTask's first connect) can reach + // this storage — see the ordering comment on + // BKTClientImpl.initializeCache(). + expect(() => evaluationStorage.getCurrentEvaluationsCondition()).toThrow( + 'Cache Evaluation entity is not loaded. Call initialize() first.', + ) + }) + + test('returns nulls after initialize() when storage is empty', async () => { + await evaluationStorage.initialize() + + expect(evaluationStorage.getCurrentEvaluationsCondition()).toStrictEqual({ + currentEvaluationsId: null, + evaluatedAt: null, + }) + }) + + test('returns the stored values after initialize()', async () => { + await storage.set({ + userId: 'user_id_1', + currentEvaluationsId: 'evaluations_id_1', + evaluations: {}, + currentFeatureTag: 'feature_tag_1', + evaluatedAt: '1234567890', + userAttributesUpdated: false, + }) + await evaluationStorage.initialize() + + expect(evaluationStorage.getCurrentEvaluationsCondition()).toStrictEqual({ + currentEvaluationsId: 'evaluations_id_1', + evaluatedAt: '1234567890', + }) + }) + + test('reflects the latest values after an update', async () => { + await evaluationStorage.initialize() + + await evaluationStorage.deleteAllAndInsert( + 'evaluations_id_2', + [evaluation1], + '9876543210', + ) + + expect(evaluationStorage.getCurrentEvaluationsCondition()).toStrictEqual({ + currentEvaluationsId: 'evaluations_id_2', + evaluatedAt: '9876543210', + }) + }) + }) + suite('updateFeatureTag', () => { test('clear currentEvaluationId if featureTag is different', async () => { await storage.set({ @@ -324,7 +517,7 @@ suite('internal/evaluation/EvaluationStorage', () => { expect((await storage.get())?.userAttributesUpdated).toBeTruthy() }) - test('getUserAttributesUpdated', async () => { + test('getUserAttributesState', async () => { await storage.set({ userId: 'user_id_1', currentEvaluationsId: 'evaluations_id_1', @@ -337,13 +530,10 @@ suite('internal/evaluation/EvaluationStorage', () => { userAttributesUpdated: true, }) await evaluationStorage.initialize() - expect(await evaluationStorage.getUserAttributesUpdated()).toBeTruthy() + expect(evaluationStorage.getUserAttributesState().userAttributesUpdated).toBeTruthy() }) - test('setUserAttributesUpdated unawait, but getUserAttributesUpdated should get the updated data', async () => { - // This test ensures that the setUserAttributesUpdated method can be called without awaiting, - // and the getUserAttributesUpdated method will still return the updated value. - // This proves that our mutex is working correctly and the data is being updated asynchronously. + test('setUserAttributesUpdated must be awaited for getUserAttributesState to observe it (synchronous cache read, same contract as getCurrentEvaluationsCondition — unlike the old mutex-queued getUserAttributesUpdated getter, this no longer tolerates a caller forgetting to await)', async () => { await storage.set({ userId: 'user_id_1', currentEvaluationsId: 'evaluations_id_1', @@ -357,14 +547,13 @@ suite('internal/evaluation/EvaluationStorage', () => { }) await evaluationStorage.initialize() // assert that userAttributesUpdated is false before setting it - expect(await evaluationStorage.getUserAttributesUpdated()).toBeFalsy() - // Important: should unawaited - evaluationStorage.setUserAttributesUpdated() + expect(evaluationStorage.getUserAttributesState().userAttributesUpdated).toBeFalsy() + await evaluationStorage.setUserAttributesUpdated() - expect(await evaluationStorage.getUserAttributesUpdated()).toBeTruthy() + expect(evaluationStorage.getUserAttributesState().userAttributesUpdated).toBeTruthy() }) - test('clearUserAttributesUpdated', async () => { + test('clearUserAttributesUpdated with the current state clears the flag', async () => { await storage.set({ userId: 'user_id_1', currentEvaluationsId: 'evaluations_id_1', @@ -377,8 +566,65 @@ suite('internal/evaluation/EvaluationStorage', () => { userAttributesUpdated: true, }) await evaluationStorage.initialize() - await evaluationStorage.clearUserAttributesUpdated() + const state = evaluationStorage.getUserAttributesState() + await evaluationStorage.clearUserAttributesUpdated(state) expect((await storage.get())?.userAttributesUpdated).toBeFalsy() }) + + test('clearUserAttributesUpdated skips the storage write when the flag is already false (matching sequence, nothing to clear)', async () => { + // The common case: every stream (re)connect and every successful poll + // clears the flag, but it is usually already false — rewriting the whole + // evaluations map to storage to set false → false is pure waste. + await storage.set({ + userId: 'user_id_1', + currentEvaluationsId: 'evaluations_id_1', + evaluations: { + [evaluation1.featureId]: evaluation1, + }, + currentFeatureTag: 'feature_tag_1', + evaluatedAt: '1234567890', + userAttributesUpdated: false, + }) + await evaluationStorage.initialize() + const setSpy = vi.spyOn(storage, 'set') + + await evaluationStorage.clearUserAttributesUpdated( + evaluationStorage.getUserAttributesState(), + ) + + expect(setSpy).not.toHaveBeenCalled() + }) + + test('getUserAttributesState().updateSequence increments on every setUserAttributesUpdated call', async () => { + await evaluationStorage.initialize() + const initial = evaluationStorage.getUserAttributesState().updateSequence + + await evaluationStorage.setUserAttributesUpdated() + expect(evaluationStorage.getUserAttributesState().updateSequence).toBe(initial + 1) + + await evaluationStorage.setUserAttributesUpdated() + expect(evaluationStorage.getUserAttributesState().updateSequence).toBe(initial + 2) + }) + + test('clearUserAttributesUpdated no-ops when a newer setUserAttributesUpdated happened after the state was captured (regression: an in-flight stale fetch must not wipe a newer flag)', async () => { + await evaluationStorage.initialize() + await evaluationStorage.setUserAttributesUpdated() + // Simulates a fallback fetch that captured state before issuing its + // request, then a concurrent updateUserAttributes() call raced ahead of it. + const staleState = evaluationStorage.getUserAttributesState() + await evaluationStorage.setUserAttributesUpdated() + + await evaluationStorage.clearUserAttributesUpdated(staleState) + + expect(evaluationStorage.getUserAttributesState().userAttributesUpdated).toBe(true) + }) + + test('clearUserAttributesUpdated is a no-op (still requires initialize) before initialize()', async () => { + await expect( + evaluationStorage.clearUserAttributesUpdated({ userAttributesUpdated: false, updateSequence: 0 }), + ).rejects.toThrow( + 'Cache Evaluation entity is not loaded. Call initialize() first.' + ) + }) }) diff --git a/test/internal/scheduler/EvaluationTask.spec.ts b/test/internal/scheduler/EvaluationTask.spec.ts index 433c7372..c9fa8060 100644 --- a/test/internal/scheduler/EvaluationTask.spec.ts +++ b/test/internal/scheduler/EvaluationTask.spec.ts @@ -11,7 +11,7 @@ import { beforeAll, } from 'vitest' -import { destroyBKTClient } from '../../../src/BKTClient' +import { BKTClientImpl, destroyBKTClient } from '../../../src/BKTClient' import { BKTConfig, defineBKTConfig } from '../../../src/BKTConfig' import { DefaultComponent } from '../../../src/internal/di/Component' import { DataModule } from '../../../src/internal/di/DataModule' @@ -105,6 +105,57 @@ suite('internal/scheduler/EventTask', () => { expect(requestCount).toBe(3) }) + test('start(true) fetches immediately and continues normal polling after', async () => { + // Stubbed one level below EvaluationTask's own fetchEvaluations(), same as + // StreamingTask.spec.ts does for EvaluationTask itself — this isolates the + // start()/reschedule() scheduling behavior under test from real network + // timing (in particular, postInternal's own abort/timeout timer, which + // would otherwise become "pending" the instant start(true) synchronously + // invokes fetchEvaluations(), unlike the timer-driven plain start()). + const fetchInternal = vi + .spyOn(BKTClientImpl, 'fetchEvaluationsInternal') + .mockResolvedValue(undefined) + + task = new EvaluationTask(component) + task.start(true) + + // Fired synchronously off of start(true) — no pollingInterval timer stands + // between them, unlike plain start(). So a stream drop's polling fallback + // doesn't leave users on stale evaluations for a full pollingInterval. + expect(fetchInternal).toHaveBeenCalledTimes(1) + + // Let fetchEvaluations()'s own success path run reschedule() — the only + // thing that arms the next poll timer, so there's never a second one. + await Promise.resolve() + expect(vi.getTimerCount()).toBe(1) + + await vi.advanceTimersByTimeAsync(config.pollingInterval) + expect(fetchInternal).toHaveBeenCalledTimes(2) + }) + + test('stop() while a fetch is in flight does not reschedule afterward', async () => { + let resolveFetch: () => void = () => {} + vi.spyOn(BKTClientImpl, 'fetchEvaluationsInternal').mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve + }), + ) + + task = new EvaluationTask(component) + task.start(true) // fetch begins synchronously and is now in flight + + task.stop() + expect(task.isRunning()).toBe(false) + + resolveFetch() // let the orphaned fetch resolve after stop() + await Promise.resolve() + await Promise.resolve() + + // The now-orphaned completion handler must not arm a new timer. + expect(vi.getTimerCount()).toBe(0) + }) + test('stop should cancel timer', async () => { let requestCount = 0 server.use( diff --git a/test/internal/scheduler/TaskScheduler.spec.ts b/test/internal/scheduler/TaskScheduler.spec.ts new file mode 100644 index 00000000..6bb2c061 --- /dev/null +++ b/test/internal/scheduler/TaskScheduler.spec.ts @@ -0,0 +1,64 @@ +import { beforeEach, afterEach, expect, suite, test, vi } from 'vitest' + +import { BKTConfig } from '../../../src/BKTConfig' +import { DefaultComponent } from '../../../src/internal/di/Component' +import { TaskScheduler } from '../../../src/internal/scheduler/TaskScheduler' +import { StreamingTask } from '../../../src/internal/streaming/StreamingTask' +import { buildTestComponent } from '../../utils' + +// Only reconnectStreaming()'s debounce is under test here — schedulers are +// constructed but never start()ed, so their own network/timer side effects +// never enter the picture. +suite('internal/scheduler/TaskScheduler', () => { + let scheduler: TaskScheduler | undefined + let reconnectSpy: ReturnType + + function buildComponent(override: Partial = {}): DefaultComponent { + return buildTestComponent(override) + } + + beforeEach(() => { + vi.useFakeTimers() + reconnectSpy = vi + .spyOn(StreamingTask.prototype, 'reconnect') + .mockImplementation(() => {}) + }) + + afterEach(() => { + scheduler = undefined + vi.useRealTimers() + vi.restoreAllMocks() + }) + + test('reconnectStreaming() debounces rapid calls into exactly one reconnect() after 200ms', () => { + scheduler = new TaskScheduler(buildComponent()) + + scheduler.reconnectStreaming() + scheduler.reconnectStreaming() + scheduler.reconnectStreaming() + + expect(reconnectSpy).not.toHaveBeenCalled() + + vi.advanceTimersByTime(200) + + expect(reconnectSpy).toHaveBeenCalledTimes(1) + }) + + test('reconnectStreaming() is a no-op when polling (no StreamingTask present)', () => { + scheduler = new TaskScheduler(buildComponent({ enableStreaming: false })) + + expect(() => scheduler?.reconnectStreaming()).not.toThrow() + vi.advanceTimersByTime(200) + expect(reconnectSpy).not.toHaveBeenCalled() + }) + + test('stop() clears a pending debounce timer', () => { + scheduler = new TaskScheduler(buildComponent()) + + scheduler.reconnectStreaming() + scheduler.stop() + + vi.advanceTimersByTime(200) + expect(reconnectSpy).not.toHaveBeenCalled() + }) +}) diff --git a/test/internal/streaming/Backoff.spec.ts b/test/internal/streaming/Backoff.spec.ts new file mode 100644 index 00000000..89f6d58c --- /dev/null +++ b/test/internal/streaming/Backoff.spec.ts @@ -0,0 +1,53 @@ +import { expect, suite, test, vi, afterEach } from 'vitest' +import { Backoff } from '../../../src/internal/streaming/Backoff' + +suite('internal/streaming/Backoff', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + suite('nextDelayMillis', () => { + test('starts at initialDelayMillis and doubles until maxDelayMillis, then caps', () => { + vi.spyOn(Math, 'random').mockReturnValue(0) + const backoff = new Backoff(1_000, 30_000) + + expect(backoff.nextDelayMillis()).toBe(1_000) + expect(backoff.nextDelayMillis()).toBe(2_000) + expect(backoff.nextDelayMillis()).toBe(4_000) + expect(backoff.nextDelayMillis()).toBe(8_000) + expect(backoff.nextDelayMillis()).toBe(16_000) + expect(backoff.nextDelayMillis()).toBe(30_000) + expect(backoff.nextDelayMillis()).toBe(30_000) + }) + + test('clamps an initialDelayMillis greater than maxDelayMillis on the first call', () => { + vi.spyOn(Math, 'random').mockReturnValue(0) + const backoff = new Backoff(40_000, 30_000) + + expect(backoff.nextDelayMillis()).toBe(30_000) + }) + + test('subtracts up to JITTER_RATIO of the base delay', () => { + vi.spyOn(Math, 'random').mockReturnValue(1) + const backoff = new Backoff(1_000, 30_000) + + // base 1_000, JITTER_RATIO 0.5, random 1 -> subtract the full 50% + expect(backoff.nextDelayMillis()).toBe(500) + expect(backoff.nextDelayMillis()).toBe(1_000) + }) + }) + + suite('reset', () => { + test('sets attempt back to 0', () => { + vi.spyOn(Math, 'random').mockReturnValue(0) + const backoff = new Backoff(1_000, 30_000) + + expect(backoff.nextDelayMillis()).toBe(1_000) + expect(backoff.nextDelayMillis()).toBe(2_000) + + backoff.reset() + + expect(backoff.nextDelayMillis()).toBe(1_000) + }) + }) +}) diff --git a/test/internal/streaming/FetchEventSource.spec.ts b/test/internal/streaming/FetchEventSource.spec.ts new file mode 100644 index 00000000..1dc61994 --- /dev/null +++ b/test/internal/streaming/FetchEventSource.spec.ts @@ -0,0 +1,447 @@ +import { expect, suite, test, vi } from 'vitest' +import { FetchEventSource } from '../../../src/internal/streaming/FetchEventSource' +import { MessageEventLike } from '../../../src/internal/streaming/EventSourceLike' +import { + FetchLike, + FetchRequestLike, + FetchResponseLike, +} from '../../../src/internal/remote/fetch' + +const encoder = new TextEncoder() + +function streamOf(...chunks: (string | Uint8Array)[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue( + typeof chunk === 'string' ? encoder.encode(chunk) : chunk, + ) + } + controller.close() + }, + }) +} + +function okResponse( + body: ReadableStream | null | undefined, +): FetchResponseLike { + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: () => Promise.resolve({}), + text: () => Promise.resolve(''), + body, + } +} + +function fetchReturning(response: FetchResponseLike): FetchLike { + return () => Promise.resolve(response) +} + +async function until(cond: () => boolean): Promise { + for (let i = 0; i < 100 && !cond(); i++) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + expect(cond()).toBe(true) +} + +// Collects every callback interaction of one FetchEventSource instance. +function instrument(es: FetchEventSource) { + const opened = vi.fn() + const messages: MessageEventLike[] = [] + const errors: unknown[] = [] + es.onopen = opened + es.onmessage = (ev) => messages.push(ev) + es.onerror = (ev) => errors.push(ev) + // Data-carrying messages only — the per-chunk liveness ticks have data: undefined. + const dataMessages = () => messages.filter((m) => m.data !== undefined) + const ended = () => errors.length > 0 + return { opened, messages, dataMessages, errors, ended } +} + +suite('internal/streaming/FetchEventSource', () => { + test('200 + data block: onopen fires and onmessage receives the data', async () => { + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning(okResponse(streamOf('data: {"a":1}\n\n'))), + ) + const t = instrument(es) + + await until(t.ended) + expect(t.opened).toHaveBeenCalledTimes(1) + expect(t.dataMessages()).toEqual([{ data: '{"a":1}' }]) + }) + + test('named event block goes to its listener, not onmessage', async () => { + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning( + okResponse(streamOf('event: evaluations\ndata: {"b":2}\n\n')), + ), + ) + const t = instrument(es) + const named: MessageEventLike[] = [] + es.addEventListener('evaluations', (ev) => named.push(ev)) + + await until(t.ended) + expect(named).toEqual([{ data: '{"b":2}' }]) + expect(t.dataMessages()).toEqual([]) + }) + + test('a named event with no registered listener is dropped, not delivered to onmessage (regression: unknown named events must not be treated as unnamed)', async () => { + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning(okResponse(streamOf('event: ping\ndata: {"c":3}\n\n'))), + ) + const t = instrument(es) + // No listener registered for 'ping'. + + await until(t.ended) + expect(t.dataMessages()).toEqual([]) // must NOT reach onmessage + }) + + test('an explicit "event: message" block reaches onmessage, matching native EventSource', async () => { + // A native EventSource dispatches any block whose type is 'message' — + // explicit or defaulted — through onmessage. StreamConnection never + // registers a 'message' listener, so an explicit event: message must fall + // back to onmessage, not be dropped like an unknown named event. + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning(okResponse(streamOf('event: message\ndata: {"m":1}\n\n'))), + ) + const t = instrument(es) + + await until(t.ended) + expect(t.dataMessages()).toEqual([{ data: '{"m":1}' }]) + }) + + test('an empty "event:" line falls back to the default message type and reaches onmessage', async () => { + // Per WHATWG, an empty event type buffer leaves the type as the default + // 'message' — so an empty event: line behaves like no event: line at all. + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning(okResponse(streamOf('event:\ndata: {"m":2}\n\n'))), + ) + const t = instrument(es) + + await until(t.ended) + expect(t.dataMessages()).toEqual([{ data: '{"m":2}' }]) + }) + + test('event: patch block calls the patch listener', async () => { + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning(okResponse(streamOf('event: patch\ndata: {}\n\n'))), + ) + const t = instrument(es) + const patches: MessageEventLike[] = [] + es.addEventListener('patch', (ev) => patches.push(ev)) + + await until(t.ended) + expect(patches).toEqual([{ data: '{}' }]) + }) + + test('SSE comment fires a liveness tick but dispatches no data', async () => { + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning(okResponse(streamOf(': ping\n\n'))), + ) + const t = instrument(es) + + await until(t.ended) + // One bare tick per received chunk proves liveness to the watchdog. + expect(t.messages).toEqual([{ data: undefined }]) + expect(t.dataMessages()).toEqual([]) + }) + + test('multi-line data lines are joined with \\n', async () => { + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning(okResponse(streamOf('data: line1\ndata: line2\n\n'))), + ) + const t = instrument(es) + + await until(t.ended) + expect(t.dataMessages()).toEqual([{ data: 'line1\nline2' }]) + }) + + test('CRLF framing parses identically to LF', async () => { + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning( + okResponse( + streamOf('event: evaluations\r\ndata: {"c":3}\r\n\r\ndata: plain\r\n\r\n'), + ), + ), + ) + const t = instrument(es) + const named: MessageEventLike[] = [] + es.addEventListener('evaluations', (ev) => named.push(ev)) + + await until(t.ended) + expect(named).toEqual([{ data: '{"c":3}' }]) + expect(t.dataMessages()).toEqual([{ data: 'plain' }]) + }) + + test('a CRLF split across two chunks produces exactly one newline', async () => { + // Chunk 1 ends with the \r of a \r\n pair. Without the pending-CR hold-back, + // normalizing each chunk separately would turn one CRLF into two newlines and + // split "a" and "b" into two separate events. + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning(okResponse(streamOf('data: a\r', '\ndata: b\r\n\r\n'))), + ) + const t = instrument(es) + + await until(t.ended) + expect(t.dataMessages()).toEqual([{ data: 'a\nb' }]) + }) + + test('a blank-line separator split across two chunks still produces two distinct events (the resume offset must back up one char so a split \\n\\n is caught)', async () => { + // Chunk 1 ends with the first '\n' of the '\n\n' block separator; chunk 2 + // supplies the second '\n'. If the next scan resumed at the buffer end + // instead of one char before it, that separator is skipped and the two + // events wrongly merge into one { data: 'a\nb' }. + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning(okResponse(streamOf('data: a\n', '\ndata: b\n\n'))), + ) + const t = instrument(es) + + await until(t.ended) + expect(t.dataMessages()).toEqual([{ data: 'a' }, { data: 'b' }]) + }) + + test('a payload delivered in many small chunks at arbitrary byte boundaries (including mid-CRLF) parses identically to one whole chunk (functional-equivalence guard for the quadratic-parsing fix)', async () => { + const payload = + 'event: put\ndata: {"a":1}\n\n' + + 'data: plain1\ndata: plain2\n\n' + + 'event: patch\r\ndata: {"b":2}\r\n\r\n' + // CRLF framing mixed in + ': heartbeat\n\n' + + 'event: evaluations\ndata: {"c":3}\n\n' + + async function collect(chunks: (string | Uint8Array)[]) { + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning(okResponse(streamOf(...chunks))), + ) + const puts: MessageEventLike[] = [] + const patches: MessageEventLike[] = [] + const evaluations: MessageEventLike[] = [] + es.addEventListener('put', (ev) => puts.push(ev)) + es.addEventListener('patch', (ev) => patches.push(ev)) + es.addEventListener('evaluations', (ev) => evaluations.push(ev)) + const t = instrument(es) + await until(t.ended) + return { puts, patches, evaluations, dataMessages: t.dataMessages() } + } + + const reference = await collect([payload]) + + // Split at arbitrary 3-byte boundaries — several of which land mid-CRLF + // (e.g. inside the '\r\n\r\n' block terminators above). + const chunks: string[] = [] + for (let i = 0; i < payload.length; i += 3) { + chunks.push(payload.slice(i, i + 3)) + } + const chunked = await collect(chunks) + + expect(chunked).toEqual(reference) + // Sanity: the reference itself actually parsed something meaningful, so + // an accidentally-empty comparison couldn't slip through as "equal". + expect(reference.evaluations).toEqual([{ data: '{"c":3}' }]) + }) + + test('non-200 response reports onerror with the status', async () => { + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning( + Object.assign(okResponse(null), { ok: false, status: 500 }), + ), + ) + const t = instrument(es) + + await until(t.ended) + expect(t.errors).toEqual([{ status: 500 }]) + expect(t.opened).not.toHaveBeenCalled() + }) + + test('missing response.body is a terminal error', async () => { + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning(okResponse(undefined)), + ) + const t = instrument(es) + + await until(t.ended) + expect(t.errors).toEqual([{ terminal: true }]) + expect(t.opened).not.toHaveBeenCalled() + }) + + test('a body without getReader (non-WHATWG stream) is terminal, onopen NOT fired', async () => { + // e.g. node-fetch returns a Node.js Readable: truthy, but cannot stream here. + const nodeReadableLike = { + on: () => {}, + pipe: () => {}, + } as unknown as ReadableStream + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning(okResponse(nodeReadableLike)), + ) + const t = instrument(es) + + await until(t.ended) + expect(t.errors).toEqual([{ terminal: true }]) + expect(t.opened).not.toHaveBeenCalled() + }) + + test('network error (fetch rejects) is passed to onerror', async () => { + const boom = new Error('network down') + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + () => Promise.reject(boom), + ) + const t = instrument(es) + + await until(t.ended) + expect(t.errors).toEqual([boom]) + expect(es.readyState).toBe(2) + }) + + test('close() aborts the request and swallows the AbortError', async () => { + let capturedRequest: FetchRequestLike | undefined + let rejectFetch!: (err: unknown) => void + const fetchImpl: FetchLike = (_url, request) => { + capturedRequest = request + return new Promise((_resolve, reject) => { + rejectFetch = reject + }) + } + const es = new FetchEventSource('https://example.test/sse', {}, fetchImpl) + const t = instrument(es) + + es.close() + expect(es.readyState).toBe(2) + expect(capturedRequest?.signal?.aborted).toBe(true) + + const abortError = new Error('The operation was aborted') + abortError.name = 'AbortError' + rejectFetch(abortError) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(t.errors).toEqual([]) + }) + + test('a stream that errors mid-read reports onerror without an unhandled rejection', async () => { + const boom = new Error('stream broke') + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: first\n\n')) + }, + pull() { + throw boom + }, + }) + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning(okResponse(body)), + ) + const t = instrument(es) + + await until(t.ended) + expect(t.dataMessages()).toEqual([{ data: 'first' }]) + // reader.cancel() on the errored stream rejects — must be swallowed, while + // the original error still surfaces through onerror. + expect(t.errors).toEqual([boom]) + }) + + test('a stream ending mid multi-byte character flushes the decoder without crashing', async () => { + // '€' is 0xE2 0x82 0xAC — send only the first two bytes, then end the stream. + const es = new FetchEventSource( + 'https://example.test/sse', + {}, + fetchReturning( + okResponse(streamOf('data: ok\n\n', new Uint8Array([0xe2, 0x82]))), + ), + ) + const t = instrument(es) + + await until(t.ended) + expect(t.dataMessages()).toEqual([{ data: 'ok' }]) + // Natural end-of-stream reports a recoverable (empty) error. + expect(t.errors).toEqual([{}]) + }) + + test('default headers are sent and caller headers win over them', async () => { + let capturedRequest: FetchRequestLike | undefined + const fetchImpl: FetchLike = (_url, request) => { + capturedRequest = request + return Promise.resolve(okResponse(streamOf())) + } + const es = new FetchEventSource( + 'https://example.test/sse', + { + headers: { + Authorization: 'api-key-value', + Accept: 'application/custom', + }, + body: '{"tag":"t"}', + }, + fetchImpl, + ) + const t = instrument(es) + + await until(t.ended) + expect(capturedRequest?.method).toBe('POST') + expect(capturedRequest?.body).toBe('{"tag":"t"}') + expect(capturedRequest?.headers).toEqual({ + 'Content-Type': 'application/json', + Accept: 'application/custom', // caller override wins + Authorization: 'api-key-value', + }) + }) + + test('the injected fetch is called receiver-free (regression: native fetch throws Illegal invocation if called as a method)', async () => { + // Per WebIDL, a bare `fetch(...)` call is legal (`this` is `undefined` in + // strict-mode ESM, or defaults to `globalThis` under non-strict/CJS + // transpilation — both are the accepted "no explicit receiver" cases). A + // *foreign* receiver — e.g. `this.fetchImpl(...)` passing the + // FetchEventSource instance itself — is what real browser fetch rejects + // with "Illegal invocation". Reproduce that brand check precisely so this + // fails loudly if connect() ever regresses to a method-style call. + let receiverCheckPassed = false + const fetchImpl: FetchLike = function (this: unknown) { + if (this !== undefined && this !== globalThis) { + throw new TypeError('Illegal invocation') + } + receiverCheckPassed = true + return Promise.resolve(okResponse(streamOf('data: {"a":1}\n\n'))) + } + const es = new FetchEventSource('https://example.test/sse', {}, fetchImpl) + const t = instrument(es) + + await until(t.ended) + + expect(receiverCheckPassed).toBe(true) + expect(t.opened).toHaveBeenCalledTimes(1) + expect(t.dataMessages()).toEqual([{ data: '{"a":1}' }]) + }) +}) diff --git a/test/internal/streaming/StreamConnection.spec.ts b/test/internal/streaming/StreamConnection.spec.ts new file mode 100644 index 00000000..a29bc97a --- /dev/null +++ b/test/internal/streaming/StreamConnection.spec.ts @@ -0,0 +1,518 @@ +import { expect, suite, test, vi, beforeEach, afterEach } from 'vitest' +import { StreamConnection } from '../../../src/internal/streaming/StreamConnection' +import { + EventSourceErrorLike, + EventSourceInstance, + EventSourceLike, + EventSourceLikeInit, + MessageEventLike, +} from '../../../src/internal/streaming/EventSourceLike' + +class FakeEventSource implements EventSourceInstance { + static instances: FakeEventSource[] = [] + + readyState = 0 + onopen: ((ev: unknown) => void) | null = null + onmessage: ((ev: MessageEventLike) => void) | null = null + onerror: ((ev: EventSourceErrorLike | unknown) => void) | null = null + closed = false + + private readonly listeners = new Map< + string, + Array<(ev: MessageEventLike) => void> + >() + + constructor( + public readonly url: string, + public readonly init?: EventSourceLikeInit, + ) { + FakeEventSource.instances.push(this) + } + + addEventListener( + type: string, + listener: (ev: MessageEventLike) => void, + ): void { + if (!this.listeners.has(type)) this.listeners.set(type, []) + this.listeners.get(type)!.push(listener) + } + + removeEventListener( + type: string, + listener: (ev: MessageEventLike) => void, + ): void { + const arr = this.listeners.get(type) + if (!arr) return + const i = arr.indexOf(listener) + if (i !== -1) arr.splice(i, 1) + } + + emit(type: string, ev: MessageEventLike): void { + this.listeners.get(type)?.forEach((listener) => listener(ev)) + } + + close(): void { + this.closed = true + } +} + +function latest(): FakeEventSource { + return FakeEventSource.instances[FakeEventSource.instances.length - 1] +} + +// Regression coverage: the backoff reset used to be a timestamp comparison inside +// Backoff itself, gated on success(), which only fires once per connection (on +// onopen) — so the reset never fired for the ordinary stable-then-drop case. It is +// now a timer owned here: armed on open, canceled if the connection drops first. +suite('internal/streaming/StreamConnection — backoff reset timer', () => { + beforeEach(() => { + FakeEventSource.instances = [] + vi.useFakeTimers() + vi.spyOn(Math, 'random').mockReturnValue(0) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + function startConnection(): StreamConnection { + const conn = new StreamConnection({ + eventSource: FakeEventSource as unknown as EventSourceLike, + requestBuilder: () => ({ url: 'https://example.test/sse' }), + events: {}, + callbacks: { onOpen: vi.fn(), onError: vi.fn() }, + }) + conn.start() + return conn + } + + test('a connection that stays open 60s resets the delay before its next drop', () => { + startConnection() + + // Two quick failures escalate the delay: 1_000ms, then 2_000ms. + FakeEventSource.instances[0].onopen?.({}) + FakeEventSource.instances[0].onerror?.({}) + vi.advanceTimersByTime(1_000) // -> instance #2 opens + FakeEventSource.instances[1].onopen?.({}) + FakeEventSource.instances[1].onerror?.({}) + vi.advanceTimersByTime(2_000) // -> instance #3 opens + expect(FakeEventSource.instances).toHaveLength(3) + + // This time, stay open long enough to be proven stable, then drop. + FakeEventSource.instances[2].onopen?.({}) + vi.advanceTimersByTime(60_000) + FakeEventSource.instances[2].onerror?.({}) + + // If the reset took effect, the next retry is back at the initial 1_000ms delay, + // not the 4_000ms it would be had attempt kept escalating. + vi.advanceTimersByTime(999) + expect(FakeEventSource.instances).toHaveLength(3) + vi.advanceTimersByTime(1) + expect(FakeEventSource.instances).toHaveLength(4) + }) + + test('a connection that drops before 60s keeps escalating (flapping protection)', () => { + startConnection() + + FakeEventSource.instances[0].onopen?.({}) + FakeEventSource.instances[0].onerror?.({}) + vi.advanceTimersByTime(1_000) // -> instance #2 opens, attempt now 1 + + // Opens again, but drops well before the 60s reset window elapses. + FakeEventSource.instances[1].onopen?.({}) + vi.advanceTimersByTime(5_000) + FakeEventSource.instances[1].onerror?.({}) + + // Delay should still be the escalated 2_000ms, not reset back to 1_000ms. + vi.advanceTimersByTime(1_999) + expect(FakeEventSource.instances).toHaveLength(2) + vi.advanceTimersByTime(1) + expect(FakeEventSource.instances).toHaveLength(3) + }) +}) + +suite('internal/streaming/StreamConnection — health model', () => { + let onOpen: ReturnType + let onError: ReturnType + let messageHandler: ReturnType + let namedHandler: ReturnType + + beforeEach(() => { + FakeEventSource.instances = [] + vi.useFakeTimers() + vi.spyOn(Math, 'random').mockReturnValue(0) + onOpen = vi.fn() + onError = vi.fn() + messageHandler = vi.fn() + namedHandler = vi.fn() + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + function startConnection(): StreamConnection { + const conn = new StreamConnection({ + eventSource: FakeEventSource as unknown as EventSourceLike, + requestBuilder: () => ({ url: 'https://example.test/sse' }), + events: { + evaluations: namedHandler, + }, + onUnhandledMessage: messageHandler, + callbacks: { onOpen, onError }, + }) + conn.start() + return conn + } + + test('open then event: onOpen called, data dispatched, watchdog reset', () => { + startConnection() + latest().onopen?.({}) + expect(onOpen).toHaveBeenCalledTimes(1) + + latest().onmessage?.({ data: '{"a":1}' }) + expect(messageHandler).toHaveBeenCalledWith('{"a":1}') + + latest().emit('evaluations', { data: '{"b":2}' }) + expect(namedHandler).toHaveBeenCalledWith('{"b":2}') + + // A bare liveness tick (data: undefined) resets the watchdog but carries no data. + latest().onmessage?.({ data: undefined }) + expect(messageHandler).toHaveBeenCalledTimes(1) + + // Watchdog was just reset — 69_999ms of silence does not reconnect... + vi.advanceTimersByTime(69_999) + expect(FakeEventSource.instances).toHaveLength(1) + // ...but the 70_000ms mark trips it and schedules a reconnect. + vi.advanceTimersByTime(1) + vi.advanceTimersByTime(1_000) + expect(FakeEventSource.instances).toHaveLength(2) + expect(onError).not.toHaveBeenCalled() + }) + + test('a named event with no data (e.g. a connection-error signal) does not reset the watchdog — it still trips on schedule', () => { + // Regression guard: see the decision chart above openConnection()'s event + // wiring in StreamConnection.ts. A named event with no data (like a native + // EventSource's connection-error 'error' event) is a failure signal, not + // proof the stream is working — it must not reset the watchdog, or a + // repeatedly failing connection could mask itself as healthy forever. + startConnection() + latest().onopen?.({}) + + vi.advanceTimersByTime(69_000) + latest().emit('evaluations', { data: undefined }) + expect(namedHandler).not.toHaveBeenCalled() // no data → handler not invoked + + // The dataless named event must NOT have reset the watchdog: only 1_000ms + // remain until the original 70s mark set at onopen. + vi.advanceTimersByTime(999) + expect(FakeEventSource.instances).toHaveLength(1) + vi.advanceTimersByTime(1) // watchdog trips → scheduleReconnect() + vi.advanceTimersByTime(1_000) // backoff delay → instance #2 + expect(FakeEventSource.instances).toHaveLength(2) + }) + + test('liveness tracking works even with no events map and no onUnhandledMessage', () => { + // Structural guarantee: the message/liveness channel is wired + // unconditionally in openConnection(), not gated behind the caller + // supplying events or onUnhandledMessage. Built independently of the + // shared startConnection() helper (which always supplies + // onUnhandledMessage) to prove the guarantee holds with neither present. + const conn = new StreamConnection({ + eventSource: FakeEventSource as unknown as EventSourceLike, + requestBuilder: () => ({ url: 'https://example.test/sse' }), + events: {}, + callbacks: { onOpen, onError }, + }) + conn.start() + latest().onopen?.({}) + + // Nothing but bare liveness ticks (data: undefined) for several watchdog + // intervals — no put/patch/error, no onUnhandledMessage wired at all. + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(60_000) + latest().onmessage?.({ data: undefined }) + } + + expect(FakeEventSource.instances).toHaveLength(1) + expect(onError).not.toHaveBeenCalled() + }) + + test('transient error after open self-heals with backoff, onError NOT called', () => { + startConnection() + latest().onopen?.({}) + latest().onerror?.({}) + + vi.advanceTimersByTime(999) + expect(FakeEventSource.instances).toHaveLength(1) + vi.advanceTimersByTime(1) + expect(FakeEventSource.instances).toHaveLength(2) + expect(onError).not.toHaveBeenCalled() + }) + + test('healthy for 10 min then a single drop reconnects — does NOT give up', () => { + startConnection() + latest().onopen?.({}) + + // Stay healthy for 10 minutes: an event every 60s keeps the watchdog fed. + for (let i = 0; i < 10; i++) { + vi.advanceTimersByTime(60_000) + latest().onmessage?.({ data: undefined }) + } + expect(FakeEventSource.instances).toHaveLength(1) + + // Single drop after a long healthy life must self-heal, not fall back. + latest().onerror?.({}) + vi.advanceTimersByTime(1_000) + expect(FakeEventSource.instances).toHaveLength(2) + expect(onError).not.toHaveBeenCalled() + }) + + test('unhealthy > 120s with no events in between gives up non-terminal', () => { + startConnection() + latest().onopen?.({}) + latest().onerror?.({}) // unhealthy clock starts + + // Drops keep failing; delays escalate 1,2,4,8,16,30,30,30 (jitter mocked to 0). + // Cumulative unhealthy time passes 120s on the last error below. + for (const delaySeconds of [1, 2, 4, 8, 16, 30, 30]) { + vi.advanceTimersByTime(delaySeconds * 1_000) + expect(onError).not.toHaveBeenCalled() + latest().onerror?.({}) + } + vi.advanceTimersByTime(30_000) // now 121s since the first failure + latest().onerror?.({}) + expect(onError).toHaveBeenCalledWith({ terminal: false }) + expect(onError).toHaveBeenCalledTimes(1) + }) + + test('an event received between drops restarts the unhealthy window', () => { + startConnection() + latest().onopen?.({}) + latest().onerror?.({}) // t=0: unhealthy clock starts + vi.advanceTimersByTime(1_000) + latest().onerror?.({}) + vi.advanceTimersByTime(2_000) // t=3s: instance #3 + + // Data arrives → healthy again, window cleared. + latest().onmessage?.({ data: undefined }) + latest().onerror?.({}) // t=3s: NEW unhealthy window starts here + + // Same escalation as before; at t=121s only 118s of THIS window have + // elapsed, so the give-up that would fire without the event must not. + for (const delaySeconds of [4, 8, 16, 30, 30, 30]) { + vi.advanceTimersByTime(delaySeconds * 1_000) + latest().onerror?.({}) + } + // t=121s — without the healthy event this would have given up already. + expect(onError).not.toHaveBeenCalled() + + vi.advanceTimersByTime(30_000) // t=151s: 148s into the restarted window + latest().onerror?.({}) + expect(onError).toHaveBeenCalledWith({ terminal: false }) + }) + + test('connect that hangs without ever opening gives up once the window elapses', () => { + startConnection() + + // No onopen ever fires. The watchdog doubles as the connect timeout. + vi.advanceTimersByTime(70_000) // watchdog trips → unhealthy clock starts + vi.advanceTimersByTime(1_000) // backoff → instance #2 + expect(FakeEventSource.instances).toHaveLength(2) + + vi.advanceTimersByTime(70_000) // second trip: 71s unhealthy → still retries + vi.advanceTimersByTime(2_000) // backoff → instance #3 + expect(FakeEventSource.instances).toHaveLength(3) + expect(onError).not.toHaveBeenCalled() + + vi.advanceTimersByTime(70_000) // third trip: 143s unhealthy → give up + expect(onError).toHaveBeenCalledWith({ terminal: false }) + expect(FakeEventSource.instances).toHaveLength(3) + }) + + test('reconnect() while a backoff retry is pending produces exactly one new connection', () => { + const conn = startConnection() + latest().onopen?.({}) + latest().onerror?.({}) // backoff retry scheduled in 1s + expect(FakeEventSource.instances).toHaveLength(1) + expect(FakeEventSource.instances[0].closed).toBe(true) + + conn.reconnect() // must clear the pending timer and open immediately + expect(FakeEventSource.instances).toHaveLength(2) + + // The stale backoff timer must not fire a third connection. + vi.advanceTimersByTime(10_000) + expect(FakeEventSource.instances).toHaveLength(2) + expect(FakeEventSource.instances[1].closed).toBe(false) + }) + + test.each([401, 403])('error with status %i gives up terminal, no retry', (status) => { + startConnection() + latest().onerror?.({ status }) + expect(onError).toHaveBeenCalledWith({ terminal: true }) + expect(FakeEventSource.instances[0].closed).toBe(true) + + vi.advanceTimersByTime(300_000) + expect(FakeEventSource.instances).toHaveLength(1) + }) + + test('error with terminal: true from the EventSource gives up terminal, no retry', () => { + startConnection() + latest().onopen?.({}) + latest().onerror?.({ terminal: true }) + expect(onError).toHaveBeenCalledWith({ terminal: true }) + + vi.advanceTimersByTime(300_000) + expect(FakeEventSource.instances).toHaveLength(1) + }) + + test('error before first open with a recoverable status retries with backoff instead of giving up immediately (regression: previously gave up on the very first failed connect attempt)', () => { + startConnection() + latest().onerror?.({ status: 500 }) + expect(onError).not.toHaveBeenCalled() + expect(FakeEventSource.instances).toHaveLength(1) + + vi.advanceTimersByTime(999) + expect(FakeEventSource.instances).toHaveLength(1) + vi.advanceTimersByTime(1) + expect(FakeEventSource.instances).toHaveLength(2) + }) + + test('repeated recoverable pre-open errors eventually give up once the unhealthy window elapses (same bound as the post-open case)', () => { + startConnection() + latest().onerror?.({ status: 500 }) // unhealthy clock starts + + // Delays escalate 1,2,4,8,16,30,30,30 (jitter mocked to 0) — same schedule + // as the post-open 'unhealthy > 120s' case above. + for (const delaySeconds of [1, 2, 4, 8, 16, 30, 30]) { + vi.advanceTimersByTime(delaySeconds * 1_000) + expect(onError).not.toHaveBeenCalled() + latest().onerror?.({ status: 500 }) + } + vi.advanceTimersByTime(30_000) // now 121s since the first failure + latest().onerror?.({ status: 500 }) + expect(onError).toHaveBeenCalledWith({ terminal: false }) + expect(onError).toHaveBeenCalledTimes(1) + }) + + test('external reconnect() must NOT reset the unhealthy give-up window — attribute-driven reconnects while the endpoint is down still fall back', () => { + // Simulates a down stream endpoint while the app calls + // updateUserAttributes() more often than the 120s give-up window. Each + // update triggers an external reconnect(); if reconnect() forgives the + // unhealthy window, onError (→ the polling fallback) is starved forever. + // The window must survive external reconnects. Each cycle is spaced 60s — + // under both the 120s give-up window and the 70s watchdog — so only + // reconnect() drives the connection churn, not auto-backoff/watchdog. + const conn = startConnection() + + // t=0: first recoverable failure starts the unhealthy clock, then an + // attribute update reconnects immediately (cancelling the backoff retry). + latest().onerror?.({ status: 500 }) + conn.reconnect() + + vi.advanceTimersByTime(60_000) // t=60 + latest().onerror?.({ status: 500 }) + conn.reconnect() + + vi.advanceTimersByTime(60_000) // t=120: 120s unhealthy is not yet > 120s + latest().onerror?.({ status: 500 }) + expect(onError).not.toHaveBeenCalled() + conn.reconnect() + + vi.advanceTimersByTime(60_000) // t=180: 180s of continuous unhealth + latest().onerror?.({ status: 500 }) + + // Give up: 180s > 120s since the first failure. The reconnects must not + // have reset the window — otherwise onError never fires and the polling + // fallback is starved for as long as the app keeps updating attributes. + expect(onError).toHaveBeenCalledWith({ terminal: false }) + }) + + test('a reconnect whose connection recovers and delivers data clears the unhealthy window — a recovered stream does not fall back', () => { + // Companion to the give-up case above. Preserving the unhealthy clock + // across reconnect() must not prevent a genuine recovery: once real bytes + // arrive on a reopened connection (markHealthy), the give-up horizon is + // forgotten, so the stream keeps running instead of falling back — even + // though more than 120s have passed since the first failure. + const conn = startConnection() + + // Down for two attribute-driven reconnect cycles (well into the window). + latest().onerror?.({ status: 500 }) // t=0: unhealthy clock starts + conn.reconnect() + vi.advanceTimersByTime(60_000) // t=60 + latest().onerror?.({ status: 500 }) + conn.reconnect() + + // t=120: this reconnect's connection actually opens and delivers a byte. + vi.advanceTimersByTime(60_000) + latest().onopen?.({}) + latest().onmessage?.({ data: undefined }) // liveness → clears the window + expect(onOpen).toHaveBeenCalled() + + // Stay alive well past the original 120s horizon; a fed watchdog keeps it up. + for (let i = 0; i < 4; i++) { + vi.advanceTimersByTime(60_000) + latest().onmessage?.({ data: undefined }) + } + + // No fallback, and no connection churn: the recovered stream is stable. + expect(onError).not.toHaveBeenCalled() + expect(FakeEventSource.instances).toHaveLength(3) + expect(latest().closed).toBe(false) + }) + + test('events and errors from a stale (replaced) EventSource instance are ignored', () => { + startConnection() + const stale = latest() + stale.onopen?.({}) + stale.onerror?.({}) // → closed, retry in 1s + vi.advanceTimersByTime(1_000) + const current = latest() + expect(FakeEventSource.instances).toHaveLength(2) + current.onopen?.({}) + expect(onOpen).toHaveBeenCalledTimes(2) + + // A misbehaving replaced instance fires late events: all must be ignored. + stale.onopen?.({}) + expect(onOpen).toHaveBeenCalledTimes(2) + stale.onmessage?.({ data: 'late' }) + expect(messageHandler).not.toHaveBeenCalled() + stale.onerror?.({ status: 401 }) + expect(onError).not.toHaveBeenCalled() + expect(current.closed).toBe(false) + }) + + test('stop() clears timers, closes the EventSource, and ignores later errors', () => { + const conn = startConnection() + latest().onopen?.({}) + latest().onerror?.({}) // backoff retry pending + + conn.stop() + expect(FakeEventSource.instances[0].closed).toBe(true) + + // Neither the pending retry nor the watchdog may fire after stop(). + vi.advanceTimersByTime(300_000) + expect(FakeEventSource.instances).toHaveLength(1) + + FakeEventSource.instances[0].onerror?.({}) + expect(onError).not.toHaveBeenCalled() + }) + + test('a synchronously-throwing eventSource constructor is routed to onError instead of crashing', () => { + const ThrowingEventSource = function () { + throw new Error('constructor boom') + } as unknown as EventSourceLike + + const conn = new StreamConnection({ + eventSource: ThrowingEventSource, + requestBuilder: () => ({ url: 'https://example.test/sse' }), + events: {}, + callbacks: { onOpen, onError }, + }) + + expect(() => conn.start()).not.toThrow() + expect(onError).toHaveBeenCalledWith({ terminal: false }) + }) +}) diff --git a/test/internal/streaming/StreamingTask.spec.ts b/test/internal/streaming/StreamingTask.spec.ts new file mode 100644 index 00000000..dcf0e588 --- /dev/null +++ b/test/internal/streaming/StreamingTask.spec.ts @@ -0,0 +1,694 @@ +import { expect, suite, test, vi, beforeEach, afterEach } from 'vitest' + +import { BKTConfig } from '../../../src/BKTConfig' +import { DefaultComponent } from '../../../src/internal/di/Component' +import { SourceId } from '../../../src/internal/model/SourceId' +import { EvaluationTask } from '../../../src/internal/scheduler/EvaluationTask' +import { + EventSourceErrorLike, + EventSourceInstance, + EventSourceLike, + EventSourceLikeInit, + MessageEventLike, +} from '../../../src/internal/streaming/EventSourceLike' +import { StreamingTask } from '../../../src/internal/streaming/StreamingTask' +import { SDK_VERSION } from '../../../src/internal/version' +import { FetchLike } from '../../../src/internal/remote/fetch' +import { buildTestComponent } from '../../utils' +import { user1 } from '../../mocks/users' +import { user1Evaluations } from '../../mocks/evaluations' + +const RECOVERY_INTERVAL_MILLIS = 5 * 60_000 + +class FakeEventSource implements EventSourceInstance { + static instances: FakeEventSource[] = [] + + readyState = 0 + onopen: ((ev: unknown) => void) | null = null + onmessage: ((ev: MessageEventLike) => void) | null = null + onerror: ((ev: EventSourceErrorLike | unknown) => void) | null = null + closed = false + + private readonly listeners = new Map< + string, + Array<(ev: MessageEventLike) => void> + >() + + constructor( + public readonly url: string, + public readonly init?: EventSourceLikeInit, + ) { + FakeEventSource.instances.push(this) + } + + addEventListener( + type: string, + listener: (ev: MessageEventLike) => void, + ): void { + if (!this.listeners.has(type)) this.listeners.set(type, []) + this.listeners.get(type)!.push(listener) + } + + removeEventListener( + type: string, + listener: (ev: MessageEventLike) => void, + ): void { + const arr = this.listeners.get(type) + if (!arr) return + const i = arr.indexOf(listener) + if (i !== -1) arr.splice(i, 1) + } + + emit(type: string, ev: MessageEventLike): void { + this.listeners.get(type)?.forEach((listener) => listener(ev)) + } + + close(): void { + this.closed = true + } +} + +function latest(): FakeEventSource { + return FakeEventSource.instances[FakeEventSource.instances.length - 1] +} + +suite('internal/streaming/StreamingTask', () => { + let task: StreamingTask | undefined + let evaluationTaskStart: ReturnType + let evaluationTaskStop: ReturnType + + beforeEach(() => { + FakeEventSource.instances = [] + vi.useFakeTimers() + vi.spyOn(Math, 'random').mockReturnValue(0) + // The polling fallback is EvaluationTask's own concern — observe it via spies + // so these tests exercise only StreamingTask's policy. + evaluationTaskStart = vi + .spyOn(EvaluationTask.prototype, 'start') + .mockImplementation(() => {}) + evaluationTaskStop = vi + .spyOn(EvaluationTask.prototype, 'stop') + .mockImplementation(() => {}) + }) + + afterEach(() => { + task?.stop() + task = undefined + vi.useRealTimers() + vi.restoreAllMocks() + }) + + function buildComponent(override: Partial = {}): DefaultComponent { + // Injects the FakeEventSource by default; the shared builder applies the + // rest. Object.assign, not spread, so an override can still set eventSource + // (e.g. to undefined) without tripping the no-spread-after-defaults rule. + return buildTestComponent( + Object.assign( + { eventSource: FakeEventSource as unknown as EventSourceLike }, + override, + ), + ) + } + + // Mirrors the real init order (BKTClient.ts's initializeBKTClientInternal()): + // evaluationInteractor().initialize() always resolves before any task's + // start() can read the cache. StreamingTask.start() synchronously reads it + // via buildRequest() → getCurrentEvaluationsCondition(), which throws if + // called too early — so tests must initialize() first, same as real usage. + async function startTask(component: DefaultComponent): Promise { + await component.evaluationInteractor().initialize() + task = new StreamingTask(component) + task.start() + return task + } + + test('buildRequest: POST to /stream_evaluations with the full header profile and body', async () => { + await startTask(buildComponent()) + + expect(FakeEventSource.instances).toHaveLength(1) + const es = latest() + expect(es.url).toBe('https://api.bucketeer.io/stream_evaluations') + expect(es.init?.method).toBe('POST') + // The FULL profile must be here — injected transports receive it as-is. + expect(es.init?.headers).toEqual({ + Authorization: 'api_key_value', + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }) + expect(JSON.parse(es.init?.body ?? '')).toEqual({ + tag: 'feature_tag_value', + user: { id: user1.id, data: user1.data }, + sourceId: SourceId.JAVASCRIPT, + sdkVersion: SDK_VERSION, + // Storage is initialized but empty (fresh install, nothing cached yet) + // — proto3 zero values. + userEvaluationsId: '', + evaluatedAt: '0', + }) + }) + + test('buildRequest includes the stored userEvaluationsId/evaluatedAt when available', async () => { + const component = buildComponent() + vi.spyOn( + component.evaluationInteractor(), + 'getCurrentEvaluationsCondition', + ).mockReturnValue({ + currentEvaluationsId: 'stored_evaluations_id', + evaluatedAt: '1700000000', + }) + await startTask(component) + + const body = JSON.parse(latest().init?.body ?? '') + expect(body.userEvaluationsId).toBe('stored_evaluations_id') + expect(body.evaluatedAt).toBe('1700000000') + }) + + test('without config.eventSource the built-in FetchEventSource is used', async () => { + const fetchImpl = vi.fn( + () => new Promise(() => {}), + ) as unknown as FetchLike + const component = buildComponent({ + eventSource: undefined, + fetch: fetchImpl, + }) + await startTask(component) + + // No injected fake constructed; the built-in transport went through fetch. + expect(FakeEventSource.instances).toHaveLength(0) + expect(fetchImpl).toHaveBeenCalledTimes(1) + const [url, request] = vi.mocked(fetchImpl).mock.calls[0] + expect(url).toBe('https://api.bucketeer.io/stream_evaluations') + expect(request.headers.Accept).toBe('text/event-stream') + expect(request.headers.Authorization).toBe('api_key_value') + }) + + test('config.eventSource injected: the injected constructor is used', async () => { + await startTask(buildComponent()) + expect(FakeEventSource.instances).toHaveLength(1) + }) + + test('put event with valid JSON is applied via applyEvaluationsResponse', async () => { + const component = buildComponent() + const apply = vi + .spyOn(component.evaluationInteractor(), 'applyEvaluationsResponse') + .mockResolvedValue(undefined) + await startTask(component) + + const response = { + evaluations: user1Evaluations, + userEvaluationsId: 'user_evaluation_id_value', + } + latest().onopen?.({}) + latest().emit('put', { data: JSON.stringify(response) }) + await Promise.resolve() + + expect(apply).toHaveBeenCalledWith(response, expect.any(Function)) + }) + + test('patch event with valid JSON is applied via applyEvaluationsResponse', async () => { + const component = buildComponent() + const apply = vi + .spyOn(component.evaluationInteractor(), 'applyEvaluationsResponse') + .mockResolvedValue(undefined) + await startTask(component) + + const response = { + evaluations: user1Evaluations, + userEvaluationsId: 'user_evaluation_id_value', + } + latest().onopen?.({}) + latest().emit('patch', { data: JSON.stringify(response) }) + await Promise.resolve() + + expect(apply).toHaveBeenCalledWith(response, expect.any(Function)) + }) + + test('error event is logged distinctly and never applied', async () => { + const component = buildComponent() + const apply = vi + .spyOn(component.evaluationInteractor(), 'applyEvaluationsResponse') + .mockResolvedValue(undefined) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + await startTask(component) + + latest().onopen?.({}) + latest().emit('error', { data: '{"code":13,"message":"internal"}' }) + await Promise.resolve() + + expect(apply).not.toHaveBeenCalled() + expect(consoleError).toHaveBeenCalledWith( + 'StreamingTask: server reported a stream error', + '{"code":13,"message":"internal"}', + ) + }) + + test('unnamed message event with valid JSON is applied via onUnhandledMessage', async () => { + // Positive counterpart to the invalid-JSON/after-stop cases below: proves + // the onUnhandledMessage → handleData wiring actually applies good data, + // not just that it safely ignores bad data. + const component = buildComponent() + const apply = vi + .spyOn(component.evaluationInteractor(), 'applyEvaluationsResponse') + .mockResolvedValue(undefined) + await startTask(component) + + const response = { + evaluations: user1Evaluations, + userEvaluationsId: 'user_evaluation_id_value', + } + latest().onopen?.({}) + latest().onmessage?.({ data: JSON.stringify(response) }) + await Promise.resolve() + + expect(apply).toHaveBeenCalledWith(response, expect.any(Function)) + }) + + test('a payload that omits forceUpdate is applied (the backend may omit false booleans), routed through the update branch', async () => { + // A protobuf-JSON marshaler that drops zero-valued fields sends no + // forceUpdate key for a patch (forceUpdate=false). The shape check must + // accept the omission — the REST path already tolerates it — instead of + // silently dropping every streamed patch. + const component = buildComponent() + const apply = vi + .spyOn(component.evaluationInteractor(), 'applyEvaluationsResponse') + .mockResolvedValue(undefined) + await startTask(component) + + const payload = { + evaluations: { + id: 'evaluations_id', + evaluations: [], + archivedFeatureIds: [], + createdAt: '1700000000', + // no forceUpdate key + }, + userEvaluationsId: 'user_evaluation_id_value', + } + latest().onopen?.({}) + latest().emit('patch', { data: JSON.stringify(payload) }) + await Promise.resolve() + + expect(apply).toHaveBeenCalledWith(payload, expect.any(Function)) + }) + + test('data event with invalid JSON is ignored', async () => { + const component = buildComponent() + const apply = vi + .spyOn(component.evaluationInteractor(), 'applyEvaluationsResponse') + .mockResolvedValue(undefined) + await startTask(component) + + latest().onopen?.({}) + latest().onmessage?.({ data: 'not-json' }) + await Promise.resolve() + + expect(apply).not.toHaveBeenCalled() + }) + + test('data event with valid JSON but the wrong shape (not a GetEvaluationsResponse) is ignored (defense in depth: a misrouted/malformed payload must not reach storage)', async () => { + const component = buildComponent() + const apply = vi + .spyOn(component.evaluationInteractor(), 'applyEvaluationsResponse') + .mockResolvedValue(undefined) + await startTask(component) + + latest().onopen?.({}) + for (const badPayload of [ + '{}', + '{"userEvaluationsId":"x"}', // missing evaluations + '{"userEvaluationsId":42,"evaluations":{"forceUpdate":false}}', // wrong type + '{"userEvaluationsId":"x","evaluations":null}', // evaluations not an object + '[]', + 'null', + ]) { + latest().onmessage?.({ data: badPayload }) + } + await Promise.resolve() + + expect(apply).not.toHaveBeenCalled() + }) + + test('applyEvaluationsResponse rejecting does not surface as an unhandled rejection', async () => { + const component = buildComponent() + const unhandled = vi.fn() + // `process` only exists under the Node test runner (`vitest-node.config.ts`); + // the browser runner (`vitest-browser.config.ts`) executes in a real Chrome via + // webdriverio, where unhandled rejections surface through the window event instead. + const isNode = typeof process !== 'undefined' + const browserListener = (ev: unknown) => unhandled(ev) + if (isNode) { + process.on('unhandledRejection', unhandled) + } else { + globalThis.addEventListener('unhandledrejection', browserListener) + } + try { + vi + .spyOn(component.evaluationInteractor(), 'applyEvaluationsResponse') + .mockRejectedValue(new Error('storage failure')) + await startTask(component) + + const response = { + evaluations: user1Evaluations, + userEvaluationsId: 'user_evaluation_id_value', + } + latest().onopen?.({}) + latest().emit('put', { data: JSON.stringify(response) }) + await Promise.resolve() + await Promise.resolve() + + expect(unhandled).not.toHaveBeenCalled() + } finally { + if (isNode) { + process.off('unhandledRejection', unhandled) + } else { + globalThis.removeEventListener('unhandledrejection', browserListener) + } + } + }) + + test('data arriving after stop() is not applied', async () => { + const component = buildComponent() + const apply = vi + .spyOn(component.evaluationInteractor(), 'applyEvaluationsResponse') + .mockResolvedValue(undefined) + const t = await startTask(component) + + const es = latest() + es.onopen?.({}) + t.stop() + es.onmessage?.({ data: '{"evaluations":{},"userEvaluationsId":"x"}' }) + await Promise.resolve() + + expect(apply).not.toHaveBeenCalled() + }) + + test('shouldNotify passed to applyEvaluationsResponse reflects running, flips false after stop() mid-apply', async () => { + // Covers the case 'data arriving after stop()' above does not: a destroy + // racing an ALREADY-STARTED applyEvaluationsResponse call (e.g. stop() + // runs while the storage write is in flight) must still be able to + // suppress the update listeners once that write resolves. + const component = buildComponent() + let capturedShouldNotify: (() => boolean) | undefined + vi.spyOn(component.evaluationInteractor(), 'applyEvaluationsResponse') + .mockImplementation(async (_response, shouldNotify) => { + capturedShouldNotify = shouldNotify + }) + const t = await startTask(component) + + latest().onopen?.({}) + latest().emit('put', { + data: '{"evaluations":{"forceUpdate":false},"userEvaluationsId":"x"}', + }) + await Promise.resolve() + + expect(capturedShouldNotify?.()).toBe(true) + t.stop() + expect(capturedShouldNotify?.()).toBe(false) + }) + + test('non-terminal error with fallback enabled starts polling AND arms recovery', async () => { + await startTask(buildComponent()) + + // Never-opened + non-recoverable, non-terminal status (an unclassified + // 4xx — neither in httpStatus.ts's TERMINAL_STATUSES nor its recoverable + // set) → StreamConnection gives up immediately, no backoff retry. + latest().onerror?.({ status: 402 }) + + // start(true) fetches immediately instead of arming a timer for the next + // poll (up to 10 min by default) — otherwise a stream drop would leave + // users on stale evaluations for that whole window. + expect(evaluationTaskStart).toHaveBeenCalledWith(true) + + // Recovery fires after 5 minutes: streaming reopens; fallback stops once + // onOpen proves the reopened stream actually works (no polling gap). + vi.advanceTimersByTime(RECOVERY_INTERVAL_MILLIS) + expect(FakeEventSource.instances).toHaveLength(2) + latest().onopen?.({}) + expect(evaluationTaskStop).toHaveBeenCalled() + }) + + test('non-terminal error with fallback DISABLED still arms recovery', async () => { + await startTask(buildComponent({ streamingFallbackToPolling: false })) + + latest().onerror?.({ status: 402 }) // unclassified 4xx — immediate give-up + + // No polling fallback... + expect(evaluationTaskStart).not.toHaveBeenCalled() + // ...but streaming must not be permanently dead: recovery still reopens it. + vi.advanceTimersByTime(RECOVERY_INTERVAL_MILLIS) + expect(FakeEventSource.instances).toHaveLength(2) + }) + + test('terminal error starts fallback but never schedules recovery', async () => { + await startTask(buildComponent()) + + latest().onerror?.({ status: 401 }) + + expect(evaluationTaskStart).toHaveBeenCalledWith(true) + // No recovery timer pending — streaming is not retried for terminal errors. + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(30 * 60_000) + expect(FakeEventSource.instances).toHaveLength(1) + }) + + test('onOpen after recovery cancels fallback and leaves no recovery pending', async () => { + await startTask(buildComponent()) + + latest().onerror?.({ status: 402 }) // unclassified 4xx → fallback + recovery + vi.advanceTimersByTime(RECOVERY_INTERVAL_MILLIS) // recovery reopens the stream + expect(FakeEventSource.instances).toHaveLength(2) + + latest().onopen?.({}) + expect(evaluationTaskStop).toHaveBeenCalled() + // Only the connection's own timers remain (watchdog + backoff-reset) — + // no 5-minute recovery timer is pending anymore. + expect(vi.getTimerCount()).toBe(2) + }) + + test('recovery timer reopening the stream does not stop the fallback poller until onOpen succeeds (regression: no polling gap during the reconnect attempt)', async () => { + await startTask(buildComponent()) + + latest().onerror?.({ status: 402 }) // → fallback + recovery + expect(evaluationTaskStart).toHaveBeenCalledWith(true) + + vi.advanceTimersByTime(RECOVERY_INTERVAL_MILLIS) // recovery timer reopens the stream + expect(FakeEventSource.instances).toHaveLength(2) + // The new stream hasn't proven it opened yet — the poller must still be running. + expect(evaluationTaskStop).not.toHaveBeenCalled() + + latest().onopen?.({}) + expect(evaluationTaskStop).toHaveBeenCalled() + }) + + test('reconnect() from fallback mode does not stop the fallback poller until onOpen succeeds (regression: no polling gap during the reconnect attempt)', async () => { + const t = await startTask(buildComponent()) + + latest().onerror?.({ status: 402 }) // → fallback + recovery + expect(evaluationTaskStart).toHaveBeenCalledWith(true) + + t.reconnect() + expect(FakeEventSource.instances).toHaveLength(2) + expect(evaluationTaskStop).not.toHaveBeenCalled() + + latest().onopen?.({}) + expect(evaluationTaskStop).toHaveBeenCalled() + }) + + test('reconnect()-opened stream plus a previously armed recovery timer produces only one live connection (openStream() must be defensive)', async () => { + const t = await startTask(buildComponent()) + + latest().onerror?.({ status: 402 }) // → fallback + recovery timer armed for +5min + t.reconnect() // opens a fresh stream while the recovery timer is still pending + expect(FakeEventSource.instances).toHaveLength(2) + latest().onopen?.({}) // new stream succeeds — resets its own watchdog + + // Keep the new connection healthy so its OWN watchdog/backoff machinery + // never fires an extra connection — isolating whether the STALE recovery + // timer (armed before reconnect(), at the original failure) fires one. + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(60_000) + latest().onmessage?.({ data: undefined }) + } + + // The stale recovery timer (armed ~5 min ago) must not have fired a third connection. + expect(FakeEventSource.instances).toHaveLength(2) + }) + + test('a terminal error on a reconnect-opened stream leaves no stale recovery timer to reopen it (openStream() must clear the recovery timer armed by the earlier failure)', async () => { + // The onOpen path clears the recovery timer via stopFallback(), so the + // 'only one live connection' test above does not actually exercise + // openStream()'s own clearTimeout. This path does: the reconnect stream + // never opens — it fails terminally — so onOpen never runs, and + // handleError(terminal) does NOT re-arm recovery. Only openStream()'s + // entry clearTimeout can cancel the timer armed by the ORIGINAL failure, + // or it fires 5 min later and reopens a stream that was permanently dead. + const t = await startTask(buildComponent()) + + latest().onerror?.({ status: 402 }) // non-terminal → fallback + recovery armed + t.reconnect() // fallback mode → openStream() opens a fresh stream + expect(FakeEventSource.instances).toHaveLength(2) + + latest().onerror?.({ status: 401 }) // reconnect stream fails terminally + + // The original failure's recovery timer must have been cleared at reconnect; + // otherwise it fires here and opens a third connection on a dead stream. + vi.advanceTimersByTime(RECOVERY_INTERVAL_MILLIS) + expect(FakeEventSource.instances).toHaveLength(2) + }) + + test('onOpen clears the userAttributesUpdated flag using the state captured when the request was built', async () => { + const component = buildComponent() + await component.evaluationInteractor().initialize() + await component.evaluationInteractor().setUserAttributesUpdated() + expect( + component.evaluationInteractor().getUserAttributesState() + .userAttributesUpdated, + ).toBe(true) + + task = new StreamingTask(component) + task.start() // buildRequest() runs synchronously here, capturing the flag=true snapshot + + latest().onopen?.({}) + // clearUserAttributesUpdated() is fire-and-forget from onOpen and goes + // through the storage mutex + a storage write, so it needs a few + // microtask ticks to actually land (fake timers are active in this + // suite, so this flushes microtasks directly rather than via a timer). + for (let i = 0; i < 10; i++) { + await Promise.resolve() + } + + expect( + component.evaluationInteractor().getUserAttributesState() + .userAttributesUpdated, + ).toBe(false) + }) + + test('userAttributesUpdated set after buildRequest() but before onOpen survives the clear (regression: a race must not wipe a newer flag)', async () => { + const component = buildComponent() + await startTask(component) // buildRequest() captures updateSequence at flag=false + + // Simulates updateUserAttributes() racing between connect and open. + await component.evaluationInteractor().setUserAttributesUpdated() + expect( + component.evaluationInteractor().getUserAttributesState() + .userAttributesUpdated, + ).toBe(true) + + latest().onopen?.({}) + await Promise.resolve() + + expect( + component.evaluationInteractor().getUserAttributesState() + .userAttributesUpdated, + ).toBe(true) + }) + + test('connect failure never clears the userAttributesUpdated flag (must survive for the polling fallback)', async () => { + const component = buildComponent() + await component.evaluationInteractor().initialize() + await component.evaluationInteractor().setUserAttributesUpdated() + + task = new StreamingTask(component) + task.start() + + latest().onerror?.({ status: 500 }) // never opened — onOpen never fires + await Promise.resolve() + + expect( + component.evaluationInteractor().getUserAttributesState() + .userAttributesUpdated, + ).toBe(true) + }) + + test('reconnect() while streaming opens exactly one fresh connection with fresh attributes', async () => { + const component = buildComponent() + const t = await startTask(component) + latest().onopen?.({}) + + component.userHolder().updateAttributes(() => ({ plan: 'premium' })) + t.reconnect() + + expect(FakeEventSource.instances).toHaveLength(2) + expect(FakeEventSource.instances[0].closed).toBe(true) + const body = JSON.parse(latest().init?.body ?? '') + expect(body.user.data).toEqual({ plan: 'premium' }) + + // No stale backoff/reconnect timer may open a third connection. + vi.advanceTimersByTime(30_000) + expect(FakeEventSource.instances).toHaveLength(2) + }) + + test('reconnect() rebuilds the body with the latest stored userEvaluationsId/evaluatedAt', async () => { + const component = buildComponent() + const conditionSpy = vi.spyOn( + component.evaluationInteractor(), + 'getCurrentEvaluationsCondition', + ) + conditionSpy.mockReturnValue({ currentEvaluationsId: '', evaluatedAt: '0' }) + const t = await startTask(component) + latest().onopen?.({}) + + // Storage advanced between the first connect and the reconnect (e.g. a + // put/patch was applied) — buildRequest() is re-invoked on every + // (re)connect, so it must pick up the new values, not the stale ones. + conditionSpy.mockReturnValue({ + currentEvaluationsId: 'updated_evaluations_id', + evaluatedAt: '1700000999', + }) + t.reconnect() + + const body = JSON.parse(latest().init?.body ?? '') + expect(body.userEvaluationsId).toBe('updated_evaluations_id') + expect(body.evaluatedAt).toBe('1700000999') + }) + + test('reconnect() while on polling fallback jumps straight back to streaming', async () => { + const t = await startTask(buildComponent()) + + latest().onerror?.({ status: 402 }) // unclassified 4xx → fallback + recovery + expect(evaluationTaskStart).toHaveBeenCalledWith(true) + + t.reconnect() + + expect(FakeEventSource.instances).toHaveLength(2) + // The old recovery timer was cancelled — the only pending timer is the new + // connection's watchdog. (Advancing time instead would trip that watchdog + // on the silent fake connection and self-heal reconnects would fire.) + expect(vi.getTimerCount()).toBe(1) + + // Fallback stops once onOpen proves the reopened stream actually works + // (no polling gap in between). + latest().onopen?.({}) + expect(evaluationTaskStop).toHaveBeenCalled() + }) + + test('reconnect() after a terminal error is a no-op (stream is permanently dead; fallback keeps polling untouched)', async () => { + const t = await startTask(buildComponent()) + + latest().onerror?.({ status: 401 }) // bad API key (terminal) + expect(evaluationTaskStart).toHaveBeenCalledWith(true) + expect(FakeEventSource.instances).toHaveLength(1) + + t.reconnect() + + // No new connection, and the fallback poller was never touched (it must + // keep running on its own schedule — same as pure polling mode, where + // updateUserAttributes() doesn't force an immediate fetch either). + expect(FakeEventSource.instances).toHaveLength(1) + expect(evaluationTaskStop).not.toHaveBeenCalled() + }) + + + test('stop() stops connection, fallback, and recovery', async () => { + const t = await startTask(buildComponent()) + + latest().onerror?.({ status: 402 }) // unclassified 4xx → fallback + recovery + t.stop() + + expect(t.isRunning()).toBe(false) + expect(evaluationTaskStop).toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(30 * 60_000) + expect(FakeEventSource.instances).toHaveLength(1) + }) +}) diff --git a/test/internal/streaming/httpStatus.spec.ts b/test/internal/streaming/httpStatus.spec.ts new file mode 100644 index 00000000..4d9510c9 --- /dev/null +++ b/test/internal/streaming/httpStatus.spec.ts @@ -0,0 +1,55 @@ +import { expect, suite, test } from 'vitest' +import { + isRecoverableStatus, + isTerminalStatus, +} from '../../../src/internal/streaming/httpStatus' + +suite('internal/streaming/httpStatus', () => { + suite('isRecoverableStatus', () => { + test('undefined (network error / unknown) is recoverable', () => { + expect(isRecoverableStatus(undefined)).toBe(true) + }) + + test.each([500, 502, 503, 504])('5xx (%i) is recoverable', (status) => { + expect(isRecoverableStatus(status)).toBe(true) + }) + + test.each([400, 408, 429])( + 'retryable 4xx (%i) is recoverable', + (status) => { + expect(isRecoverableStatus(status)).toBe(true) + }, + ) + + // 499 (deployment-related "client closed request") is a dedicated retry + // case for post.ts's polling requests (see ClientClosedRequestException in + // post.ts) — a backend rollout that polling survives must not kill the + // stream via the give-up branch either. + test('499 (deployment-related client closed request) is recoverable', () => { + expect(isRecoverableStatus(499)).toBe(true) + }) + + test.each([401, 403, 404, 405])( + 'other 4xx (%i) is not recoverable', + (status) => { + expect(isRecoverableStatus(status)).toBe(false) + }, + ) + }) + + suite('isTerminalStatus', () => { + test.each([401, 403, 404, 405, 406, 410, 413, 414, 415, 422, 431, 451])( + '%i is terminal', + (status) => { + expect(isTerminalStatus(status)).toBe(true) + }, + ) + + test.each([undefined, 400, 402, 408, 409, 428, 429, 500, 503])( + '%s is not terminal', + (status) => { + expect(isTerminalStatus(status)).toBe(false) + }, + ) + }) +}) diff --git a/test/utils.ts b/test/utils.ts index 9d92cefb..153d6fc9 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -3,12 +3,17 @@ import { SetupServer, setupServer } from 'msw/node' import { Clock, DefaultClock } from '../src/internal/Clock' import { IdGenerator } from '../src/internal/IdGenerator' import { BKTClient, BKTClientImpl } from '../src/BKTClient' +import { BKTConfig, defineBKTConfig } from '../src/BKTConfig' import { Component, DefaultComponent } from '../src/internal/di/Component' +import { DataModule } from '../src/internal/di/DataModule' +import { InteractorModule } from '../src/internal/di/InteractorModule' import { EvaluationStorageImpl } from '../src/internal/evaluation/EvaluationStorage' import { EventStorageImpl } from '../src/internal/event/EventStorage' import { PlatformModule } from '../src/internal/di/PlatformModule' +import { requiredInternalConfig } from '../src/internal/InternalConfig' import { NodeIdGenerator } from '../src/internal/IdGenerator.node' import { BrowserIdGenerator } from '../src/internal/IdGenerator.browser' +import { user1 } from './mocks/users' export function setupServerAndListen( ...handlers: Array @@ -82,6 +87,33 @@ export class TestPlatformModule implements PlatformModule { } } +// Shared component builder for the streaming/scheduler suites — same base +// config on both; callers pass suite-specific overrides (e.g. StreamingTask +// injects eventSource). Object.assign, not spread: the no-spread-after-defaults +// lint rule forbids spreading a source object over already-applied defaults. +export function buildTestComponent( + override: Partial = {}, +): DefaultComponent { + const config = defineBKTConfig( + Object.assign( + { + apiKey: 'api_key_value', + apiEndpoint: 'https://api.bucketeer.io', + featureTag: 'feature_tag_value', + appVersion: '1.2.3', + enableStreaming: true, + fetch: () => new Promise(() => {}), // never resolves; unused in these suites + }, + override, + ), + ) + return new DefaultComponent( + new TestPlatformModule(), + new DataModule(user1, requiredInternalConfig(config)), + new InteractorModule(), + ) +} + export const getDefaultComponent = (client: BKTClient): DefaultComponent => { return (client as BKTClientImpl).component as DefaultComponent }