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
301 lines
8.2 KiB
TypeScript
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,
|
|
};
|
|
}
|