From 58b88800b48b40018bcd5fb398c0a5c44d2a7c50 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 15 Jul 2026 05:15:56 +0200 Subject: [PATCH] fix(frontend): share one WebSocket connection across bridge and hooks websocketBridge.ts and useWebSocket.ts each declared their own module-scoped sharedClient plus an identical getSharedClient, so the "shared" client was not shared between them: whenever a page using useWebSocket (Clients/Inbounds) mounted alongside the always-mounted bridge, the panel opened two sockets to /ws. The server then pushed every traffic/stats/nodes/inbounds snapshot to both, doubling WebSocket bandwidth and running two independent reconnect loops, and the hook's socket was never disconnected on unmount. Hoist a single getSharedWebSocketClient into api/websocket.ts and route both the bridge and the hook through it, so exactly one connection is opened. --- frontend/src/api/websocket.ts | 9 ++++ frontend/src/api/websocketBridge.ts | 19 +------ frontend/src/hooks/useWebSocket.ts | 19 +------ .../src/test/websocket-shared-client.test.tsx | 49 +++++++++++++++++++ 4 files changed, 62 insertions(+), 34 deletions(-) create mode 100644 frontend/src/test/websocket-shared-client.test.tsx diff --git a/frontend/src/api/websocket.ts b/frontend/src/api/websocket.ts index 3db7cdfa6..a8459b50a 100644 --- a/frontend/src/api/websocket.ts +++ b/frontend/src/api/websocket.ts @@ -190,3 +190,12 @@ export class WebSocketClient { } } } + +let sharedClient: WebSocketClient | null = null; + +export function getSharedWebSocketClient(): WebSocketClient { + if (sharedClient) return sharedClient; + const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || ''; + sharedClient = new WebSocketClient(basePath); + return sharedClient; +} diff --git a/frontend/src/api/websocketBridge.ts b/frontend/src/api/websocketBridge.ts index b6d942249..21d8f8f7f 100644 --- a/frontend/src/api/websocketBridge.ts +++ b/frontend/src/api/websocketBridge.ts @@ -1,34 +1,19 @@ import { useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { WebSocketClient } from '@/api/websocket'; +import { getSharedWebSocketClient } from '@/api/websocket'; import { keys } from '@/api/queryKeys'; import { isRecentLocalInvalidate } from '@/api/invalidationTracker'; type Handler = (payload: unknown) => void; -interface SharedClient { - connect(): void; - on(event: string, fn: Handler): void; - off(event: string, fn: Handler): void; -} - -let sharedClient: SharedClient | null = null; - -function getSharedClient(): SharedClient { - if (sharedClient) return sharedClient; - const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || ''; - sharedClient = new WebSocketClient(basePath) as SharedClient; - return sharedClient; -} - let invalidateTimer: number | null = null; export function useWebSocketBridge() { const queryClient = useQueryClient(); useEffect(() => { - const client = getSharedClient(); + const client = getSharedWebSocketClient(); const onInvalidate: Handler = (payload) => { const p = payload as { type?: string } | undefined; diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 4a5e036e9..1eccb41d4 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,26 +1,11 @@ import { useEffect } from 'react'; -import { WebSocketClient } from '@/api/websocket'; +import { getSharedWebSocketClient } from '@/api/websocket'; type Handler = (payload: unknown) => void; -interface SharedClient { - connect(): void; - on(event: string, fn: Handler): void; - off(event: string, fn: Handler): void; -} - -let sharedClient: SharedClient | null = null; - -function getSharedClient(): SharedClient { - if (sharedClient) return sharedClient; - const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || ''; - sharedClient = new WebSocketClient(basePath) as SharedClient; - return sharedClient; -} - export function useWebSocket(handlers: Record) { useEffect(() => { - const client = getSharedClient(); + const client = getSharedWebSocketClient(); const entries = Object.entries(handlers); for (const [event, fn] of entries) client.on(event, fn); client.connect(); diff --git a/frontend/src/test/websocket-shared-client.test.tsx b/frontend/src/test/websocket-shared-client.test.tsx new file mode 100644 index 000000000..81f9a5497 --- /dev/null +++ b/frontend/src/test/websocket-shared-client.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +import { useWebSocket } from '@/hooks/useWebSocket'; +import { useWebSocketBridge } from '@/api/websocketBridge'; + +describe('shared WebSocket connection', () => { + let socketCount = 0; + let originalWS: typeof WebSocket; + + beforeEach(() => { + socketCount = 0; + originalWS = globalThis.WebSocket; + class FakeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + readyState = FakeWebSocket.CONNECTING; + constructor() { + socketCount += 1; + } + addEventListener() {} + removeEventListener() {} + close() {} + send() {} + } + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; + }); + + afterEach(() => { + globalThis.WebSocket = originalWS; + vi.restoreAllMocks(); + }); + + it('opens a single socket when the bridge and a page hook are both mounted', () => { + const queryClient = new QueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + renderHook(() => useWebSocketBridge(), { wrapper }); + renderHook(() => useWebSocket({ traffic: () => {} })); + + expect(socketCount).toBe(1); + }); +});