diff --git a/.github/workflows/publish-ts-sdk.yml b/.github/workflows/publish-ts-sdk.yml new file mode 100644 index 0000000..7a9a418 --- /dev/null +++ b/.github/workflows/publish-ts-sdk.yml @@ -0,0 +1,31 @@ +name: Manual NPM Publish for TS SDK + +on: + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + environment: publish + + defaults: + run: + working-directory: ./typescript + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: "20.x" + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: npm ci + + - name: Publish to NPM + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 1a93ee5..5a878b5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ target/ book/ +# TypeScript +/typescript/node_modules +/typescript/dist +/typescript/coverage + # Flamegraph flamegraph.svg flamegraph-reports/ diff --git a/typescript/.editorconfig b/typescript/.editorconfig new file mode 100644 index 0000000..960a938 --- /dev/null +++ b/typescript/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +max_line_length = 100 +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false \ No newline at end of file diff --git a/typescript/.env.example b/typescript/.env.example new file mode 100644 index 0000000..12434b2 --- /dev/null +++ b/typescript/.env.example @@ -0,0 +1,11 @@ +# Chainlink Data Streams API Credentials +API_KEY="your_api_key_here" +USER_SECRET="your_user_secret_here" + +# API Endpoints (defaults for testnet) +REST_URL="https://api.testnet-dataengine.chain.link" +WS_URL="wss://ws.testnet-dataengine.chain.link" + +#Find the correct URLs for your environment +#https://docs.chain.link/data-streams/reference/interface-api +#https://docs.chain.link/data-streams/reference/interface-ws \ No newline at end of file diff --git a/typescript/.husky/pre-commit b/typescript/.husky/pre-commit new file mode 100755 index 0000000..2312dc5 --- /dev/null +++ b/typescript/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/typescript/.prettierignore b/typescript/.prettierignore new file mode 100644 index 0000000..f98bc79 --- /dev/null +++ b/typescript/.prettierignore @@ -0,0 +1,6 @@ +node_modules +dist +coverage +.husky +.github +*.md \ No newline at end of file diff --git a/typescript/.prettierrc.json b/typescript/.prettierrc.json new file mode 100644 index 0000000..4c8e7bf --- /dev/null +++ b/typescript/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "bracketSpacing": true +} diff --git a/typescript/LICENSE b/typescript/LICENSE new file mode 100644 index 0000000..e47c7df --- /dev/null +++ b/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 SmartContract + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/typescript/README.md b/typescript/README.md new file mode 100644 index 0000000..f209f5b --- /dev/null +++ b/typescript/README.md @@ -0,0 +1,551 @@ +# Chainlink Data Streams TypeScript SDK + +TypeScript SDK for accessing Chainlink Data Streams with real-time streaming and historical data retrieval. + +## Table of Contents + +- [Features](#features) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Configuration](#configuration) +- [Examples](#examples) +- [API Reference](#api-reference) + - [Streaming](#streaming) + - [Stream Options](#stream-options) + - [REST API](#rest-api) +- [Report Format](#report-format) +- [High Availability Mode](#high-availability-mode) +- [Error Handling](#error-handling) +- [Observability (Logs & Metrics)](#observability-logs--metrics) + - [Logging (Pino/Winston/Console)](#logging-pinowinstonconsole) + - [Metrics (stream.getMetrics())](#metrics-streamgetmetrics) +- [Testing](#testing) +- [Feed IDs](#feed-ids) + +## Features + +- **Real-time streaming** via WebSocket connections +- **High Availability mode** with multiple connections and automatic failover +- **Historical data access** via REST API +- **Automatic report decoding** for all supported formats (V2, V3, V4, V5, V6, V7, V8, V9, V10) +- **Metrics** for monitoring and observability +- **Type-safe** with full TypeScript support +- **Event-driven architecture** for complete developer control + +## Installation + +```bash +npm install @chainlink/data-streams-sdk +``` + +**Requirements:** +- Node.js >= 20.0.0 +- TypeScript >= 5.3.x +- Valid Chainlink Data Streams credentials + +## Quick Start + +**Set your credentials:** + +Option 1 - Environment variables: +```bash +export API_KEY="your_api_key_here" +export USER_SECRET="your_user_secret_here" +``` + +Option 2 - `.env` file: +```bash +# Create .env file from template +cp .env.example .env + +# Edit .env with your credentials +API_KEY="your_api_key_here" +USER_SECRET="your_user_secret_here" +``` + +**Basic streaming:** +```typescript +import { createClient, LogLevel } from '@chainlink/data-streams-sdk'; + +const client = createClient({ + apiKey: process.env.API_KEY, + userSecret: process.env.USER_SECRET, + endpoint: "https://api.dataengine.chain.link", + wsEndpoint: "wss://ws.dataengine.chain.link", + // Comment to disable SDK logging for debugging: + logging: { + logger: console, + logLevel: LogLevel.INFO + } +}); + +const feedID = '0x000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba782'; +const stream = client.createStream([feedID]); + +stream.on('report', (report) => { + console.log('New report:', report); +}); + +stream.on('error', (error) => { + console.error('Stream error:', error); +}); + +await stream.connect(); +``` + +## Configuration + +### Configuration Interface + +```typescript +interface Config { + // Required + apiKey: string; // API key for authentication + userSecret: string; // User secret for authentication + endpoint: string; // REST API URL + wsEndpoint: string; // WebSocket URL + + // Optional - Request & Retry + timeout?: number; // Request timeout (default: 30000ms) + retryAttempts?: number; // Retry attempts (default: 3) + retryDelay?: number; // Retry delay (default: 1000ms) + + // Optional - High Availability + haMode?: boolean; // Enable HA mode (default: false) + haConnectionTimeout?: number; // HA connection timeout (default: 10000ms) + connectionStatusCallback?: (isConnected: boolean, host: string, origin: string) => void; + + // Optional - Logging + logging?: LoggingConfig; // See Logging Configuration section +} +``` + +### Basic Usage + +```typescript +const client = createClient({ + apiKey: process.env.API_KEY, + userSecret: process.env.USER_SECRET, + endpoint: "https://api.dataengine.chain.link", + wsEndpoint: "wss://ws.dataengine.chain.link" +}); +``` + +### High Availability Example + +```typescript +const haClient = createClient({ + apiKey: process.env.API_KEY, + userSecret: process.env.USER_SECRET, + endpoint: "https://api.dataengine.chain.link", // Mainnet only + wsEndpoint: "wss://ws.dataengine.chain.link", // Single endpoint with origin discovery + haMode: true, +}); +``` + +**Note:** High Availability mode is only available on mainnet, not testnet. + +## Examples + +**Quick Commands:** +```bash +# Real-time streaming +npx ts-node examples/stream-reports.ts 0x000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba782 + +# High Availability streaming +npx ts-node examples/stream-reports.ts 0x000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba782 --ha + +# Get latest report +npx ts-node examples/get-latest-report.ts 0x000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba782 + +# List all available feeds +npx ts-node examples/list-feeds.ts +``` + +**Complete Examples:** +See [`examples/README.md`](./examples/README.md) for detailed usage instructions, setup, and all available examples including: + +- **Streaming:** Basic streaming, HA mode, metrics monitoring +- **REST API:** Latest reports, historical data, bulk operations, feed management +- **Configuration:** Logging setup, debugging, monitoring integration + +## API Reference + +### Streaming + +```typescript +// Create stream +const stream = client.createStream(feedIds, options?); + +// Events +stream.on('report', (report) => { ... }); +stream.on('error', (error) => { ... }); +stream.on('disconnected', () => { ... }); +stream.on('reconnecting', (info) => { ... }); + +// Control +await stream.connect(); +await stream.close(); + +// Metrics +const metrics = stream.getMetrics(); +``` + + +### Stream Options + +```typescript +interface StreamOptions { + maxReconnectAttempts?: number; // Default: 5 + // Base delay (in ms) for exponential backoff. + // Actual delay grows as: base * 2^(attempt-1) with jitter, capped at 10000ms. + // Default: 1000ms; user-provided values are clamped to the safe range [200ms, 10000ms]. + reconnectInterval?: number; +} +``` + +### REST API + +```typescript +// Get feeds +const feeds = await client.listFeeds(); + +// Get latest report +const report = await client.getLatestReport(feedId); + +// Get historical report +const report = await client.getReportByTimestamp(feedId, timestamp); + +// Get report page +const reports = await client.getReportsPage(feedId, startTime, limit?); + +// Get bulk reports +const reports = await client.getReportsBulk(feedIds, timestamp); +``` + +## Report Format + +### Quick Decoder Usage + +```typescript +import { decodeReport } from '@chainlink/data-streams-sdk'; +const decoded = decodeReport(report.fullReport, report.feedID); +``` + +### Schema Auto-Detection + +The SDK automatically detects and decodes all report versions based on Feed ID patterns: +- **V2**: Feed IDs starting with `0x0002` +- **V3**: Feed IDs starting with `0x0003` (Crypto Streams) +- **V4**: Feed IDs starting with `0x0004` (Real-World Assets) +- **V5**: Feed IDs starting with `0x0005` +- **V6**: Feed IDs starting with `0x0006` (Multiple Price Values) +- **V7**: Feed IDs starting with `0x0007` +- **V8**: Feed IDs starting with `0x0008` (Non-OTC RWA) +- **V9**: Feed IDs starting with `0x0009` (NAV Fund Data) +- **V10**: Feed IDs starting with `0x000a` (Tokenized Equity) + +### Common Fields + +All reports include standard metadata: +```typescript +interface BaseFields { + version: "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" | "V10"; + nativeFee: bigint; + linkFee: bigint; + expiresAt: number; + feedID: string; + validFromTimestamp: number; + observationsTimestamp: number; +} +``` + +### Schema-Specific Fields + +- **V2/V3/V4**: `price: bigint` - Standard price data +- **V3**: `bid: bigint, ask: bigint` - Crypto bid/ask spreads +- **V4**: `marketStatus: MarketStatus` - Real-world asset market status +- **V5**: `rate: bigint, timestamp: number, duration: number` - Interest rate data with observation timestamp and duration +- **V6**: `price: bigint, price2: bigint, price3: bigint, price4: bigint, price5: bigint` - Multiple price values in a single payload +- **V7**: `exchangeRate: bigint` - Exchange rate data +- **V8**: `midPrice: bigint, lastUpdateTimestamp: number, marketStatus: MarketStatus` - Non-OTC RWA data +- **V9**: `navPerShare: bigint, navDate: number, aum: bigint, ripcord: number` - NAV fund data +- **V10**: `price: bigint, lastUpdateTimestamp: number, marketStatus: MarketStatus, currentMultiplier: bigint, newMultiplier: bigint, activationDateTime: number, tokenizedPrice: bigint` - Tokenized equity data + +For complete field definitions, see the [documentation](https://docs.chain.link/data-streams/reference/report-schema-v3). + +## High Availability Mode + +HA mode establishes multiple simultaneous connections for zero-downtime operation: + +- **Automatic failover** between connections +- **Report deduplication** across connections +- **Automatic origin discovery** to find available endpoints +- **Per-connection monitoring** and statistics + +```typescript +const client = createClient({ + // ...config + haMode: true, + wsEndpoint: "wss://ws.dataengine.chain.link", // Single endpoint (mainnet only) +}); +``` + +**How it works:** When `haMode` is `true`, the SDK automatically discovers multiple origin endpoints behind the single URL and establishes separate connections to each origin. + +**Connection monitoring:** The optional `connectionStatusCallback` can be used to integrate with external monitoring systems. The SDK already provides comprehensive connection logs, so this callback is primarily useful for custom alerting or metrics collection. See [`examples/metrics-monitoring.ts`](./examples/metrics-monitoring.ts) for a complete implementation example. + +**Important:** HA mode is only available on mainnet endpoints. + +## Error Handling + +### Error Types Overview + +| **Error Type** | **When Thrown** | **Key Properties** | +|---|---|---| +| `ValidationError` | Invalid feed IDs, timestamps, parameters | `message` | +| `AuthenticationError` | Invalid credentials, HMAC failures | `message` | +| `APIError` | HTTP 4xx/5xx, network timeouts, rate limits | `statusCode`, `message` | +| `ReportDecodingError` | Corrupted report data, unsupported versions | `message` | +| `WebSocketError` | Connection failures, protocol errors | `message` | +| `OriginDiscoveryError` | HA discovery failures | `cause`, `message` | +| `MultiConnectionError` | All HA connections failed | `message` | +| `PartialConnectionFailureError` | Some HA connections failed | `failedConnections`, `totalConnections` | +| `InsufficientConnectionsError` | HA degraded performance | `availableConnections`, `requiredConnections` | + +### Usage Examples + +```typescript +import { + ValidationError, + AuthenticationError, + APIError, + ReportDecodingError, + WebSocketError, + OriginDiscoveryError, + MultiConnectionError +} from './src'; + +// REST API error handling +try { + const report = await client.getLatestReport(feedId); +} catch (error) { + if (error instanceof ValidationError) { + // Invalid feed ID or parameters + } else if (error instanceof AuthenticationError) { + // Check API credentials + } else if (error instanceof APIError) { + // Server error - check error.statusCode (429, 500, etc.) + } else if (error instanceof ReportDecodingError) { + // Corrupted or unsupported report format + } +} + +// Streaming error handling +stream.on('error', (error) => { + if (error instanceof WebSocketError) { + // Connection issues - retry or fallback + } else if (error instanceof OriginDiscoveryError) { + // HA discovery failed - falls back to static config + } else if (error instanceof MultiConnectionError) { + // All HA connections failed - critical + } +}); +``` + +**Catch-all error handling:** +```typescript +import { DataStreamsError } from './src'; + +try { + // Any SDK operation +} catch (error) { + if (error instanceof DataStreamsError) { + // Handles ANY SDK error (base class for all error types above) + console.log('SDK error:', error.message); + } else { + // Non-SDK error (network, system, etc.) + console.log('System error:', error); + } +} +``` + +## Observability (Logs & Metrics) + +The SDK is designed to plug into your existing observability stack. + +### Logging (Pino/Winston/Console) + +Pass your logger to the SDK and choose a verbosity level. For deep WS diagnostics, enable connection debug. + +### Quick Start + +```typescript +import { createClient, LogLevel } from '@chainlink/data-streams-sdk'; + +// Silent mode (default) - Zero overhead +const client = createClient({ /* ... config without logging */ }); + +// Basic console logging +const client = createClient({ + // ... other config + logging: { + logger: { + info: console.log, + warn: console.warn, + error: console.error + } + } +}); +``` + +Using Pino (structured JSON): +```typescript +import pino from 'pino'; +import { createClient, LogLevel } from '@chainlink/data-streams-sdk'; + +const root = pino({ level: process.env.PINO_LEVEL || 'info' }); +const sdk = root.child({ component: 'sdk' }); + +const client = createClient({ + // ...config + logging: { + logger: { + info: sdk.info.bind(sdk), + warn: sdk.warn.bind(sdk), + error: sdk.error.bind(sdk), + debug: sdk.debug.bind(sdk), + }, + logLevel: LogLevel.INFO, + // For very verbose WS diagnostics, set DEBUG + enableConnectionDebug + // logLevel: LogLevel.DEBUG, + // enableConnectionDebug: true, + }, +}); +``` + +Command-line with pretty output: +```bash +PINO_LEVEL=info npx ts-node examples/metrics-monitoring.ts | npx pino-pretty +``` + +### Log Levels + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
šŸ”“ ERROR🟔 WARNšŸ”µ INFOšŸ” DEBUG
Critical failures onlyEverything in ERROR +Everything in WARN +Everything in INFO +
+ - Authentication failures
+ - Network connection errors
+ - Report decoding failures
+ - API request failures
+ - Unexpected crashes +
+ - Partial reconnections
+ - Fallback to static origins
+ - Retry attempts
+ - Connection timeouts
+ - Invalid data warnings +
+ - Client initialization
+ - Successful API calls
+ - Stream connections
+ - Report retrievals
+ - Connection status changes
+ - Connection mode determination +
+ - Feed ID validation
+ - Report decoding steps
+ - Auth header generation
+ - Request/response details
+ - WebSocket ping/pong
+ - Origin discovery process
+ - Configuration validation
+ - Origin tracking (HA mode) +
Example Use:
Production alerts & monitoring
Example Use:
Production environments
Example Use:
Development & staging
Example Use:
Debugging & development only
+ +### Logging Configuration Options + +```typescript +interface LoggingConfig { + /** External logger functions (console, winston, pino, etc.) */ + logger?: { + debug?: (message: string, ...args: any[]) => void; + info?: (message: string, ...args: any[]) => void; + warn?: (message: string, ...args: any[]) => void; + error?: (message: string, ...args: any[]) => void; + }; + + /** Minimum logging level - filters out lower priority logs */ + logLevel?: LogLevel; // DEBUG (0) | INFO (1) | WARN (2) | ERROR (3) + + /** Enable WebSocket ping/pong and connection state debugging logs */ + enableConnectionDebug?: boolean; +} +``` + +**Compatible with:** console, winston, pino, and any logger with `debug/info/warn/error` methods. See `examples/logging-basic.ts` for complete integration examples. + +**For debugging:** Use `LogLevel.DEBUG` for full diagnostics and `enableConnectionDebug: true` to see WebSocket ping/pong messages and connection state transitions. + +**Origin tracking** in HA mode shows which specific endpoint received each report. + +### Metrics (`stream.getMetrics()`) + +The `stream.getMetrics()` API provides a complete snapshot for dashboards and alerts: + +```typescript +const m = stream.getMetrics(); +// m.accepted, m.deduplicated, m.totalReceived +// m.partialReconnects, m.fullReconnects +// m.activeConnections, m.configuredConnections +// m.originStatus: { [origin]: ConnectionStatus } +``` + +Simple periodic print (example): +```typescript +setInterval(() => { + const m = stream.getMetrics(); + console.log(`accepted=${m.accepted} dedup=${m.deduplicated} active=${m.activeConnections}/${m.configuredConnections}`); +}, 30000); +``` + +Refer to `examples/metrics-monitoring.ts` for a full metrics dashboard example. + +## Testing + +```bash +npm test # All tests +npm run test:unit # Unit tests only +npm run test:integration # Integration tests only +``` + +## Feed IDs + +For available feed IDs, see the [official documentation](https://docs.chain.link/data-streams/crypto-streams). diff --git a/typescript/eslint.config.mjs b/typescript/eslint.config.mjs new file mode 100644 index 0000000..a79c027 --- /dev/null +++ b/typescript/eslint.config.mjs @@ -0,0 +1,98 @@ +import eslint from "@eslint/js"; +import tseslint from "@typescript-eslint/eslint-plugin"; +import tsparser from "@typescript-eslint/parser"; + +// Base configuration shared across all TypeScript files +const baseConfig = { + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + project: "./tsconfig.json", + }, + globals: { + // Node.js globals + console: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + fetch: "readonly", + process: "readonly", + __dirname: "readonly", + require: "readonly", + NodeJS: "readonly", + Buffer: "readonly", + global: "readonly", + performance: "readonly", + // Jest globals (available everywhere for consistency) + describe: "readonly", + it: "readonly", + test: "readonly", + expect: "readonly", + beforeEach: "readonly", + afterEach: "readonly", + beforeAll: "readonly", + afterAll: "readonly", + jest: "readonly", + fail: "readonly", + }, + }, + plugins: { + "@typescript-eslint": tseslint, + }, + rules: { + // TypeScript recommended rules + ...tseslint.configs.recommended.rules, + + // Common rules for all TypeScript files + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/explicit-function-return-type": "off", + "prefer-const": "error", + }, +}; + +export default [ + eslint.configs.recommended, + { + files: ["src/**/*.ts", "examples/**/*.ts"], + ...baseConfig, + rules: { + ...baseConfig.rules, + // Stricter rules for source code + "@typescript-eslint/no-explicit-any": [ + "warn", + { + ignoreRestArgs: true, + }, + ], + }, + }, + { + files: ["tests/**/*.ts"], + ...baseConfig, + rules: { + ...baseConfig.rules, + // More lenient for tests - allow any when testing invalid inputs + "@typescript-eslint/no-explicit-any": [ + "warn", + { + ignoreRestArgs: true, + fixToUnknown: false, // Don't auto-fix to unknown in tests + }, + ], + // Allow require() in tests for dynamic mocking + "@typescript-eslint/no-require-imports": "off", + }, + }, + { + ignores: ["dist/**", "node_modules/**", "coverage/**", "*.js"], + }, +]; diff --git a/typescript/examples/README.md b/typescript/examples/README.md new file mode 100644 index 0000000..fe8038c --- /dev/null +++ b/typescript/examples/README.md @@ -0,0 +1,152 @@ +# Chainlink Data Streams SDK - Examples + +Example scripts demonstrating various SDK features and usage patterns. + +## Setup + +1. **Clone the repository:** + +2. **Install dependencies:** + ```bash + npm install + ``` + +3. **Build the SDK:** + ```bash + npm run build + ``` + +4. **Set your API credentials:** + + Option 1 - Environment variables: + ```bash + export API_KEY="your_api_key_here" + export USER_SECRET="your_user_secret_here" + ``` + + Option 2 - `.env` file: + ```bash + # Create .env file from template + cp .env.example .env + + # Edit .env with your credentials + API_KEY="your_api_key_here" + USER_SECRET="your_user_secret_here" + ``` + +## Examples + +### Streaming Reports + +**`stream-reports.ts`** - Real-time report streaming with optional High Availability (HA) mode +```bash +# Single feed +npx ts-node examples/stream-reports.ts + +# Multiple feeds +npx ts-node examples/stream-reports.ts , + +# High Availability mode (mainnet only - uses multiple connections) +## Single feed +npx ts-node examples/stream-reports.ts --ha +## Multiple feeds +npx ts-node examples/stream-reports.ts , --ha +``` + +### Historical Data + +**`get-latest-report.ts`** - Fetch latest report for a feed +```bash +npx ts-node examples/get-latest-report.ts +``` + +**`get-report-by-timestamp.ts`** - Fetch report at specific timestamp +```bash +npx ts-node examples/get-report-by-timestamp.ts +``` + +**`get-reports-page.ts`** - Fetch range of reports +```bash +# Get 10 reports starting from timestamp +npx ts-node examples/get-reports-page.ts 10 +``` + +**`get-reports-bulk.ts`** - Fetch reports for multiple feeds +```bash +npx ts-node examples/get-reports-bulk.ts [feedID3...] +``` + +**Note**: Reports are not guaranteed to be returned in the same order as input feedIds. Always use `report.feedID` to identify each report rather than relying on array position. + +### Feed Management + +**`list-feeds.ts`** - List available feeds +```bash +npx ts-node examples/list-feeds.ts +``` + +## Feed IDs + +The SDK automatically detects and supports all report schema versions (V2, V3, V4, V5, V6, V7, V8, V9, V10). + +For available feed IDs, see the official [Chainlink documentation](https://docs.chain.link/data-streams/). + +## Configuration & Debugging + +### Logging Configuration + +**`logging-basic.ts`** - **Comprehensive logging configuration showcase** +```bash +npx ts-node examples/logging-basic.ts +``` + +This example demonstrates **6 complete logging configurations** covering different use cases: + +- **1ļøāƒ£ Silent Mode** - Default zero-overhead configuration +- **2ļøāƒ£ Basic Console** - Simple console.log integration +- **3ļøāƒ£ Advanced Level Control** - Custom log level filtering +- **4ļøāƒ£ Development Debug** - Full debugging with WebSocket logs +- **5ļøāƒ£ Production Structured** - Enterprise-ready JSON logging +- **6ļøāƒ£ Error-Resilient** - Fault-tolerant logger integration + +Shows 6 different logging scenarios. See the main [README](../README.md#logging-configuration) for detailed logging documentation. + +### Metrics & Monitoring + +**`metrics-monitoring.ts`** - **Metrics and monitoring showcase** +```bash +npx ts-node examples/metrics-monitoring.ts +``` + +**Note:** This example uses High Availability mode which requires mainnet endpoints. + +This example demonstrates **comprehensive stream monitoring** with real-time metrics: + +- **Stream Metrics** - Reports accepted, deduplicated, total received +- **Connection Health** - Active connections, reconnection tracking +- **Origin Status** - Per-origin connection status monitoring +- **Stream Status** - High Availability mode detection, stability assessment +- **Real-time Dashboard** - Live metrics updates every 30 seconds + quick initial dashboard + +Perfect for **monitoring integration** and understanding stream performance patterns. Shows how deduplication works in HA mode and provides actionable insights for reliability assessment. + +## SDK Logging in Examples + +All examples include **SDK logging integration** to help with debugging and learning. Simply uncomment the logging section to see internal SDK operations. + +### Simple Examples (REST API) +Most examples like `get-latest-report.ts`, `get-reports-bulk.ts`, etc. include commented logging configuration: + +```typescript +const config = { + apiKey: process.env.API_KEY || "YOUR_API_KEY", + userSecret: process.env.USER_SECRET || "YOUR_USER_SECRET", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + // Uncomment to enable SDK logging for debugging: + // logging: { + // logger: console, + // logLevel: LogLevel.INFO + // } +}; +``` \ No newline at end of file diff --git a/typescript/examples/get-latest-report.ts b/typescript/examples/get-latest-report.ts new file mode 100644 index 0000000..4348332 --- /dev/null +++ b/typescript/examples/get-latest-report.ts @@ -0,0 +1,58 @@ +import { createClient, decodeReport, LogLevel } from "../src"; +import { getReportVersion, formatReport } from "../src/utils/report"; +import "dotenv/config"; + +async function main() { + if (process.argv.length < 3) { + console.error("Please provide a feed ID as an argument"); + console.error( + "Example: npx ts-node examples/get-latest-report.ts 0x000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba782" + ); + process.exit(1); + } + + const feedId = process.argv[2]; + const version = getReportVersion(feedId); + + try { + const config = { + apiKey: process.env.API_KEY || "YOUR_API_KEY", + userSecret: process.env.USER_SECRET || "YOUR_USER_SECRET", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + // Comment to disable SDK logging: + logging: { + logger: console, + logLevel: LogLevel.INFO, + }, + }; + + const client = createClient(config); + console.log(`\nFetching latest report for feed ${feedId} (${version})...\n`); + + // Get raw report data + const report = await client.getLatestReport(feedId); + console.log(`Raw Report Blob: ${report.fullReport}`); + + // Decode the report + const decodedData = decodeReport(report.fullReport, report.feedID); + + // Combine decoded data with report metadata + const decodedReport = { + ...decodedData, + feedID: report.feedID, + validFromTimestamp: report.validFromTimestamp, + observationsTimestamp: report.observationsTimestamp, + }; + console.log(formatReport(decodedReport, version)); + } catch (error) { + if (error instanceof Error) { + console.error("Error:", error.message); + } else { + console.error("Unknown error:", error); + } + process.exit(1); + } +} + +main(); diff --git a/typescript/examples/get-report-by-timestamp.ts b/typescript/examples/get-report-by-timestamp.ts new file mode 100644 index 0000000..58cd1d1 --- /dev/null +++ b/typescript/examples/get-report-by-timestamp.ts @@ -0,0 +1,62 @@ +import { createClient, decodeReport, LogLevel } from "../src"; +import { getReportVersion, formatReport } from "../src/utils/report"; +import { getCurrentTimestamp } from "../src/utils/time"; +import "dotenv/config"; + +async function main() { + if (process.argv.length < 4) { + console.error("Please provide a feed ID and timestamp as arguments"); + console.error( + "Example: npx ts-node examples/get-report-by-timestamp.ts 0x000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba782 1754604071" + ); + console.error(`Current timestamp: ${getCurrentTimestamp()}`); + console.error("Note: The timestamp must be within the last 30 days."); + process.exit(1); + } + + const feedId = process.argv[2]; + const timestamp = parseInt(process.argv[3]); + const version = getReportVersion(feedId); + + try { + const config = { + apiKey: process.env.API_KEY || "YOUR_API_KEY", + userSecret: process.env.USER_SECRET || "YOUR_USER_SECRET", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + // Comment to disable SDK logging: + logging: { + logger: console, + logLevel: LogLevel.INFO, + }, + }; + + const client = createClient(config); + console.log(`\nFetching report for feed ${feedId} (${version}) at timestamp ${timestamp}...\n`); + + // Get raw report data + const report = await client.getReportByTimestamp(feedId, timestamp); + console.log(`Raw Report Blob: ${report.fullReport}`); + + // Decode the report + const decodedData = decodeReport(report.fullReport, report.feedID); + + // Combine decoded data with report metadata + const decodedReport = { + ...decodedData, + feedID: report.feedID, + validFromTimestamp: report.validFromTimestamp, + observationsTimestamp: report.observationsTimestamp, + }; + console.log(formatReport(decodedReport, version)); + } catch (error) { + if (error instanceof Error) { + console.error("Error:", error.message); + } else { + console.error("Unknown error:", error); + } + process.exit(1); + } +} + +main(); diff --git a/typescript/examples/get-reports-bulk.ts b/typescript/examples/get-reports-bulk.ts new file mode 100644 index 0000000..a54489b --- /dev/null +++ b/typescript/examples/get-reports-bulk.ts @@ -0,0 +1,80 @@ +import { createClient, decodeReport, LogLevel } from "../src"; +import { getReportVersion, formatReport } from "../src/utils/report"; +import { getCurrentTimestamp } from "../src/utils/time"; +import "dotenv/config"; + +async function main() { + if (process.argv.length < 4) { + console.error("Please provide feed IDs and timestamp as arguments"); + console.error("Get reports for multiple feeds at a specific timestamp:"); + console.error(" npx ts-node examples/get-reports-bulk.ts [feedID3...] "); + console.error("\nExample:"); + console.error( + ` npx ts-node examples/get-reports-bulk.ts 0x000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba782 0x00037da06d56d083fe599397a4769a042d63aa73dc4ef57709d31e9971a5b439 ${getCurrentTimestamp()}` + ); + console.error("\nNote: The timestamp must be within the last 30 days."); + process.exit(1); + } + + // Parse arguments: all except the last are feed IDs, last is timestamp + const args = process.argv.slice(2); + const timestamp = parseInt(args[args.length - 1]); + const feedIds = args.slice(0, -1); + + if (isNaN(timestamp)) { + console.error("Error: Last argument must be a valid timestamp"); + process.exit(1); + } + + try { + const config = { + apiKey: process.env.API_KEY || "YOUR_API_KEY", + userSecret: process.env.USER_SECRET || "YOUR_USER_SECRET", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + // Comment to disable SDK logging: + logging: { + logger: console, + logLevel: LogLevel.INFO, + }, + }; + + const client = createClient(config); + console.log(`\nFetching reports in bulk for ${feedIds.length} feed(s) at timestamp ${timestamp}:`); + feedIds.forEach(feedId => { + const version = getReportVersion(feedId); + console.log(`- ${feedId} (${version})`); + }); + console.log(); + + const reports = await client.getReportsBulk(feedIds, timestamp); + console.log(`Found ${reports.length} reports:\n`); + + // Process reports safely - order is not guaranteed to match input feedIds + reports.forEach((report, index) => { + const version = getReportVersion(report.feedID); + console.log(`Raw Report Blob #${index + 1}: ${report.fullReport}`); + + // Decode the report + const decodedData = decodeReport(report.fullReport, report.feedID); + + // Combine decoded data with report metadata + const decodedReport = { + ...decodedData, + feedID: report.feedID, + validFromTimestamp: report.validFromTimestamp, + observationsTimestamp: report.observationsTimestamp, + }; + console.log(formatReport(decodedReport, version)); + }); + } catch (error) { + if (error instanceof Error) { + console.error("Error:", error.message); + } else { + console.error("Unknown error:", error); + } + process.exit(1); + } +} + +main(); diff --git a/typescript/examples/get-reports-page.ts b/typescript/examples/get-reports-page.ts new file mode 100644 index 0000000..fcd6bdf --- /dev/null +++ b/typescript/examples/get-reports-page.ts @@ -0,0 +1,68 @@ +import { createClient, decodeReport, LogLevel } from "../src"; +import { getReportVersion, formatReport } from "../src/utils/report"; +import { getCurrentTimestamp } from "../src/utils/time"; +import "dotenv/config"; + +async function main() { + if (process.argv.length < 4) { + console.error("Please provide a feed ID and start timestamp as arguments"); + console.error( + "Example: npx ts-node examples/get-reports-page.ts 0x000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba782 1754604071 10" + ); + console.error(`Current timestamp: ${getCurrentTimestamp()}`); + console.error("Note: The timestamp must be within the last 30 days."); + process.exit(1); + } + + const feedId = process.argv[2]; + const startTime = parseInt(process.argv[3]); + const limit = process.argv[4] ? parseInt(process.argv[4]) : undefined; + const version = getReportVersion(feedId); + + try { + const config = { + apiKey: process.env.API_KEY || "YOUR_API_KEY", + userSecret: process.env.USER_SECRET || "YOUR_USER_SECRET", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + // Comment to disable SDK logging: + logging: { + logger: console, + logLevel: LogLevel.INFO, + }, + }; + + const client = createClient(config); + console.log( + `\nFetching reports for feed ${feedId} (${version}) starting from timestamp ${startTime}${limit ? ` (limit: ${limit})` : ""}...\n` + ); + + const reports = await client.getReportsPage(feedId, startTime, limit); + console.log(`Found ${reports.length} reports:\n`); + + reports.forEach((report, index) => { + console.log(`Raw Report Blob #${index + 1}: ${report.fullReport}`); + + // Decode the report + const decodedData = decodeReport(report.fullReport, report.feedID); + + // Combine decoded data with report metadata + const decodedReport = { + ...decodedData, + feedID: report.feedID, + validFromTimestamp: report.validFromTimestamp, + observationsTimestamp: report.observationsTimestamp, + }; + console.log(formatReport(decodedReport, version)); + }); + } catch (error) { + if (error instanceof Error) { + console.error("Error:", error.message); + } else { + console.error("Unknown error:", error); + } + process.exit(1); + } +} + +main(); diff --git a/typescript/examples/list-feeds.ts b/typescript/examples/list-feeds.ts new file mode 100644 index 0000000..29d8ea3 --- /dev/null +++ b/typescript/examples/list-feeds.ts @@ -0,0 +1,38 @@ +import { createClient, LogLevel } from "../src"; +import "dotenv/config"; + +async function main() { + try { + const config = { + apiKey: process.env.API_KEY || "YOUR_API_KEY", + userSecret: process.env.USER_SECRET || "YOUR_USER_SECRET", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + // Comment to disable SDK logging: + logging: { + logger: console, + logLevel: LogLevel.INFO, + }, + }; + + const client = createClient(config); + console.log("\nFetching all available feeds...\n"); + + const feeds = await client.listFeeds(); + console.log(`Found ${feeds.length} feeds:\n`); + + feeds.forEach(feed => { + console.log(`Feed ID: ${feed.feedID}`); + console.log("-".repeat(50)); + }); + } catch (error) { + if (error instanceof Error) { + console.error("Error:", error.message); + } else { + console.error("Unknown error:", error); + } + process.exit(1); + } +} + +main(); diff --git a/typescript/examples/logging-basic.ts b/typescript/examples/logging-basic.ts new file mode 100644 index 0000000..f95b50a --- /dev/null +++ b/typescript/examples/logging-basic.ts @@ -0,0 +1,252 @@ +/** + * Basic logging configuration example + * + * This example demonstrates comprehensive logging configuration options: + * - Basic console logging setup + * - Log level filtering and control + * - Connection debug controls for WebSocket troubleshooting + * - Integration patterns with external loggers (winston, pino, etc.) + * - Production vs development logging strategies + */ + +import { createClient, LogLevel } from "../src"; +import { getReportVersion } from "../src/utils/report"; + +async function demonstrateLogging() { + console.log("šŸŽ›ļø Data Streams SDK - Logging Configuration Examples\n"); + + // ===================================================== + // Example 1: Silent Mode (Default) + // ===================================================== + console.log("1ļøāƒ£ Silent Mode (Default - Zero Overhead)"); + const silentClient = createClient({ + apiKey: process.env.API_KEY!, + userSecret: process.env.USER_SECRET!, + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + // No logging config = silent mode + }); + + console.log("āœ… Silent client created (no logs will appear)\n"); + + // ===================================================== + // Example 2: Basic Console Logging + // ===================================================== + console.log("2ļøāƒ£ Basic Console Logging"); + const basicClient = createClient({ + apiKey: process.env.API_KEY!, + userSecret: process.env.USER_SECRET!, + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + logging: { + logger: { + info: console.log, + warn: console.warn, + error: console.error, + }, + }, + }); + + console.log("āœ… Basic console logging enabled\n"); + + // ===================================================== + // Example 3: Advanced Logging with Level Control + // ===================================================== + console.log("3ļøāƒ£ Advanced Logging with Level Control"); + const advancedClient = createClient({ + apiKey: process.env.API_KEY!, + userSecret: process.env.USER_SECRET!, + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + logging: { + logger: { + debug: (msg, ...args) => console.log("šŸ› DEBUG:", msg, ...args), + info: (msg, ...args) => console.log("ā„¹ļø INFO:", msg, ...args), + warn: (msg, ...args) => console.log("āš ļø WARN:", msg, ...args), + error: (msg, ...args) => console.log("āŒ ERROR:", msg, ...args), + }, + logLevel: LogLevel.INFO, // Only INFO, WARN, ERROR (no DEBUG) + enableConnectionDebug: false, // WebSocket debugging off + }, + }); + + console.log("āœ… Advanced logging configured\n"); + + // ===================================================== + // Example 4: Development Mode (Full Debugging) + // ===================================================== + console.log("4ļøāƒ£ Development Mode (Full Debugging)"); + const devClient = createClient({ + apiKey: process.env.API_KEY!, + userSecret: process.env.USER_SECRET!, + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + logging: { + logger: { + debug: (msg, ...args) => console.log("šŸ” DEBUG:", msg, ...args), + info: (msg, ...args) => console.log("šŸ“ INFO:", msg, ...args), + warn: (msg, ...args) => console.log("āš ļø WARN:", msg, ...args), + error: (msg, ...args) => console.log("šŸ’„ ERROR:", msg, ...args), + }, + logLevel: LogLevel.DEBUG, // Show everything + enableConnectionDebug: true, // Show WebSocket ping/pong + }, + }); + + console.log("āœ… Development mode logging enabled\n"); + + // ===================================================== + // Example 5: Production-Ready Structured Logging + // ===================================================== + console.log("5ļøāƒ£ Production-Ready Structured Logging"); + + // Simulate structured logger (like winston/pino) + const structuredLogger = { + debug: (msg: string, ...args: any[]) => { + console.log( + JSON.stringify({ + level: "debug", + timestamp: new Date().toISOString(), + message: msg, + data: args, + service: "data-streams-sdk", + }) + ); + }, + info: (msg: string, ...args: any[]) => { + console.log( + JSON.stringify({ + level: "info", + timestamp: new Date().toISOString(), + message: msg, + data: args, + service: "data-streams-sdk", + }) + ); + }, + warn: (msg: string, ...args: any[]) => { + console.log( + JSON.stringify({ + level: "warn", + timestamp: new Date().toISOString(), + message: msg, + data: args, + service: "data-streams-sdk", + }) + ); + }, + error: (msg: string, ...args: any[]) => { + console.log( + JSON.stringify({ + level: "error", + timestamp: new Date().toISOString(), + message: msg, + data: args, + service: "data-streams-sdk", + }) + ); + }, + }; + + const prodClient = createClient({ + apiKey: process.env.API_KEY!, + userSecret: process.env.USER_SECRET!, + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + logging: { + logger: structuredLogger, + logLevel: LogLevel.INFO, + enableConnectionDebug: false, + }, + }); + + console.log("āœ… Production structured logging enabled\n"); + + // ===================================================== + // Example 6: Error-Resilient Logging + // ===================================================== + console.log("6ļøāƒ£ Error-Resilient Logging"); + + const faultyLogger = { + info: (msg: string) => { + throw new Error("Logger is broken!"); + }, + error: console.error, // This one works + }; + + const resilientClient = createClient({ + apiKey: process.env.API_KEY!, + userSecret: process.env.USER_SECRET!, + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + logging: { + logger: faultyLogger, + }, + }); + + console.log("āœ… Error-resilient client created (SDK won't crash if logger fails)\n"); + + // ===================================================== + // Demonstrate API Calls with Logging + // ===================================================== + console.log("šŸš€ Testing API calls with different logging configurations:\n"); + + try { + console.log("šŸ“” Fetching feeds with advanced logging..."); + const feeds = await advancedClient.listFeeds(); + console.log(`šŸ“Š Retrieved ${feeds.length} feeds\n`); + + if (feeds.length > 0) { + console.log("šŸ“ˆ Creating stream with development logging..."); + const stream = devClient.createStream([feeds[0].feedID]); + + // Set up event listeners + stream.on("report", report => { + console.log("šŸ“ Received report:", { + feedID: report.feedID, + version: getReportVersion(report.feedID), + timestamp: report.observationsTimestamp, + }); + }); + + stream.on("error", error => { + console.log("āŒ Stream error:", error.message); + }); + + console.log("āœ… Stream configured (would connect in real usage)"); + + // Close stream to prevent hanging + await stream.close(); + console.log("šŸ”Œ Stream closed to allow demo to exit\n"); + } + } catch (error) { + console.log("āš ļø Demo error (expected in demo environment):", (error as Error).message); + } + + // ===================================================== + // Best Practices Summary + // ===================================================== + console.log("šŸ’” Logging Best Practices:\n"); + console.log("āœ… Use silent mode in production unless debugging"); + console.log("āœ… Enable INFO level for general monitoring"); + console.log("āœ… Enable DEBUG level only during development"); + console.log("āœ… Use enableConnectionDebug for WebSocket issues"); + console.log("āœ… Integrate with your existing logging infrastructure"); + console.log("āœ… Logger failures won't crash your application"); + console.log("āœ… Zero performance overhead when logging is disabled\n"); + + console.log("šŸŽ‰ Logging configuration examples completed!"); +} + +// Handle environment check +if (!process.env.API_KEY || !process.env.USER_SECRET) { + console.log("āš ļø Environment variables API_KEY and USER_SECRET are required"); + console.log("Set them with: export API_KEY='your_key' && export USER_SECRET='your_secret'"); + console.log("This example will still demonstrate logging configuration without API calls.\n"); +} + +// Run the demonstration +demonstrateLogging().catch(error => { + console.error("āŒ Demo failed:", error); + process.exit(1); +}); diff --git a/typescript/examples/metrics-monitoring.ts b/typescript/examples/metrics-monitoring.ts new file mode 100644 index 0000000..e95dd8d --- /dev/null +++ b/typescript/examples/metrics-monitoring.ts @@ -0,0 +1,203 @@ +/** + * Metrics Monitoring Example + * + * Demonstrates how to use the metrics API for monitoring and observability. + * + * Note: This example uses HA mode (haMode: true) which requires mainnet endpoints. + * HA mode provides multiple connections for comprehensive metrics demonstration. + * For testnet usage, set haMode: false and use testnet endpoints. + */ + +import { createClient, MetricsSnapshot, ConnectionStatus, decodeReport } from "../src"; + +async function monitoringExample() { + // Create client with HA mode for comprehensive metrics + const client = createClient({ + apiKey: process.env.API_KEY || "YOUR_API_KEY", + userSecret: process.env.USER_SECRET || "YOUR_USER_SECRET", + endpoint: "https://api.dataengine.chain.link", + wsEndpoint: "wss://ws.dataengine.chain.link", + haMode: true, + // Advanced connection monitoring with origin tracking + connectionStatusCallback: (isConnected, host, origin) => { + const timestamp = new Date().toISOString().substring(11, 19); + const status = isConnected ? "🟢 UP" : "šŸ”“ DOWN"; + console.log(`[${timestamp}] ${status} ${host}${origin || ""}`); + + // Example: Send alerts for specific origins + if (!isConnected && origin) { + console.warn(`āš ļø Alert: Origin ${origin} on ${host} went offline`); + } + }, + logging: { + logger: { + info: console.log, + error: console.error, + debug: console.debug, + warn: console.warn, + }, + }, + }); + + const stream = client.createStream([ + "0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8", // BTC/USD Mainnet + ]); + + // Track when to show first dashboard + let hasShownInitialDashboard = false; + + // Set up event handlers + stream.on("report", report => { + // Decode the report to get the price + const decodedReport = decodeReport(report.fullReport, report.feedID); + const price = + (decodedReport as { price?: bigint | string; benchmarkPrice?: bigint | string }).price || + (decodedReport as { price?: bigint | string; benchmarkPrice?: bigint | string }).benchmarkPrice || + "N/A"; + const priceStr = typeof price === "bigint" ? price.toString() : price; + console.log(`šŸ“Š Report received: ${report.feedID} = ${priceStr}`); + + // Get metrics after each report + const metrics = stream.getMetrics(); + const deduplicationRate = ((metrics.deduplicated / metrics.totalReceived) * 100).toFixed(1); + console.log( + `šŸ“ˆ Reports: ${metrics.accepted} unique, ${metrics.deduplicated} duplicates (${deduplicationRate}% dedup rate)` + ); + + // Show dashboard after 10 accepted reports for quick feedback + if (!hasShownInitialDashboard && metrics.accepted >= 10) { + hasShownInitialDashboard = true; + console.log("\n" + "=".repeat(50)); + console.log("šŸ“Š INITIAL METRICS DASHBOARD (after 10 reports)"); + console.log("=".repeat(50)); + logMetrics(stream); + } + }); + + stream.on("error", error => { + console.error("🚨 Stream error:", error); + logMetrics(stream); + }); + + stream.on("disconnected", () => { + console.warn("āš ļø Stream disconnected - all connections lost"); + logMetrics(stream); + }); + + // Connect and start monitoring + await stream.connect(); + console.log("šŸ”Œ Stream connected successfully"); + + // Log metrics every 30 seconds for ongoing monitoring + const metricsInterval = setInterval(() => { + console.log("\n" + "=".repeat(50)); + console.log("šŸ“Š METRICS DASHBOARD (periodic update)"); + console.log("=".repeat(50)); + logMetrics(stream); + }, 30000); + + // Simulate running for 5 minutes + setTimeout( + async () => { + clearInterval(metricsInterval); + console.log("\nšŸ›‘ Shutting down..."); + await stream.close(); + console.log("āœ… Stream closed gracefully"); + }, + 5 * 60 * 1000 + ); +} + +/** + * Log comprehensive metrics in a dashboard-friendly format + */ +function logMetrics(stream: { getMetrics(): MetricsSnapshot }) { + const metrics: MetricsSnapshot = stream.getMetrics(); + + console.log("\nšŸ“Š Stream Metrics:"); + console.log(` Reports Accepted: ${metrics.accepted.toLocaleString()}`); + console.log(` Reports Deduplicated: ${metrics.deduplicated.toLocaleString()}`); + console.log(` Total Received: ${metrics.totalReceived.toLocaleString()}`); + + if (metrics.totalReceived > 0) { + const deduplicationRate = ((metrics.deduplicated / metrics.totalReceived) * 100).toFixed(1); + console.log(` Deduplication Rate: ${deduplicationRate}% (${metrics.deduplicated} filtered duplicates)`); + console.log(` Data Freshness: ${metrics.accepted} unique reports processed`); + } + + console.log("\nšŸ”— Connection Health:"); + console.log(` Active Connections: ${metrics.activeConnections}/${metrics.configuredConnections}`); + console.log(` Partial Reconnects: ${metrics.partialReconnects}`); + console.log(` Full Reconnects: ${metrics.fullReconnects}`); + + // Connection status per origin (shows individual backend endpoints) + console.log("\n🌐 Origin Status:"); + Object.entries(metrics.originStatus).forEach(([origin, status]) => { + const statusIcon = getStatusIcon(status); + const host = new URL(origin).host; + const originId = origin.includes("#") ? origin.split("#")[1] : ""; + console.log(` ${statusIcon} ${host}${originId ? `#${originId}` : ""}: ${status}`); + }); + + // Connection reliability status + const isHighlyAvailable = metrics.activeConnections > 1; + const hasRecentIssues = metrics.fullReconnects > 0 || metrics.partialReconnects > 3; + + console.log(`\nšŸš€ Stream Status:`); + console.log(` Mode: ${isHighlyAvailable ? "High Availability" : "Single Connection"}`); + console.log(` Stability: ${hasRecentIssues ? "Some recent reconnections" : "Stable"}`); +} + +/** + * Get emoji icon for connection status + */ +function getStatusIcon(status: ConnectionStatus): string { + switch (status) { + case ConnectionStatus.CONNECTED: + return "🟢"; + case ConnectionStatus.CONNECTING: + return "🟔"; + case ConnectionStatus.RECONNECTING: + return "🟠"; + case ConnectionStatus.FAILED: + return "šŸ”“"; + case ConnectionStatus.DISCONNECTED: + default: + return "⚫"; + } +} + +/** + * Assess stream reliability based on connection patterns + */ +function assessStreamReliability(metrics: MetricsSnapshot) { + const reliability = { + mode: metrics.activeConnections > 1 ? "HA" : "Single", + stability: "stable", + recommendations: [] as string[], + }; + + // Check for connection issues + if (metrics.activeConnections < metrics.configuredConnections) { + reliability.stability = "degraded"; + reliability.recommendations.push("Some configured connections are inactive"); + } + + // Frequent full reconnects indicate network issues + if (metrics.fullReconnects > 2) { + reliability.stability = "unstable"; + reliability.recommendations.push("Frequent full reconnections detected - check network stability"); + } + + // Too many partial reconnects might indicate load balancing issues + if (metrics.partialReconnects > 10) { + reliability.recommendations.push("High partial reconnection rate - consider reviewing connection configuration"); + } + + return reliability; +} + +// Run the example +monitoringExample().catch(console.error); + +export { monitoringExample, logMetrics, assessStreamReliability }; diff --git a/typescript/examples/stream-reports.ts b/typescript/examples/stream-reports.ts new file mode 100644 index 0000000..2b83749 --- /dev/null +++ b/typescript/examples/stream-reports.ts @@ -0,0 +1,133 @@ +import { createClient, LogLevel } from "../src"; +import { getReportVersion, formatReport } from "../src/utils/report"; +import { decodeReport } from "../src/decoder"; +import "dotenv/config"; + +async function main() { + if (process.argv.length < 3) { + console.error("Please provide one or more feed IDs as arguments"); + console.error("\nExamples:"); + console.error(" Single feed:"); + console.error( + " npx ts-node examples/stream-reports.ts 0x000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba782" + ); + console.error(" Multiple feeds:"); + console.error( + " npx ts-node examples/stream-reports.ts 0x000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba782,0x00036fe43f87884450b4c7e093cd5ed99cac6640d8c2000e6afc02c8838d0265" + ); + console.error(" High Availability mode:"); + console.error(" npx ts-node examples/stream-reports.ts --ha"); + process.exit(1); + } + + const feedIds = process.argv[2].split(","); + const haMode = process.argv.includes("--ha"); + + console.log("Chainlink Data Streams - Report Streaming"); + console.log("=".repeat(60)); + console.log(`šŸ“Š Feeds: ${feedIds.length} feed(s)`); + console.log(`šŸŽÆ Mode: ${haMode ? "High Availability" : "Single Connection"}`); + console.log("=".repeat(60)); + + try { + const client = createClient({ + apiKey: process.env.API_KEY || "YOUR_API_KEY", + userSecret: process.env.USER_SECRET || "YOUR_USER_SECRET", + endpoint: "https://api.dataengine.chain.link", + wsEndpoint: "wss://ws.dataengine.chain.link", + haMode, + + // Comment to disable SDK logging: + logging: { + logger: console, + logLevel: LogLevel.INFO, + enableConnectionDebug: false, // Enable WebSocket ping/pong and connection state logs (logLevel should be DEBUG) + }, + }); + + let reportCount = 0; + + // Create stream with custom options + const stream = client.createStream(feedIds, { + maxReconnectAttempts: 10, + reconnectInterval: 3000, + }); + + // Event: Process incoming reports + stream.on("report", report => { + reportCount++; + + try { + console.log(`\nšŸ“ˆ Report #${reportCount} - ${new Date().toISOString()}`); + + // Show raw report blob + console.log(`\nRaw Report Blob: ${report.fullReport}`); + + // Decode the report + const decodedData = decodeReport(report.fullReport, report.feedID); + const version = getReportVersion(report.feedID); + + // Combine decoded data with report metadata + const decodedReport = { + ...decodedData, + feedID: report.feedID, + validFromTimestamp: report.validFromTimestamp, + observationsTimestamp: report.observationsTimestamp, + }; + + console.log(formatReport(decodedReport, version)); + } catch (error) { + console.error(`āŒ Error processing report: ${error instanceof Error ? error.message : error}`); + } + + // Display stats every 5 reports + if (reportCount % 5 === 0) { + const stats = stream.getMetrics(); + console.log( + `\nšŸ“Š Stats: ${stats.accepted} reports | ${stats.activeConnections}/${stats.configuredConnections} connections` + ); + } + }); + + // Event: Handle errors + stream.on("error", error => { + console.error(`\nāŒ Error: ${error.message}`); + + if (error.message.includes("authentication")) { + console.error("šŸ’” Check your API_KEY and USER_SECRET environment variables"); + } + }); + + // Event: Handle disconnections + stream.on("disconnected", () => { + console.log("\nšŸ”“ Stream disconnected - reconnecting..."); + }); + + // Event: Monitor reconnections + stream.on("reconnecting", (info: { attempt: number; delayMs: number; origin?: string; host?: string }) => { + console.log( + `šŸ”„ Reconnecting... attempt ${info.attempt} in ~${info.delayMs}ms${info.host ? ` (${info.host})` : ""}` + ); + }); + + console.log("ā³ Connecting...\n"); + await stream.connect(); + console.log("āœ… Connected! Listening for reports...\n"); + + // Graceful shutdown + const shutdown = async () => { + console.log("\nšŸ›‘ Shutting down..."); + await stream.close(); + console.log("āœ… Shutdown complete"); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + } catch (error) { + console.error("āŒ Failed to start stream:", error instanceof Error ? error.message : error); + process.exit(1); + } +} + +main(); diff --git a/typescript/jest.config.js b/typescript/jest.config.js new file mode 100644 index 0000000..36d3a39 --- /dev/null +++ b/typescript/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/tests"], + testMatch: ["**/*.test.ts"], + moduleFileExtensions: ["ts", "js", "json", "node"], + setupFilesAfterEnv: ["/tests/setup.ts"], +}; diff --git a/typescript/package-lock.json b/typescript/package-lock.json new file mode 100644 index 0000000..b909bef --- /dev/null +++ b/typescript/package-lock.json @@ -0,0 +1,6931 @@ +{ + "name": "@chainlink/data-streams-sdk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@chainlink/data-streams-sdk", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "ethers": "^6.15.0", + "ws": "^8.18.3" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^20.10.4", + "@types/ws": "^8.18.1", + "@typescript-eslint/eslint-plugin": "^8.39.0", + "@typescript-eslint/parser": "^8.39.0", + "dotenv": "^16.6.1", + "eslint": "^9.32.0", + "husky": "^9.1.7", + "jest": "^30.0.5", + "lint-staged": "^15.5.2", + "prettier": "^3.6.2", + "rimraf": "^5.0.5", + "ts-jest": "^29.4.1", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "dotenv": "^16.6.1" + }, + "peerDependenciesMeta": { + "dotenv": { + "optional": true + } + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", + "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.3", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", + "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", + "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", + "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", + "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", + "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.0.5.tgz", + "integrity": "sha512-xY6b0XiL0Nav3ReresUarwl2oIz1gTnxGbGpho9/rbUWsLH0f1OD/VT84xs8c7VmH7MChnLb0pag6PhZhAdDiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.0.5", + "jest-util": "30.0.5", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.0.5.tgz", + "integrity": "sha512-fKD0OulvRsXF1hmaFgHhVJzczWzA1RXMMo9LTPuFXo9q/alDbME3JIyWYqovWsUBWSoBcsHaGPSLF9rz4l9Qeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.0.5", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.0.5", + "jest-config": "30.0.5", + "jest-haste-map": "30.0.5", + "jest-message-util": "30.0.5", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.0.5", + "jest-resolve-dependencies": "30.0.5", + "jest-runner": "30.0.5", + "jest-runtime": "30.0.5", + "jest-snapshot": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", + "jest-watcher": "30.0.5", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.5.tgz", + "integrity": "sha512-aRX7WoaWx1oaOkDQvCWImVQ8XNtdv5sEWgk4gxR6NXb7WBUnL5sRak4WRzIQRZ1VTWPvV4VI4mgGjNL9TeKMYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.0.5.tgz", + "integrity": "sha512-6udac8KKrtTtC+AXZ2iUN/R7dp7Ydry+Fo6FPFnDG54wjVMnb6vW/XNlf7Xj8UDjAE3aAVAsR4KFyKk3TCXmTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.0.5", + "jest-snapshot": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.0.5.tgz", + "integrity": "sha512-F3lmTT7CXWYywoVUGTCmom0vXq3HTTkaZyTAzIy+bXSBizB7o5qzlC9VCtq0arOa8GqmNsbg/cE9C6HLn7Szew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.5.tgz", + "integrity": "sha512-ZO5DHfNV+kgEAeP3gK3XlpJLL4U3Sz6ebl/n68Uwt64qFFs5bv4bfEEjyRGK5uM0C90ewooNgFuKMdkbEoMEXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.1.tgz", + "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.0.5.tgz", + "integrity": "sha512-7oEJT19WW4oe6HR7oLRvHxwlJk2gev0U9px3ufs8sX9PoD1Eza68KF0/tlN7X0dq/WVsBScXQGgCldA1V9Y/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/expect": "30.0.5", + "@jest/types": "30.0.5", + "jest-mock": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.0.5.tgz", + "integrity": "sha512-mafft7VBX4jzED1FwGC1o/9QUM2xebzavImZMeqnsklgcyxBto8mV4HzNSzUrryJ+8R9MFOM3HgYuDradWR+4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.0.5", + "jest-util": "30.0.5", + "jest-worker": "30.0.5", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.0.5.tgz", + "integrity": "sha512-XcCQ5qWHLvi29UUrowgDFvV4t7ETxX91CbDczMnoqXPOIcZOxyNdSjm6kV5XMc8+HkxfRegU/MUmnTbJRzGrUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.0.5.tgz", + "integrity": "sha512-wPyztnK0gbDMQAJZ43tdMro+qblDHH1Ru/ylzUo21TBKqt88ZqnKKK2m30LKmLLoKtR2lxdpCC/P3g1vfKcawQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.0.5", + "@jest/types": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.0.5.tgz", + "integrity": "sha512-Aea/G1egWoIIozmDD7PBXUOxkekXl7ueGzrsGGi1SbeKgQqCYCIf+wfbflEbf2LiPxL8j2JZGLyrzZagjvW4YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.0.5", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.5", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.0.5.tgz", + "integrity": "sha512-Vk8amLQCmuZyy6GbBht1Jfo9RSdBtg7Lks+B0PecnjI8J+PCLQPGh7uI8Q/2wwpW2gLdiAfiHNsmekKlywULqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.0.5", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.0", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.5", + "jest-regex-util": "30.0.1", + "jest-util": "30.0.5", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.40", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.40.tgz", + "integrity": "sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", + "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz", + "integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz", + "integrity": "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/type-utils": "8.39.1", + "@typescript-eslint/utils": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.39.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.1.tgz", + "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz", + "integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.39.1", + "@typescript-eslint/types": "^8.39.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz", + "integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz", + "integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.1.tgz", + "integrity": "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1", + "@typescript-eslint/utils": "8.39.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz", + "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz", + "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.39.1", + "@typescript-eslint/tsconfig-utils": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz", + "integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz", + "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/babel-jest": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.0.5.tgz", + "integrity": "sha512-mRijnKimhGDMsizTvBTWotwNpzrkHr+VvZUQBof2AufXKB8NXrL1W69TG20EvOz7aevx6FTJIaBuBkYxS8zolg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.0.5", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.0", + "babel-preset-jest": "30.0.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.0.tgz", + "integrity": "sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.0.1.tgz", + "integrity": "sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.0.1.tgz", + "integrity": "sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.0.1", + "babel-preset-current-node-syntax": "^1.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz", + "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001733", + "electron-to-chromium": "^1.5.199", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001735", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz", + "integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", + "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.203", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.203.tgz", + "integrity": "sha512-uz4i0vLhfm6dLZWbz/iH88KNDV+ivj5+2SA+utpgjKaj9Q0iDLuwk6Idhe9BTxciHudyx6IvTvijhkPvFGUQ0g==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", + "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.33.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ethers": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.15.0.tgz", + "integrity": "sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ethers/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "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 + } + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.5.tgz", + "integrity": "sha512-P0te2pt+hHI5qLJkIR+iMvS+lYUZml8rKKsohVHAGY+uClp9XVbdyYNJOIjSRpHVp8s8YqxJCiHUkSYZGr8rtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.0.5", + "@jest/get-type": "30.0.1", + "jest-matcher-utils": "30.0.5", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "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.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.0.5.tgz", + "integrity": "sha512-y2mfcJywuTUkvLm2Lp1/pFX8kTgMO5yyQGq/Sk/n2mN7XWYp4JsCZ/QXW34M8YScgk8bPZlREH04f6blPnoHnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.0.5", + "@jest/types": "30.0.5", + "import-local": "^3.2.0", + "jest-cli": "30.0.5" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.0.5.tgz", + "integrity": "sha512-bGl2Ntdx0eAwXuGpdLdVYVr5YQHnSZlQ0y9HVDu565lCUAe9sj6JOtBbMmBBikGIegne9piDDIOeiLVoqTkz4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.0.5", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.0.5.tgz", + "integrity": "sha512-h/sjXEs4GS+NFFfqBDYT7y5Msfxh04EwWLhQi0F8kuWpe+J/7tICSlswU8qvBqumR3kFgHbfu7vU6qruWWBPug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/expect": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.0.5", + "jest-matcher-utils": "30.0.5", + "jest-message-util": "30.0.5", + "jest-runtime": "30.0.5", + "jest-snapshot": "30.0.5", + "jest-util": "30.0.5", + "p-limit": "^3.1.0", + "pretty-format": "30.0.5", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.0.5.tgz", + "integrity": "sha512-Sa45PGMkBZzF94HMrlX4kUyPOwUpdZasaliKN3mifvDmkhLYqLLg8HQTzn6gq7vJGahFYMQjXgyJWfYImKZzOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/types": "30.0.5", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.0.5.tgz", + "integrity": "sha512-aIVh+JNOOpzUgzUnPn5FLtyVnqc3TQHVMupYtyeURSb//iLColiMIR8TxCIDKyx9ZgjKnXGucuW68hCxgbrwmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.0.1", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.0.5", + "@jest/types": "30.0.5", + "babel-jest": "30.0.5", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.0.5", + "jest-docblock": "30.0.1", + "jest-environment-node": "30.0.5", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.0.5", + "jest-runner": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", + "micromatch": "^4.0.8", + "parse-json": "^5.2.0", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.5.tgz", + "integrity": "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.0.1.tgz", + "integrity": "sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.0.5.tgz", + "integrity": "sha512-dKjRsx1uZ96TVyejD3/aAWcNKy6ajMaN531CwWIsrazIqIoXI9TnnpPlkrEYku/8rkS3dh2rbH+kMOyiEIv0xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "@jest/types": "30.0.5", + "chalk": "^4.1.2", + "jest-util": "30.0.5", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.0.5.tgz", + "integrity": "sha512-ppYizXdLMSvciGsRsMEnv/5EFpvOdXBaXRBzFUDPWrsfmog4kYrOGWXarLllz6AXan6ZAA/kYokgDWuos1IKDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.0.5.tgz", + "integrity": "sha512-dkmlWNlsTSR0nH3nRfW5BKbqHefLZv0/6LCccG0xFCTWcJu8TuEwG+5Cm75iBfjVoockmO6J35o5gxtFSn5xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.0.5", + "jest-worker": "30.0.5", + "micromatch": "^4.0.8", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.0.5.tgz", + "integrity": "sha512-3Uxr5uP8jmHMcsOtYMRB/zf1gXN3yUIc+iPorhNETG54gErFIiUhLvyY/OggYpSMOEYqsmRxmuU4ZOoX5jpRFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.5.tgz", + "integrity": "sha512-uQgGWt7GOrRLP1P7IwNWwK1WAQbq+m//ZY0yXygyfWp0rJlksMSLQAA4wYQC3b6wl3zfnchyTx+k3HZ5aPtCbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "jest-diff": "30.0.5", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.5.tgz", + "integrity": "sha512-NAiDOhsK3V7RU0Aa/HnrQo+E4JlbarbmI3q6Pi4KcxicdtjV82gcIUrejOtczChtVQR4kddu1E1EJlW6EN9IyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.0.5.tgz", + "integrity": "sha512-d+DjBQ1tIhdz91B79mywH5yYu76bZuE96sSbxj8MkjWVx5WNdt1deEFRONVL4UkKLSrAbMkdhb24XN691yDRHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.5", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.0.5.tgz", + "integrity": "sha512-/xMvBR4MpwkrHW4ikZIWRttBBRZgWK4d6xt3xW1iRDSKt4tXzYkMkyPfBnSCgv96cpkrctfXs6gexeqMYqdEpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.0.5.tgz", + "integrity": "sha512-JcCOucZmgp+YuGgLAXHNy7ualBx4wYSgJVWrYMRBnb79j9PD0Jxh0EHvR5Cx/r0Ce+ZBC4hCdz2AzFFLl9hCiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.0.5", + "@jest/environment": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.0.1", + "jest-environment-node": "30.0.5", + "jest-haste-map": "30.0.5", + "jest-leak-detector": "30.0.5", + "jest-message-util": "30.0.5", + "jest-resolve": "30.0.5", + "jest-runtime": "30.0.5", + "jest-util": "30.0.5", + "jest-watcher": "30.0.5", + "jest-worker": "30.0.5", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.0.5.tgz", + "integrity": "sha512-7oySNDkqpe4xpX5PPiJTe5vEa+Ak/NnNz2bGYZrA1ftG3RL3EFlHaUkA1Cjx+R8IhK0Vg43RML5mJedGTPNz3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/fake-timers": "30.0.5", + "@jest/globals": "30.0.5", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.5", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.0.5", + "jest-snapshot": "30.0.5", + "jest-util": "30.0.5", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.0.5.tgz", + "integrity": "sha512-T00dWU/Ek3LqTp4+DcW6PraVxjk28WY5Ua/s+3zUKSERZSNyxTqhDXCWKG5p2HAJ+crVQ3WJ2P9YVHpj1tkW+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.0.5", + "@jest/get-type": "30.0.1", + "@jest/snapshot-utils": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "babel-preset-current-node-syntax": "^1.1.0", + "chalk": "^4.1.2", + "expect": "30.0.5", + "graceful-fs": "^4.2.11", + "jest-diff": "30.0.5", + "jest-matcher-utils": "30.0.5", + "jest-message-util": "30.0.5", + "jest-util": "30.0.5", + "pretty-format": "30.0.5", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.0.5.tgz", + "integrity": "sha512-ouTm6VFHaS2boyl+k4u+Qip4TSH7Uld5tyD8psQ8abGgt2uYYB8VwVfAHWHjHc0NWmGGbwO5h0sCPOGHHevefw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "@jest/types": "30.0.5", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.0.5.tgz", + "integrity": "sha512-z9slj/0vOwBDBjN3L4z4ZYaA+pG56d6p3kTUhFRYGvXbXMWhXmb/FIxREZCD06DYUwDKKnj2T80+Pb71CQ0KEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.0.5", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.0.5.tgz", + "integrity": "sha512-ojRXsWzEP16NdUuBw/4H/zkZdHOa7MMYCk4E430l+8fELeLg/mqmMlRhjL7UNZvQrDmnovWZV4DxX03fZF48fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.0.5", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lint-staged": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", + "integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", + "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/lint-staged/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", + "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-jest": { + "version": "29.4.1", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.1.tgz", + "integrity": "sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.2", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "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 + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/typescript/package.json b/typescript/package.json new file mode 100644 index 0000000..e289969 --- /dev/null +++ b/typescript/package.json @@ -0,0 +1,91 @@ +{ + "name": "@chainlink/data-streams-sdk", + "version": "1.0.0", + "description": "TypeScript SDK for Chainlink Data Streams", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "engines": { + "node": ">=20.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "npm run clean && tsc", + "test": "jest", + "test:ci": "jest --ci --coverage --watchAll=false", + "test:unit": "jest tests/unit", + "test:integration": "jest tests/integration", + "lint": "eslint src --ext .ts", + "lint:fix": "eslint src --ext .ts --fix", + "clean": "rimraf dist", + "prepare": "husky", + "prepublishOnly": "npm run build && npm run test:ci && npm run lint", + "format": "prettier --write \"src/**/*.ts\" \"examples/**/*.ts\" \"tests/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.ts\" \"examples/**/*.ts\" \"tests/**/*.ts\"", + "size-check": "npm run build && du -sh dist/", + "audit:fix": "npm audit fix" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/smartcontractkit/data-streams-sdk.git", + "directory": "typescript" + }, + "keywords": [ + "chainlink", + "data-streams", + "sdk", + "typescript" + ], + "author": "Chainlink", + "license": "MIT", + "bugs": { + "url": "https://github.com/smartcontractkit/data-streams-sdk/issues" + }, + "homepage": "https://github.com/smartcontractkit/data-streams-sdk/tree/main/typescript#readme", + "dependencies": { + "ethers": "^6.15.0", + "ws": "^8.18.3" + }, + "peerDependencies": { + "dotenv": "^16.6.1" + }, + "peerDependenciesMeta": { + "dotenv": { + "optional": true + } + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^20.10.4", + "@types/ws": "^8.18.1", + "@typescript-eslint/eslint-plugin": "^8.39.0", + "@typescript-eslint/parser": "^8.39.0", + "dotenv": "^16.6.1", + "eslint": "^9.32.0", + "husky": "^9.1.7", + "jest": "^30.0.5", + "lint-staged": "^15.5.2", + "prettier": "^3.6.2", + "rimraf": "^5.0.5", + "ts-jest": "^29.4.1", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + }, + "lint-staged": { + "*.ts": [ + "eslint --fix", + "prettier --write" + ] + } +} diff --git a/typescript/src/client/base.ts b/typescript/src/client/base.ts new file mode 100644 index 0000000..8cd88f5 --- /dev/null +++ b/typescript/src/client/base.ts @@ -0,0 +1,266 @@ +import { DEFAULT_TIMEOUT, DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_DELAY } from "../utils/constants"; +import { Config } from "../types/client"; +import { APIError, ValidationError } from "../types/errors"; +import { LoggingConfig, LogLevel } from "../types/logger"; +import { generateAuthHeaders } from "../utils/auth"; +import { SDKLogger } from "../utils/logger"; + +/** + * Base client class with common functionality + */ +export abstract class BaseClient { + protected config: Config; + protected logger: SDKLogger; + + constructor(config: Config) { + this.config = config; + this.logger = new SDKLogger(config.logging); + this.validateConfig(config); + } + + /** + * Validate the configuration object + * @param config Configuration to validate + * @throws {ValidationError} If configuration is invalid + */ + private validateConfig(config: Config): void { + this.logger.debug("Starting configuration validation"); + + // Check if config exists + if (!config || typeof config !== "object") { + throw new ValidationError("Configuration object is required"); + } + + // Validate required string fields + this.validateRequiredString(config.apiKey, "apiKey"); + this.validateRequiredString(config.userSecret, "userSecret"); + this.validateRequiredString(config.endpoint, "endpoint"); + this.validateRequiredString(config.wsEndpoint, "wsEndpoint"); + + // Validate URLs + this.validateUrl(config.endpoint, "endpoint", ["http:", "https:"]); + this.validateWebSocketUrls(config.wsEndpoint); + + // Validate optional numeric fields + if (config.timeout !== undefined) { + this.validatePositiveNumber(config.timeout, "timeout"); + } + if (config.retryAttempts !== undefined) { + this.validateNonNegativeNumber(config.retryAttempts, "retryAttempts"); + } + if (config.retryDelay !== undefined) { + this.validateNonNegativeNumber(config.retryDelay, "retryDelay"); + } + if (config.haConnectionTimeout !== undefined) { + this.validatePositiveNumber(config.haConnectionTimeout, "haConnectionTimeout"); + } + + // Validate boolean fields + if (config.haMode !== undefined && typeof config.haMode !== "boolean") { + throw new ValidationError("haMode must be a boolean"); + } + + // Validate callback function + if (config.connectionStatusCallback !== undefined && typeof config.connectionStatusCallback !== "function") { + throw new ValidationError("connectionStatusCallback must be a function"); + } + + // Validate logging config + this.validateLoggingConfig(config.logging); + + this.logger.debug("Configuration validation completed successfully"); + } + + /** + * Validate a required string field + */ + private validateRequiredString(value: unknown, fieldName: string): void { + if (value === undefined || value === null) { + throw new ValidationError(`${fieldName} is required`); + } + if (typeof value !== "string") { + throw new ValidationError(`${fieldName} must be a string`); + } + if (value.trim() === "") { + throw new ValidationError(`${fieldName} cannot be empty`); + } + } + + /** + * Validate a URL field + */ + private validateUrl(value: string, fieldName: string, allowedProtocols: string[]): void { + try { + const url = new URL(value); + if (!allowedProtocols.includes(url.protocol)) { + throw new ValidationError(`${fieldName} must use one of these protocols: ${allowedProtocols.join(", ")}`); + } + } catch (error) { + if (error instanceof ValidationError) { + throw error; + } + throw new ValidationError(`${fieldName} must be a valid URL`); + } + } + + /** + * Validate WebSocket URLs + */ + private validateWebSocketUrls(wsEndpoint: string): void { + const urls = wsEndpoint + .split(",") + .map(url => url.trim()) + .filter(url => url.length > 0); + + if (urls.length === 0) { + throw new ValidationError("wsEndpoint cannot be empty"); + } + + for (const url of urls) { + this.validateUrl(url, "wsEndpoint", ["ws:", "wss:"]); + } + } + + /** + * Validate a positive number + */ + private validatePositiveNumber(value: unknown, fieldName: string): void { + if (typeof value !== "number" || isNaN(value)) { + throw new ValidationError(`${fieldName} must be a number`); + } + if (value <= 0) { + throw new ValidationError(`${fieldName} must be positive`); + } + } + + /** + * Validate a non-negative number + */ + private validateNonNegativeNumber(value: unknown, fieldName: string): void { + if (typeof value !== "number" || isNaN(value)) { + throw new ValidationError(`${fieldName} must be a number`); + } + if (value < 0) { + throw new ValidationError(`${fieldName} cannot be negative`); + } + } + + /** + * Validate logging configuration + */ + private validateLoggingConfig(logging?: LoggingConfig): void { + if (!logging) { + return; + } + + // Validate logLevel + if (logging.logLevel !== undefined) { + if (!Object.values(LogLevel).includes(logging.logLevel)) { + throw new ValidationError( + `Invalid logLevel: ${logging.logLevel}. Must be one of: ${Object.values(LogLevel).join(", ")}` + ); + } + } + + // Validate logger functions + if (logging.logger) { + const logger = logging.logger; + const validLevels = ["debug", "info", "warn", "error"] as const; + + validLevels.forEach(level => { + if (logger[level] && typeof logger[level] !== "function") { + throw new ValidationError(`Logger.${level} must be a function, got ${typeof logger[level]}`); + } + }); + } + + // Validate enableConnectionDebug + if (logging.enableConnectionDebug !== undefined && typeof logging.enableConnectionDebug !== "boolean") { + throw new ValidationError("enableConnectionDebug must be a boolean"); + } + } + + /** + * Make an authenticated HTTP request + * @param path The API path + * @param options Request options + * @returns The response data + * @throws {APIError} If the request fails + */ + protected async makeRequest(path: string, options: RequestInit = {}): Promise { + const url = new URL(path, this.config.endpoint); + const method = options.method || "GET"; + const body = typeof options.body === "string" ? options.body : undefined; + + this.logger.debug(`Making ${method} request to ${url}`); + + // Generate auth headers + const authHeaders = generateAuthHeaders(this.config.apiKey, this.config.userSecret, method, url.toString(), body); + + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + Object.entries(authHeaders).forEach(([key, value]) => { + headers.set(key, value); + }); + + const response = await fetch(url.toString(), { + ...options, + headers, + signal: AbortSignal.timeout(this.config.timeout ?? DEFAULT_TIMEOUT), + }); + + if (!response.ok) { + let errorMessage: string; + try { + const errorData = await response.json(); + errorMessage = errorData.message || response.statusText; + } catch { + errorMessage = response.statusText; + } + this.logger.error(`Request failed: ${method} ${url} - ${response.status} ${errorMessage}`); + throw new APIError(errorMessage, response.status); + } + + this.logger.info(`Request successful: ${method} ${url} - ${response.status}`); + return response.json() as Promise; + } + + /** + * Retry a function with exponential backoff + * @param fn The function to retry + * @returns The function result + * @throws The last error encountered + */ + protected async withRetry(fn: () => Promise): Promise { + const maxAttempts = this.config.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS; + const baseDelay = this.config.retryDelay ?? DEFAULT_RETRY_DELAY; + + this.logger.debug(`Starting retry logic: max ${maxAttempts} attempts`); + + let lastError: Error | undefined; + for (let attempt = 0; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error as Error; + if (attempt === maxAttempts) { + this.logger.error(`All retry attempts failed (${maxAttempts}/${maxAttempts}):`, lastError); + break; + } + + // Skip retry for client errors (validation/authentication) + if (error instanceof APIError && (error.statusCode === 400 || error.statusCode === 401)) { + this.logger.warn(`Not retrying client error ${error.statusCode}: ${error.message}`); + throw error; + } + + // Exponential backoff with jitter + const delay = baseDelay * Math.pow(2, attempt) * (0.5 + Math.random() * 0.5); + this.logger.warn(`Retry attempt ${attempt}/${maxAttempts} failed, retrying in ${Math.round(delay)}ms:`, error); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + throw lastError; + } +} diff --git a/typescript/src/client/implementation.ts b/typescript/src/client/implementation.ts new file mode 100644 index 0000000..3520b3b --- /dev/null +++ b/typescript/src/client/implementation.ts @@ -0,0 +1,172 @@ +import { BaseClient } from "./base"; +import { Config, DataStreamsClient, IStream, StreamOptions } from "../types/client"; +import { Feed, Report } from "../types/report"; +import { validateFeedId, validateFeedIds, validateTimestamp } from "../utils/validation"; +import { Stream } from "../stream"; + +/** + * Main implementation of the Data Streams client + */ +export class DataStreamsClientImpl extends BaseClient implements DataStreamsClient { + constructor(config: Config) { + super(config); + this.validateHAConfiguration(config); + this.logger.info("Data Streams client initialized"); + } + + /** + * Validate HA mode configuration + */ + private validateHAConfiguration(config: Config): void { + if (config.haMode) { + const origins = this.parseOrigins(config.wsEndpoint); + + if (origins.length === 0) { + throw new Error("HA mode enabled but no WebSocket endpoints provided"); + } + } + + // Validate comma-separated URLs format + if (config.wsEndpoint) { + const origins = this.parseOrigins(config.wsEndpoint); + for (const origin of origins) { + if (!origin.startsWith("ws://") && !origin.startsWith("wss://")) { + throw new Error(`Invalid WebSocket URL format: ${origin}. Must start with ws:// or wss://`); + } + } + } + } + + /** + * Parse comma-separated WebSocket URLs + */ + private parseOrigins(wsEndpoint: string): string[] { + if (!wsEndpoint) { + return []; + } + + return wsEndpoint + .split(",") + .map(url => url.trim()) + .filter(url => url.length > 0); + } + + /** + * List all available feeds + * @returns Array of available feeds + */ + async listFeeds(): Promise { + this.logger.debug("Fetching available feeds"); + const response = await this.withRetry(() => this.makeRequest<{ feeds: Feed[] }>("/api/v1/feeds")); + this.logger.debug(`Retrieved ${response.feeds.length} feeds`); + return response.feeds; + } + + /** + * Get the latest report for a feed + * @param feedId The feed ID to get the report for + * @returns The raw report data + */ + async getLatestReport(feedId: string): Promise { + validateFeedId(feedId); + this.logger.debug(`Fetching latest report for feed ${feedId}`); + + const response = await this.withRetry(() => + this.makeRequest<{ report: Report }>(`/api/v1/reports/latest?feedID=${feedId}`) + ); + + this.logger.debug( + `Retrieved latest report for feed ${feedId} (timestamp: ${response.report.observationsTimestamp})` + ); + return response.report; + } + + /** + * Get a report for a feed at a specific timestamp + * @param feedId The feed ID to get the report for + * @param timestamp The timestamp to get the report for + * @returns The raw report data + */ + async getReportByTimestamp(feedId: string, timestamp: number): Promise { + validateFeedId(feedId); + validateTimestamp(timestamp); + this.logger.debug(`Fetching report for feed ${feedId} at timestamp ${timestamp}`); + + const response = await this.withRetry(() => + this.makeRequest<{ report: Report }>(`/api/v1/reports?feedID=${feedId}×tamp=${timestamp}`) + ); + + this.logger.debug(`Retrieved report for feed ${feedId} at timestamp ${timestamp}`); + return response.report; + } + + /** + * Get a range of reports for a feed + * @param feedId The feed ID to get reports for + * @param startTime The start timestamp, inclusive + * @param limit Maximum number of reports to return + * @returns Array of raw report data + */ + async getReportsPage(feedId: string, startTime: number, limit = 10): Promise { + validateFeedId(feedId); + validateTimestamp(startTime); + this.logger.debug(`Fetching ${limit} reports for feed ${feedId} starting from timestamp ${startTime}`); + + const response = await this.withRetry(() => + this.makeRequest<{ reports: Report[] }>( + `/api/v1/reports/page?feedID=${feedId}&startTimestamp=${startTime}&limit=${limit}` + ) + ); + + this.logger.info(`Retrieved ${response.reports.length} reports for feed ${feedId} (requested: ${limit})`); + return response.reports; + } + + /** + * Get reports for multiple feeds at a specific timestamp + * @param feedIds List of feed IDs to get reports for + * @param timestamp The timestamp to get reports for + * @returns Array of raw report data + * @warning Reports are not guaranteed to be returned in the same order as input feedIds. + * Always use `report.feedID` to identify each report rather than relying on array position. + */ + async getReportsBulk(feedIds: string[], timestamp: number): Promise { + validateFeedIds(feedIds); + validateTimestamp(timestamp); + this.logger.debug(`Fetching bulk reports for ${feedIds.length} feeds at timestamp ${timestamp}`); + + const response = await this.withRetry(() => + this.makeRequest<{ reports: Report[] }>( + `/api/v1/reports/bulk?feedIDs=${feedIds.join(",")}×tamp=${timestamp}` + ) + ); + + this.logger.info(`Retrieved ${response.reports.length} bulk reports for timestamp ${timestamp}`); + return response.reports; + } + + /** + * Create a new Stream instance for real-time data streaming. + * + * @param feedIds Feed ID(s) to stream + * @param options Optional stream configuration + * @returns Stream instance for real-time report processing + */ + createStream(feedIds: string | string[], options?: StreamOptions): IStream { + const feedIdArray = Array.isArray(feedIds) ? feedIds : [feedIds]; + feedIdArray.forEach(validateFeedId); + + this.logger.debug(`Creating stream for ${feedIdArray.length} feeds: ${feedIdArray.join(", ")}`); + + const streamOptions = options + ? { + reconnectInterval: options.reconnectInterval, + maxReconnectAttempts: options.maxReconnectAttempts, + } + : {}; + + const stream = new Stream(this.config, feedIdArray, streamOptions); + this.logger.info(`Stream created successfully for ${feedIdArray.length} feed(s)`); + return stream as IStream; + } +} diff --git a/typescript/src/client/index.ts b/typescript/src/client/index.ts new file mode 100644 index 0000000..94a7dca --- /dev/null +++ b/typescript/src/client/index.ts @@ -0,0 +1,14 @@ +import { Config, DataStreamsClient } from "../types/client"; +import { DataStreamsClientImpl } from "./implementation"; + +/** + * Create a new Data Streams client + * @param config Client configuration + * @returns A Data Streams client instance + */ +export function createClient(config: Config): DataStreamsClient { + return new DataStreamsClientImpl(config); +} + +export { DataStreamsClientImpl }; +export type { DataStreamsClient }; diff --git a/typescript/src/decoder/implementation.ts b/typescript/src/decoder/implementation.ts new file mode 100644 index 0000000..e0874df --- /dev/null +++ b/typescript/src/decoder/implementation.ts @@ -0,0 +1,449 @@ +import { AbiCoder, isHexString, getBytes } from "ethers"; +import { ReportDecodingError } from "../types/errors"; +import { + DecodedV2Report, + DecodedV3Report, + DecodedV4Report, + DecodedV5Report, + DecodedV6Report, + DecodedV7Report, + DecodedV8Report, + DecodedV9Report, + DecodedV10Report, + MarketStatus, +} from "../types"; + +import { SDKLogger } from "../utils/logger"; + +const globalAbiCoder = new AbiCoder(); +const outerReportAbiCoder = new AbiCoder(); + +const reportSchemaV2 = [ + { type: "bytes32", name: "feedId" }, + { type: "uint32", name: "validFromTimestamp" }, + { type: "uint32", name: "observationsTimestamp" }, + { type: "uint192", name: "nativeFee" }, + { type: "uint192", name: "linkFee" }, + { type: "uint32", name: "expiresAt" }, + { type: "int192", name: "price" }, +]; + +const reportSchemaV3 = [ + { type: "bytes32", name: "feedId" }, + { type: "uint32", name: "validFromTimestamp" }, + { type: "uint32", name: "observationsTimestamp" }, + { type: "uint192", name: "nativeFee" }, + { type: "uint192", name: "linkFee" }, + { type: "uint32", name: "expiresAt" }, + { type: "int192", name: "price" }, + { type: "int192", name: "bid" }, + { type: "int192", name: "ask" }, +]; + +const reportSchemaV4 = [ + { type: "bytes32", name: "feedId" }, + { type: "uint32", name: "validFromTimestamp" }, + { type: "uint32", name: "observationsTimestamp" }, + { type: "uint192", name: "nativeFee" }, + { type: "uint192", name: "linkFee" }, + { type: "uint32", name: "expiresAt" }, + { type: "int192", name: "price" }, + { type: "uint8", name: "marketStatus" }, +]; + +const reportSchemaV5 = [ + { type: "bytes32", name: "feedId" }, + { type: "uint32", name: "validFromTimestamp" }, + { type: "uint32", name: "observationsTimestamp" }, + { type: "uint192", name: "nativeFee" }, + { type: "uint192", name: "linkFee" }, + { type: "uint32", name: "expiresAt" }, + { type: "int192", name: "rate" }, + { type: "uint32", name: "timestamp" }, + { type: "uint32", name: "duration" }, +]; + +const reportSchemaV6 = [ + { type: "bytes32", name: "feedId" }, + { type: "uint32", name: "validFromTimestamp" }, + { type: "uint32", name: "observationsTimestamp" }, + { type: "uint192", name: "nativeFee" }, + { type: "uint192", name: "linkFee" }, + { type: "uint32", name: "expiresAt" }, + { type: "int192", name: "price" }, + { type: "int192", name: "price2" }, + { type: "int192", name: "price3" }, + { type: "int192", name: "price4" }, + { type: "int192", name: "price5" }, +]; + +const reportSchemaV7 = [ + { type: "bytes32", name: "feedId" }, + { type: "uint32", name: "validFromTimestamp" }, + { type: "uint32", name: "observationsTimestamp" }, + { type: "uint192", name: "nativeFee" }, + { type: "uint192", name: "linkFee" }, + { type: "uint32", name: "expiresAt" }, + { type: "int192", name: "exchangeRate" }, +]; + +const reportSchemaV8 = [ + { type: "bytes32", name: "feedId" }, + { type: "uint32", name: "validFromTimestamp" }, + { type: "uint32", name: "observationsTimestamp" }, + { type: "uint192", name: "nativeFee" }, + { type: "uint192", name: "linkFee" }, + { type: "uint32", name: "expiresAt" }, + { type: "uint64", name: "lastUpdateTimestamp" }, + { type: "int192", name: "midPrice" }, + { type: "uint32", name: "marketStatus" }, +]; + +const reportSchemaV9 = [ + { type: "bytes32", name: "feedId" }, + { type: "uint32", name: "validFromTimestamp" }, + { type: "uint32", name: "observationsTimestamp" }, + { type: "uint192", name: "nativeFee" }, + { type: "uint192", name: "linkFee" }, + { type: "uint32", name: "expiresAt" }, + { type: "int192", name: "navPerShare" }, + { type: "uint64", name: "navDate" }, + { type: "int192", name: "aum" }, + { type: "uint32", name: "ripcord" }, +]; + +const reportSchemaV10 = [ + { type: "bytes32", name: "feedId" }, + { type: "uint32", name: "validFromTimestamp" }, + { type: "uint32", name: "observationsTimestamp" }, + { type: "uint192", name: "nativeFee" }, + { type: "uint192", name: "linkFee" }, + { type: "uint32", name: "expiresAt" }, + { type: "uint64", name: "lastUpdateTimestamp" }, + { type: "int192", name: "price" }, + { type: "uint32", name: "marketStatus" }, + { type: "int192", name: "currentMultiplier" }, + { type: "int192", name: "newMultiplier" }, + { type: "uint32", name: "activationDateTime" }, + { type: "int192", name: "tokenizedPrice" }, +]; + +/** + * Decode a report from its hex string representation + * @param reportHex The hex string representation of the report + * @param feedId The feed ID (stream ID) which contains the version information + * @returns The decoded report data + * @throws ReportDecodingError if decoding fails + */ +export function decodeReport( + reportHex: string, + feedId: string, + logger?: SDKLogger +): + | DecodedV2Report + | DecodedV3Report + | DecodedV4Report + | DecodedV5Report + | DecodedV6Report + | DecodedV7Report + | DecodedV8Report + | DecodedV9Report + | DecodedV10Report { + logger?.debug(`Decoding report for feed ${feedId}`); + + try { + // Ensure the report starts with 0x + if (!isHexString(reportHex)) { + throw new ReportDecodingError("Report hex string must start with 0x"); + } + + // Extract version from feed ID (first 4 bytes after 0x) + const version = feedId.slice(2, 6); + logger?.debug(`Detected report version: V${version.slice(2)}`); + + // First decode the full report structure to get the report blob + const fullReportAbi = [ + { type: "bytes32[3]", name: "reportContext" }, + { type: "bytes", name: "reportBlob" }, + { type: "bytes32[]", name: "rawRs" }, + { type: "bytes32[]", name: "rawSs" }, + { type: "bytes32", name: "rawVs" }, + ]; + + const decodedFullReport = outerReportAbiCoder.decode( + fullReportAbi.map(item => item.type), + reportHex + ); + + const reportBlob = decodedFullReport[1]; + + switch (version) { + case "0002": + return decodeV2Report(reportBlob); + case "0003": + return decodeV3Report(reportBlob); + case "0004": + return decodeV4Report(reportBlob); + case "0005": + return decodeV5Report(reportBlob); + case "0006": + return decodeV6Report(reportBlob); + case "0007": + return decodeV7Report(reportBlob); + case "0008": + return decodeV8Report(reportBlob); + case "0009": + return decodeV9Report(reportBlob); + case "000a": + return decodeV10Report(reportBlob); + default: + throw new ReportDecodingError(`Unknown report version: 0x${version}`); + } + } catch (error) { + if (error instanceof ReportDecodingError) { + throw error; + } + throw new ReportDecodingError(`Failed to decode report: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function decodeV2Report(reportBlob: string): DecodedV2Report { + try { + const decoded = globalAbiCoder.decode( + reportSchemaV2.map(item => item.type), + getBytes(reportBlob) + ); + + return { + version: "V2", + nativeFee: decoded[3], + linkFee: decoded[4], + expiresAt: Number(decoded[5]), + price: decoded[6], + }; + } catch (error) { + throw new ReportDecodingError( + `Failed to decode V2 report: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function decodeV3Report(reportBlob: string): DecodedV3Report { + try { + const decoded = globalAbiCoder.decode( + reportSchemaV3.map(item => item.type), + getBytes(reportBlob) + ); + + return { + version: "V3", + nativeFee: decoded[3], + linkFee: decoded[4], + expiresAt: Number(decoded[5]), + price: decoded[6], + bid: decoded[7], + ask: decoded[8], + }; + } catch (error) { + throw new ReportDecodingError( + `Failed to decode V3 report: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function decodeV4Report(reportBlob: string): DecodedV4Report { + try { + const decoded = globalAbiCoder.decode( + reportSchemaV4.map(item => item.type), + getBytes(reportBlob) + ); + + const marketStatus = Number(decoded[7]); + if ( + marketStatus !== MarketStatus.UNKNOWN && + marketStatus !== MarketStatus.INACTIVE && + marketStatus !== MarketStatus.ACTIVE + ) { + throw new ReportDecodingError(`Invalid market status: ${marketStatus}`); + } + + return { + version: "V4", + nativeFee: decoded[3], + linkFee: decoded[4], + expiresAt: Number(decoded[5]), + price: decoded[6], + marketStatus, + }; + } catch (error) { + throw new ReportDecodingError( + `Failed to decode V4 report: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function decodeV5Report(reportBlob: string): DecodedV5Report { + try { + const decoded = globalAbiCoder.decode( + reportSchemaV5.map(item => item.type), + getBytes(reportBlob) + ); + + return { + version: "V5", + nativeFee: decoded[3], + linkFee: decoded[4], + expiresAt: Number(decoded[5]), + rate: decoded[6], + timestamp: Number(decoded[7]), + duration: Number(decoded[8]), + }; + } catch (error) { + throw new ReportDecodingError( + `Failed to decode V5 report: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function decodeV6Report(reportBlob: string): DecodedV6Report { + try { + const decoded = globalAbiCoder.decode( + reportSchemaV6.map(item => item.type), + getBytes(reportBlob) + ); + + return { + version: "V6", + nativeFee: decoded[3], + linkFee: decoded[4], + expiresAt: Number(decoded[5]), + price: decoded[6], + price2: decoded[7], + price3: decoded[8], + price4: decoded[9], + price5: decoded[10], + } as unknown as DecodedV6Report; // price fields are bigint + } catch (error) { + throw new ReportDecodingError( + `Failed to decode V6 report: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function decodeV7Report(reportBlob: string): DecodedV7Report { + try { + const decoded = globalAbiCoder.decode( + reportSchemaV7.map(item => item.type), + getBytes(reportBlob) + ); + + return { + version: "V7", + nativeFee: decoded[3], + linkFee: decoded[4], + expiresAt: Number(decoded[5]), + exchangeRate: decoded[6], + } as unknown as DecodedV7Report; + } catch (error) { + throw new ReportDecodingError( + `Failed to decode V7 report: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function decodeV8Report(reportBlob: string): DecodedV8Report { + try { + const decoded = globalAbiCoder.decode( + reportSchemaV8.map(item => item.type), + getBytes(reportBlob) + ); + + const marketStatus = Number(decoded[8]); + if ( + marketStatus !== MarketStatus.UNKNOWN && + marketStatus !== MarketStatus.INACTIVE && + marketStatus !== MarketStatus.ACTIVE + ) { + throw new ReportDecodingError(`Invalid market status: ${marketStatus}`); + } + + return { + version: "V8", + nativeFee: decoded[3], + linkFee: decoded[4], + expiresAt: Number(decoded[5]), + lastUpdateTimestamp: Number(decoded[6]), + midPrice: decoded[7], + marketStatus, + }; + } catch (error) { + throw new ReportDecodingError( + `Failed to decode V8 report: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function decodeV9Report(reportBlob: string): DecodedV9Report { + try { + const decoded = globalAbiCoder.decode( + reportSchemaV9.map(item => item.type), + getBytes(reportBlob) + ); + + const ripcord = Number(decoded[9]); + if (ripcord !== 0 && ripcord !== 1) { + throw new ReportDecodingError(`Invalid ripcord value: ${ripcord}. Must be 0 (normal) or 1 (paused)`); + } + + return { + version: "V9", + nativeFee: decoded[3], + linkFee: decoded[4], + expiresAt: Number(decoded[5]), + navPerShare: decoded[6], + navDate: Number(decoded[7]), + aum: decoded[8], + ripcord, + }; + } catch (error) { + throw new ReportDecodingError( + `Failed to decode V9 report: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function decodeV10Report(reportBlob: string): DecodedV10Report { + try { + const decoded = globalAbiCoder.decode( + reportSchemaV10.map(item => item.type), + getBytes(reportBlob) + ); + + const marketStatus = Number(decoded[8]); + if ( + marketStatus !== MarketStatus.UNKNOWN && + marketStatus !== MarketStatus.INACTIVE && + marketStatus !== MarketStatus.ACTIVE + ) { + throw new ReportDecodingError(`Invalid market status: ${marketStatus}`); + } + + return { + version: "V10", + nativeFee: decoded[3], + linkFee: decoded[4], + expiresAt: Number(decoded[5]), + lastUpdateTimestamp: Number(decoded[6]), + price: decoded[7], + marketStatus, + currentMultiplier: decoded[9], + newMultiplier: decoded[10], + activationDateTime: Number(decoded[11]), + tokenizedPrice: decoded[12], + }; + } catch (error) { + throw new ReportDecodingError( + `Failed to decode V10 report: ${error instanceof Error ? error.message : String(error)}` + ); + } +} diff --git a/typescript/src/decoder/index.ts b/typescript/src/decoder/index.ts new file mode 100644 index 0000000..cecd1a1 --- /dev/null +++ b/typescript/src/decoder/index.ts @@ -0,0 +1 @@ +export { decodeReport } from "./implementation"; diff --git a/typescript/src/defaultConfig.ts b/typescript/src/defaultConfig.ts new file mode 100644 index 0000000..a5072f9 --- /dev/null +++ b/typescript/src/defaultConfig.ts @@ -0,0 +1,18 @@ +import { Config } from "./types/client"; +import { WS_CONSTANTS, DEFAULT_TIMEOUT, DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_DELAY } from "./utils/constants"; + +/** + * Default configuration for the Data Streams client + */ +export const DEFAULT_CONFIG: Partial = { + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + retryAttempts: DEFAULT_RETRY_ATTEMPTS, + retryDelay: DEFAULT_RETRY_DELAY, + timeout: DEFAULT_TIMEOUT, + // HA mode defaults + haMode: false, // Disabled by default + haConnectionTimeout: WS_CONSTANTS.CONNECT_TIMEOUT, + // Logging defaults + logging: undefined, // Silent by default +} as const; diff --git a/typescript/src/index.ts b/typescript/src/index.ts new file mode 100644 index 0000000..d025294 --- /dev/null +++ b/typescript/src/index.ts @@ -0,0 +1,73 @@ +/** + * Chainlink Data Streams TypeScript SDK + * + * A comprehensive SDK for accessing Chainlink Data Streams with full developer control. + * Features event-driven architecture, High Availability mode, automatic failover, + * and monitoring capabilities. + * + * @example Basic Usage + * ```typescript + * import { createClient } from '@chainlink/data-streams-sdk'; + * + * const client = createClient({ + * apiKey: 'your_api_key', + * userSecret: 'your_user_secret', + * endpoint: 'https://api.testnet-dataengine.chain.link', + * wsEndpoint: 'wss://ws.testnet-dataengine.chain.link' + * }); + * + * // Event-driven streaming with full developer control + * const stream = client.createStream(['0x00037da06d56d083670...']); + * stream.on('report', (report) => { + * console.log(`Price: ${report.price}, Feed: ${report.feedID}`); + * }); + * stream.on('error', (error) => console.error('Error:', error)); + * await stream.connect(); + * ``` + * + * @example High Availability Mode + * ```typescript + * const client = createClient({ + * // ... auth config + * wsEndpoint: 'wss://ws1.example.com,wss://ws2.example.com', + * haMode: true, + * }); + * + * const stream = client.createStream(feedIds); + * stream.on('report', (report) => processReport(report)); + * await stream.connect(); + * ``` + */ + +// Core functionality +export { createClient } from "./client"; +export { decodeReport } from "./decoder"; +export { Stream } from "./stream"; + +// Types +export type { Config, DataStreamsClient } from "./types/client"; +export type { + Feed, + Report, + DecodedReport, + DecodedV2Report, + DecodedV3Report, + DecodedV4Report, + DecodedV5Report, + DecodedV6Report, + DecodedV7Report, + DecodedV8Report, + DecodedV9Report, + DecodedV10Report, + MarketStatus, +} from "./types/report"; +export type { Logger, LoggingConfig } from "./types/logger"; +export { LogLevel } from "./types/logger"; +export type { StreamOptions } from "./stream"; +export type { MetricsSnapshot } from "./types/metrics"; +export { ConnectionStatus } from "./types/metrics"; +export * from "./types/errors"; + +// Constants +export { DEFAULT_CONFIG } from "./defaultConfig"; +export { DEFAULT_TIMEOUT, DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_DELAY } from "./utils/constants"; diff --git a/typescript/src/stream/connection-manager.ts b/typescript/src/stream/connection-manager.ts new file mode 100644 index 0000000..75efb52 --- /dev/null +++ b/typescript/src/stream/connection-manager.ts @@ -0,0 +1,693 @@ +import WebSocket, { RawData } from "ws"; +import { EventEmitter } from "events"; +import { generateAuthHeaders } from "../utils/auth"; +import { getAvailableOrigins } from "../utils/origin-discovery"; +import { WS_CONSTANTS, X_CLL_ORIGIN_HEADER } from "../utils/constants"; +import { WebSocketError, MultiConnectionError, InsufficientConnectionsError } from "../types/errors"; +import { Config, ConnectionStatusCallback } from "../types/client"; +import { SDKLogger } from "../utils/logger"; +import { ConnectionStatus } from "../types/metrics"; +import { StreamStats } from "./stats"; + +/** + * Connection state enum + */ +export enum ConnectionState { + DISCONNECTED = "disconnected", + CONNECTING = "connecting", + CONNECTED = "connected", + RECONNECTING = "reconnecting", + FAILED = "failed", +} + +/** + * Individual connection wrapper with metadata + */ +export interface ManagedConnection { + id: string; + origin: string; + host: string; + ws: WebSocket | null; + state: ConnectionState; + reconnectAttempts: number; + lastError?: Error; + connectedAt?: number; + lastReconnectAt?: number; + // Health monitoring + pingInterval?: NodeJS.Timeout; + pongTimeout?: NodeJS.Timeout; +} + +/** + * Connection management events + */ +export interface ConnectionManagerEvents { + "connection-established": (connection: ManagedConnection) => void; + "connection-lost": (connection: ManagedConnection, error?: Error) => void; + "connection-restored": (connection: ManagedConnection) => void; + "all-connections-lost": () => void; + "partial-failure": (failedCount: number, totalCount: number) => void; + message: (data: RawData, connection: ManagedConnection) => void; + /** Emitted when a reconnection is scheduled for a connection */ + reconnecting: ( + info: { attempt: number; delayMs: number; origin: string; host: string }, + connection: ManagedConnection + ) => void; +} + +/** + * Configuration for connection management + */ +export interface ConnectionManagerConfig { + feedIds: string[]; + maxReconnectAttempts: number; + reconnectInterval: number; + connectTimeout: number; + haMode: boolean; + haConnectionTimeout: number; + statusCallback?: ConnectionStatusCallback; +} + +/** + * Connection manager for WebSocket connections + * Optimized for both single and multi-origin architectures + */ +export class ConnectionManager extends EventEmitter { + private config: Config; + private managerConfig: ConnectionManagerConfig; + private connections: Map = new Map(); + private isShuttingDown = false; + private reconnectTimeouts: Map = new Map(); + private logger: SDKLogger; + + private streamStats: StreamStats | null = null; + + constructor(config: Config, managerConfig: ConnectionManagerConfig) { + super(); + this.config = config; + this.managerConfig = managerConfig; + this.logger = new SDKLogger(config.logging); + } + + /** + * Set the StreamStats reference for unified metrics tracking + * This method is called by Stream to provide the unified stats instance + */ + setStreamStats(streamStats: StreamStats): void { + this.streamStats = streamStats; + } + + /** + * Initialize connections to all available origins + * Requires explicit HA mode configuration + */ + async initialize(): Promise { + try { + // Discover available origins (static + dynamic) + const origins = await getAvailableOrigins( + this.config.wsEndpoint, + this.config.apiKey, + this.config.userSecret, + this.managerConfig.haMode, + this.managerConfig.haConnectionTimeout + ); + + // Determine connection mode based on explicit HA configuration + const useHAMode = this.managerConfig.haMode && origins.length > 1; + const originsToUse = useHAMode ? origins : [origins[0]]; + + if (useHAMode) { + this.logger.info(`Initializing in HA mode with ${origins.length} origins`, { + origins: origins.map(o => new URL(o).host), + }); + } else { + if (this.managerConfig.haMode) { + this.logger.warn( + `HA mode requested but only ${origins.length} origin available, falling back to single connection`, + { + origin: new URL(origins[0]).host, + } + ); + } else { + this.logger.info(`Initializing in single connection mode`, { origin: new URL(origins[0]).host }); + } + } + + if (!useHAMode && this.managerConfig.haMode) { + this.emit("ha-fallback-warning", { + message: "HA mode requested but only one origin available, falling back to single connection", + requestedOrigins: origins.length, + actualMode: "single", + }); + } + + // Create managed connections for each origin + const connectionPromises = originsToUse.map((origin, index) => this.createConnection(origin, index)); + + // Wait for all connection attempts (some may fail) + const results = await Promise.allSettled(connectionPromises); + + // Count successful connections + const successfulConnections = results.filter(r => r.status === "fulfilled").length; + const failedConnections = results.length - successfulConnections; + + // Handle connection results + if (successfulConnections === 0) { + throw new InsufficientConnectionsError("Failed to establish any WebSocket connections", 0, 1); + } else if (failedConnections > 0 && useHAMode) { + // Partial failure in HA mode - emit warning but continue + this.emit("partial-failure", failedConnections, results.length); + } + } catch (error) { + throw new MultiConnectionError( + `Failed to initialize connections: ${error instanceof Error ? error.message : error}` + ); + } + } + + /** + * Update connection state with logging + */ + private updateConnectionState(connection: ManagedConnection, newState: ConnectionState, reason?: string): void { + const oldState = connection.state; + connection.state = newState; + + if (oldState !== newState) { + const logData = { + connectionId: connection.id, + host: connection.host, + oldState, + newState, + reason, + }; + + switch (newState) { + case ConnectionState.CONNECTING: + this.logger.connectionDebug(`Connection ${connection.id} transitioning to CONNECTING`, logData); + break; + case ConnectionState.CONNECTED: + this.logger.info(`Connection ${connection.id} established to ${connection.host}`, logData); + break; + case ConnectionState.RECONNECTING: + this.logger.warn( + `Connection ${connection.id} reconnecting to ${connection.host}: ${reason || "Unknown reason"}`, + logData + ); + break; + case ConnectionState.FAILED: + this.logger.error( + `Connection ${connection.id} failed to ${connection.host}: ${reason || "Unknown reason"}`, + logData + ); + break; + case ConnectionState.DISCONNECTED: + if (oldState === ConnectionState.CONNECTED) { + this.logger.warn( + `Connection ${connection.id} lost to ${connection.host}: ${reason || "Unknown reason"}`, + logData + ); + } else { + this.logger.connectionDebug(`Connection ${connection.id} disconnected from ${connection.host}`, logData); + } + break; + } + } + } + + /** + * Create and establish a single connection to an origin + */ + private async createConnection(origin: string, index: number): Promise { + const connectionId = `conn-${index}`; + const url = new URL(origin); + + const connection: ManagedConnection = { + id: connectionId, + origin, + host: url.host, + ws: null, + state: ConnectionState.DISCONNECTED, + reconnectAttempts: 0, + }; + + this.connections.set(connectionId, connection); + + try { + await this.establishConnection(connection); + return connection; + } catch (error) { + this.updateConnectionState( + connection, + ConnectionState.FAILED, + `Connection setup failed: ${error instanceof Error ? error.message : error}` + ); + connection.lastError = error instanceof Error ? error : new Error(String(error)); + throw error; + } + } + + /** + * Establish WebSocket connection for a managed connection + */ + private async establishConnection(connection: ManagedConnection): Promise { + return new Promise((resolve, reject) => { + try { + this.updateConnectionState(connection, ConnectionState.CONNECTING, "WebSocket connection initiated"); + + // Build WebSocket URL with feed IDs + const feedIdsParam = this.managerConfig.feedIds.join(","); + + // Extract base URL and origin identifier + let baseUrl = connection.origin; + let originId = ""; + + if (connection.origin.includes("#")) { + // Format: baseUrl#originId + [baseUrl, originId] = connection.origin.split("#"); + } else if (!connection.origin.startsWith("ws")) { + baseUrl = this.config.wsEndpoint.split(",")[0]; + originId = connection.origin; + } + + const wsUrl = `${baseUrl}/api/v1/ws?feedIDs=${feedIdsParam}`; + + // Generate authentication headers + const headers = generateAuthHeaders(this.config.apiKey, this.config.userSecret, "GET", wsUrl); + + // Add origin as header if we have an identifier + if (originId) { + headers[X_CLL_ORIGIN_HEADER] = originId; + } + + // Create WebSocket with timeout + const connectTimeout = setTimeout(() => { + if (connection.ws) { + connection.ws.terminate(); + } + reject(new WebSocketError(`Connection timeout after ${this.managerConfig.connectTimeout}ms`)); + }, this.managerConfig.connectTimeout); + + connection.ws = new WebSocket(wsUrl, { headers }); + + // Handle connection events + // Surface clearer auth errors on handshake (401/403) + connection.ws.once("unexpected-response", (_req: unknown, res: { statusCode?: number }) => { + clearTimeout(connectTimeout); + const status = res?.statusCode; + if (status === 401 || status === 403) { + reject( + new WebSocketError(`Authentication failed during WebSocket handshake (${status}). Check API key/secret.`) + ); + } else { + reject(new WebSocketError(`Unexpected WebSocket handshake response: ${String(status)}`)); + } + }); + + connection.ws.on("open", () => { + clearTimeout(connectTimeout); + this.updateConnectionState(connection, ConnectionState.CONNECTED, "WebSocket connection established"); + connection.connectedAt = Date.now(); + connection.reconnectAttempts = 0; // Reset on successful connection + + // Start health monitoring + this.startHealthMonitoring(connection); + + // Notify status callback + if (this.managerConfig.statusCallback) { + this.managerConfig.statusCallback(true, connection.host, connection.origin); + } + + this.emit("connection-established", connection); + resolve(); + }); + + connection.ws.on("message", data => { + this.emit("message", data, connection); + }); + + connection.ws.on("close", () => { + clearTimeout(connectTimeout); + this.handleConnectionLoss(connection); + }); + + connection.ws.on("error", error => { + clearTimeout(connectTimeout); + connection.lastError = error; + + if (connection.state === ConnectionState.CONNECTING) { + const message = /401|403/.test(error.message) + ? `Authentication failed during WebSocket handshake. Check API key/secret. (${error.message})` + : `Failed to connect to ${connection.origin}: ${error.message}`; + reject(new WebSocketError(message)); + } else { + this.handleConnectionLoss(connection, error); + } + }); + + // Handle ping/pong for connection health + connection.ws.on("ping", data => { + this.logger.connectionDebug(`Received ping from ${connection.origin}`); + if (connection.ws && connection.ws.readyState === WebSocket.OPEN) { + try { + this.logger.connectionDebug(`Responding with pong to ${connection.origin}`); + connection.ws.pong(data); + } catch { + // Ignore pong errors + } + } + }); + + connection.ws.on("pong", () => { + this.handlePongReceived(connection); + }); + } catch (error) { + reject(error); + } + }); + } + + /** + * Handle connection loss and manage reconnection + */ + private handleConnectionLoss(connection: ManagedConnection, error?: Error): void { + if (this.isShuttingDown) { + return; + } + + const wasConnected = connection.state === ConnectionState.CONNECTED; + this.updateConnectionState( + connection, + ConnectionState.DISCONNECTED, + error ? `Connection lost: ${error.message}` : "Connection closed" + ); + connection.lastError = error; + + // Stop health monitoring + this.stopHealthMonitoring(connection); + + // Notify status callback + if (this.managerConfig.statusCallback && wasConnected) { + this.managerConfig.statusCallback(false, connection.host, connection.origin); + } + + this.emit("connection-lost", connection, error); + + // Check if all connections are lost + const activeConnections = this.getActiveConnectionCount(); + if (activeConnections === 0) { + this.emit("all-connections-lost"); + } + + // Schedule reconnection with exponential backoff + this.scheduleReconnection(connection); + } + + /** + * Schedule reconnection with exponential backoff + */ + private scheduleReconnection(connection: ManagedConnection): void { + if (this.isShuttingDown) { + return; + } + + connection.reconnectAttempts++; + this.updateConnectionState( + connection, + ConnectionState.RECONNECTING, + `Reconnect attempt ${connection.reconnectAttempts}/${this.managerConfig.maxReconnectAttempts}` + ); + connection.lastReconnectAt = Date.now(); + + // Increment reconnection counters at the moment of reconnection attempt + const activeConnections = this.getActiveConnectionCount(); + if (activeConnections === 0) { + // All connections lost - full reconnect + if (this.streamStats) { + this.streamStats.incrementFullReconnects(); + } + this.logger.debug(`Full reconnection attempt (no active connections)`); + } else { + // Some connections remain - partial reconnect + if (this.streamStats) { + this.streamStats.incrementPartialReconnects(); + } + this.logger.debug(`Partial reconnection attempt (${activeConnections} active connections remaining)`); + } + + // Exponential backoff: start at base, double each time, cap at max + const baseDelay = this.managerConfig.reconnectInterval || WS_CONSTANTS.RECONNECT_DELAY; + const delay = Math.min( + baseDelay * Math.pow(2, connection.reconnectAttempts - 1), + WS_CONSTANTS.MAX_RECONNECT_INTERVAL + ); + + this.logger.debug( + `Scheduling reconnection for ${connection.id} in ${delay}ms (attempt ${connection.reconnectAttempts})` + ); + + this.emit( + "reconnecting", + { + attempt: connection.reconnectAttempts, + delayMs: delay, + origin: connection.origin, + host: connection.host, + }, + connection + ); + + const timeout = setTimeout(async () => { + this.reconnectTimeouts.delete(connection.id); + + if (this.isShuttingDown) { + return; + } + + // Stop reconnection if maximum attempts reached with no active connections + const activeConnections = this.getActiveConnectionCount(); + if (connection.reconnectAttempts >= this.managerConfig.maxReconnectAttempts && activeConnections === 0) { + this.updateConnectionState( + connection, + ConnectionState.FAILED, + `Max reconnection attempts (${this.managerConfig.maxReconnectAttempts}) reached with no active connections` + ); + this.emit("max-reconnect-attempts-reached", { + origin: connection.origin, + attempts: connection.reconnectAttempts, + activeConnections, + message: `Max reconnection attempts reached for ${connection.origin} with no active connections`, + }); + this.emit("all-connections-lost"); + return; + } + + try { + await this.establishConnection(connection); + this.emit("connection-restored", connection); + } catch { + // Connection failed, schedule another attempt + this.scheduleReconnection(connection); + } + }, delay); + + // Don't keep the process alive just for reconnection attempts + timeout.unref(); + this.reconnectTimeouts.set(connection.id, timeout); + } + + /** + * Get count of currently active connections + */ + getActiveConnectionCount(): number { + return Array.from(this.connections.values()).filter(conn => conn.state === ConnectionState.CONNECTED).length; + } + + /** + * Get count of configured connections + */ + getConfiguredConnectionCount(): number { + return this.connections.size; + } + + /** + * Get all connection states for monitoring + */ + getConnectionStates(): Record { + const states: Record = {}; + for (const [id, conn] of this.connections) { + states[id] = conn.state; + } + return states; + } + + /** + * Get detailed connection information + */ + getConnectionDetails(): ManagedConnection[] { + return Array.from(this.connections.values()).map(conn => ({ + ...conn, + ws: null, // Don't expose WebSocket instance + })); + } + + /** + * Start health monitoring for a connection (ping/pong) + */ + private startHealthMonitoring(connection: ManagedConnection): void { + // Start ping interval + connection.pingInterval = setInterval(() => { + this.sendPing(connection); + }, WS_CONSTANTS.PING_INTERVAL); + + // Don't keep the process alive just for health monitoring + connection.pingInterval.unref(); + } + + /** + * Send a ping to check connection health + */ + private sendPing(connection: ManagedConnection): void { + if (!connection.ws || connection.ws.readyState !== WebSocket.OPEN) { + return; + } + + try { + // Send ping and start pong timeout + this.logger.connectionDebug(`Sending ping to ${connection.origin}`); + connection.ws.ping(); + + // Set timeout for pong response + connection.pongTimeout = setTimeout(() => { + // No pong received within timeout - connection is dead + this.logger.warn(`Pong timeout for ${connection.origin} - terminating connection`); + if (connection.ws) { + connection.ws.terminate(); + } + }, WS_CONSTANTS.PONG_TIMEOUT); + + // Don't keep the process alive just for pong timeout + connection.pongTimeout.unref(); + } catch (error) { + // Ping failed - connection is likely dead + this.logger.error(`Ping failed for ${connection.origin}:`, error); + if (connection.ws) { + connection.ws.terminate(); + } + } + } + + /** + * Handle pong response received + */ + private handlePongReceived(connection: ManagedConnection): void { + this.logger.connectionDebug(`Received pong from ${connection.origin}`); + + // Clear pong timeout since we received response + if (connection.pongTimeout) { + clearTimeout(connection.pongTimeout); + connection.pongTimeout = undefined; + } + } + + /** + * Stop health monitoring for a connection + */ + private stopHealthMonitoring(connection: ManagedConnection): void { + if (connection.pingInterval) { + clearInterval(connection.pingInterval); + connection.pingInterval = undefined; + } + + if (connection.pongTimeout) { + clearTimeout(connection.pongTimeout); + connection.pongTimeout = undefined; + } + } + + /** + * Gracefully shutdown all connections + */ + async shutdown(): Promise { + this.isShuttingDown = true; + + // Clear all reconnection timeouts + for (const timeout of this.reconnectTimeouts.values()) { + clearTimeout(timeout); + } + this.reconnectTimeouts.clear(); + + // Close all connections gracefully + const closePromises = Array.from(this.connections.values()).map(connection => this.closeConnection(connection)); + + await Promise.allSettled(closePromises); + this.connections.clear(); + } + + /** + * Close a single connection gracefully + */ + private async closeConnection(connection: ManagedConnection): Promise { + return new Promise(resolve => { + if (!connection.ws) { + resolve(); + return; + } + + const ws = connection.ws; + this.updateConnectionState(connection, ConnectionState.DISCONNECTED, "Graceful shutdown initiated"); + + // Stop health monitoring + this.stopHealthMonitoring(connection); + + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + const timeout = setTimeout(() => { + ws.terminate(); + resolve(); + }, 1000); + + ws.once("close", () => { + clearTimeout(timeout); + resolve(); + }); + + ws.close(); + } else { + resolve(); + } + }); + } + + /** + * Get origin status map for StreamStats + * @returns Map of origin to connection status + */ + getOriginStatusMap(): Record { + const originStatus: Record = {}; + + for (const connection of this.connections.values()) { + // Map ConnectionState to ConnectionStatus for metrics compatibility + let status: ConnectionStatus; + switch (connection.state) { + case ConnectionState.CONNECTED: + status = ConnectionStatus.CONNECTED; + break; + case ConnectionState.CONNECTING: + status = ConnectionStatus.CONNECTING; + break; + case ConnectionState.RECONNECTING: + status = ConnectionStatus.RECONNECTING; + break; + case ConnectionState.FAILED: + status = ConnectionStatus.FAILED; + break; + case ConnectionState.DISCONNECTED: + default: + status = ConnectionStatus.DISCONNECTED; + break; + } + originStatus[connection.origin] = status; + } + + return Object.freeze(originStatus); + } +} diff --git a/typescript/src/stream/deduplication.ts b/typescript/src/stream/deduplication.ts new file mode 100644 index 0000000..bed17ce --- /dev/null +++ b/typescript/src/stream/deduplication.ts @@ -0,0 +1,218 @@ +/** + * Report deduplication using watermark timestamps + */ + +export interface ReportMetadata { + feedID: string; + observationsTimestamp: number; + validFromTimestamp: number; + fullReport: string; +} + +export interface DeduplicationResult { + isAccepted: boolean; + isDuplicate: boolean; + reason?: string; +} + +export interface DeduplicationStats { + accepted: number; + deduplicated: number; + totalReceived: number; + watermarkCount: number; +} + +/** + * Manages report deduplication using watermark timestamps + */ +export class ReportDeduplicator { + private waterMark: Map = new Map(); + private acceptedCount = 0; + private deduplicatedCount = 0; + private cleanupInterval: NodeJS.Timeout | null = null; + + // Configuration + private readonly maxWatermarkAge: number; + private readonly cleanupIntervalMs: number; + + constructor( + options: { + maxWatermarkAge?: number; // How long to keep watermarks (default: 1 hour) + cleanupIntervalMs?: number; // How often to clean old watermarks (default: 5 minutes) + } = {} + ) { + this.maxWatermarkAge = options.maxWatermarkAge ?? 60 * 60 * 1000; // 1 hour + this.cleanupIntervalMs = options.cleanupIntervalMs ?? 5 * 60 * 1000; // 5 minutes + + // Start periodic cleanup + this.startCleanup(); + } + + /** + * Process a report and determine if it should be accepted or deduplicated + */ + processReport(report: ReportMetadata): DeduplicationResult { + const feedId = report.feedID; + const observationsTimestamp = report.observationsTimestamp; + + // Get current watermark for this feed + const currentWatermark = this.waterMark.get(feedId); + + // Check if this report is older than or equal to the watermark + if (currentWatermark !== undefined && currentWatermark >= observationsTimestamp) { + this.deduplicatedCount++; + return { + isAccepted: false, + isDuplicate: true, + reason: `Report timestamp ${observationsTimestamp} <= watermark ${currentWatermark} for feed ${feedId}`, + }; + } + + // Accept the report and update watermark + this.waterMark.set(feedId, observationsTimestamp); + this.acceptedCount++; + + return { + isAccepted: true, + isDuplicate: false, + }; + } + + /** + * Get current deduplication statistics + */ + getStats(): DeduplicationStats { + return { + accepted: this.acceptedCount, + deduplicated: this.deduplicatedCount, + totalReceived: this.acceptedCount + this.deduplicatedCount, + watermarkCount: this.waterMark.size, + }; + } + + /** + * Get watermark for a specific feed ID + */ + getWatermark(feedId: string): number | undefined { + return this.waterMark.get(feedId); + } + + /** + * Get all current watermarks (for debugging/monitoring) + */ + getAllWatermarks(): Record { + const watermarks: Record = {}; + for (const [feedId, timestamp] of this.waterMark) { + watermarks[feedId] = timestamp; + } + return watermarks; + } + + /** + * Manually set watermark for a feed (useful for initialization) + */ + setWatermark(feedId: string, timestamp: number): void { + this.waterMark.set(feedId, timestamp); + } + + /** + * Clear watermark for a specific feed + */ + clearWatermark(feedId: string): boolean { + return this.waterMark.delete(feedId); + } + + /** + * Clear all watermarks + */ + clearAllWatermarks(): void { + this.waterMark.clear(); + } + + /** + * Reset all counters and watermarks + */ + reset(): void { + this.acceptedCount = 0; + this.deduplicatedCount = 0; + this.waterMark.clear(); + } + + /** + * Start periodic cleanup of old watermarks + * This prevents memory leaks for feeds that are no longer active + */ + private startCleanup(): void { + this.cleanupInterval = setInterval(() => { + this.cleanupOldWatermarks(); + }, this.cleanupIntervalMs); + } + + /** + * Clean up watermarks that are too old + * This is a safety mechanism to prevent unbounded memory growth + */ + private cleanupOldWatermarks(): void { + const now = Date.now(); + const cutoffTime = now - this.maxWatermarkAge; + + // Convert cutoff time to seconds (like the timestamps in reports) + const cutoffTimestamp = Math.floor(cutoffTime / 1000); + + let _removedCount = 0; + for (const [feedId, timestamp] of this.waterMark) { + if (timestamp < cutoffTimestamp) { + this.waterMark.delete(feedId); + _removedCount++; + } + } + } + + /** + * Stop the deduplicator and clean up resources + */ + stop(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } + + /** + * Get memory usage information + */ + getMemoryInfo(): { + watermarkCount: number; + estimatedMemoryBytes: number; + } { + const watermarkCount = this.waterMark.size; + + // Rough estimation: each entry has a string key (~64 chars) + number value + // String: ~64 bytes (feed ID) + Number: 8 bytes + Map overhead: ~32 bytes + const estimatedMemoryBytes = watermarkCount * (64 + 8 + 32); + + return { + watermarkCount, + estimatedMemoryBytes, + }; + } + + /** + * Export watermarks for persistence/debugging + */ + exportWatermarks(): Array<{ feedId: string; timestamp: number }> { + return Array.from(this.waterMark.entries()).map(([feedId, timestamp]) => ({ + feedId, + timestamp, + })); + } + + /** + * Import watermarks from external source + */ + importWatermarks(watermarks: Array<{ feedId: string; timestamp: number }>): void { + for (const { feedId, timestamp } of watermarks) { + this.waterMark.set(feedId, timestamp); + } + } +} diff --git a/typescript/src/stream/index.ts b/typescript/src/stream/index.ts new file mode 100644 index 0000000..232441c --- /dev/null +++ b/typescript/src/stream/index.ts @@ -0,0 +1,405 @@ +import { EventEmitter } from "events"; +import { Config } from "../types/client"; +import { Report } from "../types/report"; +import { StreamStats } from "./stats"; +import { MetricsSnapshot, ConnectionStatus } from "../types/metrics"; +import { WS_CONSTANTS } from "../utils/constants"; +import { ConnectionManager } from "./connection-manager"; +import { ReportDeduplicator } from "./deduplication"; +import { OriginDiscoveryError, InsufficientConnectionsError } from "../types/errors"; +import { SDKLogger } from "../utils/logger"; + +/** + * Connection type enum for distinguishing single vs multiple connection modes + */ +export enum ConnectionType { + /** Single WebSocket connection (traditional mode) */ + Single = "single", + /** Multiple WebSocket connections (High Availability mode) */ + Multiple = "multiple", +} + +/** + * Configuration options for customizing Stream behavior. + * + * These options allow fine-tuning of connection management, reconnection logic, + * and performance characteristics for different use cases. + */ +export interface StreamOptions { + /** + * Interval between reconnection attempts in milliseconds. + * + * Controls how long to wait before attempting to reconnect after a connection loss. + * Longer intervals reduce server load but increase recovery time. + * + * Base for exponential backoff. Actual delay grows as base * 2^(attempt-1), capped at 10000ms. + * + * @default 1000 (1 second) + * @range 1000-30000 + * @example 5000 // Wait 5 seconds between reconnection attempts + */ + reconnectInterval?: number; + + /** + * Maximum number of reconnection attempts before giving up. + * + * In HA mode, this applies per connection. If one connection exhausts its attempts + * but others remain active, the stream continues. Only when all connections + * exhaust attempts does the stream fail. + * + * @default 5 + * @range 1-100 + * @example 15 // Allow up to 15 reconnection attempts per connection + */ + maxReconnectAttempts?: number; +} + +/** + * Real-time WebSocket stream for Chainlink Data Streams with full developer control. + * + * This class provides a complete event-driven API for streaming reports, giving developers + * full control over connection management, error handling, and monitoring. Supports both + * single-connection and High Availability modes with automatic failover. + * + * @example Basic Usage + * ```typescript + * const stream = client.createStream(['0x00037da06d56d083670...']); + * stream.on('report', (report) => { + * console.log(`Price: ${report.price}, Feed: ${report.feedID}`); + * }); + * await stream.connect(); + * ``` + * + * @example High Availability Mode + * ```typescript + * const client = createClient({ + * wsEndpoint: "wss://ws1.example.com,wss://ws2.example.com", + * haMode: true, + * }); + * const stream = client.createStream(feedIds); + * stream.on('report', (report) => processReport(report)); + * await stream.connect(); + * ``` + */ +export class Stream extends EventEmitter { + private config: Config; + private feedIds: string[]; + private options: Required; + private isClosing = false; + private stats: StreamStats; + private connectionType: ConnectionType; + private connectionManager: ConnectionManager; + private deduplicator: ReportDeduplicator; + private origins: string[] = []; + private logger: SDKLogger; + + constructor(config: Config, feedIds: string[], options: StreamOptions = {}) { + super(); + this.config = config; + this.feedIds = feedIds; + this.logger = new SDKLogger(config.logging); + this.options = { + reconnectInterval: options.reconnectInterval || WS_CONSTANTS.RECONNECT_DELAY, + maxReconnectAttempts: options.maxReconnectAttempts || WS_CONSTANTS.MAX_RECONNECTS, + }; + + this.logger.debug(`Creating stream for feeds: ${feedIds.join(", ")}`); + this.logger.debug( + `Stream options: reconnectInterval=${this.options.reconnectInterval}ms, maxReconnectAttempts=${this.options.maxReconnectAttempts}` + ); + + // Determine connection type based on HA mode configuration + const useHAMode = config.haMode && this.parseOrigins().length > 1; + this.connectionType = useHAMode ? ConnectionType.Multiple : ConnectionType.Single; + + this.logger.info(`Initializing stream in ${this.connectionType} mode`); + + // Initialize stats with appropriate connection count + const expectedConnections = useHAMode ? this.parseOrigins().length : 1; + this.stats = new StreamStats(expectedConnections); + this.logger.debug(`Expected connections: ${expectedConnections}`); + + // Initialize ConnectionManager for both single and multiple connections + const managerConfig = { + feedIds: this.feedIds, + maxReconnectAttempts: this.options.maxReconnectAttempts, + reconnectInterval: this.options.reconnectInterval, + connectTimeout: config.haConnectionTimeout || WS_CONSTANTS.CONNECT_TIMEOUT, + haMode: config.haMode || false, + haConnectionTimeout: config.haConnectionTimeout || WS_CONSTANTS.CONNECT_TIMEOUT, + statusCallback: config.connectionStatusCallback, + }; + + this.connectionManager = new ConnectionManager(config, managerConfig); + + // Initialize deduplicator for HA mode (single mode can also benefit from deduplication) + this.deduplicator = new ReportDeduplicator(); + + // Inject StreamStats into ConnectionManager for unified metrics + this.connectionManager.setStreamStats(this.stats); + + this.setupConnectionManagerEvents(); + } + + /** + * Parse WebSocket endpoints from config, supporting comma-separated URLs + */ + private parseOrigins(): string[] { + if (!this.config.wsEndpoint) { + return []; + } + + // Support comma-separated URLs + return this.config.wsEndpoint + .split(",") + .map(url => url.trim()) + .filter(url => url.length > 0); + } + + /** + * Setup event listeners for ConnectionManager + */ + private setupConnectionManagerEvents(): void { + this.connectionManager.on("connection-established", connection => { + this.stats.setOriginStatus(connection.origin, ConnectionStatus.CONNECTED); + }); + + this.connectionManager.on("connection-lost", (connection, error) => { + this.stats.setOriginStatus(connection.origin, ConnectionStatus.DISCONNECTED); + + // Re-emit for external listeners (tests, monitoring, etc.) + this.emit("connection-lost", connection, error); + }); + + this.connectionManager.on("message", (data, connection) => { + try { + const message = JSON.parse(data.toString()); + if (message && message.report) { + const report = { + feedID: message.report.feedID, + fullReport: message.report.fullReport, + validFromTimestamp: message.report.validFromTimestamp, + observationsTimestamp: message.report.observationsTimestamp, + }; + this.logger.debug(`Received report for feed ${report.feedID} from ${connection.origin}`); + this.handleReport(report, connection.origin); + } else { + this.logger.warn(`Invalid message format received from ${connection.origin}`); + this.emit("error", new Error("Invalid message format")); + } + } catch (error) { + this.logger.error(`Failed to parse WebSocket message from ${connection.origin}:`, error); + this.emit("error", new Error("Failed to parse WebSocket message")); + } + }); + + // Re-emit reconnecting for external listeners (maintains IStream contract) + this.connectionManager.on("reconnecting", info => { + // info: { attempt, delayMs, origin, host } + this.emit("reconnecting", info); + }); + + this.connectionManager.on("all-connections-lost", () => { + this.logger.error("All connections lost - stream disconnected"); + this.emit("disconnected"); + // Re-emit for external listeners (tests, monitoring, etc.) + this.emit("all-connections-lost"); + }); + + this.connectionManager.on("partial-failure", (failedCount, totalCount) => { + this.logger.warn(`Partial connection failure: ${failedCount}/${totalCount} connections failed`); + // Note: ConnectionManager already increments these counters, no need to double-count + }); + + this.connectionManager.on("connection-restored", connection => { + this.stats.setOriginStatus(connection.origin, ConnectionStatus.CONNECTED); + + // Re-emit for external listeners (tests, monitoring, etc.) + this.emit("connection-restored", connection); + }); + } + + /** + * Handle incoming reports with deduplication + */ + private handleReport(report: Report, origin?: string): void { + // Use deduplicator for both single and multiple connections + // This provides consistency and prevents any edge case duplicates + const result = this.deduplicator.processReport({ + feedID: report.feedID, + observationsTimestamp: report.observationsTimestamp, + validFromTimestamp: report.validFromTimestamp, + fullReport: report.fullReport, + }); + + const originInfo = origin ? ` from ${new URL(origin).host}` : ""; + + if (result.isAccepted) { + this.stats.incrementAccepted(); + this.logger.debug( + `Report accepted for feed ${report.feedID}${originInfo} (timestamp: ${report.observationsTimestamp})` + ); + this.emit("report", report); + } else { + this.stats.incrementDeduplicated(); + this.logger.debug( + `Report deduplicated for feed ${report.feedID}${originInfo} (timestamp: ${report.observationsTimestamp})` + ); + } + } + + /** + * Start listening for WebSocket messages + */ + async connect(): Promise { + this.logger.info(`Connecting stream in ${this.connectionType} mode`); + + try { + // Initialize connections - ConnectionManager handles origin discovery and connection establishment in parallel + await this.connectionManager.initialize(); + + // Update origins from successful connections + const connectionDetails = this.connectionManager.getConnectionDetails(); + this.origins = connectionDetails.map(conn => conn.origin); + + if (this.origins.length === 0) { + this.logger.error("No origins available for connection"); + throw new InsufficientConnectionsError("No origins available for connection", 0, 1); + } + + // Update connection type based on actual established connections + this.connectionType = + this.origins.length > 1 && this.config.haMode ? ConnectionType.Multiple : ConnectionType.Single; + + // Update stats with actual connection count + this.stats.setConfiguredConnections(this.origins.length); + this.stats.setActiveConnections(this.connectionManager.getActiveConnectionCount()); + + this.logger.info(`Stream connected successfully with ${this.origins.length} origins: ${this.origins.join(", ")}`); + } catch (error) { + if (error instanceof OriginDiscoveryError) { + this.logger.warn("Origin discovery failed, falling back to single connection mode"); + + // Fall back to single connection if origin discovery fails + this.connectionType = ConnectionType.Single; + + // Get fallback origins from static configuration + this.origins = this.parseOrigins().slice(0, 1); + this.stats.setConfiguredConnections(1); + this.stats.setActiveConnections(this.connectionManager.getActiveConnectionCount()); + + this.logger.info(`Fallback connection established to: ${this.origins[0]}`); + } else { + this.logger.error("Failed to connect stream:", error); + throw error; + } + } + } + + /** + * Close all WebSocket connections + */ + async close(): Promise { + this.logger.info("Closing stream and shutting down connections"); + this.isClosing = true; + await this.connectionManager.shutdown(); + this.deduplicator.stop(); + this.stats.setActiveConnections(0); + this.logger.info("Stream closed successfully"); + } + + /** + * Read the next report from any active connection + */ + async read(): Promise { + return new Promise((resolve, reject) => { + const onReport = (report: Report) => { + cleanup(); + resolve(report); + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const cleanup = () => { + this.removeListener("report", onReport); + this.removeListener("error", onError); + }; + + this.once("report", onReport); + this.once("error", onError); + }); + } + + /** + * Get comprehensive stream metrics snapshot. + * + * Returns a complete metrics snapshot, including report processing statistics, connection health, and operational counters. + * + * The returned object is immutable and safe for serialization to monitoring systems. + * + * @returns Immutable metrics snapshot with all available data + * + * @example Basic Monitoring + * ```typescript + * const metrics = stream.getMetrics(); + * console.log(`Efficiency: ${metrics.accepted}/${metrics.totalReceived} reports processed`); + * console.log(`Redundancy: ${metrics.activeConnections}/${metrics.configuredConnections} connections active`); + * ``` + * + * @example Dashboard + * ```typescript + * const metrics = stream.getMetrics(); + * + * // Connection health + * monitoring.gauge('datastreams.connections.active', metrics.activeConnections); + * monitoring.gauge('datastreams.connections.configured', metrics.configuredConnections); + * + * // Report processing + * monitoring.counter('datastreams.reports.accepted', metrics.accepted); + * monitoring.counter('datastreams.reports.deduplicated', metrics.deduplicated); + * + * // Reliability metrics + * monitoring.counter('datastreams.reconnects.partial', metrics.partialReconnects); + * monitoring.counter('datastreams.reconnects.full', metrics.fullReconnects); + * ``` + */ + getMetrics(): MetricsSnapshot { + // Update active connections count and origin statuses from ConnectionManager + this.stats.setActiveConnections(this.connectionManager.getActiveConnectionCount()); + this.stats.updateOriginStatuses(this.connectionManager.getOriginStatusMap()); + + // Return unified stats from StreamStats + return this.stats.getStats(); + } + + /** + * Get the connection type being used + */ + getConnectionType(): ConnectionType { + return this.connectionType; + } + + /** + * Get the origins being used for connections + */ + getOrigins(): string[] { + return [...this.origins]; + } + + /** + * Get detailed connection information from the manager + */ + getConnectionDetails() { + return this.connectionManager.getConnectionDetails(); + } + + /** + * Get connection states for monitoring + */ + getConnectionStates() { + return this.connectionManager.getConnectionStates(); + } +} diff --git a/typescript/src/stream/stats.ts b/typescript/src/stream/stats.ts new file mode 100644 index 0000000..15da3ef --- /dev/null +++ b/typescript/src/stream/stats.ts @@ -0,0 +1,132 @@ +import { MetricsSnapshot, ConnectionStatus } from "../types/metrics"; + +/** + * Class for tracking WebSocket connection and report statistics + */ +export class StreamStats { + private _accepted = 0; + private _deduplicated = 0; + private _partialReconnects = 0; + private _fullReconnects = 0; + private _configuredConnections = 0; + private _activeConnections = 0; + private _originStatus: Record = {}; + private _totalReceived = 0; // accepted + deduplicated + + constructor(configuredConnections = 1) { + this._configuredConnections = configuredConnections; + } + + /** + * Increment the number of accepted reports (messages that passed deduplication) + */ + incrementAccepted(): void { + this._accepted++; + this._totalReceived++; + } + + /** + * Increment the number of deduplicated reports (duplicate messages filtered out) + */ + incrementDeduplicated(): void { + this._deduplicated++; + this._totalReceived++; + } + + /** + * Increment the number of partial reconnects (some connections lost but not all) + */ + incrementPartialReconnects(): void { + this._partialReconnects++; + } + + /** + * Increment the number of full reconnects (all connections lost) + */ + incrementFullReconnects(): void { + this._fullReconnects++; + } + + /** + * Set the number of active connections + */ + setActiveConnections(count: number): void { + this._activeConnections = count; + } + + /** + * Set the number of configured connections + */ + setConfiguredConnections(count: number): void { + this._configuredConnections = count; + } + + /** + * Update connection status for a specific origin + */ + setOriginStatus(origin: string, status: ConnectionStatus): void { + this._originStatus[origin] = status; + } + + /** + * Remove origin status tracking (when origin is no longer used) + */ + removeOriginStatus(origin: string): void { + delete this._originStatus[origin]; + } + + /** + * Get connection status for a specific origin + */ + getOriginStatus(origin: string): ConnectionStatus { + return this._originStatus[origin] || ConnectionStatus.DISCONNECTED; + } + + /** + * Get all origin statuses + */ + getAllOriginStatuses(): Record { + return { ...this._originStatus }; + } + + /** + * Update all origin statuses from ConnectionManager (for unified metrics) + */ + updateOriginStatuses(originStatus: Record): void { + this._originStatus = { ...originStatus }; + } + + /** + * Reset all statistics (useful for testing or reconnection scenarios) + */ + reset(): void { + this._accepted = 0; + this._deduplicated = 0; + this._partialReconnects = 0; + this._fullReconnects = 0; + this._totalReceived = 0; + this._activeConnections = 0; + this._originStatus = {}; + } + + /** + * Get a snapshot of current stream metrics. + * + * Returns an immutable snapshot of all metrics + * The returned object follows the MetricsSnapshot interface for type safety + * + * @returns Immutable metrics snapshot + */ + getStats(): MetricsSnapshot { + return { + accepted: this._accepted, + deduplicated: this._deduplicated, + partialReconnects: this._partialReconnects, + fullReconnects: this._fullReconnects, + configuredConnections: this._configuredConnections, + activeConnections: this._activeConnections, + totalReceived: this._totalReceived, + originStatus: Object.freeze({ ...this._originStatus }), + }; + } +} diff --git a/typescript/src/types/client.ts b/typescript/src/types/client.ts new file mode 100644 index 0000000..6904062 --- /dev/null +++ b/typescript/src/types/client.ts @@ -0,0 +1,245 @@ +import { Report, Feed } from "./report"; +import { LoggingConfig } from "./logger"; +import { MetricsSnapshot } from "./metrics"; + +// Forward declare types to avoid circular imports +export interface StreamOptions { + reconnectInterval?: number; + maxReconnectAttempts?: number; +} + +export interface IStream { + on(event: "report", listener: (report: Report) => void): this; + on(event: "error", listener: (error: Error) => void): this; + on(event: "disconnected", listener: () => void): this; + on( + event: "reconnecting", + listener: (info: { attempt: number; delayMs: number; origin?: string; host?: string }) => void + ): this; + on(event: string, listener: (...args: any[]) => void): this; + connect(): Promise; + close(): Promise; + + /** + * Get comprehensive stream metrics snapshot. + * + * @returns Immutable metrics snapshot + */ + getMetrics(): MetricsSnapshot; + + getConnectionType(): string; + getOrigins(): string[]; +} + +/** + * Connection status callback function type for real-time monitoring of WebSocket connections. + * + * Called whenever a connection state changes in both single and High Availability modes. + * In HA mode, you'll receive callbacks for each origin connection independently. + * + * @param isConnected - Whether the connection is established (true) or lost (false) + * @param host - The hostname of the WebSocket server (e.g., "ws.example.com") + * @param origin - The full WebSocket origin URL (e.g., "wss://ws.example.com") + */ +export type ConnectionStatusCallback = (isConnected: boolean, host: string, origin: string) => void; + +/** + * Configuration options for the Chainlink Data Streams client with High Availability support. + * + * Supports both basic single-connection usage and advanced High Availability (HA) mode + * for mission-critical applications requiring zero-downtime data delivery. + */ +export interface Config { + /** + * API key for authentication with Chainlink Data Streams. + * + * @required + * @example "your_api_key_here" + */ + apiKey: string; + + /** + * User secret for HMAC authentication with Chainlink Data Streams. + * + * @required + * @example "your_user_secret_here" + */ + userSecret: string; + + /** + * Base URL for the Data Streams REST API. + * + * @required + * @example "https://api.testnet-dataengine.chain.link" // Testnet + * @example "https://api.dataengine.chain.link" // Mainnet + */ + endpoint: string; + + /** + * WebSocket endpoint for real-time data streaming. + * + * When HA mode is enabled and single URL is provided, the client will discover + * available origins and establish concurrent connections to all endpoints for fault tolerance. + * + * @required + * @example "wss://ws.testnet-dataengine.chain.link" + */ + wsEndpoint: string; + + /** + * Number of retry attempts for failed REST API requests. + * + * Does not affect WebSocket reconnection attempts (see HA configuration). + * + * @default 1 + * @range 0-10 + * @example 5 + */ + retryAttempts?: number; + + /** + * Base delay between retry attempts in milliseconds. + * + * Uses exponential backoff: delay = retryDelay * (2 ^ attempt) with jitter. + * + * @default 1000 + * @range 100-10000 + * @example 2000 + */ + retryDelay?: number; + + /** + * Request timeout for REST API calls in milliseconds. + * + * Does not affect WebSocket connection timeouts (see haConnectionTimeout). + * + * @default 30000 + * @range 1000-120000 + * @example 45000 + */ + timeout?: number; + + /** + * Enable High Availability mode with multiple simultaneous WebSocket connections. + * + * When enabled, the SDK automatically discovers and connects to multiple origins + * + * @default false + */ + haMode?: boolean; + + /** + * Connection timeout for individual WebSocket connections in HA mode (milliseconds). + * + * @default 10000 + * @range 1000-60000 + */ + haConnectionTimeout?: number; + + /** + * SDK logging system configuration. + * + * Silent by default. Activated only when configured. + * + * @example + * ```typescript + * // Basic logging + * logging: { + * logger: { + * info: console.log, + * error: console.error + * } + * } + * + * // With debug control + * logging: { + * logger: { + * debug: (msg, ...args) => myLogger.debug(msg, ...args), + * info: (msg, ...args) => myLogger.info(msg, ...args), + * error: (msg, ...args) => myLogger.error(msg, ...args) + * }, + * logLevel: LogLevel.INFO, + * enableConnectionDebug: true + * } + * ``` + */ + logging?: LoggingConfig; + + /** + * Callback function for real-time WebSocket connection status monitoring. + * + * Called whenever connection state changes. In HA mode, called for each origin separately. + * If used, will block connection manager until callback execution is complete + */ + connectionStatusCallback?: ConnectionStatusCallback; +} + +/** + * Interface for the Data Streams client + */ +export interface DataStreamsClient { + /** + * Lists all available feeds + * + * @returns {Promise} Array of available feeds + */ + listFeeds(): Promise; + + /** + * Returns a single report with the latest timestamp for a feed + * + * @param {string} feedId - A Data Streams feed ID + * @returns {Promise} The latest report for the specified feed + */ + getLatestReport(feedId: string): Promise; + + /** + * Returns a single report for a feed at a given timestamp + * + * @param {string} feedId - A Data Streams feed ID (hex string starting with 0x) + * @param {number} timestamp - The Unix timestamp for the report (in seconds) + * @returns {Promise} The report for the specified feed and timestamp + */ + getReportByTimestamp(feedId: string, timestamp: number): Promise; + + /** + * Get up to 'limit' reports for a feed from startTime onwards + * + * @param {string} feedId - The feed ID to get reports for + * @param {number} startTime - The start timestamp + * @param {number} [limit] - Maximum number of reports to return. Reports are returned in ascending order by timestamp, starting from startTime. + * @returns {Promise} Array of reports for the specified feed + */ + getReportsPage(feedId: string, startTime: number, limit?: number): Promise; + + /** + * Get reports for multiple feeds at a specific timestamp + * + * @param {string[]} feedIds - List of feed IDs to get reports for + * @param {number} timestamp - The timestamp to get reports for + * @returns {Promise} Array of reports for the specified feeds + * + * @warning Reports are not guaranteed to be returned in the same order as input feedIds. + * Always use `report.feedID` to identify each report rather than relying on array position. + */ + getReportsBulk(feedIds: string[], timestamp: number): Promise; + + /** + * Create a new Stream instance with full developer control over event handling. + * + * This is the primary streaming API that gives developers complete control over + * connection events, error handling, and monitoring. + * + * @param {string|string[]} feedIds - Feed ID(s) to stream. Supports single feed, array of feeds, or comma-separated string. + * @param {StreamOptions} [options] - Optional configuration for stream behavior + * @returns {IStream} Stream instance with full event control + * + * @example Basic Usage + * ```typescript + * const stream = client.createStream(['0x00037da06d56d083670...']); + * stream.on('report', (report) => console.log(report.feedID)); + * await stream.connect(); + * ``` + */ + createStream(feedIds: string | string[], options?: StreamOptions): IStream; +} diff --git a/typescript/src/types/errors.ts b/typescript/src/types/errors.ts new file mode 100644 index 0000000..1afb9ae --- /dev/null +++ b/typescript/src/types/errors.ts @@ -0,0 +1,168 @@ +/** + * Base error class for all Data Streams SDK errors. + * + * All SDK-specific errors inherit from this class, enabling easy error type detection + * and unified error handling across the entire SDK. + */ +export class DataStreamsError extends Error { + constructor(message: string) { + super(message); + this.name = "DataStreamsError"; + Object.setPrototypeOf(this, DataStreamsError.prototype); + } +} + +/** + * Thrown when input validation fails. + * + * Common scenarios include invalid feed IDs, empty required parameters, + * invalid configuration values, or malformed timestamp values. + */ +export class ValidationError extends DataStreamsError { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + Object.setPrototypeOf(this, ValidationError.prototype); + } +} + +/** + * Thrown when authentication with Data Streams API fails. + * + * Common scenarios include invalid credentials, malformed HMAC signature, + * expired tokens, or missing authentication headers. + */ +export class AuthenticationError extends DataStreamsError { + constructor(message: string) { + super(message); + this.name = "AuthenticationError"; + Object.setPrototypeOf(this, AuthenticationError.prototype); + } +} + +/** + * Thrown when report data decoding fails. + * + * Common scenarios include corrupted report data, unsupported report format version, + * or invalid report structure. + */ +export class ReportDecodingError extends DataStreamsError { + constructor(message: string) { + super(message); + this.name = "ReportDecodingError"; + Object.setPrototypeOf(this, ReportDecodingError.prototype); + } +} + +/** + * Thrown when WebSocket connection establishment or management fails. + * + * Common scenarios include network connectivity issues, server unavailability, + * connection timeouts, or protocol-level errors. + * + * Note: In HA mode, individual WebSocket failures may not interrupt the stream + * if other connections remain active. + */ +export class WebSocketError extends DataStreamsError { + constructor(message: string) { + super(message); + this.name = "WebSocketError"; + Object.setPrototypeOf(this, WebSocketError.prototype); + } +} + +/** + * Thrown when REST API requests fail. + * + * Common scenarios include HTTP 4xx/5xx errors, network timeouts, + * rate limiting, or malformed requests. + * + * @param statusCode - HTTP status code from the failed request (if available) + */ +export class APIError extends DataStreamsError { + constructor( + message: string, + public statusCode?: number + ) { + super(message); + this.name = "APIError"; + Object.setPrototypeOf(this, APIError.prototype); + } +} + +/** + * Thrown when automatic origin discovery fails in High Availability mode. + * + * This occurs when the client cannot discover additional WebSocket endpoints + * via HEAD request to the origin server. The stream may fall back to static + * configuration or single-connection mode. + * + * @param cause - The underlying error that caused discovery to fail (if available) + */ +export class OriginDiscoveryError extends DataStreamsError { + constructor( + message: string, + public readonly cause?: Error + ) { + super(message); + this.name = "OriginDiscoveryError"; + Object.setPrototypeOf(this, OriginDiscoveryError.prototype); + } +} + +/** + * Thrown when all connections fail in High Availability mode. + * + * This is a critical error indicating complete failure of the HA connection system. + * No WebSocket connections could be established to any discovered or configured origins. + */ +export class MultiConnectionError extends DataStreamsError { + constructor(message: string) { + super(message); + this.name = "MultiConnectionError"; + Object.setPrototypeOf(this, MultiConnectionError.prototype); + } +} + +/** + * Thrown when some (but not all) connections fail in High Availability mode. + * + * This indicates degraded HA performance. The stream continues to operate with + * successful connections, but redundancy is reduced. + * + * @param failedConnections - Number of connections that failed to establish + * @param totalConnections - Total number of connections attempted + */ +export class PartialConnectionFailureError extends DataStreamsError { + constructor( + message: string, + public readonly failedConnections: number, + public readonly totalConnections: number + ) { + super(message); + this.name = "PartialConnectionFailureError"; + Object.setPrototypeOf(this, PartialConnectionFailureError.prototype); + } +} + +/** + * Thrown when insufficient connections are available for optimal High Availability mode. + * + * This warning-level error indicates that while some connections succeeded, the number + * is below the threshold for robust HA operation. The stream continues with reduced + * fault tolerance. + * + * @param availableConnections - Number of successful connections established + * @param requiredConnections - Minimum number of connections desired for full HA + */ +export class InsufficientConnectionsError extends DataStreamsError { + constructor( + message: string, + public readonly availableConnections: number, + public readonly requiredConnections: number + ) { + super(message); + this.name = "InsufficientConnectionsError"; + Object.setPrototypeOf(this, InsufficientConnectionsError.prototype); + } +} diff --git a/typescript/src/types/index.ts b/typescript/src/types/index.ts new file mode 100644 index 0000000..b51ffd4 --- /dev/null +++ b/typescript/src/types/index.ts @@ -0,0 +1,3 @@ +export * from "./report"; +export * from "./errors"; +export * from "./client"; diff --git a/typescript/src/types/logger.ts b/typescript/src/types/logger.ts new file mode 100644 index 0000000..05c2706 --- /dev/null +++ b/typescript/src/types/logger.ts @@ -0,0 +1,71 @@ +/** + * Hierarchical logging levels for the Data Streams SDK. + * + * Used to filter logs based on their importance and severity. + */ +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, +} + +/** + * Simple logging function compatible with most external loggers. + * + * @param message - Primary message to log + * @param args - Additional arguments (objects, errors, etc.) + */ +export type LogFunction = (message: string, ...args: any[]) => void; + +/** + * Simple logger interface for the Data Streams SDK. + * + * All methods are optional for maximum flexibility. + * Compatible with console, winston, pino, and other popular loggers. + * + * @example + * ```typescript + * const logger: Logger = { + * info: console.log, + * error: console.error, + * debug: (msg, ...args) => winston.debug(msg, ...args) + * }; + * ``` + */ +export interface Logger { + debug?: LogFunction; + info?: LogFunction; + warn?: LogFunction; + error?: LogFunction; +} + +/** + * Complete logging system configuration for the SDK. + * + * Silent by default. Activated only when explicitly configured. + * + * @example Basic logging + * ```typescript + * const config: LoggingConfig = { + * logger: { info: console.log, error: console.error } + * }; + * ``` + * + * @example Advanced logging with fine control + * ```typescript + * const config: LoggingConfig = { + * logger: myWinstonLogger, + * logLevel: LogLevel.INFO, + * enableConnectionDebug: true + * }; + * ``` + */ +export interface LoggingConfig { + /** Logger instance (optional) */ + logger?: Logger; + /** Minimum logging level */ + logLevel?: LogLevel; + /** Enable debug logs for WebSocket connections */ + enableConnectionDebug?: boolean; +} diff --git a/typescript/src/types/metrics.ts b/typescript/src/types/metrics.ts new file mode 100644 index 0000000..a8240e2 --- /dev/null +++ b/typescript/src/types/metrics.ts @@ -0,0 +1,111 @@ +/** + * Connection status for individual origins in the metrics system + */ +export enum ConnectionStatus { + DISCONNECTED = "disconnected", + CONNECTING = "connecting", + CONNECTED = "connected", + RECONNECTING = "reconnecting", + FAILED = "failed", +} + +/** + * Comprehensive metrics snapshot for Data Streams SDK operations. + * + * This interface provides detailed visibility into the SDK's runtime behavior, + * enabling integration with monitoring systems and operational dashboards. + * + * @example Basic Usage + * ```typescript + * const metrics = client.getMetrics(); + * console.log(`Reports processed: ${metrics.accepted}`); + * console.log(`Active connections: ${metrics.activeConnections}/${metrics.configuredConnections}`); + * ``` + * + * @example Monitoring + * ```typescript + * const metrics = stream.getMetrics(); + * + * // Send to monitoring system + * monitoring.gauge('datastreams.reports.accepted', metrics.accepted); + * monitoring.gauge('datastreams.connections.active', metrics.activeConnections); + * monitoring.gauge('datastreams.reconnects.full', metrics.fullReconnects); + * ``` + */ +export interface MetricsSnapshot { + /** + * Total number of reports successfully processed and emitted to the application. + * + * This represents unique reports that passed deduplication and were delivered + * to the consumer via the 'report' event or read() method. + */ + readonly accepted: number; + + /** + * Total number of duplicate reports filtered out by the deduplication system. + * + * In High Availability mode with multiple connections, the same report may be + * received from multiple origins. This counter tracks how many duplicates + * were detected and filtered to prevent double-processing. + */ + readonly deduplicated: number; + + /** + * Total number of reports received across all connections. + * + * This is the sum of accepted + deduplicated reports, representing the + * complete volume of data received from the Data Streams service. + */ + readonly totalReceived: number; + + /** + * Number of partial reconnection events in High Availability mode. + * + * A partial reconnect occurs when some (but not all) connections are lost + * and need to be re-established. The stream continues operating with the + * remaining healthy connections during this process. + */ + readonly partialReconnects: number; + + /** + * Number of full reconnection events. + * + * A full reconnect occurs when all connections are lost simultaneously, + * causing a complete service interruption until at least one connection + * is successfully re-established. + */ + readonly fullReconnects: number; + + /** + * Number of WebSocket connections configured for the stream. + * + * In single-connection mode, this is always 1. In High Availability mode, + * this represents the total number of origins configured for redundancy. + */ + readonly configuredConnections: number; + + /** + * Number of WebSocket connections currently active and healthy. + * + * This number may be less than configuredConnections if some connections + * are temporarily down or in the process of reconnecting. + */ + readonly activeConnections: number; + + /** + * Detailed status of each origin connection, keyed by origin URL. + * + * Provides granular visibility into the health of individual connections + * in High Availability mode. Useful for debugging connectivity issues + * and monitoring connection stability. + * + * @example + * ```typescript + * const metrics = stream.getMetrics(); + * Object.entries(metrics.originStatus).forEach(([origin, status]) => { + * console.log(`${origin}: ${status}`); + * }); + * ``` + */ + readonly originStatus: Readonly>; +} diff --git a/typescript/src/types/report.ts b/typescript/src/types/report.ts new file mode 100644 index 0000000..88153fa --- /dev/null +++ b/typescript/src/types/report.ts @@ -0,0 +1,196 @@ +export type FeedId = string; + +export interface Feed { + feedID: FeedId; + name: string; + decimals: number; + asset: string; + quoteAsset: string; +} + +export interface Report { + feedID: FeedId; + fullReport: string; + validFromTimestamp: number; + observationsTimestamp: number; +} + +/** + * Market status indicator for V4 reports (Real World Assets). + * Indicates whether the market for the asset is currently open, closed or unknown. + */ +export enum MarketStatus { + /** Market status is unknown */ + UNKNOWN = 0, + /** Market is closed */ + INACTIVE = 1, + /** Market is open */ + ACTIVE = 2, +} + +/** + * Base interface for all reports before decoding + */ +export interface BaseReport { + /** The unique identifier of the feed */ + feedID: string; + /** Earliest timestamp for which the report is applicable */ + validFromTimestamp: number; + /** Latest timestamp for which the report is applicable */ + observationsTimestamp: number; + /** The raw report data in hex format */ + fullReport: string; +} + +/** + * Common fields present in all decoded Data Streams reports. + */ +export interface DecodedReportFields { + nativeFee: bigint; + linkFee: bigint; + expiresAt: number; +} + +/** + * Decoded V2 report format. + */ +export interface DecodedV2Report extends DecodedReportFields { + /** Report format version identifier */ + version: "V2"; + price: bigint; +} + +/** + * Decoded V3 report format (Crypto Streams). + * Report format for cryptocurrency markets that includes bid/ask spreads + */ +export interface DecodedV3Report extends DecodedReportFields { + /** Report format version identifier */ + version: "V3"; + price: bigint; + bid: bigint; + ask: bigint; +} + +/** + * Decoded V4 report format (Real World Assets). + * + * Report format for real-world assets that includes market status information + * to indicate when the underlying market is open, closed or unknown. + */ +export interface DecodedV4Report extends DecodedReportFields { + /** Report format version identifier */ + version: "V4"; + price: bigint; + marketStatus: MarketStatus; +} + +/** + * Decoded V5 report format. + * Interest rate with timestamp and duration metadata. + */ +export interface DecodedV5Report extends DecodedReportFields { + version: "V5"; + /** Interest rate value (int192) */ + rate: bigint; + /** Timestamp when the rate was observed */ + timestamp: number; + /** Duration for which the rate applies */ + duration: number; +} + +/** + * Decoded V6 report format. + * Multiple price values in a single payload. + */ +export interface DecodedV6Report extends DecodedReportFields { + version: "V6"; + price: bigint; + price2: bigint; + price3: bigint; + price4: bigint; + price5: bigint; +} + +/** + * Decoded V7 report format. + * Exchange rate report. + */ +export interface DecodedV7Report extends DecodedReportFields { + version: "V7"; + exchangeRate: bigint; +} + +/** + * Decoded V8 report format (Non-OTC RWA Data Streams). + */ +export interface DecodedV8Report extends DecodedReportFields { + version: "V8"; + /** DON's consensus median price (18 decimal precision) */ + midPrice: bigint; + /** Timestamp of the last valid price update */ + lastUpdateTimestamp: number; + /** Market status - 0 (Unknown), 1 (Closed), 2 (Open) */ + marketStatus: MarketStatus; +} + +/** + * Decoded V9 report format (NAV Data Streams). + */ +export interface DecodedV9Report extends DecodedReportFields { + version: "V9"; + /** DON's consensus NAV per share (18 decimal precision) */ + navPerShare: bigint; + /** Timestamp for the date the NAV report was produced */ + navDate: number; + /** DON's consensus for the total Assets Under Management (18 decimal precision) */ + aum: bigint; + /** Emergency pause flag (0 = normal, 1 = paused - do not consume NAV data) */ + ripcord: number; +} + +/** + * Decoded V10 report format (Tokenized Equity). + * Provides pricing data with multipliers for corporate actions and 24/7 tokenized pricing. + */ +export interface DecodedV10Report extends DecodedReportFields { + version: "V10"; + price: bigint; + /** Timestamp of the last valid price update */ + lastUpdateTimestamp: number; + /** Market status - 0 (Unknown), 1 (Closed), 2 (Open) */ + marketStatus: MarketStatus; + /** Currently applied multiplier accounting for past corporate actions */ + currentMultiplier: bigint; + /** Multiplier to be applied at the `activationDateTime` (set to 0 if none is scheduled) */ + newMultiplier: bigint; + /** When the next corporate action takes effect (set to 0 if none is scheduled) */ + activationDateTime: number; + /** 24/7 tokenized equity price */ + tokenizedPrice: bigint; +} + +/** + * Complete decoded report structure received from Data Streams. + * + * This union type represents any valid decoded report format. The version field + * can be used to determine the specific format and access version-specific fields. + */ +export type DecodedReport = ( + | DecodedV2Report + | DecodedV3Report + | DecodedV4Report + | DecodedV5Report + | DecodedV6Report + | DecodedV7Report + | DecodedV8Report + | DecodedV9Report + | DecodedV10Report +) & { + /** Feed ID this report belongs to */ + feedID: string; + /** Earliest timestamp this report is valid for */ + validFromTimestamp: number; + /** Latest timestamp this report applies to */ + observationsTimestamp: number; +}; diff --git a/typescript/src/utils/auth.ts b/typescript/src/utils/auth.ts new file mode 100644 index 0000000..c92aa4d --- /dev/null +++ b/typescript/src/utils/auth.ts @@ -0,0 +1,36 @@ +import { createHash, createHmac } from "crypto"; + +/** + * Generate authentication headers for a request + * @param apiKey API key (UUID) for authentication + * @param userSecret User secret for signing + * @param method HTTP method + * @param url Full URL of the request + * @param body Request body (if any) + * @param timestamp Optional timestamp for testing (milliseconds since epoch) + * @returns Authentication headers + */ +export function generateAuthHeaders( + apiKey: string, + userSecret: string, + method: string, + url: string, + body?: string, + timestamp?: number +): Record { + const ts = timestamp || Date.now(); + const pathWithQuery = new URL(url).pathname + new URL(url).search; + const bodyHash = createHash("sha256") + .update(body || "") + .digest("hex"); + + const hmacBaseString = `${method} ${pathWithQuery} ${bodyHash} ${apiKey} ${ts}`; + const hmac = createHmac("sha256", userSecret); + const signature = hmac.update(hmacBaseString).digest("hex"); + + return { + Authorization: apiKey, + "X-Authorization-Timestamp": ts.toString(), + "X-Authorization-Signature-SHA256": signature, + }; +} diff --git a/typescript/src/utils/constants.ts b/typescript/src/utils/constants.ts new file mode 100644 index 0000000..f88b2c0 --- /dev/null +++ b/typescript/src/utils/constants.ts @@ -0,0 +1,36 @@ +/** + * WebSocket connection constants + */ +export const WS_CONSTANTS = { + /** Maximum time to wait for connection in milliseconds */ + CONNECT_TIMEOUT: 5000, + /** Ping interval in milliseconds */ + PING_INTERVAL: 30000, + /** Time to wait for pong response in milliseconds */ + PONG_TIMEOUT: 5000, + /** Maximum reconnection attempts */ + MAX_RECONNECTS: 5, + /** Base delay between reconnection attempts in milliseconds */ + RECONNECT_DELAY: 1000, + /** Maximum delay between reconnection attempts in milliseconds */ + MAX_RECONNECT_INTERVAL: 10000, +} as const; + +/** + * Regular expressions for validation + */ +export const VALIDATION_REGEX = { + /** Matches valid feed IDs (0x followed by 64 hex characters) */ + FEED_ID: /^0x[0-9a-fA-F]{64}$/, + /** Matches valid schema versions (0x0002-0x0009, 0x000a) */ + SCHEMA_VERSION: /^0x000([2-9]|a)$/, +} as const; + +// Request timeout constants +export const DEFAULT_TIMEOUT = 30000; // 30 seconds +export const DEFAULT_RETRY_DELAY = 1000; // 1 second +export const DEFAULT_RETRY_ATTEMPTS = 1; + +// HA Mode Constants +export const X_CLL_AVAILABLE_ORIGINS_HEADER = "X-Cll-Available-Origins"; +export const X_CLL_ORIGIN_HEADER = "X-Cll-Origin"; diff --git a/typescript/src/utils/logger.ts b/typescript/src/utils/logger.ts new file mode 100644 index 0000000..351d642 --- /dev/null +++ b/typescript/src/utils/logger.ts @@ -0,0 +1,97 @@ +import { LoggingConfig, LogLevel } from "../types/logger"; + +/** + * Simple and efficient internal logger for the Data Streams SDK. + * + * Provides configurable logging + * + * @example Basic usage + * ```typescript + * const logger = new SDKLogger({ + * logger: { info: console.log, error: console.error }, + * logLevel: LogLevel.INFO + * }); + * logger.info('Client initialized'); + * ``` + * + * @example Debugging WebSocket connections + * ```typescript + * const logger = new SDKLogger({ + * logger: myLogger, + * enableConnectionDebug: true + * }); + * logger.connectionDebug('Ping received from origin'); + * ``` + */ +export class SDKLogger { + private config: LoggingConfig; + + constructor(config: LoggingConfig = {}) { + this.config = config; + } + + /** General debug logs */ + debug(message: string, ...args: any[]): void { + this.log(LogLevel.DEBUG, message, ...args); + } + + /** Information logs */ + info(message: string, ...args: any[]): void { + this.log(LogLevel.INFO, message, ...args); + } + + /** Warning logs */ + warn(message: string, ...args: any[]): void { + this.log(LogLevel.WARN, message, ...args); + } + + /** Error logs */ + error(message: string, ...args: any[]): void { + this.log(LogLevel.ERROR, message, ...args); + } + + /** Specialized debug logs for WebSocket connections */ + connectionDebug(message: string, ...args: any[]): void { + if (this.config.enableConnectionDebug) { + this.debug(`[Connection] ${message}`, ...args); + } + } + + /** Internal logging method with level verification */ + private log(level: LogLevel, message: string, ...args: any[]): void { + // Zero overhead if no logger configured + if (!this.config.logger) { + return; + } + + // Check minimum level + const minLevel = this.config.logLevel ?? LogLevel.INFO; + if (level < minLevel) { + return; + } + + // Route to appropriate function + const timestamp = new Date().toISOString(); + const formattedMessage = `[${timestamp}] [DataStreams] ${message}`; + + try { + switch (level) { + case LogLevel.DEBUG: + this.config.logger.debug?.(formattedMessage, ...args); + break; + case LogLevel.INFO: + this.config.logger.info?.(formattedMessage, ...args); + break; + case LogLevel.WARN: + this.config.logger.warn?.(formattedMessage, ...args); + break; + case LogLevel.ERROR: + this.config.logger.error?.(formattedMessage, ...args); + break; + } + } catch { + // Silent if external logger fails + // Do not crash SDK due to logging issues + } + } +} diff --git a/typescript/src/utils/origin-discovery.ts b/typescript/src/utils/origin-discovery.ts new file mode 100644 index 0000000..3e1ff65 --- /dev/null +++ b/typescript/src/utils/origin-discovery.ts @@ -0,0 +1,197 @@ +import { X_CLL_AVAILABLE_ORIGINS_HEADER, WS_CONSTANTS } from "./constants"; +import { generateAuthHeaders } from "./auth"; +import { OriginDiscoveryError, InsufficientConnectionsError } from "../types/errors"; +import { SDKLogger } from "./logger"; + +/** + * Parses comma-separated WebSocket URLs + * @param wsUrl Comma-separated WebSocket URLs like "wss://url1,wss://url2" + * @returns Array of individual WebSocket URLs + */ +export function parseCommaSeparatedUrls(wsUrl: string): string[] { + return wsUrl + .split(",") + .map(url => url.trim()) + .filter(url => url.length > 0); +} + +/** + * Converts WebSocket URL scheme to HTTP for HEAD requests + * @param wsUrl WebSocket URL (ws:// or wss://) + * @returns HTTP URL (http:// or https://) + */ +export function convertWebSocketToHttpScheme(wsUrl: string): string { + if (wsUrl.startsWith("wss://")) { + return wsUrl.replace("wss://", "https://"); + } else if (wsUrl.startsWith("ws://")) { + return wsUrl.replace("ws://", "http://"); + } + return wsUrl; // Already HTTP/HTTPS +} + +/** + * Parses the X-Cll-Available-Origins header value + * @param headerValue Raw header value like "{origin1,origin2}" or "origin1,origin2" + * @returns Array of origin URLs + */ +export function parseOriginsHeader(headerValue: string): string[] { + if (!headerValue) { + return []; + } + + let cleaned = headerValue.trim(); + + // Remove surrounding brackets if present + if (cleaned.startsWith("{")) { + cleaned = cleaned.slice(1); + } + if (cleaned.endsWith("}")) { + cleaned = cleaned.slice(0, -1); + } + + return cleaned + .split(",") + .map(origin => origin.trim()) + .filter(origin => origin.length > 0); +} + +/** + * Discovers available origins via HEAD request + * @param baseUrl Base WebSocket URL to discover origins for + * @param apiKey API key for authentication + * @param userSecret User secret for authentication + * @param timeout Request timeout in milliseconds + * @returns Promise resolving to array of discovered origin URLs + */ +export async function discoverOrigins( + baseUrl: string, + apiKey: string, + userSecret: string, + timeout: number = WS_CONSTANTS.CONNECT_TIMEOUT, + logger?: SDKLogger +): Promise { + logger?.debug(`Starting origin discovery for ${baseUrl}`); + + try { + // Convert WebSocket URL to HTTP for HEAD request + const httpUrl = convertWebSocketToHttpScheme(baseUrl); + logger?.debug(`Converted WebSocket URL to HTTP: ${httpUrl}`); + const url = new URL("/", httpUrl); + + // Generate authentication headers + const headers = generateAuthHeaders(apiKey, userSecret, "HEAD", url.toString()); + + // Make HEAD request with timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url.toString(), { + method: "HEAD", + headers, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // Extract and parse origins header + const originsHeader = response.headers.get(X_CLL_AVAILABLE_ORIGINS_HEADER); + if (!originsHeader) { + logger?.info("No origins header found in response"); + return []; // No origins available + } + + const origins = parseOriginsHeader(originsHeader); + logger?.info(`Origin discovery successful: found ${origins.length} origins`); + return origins; + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + logger?.error(`Origin discovery failed for ${baseUrl}:`, error); + if (error instanceof Error) { + if (error.name === "AbortError") { + throw new OriginDiscoveryError(`Origin discovery timed out after ${timeout}ms`, error); + } + throw new OriginDiscoveryError(`Failed to discover origins: ${error.message}`, error); + } + throw new OriginDiscoveryError("Unknown error during origin discovery"); + } +} + +/** + * Gets available origins using both static and dynamic discovery + * @param wsUrl WebSocket URL (may be comma-separated) + * @param apiKey API key for authentication + * @param userSecret User secret for authentication + * @param haEnabled Whether High Availability mode is enabled + * @param timeout Request timeout in milliseconds + * @returns Promise resolving to array of available origin URLs + */ +export async function getAvailableOrigins( + wsUrl: string, + apiKey: string, + userSecret: string, + haEnabled: boolean = true, + timeout: number = WS_CONSTANTS.CONNECT_TIMEOUT, + logger?: SDKLogger +): Promise { + logger?.debug(`Getting available origins for ${wsUrl}, dynamic discovery: ${haEnabled}`); + + // First, parse any comma-separated URLs + const staticOrigins = parseCommaSeparatedUrls(wsUrl); + logger?.debug(`Found ${staticOrigins.length} static origins`); + + // If dynamic discovery is disabled or we have multiple static origins, use static + if (!haEnabled || staticOrigins.length > 1) { + logger?.info(`Using static origins: ${staticOrigins.join(", ")}`); + return staticOrigins; + } + + try { + // Attempt dynamic discovery + const dynamicOrigins = await discoverOrigins( + staticOrigins[0], // Use first URL as base for discovery + apiKey, + userSecret, + timeout, + logger + ); + + // Use dynamic origins if available, otherwise fall back to static + let finalOrigins = dynamicOrigins.length > 0 ? dynamicOrigins : staticOrigins; + + if (dynamicOrigins.length > 0 && !dynamicOrigins[0].startsWith("ws")) { + const baseUrl = staticOrigins[0]; + finalOrigins = dynamicOrigins.map(originId => `${baseUrl}#${originId}`); + } + + // Validate we have at least one origin (both SDKs fail with 0 connections) + if (finalOrigins.length === 0) { + throw new InsufficientConnectionsError( + "No origins available for connection", + 0, // availableConnections + 1 // requiredConnections (minimum to operate) + ); + } + + logger?.info(`Dynamic discovery completed: ${finalOrigins.length} origins available`); + return finalOrigins; + } catch (error) { + logger?.warn(`Dynamic discovery failed, falling back to static origins:`, error); + // If dynamic discovery fails, fall back to static origins + + // Validate static origins are sufficient + if (staticOrigins.length === 0) { + throw new InsufficientConnectionsError( + "No origins available for connection after discovery failure", + 0, // availableConnections + 1 // requiredConnections (minimum to operate) + ); + } + + return staticOrigins; + } +} diff --git a/typescript/src/utils/report.ts b/typescript/src/utils/report.ts new file mode 100644 index 0000000..c9d0257 --- /dev/null +++ b/typescript/src/utils/report.ts @@ -0,0 +1,139 @@ +import { + DecodedV2Report, + DecodedV3Report, + DecodedV4Report, + DecodedV5Report, + DecodedV6Report, + DecodedV7Report, + DecodedV8Report, + DecodedV9Report, + DecodedV10Report, + DecodedReport, + MarketStatus, +} from "../types"; + +/** + * Determines the version of a feed based on its ID + * @param feedId The feed ID to check + * @returns "V2", "V3", "V4", "V8", "V9", or "V10" depending on the feed ID schema version + */ +export function getReportVersion(feedId: string): "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" | "V10" { + const schemaVersion = feedId.slice(2, 6); + switch (schemaVersion) { + case "0002": + return "V2"; + case "0003": + return "V3"; + case "0004": + return "V4"; + case "0005": + return "V5"; + case "0006": + return "V6"; + case "0007": + return "V7"; + case "0008": + return "V8"; + case "0009": + return "V9"; + case "000a": + return "V10"; + default: + throw new Error(`Unknown schema version: 0x${schemaVersion}`); + } +} + +/** + * Formats a report as a human-readable string + * @param report The report object to format + * @param version The version of the report (V2, V3, V4, V5, V6, V7, V8, V9, or V10) + * @returns Formatted string representation of the report + */ +export function formatReport( + report: DecodedReport, + version: "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" | "V10" +): string { + let output = ""; + + output += "\nReport Metadata:\n"; + output += `Feed ID: ${report.feedID}\n`; + output += `Valid From: ${report.validFromTimestamp}\n`; + output += `Observations: ${report.observationsTimestamp}\n`; + + output += "\nDecoded Data:\n"; + output += `Native Fee: ${report.nativeFee.toString()}\n`; + output += `LINK Fee: ${report.linkFee.toString()}\n`; + output += `Expires At: ${report.expiresAt}\n`; + + // Handle version-specific fields + switch (version) { + case "V2": { + const r = report as DecodedV2Report; + output += `Price: ${r.price.toString()}\n`; + break; + } + case "V3": { + const r = report as DecodedV3Report; + output += `Price: ${r.price.toString()}\n`; + output += `Bid Price: ${r.bid.toString()}\n`; + output += `Ask Price: ${r.ask.toString()}\n`; + break; + } + case "V4": { + const r = report as DecodedV4Report; + output += `Price: ${r.price.toString()}\n`; + output += `Market Status: ${MarketStatus[r.marketStatus]} (${r.marketStatus})\n`; + break; + } + case "V5": { + const r = report as DecodedV5Report; + output += `Rate: ${r.rate.toString()}\n`; + output += `Rate Timestamp: ${r.timestamp}\n`; + output += `Duration: ${r.duration}\n`; + break; + } + case "V6": { + const r = report as DecodedV6Report; + output += `Price: ${r.price.toString()}\n`; + output += `Price2: ${r.price2.toString()}\n`; + output += `Price3: ${r.price3.toString()}\n`; + output += `Price4: ${r.price4.toString()}\n`; + output += `Price5: ${r.price5.toString()}\n`; + break; + } + case "V7": { + const r = report as DecodedV7Report; + output += `Exchange Rate: ${r.exchangeRate.toString()}\n`; + break; + } + case "V8": { + const r = report as DecodedV8Report; + output += `Mid Price: ${r.midPrice.toString()}\n`; + output += `Last Update: ${r.lastUpdateTimestamp}\n`; + output += `Market Status: ${MarketStatus[r.marketStatus]} (${r.marketStatus})\n`; + break; + } + case "V9": { + const r = report as DecodedV9Report; + output += `NAV per Share: ${r.navPerShare.toString()}\n`; + output += `NAV Date: ${r.navDate}\n`; + output += `AUM: ${r.aum.toString()}\n`; + output += `Ripcord: ${r.ripcord === 0 ? `Normal (${r.ripcord})` : `PAUSED - DO NOT CONSUME (${r.ripcord})`}\n`; + break; + } + case "V10": { + const r = report as DecodedV10Report; + output += `Price: ${r.price.toString()}\n`; + output += `Last Update: ${r.lastUpdateTimestamp}\n`; + output += `Market Status: ${MarketStatus[r.marketStatus]} (${r.marketStatus})\n`; + output += `Current Multiplier: ${r.currentMultiplier.toString()}\n`; + output += `New Multiplier: ${r.newMultiplier.toString()}\n`; + output += `Activation Date: ${r.activationDateTime}\n`; + output += `Tokenized Price: ${r.tokenizedPrice.toString()}\n`; + break; + } + } + + output += "-".repeat(50); + return output; +} diff --git a/typescript/src/utils/time.ts b/typescript/src/utils/time.ts new file mode 100644 index 0000000..a43dcc5 --- /dev/null +++ b/typescript/src/utils/time.ts @@ -0,0 +1,38 @@ +import { ValidationError } from "../types/errors"; + +/** + * Get current Unix timestamp in seconds + */ +export function getCurrentTimestamp(): number { + return Math.floor(Date.now() / 1000); +} + +/** + * Get Unix timestamp for 30 days ago in seconds + */ +export function getThirtyDaysAgoTimestamp(): number { + return getCurrentTimestamp() - 30 * 24 * 60 * 60; +} + +/** + * Check if a timestamp is within the last 30 days + * @param timestamp Unix timestamp in seconds + * @returns boolean indicating if timestamp is within last 30 days + */ +export function isTimestampWithinLast30Days(timestamp: number): boolean { + return timestamp >= getThirtyDaysAgoTimestamp(); +} + +/** + * Validate that a timestamp is within the last 30 days + * @param timestamp Unix timestamp in seconds + * @throws ValidationError if timestamp is not within last 30 days + */ +export function validateTimestampWithin30Days(timestamp: number): void { + if (!isTimestampWithinLast30Days(timestamp)) { + throw new ValidationError( + `Timestamp ${timestamp} is not within the last 30 days. ` + + `Earliest allowed timestamp is ${getThirtyDaysAgoTimestamp()}` + ); + } +} diff --git a/typescript/src/utils/validation.ts b/typescript/src/utils/validation.ts new file mode 100644 index 0000000..714eb04 --- /dev/null +++ b/typescript/src/utils/validation.ts @@ -0,0 +1,88 @@ +import { ValidationError } from "../types/errors"; +import { VALIDATION_REGEX } from "./constants"; + +/** + * Validates a feed ID + * @param feedId The feed ID to validate + * @throws {ValidationError} If the feed ID is invalid + */ +export function validateFeedId(feedId: string): void { + if (!feedId) { + throw new ValidationError("Feed ID is required"); + } + if (!VALIDATION_REGEX.FEED_ID.test(feedId)) { + throw new ValidationError("Invalid feed ID format. Must be 0x followed by 64 hex characters"); + } + const version = feedId.slice(2, 6); + if (!VALIDATION_REGEX.SCHEMA_VERSION.test(`0x${version}`)) { + throw new ValidationError( + "Invalid feed ID version. Must start with 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, or 0x000a" + ); + } +} + +/** + * Validates a timestamp + * @param timestamp The timestamp to validate + * @throws {ValidationError} If the timestamp is invalid + */ +export function validateTimestamp(timestamp: number): void { + if (!Number.isInteger(timestamp)) { + throw new ValidationError("Timestamp must be an integer"); + } + if (timestamp < 0) { + throw new ValidationError("Timestamp cannot be negative"); + } +} + +/** + * Validates an array of feed IDs + * @param feedIds The array of feed IDs to validate + * @throws {ValidationError} If any feed ID is invalid + */ +export function validateFeedIds(feedIds: string[]): void { + if (!Array.isArray(feedIds)) { + throw new ValidationError("Feed IDs must be an array"); + } + if (feedIds.length === 0) { + throw new ValidationError("At least one feed ID is required"); + } + feedIds.forEach(validateFeedId); +} + +/** + * Validates a hex string (must start with 0x and contain only hex characters) + * @param hexString The hex string to validate + * @param fieldName The name of the field being validated (for error messages) + * @throws {ValidationError} If the hex string is invalid + */ +export function validateHexString(hexString: string, fieldName: string = "hex string"): void { + if (!hexString) { + throw new ValidationError(`${fieldName} is required`); + } + if (typeof hexString !== "string") { + throw new ValidationError(`${fieldName} must be a string`); + } + if (!hexString.startsWith("0x")) { + throw new ValidationError(`${fieldName} must start with 0x`); + } + if (!/^0x[0-9a-fA-F]+$/.test(hexString)) { + throw new ValidationError(`${fieldName} contains invalid hex characters`); + } + if (hexString.length < 3) { + throw new ValidationError(`${fieldName} must contain at least one hex character after 0x`); + } +} + +/** + * Validate required environment variables + * @throws ValidationError if required environment variables are missing + */ +export function validateEnvironment(): void { + if (!process.env.API_KEY) { + throw new ValidationError("API_KEY environment variable is required"); + } + if (!process.env.USER_SECRET) { + throw new ValidationError("USER_SECRET environment variable is required"); + } +} diff --git a/typescript/tests/features/error-handling.test.ts b/typescript/tests/features/error-handling.test.ts new file mode 100644 index 0000000..58b1325 --- /dev/null +++ b/typescript/tests/features/error-handling.test.ts @@ -0,0 +1,688 @@ +/** + * Comprehensive Error Handling Tests + * + * These tests validate the error handling system by: + * - Testing all custom error types and their properties + * - Testing error inheritance and instanceof checks + * - Testing error message formatting and clarity + * - Testing error context preservation across operations + * - Testing error recovery scenarios and graceful degradation + * - Testing error propagation through async operations + * + * Goals: + * - Ensure robust error handling that provides clear debugging information + * - Test all error scenarios comprehensively + * - Validate error recovery and graceful degradation + * - Provide excellent developer experience with meaningful error messages + * - Build the best possible TypeScript error handling system + */ + +import { describe, it, expect } from "@jest/globals"; +import { + DataStreamsError, + ValidationError, + AuthenticationError, + ReportDecodingError, + WebSocketError, + APIError, + OriginDiscoveryError, + MultiConnectionError, + PartialConnectionFailureError, + InsufficientConnectionsError, +} from "../../src/types/errors"; + +describe("Error Handling Tests", () => { + describe("custom error types", () => { + describe("DataStreamsError (base class)", () => { + it("should create error with message", () => { + const error = new DataStreamsError("Test error message"); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DataStreamsError); + expect(error.name).toBe("DataStreamsError"); + expect(error.message).toBe("Test error message"); + expect(error.stack).toBeDefined(); + }); + + it("should preserve stack trace", () => { + const error = new DataStreamsError("Test error"); + + expect(error.stack).toBeDefined(); + expect(error.stack).toContain("DataStreamsError"); + expect(error.stack).toContain("Test error"); + }); + }); + + describe("ValidationError", () => { + it("should create validation error", () => { + const error = new ValidationError("Invalid feed ID format"); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DataStreamsError); + expect(error).toBeInstanceOf(ValidationError); + expect(error.name).toBe("ValidationError"); + expect(error.message).toBe("Invalid feed ID format"); + }); + + it("should handle feed ID validation errors", () => { + const feedId = "invalid-feed-id"; + const error = new ValidationError(`Feed ID must be a valid hex string starting with 0x: ${feedId}`); + + expect(error.message).toContain("Feed ID must be a valid hex string"); + expect(error.message).toContain(feedId); + }); + + it("should handle timestamp validation errors", () => { + const timestamp = -1; + const error = new ValidationError(`Timestamp cannot be negative: ${timestamp}`); + + expect(error.message).toContain("Timestamp cannot be negative"); + expect(error.message).toContain(String(timestamp)); + }); + }); + + describe("AuthenticationError", () => { + it("should create authentication error", () => { + const error = new AuthenticationError("Invalid API key"); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DataStreamsError); + expect(error).toBeInstanceOf(AuthenticationError); + expect(error.name).toBe("AuthenticationError"); + expect(error.message).toBe("Invalid API key"); + }); + + it("should handle signature validation errors", () => { + const error = new AuthenticationError("HMAC signature validation failed"); + + expect(error.message).toContain("HMAC signature validation failed"); + }); + + it("should handle timestamp skew errors", () => { + const error = new AuthenticationError("Request timestamp too old or too far in the future"); + + expect(error.message).toContain("Request timestamp"); + }); + }); + + describe("APIError", () => { + it("should create API error", () => { + const error = new APIError("Connection timeout"); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DataStreamsError); + expect(error).toBeInstanceOf(APIError); + expect(error.name).toBe("APIError"); + expect(error.message).toBe("Connection timeout"); + }); + + it("should create API error with status code", () => { + const error = new APIError("HTTP 500: Internal Server Error", 500); + + expect(error.message).toBe("HTTP 500: Internal Server Error"); + expect(error.statusCode).toBe(500); + }); + + it("should handle missing status code", () => { + const error = new APIError("Network unreachable"); + + expect(error.message).toBe("Network unreachable"); + expect(error.statusCode).toBeUndefined(); + }); + }); + + describe("ReportDecodingError", () => { + it("should create report decoding error", () => { + const error = new ReportDecodingError("Invalid report format"); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DataStreamsError); + expect(error).toBeInstanceOf(ReportDecodingError); + expect(error.name).toBe("ReportDecodingError"); + expect(error.message).toBe("Invalid report format"); + }); + + it("should handle ABI decoding errors", () => { + const error = new ReportDecodingError("Failed to decode V3 report: insufficient data"); + + expect(error.message).toContain("Failed to decode V3 report"); + expect(error.message).toContain("insufficient data"); + }); + + it("should handle version validation errors", () => { + const version = "0x0099"; + const error = new ReportDecodingError(`Unknown report version: ${version}`); + + expect(error.message).toContain("Unknown report version"); + expect(error.message).toContain(version); + }); + }); + + describe("WebSocketError", () => { + it("should create WebSocket error", () => { + const error = new WebSocketError("WebSocket connection failed"); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DataStreamsError); + expect(error).toBeInstanceOf(WebSocketError); + expect(error.name).toBe("WebSocketError"); + expect(error.message).toBe("WebSocket connection failed"); + }); + + it("should handle connection errors", () => { + const url = "wss://example.com"; + const error = new WebSocketError(`Failed to connect to ${url}`); + + expect(error.message).toContain("Failed to connect to"); + expect(error.message).toContain(url); + }); + + it("should handle message parsing errors", () => { + const error = new WebSocketError("Invalid message format received from stream"); + + expect(error.message).toContain("Invalid message format"); + }); + }); + + describe("OriginDiscoveryError", () => { + it("should create origin discovery error", () => { + const error = new OriginDiscoveryError("Failed to discover origins"); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DataStreamsError); + expect(error).toBeInstanceOf(OriginDiscoveryError); + expect(error.name).toBe("OriginDiscoveryError"); + expect(error.message).toBe("Failed to discover origins"); + }); + + it("should create origin discovery error with cause", () => { + const cause = new Error("Network timeout"); + const error = new OriginDiscoveryError("Failed to discover origins", cause); + + expect(error.message).toBe("Failed to discover origins"); + expect(error.cause).toBe(cause); + }); + + it("should handle HEAD request failures", () => { + const url = "https://api.example.com"; + const error = new OriginDiscoveryError(`HEAD request failed for ${url}: 404 Not Found`); + + expect(error.message).toContain("HEAD request failed"); + expect(error.message).toContain(url); + expect(error.message).toContain("404 Not Found"); + }); + + it("should handle header parsing errors", () => { + const error = new OriginDiscoveryError("Invalid X-Cll-Available-Origins header format"); + + expect(error.message).toContain("X-Cll-Available-Origins header"); + }); + }); + + describe("MultiConnectionError", () => { + it("should create multi-connection error", () => { + const error = new MultiConnectionError("Failed to establish multiple connections"); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DataStreamsError); + expect(error).toBeInstanceOf(MultiConnectionError); + expect(error.name).toBe("MultiConnectionError"); + expect(error.message).toBe("Failed to establish multiple connections"); + }); + + it("should handle connection failure details", () => { + const failedOrigins = ["wss://origin1.example.com", "wss://origin2.example.com"]; + const error = new MultiConnectionError(`Failed to connect to origins: ${failedOrigins.join(", ")}`); + + expect(error.message).toContain("Failed to connect to origins"); + expect(error.message).toContain("origin1.example.com"); + expect(error.message).toContain("origin2.example.com"); + }); + }); + + describe("PartialConnectionFailureError", () => { + it("should create partial connection failure error", () => { + const error = new PartialConnectionFailureError("Some connections failed", 2, 4); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DataStreamsError); + expect(error).toBeInstanceOf(PartialConnectionFailureError); + expect(error.name).toBe("PartialConnectionFailureError"); + expect(error.message).toBe("Some connections failed"); + expect(error.failedConnections).toBe(2); + expect(error.totalConnections).toBe(4); + }); + + it("should handle partial failure details", () => { + const activeCount = 2; + const totalCount = 4; + const error = new PartialConnectionFailureError( + `Partial connection failure: ${activeCount}/${totalCount} connections active`, + totalCount - activeCount, + totalCount + ); + + expect(error.message).toContain("Partial connection failure"); + expect(error.message).toContain("2/4 connections active"); + expect(error.failedConnections).toBe(2); + expect(error.totalConnections).toBe(4); + }); + }); + + describe("InsufficientConnectionsError", () => { + it("should create insufficient connections error", () => { + const error = new InsufficientConnectionsError("No active connections available", 0, 2); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DataStreamsError); + expect(error).toBeInstanceOf(InsufficientConnectionsError); + expect(error.name).toBe("InsufficientConnectionsError"); + expect(error.message).toBe("No active connections available"); + expect(error.availableConnections).toBe(0); + expect(error.requiredConnections).toBe(2); + }); + + it("should handle minimum connection requirements", () => { + const required = 2; + const available = 0; + const error = new InsufficientConnectionsError( + `Insufficient connections: need ${required}, have ${available}`, + available, + required + ); + + expect(error.message).toContain("Insufficient connections"); + expect(error.message).toContain("need 2, have 0"); + expect(error.availableConnections).toBe(0); + expect(error.requiredConnections).toBe(2); + }); + }); + }); + + describe("error inheritance and instanceof checks", () => { + it("should properly inherit from base Error class", () => { + const errors = [ + new DataStreamsError("test"), + new ValidationError("test"), + new AuthenticationError("test"), + new ReportDecodingError("test"), + new WebSocketError("test"), + new APIError("test"), + new OriginDiscoveryError("test"), + new MultiConnectionError("test"), + new PartialConnectionFailureError("test", 1, 2), + new InsufficientConnectionsError("test", 0, 1), + ]; + + errors.forEach(error => { + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DataStreamsError); + expect(error.name).toBeDefined(); + expect(error.message).toBeDefined(); + expect(error.stack).toBeDefined(); + }); + }); + + it("should support instanceof checks for specific error types", () => { + const validationError = new ValidationError("validation error"); + const authError = new AuthenticationError("auth error"); + const apiError = new APIError("api error"); + + expect(validationError instanceof ValidationError).toBe(true); + expect(validationError instanceof AuthenticationError).toBe(false); + expect(validationError instanceof APIError).toBe(false); + + expect(authError instanceof AuthenticationError).toBe(true); + expect(authError instanceof ValidationError).toBe(false); + expect(authError instanceof APIError).toBe(false); + + expect(apiError instanceof APIError).toBe(true); + expect(apiError instanceof ValidationError).toBe(false); + expect(apiError instanceof AuthenticationError).toBe(false); + }); + + it("should support polymorphic error handling", () => { + const errors: DataStreamsError[] = [ + new ValidationError("validation error"), + new AuthenticationError("auth error"), + new APIError("api error"), + ]; + + errors.forEach(error => { + expect(error instanceof DataStreamsError).toBe(true); + expect(error instanceof Error).toBe(true); + + // Should be able to access base properties + expect(typeof error.name).toBe("string"); + expect(typeof error.message).toBe("string"); + expect(error.stack).toBeDefined(); + }); + }); + }); + + describe("error message formatting", () => { + it("should provide clear and descriptive error messages", () => { + const testCases = [ + { + error: new ValidationError("Missing required configuration field: apiKey"), + expectedPatterns: ["Missing required", "apiKey"], + }, + { + error: new ValidationError("Feed ID must be a 64-character hex string starting with 0x"), + expectedPatterns: ["Feed ID", "64-character hex string", "0x"], + }, + { + error: new AuthenticationError("HMAC signature mismatch: expected abc123, got def456"), + expectedPatterns: ["HMAC signature mismatch", "expected", "got"], + }, + { + error: new APIError("HTTP 429: Rate limit exceeded. Retry after 60 seconds", 429), + expectedPatterns: ["HTTP 429", "Rate limit exceeded", "Retry after"], + }, + { + error: new ReportDecodingError("Failed to decode V3 report: missing bid field"), + expectedPatterns: ["Failed to decode", "V3 report", "missing bid field"], + }, + ]; + + testCases.forEach(({ error, expectedPatterns }) => { + expectedPatterns.forEach(pattern => { + expect(error.message).toContain(pattern); + }); + }); + }); + + it("should include relevant context in error messages", () => { + const feedId = "0x0003" + "1".repeat(60); + const timestamp = 1640995200; + const url = "https://api.example.com"; + + const errors = [ + new ValidationError(`Invalid feed ID format: ${feedId}`), + new ValidationError(`Timestamp cannot be negative: ${timestamp}`), + new APIError(`Failed to connect to ${url}: Connection refused`), + new OriginDiscoveryError(`Origin discovery failed for ${url}: 404 Not Found`), + ]; + + expect(errors[0].message).toContain(feedId); + expect(errors[1].message).toContain(String(timestamp)); + expect(errors[2].message).toContain(url); + expect(errors[3].message).toContain(url); + }); + + it("should format error messages consistently", () => { + const errors = [ + new ValidationError("Invalid endpoint URL"), + new ValidationError("Invalid feed ID format"), + new AuthenticationError("Invalid API key"), + new APIError("Connection timeout"), + new ReportDecodingError("Invalid report format"), + ]; + + errors.forEach(error => { + // Should start with a capital letter + expect(error.message.charAt(0)).toMatch(/[A-Z]/); + + // Should not end with a period (for consistency) + expect(error.message).not.toMatch(/\.$/); + + // Should be non-empty + expect(error.message.length).toBeGreaterThan(0); + }); + }); + }); + + describe("Documentation Test: error recovery scenarios", () => { + // These tests demonstrate example error handling patterns + it("should demonstrate graceful degradation on partial failures", () => { + const partialError = new PartialConnectionFailureError("2 of 4 connections failed", 2, 4); + + // Simulate recovery logic + function handlePartialFailure(error: PartialConnectionFailureError): { canContinue: boolean; message: string } { + if (error instanceof PartialConnectionFailureError) { + return { + canContinue: true, + message: "Continuing with reduced connection count", + }; + } + return { canContinue: false, message: "Cannot recover" }; + } + + const result = handlePartialFailure(partialError); + expect(result.canContinue).toBe(true); + expect(result.message).toContain("reduced connection count"); + }); + + it("should demonstrate retry logic patterns for recoverable errors", () => { + const retryableErrors = [ + new APIError("HTTP 500: Internal Server Error", 500), + new APIError("HTTP 502: Bad Gateway", 502), + new APIError("HTTP 503: Service Unavailable", 503), + new APIError("Connection timeout"), + ]; + + const nonRetryableErrors = [ + new AuthenticationError("Invalid API key"), + new ValidationError("Invalid feed ID"), + new ValidationError("Missing endpoint URL"), + ]; + + function isRetryable(error: DataStreamsError): boolean { + if (error instanceof APIError) { + return ( + error.statusCode === 500 || + error.statusCode === 502 || + error.statusCode === 503 || + error.message.includes("timeout") + ); + } + return false; + } + + retryableErrors.forEach(error => { + expect(isRetryable(error)).toBe(true); + }); + + nonRetryableErrors.forEach(error => { + expect(isRetryable(error)).toBe(false); + }); + }); + + it("should handle fallback mechanisms", () => { + const primaryError = new OriginDiscoveryError("Failed to discover origins"); + + function handleOriginDiscoveryFailure(error: OriginDiscoveryError): { fallbackUrl: string; message: string } { + if (error instanceof OriginDiscoveryError) { + return { + fallbackUrl: "wss://fallback.example.com", + message: "Using fallback URL due to origin discovery failure", + }; + } + throw error; + } + + const result = handleOriginDiscoveryFailure(primaryError); + expect(result.fallbackUrl).toBe("wss://fallback.example.com"); + expect(result.message).toContain("fallback URL"); + }); + }); + + describe("error propagation through async operations", () => { + it("should demonstrate error wrapping with cause preservation", async () => { + async function simulateOriginDiscovery(): Promise { + try { + // Simulates system errors that can occur during network operations + const systemError = new Error("Network timeout"); + systemError.name = "AbortError"; // Simulates fetch timeout + throw systemError; + } catch (error) { + // SDK: wrap system errors while preserving original context + throw new OriginDiscoveryError("Failed to discover origins during HA setup", error as Error); + } + } + + // Verify the wrapped error maintains both high-level context and system details + await expect(simulateOriginDiscovery()).rejects.toThrow(OriginDiscoveryError); + + try { + await simulateOriginDiscovery(); + } catch (error) { + // Type guard for proper TS handling + expect(error).toBeInstanceOf(OriginDiscoveryError); + + const originError = error as OriginDiscoveryError; + + // Validate SDK level error info + expect(originError.message).toBe("Failed to discover origins during HA setup"); + + // Verify system error is preserved + expect(originError.cause).toBeDefined(); + expect(originError.cause).toBeInstanceOf(Error); + expect(originError.cause!.name).toBe("AbortError"); + expect(originError.cause!.message).toBe("Network timeout"); + } + }); + + it("should handle concurrent error scenarios", async () => { + async function failingTask(id: number, delay: number): Promise { + await new Promise(resolve => setTimeout(resolve, delay)); + throw new APIError(`Task ${id} failed`); + } + + const tasks = [failingTask(1, 10), failingTask(2, 20), failingTask(3, 5)]; + + // Test Promise.allSettled behavior with custom errors + const results = await Promise.allSettled(tasks); + + expect(results).toHaveLength(3); + results.forEach((result, index) => { + expect(result.status).toBe("rejected"); + if (result.status === "rejected") { + expect(result.reason).toBeInstanceOf(APIError); + expect(result.reason.message).toContain(`Task ${index + 1} failed`); + } + }); + }); + }); + + describe("error serialization and debugging", () => { + it("should provide useful toString representation", () => { + const error = new ValidationError("Invalid endpoint URL"); + const stringRep = error.toString(); + + expect(stringRep).toContain("ValidationError"); + expect(stringRep).toContain("Invalid endpoint URL"); + }); + + it("should be JSON serializable for logging", () => { + const error = new APIError("Connection timeout", 408); + + const serialized = JSON.stringify({ + name: error.name, + message: error.message, + statusCode: error.statusCode, + stack: error.stack, + }); + + const parsed = JSON.parse(serialized); + expect(parsed.name).toBe("APIError"); + expect(parsed.message).toBe("Connection timeout"); + expect(parsed.statusCode).toBe(408); + expect(parsed.stack).toBeDefined(); + }); + + it("should maintain error information across JSON serialization", () => { + const originalError = new ValidationError("Invalid feed ID format"); + + // Simulate logging/serialization + const errorInfo = { + type: originalError.constructor.name, + name: originalError.name, + message: originalError.message, + timestamp: new Date().toISOString(), + }; + + const serialized = JSON.stringify(errorInfo); + const deserialized = JSON.parse(serialized); + + expect(deserialized.type).toBe("ValidationError"); + expect(deserialized.name).toBe("ValidationError"); + expect(deserialized.message).toBe("Invalid feed ID format"); + expect(deserialized.timestamp).toBeDefined(); + }); + }); + + describe("Documentation Test: error handling best practices", () => { + // These tests demonstrate recommended error handling patterns + it("should demonstrate error filtering and categorization patterns", () => { + const errors = [ + new ValidationError("Missing API key"), + new APIError("Connection timeout"), + new ValidationError("Invalid feed ID"), + new AuthenticationError("Expired token"), + new ReportDecodingError("Malformed report"), + ]; + + const userErrors = errors.filter(error => error instanceof ValidationError); + + const systemErrors = errors.filter(error => error instanceof APIError || error instanceof ReportDecodingError); + + const securityErrors = errors.filter(error => error instanceof AuthenticationError); + + expect(userErrors).toHaveLength(2); + expect(systemErrors).toHaveLength(2); + expect(securityErrors).toHaveLength(1); + }); + + it("should demonstrate error severity classification patterns", () => { + function getErrorSeverity(error: DataStreamsError): "low" | "medium" | "high" | "critical" { + if (error instanceof ValidationError) { + return "medium"; + } + if (error instanceof AuthenticationError) { + return "high"; + } + if (error instanceof APIError || error instanceof ReportDecodingError) { + return "low"; + } + if (error instanceof InsufficientConnectionsError) { + return "critical"; + } + return "medium"; + } + + const testCases = [ + { error: new ValidationError("test"), expectedSeverity: "medium" }, + { error: new AuthenticationError("test"), expectedSeverity: "high" }, + { error: new APIError("test"), expectedSeverity: "low" }, + { error: new InsufficientConnectionsError("test", 0, 1), expectedSeverity: "critical" }, + ]; + + testCases.forEach(({ error, expectedSeverity }) => { + expect(getErrorSeverity(error)).toBe(expectedSeverity); + }); + }); + + it("should support error aggregation for batch operations", () => { + const errors = [ + new ValidationError("Feed ID 1 invalid"), + new ValidationError("Feed ID 2 invalid"), + new APIError("Connection failed"), + ]; + + class BatchError extends DataStreamsError { + constructor(public readonly errors: DataStreamsError[]) { + super(`Batch operation failed with ${errors.length} errors`); + this.name = "BatchError"; + } + } + + const batchError = new BatchError(errors); + + expect(batchError.errors).toHaveLength(3); + expect(batchError.message).toContain("3 errors"); + expect(batchError.errors[0]).toBeInstanceOf(ValidationError); + expect(batchError.errors[2]).toBeInstanceOf(APIError); + }); + }); +}); diff --git a/typescript/tests/features/ha-client-features.test.ts b/typescript/tests/features/ha-client-features.test.ts new file mode 100644 index 0000000..a03322a --- /dev/null +++ b/typescript/tests/features/ha-client-features.test.ts @@ -0,0 +1,415 @@ +/** + * Unit Tests for HA Client Features + * + * These tests validate HA-specific client functionality: + * - createStream method with HA configuration + * - validateHAConfiguration method + * - Enhanced error messages for HA failures + * - Event-driven architecture integration + * + * Requirements: + * - Unit tests with mocked dependencies + * - No actual network connections + * - Focus on HA client configuration and integration + */ + +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import { DataStreamsClientImpl } from "../../src/client/implementation"; +import { Config } from "../../src/types/client"; +import { OriginDiscoveryError, InsufficientConnectionsError } from "../../src/types/errors"; + +// Mock console methods to avoid noise during tests +const originalConsoleWarn = console.warn; +const originalConsoleError = console.error; + +describe("HA Client Features Tests", () => { + let mockConfig: Config; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock console methods + console.warn = jest.fn(); + console.error = jest.fn(); + + mockConfig = { + apiKey: "test_key", + userSecret: "test_secret", + endpoint: "https://api.example.com", + wsEndpoint: "wss://ws.example.com", + }; + }); + + afterEach(() => { + // Restore console methods + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + }); + + describe("validateHAConfiguration", () => { + it("should validate HA mode with single origin without forced logging", () => { + const haConfig: Config = { + ...mockConfig, + wsEndpoint: "wss://single-origin.example.com", + haMode: true, + }; + + // Should not throw and should NOT log anything (developers control logging) + expect(() => new DataStreamsClientImpl(haConfig)).not.toThrow(); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("should validate HA mode with multiple origins without warning", () => { + const haConfig: Config = { + ...mockConfig, + wsEndpoint: "wss://origin1.example.com,wss://origin2.example.com", + haMode: true, + }; + + expect(() => new DataStreamsClientImpl(haConfig)).not.toThrow(); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("should validate low HA connection timeout without forced logging", () => { + const haConfig: Config = { + ...mockConfig, + wsEndpoint: "wss://origin1.example.com,wss://origin2.example.com", + haMode: true, + haConnectionTimeout: 500, // Very low timeout + }; + + // Should not throw and should NOT log anything (developers control logging) + expect(() => new DataStreamsClientImpl(haConfig)).not.toThrow(); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("should throw error for HA mode with no WebSocket endpoints", () => { + const haConfig: Config = { + ...mockConfig, + wsEndpoint: "", + haMode: true, + }; + + // Base validation catches empty wsEndpoint first + expect(() => new DataStreamsClientImpl(haConfig)).toThrow("wsEndpoint cannot be empty"); + }); + + it("should throw error for invalid WebSocket URL format", () => { + const haConfig: Config = { + ...mockConfig, + wsEndpoint: "https://invalid-protocol.example.com,wss://valid.example.com", + haMode: true, + }; + + // Base validation catches invalid protocol first + expect(() => new DataStreamsClientImpl(haConfig)).toThrow( + "wsEndpoint must use one of these protocols: ws:, wss:" + ); + }); + + it("should throw HA-specific error when URLs are parsed but empty", () => { + // This tests the actual HA validation logic by bypassing base validation + const haConfig: Config = { + ...mockConfig, + wsEndpoint: "wss://valid.example.com", // Valid URL that passes base validation + haMode: true, + }; + + // Mock parseOrigins to return empty array to trigger HA-specific validation + const client = new DataStreamsClientImpl(haConfig); + const parseOriginsSpy = jest.spyOn(client as any, "parseOrigins"); + parseOriginsSpy.mockReturnValue([]); + + expect(() => { + // Call validateHAConfiguration directly to test HA-specific logic + (client as any).validateHAConfiguration({ ...haConfig, wsEndpoint: "wss://valid.example.com" }); + }).toThrow("HA mode enabled but no WebSocket endpoints provided"); + }); + + it("should validate comma-separated URLs correctly", () => { + const haConfig: Config = { + ...mockConfig, + wsEndpoint: "wss://origin1.example.com, wss://origin2.example.com , wss://origin3.example.com", + haMode: true, + }; + + expect(() => new DataStreamsClientImpl(haConfig)).not.toThrow(); + }); + + it("should handle mixed ws and wss protocols", () => { + const haConfig: Config = { + ...mockConfig, + wsEndpoint: "ws://localhost:8080,wss://production.example.com", + haMode: true, + }; + + expect(() => new DataStreamsClientImpl(haConfig)).not.toThrow(); + }); + + it("should warn appropriately when origin discovery is enabled with single origin", () => { + const haConfig: Config = { + ...mockConfig, + wsEndpoint: "wss://single-origin.example.com", + haMode: true, + }; + + expect(() => new DataStreamsClientImpl(haConfig)).not.toThrow(); + expect(console.warn).not.toHaveBeenCalledWith( + expect.stringContaining("HA mode enabled but only one origin provided") + ); + }); + }); + + describe("createStream method with HA features", () => { + it("should exist and have correct signature", () => { + const client = new DataStreamsClientImpl(mockConfig); + + expect(typeof client.createStream).toBe("function"); + expect(client.createStream.length).toBe(2); // feedIds, options + }); + + it("should create stream with proper configuration", () => { + const client = new DataStreamsClientImpl(mockConfig); + const feedIds = ["0x0003" + "1".repeat(60)]; + + // Mock Stream constructor to capture configuration + const StreamConstructorSpy = jest.spyOn(require("../../src/stream"), "Stream"); + const mockStream = { + on: jest.fn().mockReturnThis(), + connect: jest.fn().mockImplementation(() => Promise.resolve()), + close: jest.fn().mockImplementation(() => Promise.resolve()), + getMetrics: jest.fn().mockReturnValue({}), + getConnectionType: jest.fn().mockReturnValue("single"), + getOrigins: jest.fn().mockReturnValue([]), + }; + StreamConstructorSpy.mockImplementation((..._args: any[]) => mockStream as any); + + try { + const stream = client.createStream(feedIds, { + maxReconnectAttempts: 10, + reconnectInterval: 5000, + }); + + // Verify Stream was created with correct parameters + expect(StreamConstructorSpy).toHaveBeenCalledWith(mockConfig, feedIds, { + maxReconnectAttempts: 10, + reconnectInterval: 5000, + }); + + expect(stream).toBe(mockStream); + } finally { + StreamConstructorSpy.mockRestore(); + } + }); + + it("should work with connection status callback in config", () => { + const statusCallback = jest.fn(); + const haConfig: Config = { + ...mockConfig, + haMode: true, + connectionStatusCallback: statusCallback, + }; + + const client = new DataStreamsClientImpl(haConfig); + const feedIds = ["0x0003" + "1".repeat(60)]; + + // Mock Stream constructor + const StreamConstructorSpy = jest.spyOn(require("../../src/stream"), "Stream"); + const mockStream = { + on: jest.fn().mockReturnThis(), + connect: jest.fn().mockImplementation(() => Promise.resolve()), + close: jest.fn().mockImplementation(() => Promise.resolve()), + getMetrics: jest.fn().mockReturnValue({}), + getConnectionType: jest.fn().mockReturnValue("multiple"), + getOrigins: jest.fn().mockReturnValue(["wss://ws.example.com"]), + }; + StreamConstructorSpy.mockImplementation((..._args: any[]) => mockStream as any); + + try { + client.createStream(feedIds); + + // Verify config includes the status callback + const passedConfig = StreamConstructorSpy.mock.calls[0][0] as Config; + expect(passedConfig.connectionStatusCallback).toBe(statusCallback); + expect(passedConfig.haMode).toBe(true); + } finally { + StreamConstructorSpy.mockRestore(); + } + }); + }); + + describe("event-driven error handling", () => { + it("should handle OriginDiscoveryError through stream events", async () => { + const client = new DataStreamsClientImpl(mockConfig); + + // Create a mock stream that emits error events + const mockStream = { + on: jest.fn(), + connect: jest.fn().mockImplementation(async () => { + throw new OriginDiscoveryError("Discovery failed", new Error("Network timeout")); + }), + close: jest.fn(), + getConnectionType: jest.fn().mockReturnValue("single"), + getOrigins: jest.fn().mockReturnValue([]), + getMetrics: jest.fn().mockReturnValue({}), + }; + + // Mock Stream constructor + const StreamConstructorSpy = jest.spyOn(require("../../src/stream"), "Stream"); + StreamConstructorSpy.mockImplementation((..._args: any[]) => mockStream as any); + + const feedIds = ["0x0003" + "1".repeat(60)]; + + try { + const stream = client.createStream(feedIds); + + // Test that errors can be handled via events + const errorHandler = jest.fn(); + stream.on("error", errorHandler); + + // When connecting fails, developers can handle the error via events + await expect(stream.connect()).rejects.toThrow(OriginDiscoveryError); + + // Verify the stream object was created correctly + expect(stream).toBe(mockStream); + expect(mockStream.on).toHaveBeenCalledWith("error", errorHandler); + } finally { + StreamConstructorSpy.mockRestore(); + } + }); + + it("should handle InsufficientConnectionsError through stream events", async () => { + const client = new DataStreamsClientImpl(mockConfig); + + // Mock Stream that throws InsufficientConnectionsError + const mockStream = { + on: jest.fn(), + connect: jest.fn().mockImplementation(async () => { + throw new InsufficientConnectionsError("No connections", 0, 2); + }), + close: jest.fn(), + getConnectionType: jest.fn().mockReturnValue("single"), + getOrigins: jest.fn().mockReturnValue([]), + getMetrics: jest.fn().mockReturnValue({}), + }; + + // Mock Stream constructor + const StreamConstructorSpy = jest.spyOn(require("../../src/stream"), "Stream"); + StreamConstructorSpy.mockImplementation((..._args: any[]) => mockStream as any); + + const feedIds = ["0x0003" + "1".repeat(60)]; + + try { + const stream = client.createStream(feedIds); + + // Test event-driven error handling + const errorHandler = jest.fn(); + stream.on("error", errorHandler); + + await expect(stream.connect()).rejects.toThrow(InsufficientConnectionsError); + + // Verify event handlers can be attached + expect(mockStream.on).toHaveBeenCalledWith("error", errorHandler); + } finally { + StreamConstructorSpy.mockRestore(); + } + }); + + it("should support all event types for production monitoring", () => { + const client = new DataStreamsClientImpl(mockConfig); + + // Mock complete stream with all event capabilities + const mockStream = { + on: jest.fn().mockReturnThis(), + connect: jest.fn().mockImplementation(() => Promise.resolve()), + close: jest.fn().mockImplementation(() => Promise.resolve()), + getMetrics: jest.fn().mockReturnValue({ + accepted: 100, + deduplicated: 10, + activeConnections: 2, + configuredConnections: 2, + }), + getConnectionType: jest.fn().mockReturnValue("multiple"), + getOrigins: jest.fn().mockReturnValue(["wss://origin1.example.com", "wss://origin2.example.com"]), + }; + + const StreamConstructorSpy = jest.spyOn(require("../../src/stream"), "Stream"); + StreamConstructorSpy.mockImplementation((..._args: any[]) => mockStream as any); + + try { + const feedIds = ["0x0003" + "1".repeat(60)]; + const stream = client.createStream(feedIds); + + // Test all event types for production monitoring + const reportHandler = jest.fn(); + const errorHandler = jest.fn(); + const disconnectedHandler = jest.fn(); + const reconnectingHandler = jest.fn(); + + stream.on("report", reportHandler); + stream.on("error", errorHandler); + stream.on("disconnected", disconnectedHandler); + stream.on("reconnecting", reconnectingHandler); + + // Verify all event handlers were registered + expect(mockStream.on).toHaveBeenCalledWith("report", reportHandler); + expect(mockStream.on).toHaveBeenCalledWith("error", errorHandler); + expect(mockStream.on).toHaveBeenCalledWith("disconnected", disconnectedHandler); + expect(mockStream.on).toHaveBeenCalledWith("reconnecting", reconnectingHandler); + + // Verify stream statistics are accessible + const stats = stream.getMetrics(); + expect(stats.accepted).toBe(100); + expect(stats.activeConnections).toBe(2); + } finally { + StreamConstructorSpy.mockRestore(); + } + }); + }); + + describe("parseOrigins method", () => { + it("should correctly parse comma-separated URLs", () => { + const client = new DataStreamsClientImpl(mockConfig); + + // Access private method for testing + const parseOrigins = (client as any).parseOrigins; + + const result = parseOrigins("wss://origin1.example.com,wss://origin2.example.com,wss://origin3.example.com"); + expect(result).toEqual(["wss://origin1.example.com", "wss://origin2.example.com", "wss://origin3.example.com"]); + }); + + it("should handle URLs with spaces", () => { + const client = new DataStreamsClientImpl(mockConfig); + const parseOrigins = (client as any).parseOrigins; + + const result = parseOrigins( + " wss://origin1.example.com , wss://origin2.example.com , wss://origin3.example.com " + ); + expect(result).toEqual(["wss://origin1.example.com", "wss://origin2.example.com", "wss://origin3.example.com"]); + }); + + it("should filter out empty URLs", () => { + const client = new DataStreamsClientImpl(mockConfig); + const parseOrigins = (client as any).parseOrigins; + + const result = parseOrigins("wss://origin1.example.com,,wss://origin3.example.com,"); + expect(result).toEqual(["wss://origin1.example.com", "wss://origin3.example.com"]); + }); + + it("should handle single URL", () => { + const client = new DataStreamsClientImpl(mockConfig); + const parseOrigins = (client as any).parseOrigins; + + const result = parseOrigins("wss://single-origin.example.com"); + expect(result).toEqual(["wss://single-origin.example.com"]); + }); + + it("should return empty array for empty string", () => { + const client = new DataStreamsClientImpl(mockConfig); + const parseOrigins = (client as any).parseOrigins; + + const result = parseOrigins(""); + expect(result).toEqual([]); + }); + }); +}); diff --git a/typescript/tests/integration/client.integration.test.ts b/typescript/tests/integration/client.integration.test.ts new file mode 100644 index 0000000..043c552 --- /dev/null +++ b/typescript/tests/integration/client.integration.test.ts @@ -0,0 +1,873 @@ +/** + * Integration Tests for DataStreams Client + * + * These tests validate the DataStreams client API functionality by: + * - Testing REST API methods (listFeeds, getLatestReport, etc.) + * - Verifying error handling for API responses + * - Validating retry behavior for failed requests + * - Testing comprehensive HTTP error scenarios (4xx, 5xx) + * - Testing network timeout simulation + * - Testing malformed response handling + * - Testing rate limiting scenarios + * - Testing request/response header validation + * - Testing authentication failure scenarios + * + * Requirements: + * - Uses Jest mocks to simulate API responses + * - No actual network connections are made + * - Tests multiple components working together (client, request handling, decoding) + */ + +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import { createClient, DataStreamsClient, decodeReport } from "../../src"; +import { Config } from "../../src/types/client"; +import { AbiCoder } from "ethers"; + +// Create a properly encoded full report for testing +const abiCoder = new AbiCoder(); + +const mockReportContext = [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x0000000000000000000000000000000000000000000000000000000000000003", +]; + +const FULL_REPORT = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + "0x0006f9b553e393ced311551efd30d1decedb63d76ad41737462e2cdbbdff157800000000000000000000000000000000000000000000000000000000351f200b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000028000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000120000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba7820000000000000000000000000000000000000000000000000000000066aa78ab0000000000000000000000000000000000000000000000000000000066aa78ab00000000000000000000000000000000000000000000000000001b6732178a04000000000000000000000000000000000000000000000000001b1e8f8f0dc6880000000000000000000000000000000000000000000000000000000066abca2b0000000000000000000000000000000000000000000000b3eba5491849628aa00000000000000000000000000000000000000000000000b3eaf356fc42b6f6c00000000000000000000000000000000000000000000000b3ecd20810b9d1c0", + ["0x0000000000000000000000000000000000000000000000000000000000000004"], + ["0x0000000000000000000000000000000000000000000000000000000000000005"], + "0x0000000000000000000000000000000000000000000000000000000000000006", + ] +); + +// Test Feed IDs for different schema versions +const TEST_FEED_IDS = { + V3: "0x0003" + "1".repeat(60), + V8: "0x0008" + "1".repeat(60), + V9: "0x0009" + "1".repeat(60), + V10: "0x000a" + "1".repeat(60), +} as const; + +describe("DataStreams Client", () => { + let client: DataStreamsClient; + const mockConfig: Config = { + apiKey: "test_key", + userSecret: "test_secret", + endpoint: "http://api.example.com", + wsEndpoint: "ws://ws.example.com", + }; + + beforeEach(() => { + client = createClient(mockConfig); + // Clear all fetch mocks before each test + jest.clearAllMocks(); + }); + + // Add type for fetch mock + type FetchMock = jest.Mock; + + describe("listFeeds", () => { + it("should successfully list feeds", async () => { + const mockFeeds = [ + { feedID: "0x0003" + "1".repeat(60), name: "ETH/USD" }, + { feedID: "0x0003" + "2".repeat(60), name: "BTC/USD" }, + ]; + + // Mock the fetch response + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ feeds: mockFeeds }), + } as Response) + ) as FetchMock; + + const feeds = await client.listFeeds(); + expect(feeds).toEqual(mockFeeds); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/api/v1/feeds"), expect.any(Object)); + }); + + it("should handle API errors", async () => { + // Mock the fetch response for error case + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 400, + statusText: "API Error", + json: () => Promise.resolve({ error: "API Error" }), + } as Response) + ) as FetchMock; + + await expect(client.listFeeds()).rejects.toThrow("API Error"); + }); + }); + + describe("getLatestReport", () => { + const mockFeedId = "0x0003" + "1".repeat(60); + + it("should fetch latest report", async () => { + const mockReport = { + feedID: mockFeedId, + validFromTimestamp: Date.now(), + observationsTimestamp: Date.now(), + fullReport: FULL_REPORT, + }; + + // Mock the fetch response + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ report: mockReport }), + } as Response) + ) as FetchMock; + + const report = await client.getLatestReport(mockFeedId); + expect(report).toBeDefined(); + expect(report.feedID).toBe(mockFeedId); + expect(typeof report.fullReport).toBe("string"); + expect(typeof report.validFromTimestamp).toBe("number"); + expect(typeof report.observationsTimestamp).toBe("number"); + + // Decode the report to check decoded fields + const decodedReport = decodeReport(report.fullReport, report.feedID); + expect(decodedReport.version).toBe("V3"); + expect(typeof decodedReport.nativeFee).toBe("bigint"); + expect(typeof decodedReport.linkFee).toBe("bigint"); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/api/v1/reports/latest"), expect.any(Object)); + }); + + it("should handle missing feed", async () => { + // Mock the fetch response for error case + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 404, + statusText: "Feed not found", + json: () => Promise.resolve({ error: "Feed not found" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Feed not found"); + }); + }); + + describe("getReportByTimestamp", () => { + const mockFeedId = "0x0003" + "1".repeat(60); + const timestamp = Date.now(); + + it("should fetch report by timestamp", async () => { + const mockReport = { + feedID: mockFeedId, + validFromTimestamp: timestamp, + observationsTimestamp: timestamp, + fullReport: FULL_REPORT, + }; + + // Mock the fetch response + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ report: mockReport }), + } as Response) + ) as FetchMock; + + const report = await client.getReportByTimestamp(mockFeedId, timestamp); + expect(report).toBeDefined(); + expect(report.feedID).toBe(mockFeedId); + expect(typeof report.fullReport).toBe("string"); + + // Decode the report to check decoded fields + const decodedReport = decodeReport(report.fullReport, report.feedID); + expect(decodedReport.version).toBe("V3"); + expect(typeof decodedReport.nativeFee).toBe("bigint"); + expect(typeof decodedReport.linkFee).toBe("bigint"); + // V3 reports have 'price' property when decoded + expect(typeof (decodedReport as { price?: bigint }).price).toBe("bigint"); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/api/v1/reports"), expect.any(Object)); + }); + + it("should handle missing feed", async () => { + // Mock the fetch response for error case + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 404, + statusText: "Feed not found", + json: () => Promise.resolve({ error: "Feed not found" }), + } as Response) + ) as FetchMock; + + await expect(client.getReportByTimestamp(mockFeedId, timestamp)).rejects.toThrow("Feed not found"); + }); + }); + + describe("getReportsBulk", () => { + const mockFeedId1 = "0x0003" + "1".repeat(60); + const mockFeedId2 = "0x0003" + "2".repeat(60); + const timestamp = Date.now(); + + it("should fetch bulk reports for multiple feeds", async () => { + const mockReports = [ + { + feedID: mockFeedId1, + validFromTimestamp: timestamp, + observationsTimestamp: timestamp, + fullReport: FULL_REPORT, + }, + { + feedID: mockFeedId2, + validFromTimestamp: timestamp, + observationsTimestamp: timestamp, + fullReport: FULL_REPORT, + }, + ]; + + // Mock the fetch response + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ reports: mockReports }), + } as Response) + ) as FetchMock; + + const reports = await client.getReportsBulk([mockFeedId1, mockFeedId2], timestamp); + expect(reports).toHaveLength(2); + + // Check that both expected feed IDs are present in the response (order independent) + const returnedFeedIds = reports.map(r => r.feedID); + expect(returnedFeedIds).toContain(mockFeedId1); + expect(returnedFeedIds).toContain(mockFeedId2); + // Decode and check version of each report + expect( + reports.every(r => { + const decoded = decodeReport(r.fullReport, r.feedID); + return decoded.version === "V3"; + }) + ).toBe(true); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining(`/api/v1/reports/bulk?feedIDs=${mockFeedId1},${mockFeedId2}×tamp=${timestamp}`), + expect.any(Object) + ); + }); + + it("should fetch bulk reports for single feed", async () => { + const mockReports = [ + { + feedID: mockFeedId1, + validFromTimestamp: timestamp, + observationsTimestamp: timestamp, + fullReport: FULL_REPORT, + }, + ]; + + // Mock the fetch response + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ reports: mockReports }), + } as Response) + ) as FetchMock; + + const reports = await client.getReportsBulk([mockFeedId1], timestamp); + expect(reports).toHaveLength(1); + expect(reports[0].feedID).toBe(mockFeedId1); + // Decode and check version + const decodedFirstReport = decodeReport(reports[0].fullReport, reports[0].feedID); + expect(decodedFirstReport.version).toBe("V3"); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining(`/api/v1/reports/bulk?feedIDs=${mockFeedId1}×tamp=${timestamp}`), + expect.any(Object) + ); + }); + + it("should handle empty feed list validation", async () => { + await expect(client.getReportsBulk([], timestamp)).rejects.toThrow("At least one feed ID is required"); + }); + + it("should handle invalid timestamp", async () => { + await expect(client.getReportsBulk([mockFeedId1], -1)).rejects.toThrow("Timestamp cannot be negative"); + }); + + it("should handle API errors", async () => { + // Mock the fetch response for error case + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 400, + statusText: "Bad Request", + json: () => Promise.resolve({ error: "Bad Request" }), + } as Response) + ) as FetchMock; + + await expect(client.getReportsBulk([mockFeedId1], timestamp)).rejects.toThrow("Bad Request"); + }); + }); + + describe("retry behavior", () => { + it("should retry failed requests", async () => { + // Mock fetch to fail once then succeed + global.fetch = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve({ + ok: false, + status: 500, + statusText: "Temporary error", + json: () => Promise.resolve({ message: "Temporary error" }), + } as Response) + ) + .mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ feeds: [] }), + } as Response) + ) as FetchMock; + + await client.listFeeds(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("should respect max retry attempts", async () => { + // Mock fetch to always fail + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 500, + statusText: "Persistent error", + json: () => Promise.resolve({ message: "Persistent error" }), + } as Response) + ) as FetchMock; + + await expect(client.listFeeds()).rejects.toThrow("Persistent error"); + expect(fetch).toHaveBeenCalledTimes(mockConfig.retryAttempts || 2); + }); + }); + + describe("comprehensive HTTP error handling", () => { + const mockFeedId = "0x0003" + "1".repeat(60); + + describe("4xx client errors", () => { + it("should handle 400 Bad Request", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 400, + statusText: "Bad Request", + json: () => Promise.resolve({ message: "Invalid feed ID format" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Invalid feed ID format"); + }); + + it("should handle 401 Unauthorized", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 401, + statusText: "Unauthorized", + json: () => Promise.resolve({ message: "Invalid API key" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Invalid API key"); + }); + + it("should handle 403 Forbidden", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 403, + statusText: "Forbidden", + json: () => Promise.resolve({ message: "Access denied for this feed" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Access denied for this feed"); + }); + + it("should handle 404 Not Found", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 404, + statusText: "Not Found", + json: () => Promise.resolve({ message: "Feed not found" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Feed not found"); + }); + + it("should handle 429 Too Many Requests", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 429, + statusText: "Too Many Requests", + json: () => Promise.resolve({ message: "Rate limit exceeded" }), + headers: new Headers({ + "Retry-After": "60", + }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Rate limit exceeded"); + }); + }); + + describe("5xx server errors", () => { + it("should handle 500 Internal Server Error", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: () => Promise.resolve({ message: "Database connection failed" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Database connection failed"); + }); + + it("should handle 502 Bad Gateway", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 502, + statusText: "Bad Gateway", + json: () => Promise.resolve({ message: "Upstream server error" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Upstream server error"); + }); + + it("should handle 503 Service Unavailable", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 503, + statusText: "Service Unavailable", + json: () => Promise.resolve({ message: "Service temporarily unavailable" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Service temporarily unavailable"); + }); + + it("should handle 504 Gateway Timeout", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 504, + statusText: "Gateway Timeout", + json: () => Promise.resolve({ message: "Request timeout" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Request timeout"); + }); + }); + }); + + describe("network timeout simulation", () => { + const mockFeedId = "0x0003" + "1".repeat(60); + + it("should handle network timeouts", async () => { + global.fetch = jest.fn( + () => + new Promise((_, reject) => { + setTimeout(() => reject(new Error("Network timeout")), 100); + }) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Network timeout"); + }); + + it("should handle connection refused", async () => { + global.fetch = jest.fn(() => Promise.reject(new Error("Connection refused"))) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Connection refused"); + }); + + it("should handle DNS resolution failures", async () => { + global.fetch = jest.fn(() => Promise.reject(new Error("DNS resolution failed"))) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("DNS resolution failed"); + }); + }); + + describe("malformed response handling", () => { + const mockFeedId = "0x0003" + "1".repeat(60); + + it("should handle invalid JSON responses", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.reject(new Error("Invalid JSON")), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Invalid JSON"); + }); + + it("should handle missing required fields in response", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + /* missing report field */ + }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow(); + }); + + it("should handle empty response body", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(null), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow(); + }); + + it("should handle non-object response", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve("not an object"), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow(); + }); + }); + + describe("rate limiting scenarios", () => { + const mockFeedId = "0x0003" + "1".repeat(60); + + it("should handle rate limiting with Retry-After header", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 429, + statusText: "Too Many Requests", + json: () => Promise.resolve({ message: "Rate limit exceeded. Try again later." }), + headers: new Headers({ + "Retry-After": "120", + "X-RateLimit-Limit": "1000", + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": String(Date.now() + 120000), + }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Rate limit exceeded. Try again later."); + }); + + it("should handle burst rate limiting", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 429, + statusText: "Too Many Requests", + json: () => Promise.resolve({ message: "Burst rate limit exceeded" }), + headers: new Headers({ + "X-RateLimit-Type": "burst", + "Retry-After": "10", + }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Burst rate limit exceeded"); + }); + + it("should handle monthly quota exceeded", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 429, + statusText: "Too Many Requests", + json: () => Promise.resolve({ message: "Monthly quota exceeded" }), + headers: new Headers({ + "X-RateLimit-Type": "quota", + "X-Quota-Remaining": "0", + }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Monthly quota exceeded"); + }); + }); + + describe("request/response header validation", () => { + const mockFeedId = "0x0003" + "1".repeat(60); + + it("should include required authentication headers", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + report: { + feedID: mockFeedId, + validFromTimestamp: Date.now(), + observationsTimestamp: Date.now(), + fullReport: FULL_REPORT, + }, + }), + } as Response) + ) as FetchMock; + + await client.getLatestReport(mockFeedId); + + // Check that fetch was called + expect(fetch).toHaveBeenCalledTimes(1); + + // Get the actual call arguments + const [url, options] = (fetch as jest.Mock).mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/api/v1/reports/latest"); + + // Check that headers object exists and is a Headers instance + expect(options.headers).toBeInstanceOf(Headers); + + // Check specific headers by calling .get() method + const headers = options.headers as Headers; + expect(headers.get("Authorization")).toBeTruthy(); + expect(headers.get("X-Authorization-Timestamp")).toBeTruthy(); + expect(headers.get("X-Authorization-Signature-SHA256")).toBeTruthy(); + expect(headers.get("Content-Type")).toBe("application/json"); + }); + + it("should include content-type headers for requests", async () => { + const timestamp = Date.now(); + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ reports: [] }), + } as Response) + ) as FetchMock; + + await client.getReportsBulk([mockFeedId], timestamp); + + // Check that fetch was called + expect(fetch).toHaveBeenCalledTimes(1); + + // Get the actual call arguments + const [url, options] = (fetch as jest.Mock).mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/api/v1/reports/bulk"); + + // Check that headers object exists and has Content-Type + expect(options.headers).toBeInstanceOf(Headers); + const headers = options.headers as Headers; + expect(headers.get("Content-Type")).toBe("application/json"); + }); + + it("should handle missing authentication headers error", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 401, + statusText: "Unauthorized", + json: () => Promise.resolve({ message: "Missing authentication headers" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Missing authentication headers"); + }); + + it("should handle invalid signature error", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 401, + statusText: "Unauthorized", + json: () => Promise.resolve({ message: "Invalid signature" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Invalid signature"); + }); + }); + + describe("authentication failure scenarios", () => { + const mockFeedId = "0x0003" + "1".repeat(60); + + it("should handle expired API key", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 401, + statusText: "Unauthorized", + json: () => Promise.resolve({ message: "API key expired" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("API key expired"); + }); + + it("should handle revoked API key", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 403, + statusText: "Forbidden", + json: () => Promise.resolve({ message: "API key revoked" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("API key revoked"); + }); + + it("should handle timestamp skew error", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 401, + statusText: "Unauthorized", + json: () => Promise.resolve({ message: "Request timestamp too old" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Request timestamp too old"); + }); + + it("should handle malformed signature", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 400, + statusText: "Bad Request", + json: () => Promise.resolve({ message: "Malformed signature format" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Malformed signature format"); + }); + + it("should handle insufficient permissions", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 403, + statusText: "Forbidden", + json: () => Promise.resolve({ message: "Insufficient permissions for this operation" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Insufficient permissions for this operation"); + }); + + it("should handle account suspended", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 403, + statusText: "Forbidden", + json: () => Promise.resolve({ message: "Account suspended" }), + } as Response) + ) as FetchMock; + + await expect(client.getLatestReport(mockFeedId)).rejects.toThrow("Account suspended"); + }); + }); + + describe("Schema Version Compatibility", () => { + it("should handle all supported schema versions (V2, V3, V4, V8, V9, V10)", async () => { + // Test that client can process feeds with different schema versions + const schemaTests = [ + { feedId: TEST_FEED_IDS.V3 }, + { feedId: TEST_FEED_IDS.V8 }, + { feedId: TEST_FEED_IDS.V9 }, + { feedId: TEST_FEED_IDS.V10 }, + ]; + + for (const { feedId } of schemaTests) { + const mockReport = { + feedID: feedId, + validFromTimestamp: Date.now(), + observationsTimestamp: Date.now(), + fullReport: FULL_REPORT, + }; + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ report: mockReport }), + } as Response) + ) as FetchMock; + + try { + const report = await client.getLatestReport(feedId); + expect(report).toBeDefined(); + expect(report.feedID).toBe(feedId); + // Decode the report to check decoded fields + const decodedReport = decodeReport(report.fullReport, report.feedID); + expect(typeof decodedReport.nativeFee).toBe("bigint"); + expect(typeof decodedReport.linkFee).toBe("bigint"); + } catch (error) { + // This is expected since we're using the same report blob for all tests + // The important thing is that the validation accepts the feed IDs + expect(error).toBeInstanceOf(Error); + } + } + }); + + it("should support mixed schema versions in bulk operations", async () => { + const mixedFeedIds = [TEST_FEED_IDS.V3, TEST_FEED_IDS.V8, TEST_FEED_IDS.V9, TEST_FEED_IDS.V10]; + const timestamp = Math.floor(Date.now() / 1000); + + const mockReports = mixedFeedIds.map(feedId => ({ + feedID: feedId, + validFromTimestamp: timestamp, + observationsTimestamp: timestamp, + fullReport: FULL_REPORT, + })); + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ reports: mockReports }), + } as Response) + ) as FetchMock; + + try { + const reports = await client.getReportsBulk(mixedFeedIds, timestamp); + expect(Array.isArray(reports)).toBe(true); + expect(reports.length).toBe(mixedFeedIds.length); + + // Verify all feed IDs are present + const returnedFeedIds = reports.map(r => r.feedID); + mixedFeedIds.forEach(feedId => { + expect(returnedFeedIds).toContain(feedId); + }); + } catch (error) { + // Expected due to mock report blob compatibility + expect(error).toBeInstanceOf(Error); + } + }); + }); +}); diff --git a/typescript/tests/integration/context-cancellation.test.ts b/typescript/tests/integration/context-cancellation.test.ts new file mode 100644 index 0000000..b4b111b --- /dev/null +++ b/typescript/tests/integration/context-cancellation.test.ts @@ -0,0 +1,233 @@ +/** + * Integration Tests for Context Cancellation + * + * Tests proper handling of cancelled operations for both REST and streaming APIs. + * Validates graceful shutdown and resource cleanup when operations are aborted. + */ + +import { describe, it, expect, beforeEach, afterEach } from "@jest/globals"; +import { createClient, DataStreamsClient } from "../../src"; +import type { IStream } from "../../src/types/client"; +import { MockWebSocketServer } from "../utils/mockWebSocketServer"; + +describe("Context Cancellation Integration Tests", () => { + let mockServer: MockWebSocketServer; + let client: DataStreamsClient; + let stream: IStream | null = null; + + beforeEach(async () => { + mockServer = new MockWebSocketServer(); + await mockServer.waitForReady(); + const serverAddress = mockServer.getAddress(); + + client = createClient({ + apiKey: "test-api-key", + userSecret: "test-user-secret", + endpoint: `http://${serverAddress}`, + wsEndpoint: `ws://${serverAddress}`, + logging: { + logger: { + info: () => {}, + error: () => {}, + debug: () => {}, + warn: () => {}, + }, + }, + }); + }); + + afterEach(async () => { + if (stream) { + await stream.close(); + stream = null; + } + await mockServer.close(); + }); + + describe("Stream connection cancellation", () => { + it("should handle cancellation during connection establishment", async () => { + // Create stream but don't await connection + stream = client.createStream(["0x0003" + "1".repeat(60)]); + + // Start connection process + const connectionPromise = stream.connect(); + + // Cancel immediately - this simulates context cancellation during connection + await stream.close(); + + // Connection should be aborted cleanly + await expect(connectionPromise).rejects.toThrow(); + + // Stream should be in closed state + const metrics = stream.getMetrics(); + expect(metrics.activeConnections).toBe(0); + }, 10000); + + it("should handle cancellation after partial connection establishment", async () => { + stream = client.createStream(["0x0003" + "1".repeat(60)]); + + // Let connection start + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verify connection was established + const initialMetrics = stream.getMetrics(); + expect(initialMetrics.activeConnections).toBeGreaterThan(0); + + // Cancel the connection + await stream.close(); + + // Verify clean shutdown + const finalMetrics = stream.getMetrics(); + expect(finalMetrics.activeConnections).toBe(0); + }, 10000); + }); + + describe("Stream operation cancellation", () => { + it("should stop receiving reports after stream closure", async () => { + stream = client.createStream(["0x0003" + "1".repeat(60)]); + + let reportCount = 0; + stream.on("report", () => { + reportCount++; + }); + + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Send some test reports + const mockReport = { + report: { + feedID: "0x0003" + "1".repeat(60), + fullReport: "0x0002" + "01020304" + "42".repeat(100), + validFromTimestamp: Date.now(), + observationsTimestamp: Date.now(), + }, + }; + + await mockServer.broadcast(Buffer.from(JSON.stringify(mockReport))); + await new Promise(resolve => setTimeout(resolve, 200)); + + const reportsBeforeClose = reportCount; + expect(reportsBeforeClose).toBeGreaterThan(0); + + // Close the stream + await stream.close(); + + // Send more reports - these should not be received + await mockServer.broadcast(Buffer.from(JSON.stringify(mockReport))); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Report count should not increase after closure + expect(reportCount).toBe(reportsBeforeClose); + }, 15000); + + it("should handle rapid connect/disconnect cycles gracefully", async () => { + stream = client.createStream(["0x0003" + "1".repeat(60)]); + + // Rapid connect/disconnect cycle + for (let i = 0; i < 3; i++) { + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 50)); + await stream.close(); + await new Promise(resolve => setTimeout(resolve, 50)); + } + + // Final state should be disconnected + const metrics = stream.getMetrics(); + expect(metrics.activeConnections).toBe(0); + }, 15000); + }); + + describe("Error handling during cancellation", () => { + it("should handle cancellation when server is unavailable", async () => { + // Close the mock server to simulate unavailable server + await mockServer.close(); + + stream = client.createStream(["0x0003" + "1".repeat(60)]); + + // Try to connect to unavailable server + const connectionPromise = stream.connect(); + + // Cancel during failed connection attempt + setTimeout(() => stream?.close(), 100); + + // Should handle cancellation gracefully even with connection failure + await expect(connectionPromise).rejects.toThrow(); + + const metrics = stream.getMetrics(); + expect(metrics.activeConnections).toBe(0); + }, 10000); + + it("should not leak resources after cancellation", async () => { + const streams: IStream[] = []; + + // Create multiple streams and cancel them + for (let i = 0; i < 5; i++) { + const testStream = client.createStream(["0x0003" + i.toString().repeat(60)]); + streams.push(testStream); + + // Start connection + const connectPromise = testStream.connect(); + + // Cancel after short delay + setTimeout(() => testStream.close(), 50 + i * 10); + + try { + await connectPromise; + } catch { + // Expected to fail due to cancellation + } + } + + // Wait for all operations to complete + await new Promise(resolve => setTimeout(resolve, 1000)); + + // All streams should be properly closed + for (const testStream of streams) { + const metrics = testStream.getMetrics(); + expect(metrics.activeConnections).toBe(0); + } + }, 15000); + }); + + describe("Event cleanup on cancellation", () => { + it("should stop emitting events after cancellation", async () => { + stream = client.createStream(["0x0003" + "1".repeat(60)]); + + let eventCount = 0; + const events: string[] = []; + + stream.on("report", () => { + eventCount++; + events.push("report"); + }); + + stream.on("error", () => { + eventCount++; + events.push("error"); + }); + + stream.on("disconnected", () => { + eventCount++; + events.push("disconnected"); + }); + + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 100)); + + const eventsBeforeClose = eventCount; + + // Close the stream + await stream.close(); + + // Trigger potential events by closing server + await mockServer.close(); + await new Promise(resolve => setTimeout(resolve, 500)); + + // Event count should not increase significantly after closure + // (disconnected event might be emitted once) + expect(eventCount - eventsBeforeClose).toBeLessThanOrEqual(1); + }, 10000); + }); +}); diff --git a/typescript/tests/integration/ha-deduplication.test.ts b/typescript/tests/integration/ha-deduplication.test.ts new file mode 100644 index 0000000..6b32d02 --- /dev/null +++ b/typescript/tests/integration/ha-deduplication.test.ts @@ -0,0 +1,317 @@ +/** + * Integration tests for HA deduplication functionality + * Tests that duplicate reports are properly filtered when multiple connections receive the same data + */ + +import { createClient } from "../../src/index"; +import { MockWebSocketServer } from "../utils/mockWebSocketServer"; + +describe("HA Deduplication Integration Tests", () => { + let mockServer: MockWebSocketServer; + let serverAddress: string; + const activeStreams: any[] = []; + + beforeEach(async () => { + mockServer = new MockWebSocketServer(); + await mockServer.waitForReady(); + serverAddress = mockServer.getAddress(); + }); + + afterEach(async () => { + // Close all active streams first + for (const stream of activeStreams) { + try { + await stream.close(); + } catch { + // Ignore close errors + } + } + activeStreams.length = 0; // Clear array + + // Then close mock server + await mockServer.close(); + }); + + describe("Report Deduplication", () => { + it("should filter duplicate reports from multiple HA connections", async () => { + // Setup HA configuration with multiple connections + const NUM_CONNECTIONS = 3; + const wsEndpoints = Array(NUM_CONNECTIONS) + .fill(0) + .map(() => `ws://${serverAddress}`) + .join(","); + + const config = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: wsEndpoints, + haMode: true, + }; + + const client = createClient(config); + const stream = client.createStream(["0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8"]); + activeStreams.push(stream); // Track for cleanup + + // Wait for all connections to establish + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 500)); + + // Validate initial connection state + const initialMetrics = stream.getMetrics(); + expect(initialMetrics.configuredConnections).toBe(NUM_CONNECTIONS); + expect(initialMetrics.activeConnections).toBe(NUM_CONNECTIONS); + + // Create a mock WebSocket message in the expected JSON format + const mockReport = { + report: { + feedID: "0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8", + fullReport: "0x0002" + "01020304" + "42".repeat(100), // Mock hex report + validFromTimestamp: Date.now(), + observationsTimestamp: Date.now(), + }, + }; + const mockReportData = Buffer.from(JSON.stringify(mockReport)); + + let receivedReportCount = 0; + const receivedReports: any[] = []; + + // Listen for reports + stream.on("report", report => { + receivedReportCount++; + receivedReports.push(report); + }); + + // Send the SAME report data from ALL connections simultaneously + // This simulates the real-world scenario where multiple origins + // broadcast the same report + await mockServer.broadcast(mockReportData); + + // Wait for processing + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Get final metrics + const finalMetrics = stream.getMetrics(); + + // CRITICAL ASSERTIONS + const expectedTotalReceived = NUM_CONNECTIONS; // Each connection sends one report + const expectedAccepted = 1; // Only one unique report should be accepted + const expectedDeduplicated = NUM_CONNECTIONS - expectedAccepted; // Rest should be deduplicated + + expect(finalMetrics.totalReceived).toBe(expectedTotalReceived); + expect(finalMetrics.accepted).toBe(expectedAccepted); + expect(finalMetrics.deduplicated).toBe(expectedDeduplicated); + + // Verify only one report was emitted to the user + expect(receivedReportCount).toBe(1); + expect(receivedReports).toHaveLength(1); + + // Verify deduplication rate calculation + const deduplicationRate = (finalMetrics.deduplicated / finalMetrics.totalReceived) * 100; + expect(deduplicationRate).toBeCloseTo((expectedDeduplicated / expectedTotalReceived) * 100, 1); + + await stream.close(); + }, 10000); + + it("should handle different reports from different connections", async () => { + const NUM_CONNECTIONS = 2; + const wsEndpoints = Array(NUM_CONNECTIONS) + .fill(0) + .map(() => `ws://${serverAddress}`) + .join(","); + + const config = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: wsEndpoints, + haMode: true, + }; + + const client = createClient(config); + const stream = client.createStream(["0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8"]); + activeStreams.push(stream); // Track for cleanup + + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 500)); + + // Create two DIFFERENT mock reports + const mockReport1 = { + report: { + feedID: "0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8", + fullReport: "0x0002" + "01010101" + "42".repeat(100), + validFromTimestamp: Date.now() - 1000, + observationsTimestamp: Date.now() - 1000, + }, + }; + const mockReportData1 = Buffer.from(JSON.stringify(mockReport1)); + + const mockReport2 = { + report: { + feedID: "0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8", + fullReport: "0x0002" + "02020202" + "43".repeat(100), + validFromTimestamp: Date.now(), + observationsTimestamp: Date.now(), + }, + }; + const mockReportData2 = Buffer.from(JSON.stringify(mockReport2)); + + let receivedReportCount = 0; + + stream.on("report", () => { + receivedReportCount++; + }); + + // Send different reports - should NOT be deduplicated + await mockServer.broadcast(mockReportData1); + await new Promise(resolve => setTimeout(resolve, 100)); + await mockServer.broadcast(mockReportData2); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + const finalMetrics = stream.getMetrics(); + + // Both reports should be accepted (no deduplication for different reports) + // In HA mode with 2 connections, each broadcast sends to both connections + const expectedTotalReceived = NUM_CONNECTIONS * 2; // 2 different reports Ɨ 2 connections + expect(finalMetrics.totalReceived).toBe(expectedTotalReceived); + expect(finalMetrics.accepted).toBe(2); // 2 unique reports + expect(finalMetrics.deduplicated).toBe(expectedTotalReceived - 2); // Rest are duplicates + expect(receivedReportCount).toBe(2); // User receives 2 unique reports + + await stream.close(); + }, 10000); + + it("should maintain deduplication across reconnections", async () => { + const NUM_CONNECTIONS = 2; + const wsEndpoints = Array(NUM_CONNECTIONS) + .fill(0) + .map(() => `ws://${serverAddress}`) + .join(","); + + const config = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: wsEndpoints, + haMode: true, + }; + + const client = createClient(config); + const stream = client.createStream(["0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8"]); + activeStreams.push(stream); // Track for cleanup + + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 500)); + + const mockReport = { + report: { + feedID: "0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8", + fullReport: "0x0002" + "01020304" + "42".repeat(100), + validFromTimestamp: Date.now(), + observationsTimestamp: Date.now(), + }, + }; + const mockReportData = Buffer.from(JSON.stringify(mockReport)); + + let receivedReportCount = 0; + + stream.on("report", () => { + receivedReportCount++; + }); + + // Send report from all connections initially + await mockServer.broadcast(mockReportData); + await new Promise(resolve => setTimeout(resolve, 500)); + + // Simulate reconnection by disconnecting and reconnecting a client + await mockServer.closeAllConnections(); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for reconnection + + // Send the same report again after reconnection + await mockServer.broadcast(mockReportData); + await new Promise(resolve => setTimeout(resolve, 500)); + + const finalMetrics = stream.getMetrics(); + + // Deduplication persists across connection changes + expect(finalMetrics.accepted).toBe(1); // Only one unique report accepted + expect(finalMetrics.deduplicated).toBeGreaterThan(0); // Some reports were deduplicated + expect(receivedReportCount).toBe(1); // Only one report emitted to user + + await stream.close(); + }, 15000); + }); + + describe("Deduplication Performance", () => { + it("should handle high-frequency duplicate reports efficiently", async () => { + const NUM_CONNECTIONS = 3; + const REPORTS_PER_CONNECTION = 10; + + const wsEndpoints = Array(NUM_CONNECTIONS) + .fill(0) + .map(() => `ws://${serverAddress}`) + .join(","); + + const config = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: wsEndpoints, + haMode: true, + }; + + const client = createClient(config); + const stream = client.createStream(["0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8"]); + activeStreams.push(stream); // Track for cleanup + + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 500)); + + const mockReport = { + report: { + feedID: "0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8", + fullReport: "0x0002" + "01020304" + "42".repeat(100), + validFromTimestamp: Date.now(), + observationsTimestamp: Date.now(), + }, + }; + const mockReportData = Buffer.from(JSON.stringify(mockReport)); + + let receivedReportCount = 0; + + stream.on("report", () => { + receivedReportCount++; + }); + + const startTime = Date.now(); + + // Send the same report multiple times from each connection + for (let i = 0; i < REPORTS_PER_CONNECTION; i++) { + await mockServer.broadcast(mockReportData); + // Small delay between sends to simulate real-world timing + await new Promise(resolve => setTimeout(resolve, 10)); + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + + const endTime = Date.now(); + const processingTime = endTime - startTime; + + const finalMetrics = stream.getMetrics(); + + // Performance assertions + expect(processingTime).toBeLessThan(5000); // Should process quickly + expect(finalMetrics.totalReceived).toBe(NUM_CONNECTIONS * REPORTS_PER_CONNECTION); + expect(finalMetrics.accepted).toBe(1); // Only one unique report + expect(finalMetrics.deduplicated).toBe(NUM_CONNECTIONS * REPORTS_PER_CONNECTION - 1); + expect(receivedReportCount).toBe(1); + + // Deduplication efficiency should be very high + const deduplicationRate = (finalMetrics.deduplicated / finalMetrics.totalReceived) * 100; + expect(deduplicationRate).toBeGreaterThan(90); // >90% efficiency + + await stream.close(); + }, 15000); + }); +}); diff --git a/typescript/tests/integration/ha-dynamic-reconnection.test.ts b/typescript/tests/integration/ha-dynamic-reconnection.test.ts new file mode 100644 index 0000000..744169b --- /dev/null +++ b/typescript/tests/integration/ha-dynamic-reconnection.test.ts @@ -0,0 +1,349 @@ +/** + * Integration tests for HA dynamic reconnection functionality + * Tests that the stream continues operating when connections drop and reconnect + * These tests simulate REAL production scenarios like network failures + */ + +import { createClient } from "../../src/index"; +import { MockWebSocketServer } from "../utils/mockWebSocketServer"; +import { ConnectionStatus } from "../../src/types/metrics"; + +describe("HA Dynamic Reconnection Integration Tests", () => { + let mockServer: MockWebSocketServer; + let serverAddress: string; + const activeStreams: any[] = []; + + beforeEach(async () => { + mockServer = new MockWebSocketServer(); + await mockServer.waitForReady(); + serverAddress = mockServer.getAddress(); + }); + + afterEach(async () => { + // Close all active streams first + for (const stream of activeStreams) { + try { + await stream.close(); + } catch { + // Ignore close errors + } + } + activeStreams.length = 0; // Clear array + + // Then close mock server + await mockServer.close(); + }); + + describe("Partial Connection Loss (Production Scenario)", () => { + it("should handle partial connection drops and attempt reconnection", async () => { + const NUM_CONNECTIONS = 4; + const wsEndpoints = Array(NUM_CONNECTIONS) + .fill(0) + .map(() => `ws://${serverAddress}`) + .join(","); + + const config = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: wsEndpoints, + haMode: true, + maxReconnectAttempts: 5, + }; + + const client = createClient(config); + const stream = client.createStream(["0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8"]); + activeStreams.push(stream); + + // Wait for all connections to establish + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 1000)); + + const initialMetrics = stream.getMetrics(); + expect(initialMetrics.configuredConnections).toBe(NUM_CONNECTIONS); + expect(initialMetrics.activeConnections).toBe(NUM_CONNECTIONS); + + // Simulate partial connection drops (drop 2 out of 4 connections) + const connectionsToDrop = 2; + mockServer.simulateConnectionDrops(connectionsToDrop); + + // Wait for disconnection detection + await new Promise(resolve => setTimeout(resolve, 1500)); + + // Verify partial connection loss + const partialLossMetrics = stream.getMetrics(); + // Connection loss detection may be asynchronous in production environments + // Validate that connection count is within expected bounds + expect(partialLossMetrics.activeConnections).toBeLessThanOrEqual(NUM_CONNECTIONS); + + // If no immediate drop detected, that's ok - the important part is reconnection attempts + + // Wait for reconnection attempts + await new Promise(resolve => setTimeout(resolve, 4000)); + + const finalMetrics = stream.getMetrics(); + + // Validate reconnection behavior + // Reconnection activity indicates proper high-availability functionality + + // streaming clients sho exhibit either: + // 1. Immediate reconnection (seamless failover) + // 2. Tracked reconnection attempts (observable resilience) + const hasReconnectionActivity = finalMetrics.partialReconnects + finalMetrics.fullReconnects > 0; + const hasStableConnections = finalMetrics.activeConnections > 0; + + expect(hasReconnectionActivity || hasStableConnections).toBe(true); + + await stream.close(); + }, 15000); + + it("should maintain stream functionality with remaining connections", async () => { + const NUM_CONNECTIONS = 3; + const wsEndpoints = Array(NUM_CONNECTIONS) + .fill(0) + .map(() => `ws://${serverAddress}`) + .join(","); + + const config = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: wsEndpoints, + haMode: true, + }; + + const client = createClient(config); + const stream = client.createStream(["0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8"]); + activeStreams.push(stream); + + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 1000)); + + let receivedReportCount = 0; + stream.on("report", () => { + receivedReportCount++; + }); + + // Establish baseline functionality before connection testing + const mockReport = { + report: { + feedID: "0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8", + fullReport: "0x0002" + "01020304" + "42".repeat(100), + validFromTimestamp: Date.now(), + observationsTimestamp: Date.now(), + }, + }; + + await mockServer.broadcast(Buffer.from(JSON.stringify(mockReport))); + await new Promise(resolve => setTimeout(resolve, 500)); + + // Confirm stream operational state + expect(receivedReportCount).toBeGreaterThan(0); + + // Simulate partial connection failure + mockServer.simulateConnectionDrops(1); + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Validate continued operation with reduced connection capacity + const preDropCount = receivedReportCount; + await mockServer.broadcast( + Buffer.from( + JSON.stringify({ + ...mockReport, + report: { + ...mockReport.report, + validFromTimestamp: Date.now() + 1000, + observationsTimestamp: Date.now() + 1000, + }, + }) + ) + ); + await new Promise(resolve => setTimeout(resolve, 500)); + + // Confirm report delivery through remaining connections + expect(receivedReportCount).toBeGreaterThan(preDropCount); + + await stream.close(); + }, 12000); + }); + + describe("Full Connection Loss (Production Scenario)", () => { + it("should detect all connections lost and trigger full reconnection", async () => { + const NUM_CONNECTIONS = 3; + const wsEndpoints = Array(NUM_CONNECTIONS) + .fill(0) + .map(() => `ws://${serverAddress}`) + .join(","); + + const config = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: wsEndpoints, + haMode: true, + maxReconnectAttempts: 4, + }; + + const client = createClient(config); + const stream = client.createStream(["0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8"]); + activeStreams.push(stream); + + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 1000)); + + const initialMetrics = stream.getMetrics(); + expect(initialMetrics.activeConnections).toBe(NUM_CONNECTIONS); + + let allConnectionsLostEvents = 0; + stream.on("all-connections-lost", () => { + allConnectionsLostEvents++; + }); + + // Drop ALL connections + mockServer.simulateConnectionDrops(); // No parameter = drop all + + // Wait for full disconnection detection + await new Promise(resolve => setTimeout(resolve, 2000)); + + const lossMetrics = stream.getMetrics(); + + // Focus on reconnection behavior rather than precise synchronization + expect(lossMetrics.activeConnections).toBeLessThanOrEqual(initialMetrics.activeConnections); + expect(allConnectionsLostEvents).toBeGreaterThanOrEqual(0); // May be 0 if detection is async + + // Wait for full reconnection attempts + await new Promise(resolve => setTimeout(resolve, 5000)); + + const finalMetrics = stream.getMetrics(); + + // CRITICAL: Must show full reconnection attempts + expect(finalMetrics.fullReconnects).toBeGreaterThan(0); + + await stream.close(); + }, 15000); + }); + + describe("Exponential Backoff Verification", () => { + it("should implement exponential backoff with jitter for reconnections", async () => { + const wsEndpoints = `ws://${serverAddress}`; + + const config = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: wsEndpoints, + haMode: true, + maxReconnectAttempts: 4, + }; + + const client = createClient(config); + const stream = client.createStream(["0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8"]); + activeStreams.push(stream); + + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 1000)); + + const reconnectionTimestamps: number[] = []; + + stream.on("connection-restored", () => { + reconnectionTimestamps.push(Date.now()); + }); + + // Repeatedly drop connection to observe backoff pattern + for (let i = 0; i < 3; i++) { + mockServer.simulateConnectionDrops(); + await new Promise(resolve => setTimeout(resolve, 100)); // Brief pause between drops + + // Wait for reconnection attempt + await new Promise(resolve => setTimeout(resolve, 3000)); + } + + const finalMetrics = stream.getMetrics(); + expect(finalMetrics.partialReconnects + finalMetrics.fullReconnects).toBeGreaterThan(0); + + // If we captured multiple reconnection events, verify increasing delays + if (reconnectionTimestamps.length > 1) { + for (let i = 1; i < reconnectionTimestamps.length; i++) { + const delay = reconnectionTimestamps[i] - reconnectionTimestamps[i - 1]; + const previousDelay = i > 1 ? reconnectionTimestamps[i - 1] - reconnectionTimestamps[i - 2] : 0; + + // With exponential backoff, delays should generally increase + // (allowing for jitter variance) + if (previousDelay > 0) { + expect(delay).toBeGreaterThan(previousDelay * 0.5); // 50% tolerance for jitter + } + } + } + + await stream.close(); + }, 20000); + }); + + describe("Connection State Transitions (Production Monitoring)", () => { + it("should accurately track origin states during reconnection cycles", async () => { + const NUM_CONNECTIONS = 3; + const wsEndpoints = Array(NUM_CONNECTIONS) + .fill(0) + .map(() => `ws://${serverAddress}`) + .join(","); + + const config = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: wsEndpoints, + haMode: true, + }; + + const client = createClient(config); + const stream = client.createStream(["0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8"]); + activeStreams.push(stream); + + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 1000)); + + const initialMetrics = stream.getMetrics(); + expect(initialMetrics.configuredConnections).toBe(NUM_CONNECTIONS); + expect(initialMetrics.activeConnections).toBe(NUM_CONNECTIONS); + + // All origins should initially be connected + const connectedOrigins = Object.values(initialMetrics.originStatus).filter( + status => status === ConnectionStatus.CONNECTED + ).length; + + // In HA mode, we expect all configured connections to be connected + expect(connectedOrigins).toBeGreaterThan(0); + expect(initialMetrics.activeConnections).toBe(NUM_CONNECTIONS); + + // Close server to force disconnection and trigger reconnection attempts + await mockServer.close(); + await new Promise(resolve => setTimeout(resolve, 2000)); + + const disconnectMetrics = stream.getMetrics(); + + // After server close, connections should be affected + expect(disconnectMetrics.activeConnections).toBeLessThanOrEqual(NUM_CONNECTIONS); + + // Origin statuses should reflect disconnections + const disconnectedOrigins = Object.values(disconnectMetrics.originStatus).filter( + status => status === ConnectionStatus.DISCONNECTED || status === ConnectionStatus.RECONNECTING + ).length; + expect(disconnectedOrigins).toBeGreaterThan(0); + + // Wait for reconnection attempts + await new Promise(resolve => setTimeout(resolve, 3000)); + + const finalMetrics = stream.getMetrics(); + + // The stream should either maintain connections OR show reconnection attempts + + const hasReconnectionActivity = finalMetrics.partialReconnects + finalMetrics.fullReconnects > 0; + const maintainsConnections = finalMetrics.activeConnections > 0; + + // Either scenario indicates a robust system + expect(hasReconnectionActivity || maintainsConnections).toBe(true); + + await stream.close(); + }, 15000); + }); +}); diff --git a/typescript/tests/integration/ha-mode.integration.test.ts b/typescript/tests/integration/ha-mode.integration.test.ts new file mode 100644 index 0000000..c0f554b --- /dev/null +++ b/typescript/tests/integration/ha-mode.integration.test.ts @@ -0,0 +1,294 @@ +/** + * Integration Tests for High Availability Mode + * + * These tests validate the Stream High Availability functionality by: + * - Testing HA mode with dynamic origin discovery + * - Verifying multi-origin connection establishment + * - Testing behavior during partial connection failures + * - Checking that reports are properly received and deduplicated + * - Validating fallback to single connection when needed + * + * Requirements: + * - Network access for WebSocket connections + * - Extended timeouts (20s) due to complex network operations + * - May require extra resources when running multiple server instances + */ + +import { describe, it, expect, afterEach, beforeEach } from "@jest/globals"; +import { MockWebSocketServer } from "../utils/mockWebSocketServer"; +import { Config, Stream } from "../../src"; +import { AbiCoder } from "ethers"; +import * as originDiscovery from "../../src/utils/origin-discovery"; + +// Longer timeouts for HA tests +const TEST_TIMEOUT = 20000; + +// Create a properly encoded full report for testing +const REAL_REPORT_BLOB = + "0x0006f9b553e393ced311551efd30d1decedb63d76ad41737462e2cdbbdff157800000000000000000000000000000000000000000000000000000000351f200b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000028000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000120000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba7820000000000000000000000000000000000000000000000000000000066aa78ab0000000000000000000000000000000000000000000000000000000066aa78ab00000000000000000000000000000000000000000000000000001b6732178a04000000000000000000000000000000000000000000000000001b1e8f8f0dc6880000000000000000000000000000000000000000000000000000000066abca2b0000000000000000000000000000000000000000000000b3eba5491849628aa00000000000000000000000000000000000000000000000b3eaf356fc42b6f6c00000000000000000000000000000000000000000000000b3ecd20810b9d1c0"; + +const abiCoder = new AbiCoder(); +const FULL_REPORT = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x0000000000000000000000000000000000000000000000000000000000000003", + ], + REAL_REPORT_BLOB, + ["0x0000000000000000000000000000000000000000000000000000000000000004"], + ["0x0000000000000000000000000000000000000000000000000000000000000005"], + "0x0000000000000000000000000000000000000000000000000000000000000006", + ] +); + +// Mock origin discovery +jest.mock("../../src/utils/origin-discovery", () => ({ + getAvailableOrigins: jest.fn(), +})); + +describe("HA Mode Integration Tests", () => { + // We'll use multiple mock servers to simulate HA connections + let mockServers: MockWebSocketServer[] = []; + let stream: Stream; + let feedIds: string[]; + + // Helper to create and start mock servers + async function createMockServers(count: number): Promise { + const servers: MockWebSocketServer[] = []; + for (let i = 0; i < count; i++) { + const server = new MockWebSocketServer(); + const isReady = await server.waitForReady(); + if (!isReady) { + throw new Error(`Mock server ${i} failed to start`); + } + servers.push(server); + } + return servers; + } + + // Helper to create a test message for a specific feed + function createMockReportMessage(feedId: string, timestamp = Date.now()) { + return JSON.stringify({ + report: { + feedID: feedId, + fullReport: FULL_REPORT, + validFromTimestamp: timestamp, + observationsTimestamp: timestamp, + }, + }); + } + + // Initialize test environment + beforeEach(async () => { + // Configure feed IDs for HA testing + feedIds = ["0x0003" + "1".repeat(60), "0x0003" + "2".repeat(60)]; + + // Clear mocks + jest.clearAllMocks(); + }); + + // Cleanup after each test + afterEach(async () => { + try { + if (stream) { + await stream.close(); + // Wait for cleanup + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } finally { + for (const server of mockServers) { + await server.close(); + // Wait for server cleanup + await new Promise(resolve => setTimeout(resolve, 1000)); + } + mockServers = []; + } + }); + + /** + * Test: HA Mode with multiple connections + * Verifies that the stream can establish multiple connections in HA mode + */ + it( + "should establish multiple connections in HA mode", + async () => { + // Create 3 mock servers for HA connections + const serverCount = 3; + mockServers = await createMockServers(serverCount); + + // Get server addresses and create WebSocket URLs + const addresses = mockServers.map(server => server.getAddress()); + const wsUrls = addresses.map(addr => `ws://${addr}`); + + // Mock origin discovery to return multiple origins + (originDiscovery.getAvailableOrigins as jest.Mock).mockResolvedValue(wsUrls); + + // Use the first server for the main endpoint + const config: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: `http://${addresses[0]}`, + wsEndpoint: wsUrls.join(","), // Comma-separated URLs for HA mode + haMode: true, + }; + + // Create stream with HA mode enabled + stream = new Stream(config, feedIds, { + maxReconnectAttempts: 2, + reconnectInterval: 500, + }); + + // Connect + await stream.connect(); + + // Wait for connections to stabilize + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Validate HA connection establishment + const stats = stream.getMetrics(); + expect(stats.configuredConnections).toBeGreaterThan(1); + expect(stats.activeConnections).toBeGreaterThan(0); + + // Verify clients connected to our mock servers + let totalConnectedClients = 0; + for (const server of mockServers) { + totalConnectedClients += server.getConnectedClientsCount(); + } + + // We should have multiple connections in HA mode + expect(totalConnectedClients).toBeGreaterThan(1); + }, + TEST_TIMEOUT + ); + + /** + * Test: Simplified reconnection test + * Verifies that stream continues to function after closing connections + */ + it( + "should handle connection changes gracefully", + async () => { + // Create 2 mock servers for HA connections + const serverCount = 2; + mockServers = await createMockServers(serverCount); + + // Get server addresses and create WebSocket URLs + const addresses = mockServers.map(server => server.getAddress()); + const wsUrls = addresses.map(addr => `ws://${addr}`); + + // Mock origin discovery to return multiple origins + (originDiscovery.getAvailableOrigins as jest.Mock).mockResolvedValue(wsUrls); + + // Use the first server for the main endpoint + const config: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: `http://${addresses[0]}`, + wsEndpoint: wsUrls.join(","), // Comma-separated URLs for HA mode + haMode: true, + }; + + // Create stream with HA mode enabled + stream = new Stream(config, feedIds, { + maxReconnectAttempts: 2, + reconnectInterval: 500, + }); + + // Connect + await stream.connect(); + + // Get initial stats + const initialStats = stream.getMetrics(); + expect(initialStats.activeConnections).toBeGreaterThan(0); + + // Close all connections on the first server + await mockServers[0].closeAllConnections(); + + // Wait for reconnection attempts + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Stream maintains operational state after connection changes + try { + const stats = stream.getMetrics(); + expect(stats).toBeDefined(); + } catch { + fail("Stream should still be usable after connection changes"); + } + }, + TEST_TIMEOUT + ); + + /** + * Test: Simplified deduplication test + * Tests basic report reception + */ + it( + "should receive reports from active connections", + async () => { + // Create 2 mock servers for HA connections + const serverCount = 2; + mockServers = await createMockServers(serverCount); + + // Get server addresses and create WebSocket URLs + const addresses = mockServers.map(server => server.getAddress()); + const wsUrls = addresses.map(addr => `ws://${addr}`); + + // Mock origin discovery to return multiple origins + (originDiscovery.getAvailableOrigins as jest.Mock).mockResolvedValue(wsUrls); + + // Use the first server for the main endpoint + const config: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: `http://${addresses[0]}`, + wsEndpoint: wsUrls.join(","), // Comma-separated URLs for HA mode + haMode: true, + }; + + // Create stream with HA mode enabled + stream = new Stream(config, feedIds, { + maxReconnectAttempts: 2, + reconnectInterval: 500, + }); + + // Create a promise that resolves when we receive a report + const reportPromise = new Promise((resolve, _reject) => { + const timeout = setTimeout(() => { + // Timeout handling for report reception + resolve(); + }, 5000); + + stream.once("report", () => { + clearTimeout(timeout); + resolve(); + }); + }); + + // Connect + await stream.connect(); + + // Wait for connections to stabilize + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Send a report to both servers + const reportMessage = createMockReportMessage(feedIds[0]); + for (const server of mockServers) { + server.broadcast(reportMessage); + } + + // Wait for report (or timeout) + await reportPromise; + + // Confirm stream operational integrity + try { + const stats = stream.getMetrics(); + expect(stats).toBeDefined(); + } catch { + fail("Stream should be usable after receiving reports"); + } + }, + TEST_TIMEOUT + ); +}); diff --git a/typescript/tests/integration/stream.integration.test.ts b/typescript/tests/integration/stream.integration.test.ts new file mode 100644 index 0000000..576f45c --- /dev/null +++ b/typescript/tests/integration/stream.integration.test.ts @@ -0,0 +1,684 @@ +/** + * Integration Tests for Stream Core Functionality + * + * These tests validate the WebSocket streaming capabilities by: + * - Testing connection establishment and report receiving + * - Validating reconnection behavior when connections are dropped + * - Checking graceful shutdown and cleanup + * - Testing behavior with multiple feeds + * - Verifying max reconnection attempt handling + * + * Requirements: + * - Network access for WebSocket connections + * - Extended timeouts for reconnection tests + * - Uses a mock WebSocket server to simulate the Data Streams API + */ + +import { describe, it, expect, afterEach } from "@jest/globals"; +import { MockWebSocketServer } from "../utils/mockWebSocketServer"; +import { Config, Stream } from "../../src"; +import { ConnectionType } from "../../src/stream"; +import { AbiCoder } from "ethers"; + +// Set to true when debugging WebSocket connection issues +const DEBUG_LOGS = false; + +// Helper function for conditional logging +const debugLog = (...args: any[]) => { + if (DEBUG_LOGS) { + console.log(...args); + } +}; + +const MAX_RECONNECT_ATTEMPTS = 2; + +const REAL_REPORT_BLOB = + "0x0006f9b553e393ced311551efd30d1decedb63d76ad41737462e2cdbbdff157800000000000000000000000000000000000000000000000000000000351f200b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000028000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000120000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba7820000000000000000000000000000000000000000000000000000000066aa78ab0000000000000000000000000000000000000000000000000000000066aa78ab00000000000000000000000000000000000000000000000000001b6732178a04000000000000000000000000000000000000000000000000001b1e8f8f0dc6880000000000000000000000000000000000000000000000000000000066abca2b0000000000000000000000000000000000000000000000b3eba5491849628aa00000000000000000000000000000000000000000000000b3eaf356fc42b6f6c00000000000000000000000000000000000000000000000b3ecd20810b9d1c0"; + +// Create a properly encoded full report +const abiCoder = new AbiCoder(); +const FULL_REPORT = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x0000000000000000000000000000000000000000000000000000000000000003", + ], + REAL_REPORT_BLOB, + ["0x0000000000000000000000000000000000000000000000000000000000000004"], + ["0x0000000000000000000000000000000000000000000000000000000000000005"], + "0x0000000000000000000000000000000000000000000000000000000000000006", + ] +); + +describe("Stream ConnectionType Detection Tests", () => { + let mockServer: MockWebSocketServer; + let stream: Stream; + + afterEach(async () => { + try { + if (stream) { + await stream.close(); + await new Promise(resolve => setTimeout(resolve, 500)); + } + } finally { + if (mockServer) { + await mockServer.close(); + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + }); + + /** + * Test: Single connection type detection + * Verifies that ConnectionType.Single is used for single WebSocket URL + */ + it("should use ConnectionType.Single for single origin", async () => { + mockServer = new MockWebSocketServer(); + const isReady = await mockServer.waitForReady(); + expect(isReady).toBe(true); + + const address = mockServer.getAddress(); + const config: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: "http://mock-api.example.com", + wsEndpoint: `ws://${address}`, + haMode: false, // Explicitly disable HA mode + }; + + stream = new Stream(config, ["feed1"], { maxReconnectAttempts: 1 }); + await stream.connect(); + + // Should detect single connection type + expect(stream.getConnectionType()).toBe(ConnectionType.Single); + + const stats = stream.getMetrics(); + expect(stats.configuredConnections).toBe(1); + expect(stats.activeConnections).toBe(1); + }); + + /** + * Test: Multiple connection type detection with HA mode + * Verifies that ConnectionType.Multiple is used when HA mode is enabled with multiple origins + */ + it("should use ConnectionType.Multiple for HA mode with multiple origins", async () => { + mockServer = new MockWebSocketServer(); + const isReady = await mockServer.waitForReady(); + expect(isReady).toBe(true); + + const address = mockServer.getAddress(); + const config: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: "http://mock-api.example.com", + wsEndpoint: `ws://${address},ws://${address}`, // Multiple URLs (same server for testing) + haMode: true, // Enable HA mode + }; + + stream = new Stream(config, ["feed1"], { maxReconnectAttempts: 1 }); + await stream.connect(); + + // Should detect multiple connection type + expect(stream.getConnectionType()).toBe(ConnectionType.Multiple); + + const stats = stream.getMetrics(); + expect(stats.configuredConnections).toBe(2); + expect(stats.activeConnections).toBeGreaterThan(0); // At least one should connect + + const origins = stream.getOrigins(); + expect(origins.length).toBe(2); + }); + + /** + * Test: Fallback to Single when HA mode fails + * Verifies that the system falls back to Single connection type when HA setup fails + */ + it("should fallback to ConnectionType.Single when HA mode conditions not met", async () => { + mockServer = new MockWebSocketServer(); + const isReady = await mockServer.waitForReady(); + expect(isReady).toBe(true); + + const address = mockServer.getAddress(); + const config: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: "http://mock-api.example.com", + wsEndpoint: `ws://${address}`, // Single URL + haMode: true, // HA mode requested + haConnectionTimeout: 1000, // Short timeout for faster test + }; + + stream = new Stream(config, ["feed1"], { maxReconnectAttempts: 1 }); + await stream.connect(); + + // Should fallback to single connection type + expect(stream.getConnectionType()).toBe(ConnectionType.Single); + + const stats = stream.getMetrics(); + expect(stats.configuredConnections).toBe(1); + expect(stats.activeConnections).toBe(1); + }); + + /** + * Test: Connection type consistency after origin discovery fallback + * Verifies connection type behavior when origin discovery fails + */ + it("should maintain correct ConnectionType after origin discovery fallback", async () => { + mockServer = new MockWebSocketServer(); + const isReady = await mockServer.waitForReady(); + expect(isReady).toBe(true); + + const address = mockServer.getAddress(); + const config: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: "http://mock-api.example.com", + wsEndpoint: `ws://${address}`, + haMode: true, // Request HA mode + haConnectionTimeout: 1000, // Short timeout for faster test + }; + + stream = new Stream(config, ["feed1"], { maxReconnectAttempts: 1 }); + + // Connect and expect fallback behavior + await stream.connect(); + + // Should maintain the determined connection type + const connectionType = stream.getConnectionType(); + expect([ConnectionType.Single, ConnectionType.Multiple]).toContain(connectionType); + + const stats = stream.getMetrics(); + expect(stats.configuredConnections).toBeGreaterThan(0); + expect(stats.activeConnections).toBeGreaterThan(0); + }); + + /** + * Test: Origins array consistency with connection type + * Verifies that getOrigins() returns correct data based on connection type + */ + it("should return consistent origins array based on connection type", async () => { + mockServer = new MockWebSocketServer(); + const isReady = await mockServer.waitForReady(); + expect(isReady).toBe(true); + + const address = mockServer.getAddress(); + + // Test single connection + const singleConfig: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: "http://mock-api.example.com", + wsEndpoint: `ws://${address}`, + haMode: false, + }; + + stream = new Stream(singleConfig, ["feed1"], { maxReconnectAttempts: 1 }); + await stream.connect(); + + expect(stream.getConnectionType()).toBe(ConnectionType.Single); + const singleOrigins = stream.getOrigins(); + expect(singleOrigins.length).toBe(1); + expect(singleOrigins[0]).toBe(`ws://${address}`); + + await stream.close(); + await new Promise(resolve => setTimeout(resolve, 500)); + + // Test multiple connections + const multiConfig: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: "http://mock-api.example.com", + wsEndpoint: `ws://${address},ws://${address}`, + haMode: true, + }; + + stream = new Stream(multiConfig, ["feed1"], { maxReconnectAttempts: 1 }); + await stream.connect(); + + expect(stream.getConnectionType()).toBe(ConnectionType.Multiple); + const multiOrigins = stream.getOrigins(); + expect(multiOrigins.length).toBe(2); + expect(multiOrigins[0]).toBe(`ws://${address}`); + expect(multiOrigins[1]).toBe(`ws://${address}`); + }); +}); + +describe("Stream Integration Tests", () => { + let mockServer: MockWebSocketServer; + let stream: Stream; + + async function prepareScenario() { + mockServer = new MockWebSocketServer(); + + // Wait for server to be ready + const isReady = await mockServer.waitForReady(); + if (!isReady) { + throw new Error("Mock server failed to start"); + } + + const address = mockServer.getAddress(); + if (!address) { + throw new Error("Mock server failed to start"); + } + + const mockServerUrl = `ws://${address}`; + const config: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: "http://mock-api.example.com", + wsEndpoint: mockServerUrl, + }; + + stream = new Stream(config, [], { + maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS, + reconnectInterval: 500, + }); + + try { + await stream.connect(); + // Wait longer for connection to stabilize + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (error) { + // Clean up if connection fails + if (mockServer) { + await mockServer.close(); + } + throw error; + } + } + + afterEach(async () => { + try { + if (stream) { + await stream.close(); + // Wait for cleanup + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } finally { + if (mockServer) { + await mockServer.close(); + // Wait for server cleanup + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + }); + + it("should handle HA mode with single origin fallback", async () => { + // Create a scenario where HA mode is requested but only one origin is available + mockServer = new MockWebSocketServer(); + + const isReady = await mockServer.waitForReady(); + if (!isReady) { + throw new Error("Mock server failed to start"); + } + + const address = mockServer.getAddress(); + if (!address) { + throw new Error("Mock server failed to start"); + } + + const mockServerUrl = `ws://${address}`; + const config: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: "http://mock-api.example.com", + wsEndpoint: mockServerUrl, + haMode: true, // Request HA mode + }; + + stream = new Stream(config, [], { + maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS, + reconnectInterval: 500, + }); + + try { + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Should fall back to single connection + const stats = stream.getMetrics(); + expect(stats.configuredConnections).toBe(1); + expect(stats.activeConnections).toBe(1); + + const mockReport = JSON.stringify({ + report: { + feedID: "0x0003" + "1".repeat(60), + fullReport: FULL_REPORT, + validFromTimestamp: Date.now(), + observationsTimestamp: Date.now(), + }, + }); + + const reportPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout waiting for report")); + }, 1000); + + stream.on("report", report => { + clearTimeout(timeout); + try { + expect(report).toBeDefined(); + expect(report.feedID).toBe("0x0003" + "1".repeat(60)); + expect(report.fullReport).toBeDefined(); + resolve(); + } catch (error) { + reject(error); + } + }); + + mockServer.broadcast(mockReport); + }); + + await reportPromise; + } catch (error) { + // Clean up if connection fails + if (mockServer) { + await mockServer.close(); + } + throw error; + } + }, 5000); + + it("should handle graceful shutdown", async () => { + await prepareScenario(); + await stream.close(); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(mockServer.getConnectedClientsCount()).toBe(0); + }); + + it("should handle connection drop and maintain stream functionality", async () => { + await prepareScenario(); + + // Add a longer delay to ensure the connection is stable + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Get initial stats + const initialStats = stream.getMetrics(); + expect(initialStats.activeConnections).toBe(1); + + const disconnectPromise = new Promise(resolve => { + const timeout = setTimeout(() => { + // Don't fail the test, just resolve - connection management is internal + resolve(); + }, 5000); + + const disconnectHandler = () => { + debugLog("Disconnect detected"); + clearTimeout(timeout); + resolve(); + }; + + stream.once("disconnected", disconnectHandler); + + // Simulate connection drop + debugLog("Closing connections..."); + mockServer.closeAllConnections(); + }); + + await disconnectPromise; + + // Verify that the stream detected the disconnection + const statsAfterDisconnect = stream.getMetrics(); + expect(statsAfterDisconnect.activeConnections).toBe(0); + + // Stream should still be in a valid state for potential reconnection + expect(stream.getMetrics()).toBeDefined(); + }, 10000); + + it("should handle multiple feeds", async () => { + const feedIds = ["0x0003" + "1".repeat(60), "0x0003" + "2".repeat(60)]; + mockServer = new MockWebSocketServer(); + + // Wait for server to be ready + const isReady = await mockServer.waitForReady(); + if (!isReady) { + throw new Error("Mock server failed to start"); + } + + const address = mockServer.getAddress(); + if (!address) { + throw new Error("Mock server failed to start"); + } + + const config: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: "http://mock-api.example.com", + wsEndpoint: `ws://${address}`, + }; + + stream = new Stream(config, feedIds, { + maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS, + reconnectInterval: 500, + }); + + try { + await stream.connect(); + // Wait longer for connection to stabilize + await new Promise(resolve => setTimeout(resolve, 1000)); + + const reports = new Set(); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout waiting for reports")); + }, 5000); + + stream.on("report", report => { + reports.add(report.feedID); + if (reports.size === feedIds.length) { + clearTimeout(timeout); + resolve(); + } + }); + + // Send reports with delay to avoid overwhelming the connection + feedIds.forEach((feedId, index) => { + setTimeout(() => { + const mockReport = JSON.stringify({ + report: { + feedID: feedId, + fullReport: FULL_REPORT, + validFromTimestamp: Date.now(), + observationsTimestamp: Date.now(), + }, + }); + mockServer.broadcast(mockReport); + }, index * 500); // Increased delay between reports + }); + }); + + expect(reports.size).toBe(feedIds.length); + } finally { + try { + await stream.close(); + await new Promise(resolve => setTimeout(resolve, 1000)); + } finally { + await mockServer.close(); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + }, 10000); + + it("should emit 'reconnecting' events with attempt and delay payload", async () => { + // Prepare a single-origin scenario and listen for reconnecting + mockServer = new MockWebSocketServer(); + + const isReady = await mockServer.waitForReady(); + if (!isReady) { + throw new Error("Mock server failed to start"); + } + + const address = mockServer.getAddress(); + if (!address) { + throw new Error("Mock server failed to start"); + } + + const config: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: "http://mock-api.example.com", + wsEndpoint: `ws://${address}`, + }; + + stream = new Stream(config, ["0x0003" + "1".repeat(60)], { + maxReconnectAttempts: 2, + reconnectInterval: 1000, + }); + + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 300)); + + // Promise resolves on first 'reconnecting' + const reconnectingPromise = new Promise(resolve => { + const timeout = setTimeout(() => { + throw new Error("Timeout waiting for reconnecting event"); + }, 5000); + stream.once("reconnecting", (info: { attempt: number; delayMs: number; origin?: string; host?: string }) => { + try { + expect(typeof info.attempt).toBe("number"); + expect(info.attempt).toBeGreaterThanOrEqual(1); + expect(typeof info.delayMs).toBe("number"); + // Basic sanity bounds: >= 200ms (min clamp) and not absurdly high for first attempt + expect(info.delayMs).toBeGreaterThanOrEqual(200); + expect(info.delayMs).toBeLessThanOrEqual(15000); + clearTimeout(timeout); + resolve(); + } catch (e) { + clearTimeout(timeout); + // Rethrow to fail test + throw e; + } + }); + }); + + // Trigger reconnection by dropping connections + await mockServer.closeAllConnections(); + + await reconnectingPromise; + }, 10000); + it("should handle max reconnection attempts", async () => { + // Mock the console.error to prevent WebSocket connection errors from appearing in test output + const originalConsoleError = console.error; + console.error = jest.fn().mockImplementation((message, ...args) => { + // Don't log expected connection errors + if (typeof message === "string" && message.includes("ECONNREFUSED")) { + return; + } + originalConsoleError(message, ...args); + }); + + try { + const maxAttempts = 2; + mockServer = new MockWebSocketServer(); + + // Wait for server to be ready + const isReady = await mockServer.waitForReady(); + if (!isReady) { + throw new Error("Mock server failed to start"); + } + + const address = mockServer.getAddress(); + if (!address) { + throw new Error("Mock server failed to start"); + } + + const config: Config = { + apiKey: "mock_key", + userSecret: "mock_secret", + endpoint: "http://mock-api.example.com", + wsEndpoint: `ws://${address}`, + }; + + stream = new Stream(config, [], { + maxReconnectAttempts: maxAttempts, + reconnectInterval: 500, + }); + + // Track if the test has resolved already to prevent multiple resolutions + let testResolved = false; + + try { + await stream.connect(); + await new Promise(resolve => setTimeout(resolve, 1000)); + + const errorPromise = new Promise(resolve => { + // If test times out, consider it successful + const timeout = setTimeout(() => { + if (!testResolved) { + testResolved = true; + debugLog("Timeout reached, but considering test successful"); + resolve(); + } + }, 10000); + + let reconnectEventCount = 0; + + const reconnectHandler = (info: { attempt: number; delayMs: number }) => { + debugLog("Reconnect event:", info); + reconnectEventCount++; + + // If we've seen maxAttempts reconnect events, we can resolve early + if (reconnectEventCount >= maxAttempts && !testResolved) { + testResolved = true; + debugLog(`Saw ${reconnectEventCount} reconnect events`); + clearTimeout(timeout); + resolve(); + } + }; + + const errorHandler = (error: Error) => { + debugLog("Stream error:", error.message); + + // Also resolve on max reconnection attempts error + if (error.message.includes("Max reconnection attempts reached") && !testResolved) { + testResolved = true; + debugLog("Saw max reconnection attempts error"); + clearTimeout(timeout); + resolve(); + } + }; + + stream.on("reconnecting", reconnectHandler); + stream.on("error", errorHandler); + + // Force multiple reconnection attempts + mockServer.closeAllConnections(); + + // Close the server to force failed reconnections + // Use a longer delay to ensure the first reconnect attempt can complete + setTimeout(() => { + if (!testResolved) { + debugLog("Closing mock server to force reconnection failures"); + mockServer.close().catch(() => { + // Ignore errors from closing the server + }); + } + }, 500); + }); + + await errorPromise; + } finally { + try { + // Prevent any further errors by setting a flag to indicate test is done + testResolved = true; + + // Try to close the stream but don't let errors stop the test + if (stream) { + await stream.close().catch(e => { + // Ignore close errors + debugLog("Ignoring stream close error:", e.message); + }); + } + } catch (e) { + // Ignore any errors during cleanup + debugLog("Ignoring cleanup error:", e); + } + } + } finally { + // Restore console.error + console.error = originalConsoleError; + } + }, 15000); +}); diff --git a/typescript/tests/setup.ts b/typescript/tests/setup.ts new file mode 100644 index 0000000..7f2fa08 --- /dev/null +++ b/typescript/tests/setup.ts @@ -0,0 +1,7 @@ +import { jest } from "@jest/globals"; + +// Increase timeout for integration tests +jest.setTimeout(30000); + +// Add any global test setup here +process.env.NODE_ENV = "test"; diff --git a/typescript/tests/unit/decoder/decoder.test.ts b/typescript/tests/unit/decoder/decoder.test.ts new file mode 100644 index 0000000..bbdfcc6 --- /dev/null +++ b/typescript/tests/unit/decoder/decoder.test.ts @@ -0,0 +1,646 @@ +/** + * Unit Tests for Report Decoder + * + * These tests validate the report decoding functionality by: + * - Testing decoding of all supported report formats (V2-V10) + * - Validating error handling for malformed reports + * - Checking edge cases like empty reports and invalid versions + * + * Requirements: + * - No external dependencies or network access needed + * - Uses ethers.js AbiCoder for creating test report blobs + * - Fast execution with minimal setup + */ + +import { describe, it, expect } from "@jest/globals"; +import { + decodeReport, + DecodedV2Report, + DecodedV3Report, + DecodedV4Report, + DecodedV5Report, + DecodedV6Report, + DecodedV7Report, + DecodedV8Report, + DecodedV9Report, + DecodedV10Report, +} from "../../../src"; +import { AbiCoder } from "ethers"; + +const abiCoder = new AbiCoder(); + +// Create mock feed IDs +const mockV2FeedId = "0x0002" + "0".repeat(60); +const mockV3FeedId = "0x0003" + "1".repeat(60); +const mockV4FeedId = "0x0004" + "2".repeat(60); +const mockV5FeedId = "0x0005" + "2".repeat(60); +const mockV6FeedId = "0x0006" + "3".repeat(60); +const mockV7FeedId = "0x0007" + "4".repeat(60); +const mockV8FeedId = "0x0008" + "5".repeat(60); +const mockV9FeedId = "0x0009" + "6".repeat(60); +const mockV10FeedId = "0x000a" + "7".repeat(60); + +// Create a properly encoded full report +const mockReportContext = [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x0000000000000000000000000000000000000000000000000000000000000003", +]; + +// Create V2 report blob +const mockV2ReportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192"], + [ + mockV2FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, // 1 native token + 2000000000000000000n, // 2 LINK + Math.floor(Date.now() / 1000) + 3600, // expires in 1 hour + 50000000000000000000n, // $50 price + ] +); + +// Create V3 report blob +const mockV3ReportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "int192", "int192"], + [ + mockV3FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, // 1 native token + 2000000000000000000n, // 2 LINK + Math.floor(Date.now() / 1000) + 3600, // expires in 1 hour + 50000000000000000000n, // $50 price + 49000000000000000000n, // $49 bid + 51000000000000000000n, // $51 ask + ] +); + +// Create V4 report blob +const mockV4ReportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint8"], + [ + mockV4FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, // 1 native token + 2000000000000000000n, // 2 LINK + Math.floor(Date.now() / 1000) + 3600, // expires in 1 hour + 50000000000000000000n, // $50 price + 2, // ACTIVE market status + ] +); + +// Create V5 report blob +const mockV5ReportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint32", "uint32"], + [ + mockV5FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, + 2000000000000000000n, + Math.floor(Date.now() / 1000) + 3600, + 1234567890000000000n, + Math.floor(Date.now() / 1000), + 3600, + ] +); + +// Create V6 report blob +const mockV6ReportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "int192", "int192", "int192", "int192"], + [ + mockV6FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, + 2000000000000000000n, + Math.floor(Date.now() / 1000) + 3600, + 50000000000000000000n, + 51000000000000000000n, + 52000000000000000000n, + 53000000000000000000n, + 54000000000000000000n, + ] +); + +// Create V7 report blob +const mockV7ReportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192"], + [ + mockV7FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, + 2000000000000000000n, + Math.floor(Date.now() / 1000) + 3600, + 987654321000000000n, + ] +); + +// Create V8 report blob +const mockV8ReportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "uint64", "int192", "uint32"], + [ + mockV8FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, // 1 native token + 2000000000000000000n, // 2 LINK + Math.floor(Date.now() / 1000) + 3600, // expires in 1 hour + BigInt(Math.floor(Date.now() / 1000)), // lastUpdateTimestamp + 60000000000000000000n, // $60 midPrice + 2, // ACTIVE market status + ] +); + +// Create V9 report blob +const mockV9ReportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint64", "int192", "uint32"], + [ + mockV9FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, // 1 native token + 2000000000000000000n, // 2 LINK + Math.floor(Date.now() / 1000) + 3600, // expires in 1 hour + 1050000000000000000n, // $1.05 navPerShare + BigInt(Math.floor(Date.now() / 1000)), // navDate + 100000000000000000000000n, // $100k AUM + 0, // Normal ripcord (not paused) + ] +); + +// Create V10 report blob +const mockV10ReportBlob = abiCoder.encode( + [ + "bytes32", + "uint32", + "uint32", + "uint192", + "uint192", + "uint32", + "uint64", + "int192", + "uint32", + "int192", + "int192", + "uint32", + "int192", + ], + [ + mockV10FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, // 1 native token + 2000000000000000000n, // 2 LINK + Math.floor(Date.now() / 1000) + 3600, // expires in 1 hour + BigInt(Math.floor(Date.now() / 1000)), // lastUpdateTimestamp + 75000000000000000000n, // $75 price + 2, // ACTIVE market status + 1000000000000000000n, // 1.0 currentMultiplier (18 decimals) + 1100000000000000000n, // 1.1 newMultiplier (18 decimals) + Math.floor(Date.now() / 1000) + 86400, // activationDateTime (1 day later) + 150000000000000000000n, // $150 tokenizedPrice (2x the base price) + ] +); + +// Create full reports +const mockV2FullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + mockV2ReportBlob, + ["0x0000000000000000000000000000000000000000000000000000000000000004"], + ["0x0000000000000000000000000000000000000000000000000000000000000005"], + "0x0000000000000000000000000000000000000000000000000000000000000006", + ] +); + +const mockV3FullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + mockV3ReportBlob, + ["0x0000000000000000000000000000000000000000000000000000000000000004"], + ["0x0000000000000000000000000000000000000000000000000000000000000005"], + "0x0000000000000000000000000000000000000000000000000000000000000006", + ] +); + +const mockV4FullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + mockV4ReportBlob, + ["0x0000000000000000000000000000000000000000000000000000000000000004"], + ["0x0000000000000000000000000000000000000000000000000000000000000005"], + "0x0000000000000000000000000000000000000000000000000000000000000006", + ] +); + +const mockV5FullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + mockV5ReportBlob, + ["0x0000000000000000000000000000000000000000000000000000000000000007"], + ["0x0000000000000000000000000000000000000000000000000000000000000008"], + "0x0000000000000000000000000000000000000000000000000000000000000009", + ] +); + +const mockV6FullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + mockV6ReportBlob, + ["0x000000000000000000000000000000000000000000000000000000000000000a"], + ["0x000000000000000000000000000000000000000000000000000000000000000b"], + "0x000000000000000000000000000000000000000000000000000000000000000c", + ] +); + +const mockV7FullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + mockV7ReportBlob, + ["0x000000000000000000000000000000000000000000000000000000000000000d"], + ["0x000000000000000000000000000000000000000000000000000000000000000e"], + "0x000000000000000000000000000000000000000000000000000000000000000f", + ] +); + +const mockV8FullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + mockV8ReportBlob, + ["0x0000000000000000000000000000000000000000000000000000000000000007"], + ["0x0000000000000000000000000000000000000000000000000000000000000008"], + "0x0000000000000000000000000000000000000000000000000000000000000009", + ] +); + +const mockV9FullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + mockV9ReportBlob, + ["0x000000000000000000000000000000000000000000000000000000000000000a"], + ["0x000000000000000000000000000000000000000000000000000000000000000b"], + "0x000000000000000000000000000000000000000000000000000000000000000c", + ] +); + +const mockV10FullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + mockV10ReportBlob, + ["0x000000000000000000000000000000000000000000000000000000000000000d"], + ["0x000000000000000000000000000000000000000000000000000000000000000e"], + "0x000000000000000000000000000000000000000000000000000000000000000f", + ] +); + +describe("Report Decoder", () => { + describe("v2 reports", () => { + it("should decode valid v2 report", () => { + const decoded = decodeReport(mockV2FullReport, mockV2FeedId) as DecodedV2Report; + expect(decoded).toBeDefined(); + expect(decoded.version).toBe("V2"); + expect(decoded.price).toBeDefined(); + }); + }); + + describe("v3 reports", () => { + it("should decode valid v3 report", () => { + const decoded = decodeReport(mockV3FullReport, mockV3FeedId) as DecodedV3Report; + + expect(decoded).toBeDefined(); + expect(decoded.version).toBe("V3"); + expect(decoded.nativeFee).toBeDefined(); + expect(decoded.linkFee).toBeDefined(); + expect(decoded.expiresAt).toBeDefined(); + expect(decoded.price).toBeDefined(); + expect(decoded.bid).toBeDefined(); + expect(decoded.ask).toBeDefined(); + }); + + it("should handle malformed v3 report", () => { + const malformedReport = "0xinvalid"; + expect(() => decodeReport(malformedReport, mockV3FeedId)).toThrow(); + }); + }); + + describe("v4 reports", () => { + it("should decode valid v4 report", () => { + const decoded = decodeReport(mockV4FullReport, mockV4FeedId) as DecodedV4Report; + + expect(decoded).toBeDefined(); + expect(decoded.version).toBe("V4"); + expect(decoded.nativeFee).toBeDefined(); + expect(decoded.linkFee).toBeDefined(); + expect(decoded.expiresAt).toBeDefined(); + expect(decoded.price).toBeDefined(); + expect(decoded.marketStatus).toBeDefined(); + }); + + it("should handle malformed v4 report", () => { + const malformedReport = "0xinvalid"; + expect(() => decodeReport(malformedReport, mockV4FeedId)).toThrow(); + }); + }); + + describe("v5 reports", () => { + it("should decode valid v5 report", () => { + const decoded = decodeReport(mockV5FullReport, mockV5FeedId) as DecodedV5Report; + + expect(decoded).toBeDefined(); + expect(decoded.version).toBe("V5"); + expect(decoded.nativeFee).toBeDefined(); + expect(decoded.linkFee).toBeDefined(); + expect(decoded.expiresAt).toBeDefined(); + expect(decoded.rate).toBeDefined(); + expect(decoded.timestamp).toBeDefined(); + expect(decoded.duration).toBeDefined(); + }); + }); + + describe("v6 reports", () => { + it("should decode valid v6 report", () => { + const decoded = decodeReport(mockV6FullReport, mockV6FeedId) as DecodedV6Report; + expect(decoded).toBeDefined(); + expect(decoded.version).toBe("V6"); + expect((decoded as any).price).toBeDefined(); + expect(decoded.price2).toBeDefined(); + expect(decoded.price3).toBeDefined(); + expect(decoded.price4).toBeDefined(); + expect(decoded.price5).toBeDefined(); + }); + }); + + describe("v7 reports", () => { + it("should decode valid v7 report", () => { + const decoded = decodeReport(mockV7FullReport, mockV7FeedId) as DecodedV7Report; + expect(decoded).toBeDefined(); + expect(decoded.version).toBe("V7"); + expect(decoded.exchangeRate).toBeDefined(); + }); + }); + + describe("v8 reports", () => { + it("should decode valid v8 report", () => { + const decoded = decodeReport(mockV8FullReport, mockV8FeedId) as DecodedV8Report; + + expect(decoded).toBeDefined(); + expect(decoded.version).toBe("V8"); + expect(decoded.nativeFee).toBeDefined(); + expect(decoded.linkFee).toBeDefined(); + expect(decoded.expiresAt).toBeDefined(); + expect(decoded.midPrice).toBeDefined(); + expect(decoded.lastUpdateTimestamp).toBeDefined(); + expect(decoded.marketStatus).toBeDefined(); + }); + + it("should handle malformed v8 report", () => { + const malformedReport = "0xinvalid"; + expect(() => decodeReport(malformedReport, mockV8FeedId)).toThrow(); + }); + + it("should validate market status for v8 reports", () => { + // Create invalid market status blob + const invalidMarketStatusBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "uint64", "int192", "uint32"], + [ + mockV8FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, + 2000000000000000000n, + Math.floor(Date.now() / 1000) + 3600, + BigInt(Math.floor(Date.now() / 1000)), + 60000000000000000000n, + 99, // Invalid market status + ] + ); + + const invalidFullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + invalidMarketStatusBlob, + ["0x0000000000000000000000000000000000000000000000000000000000000007"], + ["0x0000000000000000000000000000000000000000000000000000000000000008"], + "0x0000000000000000000000000000000000000000000000000000000000000009", + ] + ); + + expect(() => decodeReport(invalidFullReport, mockV8FeedId)).toThrow("Invalid market status"); + }); + }); + + describe("v9 reports", () => { + it("should decode valid v9 report", () => { + const decoded = decodeReport(mockV9FullReport, mockV9FeedId) as DecodedV9Report; + + expect(decoded).toBeDefined(); + expect(decoded.version).toBe("V9"); + expect(decoded.nativeFee).toBeDefined(); + expect(decoded.linkFee).toBeDefined(); + expect(decoded.expiresAt).toBeDefined(); + expect(decoded.navPerShare).toBeDefined(); + expect(decoded.navDate).toBeDefined(); + expect(decoded.aum).toBeDefined(); + expect(decoded.ripcord).toBeDefined(); + }); + + it("should handle malformed v9 report", () => { + const malformedReport = "0xinvalid"; + expect(() => decodeReport(malformedReport, mockV9FeedId)).toThrow(); + }); + + it("should handle ripcord flag validation for v9 reports", () => { + // Test normal ripcord (0) + const normalDecoded = decodeReport(mockV9FullReport, mockV9FeedId) as DecodedV9Report; + expect(normalDecoded.ripcord).toBe(0); + + // Test paused ripcord (1) + const pausedRipcordBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint64", "int192", "uint32"], + [ + mockV9FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, + 2000000000000000000n, + Math.floor(Date.now() / 1000) + 3600, + 1050000000000000000n, + BigInt(Math.floor(Date.now() / 1000)), + 100000000000000000000000n, + 1, // Paused ripcord + ] + ); + + const pausedFullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + pausedRipcordBlob, + ["0x000000000000000000000000000000000000000000000000000000000000000a"], + ["0x000000000000000000000000000000000000000000000000000000000000000b"], + "0x000000000000000000000000000000000000000000000000000000000000000c", + ] + ); + + const pausedDecoded = decodeReport(pausedFullReport, mockV9FeedId) as DecodedV9Report; + expect(pausedDecoded.ripcord).toBe(1); + }); + + it("should reject invalid ripcord values for v9 reports", () => { + // Test invalid ripcord (2) - should throw error + const invalidRipcordBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint64", "int192", "uint32"], + [ + mockV9FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, + 2000000000000000000n, + Math.floor(Date.now() / 1000) + 3600, + 1050000000000000000n, + BigInt(Math.floor(Date.now() / 1000)), + 100000000000000000000000n, + 2, // Invalid ripcord value + ] + ); + + const invalidFullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + invalidRipcordBlob, + ["0x000000000000000000000000000000000000000000000000000000000000000a"], + ["0x000000000000000000000000000000000000000000000000000000000000000b"], + "0x000000000000000000000000000000000000000000000000000000000000000c", + ] + ); + + expect(() => decodeReport(invalidFullReport, mockV9FeedId)).toThrow( + "Invalid ripcord value: 2. Must be 0 (normal) or 1 (paused)" + ); + }); + }); + + describe("v10 reports", () => { + it("should decode valid v10 report", () => { + const decoded = decodeReport(mockV10FullReport, mockV10FeedId) as DecodedV10Report; + + expect(decoded).toBeDefined(); + expect(decoded.version).toBe("V10"); + expect(decoded.nativeFee).toBeDefined(); + expect(decoded.linkFee).toBeDefined(); + expect(decoded.expiresAt).toBeDefined(); + expect(decoded.price).toBeDefined(); + expect(decoded.lastUpdateTimestamp).toBeDefined(); + expect(decoded.marketStatus).toBeDefined(); + expect(decoded.currentMultiplier).toBeDefined(); + expect(decoded.newMultiplier).toBeDefined(); + expect(decoded.activationDateTime).toBeDefined(); + expect(decoded.tokenizedPrice).toBeDefined(); + }); + + it("should handle malformed v10 report", () => { + const malformedReport = "0xinvalid"; + expect(() => decodeReport(malformedReport, mockV10FeedId)).toThrow(); + }); + + it("should validate market status for v10 reports", () => { + // Create invalid market status blob + const invalidMarketStatusBlob = abiCoder.encode( + [ + "bytes32", + "uint32", + "uint32", + "uint192", + "uint192", + "uint32", + "uint64", + "int192", + "uint32", + "int192", + "int192", + "uint32", + "int192", + ], + [ + mockV10FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, + 2000000000000000000n, + Math.floor(Date.now() / 1000) + 3600, + BigInt(Math.floor(Date.now() / 1000)), + 75000000000000000000n, + 99, // Invalid market status + 1000000000000000000n, + 1100000000000000000n, + Math.floor(Date.now() / 1000) + 86400, + 150000000000000000000n, + ] + ); + + const invalidFullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + invalidMarketStatusBlob, + ["0x000000000000000000000000000000000000000000000000000000000000000d"], + ["0x000000000000000000000000000000000000000000000000000000000000000e"], + "0x000000000000000000000000000000000000000000000000000000000000000f", + ] + ); + + expect(() => decodeReport(invalidFullReport, mockV10FeedId)).toThrow("Invalid market status"); + }); + + it("should decode all v10 fields correctly", () => { + const decoded = decodeReport(mockV10FullReport, mockV10FeedId) as DecodedV10Report; + + // Verify all numeric fields are properly parsed + expect(typeof decoded.lastUpdateTimestamp).toBe("number"); + expect(typeof decoded.marketStatus).toBe("number"); + expect(typeof decoded.activationDateTime).toBe("number"); + expect(typeof decoded.price).toBe("bigint"); + expect(typeof decoded.currentMultiplier).toBe("bigint"); + expect(typeof decoded.newMultiplier).toBe("bigint"); + expect(typeof decoded.tokenizedPrice).toBe("bigint"); + + // Verify market status is valid + expect([0, 1, 2]).toContain(decoded.marketStatus); + + expect(decoded.currentMultiplier).toBeGreaterThan(0n); + expect(decoded.newMultiplier).toBeGreaterThan(0n); + expect(decoded.currentMultiplier).not.toBe(decoded.newMultiplier); + }); + }); + + describe("edge cases", () => { + it("should handle empty report", () => { + expect(() => decodeReport("", mockV3FeedId)).toThrow(); + }); + + it("should handle non-hex input", () => { + expect(() => decodeReport("not-hex", mockV3FeedId)).toThrow(); + }); + + it("should handle invalid version", () => { + const invalidFeedId = "0x0009" + "1".repeat(60); + expect(() => decodeReport(mockV3FullReport, invalidFeedId)).toThrow(); + }); + }); +}); diff --git a/typescript/tests/unit/stream/connection-manager/auth-handshake.test.ts b/typescript/tests/unit/stream/connection-manager/auth-handshake.test.ts new file mode 100644 index 0000000..a6f6742 --- /dev/null +++ b/typescript/tests/unit/stream/connection-manager/auth-handshake.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import { ConnectionManager } from "../../../../src/stream/connection-manager"; +import { Config } from "../../../../src/types/client"; +import * as originDiscovery from "../../../../src/utils/origin-discovery"; + +type Handler = (...args: unknown[]) => void; + +// Store event handlers registered via ws.once / ws.on +const onceHandlers: Record = {}; +const onHandlers: Record = {}; + +// Mock WebSocket instance/constructor +const mockWsInstance = { + on: jest.fn((...args: unknown[]) => { + const [event, cb] = args as [string, Handler]; + onHandlers[event] = cb; + }), + once: jest.fn((...args: unknown[]) => { + const [event, cb] = args as [string, Handler]; + onceHandlers[event] = cb; + }), + terminate: jest.fn(), + close: jest.fn(), + get readyState() { + return 1; // OPEN + }, +}; + +jest.mock("ws", () => { + const ctor = jest.fn(() => mockWsInstance); + (ctor as unknown as { OPEN: number; CONNECTING: number }).OPEN = 1; + (ctor as unknown as { OPEN: number; CONNECTING: number }).CONNECTING = 0; + return { __esModule: true, default: ctor }; +}); + +jest.mock("../../../../src/utils/origin-discovery", () => ({ + getAvailableOrigins: jest.fn(), +})); + +describe("ConnectionManager - auth handshake errors", () => { + let manager: ConnectionManager; + let config: Config; + + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(onceHandlers).forEach(k => delete onceHandlers[k]); + Object.keys(onHandlers).forEach(k => delete onHandlers[k]); + + config = { + apiKey: "key", + userSecret: "secret", + endpoint: "https://api.example.com", + wsEndpoint: "wss://ws.example.com", + haMode: false, + }; + + manager = new ConnectionManager(config, { + feedIds: ["0x0003" + "1".repeat(60)], + maxReconnectAttempts: 2, + reconnectInterval: 200, + connectTimeout: 200, + haMode: false, + haConnectionTimeout: 200, + }); + + ( + originDiscovery.getAvailableOrigins as jest.MockedFunction + ).mockResolvedValue(["wss://ws.example.com"]); + }); + + it("rejects with WebSocketError on 401 unexpected-response during handshake", async () => { + const initPromise = manager.initialize(); + // Allow event subscriptions to be registered + await new Promise(resolve => setTimeout(resolve, 0)); + + // Simulate handshake failure via 'unexpected-response' + const handler = onceHandlers["unexpected-response"]; + expect(typeof handler).toBe("function"); + if (handler) { + handler({}, { statusCode: 401 }); + } + + await expect(initPromise).rejects.toThrow( + /Failed to initialize connections: Failed to establish any WebSocket connections/ + ); + }); + + it("rejects with WebSocketError on 403 unexpected-response during handshake", async () => { + const initPromise = manager.initialize(); + await new Promise(resolve => setTimeout(resolve, 0)); + + // Simulate handshake failure via 'unexpected-response' + const handler = onceHandlers["unexpected-response"]; + expect(typeof handler).toBe("function"); + if (handler) { + handler({}, { statusCode: 403 }); + } + + await expect(initPromise).rejects.toThrow( + /Failed to initialize connections: Failed to establish any WebSocket connections/ + ); + }); +}); diff --git a/typescript/tests/unit/stream/connection-manager/connection-manager.test.ts b/typescript/tests/unit/stream/connection-manager/connection-manager.test.ts new file mode 100644 index 0000000..ff7cfa4 --- /dev/null +++ b/typescript/tests/unit/stream/connection-manager/connection-manager.test.ts @@ -0,0 +1,161 @@ +import { ConnectionManager } from "../../../../src/stream/connection-manager"; +import { Config } from "../../../../src/types/client"; +import * as originDiscovery from "../../../../src/utils/origin-discovery"; + +// Mock the WebSocket +const mockWebSocket = { + on: jest.fn(), + once: jest.fn(), + send: jest.fn(), + close: jest.fn(), + terminate: jest.fn(), + readyState: 1, // OPEN +}; + +// Mock the WebSocket constructor +jest.mock("ws", () => ({ + __esModule: true, + default: jest.fn(() => mockWebSocket), +})); + +// Mock origin discovery +jest.mock("../../../../src/utils/origin-discovery", () => ({ + getAvailableOrigins: jest.fn(), +})); + +describe("ConnectionManager", () => { + let mockConfig: Config; + let mockManagerConfig: any; + + beforeEach(() => { + jest.clearAllMocks(); + + mockConfig = { + apiKey: "test-api-key", + userSecret: "test-user-secret", + endpoint: "https://test.example.com", + wsEndpoint: "wss://test.example.com", + haMode: true, + haConnectionTimeout: 5000, + }; + + mockManagerConfig = { + feedIds: ["0x123", "0x456"], + maxReconnectAttempts: 3, + reconnectInterval: 1000, + connectTimeout: 5000, + haMode: true, + haConnectionTimeout: 5000, + statusCallback: jest.fn(), + }; + }); + + describe("basic functionality", () => { + it("should create instance and get initial connection counts", () => { + const connectionManager = new ConnectionManager(mockConfig, mockManagerConfig); + + expect(connectionManager.getConfiguredConnectionCount()).toBe(0); + expect(connectionManager.getActiveConnectionCount()).toBe(0); + }); + + it("should initialize with multiple origins in HA mode", async () => { + const connectionManager = new ConnectionManager(mockConfig, mockManagerConfig); + const mockOrigins = ["wss://origin1.example.com", "wss://origin2.example.com"]; + (originDiscovery.getAvailableOrigins as jest.Mock).mockResolvedValue(mockOrigins); + + // Mock successful WebSocket connections + mockWebSocket.on.mockImplementation((event, callback) => { + if (event === "open") { + // Simulate immediate connection + setTimeout(() => callback(), 1); + } + }); + + await connectionManager.initialize(); + + expect(connectionManager.getConfiguredConnectionCount()).toBe(2); + }); + + it("should handle single origin fallback", async () => { + const connectionManager = new ConnectionManager(mockConfig, mockManagerConfig); + const mockOrigins = ["wss://single-origin.example.com"]; + (originDiscovery.getAvailableOrigins as jest.Mock).mockResolvedValue(mockOrigins); + + mockWebSocket.on.mockImplementation((event, callback) => { + if (event === "open") { + setTimeout(() => callback(), 1); + } + }); + + await connectionManager.initialize(); + + expect(connectionManager.getConfiguredConnectionCount()).toBe(1); + }); + + it("should handle origin discovery failure", async () => { + const connectionManager = new ConnectionManager(mockConfig, mockManagerConfig); + (originDiscovery.getAvailableOrigins as jest.Mock).mockRejectedValue(new Error("Discovery failed")); + + await expect(connectionManager.initialize()).rejects.toThrow("Discovery failed"); + }); + + it("should shutdown connections gracefully", async () => { + const connectionManager = new ConnectionManager(mockConfig, mockManagerConfig); + const mockOrigins = ["wss://origin1.example.com"]; + (originDiscovery.getAvailableOrigins as jest.Mock).mockResolvedValue(mockOrigins); + + // Mock WebSocket with immediate open callback + mockWebSocket.on.mockImplementation((event, callback) => { + if (event === "open") { + // Call immediately to ensure connection is established + callback(); + } + }); + + await connectionManager.initialize(); + + // Verify connection was established + expect(connectionManager.getActiveConnectionCount()).toBe(1); + + // Test that shutdown completes without throwing + await expect(connectionManager.shutdown()).resolves.not.toThrow(); + + // Verify all connections are cleared + expect(connectionManager.getConfiguredConnectionCount()).toBe(0); + expect(connectionManager.getActiveConnectionCount()).toBe(0); + }); + }); + + describe("connection status", () => { + it("should track connection states", () => { + const connectionManager = new ConnectionManager(mockConfig, mockManagerConfig); + + // Initially no connections + expect(connectionManager.getActiveConnectionCount()).toBe(0); + expect(connectionManager.getConfiguredConnectionCount()).toBe(0); + }); + + it("should handle status callbacks", async () => { + const statusCallback = jest.fn(); + const configWithCallback = { + ...mockManagerConfig, + statusCallback, + }; + + const connectionManager = new ConnectionManager(mockConfig, configWithCallback); + const mockOrigins = ["wss://origin1.example.com"]; + (originDiscovery.getAvailableOrigins as jest.Mock).mockResolvedValue(mockOrigins); + + mockWebSocket.on.mockImplementation((event, callback) => { + if (event === "open") { + setTimeout(() => callback(), 1); + } + }); + + await connectionManager.initialize(); + + // Should have called status callback for connection + expect(statusCallback).toHaveBeenCalled(); + }); + }); +}); diff --git a/typescript/tests/unit/stream/connection-manager/health.test.ts b/typescript/tests/unit/stream/connection-manager/health.test.ts new file mode 100644 index 0000000..3561200 --- /dev/null +++ b/typescript/tests/unit/stream/connection-manager/health.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import { WS_CONSTANTS } from "../../../../src/utils/constants"; +import { ConnectionManager } from "../../../../src/stream/connection-manager"; +import { Config } from "../../../../src/types/client"; +import * as originDiscovery from "../../../../src/utils/origin-discovery"; + +// Event handler type for ws events +type WsHandler = (...args: unknown[]) => void; + +// Mock WebSocket instance +const mockWsInstance = { + on: jest.fn(), + once: jest.fn(), + ping: jest.fn(), + pong: jest.fn(), + terminate: jest.fn(), + get readyState() { + return 1; + }, // WebSocket.OPEN +}; + +jest.mock("ws", () => { + const ctor = jest.fn(() => mockWsInstance); + // Provide static constants used by the implementation for readyState checks + // e.g., WebSocket.OPEN and WebSocket.CONNECTING + (ctor as unknown as { OPEN: number; CONNECTING: number }).OPEN = 1; + (ctor as unknown as { OPEN: number; CONNECTING: number }).CONNECTING = 0; + return { + __esModule: true, + default: ctor, + }; +}); + +// Mock origin discovery +jest.mock("../../../../src/utils/origin-discovery", () => ({ + getAvailableOrigins: jest.fn(), +})); + +describe("ConnectionManager - ping/pong health", () => { + let manager: ConnectionManager; + let config: Config; + + beforeEach(() => { + jest.clearAllMocks(); + + config = { + apiKey: "key", + userSecret: "secret", + endpoint: "https://api.example.com", + wsEndpoint: "wss://ws.example.com", + haMode: false, + }; + + manager = new ConnectionManager(config, { + feedIds: ["0x0003" + "1".repeat(60)], + maxReconnectAttempts: 3, + reconnectInterval: 200, + connectTimeout: 200, + haMode: false, + haConnectionTimeout: 200, + }); + + // Mock the origin discovery function to return immediately + ( + originDiscovery.getAvailableOrigins as jest.MockedFunction + ).mockResolvedValue(["wss://ws.example.com"]); + }); + + it("sets up ping/pong health monitoring after connection", async () => { + // Mock successful WebSocket connection + mockWsInstance.on.mockImplementation((...args: unknown[]) => { + const [event, callback] = args as [string, WsHandler]; + if (event === "open") { + // Simulate immediate connection + setTimeout(() => callback(), 1); + } + }); + + await manager.initialize(); + + // Verify that ping and pong event handlers are set up + expect(mockWsInstance.on).toHaveBeenCalledWith("ping", expect.any(Function)); + expect(mockWsInstance.on).toHaveBeenCalledWith("pong", expect.any(Function)); + }); + + it("responds with pong when server sends ping", async () => { + const pongSpy = jest.fn(); + // Override the pong method to capture calls + mockWsInstance.pong = pongSpy; + + // Mock successful WebSocket connection; record all subscriptions + mockWsInstance.on.mockImplementation((...args: unknown[]) => { + const [event, callback] = args as [string, WsHandler]; + if (event === "open") { + // Simulate immediate connection + setTimeout(() => callback(), 1); + } + }); + + await manager.initialize(); + + // Extract the registered ping handler directly from mock.calls + const pingCall = (mockWsInstance.on.mock.calls as unknown[] as [string, WsHandler][]) + .reverse() + .find(([evt]) => evt === "ping"); + expect(pingCall).toBeDefined(); + const pingHandler = pingCall && pingCall[1]; + expect(typeof pingHandler).toBe("function"); + + // Trigger ping + if (pingHandler) { + pingHandler(Buffer.from("data")); + } + + expect(pongSpy).toHaveBeenCalled(); + }); + + it("terminates connection on pong timeout and schedules reconnection", async () => { + jest.useFakeTimers(); + + let closeHandler: WsHandler | undefined; + + // Mock successful WebSocket connection and capture close handler + mockWsInstance.on.mockImplementation((...args: unknown[]) => { + const [event, callback] = args as [string, WsHandler]; + if (event === "open") { + // With fake timers active, invoke immediately to resolve initialize() + callback(); + } else if (event === "close") { + closeHandler = callback; + } + }); + + const reconnectingSpy = jest.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + manager as any, + "emit" + ); + + await manager.initialize(); + + // Advance timers to trigger a ping, then the pong timeout + jest.advanceTimersByTime(WS_CONSTANTS.PING_INTERVAL + WS_CONSTANTS.PONG_TIMEOUT + 1); + + // Connection should be terminated due to missing pong + expect(mockWsInstance.terminate).toHaveBeenCalledTimes(1); + + // Simulate the underlying socket closing to trigger reconnection scheduling + if (closeHandler) { + closeHandler(); + } + + // Reconnection should be scheduled (reconnecting event emitted with info object) + const calls = reconnectingSpy.mock.calls as unknown[] as unknown[][]; + const reconnectingCall = calls.find(call => call[0] === "reconnecting"); + expect(reconnectingCall).toBeDefined(); + const info = reconnectingCall && (reconnectingCall[1] as Record); + expect(info).toBeDefined(); + if (info) { + expect(typeof info.attempt).toBe("number"); + expect(typeof info.delayMs).toBe("number"); + expect(typeof info.origin).toBe("string"); + expect(typeof info.host).toBe("string"); + } + + jest.useRealTimers(); + }); +}); diff --git a/typescript/tests/unit/stream/connection-manager/max-attempts.test.ts b/typescript/tests/unit/stream/connection-manager/max-attempts.test.ts new file mode 100644 index 0000000..d426154 --- /dev/null +++ b/typescript/tests/unit/stream/connection-manager/max-attempts.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import { ConnectionManager } from "../../../../src/stream/connection-manager"; +import { Config } from "../../../../src/types/client"; + +// No WebSocket connections needed for this test; we exercise internal scheduling logic + +describe("ConnectionManager - max reconnect attempts (terminal state)", () => { + let manager: ConnectionManager; + let config: Config; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + config = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.example.com", + wsEndpoint: "wss://ws.example.com", + haMode: false, + }; + + manager = new ConnectionManager(config, { + feedIds: ["0x0003" + "1".repeat(60)], + maxReconnectAttempts: 1, // small to hit terminal quickly + reconnectInterval: 200, // base (doesn't matter, timers are mocked) + connectTimeout: 500, + haMode: false, + haConnectionTimeout: 500, + }); + }); + + it("emits max-reconnect-attempts-reached and all-connections-lost, and sets state to FAILED", () => { + // Create a fake managed connection and insert into manager internals + const connection: { + id: string; + origin: string; + host: string; + ws: unknown; + state: string; + reconnectAttempts: number; + } = { + id: "conn-0", + origin: "wss://ws.example.com", + host: "ws.example.com", + ws: null, + state: "disconnected", + reconnectAttempts: 0, + }; + + // Inject into internal map + (manager as unknown as { connections: Map }).connections.set(connection.id, connection); + + // Spy on emit to capture events + const emitSpy = jest.spyOn( + manager as unknown as { + emit: (...args: unknown[]) => void; + }, + "emit" + ); + + // Call private method via any to schedule reconnection + (manager as unknown as { scheduleReconnection: (c: typeof connection) => void }).scheduleReconnection(connection); + + // First schedule increments attempts to 1 and sets a timeout; run timers + jest.runOnlyPendingTimers(); + + // Verify terminal events were emitted + const emittedEvents = emitSpy.mock.calls.map((call: unknown[]) => (call as unknown[])[0]); + expect(emittedEvents).toContain("max-reconnect-attempts-reached"); + expect(emittedEvents).toContain("all-connections-lost"); + + // Verify state transitioned to FAILED + expect(connection.state).toBe("failed"); + }); +}); diff --git a/typescript/tests/unit/stream/connection-manager/partial-failure.test.ts b/typescript/tests/unit/stream/connection-manager/partial-failure.test.ts new file mode 100644 index 0000000..8983100 --- /dev/null +++ b/typescript/tests/unit/stream/connection-manager/partial-failure.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import { ConnectionManager } from "../../../../src/stream/connection-manager"; +import { Config } from "../../../../src/types/client"; +import WebSocket from "ws"; + +// Mock ws to simulate connection outcomes per origin +type WsHandler = (...args: unknown[]) => void; + +const wsMocks: unknown[] = []; + +jest.mock("ws", () => ({ + __esModule: true, + default: jest.fn(() => { + const instance = { + on: jest.fn((_event: string, _cb: WsHandler) => {}), + once: jest.fn((_event: string, _cb: WsHandler) => {}), + terminate: jest.fn(), + close: jest.fn(), + readyState: 1, + }; + wsMocks.push(instance); + return instance; + }), +})); + +// Mock origin discovery to return multiple origins +jest.mock("../../../../src/utils/origin-discovery", () => { + return { + getAvailableOrigins: (..._args: unknown[]) => + Promise.resolve(["wss://o1.example.com", "wss://o2.example.com", "wss://o3.example.com"]), + }; +}); + +describe("ConnectionManager - partial failure emission", () => { + beforeEach(() => { + jest.clearAllMocks(); + wsMocks.length = 0; + }); + + it("emits partial-failure when some origins fail to establish", async () => { + const config: Config = { + apiKey: "k", + userSecret: "s", + endpoint: "https://api.example.com", + wsEndpoint: "wss://o1.example.com,wss://o2.example.com,wss://o3.example.com", + haMode: true, + }; + + const manager = new ConnectionManager(config, { + feedIds: ["0x0003" + "1".repeat(60)], + maxReconnectAttempts: 2, + reconnectInterval: 200, + connectTimeout: 200, + haMode: true, + haConnectionTimeout: 200, + }); + + const emitSpy = jest.spyOn( + manager as unknown as { + emit: (...args: unknown[]) => void; + }, + "emit" + ); + + // Configure ws instances so that some fail during establishConnection + // We simulate failure by triggering 'error' before 'open' for some sockets. + (WebSocket as unknown as jest.Mock).mockImplementationOnce((): unknown => { + const ws = { + on: jest.fn((event: string, cb: WsHandler) => { + if (event === "error") setTimeout(() => cb(new Error("fail o1")), 0); + }), + once: jest.fn((_event: string, _cb: WsHandler) => {}), + terminate: jest.fn(), + close: jest.fn(), + readyState: 1, + }; + return ws; + }); + + (WebSocket as unknown as jest.Mock).mockImplementationOnce((): unknown => { + const ws = { + on: jest.fn((event: string, cb: WsHandler) => { + if (event === "open") setTimeout(() => cb(), 0); + }), + once: jest.fn((_event: string, _cb: WsHandler) => {}), + terminate: jest.fn(), + close: jest.fn(), + readyState: 1, + }; + return ws; + }); + + (WebSocket as unknown as jest.Mock).mockImplementationOnce((): unknown => { + const ws = { + on: jest.fn((event: string, cb: WsHandler) => { + if (event === "error") setTimeout(() => cb(new Error("fail o3")), 0); + }), + once: jest.fn((_event: string, _cb: WsHandler) => {}), + terminate: jest.fn(), + close: jest.fn(), + readyState: 1, + }; + return ws; + }); + + await manager.initialize().catch(() => {}); + + // partial-failure should be emitted with failed and total counts + const calls = emitSpy.mock.calls.filter((call: unknown[]) => (call as unknown[])[0] === "partial-failure"); + expect(calls.length).toBeGreaterThan(0); + const first = calls[0] as unknown[]; + expect(first[0]).toBe("partial-failure"); + expect(typeof first[1]).toBe("number"); + expect(typeof first[2]).toBe("number"); + }); +}); diff --git a/typescript/tests/unit/stream/connection-manager/status-callback.test.ts b/typescript/tests/unit/stream/connection-manager/status-callback.test.ts new file mode 100644 index 0000000..c5906b5 --- /dev/null +++ b/typescript/tests/unit/stream/connection-manager/status-callback.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import { ConnectionManager } from "../../../../src/stream/connection-manager"; +import { Config } from "../../../../src/types/client"; +import * as originDiscovery from "../../../../src/utils/origin-discovery"; + +type WsHandler = (...args: unknown[]) => void; + +// Mock WebSocket instance +const wsMock = { + on: jest.fn(), + once: jest.fn(), + readyState: 1, + ping: jest.fn(), + pong: jest.fn(), + terminate: jest.fn(), +}; + +jest.mock("ws", () => ({ + __esModule: true, + default: jest.fn(() => wsMock), +})); + +jest.mock("../../../../src/utils/origin-discovery", () => ({ + getAvailableOrigins: jest.fn(), +})); + +describe("ConnectionManager - statusCallback de-duplication", () => { + let manager: ConnectionManager; + let config: Config; + let statusCallback: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + statusCallback = jest.fn(); + + config = { + apiKey: "key", + userSecret: "secret", + endpoint: "https://api.example.com", + wsEndpoint: "wss://ws.example.com", + haMode: false, + connectionStatusCallback: statusCallback, + } as Config; + + manager = new ConnectionManager(config, { + feedIds: ["0x0003" + "1".repeat(60)], + maxReconnectAttempts: 2, + reconnectInterval: 200, + connectTimeout: 200, + haMode: false, + haConnectionTimeout: 200, + statusCallback, + }); + + // Mock the origin discovery function to return immediately + ( + originDiscovery.getAvailableOrigins as jest.MockedFunction + ).mockResolvedValue(["wss://ws.example.com"]); + }); + + it("invokes callback once for connect and once for disconnect", async () => { + let closeHandler: WsHandler | undefined; + + // Mock successful WebSocket connection and capture close handler + wsMock.on.mockImplementation((...args: unknown[]) => { + const [event, callback] = args as [string, WsHandler]; + if (event === "open") { + // Simulate immediate connection + setTimeout(() => callback(), 1); + } else if (event === "close") { + closeHandler = callback; + } + }); + + await manager.initialize(); + + expect(statusCallback).toHaveBeenCalledTimes(1); + expect(statusCallback).toHaveBeenLastCalledWith(true, expect.any(String), expect.any(String)); + + // Trigger close event + if (closeHandler) { + closeHandler(); + } + + // After close, callback should be called once more with false + expect(statusCallback).toHaveBeenCalledTimes(2); + expect(statusCallback).toHaveBeenLastCalledWith(false, expect.any(String), expect.any(String)); + }); +}); diff --git a/typescript/tests/unit/stream/deduplication.test.ts b/typescript/tests/unit/stream/deduplication.test.ts new file mode 100644 index 0000000..2ca11b5 --- /dev/null +++ b/typescript/tests/unit/stream/deduplication.test.ts @@ -0,0 +1,524 @@ +import { ReportDeduplicator, ReportMetadata } from "../../../src/stream/deduplication"; + +describe("ReportDeduplicator", () => { + let deduplicator: ReportDeduplicator; + + beforeEach(() => { + deduplicator = new ReportDeduplicator(); + }); + + afterEach(() => { + deduplicator.stop(); + }); + + describe("basic deduplication", () => { + it("should allow first report for a feed", () => { + const report: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 1000, + fullReport: "report-data", + validFromTimestamp: 900, + }; + + const result = deduplicator.processReport(report); + expect(result.isAccepted).toBe(true); + expect(result.isDuplicate).toBe(false); + }); + + it("should reject duplicate reports with same timestamp", () => { + const report: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 1000, + fullReport: "report-data", + validFromTimestamp: 900, + }; + + // First report should be accepted + const result1 = deduplicator.processReport(report); + expect(result1.isAccepted).toBe(true); + expect(result1.isDuplicate).toBe(false); + + // Duplicate should be rejected + const result2 = deduplicator.processReport(report); + expect(result2.isAccepted).toBe(false); + expect(result2.isDuplicate).toBe(true); + expect(result2.reason).toContain("watermark"); + }); + + it("should reject reports with older timestamps", () => { + const newerReport: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 2000, + fullReport: "newer-report", + validFromTimestamp: 1900, + }; + + const olderReport: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 1000, + fullReport: "older-report", + validFromTimestamp: 900, + }; + + // Accept newer report first + const result1 = deduplicator.processReport(newerReport); + expect(result1.isAccepted).toBe(true); + + // Reject older report + const result2 = deduplicator.processReport(olderReport); + expect(result2.isAccepted).toBe(false); + expect(result2.isDuplicate).toBe(true); + }); + + it("should accept reports with newer timestamps", () => { + const olderReport: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 1000, + fullReport: "older-report", + validFromTimestamp: 900, + }; + + const newerReport: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 2000, + fullReport: "newer-report", + validFromTimestamp: 1900, + }; + + // Accept older report first + const result1 = deduplicator.processReport(olderReport); + expect(result1.isAccepted).toBe(true); + + // Accept newer report + const result2 = deduplicator.processReport(newerReport); + expect(result2.isAccepted).toBe(true); + expect(result2.isDuplicate).toBe(false); + }); + }); + + describe("multi-feed handling", () => { + it("should handle multiple feeds independently", () => { + const report1: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 1000, + fullReport: "report1", + validFromTimestamp: 900, + }; + + const report2: ReportMetadata = { + feedID: "0x456", + observationsTimestamp: 1000, // Same timestamp, different feed + fullReport: "report2", + validFromTimestamp: 900, + }; + + // Both should be accepted since they're for different feeds + const result1 = deduplicator.processReport(report1); + expect(result1.isAccepted).toBe(true); + + const result2 = deduplicator.processReport(report2); + expect(result2.isAccepted).toBe(true); + + // Duplicates should be rejected + const result3 = deduplicator.processReport(report1); + expect(result3.isAccepted).toBe(false); + + const result4 = deduplicator.processReport(report2); + expect(result4.isAccepted).toBe(false); + }); + + it("should track watermarks per feed independently", () => { + const feed1Report1: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 1000, + fullReport: "report1", + validFromTimestamp: 900, + }; + + const feed2Report1: ReportMetadata = { + feedID: "0x456", + observationsTimestamp: 2000, + fullReport: "report2", + validFromTimestamp: 1900, + }; + + const feed1Report2: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 1500, + fullReport: "report3", + validFromTimestamp: 1400, + }; + + // Accept initial reports + deduplicator.processReport(feed1Report1); + deduplicator.processReport(feed2Report1); + + // Accept newer report for feed1 + const result = deduplicator.processReport(feed1Report2); + expect(result.isAccepted).toBe(true); + + // Verify watermarks are independent + expect(deduplicator.getWatermark("0x123")).toBe(1500); + expect(deduplicator.getWatermark("0x456")).toBe(2000); + }); + }); + + describe("watermark management", () => { + it("should return undefined for unknown feeds", () => { + expect(deduplicator.getWatermark("unknown-feed")).toBeUndefined(); + }); + + it("should update watermarks correctly", () => { + const report: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 1500, + fullReport: "report", + validFromTimestamp: 1400, + }; + + expect(deduplicator.getWatermark("0x123")).toBeUndefined(); + + deduplicator.processReport(report); + + expect(deduplicator.getWatermark("0x123")).toBe(1500); + }); + + it("should not update watermark for rejected reports", () => { + const report1: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 2000, + fullReport: "report1", + validFromTimestamp: 1900, + }; + + const report2: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 1000, // Older + fullReport: "report2", + validFromTimestamp: 900, + }; + + // Accept newer report + deduplicator.processReport(report1); + expect(deduplicator.getWatermark("0x123")).toBe(2000); + + // Reject older report + const result = deduplicator.processReport(report2); + expect(result.isAccepted).toBe(false); + expect(deduplicator.getWatermark("0x123")).toBe(2000); // Should remain unchanged + }); + + it("should allow manual watermark setting", () => { + deduplicator.setWatermark("0x123", 5000); + expect(deduplicator.getWatermark("0x123")).toBe(5000); + + const report: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 3000, // Lower than manual watermark + fullReport: "report", + validFromTimestamp: 2900, + }; + + const result = deduplicator.processReport(report); + expect(result.isAccepted).toBe(false); + }); + + it("should clear specific watermarks", () => { + deduplicator.setWatermark("0x123", 1000); + deduplicator.setWatermark("0x456", 2000); + + expect(deduplicator.getWatermark("0x123")).toBe(1000); + expect(deduplicator.getWatermark("0x456")).toBe(2000); + + const cleared = deduplicator.clearWatermark("0x123"); + expect(cleared).toBe(true); + expect(deduplicator.getWatermark("0x123")).toBeUndefined(); + expect(deduplicator.getWatermark("0x456")).toBe(2000); + + const alreadyCleared = deduplicator.clearWatermark("0x123"); + expect(alreadyCleared).toBe(false); + }); + + it("should clear all watermarks", () => { + deduplicator.setWatermark("0x123", 1000); + deduplicator.setWatermark("0x456", 2000); + + deduplicator.clearAllWatermarks(); + + expect(deduplicator.getWatermark("0x123")).toBeUndefined(); + expect(deduplicator.getWatermark("0x456")).toBeUndefined(); + }); + }); + + describe("statistics tracking", () => { + it("should track statistics correctly", () => { + const report1: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 1000, + fullReport: "report1", + validFromTimestamp: 900, + }; + + const report2: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 1000, // Duplicate + fullReport: "report2", + validFromTimestamp: 900, + }; + + const report3: ReportMetadata = { + feedID: "0x456", + observationsTimestamp: 2000, + fullReport: "report3", + validFromTimestamp: 1900, + }; + + // Process reports + deduplicator.processReport(report1); // Accepted + deduplicator.processReport(report2); // Deduplicated + deduplicator.processReport(report3); // Accepted + + const stats = deduplicator.getStats(); + expect(stats.accepted).toBe(2); + expect(stats.deduplicated).toBe(1); + expect(stats.totalReceived).toBe(3); + expect(stats.watermarkCount).toBe(2); + }); + + it("should reset statistics", () => { + const report: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 1000, + fullReport: "report", + validFromTimestamp: 900, + }; + + deduplicator.processReport(report); + deduplicator.processReport(report); // Duplicate + + let stats = deduplicator.getStats(); + expect(stats.accepted).toBe(1); + expect(stats.deduplicated).toBe(1); + + deduplicator.reset(); + + stats = deduplicator.getStats(); + expect(stats.accepted).toBe(0); + expect(stats.deduplicated).toBe(0); + expect(stats.totalReceived).toBe(0); + expect(stats.watermarkCount).toBe(0); + }); + }); + + describe("memory management", () => { + it("should handle large numbers of feeds efficiently", () => { + const feedCount = 1000; // Reduced for test performance + const feeds: string[] = []; + + // Generate many unique feed IDs + for (let i = 0; i < feedCount; i++) { + feeds.push(`0x${i.toString(16).padStart(64, "0")}`); + } + + // Add reports for all feeds + feeds.forEach((feedID, index) => { + const report: ReportMetadata = { + feedID, + observationsTimestamp: index + 1000, + fullReport: `report-${index}`, + validFromTimestamp: index + 900, + }; + + const result = deduplicator.processReport(report); + expect(result.isAccepted).toBe(true); + }); + + // Verify all watermarks are set correctly + feeds.forEach((feedID, index) => { + expect(deduplicator.getWatermark(feedID)).toBe(index + 1000); + }); + + const stats = deduplicator.getStats(); + expect(stats.watermarkCount).toBe(feedCount); + }); + + it("should provide memory usage information", () => { + const report: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 1000, + fullReport: "report", + validFromTimestamp: 900, + }; + + deduplicator.processReport(report); + + const memoryInfo = deduplicator.getMemoryInfo(); + expect(memoryInfo.watermarkCount).toBe(1); + expect(memoryInfo.estimatedMemoryBytes).toBeGreaterThan(0); + }); + }); + + describe("edge cases", () => { + it("should handle zero timestamp", () => { + const report: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: 0, + fullReport: "report", + validFromTimestamp: 0, + }; + + const result = deduplicator.processReport(report); + expect(result.isAccepted).toBe(true); + expect(deduplicator.getWatermark("0x123")).toBe(0); + + // Should reject duplicate with same zero timestamp + const result2 = deduplicator.processReport(report); + expect(result2.isAccepted).toBe(false); + }); + + it("should handle very large timestamps", () => { + const largeTimestamp = Number.MAX_SAFE_INTEGER; + const report: ReportMetadata = { + feedID: "0x123", + observationsTimestamp: largeTimestamp, + fullReport: "report", + validFromTimestamp: largeTimestamp - 1, + }; + + const result = deduplicator.processReport(report); + expect(result.isAccepted).toBe(true); + expect(deduplicator.getWatermark("0x123")).toBe(largeTimestamp); + }); + + it("should handle empty feed ID", () => { + const report: ReportMetadata = { + feedID: "", + observationsTimestamp: 1000, + fullReport: "report", + validFromTimestamp: 900, + }; + + const result = deduplicator.processReport(report); + expect(result.isAccepted).toBe(true); + expect(deduplicator.getWatermark("")).toBe(1000); + }); + + it("should handle special characters in feed ID", () => { + const specialFeedId = "0x!@#$%^&*()_+-=[]{}|;:,.<>?"; + const report: ReportMetadata = { + feedID: specialFeedId, + observationsTimestamp: 1000, + fullReport: "report", + validFromTimestamp: 900, + }; + + const result = deduplicator.processReport(report); + expect(result.isAccepted).toBe(true); + expect(deduplicator.getWatermark(specialFeedId)).toBe(1000); + }); + }); + + describe("export/import functionality", () => { + it("should export watermarks correctly", () => { + const reports = [ + { + feedID: "0x123", + observationsTimestamp: 1000, + fullReport: "report1", + validFromTimestamp: 900, + }, + { + feedID: "0x456", + observationsTimestamp: 2000, + fullReport: "report2", + validFromTimestamp: 1900, + }, + ]; + + reports.forEach(report => { + deduplicator.processReport(report as ReportMetadata); + }); + + const exported = deduplicator.exportWatermarks(); + expect(exported).toHaveLength(2); + expect(exported).toContainEqual({ feedId: "0x123", timestamp: 1000 }); + expect(exported).toContainEqual({ feedId: "0x456", timestamp: 2000 }); + }); + + it("should import watermarks correctly", () => { + const watermarks = [ + { feedId: "0x123", timestamp: 1500 }, + { feedId: "0x456", timestamp: 2500 }, + { feedId: "0x789", timestamp: 3500 }, + ]; + + deduplicator.importWatermarks(watermarks); + + expect(deduplicator.getWatermark("0x123")).toBe(1500); + expect(deduplicator.getWatermark("0x456")).toBe(2500); + expect(deduplicator.getWatermark("0x789")).toBe(3500); + + const stats = deduplicator.getStats(); + expect(stats.watermarkCount).toBe(3); + }); + + it("should handle empty export", () => { + const exported = deduplicator.exportWatermarks(); + expect(exported).toEqual([]); + }); + + it("should handle empty import", () => { + deduplicator.importWatermarks([]); + const stats = deduplicator.getStats(); + expect(stats.watermarkCount).toBe(0); + }); + + it("should overwrite existing watermarks on import", () => { + // Set initial watermark + deduplicator.setWatermark("0x123", 1000); + expect(deduplicator.getWatermark("0x123")).toBe(1000); + + // Import should overwrite + deduplicator.importWatermarks([{ feedId: "0x123", timestamp: 2000 }]); + expect(deduplicator.getWatermark("0x123")).toBe(2000); + }); + }); + + describe("watermark access", () => { + it("should get all watermarks", () => { + deduplicator.setWatermark("0x123", 1000); + deduplicator.setWatermark("0x456", 2000); + + const allWatermarks = deduplicator.getAllWatermarks(); + expect(allWatermarks).toEqual({ + "0x123": 1000, + "0x456": 2000, + }); + }); + + it("should return empty object when no watermarks exist", () => { + const allWatermarks = deduplicator.getAllWatermarks(); + expect(allWatermarks).toEqual({}); + }); + }); + + describe("cleanup functionality", () => { + it("should initialize with cleanup enabled", () => { + const dedup = new ReportDeduplicator({ + maxWatermarkAge: 1000, + cleanupIntervalMs: 500, + }); + + expect(dedup).toBeDefined(); + dedup.stop(); + }); + + it("should stop cleanup properly", () => { + const dedup = new ReportDeduplicator(); + dedup.stop(); + + // Should not throw when stopped multiple times + dedup.stop(); + }); + }); +}); diff --git a/typescript/tests/unit/stream/exponential-backoff.test.ts b/typescript/tests/unit/stream/exponential-backoff.test.ts new file mode 100644 index 0000000..aa9d8f1 --- /dev/null +++ b/typescript/tests/unit/stream/exponential-backoff.test.ts @@ -0,0 +1,320 @@ +/** + * Tests for exponential backoff reconnection logic + */ + +import { ConnectionManager, ConnectionState, ManagedConnection } from "../../../src/stream/connection-manager"; +import { Config } from "../../../src/types/client"; +import { WS_CONSTANTS } from "../../../src/utils/constants"; + +// Mock WebSocket +const mockWebSocket = { + on: jest.fn(), + once: jest.fn(), + send: jest.fn(), + close: jest.fn(), + terminate: jest.fn(), + readyState: 1, +}; + +jest.mock("ws", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => { + const instance = { ...mockWebSocket }; + + // Simulate successful connection after a short delay + setTimeout(() => { + const openHandler = instance.on.mock.calls.find(call => call[0] === "open")?.[1]; + if (openHandler) openHandler(); + }, 10); + + return instance; + }), +})); + +// Mock origin discovery +jest.mock("../../../src/utils/origin-discovery", () => ({ + getAvailableOrigins: jest.fn().mockResolvedValue(["wss://test1.example.com", "wss://test2.example.com"]), +})); + +// Mock setTimeout to capture delay values +const originalSetTimeout = setTimeout; +const originalClearTimeout = clearTimeout; +let capturedDelays: number[] = []; +let mockTimeouts: any[] = []; + +beforeAll(() => { + global.setTimeout = jest.fn((callback, delay) => { + capturedDelays.push(delay); + // Don't execute the callback to avoid side effects, just return a mock timeout + const mockTimeout = { + id: Math.random(), + unref: jest.fn(), + ref: jest.fn(), + hasRef: jest.fn().mockReturnValue(true), + refresh: jest.fn(), + }; + mockTimeouts.push(mockTimeout); + return mockTimeout as any; + }) as any; + + global.clearTimeout = jest.fn(timeout => { + const index = mockTimeouts.indexOf(timeout); + if (index > -1) { + mockTimeouts.splice(index, 1); + } + }) as any; +}); + +afterAll(() => { + global.setTimeout = originalSetTimeout; + global.clearTimeout = originalClearTimeout; +}); + +describe("Exponential Backoff Reconnection", () => { + let connectionManager: ConnectionManager; + let mockConfig: Config; + + beforeEach(() => { + jest.clearAllMocks(); + capturedDelays = []; + mockTimeouts = []; + + mockConfig = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://test.example.com", + wsEndpoint: "wss://test1.example.com,wss://test2.example.com", + haMode: true, + }; + + const managerConfig = { + feedIds: ["0x123"], + maxReconnectAttempts: 5, + reconnectInterval: 1000, + connectTimeout: 5000, + haMode: true, + haConnectionTimeout: 5000, + }; + + connectionManager = new ConnectionManager(mockConfig, managerConfig); + }); + + afterEach(() => { + // Clean up any remaining timeouts + mockTimeouts.length = 0; + }); + + test("should use exponential backoff for reconnection delays", async () => { + // Create a mock connection directly to test the backoff logic + const mockConnection: ManagedConnection = { + id: "test-conn", + origin: "wss://test.example.com", + host: "test.example.com", + ws: null, + state: ConnectionState.DISCONNECTED, + reconnectAttempts: 0, + }; + + // Simulate connection failures to trigger multiple reconnect attempts + for (let i = 0; i < 4; i++) { + (connectionManager as any).scheduleReconnection(mockConnection); + } + + // Verify exponential backoff pattern + expect(capturedDelays.length).toBeGreaterThanOrEqual(4); + + // Expected pattern: ~1000ms, ~2000ms, ~4000ms, ~8000ms (with jitter ±10%) + const baseDelay = WS_CONSTANTS.RECONNECT_DELAY; // 1000ms + + for (let i = 0; i < Math.min(capturedDelays.length, 4); i++) { + const expectedDelay = baseDelay * Math.pow(2, i); + const actualDelay = capturedDelays[i]; + + // Allow for ±15% jitter tolerance + const tolerance = expectedDelay * 0.15; + const minExpected = Math.max(expectedDelay - tolerance, baseDelay); + const maxExpected = expectedDelay + tolerance; + + expect(actualDelay).toBeGreaterThanOrEqual(minExpected); + expect(actualDelay).toBeLessThanOrEqual(maxExpected); + } + }); + + test("should cap delay at maximum reconnect interval", async () => { + const mockConnection: ManagedConnection = { + id: "test-conn", + origin: "wss://test.example.com", + host: "test.example.com", + ws: null, + state: ConnectionState.DISCONNECTED, + reconnectAttempts: 0, + }; + + // Simulate many reconnection attempts to trigger the cap + for (let i = 0; i < 6; i++) { + (connectionManager as any).scheduleReconnection(mockConnection); + } + + // After several attempts, delay should be capped at MAX_RECONNECT_INTERVAL + const maxDelay = WS_CONSTANTS.MAX_RECONNECT_INTERVAL; // 10000ms + const lastDelays = capturedDelays.slice(-2); // Check last 2 delays + + lastDelays.forEach(delay => { + expect(delay).toBe(maxDelay); // Should be exactly the max delay + }); + }); + + test("should use consistent delays", async () => { + // Create multiple connections with same attempt count + const delays: number[] = []; + + for (let i = 0; i < 5; i++) { + const mockConnection: ManagedConnection = { + id: `test-conn-${i}`, + origin: "wss://test.example.com", + host: "test.example.com", + ws: null, + state: ConnectionState.DISCONNECTED, + reconnectAttempts: 1, // This should give exactly 2000ms delay + }; + + (connectionManager as any).scheduleReconnection(mockConnection); + delays.push(capturedDelays[capturedDelays.length - 1]); + } + + // Verify that all delays are identical + const uniqueDelays = new Set(delays); + expect(uniqueDelays.size).toBe(1); // Should have same value + + // Verify exact delay calculation: base * 2^(attempts-1) = 2000 * 2^0 = 2000 + const expectedDelay = 2000; + delays.forEach(delay => { + expect(delay).toBe(expectedDelay); + }); + }); + + test("should never go below minimum delay", async () => { + const mockConnection: ManagedConnection = { + id: "test-conn", + origin: "wss://test.example.com", + host: "test.example.com", + ws: null, + state: ConnectionState.DISCONNECTED, + reconnectAttempts: 0, + }; + + // Test first reconnection attempt + (connectionManager as any).scheduleReconnection(mockConnection); + + const firstDelay = capturedDelays[capturedDelays.length - 1]; + const minDelay = WS_CONSTANTS.RECONNECT_DELAY; + + expect(firstDelay).toBeGreaterThanOrEqual(minDelay); + }); + + test("should use configured reconnectInterval as base delay", async () => { + // Arrange a manager with a larger custom base + const customBaseMs = 5000; + const cm = new ConnectionManager( + { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://test.example.com", + wsEndpoint: "wss://test1.example.com", + haMode: true, + } as Config, + { + feedIds: ["0x123"], + maxReconnectAttempts: 5, + reconnectInterval: customBaseMs, + connectTimeout: 5000, + haMode: true, + haConnectionTimeout: 5000, + } + ); + + // Act: schedule first reconnection (attempt increments to 1 → delay ā‰ˆ base) + const mockConnection: ManagedConnection = { + id: "test-conn-custom-base", + origin: "wss://test.example.com", + host: "test.example.com", + ws: null, + state: ConnectionState.DISCONNECTED, + reconnectAttempts: 0, + }; + (cm as any).scheduleReconnection(mockConnection); + + const lastDelay = capturedDelays[capturedDelays.length - 1]; + + // Assert: delay should be exactly the base value + expect(lastDelay).toBe(customBaseMs); + }); + + test("should respect reconnectInterval", async () => { + // MIN test: use configured value + const smallBase = 50; + const cmMin = new ConnectionManager( + { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://test.example.com", + wsEndpoint: "wss://test1.example.com", + haMode: true, + } as Config, + { + feedIds: ["0x123"], + maxReconnectAttempts: 5, + reconnectInterval: smallBase, + connectTimeout: 5000, + haMode: true, + haConnectionTimeout: 5000, + } + ); + + const connMin: ManagedConnection = { + id: "conn-min", + origin: "wss://test.example.com", + host: "test.example.com", + ws: null, + state: ConnectionState.DISCONNECTED, + reconnectAttempts: 0, + }; + (cmMin as any).scheduleReconnection(connMin); + const minDelayObserved = capturedDelays[capturedDelays.length - 1]; + // Should use configured value exactly + expect(minDelayObserved).toBe(50); // smallBase * 2^0 = 50 + + // MAX clamp test: set an excessively large base + const largeBase = 60000; // > MAX_RECONNECT_INTERVAL (10000) + const cmMax = new ConnectionManager( + { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://test.example.com", + wsEndpoint: "wss://test1.example.com", + haMode: true, + } as Config, + { + feedIds: ["0x123"], + maxReconnectAttempts: 5, + reconnectInterval: largeBase, + connectTimeout: 5000, + haMode: true, + haConnectionTimeout: 5000, + } + ); + + const connMax: ManagedConnection = { + id: "conn-max", + origin: "wss://test.example.com", + host: "test.example.com", + ws: null, + state: ConnectionState.DISCONNECTED, + reconnectAttempts: 0, + }; + (cmMax as any).scheduleReconnection(connMax); + const maxDelayObserved = capturedDelays[capturedDelays.length - 1]; + // Large base should be capped at MAX_RECONNECT_INTERVAL (10000ms) + expect(maxDelayObserved).toBe(WS_CONSTANTS.MAX_RECONNECT_INTERVAL); + }); +}); diff --git a/typescript/tests/unit/stream/stream-stats.test.ts b/typescript/tests/unit/stream/stream-stats.test.ts new file mode 100644 index 0000000..c7241d0 --- /dev/null +++ b/typescript/tests/unit/stream/stream-stats.test.ts @@ -0,0 +1,178 @@ +/** + * Unit Tests for StreamStats Class + * + * These tests validate the functionality of the StreamStats class by: + * - Testing initialization with default and custom values + * - Verifying counter increment methods (accepted, deduplicated, reconnects) + * - Validating active connection count tracking + * - Testing overall stats accuracy through simulated operations + * + * Requirements: + * - No external dependencies or network access needed + * - Fast execution with no special environment setup + */ + +import { describe, it, expect, beforeEach } from "@jest/globals"; +import { StreamStats } from "../../../src/stream/stats"; + +describe("StreamStats Tests", () => { + let stats: StreamStats; + + beforeEach(() => { + stats = new StreamStats(); + }); + + /** + * Test: Default initialization + * Verifies that stats are initialized with correct default values + */ + it("should initialize with correct default values", () => { + const initialStats = stats.getStats(); + expect(initialStats).toEqual({ + accepted: 0, + deduplicated: 0, + partialReconnects: 0, + fullReconnects: 0, + configuredConnections: 1, // Default is 1 + activeConnections: 0, + totalReceived: 0, + originStatus: {}, + }); + }); + + /** + * Test: Custom configured connections + * Verifies that configuredConnections is set correctly when provided + */ + it("should initialize with custom configured connections value", () => { + const customStats = new StreamStats(5); + const initialStats = customStats.getStats(); + expect(initialStats.configuredConnections).toBe(5); + }); + + /** + * Test: Incrementing accepted reports counter + * Verifies that the accepted counter increments correctly + */ + it("should increment accepted reports counter", () => { + // Increment accepted reports + stats.incrementAccepted(); + stats.incrementAccepted(); + stats.incrementAccepted(); + + const currentStats = stats.getStats(); + expect(currentStats.accepted).toBe(3); + }); + + /** + * Test: Incrementing deduplicated reports counter + * Verifies that the deduplicated counter increments correctly + */ + it("should increment deduplicated reports counter", () => { + // Increment deduplicated reports + stats.incrementDeduplicated(); + stats.incrementDeduplicated(); + + const currentStats = stats.getStats(); + expect(currentStats.deduplicated).toBe(2); + }); + + /** + * Test: Incrementing partial reconnects counter + * Verifies that the partial reconnects counter increments correctly + */ + it("should increment partial reconnects counter", () => { + // Increment partial reconnects + stats.incrementPartialReconnects(); + + const currentStats = stats.getStats(); + expect(currentStats.partialReconnects).toBe(1); + }); + + /** + * Test: Incrementing full reconnects counter + * Verifies that the full reconnects counter increments correctly + */ + it("should increment full reconnects counter", () => { + // Increment full reconnects + stats.incrementFullReconnects(); + stats.incrementFullReconnects(); + + const currentStats = stats.getStats(); + expect(currentStats.fullReconnects).toBe(2); + }); + + /** + * Test: Setting active connections + * Verifies that the active connections count is updated correctly + */ + it("should update active connections count", () => { + // Set active connections + stats.setActiveConnections(3); + + const currentStats = stats.getStats(); + expect(currentStats.activeConnections).toBe(3); + + // Update active connections + stats.setActiveConnections(1); + + const updatedStats = stats.getStats(); + expect(updatedStats.activeConnections).toBe(1); + }); + + /** + * Test: Stats accuracy during simulated operation + * Verifies that all stats are tracked accurately during a simulated operation + */ + it("should accurately track all stats during operation", () => { + // Initialize with 2 configured connections + const operationStats = new StreamStats(2); + + // Simulate a sequence of events + operationStats.setActiveConnections(2); // Both connections active + operationStats.incrementAccepted(); // Received report 1 + operationStats.incrementAccepted(); // Received report 2 + operationStats.incrementDeduplicated(); // Duplicate of report 2 + operationStats.setActiveConnections(1); // One connection dropped + operationStats.incrementPartialReconnects(); // Partial reconnect occurred + operationStats.incrementAccepted(); // Received report 3 + operationStats.setActiveConnections(0); // All connections dropped + operationStats.incrementFullReconnects(); // Full reconnect occurred + operationStats.setActiveConnections(2); // Both connections restored + operationStats.incrementAccepted(); // Received report 4 + operationStats.incrementDeduplicated(); // Duplicate of report 4 + + // Verify final stats + const finalStats = operationStats.getStats(); + expect(finalStats).toEqual({ + accepted: 4, + deduplicated: 2, + partialReconnects: 1, + fullReconnects: 1, + configuredConnections: 2, + activeConnections: 2, + totalReceived: 6, // 4 accepted + 2 deduplicated + originStatus: {}, + }); + }); + + /** + * Test: Combined reporting metrics + * Verifies that the total reports received can be calculated from accepted + deduplicated + */ + it("should allow calculating total reports from accepted + deduplicated", () => { + // Simulate receiving reports, some duplicated + stats.incrementAccepted(); // Unique report 1 + stats.incrementAccepted(); // Unique report 2 + stats.incrementDeduplicated(); // Duplicate of report 1 + stats.incrementDeduplicated(); // Duplicate of report 2 + stats.incrementDeduplicated(); // Another duplicate of report 1 + + // Get current stats + const currentStats = stats.getStats(); + + // Calculate total reports received (accepted + deduplicated) + const totalReports = currentStats.accepted + currentStats.deduplicated; + expect(totalReports).toBe(5); // 2 unique + 3 duplicates + }); +}); diff --git a/typescript/tests/unit/stream/stream/events.test.ts b/typescript/tests/unit/stream/stream/events.test.ts new file mode 100644 index 0000000..0e82d75 --- /dev/null +++ b/typescript/tests/unit/stream/stream/events.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import { Stream } from "../../../../src/stream"; +import { Config } from "../../../../src/types/client"; +import { LogLevel } from "../../../../src/types/logger"; +import * as originDiscovery from "../../../../src/utils/origin-discovery"; + +// Mock ConnectionManager to control event emission +const mockConnectionManager = { + on: jest.fn(), + removeAllListeners: jest.fn(), + initialize: jest.fn(() => Promise.resolve()), + shutdown: jest.fn(() => Promise.resolve()), + getActiveConnectionCount: jest.fn().mockReturnValue(1), + getConfiguredConnectionCount: jest.fn().mockReturnValue(1), + getConnectionDetails: jest.fn().mockReturnValue([{ origin: "wss://ws.example.com", host: "ws.example.com" }]), + getOriginStatusMap: jest.fn().mockReturnValue({}), + setStreamStats: jest.fn(), +}; + +// Store event handlers from ConnectionManager +const connectionManagerHandlers: Record void)[]> = {}; + +jest.mock("../../../../src/stream/connection-manager", () => ({ + ConnectionManager: jest.fn(() => { + mockConnectionManager.on.mockImplementation((...args: unknown[]) => { + const [event, handler] = args as [string, (...args: unknown[]) => void]; + if (!connectionManagerHandlers[event]) { + connectionManagerHandlers[event] = []; + } + connectionManagerHandlers[event].push(handler); + }); + return mockConnectionManager; + }), +})); + +jest.mock("../../../../src/utils/origin-discovery", () => ({ + getAvailableOrigins: jest.fn(), +})); + +describe("Stream - Event Re-emission", () => { + let stream: Stream; + let config: Config; + + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(connectionManagerHandlers).forEach(key => delete connectionManagerHandlers[key]); + + const silent = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }; + + config = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.example.com", + wsEndpoint: "wss://ws.example.com", + logging: { + logger: silent, + logLevel: LogLevel.ERROR, // Suppress logs in tests + }, + }; + + ( + originDiscovery.getAvailableOrigins as jest.MockedFunction + ).mockResolvedValue(["wss://ws.example.com"]); + + stream = new Stream(config, ["0x0003" + "1".repeat(60)]); + }); + + afterEach(async () => { + try { + await stream.close(); + } catch { + // Ignore cleanup errors + } + }); + + it("re-emits 'reconnecting' event exactly once per transition", async () => { + const reconnectingSpy = jest.fn(); + stream.on("reconnecting", reconnectingSpy); + + await stream.connect(); + + // Simulate reconnecting event from ConnectionManager + const reconnectingInfo = { attempt: 1, delayMs: 1000, origin: "wss://ws.example.com", host: "ws.example.com" }; + const reconnectingHandlers = connectionManagerHandlers["reconnecting"] || []; + + expect(reconnectingHandlers).toHaveLength(1); + + // Emit reconnecting event multiple times (should only re-emit once each) + reconnectingHandlers[0](reconnectingInfo); + reconnectingHandlers[0](reconnectingInfo); + + expect(reconnectingSpy).toHaveBeenCalledTimes(2); + expect(reconnectingSpy).toHaveBeenCalledWith(reconnectingInfo); + }); + + it("re-emits 'connection-lost' event exactly once per transition", async () => { + const connectionLostSpy = jest.fn(); + stream.on("connection-lost", connectionLostSpy); + + await stream.connect(); + + // Simulate connection-lost event from ConnectionManager + const mockConnection = { id: "conn-1", origin: "wss://ws.example.com", host: "ws.example.com" }; + const mockError = new Error("Connection lost"); + const connectionLostHandlers = connectionManagerHandlers["connection-lost"] || []; + + expect(connectionLostHandlers).toHaveLength(1); + + connectionLostHandlers[0](mockConnection, mockError); + + expect(connectionLostSpy).toHaveBeenCalledTimes(1); + expect(connectionLostSpy).toHaveBeenCalledWith(mockConnection, mockError); + }); + + it("re-emits 'all-connections-lost' and 'disconnected' events exactly once per transition", async () => { + const allConnectionsLostSpy = jest.fn(); + const disconnectedSpy = jest.fn(); + + stream.on("all-connections-lost", allConnectionsLostSpy); + stream.on("disconnected", disconnectedSpy); + + await stream.connect(); + + // Simulate all-connections-lost event from ConnectionManager + const allConnectionsLostHandlers = connectionManagerHandlers["all-connections-lost"] || []; + + expect(allConnectionsLostHandlers).toHaveLength(1); + + allConnectionsLostHandlers[0](); + + expect(allConnectionsLostSpy).toHaveBeenCalledTimes(1); + expect(disconnectedSpy).toHaveBeenCalledTimes(1); + }); + + it("handles 'connection-restored' event without re-emission (internal only)", async () => { + // connection-restored is handled internally for stats but not re-emitted to public API + const connectionRestoredSpy = jest.fn(); + stream.on("connection-restored", connectionRestoredSpy); + + await stream.connect(); + + // The Stream class doesn't re-emit connection-restored, so we shouldn't have any public listeners + expect(connectionRestoredSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/typescript/tests/unit/stream/stream/read.test.ts b/typescript/tests/unit/stream/stream/read.test.ts new file mode 100644 index 0000000..8bb3521 --- /dev/null +++ b/typescript/tests/unit/stream/stream/read.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import { Stream } from "../../../../src/stream"; +import { Config } from "../../../../src/types/client"; +import { LogLevel } from "../../../../src/types/logger"; +import { Report } from "../../../../src/types/report"; +import * as originDiscovery from "../../../../src/utils/origin-discovery"; + +// Mock ConnectionManager +const mockConnectionManager = { + on: jest.fn(), + removeAllListeners: jest.fn(), + initialize: jest.fn(() => Promise.resolve()), + shutdown: jest.fn(() => Promise.resolve()), + getActiveConnectionCount: jest.fn().mockReturnValue(1), + getConfiguredConnectionCount: jest.fn().mockReturnValue(1), + getConnectionDetails: jest.fn().mockReturnValue([{ origin: "wss://ws.example.com", host: "ws.example.com" }]), + getOriginStatusMap: jest.fn().mockReturnValue({}), + setStreamStats: jest.fn(), +}; + +jest.mock("../../../../src/stream/connection-manager", () => ({ + ConnectionManager: jest.fn(() => mockConnectionManager), +})); + +jest.mock("../../../../src/utils/origin-discovery", () => ({ + getAvailableOrigins: jest.fn(), +})); + +describe("Stream - read() method", () => { + let stream: Stream; + let config: Config; + + beforeEach(async () => { + jest.clearAllMocks(); + + // Close any existing stream to ensure clean state + if (stream) { + try { + await stream.close(); + } catch { + // Ignore close errors + } + } + + const silent = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }; + + config = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.example.com", + wsEndpoint: "wss://ws.example.com", + logging: { + logger: silent, + logLevel: LogLevel.ERROR, // Suppress logs in tests + }, + }; + + ( + originDiscovery.getAvailableOrigins as jest.MockedFunction + ).mockResolvedValue(["wss://ws.example.com"]); + + stream = new Stream(config, ["0x0003" + "1".repeat(60)]); + }); + + afterEach(async () => { + try { + await stream.close(); + } catch { + // Ignore cleanup errors + } + }); + + it("resolves on next 'report' event and cleans up listeners", async () => { + await stream.connect(); + + const mockReport: Report = { + feedID: "0x0003" + "1".repeat(60), + fullReport: "0x" + "a".repeat(64), + validFromTimestamp: 1234567890, + observationsTimestamp: 1234567890, + }; + + // Start read() operation + const readPromise = stream.read(); + + // Simulate receiving a report + setTimeout(() => { + stream.emit("report", mockReport); + }, 10); + + // Should resolve with the report + const result = await readPromise; + expect(result).toEqual(mockReport); + + // Verify listeners are cleaned up + expect(stream.listenerCount("report")).toBe(0); + expect(stream.listenerCount("error")).toBe(0); + }); + + it("rejects on 'error' event and cleans up listeners", async () => { + await stream.connect(); + + const mockError = new Error("Test error"); + + // Start read() operation + const readPromise = stream.read(); + + // Simulate an error + setTimeout(() => { + stream.emit("error", mockError); + }, 10); + + // Should reject with the error + await expect(readPromise).rejects.toThrow("Test error"); + + // Verify listeners are cleaned up + expect(stream.listenerCount("report")).toBe(0); + expect(stream.listenerCount("error")).toBe(0); + }); + + it("cleans up listeners when report arrives first", async () => { + await stream.connect(); + + const mockReport: Report = { + feedID: "0x0003" + "1".repeat(60), + fullReport: "0x" + "b".repeat(64), + validFromTimestamp: 1234567891, + observationsTimestamp: 1234567891, + }; + + // Start read() operation + const readPromise = stream.read(); + + // Emit report first - this should resolve immediately and clean up listeners + stream.emit("report", mockReport); + + // Should resolve with the report + const result = await readPromise; + expect(result).toEqual(mockReport); + + // Verify listeners are cleaned up + expect(stream.listenerCount("report")).toBe(0); + expect(stream.listenerCount("error")).toBe(0); + }); + + it("cleans up listeners when error arrives first", async () => { + await stream.connect(); + + const mockError = new Error("First error specific test"); + const mockReport: Report = { + feedID: "0x0003" + "1".repeat(60), + fullReport: "0x" + "c".repeat(64), + validFromTimestamp: 1234567892, + observationsTimestamp: 1234567892, + }; + + // Start read() operation + const readPromise = stream.read(); + + // Emit error first, then report + setTimeout(() => { + stream.emit("error", mockError); + setTimeout(() => { + stream.emit("report", mockReport); + }, 0); + }, 10); + + // Should reject with the error (report ignored after cleanup) + await expect(readPromise).rejects.toThrow("First error specific test"); + + // Verify listeners are cleaned up + expect(stream.listenerCount("report")).toBe(0); + expect(stream.listenerCount("error")).toBe(0); + }); + + it("supports multiple concurrent read() operations", async () => { + await stream.connect(); + + const mockReport: Report = { + feedID: "0x0003" + "1".repeat(60), + fullReport: "0x" + "d".repeat(64), + validFromTimestamp: 1234567895, + observationsTimestamp: 1234567895, + }; + + // Start two concurrent read() operations + const readPromise1 = stream.read(); + const readPromise2 = stream.read(); + + // Should have 2 report listeners and 2 error listeners + expect(stream.listenerCount("report")).toBe(2); + expect(stream.listenerCount("error")).toBe(2); + + // Emit one report - both read() operations should resolve with the same report + stream.emit("report", mockReport); + stream.emit("report", mockReport); // Emit twice to satisfy both listeners + + // Both should resolve with the same report + const results = await Promise.all([readPromise1, readPromise2]); + + // Both operations should succeed + expect(results).toHaveLength(2); + expect(results[0]).toEqual(mockReport); + expect(results[1]).toEqual(mockReport); + + // All listeners should be cleaned up + expect(stream.listenerCount("report")).toBe(0); + expect(stream.listenerCount("error")).toBe(0); + }); +}); diff --git a/typescript/tests/unit/stream/stream/terminal.test.ts b/typescript/tests/unit/stream/stream/terminal.test.ts new file mode 100644 index 0000000..142c1bc --- /dev/null +++ b/typescript/tests/unit/stream/stream/terminal.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import { Stream } from "../../../../src/stream"; +import { Config } from "../../../../src/types/client"; +import { LogLevel } from "../../../../src/types/logger"; +import * as originDiscovery from "../../../../src/utils/origin-discovery"; + +// Mock ConnectionManager to control behavior +const mockConnectionManager = { + on: jest.fn(), + removeAllListeners: jest.fn(), + initialize: jest.fn(() => Promise.resolve()), + shutdown: jest.fn(() => Promise.resolve()), + getActiveConnectionCount: jest.fn().mockReturnValue(0), // No active connections + getConfiguredConnectionCount: jest.fn().mockReturnValue(1), + getConnectionDetails: jest.fn().mockReturnValue([{ origin: "wss://ws.example.com", host: "ws.example.com" }]), + getOriginStatusMap: jest.fn().mockReturnValue({}), + setStreamStats: jest.fn(), +}; + +// Store event handlers from ConnectionManager +const connectionManagerHandlers: Record void)[]> = {}; + +jest.mock("../../../../src/stream/connection-manager", () => ({ + ConnectionManager: jest.fn(() => { + mockConnectionManager.on.mockImplementation((...args: unknown[]) => { + const [event, handler] = args as [string, (...args: unknown[]) => void]; + if (!connectionManagerHandlers[event]) { + connectionManagerHandlers[event] = []; + } + connectionManagerHandlers[event].push(handler); + }); + return mockConnectionManager; + }), +})); + +jest.mock("../../../../src/utils/origin-discovery", () => ({ + getAvailableOrigins: jest.fn(), +})); + +describe("Stream - Terminal Disconnected State", () => { + let stream: Stream; + let config: Config; + + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(connectionManagerHandlers).forEach(key => delete connectionManagerHandlers[key]); + + // Reset the initialize method to successful resolution for each test + mockConnectionManager.initialize = jest.fn(() => Promise.resolve()); + + const silent = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }; + + config = { + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.example.com", + wsEndpoint: "wss://ws.example.com", + logging: { + logger: silent, + logLevel: LogLevel.ERROR, // Suppress logs in tests + }, + }; + + ( + originDiscovery.getAvailableOrigins as jest.MockedFunction + ).mockResolvedValue(["wss://ws.example.com"]); + + stream = new Stream(config, ["0x0003" + "1".repeat(60)]); + }); + + afterEach(async () => { + try { + await stream.close(); + } catch { + // Ignore cleanup errors + } + }); + + it("emits 'disconnected' when all connections are lost terminally", async () => { + const disconnectedSpy = jest.fn(); + const allConnectionsLostSpy = jest.fn(); + + stream.on("disconnected", disconnectedSpy); + stream.on("all-connections-lost", allConnectionsLostSpy); + + await stream.connect(); + + // Simulate terminal disconnection (all connections lost after max attempts) + const allConnectionsLostHandlers = connectionManagerHandlers["all-connections-lost"] || []; + expect(allConnectionsLostHandlers).toHaveLength(1); + + allConnectionsLostHandlers[0](); + + expect(disconnectedSpy).toHaveBeenCalledTimes(1); + expect(allConnectionsLostSpy).toHaveBeenCalledTimes(1); + }); + + it("does not attempt further reconnections after disconnected state", async () => { + const reconnectingSpy = jest.fn(); + const disconnectedSpy = jest.fn(); + + stream.on("reconnecting", reconnectingSpy); + stream.on("disconnected", disconnectedSpy); + + await stream.connect(); + + // Simulate terminal disconnection + const allConnectionsLostHandlers = connectionManagerHandlers["all-connections-lost"] || []; + allConnectionsLostHandlers[0](); + + expect(disconnectedSpy).toHaveBeenCalledTimes(1); + + // The test concept here is that in practice, the ConnectionManager wouldn't emit + // more 'reconnecting' events after reaching terminal state, but we can't easily test that + // without the real ConnectionManager logic. This test passes as the behavior + // of preventing reconnections is handled by the ConnectionManager itself. + expect(reconnectingSpy).toHaveBeenCalledTimes(0); + }); + + it("calling connect() again on same instance should not revive connection", async () => { + const disconnectedSpy = jest.fn(); + + stream.on("disconnected", disconnectedSpy); + + // Initial connection + await stream.connect(); + + // Simulate terminal disconnection + const allConnectionsLostHandlers = connectionManagerHandlers["all-connections-lost"] || []; + allConnectionsLostHandlers[0](); + + expect(disconnectedSpy).toHaveBeenCalledTimes(1); + + // Try to connect again - this should fail or not restart connections + // The ConnectionManager should be in a terminal state + mockConnectionManager.initialize = jest.fn(() => + Promise.reject(new Error("Connection manager is in terminal state")) + ); + + await expect(stream.connect()).rejects.toThrow("Connection manager is in terminal state"); + + // Should not emit new connection events + expect(disconnectedSpy).toHaveBeenCalledTimes(1); // Still only the original disconnection + }); + + it("read() should reject after disconnected state", async () => { + await stream.connect(); + + // Simulate terminal disconnection + const allConnectionsLostHandlers = connectionManagerHandlers["all-connections-lost"] || []; + allConnectionsLostHandlers[0](); + + // read() after disconnection should fail gracefully + const readPromise = stream.read(); + + // Simulate that no reports will come + setTimeout(() => { + stream.emit("error", new Error("Stream is disconnected")); + }, 10); + + await expect(readPromise).rejects.toThrow("Stream is disconnected"); + }); + + it("getMetrics() should reflect disconnected state", async () => { + await stream.connect(); + + // Check initial state + let metrics = stream.getMetrics(); + expect(metrics.activeConnections).toBe(0); // Mocked to return 0 + + // Simulate terminal disconnection + const allConnectionsLostHandlers = connectionManagerHandlers["all-connections-lost"] || []; + allConnectionsLostHandlers[0](); + + // Metrics should still reflect the disconnected state + metrics = stream.getMetrics(); + expect(metrics.activeConnections).toBe(0); + expect(metrics.configuredConnections).toBe(1); + }); + + it("close() should work gracefully even after disconnected state", async () => { + await stream.connect(); + + // Simulate terminal disconnection + const allConnectionsLostHandlers = connectionManagerHandlers["all-connections-lost"] || []; + allConnectionsLostHandlers[0](); + + // close() should still work + await expect(stream.close()).resolves.not.toThrow(); + + // Verify shutdown was called + expect(mockConnectionManager.shutdown).toHaveBeenCalled(); + }); +}); diff --git a/typescript/tests/unit/utils/auth.test.ts b/typescript/tests/unit/utils/auth.test.ts new file mode 100644 index 0000000..d284da8 --- /dev/null +++ b/typescript/tests/unit/utils/auth.test.ts @@ -0,0 +1,519 @@ +/** + * Unit Tests for Authentication Functions + * + * These tests validate the authentication functionality by: + * - Testing HMAC generation and signature validation + * - Testing authentication header generation + * - Testing timestamp handling and validation + * - Testing auth header format compliance + * - Testing auth with different HTTP methods (GET, POST) + * - Testing auth with request bodies + * - Testing auth error scenarios (invalid keys, malformed data) + * + * Goals: + * - Ensure our auth implementation works correctly and securely + * - Test all edge cases and error scenarios comprehensively + * - Use millisecond precision timestamps + * - Maintain functional compatibility (can authenticate with same backend) + * - Build the best possible TypeScript authentication implementation + */ + +import { describe, it, expect, jest, beforeEach, afterEach } from "@jest/globals"; +import { generateAuthHeaders } from "../../../src/utils/auth"; + +describe("Authentication Tests", () => { + let originalDateNow: typeof Date.now; + + beforeEach(() => { + // Mock Date.now for consistent testing + originalDateNow = Date.now; + }); + + afterEach(() => { + // Restore Date.now + Date.now = originalDateNow; + }); + + describe("HMAC generation and signature validation", () => { + it("should generate consistent signatures with fixed timestamp", () => { + const timestamp = 1718885772000; + const headers1 = generateAuthHeaders( + "clientId", + "userSecret", + "GET", + "https://api.example.com/api/v1/feeds", + undefined, + timestamp + ); + + const headers2 = generateAuthHeaders( + "clientId", + "userSecret", + "GET", + "https://api.example.com/api/v1/feeds", + undefined, + timestamp + ); + + // Same inputs should produce identical signatures + expect(headers1["X-Authorization-Signature-SHA256"]).toBe(headers2["X-Authorization-Signature-SHA256"]); + expect(headers1["Authorization"]).toBe("clientId"); + expect(headers1["X-Authorization-Timestamp"]).toBe(timestamp.toString()); + }); + + it("should generate different signatures for different parameters", () => { + const timestamp = 12000000; + + const headers1 = generateAuthHeaders( + "clientId1", + "secret1", + "POST", + "https://api.example.com/api/v1/feeds", + undefined, + timestamp + ); + + const headers2 = generateAuthHeaders( + "clientId2", // Different client ID + "secret1", + "POST", + "https://api.example.com/api/v1/feeds", + undefined, + timestamp + ); + + // Different inputs should produce different signatures + expect(headers1["X-Authorization-Signature-SHA256"]).not.toBe(headers2["X-Authorization-Signature-SHA256"]); + expect(headers1["Authorization"]).toBe("clientId1"); + expect(headers2["Authorization"]).toBe("clientId2"); + }); + + it("should include request body in signature calculation", () => { + const timestamp = 1718885772000; + + const withoutBody = generateAuthHeaders( + "clientId2", + "secret2", + "POST", + "https://api.example.com/api/v1/reports/bulk", + undefined, + timestamp + ); + + const withBody = generateAuthHeaders( + "clientId2", + "secret2", + "POST", + "https://api.example.com/api/v1/reports/bulk", + '{"attr1": "value1","attr2": [1,2,3]}', + timestamp + ); + + // Body should affect signature + expect(withoutBody["X-Authorization-Signature-SHA256"]).not.toBe(withBody["X-Authorization-Signature-SHA256"]); + expect(withoutBody["Authorization"]).toBe(withBody["Authorization"]); + expect(withoutBody["X-Authorization-Timestamp"]).toBe(withBody["X-Authorization-Timestamp"]); + }); + }); + + describe("authentication header generation", () => { + it("should generate all required headers", () => { + const headers = generateAuthHeaders("test-api-key", "test-secret", "GET", "https://api.example.com/api/v1/feeds"); + + expect(headers).toHaveProperty("Authorization"); + expect(headers).toHaveProperty("X-Authorization-Timestamp"); + expect(headers).toHaveProperty("X-Authorization-Signature-SHA256"); + expect(Object.keys(headers)).toHaveLength(3); + }); + + it("should use correct header names", () => { + const headers = generateAuthHeaders("test-api-key", "test-secret", "GET", "https://api.example.com/api/v1/feeds"); + + // Verify exact header names match + expect(headers).toHaveProperty("Authorization"); + expect(headers).toHaveProperty("X-Authorization-Timestamp"); + expect(headers).toHaveProperty("X-Authorization-Signature-SHA256"); + }); + + it("should set Authorization header to API key", () => { + const apiKey = "my-test-api-key-12345"; + const headers = generateAuthHeaders(apiKey, "test-secret", "GET", "https://api.example.com/api/v1/feeds"); + + expect(headers["Authorization"]).toBe(apiKey); + }); + + it("should set timestamp as string", () => { + const mockTimestamp = 1234567890123; + Date.now = jest.fn(() => mockTimestamp); + + const headers = generateAuthHeaders("test-api-key", "test-secret", "GET", "https://api.example.com/api/v1/feeds"); + + expect(headers["X-Authorization-Timestamp"]).toBe(mockTimestamp.toString()); + expect(typeof headers["X-Authorization-Timestamp"]).toBe("string"); + }); + }); + + describe("timestamp handling and validation", () => { + it("should use current timestamp when none provided", () => { + const mockTimestamp = 1234567890123; + Date.now = jest.fn(() => mockTimestamp); + + const headers = generateAuthHeaders("test-api-key", "test-secret", "GET", "https://api.example.com/api/v1/feeds"); + + expect(Date.now).toHaveBeenCalled(); + expect(headers["X-Authorization-Timestamp"]).toBe(mockTimestamp.toString()); + }); + + it("should generate different signatures for different timestamps", () => { + Date.now = jest.fn(() => 1000000); + const headers1 = generateAuthHeaders( + "test-api-key", + "test-secret", + "GET", + "https://api.example.com/api/v1/feeds" + ); + + Date.now = jest.fn(() => 2000000); + const headers2 = generateAuthHeaders( + "test-api-key", + "test-secret", + "GET", + "https://api.example.com/api/v1/feeds" + ); + + expect(headers1["X-Authorization-Signature-SHA256"]).not.toBe(headers2["X-Authorization-Signature-SHA256"]); + expect(headers1["X-Authorization-Timestamp"]).not.toBe(headers2["X-Authorization-Timestamp"]); + }); + }); + + describe("auth with different HTTP methods", () => { + it("should generate different signatures for different methods", () => { + const mockTimestamp = 1234567890123; + Date.now = jest.fn(() => mockTimestamp); + + const getHeaders = generateAuthHeaders( + "test-api-key", + "test-secret", + "GET", + "https://api.example.com/api/v1/feeds" + ); + + const postHeaders = generateAuthHeaders( + "test-api-key", + "test-secret", + "POST", + "https://api.example.com/api/v1/feeds" + ); + + expect(getHeaders["X-Authorization-Signature-SHA256"]).not.toBe(postHeaders["X-Authorization-Signature-SHA256"]); + expect(getHeaders["Authorization"]).toBe(postHeaders["Authorization"]); + expect(getHeaders["X-Authorization-Timestamp"]).toBe(postHeaders["X-Authorization-Timestamp"]); + }); + + it("should handle GET requests", () => { + const headers = generateAuthHeaders("test-api-key", "test-secret", "GET", "https://api.example.com/api/v1/feeds"); + + expect(headers["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + }); + + it("should handle POST requests", () => { + const headers = generateAuthHeaders( + "test-api-key", + "test-secret", + "POST", + "https://api.example.com/api/v1/reports" + ); + + expect(headers["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + }); + + it("should handle PUT requests", () => { + const headers = generateAuthHeaders("test-api-key", "test-secret", "PUT", "https://api.example.com/api/v1/feeds"); + + expect(headers["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + }); + }); + + describe("auth with request bodies", () => { + it("should generate different signatures for requests with and without body", () => { + const mockTimestamp = 1234567890123; + Date.now = jest.fn(() => mockTimestamp); + + const withoutBody = generateAuthHeaders( + "test-api-key", + "test-secret", + "POST", + "https://api.example.com/api/v1/reports" + ); + + const withBody = generateAuthHeaders( + "test-api-key", + "test-secret", + "POST", + "https://api.example.com/api/v1/reports", + '{"test": "data"}' + ); + + expect(withoutBody["X-Authorization-Signature-SHA256"]).not.toBe(withBody["X-Authorization-Signature-SHA256"]); + }); + + it("should handle empty body same as no body", () => { + const mockTimestamp = 1234567890123; + Date.now = jest.fn(() => mockTimestamp); + + const noBody = generateAuthHeaders( + "test-api-key", + "test-secret", + "POST", + "https://api.example.com/api/v1/reports" + ); + + const emptyBody = generateAuthHeaders( + "test-api-key", + "test-secret", + "POST", + "https://api.example.com/api/v1/reports", + "" + ); + + expect(noBody["X-Authorization-Signature-SHA256"]).toBe(emptyBody["X-Authorization-Signature-SHA256"]); + }); + + it("should handle JSON body", () => { + const headers = generateAuthHeaders( + "test-api-key", + "test-secret", + "POST", + "https://api.example.com/api/v1/reports", + '{"feedIDs": ["0x123", "0x456"], "timestamp": 1234567890}' + ); + + expect(headers["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + }); + + it("should handle large body", () => { + const largeBody = JSON.stringify({ + data: "x".repeat(10000), + timestamp: Date.now(), + }); + + const headers = generateAuthHeaders( + "test-api-key", + "test-secret", + "POST", + "https://api.example.com/api/v1/reports", + largeBody + ); + + expect(headers["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + }); + }); + + describe("auth error scenarios", () => { + it("should handle empty API key", () => { + const headers = generateAuthHeaders("", "test-secret", "GET", "https://api.example.com/api/v1/feeds"); + + expect(headers["Authorization"]).toBe(""); + expect(headers["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + }); + + it("should handle empty user secret", () => { + const headers = generateAuthHeaders("test-api-key", "", "GET", "https://api.example.com/api/v1/feeds"); + + expect(headers["Authorization"]).toBe("test-api-key"); + expect(headers["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + }); + + it("should handle special characters in API key", () => { + const specialApiKey = "test-api-key-!@#$%^&*()"; + const headers = generateAuthHeaders(specialApiKey, "test-secret", "GET", "https://api.example.com/api/v1/feeds"); + + expect(headers["Authorization"]).toBe(specialApiKey); + expect(headers["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + }); + + it("should handle special characters in user secret", () => { + const specialSecret = "test-secret-!@#$%^&*()"; + const headers = generateAuthHeaders("test-api-key", specialSecret, "GET", "https://api.example.com/api/v1/feeds"); + + expect(headers["Authorization"]).toBe("test-api-key"); + expect(headers["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + }); + + it("should handle Unicode characters", () => { + const headers = generateAuthHeaders( + "test-api-key-šŸ”‘", + "test-secret-šŸ”", + "GET", + "https://api.example.com/api/v1/feeds" + ); + + expect(headers["Authorization"]).toBe("test-api-key-šŸ”‘"); + expect(headers["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + }); + + it("should handle malformed URL gracefully", () => { + // Our implementation should handle this without throwing + expect(() => { + generateAuthHeaders("test-api-key", "test-secret", "GET", "not-a-valid-url"); + }).toThrow(); // This should throw because URL constructor will fail + }); + }); + + describe("signature consistency", () => { + it("should generate identical signatures for identical inputs", () => { + const mockTimestamp = 1234567890123; + Date.now = jest.fn(() => mockTimestamp); + + const headers1 = generateAuthHeaders( + "test-api-key", + "test-secret", + "GET", + "https://api.example.com/api/v1/feeds" + ); + + Date.now = jest.fn(() => mockTimestamp); // Same timestamp + const headers2 = generateAuthHeaders( + "test-api-key", + "test-secret", + "GET", + "https://api.example.com/api/v1/feeds" + ); + + expect(headers1["X-Authorization-Signature-SHA256"]).toBe(headers2["X-Authorization-Signature-SHA256"]); + }); + + it("should generate hex-encoded signatures", () => { + const headers = generateAuthHeaders("test-api-key", "test-secret", "GET", "https://api.example.com/api/v1/feeds"); + + // Should be 64 character hex string (SHA256) + expect(headers["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + }); + + it("should be case sensitive for inputs", () => { + const mockTimestamp = 1234567890123; + Date.now = jest.fn(() => mockTimestamp); + + const lowercase = generateAuthHeaders( + "test-api-key", + "test-secret", + "get", + "https://api.example.com/api/v1/feeds" + ); + + Date.now = jest.fn(() => mockTimestamp); + const uppercase = generateAuthHeaders( + "test-api-key", + "test-secret", + "GET", + "https://api.example.com/api/v1/feeds" + ); + + expect(lowercase["X-Authorization-Signature-SHA256"]).not.toBe(uppercase["X-Authorization-Signature-SHA256"]); + }); + }); + + describe("Documentation Test: Production authentication scenarios", () => { + // These tests demonstrate cross-platform HMAC compatibility patterns + it("should demonstrate HMAC signature validation for known test vectors", () => { + // Test vector 1: Standard GET request + const headers1 = generateAuthHeaders( + "clientId", + "userSecret", + "GET", + "https://api.example.com/api/v1/feeds", + undefined, + 1718885772000 + ); + + // Validate signature is deterministic and hex format + expect(headers1["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + expect(headers1["Authorization"]).toBe("clientId"); + expect(headers1["X-Authorization-Timestamp"]).toBe("1718885772000"); + + // Test vector 2: POST request without body + const headers2 = generateAuthHeaders( + "clientId1", + "secret1", + "POST", + "https://api.example.com/api/v1/feeds", + undefined, + 12000000 + ); + + expect(headers2["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + expect(headers2["Authorization"]).toBe("clientId1"); + expect(headers2["X-Authorization-Timestamp"]).toBe("12000000"); + + // Test vector 3: POST request with JSON body + const headers3 = generateAuthHeaders( + "clientId2", + "secret2", + "POST", + "https://api.example.com/api/v1/reports/bulk", + '{"attr1": "value1","attr2": [1,2,3]}', + 1718885772000 + ); + + expect(headers3["X-Authorization-Signature-SHA256"]).toMatch(/^[a-f0-9]{64}$/); + expect(headers3["Authorization"]).toBe("clientId2"); + expect(headers3["X-Authorization-Timestamp"]).toBe("1718885772000"); + + // Test consistency: same inputs should produce same signatures + const headers1_repeat = generateAuthHeaders( + "clientId", + "userSecret", + "GET", + "https://api.example.com/api/v1/feeds", + undefined, + 1718885772000 + ); + expect(headers1["X-Authorization-Signature-SHA256"]).toBe(headers1_repeat["X-Authorization-Signature-SHA256"]); + }); + + it("should handle URL path extraction correctly", () => { + // Ensure path-only signatures work correctly + const headersFullUrl = generateAuthHeaders( + "clientId", + "userSecret", + "GET", + "https://api.example.com/api/v1/feeds", + undefined, + 1718885772000 + ); + + const headersWithQuery = generateAuthHeaders( + "clientId", + "userSecret", + "GET", + "https://api.example.com/api/v1/feeds?param=value", + undefined, + 1718885772000 + ); + + // Query parameters should affect signature calculation + expect(headersFullUrl["X-Authorization-Signature-SHA256"]).not.toBe( + headersWithQuery["X-Authorization-Signature-SHA256"] + ); + }); + + it("should handle high precision timestamps correctly", () => { + // Validate millisecond timestamp precision + const timestampMs = 1718885772000; + const timestampSec = Math.floor(timestampMs / 1000); + + const headers = generateAuthHeaders( + "clientId", + "userSecret", + "GET", + "https://api.example.com/api/v1/feeds", + undefined, + timestampMs + ); + + // Timestamp should maintain millisecond precision + expect(headers["X-Authorization-Timestamp"]).toBe(timestampMs.toString()); + expect(parseInt(headers["X-Authorization-Timestamp"])).toBeGreaterThan(timestampSec); + }); + }); +}); diff --git a/typescript/tests/unit/utils/logger.test.ts b/typescript/tests/unit/utils/logger.test.ts new file mode 100644 index 0000000..d93d287 --- /dev/null +++ b/typescript/tests/unit/utils/logger.test.ts @@ -0,0 +1,362 @@ +import { SDKLogger } from "../../../src/utils/logger"; +import { LogLevel } from "../../../src/types/logger"; +import { createClient } from "../../../src"; + +describe("SDKLogger", () => { + describe("Unit Tests", () => { + it("should be silent by default", () => { + const logger = new SDKLogger(); + // Should not throw or log + logger.info("test message"); + logger.debug("debug message"); + logger.error("error message"); + logger.warn("warn message"); + logger.connectionDebug("connection message"); + }); + + it("should respect log level filtering", () => { + const mockLogger = { info: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() }; + const logger = new SDKLogger({ + logger: mockLogger, + logLevel: LogLevel.INFO, + }); + + logger.debug("debug message"); // Should be filtered + logger.info("info message"); // Should pass + logger.warn("warn message"); // Should pass + logger.error("error message"); // Should pass + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringMatching(/\[.*\] \[DataStreams\] info message/)); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringMatching(/\[.*\] \[DataStreams\] warn message/)); + expect(mockLogger.error).toHaveBeenCalledWith(expect.stringMatching(/\[.*\] \[DataStreams\] error message/)); + }); + + it("should handle all log levels correctly", () => { + const mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const logger = new SDKLogger({ + logger: mockLogger, + logLevel: LogLevel.DEBUG, // Allow all levels + }); + + logger.debug("debug message"); + logger.info("info message"); + logger.warn("warn message"); + logger.error("error message"); + + expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringMatching(/\[.*\] \[DataStreams\] debug message/)); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringMatching(/\[.*\] \[DataStreams\] info message/)); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringMatching(/\[.*\] \[DataStreams\] warn message/)); + expect(mockLogger.error).toHaveBeenCalledWith(expect.stringMatching(/\[.*\] \[DataStreams\] error message/)); + }); + + it("should handle connection debug logs", () => { + const mockLogger = { debug: jest.fn() }; + const logger = new SDKLogger({ + logger: mockLogger, + logLevel: LogLevel.DEBUG, // Enable DEBUG level + enableConnectionDebug: true, + }); + + logger.connectionDebug("connection event"); + expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining("[Connection] connection event")); + }); + + it("should not log connection debug when disabled", () => { + const mockLogger = { debug: jest.fn() }; + const logger = new SDKLogger({ + logger: mockLogger, + enableConnectionDebug: false, + }); + + logger.connectionDebug("connection event"); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + + it("should handle logger errors gracefully", () => { + const faultyLogger = { + info: () => { + throw new Error("Logger failed"); + }, + }; + + const logger = new SDKLogger({ logger: faultyLogger }); + + // Should not throw despite logger error + expect(() => logger.info("test")).not.toThrow(); + }); + + it("should format messages with timestamp and prefix", () => { + const mockLogger = { info: jest.fn() }; + const logger = new SDKLogger({ logger: mockLogger }); + + logger.info("test message"); + + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringMatching(/^\[[\d-T:.Z]+\] \[DataStreams\] test message$/) + ); + }); + + it("should pass additional arguments to logger", () => { + const mockLogger = { error: jest.fn() }; + const logger = new SDKLogger({ logger: mockLogger }); + + const error = new Error("test error"); + const extraData = { key: "value" }; + logger.error("Error occurred", error, extraData); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringMatching(/\[.*\] \[DataStreams\] Error occurred/), + error, + extraData + ); + }); + + it("should handle missing logger methods gracefully", () => { + const incompleteLogger = { info: jest.fn() }; // Missing debug, warn, error + const logger = new SDKLogger({ logger: incompleteLogger }); + + // Should not throw for missing methods + expect(() => { + logger.debug("debug message"); + logger.warn("warn message"); + logger.error("error message"); + logger.info("info message"); // Add the missing call + }).not.toThrow(); + + // Only info should be called + expect(incompleteLogger.info).toHaveBeenCalledWith(expect.stringMatching(/\[.*\] \[DataStreams\] info message/)); + }); + + it("should have zero overhead when logging disabled", () => { + const start = performance.now(); + const logger = new SDKLogger(); // No config = disabled + + // Test with high-frequency logging calls + for (let i = 0; i < 10000; i++) { + logger.info("test message"); + logger.debug("debug message"); + logger.connectionDebug("connection event"); + logger.error("error message", new Error("test")); + } + + const duration = performance.now() - start; + expect(duration).toBeLessThan(200); // Should be near-instant (< 200ms) + }); + }); + + describe("Integration Tests", () => { + // Mock fetch globally for these tests + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ feeds: [] }), + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + jest.clearAllMocks(); + }); + + it("should log during real client operations", async () => { + const mockLogger = { + info: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }; + + const client = createClient({ + apiKey: "test-key", + userSecret: "test-secret", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + logging: { + logger: mockLogger, + logLevel: LogLevel.INFO, + }, + }); + + try { + // Verify that initialization is logged + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Data Streams client initialized")); + + await client.listFeeds(); + + // Verify that API logs were called + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Request successful")); + } finally { + // Clean up any potential resources + // Note: DataStreamsClient is a stateless REST client (no persistent connections/timers) + // Only streams created by client.createStream() need explicit cleanup via stream.close() + } + }); + + it("should validate logging config correctly", () => { + expect(() => + createClient({ + apiKey: "test", + userSecret: "test", + endpoint: "https://test.example.com", + wsEndpoint: "wss://test.example.com", + logging: { + logLevel: 999 as any, // Invalid level + }, + }) + ).toThrow("Invalid logLevel"); + + expect(() => + createClient({ + apiKey: "test", + userSecret: "test", + endpoint: "https://test.example.com", + wsEndpoint: "wss://test.example.com", + logging: { + logger: { info: "not a function" as any }, + }, + }) + ).toThrow("Logger.info must be a function"); + + expect(() => + createClient({ + apiKey: "test", + userSecret: "test", + endpoint: "https://test.example.com", + wsEndpoint: "wss://test.example.com", + logging: { + enableConnectionDebug: "not a boolean" as any, + }, + }) + ).toThrow("enableConnectionDebug must be a boolean"); + }); + + it("should work with different logger interfaces", () => { + // Test with console-like logger (using mocks to keep tests silent) + const consoleLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + expect(() => + createClient({ + apiKey: "test", + userSecret: "test", + endpoint: "https://test.example.com", + wsEndpoint: "wss://test.example.com", + logging: { logger: consoleLogger }, + }) + ).not.toThrow(); + + // Verify the logger was actually used during client initialization + expect(consoleLogger.info).toHaveBeenCalledWith(expect.stringContaining("Data Streams client initialized")); + + // Test with partial logger + const partialLogger = { + info: jest.fn(), + error: jest.fn(), + }; + + expect(() => + createClient({ + apiKey: "test", + userSecret: "test", + endpoint: "https://test.example.com", + wsEndpoint: "wss://test.example.com", + logging: { logger: partialLogger }, + }) + ).not.toThrow(); + }); + + it("should handle stream logging integration", async () => { + const mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const client = createClient({ + apiKey: "test", + userSecret: "test", + endpoint: "https://test.example.com", + wsEndpoint: "wss://test.example.com", + logging: { + logger: mockLogger, + logLevel: LogLevel.DEBUG, + }, + }); + + const stream = client.createStream("0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8"); + + try { + // Verify stream creation logging + expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining("Creating stream for 1 feeds")); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Stream created successfully")); + } finally { + // Clean up stream resources to prevent leaks + await stream.close(); + } + }); + + it("should handle different log levels in production scenario", async () => { + const logs: { level: string; message: string }[] = []; + const productionLogger = { + debug: (msg: string) => logs.push({ level: "debug", message: msg }), + info: (msg: string) => logs.push({ level: "info", message: msg }), + warn: (msg: string) => logs.push({ level: "warn", message: msg }), + error: (msg: string) => logs.push({ level: "error", message: msg }), + }; + + // Test with INFO level (should exclude debug) + const clientInfo = createClient({ + apiKey: "test", + userSecret: "test", + endpoint: "https://test.example.com", + wsEndpoint: "wss://test.example.com", + logging: { + logger: productionLogger, + logLevel: LogLevel.INFO, + }, + }); + + logs.length = 0; // Clear initialization logs + + const stream = clientInfo.createStream("0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8"); + + try { + const debugLogs = logs.filter(log => log.level === "debug"); + const infoLogs = logs.filter(log => log.level === "info"); + + expect(debugLogs).toHaveLength(0); // Debug should be filtered + expect(infoLogs.length).toBeGreaterThan(0); // Info should pass + } finally { + // Clean up stream to prevent resource leaks + await stream.close(); + } + + // Test with ERROR level (should only allow errors) + logs.length = 0; + const errorLogger = new SDKLogger({ + logger: productionLogger, + logLevel: LogLevel.ERROR, + }); + + errorLogger.debug("debug"); + errorLogger.info("info"); + errorLogger.warn("warn"); + errorLogger.error("error"); + + expect(logs).toEqual([expect.objectContaining({ level: "error", message: expect.stringContaining("error") })]); + }); + }); +}); diff --git a/typescript/tests/unit/utils/origin-discovery.test.ts b/typescript/tests/unit/utils/origin-discovery.test.ts new file mode 100644 index 0000000..3e9419e --- /dev/null +++ b/typescript/tests/unit/utils/origin-discovery.test.ts @@ -0,0 +1,381 @@ +import { + discoverOrigins, + parseOriginsHeader, + parseCommaSeparatedUrls, + convertWebSocketToHttpScheme, + getAvailableOrigins, +} from "../../../src/utils/origin-discovery"; +import { OriginDiscoveryError, InsufficientConnectionsError } from "../../../src/types/errors"; +import { X_CLL_AVAILABLE_ORIGINS_HEADER } from "../../../src/utils/constants"; + +// Mock fetch globally +global.fetch = jest.fn(); + +describe("Origin Discovery", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe("parseOriginsHeader", () => { + it("should parse comma-separated origins", () => { + const result = parseOriginsHeader("origin1,origin2,origin3"); + expect(result).toEqual(["origin1", "origin2", "origin3"]); + }); + + it("should handle origins with brackets", () => { + const result = parseOriginsHeader("{origin1,origin2,origin3}"); + expect(result).toEqual(["origin1", "origin2", "origin3"]); + }); + + it("should trim whitespace", () => { + const result = parseOriginsHeader(" origin1 , origin2 , origin3 "); + expect(result).toEqual(["origin1", "origin2", "origin3"]); + }); + + it("should handle single origin", () => { + const result = parseOriginsHeader("single-origin"); + expect(result).toEqual(["single-origin"]); + }); + + it("should handle empty string", () => { + const result = parseOriginsHeader(""); + expect(result).toEqual([]); + }); + + it("should filter out empty origins", () => { + const result = parseOriginsHeader("origin1,,origin3,"); + expect(result).toEqual(["origin1", "origin3"]); + }); + + it("should handle complex URLs", () => { + const result = parseOriginsHeader("wss://host1.example.com:443,wss://host2.example.com:443"); + expect(result).toEqual(["wss://host1.example.com:443", "wss://host2.example.com:443"]); + }); + }); + + describe("parseCommaSeparatedUrls", () => { + it("should parse comma-separated WebSocket URLs", () => { + const result = parseCommaSeparatedUrls("wss://url1,wss://url2"); + expect(result).toEqual(["wss://url1", "wss://url2"]); + }); + + it("should handle single URL", () => { + const result = parseCommaSeparatedUrls("wss://single-url"); + expect(result).toEqual(["wss://single-url"]); + }); + + it("should trim whitespace", () => { + const result = parseCommaSeparatedUrls(" wss://url1 , wss://url2 "); + expect(result).toEqual(["wss://url1", "wss://url2"]); + }); + + it("should filter empty URLs", () => { + const result = parseCommaSeparatedUrls("wss://url1,,wss://url3"); + expect(result).toEqual(["wss://url1", "wss://url3"]); + }); + }); + + describe("convertWebSocketToHttpScheme", () => { + it("should convert ws to http", () => { + const result = convertWebSocketToHttpScheme("ws://example.com"); + expect(result).toBe("http://example.com"); + }); + + it("should convert wss to https", () => { + const result = convertWebSocketToHttpScheme("wss://example.com"); + expect(result).toBe("https://example.com"); + }); + + it("should preserve http scheme", () => { + const result = convertWebSocketToHttpScheme("http://example.com"); + expect(result).toBe("http://example.com"); + }); + + it("should preserve https scheme", () => { + const result = convertWebSocketToHttpScheme("https://example.com"); + expect(result).toBe("https://example.com"); + }); + + it("should handle URLs with paths", () => { + const result = convertWebSocketToHttpScheme("wss://example.com/path"); + expect(result).toBe("https://example.com/path"); + }); + + it("should handle URLs with ports", () => { + const result = convertWebSocketToHttpScheme("ws://example.com:8080"); + expect(result).toBe("http://example.com:8080"); + }); + }); + + describe("discoverOrigins", () => { + const mockFetch = global.fetch as jest.MockedFunction; + + it("should discover origins from header", async () => { + const mockResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue("origin1,origin2,origin3"), + }, + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await discoverOrigins("wss://example.com", "api-key", "user-secret"); + + expect(result).toEqual(["origin1", "origin2", "origin3"]); + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/", + expect.objectContaining({ + method: "HEAD", + headers: expect.any(Object), + signal: expect.any(AbortSignal), + }) + ); + }); + + it("should handle origins with brackets", async () => { + const mockResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue("{origin1,origin2}"), + }, + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await discoverOrigins("wss://example.com", "api-key", "user-secret"); + + expect(result).toEqual(["origin1", "origin2"]); + }); + + it("should return empty array when header is missing", async () => { + const mockResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue(null), + }, + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await discoverOrigins("wss://example.com", "api-key", "user-secret"); + + expect(result).toEqual([]); + }); + + it("should throw OriginDiscoveryError on HTTP error", async () => { + const mockResponse = { + ok: false, + status: 404, + statusText: "Not Found", + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + await expect(discoverOrigins("wss://example.com", "api-key", "user-secret")).rejects.toThrow( + OriginDiscoveryError + ); + }); + + it("should throw OriginDiscoveryError on network error", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + await expect(discoverOrigins("wss://example.com", "api-key", "user-secret")).rejects.toThrow( + OriginDiscoveryError + ); + }); + + it("should handle timeout", async () => { + const abortError = new Error("The operation was aborted"); + abortError.name = "AbortError"; + + // Mock fetch to reject with AbortError to simulate timeout + mockFetch.mockRejectedValueOnce(abortError); + + await expect( + discoverOrigins( + "wss://example.com", + "api-key", + "user-secret", + 1000 // 1 second timeout + ) + ).rejects.toThrow(OriginDiscoveryError); + + // Test the error message separately + mockFetch.mockRejectedValueOnce(abortError); + + await expect(discoverOrigins("wss://example.com", "api-key", "user-secret", 1000)).rejects.toThrow(/timed out/); + }); + + it("should convert WebSocket scheme to HTTP", async () => { + const mockResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue("origin1"), + }, + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + await discoverOrigins("ws://example.com", "api-key", "user-secret"); + + expect(mockFetch).toHaveBeenCalledWith("http://example.com/", expect.any(Object)); + }); + + it("should include authentication headers", async () => { + const mockResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue("origin1"), + }, + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + await discoverOrigins("wss://example.com", "test-key", "test-secret"); + + const [, options] = mockFetch.mock.calls[0]; + expect(options).toBeDefined(); + expect(options!.headers).toHaveProperty("Authorization"); + expect(options!.headers).toHaveProperty("X-Authorization-Signature-SHA256"); + expect(options!.headers).toHaveProperty("X-Authorization-Timestamp"); + }); + + it("should request the correct origins header", async () => { + const mockResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue("origin1,origin2"), + }, + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + await discoverOrigins("wss://example.com", "api-key", "user-secret"); + + // Verify that the response.headers.get was called with the correct header name + expect(mockResponse.headers.get).toHaveBeenCalledWith(X_CLL_AVAILABLE_ORIGINS_HEADER); + }); + }); + + describe("getAvailableOrigins", () => { + const mockFetch = global.fetch as jest.MockedFunction; + + it("should use static origins when dynamic discovery is disabled", async () => { + const result = await getAvailableOrigins( + "wss://origin1,wss://origin2", + "api-key", + "user-secret", + false // dynamic discovery disabled + ); + + expect(result).toEqual(["wss://origin1", "wss://origin2"]); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should use static origins when multiple static origins exist", async () => { + const result = await getAvailableOrigins( + "wss://origin1,wss://origin2", + "api-key", + "user-secret", + true // dynamic discovery enabled + ); + + expect(result).toEqual(["wss://origin1", "wss://origin2"]); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should attempt dynamic discovery for single static origin", async () => { + const mockResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue("dynamic1,dynamic2"), + }, + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getAvailableOrigins( + "wss://single-origin", + "api-key", + "user-secret", + true // dynamic discovery enabled + ); + + expect(result).toEqual(["wss://single-origin#dynamic1", "wss://single-origin#dynamic2"]); + expect(mockFetch).toHaveBeenCalled(); + }); + + it("should fall back to static origins when dynamic discovery fails", async () => { + mockFetch.mockRejectedValueOnce(new Error("Discovery failed")); + + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); + + const result = await getAvailableOrigins("wss://static-origin", "api-key", "user-secret", true); + + expect(result).toEqual(["wss://static-origin"]); + // Should NOT log anything - developers control logging through events + expect(consoleSpy).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it("should fall back to static origins when no dynamic origins found", async () => { + const mockResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue(null), // No origins header + }, + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getAvailableOrigins("wss://static-origin", "api-key", "user-secret", true); + + expect(result).toEqual(["wss://static-origin"]); + }); + + it("should return empty array when no origins available and discovery disabled", async () => { + const result = await getAvailableOrigins( + "", // Empty URL + "api-key", + "user-secret", + false // dynamic discovery disabled + ); + + expect(result).toEqual([]); + }); + + it("should throw InsufficientConnectionsError when discovery fails and no static origins", async () => { + mockFetch.mockRejectedValueOnce(new Error("Discovery failed")); + + await expect( + getAvailableOrigins( + "", // Empty URL + "api-key", + "user-secret", + true + ) + ).rejects.toThrow(InsufficientConnectionsError); + }); + + it("should respect timeout parameter", async () => { + const mockResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue("origin1"), + }, + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + await getAvailableOrigins( + "wss://single-origin", + "api-key", + "user-secret", + true, + 5000 // custom timeout + ); + + // Verify that the timeout was passed to discoverOrigins + // (This is implicit since discoverOrigins was called with the timeout) + expect(mockFetch).toHaveBeenCalled(); + }); + }); +}); diff --git a/typescript/tests/unit/utils/validation/config-validation.test.ts b/typescript/tests/unit/utils/validation/config-validation.test.ts new file mode 100644 index 0000000..6975c03 --- /dev/null +++ b/typescript/tests/unit/utils/validation/config-validation.test.ts @@ -0,0 +1,684 @@ +/** + * Unit Tests for Configuration Validation + * + * These tests validate the configuration functionality by: + * - Testing valid config creation and validation + * - Testing missing required fields (apiKey, userSecret, endpoint, wsEndpoint) + * - Testing invalid URL formats (REST and WebSocket) + * - Testing URL scheme validation + * - Testing timeout and retry configuration validation + * - Testing HA mode configuration validation + * - Testing config edge cases and error messages + * - Testing config normalization and defaults + * + * Goals: + * - Ensure robust config validation that prevents invalid configurations + * - Test all edge cases and error scenarios comprehensively + * - Provide clear, helpful error messages for developers + * - Build the best possible TypeScript configuration validation + */ + +import { describe, it, expect } from "@jest/globals"; +import { createClient } from "../../../../src/client"; +import { Config } from "../../../../src/types/client"; + +// Mock console methods to avoid noise during tests +jest.spyOn(console, "info").mockImplementation(() => {}); +jest.spyOn(console, "warn").mockImplementation(() => {}); +jest.spyOn(console, "error").mockImplementation(() => {}); +jest.spyOn(console, "log").mockImplementation(() => {}); + +describe("Configuration Validation Tests", () => { + // Valid base configuration for testing + const VALID_CONFIG: Config = { + apiKey: "test-api-key-12345", + userSecret: "test-user-secret-67890", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + }; + + describe("valid configuration creation", () => { + it("should accept minimal valid configuration", () => { + expect(() => createClient(VALID_CONFIG)).not.toThrow(); + }); + + it("should accept configuration with all optional fields", () => { + const fullConfig: Config = { + ...VALID_CONFIG, + retryAttempts: 5, + retryDelay: 2000, + timeout: 60000, + haMode: true, + haConnectionTimeout: 10000, + logging: { + logger: console, + }, + }; + expect(() => createClient(fullConfig)).not.toThrow(); + }); + + it("should accept configuration with HA mode enabled", () => { + const haConfig: Config = { + ...VALID_CONFIG, + haMode: true, + wsEndpoint: "wss://ws1.example.com,wss://ws2.example.com", + }; + expect(() => createClient(haConfig)).not.toThrow(); + }); + + it("should accept configuration with single WebSocket URL", () => { + const singleWsConfig: Config = { + ...VALID_CONFIG, + wsEndpoint: "wss://ws.example.com", + }; + expect(() => createClient(singleWsConfig)).not.toThrow(); + }); + + it("should accept configuration with multiple comma-separated WebSocket URLs", () => { + const multiWsConfig: Config = { + ...VALID_CONFIG, + wsEndpoint: "wss://ws1.example.com,wss://ws2.example.com,wss://ws3.example.com", + }; + expect(() => createClient(multiWsConfig)).not.toThrow(); + }); + }); + + describe("required field validation", () => { + it("should reject configuration without apiKey", () => { + const configWithoutApiKey = { + ...VALID_CONFIG, + apiKey: undefined as any, + }; + expect(() => createClient(configWithoutApiKey)).toThrow(); + }); + + it("should reject configuration with empty apiKey", () => { + const configWithEmptyApiKey = { + ...VALID_CONFIG, + apiKey: "", + }; + expect(() => createClient(configWithEmptyApiKey)).toThrow(); + }); + + it("should reject configuration without userSecret", () => { + const configWithoutUserSecret = { + ...VALID_CONFIG, + userSecret: undefined as any, + }; + expect(() => createClient(configWithoutUserSecret)).toThrow(); + }); + + it("should reject configuration with empty userSecret", () => { + const configWithEmptyUserSecret = { + ...VALID_CONFIG, + userSecret: "", + }; + expect(() => createClient(configWithEmptyUserSecret)).toThrow(); + }); + + it("should reject configuration without endpoint", () => { + const configWithoutEndpoint = { + ...VALID_CONFIG, + endpoint: undefined as any, + }; + expect(() => createClient(configWithoutEndpoint)).toThrow(); + }); + + it("should reject configuration with empty endpoint", () => { + const configWithEmptyEndpoint = { + ...VALID_CONFIG, + endpoint: "", + }; + expect(() => createClient(configWithEmptyEndpoint)).toThrow(); + }); + + it("should reject configuration without wsEndpoint", () => { + const configWithoutWsEndpoint = { + ...VALID_CONFIG, + wsEndpoint: undefined as any, + }; + expect(() => createClient(configWithoutWsEndpoint)).toThrow(); + }); + + it("should reject configuration with empty wsEndpoint", () => { + const configWithEmptyWsEndpoint = { + ...VALID_CONFIG, + wsEndpoint: "", + }; + expect(() => createClient(configWithEmptyWsEndpoint)).toThrow(); + }); + }); + + describe("URL format validation", () => { + it("should reject invalid REST URL format", () => { + const configWithInvalidRestUrl = { + ...VALID_CONFIG, + endpoint: "not-a-valid-url", + }; + expect(() => createClient(configWithInvalidRestUrl)).toThrow(); + }); + + it("should reject REST URL without protocol", () => { + const configWithoutProtocol = { + ...VALID_CONFIG, + endpoint: "api.example.com", + }; + expect(() => createClient(configWithoutProtocol)).toThrow(); + }); + + it("should reject REST URL with invalid protocol", () => { + const configWithInvalidProtocol = { + ...VALID_CONFIG, + endpoint: "ftp://api.example.com", + }; + expect(() => createClient(configWithInvalidProtocol)).toThrow(); + }); + + it("should accept HTTPS REST URLs", () => { + const configWithHttps = { + ...VALID_CONFIG, + endpoint: "https://api.example.com", + }; + expect(() => createClient(configWithHttps)).not.toThrow(); + }); + + it("should accept HTTP REST URLs (for testing)", () => { + const configWithHttp = { + ...VALID_CONFIG, + endpoint: "http://localhost:8080", + }; + expect(() => createClient(configWithHttp)).not.toThrow(); + }); + + it("should reject invalid WebSocket URL format", () => { + const configWithInvalidWsUrl = { + ...VALID_CONFIG, + wsEndpoint: "not-a-valid-websocket-url", + }; + expect(() => createClient(configWithInvalidWsUrl)).toThrow(); + }); + + it("should reject WebSocket URL without ws/wss protocol", () => { + const configWithoutWsProtocol = { + ...VALID_CONFIG, + wsEndpoint: "https://ws.example.com", + }; + expect(() => createClient(configWithoutWsProtocol)).toThrow(); + }); + + it("should accept WSS WebSocket URLs", () => { + const configWithWss = { + ...VALID_CONFIG, + wsEndpoint: "wss://ws.example.com", + }; + expect(() => createClient(configWithWss)).not.toThrow(); + }); + + it("should accept WS WebSocket URLs (for testing)", () => { + const configWithWs = { + ...VALID_CONFIG, + wsEndpoint: "ws://localhost:8080", + }; + expect(() => createClient(configWithWs)).not.toThrow(); + }); + + it("should reject malformed URLs in comma-separated list", () => { + const configWithMalformedUrls = { + ...VALID_CONFIG, + wsEndpoint: "wss://ws1.example.com,invalid-url,wss://ws2.example.com", + }; + expect(() => createClient(configWithMalformedUrls)).toThrow(); + }); + + it("should reject mixed protocols in comma-separated list", () => { + const configWithMixedProtocols = { + ...VALID_CONFIG, + wsEndpoint: "wss://ws1.example.com,https://ws2.example.com", + }; + expect(() => createClient(configWithMixedProtocols)).toThrow(); + }); + }); + + describe("timeout and retry configuration validation", () => { + it("should accept valid timeout values", () => { + const configWithTimeout = { + ...VALID_CONFIG, + timeout: 30000, + }; + expect(() => createClient(configWithTimeout)).not.toThrow(); + }); + + it("should reject negative timeout", () => { + const configWithNegativeTimeout = { + ...VALID_CONFIG, + timeout: -1000, + }; + expect(() => createClient(configWithNegativeTimeout)).toThrow(); + }); + + it("should reject zero timeout", () => { + const configWithZeroTimeout = { + ...VALID_CONFIG, + timeout: 0, + }; + expect(() => createClient(configWithZeroTimeout)).toThrow(); + }); + + it("should accept valid retry attempts", () => { + const configWithRetryAttempts = { + ...VALID_CONFIG, + retryAttempts: 5, + }; + expect(() => createClient(configWithRetryAttempts)).not.toThrow(); + }); + + it("should reject negative retry attempts", () => { + const configWithNegativeRetry = { + ...VALID_CONFIG, + retryAttempts: -1, + }; + expect(() => createClient(configWithNegativeRetry)).toThrow(); + }); + + it("should accept zero retry attempts", () => { + const configWithZeroRetry = { + ...VALID_CONFIG, + retryAttempts: 0, + }; + expect(() => createClient(configWithZeroRetry)).not.toThrow(); + }); + + it("should accept valid retry delay", () => { + const configWithRetryDelay = { + ...VALID_CONFIG, + retryDelay: 2000, + }; + expect(() => createClient(configWithRetryDelay)).not.toThrow(); + }); + + it("should reject negative retry delay", () => { + const configWithNegativeDelay = { + ...VALID_CONFIG, + retryDelay: -500, + }; + expect(() => createClient(configWithNegativeDelay)).toThrow(); + }); + + it("should accept zero retry delay", () => { + const configWithZeroDelay = { + ...VALID_CONFIG, + retryDelay: 0, + }; + expect(() => createClient(configWithZeroDelay)).not.toThrow(); + }); + }); + + describe("HA mode configuration validation", () => { + it("should accept HA mode with multiple origins", () => { + const haConfig = { + ...VALID_CONFIG, + haMode: true, + wsEndpoint: "wss://ws1.example.com,wss://ws2.example.com", + }; + expect(() => createClient(haConfig)).not.toThrow(); + }); + + it("should validate HA mode with single origin without forced logging", () => { + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); + + const haConfigSingleOrigin = { + ...VALID_CONFIG, + haMode: true, + wsEndpoint: "wss://ws.example.com", + }; + + // Should not throw and should NOT log anything (developers control logging) + expect(() => createClient(haConfigSingleOrigin)).not.toThrow(); + expect(consoleSpy).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it("should accept HA mode with origin discovery enabled", () => { + const haConfigWithDiscovery = { + ...VALID_CONFIG, + haMode: true, + wsEndpoint: "wss://ws.example.com", + }; + expect(() => createClient(haConfigWithDiscovery)).not.toThrow(); + }); + + it("should accept valid HA connection timeout", () => { + const haConfigWithTimeout = { + ...VALID_CONFIG, + haMode: true, + haConnectionTimeout: 10000, + }; + expect(() => createClient(haConfigWithTimeout)).not.toThrow(); + }); + + it("should validate very low HA connection timeout without forced logging", () => { + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); + + const haConfigLowTimeout = { + ...VALID_CONFIG, + haMode: true, + haConnectionTimeout: 500, // Less than 1 second + }; + + // Should not throw and should NOT log anything (developers control logging) + expect(() => createClient(haConfigLowTimeout)).not.toThrow(); + expect(consoleSpy).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it("should accept connection status callback", () => { + const callback = jest.fn(); + const haConfigWithCallback = { + ...VALID_CONFIG, + haMode: true, + connectionStatusCallback: callback, + }; + expect(() => createClient(haConfigWithCallback)).not.toThrow(); + }); + }); + + describe("detailed URL validation scenarios", () => { + it("should reject malformed REST URL with colon prefix", () => { + const configWithColonUrl = { + ...VALID_CONFIG, + endpoint: ":rest.domain.link", + }; + expect(() => createClient(configWithColonUrl)).toThrow(); + }); + + it("should reject malformed WebSocket URL with colon prefix", () => { + const configWithColonWsUrl = { + ...VALID_CONFIG, + wsEndpoint: ":ws.domain.link", + }; + expect(() => createClient(configWithColonWsUrl)).toThrow(); + }); + + it("should reject URLs with invalid characters", () => { + const configWithInvalidChars = { + ...VALID_CONFIG, + endpoint: "https://api[invalid].example.com", + }; + expect(() => createClient(configWithInvalidChars)).toThrow(); + }); + + it("should reject WebSocket URLs with spaces", () => { + const configWithSpacesInWs = { + ...VALID_CONFIG, + wsEndpoint: "wss://ws .example.com", + }; + expect(() => createClient(configWithSpacesInWs)).toThrow(); + }); + + it("should accept valid URLs with subdomains and paths", () => { + const configWithComplexUrls = { + ...VALID_CONFIG, + endpoint: "https://api.prod.dataengine.chain.link/v1/streams", + wsEndpoint: "wss://ws.prod.dataengine.chain.link/stream/v1", + }; + expect(() => createClient(configWithComplexUrls)).not.toThrow(); + }); + }); + + describe("configuration validation error messages", () => { + it("should provide clear error message for missing API key", () => { + const configWithoutApiKey = { + ...VALID_CONFIG, + apiKey: undefined as any, + }; + expect(() => createClient(configWithoutApiKey)).toThrow(/apiKey/i); + }); + + it("should provide clear error message for missing user secret", () => { + const configWithoutSecret = { + ...VALID_CONFIG, + userSecret: undefined as any, + }; + expect(() => createClient(configWithoutSecret)).toThrow(/userSecret/i); + }); + + it("should provide clear error message for invalid endpoint", () => { + const configWithInvalidEndpoint = { + ...VALID_CONFIG, + endpoint: "invalid-url", + }; + expect(() => createClient(configWithInvalidEndpoint)).toThrow(/endpoint/i); + }); + + it("should provide clear error message for invalid WebSocket endpoint", () => { + const configWithInvalidWs = { + ...VALID_CONFIG, + wsEndpoint: "invalid-ws-url", + }; + expect(() => createClient(configWithInvalidWs)).toThrow(/websocket|ws/i); + }); + }); + + describe("edge cases and error scenarios", () => { + it("should reject null configuration", () => { + expect(() => createClient(null as any)).toThrow(); + }); + + it("should reject undefined configuration", () => { + expect(() => createClient(undefined as any)).toThrow(); + }); + + it("should reject empty configuration object", () => { + expect(() => createClient({} as any)).toThrow(); + }); + + it("should handle configuration with extra properties", () => { + const configWithExtra = { + ...VALID_CONFIG, + extraProperty: "should be ignored", + } as any; + expect(() => createClient(configWithExtra)).not.toThrow(); + }); + + it("should reject configuration with wrong type for apiKey", () => { + const configWithWrongType = { + ...VALID_CONFIG, + apiKey: 12345, + } as any; + expect(() => createClient(configWithWrongType)).toThrow(); + }); + + it("should reject configuration with wrong type for haMode", () => { + const configWithWrongHaType = { + ...VALID_CONFIG, + haMode: "true", + } as any; + expect(() => createClient(configWithWrongHaType)).toThrow(); + }); + + it("should handle whitespace in URLs", () => { + const configWithWhitespace = { + ...VALID_CONFIG, + endpoint: " https://api.example.com ", + wsEndpoint: " wss://ws.example.com ", + }; + // This might pass or fail depending on our implementation + // The test documents the current behavior + expect(() => createClient(configWithWhitespace)).not.toThrow(); + }); + + it("should handle Unicode characters in configuration", () => { + const configWithUnicode = { + ...VALID_CONFIG, + apiKey: "test-api-key-šŸ”‘", + userSecret: "test-secret-šŸ”", + }; + expect(() => createClient(configWithUnicode)).not.toThrow(); + }); + }); + + describe("configuration defaults", () => { + it("should apply default values for optional fields", () => { + const client = createClient(VALID_CONFIG); + // We can't directly inspect the config, but we can test that defaults are used + expect(client).toBeDefined(); + }); + + it("should use default timeout when not specified", () => { + const client = createClient(VALID_CONFIG); + expect(client).toBeDefined(); + // Default timeout should be applied internally + }); + + it("should use default retry settings when not specified", () => { + const client = createClient(VALID_CONFIG); + expect(client).toBeDefined(); + // Default retry settings should be applied internally + }); + + it("should use default HA mode settings when not specified", () => { + const client = createClient(VALID_CONFIG); + expect(client).toBeDefined(); + // HA mode should be disabled by default + }); + + it("should override defaults when explicitly provided", () => { + const customConfig = { + ...VALID_CONFIG, + timeout: 60000, + retryAttempts: 10, + haMode: true, + }; + const client = createClient(customConfig); + expect(client).toBeDefined(); + // Custom values should override defaults + }); + }); + + describe("configuration normalization", () => { + it("should handle trailing slashes in URLs", () => { + const configWithTrailingSlashes = { + ...VALID_CONFIG, + endpoint: "https://api.example.com/", + wsEndpoint: "wss://ws.example.com/", + }; + expect(() => createClient(configWithTrailingSlashes)).not.toThrow(); + }); + + it("should handle URLs with paths", () => { + const configWithPaths = { + ...VALID_CONFIG, + endpoint: "https://api.example.com/v1", + wsEndpoint: "wss://ws.example.com/stream", + }; + expect(() => createClient(configWithPaths)).not.toThrow(); + }); + + it("should handle URLs with query parameters", () => { + const configWithQuery = { + ...VALID_CONFIG, + endpoint: "https://api.example.com?version=1", + wsEndpoint: "wss://ws.example.com?protocol=v1", + }; + expect(() => createClient(configWithQuery)).not.toThrow(); + }); + + it("should handle URLs with ports", () => { + const configWithPorts = { + ...VALID_CONFIG, + endpoint: "https://api.example.com:8443", + wsEndpoint: "wss://ws.example.com:8443", + }; + expect(() => createClient(configWithPorts)).not.toThrow(); + }); + }); + + describe("real-world configuration scenarios", () => { + it("should accept production-like configuration", () => { + const prodConfig = { + apiKey: "prod-api-key-abcdef123456", + userSecret: "prod-secret-xyz789", + endpoint: "https://api.dataengine.chain.link", + wsEndpoint: "wss://ws.dataengine.chain.link", + timeout: 30000, + retryAttempts: 3, + retryDelay: 1000, + }; + expect(() => createClient(prodConfig)).not.toThrow(); + }); + + it("should accept testnet configuration", () => { + const testnetConfig = { + apiKey: "test-api-key", + userSecret: "test-secret", + endpoint: "https://api.testnet-dataengine.chain.link", + wsEndpoint: "wss://ws.testnet-dataengine.chain.link", + }; + expect(() => createClient(testnetConfig)).not.toThrow(); + }); + + it("should accept local development configuration", () => { + const devConfig = { + apiKey: "dev-key", + userSecret: "dev-secret", + endpoint: "http://localhost:3000", + wsEndpoint: "ws://localhost:3001", + timeout: 5000, + retryAttempts: 1, + }; + expect(() => createClient(devConfig)).not.toThrow(); + }); + + it("should accept HA production configuration", () => { + const haProdConfig = { + apiKey: "ha-prod-key", + userSecret: "ha-prod-secret", + endpoint: "https://api.dataengine.chain.link", + wsEndpoint: "wss://ws1.dataengine.chain.link,wss://ws2.dataengine.chain.link", + haMode: true, + haConnectionTimeout: 10000, + connectionStatusCallback: (isConnected: boolean, host: string, origin: string) => { + console.log(`Connection ${isConnected ? "established" : "lost"} to ${host} (${origin})`); + }, + }; + expect(() => createClient(haProdConfig)).not.toThrow(); + }); + }); + + describe("performance and memory", () => { + it("should create clients efficiently", () => { + const start = performance.now(); + + // Create 100 clients + for (let i = 0; i < 100; i++) { + const client = createClient(VALID_CONFIG); + expect(client).toBeDefined(); + } + + const end = performance.now(); + const duration = end - start; + + // Should complete in reasonable time (less than 1000ms) + expect(duration).toBeLessThan(1000); + }); + + it("should handle large configuration objects", () => { + const largeConfig = { + ...VALID_CONFIG, + // Add many optional fields + retryAttempts: 10, + retryDelay: 2000, + timeout: 60000, + haMode: true, + haConnectionTimeout: 15000, + connectionStatusCallback: () => {}, + // Simulate large comma-separated URL list + wsEndpoint: Array(50).fill("wss://ws.example.com").join(","), + }; + + expect(() => createClient(largeConfig)).not.toThrow(); + }); + }); +}); diff --git a/typescript/tests/unit/utils/validation/feed-validation.test.ts b/typescript/tests/unit/utils/validation/feed-validation.test.ts new file mode 100644 index 0000000..d725a4d --- /dev/null +++ b/typescript/tests/unit/utils/validation/feed-validation.test.ts @@ -0,0 +1,424 @@ +/** + * Unit Tests for Feed ID Validation Functions + * + * These tests validate the feed ID functionality by: + * - Testing feed ID hex string parsing and validation + * - Testing feed ID format validation (length, prefix, characters) + * - Testing invalid feed ID rejection with clear error messages + * - Testing feed ID normalization and case handling + * - Testing version extraction from feed IDs + * - Testing edge cases (empty, null, malformed, special characters) + * - Testing feed ID comparison and equality + * - Testing feed ID array validation + * + * Goals: + * - Ensure robust feed ID validation that prevents invalid data + * - Test all edge cases and error scenarios comprehensively + * - Support feed versions V2, V3, V4, V5, V6, V7, V8, V9, V10 + * - Provide clear, helpful error messages for developers + * - Build the best possible TypeScript feed ID validation + */ + +import { describe, it, expect } from "@jest/globals"; +import { validateFeedId, validateFeedIds } from "../../../../src/utils/validation"; +import { ValidationError } from "../../../../src/types/errors"; + +describe("Feed ID Validation Tests", () => { + // Test vectors from reference implementations + const VALID_FEED_IDS = { + V1: "0x00016b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V2: "0x00026b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V3: "0x00036b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V4: "0x00046b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V5: "0x00056b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V6: "0x00066b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V7: "0x00076b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V8: "0x00086b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V9: "0x00096b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V10: "0x000a6b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + }; + + const REAL_WORLD_FEED_IDS = { + // Real feed IDs from reference tests + FEED1: "0x00020ffa644e6c585a5bec0e25ca476b6666666666e22b6240957720dcba0e14", + FEED2: "0x00020ffa644e6c585a88888825ca476b6666666666e22b6240957720dcba0e14", + }; + + describe("valid feed ID formats", () => { + it("should reject unsupported V1 feed ID", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V1)).toThrow(ValidationError); + expect(() => validateFeedId(VALID_FEED_IDS.V1)).toThrow("Invalid feed ID version"); + }); + + it("should accept valid V2 feed ID", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V2)).not.toThrow(); + }); + + it("should accept valid V3 feed ID", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V3)).not.toThrow(); + }); + + it("should accept valid V4 feed ID", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V4)).not.toThrow(); + }); + + it("should accept valid V5 feed ID", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V5)).not.toThrow(); + }); + + it("should accept valid V6 feed ID", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V6)).not.toThrow(); + }); + + it("should accept valid V7 feed ID", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V7)).not.toThrow(); + }); + + it("should accept valid V8 feed ID", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V8)).not.toThrow(); + }); + + it("should accept valid V9 feed ID", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V9)).not.toThrow(); + }); + + it("should accept valid V10 feed ID", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V10)).not.toThrow(); + }); + + it("should accept real-world feed IDs", () => { + expect(() => validateFeedId(REAL_WORLD_FEED_IDS.FEED1)).not.toThrow(); + expect(() => validateFeedId(REAL_WORLD_FEED_IDS.FEED2)).not.toThrow(); + }); + + it("should reject uppercase prefix but accept uppercase hex", () => { + const uppercaseFeedId = VALID_FEED_IDS.V3.toUpperCase(); // "0X..." format + expect(() => validateFeedId(uppercaseFeedId)).toThrow(ValidationError); + + // But lowercase prefix with uppercase hex should work + const mixedCase = "0x" + VALID_FEED_IDS.V3.slice(2).toUpperCase(); + expect(() => validateFeedId(mixedCase)).not.toThrow(); + }); + + it("should accept mixed case hex characters", () => { + const mixedCaseFeedId = "0x00036B4aa7E57ca7B68ae1BF45653f56B656fd3AA335ef7fAE696b663F1b8472"; + expect(() => validateFeedId(mixedCaseFeedId)).not.toThrow(); + }); + }); + + describe("feed ID format validation", () => { + it("should reject feed ID without 0x prefix", () => { + const withoutPrefix = VALID_FEED_IDS.V3.slice(2); // Remove 0x + expect(() => validateFeedId(withoutPrefix)).toThrow(ValidationError); + expect(() => validateFeedId(withoutPrefix)).toThrow("Invalid feed ID format"); + }); + + it("should reject feed ID with wrong prefix", () => { + const wrongPrefix = "0y" + VALID_FEED_IDS.V3.slice(2); + expect(() => validateFeedId(wrongPrefix)).toThrow(ValidationError); + expect(() => validateFeedId(wrongPrefix)).toThrow("Invalid feed ID format"); + }); + + it("should reject feed ID with incorrect length (too short)", () => { + const tooShort = "0x00036b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b84"; // 62 chars instead of 64 + expect(() => validateFeedId(tooShort)).toThrow(ValidationError); + expect(() => validateFeedId(tooShort)).toThrow("Invalid feed ID format"); + }); + + it("should reject feed ID with incorrect length (too long)", () => { + const tooLong = VALID_FEED_IDS.V3 + "72"; // Extra characters + expect(() => validateFeedId(tooLong)).toThrow(ValidationError); + expect(() => validateFeedId(tooLong)).toThrow("Invalid feed ID format"); + }); + + it("should reject feed ID with invalid hex characters", () => { + const invalidHex = "0x00036g4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472"; // 'g' is not valid hex + expect(() => validateFeedId(invalidHex)).toThrow(ValidationError); + expect(() => validateFeedId(invalidHex)).toThrow("Invalid feed ID format"); + }); + + it("should reject feed ID with special characters", () => { + const specialChars = "0x00036b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b84@#"; + expect(() => validateFeedId(specialChars)).toThrow(ValidationError); + expect(() => validateFeedId(specialChars)).toThrow("Invalid feed ID format"); + }); + + it("should reject feed ID with spaces", () => { + const withSpaces = "0x0003 6b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472"; + expect(() => validateFeedId(withSpaces)).toThrow(ValidationError); + expect(() => validateFeedId(withSpaces)).toThrow("Invalid feed ID format"); + }); + }); + + describe("feed ID version validation", () => { + it("should reject unsupported version V1 (0x0001)", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V1)).toThrow(ValidationError); + expect(() => validateFeedId(VALID_FEED_IDS.V1)).toThrow("Invalid feed ID version"); + }); + + it("should accept supported version V2 (0x0002)", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V2)).not.toThrow(); + }); + + it("should accept supported version V3 (0x0003)", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V3)).not.toThrow(); + }); + + it("should accept supported version V4 (0x0004)", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V4)).not.toThrow(); + }); + + it("should accept supported version V5 (0x0005)", () => { + const v5FeedId = "0x00056b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472"; + expect(() => validateFeedId(v5FeedId)).not.toThrow(); + }); + + it("should reject version 0 (0x0000)", () => { + const v0FeedId = "0x00006b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472"; + expect(() => validateFeedId(v0FeedId)).toThrow(ValidationError); + expect(() => validateFeedId(v0FeedId)).toThrow("Invalid feed ID version"); + }); + + it("should reject very high version numbers", () => { + const highVersionFeedId = "0xFFFF6b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472"; + expect(() => validateFeedId(highVersionFeedId)).toThrow(ValidationError); + expect(() => validateFeedId(highVersionFeedId)).toThrow("Invalid feed ID version"); + }); + }); + + describe("edge cases and error scenarios", () => { + it("should reject empty string", () => { + expect(() => validateFeedId("")).toThrow(ValidationError); + expect(() => validateFeedId("")).toThrow("Feed ID is required"); + }); + + it("should reject null feed ID", () => { + expect(() => validateFeedId(null as any)).toThrow(ValidationError); + expect(() => validateFeedId(null as any)).toThrow("Feed ID is required"); + }); + + it("should reject undefined feed ID", () => { + expect(() => validateFeedId(undefined as any)).toThrow(ValidationError); + expect(() => validateFeedId(undefined as any)).toThrow("Feed ID is required"); + }); + + it("should reject whitespace-only string", () => { + expect(() => validateFeedId(" ")).toThrow(ValidationError); + expect(() => validateFeedId(" ")).toThrow("Invalid feed ID format"); + }); + + it("should reject number instead of string", () => { + expect(() => validateFeedId(123 as any)).toThrow(ValidationError); + expect(() => validateFeedId(123 as any)).toThrow("Invalid feed ID format"); + }); + + it("should reject object instead of string", () => { + expect(() => validateFeedId({} as any)).toThrow(ValidationError); + expect(() => validateFeedId({} as any)).toThrow("Invalid feed ID format"); + }); + + it("should reject array instead of string", () => { + expect(() => validateFeedId([] as any)).toThrow(ValidationError); + expect(() => validateFeedId([] as any)).toThrow("Invalid feed ID format"); + }); + + it("should reject boolean instead of string", () => { + expect(() => validateFeedId(true as any)).toThrow(ValidationError); + expect(() => validateFeedId(true as any)).toThrow("Invalid feed ID format"); + }); + }); + + describe("feed ID normalization and case handling", () => { + it("should handle leading/trailing whitespace (if we decide to be permissive)", () => { + // Note: Our current implementation doesn't trim, but we could enhance it + const withWhitespace = ` ${VALID_FEED_IDS.V3} `; + expect(() => validateFeedId(withWhitespace)).toThrow(); // Current behavior + }); + + it("should be case insensitive for hex characters but not prefix", () => { + const lowercase = VALID_FEED_IDS.V3.toLowerCase(); + const uppercaseHex = "0x" + VALID_FEED_IDS.V3.slice(2).toUpperCase(); + + expect(() => validateFeedId(lowercase)).not.toThrow(); + expect(() => validateFeedId(uppercaseHex)).not.toThrow(); + }); + + it("should reject uppercase 0X prefix", () => { + const uppercasePrefix = VALID_FEED_IDS.V3.replace("0x", "0X"); + expect(() => validateFeedId(uppercasePrefix)).toThrow(ValidationError); + expect(() => validateFeedId(uppercasePrefix)).toThrow("Invalid feed ID format"); + }); + }); + + describe("feed ID array validation", () => { + it("should accept array of valid feed IDs", () => { + const validArray = [VALID_FEED_IDS.V2, VALID_FEED_IDS.V3, VALID_FEED_IDS.V4]; + expect(() => validateFeedIds(validArray)).not.toThrow(); + }); + + it("should accept single feed ID in array", () => { + const singleArray = [VALID_FEED_IDS.V3]; + expect(() => validateFeedIds(singleArray)).not.toThrow(); + }); + + it("should reject empty array", () => { + expect(() => validateFeedIds([])).toThrow(ValidationError); + expect(() => validateFeedIds([])).toThrow("At least one feed ID is required"); + }); + + it("should reject non-array input", () => { + expect(() => validateFeedIds("not-an-array" as any)).toThrow(ValidationError); + expect(() => validateFeedIds("not-an-array" as any)).toThrow("Feed IDs must be an array"); + }); + + it("should reject null array", () => { + expect(() => validateFeedIds(null as any)).toThrow(ValidationError); + expect(() => validateFeedIds(null as any)).toThrow("Feed IDs must be an array"); + }); + + it("should reject undefined array", () => { + expect(() => validateFeedIds(undefined as any)).toThrow(ValidationError); + expect(() => validateFeedIds(undefined as any)).toThrow("Feed IDs must be an array"); + }); + + it("should reject array with invalid feed ID", () => { + const mixedArray = [VALID_FEED_IDS.V3, "invalid-feed-id", VALID_FEED_IDS.V4]; + expect(() => validateFeedIds(mixedArray)).toThrow(ValidationError); + expect(() => validateFeedIds(mixedArray)).toThrow("Invalid feed ID format"); + }); + + it("should reject array with empty string", () => { + const arrayWithEmpty = [VALID_FEED_IDS.V3, "", VALID_FEED_IDS.V4]; + expect(() => validateFeedIds(arrayWithEmpty)).toThrow(ValidationError); + expect(() => validateFeedIds(arrayWithEmpty)).toThrow("Feed ID is required"); + }); + + it("should reject array with null element", () => { + const arrayWithNull = [VALID_FEED_IDS.V3, null, VALID_FEED_IDS.V4]; + expect(() => validateFeedIds(arrayWithNull as any)).toThrow(ValidationError); + expect(() => validateFeedIds(arrayWithNull as any)).toThrow("Feed ID is required"); + }); + + it("should handle large arrays efficiently", () => { + const largeArray = Array(1000).fill(VALID_FEED_IDS.V3); + expect(() => validateFeedIds(largeArray)).not.toThrow(); + }); + + it("should handle duplicate feed IDs in array", () => { + const duplicateArray = [VALID_FEED_IDS.V3, VALID_FEED_IDS.V3, VALID_FEED_IDS.V4]; + expect(() => validateFeedIds(duplicateArray)).not.toThrow(); // Duplicates are allowed + }); + }); + + describe("error message quality", () => { + it("should provide specific error for missing prefix", () => { + const withoutPrefix = VALID_FEED_IDS.V3.slice(2); + expect(() => validateFeedId(withoutPrefix)).toThrow( + "Invalid feed ID format. Must be 0x followed by 64 hex characters" + ); + }); + + it("should provide specific error for wrong length", () => { + const wrongLength = "0x123"; + expect(() => validateFeedId(wrongLength)).toThrow( + "Invalid feed ID format. Must be 0x followed by 64 hex characters" + ); + }); + + it("should provide specific error for unsupported version", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V1)).toThrow( + "Invalid feed ID version. Must start with 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, or 0x000a" + ); + }); + + it("should provide helpful error for empty input", () => { + expect(() => validateFeedId("")).toThrow("Feed ID is required"); + }); + }); + + describe("performance and efficiency", () => { + it("should validate feed IDs efficiently", () => { + const start = performance.now(); + + // Validate 1000 feed IDs + for (let i = 0; i < 1000; i++) { + validateFeedId(VALID_FEED_IDS.V3); + } + + const end = performance.now(); + const duration = end - start; + + // Should complete in reasonable time (less than 100ms) + expect(duration).toBeLessThan(100); + }); + + it("should fail fast for obviously invalid inputs", () => { + const start = performance.now(); + + try { + validateFeedId(""); + } catch { + // Expected to throw + } + + const end = performance.now(); + const duration = end - start; + + // Should fail very quickly (less than 1ms) + expect(duration).toBeLessThan(1); + }); + }); + + describe("integration with validation constants", () => { + it("should use the same regex patterns as constants", () => { + // This test ensures our validation logic is consistent + // We're testing the behavior rather than implementation details + expect(() => validateFeedId(VALID_FEED_IDS.V3)).not.toThrow(); + expect(() => validateFeedId("invalid")).toThrow(); + }); + + it("should support all documented feed versions", () => { + // Test that our version validation matches what we claim to support + expect(() => validateFeedId(VALID_FEED_IDS.V2)).not.toThrow(); // V2 supported + expect(() => validateFeedId(VALID_FEED_IDS.V3)).not.toThrow(); // V3 supported + expect(() => validateFeedId(VALID_FEED_IDS.V4)).not.toThrow(); // V4 supported + expect(() => validateFeedId(VALID_FEED_IDS.V5)).not.toThrow(); + expect(() => validateFeedId(VALID_FEED_IDS.V6)).not.toThrow(); + expect(() => validateFeedId(VALID_FEED_IDS.V7)).not.toThrow(); + expect(() => validateFeedId(VALID_FEED_IDS.V8)).not.toThrow(); + expect(() => validateFeedId(VALID_FEED_IDS.V9)).not.toThrow(); + expect(() => validateFeedId(VALID_FEED_IDS.V10)).not.toThrow(); + expect(() => validateFeedId(VALID_FEED_IDS.V1)).toThrow(); // V1 not supported in our implementation + }); + }); + + describe("real-world compatibility", () => { + it("should accept feed IDs from examples", () => { + // These are actual feed IDs used in tests + const goSdkFeedIds = [ + "0x00020ffa644e6c585a5bec0e25ca476b6666666666e22b6240957720dcba0e14", + "0x00020ffa644e6c585a88888825ca476b6666666666e22b6240957720dcba0e14", + ]; + + goSdkFeedIds.forEach(feedId => { + expect(() => validateFeedId(feedId)).not.toThrow(); + }); + }); + + it("should accept feed IDs from additional examples", () => { + // These are additional test vectors + const rustSdkFeedIds = [ + "0x00016b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", // V1 (we reject) + "0x00026b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", // V2 (we accept) + "0x00036b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", // V3 (we accept) + "0x00046b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", // V4 (we accept) + ]; + + // V1 is not supported in our implementation (we only support V2, V3, V4) + expect(() => validateFeedId(rustSdkFeedIds[0])).toThrow(); // V1 + expect(() => validateFeedId(rustSdkFeedIds[1])).not.toThrow(); // V2 + expect(() => validateFeedId(rustSdkFeedIds[2])).not.toThrow(); // V3 + expect(() => validateFeedId(rustSdkFeedIds[3])).not.toThrow(); // V4 + }); + }); +}); diff --git a/typescript/tests/unit/utils/validation/report-validation.test.ts b/typescript/tests/unit/utils/validation/report-validation.test.ts new file mode 100644 index 0000000..8a363bf --- /dev/null +++ b/typescript/tests/unit/utils/validation/report-validation.test.ts @@ -0,0 +1,1055 @@ +/** + * Unit Tests for Report Validation and Decoding + * + * These tests validate the report functionality by: + * - Testing report structure validation for all versions (V2, V3, V4, V5, V6, V7, V8, V9, V10) + * - Testing report version handling and extraction + * - Testing malformed report rejection with clear error messages + * - Testing report timestamp validation + * - Testing report data integrity checks + * - Testing report metadata extraction + * - Testing ABI decoding edge cases + * - Testing market status validation (V4, V8) + * - Testing ripcord validation (V9) + * - Testing large number handling (int192, uint192, uint64) + * + * Goals: + * - Ensure robust report validation that prevents invalid data + * - Test all edge cases and error scenarios comprehensively + * - Support all report versions (V2, V3, V4, V5, V6, V7, V8, V9, V10) + * - Provide clear, helpful error messages for developers + * - Build the best possible TypeScript report validation + */ + +import { describe, it, expect } from "@jest/globals"; +import { decodeReport } from "../../../../src/decoder"; +import { ReportDecodingError } from "../../../../src/types/errors"; +import { MarketStatus } from "../../../../src/types"; +import { AbiCoder } from "ethers"; + +describe("Report Validation Tests", () => { + // Valid feed IDs for different versions + const FEED_IDS = { + V2: "0x00026b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V3: "0x00036b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V4: "0x00046b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V5: "0x00056b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V6: "0x00066b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V7: "0x00076b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V8: "0x00086b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V9: "0x00096b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + V10: "0x000a6b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + }; + + // Helper function to create a valid full report structure + function createFullReport(reportBlob: string): string { + const abiCoder = new AbiCoder(); + + // Create mock report context (3 x bytes32) + const reportContext = [ + "0x0001020304050607080910111213141516171819202122232425262728293031", + "0x3132333435363738394041424344454647484950515253545556575859606162", + "0x6364656667686970717273747576777879808182838485868788899091929394", + ]; + + // Create mock signature data + const rawRs = [ + "0x1111111111111111111111111111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222222222222222222222222222", + ]; + const rawSs = [ + "0x3333333333333333333333333333333333333333333333333333333333333333", + "0x4444444444444444444444444444444444444444444444444444444444444444", + ]; + const rawVs = "0x5555555555555555555555555555555555555555555555555555555555555555"; + + return abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [reportContext, reportBlob, rawRs, rawSs, rawVs] + ); + } + + // Helper function to create a valid V2 report blob + function createV2ReportBlob(): string { + const abiCoder = new AbiCoder(); + return abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192"], + [ + FEED_IDS.V2, // feedId + 1640995200, // validFromTimestamp + 1640995300, // observationsTimestamp + "1000000000000000000", // nativeFee (1 ETH in wei) + "500000000000000000", // linkFee (0.5 LINK) + 1640995400, // expiresAt + "2000000000000000000000", // price (2000 USD with 18 decimals) + ] + ); + } + + // Helper function to create a valid V3 report blob + function createV3ReportBlob(): string { + const abiCoder = new AbiCoder(); + return abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "int192", "int192"], + [ + FEED_IDS.V3, // feedId + 1640995200, // validFromTimestamp + 1640995300, // observationsTimestamp + "1000000000000000000", // nativeFee + "500000000000000000", // linkFee + 1640995400, // expiresAt + "2000000000000000000000", // price (benchmark) + "1995000000000000000000", // bid + "2005000000000000000000", // ask + ] + ); + } + + // Helper function to create a valid V4 report blob + function createV4ReportBlob(): string { + const abiCoder = new AbiCoder(); + return abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint8"], + [ + FEED_IDS.V4, // feedId + 1640995200, // validFromTimestamp + 1640995300, // observationsTimestamp + "1000000000000000000", // nativeFee + "500000000000000000", // linkFee + 1640995400, // expiresAt + "2000000000000000000000", // price + MarketStatus.ACTIVE, // marketStatus + ] + ); + } + + // Helper function to create a valid V8 report blob + function createV8ReportBlob(): string { + const abiCoder = new AbiCoder(); + return abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "uint64", "int192", "uint32"], + [ + FEED_IDS.V8, // feedId + 1640995200, // validFromTimestamp + 1640995300, // observationsTimestamp + "1000000000000000000", // nativeFee + "500000000000000000", // linkFee + 1640995400, // expiresAt + 1640995250, // lastUpdateTimestamp + "2500000000000000000000", // midPrice (2500 USD with 18 decimals) + MarketStatus.ACTIVE, // marketStatus + ] + ); + } + + // Helper function to create a valid V9 report blob + function createV9ReportBlob(): string { + const abiCoder = new AbiCoder(); + return abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint64", "int192", "uint32"], + [ + FEED_IDS.V9, // feedId + 1640995200, // validFromTimestamp + 1640995300, // observationsTimestamp + "1000000000000000000", // nativeFee + "500000000000000000", // linkFee + 1640995400, // expiresAt + "1050000000000000000", // navPerShare ($1.05 with 18 decimals) + 1640995250, // navDate + "100000000000000000000000", // aum ($100k with 18 decimals) + 0, // ripcord (normal) + ] + ); + } + + // Helper function to create a valid V5 report blob + function createV5ReportBlob(): string { + const abiCoder = new AbiCoder(); + return abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint32", "uint32"], + [ + FEED_IDS.V5, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + "1234567890000000000", + 1640995250, + 3600, + ] + ); + } + + // Helper function to create a valid V6 report blob + function createV6ReportBlob(): string { + const abiCoder = new AbiCoder(); + return abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "int192", "int192", "int192", "int192"], + [ + FEED_IDS.V6, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + "2000000000000000000000", + "2100000000000000000000", + "2200000000000000000000", + "2300000000000000000000", + "2400000000000000000000", + ] + ); + } + + // Helper function to create a valid V7 report blob + function createV7ReportBlob(): string { + const abiCoder = new AbiCoder(); + return abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192"], + [ + FEED_IDS.V7, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + "987654321000000000", + ] + ); + } + + // Helper function to create a valid V10 report blob + function createV10ReportBlob(): string { + const abiCoder = new AbiCoder(); + return abiCoder.encode( + [ + "bytes32", + "uint32", + "uint32", + "uint192", + "uint192", + "uint32", + "uint64", + "int192", + "uint32", + "int192", + "int192", + "uint32", + "int192", + ], + [ + FEED_IDS.V10, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + 1640995250, + "75000000000000000000", + MarketStatus.ACTIVE, + "1000000000000000000", + "1100000000000000000", + 1641081600, + "150000000000000000000", + ] + ); + } + + describe("valid report decoding", () => { + it("should decode valid V2 report", () => { + const reportBlob = createV2ReportBlob(); + const fullReport = createFullReport(reportBlob); + + const decoded = decodeReport(fullReport, FEED_IDS.V2); + + expect(decoded.version).toBe("V2"); + expect(decoded.nativeFee).toBe(1000000000000000000n); + expect(decoded.linkFee).toBe(500000000000000000n); + expect(decoded.expiresAt).toBe(1640995400); + expect((decoded as any).price).toBe(2000000000000000000000n); + }); + + it("should decode valid V3 report", () => { + const reportBlob = createV3ReportBlob(); + const fullReport = createFullReport(reportBlob); + + const decoded = decodeReport(fullReport, FEED_IDS.V3); + + expect(decoded.version).toBe("V3"); + expect(decoded.nativeFee).toBe(1000000000000000000n); + expect(decoded.linkFee).toBe(500000000000000000n); + expect(decoded.expiresAt).toBe(1640995400); + expect((decoded as any).price).toBe(2000000000000000000000n); + expect((decoded as any).bid).toBe(1995000000000000000000n); + expect((decoded as any).ask).toBe(2005000000000000000000n); + }); + + it("should decode valid V4 report", () => { + const reportBlob = createV4ReportBlob(); + const fullReport = createFullReport(reportBlob); + + const decoded = decodeReport(fullReport, FEED_IDS.V4); + + expect(decoded.version).toBe("V4"); + expect(decoded.nativeFee).toBe(1000000000000000000n); + expect(decoded.linkFee).toBe(500000000000000000n); + expect(decoded.expiresAt).toBe(1640995400); + expect((decoded as any).price).toBe(2000000000000000000000n); + expect((decoded as any).marketStatus).toBe(MarketStatus.ACTIVE); + }); + + it("should decode valid V8 report", () => { + const reportBlob = createV8ReportBlob(); + const fullReport = createFullReport(reportBlob); + + const decoded = decodeReport(fullReport, FEED_IDS.V8); + + expect(decoded.version).toBe("V8"); + expect(decoded.nativeFee).toBe(1000000000000000000n); + expect(decoded.linkFee).toBe(500000000000000000n); + expect(decoded.expiresAt).toBe(1640995400); + expect((decoded as any).midPrice).toBe(2500000000000000000000n); + expect((decoded as any).lastUpdateTimestamp).toBe(1640995250); + expect((decoded as any).marketStatus).toBe(MarketStatus.ACTIVE); + }); + + it("should decode valid V9 report", () => { + const reportBlob = createV9ReportBlob(); + const fullReport = createFullReport(reportBlob); + + const decoded = decodeReport(fullReport, FEED_IDS.V9); + + expect(decoded.version).toBe("V9"); + expect(decoded.nativeFee).toBe(1000000000000000000n); + expect(decoded.linkFee).toBe(500000000000000000n); + expect(decoded.expiresAt).toBe(1640995400); + expect((decoded as any).navPerShare).toBe(1050000000000000000n); + expect((decoded as any).navDate).toBe(1640995250); + expect((decoded as any).aum).toBe(100000000000000000000000n); + expect((decoded as any).ripcord).toBe(0); + }); + + it("should decode valid V10 report", () => { + const reportBlob = createV10ReportBlob(); + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V10); + + expect(decoded.version).toBe("V10"); + expect((decoded as any).price).toBe(75000000000000000000n); + expect((decoded as any).tokenizedPrice).toBe(150000000000000000000n); + }); + + it("should handle reports without 0x prefix", () => { + const reportBlob = createV2ReportBlob(); + const fullReport = createFullReport(reportBlob); + const withoutPrefix = fullReport.slice(2); // Remove 0x + + expect(() => decodeReport(withoutPrefix, FEED_IDS.V2)).toThrow(ReportDecodingError); + expect(() => decodeReport(withoutPrefix, FEED_IDS.V2)).toThrow("Report hex string must start with 0x"); + }); + + it("should handle large price values", () => { + const abiCoder = new AbiCoder(); + const largePrice = "999999999999999999999999999999999999999999999999"; // Very large int192 + + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192"], + [FEED_IDS.V2, 1640995200, 1640995300, "1000000000000000000", "500000000000000000", 1640995400, largePrice] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V2); + + expect((decoded as any).price).toBe(BigInt(largePrice)); + }); + + it("should handle negative price values", () => { + const abiCoder = new AbiCoder(); + const negativePrice = "-1000000000000000000000"; // Negative price + + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192"], + [FEED_IDS.V2, 1640995200, 1640995300, "1000000000000000000", "500000000000000000", 1640995400, negativePrice] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V2); + + expect((decoded as any).price).toBe(BigInt(negativePrice)); + }); + + it("should handle zero values", () => { + const abiCoder = new AbiCoder(); + + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192"], + [ + FEED_IDS.V2, + 0, // validFromTimestamp + 0, // observationsTimestamp + "0", // nativeFee + "0", // linkFee + 0, // expiresAt + "0", // price + ] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V2); + + expect(decoded.nativeFee).toBe(0n); + expect(decoded.linkFee).toBe(0n); + expect(decoded.expiresAt).toBe(0); + expect((decoded as any).price).toBe(0n); + }); + }); + + describe("report version handling", () => { + it("should reject unsupported V1 version", () => { + const v1FeedId = "0x00016b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472"; + const reportBlob = createV2ReportBlob(); // Use V2 blob but V1 feed ID + const fullReport = createFullReport(reportBlob); + + expect(() => decodeReport(fullReport, v1FeedId)).toThrow(ReportDecodingError); + expect(() => decodeReport(fullReport, v1FeedId)).toThrow("Unknown report version: 0x0001"); + }); + + it("should reject unknown version V0", () => { + const v0FeedId = "0x00006b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472"; + const reportBlob = createV2ReportBlob(); + const fullReport = createFullReport(reportBlob); + + expect(() => decodeReport(fullReport, v0FeedId)).toThrow(ReportDecodingError); + expect(() => decodeReport(fullReport, v0FeedId)).toThrow("Unknown report version: 0x0000"); + }); + + it("should reject version 0", () => { + const v0FeedId = "0x00006b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472"; + const reportBlob = createV2ReportBlob(); + const fullReport = createFullReport(reportBlob); + + expect(() => decodeReport(fullReport, v0FeedId)).toThrow(ReportDecodingError); + expect(() => decodeReport(fullReport, v0FeedId)).toThrow("Unknown report version: 0x0000"); + }); + + it("should extract version from feed ID correctly", () => { + // Test that version extraction works for all supported versions + const versions = [ + { feedId: FEED_IDS.V2, expectedVersion: "V2" }, + { feedId: FEED_IDS.V3, expectedVersion: "V3" }, + { feedId: FEED_IDS.V4, expectedVersion: "V4" }, + { feedId: FEED_IDS.V5, expectedVersion: "V5" }, + { feedId: FEED_IDS.V6, expectedVersion: "V6" }, + { feedId: FEED_IDS.V7, expectedVersion: "V7" }, + { feedId: FEED_IDS.V8, expectedVersion: "V8" }, + { feedId: FEED_IDS.V9, expectedVersion: "V9" }, + { feedId: FEED_IDS.V10, expectedVersion: "V10" }, + ]; + + versions.forEach(({ feedId, expectedVersion }) => { + const reportBlob = + expectedVersion === "V2" + ? createV2ReportBlob() + : expectedVersion === "V3" + ? createV3ReportBlob() + : expectedVersion === "V4" + ? createV4ReportBlob() + : expectedVersion === "V5" + ? createV5ReportBlob() + : expectedVersion === "V6" + ? createV6ReportBlob() + : expectedVersion === "V7" + ? createV7ReportBlob() + : expectedVersion === "V8" + ? createV8ReportBlob() + : expectedVersion === "V9" + ? createV9ReportBlob() + : createV10ReportBlob(); + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, feedId); + expect(decoded.version).toBe(expectedVersion); + }); + }); + }); + + describe("malformed report rejection", () => { + it("should reject empty report", () => { + expect(() => decodeReport("", FEED_IDS.V2)).toThrow(ReportDecodingError); + expect(() => decodeReport("", FEED_IDS.V2)).toThrow("Report hex string must start with 0x"); + }); + + it("should reject invalid hex string", () => { + expect(() => decodeReport("not-hex-string", FEED_IDS.V2)).toThrow(ReportDecodingError); + expect(() => decodeReport("not-hex-string", FEED_IDS.V2)).toThrow("Report hex string must start with 0x"); + }); + + it("should reject malformed hex data", () => { + const invalidHex = "0xZZZZZZZZ"; // Invalid hex characters + expect(() => decodeReport(invalidHex, FEED_IDS.V2)).toThrow(ReportDecodingError); + }); + + it("should reject truncated report data", () => { + const reportBlob = createV2ReportBlob(); + const fullReport = createFullReport(reportBlob); + const truncated = fullReport.slice(0, 100); // Truncate the report + + expect(() => decodeReport(truncated, FEED_IDS.V2)).toThrow(ReportDecodingError); + }); + + it("should reject report with wrong structure", () => { + const abiCoder = new AbiCoder(); + // Create malformed structure (missing fields) + const malformedReport = abiCoder.encode( + ["bytes32", "uint32"], // Only 2 fields instead of required structure + [FEED_IDS.V2, 1640995200] + ); + + expect(() => decodeReport(malformedReport, FEED_IDS.V2)).toThrow(ReportDecodingError); + }); + + it("should reject report blob with insufficient data for V2", () => { + const abiCoder = new AbiCoder(); + // Create report blob with insufficient fields for V2 (needs 7 fields) + const insufficientBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32"], // Only 3 fields + [FEED_IDS.V2, 1640995200, 1640995300] + ); + + const fullReport = createFullReport(insufficientBlob); + expect(() => decodeReport(fullReport, FEED_IDS.V2)).toThrow(ReportDecodingError); + }); + + it("should reject report blob with insufficient data for V3", () => { + const abiCoder = new AbiCoder(); + // Create report blob with insufficient fields for V3 (needs 9 fields) + const insufficientBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192"], // Only 5 fields + [FEED_IDS.V3, 1640995200, 1640995300, "1000000000000000000", "500000000000000000"] + ); + + const fullReport = createFullReport(insufficientBlob); + expect(() => decodeReport(fullReport, FEED_IDS.V3)).toThrow(ReportDecodingError); + }); + + it("should reject report blob with insufficient data for V4", () => { + const abiCoder = new AbiCoder(); + // Create report blob with insufficient fields for V4 (needs 8 fields) + const insufficientBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192"], // Only 4 fields + [FEED_IDS.V4, 1640995200, 1640995300, "1000000000000000000"] + ); + + const fullReport = createFullReport(insufficientBlob); + expect(() => decodeReport(fullReport, FEED_IDS.V4)).toThrow(ReportDecodingError); + }); + }); + + describe("market status validation (V4)", () => { + it("should accept valid ACTIVE market status", () => { + const abiCoder = new AbiCoder(); + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint8"], + [ + FEED_IDS.V4, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + "2000000000000000000000", + MarketStatus.ACTIVE, + ] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V4); + + expect((decoded as any).marketStatus).toBe(MarketStatus.ACTIVE); + }); + + it("should accept valid INACTIVE market status", () => { + const abiCoder = new AbiCoder(); + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint8"], + [ + FEED_IDS.V4, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + "2000000000000000000000", + MarketStatus.INACTIVE, + ] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V4); + + expect((decoded as any).marketStatus).toBe(MarketStatus.INACTIVE); + }); + + it("should reject invalid market status", () => { + const abiCoder = new AbiCoder(); + const invalidMarketStatus = 99; // Invalid status + + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint8"], + [ + FEED_IDS.V4, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + "2000000000000000000000", + invalidMarketStatus, + ] + ); + + const fullReport = createFullReport(reportBlob); + expect(() => decodeReport(fullReport, FEED_IDS.V4)).toThrow(ReportDecodingError); + expect(() => decodeReport(fullReport, FEED_IDS.V4)).toThrow("Invalid market status: 99"); + }); + }); + + describe("market status validation (V8)", () => { + it("should accept valid ACTIVE market status", () => { + const abiCoder = new AbiCoder(); + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "uint64", "int192", "uint32"], + [ + FEED_IDS.V8, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + 1640995250, + "2500000000000000000000", + MarketStatus.ACTIVE, + ] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V8); + + expect((decoded as any).marketStatus).toBe(MarketStatus.ACTIVE); + }); + + it("should reject invalid market status", () => { + const abiCoder = new AbiCoder(); + const invalidMarketStatus = 99; // Invalid status + + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "uint64", "int192", "uint32"], + [ + FEED_IDS.V8, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + 1640995250, + "2500000000000000000000", + invalidMarketStatus, + ] + ); + + const fullReport = createFullReport(reportBlob); + expect(() => decodeReport(fullReport, FEED_IDS.V8)).toThrow(ReportDecodingError); + expect(() => decodeReport(fullReport, FEED_IDS.V8)).toThrow("Invalid market status: 99"); + }); + }); + + describe("ripcord validation (V9)", () => { + it("should accept normal ripcord (0)", () => { + const reportBlob = createV9ReportBlob(); + const fullReport = createFullReport(reportBlob); + + const decoded = decodeReport(fullReport, FEED_IDS.V9); + + expect((decoded as any).ripcord).toBe(0); + }); + + it("should accept paused ripcord (1)", () => { + const abiCoder = new AbiCoder(); + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint64", "int192", "uint32"], + [ + FEED_IDS.V9, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + "1050000000000000000", + 1640995250, + "100000000000000000000000", + 1, // Paused ripcord + ] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V9); + + expect((decoded as any).ripcord).toBe(1); + }); + }); + + describe("edge cases and boundary conditions", () => { + it("should handle maximum uint32 values", () => { + const abiCoder = new AbiCoder(); + const maxUint32 = "4294967295"; // 2^32 - 1 + + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192"], + [ + FEED_IDS.V2, + maxUint32, // validFromTimestamp + maxUint32, // observationsTimestamp + "1000000000000000000", + "500000000000000000", + maxUint32, // expiresAt + "2000000000000000000000", + ] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V2); + + expect(decoded.expiresAt).toBe(4294967295); + }); + + it("should handle maximum uint192 values", () => { + const abiCoder = new AbiCoder(); + const maxUint192 = "6277101735386680763835789423207666416102355444464034512895"; // 2^192 - 1 + + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192"], + [ + FEED_IDS.V2, + 1640995200, + 1640995300, + maxUint192, // nativeFee + maxUint192, // linkFee + 1640995400, + "2000000000000000000000", + ] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V2); + + expect(decoded.nativeFee).toBe(BigInt(maxUint192)); + expect(decoded.linkFee).toBe(BigInt(maxUint192)); + }); + + it("should handle minimum int192 values", () => { + const abiCoder = new AbiCoder(); + const minInt192 = "-3138550867693340381917894711603833208051177722232017256448"; // -(2^191) + + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192"], + [ + FEED_IDS.V2, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + minInt192, // price + ] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V2); + + expect((decoded as any).price).toBe(BigInt(minInt192)); + }); + + it("should handle reports with very long hex strings", () => { + // Create a report with maximum valid data + const reportBlob = createV3ReportBlob(); // V3 has the most fields + const fullReport = createFullReport(reportBlob); + + // Should handle long but valid hex strings + expect(() => decodeReport(fullReport, FEED_IDS.V3)).not.toThrow(); + }); + + it("should reject reports with null bytes", () => { + const reportWithNulls = "0x00000000000000000000000000000000"; + expect(() => decodeReport(reportWithNulls, FEED_IDS.V2)).toThrow(ReportDecodingError); + }); + }); + + describe("error message quality", () => { + it("should provide specific error for invalid hex", () => { + expect(() => decodeReport("invalid-hex", FEED_IDS.V2)).toThrow("Report hex string must start with 0x"); + }); + + it("should provide specific error for unknown version", () => { + const unknownVersionFeedId = "0x00996b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472"; + const reportBlob = createV2ReportBlob(); + const fullReport = createFullReport(reportBlob); + + expect(() => decodeReport(fullReport, unknownVersionFeedId)).toThrow("Unknown report version: 0x0099"); + }); + + it("should provide specific error for V2 decoding failure", () => { + const abiCoder = new AbiCoder(); + const malformedBlob = abiCoder.encode(["bytes32"], [FEED_IDS.V2]); // Insufficient data + const fullReport = createFullReport(malformedBlob); + + expect(() => decodeReport(fullReport, FEED_IDS.V2)).toThrow("Failed to decode V2 report"); + }); + + it("should provide specific error for V3 decoding failure", () => { + const abiCoder = new AbiCoder(); + const malformedBlob = abiCoder.encode(["bytes32"], [FEED_IDS.V3]); // Insufficient data + const fullReport = createFullReport(malformedBlob); + + expect(() => decodeReport(fullReport, FEED_IDS.V3)).toThrow("Failed to decode V3 report"); + }); + + it("should provide specific error for V4 decoding failure", () => { + const abiCoder = new AbiCoder(); + const malformedBlob = abiCoder.encode(["bytes32"], [FEED_IDS.V4]); // Insufficient data + const fullReport = createFullReport(malformedBlob); + + expect(() => decodeReport(fullReport, FEED_IDS.V4)).toThrow("Failed to decode V4 report"); + }); + + it("should provide specific error for invalid market status", () => { + const abiCoder = new AbiCoder(); + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint8"], + [ + FEED_IDS.V4, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + "2000000000000000000000", + 255, + ] + ); + + const fullReport = createFullReport(reportBlob); + expect(() => decodeReport(fullReport, FEED_IDS.V4)).toThrow("Invalid market status: 255"); + }); + }); + + describe("performance and memory efficiency", () => { + it("should decode reports efficiently", () => { + const reportBlob = createV2ReportBlob(); + const fullReport = createFullReport(reportBlob); + + const start = performance.now(); + + // Decode 1000 reports + for (let i = 0; i < 1000; i++) { + decodeReport(fullReport, FEED_IDS.V2); + } + + const end = performance.now(); + const duration = end - start; + + // Should complete in reasonable time (less than 1000ms) + expect(duration).toBeLessThan(1000); + }); + + it("should handle large report data efficiently", () => { + // Create report with maximum values to test memory efficiency + const abiCoder = new AbiCoder(); + const maxValues = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "int192", "int192"], + [ + FEED_IDS.V3, + 4294967295, // max uint32 + 4294967295, // max uint32 + "6277101735386680763835789423207666416102355444464034512895", // max uint192 + "6277101735386680763835789423207666416102355444464034512895", // max uint192 + 4294967295, // max uint32 + "3138550867693340381917894711603833208051177722232017256447", // max int192 + "3138550867693340381917894711603833208051177722232017256447", // max int192 + "3138550867693340381917894711603833208051177722232017256447", // max int192 + ] + ); + + const fullReport = createFullReport(maxValues); + + expect(() => decodeReport(fullReport, FEED_IDS.V3)).not.toThrow(); + }); + + it("should fail fast for obviously invalid reports", () => { + const start = performance.now(); + + try { + decodeReport("invalid", FEED_IDS.V2); + } catch { + // Expected to throw + } + + const end = performance.now(); + const duration = end - start; + + // Should fail very quickly (less than 10ms) + expect(duration).toBeLessThan(10); + }); + }); + + describe("real-world compatibility", () => { + it("should handle reports in standard format", () => { + // Test with standard output format structure + const reportBlob = createV2ReportBlob(); + const fullReport = createFullReport(reportBlob); + + const decoded = decodeReport(fullReport, FEED_IDS.V2); + + // Verify all expected fields are present and correctly typed + expect(typeof decoded.version).toBe("string"); + expect(typeof decoded.nativeFee).toBe("bigint"); + expect(typeof decoded.linkFee).toBe("bigint"); + expect(typeof decoded.expiresAt).toBe("number"); + expect(typeof (decoded as any).price).toBe("bigint"); + }); + + it("should handle reports in alternative format", () => { + // Test with alternative output format structure + const reportBlob = createV4ReportBlob(); + const fullReport = createFullReport(reportBlob); + + const decoded = decodeReport(fullReport, FEED_IDS.V4); + + // Verify V4-specific fields + expect(decoded.version).toBe("V4"); + expect((decoded as any).marketStatus).toBeDefined(); + expect(typeof (decoded as any).marketStatus).toBe("number"); + }); + + it("should maintain precision for financial data", () => { + // Test with realistic financial values + const abiCoder = new AbiCoder(); + const realisticPrice = "2000123456789012345678"; // $2000.123456789012345678 with 18 decimals + const realisticFee = "1234567890123456"; // 0.001234567890123456 ETH + + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192"], + [FEED_IDS.V2, 1640995200, 1640995300, realisticFee, realisticFee, 1640995400, realisticPrice] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V2); + + // Verify precision is maintained + expect((decoded as any).price.toString()).toBe(realisticPrice); + expect(decoded.nativeFee.toString()).toBe(realisticFee); + expect(decoded.linkFee.toString()).toBe(realisticFee); + }); + + it("should handle maximum uint64 values for V8 lastUpdateTimestamp", () => { + const abiCoder = new AbiCoder(); + const maxUint64 = "18446744073709551615"; // 2^64 - 1 + + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "uint64", "int192", "uint32"], + [ + FEED_IDS.V8, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + maxUint64, // lastUpdateTimestamp + "2500000000000000000000", + MarketStatus.ACTIVE, + ] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V8); + + expect((decoded as any).lastUpdateTimestamp).toBe(Number(maxUint64)); + }); + + it("should handle large V8 midPrice values", () => { + const abiCoder = new AbiCoder(); + const largeMidPrice = "3138550867693340381917894711603833208051177722232017256447"; // Large int192 + + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "uint64", "int192", "uint32"], + [ + FEED_IDS.V8, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + 1640995250, + largeMidPrice, + MarketStatus.ACTIVE, + ] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V8); + + expect((decoded as any).midPrice).toBe(BigInt(largeMidPrice)); + }); + + it("should handle large V9 AUM values", () => { + const abiCoder = new AbiCoder(); + const largeAum = "1000000000000000000000000000000000000000000"; // Very large AUM + + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint64", "int192", "uint32"], + [ + FEED_IDS.V9, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + "1050000000000000000", + 1640995250, + largeAum, + 0, + ] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V9); + + expect((decoded as any).aum).toBe(BigInt(largeAum)); + }); + + it("should handle maximum uint64 values for V9 navDate", () => { + const abiCoder = new AbiCoder(); + const maxUint64 = "18446744073709551615"; // 2^64 - 1 + + const reportBlob = abiCoder.encode( + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "uint64", "int192", "uint32"], + [ + FEED_IDS.V9, + 1640995200, + 1640995300, + "1000000000000000000", + "500000000000000000", + 1640995400, + "1050000000000000000", + maxUint64, // navDate + "100000000000000000000000", + 0, + ] + ); + + const fullReport = createFullReport(reportBlob); + const decoded = decodeReport(fullReport, FEED_IDS.V9); + + expect((decoded as any).navDate).toBe(Number(maxUint64)); + }); + }); +}); diff --git a/typescript/tests/utils/mockWebSocketServer.ts b/typescript/tests/utils/mockWebSocketServer.ts new file mode 100644 index 0000000..f29ae63 --- /dev/null +++ b/typescript/tests/utils/mockWebSocketServer.ts @@ -0,0 +1,236 @@ +import { WebSocket, WebSocketServer } from "ws"; +import { AddressInfo } from "net"; + +export class MockWebSocketServer { + private wss: WebSocketServer | null = null; + private clients: Set = new Set(); + private address: string = ""; + private isReady: boolean = false; + + constructor() { + this.start(); + } + + private start() { + if (this.wss) { + return; + } + + this.wss = new WebSocketServer({ port: 0 }); + this.setupServer(); + } + + private setupServer() { + if (!this.wss) return; + + this.wss.on("listening", () => { + if (!this.wss) return; + const addr = this.wss.address() as AddressInfo; + this.address = `127.0.0.1:${addr.port}`; + this.isReady = true; + }); + + this.wss.on("error", error => { + console.error("WebSocket server error:", error); + this.isReady = false; + }); + + this.wss.on("connection", (ws: WebSocket) => { + // Set up error handling first + ws.on("error", error => { + console.error("WebSocket connection error:", error); + this.clients.delete(ws); + try { + ws.close(); + } catch { + // Ignore close errors + } + }); + + // Handle pings to keep connection alive + ws.on("ping", () => { + try { + ws.pong(); + } catch { + // Ignore pong errors + } + }); + + this.clients.add(ws); + + ws.on("close", () => { + this.clients.delete(ws); + }); + }); + } + + public async waitForReady(timeout = 5000): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + if (this.isReady && this.address) { + return true; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + return false; + } + + public getAddress(): string { + return this.isReady ? this.address : ""; + } + + public broadcast(data: Buffer | string) { + if (!this.isReady) return; + + const deadClients = new Set(); + + this.clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + try { + client.send(data); + } catch (error) { + console.error("Error broadcasting to client:", error); + deadClients.add(client); + } + } else { + deadClients.add(client); + } + }); + + // Clean up dead clients + deadClients.forEach(client => { + this.clients.delete(client); + try { + client.close(); + } catch { + // Ignore close errors + } + }); + } + + public async closeAllConnections(): Promise { + const closePromises: Promise[] = []; + + this.clients.forEach(client => { + closePromises.push( + new Promise(resolve => { + const timeout = setTimeout(() => { + try { + client.terminate(); + } catch { + // Ignore terminate errors + } + resolve(); + }, 1000); + + client.on("close", () => { + clearTimeout(timeout); + resolve(); + }); + + try { + client.close(); + } catch { + clearTimeout(timeout); + resolve(); + } + }) + ); + }); + + await Promise.all(closePromises); + this.clients.clear(); + } + + /** + * Simulate temporary disconnections that trigger reconnection attempts + * Unlike closeAllConnections, this doesn't clear the client set entirely + */ + public simulateNetworkInterruption(): void { + const clientsToDisconnect = Array.from(this.clients); + + clientsToDisconnect.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + try { + // Send close frame to simulate network disconnection + client.close(1006, "Network interruption simulation"); + } catch { + // Ignore close errors + } + } + }); + + // Remove disconnected clients from tracking + // but don't clear the entire set - new connections can still come in + this.clients.clear(); + } + + /** + * Simulate realistic connection drops that will trigger SDK reconnection logic + * This closes connections but keeps the server running to accept reconnections + */ + public simulateConnectionDrops(dropCount?: number): void { + const clientsArray = Array.from(this.clients); + const toDrop = dropCount || clientsArray.length; + + for (let i = 0; i < Math.min(toDrop, clientsArray.length); i++) { + const client = clientsArray[i]; + if (client.readyState === WebSocket.OPEN) { + try { + // Simulate unexpected disconnection (code 1006 = abnormal closure) + client.terminate(); // Force close without handshake + } catch { + // Ignore errors + } + } + this.clients.delete(client); + } + } + + /** + * Get current connection count for testing + */ + public getActiveConnectionCount(): number { + return Array.from(this.clients).filter(client => client.readyState === WebSocket.OPEN).length; + } + + public async close(): Promise { + this.isReady = false; + this.address = ""; + + await this.closeAllConnections(); + + return new Promise(resolve => { + if (!this.wss) { + resolve(); + return; + } + + const wss = this.wss; + this.wss = null; + + const timeout = setTimeout(() => { + try { + wss.close(); + } catch { + // Ignore close errors + } + resolve(); + }, 1000); + + try { + wss.close(() => { + clearTimeout(timeout); + resolve(); + }); + } catch { + clearTimeout(timeout); + resolve(); + } + }); + } + + public getConnectedClientsCount(): number { + return Array.from(this.clients).filter(client => client.readyState === WebSocket.OPEN).length; + } +} diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json new file mode 100644 index 0000000..3e63a05 --- /dev/null +++ b/typescript/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "declaration": true, + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "sourceMap": true, + "baseUrl": ".", + "paths": { + "*": ["node_modules/*"] + } + }, + "include": ["src/**/*", "examples/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +}