/** * WioEX Stream SDK - Main Client */ import EventEmitter from 'eventemitter3'; import { encode as msgpackEncode, decode as msgpackDecode } from '@msgpack/msgpack'; import { ClientMessageType } from './MessageTypes'; import { normalizeError, extractErrorMessage, retryWithBackoff, withTimeout, isBrowser, isNode, hasFeature } from './utils/ErrorHandling'; import type { WioexStreamConfig, WioexStreamEvents, ConnectionState, OutgoingMessage, RegistrationResponse, SubscriptionResponse, TickerUpdateMessage, SignalMessage, SignalTriggeredMessage, SignalSubscriptionResponse, SignalUnsubscriptionResponse, ClientStats, ErrorContext, TickerData, ErrorReportingLevel, TokenFetchRetryConfig, } from './types'; import { ConnectionState as ConnectionStateEnum } from './types'; import { ErrorReporter } from './ErrorReporter'; import { EventThrottler } from './utils/EventThrottler'; import { Telemetry } from './Telemetry'; import { Logger } from './utils/Logger'; /** * Internal configuration with required and optional fields */ interface InternalConfig { apiKey: string | undefined; token: string | undefined; tokenExpiresAt: number | undefined; tokenEndpoint: string | undefined; tokenFetchHeaders: Record | undefined; tokenFetchRetry: TokenFetchRetryConfig; onTokenExpiring: (() => Promise<{ token: string; expiresAt: number }>) | undefined; autoRefreshToken: boolean; refreshBeforeExpiry: number; lazyConnect: boolean; debug: boolean; maxSymbols: number; autoReconnect: boolean; reconnectDelay: number; maxReconnectAttempts: number; streamUrl: string; errorReportingLevel: 'none' | 'minimal' | 'standard' | 'detailed'; errorReportingUrl: string | undefined; includeMessageData: boolean | undefined; includeConnectionData: boolean | undefined; enableStats: boolean; useBinaryProtocol: boolean; baseUrl: string; } /** * WioEX WebSocket Streaming Client * * Provides real-time market data streaming via WebSocket */ export class WioexStreamClient extends EventEmitter { private config: InternalConfig; private ws: WebSocket | null = null; private state: ConnectionState = ConnectionStateEnum.DISCONNECTED; private subscribedStocks: Set = new Set(); private reconnectAttempts: number = 0; private reconnectTimer: ReturnType | null = null; private tokenExpiresAt: number | null = null; private tokenRefreshTimer: ReturnType | null = null; private isRefreshingToken: boolean = false; private isReconnecting: boolean = false; private reconnectionLock: boolean = false; private stats: ClientStats; private errorReporter: ErrorReporter; private lastReceivedMessage: string | null = null; private tickerThrottler: EventThrottler | null = null; private logger: Logger; private signalSubscriptions: Set = new Set(); constructor(config: WioexStreamConfig) { super(); // SECURITY: Block apiKey usage in browser if (isBrowser() && config.apiKey) { throw new Error( '🔒 SECURITY ERROR: apiKey cannot be used in browser!\n\n' + 'API keys MUST stay on your backend server.\n\n' + '✅ CORRECT - Use tokenEndpoint:\n' + ' const client = new WioexStream({\n' + ' tokenEndpoint: "/stream/token"\n' + ' });\n\n' + '❌ WRONG - Never use apiKey in browser:\n' + ' const client = new WioexStream({\n' + ' apiKey: "xxx" // ← Visible in DevTools!\n' + ' });\n\n' + 'Backend examples: examples/backend-token-proxy.{php,js,py,go,java}\n' + 'Docs: https://github.com/wioex/stream-sdk#browser-security' ); } // Validation: Either token or apiKey must be provided if (!config.token && !config.apiKey && !config.tokenEndpoint) { throw new Error('Either token, tokenEndpoint, or apiKey is required.'); } // Enhanced token validation if (config.token !== undefined) { this.validateToken(config.token); } const maxSymbols = config.maxSymbols ?? 50; if (maxSymbols < 1 || maxSymbols > 50) { throw new Error('maxSymbols must be between 1 and 50'); } this.config = { apiKey: config.apiKey, // Only used in Node.js for token fetching token: config.token, tokenExpiresAt: config.tokenExpiresAt, tokenEndpoint: config.tokenEndpoint, // Browser endpoint for token fetching tokenFetchHeaders: config.tokenFetchHeaders, tokenFetchRetry: config.tokenFetchRetry ?? { maxAttempts: 3, delay: 1000, backoff: 'exponential', }, onTokenExpiring: config.onTokenExpiring, autoRefreshToken: config.autoRefreshToken ?? config.onTokenExpiring !== undefined, refreshBeforeExpiry: config.refreshBeforeExpiry ?? 3600000, // 1 hour default lazyConnect: config.lazyConnect ?? true, debug: config.debug ?? false, maxSymbols, autoReconnect: config.autoReconnect ?? true, reconnectDelay: config.reconnectDelay ?? 500, // Start with 500ms for faster reconnects maxReconnectAttempts: config.maxReconnectAttempts ?? 0, streamUrl: config.streamUrl ?? `wss://stream.${(config.baseUrl ?? 'https://api.wioex.com').replace('https://api.', '')}/${this.generateRandomId(8)}/echo`, errorReportingLevel: config.errorReportingLevel ?? 'detailed', errorReportingUrl: config.errorReportingUrl, includeMessageData: config.includeMessageData, includeConnectionData: config.includeConnectionData, enableStats: config.performance?.enableStats ?? true, useBinaryProtocol: config.performance?.useBinaryProtocol ?? false, baseUrl: config.baseUrl ?? 'https://api.wioex.com', }; // Initialize token expiry tracking if (config.tokenExpiresAt) { this.tokenExpiresAt = config.tokenExpiresAt; } this.stats = { connectedAt: null, reconnectAttempts: 0, messagesReceived: 0, messagesSent: 0, tickersReceived: 0, subscribedStocks: [], state: this.state, }; // Initialize error reporter with performance config const errorQueueConfig: Record = {}; if (config.performance?.errorBatchSize !== undefined) { errorQueueConfig['maxBatchSize'] = config.performance.errorBatchSize; } if (config.performance?.errorBatchInterval !== undefined) { errorQueueConfig['flushInterval'] = config.performance.errorBatchInterval; } if (config.performance?.errorDeduplication !== undefined) { errorQueueConfig['deduplication'] = config.performance.errorDeduplication; } // Build ErrorReporter config with only defined properties for exactOptionalPropertyTypes const errorReporterConfig: { apiKey?: string; token?: string; errorReportingLevel?: ErrorReportingLevel; errorReportingUrl?: string; includeMessageData?: boolean; includeConnectionData?: boolean; errorQueue?: { maxBatchSize?: number; flushInterval?: number; deduplication?: boolean }; debug?: boolean; } = {}; if (this.config.apiKey !== undefined) errorReporterConfig.apiKey = this.config.apiKey; if (this.config.token !== undefined) errorReporterConfig.token = this.config.token; if (this.config.errorReportingLevel !== undefined) errorReporterConfig.errorReportingLevel = this.config.errorReportingLevel; if (this.config.errorReportingUrl !== undefined) errorReporterConfig.errorReportingUrl = this.config.errorReportingUrl; if (this.config.includeMessageData !== undefined) errorReporterConfig.includeMessageData = this.config.includeMessageData; if (this.config.includeConnectionData !== undefined) errorReporterConfig.includeConnectionData = this.config.includeConnectionData; if (Object.keys(errorQueueConfig).length > 0) errorReporterConfig.errorQueue = errorQueueConfig as { maxBatchSize?: number; flushInterval?: number; deduplication?: boolean; }; errorReporterConfig.debug = this.config.debug; this.errorReporter = new ErrorReporter(errorReporterConfig); // Initialize logger with debug mode this.logger = new Logger(this.config.debug); // Initialize ticker throttler if enabled const throttleMs = config.performance?.tickerThrottle ?? 16; // 60 FPS default if (throttleMs > 0) { this.tickerThrottler = new EventThrottler( (data) => { if (Array.isArray(data)) { // Batched mode - emit array data.forEach((ticker) => super.emit('ticker', ticker)); } else { // Throttled mode - emit single super.emit('ticker', data); } }, { throttleInterval: throttleMs, enableBatching: config.performance?.batchTickers ?? false, maxBatchSize: config.performance?.tickerBatchSize ?? 10, } ); } // Schedule token refresh if auto-refresh is enabled if (this.config.autoRefreshToken && this.tokenExpiresAt) { this.scheduleTokenRefresh(); } // Report anonymous telemetry on first usage (fire-and-forget) Telemetry.reportFirstUsage(); } /** * DEBUG FIX: Restored proper debug logging functionality * Previously broken by unconditional return statement */ private log(message: string, data?: unknown): void { if (this.config.debug && this.logger) { this.logger.log(message, data); } } /** * DEBUG FIX: Restored lazy logging for expensive debug operations * Only executed when debug mode is enabled */ private logLazy(message: string, dataFactory?: () => unknown): void { if (this.config.debug && this.logger) { const data = dataFactory ? dataFactory() : undefined; this.logger.log(message, data); } } /** * Connect to WioEX WebSocket stream * Manual connect() calls have priority over auto-reconnect */ public async connect(): Promise { // Manual connect() has priority - cancel any pending auto-reconnect if (this.reconnectTimer !== null) { // Removed manual connect logging clearTimeout(this.reconnectTimer); this.reconnectTimer = null; this.isReconnecting = false; } if ( this.state === ConnectionStateEnum.CONNECTED || this.state === ConnectionStateEnum.CONNECTING ) { // Removed connection status logging return; } // Reset reconnect attempts on manual connect this.reconnectAttempts = 0; // Removed connection logging for performance // Token fetch priority: // 1. If token exists, use it // 2. If tokenEndpoint exists, fetch from endpoint // 3. If apiKey exists, fetch from WioEX API if (!this.config.token) { if (this.config.tokenEndpoint) { // Removed token fetch logging try { await this.fetchTokenFromEndpoint(); } catch (error: unknown) { const err = error instanceof Error ? error : new Error(String(error)); this.handleError(err); return; } } else if (this.config.apiKey) { // Removed API key logging try { await this.fetchTokenFromApiKey(); } catch (error: unknown) { const err = error instanceof Error ? error : new Error(String(error)); this.handleError(err); return; } } else { this.handleError( new Error( 'Token is required but not available.\n' + 'Please provide either apiKey or token in config.' ) ); return; } } this.setState(ConnectionStateEnum.CONNECTING); try { // Use ws library for Node.js, native WebSocket for browser const WebSocketImpl = this.getWebSocketImplementation(); this.ws = new WebSocketImpl(this.config.streamUrl); // Phase 3: Set binary type for receiving binary data (MessagePack) this.ws.binaryType = 'arraybuffer'; this.ws.onopen = this.handleOpen.bind(this); this.ws.onmessage = this.handleMessage.bind(this); this.ws.onerror = this.handleError.bind(this); this.ws.onclose = this.handleClose.bind(this); } catch (error: unknown) { this.handleError(error instanceof Error ? error : new Error(String(error))); } } /** * Disconnect from WebSocket stream */ public disconnect(): void { this.config.autoReconnect = false; this.clearTimers(); // Flush ticker throttler before disconnecting this.tickerThrottler?.flush(); // Flush error queue before disconnecting void this.errorReporter.flush(); if (this.ws !== null) { this.ws.close(1000, 'Client disconnected'); this.ws = null; } this.setState(ConnectionStateEnum.DISCONNECTED); } /** * Subscribe to stock ticker updates */ public subscribe(stocks: string | string[]): void { const stockArray = Array.isArray(stocks) ? stocks : [stocks]; if (stockArray.length === 0) { throw new Error('At least one stock symbol is required'); } // Check max symbols limit const newTotal = this.subscribedStocks.size + stockArray.filter((s) => !this.subscribedStocks.has(s)).length; if (newTotal > this.config.maxSymbols) { throw new Error( `Cannot subscribe to more than ${this.config.maxSymbols} symbols. Current: ${this.subscribedStocks.size}` ); } // Filter out already subscribed symbols to prevent duplicates const newStocks = stockArray .map((stock) => stock.toUpperCase()) .filter((stock) => !this.subscribedStocks.has(stock)); // If no new symbols to subscribe, return early if (newStocks.length === 0) { // Removed subscription duplicate logging return; } // Add only new symbols to subscribed set newStocks.forEach((stock) => this.subscribedStocks.add(stock)); // Removed subscription logging // Lazy connect: If not connected and lazyConnect is enabled, connect automatically if (this.config.lazyConnect && this.state === ConnectionStateEnum.DISCONNECTED) { // Removed lazy connect logging void this.connect(); // After connect() completes, handleRegistration() will automatically subscribe to all stocks in subscribedStocks return; } // Send subscribe message with only new symbols if connected if (this.state === ConnectionStateEnum.REGISTERED) { this.sendMessage({ type: ClientMessageType.SUBSCRIBE, symbols: newStocks, }); } } /** * Unsubscribe from stock ticker updates */ public unsubscribe(stocks: string | string[]): void { const stockArray = Array.isArray(stocks) ? stocks : [stocks]; if (stockArray.length === 0) { throw new Error('At least one stock symbol is required'); } // Remove from subscribed set stockArray.forEach((stock) => this.subscribedStocks.delete(stock.toUpperCase())); // Send unsubscribe message if connected if (this.state === ConnectionStateEnum.REGISTERED) { this.sendMessage({ type: ClientMessageType.UNSUBSCRIBE, symbols: stockArray.map((s) => s.toUpperCase()), }); } } /** * Subscribe to trading signals for specific symbols */ public subscribeSignals(symbols: string | string[]): void { const symbolArray = Array.isArray(symbols) ? symbols : [symbols]; if (symbolArray.length === 0) { throw new Error('At least one symbol is required'); } // Removed signal subscription logging // Add to signal subscriptions set symbolArray.forEach((symbol) => this.signalSubscriptions.add(symbol.toUpperCase())); // Send signal subscription message if connected if (this.state === ConnectionStateEnum.REGISTERED) { this.sendMessage({ type: ClientMessageType.SUBSCRIBE_SIGNALS, symbols: [...this.signalSubscriptions], }); } else { // Removed connection status logging } } /** * Unsubscribe from trading signals */ public unsubscribeSignals(symbols: string | string[]): void { const symbolArray = Array.isArray(symbols) ? symbols : [symbols]; if (symbolArray.length === 0) { throw new Error('At least one symbol is required'); } // Removed signal unsubscription logging // Remove from signal subscriptions set symbolArray.forEach((symbol) => this.signalSubscriptions.delete(symbol.toUpperCase())); // Send signal unsubscription message if connected if (this.state === ConnectionStateEnum.REGISTERED) { this.sendMessage({ type: ClientMessageType.UNSUBSCRIBE_SIGNALS, symbols: symbolArray.map((s) => s.toUpperCase()), }); } } /** * Get currently subscribed signal symbols */ public getSignalSubscriptions(): string[] { return Array.from(this.signalSubscriptions); } /** * Get current connection state */ public getState(): ConnectionState { return this.state; } /** * Get subscribed stocks */ public getSubscribedStocks(): string[] { return [...this.subscribedStocks]; } /** * Get client statistics */ public getStats(): ClientStats { return { ...this.stats, subscribedStocks: this.getSubscribedStocks(), state: this.state, }; } /** * Check if connected */ public isConnected(): boolean { return ( this.state === ConnectionStateEnum.CONNECTED || this.state === ConnectionStateEnum.REGISTERED ); } /** * Handle WebSocket open event */ private handleOpen(): void { this.setState(ConnectionStateEnum.CONNECTED); if (this.config.enableStats) { this.stats.connectedAt = Date.now(); this.stats.reconnectAttempts = 0; } // Reset reconnect attempts and flags on successful connection this.reconnectAttempts = 0; this.isReconnecting = false; this.emit('connected'); // Register with token (token must exist at this point - validated in connect()) if (!this.config.token) { this.handleError(new Error('Token is not available for registration')); return; } this.sendMessage({ type: ClientMessageType.REGISTER, token: this.config.token, }); } /** * Handle WebSocket message event * Phase 3: Support both JSON and Binary (MessagePack) protocols */ private handleMessage(event: MessageEvent): void { if (this.config.enableStats) { this.stats.messagesReceived++; } // Store last received message for error reporting const messageData = event.data; this.lastReceivedMessage = typeof messageData === 'string' ? messageData : '[Binary Data]'; try { let data: unknown; // Phase 3: Auto-detect binary vs JSON data if (messageData instanceof ArrayBuffer || messageData instanceof Uint8Array) { // Binary data - decode with MessagePack data = msgpackDecode( messageData instanceof ArrayBuffer ? new Uint8Array(messageData) : messageData ); } else if (typeof messageData === 'string') { // JSON data - parse normally data = JSON.parse(messageData); } else { throw new Error('Unsupported message format'); } // Basic validation if (typeof data !== 'object' || data === null) { throw new Error('Invalid message format'); } // Cast to record for type-safe property access const record = data as Record; // Fast path: Handle ticker update first (most frequent) // Optimize: Inline type check to avoid function call overhead if ('type' in record && record['type'] === 'ticker') { this.handleTicker(data as TickerUpdateMessage); return; } // Handle registration response (second most common) if ('type' in record && record['type'] === 'registered') { this.handleRegistration(data as RegistrationResponse); return; } // Handle subscription response if ( 'type' in record && (record['type'] === 'subscribed' || record['type'] === 'unsubscribed') ) { this.handleSubscription(data as SubscriptionResponse); return; } // Handle signal message if ('type' in record && record['type'] === 'signal') { this.handleSignal(data as SignalMessage); return; } // Handle signal triggered message if ('type' in record && record['type'] === 'signal-triggered') { this.handleSignalTriggered(data as SignalTriggeredMessage); return; } // Handle signal subscription response if ('type' in record && record['type'] === 'signalSubscribed') { this.handleSignalSubscription(data as SignalSubscriptionResponse); return; } // Handle signal unsubscription response if ('type' in record && record['type'] === 'signalUnsubscribed') { this.handleSignalUnsubscription(data as SignalUnsubscriptionResponse); return; } // Handle error if ('status' in record && record['status'] === 'error') { const err = new Error( typeof record['message'] === 'string' ? record['message'] : 'Unknown error' ); // Report server error to WioEX (optimize: spread instead of Array.from) void this.reportError(err, { connectionState: this.state, subscribedStocks: [...this.subscribedStocks], triggerMessage: data, }); this.emit('error', err); return; } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); const err = new Error(`Failed to parse message: ${errorMessage}`); // Report parsing error to WioEX (optimize: spread instead of Array.from) void this.reportError(err, { connectionState: this.state, subscribedStocks: [...this.subscribedStocks], lastMessage: this.lastReceivedMessage ?? undefined, }); this.emit('error', err); } } /** * Handle WebSocket error event */ private handleError(error: Error | Event): void { const err = error instanceof Error ? error : new Error('WebSocket error'); // Report error to WioEX (optimize: spread instead of Array.from) void this.reportError(err, { connectionState: this.state, subscribedStocks: [...this.subscribedStocks], }); this.emit('error', err); } /** * Handle WebSocket close event */ private handleClose(event: CloseEvent): void { this.clearTimers(); const wasConnected = this.isConnected(); this.setState(ConnectionStateEnum.DISCONNECTED); this.emit('disconnected', event.code, event.reason); // Attempt reconnection if enabled and was actually connected before if (this.config.autoReconnect && wasConnected && !this.isReconnecting) { this.attemptReconnect(); } } /** * Handle registration response */ private handleRegistration(data: RegistrationResponse): void { if (data.status === 'success') { this.setState(ConnectionStateEnum.REGISTERED); this.emit('registered', data); // Re-subscribe to stocks after reconnection if (this.subscribedStocks.size > 0) { this.sendMessage({ type: ClientMessageType.SUBSCRIBE, symbols: [...this.subscribedStocks], }); } // Re-subscribe to signals after reconnection if (this.signalSubscriptions.size > 0) { this.sendMessage({ type: ClientMessageType.SUBSCRIBE_SIGNALS, symbols: [...this.signalSubscriptions], }); } } else { const err = new Error(`Registration failed: ${data.message}`); // Report registration failure to WioEX (optimize: spread instead of Array.from) void this.reportError(err, { connectionState: this.state, subscribedStocks: [...this.subscribedStocks], triggerMessage: data, }); this.emit('error', err); this.disconnect(); } } /** * Handle subscription response */ private handleSubscription(data: SubscriptionResponse): void { if (data.status === 'success') { if (data.type === 'subscribed' && data.stocks !== undefined) { this.emit('subscribed', data.stocks); } else if (data.type === 'unsubscribed' && data.stocks !== undefined) { this.emit('unsubscribed', data.stocks); } } else { const err = new Error(`Subscription failed: ${data.message}`); // Report subscription failure to WioEX (optimize: spread instead of Array.from) void this.reportError(err, { connectionState: this.state, subscribedStocks: [...this.subscribedStocks], triggerMessage: data, }); this.emit('error', err); } } /** * Handle ticker update (with optional throttling) */ private handleTicker(data: TickerUpdateMessage): void { if (this.config.enableStats) { this.stats.tickersReceived++; } const ticker = data.data; // Use throttler if enabled, otherwise emit directly if (this.tickerThrottler !== null) { this.tickerThrottler.emit(ticker); } else { this.emit('ticker', ticker); } } /** * Handle signal message */ private handleSignal(data: SignalMessage): void { // Removed signal received logging this.emit('signal', data.data); } /** * Handle signal triggered message */ private handleSignalTriggered(data: SignalTriggeredMessage): void { // Removed signal triggered logging this.emit('signalTriggered', data.data); } /** * Handle signal subscription response */ private handleSignalSubscription(data: SignalSubscriptionResponse): void { if (data.status === 'success') { // Removed signal subscription success logging this.emit('signalSubscribed', data.symbols || []); } else { const err = new Error(`Signal subscription failed: ${data.message}`); // Removed signal subscription error logging void this.reportError(err, { connectionState: this.state, subscribedStocks: [...this.subscribedStocks], triggerMessage: data, }); this.emit('signalError', err); } } /** * Handle signal unsubscription response */ private handleSignalUnsubscription(data: SignalUnsubscriptionResponse): void { if (data.status === 'success') { // Removed signal unsubscription success logging this.emit('signalUnsubscribed', []); } else { const err = new Error(`Signal unsubscription failed: ${data.message}`); // Removed signal unsubscription error logging this.emit('signalError', err); } } /** * Send message to WebSocket * Phase 3: Support both JSON and Binary (MessagePack) protocols */ private sendMessage(message: OutgoingMessage): void { if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) { throw new Error('WebSocket is not connected'); } // Phase 3: Encode with binary protocol if enabled if (this.config.useBinaryProtocol) { const binaryData = msgpackEncode(message); this.ws.send(binaryData); } else { this.ws.send(JSON.stringify(message)); } if (this.config.enableStats) { this.stats.messagesSent++; } } /** * Calculate adaptive reconnection delay * First attempt: 500ms, Second: 1000ms, Third+: 3000ms */ private getReconnectDelay(): number { if (this.reconnectAttempts === 0) { return this.config.reconnectDelay; // 500ms (base delay) } else if (this.reconnectAttempts === 1) { return this.config.reconnectDelay * 2; // 1000ms } else { return Math.min(this.config.reconnectDelay * 6, 5000); // 3000ms max 5s cap } } /** * Attempt to reconnect with adaptive delay * Phase 2 Optimization: Prevent duplicate reconnection attempts + Adaptive timing */ private attemptReconnect(): void { // Phase 2: Prevent duplicate reconnection attempts with atomic lock if (this.reconnectionLock) { return; } this.reconnectionLock = true; if ( this.config.maxReconnectAttempts > 0 && this.reconnectAttempts >= this.config.maxReconnectAttempts ) { this.setState(ConnectionStateEnum.FAILED); const err = new Error('Max reconnection attempts reached'); // Report max reconnection failure to WioEX (optimize: spread instead of Array.from) void this.reportError(err, { connectionState: this.state, subscribedStocks: [...this.subscribedStocks], reconnectAttempts: this.reconnectAttempts, }); this.emit('error', err); this.reconnectionLock = false; return; } this.isReconnecting = true; this.reconnectAttempts++; if (this.config.enableStats) { this.stats.reconnectAttempts++; } this.setState(ConnectionStateEnum.RECONNECTING); this.emit('reconnecting', this.reconnectAttempts); // Use adaptive delay instead of fixed delay const adaptiveDelay = this.getReconnectDelay(); // Removed reconnection delay logging this.reconnectTimer = setTimeout(async () => { try { await this.connect(); this.reconnectAttempts = 0; this.isReconnecting = false; this.reconnectionLock = false; // Release lock on success } catch (err) { this.isReconnecting = false; this.reconnectionLock = false; // Release lock on error // Small delay to prevent tight recursive loop on immediate failures setTimeout(() => { this.attemptReconnect(); }, 100); } }, adaptiveDelay); } /** * Clear all timers * Phase 2: Also reset reconnection flag */ private clearTimers(): void { if (this.reconnectTimer !== null) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; this.isReconnecting = false; this.reconnectionLock = false; // Release lock when clearing timers } this.cancelTokenRefresh(); } /** * Set connection state and emit event */ private setState(state: ConnectionState): void { const oldState = this.state; this.state = state; if (this.config.enableStats) { this.stats.state = state; } if (oldState !== state) { this.emit('stateChange', state); } } /** * Get WebSocket implementation (Node.js vs Browser) */ private getWebSocketImplementation(): typeof WebSocket { // Browser environment if (typeof WebSocket !== 'undefined') { return WebSocket; } // Node.js environment try { // Dynamic require for Node.js ws package /* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ const wsModule: { WebSocket: typeof WebSocket } = require('ws'); /* eslint-enable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ return wsModule.WebSocket; } catch (error: unknown) { throw new Error( 'WebSocket implementation not found. Please install "ws" package for Node.js' ); } } /** * Update authentication token (e.g., when token expires) * * @param token - New authentication token * @param expiresAt - Token expiration time (Unix timestamp in seconds) */ public updateToken(token: string, expiresAt: number): void { if (!token || token.trim() === '') { throw new Error('Token cannot be empty'); } // Update config and internal state this.config.token = token; this.tokenExpiresAt = expiresAt; // Cancel existing refresh timer this.cancelTokenRefresh(); // Schedule new refresh if auto-refresh is enabled if (this.config.autoRefreshToken) { this.scheduleTokenRefresh(); } // Emit event this.emit('tokenRefreshed', { token, expiresAt }); } /** * Schedule token refresh before expiry */ private scheduleTokenRefresh(): void { if (!this.tokenExpiresAt || !this.config.onTokenExpiring) { return; } // Cancel existing timer this.cancelTokenRefresh(); // Calculate when to refresh (default: 1 hour before expiry) const now = Date.now(); const expiresAtMs = this.tokenExpiresAt * 1000; const refreshAt = expiresAtMs - this.config.refreshBeforeExpiry; const delay = Math.max(0, refreshAt - now); // Schedule refresh this.tokenRefreshTimer = setTimeout(() => { void this.handleTokenRefresh(); }, delay); } /** * Handle token refresh */ private async handleTokenRefresh(): Promise { if (this.isRefreshingToken || !this.config.onTokenExpiring) { return; } // Check if token exists if (!this.config.token) { const err = new Error('Token is not available for refresh'); this.emit('tokenRefreshFailed', err); this.reportError(err, { connectionState: this.state, subscribedStocks: [...this.subscribedStocks], }); return; } this.isRefreshingToken = true; try { // Emit tokenExpiring event this.emit('tokenExpiring', { currentToken: this.config.token, expiresAt: this.tokenExpiresAt ?? 0, }); // Call user-provided callback to get new token const { token: newToken, expiresAt: newExpiresAt } = await this.config.onTokenExpiring(); // Update token this.updateToken(newToken, newExpiresAt); // If connected, we might need to reconnect with new token if (this.isConnected()) { // Save subscribed stocks const subscribedStocks = [...this.subscribedStocks]; // Disconnect this.disconnect(); // Wait a bit await new Promise((resolve) => setTimeout(resolve, 100)); // Reconnect with new token this.connect(); // Resubscribe to stocks after registration if (subscribedStocks.length > 0) { this.once('registered', () => { this.subscribe(subscribedStocks); }); } } } catch (error: unknown) { const err = error instanceof Error ? error : new Error(String(error)); this.emit('tokenRefreshFailed', err); this.reportError(err, { connectionState: this.state, subscribedStocks: [...this.subscribedStocks], }); } finally { this.isRefreshingToken = false; } } /** * Cancel scheduled token refresh */ private cancelTokenRefresh(): void { if (this.tokenRefreshTimer !== null) { clearTimeout(this.tokenRefreshTimer); this.tokenRefreshTimer = null; } } /** * Retry a function with exponential or linear backoff */ private async retryWithBackoff(fn: () => Promise): Promise { const { maxAttempts = 3, delay = 1000, backoff = 'exponential' } = this.config.tokenFetchRetry; let lastError: Error | null = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn(); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); const willRetry = attempt < maxAttempts; this.emit('tokenFetchFailed', lastError, attempt, willRetry); if (!willRetry) { break; } // Calculate delay with backoff const retryDelay = backoff === 'exponential' ? delay * Math.pow(2, attempt - 1) : delay * attempt; // Removed retry logging for performance await new Promise((resolve) => setTimeout(resolve, retryDelay)); } } throw lastError; } /** * Fetch token from backend endpoint (browser only) */ private async fetchTokenFromEndpoint(): Promise { const tokenEndpoint = this.config.tokenEndpoint; if (!tokenEndpoint) { throw new Error( 'tokenEndpoint is required for browser token fetching.\n' + '→ Set tokenEndpoint config to your backend endpoint URL\n' + '→ Example: tokenEndpoint: "/stream/token"\n' + '→ See: https://docs.wioex.com/stream-sdk/token-setup' ); } this.emit('tokenFetchStarted', 'endpoint'); // Removed endpoint fetch logging // Retry logic ile fetch const data = await this.retryWithBackoff(async () => { const response = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.tokenFetchHeaders, }, }); if (!response.ok) { throw new Error( `Failed to fetch token from '${tokenEndpoint}' (${response.status} ${response.statusText})\n` + `→ Ensure you have created the token endpoint in your backend\n` + `→ Endpoint should return: { token: string, expires_at: number }\n` + `→ See: https://docs.wioex.com/stream-sdk/token-setup` ); } return (await response.json()) as { token: string; expires_at: number }; }); this.config.token = data.token; this.tokenExpiresAt = data.expires_at; this.emit('tokenFetchSucceeded', { token: data.token, expiresAt: data.expires_at, source: 'endpoint', }); // Removed successful token fetch logging // Schedule auto-refresh if enabled if (this.config.autoRefreshToken) { this.scheduleTokenRefresh(); } } /** * Fetch token from WioEX API using API key (Node.js/Backend only) * * SECURITY: This method is blocked in browser environments. * Browser users must use tokenEndpoint pointing to their backend. */ private async fetchTokenFromApiKey(): Promise { // Double-check: Should never reach here in browser due to constructor check const isBrowser = typeof window !== 'undefined'; if (isBrowser) { throw new Error('API key usage is blocked in browser for security'); } if (!this.config.apiKey) { throw new Error('API key is required for token fetching'); } this.emit('tokenFetchStarted', 'api'); // Removed API token fetch logging const tokenUrl = `${this.config.baseUrl}/stream/token`; try { // Use fetch (available in Node.js 18+) if (hasFeature('fetch')) { const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${this.config.apiKey}`, 'Content-Type': 'application/json', 'User-Agent': `WioEX-Stream-SDK/${this.errorReporter['sdkVersion'] ?? '1.5.0'}`, }, }); if (!response.ok) { throw new Error(`Failed to fetch token: HTTP ${response.status}`); } const data = (await response.json()) as { token: string; expires_at: number }; this.config.token = data.token; this.tokenExpiresAt = data.expires_at; this.emit('tokenFetchSucceeded', { token: data.token, expiresAt: data.expires_at, source: 'api', }); // Removed API token success logging // Schedule auto-refresh if enabled if (this.config.autoRefreshToken) { this.scheduleTokenRefresh(); } } else { // Fallback to https module for older Node.js versions await this.fetchTokenWithHttps(); } } catch (error: unknown) { const err = normalizeError(error); throw new Error(`Token fetch failed: ${err.message}`); } } /** * Fetch token using https module (fallback for older Node.js) */ private async fetchTokenWithHttps(): Promise { const https = await import('https'); const { URL } = await import('url'); return new Promise((resolve, reject) => { const tokenUrl = `${this.config.baseUrl}/stream/token`; const parsedUrl = new URL(tokenUrl); const req = https.request( { hostname: parsedUrl.hostname, port: parsedUrl.port || 443, path: parsedUrl.pathname, method: 'POST', headers: { 'Authorization': `Bearer ${this.config.apiKey}`, 'Content-Type': 'application/json', 'User-Agent': `WioEX-Stream-SDK/${this.errorReporter['sdkVersion'] ?? '1.4.0'}`, }, }, (res) => { let data = ''; res.on('data', (chunk: Buffer) => { data += chunk.toString(); }); res.on('end', () => { try { if (res.statusCode && res.statusCode >= 400) { reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage ?? 'Error'}`)); return; } const json = JSON.parse(data) as { token: string; expires_at: number }; this.config.token = json.token; this.tokenExpiresAt = json.expires_at; // Schedule auto-refresh if enabled if (this.config.autoRefreshToken) { this.scheduleTokenRefresh(); } resolve(); } catch (error: unknown) { reject(error); } }); } ); req.on('error', reject); req.end(); }); } /** * Enhanced token validation */ private validateToken(token: string): void { // Basic checks if (token.trim() === '') { throw new Error('Token cannot be empty or whitespace'); } if (token.length < 10) { throw new Error('Token is too short (minimum 10 characters)'); } if (token.length > 2048) { throw new Error('Token is too long (maximum 2048 characters)'); } // Check for common invalid patterns if (token.includes(' ')) { throw new Error('Token cannot contain spaces'); } if (token.includes('\n') || token.includes('\r') || token.includes('\t')) { throw new Error('Token cannot contain whitespace characters'); } // Check for potential test/placeholder tokens const invalidPatterns = [ 'test', 'demo', 'placeholder', 'example', 'fake', 'mock', '123456', 'abcdef', 'xxxxxx', 'your-token-here' ]; const lowerToken = token.toLowerCase(); for (const pattern of invalidPatterns) { if (lowerToken.includes(pattern)) { throw new Error(`Token appears to be a placeholder or test token`); } } // Check for proper JWT format if it looks like a JWT if (token.includes('.')) { const parts = token.split('.'); if (parts.length === 3) { try { // Basic JWT validation - check if parts are base64 for (const part of parts) { if (part.length === 0) { throw new Error('Invalid JWT token format'); } // Simple base64 character check if (!/^[A-Za-z0-9_-]+$/.test(part)) { throw new Error('Invalid JWT token format'); } } } catch { throw new Error('Invalid JWT token format'); } } } } /** * Generate random ID for WebSocket connection */ private generateRandomId(length: number): string { const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < length; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; } /** * Report error to WioEX API */ private reportError(error: Error, context: ErrorContext): void { // Fire and forget - don't await to avoid blocking void this.errorReporter.report(error, context); } } export default WioexStreamClient;