From 8a6ec8fc39077342e6810956d09b9d8238f9ff01 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:14:13 +0100 Subject: [PATCH] 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 --- .../features/websocket/WebSocketGate.test.tsx | 337 +++++++++--------- public/.env.example.js | 2 + src/locales/en.json | 7 + src/locales/fr.json | 7 + src/locales/ru.json | 7 + src/locales/vi.json | 7 + src/setupTests.ts | 5 +- src/websocket/WebSocketGate.tsx | 80 ++++- src/websocket/WebSocketStatusSnackbar.tsx | 37 ++ .../connection/lifecycle/pingWebSocket.ts | 180 ++++++++++ src/websocket/messaging/parseMessage.ts | 3 +- src/websocket/protocols/index.ts | 1 + 12 files changed, 496 insertions(+), 177 deletions(-) create mode 100644 src/websocket/WebSocketStatusSnackbar.tsx create mode 100644 src/websocket/connection/lifecycle/pingWebSocket.ts 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;