diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index ebd7671..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,9 +50,13 @@ 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 + 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." 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..435d602 --- /dev/null +++ b/packages/flags-client-node/.eslintrc.json @@ -0,0 +1,19 @@ +{ + "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", "**/*.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-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..b5d5ed7 --- /dev/null +++ b/packages/flags-client-node/src/client.ts @@ -0,0 +1,257 @@ +/** + * 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) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (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-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 new file mode 100644 index 0000000..435d602 --- /dev/null +++ b/packages/flags-client-web/.eslintrc.json @@ -0,0 +1,19 @@ +{ + "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", "**/*.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-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..159240e --- /dev/null +++ b/packages/flags-client-web/src/client.ts @@ -0,0 +1,267 @@ +/** + * 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) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (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) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (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/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/.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/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"] 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'}