/** * Browser/React Native-safe feature flag client for platform-service. * * Supports two modes: * 1. **Polling** (default) — GET /flags/poll on a configurable interval * 2. **Streaming** — SSE via GET /flags/stream for real-time updates * * Both modes support multi-variate evaluation via POST /flags/evaluate. * No Node.js dependencies — uses globalThis.fetch and EventSource. * * @example * ```ts * import { createFeatureFlagClient } from '@bytelyst/feature-flag-client'; * * const flags = createFeatureFlagClient({ * baseUrl: 'http://localhost:4003/api', * productId: 'nomgap', * platform: 'mobile', * }); * * await flags.init({ userId: 'user-123' }); * * // Boolean check (legacy) * if (flags.isEnabled('premium_body_viz')) { ... } * * // Multi-variate * const color = flags.getValue('cta_color', '#000000'); * const config = flags.getValue('rate_limits', { maxReqs: 100 }); * * // Listen for changes (SSE or polling refresh) * const unsub = flags.onChange((key) => console.log(`${key} changed`)); * ``` */ import type { FeatureFlagClient, FeatureFlagClientConfig, EvaluationResult } from './types.js'; export function createFeatureFlagClient(config: FeatureFlagClientConfig): FeatureFlagClient { const { baseUrl, productId, platform, pollIntervalMs = 5 * 60 * 1000, storage, storagePrefix, useStreaming = false, getAccessToken, } = config; const prefix = storagePrefix ?? productId; const BOOL_KEY = `${prefix}-feature-flags`; const EVAL_KEY = `${prefix}-feature-evals`; let boolFlags: Record = {}; let evaluations: Record = {}; let initialized = false; let intervalId: ReturnType | null = null; // eslint-disable-next-line no-undef let eventSource: InstanceType | null = null; let userId: string | undefined; const listeners = new Set<(flagKey: string) => void>(); // Restore from storage on creation if (storage) { try { const cached = storage.getItem(BOOL_KEY); if (cached) boolFlags = JSON.parse(cached); } catch { /* Ignore parse errors */ } try { const cached = storage.getItem(EVAL_KEY); if (cached) evaluations = JSON.parse(cached); } catch { /* Ignore parse errors */ } } function buildHeaders(): Record { const requestId = typeof globalThis.crypto?.randomUUID === 'function' ? globalThis.crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const headers: Record = { 'x-product-id': productId, 'x-request-id': requestId, }; if (getAccessToken) { const token = getAccessToken(); if (token) headers['authorization'] = `Bearer ${token}`; } return headers; } function notifyListeners(flagKey: string): void { for (const listener of listeners) { try { listener(flagKey); } catch { /* best-effort */ } } } function persistToStorage(): void { if (!storage) return; try { storage.setItem(BOOL_KEY, JSON.stringify(boolFlags)); } catch { /* non-fatal */ } try { storage.setItem(EVAL_KEY, JSON.stringify(evaluations)); } catch { /* non-fatal */ } } async function fetchBoolFlags(): Promise { try { const parts = [`platform=${encodeURIComponent(platform)}`]; if (userId) parts.push(`userId=${encodeURIComponent(userId)}`); const res = await globalThis.fetch(`${baseUrl}/flags/poll?${parts.join('&')}`, { headers: buildHeaders(), }); if (!res.ok) return; const data = (await res.json()) as { flags?: Record }; const prev = boolFlags; boolFlags = data.flags ?? {}; // Detect changes and notify const allKeys = new Set([...Object.keys(prev), ...Object.keys(boolFlags)]); for (const key of allKeys) { if (prev[key] !== boolFlags[key]) notifyListeners(key); } persistToStorage(); } catch { // Keep existing flags on network error } } async function fetchEvaluations(): Promise { try { const body: Record = { platform }; if (userId) body.userId = userId; const res = await globalThis.fetch(`${baseUrl}/flags/evaluate`, { method: 'POST', headers: { ...buildHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) return; const data = (await res.json()) as { evaluations?: Record }; const prev = evaluations; evaluations = data.evaluations ?? {}; // Also update bool flags for backward compatibility for (const [key, result] of Object.entries(evaluations)) { const newBool = result.reason !== 'off' && result.reason !== 'prerequisite_failed' && result.reason !== 'schedule_inactive' && result.reason !== 'error'; if (boolFlags[key] !== newBool) { boolFlags[key] = newBool; notifyListeners(key); } else if (JSON.stringify(prev[key]?.value) !== JSON.stringify(result.value)) { notifyListeners(key); } } persistToStorage(); } catch { // Keep existing evaluations on network error } } async function fetchAll(): Promise { await Promise.all([fetchBoolFlags(), fetchEvaluations()]); } function startStreaming(): void { if (typeof globalThis.EventSource === 'undefined') { // SSE not available (e.g. React Native) — fall back to polling intervalId = setInterval(() => { void fetchAll(); }, pollIntervalMs); return; } const parts = [`productId=${encodeURIComponent(productId)}`]; if (getAccessToken) { const token = getAccessToken(); if (token) parts.push(`token=${encodeURIComponent(token)}`); } const url = `${baseUrl}/flags/stream?${parts.join('&')}`; eventSource = new globalThis.EventSource(url); eventSource.onmessage = event => { try { const data = JSON.parse(event.data) as { type?: string; flagKey?: string }; if (data.type === 'flag_change' && data.flagKey) { // Re-fetch all evaluations on any flag change void fetchAll().then(() => notifyListeners(data.flagKey!)); } } catch { /* Ignore parse errors */ } }; eventSource.onerror = () => { // Reconnect handled automatically by EventSource spec }; } async function init(params?: { userId?: string }): Promise { if (initialized) return; initialized = true; userId = params?.userId; await fetchAll(); if (useStreaming) { startStreaming(); } else { intervalId = setInterval(() => { void fetchAll(); }, pollIntervalMs); } } function isEnabled(key: string): boolean { return boolFlags[key] === true; } function getValue>( key: string, defaultValue: T ): T { const result = evaluations[key]; if (!result) return defaultValue; if (result.reason === 'off' || result.reason === 'error') return defaultValue; return result.value as T; } function getEvaluation(key: string): EvaluationResult | undefined { return evaluations[key]; } function getAllFlags(): Readonly> { return boolFlags; } function getAllEvaluations(): Readonly> { return evaluations; } async function refresh(): Promise { await fetchAll(); } function onChange(listener: (flagKey: string) => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; } function stop(): void { if (intervalId) clearInterval(intervalId); intervalId = null; if (eventSource) { eventSource.close(); eventSource = null; } boolFlags = {}; evaluations = {}; initialized = false; userId = undefined; listeners.clear(); } return { init, isEnabled, getValue, getEvaluation, getAllFlags, getAllEvaluations, refresh, onChange, stop, }; }