tahamajs/IE / Projects /front /src /context /WebSocketContext.tsx
tahamajs's picture
download
raw
23 kB
// src/context/WebSocketContext.tsx
import { createContext, useContext, useEffect, useState, useCallback, useRef, ReactNode } from "react";
import { useAuth } from "@/context/AuthContext";
// ============================================
// Types
// ============================================
export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "error";
export interface WebSocketMessage {
type: string;
payload?: any;
id?: string;
timestamp?: number;
expectsResponse?: boolean;
[key: string]: any;
}
export interface QueuedMessage {
id: string;
message: WebSocketMessage;
retries: number;
timestamp: number;
}
interface WebSocketContextValue {
connectionStatus: ConnectionStatus;
isConnected: boolean;
sendMessage: (message: WebSocketMessage) => void;
sendMessageSync: (message: WebSocketMessage) => Promise<any>;
subscribe: (eventType: string, callback: (data: any) => void) => () => void;
unsubscribe: (eventType: string, callback: (data: any) => void) => void;
lastMessage: WebSocketMessage | null;
lastPing: number | null;
reconnect: () => void;
getConnectionStatus: () => ConnectionStatus;
pingLatency: number | null;
}
// ============================================
// Constants
// ============================================
const MAX_RETRIES = 5;
const BASE_RETRY_DELAY = 1000;
const MAX_RETRY_DELAY = 30000;
const HEARTBEAT_INTERVAL = 30000;
const SERVER_HEARTBEAT_TIMEOUT = 45000; // 45 seconds
const CONNECTION_TIMEOUT = 15000; // 15 seconds
const MESSAGE_QUEUE_KEY = "ws_offline_queue";
const MAX_QUEUE_AGE = 24 * 60 * 60 * 1000;
// ============================================
// Singleton WebSocket Manager
// ============================================
let globalSocket: WebSocket | null = null;
let globalStatus: ConnectionStatus = "disconnected";
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let connectionTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
let heartbeatTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
let isConnecting = false;
let currentUser: any = null;
let reconnectAttempts = 0;
let lastPongTime = 0;
let lastPingSentTime = 0;
let intentionalClose = false;
let pendingPingCount = 0;
// Event listeners
const eventListeners = new Map<string, Set<(data: any) => void>>();
const messageQueue: QueuedMessage[] = [];
let isProcessingQueue = false;
// ============================================
// Helper Functions
// ============================================
/**
* Emit an event to all registered listeners
*/
const emitEvent = (type: string, data: any) => {
const listeners = eventListeners.get(type);
if (listeners) {
listeners.forEach((callback) => {
try {
callback(data);
} catch (err) {
console.error(`Error in event listener for "${type}":`, err);
}
});
}
const genericListeners = eventListeners.get("*");
if (genericListeners) {
genericListeners.forEach((callback) => {
try {
callback({ type, data });
} catch (err) {
console.error(`Error in generic event listener:`, err);
}
});
}
};
/**
* Load queued messages from localStorage
*/
const loadQueue = (): QueuedMessage[] => {
try {
const stored = localStorage.getItem(MESSAGE_QUEUE_KEY);
if (stored) {
const queue = JSON.parse(stored);
const now = Date.now();
return queue.filter((msg: QueuedMessage) => now - msg.timestamp < MAX_QUEUE_AGE);
}
} catch (e) {
console.error("Failed to load message queue:", e);
}
return [];
};
/**
* Save queued messages to localStorage
*/
const saveQueue = () => {
try {
localStorage.setItem(MESSAGE_QUEUE_KEY, JSON.stringify(messageQueue));
} catch (e) {
console.error("Failed to save message queue:", e);
}
};
/**
* Get auth token from localStorage
*/
const getAuthToken = (): string | null => {
try {
return localStorage.getItem("auth_token");
} catch {
return null;
}
};
/**
* Clear all heartbeat-related timers
*/
const clearHeartbeat = () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (heartbeatTimeoutTimer) {
clearTimeout(heartbeatTimeoutTimer);
heartbeatTimeoutTimer = null;
}
};
/**
* Start heartbeat with improved timeout handling
*/
const startHeartbeat = (ws: WebSocket) => {
clearHeartbeat();
lastPongTime = Date.now();
pendingPingCount = 0;
heartbeatInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
try {
lastPingSentTime = Date.now();
ws.send(JSON.stringify({ type: "ping", timestamp: lastPingSentTime }));
pendingPingCount++;
// Clear existing heartbeat timeout
if (heartbeatTimeoutTimer) {
clearTimeout(heartbeatTimeoutTimer);
heartbeatTimeoutTimer = null;
}
// Set a timeout to check for pong response
heartbeatTimeoutTimer = setTimeout(() => {
const timeSinceLastPong = Date.now() - lastPongTime;
if (timeSinceLastPong > SERVER_HEARTBEAT_TIMEOUT) {
console.warn(`⚠️ Heartbeat timeout — no pong received for ${timeSinceLastPong}ms`);
// Try one more ping before giving up
if (pendingPingCount < 3) {
try {
ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
pendingPingCount++;
// Give it another 5 seconds
setTimeout(() => {
if (Date.now() - lastPongTime > SERVER_HEARTBEAT_TIMEOUT + 5000) {
console.warn("❌ Heartbeat failed after retry — closing connection");
clearHeartbeat();
intentionalClose = true;
try {
ws.close(4001, "Heartbeat timeout");
} catch (e) {
// Ignore close errors
}
}
}, 5000);
} catch (e) {
console.warn("❌ Failed to send retry ping:", e);
clearHeartbeat();
intentionalClose = true;
try {
ws.close(4001, "Heartbeat timeout");
} catch (err) {
// Ignore close errors
}
}
} else {
console.warn("❌ Multiple heartbeat failures — closing connection");
clearHeartbeat();
intentionalClose = true;
try {
ws.close(4001, "Heartbeat timeout");
} catch (e) {
// Ignore close errors
}
}
}
}, SERVER_HEARTBEAT_TIMEOUT);
} catch (err) {
console.warn("⚠️ Failed to send heartbeat ping:", err);
clearHeartbeat();
intentionalClose = true;
try {
ws.close(4001, "Heartbeat send failed");
} catch (e) {
// Ignore close errors
}
}
} else {
clearHeartbeat();
}
}, HEARTBEAT_INTERVAL);
};
/**
* Get WebSocket URL with fallback
*/
const getWebSocketUrl = (): string => {
const customUrl = import.meta.env.VITE_WS_URL;
const isLanHost =
typeof window !== "undefined" &&
!["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
if (customUrl && !(isLanHost && customUrl.includes("localhost"))) {
return customUrl;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.hostname;
const port = "9093";
return `${protocol}//${host}:${port}/ws`;
};
/**
* Sanitize WebSocket URL for logging (remove token)
*/
const sanitizeWebSocketUrl = (url: string) => {
try {
const parsed = new URL(url);
if (parsed.searchParams.has("token")) {
parsed.searchParams.set("token", "[TOKEN]");
}
return parsed.toString();
} catch {
return url.replace(/([?&]token=)[^&]+/i, "$1[TOKEN]");
}
};
/**
* Schedule reconnect with exponential backoff
*/
const scheduleReconnect = () => {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (!currentUser) return;
const delay = Math.min(
BASE_RETRY_DELAY * Math.pow(1.5, reconnectAttempts),
MAX_RETRY_DELAY
);
reconnectAttempts++;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (!globalSocket || globalSocket.readyState !== WebSocket.OPEN) {
if (reconnectAttempts <= MAX_RETRIES) {
connect();
} else {
console.warn("❌ Max reconnect attempts reached");
globalStatus = "error";
emitEvent("connection", { status: "max_retries_reached" });
}
}
}, delay);
};
/**
* Process queued messages
*/
const processQueue = async () => {
if (isProcessingQueue) return;
if (globalStatus !== "connected" || messageQueue.length === 0) return;
if (!globalSocket || globalSocket.readyState !== WebSocket.OPEN) return;
isProcessingQueue = true;
try {
const toRemove: string[] = [];
for (const queued of messageQueue) {
if (globalSocket.readyState === WebSocket.OPEN) {
try {
globalSocket.send(JSON.stringify(queued.message));
toRemove.push(queued.id);
await new Promise(resolve => setTimeout(resolve, 50));
} catch (err) {
console.warn(`Failed to send queued message ${queued.id}:`, err);
queued.retries++;
if (queued.retries >= MAX_RETRIES) {
toRemove.push(queued.id);
emitEvent("message_failed", { id: queued.id, message: queued.message });
}
}
}
}
for (const id of toRemove) {
const idx = messageQueue.findIndex((m) => m.id === id);
if (idx !== -1) messageQueue.splice(idx, 1);
}
saveQueue();
} finally {
isProcessingQueue = false;
if (messageQueue.length > 0 && globalStatus === "connected") {
setTimeout(processQueue, 200);
}
}
};
/**
* Connect WebSocket
*/
const connect = () => {
if (!currentUser) {
return;
}
if (globalSocket?.readyState === WebSocket.OPEN || isConnecting) {
return;
}
// Clean up any existing connection
if (globalSocket) {
try {
intentionalClose = true;
globalSocket.close(1000, "Reconnecting");
} catch (e) {
// Ignore
}
globalSocket = null;
}
isConnecting = true;
globalStatus = "connecting";
intentionalClose = false;
let url = getWebSocketUrl();
const token = getAuthToken();
if (token) {
const separator = url.includes('?') ? '&' : '?';
url = `${url}${separator}token=${encodeURIComponent(token)}`;
}
const ws = new WebSocket(url);
globalSocket = ws;
// Connection timeout
if (connectionTimeoutTimer) {
clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = null;
}
connectionTimeoutTimer = setTimeout(() => {
if (globalStatus === "connecting") {
console.warn("⏰ WebSocket connection timeout");
intentionalClose = true;
try {
ws.close(4000, "Connection timeout");
} catch (e) {
// Ignore
}
globalStatus = "error";
isConnecting = false;
emitEvent("connection", { status: "error", reason: "timeout" });
scheduleReconnect();
}
}, CONNECTION_TIMEOUT);
// WebSocket event handlers
ws.onopen = () => {
if (connectionTimeoutTimer) {
clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = null;
}
isConnecting = false;
globalStatus = "connected";
reconnectAttempts = 0;
lastPongTime = Date.now();
pendingPingCount = 0;
startHeartbeat(ws);
emitEvent("connection", { status: "connected" });
setTimeout(processQueue, 100);
};
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
// Handle pong responses
if (data.type === "pong") {
lastPongTime = Date.now();
const lat = lastPingSentTime > 0 ? (Date.now() - lastPingSentTime) : null;
emitEvent("ping_latency", lat);
pendingPingCount = Math.max(0, pendingPingCount - 1);
// Clear heartbeat timeout on successful pong
if (heartbeatTimeoutTimer) {
clearTimeout(heartbeatTimeoutTimer);
heartbeatTimeoutTimer = null;
}
return;
}
// Handle server ping requests
if (data.type === "ping") {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "pong", timestamp: Date.now() }));
}
return;
}
// Handle errors from server
if (data.type === "error") {
console.error("⚠️ Server error:", data.payload);
emitEvent("server_error", data.payload);
return;
}
// Emit all other messages to listeners
emitEvent(data.type, data.payload || data);
} catch (err) {
console.error("❌ Failed to parse WebSocket message:", err);
}
};
ws.onclose = (e) => {
if (connectionTimeoutTimer) {
clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = null;
}
clearHeartbeat();
globalStatus = "disconnected";
isConnecting = false;
emitEvent("connection", {
status: "disconnected",
code: e.code,
reason: e.reason || "Unknown"
});
// Only reconnect if not intentional and not normal closure
if (!intentionalClose && e.code !== 1000 && e.code !== 1001) {
scheduleReconnect();
}
};
ws.onerror = (err) => {
console.error("⚠️ WebSocket error:", err);
globalStatus = "error";
emitEvent("error", { type: "websocket_error", error: err });
};
};
/**
* Disconnect WebSocket
*/
const disconnect = () => {
intentionalClose = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (connectionTimeoutTimer) {
clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = null;
}
clearHeartbeat();
if (globalSocket) {
try {
globalSocket.close(1000, "Manual disconnect");
} catch (e) {
// Ignore
}
globalSocket = null;
}
isConnecting = false;
globalStatus = "disconnected";
reconnectAttempts = 0;
};
// ============================================
// React Context
// ============================================
const WebSocketContext = createContext<WebSocketContextValue | null>(null);
export function WebSocketProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const [status, setStatus] = useState<ConnectionStatus>(globalStatus);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const [pingLatency, setPingLatency] = useState<number | null>(null);
const statusRef = useRef(globalStatus);
const mountedRef = useRef(true);
// ============================================
// Subscribe to events
// ============================================
const subscribe = useCallback((eventType: string, callback: (data: any) => void) => {
if (!eventListeners.has(eventType)) {
eventListeners.set(eventType, new Set());
}
eventListeners.get(eventType)!.add(callback);
return () => {
const listeners = eventListeners.get(eventType);
if (listeners) {
listeners.delete(callback);
if (listeners.size === 0) {
eventListeners.delete(eventType);
}
}
};
}, []);
// ============================================
// Unsubscribe from events
// ============================================
const unsubscribe = useCallback((eventType: string, callback: (data: any) => void) => {
const listeners = eventListeners.get(eventType);
if (listeners) {
listeners.delete(callback);
if (listeners.size === 0) {
eventListeners.delete(eventType);
}
}
}, []);
// ============================================
// Load saved queue on mount
// ============================================
useEffect(() => {
const savedQueue = loadQueue();
if (savedQueue.length > 0) {
const existingIds = new Set(messageQueue.map((m) => m.id));
for (const msg of savedQueue) {
if (!existingIds.has(msg.id)) {
messageQueue.push(msg);
}
}
saveQueue();
}
}, []);
// ============================================
// Connect/Disconnect on user change
// ============================================
useEffect(() => {
currentUser = user;
if (user) {
reconnectAttempts = 0;
connect();
} else {
disconnect();
}
return () => {
mountedRef.current = false;
};
}, [user]);
// ============================================
// Sync status to React state
// ============================================
useEffect(() => {
const interval = setInterval(() => {
if (mountedRef.current && statusRef.current !== globalStatus) {
statusRef.current = globalStatus;
setStatus(globalStatus);
}
}, 250);
return () => clearInterval(interval);
}, []);
// Subscribe to ping latency changes
useEffect(() => {
const unsubPing = subscribe("ping_latency", (lat: any) => {
if (mountedRef.current) {
setPingLatency(lat);
}
});
return unsubPing;
}, [subscribe]);
// ============================================
// Last message listener
// ============================================
useEffect(() => {
const unsubscribeGlobal = subscribe("*", (data: any) => {
if (mountedRef.current) {
setLastMessage({
type: data.type || "unknown",
payload: data.data || data,
timestamp: Date.now(),
});
}
});
return unsubscribeGlobal;
}, [subscribe]);
// ============================================
// Process queue on connection
// ============================================
useEffect(() => {
if (status === "connected" && messageQueue.length > 0) {
processQueue();
}
}, [status]);
// ============================================
// Queue message
// ============================================
const queueMessage = useCallback((message: WebSocketMessage) => {
const queued: QueuedMessage = {
id: message.id || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
message,
retries: 0,
timestamp: Date.now(),
};
const existingIdx = messageQueue.findIndex((m) => m.id === queued.id);
if (existingIdx !== -1) {
messageQueue[existingIdx] = queued;
} else {
messageQueue.push(queued);
}
saveQueue();
emitEvent("message_queued", queued);
}, []);
// ============================================
// Send Message
// ============================================
const sendMessage = useCallback((message: WebSocketMessage) => {
const messageWithId: WebSocketMessage = {
...message,
id: message.id || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
timestamp: message.timestamp || Date.now(),
};
if (globalSocket?.readyState === WebSocket.OPEN) {
try {
globalSocket.send(JSON.stringify(messageWithId));
} catch (err) {
console.error("❌ Send error:", err);
queueMessage(messageWithId);
}
} else {
queueMessage(messageWithId);
if (globalStatus !== "connecting" && currentUser) {
connect();
}
}
}, [queueMessage]);
// ============================================
// Send Message Sync (with response)
// ============================================
const sendMessageSync = useCallback((message: WebSocketMessage): Promise<any> => {
return new Promise((resolve, reject) => {
const messageId = message.id || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let unsubFn: (() => void) | null = null;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (unsubFn) {
unsubFn();
unsubFn = null;
}
};
timeoutId = setTimeout(() => {
cleanup();
reject(new Error(`Response timeout for message ${messageId}`));
}, 30000);
const handler = (response: any) => {
// Check if this response matches our request
if (
response.id === messageId ||
response.inReplyTo === messageId ||
response.payload?.id === messageId ||
response.messageId === messageId
) {
cleanup();
resolve(response);
}
};
unsubFn = subscribe("response", handler);
// Also subscribe to error responses
const errorUnsub = subscribe("error_response", (errorData: any) => {
if (errorData.inReplyTo === messageId || errorData.messageId === messageId) {
cleanup();
reject(new Error(errorData.error || "Request failed"));
}
});
// Store both unsubscriptions
const originalUnsub = unsubFn;
unsubFn = () => {
originalUnsub();
errorUnsub();
};
sendMessage({ ...message, id: messageId, expectsResponse: true });
});
}, [sendMessage, subscribe]);
// ============================================
// Reconnect
// ============================================
const reconnect = useCallback(() => {
disconnect();
reconnectAttempts = 0;
setTimeout(() => connect(), 200);
}, []);
// ============================================
// Get Connection Status
// ============================================
const getConnectionStatus = useCallback(() => globalStatus, []);
// ============================================
// Cleanup on unmount
// ============================================
useEffect(() => {
return () => {
mountedRef.current = false;
// Don't disconnect here - the singleton should persist
};
}, []);
// ============================================
// Value
// ============================================
const value: WebSocketContextValue = {
connectionStatus: status,
isConnected: status === "connected",
sendMessage,
sendMessageSync,
subscribe,
unsubscribe,
lastMessage,
lastPing: lastPongTime || null,
reconnect,
getConnectionStatus,
pingLatency,
};
return (
<WebSocketContext.Provider value={value}>
{children}
</WebSocketContext.Provider>
);
}
export const useWebSocket = () => {
const ctx = useContext(WebSocketContext);
if (!ctx) throw new Error("useWebSocket must be used within WebSocketProvider");
return ctx;
};

Xet Storage Details

Size:
23 kB
·
Xet hash:
3be80e9d71a99e1938cdb0a00659be98c6c1e57212f25830d4a15c39425c65f8

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.