/** * RelayTransport — WebSocket client that connects the RN app to a cloud relay * server for production usage. * * Uses React Native's built-in WebSocket global (no native modules needed). * * The transport: * - Connects to the relay as a "device" client * - Receives command messages from controllers via the relay * - Sends responses back through the relay * - Auto-reconnects with exponential backoff on disconnect */ import { logDebug, logInfo, logWarn } from './logger'; export type MessageHandler = (message: string) => Promise; export interface RelayTransportConfig { /** Full WebSocket URL of the relay server */ relayUrl: string; /** Channel ID for pairing with CLI controllers */ channelId: string; /** Shared secret for channel authentication */ channelSecret?: string; /** Callback when a command message arrives; must return a response string */ onMessage: MessageHandler; /** Called when connection state changes */ onStatusChange?: (connected: boolean) => void; } const INITIAL_RECONNECT_DELAY = 1000; const MAX_RECONNECT_DELAY = 30000; export class RelayTransport { private config: RelayTransportConfig; private ws: WebSocket | null = null; private reconnectDelay = INITIAL_RECONNECT_DELAY; private reconnectTimer: ReturnType | null = null; private isStopped = false; private _isConnected = false; constructor(config: RelayTransportConfig) { this.config = config; } /** Start connecting to the relay */ start(): void { this.isStopped = false; this.connect(); } /** Stop the transport and close the connection */ stop(): void { this.isStopped = true; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.ws) { this.ws.close(1000, 'Transport stopped'); this.ws = null; } this.setConnected(false); } /** Whether the transport is currently connected to the relay */ get isConnected(): boolean { return this._isConnected; } // ─── Internal ────────────────────────────────────────────────────────── private connect(): void { if (this.isStopped) return; const { relayUrl, channelId, channelSecret } = this.config; const separator = relayUrl.includes('?') ? '&' : '?'; let url = `${relayUrl}${separator}channel=${encodeURIComponent( channelId )}&role=device`; if (channelSecret) { url += `&secret=${encodeURIComponent(channelSecret)}`; } logInfo(`Relay connecting to ${url}`); try { // React Native provides a global WebSocket implementation this.ws = new WebSocket(url); } catch (e: any) { logWarn(`Relay WebSocket creation failed: ${e.message}`); this.scheduleReconnect(); return; } this.ws.onopen = () => { logInfo(`Relay connected to channel "${channelId}"`); this.reconnectDelay = INITIAL_RECONNECT_DELAY; this.setConnected(true); }; this.ws.onmessage = (event: WebSocketMessageEvent) => { const data = typeof event.data === 'string' ? event.data : String(event.data); // Ignore relay control messages (prefixed with _relay) try { const parsed = JSON.parse(data); if (parsed._relay) { logDebug(`Relay event: ${parsed.event}`); return; } } catch { // Not JSON — treat as a command } // Process the command and send the response back this.config .onMessage(data) .then((response) => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(response); } }) .catch((err) => { logWarn(`Relay message handler error: ${err.message}`); const errorResponse = JSON.stringify({ success: false, command: 'unknown', result: {}, error: `Internal error: ${err.message}`, timestamp: Date.now(), }); if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(errorResponse); } }); }; this.ws.onclose = (event: WebSocketCloseEvent) => { logInfo( `Relay disconnected (code=${event.code}, reason="${ event.reason || 'none' }")` ); this.ws = null; this.setConnected(false); if (!this.isStopped) { this.scheduleReconnect(); } }; this.ws.onerror = (event: Event) => { // onclose will fire after onerror, so reconnect is handled there logWarn(`Relay WebSocket error: ${(event as any).message || 'unknown'}`); }; } private scheduleReconnect(): void { if (this.isStopped) return; logDebug(`Relay reconnecting in ${this.reconnectDelay}ms...`); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(); }, this.reconnectDelay); // Exponential backoff this.reconnectDelay = Math.min( this.reconnectDelay * 2, MAX_RECONNECT_DELAY ); } private setConnected(connected: boolean): void { this._isConnected = connected; this.config.onStatusChange?.(connected); } }