Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
d537c8d
chore: init plan for implement SSE
duyhungtnn Jun 30, 2026
cb799e9
Create CLAUDE.md
duyhungtnn Jun 30, 2026
fd4debf
chore: remove planning file from git
duyhungtnn Jul 9, 2026
647de94
Update .gitignore
duyhungtnn Jul 9, 2026
78ee4ec
chore: add new BKTConfig that enable streaming
duyhungtnn Jul 9, 2026
348a835
chore: init backoff logic
duyhungtnn Jul 9, 2026
37c719e
chore: update FetchLike interface
duyhungtnn Jul 9, 2026
96defb4
chore: make BackOff logic simpler
duyhungtnn Jul 9, 2026
6f3d13d
feat: SSE darft implementation
duyhungtnn Jul 9, 2026
7b7b1e4
Update .gitignore
duyhungtnn Jul 10, 2026
72d4c71
feat(streaming): add EventSourceErrorLike type and terminal status check
duyhungtnn Jul 11, 2026
82b6642
refactor(streaming): overhaul StreamConnection health and retry model
duyhungtnn Jul 12, 2026
28334b7
fix(streaming): harden FetchEventSource for spec compliance and compat
duyhungtnn Jul 12, 2026
e0fd936
fix(streaming): skip recovery timer for terminal errors, complete hea…
duyhungtnn Jul 12, 2026
c43d03b
fix(evaluation): clear userAttributesUpdated only after fetch, not on…
duyhungtnn Jul 13, 2026
0603300
feat: export EventSourceLike types from all platform entry points
duyhungtnn Jul 13, 2026
a28d4c6
fix(streaming): suppress unhandled rejections from async event handlers
duyhungtnn Jul 13, 2026
4b48860
fix(streaming): expand terminal status set to all non-retriable 4xx c…
duyhungtnn Jul 13, 2026
92996d7
fix(streaming): log handleData errors instead of silently swallowing …
duyhungtnn Jul 13, 2026
475f414
fix(streaming): align event names with backend, tighten liveness dete…
duyhungtnn Jul 14, 2026
a50c6f5
refactor(streaming): make liveness tracking unconditional in StreamCo…
duyhungtnn Jul 14, 2026
9b46daa
feat(streaming): send last-known evaluation state on every (re)connect
duyhungtnn Jul 14, 2026
3ee1417
fix(streaming): initialize evaluation cache before scheduling tasks
duyhungtnn Jul 14, 2026
2e3ce37
fix(streaming): fetch immediately when falling back to polling
duyhungtnn Jul 14, 2026
01d0522
refactor(streaming): fold immediate fetch into EvaluationTask.start()
duyhungtnn Jul 17, 2026
c2c345d
Update EvaluationStorage.ts
duyhungtnn Jul 17, 2026
6243e74
fix(scheduler): guard EvaluationTask reschedule after stop()
duyhungtnn Jul 17, 2026
e30a294
fix(streaming): call injected fetch unbound to avoid illegal invocation
duyhungtnn Jul 24, 2026
b711830
fix(client): guard against destroy racing pending initialize/apply
duyhungtnn Jul 24, 2026
014862c
fix(client): clear singleton when evaluation initialize fails
duyhungtnn Jul 24, 2026
195ddca
fix(evaluation): clear userAttributesUpdated before notifying listeners
duyhungtnn Jul 25, 2026
babdcf6
fix(evaluation): guard clearUserAttributesUpdated against stale requests
duyhungtnn Jul 25, 2026
313c40e
fix(streaming): clear userAttributesUpdated flag on stream open
duyhungtnn Jul 26, 2026
6479068
fix(streaming): stop reconnect() retrying a terminally failed stream
duyhungtnn Jul 26, 2026
b20f217
Update StreamingTask.ts
duyhungtnn Jul 26, 2026
7ebb5eb
fix(streaming): retry pre-open recoverable errors instead of giving up
duyhungtnn Jul 26, 2026
9189941
fix(streaming): treat 499 as a recoverable SSE status
duyhungtnn Jul 27, 2026
3ed0a14
fix(streaming): stop unknown SSE events reaching evaluation storage
duyhungtnn Jul 27, 2026
170db7f
fix(evaluation): guard against stale writes rewinding evaluation state
duyhungtnn Jul 27, 2026
f9a1170
fix(streaming): catch sync throws from the EventSource constructor
duyhungtnn Jul 28, 2026
2697c7e
fix(streaming): keep polling until a reopened stream proves it works
duyhungtnn Jul 28, 2026
3d2e83e
perf(streaming): debounce reconnectStreaming to coalesce attribute bu…
duyhungtnn Jul 28, 2026
8c97beb
perf(streaming): avoid quadratic re-parsing of the SSE buffer
duyhungtnn Jul 28, 2026
d558ede
style(streaming): reformat to satisfy prettier
duyhungtnn Jul 28, 2026
5f0607f
fix(evaluation): snapshot attributes state before the first await
duyhungtnn Jul 29, 2026
080fc32
fix(evaluation): clear the attributes flag only after the write lands
duyhungtnn Jul 30, 2026
1772fb2
fix(streaming): keep the unhealthy give-up window across reconnect()
duyhungtnn Jul 30, 2026
b2fd5cf
fix(streaming): accept SSE payloads that omit forceUpdate
duyhungtnn Jul 30, 2026
c3ed25f
fix(streaming): treat explicit "event: message" as the default type
duyhungtnn Jul 30, 2026
8650f64
perf(evaluation): skip the storage write when the flag is already clear
duyhungtnn Jul 30, 2026
fd436eb
refactor: replace redundant destroy guards with existing invariants
duyhungtnn Jul 30, 2026
eba5d7a
refactor(streaming): simplify the SSE buffer parse return value
duyhungtnn Jul 30, 2026
ae2acae
docs(evaluation): point the staleness docs at their single source
duyhungtnn Jul 30, 2026
f8a9529
test: extract a shared component builder for the task suites
duyhungtnn Jul 30, 2026
ee09a58
refactor(client): own the singleton lifecycle in the init entry point
duyhungtnn Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ types
*.sln
*.sw?
*.tsbuildinfo
/refs
102 changes: 102 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.<env>.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: <apiKey> }` (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`.

