Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
@@ -1,12 +1,16 @@
|
|||||||
import { Auth } from "@/features/User/oidcAuth";
|
import { Auth } from "@/features/User/oidcAuth";
|
||||||
|
import { assertWebSocketAlive } from "@/websocket/connection/lifecycle/assertWebSocketAlive";
|
||||||
import ky from "ky";
|
import ky from "ky";
|
||||||
import { getRetryDelay } from "./getRetryDelay";
|
import { getRetryDelay } from "./getRetryDelay";
|
||||||
|
|
||||||
|
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||||
|
|
||||||
const RETRY_CONFIG = {
|
const RETRY_CONFIG = {
|
||||||
maxRetries: 10,
|
maxRetries: 10,
|
||||||
initialDelay: 1000,
|
initialDelay: 1000,
|
||||||
maxDelay: 120000,
|
maxDelay: 120000,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const api = ky.extend({
|
export const api = ky.extend({
|
||||||
prefixUrl: window.CALENDAR_BASE_URL,
|
prefixUrl: window.CALENDAR_BASE_URL,
|
||||||
retry: {
|
retry: {
|
||||||
@@ -28,6 +32,10 @@ export const api = ky.extend({
|
|||||||
if (access_token) {
|
if (access_token) {
|
||||||
request.headers.set("Authorization", `Bearer ${access_token}`);
|
request.headers.set("Authorization", `Bearer ${access_token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (MUTATING_METHODS.has(request.method)) {
|
||||||
|
await assertWebSocketAlive();
|
||||||
|
}
|
||||||
return request;
|
return request;
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ import { useI18n } from "twake-i18n";
|
|||||||
import type { WebSocketWithCleanup } from "./connection";
|
import type { WebSocketWithCleanup } from "./connection";
|
||||||
import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection";
|
import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection";
|
||||||
import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection";
|
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 {
|
import {
|
||||||
setupWebSocketPing,
|
setupWebSocketPing,
|
||||||
type PingCleanup,
|
type PingCleanup,
|
||||||
} from "./connection/lifecycle/pingWebSocket";
|
} 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() {
|
export function WebSocketGate() {
|
||||||
const socketRef = useRef<WebSocketWithCleanup | null>(null);
|
const socketRef = useRef<WebSocketWithCleanup | null>(null);
|
||||||
@@ -183,6 +187,7 @@ export function WebSocketGate() {
|
|||||||
const connect = async () => {
|
const connect = async () => {
|
||||||
if (isConnectingRef.current || isSocketOpen) return;
|
if (isConnectingRef.current || isSocketOpen) return;
|
||||||
isConnectingRef.current = true;
|
isConnectingRef.current = true;
|
||||||
|
setWebSocketConnecting(true);
|
||||||
didConnectTimeoutRef.current = false;
|
didConnectTimeoutRef.current = false;
|
||||||
connectTimeoutRef.current = setTimeout(() => {
|
connectTimeoutRef.current = setTimeout(() => {
|
||||||
console.warn("WebSocket connection attempt timed out");
|
console.warn("WebSocket connection attempt timed out");
|
||||||
@@ -191,6 +196,7 @@ export function WebSocketGate() {
|
|||||||
abortController.abort();
|
abortController.abort();
|
||||||
connectTimeoutRef.current = null;
|
connectTimeoutRef.current = null;
|
||||||
isConnectingRef.current = false;
|
isConnectingRef.current = false;
|
||||||
|
setWebSocketConnecting(false);
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
||||||
scheduleReconnect();
|
scheduleReconnect();
|
||||||
@@ -217,6 +223,7 @@ export function WebSocketGate() {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isConnectingRef.current = false;
|
isConnectingRef.current = false;
|
||||||
|
setWebSocketConnecting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -340,6 +347,16 @@ export function WebSocketGate() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isSocketOpen]);
|
}, [isSocketOpen]);
|
||||||
|
|
||||||
|
const triggerReconnect = useCallback(() => {
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
clearReconnectTimeout();
|
||||||
|
setShouldConnect((prev) => !prev);
|
||||||
|
}, [clearReconnectTimeout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerWebSocketState(socketRef, triggerReconnect);
|
||||||
|
}, [triggerReconnect]);
|
||||||
|
|
||||||
return websocketStatus ? (
|
return websocketStatus ? (
|
||||||
<WebSocketStatusSnackbar
|
<WebSocketStatusSnackbar
|
||||||
message={websocketStatus}
|
message={websocketStatus}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { type MutableRefObject } from "react";
|
||||||
|
import type { WebSocketWithCleanup } from "../types";
|
||||||
|
import { getWebSocketState } from "../webSocketState";
|
||||||
|
|
||||||
|
const TIMEOUT_MS = window.WS_PING_TIMEOUT_PERIOD_MS ?? 10_000;
|
||||||
|
let inFlightCheck: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function waitForSocketOpen(
|
||||||
|
socketRef: MutableRefObject<WebSocketWithCleanup | null>
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>((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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { type MutableRefObject } from "react";
|
||||||
|
import type { WebSocketWithCleanup } from "./types";
|
||||||
|
|
||||||
|
interface WebSocketState {
|
||||||
|
socketRef: MutableRefObject<WebSocketWithCleanup | null> | null;
|
||||||
|
triggerReconnect: (() => void) | null;
|
||||||
|
isConnecting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: WebSocketState = {
|
||||||
|
socketRef: null,
|
||||||
|
triggerReconnect: null,
|
||||||
|
isConnecting: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function registerWebSocketState(
|
||||||
|
socketRef: React.MutableRefObject<WebSocketWithCleanup | null>,
|
||||||
|
triggerReconnect: () => void
|
||||||
|
) {
|
||||||
|
state.socketRef = socketRef;
|
||||||
|
state.triggerReconnect = triggerReconnect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWebSocketConnecting(value: boolean) {
|
||||||
|
state.isConnecting = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebSocketState(): Readonly<WebSocketState> {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user