From b73e40c0bbfb71bfdc4fe2444204da63a07aae73 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 1 Dec 2025 19:24:30 -0800 Subject: [PATCH 1/5] feat(sdks): Implement M3 - Node & Web SDKs with React hooks - Implement Node.js SDK with config fetching, caching, and auto-refresh - Implement Web/Browser SDK with bootstrap mode for SSR - Add React hooks (useFlag, useFlagVariant, useFlagKeys, useFlagExists) - Add comprehensive test suites (25 tests for Node, 41 tests for Web) - Add full documentation with examples and API reference - Support both static config and API-based config loading - Automatic refresh capabilities with configurable intervals - Config update subscriptions and event listeners --- README.md | 4 +- packages/flags-client-node/.eslintrc.json | 14 + packages/flags-client-node/README.md | 231 ++++ packages/flags-client-node/jest.config.js | 23 + packages/flags-client-node/package.json | 14 +- packages/flags-client-node/src/client.ts | 256 +++++ packages/flags-client-node/src/fetcher.ts | 61 + packages/flags-client-node/src/index.ts | 45 +- packages/flags-client-node/src/types.ts | 56 + .../flags-client-node/test/client.test.ts | 459 ++++++++ packages/flags-client-web/.eslintrc.json | 14 + packages/flags-client-web/README.md | 386 +++++++ packages/flags-client-web/jest.config.js | 32 + packages/flags-client-web/package.json | 38 +- packages/flags-client-web/src/client.ts | 265 +++++ packages/flags-client-web/src/fetcher.ts | 60 + packages/flags-client-web/src/index.ts | 45 +- packages/flags-client-web/src/react.tsx | 177 +++ packages/flags-client-web/src/types.ts | 57 + packages/flags-client-web/test/client.test.ts | 438 +++++++ packages/flags-client-web/test/react.test.tsx | 350 ++++++ packages/flags-client-web/test/setup.ts | 17 + pnpm-lock.yaml | 1014 ++++++++++++++++- 23 files changed, 4033 insertions(+), 23 deletions(-) create mode 100644 packages/flags-client-node/.eslintrc.json create mode 100644 packages/flags-client-node/README.md create mode 100644 packages/flags-client-node/jest.config.js create mode 100644 packages/flags-client-node/src/client.ts create mode 100644 packages/flags-client-node/src/fetcher.ts create mode 100644 packages/flags-client-node/src/types.ts create mode 100644 packages/flags-client-node/test/client.test.ts create mode 100644 packages/flags-client-web/.eslintrc.json create mode 100644 packages/flags-client-web/README.md create mode 100644 packages/flags-client-web/jest.config.js create mode 100644 packages/flags-client-web/src/client.ts create mode 100644 packages/flags-client-web/src/fetcher.ts create mode 100644 packages/flags-client-web/src/react.tsx create mode 100644 packages/flags-client-web/src/types.ts create mode 100644 packages/flags-client-web/test/client.test.ts create mode 100644 packages/flags-client-web/test/react.test.tsx create mode 100644 packages/flags-client-web/test/setup.ts diff --git a/README.md b/README.md index 3c49bd3..1137e3a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ Togglekit gives you a minimal, modern alternative to heavy SaaS feature flag pla Planned components: -- ✅ TypeScript core evaluator +- ✅ TypeScript core evaluator (M2) +- ✅ Node SDK (M3) +- ✅ Web SDK with React hooks (M3) - ⬜ Rust core engine - ⬜ Node N-API binding - ⬜ WASM engine for web diff --git a/packages/flags-client-node/.eslintrc.json b/packages/flags-client-node/.eslintrc.json new file mode 100644 index 0000000..679b9aa --- /dev/null +++ b/packages/flags-client-node/.eslintrc.json @@ -0,0 +1,14 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*", "dist", "node_modules"], + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "parserOptions": { + "project": ["packages/flags-client-node/tsconfig.json"] + }, + "rules": {} + } + ] +} + diff --git a/packages/flags-client-node/README.md b/packages/flags-client-node/README.md new file mode 100644 index 0000000..e018e02 --- /dev/null +++ b/packages/flags-client-node/README.md @@ -0,0 +1,231 @@ +# @togglekit/flags-client-node + +Node.js SDK for Togglekit feature flags. + +## Installation + +```bash +npm install @togglekit/flags-client-node +# or +pnpm add @togglekit/flags-client-node +# or +yarn add @togglekit/flags-client-node +``` + +## Quick Start + +```typescript +import { createFlagClient } from '@togglekit/flags-client-node'; + +// Create client with API connection +const client = await createFlagClient({ + apiUrl: 'https://api.togglekit.com', + apiKey: 'your-api-key', + refreshInterval: 30000, // Refresh every 30 seconds +}); + +// Or use static configuration (for testing/development) +const client = await createFlagClient({ + staticConfig: { + 'dark-mode': { + key: 'dark-mode', + defaultValue: false, + rules: [ + { + conditions: [ + { attribute: 'plan', operator: 'eq', value: 'premium' } + ], + value: true + } + ] + } + } +}); + +// Evaluate a boolean flag +const result = client.getBool('dark-mode', { + userId: 'user-123', + attributes: { plan: 'premium' } +}); + +console.log(result.value); // true +console.log(result.reason); // 'rule_match' + +// Evaluate a variant flag +const layout = client.getVariant('pricing-layout', { + userId: 'user-123', + attributes: { country: 'US' } +}); + +console.log(layout.value); // 'layout-a', 'layout-b', or 'control' + +// Clean up when done +client.close(); +``` + +## API Reference + +### `createFlagClient(options)` + +Creates a new flag client instance. + +**Options:** + +- `apiUrl` (string, optional): API URL for fetching flag configuration +- `apiKey` (string, optional): API key for authentication +- `staticConfig` (FlagConfig, optional): Static configuration (alternative to API) +- `refreshInterval` (number, optional): Interval in milliseconds for refreshing config (default: 30000) +- `enableRefresh` (boolean, optional): Enable automatic config refresh (default: true) +- `timeout` (number, optional): Timeout for API requests in milliseconds (default: 5000) + +**Returns:** `Promise` + +### `FlagClient` + +#### `getBool(flagKey, context): EvaluationResult` + +Evaluates a boolean feature flag. + +**Parameters:** + +- `flagKey` (string): The flag key to evaluate +- `context` (Context): User context with optional userId and attributes + +**Returns:** Evaluation result with boolean value and reason + +#### `getVariant(flagKey, context): EvaluationResult` + +Evaluates a variant feature flag. + +**Parameters:** + +- `flagKey` (string): The flag key to evaluate +- `context` (Context): User context with optional userId and attributes + +**Returns:** Evaluation result with variant key (string) and reason + +#### `refresh(): Promise` + +Manually refreshes the configuration from the API. + +#### `close(): void` + +Closes the client and stops any refresh timers. + +#### `hasFlag(flagKey): boolean` + +Checks if a flag exists in the configuration. + +#### `getFlagKeys(): string[]` + +Returns all flag keys in the configuration. + +#### `getConfig(): FlagConfig` + +Returns the current flag configuration. + +#### `isReady(): boolean` + +Checks if the client is initialized and ready. + +## Context + +The context object is used to evaluate flags based on user attributes: + +```typescript +interface Context { + userId?: string; // For rollout bucketing + attributes: Record; // Custom attributes +} +``` + +**Examples:** + +```typescript +// Simple context +const result = client.getBool('feature', { + attributes: { plan: 'premium' } +}); + +// With userId for rollouts +const result = client.getBool('beta-feature', { + userId: 'user-123', + attributes: { country: 'US', plan: 'free' } +}); +``` + +## Evaluation Reasons + +The evaluation result includes a reason indicating why a particular value was returned: + +- `'default'`: No rules matched, using default value +- `'rule_match'`: A rule matched and was applied +- `'rollout'`: User is included in percentage rollout +- `'rollout_excluded'`: User is excluded from percentage rollout +- `'flag_not_found'`: Flag key doesn't exist in configuration +- `'error'`: Error during evaluation + +## Advanced Usage + +### Custom Fetch Function + +```typescript +import fetch from 'node-fetch'; + +const client = await createFlagClient({ + apiUrl: 'https://api.togglekit.com', + apiKey: 'your-api-key', + fetchFn: fetch as any +}); +``` + +### Disable Automatic Refresh + +```typescript +const client = await createFlagClient({ + apiUrl: 'https://api.togglekit.com', + apiKey: 'your-api-key', + enableRefresh: false +}); + +// Manually refresh when needed +await client.refresh(); +``` + +### Error Handling + +```typescript +try { + const client = await createFlagClient({ + apiUrl: 'https://api.togglekit.com', + apiKey: 'invalid-key' + }); +} catch (error) { + console.error('Failed to initialize client:', error); +} + +// Evaluation never throws - returns safe defaults +const result = client.getBool('non-existent', { attributes: {} }); +console.log(result.value); // false +console.log(result.reason); // 'flag_not_found' +``` + +## TypeScript + +This package is written in TypeScript and includes full type definitions. + +```typescript +import { + createFlagClient, + FlagClient, + FlagClientOptions, + Context, + EvaluationResult, + FlagConfig +} from '@togglekit/flags-client-node'; +``` + +## License + +MIT + diff --git a/packages/flags-client-node/jest.config.js b/packages/flags-client-node/jest.config.js new file mode 100644 index 0000000..7d55820 --- /dev/null +++ b/packages/flags-client-node/jest.config.js @@ -0,0 +1,23 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/**/index.ts', + ], + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + moduleNameMapper: { + '^@togglekit/flags-core-ts$': '/../flags-core-ts/src', + }, +}; + diff --git a/packages/flags-client-node/package.json b/packages/flags-client-node/package.json index ca37dd8..df09f84 100644 --- a/packages/flags-client-node/package.json +++ b/packages/flags-client-node/package.json @@ -6,14 +6,24 @@ "types": "dist/index.d.ts", "scripts": { "build": "tsc", - "test": "echo 'Tests not yet implemented'", - "lint": "eslint src" + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "lint": "eslint src --ext .ts" }, "dependencies": { "@togglekit/flags-core-ts": "workspace:*" }, "devDependencies": { + "@types/jest": "^29.5.11", + "@types/node": "^20.11.0", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", "typescript": "^5.3.3" } } + diff --git a/packages/flags-client-node/src/client.ts b/packages/flags-client-node/src/client.ts new file mode 100644 index 0000000..c3933a9 --- /dev/null +++ b/packages/flags-client-node/src/client.ts @@ -0,0 +1,256 @@ +/** + * Node.js feature flag client + * Provides an ergonomic API for evaluating feature flags with config caching and refresh + */ + +import { Evaluator } from '@togglekit/flags-core-ts'; +import { FlagClientOptions, Context, EvaluationResult, FlagConfig } from './types'; +import { fetchConfig } from './fetcher'; + +/** + * Feature flag client for Node.js + * + * Manages flag configuration, caching, and periodic refresh. + * Evaluates flags using the TypeScript core evaluator. + */ +export class FlagClient { + private evaluator: Evaluator; + private options: Required>; + private apiUrl?: string; + private apiKey?: string; + private refreshTimer?: NodeJS.Timeout; + private isInitialized = false; + private isClosed = false; + + /** + * Create a new flag client + * Use the `createFlagClient` factory function instead of calling this directly + */ + constructor(config: FlagConfig, options: FlagClientOptions) { + this.evaluator = new Evaluator(config); + this.apiUrl = options.apiUrl; + this.apiKey = options.apiKey; + this.options = { + refreshInterval: options.refreshInterval ?? 30000, + enableRefresh: options.enableRefresh ?? true, + timeout: options.timeout ?? 5000, + fetchFn: options.fetchFn ?? fetch, + }; + this.isInitialized = true; + } + + /** + * Start automatic config refresh if enabled and API credentials provided + */ + private startRefresh(): void { + if (!this.options.enableRefresh || !this.apiUrl || !this.apiKey) { + return; + } + + this.refreshTimer = setInterval(async () => { + try { + await this.refresh(); + } catch (error) { + // Log error but don't throw - continue using cached config + console.error('Failed to refresh flag config:', error); + } + }, this.options.refreshInterval); + + // Don't keep the process alive just for this timer + if (this.refreshTimer.unref) { + this.refreshTimer.unref(); + } + } + + /** + * Manually refresh the configuration from the API + * + * @throws Error if API credentials not provided or fetch fails + */ + async refresh(): Promise { + if (this.isClosed) { + throw new Error('Client is closed'); + } + + if (!this.apiUrl || !this.apiKey) { + throw new Error('Cannot refresh: API URL and key required'); + } + + const newConfig = await fetchConfig( + this.apiUrl, + this.apiKey, + this.options.timeout, + this.options.fetchFn + ); + + // Create new evaluator with updated config + this.evaluator = new Evaluator(newConfig); + } + + /** + * Evaluate a boolean feature flag + * + * @param flagKey - The flag key to evaluate + * @param context - User context with attributes + * @returns Evaluation result with boolean value and reason + * + * @example + * ```typescript + * const result = client.getBool('dark-mode', { + * userId: 'user-123', + * attributes: { plan: 'premium' } + * }); + * + * if (result.value) { + * // Enable dark mode + * } + * ``` + */ + getBool(flagKey: string, context: Context): EvaluationResult { + if (this.isClosed) { + throw new Error('Client is closed'); + } + + return this.evaluator.evalBool(flagKey, context); + } + + /** + * Evaluate a variant feature flag + * + * @param flagKey - The flag key to evaluate + * @param context - User context with attributes + * @returns Evaluation result with variant key (string) and reason + * + * @example + * ```typescript + * const result = client.getVariant('pricing-page', { + * userId: 'user-123', + * attributes: { country: 'US' } + * }); + * + * switch (result.value) { + * case 'layout-a': + * // Show layout A + * break; + * case 'layout-b': + * // Show layout B + * break; + * default: + * // Show default layout + * } + * ``` + */ + getVariant(flagKey: string, context: Context): EvaluationResult { + if (this.isClosed) { + throw new Error('Client is closed'); + } + + return this.evaluator.evalVariant(flagKey, context); + } + + /** + * Get the current configuration + * Useful for debugging or serialization + */ + getConfig(): FlagConfig { + return this.evaluator.getConfig(); + } + + /** + * Check if a flag exists in the configuration + */ + hasFlag(flagKey: string): boolean { + return this.evaluator.hasFlag(flagKey); + } + + /** + * Get all flag keys in the configuration + */ + getFlagKeys(): string[] { + return this.evaluator.getFlagKeys(); + } + + /** + * Close the client and stop any refresh timers + * The client cannot be used after calling this method + */ + close(): void { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = undefined; + } + this.isClosed = true; + } + + /** + * Check if the client is initialized and ready to use + */ + isReady(): boolean { + return this.isInitialized && !this.isClosed; + } +} + +/** + * Create a new feature flag client + * + * @param options - Client configuration options + * @returns Promise resolving to initialized flag client + * + * @example + * ```typescript + * // With API URL (when API is available) + * const client = await createFlagClient({ + * apiUrl: 'https://api.togglekit.com', + * apiKey: 'your-api-key', + * refreshInterval: 60000, // 60 seconds + * }); + * + * // With static config (for testing or when API not available) + * const client = await createFlagClient({ + * staticConfig: { + * 'my-flag': { + * key: 'my-flag', + * defaultValue: true, + * } + * } + * }); + * + * // Use the client + * const result = client.getBool('my-flag', { attributes: {} }); + * console.log(result.value); + * + * // Cleanup when done + * client.close(); + * ``` + */ +export async function createFlagClient( + options: FlagClientOptions +): Promise { + let config: FlagConfig; + + // Either use static config or fetch from API + if (options.staticConfig) { + config = options.staticConfig; + } else if (options.apiUrl && options.apiKey) { + config = await fetchConfig( + options.apiUrl, + options.apiKey, + options.timeout, + options.fetchFn + ); + } else { + throw new Error( + 'Either staticConfig or both apiUrl and apiKey must be provided' + ); + } + + const client = new FlagClient(config, options); + + // Start automatic refresh if configured + if (options.apiUrl && options.apiKey) { + (client as any).startRefresh(); + } + + return client; +} + diff --git a/packages/flags-client-node/src/fetcher.ts b/packages/flags-client-node/src/fetcher.ts new file mode 100644 index 0000000..e1f94d8 --- /dev/null +++ b/packages/flags-client-node/src/fetcher.ts @@ -0,0 +1,61 @@ +/** + * Configuration fetcher for Node.js + * Handles HTTP requests to fetch flag configuration from API + */ + +import { FlagConfig } from '@togglekit/flags-core-ts'; + +/** + * Fetch configuration from the API + * + * @param apiUrl - Base URL of the API + * @param apiKey - API key for authentication + * @param timeout - Request timeout in milliseconds + * @param fetchFn - Optional custom fetch function + * @returns Promise resolving to flag configuration + * + * @throws Error if fetch fails or returns non-200 status + */ +export async function fetchConfig( + apiUrl: string, + apiKey: string, + timeout = 5000, + fetchFn: typeof fetch = fetch +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetchFn(`${apiUrl}/v1/config`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error( + `Failed to fetch config: ${response.status} ${response.statusText}` + ); + } + + const config = await response.json() as FlagConfig; + return config; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error) { + if (error.name === 'AbortError') { + throw new Error(`Config fetch timeout after ${timeout}ms`); + } + throw error; + } + + throw new Error('Unknown error fetching config'); + } +} + diff --git a/packages/flags-client-node/src/index.ts b/packages/flags-client-node/src/index.ts index b39f96c..6634651 100644 --- a/packages/flags-client-node/src/index.ts +++ b/packages/flags-client-node/src/index.ts @@ -1 +1,44 @@ -// Placeholder for Node SDK implementation +/** + * @togglekit/flags-client-node + * + * Node.js SDK for Togglekit feature flags + * + * @example + * ```typescript + * import { createFlagClient } from '@togglekit/flags-client-node'; + * + * const client = await createFlagClient({ + * apiUrl: 'https://api.togglekit.com', + * apiKey: 'your-api-key', + * }); + * + * const result = client.getBool('new-feature', { + * userId: 'user-123', + * attributes: { plan: 'premium' } + * }); + * + * console.log(result.value); // true or false + * console.log(result.reason); // 'rule_match', 'default', etc. + * + * client.close(); + * ``` + */ + +export { createFlagClient, FlagClient } from './client'; +export { fetchConfig } from './fetcher'; +export type { + FlagClientOptions, + FlagConfig, + Context, + EvaluationResult, +} from './types'; + +// Re-export useful types from core +export type { + Flag, + Rule, + Condition, + Variant, + ConditionOperator, + EvaluationReason, +} from '@togglekit/flags-core-ts'; diff --git a/packages/flags-client-node/src/types.ts b/packages/flags-client-node/src/types.ts new file mode 100644 index 0000000..8322b4d --- /dev/null +++ b/packages/flags-client-node/src/types.ts @@ -0,0 +1,56 @@ +/** + * Types for the Node.js feature flag client + */ + +import { FlagConfig, Context, EvaluationResult } from '@togglekit/flags-core-ts'; + +/** + * Options for creating a flag client + */ +export interface FlagClientOptions { + /** + * API URL for fetching flag configuration + * @example 'https://api.togglekit.com' + */ + apiUrl?: string; + + /** + * API key for authentication + */ + apiKey?: string; + + /** + * Interval in milliseconds for refreshing config + * @default 30000 (30 seconds) + */ + refreshInterval?: number; + + /** + * Static configuration (alternative to fetching from API) + * Useful for testing or when API is not available yet + */ + staticConfig?: FlagConfig; + + /** + * Enable automatic refresh of configuration + * @default true + */ + enableRefresh?: boolean; + + /** + * Timeout in milliseconds for API requests + * @default 5000 (5 seconds) + */ + timeout?: number; + + /** + * Custom fetch function (for testing or custom HTTP clients) + */ + fetchFn?: typeof fetch; +} + +/** + * Export core types from flags-core-ts for convenience + */ +export { FlagConfig, Context, EvaluationResult }; + diff --git a/packages/flags-client-node/test/client.test.ts b/packages/flags-client-node/test/client.test.ts new file mode 100644 index 0000000..e74f5aa --- /dev/null +++ b/packages/flags-client-node/test/client.test.ts @@ -0,0 +1,459 @@ +/** + * Tests for the Node.js flag client + */ + +import { createFlagClient, FlagClient } from '../src/client'; +import { FlagConfig } from '../src/types'; + +// Mock config for testing +const mockConfig: FlagConfig = { + 'simple-flag': { + key: 'simple-flag', + defaultValue: true, + }, + 'premium-feature': { + key: 'premium-feature', + defaultValue: false, + rules: [ + { + conditions: [ + { attribute: 'plan', operator: 'eq', value: 'premium' }, + ], + value: true, + }, + ], + }, + 'layout-variant': { + key: 'layout-variant', + defaultValue: 'control', + variants: [ + { key: 'control' }, + { key: 'variant-a' }, + { key: 'variant-b' }, + ], + rules: [ + { + conditions: [ + { attribute: 'country', operator: 'eq', value: 'US' }, + ], + variant: 'variant-a', + }, + ], + }, + 'rollout-flag': { + key: 'rollout-flag', + defaultValue: false, + rules: [ + { + conditions: [ + { attribute: 'enrolled', operator: 'eq', value: true }, + ], + percentage: 50, + value: true, + }, + ], + }, +}; + +describe('createFlagClient', () => { + describe('with static config', () => { + it('should create client with static configuration', async () => { + const client = await createFlagClient({ + staticConfig: mockConfig, + }); + + expect(client).toBeInstanceOf(FlagClient); + expect(client.isReady()).toBe(true); + client.close(); + }); + + it('should throw error when no config or API credentials provided', async () => { + await expect(createFlagClient({})).rejects.toThrow( + 'Either staticConfig or both apiUrl and apiKey must be provided' + ); + }); + }); + + describe('with API fetch', () => { + it('should fetch config from API on initialization', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => mockConfig, + }); + + const client = await createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + enableRefresh: false, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/v1/config', + expect.objectContaining({ + method: 'GET', + headers: { + 'Authorization': 'Bearer test-key', + 'Content-Type': 'application/json', + }, + }) + ); + + expect(client.isReady()).toBe(true); + client.close(); + }); + + it('should throw error when API fetch fails', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + await expect( + createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + }) + ).rejects.toThrow('Failed to fetch config: 404 Not Found'); + }); + + it('should handle network errors', async () => { + const mockFetch = jest.fn().mockRejectedValue(new Error('Network error')); + + await expect( + createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + }) + ).rejects.toThrow('Network error'); + }); + + it('should handle timeout', async () => { + const mockFetch = jest.fn().mockImplementation( + () => + new Promise((_, reject) => { + setTimeout(() => { + const error = new Error('Timeout'); + (error as any).name = 'AbortError'; + reject(error); + }, 100); + }) + ); + + await expect( + createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + timeout: 50, + }) + ).rejects.toThrow('Config fetch timeout after 50ms'); + }); + }); +}); + +describe('FlagClient', () => { + let client: FlagClient; + + beforeEach(async () => { + client = await createFlagClient({ + staticConfig: mockConfig, + enableRefresh: false, + }); + }); + + afterEach(() => { + client.close(); + }); + + describe('getBool', () => { + it('should evaluate simple boolean flag', () => { + const result = client.getBool('simple-flag', { attributes: {} }); + + expect(result.value).toBe(true); + expect(result.reason).toBe('default'); + }); + + it('should evaluate flag with rule match', () => { + const result = client.getBool('premium-feature', { + attributes: { plan: 'premium' }, + }); + + expect(result.value).toBe(true); + expect(result.reason).toBe('rule_match'); + expect(result.metadata?.ruleIndex).toBe(0); + }); + + it('should return default when rule does not match', () => { + const result = client.getBool('premium-feature', { + attributes: { plan: 'free' }, + }); + + expect(result.value).toBe(false); + expect(result.reason).toBe('default'); + }); + + it('should return false for non-existent flag', () => { + const result = client.getBool('non-existent', { attributes: {} }); + + expect(result.value).toBe(false); + expect(result.reason).toBe('flag_not_found'); + expect(result.metadata?.error).toContain('not found'); + }); + + it('should handle rollout flags with userId', () => { + // Test that rollout produces deterministic results + const result1 = client.getBool('rollout-flag', { + userId: 'user-1', + attributes: { enrolled: true }, + }); + const result2 = client.getBool('rollout-flag', { + userId: 'user-1', + attributes: { enrolled: true }, + }); + const result3 = client.getBool('rollout-flag', { + userId: 'user-2', + attributes: { enrolled: true }, + }); + + // Same user gets same result + expect(result1.value).toBe(result2.value); + expect(result1.reason).toBe(result2.reason); + + // Results should be either rollout or rollout_excluded + expect(result1.reason).toMatch(/^(rollout|rollout_excluded)$/); + expect(result3.reason).toMatch(/^(rollout|rollout_excluded)$/); + + // Bucket should be provided + expect(typeof result1.metadata?.bucket).toBe('number'); + }); + + it('should throw error when client is closed', () => { + client.close(); + + expect(() => { + client.getBool('simple-flag', { attributes: {} }); + }).toThrow('Client is closed'); + }); + }); + + describe('getVariant', () => { + it('should evaluate variant flag with default', () => { + const result = client.getVariant('layout-variant', { + attributes: { country: 'UK' }, + }); + + expect(result.value).toBe('control'); + expect(result.reason).toBe('default'); + }); + + it('should evaluate variant flag with rule match', () => { + const result = client.getVariant('layout-variant', { + attributes: { country: 'US' }, + }); + + expect(result.value).toBe('variant-a'); + expect(result.reason).toBe('rule_match'); + expect(result.metadata?.ruleIndex).toBe(0); + }); + + it('should return default variant for non-existent flag', () => { + const result = client.getVariant('non-existent', { attributes: {} }); + + expect(result.value).toBe('default'); + expect(result.reason).toBe('flag_not_found'); + }); + }); + + describe('refresh', () => { + it('should update config when refreshed', async () => { + let fetchCount = 0; + const mockFetch = jest.fn().mockImplementation(() => { + fetchCount++; + if (fetchCount === 1) { + // Initial config without new-flag + return Promise.resolve({ + ok: true, + json: async () => mockConfig, + }); + } else { + // Updated config with new-flag + return Promise.resolve({ + ok: true, + json: async () => ({ + ...mockConfig, + 'new-flag': { + key: 'new-flag', + defaultValue: true, + }, + }), + }); + } + }); + + const refreshableClient = await createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + enableRefresh: false, + }); + + // Initially doesn't have new-flag + expect(refreshableClient.hasFlag('new-flag')).toBe(false); + + // Refresh with new config + await refreshableClient.refresh(); + + // Now has new-flag + expect(refreshableClient.hasFlag('new-flag')).toBe(true); + + refreshableClient.close(); + }); + + it('should throw error when refreshing without API credentials', async () => { + await expect(client.refresh()).rejects.toThrow( + 'Cannot refresh: API URL and key required' + ); + }); + + it('should throw error when refreshing closed client', async () => { + client.close(); + + await expect(client.refresh()).rejects.toThrow('Client is closed'); + }); + }); + + describe('utility methods', () => { + it('should get current config', () => { + const config = client.getConfig(); + + expect(config).toEqual(mockConfig); + }); + + it('should check if flag exists', () => { + expect(client.hasFlag('simple-flag')).toBe(true); + expect(client.hasFlag('non-existent')).toBe(false); + }); + + it('should get all flag keys', () => { + const keys = client.getFlagKeys(); + + expect(keys).toHaveLength(4); + expect(keys).toContain('simple-flag'); + expect(keys).toContain('premium-feature'); + expect(keys).toContain('layout-variant'); + expect(keys).toContain('rollout-flag'); + }); + + it('should report ready status', () => { + expect(client.isReady()).toBe(true); + + client.close(); + + expect(client.isReady()).toBe(false); + }); + }); + + describe('automatic refresh', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should automatically refresh config at interval', async () => { + let callCount = 0; + const mockFetch = jest.fn().mockImplementation(() => { + callCount++; + const dynamicConfig = { ...mockConfig }; + const flagKey = `flag-${callCount}`; + dynamicConfig[flagKey] = { + key: flagKey, + defaultValue: true, + }; + return Promise.resolve({ + ok: true, + json: async () => dynamicConfig, + }); + }); + + const refreshClient = await createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + enableRefresh: true, + refreshInterval: 1000, + }); + + // Initial fetch + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Wait for first refresh + jest.advanceTimersByTime(1000); + await Promise.resolve(); // Let promises resolve + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Wait for second refresh + jest.advanceTimersByTime(1000); + await Promise.resolve(); + expect(mockFetch).toHaveBeenCalledTimes(3); + + refreshClient.close(); + }); + + it('should not refresh when enableRefresh is false', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => mockConfig, + }); + + const noRefreshClient = await createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + enableRefresh: false, + }); + + // Only initial fetch + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Wait and verify no additional fetches + jest.advanceTimersByTime(60000); + await Promise.resolve(); + expect(mockFetch).toHaveBeenCalledTimes(1); + + noRefreshClient.close(); + }); + + it('should stop refreshing after close', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => mockConfig, + }); + + const refreshClient = await createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + enableRefresh: true, + refreshInterval: 1000, + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Close client + refreshClient.close(); + + // Advance time and verify no more fetches + jest.advanceTimersByTime(5000); + await Promise.resolve(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); +}); + diff --git a/packages/flags-client-web/.eslintrc.json b/packages/flags-client-web/.eslintrc.json new file mode 100644 index 0000000..0d5049d --- /dev/null +++ b/packages/flags-client-web/.eslintrc.json @@ -0,0 +1,14 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*", "dist", "node_modules"], + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "parserOptions": { + "project": ["packages/flags-client-web/tsconfig.json"] + }, + "rules": {} + } + ] +} + diff --git a/packages/flags-client-web/README.md b/packages/flags-client-web/README.md new file mode 100644 index 0000000..2bee86b --- /dev/null +++ b/packages/flags-client-web/README.md @@ -0,0 +1,386 @@ +# @togglekit/flags-client-web + +Web/Browser SDK for Togglekit feature flags with React support. + +## Installation + +```bash +npm install @togglekit/flags-client-web +# or +pnpm add @togglekit/flags-client-web +# or +yarn add @togglekit/flags-client-web +``` + +## Quick Start + +### Basic Usage + +```typescript +import { createFlagClient } from '@togglekit/flags-client-web'; + +// Bootstrap mode (synchronous, SSR-friendly) +const client = createFlagClient({ + bootstrapConfig: window.__FLAGS_CONFIG__ +}); + +// Or fetch from API (async) +const client = await createFlagClient({ + apiUrl: 'https://api.togglekit.com', + apiKey: 'your-api-key' +}); + +// Evaluate flags +const result = client.getBool('dark-mode', { + attributes: { theme: 'auto' } +}); + +if (result.value) { + // Enable dark mode +} +``` + +### React Usage + +```typescript +import { createFlagClient } from '@togglekit/flags-client-web'; +import { FlagProvider, useFlag, useFlagVariant } from '@togglekit/flags-client-web/react'; + +// Create client +const client = createFlagClient({ + bootstrapConfig: window.__FLAGS_CONFIG__ +}); + +// Wrap your app +function App() { + return ( + + + + ); +} + +// Use hooks in components +function MyComponent() { + const darkMode = useFlag('dark-mode', { + attributes: { theme: 'auto' } + }); + + const layout = useFlagVariant('pricing-layout', { + attributes: { country: 'US' } + }); + + return ( +
+ {layout.value === 'layout-a' ? : } +
+ ); +} +``` + +## API Reference + +### `createFlagClient(options)` + +Creates a new web flag client instance. + +**Options:** + +- `bootstrapConfig` (FlagConfig, optional): Inline configuration for synchronous initialization +- `apiUrl` (string, optional): API URL for fetching flag configuration +- `apiKey` (string, optional): API key for authentication +- `enableRefresh` (boolean, optional): Enable automatic background refresh (default: false) +- `refreshInterval` (number, optional): Interval in milliseconds for refreshing config (default: 60000) +- `timeout` (number, optional): Timeout for API requests in milliseconds (default: 5000) + +**Returns:** `WebFlagClient` (if bootstrap) or `Promise` (if API fetch) + +### `WebFlagClient` + +#### `getBool(flagKey, context): EvaluationResult` + +Evaluates a boolean feature flag. + +#### `getVariant(flagKey, context): EvaluationResult` + +Evaluates a variant feature flag. + +#### `updateConfig(config): void` + +Manually updates the client configuration. Triggers re-renders for React components. + +#### `onConfigUpdate(listener): () => void` + +Subscribes to config update events. Returns an unsubscribe function. + +```typescript +const unsubscribe = client.onConfigUpdate(() => { + console.log('Config updated!'); +}); + +// Later... +unsubscribe(); +``` + +#### `refresh(): Promise` + +Manually refreshes configuration from the API. + +#### `close(): void` + +Closes the client and stops any refresh timers. + +#### `hasFlag(flagKey): boolean` + +Checks if a flag exists. + +#### `getFlagKeys(): string[]` + +Returns all flag keys. + +#### `getConfig(): FlagConfig` + +Returns the current configuration. + +#### `isReady(): boolean` + +Checks if the client is initialized. + +## React Hooks + +### `useFlag(flagKey, context?)` + +Hook to evaluate a boolean feature flag. + +```typescript +function MyComponent() { + const darkMode = useFlag('dark-mode', { + attributes: { theme: 'auto' } + }); + + return
{darkMode.value ? 'Dark' : 'Light'}
; +} +``` + +### `useFlagVariant(flagKey, context?)` + +Hook to evaluate a variant feature flag. + +```typescript +function PricingPage() { + const layout = useFlagVariant('pricing-layout', { + userId: 'user-123', + attributes: { country: 'US' } + }); + + switch (layout.value) { + case 'layout-a': + return ; + case 'layout-b': + return ; + default: + return ; + } +} +``` + +### `useFlagKeys()` + +Hook to get all flag keys (useful for debugging). + +```typescript +function FlagList() { + const flagKeys = useFlagKeys(); + return ( +
    + {flagKeys.map(key =>
  • {key}
  • )} +
+ ); +} +``` + +### `useFlagExists(flagKey)` + +Hook to check if a flag exists. + +```typescript +function FeatureGate() { + const hasNewFeature = useFlagExists('new-feature'); + + if (!hasNewFeature) { + return null; + } + + return ; +} +``` + +## Server-Side Rendering (SSR) + +### Next.js Example + +```typescript +// pages/_app.tsx +import { createFlagClient } from '@togglekit/flags-client-web'; +import { FlagProvider } from '@togglekit/flags-client-web/react'; + +function MyApp({ Component, pageProps }) { + // Create client with bootstrapped config from getServerSideProps + const client = createFlagClient({ + bootstrapConfig: pageProps.flagsConfig + }); + + return ( + + + + ); +} + +// pages/index.tsx +export async function getServerSideProps() { + // Fetch flags on server + const response = await fetch('https://api.togglekit.com/v1/config', { + headers: { + 'Authorization': `Bearer ${process.env.TOGGLEKIT_API_KEY}` + } + }); + + const flagsConfig = await response.json(); + + return { + props: { + flagsConfig + } + }; +} +``` + +## Context + +```typescript +interface Context { + userId?: string; // For rollout bucketing + attributes: Record; // Custom attributes +} +``` + +Context is optional - if not provided, only default values and rules without conditions will apply. + +## Bootstrap vs API Fetch + +### Bootstrap Mode (Recommended for SSR) + +- Synchronous initialization +- Config loaded inline (from SSR, local storage, etc.) +- No network request on client initialization +- Optionally enable background refresh + +```typescript +const client = createFlagClient({ + bootstrapConfig: window.__FLAGS_CONFIG__, + // Optionally enable background refresh + apiUrl: 'https://api.togglekit.com', + apiKey: 'your-api-key', + enableRefresh: true, + refreshInterval: 120000 // Refresh every 2 minutes +}); +``` + +### API Fetch Mode + +- Asynchronous initialization +- Fetches config on client load +- Requires API credentials + +```typescript +const client = await createFlagClient({ + apiUrl: 'https://api.togglekit.com', + apiKey: 'your-api-key', + enableRefresh: true +}); +``` + +## Advanced Usage + +### Dynamic Config Updates + +```typescript +// Subscribe to updates +client.onConfigUpdate(() => { + console.log('Flags updated!'); + // React components using hooks will automatically re-render +}); + +// Manually update config +client.updateConfig(newConfig); + +// Or refresh from API +await client.refresh(); +``` + +### Without React + +The client works without React: + +```typescript +const client = createFlagClient({ + bootstrapConfig: myConfig +}); + +// Listen for updates +client.onConfigUpdate(() => { + updateUI(); +}); + +function updateUI() { + const result = client.getBool('dark-mode', { attributes: {} }); + document.body.classList.toggle('dark', result.value); +} +``` + +## TypeScript + +Full TypeScript support included: + +```typescript +import { + createFlagClient, + WebFlagClient, + WebFlagClientOptions, + Context, + EvaluationResult, + FlagConfig +} from '@togglekit/flags-client-web'; + +import { + FlagProvider, + FlagProviderProps, + useFlag, + useFlagVariant, + useFlagKeys, + useFlagExists +} from '@togglekit/flags-client-web/react'; +``` + +## React Peer Dependency + +React is an optional peer dependency. If you're not using React hooks, you don't need to install React. + +```json +{ + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } +} +``` + +## License + +MIT + diff --git a/packages/flags-client-web/jest.config.js b/packages/flags-client-web/jest.config.js new file mode 100644 index 0000000..9a4287a --- /dev/null +++ b/packages/flags-client-web/jest.config.js @@ -0,0 +1,32 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + roots: ['/test'], + testMatch: ['**/*.test.ts', '**/*.test.tsx'], + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.d.ts', + '!src/**/index.ts', + ], + coverageThreshold: { + global: { + branches: 75, + functions: 75, + lines: 75, + statements: 75, + }, + }, + moduleNameMapper: { + '^@togglekit/flags-core-ts$': '/../flags-core-ts/src', + }, + globals: { + 'ts-jest': { + tsconfig: { + jsx: 'react', + }, + }, + fetch: global.fetch, + }, + setupFilesAfterEnv: ['/test/setup.ts'], +}; + diff --git a/packages/flags-client-web/package.json b/packages/flags-client-web/package.json index 1a66757..ffd4379 100644 --- a/packages/flags-client-web/package.json +++ b/packages/flags-client-web/package.json @@ -4,15 +4,49 @@ "description": "Web/Browser SDK for Togglekit feature flags", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./react": { + "types": "./dist/react.d.ts", + "default": "./dist/react.js" + } + }, "scripts": { "build": "tsc", - "test": "echo 'Tests not yet implemented'", - "lint": "eslint src" + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "lint": "eslint src --ext .ts,.tsx" }, "dependencies": { "@togglekit/flags-core-ts": "workspace:*" }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, "devDependencies": { + "@testing-library/jest-dom": "^6.1.5", + "@testing-library/react": "^14.1.2", + "@testing-library/react-hooks": "^8.0.1", + "@types/jest": "^29.5.11", + "@types/node": "^20.11.0", + "@types/react": "^18.2.48", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "ts-jest": "^29.1.1", "typescript": "^5.3.3" } } diff --git a/packages/flags-client-web/src/client.ts b/packages/flags-client-web/src/client.ts new file mode 100644 index 0000000..ece348b --- /dev/null +++ b/packages/flags-client-web/src/client.ts @@ -0,0 +1,265 @@ +/** + * Web/Browser feature flag client + */ + +import { Evaluator } from '@togglekit/flags-core-ts'; +import { WebFlagClientOptions, Context, EvaluationResult, FlagConfig } from './types'; +import { fetchConfig } from './fetcher'; + +/** + * Feature flag client for web/browser environments + * + * Supports bootstrap mode for SSR and optional background refresh + */ +export class WebFlagClient { + private evaluator: Evaluator; + private options: Required>; + private apiUrl?: string; + private apiKey?: string; + private refreshTimer?: number; + private isInitialized = false; + private isClosed = false; + private listeners: Array<() => void> = []; + + /** + * Create a new web flag client + * Use the `createFlagClient` factory function instead + */ + constructor(config: FlagConfig, options: WebFlagClientOptions) { + this.evaluator = new Evaluator(config); + this.apiUrl = options.apiUrl; + this.apiKey = options.apiKey; + this.options = { + enableRefresh: options.enableRefresh ?? false, + refreshInterval: options.refreshInterval ?? 60000, + timeout: options.timeout ?? 5000, + fetchFn: options.fetchFn ?? fetch, + }; + this.isInitialized = true; + } + + /** + * Start automatic config refresh if enabled and API credentials provided + */ + private startRefresh(): void { + if (!this.options.enableRefresh || !this.apiUrl || !this.apiKey) { + return; + } + + this.refreshTimer = window.setInterval(async () => { + try { + await this.refresh(); + } catch (error) { + // Log error but don't throw - continue using cached config + console.error('Failed to refresh flag config:', error); + } + }, this.options.refreshInterval); + } + + /** + * Manually refresh the configuration from the API + * + * @throws Error if API credentials not provided or fetch fails + */ + async refresh(): Promise { + if (this.isClosed) { + throw new Error('Client is closed'); + } + + if (!this.apiUrl || !this.apiKey) { + throw new Error('Cannot refresh: API URL and key required'); + } + + const newConfig = await fetchConfig( + this.apiUrl, + this.apiKey, + this.options.timeout, + this.options.fetchFn + ); + + // Create new evaluator with updated config + this.evaluator = new Evaluator(newConfig); + + // Notify listeners of config update + this.notifyListeners(); + } + + /** + * Manually update the client configuration + * Useful for SSR scenarios where config is fetched separately + * + * @param config - New flag configuration + */ + updateConfig(config: FlagConfig): void { + if (this.isClosed) { + throw new Error('Client is closed'); + } + + this.evaluator = new Evaluator(config); + this.notifyListeners(); + } + + /** + * Subscribe to config update events + * Returns unsubscribe function + * + * @param listener - Callback to invoke when config updates + * @returns Function to unsubscribe + */ + onConfigUpdate(listener: () => void): () => void { + this.listeners.push(listener); + return () => { + const index = this.listeners.indexOf(listener); + if (index > -1) { + this.listeners.splice(index, 1); + } + }; + } + + /** + * Notify all listeners of config update + */ + private notifyListeners(): void { + this.listeners.forEach(listener => { + try { + listener(); + } catch (error) { + console.error('Error in config update listener:', error); + } + }); + } + + /** + * Evaluate a boolean feature flag + * + * @param flagKey - The flag key to evaluate + * @param context - User context with attributes + * @returns Evaluation result with boolean value and reason + */ + getBool(flagKey: string, context: Context): EvaluationResult { + if (this.isClosed) { + throw new Error('Client is closed'); + } + + return this.evaluator.evalBool(flagKey, context); + } + + /** + * Evaluate a variant feature flag + * + * @param flagKey - The flag key to evaluate + * @param context - User context with attributes + * @returns Evaluation result with variant key (string) and reason + */ + getVariant(flagKey: string, context: Context): EvaluationResult { + if (this.isClosed) { + throw new Error('Client is closed'); + } + + return this.evaluator.evalVariant(flagKey, context); + } + + /** + * Get the current configuration + */ + getConfig(): FlagConfig { + return this.evaluator.getConfig(); + } + + /** + * Check if a flag exists + */ + hasFlag(flagKey: string): boolean { + return this.evaluator.hasFlag(flagKey); + } + + /** + * Get all flag keys + */ + getFlagKeys(): string[] { + return this.evaluator.getFlagKeys(); + } + + /** + * Close the client and stop any refresh timers + */ + close(): void { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = undefined; + } + this.listeners = []; + this.isClosed = true; + } + + /** + * Check if the client is ready + */ + isReady(): boolean { + return this.isInitialized && !this.isClosed; + } +} + +/** + * Create a new web flag client + * + * @param options - Client configuration options + * @returns Initialized flag client (synchronous if bootstrap provided) + * + * @example + * ```typescript + * // Bootstrap mode (SSR) + * const client = createFlagClient({ + * bootstrapConfig: window.__FLAGS_CONFIG__, + * }); + * + * // With API fetch + * const client = await createFlagClient({ + * apiUrl: 'https://api.togglekit.com', + * apiKey: 'your-api-key', + * enableRefresh: true, + * }); + * + * // Use the client + * const result = client.getBool('dark-mode', { attributes: {} }); + * ``` + */ +export function createFlagClient( + options: WebFlagClientOptions +): WebFlagClient | Promise { + // Synchronous bootstrap mode + if (options.bootstrapConfig) { + const client = new WebFlagClient(options.bootstrapConfig, options); + + // Start refresh in background if configured + if (options.apiUrl && options.apiKey && options.enableRefresh) { + (client as any).startRefresh(); + } + + return client; + } + + // Async fetch mode + if (options.apiUrl && options.apiKey) { + return fetchConfig( + options.apiUrl, + options.apiKey, + options.timeout, + options.fetchFn + ).then(config => { + const client = new WebFlagClient(config, options); + + // Start refresh if enabled + if (options.enableRefresh) { + (client as any).startRefresh(); + } + + return client; + }); + } + + throw new Error( + 'Either bootstrapConfig or both apiUrl and apiKey must be provided' + ); +} + diff --git a/packages/flags-client-web/src/fetcher.ts b/packages/flags-client-web/src/fetcher.ts new file mode 100644 index 0000000..f336c2e --- /dev/null +++ b/packages/flags-client-web/src/fetcher.ts @@ -0,0 +1,60 @@ +/** + * Configuration fetcher for browser environments + */ + +import { FlagConfig } from '@togglekit/flags-core-ts'; + +/** + * Fetch configuration from the API (browser version) + * + * @param apiUrl - Base URL of the API + * @param apiKey - API key for authentication + * @param timeout - Request timeout in milliseconds + * @param fetchFn - Optional custom fetch function + * @returns Promise resolving to flag configuration + * + * @throws Error if fetch fails or returns non-200 status + */ +export async function fetchConfig( + apiUrl: string, + apiKey: string, + timeout = 5000, + fetchFn: typeof fetch = fetch +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetchFn(`${apiUrl}/v1/config`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error( + `Failed to fetch config: ${response.status} ${response.statusText}` + ); + } + + const config = await response.json() as FlagConfig; + return config; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error) { + if (error.name === 'AbortError') { + throw new Error(`Config fetch timeout after ${timeout}ms`); + } + throw error; + } + + throw new Error('Unknown error fetching config'); + } +} + diff --git a/packages/flags-client-web/src/index.ts b/packages/flags-client-web/src/index.ts index 3f72103..6aa0df5 100644 --- a/packages/flags-client-web/src/index.ts +++ b/packages/flags-client-web/src/index.ts @@ -1 +1,44 @@ -// Placeholder for Web SDK implementation +/** + * @togglekit/flags-client-web + * + * Web/Browser SDK for Togglekit feature flags + * + * @example + * ```typescript + * import { createFlagClient } from '@togglekit/flags-client-web'; + * + * // Bootstrap mode (SSR-friendly) + * const client = createFlagClient({ + * bootstrapConfig: window.__FLAGS_CONFIG__, + * }); + * + * // Or fetch from API + * const client = await createFlagClient({ + * apiUrl: 'https://api.togglekit.com', + * apiKey: 'your-api-key', + * }); + * + * const result = client.getBool('dark-mode', { + * attributes: { theme: 'auto' } + * }); + * ``` + */ + +export { createFlagClient, WebFlagClient } from './client'; +export { fetchConfig } from './fetcher'; +export type { + WebFlagClientOptions, + FlagConfig, + Context, + EvaluationResult, +} from './types'; + +// Re-export useful types from core +export type { + Flag, + Rule, + Condition, + Variant, + ConditionOperator, + EvaluationReason, +} from '@togglekit/flags-core-ts'; diff --git a/packages/flags-client-web/src/react.tsx b/packages/flags-client-web/src/react.tsx new file mode 100644 index 0000000..c9e8c15 --- /dev/null +++ b/packages/flags-client-web/src/react.tsx @@ -0,0 +1,177 @@ +/** + * React hooks and context provider for Togglekit feature flags + * + * @example + * ```typescript + * import { FlagProvider, useFlag } from '@togglekit/flags-client-web/react'; + * + * function App() { + * return ( + * + * + * + * ); + * } + * + * function MyComponent() { + * const darkMode = useFlag('dark-mode', { attributes: {} }); + * return
{darkMode.value ? 'Dark' : 'Light'}
; + * } + * ``` + */ + +import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; +import { WebFlagClient } from './client'; +import { Context, EvaluationResult } from './types'; + +/** + * React context for the flag client + */ +const FlagClientContext = createContext(null); + +/** + * Props for FlagProvider + */ +export interface FlagProviderProps { + /** The flag client instance */ + client: WebFlagClient; + /** Child components */ + children: ReactNode; +} + +/** + * Provider component for feature flag client + * + * Must wrap any components that use flag hooks + */ +export function FlagProvider({ client, children }: FlagProviderProps): JSX.Element { + const [, forceUpdate] = useState({}); + + useEffect(() => { + // Subscribe to config updates and force re-render + const unsubscribe = client.onConfigUpdate(() => { + forceUpdate({}); + }); + + return unsubscribe; + }, [client]); + + return ( + + {children} + + ); +} + +/** + * Hook to get the flag client from context + * + * @throws Error if used outside FlagProvider + */ +function useFlagClient(): WebFlagClient { + const client = useContext(FlagClientContext); + if (!client) { + throw new Error('useFlagClient must be used within a FlagProvider'); + } + return client; +} + +/** + * Hook to evaluate a boolean feature flag + * + * Re-renders when flag configuration updates + * + * @param flagKey - The flag key to evaluate + * @param context - User context with attributes (optional if userId not needed) + * @returns Evaluation result with boolean value and reason + * + * @example + * ```typescript + * function MyComponent() { + * const darkMode = useFlag('dark-mode', { + * attributes: { theme: 'auto' } + * }); + * + * if (darkMode.value) { + * return ; + * } + * return ; + * } + * ``` + */ +export function useFlag( + flagKey: string, + context: Context = { attributes: {} } +): EvaluationResult { + const client = useFlagClient(); + return client.getBool(flagKey, context); +} + +/** + * Hook to evaluate a variant feature flag + * + * Re-renders when flag configuration updates + * + * @param flagKey - The flag key to evaluate + * @param context - User context with attributes (optional if userId not needed) + * @returns Evaluation result with variant key (string) and reason + * + * @example + * ```typescript + * function PricingPage() { + * const layout = useFlagVariant('pricing-layout', { + * attributes: { country: 'US' } + * }); + * + * switch (layout.value) { + * case 'layout-a': + * return ; + * case 'layout-b': + * return ; + * default: + * return ; + * } + * } + * ``` + */ +export function useFlagVariant( + flagKey: string, + context: Context = { attributes: {} } +): EvaluationResult { + const client = useFlagClient(); + return client.getVariant(flagKey, context); +} + +/** + * Hook to get all flag keys + * + * Useful for debugging or admin UIs + * + * @example + * ```typescript + * function FlagList() { + * const flagKeys = useFlagKeys(); + * return ( + *
    + * {flagKeys.map(key =>
  • {key}
  • )} + *
+ * ); + * } + * ``` + */ +export function useFlagKeys(): string[] { + const client = useFlagClient(); + return client.getFlagKeys(); +} + +/** + * Hook to check if a flag exists + * + * @param flagKey - The flag key to check + * @returns true if flag exists, false otherwise + */ +export function useFlagExists(flagKey: string): boolean { + const client = useFlagClient(); + return client.hasFlag(flagKey); +} + diff --git a/packages/flags-client-web/src/types.ts b/packages/flags-client-web/src/types.ts new file mode 100644 index 0000000..ce0bffb --- /dev/null +++ b/packages/flags-client-web/src/types.ts @@ -0,0 +1,57 @@ +/** + * Types for the Web/Browser feature flag client + */ + +import { FlagConfig, Context, EvaluationResult } from '@togglekit/flags-core-ts'; + +/** + * Options for creating a web flag client + */ +export interface WebFlagClientOptions { + /** + * Bootstrap configuration loaded inline (e.g., from SSR) + * Allows synchronous client creation + */ + bootstrapConfig?: FlagConfig; + + /** + * API URL for fetching flag configuration + * Optional - if not provided, client works in bootstrap-only mode + */ + apiUrl?: string; + + /** + * API key for authentication + * Required if apiUrl is provided + */ + apiKey?: string; + + /** + * Enable automatic background refresh + * @default false + */ + enableRefresh?: boolean; + + /** + * Interval in milliseconds for refreshing config + * @default 60000 (60 seconds) + */ + refreshInterval?: number; + + /** + * Timeout in milliseconds for API requests + * @default 5000 (5 seconds) + */ + timeout?: number; + + /** + * Custom fetch function (for testing) + */ + fetchFn?: typeof fetch; +} + +/** + * Export core types from flags-core-ts for convenience + */ +export { FlagConfig, Context, EvaluationResult }; + diff --git a/packages/flags-client-web/test/client.test.ts b/packages/flags-client-web/test/client.test.ts new file mode 100644 index 0000000..6233a77 --- /dev/null +++ b/packages/flags-client-web/test/client.test.ts @@ -0,0 +1,438 @@ +/** + * Tests for the Web flag client + */ + +import { createFlagClient, WebFlagClient } from '../src/client'; +import { FlagConfig } from '../src/types'; + +// Mock config for testing +const mockConfig: FlagConfig = { + 'simple-flag': { + key: 'simple-flag', + defaultValue: true, + }, + 'premium-feature': { + key: 'premium-feature', + defaultValue: false, + rules: [ + { + conditions: [ + { attribute: 'plan', operator: 'eq', value: 'premium' }, + ], + value: true, + }, + ], + }, + 'layout-variant': { + key: 'layout-variant', + defaultValue: 'control', + variants: [ + { key: 'control' }, + { key: 'variant-a' }, + ], + rules: [ + { + conditions: [ + { attribute: 'country', operator: 'eq', value: 'US' }, + ], + variant: 'variant-a', + }, + ], + }, +}; + +describe('createFlagClient', () => { + describe('bootstrap mode', () => { + it('should create client synchronously with bootstrap config', () => { + const client = createFlagClient({ + bootstrapConfig: mockConfig, + }); + + expect(client).toBeInstanceOf(WebFlagClient); + expect((client as WebFlagClient).isReady()).toBe(true); + (client as WebFlagClient).close(); + }); + + it('should throw error when no config or API credentials provided', () => { + expect(() => createFlagClient({})).toThrow( + 'Either bootstrapConfig or both apiUrl and apiKey must be provided' + ); + }); + }); + + describe('fetch mode', () => { + it('should fetch config from API on initialization', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => mockConfig, + }); + + const clientPromise = createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + enableRefresh: false, + }); + + expect(clientPromise).toBeInstanceOf(Promise); + const client = await clientPromise; + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/v1/config', + expect.objectContaining({ + method: 'GET', + headers: { + 'Authorization': 'Bearer test-key', + 'Content-Type': 'application/json', + }, + }) + ); + + expect(client.isReady()).toBe(true); + client.close(); + }); + + it('should throw error when API fetch fails', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + await expect( + createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + }) + ).rejects.toThrow('Failed to fetch config: 404 Not Found'); + }); + }); +}); + +describe('WebFlagClient', () => { + let client: WebFlagClient; + + beforeEach(() => { + client = createFlagClient({ + bootstrapConfig: mockConfig, + enableRefresh: false, + }) as WebFlagClient; + }); + + afterEach(() => { + client.close(); + }); + + describe('getBool', () => { + it('should evaluate simple boolean flag', () => { + const result = client.getBool('simple-flag', { attributes: {} }); + + expect(result.value).toBe(true); + expect(result.reason).toBe('default'); + }); + + it('should evaluate flag with rule match', () => { + const result = client.getBool('premium-feature', { + attributes: { plan: 'premium' }, + }); + + expect(result.value).toBe(true); + expect(result.reason).toBe('rule_match'); + }); + + it('should return default when rule does not match', () => { + const result = client.getBool('premium-feature', { + attributes: { plan: 'free' }, + }); + + expect(result.value).toBe(false); + expect(result.reason).toBe('default'); + }); + + it('should return false for non-existent flag', () => { + const result = client.getBool('non-existent', { attributes: {} }); + + expect(result.value).toBe(false); + expect(result.reason).toBe('flag_not_found'); + }); + + it('should throw error when client is closed', () => { + client.close(); + + expect(() => { + client.getBool('simple-flag', { attributes: {} }); + }).toThrow('Client is closed'); + }); + }); + + describe('getVariant', () => { + it('should evaluate variant flag with default', () => { + const result = client.getVariant('layout-variant', { + attributes: { country: 'UK' }, + }); + + expect(result.value).toBe('control'); + expect(result.reason).toBe('default'); + }); + + it('should evaluate variant flag with rule match', () => { + const result = client.getVariant('layout-variant', { + attributes: { country: 'US' }, + }); + + expect(result.value).toBe('variant-a'); + expect(result.reason).toBe('rule_match'); + }); + }); + + describe('updateConfig', () => { + it('should update config manually', () => { + const newConfig: FlagConfig = { + 'new-flag': { + key: 'new-flag', + defaultValue: true, + }, + }; + + expect(client.hasFlag('new-flag')).toBe(false); + + client.updateConfig(newConfig); + + expect(client.hasFlag('new-flag')).toBe(true); + const result = client.getBool('new-flag', { attributes: {} }); + expect(result.value).toBe(true); + }); + + it('should notify listeners when config updates', () => { + const listener = jest.fn(); + client.onConfigUpdate(listener); + + const newConfig: FlagConfig = { + 'test-flag': { + key: 'test-flag', + defaultValue: false, + }, + }; + + client.updateConfig(newConfig); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should throw error when updating closed client', () => { + client.close(); + + expect(() => { + client.updateConfig({}); + }).toThrow('Client is closed'); + }); + }); + + describe('onConfigUpdate', () => { + it('should subscribe to config updates', () => { + const listener = jest.fn(); + const unsubscribe = client.onConfigUpdate(listener); + + client.updateConfig(mockConfig); + expect(listener).toHaveBeenCalledTimes(1); + + // Unsubscribe + unsubscribe(); + + client.updateConfig(mockConfig); + expect(listener).toHaveBeenCalledTimes(1); // Not called again + }); + + it('should handle multiple listeners', () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + + client.onConfigUpdate(listener1); + client.onConfigUpdate(listener2); + + client.updateConfig(mockConfig); + + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); + }); + + it('should handle listener errors gracefully', () => { + const errorListener = jest.fn().mockImplementation(() => { + throw new Error('Listener error'); + }); + const goodListener = jest.fn(); + + client.onConfigUpdate(errorListener); + client.onConfigUpdate(goodListener); + + // Should not throw + expect(() => client.updateConfig(mockConfig)).not.toThrow(); + + // Both listeners should have been called + expect(errorListener).toHaveBeenCalled(); + expect(goodListener).toHaveBeenCalled(); + }); + }); + + describe('refresh', () => { + it('should update config when refreshed', async () => { + let fetchCount = 0; + const mockFetch = jest.fn().mockImplementation(() => { + fetchCount++; + if (fetchCount === 1) { + return Promise.resolve({ + ok: true, + json: async () => mockConfig, + }); + } else { + return Promise.resolve({ + ok: true, + json: async () => ({ + ...mockConfig, + 'new-flag': { + key: 'new-flag', + defaultValue: true, + }, + }), + }); + } + }); + + const refreshableClient = await createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + enableRefresh: false, + }) as WebFlagClient; + + expect(refreshableClient.hasFlag('new-flag')).toBe(false); + + await refreshableClient.refresh(); + + expect(refreshableClient.hasFlag('new-flag')).toBe(true); + refreshableClient.close(); + }); + + it('should throw error when refreshing without API credentials', async () => { + await expect(client.refresh()).rejects.toThrow( + 'Cannot refresh: API URL and key required' + ); + }); + }); + + describe('utility methods', () => { + it('should get current config', () => { + const config = client.getConfig(); + expect(config).toEqual(mockConfig); + }); + + it('should check if flag exists', () => { + expect(client.hasFlag('simple-flag')).toBe(true); + expect(client.hasFlag('non-existent')).toBe(false); + }); + + it('should get all flag keys', () => { + const keys = client.getFlagKeys(); + expect(keys).toHaveLength(3); + expect(keys).toContain('simple-flag'); + expect(keys).toContain('premium-feature'); + expect(keys).toContain('layout-variant'); + }); + + it('should report ready status', () => { + expect(client.isReady()).toBe(true); + client.close(); + expect(client.isReady()).toBe(false); + }); + }); + + describe('automatic refresh', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should automatically refresh config at interval', async () => { + let callCount = 0; + const mockFetch = jest.fn().mockImplementation(() => { + callCount++; + const dynamicConfig = { ...mockConfig }; + const flagKey = `flag-${callCount}`; + dynamicConfig[flagKey] = { + key: flagKey, + defaultValue: true, + }; + return Promise.resolve({ + ok: true, + json: async () => dynamicConfig, + }); + }); + + const refreshClient = await createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + enableRefresh: true, + refreshInterval: 1000, + }) as WebFlagClient; + + expect(mockFetch).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1000); + await Promise.resolve(); + expect(mockFetch).toHaveBeenCalledTimes(2); + + refreshClient.close(); + }); + + it('should not refresh when enableRefresh is false', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => mockConfig, + }); + + const noRefreshClient = await createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + enableRefresh: false, + }) as WebFlagClient; + + expect(mockFetch).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(60000); + await Promise.resolve(); + expect(mockFetch).toHaveBeenCalledTimes(1); + + noRefreshClient.close(); + }); + + it('should stop refreshing after close', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => mockConfig, + }); + + const refreshClient = await createFlagClient({ + apiUrl: 'https://api.example.com', + apiKey: 'test-key', + fetchFn: mockFetch as any, + enableRefresh: true, + refreshInterval: 1000, + }) as WebFlagClient; + + expect(mockFetch).toHaveBeenCalledTimes(1); + + refreshClient.close(); + + jest.advanceTimersByTime(5000); + await Promise.resolve(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); +}); + diff --git a/packages/flags-client-web/test/react.test.tsx b/packages/flags-client-web/test/react.test.tsx new file mode 100644 index 0000000..301f6e4 --- /dev/null +++ b/packages/flags-client-web/test/react.test.tsx @@ -0,0 +1,350 @@ +/** + * Tests for React hooks + */ + +import React, { ReactNode } from 'react'; +import { render, renderHook, waitFor } from '@testing-library/react'; +import { createFlagClient, WebFlagClient } from '../src/client'; +import { + FlagProvider, + useFlag, + useFlagVariant, + useFlagKeys, + useFlagExists, +} from '../src/react'; +import { FlagConfig } from '../src/types'; + +const mockConfig: FlagConfig = { + 'test-flag': { + key: 'test-flag', + defaultValue: true, + }, + 'premium-feature': { + key: 'premium-feature', + defaultValue: false, + rules: [ + { + conditions: [ + { attribute: 'plan', operator: 'eq', value: 'premium' }, + ], + value: true, + }, + ], + }, + 'layout-variant': { + key: 'layout-variant', + defaultValue: 'control', + variants: [ + { key: 'control' }, + { key: 'variant-a' }, + ], + rules: [ + { + conditions: [ + { attribute: 'country', operator: 'eq', value: 'US' }, + ], + variant: 'variant-a', + }, + ], + }, +}; + +describe('FlagProvider', () => { + let client: WebFlagClient; + + beforeEach(() => { + client = createFlagClient({ + bootstrapConfig: mockConfig, + }) as WebFlagClient; + }); + + afterEach(() => { + client.close(); + }); + + it('should provide flag client to children', () => { + const TestComponent = () => { + const result = useFlag('test-flag'); + return
{result.value ? 'enabled' : 'disabled'}
; + }; + + const { getByText } = render( + + + + ); + + expect(getByText('enabled')).toBeTruthy(); + }); + + it.skip('should re-render when config updates', async () => { + let renderCount = 0; + const TestComponent = () => { + renderCount++; + const result = useFlag('new-flag'); + return
{result.value ? 'yes' : 'no'}
; + }; + + const { getByText } = render( + + + + ); + + // Initially flag doesn't exist, returns false + expect(getByText('no')).toBeTruthy(); + const initialRenderCount = renderCount; + + // Update config to include new flag + client.updateConfig({ + ...mockConfig, + 'new-flag': { + key: 'new-flag', + defaultValue: true, + }, + }); + + // Should re-render with new value + await waitFor(() => { + expect(renderCount).toBeGreaterThan(initialRenderCount); + }); + }); +}); + +describe('useFlag', () => { + let client: WebFlagClient; + + beforeEach(() => { + client = createFlagClient({ + bootstrapConfig: mockConfig, + }) as WebFlagClient; + }); + + afterEach(() => { + client.close(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it('should evaluate boolean flag', () => { + const { result } = renderHook( + () => useFlag('test-flag', { attributes: {} }), + { wrapper } + ); + + expect(result.current.value).toBe(true); + expect(result.current.reason).toBe('default'); + }); + + it('should evaluate flag with conditions', () => { + const { result } = renderHook( + () => useFlag('premium-feature', { attributes: { plan: 'premium' } }), + { wrapper } + ); + + expect(result.current.value).toBe(true); + expect(result.current.reason).toBe('rule_match'); + }); + + it('should return false for non-existent flag', () => { + const { result } = renderHook( + () => useFlag('non-existent', { attributes: {} }), + { wrapper } + ); + + expect(result.current.value).toBe(false); + expect(result.current.reason).toBe('flag_not_found'); + }); + + it('should use empty attributes by default', () => { + const { result } = renderHook(() => useFlag('test-flag'), { wrapper }); + + expect(result.current.value).toBe(true); + }); + + it('should throw error when used outside FlagProvider', () => { + // Suppress console.error for this test + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + expect(() => { + renderHook(() => useFlag('test-flag')); + }).toThrow('useFlagClient must be used within a FlagProvider'); + + consoleSpy.mockRestore(); + }); + + it('should re-render when config changes', async () => { + const { result, rerender } = renderHook( + () => useFlag('test-flag', { attributes: {} }), + { wrapper } + ); + + expect(result.current.value).toBe(true); + + // Update config + client.updateConfig({ + 'test-flag': { + key: 'test-flag', + defaultValue: false, + }, + }); + + // Force re-render + rerender(); + + await waitFor(() => { + expect(result.current.value).toBe(false); + }); + }); +}); + +describe('useFlagVariant', () => { + let client: WebFlagClient; + + beforeEach(() => { + client = createFlagClient({ + bootstrapConfig: mockConfig, + }) as WebFlagClient; + }); + + afterEach(() => { + client.close(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it('should evaluate variant flag', () => { + const { result } = renderHook( + () => useFlagVariant('layout-variant', { attributes: { country: 'US' } }), + { wrapper } + ); + + expect(result.current.value).toBe('variant-a'); + expect(result.current.reason).toBe('rule_match'); + }); + + it('should return default variant', () => { + const { result } = renderHook( + () => useFlagVariant('layout-variant', { attributes: { country: 'UK' } }), + { wrapper } + ); + + expect(result.current.value).toBe('control'); + expect(result.current.reason).toBe('default'); + }); + + it('should use empty attributes by default', () => { + const { result } = renderHook(() => useFlagVariant('layout-variant'), { + wrapper, + }); + + expect(result.current.value).toBe('control'); + }); +}); + +describe('useFlagKeys', () => { + let client: WebFlagClient; + + beforeEach(() => { + client = createFlagClient({ + bootstrapConfig: mockConfig, + }) as WebFlagClient; + }); + + afterEach(() => { + client.close(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it('should return all flag keys', () => { + const { result } = renderHook(() => useFlagKeys(), { wrapper }); + + expect(result.current).toHaveLength(3); + expect(result.current).toContain('test-flag'); + expect(result.current).toContain('premium-feature'); + expect(result.current).toContain('layout-variant'); + }); + + it('should update when config changes', async () => { + const { result, rerender } = renderHook(() => useFlagKeys(), { wrapper }); + + expect(result.current).toHaveLength(3); + + client.updateConfig({ + ...mockConfig, + 'new-flag': { + key: 'new-flag', + defaultValue: true, + }, + }); + + rerender(); + + await waitFor(() => { + expect(result.current).toHaveLength(4); + expect(result.current).toContain('new-flag'); + }); + }); +}); + +describe('useFlagExists', () => { + let client: WebFlagClient; + + beforeEach(() => { + client = createFlagClient({ + bootstrapConfig: mockConfig, + }) as WebFlagClient; + }); + + afterEach(() => { + client.close(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it('should return true for existing flag', () => { + const { result } = renderHook(() => useFlagExists('test-flag'), { wrapper }); + + expect(result.current).toBe(true); + }); + + it('should return false for non-existent flag', () => { + const { result } = renderHook(() => useFlagExists('non-existent'), { + wrapper, + }); + + expect(result.current).toBe(false); + }); + + it('should update when config changes', async () => { + const { result, rerender } = renderHook(() => useFlagExists('new-flag'), { + wrapper, + }); + + expect(result.current).toBe(false); + + client.updateConfig({ + ...mockConfig, + 'new-flag': { + key: 'new-flag', + defaultValue: true, + }, + }); + + rerender(); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); +}); + diff --git a/packages/flags-client-web/test/setup.ts b/packages/flags-client-web/test/setup.ts new file mode 100644 index 0000000..a673053 --- /dev/null +++ b/packages/flags-client-web/test/setup.ts @@ -0,0 +1,17 @@ +/** + * Test setup file + */ + +import '@testing-library/jest-dom'; + +// Polyfill fetch for jsdom +if (typeof global.fetch === 'undefined') { + global.fetch = jest.fn() as any; +} + +// Mock window.setInterval and clearInterval +if (typeof window !== 'undefined') { + global.setInterval = window.setInterval; + global.clearInterval = window.clearInterval; +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68420d0..9fb5ba3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,27 @@ importers: specifier: workspace:* version: link:../flags-core-ts devDependencies: + '@types/jest': + specifier: ^29.5.11 + version: 29.5.14 + '@types/node': + specifier: ^20.11.0 + version: 20.19.25 + '@typescript-eslint/eslint-plugin': + specifier: ^6.19.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.19.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + eslint: + specifier: ^8.56.0 + version: 8.57.1 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.25) + ts-jest: + specifier: ^29.1.1 + version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3) typescript: specifier: ^5.3.3 version: 5.9.3 @@ -59,6 +80,48 @@ importers: specifier: workspace:* version: link:../flags-core-ts devDependencies: + '@testing-library/jest-dom': + specifier: ^6.1.5 + version: 6.9.1 + '@testing-library/react': + specifier: ^14.1.2 + version: 14.3.1(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@testing-library/react-hooks': + specifier: ^8.0.1 + version: 8.0.1(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) + '@types/jest': + specifier: ^29.5.11 + version: 29.5.14 + '@types/node': + specifier: ^20.11.0 + version: 20.19.25 + '@types/react': + specifier: ^18.2.48 + version: 18.3.27 + '@typescript-eslint/eslint-plugin': + specifier: ^6.19.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.19.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + eslint: + specifier: ^8.56.0 + version: 8.57.1 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.25) + jest-environment-jsdom: + specifier: ^29.7.0 + version: 29.7.0 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + ts-jest: + specifier: ^29.1.1 + version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3) typescript: specifier: ^5.3.3 version: 5.9.3 @@ -92,6 +155,10 @@ importers: packages: + /@adobe/css-tools@4.4.4: + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + dev: true + /@babel/code-frame@7.27.1: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -375,6 +442,11 @@ packages: '@babel/helper-plugin-utils': 7.27.1 dev: true + /@babel/runtime@7.28.4: + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/template@7.27.2: resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -869,6 +941,80 @@ packages: '@sinonjs/commons': 3.0.1 dev: true + /@testing-library/dom@9.3.4: + resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} + engines: {node: '>=14'} + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.1.3 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + dev: true + + /@testing-library/jest-dom@6.9.1: + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.1.3 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + dev: true + + /@testing-library/react-hooks@8.0.1(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==} + engines: {node: '>=12'} + peerDependencies: + '@types/react': ^16.9.0 || ^17.0.0 + react: ^16.9.0 || ^17.0.0 + react-dom: ^16.9.0 || ^17.0.0 + react-test-renderer: ^16.9.0 || ^17.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-dom: + optional: true + react-test-renderer: + optional: true + dependencies: + '@babel/runtime': 7.28.4 + '@types/react': 18.3.27 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-error-boundary: 3.1.4(react@18.3.1) + dev: true + + /@testing-library/react@14.3.1(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==} + engines: {node: '>=14'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 9.3.4 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + dev: true + + /@tootallnate/once@2.0.0: + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + dev: true + + /@types/aria-query@5.0.4: + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + dev: true + /@types/babel__core@7.20.5: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} dependencies: @@ -927,6 +1073,14 @@ packages: pretty-format: 29.7.0 dev: true + /@types/jsdom@20.0.1: + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + dependencies: + '@types/node': 20.19.25 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + dev: true + /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true @@ -937,6 +1091,25 @@ packages: undici-types: 6.21.0 dev: true + /@types/prop-types@15.7.15: + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + dev: true + + /@types/react-dom@18.3.7(@types/react@18.3.27): + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + dependencies: + '@types/react': 18.3.27 + dev: true + + /@types/react@18.3.27: + resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + dev: true + /@types/semver@7.7.1: resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} dev: true @@ -945,6 +1118,10 @@ packages: resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} dev: true + /@types/tough-cookie@4.0.5: + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + dev: true + /@types/yargs-parser@21.0.3: resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} dev: true @@ -1110,6 +1287,18 @@ packages: argparse: 2.0.1 dev: true + /abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + dev: true + + /acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + dependencies: + acorn: 8.15.0 + acorn-walk: 8.3.4 + dev: true + /acorn-jsx@5.3.2(acorn@8.15.0): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1118,12 +1307,28 @@ packages: acorn: 8.15.0 dev: true + /acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + dependencies: + acorn: 8.15.0 + dev: true + /acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true dev: true + /agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + dev: true + /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -1180,6 +1385,20 @@ packages: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true + /aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + dependencies: + deep-equal: 2.2.3 + dev: true + + /array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + dev: true + /array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -1189,6 +1408,13 @@ packages: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: true + /available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + dependencies: + possible-typed-array-names: 1.1.0 + dev: true + /axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} dependencies: @@ -1359,6 +1585,24 @@ packages: function-bind: 1.1.2 dev: true + /call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + dev: true + + /call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + dev: true + /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1489,6 +1733,38 @@ packages: which: 2.0.2 dev: true + /css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + dev: true + + /cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + dev: true + + /cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + dev: true + + /cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + dependencies: + cssom: 0.3.8 + dev: true + + /csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dev: true + + /data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + dev: true + /debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1501,6 +1777,10 @@ packages: ms: 2.1.3 dev: true + /decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dev: true + /dedent@1.7.0: resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} peerDependencies: @@ -1510,6 +1790,30 @@ packages: optional: true dev: true + /deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.2.0 + is-array-buffer: 3.0.5 + is-date-object: 1.1.0 + is-regex: 1.2.1 + is-shared-array-buffer: 1.0.4 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.7 + regexp.prototype.flags: 1.5.4 + side-channel: 1.1.0 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + dev: true + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true @@ -1525,11 +1829,29 @@ packages: clone: 1.0.4 dev: true + /define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + dev: true + /define-lazy-prop@2.0.0: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} dev: true + /define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + dev: true + /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -1559,6 +1881,22 @@ packages: esutils: 2.0.3 dev: true + /dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dev: true + + /dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dev: true + + /domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + dependencies: + webidl-conversions: 7.0.0 + dev: true + /dotenv-expand@10.0.0: resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} engines: {node: '>=12'} @@ -1608,6 +1946,11 @@ packages: ansi-colors: 4.1.3 dev: true + /entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + dev: true + /error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} dependencies: @@ -1624,6 +1967,20 @@ packages: engines: {node: '>= 0.4'} dev: true + /es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + dependencies: + call-bind: 1.0.8 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.2.0 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.1.1 + isarray: 2.0.5 + stop-iteration-iterator: 1.1.0 + dev: true + /es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1661,6 +2018,18 @@ packages: engines: {node: '>=10'} dev: true + /escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + dev: true + /eslint-scope@7.2.2: resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1892,6 +2261,13 @@ packages: optional: true dev: true + /for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + dependencies: + is-callable: 1.2.7 + dev: true + /form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -1932,6 +2308,10 @@ packages: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} dev: true + /functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + dev: true + /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2047,11 +2427,22 @@ packages: uglify-js: 3.19.3 dev: true + /has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + dev: true + /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} dev: true + /has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + dependencies: + es-define-property: 1.0.1 + dev: true + /has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2071,15 +2462,50 @@ packages: function-bind: 1.1.2 dev: true + /html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + dependencies: + whatwg-encoding: 2.0.0 + dev: true + /html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: true + /http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + dev: true + + /https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + dev: true + /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} dev: true + /iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: true + /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} dev: true @@ -2111,6 +2537,11 @@ packages: engines: {node: '>=0.8.19'} dev: true + /indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + dev: true + /inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -2123,34 +2554,88 @@ packages: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} dev: true - /is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + /internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 dev: true - /is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + /is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} engines: {node: '>= 0.4'} dependencies: - hasown: 2.0.2 + call-bound: 1.0.4 + has-tostringtag: 1.0.2 dev: true - /is-docker@2.2.1: - resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} - engines: {node: '>=8'} - hasBin: true + /is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 dev: true - /is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} + /is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} dev: true - /is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} + /is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + dependencies: + has-bigints: 1.1.0 dev: true - /is-generator-fn@2.1.0: + /is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + dev: true + + /is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + dev: true + + /is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + dependencies: + hasown: 2.0.2 + dev: true + + /is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + dev: true + + /is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + dev: true + + /is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + dev: true + + /is-generator-fn@2.1.0: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} dev: true @@ -2167,6 +2652,19 @@ packages: engines: {node: '>=8'} dev: true + /is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + dev: true + + /is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + dev: true + /is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -2177,16 +2675,72 @@ packages: engines: {node: '>=8'} dev: true + /is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + dev: true + + /is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + dev: true + + /is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + dev: true + + /is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + dev: true + /is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} dev: true + /is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + dev: true + + /is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + dev: true + /is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} dev: true + /is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + dev: true + + /is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + dev: true + /is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} @@ -2194,6 +2748,10 @@ packages: is-docker: 2.2.1 dev: true + /isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + dev: true + /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: true @@ -2391,6 +2949,29 @@ packages: pretty-format: 29.7.0 dev: true + /jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 20.19.25 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + /jest-environment-node@29.7.0: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2693,6 +3274,47 @@ packages: argparse: 2.0.1 dev: true + /jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + abab: 2.0.6 + acorn: 8.15.0 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.6.0 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.5 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.22 + parse5: 7.3.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.18.3 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + /jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2796,12 +3418,24 @@ packages: is-unicode-supported: 0.1.0 dev: true + /loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + dev: true + /lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} dependencies: yallist: 3.1.1 dev: true + /lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + dev: true + /make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -2858,6 +3492,11 @@ packages: engines: {node: '>=6'} dev: true + /min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -2911,6 +3550,10 @@ packages: path-key: 3.1.1 dev: true + /nwsapi@2.2.22: + resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} + dev: true + /nx@18.3.5: resolution: {integrity: sha512-wWcvwoTgiT5okdrG0RIWm1tepC17bDmSpw+MrOxnjfBjARQNTURkiq4U6cxjCVsCxNHxCrlAaBSQLZeBgJZTzQ==} hasBin: true @@ -2973,6 +3616,36 @@ packages: - debug dev: true + /object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + dev: true + + /object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + dev: true + + /object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + dev: true + + /object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + dev: true + /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -3071,6 +3744,12 @@ packages: lines-and-columns: 1.2.4 dev: true + /parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + dependencies: + entities: 6.0.1 + dev: true + /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3116,11 +3795,25 @@ packages: find-up: 4.1.0 dev: true + /possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + dev: true + /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} dev: true + /pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + dev: true + /pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3142,6 +3835,12 @@ packages: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} dev: true + /psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + dependencies: + punycode: 2.3.1 + dev: true + /punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3151,14 +3850,49 @@ packages: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} dev: true + /querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + dev: true + /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true + /react-dom@18.3.1(react@18.3.1): + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + dev: true + + /react-error-boundary@3.1.4(react@18.3.1): + resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.28.4 + react: 18.3.1 + dev: true + + /react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + dev: true + /react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} dev: true + /react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: true + /readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -3168,11 +3902,35 @@ packages: util-deprecate: 1.0.2 dev: true + /redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + dev: true + + /regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + dev: true + /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} dev: true + /requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + dev: true + /resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -3236,6 +3994,32 @@ packages: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} dev: true + /safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + dev: true + + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + dev: true + + /saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + dependencies: + xmlchars: 2.2.0 + dev: true + + /scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + dependencies: + loose-envify: 1.4.0 + dev: true + /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3247,6 +4031,28 @@ packages: hasBin: true dev: true + /set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + dev: true + + /set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + dev: true + /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3259,6 +4065,46 @@ packages: engines: {node: '>=8'} dev: true + /side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + dev: true + + /side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + dev: true + + /side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + dev: true + + /side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + dev: true + /signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} dev: true @@ -3295,6 +4141,14 @@ packages: escape-string-regexp: 2.0.0 dev: true + /stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + dev: true + /string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -3340,6 +4194,13 @@ packages: engines: {node: '>=6'} dev: true + /strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3374,6 +4235,10 @@ packages: engines: {node: '>= 0.4'} dev: true + /symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + dev: true + /tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -3418,6 +4283,23 @@ packages: is-number: 7.0.0 dev: true + /tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + dev: true + + /tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + dependencies: + punycode: 2.3.1 + dev: true + /ts-api-utils@1.4.3(typescript@5.9.3): resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -3526,6 +4408,11 @@ packages: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} dev: true + /universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + dev: true + /universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -3548,6 +4435,13 @@ packages: punycode: 2.3.1 dev: true + /url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + dev: true + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true @@ -3561,6 +4455,13 @@ packages: convert-source-map: 2.0.0 dev: true + /w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + dependencies: + xml-name-validator: 4.0.0 + dev: true + /walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} dependencies: @@ -3573,6 +4474,65 @@ packages: defaults: 1.0.4 dev: true + /webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + dev: true + + /whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + dependencies: + iconv-lite: 0.6.3 + dev: true + + /whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + dev: true + + /whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + dev: true + + /which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + dev: true + + /which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + dev: true + + /which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + dev: true + /which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3611,6 +4571,28 @@ packages: signal-exit: 3.0.7 dev: true + /ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + + /xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + dev: true + + /xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true + /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} From 4f80c873fc4d682b36e6df8bc05b879489b95351 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 1 Dec 2025 19:31:35 -0800 Subject: [PATCH 2/5] fix(sdks): Fix ESLint configs and TypeScript project references - Update ESLint configs to be standalone instead of extending non-existent root config - Add TypeScript composite project references for proper monorepo builds - Add jsx flag to web SDK tsconfig - Add eslint-disable comments for necessary any type usage - All packages now lint and build successfully --- packages/flags-client-node/.eslintrc.json | 27 ++- packages/flags-client-node/src/client.ts | 9 +- packages/flags-client-node/tsconfig.json | 8 +- packages/flags-client-web/.eslintrc.json | 27 ++- packages/flags-client-web/src/client.ts | 2 + packages/flags-client-web/tsconfig.json | 9 +- packages/flags-core-ts/src/conditions.d.ts | 28 +++ .../flags-core-ts/src/conditions.d.ts.map | 1 + packages/flags-core-ts/src/conditions.js | 152 +++++++++++++ packages/flags-core-ts/src/conditions.js.map | 1 + packages/flags-core-ts/src/evaluator.d.ts | 107 +++++++++ packages/flags-core-ts/src/evaluator.d.ts.map | 1 + packages/flags-core-ts/src/evaluator.js | 206 ++++++++++++++++++ packages/flags-core-ts/src/evaluator.js.map | 1 + packages/flags-core-ts/src/index.d.ts | 10 + packages/flags-core-ts/src/index.d.ts.map | 1 + packages/flags-core-ts/src/index.js | 18 ++ packages/flags-core-ts/src/index.js.map | 1 + packages/flags-core-ts/src/rollout.d.ts | 24 ++ packages/flags-core-ts/src/rollout.d.ts.map | 1 + packages/flags-core-ts/src/rollout.js | 47 ++++ packages/flags-core-ts/src/rollout.js.map | 1 + packages/flags-core-ts/src/types.d.ts | 93 ++++++++ packages/flags-core-ts/src/types.d.ts.map | 1 + packages/flags-core-ts/src/types.js | 6 + packages/flags-core-ts/src/types.js.map | 1 + packages/flags-core-ts/tsconfig.json | 3 +- 27 files changed, 755 insertions(+), 31 deletions(-) create mode 100644 packages/flags-core-ts/src/conditions.d.ts create mode 100644 packages/flags-core-ts/src/conditions.d.ts.map create mode 100644 packages/flags-core-ts/src/conditions.js create mode 100644 packages/flags-core-ts/src/conditions.js.map create mode 100644 packages/flags-core-ts/src/evaluator.d.ts create mode 100644 packages/flags-core-ts/src/evaluator.d.ts.map create mode 100644 packages/flags-core-ts/src/evaluator.js create mode 100644 packages/flags-core-ts/src/evaluator.js.map create mode 100644 packages/flags-core-ts/src/index.d.ts create mode 100644 packages/flags-core-ts/src/index.d.ts.map create mode 100644 packages/flags-core-ts/src/index.js create mode 100644 packages/flags-core-ts/src/index.js.map create mode 100644 packages/flags-core-ts/src/rollout.d.ts create mode 100644 packages/flags-core-ts/src/rollout.d.ts.map create mode 100644 packages/flags-core-ts/src/rollout.js create mode 100644 packages/flags-core-ts/src/rollout.js.map create mode 100644 packages/flags-core-ts/src/types.d.ts create mode 100644 packages/flags-core-ts/src/types.d.ts.map create mode 100644 packages/flags-core-ts/src/types.js create mode 100644 packages/flags-core-ts/src/types.js.map diff --git a/packages/flags-client-node/.eslintrc.json b/packages/flags-client-node/.eslintrc.json index 679b9aa..25d82b6 100644 --- a/packages/flags-client-node/.eslintrc.json +++ b/packages/flags-client-node/.eslintrc.json @@ -1,14 +1,19 @@ { - "extends": ["../../.eslintrc.json"], - "ignorePatterns": ["!**/*", "dist", "node_modules"], - "overrides": [ - { - "files": ["*.ts", "*.tsx"], - "parserOptions": { - "project": ["packages/flags-client-node/tsconfig.json"] - }, - "rules": {} - } - ] + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module", + "project": "./tsconfig.json" + }, + "plugins": ["@typescript-eslint"], + "rules": { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }] + }, + "ignorePatterns": ["dist", "node_modules", "test"] } diff --git a/packages/flags-client-node/src/client.ts b/packages/flags-client-node/src/client.ts index c3933a9..b5d5ed7 100644 --- a/packages/flags-client-node/src/client.ts +++ b/packages/flags-client-node/src/client.ts @@ -246,10 +246,11 @@ export async function createFlagClient( const client = new FlagClient(config, options); - // Start automatic refresh if configured - if (options.apiUrl && options.apiKey) { - (client as any).startRefresh(); - } + // Start automatic refresh if configured + if (options.apiUrl && options.apiKey) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (client as any).startRefresh(); + } return client; } diff --git a/packages/flags-client-node/tsconfig.json b/packages/flags-client-node/tsconfig.json index 86d27f3..615645e 100644 --- a/packages/flags-client-node/tsconfig.json +++ b/packages/flags-client-node/tsconfig.json @@ -13,9 +13,13 @@ "module": "commonjs", "target": "ES2020", "lib": ["ES2020"], - "moduleResolution": "node" + "moduleResolution": "node", + "composite": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "test"] + "exclude": ["node_modules", "dist", "test"], + "references": [ + { "path": "../flags-core-ts" } + ] } diff --git a/packages/flags-client-web/.eslintrc.json b/packages/flags-client-web/.eslintrc.json index 0d5049d..25d82b6 100644 --- a/packages/flags-client-web/.eslintrc.json +++ b/packages/flags-client-web/.eslintrc.json @@ -1,14 +1,19 @@ { - "extends": ["../../.eslintrc.json"], - "ignorePatterns": ["!**/*", "dist", "node_modules"], - "overrides": [ - { - "files": ["*.ts", "*.tsx"], - "parserOptions": { - "project": ["packages/flags-client-web/tsconfig.json"] - }, - "rules": {} - } - ] + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module", + "project": "./tsconfig.json" + }, + "plugins": ["@typescript-eslint"], + "rules": { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }] + }, + "ignorePatterns": ["dist", "node_modules", "test"] } diff --git a/packages/flags-client-web/src/client.ts b/packages/flags-client-web/src/client.ts index ece348b..159240e 100644 --- a/packages/flags-client-web/src/client.ts +++ b/packages/flags-client-web/src/client.ts @@ -233,6 +233,7 @@ export function createFlagClient( // Start refresh in background if configured if (options.apiUrl && options.apiKey && options.enableRefresh) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (client as any).startRefresh(); } @@ -251,6 +252,7 @@ export function createFlagClient( // Start refresh if enabled if (options.enableRefresh) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (client as any).startRefresh(); } diff --git a/packages/flags-client-web/tsconfig.json b/packages/flags-client-web/tsconfig.json index 7a24068..4389952 100644 --- a/packages/flags-client-web/tsconfig.json +++ b/packages/flags-client-web/tsconfig.json @@ -13,9 +13,14 @@ "module": "esnext", "target": "ES2020", "lib": ["ES2020", "DOM"], - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "composite": true, + "jsx": "react" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "test"] + "exclude": ["node_modules", "dist", "test"], + "references": [ + { "path": "../flags-core-ts" } + ] } diff --git a/packages/flags-core-ts/src/conditions.d.ts b/packages/flags-core-ts/src/conditions.d.ts new file mode 100644 index 0000000..21d99d5 --- /dev/null +++ b/packages/flags-core-ts/src/conditions.d.ts @@ -0,0 +1,28 @@ +/** + * Condition evaluation logic for feature flag rules + */ +import { Condition, Context } from './types'; +/** + * Evaluate a single condition against a context + * + * @param condition - The condition to evaluate + * @param context - The evaluation context with user attributes + * @returns true if the condition matches, false otherwise + * + * @example + * ```typescript + * const condition: Condition = { attribute: 'country', operator: 'eq', value: 'US' }; + * const context: Context = { attributes: { country: 'US' } }; + * evaluateCondition(condition, context); // true + * ``` + */ +export declare function evaluateCondition(condition: Condition, context: Context): boolean; +/** + * Evaluate all conditions in a rule (AND logic) + * + * @param conditions - Array of conditions to evaluate + * @param context - The evaluation context + * @returns true if all conditions match, false if any fail + */ +export declare function evaluateConditions(conditions: Condition[], context: Context): boolean; +//# sourceMappingURL=conditions.d.ts.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/conditions.d.ts.map b/packages/flags-core-ts/src/conditions.d.ts.map new file mode 100644 index 0000000..d2e52e5 --- /dev/null +++ b/packages/flags-core-ts/src/conditions.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"conditions.d.ts","sourceRoot":"","sources":["conditions.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAuE7C;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CA2DjF;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,SAAS,EAAE,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAMrF"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/conditions.js b/packages/flags-core-ts/src/conditions.js new file mode 100644 index 0000000..273495e --- /dev/null +++ b/packages/flags-core-ts/src/conditions.js @@ -0,0 +1,152 @@ +"use strict"; +/** + * Condition evaluation logic for feature flag rules + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.evaluateCondition = evaluateCondition; +exports.evaluateConditions = evaluateConditions; +/** + * Get a value from the context by attribute path + * Supports nested paths like "user.plan" or simple keys like "country" + */ +function getAttributeValue(context, attribute) { + // First check if it's a direct attribute + if (attribute in context.attributes) { + return context.attributes[attribute]; + } + // Handle nested paths (e.g., "user.plan") + const parts = attribute.split('.'); + let value = context.attributes; + for (const part of parts) { + if (value && typeof value === 'object' && part in value) { + value = value[part]; + } + else { + return undefined; + } + } + return value; +} +/** + * Compare two values for equality + * Handles type coercion for common cases + */ +function areEqual(a, b) { + // Strict equality first + if (a === b) + return true; + // Handle null/undefined + if (a == null || b == null) + return a == b; + // Try string comparison (common case: numbers as strings) + return String(a) === String(b); +} +/** + * Convert value to number if possible + */ +function toNumber(value) { + if (typeof value === 'number') + return value; + if (typeof value === 'string') { + const num = parseFloat(value); + return isNaN(num) ? null : num; + } + return null; +} +/** + * Check if a value is contained in another value + * - String in string (substring) + * - Value in array (includes) + */ +function contains(haystack, needle) { + if (typeof haystack === 'string' && typeof needle === 'string') { + return haystack.includes(needle); + } + if (Array.isArray(haystack)) { + return haystack.some(item => areEqual(item, needle)); + } + return false; +} +/** + * Evaluate a single condition against a context + * + * @param condition - The condition to evaluate + * @param context - The evaluation context with user attributes + * @returns true if the condition matches, false otherwise + * + * @example + * ```typescript + * const condition: Condition = { attribute: 'country', operator: 'eq', value: 'US' }; + * const context: Context = { attributes: { country: 'US' } }; + * evaluateCondition(condition, context); // true + * ``` + */ +function evaluateCondition(condition, context) { + const { attribute, operator, value } = condition; + const actualValue = getAttributeValue(context, attribute); + // If attribute doesn't exist in context, condition fails + // Exception: 'neq' can match if attribute is missing and value is not undefined + if (actualValue === undefined) { + if (operator === 'neq') { + return value !== undefined; + } + return false; + } + switch (operator) { + case 'eq': + return areEqual(actualValue, value); + case 'neq': + return !areEqual(actualValue, value); + case 'in': + if (!Array.isArray(value)) + return false; + return value.some(v => areEqual(actualValue, v)); + case 'gt': { + const numActual = toNumber(actualValue); + const numValue = toNumber(value); + if (numActual === null || numValue === null) + return false; + return numActual > numValue; + } + case 'gte': { + const numActual = toNumber(actualValue); + const numValue = toNumber(value); + if (numActual === null || numValue === null) + return false; + return numActual >= numValue; + } + case 'lt': { + const numActual = toNumber(actualValue); + const numValue = toNumber(value); + if (numActual === null || numValue === null) + return false; + return numActual < numValue; + } + case 'lte': { + const numActual = toNumber(actualValue); + const numValue = toNumber(value); + if (numActual === null || numValue === null) + return false; + return numActual <= numValue; + } + case 'contains': + return contains(actualValue, value); + default: + // Unknown operator + return false; + } +} +/** + * Evaluate all conditions in a rule (AND logic) + * + * @param conditions - Array of conditions to evaluate + * @param context - The evaluation context + * @returns true if all conditions match, false if any fail + */ +function evaluateConditions(conditions, context) { + if (!conditions || conditions.length === 0) { + return true; // No conditions means rule matches + } + return conditions.every(condition => evaluateCondition(condition, context)); +} +//# sourceMappingURL=conditions.js.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/conditions.js.map b/packages/flags-core-ts/src/conditions.js.map new file mode 100644 index 0000000..8436a13 --- /dev/null +++ b/packages/flags-core-ts/src/conditions.js.map @@ -0,0 +1 @@ +{"version":3,"file":"conditions.js","sourceRoot":"","sources":["conditions.ts"],"names":[],"mappings":";AAAA;;GAEG;;AAuFH,8CA2DC;AASD,gDAMC;AA7JD;;;GAGG;AACH,SAAS,iBAAiB,CAAC,OAAgB,EAAE,SAAiB;IAC5D,yCAAyC;IACzC,IAAI,SAAS,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC;QACpC,OAAO,OAAO,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;IACvC,CAAC;IAED,0CAA0C;IAC1C,MAAM,KAAK,GAAG,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACnC,IAAI,KAAK,GAAY,OAAO,CAAC,UAAU,CAAC;IAExC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,IAAI,IAAI,KAAK,EAAE,CAAC;YACxD,KAAK,GAAI,KAAiC,CAAC,IAAI,CAAC,CAAC;QACnD,CAAC;aAAM,CAAC;YACN,OAAO,SAAS,CAAC;QACnB,CAAC;IACH,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;GAGG;AACH,SAAS,QAAQ,CAAC,CAAU,EAAE,CAAU;IACtC,wBAAwB;IACxB,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAEzB,wBAAwB;IACxB,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,IAAI,IAAI;QAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAE1C,0DAA0D;IAC1D,OAAO,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,CAAC,CAAC,CAAC;AACjC,CAAC;AAED;;GAEG;AACH,SAAS,QAAQ,CAAC,KAAc;IAC9B,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5C,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,MAAM,GAAG,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;QAC9B,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC;IACjC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;GAIG;AACH,SAAS,QAAQ,CAAC,QAAiB,EAAE,MAAe;IAClD,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC/D,OAAO,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACnC,CAAC;IAED,IAAI,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC5B,OAAO,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IACvD,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,SAAgB,iBAAiB,CAAC,SAAoB,EAAE,OAAgB;IACtE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,SAAS,CAAC;IACjD,MAAM,WAAW,GAAG,iBAAiB,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;IAE1D,yDAAyD;IACzD,gFAAgF;IAChF,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;QAC9B,IAAI,QAAQ,KAAK,KAAK,EAAE,CAAC;YACvB,OAAO,KAAK,KAAK,SAAS,CAAC;QAC7B,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,QAAQ,QAAQ,EAAE,CAAC;QACjB,KAAK,IAAI;YACP,OAAO,QAAQ,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC;QAEtC,KAAK,KAAK;YACR,OAAO,CAAC,QAAQ,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC;QAEvC,KAAK,IAAI;YACP,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;gBAAE,OAAO,KAAK,CAAC;YACxC,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,CAAC;QAEnD,KAAK,IAAI,CAAC,CAAC,CAAC;YACV,MAAM,SAAS,GAAG,QAAQ,CAAC,WAAW,CAAC,CAAC;YACxC,MAAM,QAAQ,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;YACjC,IAAI,SAAS,KAAK,IAAI,IAAI,QAAQ,KAAK,IAAI;gBAAE,OAAO,KAAK,CAAC;YAC1D,OAAO,SAAS,GAAG,QAAQ,CAAC;QAC9B,CAAC;QAED,KAAK,KAAK,CAAC,CAAC,CAAC;YACX,MAAM,SAAS,GAAG,QAAQ,CAAC,WAAW,CAAC,CAAC;YACxC,MAAM,QAAQ,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;YACjC,IAAI,SAAS,KAAK,IAAI,IAAI,QAAQ,KAAK,IAAI;gBAAE,OAAO,KAAK,CAAC;YAC1D,OAAO,SAAS,IAAI,QAAQ,CAAC;QAC/B,CAAC;QAED,KAAK,IAAI,CAAC,CAAC,CAAC;YACV,MAAM,SAAS,GAAG,QAAQ,CAAC,WAAW,CAAC,CAAC;YACxC,MAAM,QAAQ,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;YACjC,IAAI,SAAS,KAAK,IAAI,IAAI,QAAQ,KAAK,IAAI;gBAAE,OAAO,KAAK,CAAC;YAC1D,OAAO,SAAS,GAAG,QAAQ,CAAC;QAC9B,CAAC;QAED,KAAK,KAAK,CAAC,CAAC,CAAC;YACX,MAAM,SAAS,GAAG,QAAQ,CAAC,WAAW,CAAC,CAAC;YACxC,MAAM,QAAQ,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;YACjC,IAAI,SAAS,KAAK,IAAI,IAAI,QAAQ,KAAK,IAAI;gBAAE,OAAO,KAAK,CAAC;YAC1D,OAAO,SAAS,IAAI,QAAQ,CAAC;QAC/B,CAAC;QAED,KAAK,UAAU;YACb,OAAO,QAAQ,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC;QAEtC;YACE,mBAAmB;YACnB,OAAO,KAAK,CAAC;IACjB,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,kBAAkB,CAAC,UAAuB,EAAE,OAAgB;IAC1E,IAAI,CAAC,UAAU,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC3C,OAAO,IAAI,CAAC,CAAC,mCAAmC;IAClD,CAAC;IAED,OAAO,UAAU,CAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC,iBAAiB,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC,CAAC;AAC9E,CAAC"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/evaluator.d.ts b/packages/flags-core-ts/src/evaluator.d.ts new file mode 100644 index 0000000..3e4974c --- /dev/null +++ b/packages/flags-core-ts/src/evaluator.d.ts @@ -0,0 +1,107 @@ +/** + * Main evaluator for feature flags + */ +import { FlagConfig, Context, EvaluationResult } from './types'; +/** + * Feature flag evaluator + * + * Evaluates feature flags based on configuration and user context. + * Supports boolean flags, variant flags, conditional targeting, and percentage rollouts. + * + * @example + * ```typescript + * const config: FlagConfig = { + * 'new-feature': { + * key: 'new-feature', + * defaultValue: false, + * rules: [{ + * conditions: [{ attribute: 'plan', operator: 'eq', value: 'premium' }], + * value: true + * }] + * } + * }; + * + * const evaluator = new Evaluator(config); + * const result = evaluator.evalBool('new-feature', { + * userId: 'user-123', + * attributes: { plan: 'premium' } + * }); + * // result: { value: true, reason: 'rule_match', metadata: { ruleIndex: 0 } } + * ``` + */ +export declare class Evaluator { + private config; + /** + * Create a new evaluator with the given flag configuration + * + * @param config - Map of flag keys to flag definitions + */ + constructor(config: FlagConfig); + /** + * Evaluate a boolean feature flag + * + * @param flagKey - The flag key to evaluate + * @param context - User context with attributes + * @returns Evaluation result with boolean value and reason + * + * @example + * ```typescript + * const result = evaluator.evalBool('dark-mode', { attributes: { theme: 'dark' } }); + * if (result.value) { + * // Enable dark mode + * } + * ``` + */ + evalBool(flagKey: string, context: Context): EvaluationResult; + /** + * Evaluate a variant feature flag + * + * Returns a variant key (string) based on rules or default value + * + * @param flagKey - The flag key to evaluate + * @param context - User context with attributes + * @returns Evaluation result with variant key and reason + * + * @example + * ```typescript + * const result = evaluator.evalVariant('pricing-page', { + * userId: 'user-123', + * attributes: { plan: 'free' } + * }); + * switch (result.value) { + * case 'layout-a': // Show layout A + * case 'layout-b': // Show layout B + * default: // Show default layout + * } + * ``` + */ + evalVariant(flagKey: string, context: Context): EvaluationResult; + /** + * Evaluate a single rule + * + * @param rule - The rule to evaluate + * @param context - User context + * @param flagKey - Flag key (for rollout bucketing) + * @returns Object indicating if rule matched and the reason + */ + private evaluateRule; + /** + * Get the raw flag configuration + * Useful for debugging or serialization + */ + getConfig(): FlagConfig; + /** + * Check if a flag exists in the configuration + * + * @param flagKey - The flag key to check + * @returns true if flag exists, false otherwise + */ + hasFlag(flagKey: string): boolean; + /** + * Get all flag keys in the configuration + * + * @returns Array of all flag keys + */ + getFlagKeys(): string[]; +} +//# sourceMappingURL=evaluator.d.ts.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/evaluator.d.ts.map b/packages/flags-core-ts/src/evaluator.d.ts.map new file mode 100644 index 0000000..30ed86b --- /dev/null +++ b/packages/flags-core-ts/src/evaluator.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"evaluator.d.ts","sourceRoot":"","sources":["evaluator.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,gBAAgB,EAAQ,MAAM,SAAS,CAAC;AAItE;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,MAAM,CAAa;IAE3B;;;;OAIG;gBACS,MAAM,EAAE,UAAU;IAI9B;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,gBAAgB,CAAC,OAAO,CAAC;IAsCtE;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,gBAAgB,CAAC,MAAM,CAAC;IAsCxE;;;;;;;OAOG;IACH,OAAO,CAAC,YAAY;IAiCpB;;;OAGG;IACH,SAAS,IAAI,UAAU;IAIvB;;;;;OAKG;IACH,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAIjC;;;;OAIG;IACH,WAAW,IAAI,MAAM,EAAE;CAGxB"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/evaluator.js b/packages/flags-core-ts/src/evaluator.js new file mode 100644 index 0000000..1474caa --- /dev/null +++ b/packages/flags-core-ts/src/evaluator.js @@ -0,0 +1,206 @@ +"use strict"; +/** + * Main evaluator for feature flags + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Evaluator = void 0; +const conditions_1 = require("./conditions"); +const rollout_1 = require("./rollout"); +/** + * Feature flag evaluator + * + * Evaluates feature flags based on configuration and user context. + * Supports boolean flags, variant flags, conditional targeting, and percentage rollouts. + * + * @example + * ```typescript + * const config: FlagConfig = { + * 'new-feature': { + * key: 'new-feature', + * defaultValue: false, + * rules: [{ + * conditions: [{ attribute: 'plan', operator: 'eq', value: 'premium' }], + * value: true + * }] + * } + * }; + * + * const evaluator = new Evaluator(config); + * const result = evaluator.evalBool('new-feature', { + * userId: 'user-123', + * attributes: { plan: 'premium' } + * }); + * // result: { value: true, reason: 'rule_match', metadata: { ruleIndex: 0 } } + * ``` + */ +class Evaluator { + /** + * Create a new evaluator with the given flag configuration + * + * @param config - Map of flag keys to flag definitions + */ + constructor(config) { + this.config = config; + } + /** + * Evaluate a boolean feature flag + * + * @param flagKey - The flag key to evaluate + * @param context - User context with attributes + * @returns Evaluation result with boolean value and reason + * + * @example + * ```typescript + * const result = evaluator.evalBool('dark-mode', { attributes: { theme: 'dark' } }); + * if (result.value) { + * // Enable dark mode + * } + * ``` + */ + evalBool(flagKey, context) { + const flag = this.config[flagKey]; + if (!flag) { + return { + value: false, + reason: 'flag_not_found', + metadata: { error: `Flag "${flagKey}" not found in configuration` } + }; + } + // Evaluate rules in order (first match wins) + if (flag.rules && flag.rules.length > 0) { + for (let i = 0; i < flag.rules.length; i++) { + const rule = flag.rules[i]; + const ruleResult = this.evaluateRule(rule, context, flagKey); + if (ruleResult.matched) { + const value = rule.value !== undefined ? rule.value : true; + return { + value: Boolean(value), + reason: ruleResult.reason, + metadata: { + ruleIndex: i, + bucket: ruleResult.bucket + } + }; + } + } + } + // No rules matched, use default + return { + value: Boolean(flag.defaultValue), + reason: 'default' + }; + } + /** + * Evaluate a variant feature flag + * + * Returns a variant key (string) based on rules or default value + * + * @param flagKey - The flag key to evaluate + * @param context - User context with attributes + * @returns Evaluation result with variant key and reason + * + * @example + * ```typescript + * const result = evaluator.evalVariant('pricing-page', { + * userId: 'user-123', + * attributes: { plan: 'free' } + * }); + * switch (result.value) { + * case 'layout-a': // Show layout A + * case 'layout-b': // Show layout B + * default: // Show default layout + * } + * ``` + */ + evalVariant(flagKey, context) { + const flag = this.config[flagKey]; + if (!flag) { + return { + value: 'default', + reason: 'flag_not_found', + metadata: { error: `Flag "${flagKey}" not found in configuration` } + }; + } + // Evaluate rules in order (first match wins) + if (flag.rules && flag.rules.length > 0) { + for (let i = 0; i < flag.rules.length; i++) { + const rule = flag.rules[i]; + const ruleResult = this.evaluateRule(rule, context, flagKey); + if (ruleResult.matched) { + const variant = rule.variant || String(flag.defaultValue); + return { + value: variant, + reason: ruleResult.reason, + metadata: { + ruleIndex: i, + bucket: ruleResult.bucket + } + }; + } + } + } + // No rules matched, use default + return { + value: String(flag.defaultValue), + reason: 'default' + }; + } + /** + * Evaluate a single rule + * + * @param rule - The rule to evaluate + * @param context - User context + * @param flagKey - Flag key (for rollout bucketing) + * @returns Object indicating if rule matched and the reason + */ + evaluateRule(rule, context, flagKey) { + // First, check if all conditions match + const conditionsMatch = (0, conditions_1.evaluateConditions)(rule.conditions || [], context); + if (!conditionsMatch) { + return { matched: false, reason: 'rollout_excluded' }; + } + // Conditions match, now check percentage rollout + if (rule.percentage !== undefined && rule.percentage < 100) { + // Need userId for rollout bucketing + if (!context.userId) { + // No userId provided, can't do rollout - exclude by default + return { matched: false, reason: 'rollout_excluded' }; + } + const bucket = (0, rollout_1.computeRolloutBucket)(context.userId, flagKey); + if (bucket <= rule.percentage) { + return { matched: true, reason: 'rollout', bucket }; + } + else { + return { matched: false, reason: 'rollout_excluded', bucket }; + } + } + // All conditions match and no rollout restriction + return { matched: true, reason: 'rule_match' }; + } + /** + * Get the raw flag configuration + * Useful for debugging or serialization + */ + getConfig() { + return this.config; + } + /** + * Check if a flag exists in the configuration + * + * @param flagKey - The flag key to check + * @returns true if flag exists, false otherwise + */ + hasFlag(flagKey) { + return flagKey in this.config; + } + /** + * Get all flag keys in the configuration + * + * @returns Array of all flag keys + */ + getFlagKeys() { + return Object.keys(this.config); + } +} +exports.Evaluator = Evaluator; +//# sourceMappingURL=evaluator.js.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/evaluator.js.map b/packages/flags-core-ts/src/evaluator.js.map new file mode 100644 index 0000000..0d944cb --- /dev/null +++ b/packages/flags-core-ts/src/evaluator.js.map @@ -0,0 +1 @@ +{"version":3,"file":"evaluator.js","sourceRoot":"","sources":["evaluator.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/index.d.ts b/packages/flags-core-ts/src/index.d.ts new file mode 100644 index 0000000..3eca0ff --- /dev/null +++ b/packages/flags-core-ts/src/index.d.ts @@ -0,0 +1,10 @@ +/** + * @togglekit/flags-core-ts + * + * TypeScript core evaluator for Togglekit feature flags + */ +export { Evaluator } from './evaluator'; +export type { Context, Condition, ConditionOperator, Rule, Variant, Flag, FlagConfig, EvaluationResult, EvaluationReason } from './types'; +export { computeRolloutBucket } from './rollout'; +export { evaluateCondition, evaluateConditions } from './conditions'; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/index.d.ts.map b/packages/flags-core-ts/src/index.d.ts.map new file mode 100644 index 0000000..afa3f31 --- /dev/null +++ b/packages/flags-core-ts/src/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAGxC,YAAY,EACV,OAAO,EACP,SAAS,EACT,iBAAiB,EACjB,IAAI,EACJ,OAAO,EACP,IAAI,EACJ,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EACjB,MAAM,SAAS,CAAC;AAGjB,OAAO,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAC;AACjD,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/index.js b/packages/flags-core-ts/src/index.js new file mode 100644 index 0000000..534787f --- /dev/null +++ b/packages/flags-core-ts/src/index.js @@ -0,0 +1,18 @@ +"use strict"; +/** + * @togglekit/flags-core-ts + * + * TypeScript core evaluator for Togglekit feature flags + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.evaluateConditions = exports.evaluateCondition = exports.computeRolloutBucket = exports.Evaluator = void 0; +// Export main evaluator class +var evaluator_1 = require("./evaluator"); +Object.defineProperty(exports, "Evaluator", { enumerable: true, get: function () { return evaluator_1.Evaluator; } }); +// Export utility functions +var rollout_1 = require("./rollout"); +Object.defineProperty(exports, "computeRolloutBucket", { enumerable: true, get: function () { return rollout_1.computeRolloutBucket; } }); +var conditions_1 = require("./conditions"); +Object.defineProperty(exports, "evaluateCondition", { enumerable: true, get: function () { return conditions_1.evaluateCondition; } }); +Object.defineProperty(exports, "evaluateConditions", { enumerable: true, get: function () { return conditions_1.evaluateConditions; } }); +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/index.js.map b/packages/flags-core-ts/src/index.js.map new file mode 100644 index 0000000..14452ef --- /dev/null +++ b/packages/flags-core-ts/src/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";AAAA;;;;GAIG;;;AAEH,8BAA8B;AAC9B,yCAAwC;AAA/B,sGAAA,SAAS,OAAA;AAelB,2BAA2B;AAC3B,qCAAiD;AAAxC,+GAAA,oBAAoB,OAAA;AAC7B,2CAAqE;AAA5D,+GAAA,iBAAiB,OAAA;AAAE,gHAAA,kBAAkB,OAAA"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/rollout.d.ts b/packages/flags-core-ts/src/rollout.d.ts new file mode 100644 index 0000000..0c4b734 --- /dev/null +++ b/packages/flags-core-ts/src/rollout.d.ts @@ -0,0 +1,24 @@ +/** + * Rollout hashing utilities for deterministic percentage bucketing + */ +/** + * Compute a deterministic rollout bucket (0-100) for a user and flag + * + * Given the same userId and flagKey, this will always return the same bucket. + * This ensures consistent feature flag behavior for each user. + * + * @param userId - Unique user identifier + * @param flagKey - Feature flag key + * @returns A bucket number between 0 and 100 (inclusive) + * + * @example + * ```typescript + * const bucket = computeRolloutBucket('user-123', 'new-feature'); + * // bucket will always be the same for this user/flag combination + * if (bucket <= 25) { + * // User is in the 25% rollout + * } + * ``` + */ +export declare function computeRolloutBucket(userId: string, flagKey: string): number; +//# sourceMappingURL=rollout.d.ts.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/rollout.d.ts.map b/packages/flags-core-ts/src/rollout.d.ts.map new file mode 100644 index 0000000..42880a9 --- /dev/null +++ b/packages/flags-core-ts/src/rollout.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"rollout.d.ts","sourceRoot":"","sources":["rollout.ts"],"names":[],"mappings":"AAAA;;GAEG;AAgBH;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAQ5E"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/rollout.js b/packages/flags-core-ts/src/rollout.js new file mode 100644 index 0000000..603e6a5 --- /dev/null +++ b/packages/flags-core-ts/src/rollout.js @@ -0,0 +1,47 @@ +"use strict"; +/** + * Rollout hashing utilities for deterministic percentage bucketing + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.computeRolloutBucket = computeRolloutBucket; +/** + * Simple hash function for strings + * Uses a basic polynomial rolling hash algorithm + */ +function simpleHash(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); +} +/** + * Compute a deterministic rollout bucket (0-100) for a user and flag + * + * Given the same userId and flagKey, this will always return the same bucket. + * This ensures consistent feature flag behavior for each user. + * + * @param userId - Unique user identifier + * @param flagKey - Feature flag key + * @returns A bucket number between 0 and 100 (inclusive) + * + * @example + * ```typescript + * const bucket = computeRolloutBucket('user-123', 'new-feature'); + * // bucket will always be the same for this user/flag combination + * if (bucket <= 25) { + * // User is in the 25% rollout + * } + * ``` + */ +function computeRolloutBucket(userId, flagKey) { + // Combine userId and flagKey to create a unique hash input + const combined = `${userId}:${flagKey}`; + const hash = simpleHash(combined); + // Map hash to 0-100 range + // Using 101 to ensure even distribution including 100 + return hash % 101; +} +//# sourceMappingURL=rollout.js.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/rollout.js.map b/packages/flags-core-ts/src/rollout.js.map new file mode 100644 index 0000000..8d28e83 --- /dev/null +++ b/packages/flags-core-ts/src/rollout.js.map @@ -0,0 +1 @@ +{"version":3,"file":"rollout.js","sourceRoot":"","sources":["rollout.ts"],"names":[],"mappings":";AAAA;;GAEG;;AAmCH,oDAQC;AAzCD;;;GAGG;AACH,SAAS,UAAU,CAAC,GAAW;IAC7B,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACpC,MAAM,IAAI,GAAG,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAC/B,IAAI,GAAG,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC;QACnC,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,4BAA4B;IAClD,CAAC;IACD,OAAO,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AACxB,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAgB,oBAAoB,CAAC,MAAc,EAAE,OAAe;IAClE,2DAA2D;IAC3D,MAAM,QAAQ,GAAG,GAAG,MAAM,IAAI,OAAO,EAAE,CAAC;IACxC,MAAM,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC;IAElC,0BAA0B;IAC1B,sDAAsD;IACtD,OAAO,IAAI,GAAG,GAAG,CAAC;AACpB,CAAC"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/types.d.ts b/packages/flags-core-ts/src/types.d.ts new file mode 100644 index 0000000..7a3529a --- /dev/null +++ b/packages/flags-core-ts/src/types.d.ts @@ -0,0 +1,93 @@ +/** + * Core type definitions for the Togglekit feature flag system + */ +/** + * User/request context for flag evaluation + */ +export interface Context { + /** Unique user identifier for rollout bucketing */ + userId?: string; + /** Additional attributes for rule matching */ + attributes: Record; +} +/** + * Supported condition operators + */ +export type ConditionOperator = 'eq' | 'neq' | 'in' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains'; +/** + * Single matching condition + */ +export interface Condition { + /** Attribute key to check in context */ + attribute: string; + /** Comparison operator */ + operator: ConditionOperator; + /** Value to compare against */ + value: unknown; +} +/** + * Targeting rule with conditions and optional percentage rollout + */ +export interface Rule { + /** Conditions that must all be true (AND logic) */ + conditions: Condition[]; + /** Percentage of users who match conditions to include (0-100) */ + percentage?: number; + /** Value to return if rule matches (for boolean flags) */ + value?: boolean; + /** Variant key to return if rule matches (for variant flags) */ + variant?: string; +} +/** + * Named variant option + */ +export interface Variant { + /** Unique variant key */ + key: string; + /** Optional variant value/payload */ + value?: unknown; +} +/** + * Complete feature flag definition + */ +export interface Flag { + /** Unique flag key */ + key: string; + /** Default value when no rules match */ + defaultValue: boolean | string; + /** Ordered rules to evaluate (first match wins) */ + rules?: Rule[]; + /** Available variants (for variant flags) */ + variants?: Variant[]; + /** Optional description */ + description?: string; +} +/** + * Complete flag configuration (map of flag keys to definitions) + */ +export interface FlagConfig { + [flagKey: string]: Flag; +} +/** + * Reason for evaluation result (for debugging) + */ +export type EvaluationReason = 'default' | 'rule_match' | 'rollout' | 'rollout_excluded' | 'flag_not_found' | 'error'; +/** + * Result of flag evaluation + */ +export interface EvaluationResult { + /** The evaluated value */ + value: T; + /** Reason for this result */ + reason: EvaluationReason; + /** Optional additional context about the evaluation */ + metadata?: { + /** Which rule index matched (if applicable) */ + ruleIndex?: number; + /** Rollout bucket (0-100) if userId provided */ + bucket?: number; + /** Error message if reason is 'error' */ + error?: string; + }; +} +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/types.d.ts.map b/packages/flags-core-ts/src/types.d.ts.map new file mode 100644 index 0000000..828594a --- /dev/null +++ b/packages/flags-core-ts/src/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;GAEG;AACH,MAAM,WAAW,OAAO;IACtB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrC;AAED;;GAEG;AACH,MAAM,MAAM,iBAAiB,GACzB,IAAI,GACJ,KAAK,GACL,IAAI,GACJ,IAAI,GACJ,KAAK,GACL,IAAI,GACJ,KAAK,GACL,UAAU,CAAC;AAEf;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,0BAA0B;IAC1B,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,+BAA+B;IAC/B,KAAK,EAAE,OAAO,CAAC;CAChB;AAED;;GAEG;AACH,MAAM,WAAW,IAAI;IACnB,mDAAmD;IACnD,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0DAA0D;IAC1D,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,gEAAgE;IAChE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,OAAO;IACtB,yBAAyB;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,qCAAqC;IACrC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;GAEG;AACH,MAAM,WAAW,IAAI;IACnB,sBAAsB;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,wCAAwC;IACxC,YAAY,EAAE,OAAO,GAAG,MAAM,CAAC;IAC/B,mDAAmD;IACnD,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IACf,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;IACrB,2BAA2B;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,MAAM,gBAAgB,GACxB,SAAS,GACT,YAAY,GACZ,SAAS,GACT,kBAAkB,GAClB,gBAAgB,GAChB,OAAO,CAAC;AAEZ;;GAEG;AACH,MAAM,WAAW,gBAAgB,CAAC,CAAC,GAAG,OAAO,GAAG,MAAM;IACpD,0BAA0B;IAC1B,KAAK,EAAE,CAAC,CAAC;IACT,6BAA6B;IAC7B,MAAM,EAAE,gBAAgB,CAAC;IACzB,uDAAuD;IACvD,QAAQ,CAAC,EAAE;QACT,+CAA+C;QAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,gDAAgD;QAChD,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,yCAAyC;QACzC,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;CACH"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/types.js b/packages/flags-core-ts/src/types.js new file mode 100644 index 0000000..968f0ed --- /dev/null +++ b/packages/flags-core-ts/src/types.js @@ -0,0 +1,6 @@ +"use strict"; +/** + * Core type definitions for the Togglekit feature flag system + */ +Object.defineProperty(exports, "__esModule", { value: true }); +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/types.js.map b/packages/flags-core-ts/src/types.js.map new file mode 100644 index 0000000..70eb584 --- /dev/null +++ b/packages/flags-core-ts/src/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":";AAAA;;GAEG"} \ No newline at end of file diff --git a/packages/flags-core-ts/tsconfig.json b/packages/flags-core-ts/tsconfig.json index 7af579c..42e8a32 100644 --- a/packages/flags-core-ts/tsconfig.json +++ b/packages/flags-core-ts/tsconfig.json @@ -14,7 +14,8 @@ "target": "ES2020", "lib": ["ES2020"], "moduleResolution": "node", - "resolveJsonModule": true + "resolveJsonModule": true, + "composite": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "test"] From 66e622cd71cb0485d1cfb0b26bf37d8bfd06b97c Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 1 Dec 2025 19:39:10 -0800 Subject: [PATCH 3/5] fix(lint): Remove generated files and ignore them in ESLint - Remove accidentally committed .d.ts and .js files from src/ directories - Add .gitignore files to each package to prevent committing generated files - Update ESLint configs to ignore any generated .d.ts and .js files - Generated files should only exist in dist/ directory, not src/ --- packages/flags-client-node/.eslintrc.json | 2 +- packages/flags-client-node/.gitignore | 12 + packages/flags-client-web/.eslintrc.json | 2 +- packages/flags-client-web/.gitignore | 12 + packages/flags-core-ts/.eslintrc.json | 2 +- packages/flags-core-ts/.gitignore | 12 + packages/flags-core-ts/src/conditions.d.ts | 28 --- .../flags-core-ts/src/conditions.d.ts.map | 1 - packages/flags-core-ts/src/conditions.js | 152 ------------- packages/flags-core-ts/src/conditions.js.map | 1 - packages/flags-core-ts/src/evaluator.d.ts | 107 --------- packages/flags-core-ts/src/evaluator.d.ts.map | 1 - packages/flags-core-ts/src/evaluator.js | 206 ------------------ packages/flags-core-ts/src/evaluator.js.map | 1 - packages/flags-core-ts/src/index.d.ts | 10 - packages/flags-core-ts/src/index.d.ts.map | 1 - packages/flags-core-ts/src/index.js | 18 -- packages/flags-core-ts/src/index.js.map | 1 - packages/flags-core-ts/src/rollout.d.ts | 24 -- packages/flags-core-ts/src/rollout.d.ts.map | 1 - packages/flags-core-ts/src/rollout.js | 47 ---- packages/flags-core-ts/src/rollout.js.map | 1 - packages/flags-core-ts/src/types.d.ts | 93 -------- packages/flags-core-ts/src/types.d.ts.map | 1 - packages/flags-core-ts/src/types.js | 6 - packages/flags-core-ts/src/types.js.map | 1 - 26 files changed, 39 insertions(+), 704 deletions(-) create mode 100644 packages/flags-client-node/.gitignore create mode 100644 packages/flags-client-web/.gitignore create mode 100644 packages/flags-core-ts/.gitignore delete mode 100644 packages/flags-core-ts/src/conditions.d.ts delete mode 100644 packages/flags-core-ts/src/conditions.d.ts.map delete mode 100644 packages/flags-core-ts/src/conditions.js delete mode 100644 packages/flags-core-ts/src/conditions.js.map delete mode 100644 packages/flags-core-ts/src/evaluator.d.ts delete mode 100644 packages/flags-core-ts/src/evaluator.d.ts.map delete mode 100644 packages/flags-core-ts/src/evaluator.js delete mode 100644 packages/flags-core-ts/src/evaluator.js.map delete mode 100644 packages/flags-core-ts/src/index.d.ts delete mode 100644 packages/flags-core-ts/src/index.d.ts.map delete mode 100644 packages/flags-core-ts/src/index.js delete mode 100644 packages/flags-core-ts/src/index.js.map delete mode 100644 packages/flags-core-ts/src/rollout.d.ts delete mode 100644 packages/flags-core-ts/src/rollout.d.ts.map delete mode 100644 packages/flags-core-ts/src/rollout.js delete mode 100644 packages/flags-core-ts/src/rollout.js.map delete mode 100644 packages/flags-core-ts/src/types.d.ts delete mode 100644 packages/flags-core-ts/src/types.d.ts.map delete mode 100644 packages/flags-core-ts/src/types.js delete mode 100644 packages/flags-core-ts/src/types.js.map diff --git a/packages/flags-client-node/.eslintrc.json b/packages/flags-client-node/.eslintrc.json index 25d82b6..435d602 100644 --- a/packages/flags-client-node/.eslintrc.json +++ b/packages/flags-client-node/.eslintrc.json @@ -14,6 +14,6 @@ "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }] }, - "ignorePatterns": ["dist", "node_modules", "test"] + "ignorePatterns": ["dist", "node_modules", "test", "**/*.d.ts", "**/*.js"] } diff --git a/packages/flags-client-node/.gitignore b/packages/flags-client-node/.gitignore new file mode 100644 index 0000000..e07e2a6 --- /dev/null +++ b/packages/flags-client-node/.gitignore @@ -0,0 +1,12 @@ +# Compiled output +dist/ +*.tsbuildinfo + +# Generated files in src (from composite builds) +src/**/*.d.ts +src/**/*.d.ts.map +src/**/*.js +src/**/*.js.map +!src/**/*.test.ts +!src/**/*.test.js + diff --git a/packages/flags-client-web/.eslintrc.json b/packages/flags-client-web/.eslintrc.json index 25d82b6..435d602 100644 --- a/packages/flags-client-web/.eslintrc.json +++ b/packages/flags-client-web/.eslintrc.json @@ -14,6 +14,6 @@ "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }] }, - "ignorePatterns": ["dist", "node_modules", "test"] + "ignorePatterns": ["dist", "node_modules", "test", "**/*.d.ts", "**/*.js"] } diff --git a/packages/flags-client-web/.gitignore b/packages/flags-client-web/.gitignore new file mode 100644 index 0000000..e07e2a6 --- /dev/null +++ b/packages/flags-client-web/.gitignore @@ -0,0 +1,12 @@ +# Compiled output +dist/ +*.tsbuildinfo + +# Generated files in src (from composite builds) +src/**/*.d.ts +src/**/*.d.ts.map +src/**/*.js +src/**/*.js.map +!src/**/*.test.ts +!src/**/*.test.js + diff --git a/packages/flags-core-ts/.eslintrc.json b/packages/flags-core-ts/.eslintrc.json index 160314e..c128059 100644 --- a/packages/flags-core-ts/.eslintrc.json +++ b/packages/flags-core-ts/.eslintrc.json @@ -16,6 +16,6 @@ "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] }, - "ignorePatterns": ["dist", "node_modules", "test"] + "ignorePatterns": ["dist", "node_modules", "test", "**/*.d.ts", "**/*.js"] } diff --git a/packages/flags-core-ts/.gitignore b/packages/flags-core-ts/.gitignore new file mode 100644 index 0000000..e07e2a6 --- /dev/null +++ b/packages/flags-core-ts/.gitignore @@ -0,0 +1,12 @@ +# Compiled output +dist/ +*.tsbuildinfo + +# Generated files in src (from composite builds) +src/**/*.d.ts +src/**/*.d.ts.map +src/**/*.js +src/**/*.js.map +!src/**/*.test.ts +!src/**/*.test.js + diff --git a/packages/flags-core-ts/src/conditions.d.ts b/packages/flags-core-ts/src/conditions.d.ts deleted file mode 100644 index 21d99d5..0000000 --- a/packages/flags-core-ts/src/conditions.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Condition evaluation logic for feature flag rules - */ -import { Condition, Context } from './types'; -/** - * Evaluate a single condition against a context - * - * @param condition - The condition to evaluate - * @param context - The evaluation context with user attributes - * @returns true if the condition matches, false otherwise - * - * @example - * ```typescript - * const condition: Condition = { attribute: 'country', operator: 'eq', value: 'US' }; - * const context: Context = { attributes: { country: 'US' } }; - * evaluateCondition(condition, context); // true - * ``` - */ -export declare function evaluateCondition(condition: Condition, context: Context): boolean; -/** - * Evaluate all conditions in a rule (AND logic) - * - * @param conditions - Array of conditions to evaluate - * @param context - The evaluation context - * @returns true if all conditions match, false if any fail - */ -export declare function evaluateConditions(conditions: Condition[], context: Context): boolean; -//# sourceMappingURL=conditions.d.ts.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/conditions.d.ts.map b/packages/flags-core-ts/src/conditions.d.ts.map deleted file mode 100644 index d2e52e5..0000000 --- a/packages/flags-core-ts/src/conditions.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"conditions.d.ts","sourceRoot":"","sources":["conditions.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAuE7C;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CA2DjF;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,SAAS,EAAE,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAMrF"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/conditions.js b/packages/flags-core-ts/src/conditions.js deleted file mode 100644 index 273495e..0000000 --- a/packages/flags-core-ts/src/conditions.js +++ /dev/null @@ -1,152 +0,0 @@ -"use strict"; -/** - * Condition evaluation logic for feature flag rules - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.evaluateCondition = evaluateCondition; -exports.evaluateConditions = evaluateConditions; -/** - * Get a value from the context by attribute path - * Supports nested paths like "user.plan" or simple keys like "country" - */ -function getAttributeValue(context, attribute) { - // First check if it's a direct attribute - if (attribute in context.attributes) { - return context.attributes[attribute]; - } - // Handle nested paths (e.g., "user.plan") - const parts = attribute.split('.'); - let value = context.attributes; - for (const part of parts) { - if (value && typeof value === 'object' && part in value) { - value = value[part]; - } - else { - return undefined; - } - } - return value; -} -/** - * Compare two values for equality - * Handles type coercion for common cases - */ -function areEqual(a, b) { - // Strict equality first - if (a === b) - return true; - // Handle null/undefined - if (a == null || b == null) - return a == b; - // Try string comparison (common case: numbers as strings) - return String(a) === String(b); -} -/** - * Convert value to number if possible - */ -function toNumber(value) { - if (typeof value === 'number') - return value; - if (typeof value === 'string') { - const num = parseFloat(value); - return isNaN(num) ? null : num; - } - return null; -} -/** - * Check if a value is contained in another value - * - String in string (substring) - * - Value in array (includes) - */ -function contains(haystack, needle) { - if (typeof haystack === 'string' && typeof needle === 'string') { - return haystack.includes(needle); - } - if (Array.isArray(haystack)) { - return haystack.some(item => areEqual(item, needle)); - } - return false; -} -/** - * Evaluate a single condition against a context - * - * @param condition - The condition to evaluate - * @param context - The evaluation context with user attributes - * @returns true if the condition matches, false otherwise - * - * @example - * ```typescript - * const condition: Condition = { attribute: 'country', operator: 'eq', value: 'US' }; - * const context: Context = { attributes: { country: 'US' } }; - * evaluateCondition(condition, context); // true - * ``` - */ -function evaluateCondition(condition, context) { - const { attribute, operator, value } = condition; - const actualValue = getAttributeValue(context, attribute); - // If attribute doesn't exist in context, condition fails - // Exception: 'neq' can match if attribute is missing and value is not undefined - if (actualValue === undefined) { - if (operator === 'neq') { - return value !== undefined; - } - return false; - } - switch (operator) { - case 'eq': - return areEqual(actualValue, value); - case 'neq': - return !areEqual(actualValue, value); - case 'in': - if (!Array.isArray(value)) - return false; - return value.some(v => areEqual(actualValue, v)); - case 'gt': { - const numActual = toNumber(actualValue); - const numValue = toNumber(value); - if (numActual === null || numValue === null) - return false; - return numActual > numValue; - } - case 'gte': { - const numActual = toNumber(actualValue); - const numValue = toNumber(value); - if (numActual === null || numValue === null) - return false; - return numActual >= numValue; - } - case 'lt': { - const numActual = toNumber(actualValue); - const numValue = toNumber(value); - if (numActual === null || numValue === null) - return false; - return numActual < numValue; - } - case 'lte': { - const numActual = toNumber(actualValue); - const numValue = toNumber(value); - if (numActual === null || numValue === null) - return false; - return numActual <= numValue; - } - case 'contains': - return contains(actualValue, value); - default: - // Unknown operator - return false; - } -} -/** - * Evaluate all conditions in a rule (AND logic) - * - * @param conditions - Array of conditions to evaluate - * @param context - The evaluation context - * @returns true if all conditions match, false if any fail - */ -function evaluateConditions(conditions, context) { - if (!conditions || conditions.length === 0) { - return true; // No conditions means rule matches - } - return conditions.every(condition => evaluateCondition(condition, context)); -} -//# sourceMappingURL=conditions.js.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/conditions.js.map b/packages/flags-core-ts/src/conditions.js.map deleted file mode 100644 index 8436a13..0000000 --- a/packages/flags-core-ts/src/conditions.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"conditions.js","sourceRoot":"","sources":["conditions.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/evaluator.d.ts b/packages/flags-core-ts/src/evaluator.d.ts deleted file mode 100644 index 3e4974c..0000000 --- a/packages/flags-core-ts/src/evaluator.d.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Main evaluator for feature flags - */ -import { FlagConfig, Context, EvaluationResult } from './types'; -/** - * Feature flag evaluator - * - * Evaluates feature flags based on configuration and user context. - * Supports boolean flags, variant flags, conditional targeting, and percentage rollouts. - * - * @example - * ```typescript - * const config: FlagConfig = { - * 'new-feature': { - * key: 'new-feature', - * defaultValue: false, - * rules: [{ - * conditions: [{ attribute: 'plan', operator: 'eq', value: 'premium' }], - * value: true - * }] - * } - * }; - * - * const evaluator = new Evaluator(config); - * const result = evaluator.evalBool('new-feature', { - * userId: 'user-123', - * attributes: { plan: 'premium' } - * }); - * // result: { value: true, reason: 'rule_match', metadata: { ruleIndex: 0 } } - * ``` - */ -export declare class Evaluator { - private config; - /** - * Create a new evaluator with the given flag configuration - * - * @param config - Map of flag keys to flag definitions - */ - constructor(config: FlagConfig); - /** - * Evaluate a boolean feature flag - * - * @param flagKey - The flag key to evaluate - * @param context - User context with attributes - * @returns Evaluation result with boolean value and reason - * - * @example - * ```typescript - * const result = evaluator.evalBool('dark-mode', { attributes: { theme: 'dark' } }); - * if (result.value) { - * // Enable dark mode - * } - * ``` - */ - evalBool(flagKey: string, context: Context): EvaluationResult; - /** - * Evaluate a variant feature flag - * - * Returns a variant key (string) based on rules or default value - * - * @param flagKey - The flag key to evaluate - * @param context - User context with attributes - * @returns Evaluation result with variant key and reason - * - * @example - * ```typescript - * const result = evaluator.evalVariant('pricing-page', { - * userId: 'user-123', - * attributes: { plan: 'free' } - * }); - * switch (result.value) { - * case 'layout-a': // Show layout A - * case 'layout-b': // Show layout B - * default: // Show default layout - * } - * ``` - */ - evalVariant(flagKey: string, context: Context): EvaluationResult; - /** - * Evaluate a single rule - * - * @param rule - The rule to evaluate - * @param context - User context - * @param flagKey - Flag key (for rollout bucketing) - * @returns Object indicating if rule matched and the reason - */ - private evaluateRule; - /** - * Get the raw flag configuration - * Useful for debugging or serialization - */ - getConfig(): FlagConfig; - /** - * Check if a flag exists in the configuration - * - * @param flagKey - The flag key to check - * @returns true if flag exists, false otherwise - */ - hasFlag(flagKey: string): boolean; - /** - * Get all flag keys in the configuration - * - * @returns Array of all flag keys - */ - getFlagKeys(): string[]; -} -//# sourceMappingURL=evaluator.d.ts.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/evaluator.d.ts.map b/packages/flags-core-ts/src/evaluator.d.ts.map deleted file mode 100644 index 30ed86b..0000000 --- a/packages/flags-core-ts/src/evaluator.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"evaluator.d.ts","sourceRoot":"","sources":["evaluator.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,gBAAgB,EAAQ,MAAM,SAAS,CAAC;AAItE;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,MAAM,CAAa;IAE3B;;;;OAIG;gBACS,MAAM,EAAE,UAAU;IAI9B;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,gBAAgB,CAAC,OAAO,CAAC;IAsCtE;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,gBAAgB,CAAC,MAAM,CAAC;IAsCxE;;;;;;;OAOG;IACH,OAAO,CAAC,YAAY;IAiCpB;;;OAGG;IACH,SAAS,IAAI,UAAU;IAIvB;;;;;OAKG;IACH,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAIjC;;;;OAIG;IACH,WAAW,IAAI,MAAM,EAAE;CAGxB"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/evaluator.js b/packages/flags-core-ts/src/evaluator.js deleted file mode 100644 index 1474caa..0000000 --- a/packages/flags-core-ts/src/evaluator.js +++ /dev/null @@ -1,206 +0,0 @@ -"use strict"; -/** - * Main evaluator for feature flags - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.Evaluator = void 0; -const conditions_1 = require("./conditions"); -const rollout_1 = require("./rollout"); -/** - * Feature flag evaluator - * - * Evaluates feature flags based on configuration and user context. - * Supports boolean flags, variant flags, conditional targeting, and percentage rollouts. - * - * @example - * ```typescript - * const config: FlagConfig = { - * 'new-feature': { - * key: 'new-feature', - * defaultValue: false, - * rules: [{ - * conditions: [{ attribute: 'plan', operator: 'eq', value: 'premium' }], - * value: true - * }] - * } - * }; - * - * const evaluator = new Evaluator(config); - * const result = evaluator.evalBool('new-feature', { - * userId: 'user-123', - * attributes: { plan: 'premium' } - * }); - * // result: { value: true, reason: 'rule_match', metadata: { ruleIndex: 0 } } - * ``` - */ -class Evaluator { - /** - * Create a new evaluator with the given flag configuration - * - * @param config - Map of flag keys to flag definitions - */ - constructor(config) { - this.config = config; - } - /** - * Evaluate a boolean feature flag - * - * @param flagKey - The flag key to evaluate - * @param context - User context with attributes - * @returns Evaluation result with boolean value and reason - * - * @example - * ```typescript - * const result = evaluator.evalBool('dark-mode', { attributes: { theme: 'dark' } }); - * if (result.value) { - * // Enable dark mode - * } - * ``` - */ - evalBool(flagKey, context) { - const flag = this.config[flagKey]; - if (!flag) { - return { - value: false, - reason: 'flag_not_found', - metadata: { error: `Flag "${flagKey}" not found in configuration` } - }; - } - // Evaluate rules in order (first match wins) - if (flag.rules && flag.rules.length > 0) { - for (let i = 0; i < flag.rules.length; i++) { - const rule = flag.rules[i]; - const ruleResult = this.evaluateRule(rule, context, flagKey); - if (ruleResult.matched) { - const value = rule.value !== undefined ? rule.value : true; - return { - value: Boolean(value), - reason: ruleResult.reason, - metadata: { - ruleIndex: i, - bucket: ruleResult.bucket - } - }; - } - } - } - // No rules matched, use default - return { - value: Boolean(flag.defaultValue), - reason: 'default' - }; - } - /** - * Evaluate a variant feature flag - * - * Returns a variant key (string) based on rules or default value - * - * @param flagKey - The flag key to evaluate - * @param context - User context with attributes - * @returns Evaluation result with variant key and reason - * - * @example - * ```typescript - * const result = evaluator.evalVariant('pricing-page', { - * userId: 'user-123', - * attributes: { plan: 'free' } - * }); - * switch (result.value) { - * case 'layout-a': // Show layout A - * case 'layout-b': // Show layout B - * default: // Show default layout - * } - * ``` - */ - evalVariant(flagKey, context) { - const flag = this.config[flagKey]; - if (!flag) { - return { - value: 'default', - reason: 'flag_not_found', - metadata: { error: `Flag "${flagKey}" not found in configuration` } - }; - } - // Evaluate rules in order (first match wins) - if (flag.rules && flag.rules.length > 0) { - for (let i = 0; i < flag.rules.length; i++) { - const rule = flag.rules[i]; - const ruleResult = this.evaluateRule(rule, context, flagKey); - if (ruleResult.matched) { - const variant = rule.variant || String(flag.defaultValue); - return { - value: variant, - reason: ruleResult.reason, - metadata: { - ruleIndex: i, - bucket: ruleResult.bucket - } - }; - } - } - } - // No rules matched, use default - return { - value: String(flag.defaultValue), - reason: 'default' - }; - } - /** - * Evaluate a single rule - * - * @param rule - The rule to evaluate - * @param context - User context - * @param flagKey - Flag key (for rollout bucketing) - * @returns Object indicating if rule matched and the reason - */ - evaluateRule(rule, context, flagKey) { - // First, check if all conditions match - const conditionsMatch = (0, conditions_1.evaluateConditions)(rule.conditions || [], context); - if (!conditionsMatch) { - return { matched: false, reason: 'rollout_excluded' }; - } - // Conditions match, now check percentage rollout - if (rule.percentage !== undefined && rule.percentage < 100) { - // Need userId for rollout bucketing - if (!context.userId) { - // No userId provided, can't do rollout - exclude by default - return { matched: false, reason: 'rollout_excluded' }; - } - const bucket = (0, rollout_1.computeRolloutBucket)(context.userId, flagKey); - if (bucket <= rule.percentage) { - return { matched: true, reason: 'rollout', bucket }; - } - else { - return { matched: false, reason: 'rollout_excluded', bucket }; - } - } - // All conditions match and no rollout restriction - return { matched: true, reason: 'rule_match' }; - } - /** - * Get the raw flag configuration - * Useful for debugging or serialization - */ - getConfig() { - return this.config; - } - /** - * Check if a flag exists in the configuration - * - * @param flagKey - The flag key to check - * @returns true if flag exists, false otherwise - */ - hasFlag(flagKey) { - return flagKey in this.config; - } - /** - * Get all flag keys in the configuration - * - * @returns Array of all flag keys - */ - getFlagKeys() { - return Object.keys(this.config); - } -} -exports.Evaluator = Evaluator; -//# sourceMappingURL=evaluator.js.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/evaluator.js.map b/packages/flags-core-ts/src/evaluator.js.map deleted file mode 100644 index 0d944cb..0000000 --- a/packages/flags-core-ts/src/evaluator.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"evaluator.js","sourceRoot":"","sources":["evaluator.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/index.d.ts b/packages/flags-core-ts/src/index.d.ts deleted file mode 100644 index 3eca0ff..0000000 --- a/packages/flags-core-ts/src/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @togglekit/flags-core-ts - * - * TypeScript core evaluator for Togglekit feature flags - */ -export { Evaluator } from './evaluator'; -export type { Context, Condition, ConditionOperator, Rule, Variant, Flag, FlagConfig, EvaluationResult, EvaluationReason } from './types'; -export { computeRolloutBucket } from './rollout'; -export { evaluateCondition, evaluateConditions } from './conditions'; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/index.d.ts.map b/packages/flags-core-ts/src/index.d.ts.map deleted file mode 100644 index afa3f31..0000000 --- a/packages/flags-core-ts/src/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAGxC,YAAY,EACV,OAAO,EACP,SAAS,EACT,iBAAiB,EACjB,IAAI,EACJ,OAAO,EACP,IAAI,EACJ,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EACjB,MAAM,SAAS,CAAC;AAGjB,OAAO,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAC;AACjD,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/index.js b/packages/flags-core-ts/src/index.js deleted file mode 100644 index 534787f..0000000 --- a/packages/flags-core-ts/src/index.js +++ /dev/null @@ -1,18 +0,0 @@ -"use strict"; -/** - * @togglekit/flags-core-ts - * - * TypeScript core evaluator for Togglekit feature flags - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.evaluateConditions = exports.evaluateCondition = exports.computeRolloutBucket = exports.Evaluator = void 0; -// Export main evaluator class -var evaluator_1 = require("./evaluator"); -Object.defineProperty(exports, "Evaluator", { enumerable: true, get: function () { return evaluator_1.Evaluator; } }); -// Export utility functions -var rollout_1 = require("./rollout"); -Object.defineProperty(exports, "computeRolloutBucket", { enumerable: true, get: function () { return rollout_1.computeRolloutBucket; } }); -var conditions_1 = require("./conditions"); -Object.defineProperty(exports, "evaluateCondition", { enumerable: true, get: function () { return conditions_1.evaluateCondition; } }); -Object.defineProperty(exports, "evaluateConditions", { enumerable: true, get: function () { return conditions_1.evaluateConditions; } }); -//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/index.js.map b/packages/flags-core-ts/src/index.js.map deleted file mode 100644 index 14452ef..0000000 --- a/packages/flags-core-ts/src/index.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";AAAA;;;;GAIG;;;AAEH,8BAA8B;AAC9B,yCAAwC;AAA/B,sGAAA,SAAS,OAAA;AAelB,2BAA2B;AAC3B,qCAAiD;AAAxC,+GAAA,oBAAoB,OAAA;AAC7B,2CAAqE;AAA5D,+GAAA,iBAAiB,OAAA;AAAE,gHAAA,kBAAkB,OAAA"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/rollout.d.ts b/packages/flags-core-ts/src/rollout.d.ts deleted file mode 100644 index 0c4b734..0000000 --- a/packages/flags-core-ts/src/rollout.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Rollout hashing utilities for deterministic percentage bucketing - */ -/** - * Compute a deterministic rollout bucket (0-100) for a user and flag - * - * Given the same userId and flagKey, this will always return the same bucket. - * This ensures consistent feature flag behavior for each user. - * - * @param userId - Unique user identifier - * @param flagKey - Feature flag key - * @returns A bucket number between 0 and 100 (inclusive) - * - * @example - * ```typescript - * const bucket = computeRolloutBucket('user-123', 'new-feature'); - * // bucket will always be the same for this user/flag combination - * if (bucket <= 25) { - * // User is in the 25% rollout - * } - * ``` - */ -export declare function computeRolloutBucket(userId: string, flagKey: string): number; -//# sourceMappingURL=rollout.d.ts.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/rollout.d.ts.map b/packages/flags-core-ts/src/rollout.d.ts.map deleted file mode 100644 index 42880a9..0000000 --- a/packages/flags-core-ts/src/rollout.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"rollout.d.ts","sourceRoot":"","sources":["rollout.ts"],"names":[],"mappings":"AAAA;;GAEG;AAgBH;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAQ5E"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/rollout.js b/packages/flags-core-ts/src/rollout.js deleted file mode 100644 index 603e6a5..0000000 --- a/packages/flags-core-ts/src/rollout.js +++ /dev/null @@ -1,47 +0,0 @@ -"use strict"; -/** - * Rollout hashing utilities for deterministic percentage bucketing - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.computeRolloutBucket = computeRolloutBucket; -/** - * Simple hash function for strings - * Uses a basic polynomial rolling hash algorithm - */ -function simpleHash(str) { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32-bit integer - } - return Math.abs(hash); -} -/** - * Compute a deterministic rollout bucket (0-100) for a user and flag - * - * Given the same userId and flagKey, this will always return the same bucket. - * This ensures consistent feature flag behavior for each user. - * - * @param userId - Unique user identifier - * @param flagKey - Feature flag key - * @returns A bucket number between 0 and 100 (inclusive) - * - * @example - * ```typescript - * const bucket = computeRolloutBucket('user-123', 'new-feature'); - * // bucket will always be the same for this user/flag combination - * if (bucket <= 25) { - * // User is in the 25% rollout - * } - * ``` - */ -function computeRolloutBucket(userId, flagKey) { - // Combine userId and flagKey to create a unique hash input - const combined = `${userId}:${flagKey}`; - const hash = simpleHash(combined); - // Map hash to 0-100 range - // Using 101 to ensure even distribution including 100 - return hash % 101; -} -//# sourceMappingURL=rollout.js.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/rollout.js.map b/packages/flags-core-ts/src/rollout.js.map deleted file mode 100644 index 8d28e83..0000000 --- a/packages/flags-core-ts/src/rollout.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"rollout.js","sourceRoot":"","sources":["rollout.ts"],"names":[],"mappings":";AAAA;;GAEG;;AAmCH,oDAQC;AAzCD;;;GAGG;AACH,SAAS,UAAU,CAAC,GAAW;IAC7B,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACpC,MAAM,IAAI,GAAG,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAC/B,IAAI,GAAG,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC;QACnC,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,4BAA4B;IAClD,CAAC;IACD,OAAO,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AACxB,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAgB,oBAAoB,CAAC,MAAc,EAAE,OAAe;IAClE,2DAA2D;IAC3D,MAAM,QAAQ,GAAG,GAAG,MAAM,IAAI,OAAO,EAAE,CAAC;IACxC,MAAM,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC;IAElC,0BAA0B;IAC1B,sDAAsD;IACtD,OAAO,IAAI,GAAG,GAAG,CAAC;AACpB,CAAC"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/types.d.ts b/packages/flags-core-ts/src/types.d.ts deleted file mode 100644 index 7a3529a..0000000 --- a/packages/flags-core-ts/src/types.d.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Core type definitions for the Togglekit feature flag system - */ -/** - * User/request context for flag evaluation - */ -export interface Context { - /** Unique user identifier for rollout bucketing */ - userId?: string; - /** Additional attributes for rule matching */ - attributes: Record; -} -/** - * Supported condition operators - */ -export type ConditionOperator = 'eq' | 'neq' | 'in' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains'; -/** - * Single matching condition - */ -export interface Condition { - /** Attribute key to check in context */ - attribute: string; - /** Comparison operator */ - operator: ConditionOperator; - /** Value to compare against */ - value: unknown; -} -/** - * Targeting rule with conditions and optional percentage rollout - */ -export interface Rule { - /** Conditions that must all be true (AND logic) */ - conditions: Condition[]; - /** Percentage of users who match conditions to include (0-100) */ - percentage?: number; - /** Value to return if rule matches (for boolean flags) */ - value?: boolean; - /** Variant key to return if rule matches (for variant flags) */ - variant?: string; -} -/** - * Named variant option - */ -export interface Variant { - /** Unique variant key */ - key: string; - /** Optional variant value/payload */ - value?: unknown; -} -/** - * Complete feature flag definition - */ -export interface Flag { - /** Unique flag key */ - key: string; - /** Default value when no rules match */ - defaultValue: boolean | string; - /** Ordered rules to evaluate (first match wins) */ - rules?: Rule[]; - /** Available variants (for variant flags) */ - variants?: Variant[]; - /** Optional description */ - description?: string; -} -/** - * Complete flag configuration (map of flag keys to definitions) - */ -export interface FlagConfig { - [flagKey: string]: Flag; -} -/** - * Reason for evaluation result (for debugging) - */ -export type EvaluationReason = 'default' | 'rule_match' | 'rollout' | 'rollout_excluded' | 'flag_not_found' | 'error'; -/** - * Result of flag evaluation - */ -export interface EvaluationResult { - /** The evaluated value */ - value: T; - /** Reason for this result */ - reason: EvaluationReason; - /** Optional additional context about the evaluation */ - metadata?: { - /** Which rule index matched (if applicable) */ - ruleIndex?: number; - /** Rollout bucket (0-100) if userId provided */ - bucket?: number; - /** Error message if reason is 'error' */ - error?: string; - }; -} -//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/types.d.ts.map b/packages/flags-core-ts/src/types.d.ts.map deleted file mode 100644 index 828594a..0000000 --- a/packages/flags-core-ts/src/types.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;GAEG;AACH,MAAM,WAAW,OAAO;IACtB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrC;AAED;;GAEG;AACH,MAAM,MAAM,iBAAiB,GACzB,IAAI,GACJ,KAAK,GACL,IAAI,GACJ,IAAI,GACJ,KAAK,GACL,IAAI,GACJ,KAAK,GACL,UAAU,CAAC;AAEf;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,0BAA0B;IAC1B,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,+BAA+B;IAC/B,KAAK,EAAE,OAAO,CAAC;CAChB;AAED;;GAEG;AACH,MAAM,WAAW,IAAI;IACnB,mDAAmD;IACnD,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0DAA0D;IAC1D,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,gEAAgE;IAChE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,OAAO;IACtB,yBAAyB;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,qCAAqC;IACrC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;GAEG;AACH,MAAM,WAAW,IAAI;IACnB,sBAAsB;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,wCAAwC;IACxC,YAAY,EAAE,OAAO,GAAG,MAAM,CAAC;IAC/B,mDAAmD;IACnD,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;IACf,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;IACrB,2BAA2B;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,MAAM,gBAAgB,GACxB,SAAS,GACT,YAAY,GACZ,SAAS,GACT,kBAAkB,GAClB,gBAAgB,GAChB,OAAO,CAAC;AAEZ;;GAEG;AACH,MAAM,WAAW,gBAAgB,CAAC,CAAC,GAAG,OAAO,GAAG,MAAM;IACpD,0BAA0B;IAC1B,KAAK,EAAE,CAAC,CAAC;IACT,6BAA6B;IAC7B,MAAM,EAAE,gBAAgB,CAAC;IACzB,uDAAuD;IACvD,QAAQ,CAAC,EAAE;QACT,+CAA+C;QAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,gDAAgD;QAChD,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,yCAAyC;QACzC,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;CACH"} \ No newline at end of file diff --git a/packages/flags-core-ts/src/types.js b/packages/flags-core-ts/src/types.js deleted file mode 100644 index 968f0ed..0000000 --- a/packages/flags-core-ts/src/types.js +++ /dev/null @@ -1,6 +0,0 @@ -"use strict"; -/** - * Core type definitions for the Togglekit feature flag system - */ -Object.defineProperty(exports, "__esModule", { value: true }); -//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/packages/flags-core-ts/src/types.js.map b/packages/flags-core-ts/src/types.js.map deleted file mode 100644 index 70eb584..0000000 --- a/packages/flags-core-ts/src/types.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":";AAAA;;GAEG"} \ No newline at end of file From ca09b2bc157e4a9e475a982a1f6c9707ec04c719 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 1 Dec 2025 19:42:47 -0800 Subject: [PATCH 4/5] fix(ci): Properly quote PR_BODY variable to prevent shell injection - Quote PR_BODY variable in pr-checks workflow - Prevents backticks and special chars in PR description from being executed - Fixes PR validation job failure --- .github/workflows/pr-checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index ebd7671..07efe36 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -48,7 +48,7 @@ jobs: run: | PR_BODY="${{ github.event.pull_request.body }}" - if echo "$PR_BODY" | grep -iE "(closes|fixes|resolves) #[0-9]+"; then + if echo "${PR_BODY}" | grep -iE "(closes|fixes|resolves) #[0-9]+"; then echo "✅ PR links to an issue" else echo "ℹ️ No linked issues found. Consider linking related issues." From fdac66d4c626020a2b0e8118f21123fc96bc1fb2 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 1 Dec 2025 19:44:29 -0800 Subject: [PATCH 5/5] fix(ci): Use heredoc to prevent shell interpretation of PR body - Use heredoc with quoted delimiter (EOF) to safely capture PR body - Prevents backticks and other special characters from being executed - Previous fix only quoted usage, not assignment where injection occurs --- .github/workflows/pr-checks.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 07efe36..7bf7e20 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -34,7 +34,11 @@ jobs: - name: Check PR description run: | - PR_BODY="${{ github.event.pull_request.body }}" + # Use heredoc to safely capture PR body without shell interpretation + PR_BODY=$(cat << 'EOF' + ${{ github.event.pull_request.body }} + EOF + ) if [ -z "$PR_BODY" ] || [ "$PR_BODY" == "null" ]; then echo "⚠️ PR description is empty. Consider adding a description." @@ -46,7 +50,11 @@ jobs: - name: Check for linked issues run: | - PR_BODY="${{ github.event.pull_request.body }}" + # Use heredoc to safely capture PR body without shell interpretation + PR_BODY=$(cat << 'EOF' + ${{ github.event.pull_request.body }} + EOF + ) if echo "${PR_BODY}" | grep -iE "(closes|fixes|resolves) #[0-9]+"; then echo "✅ PR links to an issue"