| |
| const WS_URL = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`; |
| const RECONNECT_DELAY_MS = 2000; |
| const MAX_RECONNECT_DELAY = 30000; |
|
|
| let ws = null; |
| let reconnectDelay = RECONNECT_DELAY_MS; |
| let reconnectTimer = null; |
| let pendingCallbacks = new Map(); |
| let msgId = 0; |
| const listeners = new Map(); |
|
|
| export function send(data) { |
| if (ws?.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify(data)); |
| return true; |
| } |
| return false; |
| } |
|
|
| export function request(data, timeoutMs = 15000) { |
| return new Promise((resolve, reject) => { |
| const id = `req_${++msgId}`; |
| const full = { ...data, _reqId: id }; |
| const timer = setTimeout(() => { |
| pendingCallbacks.delete(id); |
| reject(new Error('Request timeout')); |
| }, timeoutMs); |
| pendingCallbacks.set(id, { resolve, reject, timer }); |
| if (!send(full)) { |
| clearTimeout(timer); |
| pendingCallbacks.delete(id); |
| reject(new Error('WebSocket not connected')); |
| } |
| }); |
| } |
|
|
| export function on(type, fn) { |
| if (!listeners.has(type)) listeners.set(type, new Set()); |
| listeners.get(type).add(fn); |
| return () => listeners.get(type)?.delete(fn); |
| } |
|
|
| export function off(type, fn) { |
| listeners.get(type)?.delete(fn); |
| } |
|
|
| function emit(type, data) { |
| listeners.get(type)?.forEach(fn => fn(data)); |
| listeners.get('*')?.forEach(fn => fn({ type, ...data })); |
| } |
|
|
| function connect() { |
| if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return; |
|
|
| ws = new WebSocket(WS_URL); |
|
|
| ws.onopen = () => { |
| reconnectDelay = RECONNECT_DELAY_MS; |
| emit('ws:connected', {}); |
| }; |
|
|
| ws.onmessage = (event) => { |
| let msg; |
| try { msg = JSON.parse(event.data); } catch { return; } |
| if (!msg?.type) return; |
|
|
| |
| if (msg._reqId && pendingCallbacks.has(msg._reqId)) { |
| const cb = pendingCallbacks.get(msg._reqId); |
| pendingCallbacks.delete(msg._reqId); |
| clearTimeout(cb.timer); |
| if (msg.error) cb.reject(new Error(msg.error)); |
| else cb.resolve(msg); |
| return; |
| } |
|
|
| emit(msg.type, msg); |
| }; |
|
|
| ws.onclose = () => { |
| emit('ws:disconnected', {}); |
| scheduleReconnect(); |
| }; |
|
|
| ws.onerror = () => { |
| ws?.close(); |
| }; |
| } |
|
|
| function scheduleReconnect() { |
| clearTimeout(reconnectTimer); |
| reconnectTimer = setTimeout(() => { |
| reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_RECONNECT_DELAY); |
| connect(); |
| }, reconnectDelay); |
| } |
|
|
| export function getReadyState() { |
| return ws?.readyState ?? WebSocket.CLOSED; |
| } |
|
|
| export function isConnected() { |
| return ws?.readyState === WebSocket.OPEN; |
| } |
|
|
| |
| connect(); |
| window.ws = { send, request, on, off, isConnected, getReadyState }; |
|
|