import { captureClientError } from './client-error-reporter.js'; const DEFAULT_TIMEOUT_MS = 12000; function buildWsUrl(raw) { const value = (raw || '').trim(); if (!value) return 'wss://shineup.me/ws'; if (value.startsWith('/')) { const secure = window.location.protocol === 'https:'; const scheme = secure ? 'wss' : 'ws'; return `${scheme}://${window.location.host}${value}`; } if (value.startsWith('ws://') || value.startsWith('wss://')) { try { const parsed = new URL(value); if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws'; return parsed.toString(); } catch { return value; } } if (value.startsWith('http://') || value.startsWith('https://')) { try { const parsed = new URL(value); parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:'; if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws'; return parsed.toString(); } catch { return value.startsWith('https://') ? `wss://${value.slice('https://'.length)}` : `ws://${value.slice('http://'.length)}`; } } return value; } function isLoopbackHost(hostname = '') { const host = String(hostname || '').toLowerCase(); return host === 'localhost' || host === '127.0.0.1' || host === '[::1]'; } function isMixedContentWs(url) { try { const pageIsHttps = window.location.protocol === 'https:'; if (!pageIsHttps) return false; const parsed = new URL(url); if (parsed.protocol !== 'ws:') return false; return !isLoopbackHost(parsed.hostname); } catch { return false; } } function createRequestId(op) { return `${op}-${Date.now()}-${Math.random().toString(16).slice(2)}`; } export class WsJsonClient { constructor(url) { this.url = buildWsUrl(url); this.ws = null; this.pending = new Map(); this.openPromise = null; this.eventListeners = new Map(); } async open() { if (this.ws && this.ws.readyState === WebSocket.OPEN) return; if (this.openPromise) return this.openPromise; if (isMixedContentWs(this.url)) { const error = new Error('Страница открыта по HTTPS, а сервер указан как ws://. Используйте wss:// адрес для Shine сервера.'); captureClientError({ kind: 'ws_mixed_content_blocked', message: error.message, context: { url: this.url, pageProtocol: window.location.protocol }, }); throw error; } this.openPromise = new Promise((resolve, reject) => { const ws = new WebSocket(this.url); this.ws = ws; ws.addEventListener('open', () => { resolve(); }, { once: true }); ws.addEventListener('error', () => { captureClientError({ kind: 'ws_open_error', message: `Failed to connect WebSocket ${this.url}`, context: { url: this.url }, }); reject(new Error(`Не удалось подключиться к ${this.url}`)); }, { once: true }); ws.addEventListener('close', () => { this.failPending('Соединение WebSocket закрыто'); }); ws.addEventListener('message', (event) => { this.handleMessage(event.data); }); }).finally(() => { this.openPromise = null; }); return this.openPromise; } async request(op, payload = {}, timeoutMs = DEFAULT_TIMEOUT_MS) { await this.open(); const requestId = createRequestId(op); const body = { op, requestId, payload }; const responsePromise = new Promise((resolve, reject) => { const timer = window.setTimeout(() => { this.pending.delete(requestId); if (op !== 'ClientErrorLog') { captureClientError({ kind: 'ws_timeout', message: `Timeout waiting for ${op}`, requestOp: op, requestIdRef: requestId, context: { url: this.url, timeoutMs }, }); } reject(new Error(`Таймаут ответа для операции ${op}`)); }, timeoutMs); this.pending.set(requestId, { op, resolve: (value) => { window.clearTimeout(timer); resolve(value); }, reject: (error) => { window.clearTimeout(timer); reject(error); }, }); }); this.ws.send(JSON.stringify(body)); return responsePromise; } close() { if (this.ws) { this.ws.close(); this.ws = null; } } handleMessage(raw) { let data; try { data = JSON.parse(raw); } catch { captureClientError({ kind: 'ws_bad_json', message: 'Received non-JSON message from server', context: { raw: String(raw).slice(0, 1000) }, }); return; } const requestId = data?.requestId; const isEvent = data?.event === true || (requestId && !this.pending.has(requestId)); if (isEvent) { this.emitEvent(data?.op || '', data); return; } if (!requestId) return; const slot = this.pending.get(requestId); if (!slot) return; this.pending.delete(requestId); slot.resolve(data); } onEvent(op, callback) { if (!op || typeof callback !== 'function') return () => {}; if (!this.eventListeners.has(op)) { this.eventListeners.set(op, new Set()); } const set = this.eventListeners.get(op); set.add(callback); return () => { set.delete(callback); if (!set.size) this.eventListeners.delete(op); }; } emitEvent(op, data) { const listeners = this.eventListeners.get(op); if (!listeners) return; listeners.forEach((cb) => { try { cb(data); } catch {} }); } failPending(message) { const pendingOps = [...this.pending.values()] .map((slot) => slot.op) .filter((op) => op && op !== 'ClientErrorLog'); if (pendingOps.length > 0) { captureClientError({ kind: 'ws_closed', message, context: { url: this.url, pendingOps }, }); } const error = new Error(message); for (const [, slot] of this.pending.entries()) { slot.reject(error); } this.pending.clear(); } }