474 websocket add a snack bar to notify the user on websocket status changes (#483)

* [#474] added snackbar for websocket status

* [#474 & #447] added ping to complete browser online/offline status

* [#447] ping timeout as an env var
This commit is contained in:
Camille Moussu
2026-02-09 19:14:13 +01:00
committed by GitHub
parent ce9ad27256
commit 8a6ec8fc39
12 changed files with 496 additions and 177 deletions
+169 -168
View File
@@ -3,13 +3,40 @@ import { createWebSocketConnection } from "@/websocket/connection/createConnecti
import { registerToCalendars } from "@/websocket/operations/registerToCalendars";
import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars";
import { WebSocketGate } from "@/websocket/WebSocketGate";
import { configureStore } from "@reduxjs/toolkit";
import { configureStore, Store } from "@reduxjs/toolkit";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { Provider } from "react-redux";
import { I18nContext } from "twake-i18n";
jest.mock("@/websocket/connection/createConnection");
jest.mock("@/websocket/operations/registerToCalendars");
jest.mock("@/websocket/operations/unregisterToCalendars");
jest.mock("@/websocket/connection/lifecycle/pingWebSocket");
function TestWrapper({ store }: { store: Store }) {
return (
<I18nContext.Provider
value={{
t: (key: string, vars?: Record<string, string>) => {
if (key === "locale") return "en";
if (vars) {
const params = Object.entries(vars)
.map(([k, v]) => `${k}=${v}`)
.join(",");
return `${key}(${params})`;
}
return key;
},
f: (date: Date) => date.toString(),
lang: "en",
}}
>
<Provider store={store}>
<WebSocketGate />
</Provider>
</I18nContext.Provider>
);
}
describe("WebSocketGate", () => {
let store: any;
@@ -30,9 +57,11 @@ describe("WebSocketGate", () => {
const createMockSocket = (readyState = WebSocket.OPEN) => ({
readyState,
close: jest.fn(),
send: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
cleanup: jest.fn(),
onmessage: null,
});
beforeEach(() => {
@@ -44,6 +73,7 @@ describe("WebSocketGate", () => {
afterEach(() => {
cleanup();
jest.clearAllMocks();
jest.clearAllTimers();
localStorage.clear();
});
@@ -61,11 +91,7 @@ describe("WebSocketGate", () => {
it("should not create connection when user is not authenticated", () => {
const unauthStore = createMockStore(null, null);
render(
<Provider store={unauthStore}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={unauthStore} />);
expect(createWebSocketConnection).not.toHaveBeenCalled();
});
@@ -73,11 +99,7 @@ describe("WebSocketGate", () => {
it("should create connection when user is authenticated", async () => {
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -87,22 +109,14 @@ describe("WebSocketGate", () => {
it("should close existing socket when user logs out", async () => {
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
const { rerender } = render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
const { rerender } = render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
});
const unauthStore = createMockStore(null, null);
rerender(
<Provider store={unauthStore}>
<WebSocketGate />
</Provider>
);
rerender(<TestWrapper store={unauthStore} />);
await waitFor(() => {
expect(mockSocket.close).toHaveBeenCalled();
@@ -114,11 +128,7 @@ describe("WebSocketGate", () => {
it("should create connection with callbacks", async () => {
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledWith(
@@ -141,11 +151,7 @@ describe("WebSocketGate", () => {
}
);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
@@ -172,11 +178,7 @@ describe("WebSocketGate", () => {
new Error("Connection failed")
);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
@@ -191,11 +193,7 @@ describe("WebSocketGate", () => {
it("should close socket on component unmount", async () => {
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
const { unmount } = render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
const { unmount } = render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
@@ -230,11 +228,7 @@ describe("WebSocketGate", () => {
}
);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -275,11 +269,7 @@ describe("WebSocketGate", () => {
}
);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -311,11 +301,7 @@ describe("WebSocketGate", () => {
}
);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -346,11 +332,7 @@ describe("WebSocketGate", () => {
}
);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -362,19 +344,11 @@ describe("WebSocketGate", () => {
jest.advanceTimersByTime(1000);
});
// Second failure
await act(async () => {
onCloseCallback?.(new CloseEvent("close", { code: 1006 }));
jest.advanceTimersByTime(2000);
});
// Success - should log and reset counter
await waitFor(() => {
expect(consoleLog).toHaveBeenCalledWith(
"WebSocket connected successfully"
);
});
expect(consoleLog).toHaveBeenCalledWith(
expect.stringContaining("(attempt 1/10)")
);
// Success - should reset counter
// Next failure should start from attempt 1 again
await act(async () => {
onCloseCallback?.(new CloseEvent("close", { code: 1006 }));
@@ -400,11 +374,7 @@ describe("WebSocketGate", () => {
}
);
const { rerender } = render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
const { rerender } = render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -417,11 +387,7 @@ describe("WebSocketGate", () => {
// Lose authentication before timeout fires
const unauthStore = createMockStore(null, null);
rerender(
<Provider store={unauthStore}>
<WebSocketGate />
</Provider>
);
rerender(<TestWrapper store={unauthStore} />);
// Advance timer
await act(async () => {
@@ -444,11 +410,7 @@ describe("WebSocketGate", () => {
}
);
const { unmount } = render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
const { unmount } = render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -487,11 +449,7 @@ describe("WebSocketGate", () => {
}
);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
@@ -540,11 +498,7 @@ describe("WebSocketGate", () => {
}
);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -577,11 +531,7 @@ describe("WebSocketGate", () => {
}
);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -609,11 +559,7 @@ describe("WebSocketGate", () => {
it("should not reconnect when online event fires if already connected", async () => {
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -640,11 +586,7 @@ describe("WebSocketGate", () => {
}
);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -693,11 +635,7 @@ describe("WebSocketGate", () => {
connectingSocket
);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
@@ -713,11 +651,7 @@ describe("WebSocketGate", () => {
);
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
@@ -731,11 +665,7 @@ describe("WebSocketGate", () => {
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
@@ -764,11 +694,7 @@ describe("WebSocketGate", () => {
);
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalled();
@@ -795,11 +721,7 @@ describe("WebSocketGate", () => {
);
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalled();
@@ -829,11 +751,7 @@ describe("WebSocketGate", () => {
throw new Error("Registration failed");
});
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
@@ -868,11 +786,7 @@ describe("WebSocketGate", () => {
localStorage.setItem("selectedCalendars", JSON.stringify([]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
@@ -888,11 +802,7 @@ describe("WebSocketGate", () => {
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalledTimes(1);
@@ -913,11 +823,7 @@ describe("WebSocketGate", () => {
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalled();
@@ -945,11 +851,7 @@ describe("WebSocketGate", () => {
);
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalled();
@@ -979,11 +881,7 @@ describe("WebSocketGate", () => {
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(closedSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
@@ -1004,4 +902,107 @@ describe("WebSocketGate", () => {
expect(registerToCalendars).not.toHaveBeenCalled();
});
});
describe("Ping/Pong Integration", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it("should trigger reconnection when ping detects dead connection (via socket close)", async () => {
const consoleWarn = jest.spyOn(console, "warn").mockImplementation();
let onCloseCallback: ((event: CloseEvent) => void) | undefined;
(createWebSocketConnection as jest.Mock).mockImplementation(
(callbacks) => {
onCloseCallback = callbacks.onClose;
return Promise.resolve(mockSocket);
}
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
});
(createWebSocketConnection as jest.Mock).mockClear();
// In the real implementation, when ping detects dead connection,
// it calls socket.close() which triggers the onClose callback
// This simulates that flow
await act(async () => {
if (onCloseCallback) {
onCloseCallback(new CloseEvent("close", { code: 1006 }));
}
});
// Should schedule reconnection
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining("WebSocket closed unexpectedly")
);
// Advance to trigger reconnection
await act(async () => {
jest.advanceTimersByTime(1500);
});
// Should reconnect
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
});
consoleWarn.mockRestore();
});
it("should stop ping monitoring when socket closes normally", async () => {
let onCloseCallback: ((event: CloseEvent) => void) | undefined;
(createWebSocketConnection as jest.Mock).mockImplementation(
(callbacks) => {
onCloseCallback = callbacks.onClose;
return Promise.resolve(mockSocket);
}
);
render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
});
// Normal close (code 1000) - like logout or page navigation
await act(async () => {
if (onCloseCallback) {
onCloseCallback(new CloseEvent("close", { code: 1000 }));
}
});
// Should not attempt reconnection
await act(async () => {
jest.advanceTimersByTime(5000);
});
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
});
it("should cleanup ping monitoring on component unmount", async () => {
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
const { unmount } = render(<TestWrapper store={store} />);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
});
// Unmount should cleanup
unmount();
expect(mockSocket.close).toHaveBeenCalled();
});
});
});
+2
View File
@@ -12,3 +12,5 @@ var DEBUG = false;
var LANG = "en";
var WEBSOCKET_URL = "wss://calendar.example.com";
var WS_DEBOUNCE_PERIOD_MS = 100; // milliseconds, remove or set to 0 to disable debounce
var WS_PING_PERIOD_MS = 30000;
var WS_PING_TIMEOUT_PERIOD_MS = 35000;
+7
View File
@@ -299,6 +299,13 @@
"endDate": "End Date",
"endTime": "End Time"
},
"websocket": {
"browserOnline": "Youre back online. Reconnecting live updates…",
"browserOffline": "Youre offline. Live updates are paused until the connection is restored.",
"reconnected": "Live updates are back.",
"error": "There was a problem with live updates: %{error}",
"closedUnexpectedly": "Live updates were interrupted. Trying to reconnect…"
},
"months": {
"standalone": {
"0": "January",
+7
View File
@@ -300,6 +300,13 @@
"endDate": "Date de la fin",
"endTime": "Heure de la fin"
},
"websocket": {
"browserOnline": "Vous êtes de nouveau en ligne. Reconnexion des mises à jour en temps réel…",
"browserOffline": "Vous êtes hors ligne. Les mises à jour en temps réel sont mises en pause jusquau rétablissement de la connexion.",
"reconnected": "Les mises à jour en temps réel sont de nouveau actives.",
"error": "Un problème est survenu avec les mises à jour en temps réel : %{error}",
"closedUnexpectedly": "Les mises à jour en temps réel ont été interrompues. Tentative de reconnexion…"
},
"months": {
"standalone": {
"0": "Janvier",
+7
View File
@@ -300,6 +300,13 @@
"endDate": "Дата окончания",
"endTime": "Время окончания"
},
"websocket": {
"browserOnline": "Вы снова в сети. Подключаем обновления в реальном времени…",
"browserOffline": "Вы не в сети. Обновления в реальном времени приостановлены до восстановления соединения.",
"reconnected": "Обновления в реальном времени снова работают.",
"error": "Произошла ошибка в обновлениях в реальном времени: %{error}",
"closedUnexpectedly": "Обновления в реальном времени были прерваны. Пробуем переподключиться…"
},
"months": {
"standalone": {
"0": "Январь",
+7
View File
@@ -298,6 +298,13 @@
"endDate": "Ngày kết thúc",
"endTime": "Giờ kết thúc"
},
"websocket": {
"browserOnline": "Bạn đã trực tuyến trở lại. Đang kết nối lại cập nhật thời gian thực…",
"browserOffline": "Bạn đang ngoại tuyến. Cập nhật thời gian thực sẽ tạm dừng cho đến khi kết nối được khôi phục.",
"reconnected": "Cập nhật thời gian thực đã hoạt động trở lại.",
"error": "Đã xảy ra sự cố với cập nhật thời gian thực: %{error}",
"closedUnexpectedly": "Cập nhật thời gian thực đã bị gián đoạn. Đang thử kết nối lại…"
},
"months": {
"standalone": {
"0": "Tháng 1",
+4 -1
View File
@@ -58,7 +58,10 @@ class IntersectionObserverMock {
}
(global as any).IntersectionObserver = IntersectionObserverMock;
if (typeof window !== "undefined") {
(window as any).WS_PING_PERIOD_MS = 5000;
(window as any).WS_PING_TIMEOUT_PERIOD_MS = 5000;
}
// Suppress jsdom CSS selector parsing errors for Emotion/MUI
if (typeof window !== "undefined" && window.getComputedStyle) {
const originalGetComputedStyle = window.getComputedStyle;
+73 -7
View File
@@ -2,12 +2,18 @@ import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { AppDispatch } from "@/app/store";
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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";
export function WebSocketGate() {
const socketRef = useRef<WebSocketWithCleanup | null>(null);
@@ -16,12 +22,19 @@ export function WebSocketGate() {
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const isConnectingRef = useRef(false);
const pingCleanupRef = useRef<PingCleanup | null>(null);
const connectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const CONNECT_TIMEOUT_MS = 10_000;
const hadSocketBeforeRef = useRef(false);
const justReconnectedRef = useRef(false);
const [websocketStatus, setWebSocketStatus] = useState("");
const [websocketStatusSerity, setWebSocketStatusSerity] = useState<
"success" | "info" | "warning" | "error" | undefined
>();
const { t } = useI18n();
const dispatch = useAppDispatch();
const isAuthenticated = useAppSelector((state) =>
@@ -81,6 +94,8 @@ export function WebSocketGate() {
`WebSocket closed unexpectedly (code: ${event.code}, reason: ${event.reason || "none"}). ` +
`Attempting to reconnect...`
);
setWebSocketStatus(t("websocket.closedUnexpectedly"));
setWebSocketStatusSerity("warning");
scheduleReconnect();
} else {
reconnectAttemptsRef.current = 0;
@@ -92,6 +107,10 @@ export function WebSocketGate() {
const onError = useCallback((error: Event) => {
console.error("WebSocket error:", error);
const errorMessage =
(error as ErrorEvent)?.message ?? error.type ?? "unknown";
setWebSocketStatus(t("websocket.error", { error: errorMessage }));
setWebSocketStatusSerity("error");
}, []);
const callBacks = useMemo(
@@ -110,8 +129,6 @@ export function WebSocketGate() {
// Reset reconnection state on successful connection and mark for calendar re-sync
useEffect(() => {
if (isSocketOpen) {
console.log("WebSocket connected successfully");
if (connectTimeoutRef.current) {
clearTimeout(connectTimeoutRef.current);
connectTimeoutRef.current = null;
@@ -119,6 +136,8 @@ export function WebSocketGate() {
if (hadSocketBeforeRef.current) {
justReconnectedRef.current = true;
setWebSocketStatus(t("websocket.reconnected"));
setWebSocketStatusSerity("success");
}
hadSocketBeforeRef.current = true;
@@ -227,7 +246,8 @@ export function WebSocketGate() {
// Handle browser online/offline events
useEffect(() => {
const handleOnline = () => {
console.log("Browser is online, attempting WebSocket reconnection");
setWebSocketStatus(t("websocket.browserOnline"));
setWebSocketStatusSerity("success");
if (!isSocketOpen && isAuthenticatedRef.current) {
reconnectAttemptsRef.current = 0;
clearReconnectTimeout();
@@ -236,9 +256,8 @@ export function WebSocketGate() {
};
const handleOffline = () => {
console.log(
"Browser is offline, pausing WebSocket reconnection attempts"
);
setWebSocketStatus(t("websocket.browserOffline"));
setWebSocketStatusSerity("warning");
cleanupConnection();
};
@@ -260,5 +279,52 @@ export function WebSocketGate() {
};
}, [isSocketOpen, isAuthenticated, clearReconnectTimeout]);
return null;
useEffect(() => {
// Only set up ping if socket is open
if (!isSocketOpen || !socketRef.current) {
// Clean up existing ping if socket closed
if (pingCleanupRef.current) {
pingCleanupRef.current.stop();
pingCleanupRef.current = null;
}
return;
}
// Set up ping monitoring
const pingCleanup = setupWebSocketPing(socketRef.current, {
onConnectionDead: () => {
console.warn("WebSocket connection appears dead (no pong received)");
setWebSocketStatus(t("websocket.browserOffline"));
setWebSocketStatusSerity("warning");
// Trigger reconnection
if (socketRef.current) {
socketRef.current.close();
}
},
onPingFail: () => {
console.warn("Failed to send ping");
},
});
pingCleanupRef.current = pingCleanup;
return () => {
if (pingCleanupRef.current) {
pingCleanupRef.current.stop();
pingCleanupRef.current = null;
}
};
}, [isSocketOpen]);
return websocketStatus ? (
<WebSocketStatusSnackbar
message={websocketStatus}
severity={websocketStatusSerity}
onClose={() => {
setWebSocketStatus("");
setWebSocketStatusSerity(undefined);
}}
/>
) : null;
}
+37
View File
@@ -0,0 +1,37 @@
import { Alert, Button, Snackbar } from "@linagora/twake-mui";
import { useI18n } from "twake-i18n";
export function WebSocketStatusSnackbar({
message,
severity,
onClose,
}: {
message: string;
severity: "success" | "info" | "warning" | "error" | undefined;
onClose: () => void;
}) {
const { t } = useI18n();
return (
<Snackbar
open={!!message}
onClose={onClose}
autoHideDuration={
severity === "warning" || severity === "error" ? null : 6000
}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert
severity={severity}
onClose={onClose}
sx={{ width: "100%" }}
action={
<Button color="inherit" size="small" onClick={onClose}>
{t("common.ok")}
</Button>
}
>
{message}
</Alert>
</Snackbar>
);
}
@@ -0,0 +1,180 @@
import { WebSocketWithCleanup } from "../types";
export interface PingConfig {
/** Interval between ping attempts in milliseconds */
pingInterval?: number;
/** Timeout for pong response in milliseconds */
pongTimeout?: number;
/** Callback when connection is deemed dead */
onConnectionDead?: () => void;
/** Callback when ping fails */
onPingFail?: () => void;
/** Callback when pong is received successfully */
onPongReceived?: () => void;
}
export interface PingCleanup {
stop: () => void;
/** Force send a ping immediately */
sendPing: () => void;
}
const DEFAULT_PING_INTERVAL = (window as any).WS_PING_PERIOD_MS ?? 30000;
const DEFAULT_PONG_TIMEOUT = (window as any).WS_PING_TIMEOUT_PERIOD_MS ?? 35000;
/**
* Sets up a ping/pong mechanism to monitor WebSocket connection health
*
* @param socket - The WebSocket connection to monitor
* @param config - Configuration options for ping behavior
* @returns Cleanup object with stop() and sendPing() methods
*
* @example
* const pingCleanup = setupWebSocketPing(socket, {
* pingInterval: 30000,
* pongTimeout: 5000,
* onConnectionDead: () => {
* console.log('Connection is dead, reconnecting...');
* reconnect();
* }
* });
*
* // Later, when closing the connection:
* pingCleanup.stop();
*/
export function setupWebSocketPing(
socket: WebSocketWithCleanup | null,
config: PingConfig = {}
): PingCleanup {
const {
pingInterval = DEFAULT_PING_INTERVAL,
pongTimeout = DEFAULT_PONG_TIMEOUT,
onConnectionDead,
onPingFail,
onPongReceived,
} = config;
let pingIntervalId: NodeJS.Timeout | null = null;
let pongTimeoutId: NodeJS.Timeout | null = null;
let isWaitingForPong = false;
let isStopped = false;
const cleanup = () => {
if (pingIntervalId) {
clearInterval(pingIntervalId);
pingIntervalId = null;
}
if (pongTimeoutId) {
clearTimeout(pongTimeoutId);
pongTimeoutId = null;
}
isWaitingForPong = false;
};
const sendPing = () => {
if (isStopped || !socket || socket.readyState !== WebSocket.OPEN) {
return;
}
// If we're still waiting for a previous pong, connection might be dead
if (isWaitingForPong) {
console.warn(
"Pong not received for previous ping. Connection may be dead."
);
onPingFail?.();
onConnectionDead?.();
isStopped = true;
cleanup();
return;
}
try {
socket.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
isWaitingForPong = true;
// Set timeout for pong response
pongTimeoutId = setTimeout(() => {
if (isWaitingForPong) {
console.warn("Pong timeout exceeded. Connection may be dead.");
onPingFail?.();
onConnectionDead?.();
cleanup();
}
}, pongTimeout);
} catch (error) {
console.error("Failed to send ping:", error);
onPingFail?.();
cleanup();
}
};
const handlePong = () => {
if (pongTimeoutId) {
clearTimeout(pongTimeoutId);
pongTimeoutId = null;
}
isWaitingForPong = false;
onPongReceived?.();
};
// Start ping interval
const startPinging = () => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
console.warn("Cannot start pinging: socket is not open");
return;
}
// Send first ping immediately
sendPing();
// Set up recurring pings
pingIntervalId = setInterval(() => {
sendPing();
}, pingInterval);
};
// Set up message listener for pong responses
const originalOnMessage = socket?.onmessage;
if (socket) {
socket.onmessage = (event) => {
let isPong = false;
if (typeof event.data === "string") {
try {
const payload = JSON.parse(event.data);
// Check if it's an empty object {}
isPong =
typeof payload === "object" &&
payload !== null &&
Object.keys(payload).length === 0;
} catch {
// Non-JSON payload
}
}
if (isPong) {
handlePong();
}
// Call original handler
originalOnMessage?.call(socket, event);
};
}
// Start the ping mechanism
startPinging();
return {
stop: () => {
isStopped = true;
cleanup();
// Restore original onmessage handler
if (socket) {
socket.onmessage = originalOnMessage ?? null;
}
},
sendPing: () => {
if (!isStopped) {
sendPing();
}
},
};
}
+2 -1
View File
@@ -1,7 +1,6 @@
import { WS_INBOUND_EVENTS } from "../protocols";
export function parseMessage(message: unknown) {
console.log("WebSocket message received:", message);
const calendarsToRefresh = new Set<string>();
const calendarsToHide = new Set<string>();
if (typeof message !== "object" || message === null) {
@@ -20,6 +19,8 @@ export function parseMessage(message: unknown) {
value.forEach((cal: string) => calendarsToHide.add(cal));
}
break;
case WS_INBOUND_EVENTS.CALENDAR_CLIENT_REGISTERED:
break;
default: {
calendarsToRefresh.add(key);
}
+1
View File
@@ -5,4 +5,5 @@ export const WS_INBOUND_EVENTS = {
CONNECTION_CLOSED: "close",
CLIENT_REGISTERED: "registered",
CLIENT_UNREGISTERED: "unregistered",
CALENDAR_CLIENT_REGISTERED: "calendarListRegistered",
} as const;