diff --git a/src/feature-flags/evaluator.spec.ts b/src/feature-flags/evaluator.spec.ts new file mode 100644 index 000000000..fe399deef --- /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..9b7ffd217 --- /dev/null +++ b/src/feature-flags/evaluator.ts @@ -0,0 +1,53 @@ +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; + } +} 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); + } } 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; + } +} 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; +} diff --git a/src/feature-flags/runtime-client.spec.ts b/src/feature-flags/runtime-client.spec.ts new file mode 100644 index 000000000..935427ec2 --- /dev/null +++ b/src/feature-flags/runtime-client.spec.ts @@ -0,0 +1,487 @@ +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('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 + 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..8d6b06854 --- /dev/null +++ b/src/feature-flags/runtime-client.ts @@ -0,0 +1,248 @@ +import { EventEmitter } from 'node: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; +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; + private readonly evaluator: Evaluator; + private readonly pollingIntervalMs: number; + private readonly requestTimeoutMs: number; + private readonly logger?: RuntimeClientLogger; + + private closed = false; + private initialized = false; + private consecutiveErrors = 0; + 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; + this.consecutiveErrors = 0; + + if (this.initialized) { + this.emitChanges(previousFlags, data); + } + this.initialized = true; + this.resolveReady(); + + 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); + + 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; + } + + 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, baseDelay * 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';