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.
This commit is contained in:
MHSanaei
2026-07-15 05:15:56 +02:00
parent ab418a47be
commit 58b88800b4
4 changed files with 62 additions and 34 deletions
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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();
@@ -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);
});
});