Files
diplom-frontend/app/store/gateway-websocket.ts
2025-05-15 05:20:01 +03:00

435 lines
21 KiB
TypeScript

import { create } from 'zustand';
import type {
ClientMessage,
EventServerPayload,
ServerMessage,
VoiceStateUpdateClientPayload,
WebSocketStatus
} from '~/lib/websocket/gateway.types'; // Adjust path as needed
import { useServerListStore } from './server-list'; // Adjust path as needed
// --- Configuration ---
const WEBSOCKET_URL = "ws://localhost:12345/gateway/ws";
const INITIAL_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 30000;
const BACKOFF_FACTOR = 2;
const MAX_RECONNECT_ATTEMPTS = 10;
let isConnectingInProgress = false;
let currentConnectCallId = 0;
function handleBusinessEvent(event: EventServerPayload) {
console.debug("WS: Received business event:", event.type, event.data);
switch (event.type) {
case "ADD_SERVER":
useServerListStore.getState().addServer(event.data.server);
break;
case "REMOVE_SERVER":
useServerListStore.getState().removeServer(event.data.serverId);
break;
// Add other event types from your application
case "VOICE_SERVER_UPDATE":
break;
default:
// This ensures all event types are handled if EventServerPayload is a strict union
const _exhaustiveCheck: never = event;
console.warn("WS: Received unhandled business event type:", _exhaustiveCheck);
return _exhaustiveCheck;
}
}
interface GatewayWebSocketState {
status: WebSocketStatus;
userId: string | null;
sessionKey: string | null;
lastError: string | null;
connect: (getToken: () => string | null | Promise<string | null>) => void;
disconnect: (intentional?: boolean) => void;
sendVoiceStateUpdate: (payload: VoiceStateUpdateClientPayload) => boolean;
}
let socket: WebSocket | null = null;
let pingIntervalId: ReturnType<typeof setInterval> | null = null;
let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempts = 0;
let getTokenFunc: (() => string | null | Promise<string | null>) | null = null;
let isIntentionalDisconnect = false;
let serverReportedError = false; // Flag: true if server sent an ERROR message
export const useGatewayWebSocketStore = create<GatewayWebSocketState>((set, get) => {
const clearPingInterval = () => {
if (pingIntervalId) clearInterval(pingIntervalId);
pingIntervalId = null;
};
const clearReconnectTimeout = () => {
if (reconnectTimeoutId) clearTimeout(reconnectTimeoutId);
reconnectTimeoutId = null;
};
const sendWebSocketMessage = (message: ClientMessage): boolean => {
if (socket?.readyState === WebSocket.OPEN) {
console.debug("WS: Sending message:", message);
socket.send(JSON.stringify(message));
return true;
}
console.warn(`WS: Cannot send ${message.type}. WebSocket not open (state: ${socket?.readyState}).`);
return false;
};
const resetConnectingFlag = () => {
if (isConnectingInProgress) {
console.debug("WS: Resetting isConnectingInProgress flag.");
isConnectingInProgress = false;
}
};
const handleOpen = async () => {
console.log("WS: Connection established. Authenticating...");
set({ status: "AUTHENTICATING", lastError: null });
if (!getTokenFunc) {
console.error("WS: Auth failed. Token getter missing.");
get().disconnect(false); // Non-intentional, treat as error
set({ lastError: "Token provider missing for authentication." });
return;
}
try {
const token = await getTokenFunc();
if (!token) {
console.error("WS: Auth failed. No token from getter.");
isIntentionalDisconnect = true; // Prevent auto-reconnect for this specific failure
get().disconnect(true);
set({ lastError: "Authentication token not available." });
return;
}
sendWebSocketMessage({ type: "AUTHENTICATE", data: { token } });
} catch (error) {
console.error("WS: Error getting token for auth:", error);
isIntentionalDisconnect = true;
get().disconnect(true);
set({ lastError: "Failed to retrieve authentication token." });
}
};
const handleMessage = async (event: MessageEvent) => {
try {
const message = JSON.parse(event.data as string) as ServerMessage;
console.debug("WS: Received message:", message);
switch (message.type) {
case "AUTHENTICATE_ACCEPTED":
resetConnectingFlag();
reconnectAttempts = 0;
clearReconnectTimeout();
set({ status: "CONNECTED", userId: message.data.userId, sessionKey: message.data.sessionKey, lastError: null });
console.log(`WS: Authenticated as user ${message.data.userId}.`);
break;
case "AUTHENTICATE_DENIED":
resetConnectingFlag();
console.warn("WS: Authentication denied by server.");
isIntentionalDisconnect = true; // Prevent auto-reconnect
serverReportedError = false; // This is an auth denial, not a runtime server error
get().disconnect(true); // Disconnect, don't retry
set({ status: "ERROR", lastError: "Authentication denied by server." });
break;
case "EVENT":
handleBusinessEvent(message.data.event);
break;
case "ERROR":
resetConnectingFlag();
console.error(`WS: Server reported error. Code: ${message.data.code}`);
serverReportedError = true; // CRITICAL: Set flag
isIntentionalDisconnect = true; // Treat as a definitive stop from server
set({
status: "ERROR",
lastError: `Server error (${message.data.code})`,
userId: null, // Clear user session on server error
sessionKey: null, // Clear session key on server error
});
// Server is expected to close the connection. `onclose` will use `serverReportedError`.
// Ping interval will be cleared by `cleanupConnection` via `onclose`.
break;
default:
const _exhaustiveCheck: never = message;
console.warn("WS: Received unknown server message type:", _exhaustiveCheck);
set({ lastError: "Received unknown message type from server." });
}
} catch (error) {
console.error("WS: Failed to parse or handle server message:", error, "Raw data:", event.data);
set({ lastError: "Failed to parse server message." });
// This is a client-side parsing error. We might want to disconnect if this happens.
// serverReportedError = true; // Consider this a fatal client error
// isIntentionalDisconnect = true;
// get().disconnect(false); // Or true depending on desired reconnect behavior
}
};
const handleError = (event: Event) => {
resetConnectingFlag();
// This event is often vague and usually followed by onclose.
console.error("WS: WebSocket error event occurred:", event);
// Avoid changing status directly if onclose will handle it, to prevent state flickering.
// But, set lastError as it might be the only indication of a problem if onclose is not detailed.
set(state => ({ lastError: state.lastError || `WebSocket error: ${event.type || 'Unknown error'}` }));
// `onclose` will call `cleanupConnection`.
};
const handleClose = (event: CloseEvent) => {
const wasInProgress = isConnectingInProgress;
resetConnectingFlag();
console.log(
`WS: Connection closed. Code: ${event.code}, Reason: "${event.reason || 'N/A'}", Clean: ${event.wasClean}, Intentional: ${isIntentionalDisconnect}, ServerError: ${serverReportedError}, WasInProgress: ${wasInProgress}`
);
const wasNormalClosure = event.code === 1000 || event.code === 1001; // 1001: Going Away
const shouldAttemptReconnect = !isIntentionalDisconnect && !serverReportedError && !wasNormalClosure;
const previousStatus = get().status;
cleanupConnection(shouldAttemptReconnect);
if (!shouldAttemptReconnect) {
console.log("WS: No reconnect attempt scheduled.");
if (serverReportedError) {
// State already set to ERROR by handleMessage or will be set by cleanup.
if (get().status !== 'ERROR') set({ status: "ERROR", userId: null, sessionKey: null }); // Ensure it
} else if (isIntentionalDisconnect && previousStatus !== "ERROR") {
set({ status: "DISCONNECTED", userId: null, sessionKey: null });
} else if (wasNormalClosure && previousStatus !== "ERROR") {
set({ status: "DISCONNECTED", userId: null, sessionKey: null });
} else if (previousStatus !== "ERROR" && previousStatus !== "DISCONNECTED" && previousStatus !== "IDLE") {
set({ status: "DISCONNECTED", userId: null, sessionKey: null, lastError: get().lastError || "Connection closed unexpectedly." });
}
}
// If shouldAttemptReconnect, cleanupConnection -> scheduleReconnect sets status to RECONNECTING.
};
const cleanupConnection = (attemptReconnect: boolean) => {
resetConnectingFlag();
console.debug(`WS: Cleaning up connection. Attempt reconnect: ${attemptReconnect}`);
clearPingInterval();
clearReconnectTimeout();
if (socket) {
socket.onopen = null; socket.onmessage = null; socket.onerror = null; socket.onclose = null;
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
try { socket.close(1000, "Client cleanup"); }
catch (e) { console.warn("WS: Error closing socket during cleanup:", e); }
}
socket = null;
}
if (attemptReconnect) {
if (!serverReportedError) { // Don't retry if server explicitly said ERROR
scheduleReconnect();
} else {
console.warn("WS: Reconnect attempt skipped due to server-reported error.");
if (get().status !== 'ERROR') {
set({ status: "ERROR", userId: null, sessionKey: null, lastError: get().lastError || "Server error, stopping." });
}
}
}
};
const scheduleReconnect = () => {
if (get().status === "ERROR" && serverReportedError) { // Double check
console.log("WS: Reconnect inhibited by server-reported error state.");
return;
}
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.warn(`WS: Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached.`);
set({ status: "ERROR", lastError: "Reconnection failed after multiple attempts.", userId: null, sessionKey: null });
return;
}
const delay = Math.min(INITIAL_RECONNECT_DELAY * Math.pow(BACKOFF_FACTOR, reconnectAttempts), MAX_RECONNECT_DELAY);
reconnectAttempts++;
console.log(`WS: Scheduling reconnect attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} in ${delay / 1000}s...`);
set(state => ({ status: "RECONNECTING", lastError: state.lastError }));
reconnectTimeoutId = setTimeout(() => {
if (get().status !== "RECONNECTING") { // Status might have changed (e.g. explicit disconnect)
console.log("WS: Reconnect attempt cancelled (status changed).");
return;
}
_connect();
}, delay);
};
const _connect = async () => { // Internal connect, used for retries and initial
const callId = currentConnectCallId;
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
console.warn(`WS (_connect [${callId}]): Called but WebSocket already open/connecting.`);
if (socket.readyState === WebSocket.OPEN && get().status === 'CONNECTED') resetConnectingFlag();
return;
}
if (isConnectingInProgress) {
console.warn(`WS (_connect [${callId}]): Aborted, another connection attempt is already in progress.`);
return;
}
// Critical: If serverReportedError is true, _connect (retry mechanism) should not proceed.
// Only an explicit call to the public `connect()` (which resets serverReportedError) should bypass this.
if (serverReportedError) {
resetConnectingFlag();
console.warn("WS: _connect aborted due to server-reported error. Explicit connect() needed to retry.");
if (get().status !== 'ERROR') set({ status: 'ERROR', lastError: get().lastError || 'Server reported an error.' });
return;
}
clearReconnectTimeout();
if (!getTokenFunc) {
console.error("WS: Cannot connect. Token getter missing.");
set({ status: "ERROR", lastError: "Token provider missing." }); // Terminal for this attempt cycle
return;
}
console.log(`WS (_connect [${callId}]): Attempting to connect to ${WEBSOCKET_URL}... (Overall Attempt ${reconnectAttempts + 1})`);
isConnectingInProgress = true;
try { // Pre-flight check for token availability
const token = await getTokenFunc();
if (!token) {
console.warn("WS: No token available. Aborting connection attempt cycle.");
set({ status: "DISCONNECTED", lastError: "Authentication token unavailable.", userId: null, sessionKey: null });
isIntentionalDisconnect = true; // Stop retrying if token is consistently unavailable
return;
}
} catch (error) {
console.error("WS: Error getting token before connecting:", error);
set({ status: "ERROR", lastError: "Failed to retrieve authentication token.", userId: null, sessionKey: null });
isIntentionalDisconnect = true; // Stop retrying for this
return;
}
console.log(`WS: Attempting to connect to ${WEBSOCKET_URL}... (Attempt ${reconnectAttempts + 1})`);
// Preserve lastError if we are RECONNECTING, otherwise clear it for a fresh CONNECTING attempt
set(state => ({ status: "CONNECTING", lastError: state.status === 'RECONNECTING' ? state.lastError : null }));
try {
socket = new WebSocket(WEBSOCKET_URL);
// Flags (isIntentionalDisconnect, serverReportedError) are reset by public `connect()`
socket.onopen = handleOpen;
socket.onmessage = handleMessage;
socket.onerror = handleError;
socket.onclose = handleClose;
} catch (error) {
console.error("WS: Failed to create WebSocket instance:", error);
set({ status: "ERROR", lastError: "Failed to initialize WebSocket connection." });
socket = null;
resetConnectingFlag();
cleanupConnection(!isIntentionalDisconnect && !serverReportedError); // Attempt reconnect if appropriate
}
};
return {
status: "IDLE",
userId: null,
sessionKey: null,
lastError: null,
connect: (newGetTokenFunc) => {
const localCallId = ++currentConnectCallId;
console.log(`WS: Explicit connect requested (Call ID: ${localCallId}). Current status: ${get().status}, InProgress: ${isConnectingInProgress}`);
// If already connected and socket is open, do nothing.
if (get().status === "CONNECTED" && socket?.readyState === WebSocket.OPEN) {
console.warn(`WS (connect [${localCallId}]): Already connected.`);
resetConnectingFlag(); // Ensure it's false if we are truly connected.
return;
}
// If a connection is actively being established by another call, log and return.
// Allow if status is IDLE/DISCONNECTED/ERROR, as these are states where a new attempt is valid.
if (isConnectingInProgress && !['IDLE', 'DISCONNECTED', 'ERROR', 'RECONNECTING'].includes(get().status)) {
console.warn(`WS (connect [${localCallId}]): Connect called while a connection attempt is already in progress and status is ${get().status}.`);
return;
}
// If status is CONNECTING/AUTHENTICATING but isConnectingInProgress is false (e.g., after a bug),
// allow cleanup and new attempt.
// Reset flags for a *new* explicit connection sequence
isIntentionalDisconnect = false;
serverReportedError = false;
reconnectAttempts = 0;
// isConnectingInProgress will be set by _connect if it proceeds.
// Resetting it here might be too early if _connect has checks that depend on previous state of isConnectingInProgress.
// Instead, ensure _connect and its outcomes (open, error, close) reliably reset it.
clearReconnectTimeout();
if (socket) { // If there's an old socket (e.g. from a failed/interrupted attempt)
console.warn(`WS (connect [${localCallId}]): Explicit connect called with existing socket (state: ${socket.readyState}). Cleaning up old one.`);
cleanupConnection(false); // This will call resetConnectingFlag()
}
// At this point, isConnectingInProgress should be false due to cleanupConnection or initial state.
getTokenFunc = newGetTokenFunc;
_connect(); // _connect will set isConnectingInProgress = true if it starts.
},
disconnect: (intentional = true) => {
const callId = currentConnectCallId; // For logging
console.log(`WS (disconnect [${callId}]): Disconnect requested. Intentional: ${intentional}, InProgress: ${isConnectingInProgress}`);
isIntentionalDisconnect = intentional;
if (intentional) {
serverReportedError = false;
}
// No matter what, a disconnect means any "connecting in progress" is now void.
resetConnectingFlag(); // Crucial: stop any perceived ongoing connection attempt.
reconnectAttempts = 0;
const currentLastError = get().lastError;
const wasServerError = serverReportedError || (currentLastError && currentLastError.startsWith("Server error"));
const wasAuthDenied = currentLastError === "Authentication denied by server.";
cleanupConnection(false); // Clean up, do not attempt reconnect
set({
status: "DISCONNECTED",
userId: null,
sessionKey: null,
// Preserve significant errors (server error, auth denied) even on user disconnect.
// Otherwise, set a generic disconnect message.
lastError: (wasServerError || wasAuthDenied) ? currentLastError : (intentional ? "User disconnected" : "Disconnected"),
});
},
sendVoiceStateUpdate: (payload: VoiceStateUpdateClientPayload) => {
return sendWebSocketMessage({ type: "VOICE_STATE_UPDATE", data: payload });
}
};
});
// Optional: Online/Offline listeners. Consider placing in a React component
// that can supply `getTokenFunc` more naturally upon reconnection.
if (typeof window !== 'undefined') {
const tryAutoConnectOnline = () => {
const { status, connect } = useGatewayWebSocketStore.getState();
console.log("WS: Browser online event detected.");
if (status === 'DISCONNECTED' || status === 'ERROR' || status === 'RECONNECTING') {
if (!serverReportedError) { // Don't auto-connect if last state was a server-reported fatal error
console.log("WS: Attempting to connect after coming online.");
if (getTokenFunc) { // Check if we still have a token getter
connect(getTokenFunc);
} else {
console.warn("WS: Cannot auto-connect on 'online': token getter not available. App needs to call connect() again.");
}
} else {
console.log("WS: Browser online, but previous server-reported error prevents auto-reconnect. Manual connect needed.");
}
}
};
window.addEventListener('online', tryAutoConnectOnline);
const handleAppClose = () => { // Best-effort for page unload
const { disconnect, status } = useGatewayWebSocketStore.getState();
if (status !== "IDLE" && status !== "DISCONNECTED") {
disconnect(true);
}
};
window.addEventListener('beforeunload', handleAppClose);
}