From 482e8a18677128e022665354852e8909b3254049 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 12 Feb 2026 17:14:23 -0800 Subject: [PATCH 01/16] feat: Add feature flag polling interfaces Add interfaces for the runtime client: EvaluationContext, FlagPollEntry, FlagPollResponse, RuntimeClientOptions, RuntimeClientStats, and RuntimeClientLogger. These define the contract for the polling-based feature flag client. Co-Authored-By: Claude Opus 4.6 --- .../interfaces/evaluation-context.interface.ts | 4 ++++ .../interfaces/flag-poll-response.interface.ts | 16 ++++++++++++++++ src/feature-flags/interfaces/index.ts | 4 ++++ .../runtime-client-options.interface.ts | 15 +++++++++++++++ .../interfaces/runtime-client-stats.interface.ts | 8 ++++++++ 5 files changed, 47 insertions(+) create mode 100644 src/feature-flags/interfaces/evaluation-context.interface.ts create mode 100644 src/feature-flags/interfaces/flag-poll-response.interface.ts create mode 100644 src/feature-flags/interfaces/runtime-client-options.interface.ts create mode 100644 src/feature-flags/interfaces/runtime-client-stats.interface.ts diff --git a/src/feature-flags/interfaces/evaluation-context.interface.ts b/src/feature-flags/interfaces/evaluation-context.interface.ts new file mode 100644 index 000000000..29fd5ec4a --- /dev/null +++ b/src/feature-flags/interfaces/evaluation-context.interface.ts @@ -0,0 +1,4 @@ +export interface EvaluationContext { + userId?: string; + organizationId?: string; +} diff --git a/src/feature-flags/interfaces/flag-poll-response.interface.ts b/src/feature-flags/interfaces/flag-poll-response.interface.ts new file mode 100644 index 000000000..3db6832ea --- /dev/null +++ b/src/feature-flags/interfaces/flag-poll-response.interface.ts @@ -0,0 +1,16 @@ +export interface FlagTarget { + id: string; + enabled: boolean; +} + +export interface FlagPollEntry { + slug: string; + enabled: boolean; + default_value: boolean; + targets: { + users: FlagTarget[]; + organizations: FlagTarget[]; + }; +} + +export type FlagPollResponse = Record; diff --git a/src/feature-flags/interfaces/index.ts b/src/feature-flags/interfaces/index.ts index f3d8acc98..cd0680c66 100644 --- a/src/feature-flags/interfaces/index.ts +++ b/src/feature-flags/interfaces/index.ts @@ -1,4 +1,8 @@ export * from './add-flag-target-options.interface'; +export * from './evaluation-context.interface'; export * from './feature-flag.interface'; +export * from './flag-poll-response.interface'; export * from './list-feature-flags-options.interface'; export * from './remove-flag-target-options.interface'; +export * from './runtime-client-options.interface'; +export * from './runtime-client-stats.interface'; diff --git a/src/feature-flags/interfaces/runtime-client-options.interface.ts b/src/feature-flags/interfaces/runtime-client-options.interface.ts new file mode 100644 index 000000000..02726bd61 --- /dev/null +++ b/src/feature-flags/interfaces/runtime-client-options.interface.ts @@ -0,0 +1,15 @@ +import { FlagPollEntry } from './flag-poll-response.interface'; + +export interface RuntimeClientLogger { + debug(...args: unknown[]): void; + info(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; +} + +export interface RuntimeClientOptions { + pollingIntervalMs?: number; + bootstrapFlags?: Record; + requestTimeoutMs?: number; + logger?: RuntimeClientLogger; +} diff --git a/src/feature-flags/interfaces/runtime-client-stats.interface.ts b/src/feature-flags/interfaces/runtime-client-stats.interface.ts new file mode 100644 index 000000000..a5168f80a --- /dev/null +++ b/src/feature-flags/interfaces/runtime-client-stats.interface.ts @@ -0,0 +1,8 @@ +export interface RuntimeClientStats { + pollCount: number; + pollErrorCount: number; + lastPollAt: Date | null; + lastSuccessfulPollAt: Date | null; + cacheAge: number | null; + flagCount: number; +} From 35b0fe8cfc80a30d9c1e655d5f5f32ed65312d07 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 12 Feb 2026 17:14:50 -0800 Subject: [PATCH 02/16] feat: Add InMemoryStore for flag cache Simple in-memory store that holds the polling response. Supports atomic swap of the full flag map, O(1) lookup by slug, and size tracking. Co-Authored-By: Claude Opus 4.6 --- src/feature-flags/in-memory-store.spec.ts | 69 +++++++++++++++++++++++ src/feature-flags/in-memory-store.ts | 21 +++++++ 2 files changed, 90 insertions(+) create mode 100644 src/feature-flags/in-memory-store.spec.ts create mode 100644 src/feature-flags/in-memory-store.ts diff --git a/src/feature-flags/in-memory-store.spec.ts b/src/feature-flags/in-memory-store.spec.ts new file mode 100644 index 000000000..c567dd25e --- /dev/null +++ b/src/feature-flags/in-memory-store.spec.ts @@ -0,0 +1,69 @@ +import { InMemoryStore } from './in-memory-store'; +import { FlagPollEntry } from './interfaces'; + +describe('InMemoryStore', () => { + let store: InMemoryStore; + + const flagA: FlagPollEntry = { + slug: 'flag-a', + enabled: true, + default_value: true, + targets: { users: [], organizations: [] }, + }; + + const flagB: FlagPollEntry = { + slug: 'flag-b', + enabled: false, + default_value: false, + targets: { + users: [{ id: 'user_123', enabled: true }], + organizations: [], + }, + }; + + beforeEach(() => { + store = new InMemoryStore(); + }); + + describe('swap', () => { + it('replaces all flags', () => { + store.swap({ 'flag-a': flagA }); + expect(store.size).toBe(1); + + store.swap({ 'flag-b': flagB }); + expect(store.size).toBe(1); + expect(store.get('flag-a')).toBeUndefined(); + expect(store.get('flag-b')).toEqual(flagB); + }); + }); + + describe('get', () => { + it('returns the entry for a known slug', () => { + store.swap({ 'flag-a': flagA }); + expect(store.get('flag-a')).toEqual(flagA); + }); + + it('returns undefined for an unknown slug', () => { + expect(store.get('unknown')).toBeUndefined(); + }); + }); + + describe('getAll', () => { + it('returns the full map', () => { + const flags = { 'flag-a': flagA, 'flag-b': flagB }; + store.swap(flags); + expect(store.getAll()).toEqual(flags); + }); + }); + + describe('size', () => { + it('starts at 0', () => { + expect(store.size).toBe(0); + }); + + it('tracks the number of flags', () => { + store.swap({ 'flag-a': flagA, 'flag-b': flagB }); + expect(store.size).toBe(2); + }); + }); +}); diff --git a/src/feature-flags/in-memory-store.ts b/src/feature-flags/in-memory-store.ts new file mode 100644 index 000000000..c0010bd52 --- /dev/null +++ b/src/feature-flags/in-memory-store.ts @@ -0,0 +1,21 @@ +import { FlagPollEntry, FlagPollResponse } from './interfaces'; + +export class InMemoryStore { + private flags: FlagPollResponse = {}; + + swap(newFlags: FlagPollResponse): void { + this.flags = { ...newFlags }; + } + + get(slug: string): FlagPollEntry | undefined { + return this.flags[slug]; + } + + getAll(): FlagPollResponse { + return this.flags; + } + + get size(): number { + return Object.keys(this.flags).length; + } +} From 5f5ce136eb286482659cddefd2d5f118bcb76bdf Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 12 Feb 2026 17:14:59 -0800 Subject: [PATCH 03/16] feat: Add Evaluator for local flag evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synchronous flag evaluation against the in-memory store. Evaluation order: flag not found → defaultValue, flag disabled → false, org target match → target.enabled, user target match → target.enabled, no match → default_value. Co-Authored-By: Claude Opus 4.6 --- src/feature-flags/evaluator.spec.ts | 106 ++++++++++++++++++++++++++++ src/feature-flags/evaluator.ts | 55 +++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/feature-flags/evaluator.spec.ts create mode 100644 src/feature-flags/evaluator.ts diff --git a/src/feature-flags/evaluator.spec.ts b/src/feature-flags/evaluator.spec.ts new file mode 100644 index 000000000..5b6d2e556 --- /dev/null +++ b/src/feature-flags/evaluator.spec.ts @@ -0,0 +1,106 @@ +import { Evaluator } from './evaluator'; +import { InMemoryStore } from './in-memory-store'; +import { FlagPollEntry } from './interfaces'; + +describe('Evaluator', () => { + let store: InMemoryStore; + let evaluator: Evaluator; + + const enabledFlag: FlagPollEntry = { + slug: 'enabled-flag', + enabled: true, + default_value: true, + targets: { users: [], organizations: [] }, + }; + + const disabledFlag: FlagPollEntry = { + slug: 'disabled-flag', + enabled: false, + default_value: true, + targets: { users: [], organizations: [] }, + }; + + const targetedFlag: FlagPollEntry = { + slug: 'targeted-flag', + enabled: true, + default_value: false, + targets: { + organizations: [{ id: 'org_123', enabled: true }], + users: [ + { id: 'user_456', enabled: true }, + { id: 'user_blocked', enabled: false }, + ], + }, + }; + + beforeEach(() => { + store = new InMemoryStore(); + evaluator = new Evaluator(store); + store.swap({ + 'enabled-flag': enabledFlag, + 'disabled-flag': disabledFlag, + 'targeted-flag': targetedFlag, + }); + }); + + describe('isEnabled', () => { + it('returns defaultValue when flag is not found', () => { + expect(evaluator.isEnabled('unknown')).toBe(false); + expect(evaluator.isEnabled('unknown', {}, true)).toBe(true); + }); + + it('returns false when flag is disabled (enabled=false)', () => { + expect(evaluator.isEnabled('disabled-flag')).toBe(false); + }); + + it('returns target.enabled for matching organization', () => { + expect( + evaluator.isEnabled('targeted-flag', { organizationId: 'org_123' }), + ).toBe(true); + }); + + it('returns target.enabled for matching user', () => { + expect( + evaluator.isEnabled('targeted-flag', { userId: 'user_456' }), + ).toBe(true); + }); + + it('returns false for user target with enabled=false', () => { + expect( + evaluator.isEnabled('targeted-flag', { userId: 'user_blocked' }), + ).toBe(false); + }); + + it('returns default_value when no target matches', () => { + expect( + evaluator.isEnabled('targeted-flag', { userId: 'user_other' }), + ).toBe(false); + + expect(evaluator.isEnabled('enabled-flag', { userId: 'user_other' })).toBe( + true, + ); + }); + }); + + describe('getAllFlags', () => { + it('evaluates all flags for the given context', () => { + const result = evaluator.getAllFlags({ userId: 'user_456' }); + + expect(result).toEqual({ + 'enabled-flag': true, + 'disabled-flag': false, + 'targeted-flag': true, + }); + }); + + it('works with empty context', () => { + const result = evaluator.getAllFlags(); + + expect(result).toEqual({ + 'enabled-flag': true, + 'disabled-flag': false, + 'targeted-flag': false, + }); + }); + }); +}); diff --git a/src/feature-flags/evaluator.ts b/src/feature-flags/evaluator.ts new file mode 100644 index 000000000..4734cb8c0 --- /dev/null +++ b/src/feature-flags/evaluator.ts @@ -0,0 +1,55 @@ +import { InMemoryStore } from './in-memory-store'; +import { EvaluationContext } from './interfaces'; + +export class Evaluator { + constructor(private readonly store: InMemoryStore) {} + + isEnabled( + flagKey: string, + context: EvaluationContext = {}, + defaultValue: boolean = false, + ): boolean { + const entry = this.store.get(flagKey); + + if (!entry) { + return defaultValue; + } + + if (!entry.enabled) { + return false; + } + + if (context.organizationId) { + const orgTarget = entry.targets.organizations.find( + (t) => t.id === context.organizationId, + ); + if (orgTarget) { + return orgTarget.enabled; + } + } + + if (context.userId) { + const userTarget = entry.targets.users.find( + (t) => t.id === context.userId, + ); + if (userTarget) { + return userTarget.enabled; + } + } + + return entry.default_value; + } + + getAllFlags( + context: EvaluationContext = {}, + ): Record { + const flags = this.store.getAll(); + const result: Record = {}; + + for (const slug of Object.keys(flags)) { + result[slug] = this.isEnabled(slug, context); + } + + return result; + } +} From a3ce753e6f2b61499a0833b2f788fcb577eaa39e Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 12 Feb 2026 17:15:08 -0800 Subject: [PATCH 04/16] feat: Add FeatureFlagsRuntimeClient with polling EventEmitter-based client that polls GET /sdk/feature-flags, caches flags in memory, and evaluates locally. Features: configurable polling interval with jitter, per-request timeout via Promise.race, bootstrap flags, waitUntilReady with timeout, change events on subsequent polls, and 401 detection that stops polling and emits 'failed'. Co-Authored-By: Claude Opus 4.6 --- src/feature-flags/runtime-client.spec.ts | 380 +++++++++++++++++++++++ src/feature-flags/runtime-client.ts | 227 ++++++++++++++ src/index.ts | 1 + 3 files changed, 608 insertions(+) create mode 100644 src/feature-flags/runtime-client.spec.ts create mode 100644 src/feature-flags/runtime-client.ts diff --git a/src/feature-flags/runtime-client.spec.ts b/src/feature-flags/runtime-client.spec.ts new file mode 100644 index 000000000..a332f26aa --- /dev/null +++ b/src/feature-flags/runtime-client.spec.ts @@ -0,0 +1,380 @@ +import fetch from 'jest-fetch-mock'; +import { fetchOnce, fetchURL } from '../common/utils/test-utils'; +import { UnauthorizedException } from '../common/exceptions'; +import { WorkOS } from '../workos'; +import { FeatureFlagsRuntimeClient } from './runtime-client'; +import { FlagPollResponse } from './interfaces'; + +const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + +const pollResponse: FlagPollResponse = { + 'flag-a': { + slug: 'flag-a', + enabled: true, + default_value: true, + targets: { users: [], organizations: [] }, + }, + 'flag-b': { + slug: 'flag-b', + enabled: true, + default_value: false, + targets: { + users: [{ id: 'user_123', enabled: true }], + organizations: [], + }, + }, +}; + +describe('FeatureFlagsRuntimeClient', () => { + beforeEach(() => { + fetch.resetMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + function createClientAndWait( + options?: Parameters[0], + ): FeatureFlagsRuntimeClient { + fetchOnce(pollResponse); + const client = workos.featureFlags.createRuntimeClient(options); + return client; + } + + describe('polling', () => { + it('starts polling on construction', async () => { + const client = createClientAndWait(); + + // First poll happens immediately in constructor + await jest.advanceTimersByTimeAsync(0); + expect(fetchURL()).toContain('/sdk/feature-flags'); + + client.close(); + }); + + it('schedules subsequent polls', async () => { + const client = createClientAndWait(); + await jest.advanceTimersByTimeAsync(0); + + // Queue up second poll response + fetchOnce(pollResponse); + await jest.advanceTimersByTimeAsync(35_000); + + expect(fetch.mock.calls.length).toBe(2); + + client.close(); + }); + }); + + describe('waitUntilReady', () => { + it('resolves after first successful poll', async () => { + const client = createClientAndWait(); + + let resolved = false; + client.waitUntilReady().then(() => { + resolved = true; + }); + + await jest.advanceTimersByTimeAsync(0); + // Allow microtasks to flush + await Promise.resolve(); + expect(resolved).toBe(true); + + client.close(); + }); + + it('resolves immediately with bootstrap flags', async () => { + fetchOnce(pollResponse); + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags: pollResponse, + }); + + // waitUntilReady should resolve immediately since bootstrap flags were provided + await client.waitUntilReady(); + + // Handle the poll that fires in constructor + await jest.advanceTimersByTimeAsync(0); + + client.close(); + }); + + it('rejects after timeoutMs', async () => { + // Don't provide a fetch response so the poll hangs + fetch.mockResponseOnce( + () => new Promise(() => {}), // never resolves + ); + const client = workos.featureFlags.createRuntimeClient({ + requestTimeoutMs: 50, + }); + + // Suppress error events from the client + client.on('error', () => {}); + + const promise = client.waitUntilReady({ timeoutMs: 100 }); + + // Use synchronous timer advancement to avoid async rejection propagation + jest.advanceTimersByTime(150); + + await expect(promise).rejects.toThrow('waitUntilReady timed out'); + + client.close(); + }); + }); + + describe('close', () => { + it('stops polling', async () => { + const client = createClientAndWait(); + await jest.advanceTimersByTimeAsync(0); + + client.close(); + + fetchOnce(pollResponse); + await jest.advanceTimersByTimeAsync(60_000); + + // Only the initial poll should have happened + expect(fetch.mock.calls.length).toBe(1); + }); + }); + + describe('isEnabled', () => { + it('evaluates flags from the store', async () => { + const client = createClientAndWait(); + await jest.advanceTimersByTimeAsync(0); + await client.waitUntilReady(); + + expect(client.isEnabled('flag-a')).toBe(true); + expect(client.isEnabled('flag-b')).toBe(false); + expect(client.isEnabled('flag-b', { userId: 'user_123' })).toBe(true); + expect(client.isEnabled('unknown')).toBe(false); + expect(client.isEnabled('unknown', {}, true)).toBe(true); + + client.close(); + }); + }); + + describe('getAllFlags', () => { + it('evaluates all flags', async () => { + const client = createClientAndWait(); + await jest.advanceTimersByTimeAsync(0); + await client.waitUntilReady(); + + expect(client.getAllFlags()).toEqual({ + 'flag-a': true, + 'flag-b': false, + }); + + expect(client.getAllFlags({ userId: 'user_123' })).toEqual({ + 'flag-a': true, + 'flag-b': true, + }); + + client.close(); + }); + }); + + describe('getFlag', () => { + it('returns raw flag entry', async () => { + const client = createClientAndWait(); + await jest.advanceTimersByTimeAsync(0); + await client.waitUntilReady(); + + expect(client.getFlag('flag-a')).toEqual(pollResponse['flag-a']); + expect(client.getFlag('unknown')).toBeUndefined(); + + client.close(); + }); + }); + + describe('getStats', () => { + it('returns accurate stats after polling', async () => { + const client = createClientAndWait(); + await jest.advanceTimersByTimeAsync(0); + await client.waitUntilReady(); + + const stats = client.getStats(); + + expect(stats.pollCount).toBe(1); + expect(stats.pollErrorCount).toBe(0); + expect(stats.lastPollAt).toBeInstanceOf(Date); + expect(stats.lastSuccessfulPollAt).toBeInstanceOf(Date); + expect(stats.flagCount).toBe(2); + expect(typeof stats.cacheAge).toBe('number'); + + client.close(); + }); + }); + + describe('options', () => { + it('clamps pollingIntervalMs to minimum of 5000', async () => { + const client = createClientAndWait({ pollingIntervalMs: 1000 }); + await jest.advanceTimersByTimeAsync(0); + + // Should not fire a second poll at 2s + fetchOnce(pollResponse); + await jest.advanceTimersByTimeAsync(2000); + expect(fetch.mock.calls.length).toBe(1); + + // Should fire by 6s (5000 + jitter) + await jest.advanceTimersByTimeAsync(4000); + expect(fetch.mock.calls.length).toBe(2); + + client.close(); + }); + }); + + describe('change events', () => { + it('does not emit change events on first poll', async () => { + const changes: unknown[] = []; + + const client = createClientAndWait(); + client.on('change', (change) => changes.push(change)); + + await jest.advanceTimersByTimeAsync(0); + await client.waitUntilReady(); + + expect(changes).toEqual([]); + + client.close(); + }); + + it('emits change events when flags change', async () => { + const client = createClientAndWait(); + await jest.advanceTimersByTimeAsync(0); + await client.waitUntilReady(); + + const changes: unknown[] = []; + client.on('change', (change) => changes.push(change)); + + const updatedResponse: FlagPollResponse = { + 'flag-a': { + slug: 'flag-a', + enabled: false, + default_value: true, + targets: { users: [], organizations: [] }, + }, + 'flag-b': pollResponse['flag-b'], + }; + + fetchOnce(updatedResponse); + await jest.advanceTimersByTimeAsync(35_000); + + expect(changes).toEqual([ + { + key: 'flag-a', + previous: pollResponse['flag-a'], + current: updatedResponse['flag-a'], + }, + ]); + + client.close(); + }); + + it('emits change when a flag is removed', async () => { + const client = createClientAndWait(); + await jest.advanceTimersByTimeAsync(0); + await client.waitUntilReady(); + + const changes: unknown[] = []; + client.on('change', (change) => changes.push(change)); + + // Second poll returns only flag-a + fetchOnce({ 'flag-a': pollResponse['flag-a'] }); + await jest.advanceTimersByTimeAsync(35_000); + + expect(changes).toEqual([ + { + key: 'flag-b', + previous: pollResponse['flag-b'], + current: null, + }, + ]); + + client.close(); + }); + }); + + describe('bootstrap flags', () => { + it('first poll replaces bootstrap data', async () => { + const bootstrapFlags: FlagPollResponse = { + 'bootstrap-flag': { + slug: 'bootstrap-flag', + enabled: true, + default_value: true, + targets: { users: [], organizations: [] }, + }, + }; + + fetchOnce(pollResponse); + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags, + }); + + // Bootstrap data is available immediately + expect(client.isEnabled('bootstrap-flag')).toBe(true); + expect(client.isEnabled('flag-a')).toBe(false); + + // First poll replaces bootstrap data with API response + await jest.advanceTimersByTimeAsync(0); + + expect(client.isEnabled('bootstrap-flag')).toBe(false); + expect(client.isEnabled('flag-a')).toBe(true); + expect(client.getStats().flagCount).toBe(2); + + client.close(); + }); + }); + + describe('error handling', () => { + it('emits error on poll failure and continues polling', async () => { + // First poll fails + fetch.mockRejectOnce(new Error('Network error')); + + const client = workos.featureFlags.createRuntimeClient(); + + const errors: unknown[] = []; + client.on('error', (err) => errors.push(err)); + + await jest.advanceTimersByTimeAsync(0); + expect(errors.length).toBe(1); + + // Continues polling — second poll succeeds + fetchOnce(pollResponse); + await jest.advanceTimersByTimeAsync(35_000); + + expect(client.getStats().pollCount).toBe(2); + expect(client.getStats().pollErrorCount).toBe(1); + + client.close(); + }); + + it('emits failed and stops polling on 401', async () => { + fetchOnce( + { message: 'Unauthorized' }, + { status: 401, headers: { 'X-Request-ID': 'req_123' } }, + ); + + const client = workos.featureFlags.createRuntimeClient(); + + const errors: unknown[] = []; + const failures: unknown[] = []; + client.on('error', (err) => errors.push(err)); + client.on('failed', (err) => failures.push(err)); + + await jest.advanceTimersByTimeAsync(0); + + expect(errors.length).toBe(1); + expect(failures.length).toBe(1); + expect(failures[0]).toBeInstanceOf(UnauthorizedException); + + // Should not schedule another poll + fetchOnce(pollResponse); + await jest.advanceTimersByTimeAsync(60_000); + + expect(fetch.mock.calls.length).toBe(1); + + client.close(); + }); + }); +}); diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts new file mode 100644 index 000000000..d221f3f02 --- /dev/null +++ b/src/feature-flags/runtime-client.ts @@ -0,0 +1,227 @@ +import { EventEmitter } from 'events'; +import { WorkOS } from '../workos'; +import { UnauthorizedException } from '../common/exceptions'; +import { InMemoryStore } from './in-memory-store'; +import { Evaluator } from './evaluator'; +import { + EvaluationContext, + FlagPollResponse, + RuntimeClientOptions, + RuntimeClientLogger, + RuntimeClientStats, +} from './interfaces'; + +const DEFAULT_POLLING_INTERVAL_MS = 30_000; +const MIN_POLLING_INTERVAL_MS = 5_000; +const MIN_DELAY_MS = 1_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; +const JITTER_FACTOR = 0.1; + +export class FeatureFlagsRuntimeClient extends EventEmitter { + private readonly store: InMemoryStore; + private readonly evaluator: Evaluator; + private readonly pollingIntervalMs: number; + private readonly requestTimeoutMs: number; + private readonly logger?: RuntimeClientLogger; + + private closed = false; + private initialized = false; + private pollTimer: ReturnType | null = null; + + private readyResolve: (() => void) | null = null; + private readyPromise: Promise; + + private stats: RuntimeClientStats = { + pollCount: 0, + pollErrorCount: 0, + lastPollAt: null, + lastSuccessfulPollAt: null, + cacheAge: null, + flagCount: 0, + }; + + constructor( + private readonly workos: WorkOS, + options: RuntimeClientOptions = {}, + ) { + super(); + + this.pollingIntervalMs = Math.max( + MIN_POLLING_INTERVAL_MS, + options.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS, + ); + this.requestTimeoutMs = + options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + this.logger = options.logger; + + this.store = new InMemoryStore(); + this.evaluator = new Evaluator(this.store); + + this.readyPromise = new Promise((resolve) => { + this.readyResolve = resolve; + }); + // Prevent unhandled rejection if no one awaits waitUntilReady + this.readyPromise.catch(() => {}); + + if (options.bootstrapFlags) { + this.store.swap(options.bootstrapFlags); + this.stats.flagCount = this.store.size; + this.resolveReady(); + } + + this.poll(); + } + + async waitUntilReady(options?: { timeoutMs?: number }): Promise { + if (!options?.timeoutMs) { + return this.readyPromise; + } + + let timeoutId: ReturnType; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error('waitUntilReady timed out')), + options.timeoutMs, + ); + }); + // Prevent unhandled rejection when race settles via readyPromise + timeoutPromise.catch(() => {}); + + return Promise.race([this.readyPromise, timeoutPromise]).finally(() => { + clearTimeout(timeoutId); + }); + } + + close(): void { + this.closed = true; + if (this.pollTimer) { + clearTimeout(this.pollTimer); + this.pollTimer = null; + } + this.removeAllListeners(); + } + + isEnabled( + flagKey: string, + context?: EvaluationContext, + defaultValue?: boolean, + ): boolean { + return this.evaluator.isEnabled(flagKey, context, defaultValue); + } + + getAllFlags(context?: EvaluationContext): Record { + return this.evaluator.getAllFlags(context); + } + + getFlag(flagKey: string) { + return this.store.get(flagKey); + } + + getStats(): RuntimeClientStats { + return { + ...this.stats, + cacheAge: this.stats.lastSuccessfulPollAt + ? Date.now() - this.stats.lastSuccessfulPollAt.getTime() + : null, + }; + } + + private resolveReady(): void { + if (this.readyResolve) { + this.readyResolve(); + this.readyResolve = null; + } + } + + private async poll(): Promise { + if (this.closed) { + return; + } + + const previousFlags = this.store.getAll(); + + try { + this.stats.pollCount++; + this.stats.lastPollAt = new Date(); + + const data = await this.fetchWithTimeout(); + + this.store.swap(data); + this.stats.lastSuccessfulPollAt = new Date(); + this.stats.flagCount = this.store.size; + + if (this.initialized) { + this.emitChanges(previousFlags, data); + } + this.initialized = true; + this.resolveReady(); + + this.logger?.debug('Poll successful', { flagCount: this.store.size }); + } catch (error) { + this.stats.pollErrorCount++; + this.emit('error', error); + this.logger?.error('Poll failed', error); + + if (error instanceof UnauthorizedException) { + this.emit('failed', error); + return; + } + } + + this.scheduleNextPoll(); + } + + private async fetchWithTimeout(): Promise { + let timeoutId: ReturnType; + + const fetchPromise = this.workos + .get('/sdk/feature-flags') + .then(({ data }) => data); + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error('Request timed out')), + this.requestTimeoutMs, + ); + }); + + return Promise.race([fetchPromise, timeoutPromise]).finally(() => { + clearTimeout(timeoutId); + }); + } + + private scheduleNextPoll(): void { + if (this.closed) { + return; + } + + const jitter = 1 + (Math.random() * 2 - 1) * JITTER_FACTOR; + const delay = Math.max(MIN_DELAY_MS, this.pollingIntervalMs * jitter); + + this.pollTimer = setTimeout(() => this.poll(), delay); + } + + private emitChanges( + previous: FlagPollResponse, + current: FlagPollResponse, + ): void { + if (!previous || !current) { + return; + } + + const allKeys = new Set([ + ...Object.keys(previous), + ...Object.keys(current), + ]); + + for (const key of allKeys) { + const prev = previous[key]; + const curr = current[key]; + + if (JSON.stringify(prev) !== JSON.stringify(curr)) { + this.emit('change', { key, previous: prev ?? null, current: curr ?? null }); + } + } + } +} diff --git a/src/index.ts b/src/index.ts index 32787d7b3..973b0dcf7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ export * from './common/utils/pagination'; export * from './directory-sync/interfaces'; export * from './events/interfaces'; export * from './feature-flags/interfaces'; +export { FeatureFlagsRuntimeClient } from './feature-flags/runtime-client'; export * from './fga/interfaces'; export * from './organizations/interfaces'; export * from './organization-domains/interfaces'; From 1b5f4c17ea3d657a4ac6ed5340c76ebb979e5827 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 12 Feb 2026 17:17:16 -0800 Subject: [PATCH 05/16] feat: Wire createRuntimeClient into FeatureFlags class Adds createRuntimeClient() method to FeatureFlags module, allowing users to create a polling-based runtime client via workos.featureFlags.createRuntimeClient(). Co-Authored-By: Claude Opus 4.6 --- src/feature-flags/feature-flags.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/feature-flags/feature-flags.ts b/src/feature-flags/feature-flags.ts index bf124d58d..c55599131 100644 --- a/src/feature-flags/feature-flags.ts +++ b/src/feature-flags/feature-flags.ts @@ -6,9 +6,11 @@ import { FeatureFlagResponse, ListFeatureFlagsOptions, RemoveFlagTargetOptions, + RuntimeClientOptions, } from './interfaces'; import { deserializeFeatureFlag } from './serializers'; import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize'; +import { FeatureFlagsRuntimeClient } from './runtime-client'; export class FeatureFlags { constructor(private readonly workos: WorkOS) {} @@ -69,4 +71,10 @@ export class FeatureFlags { const { slug, targetId } = options; await this.workos.delete(`/feature-flags/${slug}/targets/${targetId}`); } + + createRuntimeClient( + options?: RuntimeClientOptions, + ): FeatureFlagsRuntimeClient { + return new FeatureFlagsRuntimeClient(this.workos, options); + } } From 2c751a8e5dc376ef1f825bf87e12ea94acb43bfa Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Wed, 18 Feb 2026 15:11:23 -0800 Subject: [PATCH 06/16] feat: Add exponential backoff on consecutive poll errors Adds exponential backoff (1s base, 2x multiplier, 60s cap) when polls fail consecutively. The backoff delay is used when it exceeds the normal polling interval, and resets after a successful poll. Co-Authored-By: Claude Opus 4.6 --- src/feature-flags/runtime-client.spec.ts | 107 +++++++++++++++++++++++ src/feature-flags/runtime-client.ts | 19 +++- 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/src/feature-flags/runtime-client.spec.ts b/src/feature-flags/runtime-client.spec.ts index a332f26aa..935427ec2 100644 --- a/src/feature-flags/runtime-client.spec.ts +++ b/src/feature-flags/runtime-client.spec.ts @@ -326,6 +326,113 @@ describe('FeatureFlagsRuntimeClient', () => { }); }); + describe('exponential backoff', () => { + let randomSpy: jest.SpyInstance; + + beforeEach(() => { + // Remove jitter for deterministic timing + randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.5); + }); + + afterEach(() => { + randomSpy.mockRestore(); + }); + + it('backs off on consecutive errors', async () => { + // First poll fails + fetch.mockRejectOnce(new Error('fail 1')); + const client = workos.featureFlags.createRuntimeClient({ + pollingIntervalMs: 5000, + }); + client.on('error', () => {}); + + await jest.advanceTimersByTimeAsync(0); // first poll fires immediately + expect(fetch.mock.calls.length).toBe(1); + + // 2nd poll: backoff = max(1s, 5s) = 5s + fetch.mockRejectOnce(new Error('fail 2')); + await jest.advanceTimersByTimeAsync(4999); + expect(fetch.mock.calls.length).toBe(1); + await jest.advanceTimersByTimeAsync(1); + expect(fetch.mock.calls.length).toBe(2); + + // 3rd poll: backoff = max(2s, 5s) = 5s + fetch.mockRejectOnce(new Error('fail 3')); + await jest.advanceTimersByTimeAsync(5000); + expect(fetch.mock.calls.length).toBe(3); + + // 4th poll: backoff = max(4s, 5s) = 5s + fetch.mockRejectOnce(new Error('fail 4')); + await jest.advanceTimersByTimeAsync(5000); + expect(fetch.mock.calls.length).toBe(4); + + // 5th poll: backoff = max(8s, 5s) = 8s — backoff exceeds polling interval + fetch.mockRejectOnce(new Error('fail 5')); + await jest.advanceTimersByTimeAsync(5000); + expect(fetch.mock.calls.length).toBe(4); // not yet at 5s + await jest.advanceTimersByTimeAsync(3000); + expect(fetch.mock.calls.length).toBe(5); // fires at 8s + + client.close(); + }); + + it('resets backoff after successful poll', async () => { + // First poll fails + fetch.mockRejectOnce(new Error('fail')); + const client = workos.featureFlags.createRuntimeClient({ + pollingIntervalMs: 5000, + }); + client.on('error', () => {}); + + await jest.advanceTimersByTimeAsync(0); // first poll fails + + // Second poll succeeds + fetchOnce(pollResponse); + await jest.advanceTimersByTimeAsync(5000); + expect(fetch.mock.calls.length).toBe(2); + + // Third poll should use normal 5s interval (backoff reset) + fetchOnce(pollResponse); + await jest.advanceTimersByTimeAsync(4999); + expect(fetch.mock.calls.length).toBe(2); + await jest.advanceTimersByTimeAsync(1); + expect(fetch.mock.calls.length).toBe(3); + + client.close(); + }); + + it('caps backoff at 60 seconds', async () => { + // Use mockReject (persistent) to avoid mock exhaustion issues + fetch.mockReject(new Error('always fail')); + + const client = workos.featureFlags.createRuntimeClient({ + pollingIntervalMs: 5000, + }); + client.on('error', () => {}); + + // Fire first 7 polls to build up consecutiveErrors to 7 + // Delays: 0, 5s, 5s, 5s, 8s, 16s, 32s + await jest.advanceTimersByTimeAsync(0); // poll 1 (immediate) + await jest.advanceTimersByTimeAsync(5000); // poll 2 + await jest.advanceTimersByTimeAsync(5000); // poll 3 + await jest.advanceTimersByTimeAsync(5000); // poll 4 + await jest.advanceTimersByTimeAsync(8000); // poll 5 + await jest.advanceTimersByTimeAsync(16_000); // poll 6 + await jest.advanceTimersByTimeAsync(32_000); // poll 7 + expect(fetch.mock.calls.length).toBe(7); + expect(client.getStats().pollErrorCount).toBe(7); + + // Next: backoff = min(1s * 2^6, 60s) = 60s (capped, not 64s) + await jest.advanceTimersByTimeAsync(55_000); + expect(fetch.mock.calls.length).toBe(7); // not yet at 55s + + await jest.advanceTimersByTimeAsync(6_000); + expect(fetch.mock.calls.length).toBe(8); // fires by 61s + + client.close(); + }); + }); + describe('error handling', () => { it('emits error on poll failure and continues polling', async () => { // First poll fails diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index d221f3f02..cc02f0d72 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -16,6 +16,9 @@ const MIN_POLLING_INTERVAL_MS = 5_000; const MIN_DELAY_MS = 1_000; const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; const JITTER_FACTOR = 0.1; +const INITIAL_RETRY_MS = 1_000; +const MAX_RETRY_MS = 60_000; +const BACKOFF_MULTIPLIER = 2; export class FeatureFlagsRuntimeClient extends EventEmitter { private readonly store: InMemoryStore; @@ -26,6 +29,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { private closed = false; private initialized = false; + private consecutiveErrors = 0; private pollTimer: ReturnType | null = null; private readyResolve: (() => void) | null = null; @@ -150,6 +154,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { this.store.swap(data); this.stats.lastSuccessfulPollAt = new Date(); this.stats.flagCount = this.store.size; + this.consecutiveErrors = 0; if (this.initialized) { this.emitChanges(previousFlags, data); @@ -159,6 +164,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { this.logger?.debug('Poll successful', { flagCount: this.store.size }); } catch (error) { + this.consecutiveErrors++; this.stats.pollErrorCount++; this.emit('error', error); this.logger?.error('Poll failed', error); @@ -196,8 +202,19 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { return; } + let baseDelay = this.pollingIntervalMs; + + if (this.consecutiveErrors > 0) { + const backoff = Math.min( + INITIAL_RETRY_MS * + Math.pow(BACKOFF_MULTIPLIER, this.consecutiveErrors - 1), + MAX_RETRY_MS, + ); + baseDelay = Math.max(baseDelay, backoff); + } + const jitter = 1 + (Math.random() * 2 - 1) * JITTER_FACTOR; - const delay = Math.max(MIN_DELAY_MS, this.pollingIntervalMs * jitter); + const delay = Math.max(MIN_DELAY_MS, baseDelay * jitter); this.pollTimer = setTimeout(() => this.poll(), delay); } From cee4d905bd3acd260959007117e066cf7311c4f8 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Wed, 4 Mar 2026 00:06:50 +0900 Subject: [PATCH 07/16] style: Fix prettier formatting in feature-flags module Co-Authored-By: Claude Opus 4.6 --- src/feature-flags/evaluator.spec.ts | 12 ++++++------ src/feature-flags/evaluator.ts | 4 +--- src/feature-flags/runtime-client.ts | 6 +++++- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/feature-flags/evaluator.spec.ts b/src/feature-flags/evaluator.spec.ts index 5b6d2e556..fe399deef 100644 --- a/src/feature-flags/evaluator.spec.ts +++ b/src/feature-flags/evaluator.spec.ts @@ -60,9 +60,9 @@ describe('Evaluator', () => { }); it('returns target.enabled for matching user', () => { - expect( - evaluator.isEnabled('targeted-flag', { userId: 'user_456' }), - ).toBe(true); + expect(evaluator.isEnabled('targeted-flag', { userId: 'user_456' })).toBe( + true, + ); }); it('returns false for user target with enabled=false', () => { @@ -76,9 +76,9 @@ describe('Evaluator', () => { evaluator.isEnabled('targeted-flag', { userId: 'user_other' }), ).toBe(false); - expect(evaluator.isEnabled('enabled-flag', { userId: 'user_other' })).toBe( - true, - ); + expect( + evaluator.isEnabled('enabled-flag', { userId: 'user_other' }), + ).toBe(true); }); }); diff --git a/src/feature-flags/evaluator.ts b/src/feature-flags/evaluator.ts index 4734cb8c0..9b7ffd217 100644 --- a/src/feature-flags/evaluator.ts +++ b/src/feature-flags/evaluator.ts @@ -40,9 +40,7 @@ export class Evaluator { return entry.default_value; } - getAllFlags( - context: EvaluationContext = {}, - ): Record { + getAllFlags(context: EvaluationContext = {}): Record { const flags = this.store.getAll(); const result: Record = {}; diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index cc02f0d72..97f99724b 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -237,7 +237,11 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { const curr = current[key]; if (JSON.stringify(prev) !== JSON.stringify(curr)) { - this.emit('change', { key, previous: prev ?? null, current: curr ?? null }); + this.emit('change', { + key, + previous: prev ?? null, + current: curr ?? null, + }); } } } From 3772742cf695685473e682dc31f80f3b1e0a67c2 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Wed, 4 Mar 2026 00:39:18 +0900 Subject: [PATCH 08/16] fix: Use node: prefix for events import for Deno compatibility Co-Authored-By: Claude Opus 4.6 --- src/feature-flags/runtime-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index 97f99724b..8d6b06854 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -1,4 +1,4 @@ -import { EventEmitter } from 'events'; +import { EventEmitter } from 'node:events'; import { WorkOS } from '../workos'; import { UnauthorizedException } from '../common/exceptions'; import { InMemoryStore } from './in-memory-store'; From 04160d921a4d37d69416a8feae566473f486a57f Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Wed, 18 Mar 2026 15:16:42 -0700 Subject: [PATCH 09/16] fix: Prioritize user targets over org targets in flag evaluation User-level targets are more specific than organization-level targets and should take precedence when both are provided in the evaluation context. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/feature-flags/evaluator.spec.ts | 18 ++++++++++++++++++ src/feature-flags/evaluator.ts | 18 +++++++++--------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/feature-flags/evaluator.spec.ts b/src/feature-flags/evaluator.spec.ts index fe399deef..fe47a8ae7 100644 --- a/src/feature-flags/evaluator.spec.ts +++ b/src/feature-flags/evaluator.spec.ts @@ -71,6 +71,24 @@ describe('Evaluator', () => { ).toBe(false); }); + it('prioritizes user target over organization target', () => { + expect( + evaluator.isEnabled('targeted-flag', { + userId: 'user_blocked', + organizationId: 'org_123', + }), + ).toBe(false); + }); + + it('falls back to organization target when user target does not match', () => { + expect( + evaluator.isEnabled('targeted-flag', { + userId: 'user_other', + organizationId: 'org_123', + }), + ).toBe(true); + }); + it('returns default_value when no target matches', () => { expect( evaluator.isEnabled('targeted-flag', { userId: 'user_other' }), diff --git a/src/feature-flags/evaluator.ts b/src/feature-flags/evaluator.ts index 9b7ffd217..0a91684a2 100644 --- a/src/feature-flags/evaluator.ts +++ b/src/feature-flags/evaluator.ts @@ -19,15 +19,6 @@ export class Evaluator { return false; } - if (context.organizationId) { - const orgTarget = entry.targets.organizations.find( - (t) => t.id === context.organizationId, - ); - if (orgTarget) { - return orgTarget.enabled; - } - } - if (context.userId) { const userTarget = entry.targets.users.find( (t) => t.id === context.userId, @@ -37,6 +28,15 @@ export class Evaluator { } } + if (context.organizationId) { + const orgTarget = entry.targets.organizations.find( + (t) => t.id === context.organizationId, + ); + if (orgTarget) { + return orgTarget.enabled; + } + } + return entry.default_value; } From 1bda102b669af3a7adc55599328c3a3367581271 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Fri, 27 Mar 2026 10:57:08 -0700 Subject: [PATCH 10/16] fix: Defer first poll and use field-level comparison for change detection Defer the first poll via setTimeout(0) so callers can attach error/failed event listeners before the initial network request fires. Replace JSON.stringify-based flag comparison with field-by-field checks to avoid false change events from key ordering differences. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/feature-flags/runtime-client.ts | 32 +++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index 8d6b06854..fa6d9172b 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -5,6 +5,7 @@ import { InMemoryStore } from './in-memory-store'; import { Evaluator } from './evaluator'; import { EvaluationContext, + FlagPollEntry, FlagPollResponse, RuntimeClientOptions, RuntimeClientLogger, @@ -73,7 +74,8 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { this.resolveReady(); } - this.poll(); + // Defer first poll so callers can attach event listeners after construction + setTimeout(() => this.poll(), 0); } async waitUntilReady(options?: { timeoutMs?: number }): Promise { @@ -236,7 +238,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { const prev = previous[key]; const curr = current[key]; - if (JSON.stringify(prev) !== JSON.stringify(curr)) { + if (this.hasEntryChanged(prev, curr)) { this.emit('change', { key, previous: prev ?? null, @@ -245,4 +247,30 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { } } } + + private hasEntryChanged( + a: FlagPollEntry | undefined, + b: FlagPollEntry | undefined, + ): boolean { + if (!a || !b) { + return a !== b; + } + + return ( + a.enabled !== b.enabled || + a.default_value !== b.default_value || + a.targets.users.length !== b.targets.users.length || + a.targets.organizations.length !== b.targets.organizations.length || + a.targets.users.some( + (t, i) => + t.id !== b.targets.users[i].id || + t.enabled !== b.targets.users[i].enabled, + ) || + a.targets.organizations.some( + (t, i) => + t.id !== b.targets.organizations[i].id || + t.enabled !== b.targets.organizations[i].enabled, + ) + ); + } } From 20c48ed2562e6b5ecc047a64475e2e9da30e45c3 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Fri, 27 Mar 2026 12:07:52 -0700 Subject: [PATCH 11/16] fix: Add AbortController to cancel in-flight polls on close and timeout Introduces a pollAbortController that races alongside the fetch and timeout promises. close() now aborts any in-flight request immediately instead of letting it complete silently in the background. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/feature-flags/runtime-client.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index fa6d9172b..f7f982e75 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -32,6 +32,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { private initialized = false; private consecutiveErrors = 0; private pollTimer: ReturnType | null = null; + private pollAbortController: AbortController | null = null; private readyResolve: (() => void) | null = null; private readyPromise: Promise; @@ -101,6 +102,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { close(): void { this.closed = true; + this.pollAbortController?.abort(); if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = null; @@ -181,6 +183,9 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { } private async fetchWithTimeout(): Promise { + this.pollAbortController = new AbortController(); + const { signal } = this.pollAbortController; + let timeoutId: ReturnType; const fetchPromise = this.workos From 6d1c765e88366568ebd0f79ccb2f5dbe97f0a9cd Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Fri, 27 Mar 2026 12:07:57 -0700 Subject: [PATCH 12/16] fix: Harden public API types and defensive copy in store Add explicit return type to getFlag() and return a shallow copy from InMemoryStore.getAll() to prevent external mutation of internal state. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/feature-flags/in-memory-store.ts | 2 +- src/feature-flags/runtime-client.ts | 26 +++++++++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/feature-flags/in-memory-store.ts b/src/feature-flags/in-memory-store.ts index c0010bd52..d93c009c7 100644 --- a/src/feature-flags/in-memory-store.ts +++ b/src/feature-flags/in-memory-store.ts @@ -12,7 +12,7 @@ export class InMemoryStore { } getAll(): FlagPollResponse { - return this.flags; + return { ...this.flags }; } get size(): number { diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index f7f982e75..aa0c15ad6 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -122,7 +122,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { return this.evaluator.getAllFlags(context); } - getFlag(flagKey: string) { + getFlag(flagKey: string): FlagPollEntry | undefined { return this.store.get(flagKey); } @@ -193,15 +193,27 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { .then(({ data }) => data); const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout( - () => reject(new Error('Request timed out')), - this.requestTimeoutMs, - ); + timeoutId = setTimeout(() => { + this.pollAbortController?.abort(); + reject(new Error('Request timed out')); + }, this.requestTimeoutMs); }); - return Promise.race([fetchPromise, timeoutPromise]).finally(() => { - clearTimeout(timeoutId); + const abortPromise = new Promise((_, reject) => { + if (signal.aborted) { + reject(new Error('Poll aborted')); + return; + } + signal.addEventListener('abort', () => reject(new Error('Poll aborted')), { + once: true, + }); }); + + return Promise.race([fetchPromise, timeoutPromise, abortPromise]).finally( + () => { + clearTimeout(timeoutId); + }, + ); } private scheduleNextPoll(): void { From b53e4870f6c13597d72b6ed83516f069f746f1b2 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Fri, 27 Mar 2026 12:28:08 -0700 Subject: [PATCH 13/16] style: Fix prettier formatting in runtime client Co-Authored-By: Claude Opus 4.6 (1M context) --- src/feature-flags/runtime-client.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index aa0c15ad6..1c6de4fb5 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -204,9 +204,13 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { reject(new Error('Poll aborted')); return; } - signal.addEventListener('abort', () => reject(new Error('Poll aborted')), { - once: true, - }); + signal.addEventListener( + 'abort', + () => reject(new Error('Poll aborted')), + { + once: true, + }, + ); }); return Promise.race([fetchPromise, timeoutPromise, abortPromise]).finally( From a73ed3435d6941975e37bf276e60dcf76867fc99 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Fri, 27 Mar 2026 14:55:42 -0700 Subject: [PATCH 14/16] fix: Guard error emission after close and use order-insensitive target comparison Prevent unhandled 'error' event crash when close() aborts an in-flight poll by short-circuiting the catch block when closed. Switch target comparison to a Map-based lookup so reordered targets from the server don't trigger spurious change events. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/feature-flags/runtime-client.ts | 32 ++++++++++++++++------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index 1c6de4fb5..a1ca830eb 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -7,6 +7,7 @@ import { EvaluationContext, FlagPollEntry, FlagPollResponse, + FlagTarget, RuntimeClientOptions, RuntimeClientLogger, RuntimeClientStats, @@ -168,6 +169,8 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { this.logger?.debug('Poll successful', { flagCount: this.store.size }); } catch (error) { + if (this.closed) return; + this.consecutiveErrors++; this.stats.pollErrorCount++; this.emit('error', error); @@ -277,21 +280,22 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { return a !== b; } + if (a.enabled !== b.enabled || a.default_value !== b.default_value) { + return true; + } + + const targetsChanged = ( + xs: FlagTarget[], + ys: FlagTarget[], + ): boolean => { + if (xs.length !== ys.length) return true; + const map = new Map(ys.map((t) => [t.id, t.enabled])); + return xs.some((t) => map.get(t.id) !== t.enabled); + }; + return ( - a.enabled !== b.enabled || - a.default_value !== b.default_value || - a.targets.users.length !== b.targets.users.length || - a.targets.organizations.length !== b.targets.organizations.length || - a.targets.users.some( - (t, i) => - t.id !== b.targets.users[i].id || - t.enabled !== b.targets.users[i].enabled, - ) || - a.targets.organizations.some( - (t, i) => - t.id !== b.targets.organizations[i].id || - t.enabled !== b.targets.organizations[i].enabled, - ) + targetsChanged(a.targets.users, b.targets.users) || + targetsChanged(a.targets.organizations, b.targets.organizations) ); } } From 49de92c5b7573b89feb9bde053e5143232900555 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Fri, 27 Mar 2026 15:13:19 -0700 Subject: [PATCH 15/16] style: Fix prettier formatting in runtime client Co-Authored-By: Claude Opus 4.6 (1M context) --- src/feature-flags/runtime-client.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index a1ca830eb..aefcb9c0d 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -284,10 +284,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { return true; } - const targetsChanged = ( - xs: FlagTarget[], - ys: FlagTarget[], - ): boolean => { + const targetsChanged = (xs: FlagTarget[], ys: FlagTarget[]): boolean => { if (xs.length !== ys.length) return true; const map = new Map(ys.map((t) => [t.id, t.enabled])); return xs.some((t) => map.get(t.id) !== t.enabled); From acb94788e18922a944acb88a468fd9baca7f9c35 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Tue, 31 Mar 2026 14:56:05 -0700 Subject: [PATCH 16/16] fix: Reject waitUntilReady on 401 before initialization If the first poll returns a 401, the ready promise was never settled, causing waitUntilReady() without a timeout to hang indefinitely. Now reject the ready promise with the UnauthorizedException so callers get a clear error instead of hanging. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/feature-flags/runtime-client.spec.ts | 19 +++++++++++++++++++ src/feature-flags/runtime-client.ts | 8 +++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/feature-flags/runtime-client.spec.ts b/src/feature-flags/runtime-client.spec.ts index 935427ec2..9b087c55c 100644 --- a/src/feature-flags/runtime-client.spec.ts +++ b/src/feature-flags/runtime-client.spec.ts @@ -456,6 +456,25 @@ describe('FeatureFlagsRuntimeClient', () => { client.close(); }); + it('rejects waitUntilReady on 401 before initialization', async () => { + fetchOnce( + { message: 'Unauthorized' }, + { status: 401, headers: { 'X-Request-ID': 'req_123' } }, + ); + + const client = workos.featureFlags.createRuntimeClient(); + client.on('error', () => {}); + client.on('failed', () => {}); + + await jest.advanceTimersByTimeAsync(0); + + await expect(client.waitUntilReady()).rejects.toThrow( + UnauthorizedException, + ); + + client.close(); + }); + it('emits failed and stops polling on 401', async () => { fetchOnce( { message: 'Unauthorized' }, diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index aefcb9c0d..62316a86f 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -36,6 +36,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { private pollAbortController: AbortController | null = null; private readyResolve: (() => void) | null = null; + private readyReject: ((err: Error) => void) | null = null; private readyPromise: Promise; private stats: RuntimeClientStats = { @@ -64,8 +65,9 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { this.store = new InMemoryStore(); this.evaluator = new Evaluator(this.store); - this.readyPromise = new Promise((resolve) => { + this.readyPromise = new Promise((resolve, reject) => { this.readyResolve = resolve; + this.readyReject = reject; }); // Prevent unhandled rejection if no one awaits waitUntilReady this.readyPromise.catch(() => {}); @@ -178,6 +180,10 @@ export class FeatureFlagsRuntimeClient extends EventEmitter { if (error instanceof UnauthorizedException) { this.emit('failed', error); + if (!this.initialized && this.readyReject) { + this.readyReject(error); + this.readyReject = null; + } return; } }