'use strict' const { EventEmitter } = require('events') const { randomUUID } = require('crypto') const WebSocket = require('ws') // ─── URL builder ────────────────────────────────────────────────────────────── // Accepts any of: // srt.queo.ru → wss://srt.queo.ru:443 // srt.queo.ru:8080 → ws://srt.queo.ru:8080 (non-443 = assume no SSL) // 192.168.1.5 → ws://192.168.1.5:443 // 192.168.1.5:3000 → ws://192.168.1.5:3000 // https://srt.queo.ru → wss://srt.queo.ru:443 // http://pi.local:3000 → ws://pi.local:3000 function buildWsUrl(server, port) { let s = (server || '').trim() // Already has explicit ws:// or wss:// — use as-is (port ignored) if (/^wss?:\/\//i.test(s)) return s // Strip http(s):// prefix, remember the scheme let forceSecure = null if (/^https:\/\//i.test(s)) { s = s.replace(/^https:\/\//i, ''); forceSecure = true } else if (/^http:\/\//i.test(s)) { s = s.replace(/^http:\/\//i, ''); forceSecure = false } // Parse host and optional inline port let host = s let resolvedPort = parseInt(port) || 443 const colonIdx = s.lastIndexOf(':') if (colonIdx !== -1) { const maybePort = parseInt(s.slice(colonIdx + 1)) if (maybePort > 0 && maybePort < 65536) { host = s.slice(0, colonIdx) resolvedPort = maybePort } } // Choose protocol: wss for port 443 or explicit https, ws otherwise const secure = forceSecure !== null ? forceSecure : resolvedPort === 443 const proto = secure ? 'wss' : 'ws' return `${proto}://${host}:${resolvedPort}` } // ─── RemoteClient ───────────────────────────────────────────────────────────── class RemoteClient extends EventEmitter { constructor() { super() this.ws = null this.connected = false this.connecting = false this._manualClose = false this._retryTimer = null this._retryCount = 0 this._pingInterval = null this.serverUrl = null this.token = null this.machineName = null } // ── Connect ──────────────────────────────────────────────────────────────── connect({ server, port, token, machineName }) { this._manualClose = false this.token = token this.machineName = machineName || require('os').hostname() this.serverUrl = buildWsUrl(server, port) console.log('[Remote] Connecting to', this.serverUrl) this._doConnect() } _doConnect() { if (this.ws) { try { this.ws.terminate() } catch {} this.ws = null } this.connecting = true this.emit('status', { status: 'connecting', url: this.serverUrl }) const ws = new WebSocket(this.serverUrl, { handshakeTimeout: 10000, // Allow self-signed certs on local network rejectUnauthorized: !this.serverUrl.includes('192.168.') && !this.serverUrl.includes('10.') && !this.serverUrl.includes('172.') && !this.serverUrl.includes('localhost') && !this.serverUrl.includes('.local') }) this.ws = ws ws.on('open', () => { this.connected = false // becomes true after auth_ok this.connecting = false this._retryCount = 0 // Send auth immediately this._send({ type: 'auth', token: this.token, machineName: this.machineName, version: '2.0.0' }) // Keepalive ping every 25s this._pingInterval = setInterval(() => this._send({ type: 'ping' }), 25000) }) ws.on('message', (raw) => { try { const msg = JSON.parse(raw) this._handleMessage(msg) } catch (e) { console.warn('[Remote] Invalid message:', raw) } }) ws.on('close', (code, reason) => { this._cleanup() if (!this._manualClose) { const delay = Math.min(3000 * Math.pow(1.5, this._retryCount), 60000) this._retryCount++ console.log(`[Remote] Disconnected (${code}), retrying in ${(delay/1000).toFixed(1)}s`) this.emit('status', { status: 'disconnected', retryIn: delay }) this._retryTimer = setTimeout(() => this._doConnect(), delay) } else { this.emit('status', { status: 'disconnected' }) } }) ws.on('error', (err) => { console.error('[Remote] WS error:', err.message) this.emit('status', { status: 'error', error: err.message }) // 'close' fires after 'error', so reconnect is handled there }) } disconnect() { this._manualClose = true this._cleanup() if (this.ws) { try { this.ws.terminate() } catch {} this.ws = null } this.emit('status', { status: 'disconnected' }) } _cleanup() { this.connected = false this.connecting = false clearInterval(this._pingInterval) clearTimeout(this._retryTimer) this._pingInterval = null this._retryTimer = null } // ── Incoming messages ────────────────────────────────────────────────────── _handleMessage(msg) { switch (msg.type) { case 'auth_ok': this.connected = true console.log('[Remote] Authenticated, server name:', msg.serverName) this.emit('status', { status: 'connected', serverName: msg.serverName }) // Server may request initial state right away this.emit('request_devices') break case 'auth_fail': console.error('[Remote] Auth failed:', msg.reason) this.emit('status', { status: 'auth_failed', reason: msg.reason }) this.disconnect() break case 'get_devices': this.emit('request_devices') break case 'start_stream': this.emit('command', { action: 'start_stream', config: msg.config }) break case 'stop_stream': this.emit('command', { action: 'stop_stream', id: msg.id }) break case 'update_stream': this.emit('command', { action: 'update_stream', id: msg.id, changes: msg.changes }) break case 'stop_all': this.emit('command', { action: 'stop_all' }) break case 'pong': break // keepalive response default: console.log('[Remote] Unknown message type:', msg.type) } } // ── Outgoing messages ────────────────────────────────────────────────────── _send(obj) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { try { this.ws.send(JSON.stringify(obj)) } catch {} } } sendDevices(devices) { this._send({ type: 'devices', devices }) } sendStreamStatus(id, status) { this._send({ type: 'stream_status', id, status }) } sendAllStatus(streams) { this._send({ type: 'all_status', streams }) } sendLog(id, text) { // Throttle logs — send only every ~500ms or on errors this._send({ type: 'log', id, text }) } // ── Static helper ────────────────────────────────────────────────────────── static generateToken() { return randomUUID() } static buildWsUrl(server, port) { return buildWsUrl(server, port) } } module.exports = new RemoteClient() module.exports.RemoteClient = RemoteClient module.exports.buildWsUrl = buildWsUrl