116 lines
3.0 KiB
TypeScript
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 };
|
|
}
|