Matthias Nott
2026-03-02 a0f39302919fbacf7a0d407f01b1a50413ea6f70
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
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import { WebSocketMessage } from "../types";
type MessageCallback = (data: WebSocketMessage) => void;
type StatusCallback = () => void;
type ErrorCallback = (error: Event) => void;
interface WebSocketClientOptions {
  onMessage?: MessageCallback;
  onOpen?: StatusCallback;
  onClose?: StatusCallback;
  onError?: ErrorCallback;
}
const INITIAL_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 30000;
const RECONNECT_MULTIPLIER = 2;
export class WebSocketClient {
  private ws: WebSocket | null = null;
  private url: string = "";
  private reconnectDelay: number = INITIAL_RECONNECT_DELAY;
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  private shouldReconnect: boolean = false;
  private callbacks: WebSocketClientOptions = {};
  setCallbacks(callbacks: WebSocketClientOptions) {
    this.callbacks = callbacks;
  }
  connect(url: string) {
    this.url = url;
    this.shouldReconnect = true;
    this.reconnectDelay = INITIAL_RECONNECT_DELAY;
    this.openConnection();
  }
  private openConnection() {
    if (this.ws) {
      this.ws.close();
      this.ws = null;
    }
    try {
      this.ws = new WebSocket(this.url);
      this.ws.onopen = () => {
        this.reconnectDelay = INITIAL_RECONNECT_DELAY;
        this.callbacks.onOpen?.();
      };
      this.ws.onmessage = (event) => {
        try {
          const data = JSON.parse(event.data) as WebSocketMessage;
          this.callbacks.onMessage?.(data);
        } catch {
          // Non-JSON message — treat as plain text
          const data: WebSocketMessage = {
            type: "text",
            content: String(event.data),
          };
          this.callbacks.onMessage?.(data);
        }
      };
      this.ws.onclose = () => {
        this.callbacks.onClose?.();
        if (this.shouldReconnect) {
          this.scheduleReconnect();
        }
      };
      this.ws.onerror = (error) => {
        this.callbacks.onError?.(error);
      };
    } catch {
      if (this.shouldReconnect) {
        this.scheduleReconnect();
      }
    }
  }
  private scheduleReconnect() {
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
    }
    this.reconnectTimer = setTimeout(() => {
      this.reconnectDelay = Math.min(
        this.reconnectDelay * RECONNECT_MULTIPLIER,
        MAX_RECONNECT_DELAY
      );
      this.openConnection();
    }, this.reconnectDelay);
  }
  disconnect() {
    this.shouldReconnect = false;
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
      this.reconnectTimer = null;
    }
    if (this.ws) {
      this.ws.close();
      this.ws = null;
    }
  }
  send(message: WebSocketMessage) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(message));
      return true;
    }
    return false;
  }
  get readyState(): number {
    return this.ws?.readyState ?? WebSocket.CLOSED;
  }
  get isConnected(): boolean {
    return this.ws?.readyState === WebSocket.OPEN;
  }
}
export const wsClient = new WebSocketClient();