mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
fix: use single shared WebSocket connection and optimize build chunks
This commit is contained in:
@@ -1,48 +1,8 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useWebSocketContext, WSMessage } from '../providers/WebSocketProvider';
|
||||||
|
|
||||||
const isDev = import.meta.env.DEV;
|
// Re-export WSMessage type for convenience
|
||||||
|
export type { WSMessage };
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseWebSocketOptions {
|
interface UseWebSocketOptions {
|
||||||
onMessage?: (message: WSMessage) => void;
|
onMessage?: (message: WSMessage) => void;
|
||||||
@@ -51,141 +11,39 @@ interface UseWebSocketOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
||||||
const { accessToken, isAuthenticated } = useAuthStore();
|
const { isConnected, subscribe } = useWebSocketContext();
|
||||||
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;
|
|
||||||
const optionsRef = useRef(options);
|
const optionsRef = useRef(options);
|
||||||
|
const wasConnectedRef = useRef(false);
|
||||||
|
|
||||||
// Update options ref when they change
|
// Update options ref when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
optionsRef.current = options;
|
optionsRef.current = options;
|
||||||
}, [options]);
|
}, [options]);
|
||||||
|
|
||||||
const cleanup = useCallback(() => {
|
// Handle connection state changes
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If apiUrl is relative (like /api), 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;
|
|
||||||
optionsRef.current.onConnect?.();
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
optionsRef.current.onMessage?.(message);
|
|
||||||
} 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);
|
|
||||||
optionsRef.current.onDisconnect?.();
|
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && accessToken) {
|
if (isConnected && !wasConnectedRef.current) {
|
||||||
connect();
|
wasConnectedRef.current = true;
|
||||||
} else {
|
optionsRef.current.onConnect?.();
|
||||||
cleanup();
|
} else if (!isConnected && wasConnectedRef.current) {
|
||||||
setIsConnected(false);
|
wasConnectedRef.current = false;
|
||||||
|
optionsRef.current.onDisconnect?.();
|
||||||
|
}
|
||||||
|
}, [isConnected]);
|
||||||
|
|
||||||
|
// Subscribe to messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (!optionsRef.current.onMessage) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanup;
|
const handler = (message: WSMessage) => {
|
||||||
}, [isAuthenticated, accessToken, connect, cleanup]);
|
optionsRef.current.onMessage?.(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = subscribe(handler);
|
||||||
|
return unsubscribe;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
return { isConnected };
|
return { isConnected };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import { PlatformProvider } from './platform/PlatformProvider';
|
import { PlatformProvider } from './platform/PlatformProvider';
|
||||||
import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
|
import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
|
||||||
|
import { WebSocketProvider } from './providers/WebSocketProvider';
|
||||||
import { ToastProvider } from './components/Toast';
|
import { ToastProvider } from './components/Toast';
|
||||||
import { TooltipProvider } from './components/primitives/Tooltip';
|
import { TooltipProvider } from './components/primitives/Tooltip';
|
||||||
import { initLogoPreload } from './api/branding';
|
import { initLogoPreload } from './api/branding';
|
||||||
@@ -35,7 +36,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||||||
<ThemeColorsProvider>
|
<ThemeColorsProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<WebSocketProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</WebSocketProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ThemeColorsProvider>
|
</ThemeColorsProvider>
|
||||||
|
|||||||
217
src/providers/WebSocketProvider.tsx
Normal file
217
src/providers/WebSocketProvider.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -28,5 +28,36 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
||||||
|
'vendor-query': ['@tanstack/react-query'],
|
||||||
|
'vendor-i18n': ['i18next', 'react-i18next', 'i18next-browser-languagedetector'],
|
||||||
|
'vendor-motion': ['framer-motion'],
|
||||||
|
'vendor-radix': [
|
||||||
|
'@radix-ui/react-dialog',
|
||||||
|
'@radix-ui/react-tooltip',
|
||||||
|
'@radix-ui/react-select',
|
||||||
|
'@radix-ui/react-popover',
|
||||||
|
'@radix-ui/react-dropdown-menu',
|
||||||
|
'@radix-ui/react-tabs',
|
||||||
|
'@radix-ui/react-switch',
|
||||||
|
'@radix-ui/react-alert-dialog',
|
||||||
|
],
|
||||||
|
'vendor-dnd': ['@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/utilities'],
|
||||||
|
'vendor-telegram': ['@tma.js/sdk-react'],
|
||||||
|
'vendor-utils': [
|
||||||
|
'axios',
|
||||||
|
'zustand',
|
||||||
|
'clsx',
|
||||||
|
'tailwind-merge',
|
||||||
|
'class-variance-authority',
|
||||||
|
],
|
||||||
|
'vendor-lottie': ['@lottiefiles/dotlottie-react'],
|
||||||
|
'vendor-webgl': ['ogl'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user