Selaa lähdekoodia

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.
MHSanaei 1 päivä sitten
vanhempi
sitoutus
58b88800b4

+ 9 - 0
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;
+}

+ 2 - 17
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;

+ 2 - 17
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<string, Handler>) {
   useEffect(() => {
-    const client = getSharedClient();
+    const client = getSharedWebSocketClient();
     const entries = Object.entries(handlers);
     for (const [event, fn] of entries) client.on(event, fn);
     client.connect();

+ 49 - 0
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 }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+
+    renderHook(() => useWebSocketBridge(), { wrapper });
+    renderHook(() => useWebSocket({ traffic: () => {} }));
+
+    expect(socketCount).toBe(1);
+  });
+});