fix: use single shared WebSocket connection and optimize build chunks

This commit is contained in:
c0mrade
2026-02-01 23:34:29 +03:00
parent e884860ab8
commit f6854c6c3a
4 changed files with 279 additions and 170 deletions

View File

@@ -0,0 +1,217 @@
import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react';
import { useAuthStore } from '../store/auth';
const isDev = import.meta.env.DEV;
export interface WSMessage {
type: string;
// Ticket events
ticket_id?: number;
title?: string;
// Common
message?: string;
user_id?: number;
is_admin?: boolean;
// Balance events
amount_kopeks?: number;
amount_rubles?: number;
new_balance_kopeks?: number;
new_balance_rubles?: number;
description?: string;
// Subscription events
expires_at?: string;
new_expires_at?: string;
tariff_name?: string;
days_left?: number;
// Device purchase events
devices_added?: number;
new_device_limit?: number;
// Traffic purchase events
traffic_gb_added?: number;
new_traffic_limit_gb?: number;
// Autopay events
required_kopeks?: number;
required_rubles?: number;
balance_kopeks?: number;
balance_rubles?: number;
reason?: string;
// Account events (ban/warning)
// Referral events
bonus_kopeks?: number;
bonus_rubles?: number;
referral_name?: string;
// Payment events
payment_method?: string;
}
type MessageHandler = (message: WSMessage) => void;
interface WebSocketContextValue {
isConnected: boolean;
subscribe: (handler: MessageHandler) => () => void;
}
const WebSocketContext = createContext<WebSocketContextValue | null>(null);
export function WebSocketProvider({ children }: { children: React.ReactNode }) {
const { accessToken, isAuthenticated } = useAuthStore();
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [isConnected, setIsConnected] = useState(false);
const reconnectAttemptsRef = useRef(0);
const maxReconnectAttempts = 5;
// Store message handlers
const handlersRef = useRef<Set<MessageHandler>>(new Set());
const cleanup = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
}, []);
const connect = useCallback(() => {
if (!accessToken || !isAuthenticated) {
return;
}
// Don't reconnect if already connected
if (wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
cleanup();
// Build WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let host = window.location.host;
// Handle VITE_API_URL - can be absolute URL or relative path
const apiUrl = import.meta.env.VITE_API_URL;
if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) {
try {
host = new URL(apiUrl).host;
} catch {
// If URL parsing fails, use window.location.host
}
}
const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`;
try {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
if (isDev) console.log('[WS] Connected');
setIsConnected(true);
reconnectAttemptsRef.current = 0;
// Setup ping interval (every 25 seconds)
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 25000);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as WSMessage;
// Ignore pong messages
if (message.type === 'pong' || message.type === 'connected') {
return;
}
// Notify all subscribers
handlersRef.current.forEach((handler) => {
try {
handler(message);
} catch (e) {
if (isDev) console.error('[WS] Handler error:', e);
}
});
} catch (e) {
if (isDev) console.error('[WS] Failed to parse message:', e);
}
};
ws.onclose = (event) => {
if (isDev) console.log('[WS] Disconnected:', event.code, event.reason);
setIsConnected(false);
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Attempt to reconnect if not closed intentionally
if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
if (isDev)
console.log(
`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
}
};
ws.onerror = (error) => {
if (isDev) console.error('[WS] Error:', error);
};
} catch (e) {
if (isDev) console.error('[WS] Failed to connect:', e);
}
}, [accessToken, isAuthenticated, cleanup]);
// Connect when authenticated
useEffect(() => {
if (isAuthenticated && accessToken) {
connect();
} else {
cleanup();
setIsConnected(false);
}
return cleanup;
}, [isAuthenticated, accessToken, connect, cleanup]);
// Subscribe function for components
const subscribe = useCallback((handler: MessageHandler) => {
handlersRef.current.add(handler);
// Return unsubscribe function
return () => {
handlersRef.current.delete(handler);
};
}, []);
return (
<WebSocketContext.Provider value={{ isConnected, subscribe }}>
{children}
</WebSocketContext.Provider>
);
}
export function useWebSocketContext() {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error('useWebSocketContext must be used within a WebSocketProvider');
}
return context;
}