1 change: 1 addition & 0 deletions eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ module.exports = [
'**/*.d.ts',
'.github',
'eslint-rules/',
'refs/',
],
},
...tseslint.configs.recommended,
Expand Down
64 changes: 59 additions & 5 deletions src/BKTClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,34 @@ export class BKTClientImpl implements BKTClient {

constructor(public component: Component) {}

async initializeInternal(timeoutMillis: number): Promise<void> {
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<void> {
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<void> {
this.scheduleTasks()
return this.fetchEvaluations(timeoutMillis)
}

Expand Down Expand Up @@ -179,6 +204,7 @@ export class BKTClientImpl implements BKTClient {
): Promise<void> {
this.component.userHolder().updateAttributes((_prev) => ({ ...attributes }))
await this.component.evaluationInteractor().setUserAttributesUpdated()
this.taskScheduler?.reconnectStreaming()
}

async fetchEvaluations(timeoutMillis?: number): Promise<void> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -362,18 +393,41 @@ export const getBKTClient = (): BKTClient | null => {
return getInstance()
}

export const initializeBKTClientInternal = (
export const initializeBKTClientInternal = async (
component: Component,
timeoutMillis = 5_000,
): Promise<void> => {
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 => {
Expand Down
20 changes: 20 additions & 0 deletions src/BKTConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -69,6 +82,8 @@ export interface BKTConfig extends RawBKTConfig {
fetch: FetchLike
storageFactory: <T>(key: string) => BKTStorage<T>
enableAutoPageLifecycleFlush: boolean
enableStreaming: boolean
streamingFallbackToPolling: boolean
}

const defaultUserAgent = () => {
Expand Down Expand Up @@ -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)
Expand All @@ -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')
Expand Down
Loading