From 5e9e52c8dde071290b64fb6ac5f1829d93607369 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:21:54 +0100 Subject: [PATCH] [#552] added check for ws status before write REST calls (#569) Co-authored-by: Camille Moussu --- src/utils/apiUtils.ts | 8 +++ src/websocket/WebSocketGate.tsx | 25 +++++-- .../lifecycle/assertWebSocketAlive.ts | 71 +++++++++++++++++++ src/websocket/connection/webSocketState.ts | 30 ++++++++ 4 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 src/websocket/connection/lifecycle/assertWebSocketAlive.ts create mode 100644 src/websocket/connection/webSocketState.ts diff --git a/src/utils/apiUtils.ts b/src/utils/apiUtils.ts index 1f8497b..e6f04f2 100644 --- a/src/utils/apiUtils.ts +++ b/src/utils/apiUtils.ts @@ -1,12 +1,16 @@ import { Auth } from "@/features/User/oidcAuth"; +import { assertWebSocketAlive } from "@/websocket/connection/lifecycle/assertWebSocketAlive"; import ky from "ky"; import { getRetryDelay } from "./getRetryDelay"; +const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + const RETRY_CONFIG = { maxRetries: 10, initialDelay: 1000, maxDelay: 120000, }; + export const api = ky.extend({ prefixUrl: window.CALENDAR_BASE_URL, retry: { @@ -28,6 +32,10 @@ export const api = ky.extend({ if (access_token) { request.headers.set("Authorization", `Bearer ${access_token}`); } + + if (MUTATING_METHODS.has(request.method)) { + await assertWebSocketAlive(); + } return request; }, ], diff --git a/src/websocket/WebSocketGate.tsx b/src/websocket/WebSocketGate.tsx index 2714311..c927aeb 100644 --- a/src/websocket/WebSocketGate.tsx +++ b/src/websocket/WebSocketGate.tsx @@ -7,14 +7,18 @@ import { useI18n } from "twake-i18n"; import type { WebSocketWithCleanup } from "./connection"; import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection"; import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection"; -import { useWebSocketReconnect } from "./connection/lifecycle/useWebSocketReconnect"; -import { updateCalendars } from "./messaging/updateCalendars"; -import { syncCalendarRegistrations } from "./operations"; -import { WebSocketStatusSnackbar } from "./WebSocketStatusSnackbar"; import { setupWebSocketPing, type PingCleanup, } from "./connection/lifecycle/pingWebSocket"; +import { useWebSocketReconnect } from "./connection/lifecycle/useWebSocketReconnect"; +import { + registerWebSocketState, + setWebSocketConnecting, +} from "./connection/webSocketState"; +import { updateCalendars } from "./messaging/updateCalendars"; +import { syncCalendarRegistrations } from "./operations"; +import { WebSocketStatusSnackbar } from "./WebSocketStatusSnackbar"; export function WebSocketGate() { const socketRef = useRef(null); @@ -183,6 +187,7 @@ export function WebSocketGate() { const connect = async () => { if (isConnectingRef.current || isSocketOpen) return; isConnectingRef.current = true; + setWebSocketConnecting(true); didConnectTimeoutRef.current = false; connectTimeoutRef.current = setTimeout(() => { console.warn("WebSocket connection attempt timed out"); @@ -191,6 +196,7 @@ export function WebSocketGate() { abortController.abort(); connectTimeoutRef.current = null; isConnectingRef.current = false; + setWebSocketConnecting(false); cleanup(); scheduleReconnect(); @@ -217,6 +223,7 @@ export function WebSocketGate() { } } finally { isConnectingRef.current = false; + setWebSocketConnecting(false); } }; @@ -340,6 +347,16 @@ export function WebSocketGate() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSocketOpen]); + const triggerReconnect = useCallback(() => { + reconnectAttemptsRef.current = 0; + clearReconnectTimeout(); + setShouldConnect((prev) => !prev); + }, [clearReconnectTimeout]); + + useEffect(() => { + registerWebSocketState(socketRef, triggerReconnect); + }, [triggerReconnect]); + return websocketStatus ? ( | null = null; + +function waitForSocketOpen( + socketRef: MutableRefObject +): Promise { + return new Promise((resolve, reject) => { + const deadline = Date.now() + TIMEOUT_MS; + + const poll = () => { + if (socketRef.current?.readyState === WebSocket.OPEN) return resolve(); + if (Date.now() >= deadline) + return reject(new Error("[WS] Timed out waiting for reconnection")); + setTimeout(poll, 100); + }; + + poll(); + }); +} + +export function assertWebSocketAlive(): Promise { + if (inFlightCheck) return inFlightCheck; + const { socketRef, triggerReconnect, isConnecting } = getWebSocketState(); + + // Not registered yet or mid-bootstrap — don't interfere, don't block + if (!socketRef || isConnecting || !triggerReconnect) return Promise.resolve(); + + const socket = socketRef.current; + + if (!socket || socket.readyState !== WebSocket.OPEN) { + triggerReconnect(); + return waitForSocketOpen(socketRef); + } + + const promise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + console.warn("[WS] Pong not received — triggering reconnect"); + triggerReconnect(); + waitForSocketOpen(socketRef).then(resolve, reject); + }, TIMEOUT_MS); + + const handlePong = (event: MessageEvent) => { + try { + const msg = JSON.parse(event.data); + if (msg) { + cleanup(); + resolve(); + } + } catch { + // not parseable, keep waiting + } + }; + + const cleanup = () => { + clearTimeout(timeout); + socket.removeEventListener("message", handlePong); + }; + + socket.addEventListener("message", handlePong); + socket.send(JSON.stringify({ type: "ping" })); + }).finally(() => { + inFlightCheck = null; + }); + inFlightCheck = promise; + return promise; +} diff --git a/src/websocket/connection/webSocketState.ts b/src/websocket/connection/webSocketState.ts new file mode 100644 index 0000000..17a545b --- /dev/null +++ b/src/websocket/connection/webSocketState.ts @@ -0,0 +1,30 @@ +import { type MutableRefObject } from "react"; +import type { WebSocketWithCleanup } from "./types"; + +interface WebSocketState { + socketRef: MutableRefObject | null; + triggerReconnect: (() => void) | null; + isConnecting: boolean; +} + +const state: WebSocketState = { + socketRef: null, + triggerReconnect: null, + isConnecting: false, +}; + +export function registerWebSocketState( + socketRef: React.MutableRefObject, + triggerReconnect: () => void +) { + state.socketRef = socketRef; + state.triggerReconnect = triggerReconnect; +} + +export function setWebSocketConnecting(value: boolean) { + state.isConnecting = value; +} + +export function getWebSocketState(): Readonly { + return state; +}