.
This commit is contained in:
435
app/store/gateway-websocket.ts
Normal file
435
app/store/gateway-websocket.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user