In the ground since Sun Nov 16 2025
Last watered inSun Nov 16 2025
Recap
Phase 1 focuses on building the vanilla TypeScript core client - the foundation that will later be wrapped by React hooks. This phase creates the framework-agnostic logic for fetching and managing feature flags, ensuring we can potentially support other frameworks (Vue, Svelte) in the future.
Key Achievement: Created type-safe client foundation that imports shared types from @peek-a-boo/core, maintaining single source of truth in the monorepo.
Phase 1.1: Type Definitions
The Problem
The React SDK needs type definitions for:
- Feature flags (structure matching API responses)
- Client configuration (projectId, environment, baseUrl, timeout)
- Hook return values (enabled, value, loading, error states)
- Error handling (custom error classes with codes)
Initial mistake: Almost created duplicate FeatureFlag and Environment types locally in the React SDK.
Critical realization: These types already exist in @peek-a-boo/core from the Prisma schema. Creating duplicates would:
- ❌ Create two sources of truth
- ❌ Cause type mismatches if schema changes
- ❌ Defeat the purpose of a monorepo
The Solution: Import from Core
Single Source of Truth Pattern
1// src/client/types.ts
2
3// ============================================================================
4// IMPORTED FROM CORE (Single Source of Truth)
5// ============================================================================
6
7import type { FeatureFlag, Environment } from '@peek-a-boo/core';
8
9// Re-export for SDK consumers
10export type { FeatureFlag, Environment };
11
12// ============================================================================
13// SDK-SPECIFIC TYPES
14// ============================================================================
15
16export interface ClientConfig {
17 projectId: string;
18 environment: Environment; // ← Uses imported type
19 baseUrl?: string;
20 timeout?: number;
21}
22
23export interface FlagResult<T = unknown> {
24 enabled: boolean;
25 value?: T;
26 loading: boolean;
27 error?: Error;
28}
The pattern:
- Import from core: Database models, enums, anything from Prisma
- Create in SDK: API shapes, client configuration, React-specific types
- Re-export: Make core types available to SDK consumers
Adding the Workspace Dependency
To import from @peek-a-boo/core, we needed to add it as a dependency:
1// packages/react-sdk/package.json
2{
3 "name": "@peek-a-boo/react-sdk",
4 "dependencies": {
5 "@peek-a-boo/core": "workspace:*"
6 // ^^^^^^^^^^^^
7 // pnpm workspace protocol
8 }
9}
What workspace:* does:
- Tells pnpm to link the local @peek-a-boo/core package
- Creates symlink: node_modules/@peek-a-boo/core → ../../core
- No npm registry lookup - uses local code
- Turborepo uses this to determine build order (core builds before react-sdk)
Installation:
1# Added dependency to package.json, then:
2pnpm install
3
4# Result:
5# node_modules/@peek-a-boo/core → ../../core (symlink created)
SDK-Specific Types Created
ClientConfig
1export interface ClientConfig {
2 /** Project ID from Peek-a-boo dashboard */
3 projectId: string;
4
5 /** Environment to fetch flags for */
6 environment: Environment;
7
8 /** API base URL (defaults to http://localhost:6001 in development) */
9 baseUrl?: string;
10
11 /** Request timeout in milliseconds (default: 5000) */
12 timeout?: number;
13}
Design decisions:
- projectId is required (can't fetch flags without it)
- environment is required (flags are environment-specific)
- baseUrl is optional (defaults to local dev server)
- timeout is optional (sensible default of 5 seconds)
FlagResult (Hook Return Type)
1export interface FlagResult<T = unknown> {
2 /** Whether the flag is enabled */
3 enabled: boolean;
4
5 /** Flag value (typed based on generic parameter) */
6 value?: T;
7
8 /** Loading state (true while fetching) */
9 loading: boolean;
10
11 /** Error if fetch failed */
12 error?: Error;
13}
Why generic <T = unknown>?
Allows consumers to specify the value type:
1interface CheckoutConfig {
2 timeout: number;
3 retries: number;
4}
5
6const { enabled, value } = useFeatureFlag<CheckoutConfig>('new-checkout');
7// ^^^^^^^^^^^^^^^ Type parameter
8
9if (enabled && value) {
10 value.timeout // ✅ TypeScript knows this exists
11}
Default unknown instead of any:
- Forces type checking before use (safer)
- Consumer must provide type or handle unknown
- Prevents accidental bugs from assuming structure
PeekabooError (Custom Error Class)
1export class PeekabooError extends Error {
2 constructor(
3 message: string,
4 public code: ErrorCode
5 ) {
6 super(message);
7 this.name = 'PeekabooError';
8
9 // Maintains proper stack trace (V8 only)
10 if (Error.captureStackTrace) {
11 Error.captureStackTrace(this, PeekabooError);
12 }
13 }
14}
15
16export enum ErrorCode {
17 INVALID_PROJECT_ID = 'INVALID_PROJECT_ID',
18 INVALID_ENVIRONMENT = 'INVALID_ENVIRONMENT',
19 NOT_FOUND = 'NOT_FOUND',
20 HTTP_ERROR = 'HTTP_ERROR',
21 NETWORK_ERROR = 'NETWORK_ERROR',
22 TIMEOUT = 'TIMEOUT',
23 UNKNOWN = 'UNKNOWN',
24}
Why custom error class?
- Programmatic error handling (check error.code)
- Better error messages
- Clear distinction from generic errors
Usage:
1try {
2 await client.initialize();
3} catch (error) {
4 if (error instanceof PeekabooError) {
5 switch (error.code) {
6 case ErrorCode.INVALID_PROJECT_ID:
7 // Handle invalid project
8 break;
9 case ErrorCode.NETWORK_ERROR:
10 // Handle network issues
11 break;
12 }
13 }
14}
FlagsResponse (API Response Shape)
1export interface FlagsResponse {
2 /** Array of feature flags */
3 flags: FeatureFlag[];
4
5 /** Environment the flags belong to */
6 environment: string;
7}
Matches SDK service API:
1GET /api/v1/flags?projectId=xxx&environment=DEVELOPMENT
2
3Response:
4{
5 "flags": [...],
6 "environment": "DEVELOPMENT"
7}
Developer Experience Enhancements
JSDoc Comments
Added JSDoc comments for IDE tooltips:
1/**
2 * Configuration for FeatureFlagClient
3 */
4export interface ClientConfig {
5 /** Project ID from Peek-a-boo dashboard */
6 projectId: string;
7 // ...
8}
When developers hover over ClientConfig in VS Code:
- They see the description
- Each field shows its purpose
- No need to read source code
Type-Only Exports
Used export type for type-only exports:
1// ✅ Explicit type export
2export type { FeatureFlag, Environment };
3
4// ✅ Runtime export
5export { PeekabooError };
Why this matters:
- Bundlers can optimize better (know what's compile-time only)
- Prevents accidentally including types in runtime bundle
- Clearer intent when reading code
Verification
1# Type check passed
2pnpm run type:check
3# ✅ No errors
4
5# Verify symlink created
6ls -la node_modules/@peek-a-boo/
7# core -> ../../core ✅
Key Lessons
1. Think Monorepo-First
Wrong approach:
1// ❌ Duplicating types
2export interface FeatureFlag {
3 key: string;
4 enabled: boolean;
5 // ... duplicate from core
6}
Right approach:
1// ✅ Import from single source
2import type { FeatureFlag } from '@peek-a-boo/core';
3export type { FeatureFlag };
2. Workspace Dependencies Enable Sharing
1{
2 "dependencies": {
3 "@peek-a-boo/core": "workspace:*"
4 }
5}
This simple line:
- Links packages in monorepo
- Enables type sharing
- Tells Turborepo build order
- Maintains single source of truth
3. Generic Types for Flexibility
1export interface FlagResult<T = unknown> {
2 value?: T; // User can specify type
3}
Gives consumers flexibility without complexity:
- Power users: Specify exact types
- Quick users: Use defaults (unknown)
4. JSDoc = Better DX
Small effort, huge impact:
1/** Project ID from Peek-a-boo dashboard */
2projectId: string;
Developers see this in IDE tooltips - reduces documentation lookups.
Phase 1.2: HTTP Client with Retry Logic
The Purpose
Create a resilient HTTP client to communicate with the SDK service API endpoints (GET /api/v1/flags) with built-in retry logic, timeout handling, and proper error classification.
Design Goal: Zero dependencies - use native browser/Node.js APIs only.
Key Features Implemented
1. Exponential Backoff Retry Strategy
When network errors occur (connection failures), retry with increasing delays:
1Attempt 1: Immediate
2Attempt 2: Wait 500ms (baseDelay * 2^0)
3Attempt 3: Wait 1000ms (baseDelay * 2^1)
4Attempt 4: Wait 2000ms (baseDelay * 2^2)
Why exponential backoff?
- Gives the server time to recover
- Prevents thundering herd (all clients retrying immediately)
- Industry standard pattern (AWS SDK, Google Cloud SDK use this)
Implementation:
1private async fetchWithRetry<T>(url: string, attempt: number): Promise<T> {
2 try {
3 // ... fetch logic
4 } catch (error) {
5 // Calculate delay: 500ms * 2^attempt
6 const delay = this.retryConfig.baseDelay * Math.pow(2, attempt);
7 await this.sleep(delay);
8 return this.fetchWithRetry<T>(url, attempt + 1);
9 }
10}
2. Timeout Handling with AbortController
Use native AbortController - no dependencies needed!
1const controller = new AbortController();
2const timeoutId = setTimeout(() => controller.abort(), this.timeout);
3
4const response = await fetch(url, {
5 signal: controller.signal // ← Attach abort signal
6});
7
8clearTimeout(timeoutId);
How it works:
- Create AbortController
- Set timeout that calls controller.abort() after 5 seconds
- Pass controller.signal to fetch
- If timeout fires, fetch throws AbortError
- Clear timeout if fetch succeeds
Why AbortController?
- Native API (no dependencies)
- Supported in all modern browsers + Node 18+
- Proper cancellation (not just ignoring the response)
3. Query Parameter Building
Clean API for building URLs with query params:
1client.get('/api/v1/flags', {
2 projectId: 'xxx',
3 environment: 'DEVELOPMENT'
4})
5
6// Internally builds:
7// → GET /api/v1/flags?projectId=xxx&environment=DEVELOPMENT
Implementation:
1private buildUrl(path: string, params?: Record<string, string>): string {
2 const url = `${this.baseUrl}${path}`;
3
4 if (!params || Object.keys(params).length === 0) {
5 return url;
6 }
7
8 const searchParams = new URLSearchParams(params);
9 return `${url}?${searchParams.toString()}`;
10}
Why URLSearchParams?
- Native API (no dependencies)
- Automatically handles URL encoding
- Works with complex characters (spaces, special chars)
4. Error Classification (Smart Retry Logic)
Not all errors should be retried!
1// ✅ RETRY: Network errors (transient failures)
2fetch('http://api.example.com/flags')
3// → Connection refused → RETRY
4// → DNS lookup failed → RETRY
5// → Network cable unplugged → RETRY
6
7// ❌ DON'T RETRY: HTTP errors (persistent issues)
8fetch('http://api.example.com/flags')
9// → 404 Not Found → Throw immediately (flag doesn't exist)
10// → 500 Server Error → Throw immediately (server bug, won't fix itself)
11// → 401 Unauthorized → Throw immediately (wrong credentials)
Implementation:
1if (!response.ok) {
2 // HTTP error (404, 500, etc.) - throw immediately, don't retry
3 throw new PeekabooError(
4 `HTTP ${response.status}: ${response.statusText}`,
5 ErrorCode.HTTP_ERROR
6 );
7}
8
9// ... later in catch block
10
11if (error instanceof PeekabooError) {
12 throw error; // Already classified, don't retry
13}
14
15// Network error - retry with backoff
16const isLastAttempt = attempt >= this.retryConfig.maxRetries;
17if (isLastAttempt) {
18 throw new PeekabooError(
19 `Network error after ${attempt + 1} attempts: ${message}`,
20 ErrorCode.NETWORK_ERROR
21 );
22}
Why this matters:
- Retrying 404s wastes time (flag still won't exist)
- Retrying 500s might overload already-struggling server
- Only network errors are transient and worth retrying
5. Simple Public API
1export class HttpClient {
2 constructor(
3 private baseUrl: string,
4 private timeout: number = 5000
5 ) {}
6
7 async get<T>(path: string, params?: Record<string, string>): Promise<T>
8}
Usage:
1const client = new HttpClient('http://localhost:6001', 5000);
2
3const response = await client.get<FlagsResponse>('/api/v1/flags', {
4 projectId: 'abc123',
5 environment: 'DEVELOPMENT'
6});
7
8console.log(response.flags); // Array of FeatureFlag
Design Decisions Explained
Why Only GET Method?
1async get<T>(path: string, params?: Record<string, string>): Promise<T>
MVP scope:
- SDK only needs to fetch flags (read-only)
- No create/update/delete from client
- Keeps implementation simple
Future phases:
- If we add client-side flag creation → Add post()
- If we add flag updates → Add patch()
- For now: YAGNI (You Ain't Gonna Need It)
Why No Authentication Yet?
1headers: {
2 'Content-Type': 'application/json',
3 // No Authorization header yet
4}
Reason:
- Phase 0 SDK service endpoints don't require authentication
- API keys will be added in future phase when we implement SDK key generation in dashboard
Future:
1headers: {
2 'Authorization': `Bearer ${this.apiKey}`,
3 'Content-Type': 'application/json',
4}
Why Native Fetch (No Axios)?
Benefits:
- ✅ Zero dependencies (smaller bundle)
- ✅ Native to browsers + Node 18+
- ✅ Built-in AbortController support
- ✅ Simpler (no learning curve)
Comparison:
1// Our implementation (0 dependencies)
2const client = new HttpClient('http://api.example.com');
3const data = await client.get('/flags', { projectId: 'xxx' });
4
5// With Axios (adds ~14KB to bundle)
6import axios from 'axios';
7const { data } = await axios.get('/flags', {
8 params: { projectId: 'xxx' },
9 timeout: 5000
10});
Trade-off:
- We write more code (retry logic, timeout handling)
- But we control everything and have zero dependencies
Configuration Defaults
1private readonly retryConfig: RetryConfig = {
2 maxRetries: 3, // Total 4 attempts (1 initial + 3 retries)
3 baseDelay: 500, // 500ms → 1000ms → 2000ms
4};
5
6constructor(
7 private baseUrl: string,
8 private timeout: number = 5000 // 5 second default
9) {}
Why these values?
- 3 retries: Industry standard, balances resilience vs responsiveness
- 500ms base delay: Fast enough for users, slow enough to help recovery
- 5 second timeout: Long enough for slow connections, short enough users won't wait forever
Implementation Highlights
Clean URL Building
1constructor(private baseUrl: string, ...) {
2 // Remove trailing slash for consistent URL building
3 this.baseUrl = baseUrl.replace(/\/$/, '');
4}
5
6private buildUrl(path: string, params?: Record<string, string>): string {
7 const url = `${this.baseUrl}${path}`;
8 // ...
9}
Handles edge cases:
1// Both work correctly:
2new HttpClient('http://localhost:6001/') // ← trailing slash
3new HttpClient('http://localhost:6001') // ← no trailing slash
4
5// Both produce: http://localhost:6001/api/v1/flags
Proper Timeout Cleanup
1const timeoutId = setTimeout(() => controller.abort(), this.timeout);
2
3try {
4 const response = await fetch(url, { signal: controller.signal });
5 clearTimeout(timeoutId); // ← Important! Prevent memory leak
6 // ...
7} catch (error) {
8 clearTimeout(timeoutId); // ← Also clear on error
9 // ...
10}
Why clear timeout?
- If fetch succeeds quickly, timeout is still scheduled
- Clearing prevents unnecessary timer firing
- Prevents memory leaks in long-running apps
Type-Safe Generic Response
1async get<T>(path: string, params?: Record<string, string>): Promise<T> {
2 // ...
3 return (await response.json()) as T;
4}
Usage with type safety:
1interface FlagsResponse {
2 flags: FeatureFlag[];
3 environment: string;
4}
5
6const response = await client.get<FlagsResponse>('/api/v1/flags', {...});
7// ^^^^^^^^ TypeScript knows the shape!
8
9response.flags.forEach(flag => {
10 console.log(flag.key); // ✅ TypeScript knows FeatureFlag has .key
11});
Testing Strategy
How we'll test this (Phase 1.4):
1import { setupServer } from 'msw/node';
2import { rest } from 'msw';
3
4describe('HttpClient', () => {
5 it('retries on network error', async () => {
6 let attempts = 0;
7
8 server.use(
9 rest.get('/api/v1/flags', (req, res, ctx) => {
10 attempts++;
11 if (attempts < 3) {
12 return res.networkError('Connection failed');
13 }
14 return res(ctx.json({ flags: [] }));
15 })
16 );
17
18 const client = new HttpClient('http://localhost');
19 const result = await client.get('/api/v1/flags');
20
21 expect(attempts).toBe(3); // Retried 2 times, succeeded on 3rd
22 });
23
24 it('does not retry HTTP errors', async () => {
25 let attempts = 0;
26
27 server.use(
28 rest.get('/api/v1/flags', (req, res, ctx) => {
29 attempts++;
30 return res(ctx.status(404));
31 })
32 );
33
34 const client = new HttpClient('http://localhost');
35
36 await expect(client.get('/api/v1/flags')).rejects.toThrow(
37 PeekabooError
38 );
39
40 expect(attempts).toBe(1); // No retries on 404
41 });
42});
Key Lessons
1. Retry Logic Isn't Simple
What seems simple:
1// ❌ Naive retry
2for (let i = 0; i < 3; i++) {
3 try {
4 return await fetch(url);
5 } catch {
6 // Just retry
7 }
8}
What's actually needed:
- ✅ Exponential backoff (not constant delay)
- ✅ Error classification (don't retry everything)
- ✅ Maximum attempts (don't retry forever)
- ✅ Proper error messages (include attempt count)
2. Native APIs Are Powerful
We built production-ready HTTP client with:
- Zero dependencies
- ~200 lines of code
- Full TypeScript support
- Retry logic
- Timeout handling
- Query parameters
All using native APIs:
- fetch (HTTP requests)
- AbortController (cancellation)
- URLSearchParams (URL building)
- setTimeout (delays)
3. Error Classification Matters
Not all errors are equal:
- Transient errors (network) → Retry makes sense
- Permanent errors (404, 500) → Retry wastes time
Real-world impact:
- User sees "Loading..." for 10 seconds
- Because we're retrying a 404 that will never succeed
- Bad UX!
4. Cleanup Prevents Memory Leaks
1clearTimeout(timeoutId); // Always clear timers
In long-running apps (SPAs):
- Forgotten timers accumulate
- Memory usage grows
- Browser slows down
- Eventually crashes
Proper cleanup = Professional code
Phase 1.3: FeatureFlagClient - Bringing It All Together
The Purpose
Create a client class that uses our HTTP client to fetch and manage feature flags with a simple, intuitive API. This is the vanilla JavaScript client that React hooks will wrap in Phase 2.
Design Goals:
- Lazy loading (don't fetch until needed)
- In-memory caching (fast lookups)
- Type-safe value access
- Graceful error handling
- Race condition protection
Understanding initialize(): The Foundation
The Goal: Lazy Loading
Problem: When should we fetch flags from the API?
❌ Bad: Fetch in Constructor
1User creates client → Immediate API call
2 ↓
3const client = new FeatureFlagClient({...}) ← API call happens here!
Why bad?
- User can't handle errors during construction
- Blocks synchronously (constructors can't be async)
- Wastes network if user never uses the client
✅ Good: Lazy Loading with initialize()
1User creates client → No API call (instant)
2 ↓
3const client = new FeatureFlagClient({...}) ← Instant!
4 ↓
5User calls initialize → API call happens NOW
6 ↓
7await client.initialize() ← Can handle errors with try/catch
Why good?
- Instant construction (no blocking)
- User controls WHEN to fetch
- User can handle errors gracefully
- Testable (can mock initialize)
Visual Flow: First Initialize Call
1┌─────────────────────────────────────────────────────────────┐
2│ User calls: await client.initialize() │
3└─────────────────────────────────────────────────────────────┘
4 ↓
5┌─────────────────────────────────────────────────────────────┐
6│ Check: Is initialized already? │
7│ → this.initialized === false │
8└─────────────────────────────────────────────────────────────┘
9 ↓ NO
10┌─────────────────────────────────────────────────────────────┐
11│ Check: Is initialization in progress? │
12│ → this.initializing === null │
13└─────────────────────────────────────────────────────────────┘
14 ↓ NO
15┌─────────────────────────────────────────────────────────────┐
16│ Start initialization: │
17│ this.initializing = this.fetchFlags() │
18│ │
19│ State now: │
20│ • this.initialized = false │
21│ • this.initializing = Promise<void> (pending) │
22└─────────────────────────────────────────────────────────────┘
23 ↓
24┌─────────────────────────────────────────────────────────────┐
25│ fetchFlags() runs: │
26│ 1. Call API: GET /api/v1/flags │
27│ 2. Wait for response... │
28│ 3. Parse JSON │
29│ 4. Populate this.flags Map │
30└─────────────────────────────────────────────────────────────┘
31 ↓
32┌─────────────────────────────────────────────────────────────┐
33│ Success! │
34│ State update: │
35│ • this.initialized = true │
36│ • this.initializing = null │
37│ • this.flags = Map with all flags │
38└─────────────────────────────────────────────────────────────┘
39 ↓
40┌─────────────────────────────────────────────────────────────┐
41│ Return to user │
42│ User's await completes │
43└─────────────────────────────────────────────────────────────┘
Pattern 1: Idempotent (Safe to Call Multiple Times)
What is "Idempotent"?
From mathematics: f(f(x)) = f(x)
In programming: Calling a function multiple times has the same effect as calling it once.
Example:
1await client.initialize(); // Fetches flags from API
2await client.initialize(); // Does NOTHING (already initialized)
3await client.initialize(); // Does NOTHING (already initialized)
4
5// Only ONE API call was made!
Visual Flow: Second Initialize Call
1┌─────────────────────────────────────────────────────────────┐
2│ User calls: await client.initialize() AGAIN │
3└─────────────────────────────────────────────────────────────┘
4 ↓
5┌─────────────────────────────────────────────────────────────┐
6│ Check: Is initialized already? │
7│ → this.initialized === true ✅ │
8└─────────────────────────────────────────────────────────────┘
9 ↓ YES!
10┌─────────────────────────────────────────────────────────────┐
11│ Return immediately │
12│ if (this.initialized) { │
13│ return; ← Early exit! │
14│ } │
15│ │
16│ NO API CALL! ✅ │
17└─────────────────────────────────────────────────────────────┘
Why Idempotent Matters
1// React component might call initialize multiple times
2useEffect(() => {
3 client.initialize(); // First render
4}, []);
5
6useEffect(() => {
7 client.initialize(); // Some other effect
8}, [someDep]);
9
10// ✅ Only ONE API call (idempotent)
11// ❌ Without idempotency → TWO API calls (wasteful!)
Pattern 2: Race Condition Protection
The Problem: Concurrent Calls
1// User accidentally calls initialize multiple times simultaneously
2Promise.all([
3 client.initialize(), // Call 1
4 client.initialize(), // Call 2
5 client.initialize(), // Call 3
6]);
❌ WITHOUT Race Protection:
1Call 1 → API Request 1 → Fetch flags
2Call 2 → API Request 2 → Fetch flags (DUPLICATE!)
3Call 3 → API Request 3 → Fetch flags (DUPLICATE!)
4
5Result: 3 API calls for same data! 😱
✅ WITH Race Protection:
1Call 1 → Start fetch → this.initializing = Promise
2Call 2 → Wait for Call 1's promise
3Call 3 → Wait for Call 1's promise
4
5Result: 1 API call, all callers wait for same fetch! ✅
Visual Flow: Concurrent Calls
1Timeline →
2
3T0: Call 1 arrives
4 ↓
5 Check: initialized? NO
6 Check: initializing? NO
7 Start fetch: this.initializing = fetchFlags()
8 State: { initialized: false, initializing: Promise (pending) }
9
10T1: Call 2 arrives (while Call 1 is still fetching)
11 ↓
12 Check: initialized? NO
13 Check: initializing? YES! ← There's already a promise
14 ↓
15 Return this.initializing ← Wait for the SAME promise
16
17T2: Call 3 arrives (while Call 1 is still fetching)
18 ↓
19 Check: initialized? NO
20 Check: initializing? YES! ← There's already a promise
21 ↓
22 Return this.initializing ← Wait for the SAME promise
23
24T3: API responds
25 ↓
26 All three callers' promises resolve simultaneously
27 State: { initialized: true, initializing: null }
The Code That Enables This
1async initialize(): Promise<void> {
2 // Already done? Return immediately (Idempotent)
3 if (this.initialized) {
4 return;
5 }
6
7 // In progress? Wait for existing promise (Race Protection)
8 if (this.initializing) {
9 return this.initializing; // ← KEY LINE!
10 }
11
12 // Start new initialization
13 this.initializing = this.fetchFlags();
14
15 try {
16 await this.initializing;
17 this.initialized = true;
18 } finally {
19 this.initializing = null; // Clear promise (success or failure)
20 }
21}
Pattern 3: State Machine
The client has 3 states:
1┌─────────────────┐
2│ UNINITIALIZED │ ← Initial state after construction
3│ │
4│ initialized: false
5│ initializing: null
6└─────────────────┘
7 ↓ initialize() called
8 ↓
9┌─────────────────┐
10│ INITIALIZING │ ← API call in progress
11│ │
12│ initialized: false
13│ initializing: Promise
14└─────────────────┘
15 ↓ API responds
16 ↓
17┌─────────────────┐
18│ INITIALIZED │ ← Ready to use
19│ │
20│ initialized: true
21│ initializing: null
22└─────────────────┘
State Transitions
1UNINITIALIZED → INITIALIZING
2 Trigger: First initialize() call
3 Action: Start API fetch
4
5INITIALIZING → INITIALIZED
6 Trigger: API success
7 Action: Set flags, mark as initialized
8
9INITIALIZING → UNINITIALIZED
10 Trigger: API failure
11 Action: Clear initializing promise, stay uninitialized
Handling Each State
1const client = new FeatureFlagClient({...});
2
3// State: UNINITIALIZED
4console.log(client.isInitialized()); // false
5
6try {
7 client.isEnabled('foo');
8 // ❌ Throws: "Client not initialized. Call initialize() first."
9} catch (error) {
10 // User is forced to initialize first
11}
12
13// State: INITIALIZING (briefly)
14await client.initialize();
15
16// State: INITIALIZED
17console.log(client.isInitialized()); // true
18client.isEnabled('foo'); // ✅ Works now
Real-World Analogy
Think of initialize() like turning on your car:
Without initialize pattern:
1Buy car → Engine starts automatically (constructor)
2 ↓
3What if you're not ready to drive?
4What if there's no gas?
5Can't catch errors during purchase!
With initialize pattern:
1Buy car → Just gets keys (constructor)
2 ↓
3Turn ignition → Engine starts (initialize)
4 ↓
5Can handle:
6 - No gas? Try/catch
7 - Not ready? Wait to turn ignition
8 - Already running? Ignition does nothing (idempotent)
The Complete FeatureFlagClient API
Lifecycle Methods
1// Create client (instant, no API call)
2const client = new FeatureFlagClient({
3 projectId: 'my-project',
4 environment: 'PRODUCTION',
5 baseUrl: 'https://api.peekaboo.com', // optional
6 timeout: 10000 // optional
7});
8
9// Initialize (fetches flags)
10await client.initialize();
11
12// Refresh (re-fetch flags)
13await client.refresh();
14
15// Check state
16client.isInitialized(); // boolean
Flag Access Methods
1// Get full flag object
2const flag = client.getFlag('new-checkout');
3if (flag) {
4 console.log(flag.key, flag.enabled, flag.value);
5}
6
7// Get all flags
8const allFlags = client.getAllFlags();
9console.log(`Loaded ${allFlags.length} flags`);
10
11// Check if enabled (simple boolean)
12if (client.isEnabled('new-checkout')) {
13 // Feature is on
14}
15
16// Get typed value
17interface CheckoutConfig {
18 timeout: number;
19 retries: number;
20}
21
22const config = client.getValue<CheckoutConfig>('new-checkout');
23if (config) {
24 console.log(config.timeout); // TypeScript knows this exists
25}
26
27// Get value with fallback
28const timeout = client.getValueWithFallback<number>('timeout', 5000);
29console.log(timeout); // Flag value or 5000 if not found
Utility Methods
1// Count flags
2console.log(`Loaded ${client.size()} flags`);
3
4// Check initialization state
5if (!client.isInitialized()) {
6 await client.initialize();
7}
Key Implementation Details
Config Validation (Fail Fast)
1constructor(config: ClientConfig) {
2 // Validate immediately on construction
3 const projectId = config.projectId.trim();
4 if (!projectId) {
5 throw new PeekabooError(
6 'projectId is required',
7 ErrorCode.INVALID_PROJECT_ID
8 );
9 }
10
11 if (!config.environment) {
12 throw new PeekabooError(
13 'environment is required',
14 ErrorCode.INVALID_ENVIRONMENT
15 );
16 }
17
18 // Setup HTTP client
19 const baseUrl = config.baseUrl || 'http://localhost:6001';
20 this.http = new HttpClient(baseUrl, config.timeout);
21}
Why fail fast?
- User knows immediately if config is wrong
- Better than failing later during initialize()
- Clear error messages guide user to fix
In-Memory Caching with Map
1private flags: Map<string, FeatureFlag> = new Map();
2
3// Populate cache
4response.flags.forEach((flag) => {
5 this.flags.set(flag.key, flag); // O(1) insert
6});
7
8// Fast lookups
9getFlag(key: string): FeatureFlag | null {
10 return this.flags.get(key) || null; // O(1) lookup
11}
Why Map instead of Array?
1// ❌ Array: O(n) lookup
2const flags: FeatureFlag[] = [...];
3const flag = flags.find(f => f.key === 'new-checkout'); // Loops through all
4
5// ✅ Map: O(1) lookup
6const flags = new Map<string, FeatureFlag>();
7const flag = flags.get('new-checkout'); // Instant hash lookup
Ensure Initialized Guard
1private ensureInitialized(): void {
2 if (!this.initialized) {
3 throw new PeekabooError(
4 'Client not initialized. Call initialize() first.',
5 ErrorCode.UNKNOWN
6 );
7 }
8}
9
10// Used in all flag access methods
11getFlag(key: string): FeatureFlag | null {
12 this.ensureInitialized(); // ← Throws if not initialized
13 return this.flags.get(key) || null;
14}
Forces proper usage:
1const client = new FeatureFlagClient({...});
2
3// ❌ This will throw
4client.isEnabled('foo');
5// Error: "Client not initialized. Call initialize() first."
6
7// ✅ This works
8await client.initialize();
9client.isEnabled('foo'); // Works!
Type-Safe Value Access
1getValue<T = unknown>(key: string): T | undefined {
2 this.ensureInitialized();
3 const flag = this.flags.get(key);
4
5 // Only return value if flag exists AND is enabled
6 if (flag && flag.enabled) {
7 return flag.value as T;
8 }
9
10 return undefined;
11}
Usage patterns:
1// Pattern 1: Check undefined
2const timeout = client.getValue<number>('timeout');
3if (timeout !== undefined) {
4 console.log(timeout * 2);
5}
6
7// Pattern 2: Use fallback
8const timeout = client.getValueWithFallback<number>('timeout', 5000);
9console.log(timeout); // Always has a value
10
11// Pattern 3: Complex types
12interface CheckoutConfig {
13 timeout: number;
14 retries: number;
15 apiUrl: string;
16}
17
18const config = client.getValue<CheckoutConfig>('checkout-config');
19if (config) {
20 // TypeScript knows config has timeout, retries, apiUrl
21 console.log(config.timeout);
22}
Key Lessons
1. Lazy Loading > Eager Loading
Don't fetch until needed:
- Faster construction
- User controls timing
- Errors are handleable
2. Idempotency Prevents Bugs
Safe to call multiple times:
- React effects can call freely
- No duplicate API calls
- Predictable behavior
3. Race Protection is Critical
Concurrent calls must wait for same fetch:
- Only ONE API call
- All callers get same result
- No race conditions
4. State Machines Clarify Logic
Three clear states:
- UNINITIALIZED: Created, not ready
- INITIALIZING: Fetching from API
- INITIALIZED: Ready to use
5. Fail Fast with Clear Errors
Validate immediately:
- Constructor validates config
- Methods throw if not initialized
- Clear error messages guide users
6. O(1) Lookups with Map
Map > Array for lookups:
- Constant time access
- Scales to thousands of flags
- No performance degradation
Used in Projects
- Peek-a-boo React SDK - Complete vanilla TypeScript client for feature flags
- Pattern applicable to any SDK requiring initialization and caching
Next Steps
With the vanilla TypeScript client complete (types, HTTP client, FeatureFlagClient), Phase 2 will wrap this in React hooks (useFeatureFlag, useFeatureFlags) and Context for easy integration into React apps.