Corp/frontend/src/lib/websocketManager.ts
2026-09-07 19:43:48 +03:00

343 lines
9.8 KiB
TypeScript

/** Shared, authenticated and self-healing WebSocket transport. */
import { apiClient } from "@/lib/api";
import {
AUTH_SESSION_EVENT,
type AuthSessionEventDetail,
} from "@/lib/api/client";
import { getWebSocketUrl } from "@/lib/url";
export type RealtimeEvent = {
type: string;
[key: string]: unknown;
};
type MessageHandler = (data: RealtimeEvent) => void;
export type ConnectionStatus = {
isConnected: boolean;
isConnecting: boolean;
reconnectAttempts: number;
maxReconnectAttempts: number;
};
type StatusHandler = (status: ConnectionStatus) => void;
type WebSocketTicketResponse = { ticket: string; expires_in: number };
const RAPID_RECONNECT_LIMIT = 5;
const MAX_RECONNECT_DELAY_MS = 60_000;
const HEARTBEAT_TIMEOUT_MS = 70_000;
const HEARTBEAT_CHECK_MS = 15_000;
export class WebSocketManager {
private static instance: WebSocketManager | null = null;
private ws: WebSocket | null = null;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private reconnectAttempts = 0;
private activeChatId: number | null = null;
private readonly messageHandlers = new Set<MessageHandler>();
private readonly statusHandlers = new Set<StatusHandler>();
private isConnecting = false;
private shouldReconnect = false;
private connectionGeneration = 0;
private lastServerMessageAt = 0;
private constructor() {
if (typeof window === "undefined") return;
window.addEventListener(AUTH_SESSION_EVENT, (event) => {
const detail = (event as CustomEvent<AuthSessionEventDetail>).detail;
if (detail?.type === "session-cleared") {
this.disconnect();
} else if (
detail?.type === "token-refreshed" &&
!this.ws &&
this.hasSubscribers()
) {
this.reconnectAttempts = 0;
this.connect();
}
});
window.addEventListener("storage", (event) => {
if (event.key !== "access_token") return;
if (!event.newValue) this.disconnect();
else if (!this.ws && this.hasSubscribers()) this.connectImmediately();
});
window.addEventListener("online", () => this.connectImmediately());
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") this.connectImmediately();
});
}
}
static getInstance(): WebSocketManager {
if (!WebSocketManager.instance) {
WebSocketManager.instance = new WebSocketManager();
}
return WebSocketManager.instance;
}
private hasSubscribers(): boolean {
return this.messageHandlers.size > 0 || this.statusHandlers.size > 0;
}
private getConnectionStatus(): ConnectionStatus {
return {
isConnected: this.ws?.readyState === WebSocket.OPEN,
isConnecting: this.isConnecting,
reconnectAttempts: this.reconnectAttempts,
maxReconnectAttempts: RAPID_RECONNECT_LIMIT,
};
}
private notifyStatus() {
const status = this.getConnectionStatus();
this.statusHandlers.forEach((handler) => {
try {
handler(status);
} catch (error) {
console.error("[WS Manager] Status handler error:", error);
}
});
}
send(payload: Record<string, unknown>): boolean {
if (this.ws?.readyState !== WebSocket.OPEN) return false;
this.ws.send(JSON.stringify(payload));
return true;
}
private syncActiveChatOnSocket() {
if (this.activeChatId === null) return;
this.send({
action: "open_chat",
chat_id: this.activeChatId,
load_history: false,
});
}
private async requestTicket(): Promise<string> {
const response = await apiClient.request<WebSocketTicketResponse>(
"/api/auth/ws-ticket/",
{ method: "POST" },
);
if (!response.ticket) throw new Error("WebSocket ticket is missing");
return response.ticket;
}
connect() {
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) return;
if (!this.hasSubscribers()) {
this.shouldReconnect = false;
return;
}
if (
typeof window === "undefined" ||
!localStorage.getItem("access_token")
) {
this.notifyStatus();
return;
}
this.clearReconnectTimer();
this.shouldReconnect = true;
this.isConnecting = true;
const generation = ++this.connectionGeneration;
this.notifyStatus();
void this.openConnection(generation);
}
private async openConnection(generation: number) {
try {
const ticket = await this.requestTicket();
if (
generation !== this.connectionGeneration ||
!this.shouldReconnect ||
!this.hasSubscribers()
)
return;
const baseUrl = getWebSocketUrl();
const wsUrl = `${baseUrl}${baseUrl.includes("?") ? "&" : "?"}ticket=${encodeURIComponent(ticket)}`;
const socket = new WebSocket(wsUrl);
this.ws = socket;
socket.onopen = () => {
if (this.ws !== socket) return;
this.isConnecting = false;
this.reconnectAttempts = 0;
this.lastServerMessageAt = Date.now();
this.startHeartbeatWatchdog(socket);
this.syncActiveChatOnSocket();
this.notifyStatus();
};
socket.onmessage = (event) => {
if (this.ws !== socket) return;
this.lastServerMessageAt = Date.now();
try {
const data = JSON.parse(event.data) as RealtimeEvent;
if (data.type === "ping") {
this.send({ action: "pong", timestamp: data.timestamp });
return;
}
this.messageHandlers.forEach((handler) => {
try {
handler(data);
} catch (error) {
console.error("[WS Manager] Handler error:", error);
}
});
} catch (error) {
console.error("[WS Manager] Parse error:", error);
}
};
socket.onerror = () => {
if (this.ws !== socket) return;
this.isConnecting = false;
this.notifyStatus();
};
socket.onclose = () => {
if (this.ws !== socket) return;
this.stopHeartbeatWatchdog();
this.ws = null;
this.isConnecting = false;
this.notifyStatus();
this.scheduleReconnect();
};
} catch (error) {
if (generation !== this.connectionGeneration) return;
console.error("[WS Manager] Connection preparation failed:", error);
this.isConnecting = false;
this.notifyStatus();
this.scheduleReconnect();
}
}
private scheduleReconnect() {
if (
!this.shouldReconnect ||
!this.hasSubscribers() ||
this.reconnectTimeout
)
return;
this.reconnectAttempts += 1;
this.notifyStatus();
const exponentialAttempt = Math.min(
this.reconnectAttempts,
RAPID_RECONNECT_LIMIT,
);
const baseDelay =
this.reconnectAttempts > RAPID_RECONNECT_LIMIT
? MAX_RECONNECT_DELAY_MS
: Math.min(1000 * 2 ** exponentialAttempt, 30_000);
const jitteredDelay = Math.round(baseDelay * (0.8 + Math.random() * 0.4));
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
this.connect();
}, jitteredDelay);
}
private connectImmediately() {
if (
!this.shouldReconnect ||
!this.hasSubscribers() ||
this.ws ||
this.isConnecting
)
return;
this.clearReconnectTimer();
this.reconnectAttempts = 0;
this.connect();
}
private startHeartbeatWatchdog(socket: WebSocket) {
this.stopHeartbeatWatchdog();
this.heartbeatTimer = setInterval(() => {
if (
this.ws === socket &&
socket.readyState === WebSocket.OPEN &&
Date.now() - this.lastServerMessageAt > HEARTBEAT_TIMEOUT_MS
) {
socket.close(4408, "Heartbeat timeout");
}
}, HEARTBEAT_CHECK_MS);
}
private stopHeartbeatWatchdog() {
if (!this.heartbeatTimer) return;
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
private clearReconnectTimer() {
if (!this.reconnectTimeout) return;
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
disconnect() {
this.shouldReconnect = false;
this.connectionGeneration += 1;
this.clearReconnectTimer();
this.stopHeartbeatWatchdog();
const socket = this.ws;
this.ws = null;
if (socket) {
socket.onclose = null;
socket.close(1000, "Client disconnect");
}
this.isConnecting = false;
this.reconnectAttempts = 0;
this.notifyStatus();
}
setActiveChat(chatId: number) {
if (!Number.isFinite(chatId) || chatId <= 0 || this.activeChatId === chatId)
return;
const previousChatId = this.activeChatId;
this.activeChatId = chatId;
if (previousChatId !== null) {
this.send({ action: "close_chat", chat_id: previousChatId });
}
this.syncActiveChatOnSocket();
}
clearActiveChat(chatId?: number) {
if (this.activeChatId === null) return;
if (typeof chatId === "number" && this.activeChatId !== chatId) return;
const chatToClose = this.activeChatId;
this.activeChatId = null;
this.send({ action: "close_chat", chat_id: chatToClose });
}
subscribe(handler: MessageHandler) {
this.messageHandlers.add(handler);
this.shouldReconnect = true;
if (this.hasSubscribers() && !this.ws && !this.isConnecting) this.connect();
return () => {
this.messageHandlers.delete(handler);
if (!this.hasSubscribers()) this.disconnect();
};
}
subscribeStatus(handler: StatusHandler) {
this.statusHandlers.add(handler);
handler(this.getConnectionStatus());
this.shouldReconnect = true;
if (this.hasSubscribers() && !this.ws && !this.isConnecting) this.connect();
return () => {
this.statusHandlers.delete(handler);
if (!this.hasSubscribers()) this.disconnect();
};
}
getState(): number {
return this.ws?.readyState ?? 3;
}
}
export default WebSocketManager.getInstance();