From af1543135d42adc2e97dc5243aeef7418cd3b00d Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 08:39:26 +0100
Subject: [PATCH] feat: dual address auto-switch, custom icon, notifications, image support
---
services/websocket.ts | 139 ++++++++++++++++++++++++++++-----------------
1 files changed, 86 insertions(+), 53 deletions(-)
diff --git a/services/websocket.ts b/services/websocket.ts
index 2b24eef..03f1a59 100644
--- a/services/websocket.ts
+++ b/services/websocket.ts
@@ -1,5 +1,6 @@
-import { WebSocketMessage } from "../types";
+import { WsOutgoing } from "../types";
+type WebSocketMessage = Record<string, unknown>;
type MessageCallback = (data: WebSocketMessage) => void;
type StatusCallback = () => void;
type ErrorCallback = (error: Event) => void;
@@ -14,97 +15,125 @@
const INITIAL_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 30000;
const RECONNECT_MULTIPLIER = 2;
+const LOCAL_TIMEOUT = 2500;
export class WebSocketClient {
private ws: WebSocket | null = null;
- private url: string = "";
+ private urls: string[] = [];
+ private urlIndex: number = 0;
private reconnectDelay: number = INITIAL_RECONNECT_DELAY;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
+ private localTimer: ReturnType<typeof setTimeout> | null = null;
private shouldReconnect: boolean = false;
+ private connected: boolean = false;
private callbacks: WebSocketClientOptions = {};
setCallbacks(callbacks: WebSocketClientOptions) {
this.callbacks = callbacks;
}
- connect(url: string) {
- this.url = url;
+ connect(urls: string[]) {
+ this.urls = urls.filter(Boolean);
+ if (this.urls.length === 0) return;
this.shouldReconnect = true;
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
- this.openConnection();
+ this.urlIndex = 0;
+ this.connected = false;
+ this.tryUrl();
}
- private openConnection() {
+ private cleanup() {
+ if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
if (this.ws) {
- this.ws.close();
+ const old = this.ws;
this.ws = null;
+ old.onopen = null;
+ old.onclose = null;
+ old.onerror = null;
+ old.onmessage = null;
+ try { old.close(); } catch { /* ignore */ }
+ }
+ }
+
+ private tryUrl() {
+ this.cleanup();
+
+ const url = this.urls[this.urlIndex];
+ if (!url) return;
+
+ const ws = new WebSocket(url);
+ this.ws = ws;
+
+ // If trying local (index 0) and we have a remote fallback,
+ // give local 2.5s before switching to remote
+ if (this.urlIndex === 0 && this.urls.length > 1) {
+ this.localTimer = setTimeout(() => {
+ this.localTimer = null;
+ if (this.connected) return; // already connected, ignore
+ // Local didn't connect in time — try remote
+ this.urlIndex = 1;
+ this.tryUrl();
+ }, LOCAL_TIMEOUT);
}
- try {
- this.ws = new WebSocket(this.url);
+ ws.onopen = () => {
+ if (ws !== this.ws) return; // stale
+ this.connected = true;
+ if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+ this.callbacks.onOpen?.();
+ };
- this.ws.onopen = () => {
- this.reconnectDelay = INITIAL_RECONNECT_DELAY;
- this.callbacks.onOpen?.();
- };
+ ws.onmessage = (event) => {
+ if (ws !== this.ws) return; // stale
+ try {
+ const data = JSON.parse(event.data) as WebSocketMessage;
+ this.callbacks.onMessage?.(data);
+ } catch {
+ this.callbacks.onMessage?.({ type: "text", content: String(event.data) });
+ }
+ };
- 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 {
+ ws.onclose = () => {
+ if (ws !== this.ws) return; // stale
+ this.connected = false;
+ this.callbacks.onClose?.();
if (this.shouldReconnect) {
this.scheduleReconnect();
}
- }
+ };
+
+ ws.onerror = () => {
+ if (ws !== this.ws) return; // stale
+ // Don't do anything here — onclose always fires after onerror
+ // and handles reconnect. Just swallow the error event.
+ };
}
private scheduleReconnect() {
- if (this.reconnectTimer) {
- clearTimeout(this.reconnectTimer);
- }
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = setTimeout(() => {
+ this.reconnectTimer = null;
this.reconnectDelay = Math.min(
this.reconnectDelay * RECONNECT_MULTIPLIER,
MAX_RECONNECT_DELAY
);
- this.openConnection();
+ // Alternate between URLs on each reconnect attempt
+ if (this.urls.length > 1) {
+ this.urlIndex = this.urlIndex === 0 ? 1 : 0;
+ }
+ this.tryUrl();
}, this.reconnectDelay);
}
disconnect() {
this.shouldReconnect = false;
- if (this.reconnectTimer) {
- clearTimeout(this.reconnectTimer);
- this.reconnectTimer = null;
- }
- if (this.ws) {
- this.ws.close();
- this.ws = null;
- }
+ this.connected = false;
+ this.cleanup();
}
- send(message: WebSocketMessage) {
+ send(message: WsOutgoing) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
return true;
@@ -117,7 +146,11 @@
}
get isConnected(): boolean {
- return this.ws?.readyState === WebSocket.OPEN;
+ return this.connected;
+ }
+
+ get currentUrl(): string {
+ return this.urls[this.urlIndex] ?? "";
}
}
--
Gitblit v1.3.1