diff --git a/__test__/features/websocket/WebSocketGate.test.tsx b/__test__/features/websocket/WebSocketGate.test.tsx
index 7a3a90e..2784885 100644
--- a/__test__/features/websocket/WebSocketGate.test.tsx
+++ b/__test__/features/websocket/WebSocketGate.test.tsx
@@ -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 (
+ ) => {
+ 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",
+ }}
+ >
+
+
+
+
+ );
+}
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(
-
-
-
- );
+ render();
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(
-
-
-
- );
+ render();
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(
-
-
-
- );
+ const { rerender } = render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
});
const unauthStore = createMockStore(null, null);
- rerender(
-
-
-
- );
+ rerender();
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(
-
-
-
- );
+ render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledWith(
@@ -141,11 +151,7 @@ describe("WebSocketGate", () => {
}
);
- render(
-
-
-
- );
+ render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
@@ -172,11 +178,7 @@ describe("WebSocketGate", () => {
new Error("Connection failed")
);
- render(
-
-
-
- );
+ render();
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(
-
-
-
- );
+ const { unmount } = render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
@@ -230,11 +228,7 @@ describe("WebSocketGate", () => {
}
);
- render(
-
-
-
- );
+ render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -275,11 +269,7 @@ describe("WebSocketGate", () => {
}
);
- render(
-
-
-
- );
+ render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -311,11 +301,7 @@ describe("WebSocketGate", () => {
}
);
- render(
-
-
-
- );
+ render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -346,11 +332,7 @@ describe("WebSocketGate", () => {
}
);
- render(
-
-
-
- );
+ render();
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(
-
-
-
- );
+ const { rerender } = render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -417,11 +387,7 @@ describe("WebSocketGate", () => {
// Lose authentication before timeout fires
const unauthStore = createMockStore(null, null);
- rerender(
-
-
-
- );
+ rerender();
// Advance timer
await act(async () => {
@@ -444,11 +410,7 @@ describe("WebSocketGate", () => {
}
);
- const { unmount } = render(
-
-
-
- );
+ const { unmount } = render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -487,11 +449,7 @@ describe("WebSocketGate", () => {
}
);
- render(
-
-
-
- );
+ render();
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
@@ -540,11 +498,7 @@ describe("WebSocketGate", () => {
}
);
- render(
-
-
-
- );
+ render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -577,11 +531,7 @@ describe("WebSocketGate", () => {
}
);
- render(
-
-
-
- );
+ render();
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(
-
-
-
- );
+ render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -640,11 +586,7 @@ describe("WebSocketGate", () => {
}
);
- render(
-
-
-
- );
+ render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
@@ -693,11 +635,7 @@ describe("WebSocketGate", () => {
connectingSocket
);
- render(
-
-
-
- );
+ render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
@@ -713,11 +651,7 @@ describe("WebSocketGate", () => {
);
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
- render(
-
-
-
- );
+ render();
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(
-
-
-
- );
+ render();
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
@@ -764,11 +694,7 @@ describe("WebSocketGate", () => {
);
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
- render(
-
-
-
- );
+ render();
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalled();
@@ -795,11 +721,7 @@ describe("WebSocketGate", () => {
);
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
- render(
-
-
-
- );
+ render();
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalled();
@@ -829,11 +751,7 @@ describe("WebSocketGate", () => {
throw new Error("Registration failed");
});
- render(
-
-
-
- );
+ render();
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
@@ -868,11 +786,7 @@ describe("WebSocketGate", () => {
localStorage.setItem("selectedCalendars", JSON.stringify([]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
- render(
-
-
-
- );
+ render();
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
@@ -888,11 +802,7 @@ describe("WebSocketGate", () => {
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
- render(
-
-
-
- );
+ render();
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(
-
-
-
- );
+ render();
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalled();
@@ -945,11 +851,7 @@ describe("WebSocketGate", () => {
);
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
- render(
-
-
-
- );
+ render();
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalled();
@@ -979,11 +881,7 @@ describe("WebSocketGate", () => {
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(closedSocket);
- render(
-
-
-
- );
+ render();
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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(createWebSocketConnection).toHaveBeenCalled();
+ });
+
+ // Unmount should cleanup
+ unmount();
+
+ expect(mockSocket.close).toHaveBeenCalled();
+ });
+ });
});
diff --git a/public/.env.example.js b/public/.env.example.js
index 1cea3e8..495d679 100644
--- a/public/.env.example.js
+++ b/public/.env.example.js
@@ -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;
diff --git a/src/locales/en.json b/src/locales/en.json
index 1c30495..21a25ff 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -299,6 +299,13 @@
"endDate": "End Date",
"endTime": "End Time"
},
+ "websocket": {
+ "browserOnline": "You’re back online. Reconnecting live updates…",
+ "browserOffline": "You’re 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",
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 3e9ab70..8ff8226 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -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 jusqu’au 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",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index f3629dd..5952cd3 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -300,6 +300,13 @@
"endDate": "Дата окончания",
"endTime": "Время окончания"
},
+ "websocket": {
+ "browserOnline": "Вы снова в сети. Подключаем обновления в реальном времени…",
+ "browserOffline": "Вы не в сети. Обновления в реальном времени приостановлены до восстановления соединения.",
+ "reconnected": "Обновления в реальном времени снова работают.",
+ "error": "Произошла ошибка в обновлениях в реальном времени: %{error}",
+ "closedUnexpectedly": "Обновления в реальном времени были прерваны. Пробуем переподключиться…"
+ },
"months": {
"standalone": {
"0": "Январь",
diff --git a/src/locales/vi.json b/src/locales/vi.json
index f71812a..499c0f3 100644
--- a/src/locales/vi.json
+++ b/src/locales/vi.json
@@ -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",
diff --git a/src/setupTests.ts b/src/setupTests.ts
index a7817ef..ecfb6f8 100644
--- a/src/setupTests.ts
+++ b/src/setupTests.ts
@@ -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;
diff --git a/src/websocket/WebSocketGate.tsx b/src/websocket/WebSocketGate.tsx
index 1537ce7..6202d3a 100644
--- a/src/websocket/WebSocketGate.tsx
+++ b/src/websocket/WebSocketGate.tsx
@@ -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(null);
@@ -16,12 +22,19 @@ export function WebSocketGate() {
const reconnectTimeoutRef = useRef(null);
const reconnectAttemptsRef = useRef(0);
const isConnectingRef = useRef(false);
+ const pingCleanupRef = useRef(null);
const connectTimeoutRef = useRef(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 ? (
+ {
+ setWebSocketStatus("");
+ setWebSocketStatusSerity(undefined);
+ }}
+ />
+ ) : null;
}
diff --git a/src/websocket/WebSocketStatusSnackbar.tsx b/src/websocket/WebSocketStatusSnackbar.tsx
new file mode 100644
index 0000000..2a2b546
--- /dev/null
+++ b/src/websocket/WebSocketStatusSnackbar.tsx
@@ -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 (
+
+
+ {t("common.ok")}
+
+ }
+ >
+ {message}
+
+
+ );
+}
diff --git a/src/websocket/connection/lifecycle/pingWebSocket.ts b/src/websocket/connection/lifecycle/pingWebSocket.ts
new file mode 100644
index 0000000..78b39a7
--- /dev/null
+++ b/src/websocket/connection/lifecycle/pingWebSocket.ts
@@ -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();
+ }
+ },
+ };
+}
diff --git a/src/websocket/messaging/parseMessage.ts b/src/websocket/messaging/parseMessage.ts
index 9754ff6..c3b419c 100644
--- a/src/websocket/messaging/parseMessage.ts
+++ b/src/websocket/messaging/parseMessage.ts
@@ -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();
const calendarsToHide = new Set();
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);
}
diff --git a/src/websocket/protocols/index.ts b/src/websocket/protocols/index.ts
index d62a18b..95bb84a 100644
--- a/src/websocket/protocols/index.ts
+++ b/src/websocket/protocols/index.ts
@@ -5,4 +5,5 @@ export const WS_INBOUND_EVENTS = {
CONNECTION_CLOSED: "close",
CLIENT_REGISTERED: "registered",
CLIENT_UNREGISTERED: "unregistered",
+ CALENDAR_CLIENT_REGISTERED: "calendarListRegistered",
} as const;