Files
bookhoard/web/src/websocket.ts
T
john-okeefe c4f972aba1 refactor(websocket): convert to pub/sub pattern with addListener/removeListener
Replace the single-listener createWebSocket pattern with a pub/sub model
using addListener/removeListener. This allows multiple components (header
spinner, dashboard refresh) to subscribe to WebSocket messages independently
without clobbering each other's handlers.

- Maintain a Set of message listeners
- Auto-connect on first addListener, auto-disconnect when last listener removed
- Retain reconnect logic with configurable delay
2026-05-16 19:31:16 -04:00

129 lines
2.9 KiB
TypeScript

import { getToken } from "./storage";
type MessageListener = (message: any) => void;
let activeWebSocket: WebSocket | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let listeners: Set<MessageListener> = new Set();
let currentConfig: { enableReconnect?: boolean; reconnectDelay?: number } = {};
function connectWebSocket(): WebSocket | null {
const token = getToken();
if (!token) {
return null;
}
if (activeWebSocket) {
return activeWebSocket;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws/sync?token=${token}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log("WebSocket connected");
};
ws.onmessage = (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
listeners.forEach((listener) => {
try {
listener(message);
} catch (error) {
console.error("Error in WebSocket listener:", error);
}
});
} catch (error) {
console.error("Error parsing WebSocket message:", error);
}
};
ws.onerror = (error: Event) => {
console.error("WebSocket error:", error);
};
ws.onclose = () => {
console.log("WebSocket disconnected");
activeWebSocket = null;
if (currentConfig.enableReconnect !== false) {
const delay = currentConfig.reconnectDelay || 5000;
console.log(`Reconnecting in ${delay / 1000}s...`);
reconnectTimeout = setTimeout(() => {
connectWebSocket();
}, delay);
}
};
activeWebSocket = ws;
return ws;
}
function addListener(listener: MessageListener): void {
listeners.add(listener);
if (!activeWebSocket) {
connectWebSocket();
}
}
function removeListener(listener: MessageListener): void {
listeners.delete(listener);
if (listeners.size === 0) {
disconnectWebSocket();
}
}
function disconnectWebSocket(): void {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
if (activeWebSocket) {
activeWebSocket.close();
activeWebSocket = null;
}
}
function createWebSocket(config: {
onMessage: (data: any) => void;
onOpen?: () => void;
onClose?: () => void;
onError?: (error: Event) => void;
enableReconnect?: boolean;
reconnectDelay?: number;
}): WebSocket | null {
currentConfig = config;
const listener: MessageListener = (message) => {
config.onMessage(message);
};
listeners.add(listener);
if (activeWebSocket) {
activeWebSocket.close();
activeWebSocket = null;
}
const ws = connectWebSocket();
if (ws && config.onOpen) {
if (ws.readyState === WebSocket.OPEN) {
config.onOpen();
} else {
ws.addEventListener("open", () => config.onOpen?.(), { once: true });
}
}
return ws;
}
export {
addListener,
createWebSocket,
disconnectWebSocket,
removeListener,
};