UNPKG

@telnyx/react-native-voice-sdk

Version:

Telnyx React Native Voice SDK - A complete WebRTC voice calling solution

248 lines (220 loc) 8.13 kB
import { EventEmitter } from 'eventemitter3'; import { PROD_HOST } from './env'; import { isMessage } from './message'; import { createDeferredPromise } from './promise'; import type { DeferredPromise } from './promise'; import log from 'loglevel'; import { updateVoiceSDKId, VOICE_SDK_ID } from './global'; import WebSocketSelfSigned from 'react-native-websocket-self-signed'; type ConnectionEvents = { 'telnyx.socket.open': () => void; 'telnyx.socket.close': () => void; 'telnyx.socket.error': (ev: Event) => void; 'telnyx.socket.message': (message: unknown) => void; }; export class Connection extends EventEmitter<ConnectionEvents> { public socket: WebSocketSelfSigned; private isSocketConnected: boolean = false; private customVoiceSDKId?: string; public _client: any = null; // Reference to the client for CallKit UUID storage private reconnectEnabled: boolean = true; private reconnectAttempts: number = 0; private reconnectTimer: any = null; // Track last time we observed live traffic in either direction. Used by // callers to decide whether the socket is trustworthy after the app has // been frozen by iOS (which can leave the JS side thinking it's still // connected while the kernel has already discarded the TLS session). private _lastActivityAt: number = 0; private transactions: Map<string, DeferredPromise<unknown>>; private messageQueue: unknown[]; constructor(voiceSDKId?: string) { super(); this.customVoiceSDKId = voiceSDKId; // TODO: Toggle between PROD and DEV const wsUrl = this.buildWebsocketURL(); log.debug('[Connection]: WebSocket URL:', wsUrl); log.debug('[Connection]: Attempting to connect to:', wsUrl); // Console logs for release debugging console.log('[Connection] RELEASE DEBUG - WebSocket URL:', wsUrl); console.log('[Connection] RELEASE DEBUG - Attempting to connect to:', wsUrl); // Use WebSocketSelfSigned for iOS to handle self-signed certificates log.debug('[Connection]: Using WebSocketSelfSigned with self-signed certificate support'); this.socket = WebSocketSelfSigned.getInstance(wsUrl); this.socket.onOpen(() => { log.debug('[Connection]: WebSocket connection opened'); this.isSocketConnected = true; this._lastActivityAt = Date.now(); if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } this.reconnectAttempts = 0; try { this.emit('telnyx.socket.open'); } catch (error) { log.error( '[Connection]: Failed to emit socket.open event - bridge may be disconnected:', error ); } this.flushMessageQueue(); }); this.socket.onError((error: string) => { log.error('[Connection]: WebSocket error occurred', error); try { this.emit('telnyx.socket.error', error as any); } catch (emitError) { log.error( '[Connection]: Failed to emit socket.error event - bridge may be disconnected:', emitError ); } }); this.socket.onClose(() => { log.debug('[Connection]: WebSocket connection closed'); this.isSocketConnected = false; try { this.emit('telnyx.socket.close'); } catch (error) { log.error( '[Connection]: Failed to emit socket.close event - bridge may be disconnected:', error ); } }); this.socket.onMessage((message: string) => { const parsedMessage = this.safeParseMessage(message); log.debug('Received message:', parsedMessage); this._lastActivityAt = Date.now(); if (isMessage(parsedMessage)) { const transaction = this.transactions.get(parsedMessage.id); transaction?.resolve(parsedMessage); this.transactions.delete(parsedMessage.id); } if (parsedMessage.voice_sdk_id) { updateVoiceSDKId(parsedMessage.voice_sdk_id); } try { this.emit('telnyx.socket.message', parsedMessage); } catch (error) { log.error( '[Connection]: Failed to emit socket.message event - bridge may be disconnected:', error ); } }); this.transactions = new Map(); this.messageQueue = []; // Initiate connection this.socket.connect().catch((error) => { log.error('[Connection]: Failed to connect WebSocket', error); }); } public send = (msg: unknown) => { if (!this.isConnected) { this.messageQueue.push(msg); return; } try { log.debug('Sending message to gateway', msg); this.socket.send(JSON.stringify(msg)); // Only stamp activity AFTER a successful send so a throwing/queued // send doesn't make a dead socket look fresh to isFresh() callers. this._lastActivityAt = Date.now(); } catch (error) { log.error( '[Connection]: Failed to send WebSocket message - bridge may be disconnected:', error ); // Don't throw the error, just log it to prevent app crashes during app state transitions // The message will be lost, but this is better than crashing the entire app } }; public sendAndWait = (msg: { id: string }): Promise<unknown> => { const deferred = createDeferredPromise<unknown>(); this.transactions.set(msg.id, deferred); this.send(msg); return deferred.promise; }; public close = () => { this.reconnectEnabled = false; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } this.socket.close(); this.socket.removeOnOpenListener(); this.socket.removeOnErrorListener(); this.socket.removeOnCloseListener(); this.socket.removeOnMessageListener(); this.removeAllListeners(); this.transactions.clear(); this.messageQueue = []; }; public get isConnected() { return this.isSocketConnected; } /** * Milliseconds since the last observed traffic on the socket (open, send, * or receive). Returns `Infinity` if we've never seen traffic. Used to * detect silently-dead sockets when iOS thaws the app after a freeze. */ public get idleMs(): number { if (this._lastActivityAt === 0) return Infinity; return Date.now() - this._lastActivityAt; } private buildWebsocketURL = () => { const url = new URL(PROD_HOST); // Use custom voice_sdk_id from push notification if available, otherwise use global one const voiceSDKIdToUse = this.customVoiceSDKId || VOICE_SDK_ID; if (voiceSDKIdToUse) { url.searchParams.set('voice_sdk_id', voiceSDKIdToUse); if (this.customVoiceSDKId) { log.debug( '[Connection]: Using custom voice_sdk_id from push notification:', this.customVoiceSDKId ); } } const result = url.toString(); log.debug('[Connection]: Final WebSocket URL:', result); // Console log for release debugging console.log('[Connection] RELEASE DEBUG - Final WebSocket URL with params:', result); return result; }; private flushMessageQueue = () => { if (this.messageQueue.length === 0) { return; } this.messageQueue.forEach((msg) => this.send(msg)); this.messageQueue = []; }; private safeParseMessage = (msg: string) => { try { return JSON.parse(msg); } catch (error) { log.error('Failed to parse message:', msg, error); return msg; } }; private scheduleReconnect = () => { if (!this.reconnectEnabled || this.isSocketConnected || this.reconnectTimer) { return; } const attempt = this.reconnectAttempts; const delay = Math.min(1000 * Math.pow(2, attempt), 30000); this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = null; if (!this.reconnectEnabled || this.isSocketConnected) { return; } try { await this.socket.connect(); } catch (e) { log.error('[Connection]: Reconnect attempt failed', e); this.reconnectAttempts = attempt + 1; this.scheduleReconnect(); } }, delay); }; }