learning_ai_common_plat/packages/feature-flag-client/src/client.ts

116 lines
3.0 KiB
TypeScript

/**
* Browser/React Native-safe feature flag client for platform-service.
*
* Polls GET /api/flags/poll on a configurable interval and caches results.
* No Node.js dependencies — uses globalThis.fetch.
*
* @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' });
* if (flags.isEnabled('premium_body_viz')) { ... }
* ```
*/
import type { FeatureFlagClient, FeatureFlagClientConfig } from './types.js';
export function createFeatureFlagClient(config: FeatureFlagClientConfig): FeatureFlagClient {
const {
baseUrl,
productId,
platform,
pollIntervalMs = 5 * 60 * 1000,
storage,
storagePrefix,
} = config;
const prefix = storagePrefix ?? productId;
const STORAGE_KEY = `${prefix}-feature-flags`;
let flags: Record<string, boolean> = {};
let initialized = false;
let intervalId: ReturnType<typeof setInterval> | null = null;
let userId: string | undefined;
// Restore from storage on creation
if (storage) {
try {
const cached = storage.getItem(STORAGE_KEY);
if (cached) flags = JSON.parse(cached);
} catch {
// Ignore parse errors
}
}
async function fetchFlags(): Promise<void> {
try {
const parts = [`platform=${encodeURIComponent(platform)}`];
if (userId) parts.push(`userId=${encodeURIComponent(userId)}`);
const requestId =
typeof globalThis.crypto?.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const res = await globalThis.fetch(`${baseUrl}/flags/poll?${parts.join('&')}`, {
headers: { 'x-product-id': productId, 'x-request-id': requestId },
});
if (!res.ok) return;
const data = (await res.json()) as { flags?: Record<string, boolean> };
flags = data.flags ?? {};
// Persist to storage
if (storage) {
try {
storage.setItem(STORAGE_KEY, JSON.stringify(flags));
} catch {
// Storage write failure — non-fatal
}
}
} catch {
// Keep existing flags on network error
}
}
async function init(params?: { userId?: string }): Promise<void> {
if (initialized) return;
initialized = true;
userId = params?.userId;
await fetchFlags();
intervalId = setInterval(() => {
void fetchFlags();
}, pollIntervalMs);
}
function isEnabled(key: string): boolean {
return flags[key] === true;
}
function getAllFlags(): Readonly<Record<string, boolean>> {
return flags;
}
async function refresh(): Promise<void> {
await fetchFlags();
}
function stop(): void {
if (intervalId) clearInterval(intervalId);
intervalId = null;
flags = {};
initialized = false;
userId = undefined;
}
return { init, isEnabled, getAllFlags, refresh, stop };
}