File size: 2,799 Bytes
5383ef0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// ws.js - WebSocket connection manager
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(); // id -> { resolve, reject, timeout }
let msgId = 0;
const listeners = new Map(); // type -> Set<fn>

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;

    // Resolve pending request callbacks
    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;
}

// Boot
connect();
window.ws = { send, request, on, off, isConnected, getReadyState };