Files
claudecodeui/src/contexts/WebSocketContext.tsx
Haileyesus e7d6c40452 Refactor WebSocket context + centralize platform flag (#363)
* fix: remove unnecessary websocket.js file and replace its usage directly in `WebSocketContext`

* fix: connect() doesn't need to be async

* fix: update WebSocket context import to use useWebSocket hook

* fix: use `useRef` for WebSocketContext

The main issue with using states was, previously the websocket never closed
properly on unmount, so multiple connections could be opened.

This was because the useEffect cleanup function was closing an old websocket
(that was initialized to null) instead of the current one.

We could have fixed this by adding `ws` to the useEffect dependency array, but
this was unnecessary since `ws` doesn't affect rendering so we shouldn't use a state.

* fix: replace `WebSocketContext` default value with null and add type definitions

* fix: add type definition for WebSocket URL and remove redundant protocol declaration

* fix: Prevent WebSocket reconnection attempts after unmount

Right now, when the WebSocketContext component unmounts,
there is still a pending reconnection attempt that tries
to reconnect the WebSocket after 3 seconds.

* refactor: Extract WebSocket URL construction into a separate function

* refactor: Centralize platform mode detection using IS_PLATFORM constant; use `token` from Auth context in WebSocket connection

* refactor: Use IS_PLATFORM constant for platform detection in authenticatedFetch function (backend)

* refactor: move IS_PLATFORM to config file for both frontend and backend

The reason we couldn't place it in shared/modelConstants.js is that the
frontend uses Vite which requires import.meta.env for environment variables,
while the backend uses process.env. Therefore, we created separate config files
for the frontend (src/constants/config.ts) and backend (server/constants/config.js).

* refactor: update import path for IS_PLATFORM constant to use config file

* refactor: replace `messages` with `latestMessage` in WebSocket context and related components

Why?
Because, messages was only being used to access the latest message in the components it's used in.

* refactor: optimize WebSocket connection handling with useCallback and useMemo

* refactor: comment out debug log for render count in AppContent component

* refactor(backend): update environment variable handling and replace VITE_IS_PLATFORM with IS_PLATFORM constant

* refactor: update WebSocket connection effect to depend on token changes for reconnection

---------
2026-02-03 10:05:15 +01:00

126 lines
3.8 KiB
TypeScript

import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from './AuthContext';
import { IS_PLATFORM } from '../constants/config';
type WebSocketContextType = {
ws: WebSocket | null;
sendMessage: (message: any) => void;
latestMessage: any | null;
isConnected: boolean;
};
const WebSocketContext = createContext<WebSocketContextType | null>(null);
export const useWebSocket = () => {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error('useWebSocket must be used within a WebSocketProvider');
}
return context;
};
const buildWebSocketUrl = (token: string | null) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
if (IS_PLATFORM) return `${protocol}//${window.location.host}/ws`; // Platform mode: Use same domain as the page (goes through proxy)
if (!token) return null;
return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; // OSS mode: Use same host:port that served the page
};
const useWebSocketProviderState = (): WebSocketContextType => {
const wsRef = useRef<WebSocket | null>(null);
const unmountedRef = useRef(false); // Track if component is unmounted
const [latestMessage, setLatestMessage] = useState<any>(null);
const [isConnected, setIsConnected] = useState(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { token } = useAuth();
useEffect(() => {
connect();
return () => {
unmountedRef.current = true;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [token]); // everytime token changes, we reconnect
const connect = useCallback(() => {
if (unmountedRef.current) return; // Prevent connection if unmounted
try {
// Construct WebSocket URL
const wsUrl = buildWebSocketUrl(token);
if (!wsUrl) return console.warn('No authentication token found for WebSocket connection');
const websocket = new WebSocket(wsUrl);
websocket.onopen = () => {
setIsConnected(true);
wsRef.current = websocket;
};
websocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setLatestMessage(data);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
websocket.onclose = () => {
setIsConnected(false);
wsRef.current = null;
// Attempt to reconnect after 3 seconds
reconnectTimeoutRef.current = setTimeout(() => {
if (unmountedRef.current) return; // Prevent reconnection if unmounted
connect();
}, 3000);
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (error) {
console.error('Error creating WebSocket connection:', error);
}
}, [token]); // everytime token changes, we reconnect
const sendMessage = useCallback((message: any) => {
const socket = wsRef.current;
if (socket && isConnected) {
socket.send(JSON.stringify(message));
} else {
console.warn('WebSocket not connected');
}
}, [isConnected]);
const value: WebSocketContextType = useMemo(() =>
({
ws: wsRef.current,
sendMessage,
latestMessage,
isConnected
}), [sendMessage, latestMessage, isConnected]);
return value;
};
export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => {
const webSocketData = useWebSocketProviderState();
return (
<WebSocketContext.Provider value={webSocketData}>
{children}
</WebSocketContext.Provider>
);
};
export default WebSocketContext;