learning_ai_common_plat/packages/feature-flag-client/src/client.ts
saravanakumardb1 4a47db72ae fix(flags): SSE stream endpoint + client — pass productId via query string
EventSource API cannot set custom headers, so the SSE /flags/stream
endpoint and feature-flag-client were broken for streaming mode:
- Server: accept productId and token from query string as fallback
  when x-product-id / authorization headers are absent
- Client: pass productId (and optional auth token) as query params
  when constructing the EventSource URL
2026-03-21 12:12:14 -07:00

301 lines
8.2 KiB
TypeScript

/**
* 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<string, boolean> = {};
let evaluations: Record<string, EvaluationResult> = {};
let initialized = false;
let intervalId: ReturnType<typeof setInterval> | null = null;
// eslint-disable-next-line no-undef
let eventSource: InstanceType<typeof EventSource> | 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<string, string> {
const requestId =
typeof globalThis.crypto?.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const headers: Record<string, string> = {
'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<void> {
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<string, boolean> };
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<void> {
try {
const body: Record<string, unknown> = { 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<string, EvaluationResult> };
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<void> {
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<void> {
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<T = boolean | string | number | Record<string, unknown>>(
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<Record<string, boolean>> {
return boolFlags;
}
function getAllEvaluations(): Readonly<Record<string, EvaluationResult>> {
return evaluations;
}
async function refresh(): Promise<void> {
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,
};
}