From 90e7fa5611c4f6752d3dd2225c06b9f87b23954d Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 12 Feb 2026 17:14:23 -0800 Subject: [PATCH 1/8] 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 3c15c97320953a57522814139602386e2297c0f5 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 12 Feb 2026 17:14:50 -0800 Subject: [PATCH 2/8] 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 aa39a911c98464a30f2ddbb677757e6f9d1b87bd Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 12 Feb 2026 17:14:59 -0800 Subject: [PATCH 3/8] 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 233fc5f48c395337f07bd122503cd458624ef479 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 12 Feb 2026 17:15:08 -0800 Subject: [PATCH 4/8] 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 59470ae6ccfe661973d3cedbd2239baedf9ad7cc Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 12 Feb 2026 17:17:16 -0800 Subject: [PATCH 5/8] 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 9345f2c34dc6bf8420d1c696a64dbad533ed68e3 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Wed, 18 Feb 2026 15:11:23 -0800 Subject: [PATCH 6/8] 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 4d5d6549633499074a05bbaf84a5e06447d99f9b Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Wed, 4 Mar 2026 00:06:50 +0900 Subject: [PATCH 7/8] 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 859c04ede683669c1e2acbad69ae182103cc0cb0 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Wed, 4 Mar 2026 00:39:18 +0900 Subject: [PATCH 8/8] 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';