* [#451] added websocket connexion * [#451] added tests * [#451] add missing cleanup for web socket causing memory leaks
This commit is contained in:
@@ -26,6 +26,7 @@ import fr from "./locales/fr.json";
|
||||
import ru from "./locales/ru.json";
|
||||
import vi from "./locales/vi.json";
|
||||
import I18n from "twake-i18n";
|
||||
import { WebSocketGate } from "./websocket/WebSocketGate";
|
||||
|
||||
const locale = { en, fr, ru, vi };
|
||||
const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale };
|
||||
@@ -68,6 +69,7 @@ function App() {
|
||||
locales={dateLocales}
|
||||
>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<WebSocketGate />
|
||||
<Router history={history}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HandleLogin />} />
|
||||
|
||||
@@ -45,6 +45,7 @@ import SearchResultsPage from "../../features/Search/SearchResultsPage";
|
||||
import { setTimeZone } from "../../features/Settings/SettingsSlice";
|
||||
import { browserDefaultTimeZone } from "../../utils/timezone";
|
||||
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||
import { setSelectedCalendars as setSelectedCalendarsToStorage } from "../../utils/storage/setSelectedCalendars";
|
||||
|
||||
const localeMap: Record<string, any> = {
|
||||
fr: frLocale,
|
||||
@@ -164,10 +165,7 @@ export default function CalendarApp({
|
||||
// Save selected cals to cache
|
||||
useEffect(() => {
|
||||
if (calendarIds.length > 0) {
|
||||
localStorage.setItem(
|
||||
"selectedCalendars",
|
||||
JSON.stringify(selectedCalendars)
|
||||
);
|
||||
setSelectedCalendarsToStorage(selectedCalendars);
|
||||
}
|
||||
}, [selectedCalendars, calendarIds.length]);
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export function setSelectedCalendars(calendars: string[]) {
|
||||
try {
|
||||
localStorage.setItem("selectedCalendars", JSON.stringify(calendars));
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("selectedCalendarsChanged", {
|
||||
detail: calendars,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to save selected calendars:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useSelectedCalendars(): string[] {
|
||||
const [calendars, setCalendars] = useState<string[]>(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("selectedCalendars") ?? "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === "selectedCalendars") {
|
||||
try {
|
||||
setCalendars(JSON.parse(e.newValue ?? "[]"));
|
||||
} catch {
|
||||
setCalendars([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onLocalChange = (e: CustomEvent<string[]>) => {
|
||||
setCalendars(e.detail);
|
||||
};
|
||||
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(
|
||||
"selectedCalendarsChanged",
|
||||
onLocalChange as EventListener
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(
|
||||
"selectedCalendarsChanged",
|
||||
onLocalChange as EventListener
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return calendars;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAppSelector } from "../app/hooks";
|
||||
import { useSelectedCalendars } from "../utils/storage/useSelectedCalendars";
|
||||
import {
|
||||
createWebSocketConnection,
|
||||
WebSocketWithCleanup,
|
||||
} from "./createWebSocketConnection";
|
||||
import { registerToCalendars } from "./ws/registerToCalendars";
|
||||
import { unregisterToCalendars } from "./ws/unregisterToCalendars";
|
||||
|
||||
export function WebSocketGate() {
|
||||
const socketRef = useRef<WebSocketWithCleanup | null>(null);
|
||||
const previousCalendarListRef = useRef<string[]>([]);
|
||||
|
||||
const isAuthenticated = useAppSelector((state) =>
|
||||
Boolean(state.user.userData && state.user.tokens)
|
||||
);
|
||||
const [isSocketOpen, setIsSocketOpen] = useState(false);
|
||||
|
||||
const calendarList = useSelectedCalendars();
|
||||
|
||||
// Manage WebSocket connection
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.cleanup();
|
||||
socketRef.current.close();
|
||||
socketRef.current = null;
|
||||
setIsSocketOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
const socket = await createWebSocketConnection();
|
||||
socketRef.current = socket;
|
||||
socket.addEventListener("close", () => {
|
||||
setIsSocketOpen(false);
|
||||
socketRef.current = null;
|
||||
});
|
||||
// Check if socket closed during setup
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
setIsSocketOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create WebSocket connection:", error);
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.cleanup();
|
||||
socketRef.current.close();
|
||||
socketRef.current = null;
|
||||
setIsSocketOpen(false);
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Register using a diff with previous calendars
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isSocketOpen ||
|
||||
!socketRef.current ||
|
||||
socketRef.current.readyState !== WebSocket.OPEN
|
||||
)
|
||||
return;
|
||||
|
||||
const currentPaths = calendarList.map((cal) => `/calendars/${cal}`);
|
||||
const previousPaths = previousCalendarListRef.current.map(
|
||||
(cal) => `/calendars/${cal}`
|
||||
);
|
||||
|
||||
// calendars to register
|
||||
const toRegister = currentPaths.filter(
|
||||
(path) => !previousPaths.includes(path)
|
||||
);
|
||||
|
||||
// calendars to unregister
|
||||
const toUnregister = previousPaths.filter(
|
||||
(path) => !currentPaths.includes(path)
|
||||
);
|
||||
|
||||
try {
|
||||
if (toRegister.length > 0) {
|
||||
registerToCalendars(socketRef.current, toRegister);
|
||||
}
|
||||
|
||||
if (toUnregister.length > 0) {
|
||||
unregisterToCalendars(socketRef.current, toUnregister);
|
||||
}
|
||||
|
||||
// Only update the ref if operations succeeded
|
||||
previousCalendarListRef.current = calendarList;
|
||||
} catch (error) {
|
||||
console.error("Failed to update calendar registrations:", error);
|
||||
}
|
||||
}, [isSocketOpen, calendarList]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { api } from "../../utils/apiUtils";
|
||||
import { WebSocketTicket } from "./types";
|
||||
|
||||
export async function fetchWebSocketTicket(): Promise<WebSocketTicket> {
|
||||
const response = await api.post("ws/ticket");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch WebSocket ticket");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface WebSocketTicket {
|
||||
clientAddress: string;
|
||||
value: string;
|
||||
generatedOn: string;
|
||||
validUntil: string;
|
||||
username: string;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { fetchWebSocketTicket } from "./api/fetchWebSocketTicket";
|
||||
import { WS_INBOUND_EVENTS } from "./protocols";
|
||||
|
||||
export interface WebSocketWithCleanup extends WebSocket {
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
export async function createWebSocketConnection(): Promise<WebSocketWithCleanup> {
|
||||
const wsBaseUrl =
|
||||
(window as any).WEBSOCKET_URL ??
|
||||
(window as any).CALENDAR_BASE_URL?.replace(
|
||||
/^http(s)?:/,
|
||||
(_: boolean, s: boolean) => (s ? "wss:" : "ws:")
|
||||
) ??
|
||||
"";
|
||||
|
||||
if (!wsBaseUrl) {
|
||||
throw new Error("WEBSOCKET_URL is not defined");
|
||||
}
|
||||
|
||||
const ticket = await fetchWebSocketTicket();
|
||||
|
||||
const socket = new WebSocket(
|
||||
`${wsBaseUrl}/ws?ticket=${encodeURIComponent(ticket.value)}`
|
||||
);
|
||||
|
||||
const CONNECTION_TIMEOUT_MS = 10_000;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
socket.removeEventListener(
|
||||
WS_INBOUND_EVENTS.CONNECTION_OPENED,
|
||||
openHandler
|
||||
);
|
||||
socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
|
||||
socket.close();
|
||||
reject(new Error("WebSocket connection timed out"));
|
||||
}, CONNECTION_TIMEOUT_MS);
|
||||
const openHandler = () => {
|
||||
console.log("WebSocket connection opened");
|
||||
clearTimeout(timeoutId);
|
||||
socket.removeEventListener(
|
||||
WS_INBOUND_EVENTS.CONNECTION_OPENED,
|
||||
openHandler
|
||||
);
|
||||
socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const errorHandler = (error: Event) => {
|
||||
console.error("WebSocket connection failed:", error);
|
||||
clearTimeout(timeoutId);
|
||||
socket.removeEventListener(
|
||||
WS_INBOUND_EVENTS.CONNECTION_OPENED,
|
||||
openHandler
|
||||
);
|
||||
socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
|
||||
reject(new Error("WebSocket connection failed"));
|
||||
};
|
||||
|
||||
socket.addEventListener(WS_INBOUND_EVENTS.CONNECTION_OPENED, openHandler);
|
||||
socket.addEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
|
||||
});
|
||||
|
||||
// Store references to event handlers so they can be cleaned up later
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log("WebSocket message received:", message);
|
||||
|
||||
// TODO: Handle different message types
|
||||
} catch (error) {
|
||||
console.error("Failed to parse WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeHandler = (event: CloseEvent) => {
|
||||
console.log("WebSocket closed:", event.code, event.reason);
|
||||
// Clean up all event listeners when socket closes
|
||||
cleanup();
|
||||
// TODO: Add reconnection logic
|
||||
};
|
||||
|
||||
const errorHandler = (error: Event) => {
|
||||
console.error("WebSocket error:", error);
|
||||
};
|
||||
|
||||
// Cleanup function to remove all event listeners
|
||||
const cleanup = () => {
|
||||
socket.removeEventListener(WS_INBOUND_EVENTS.MESSAGE, messageHandler);
|
||||
socket.removeEventListener(
|
||||
WS_INBOUND_EVENTS.CONNECTION_CLOSED,
|
||||
closeHandler
|
||||
);
|
||||
socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
|
||||
};
|
||||
|
||||
socket.addEventListener(WS_INBOUND_EVENTS.MESSAGE, messageHandler);
|
||||
socket.addEventListener(WS_INBOUND_EVENTS.CONNECTION_CLOSED, closeHandler);
|
||||
socket.addEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
|
||||
|
||||
// Attach cleanup method to socket
|
||||
const socketWithCleanup = socket as WebSocketWithCleanup;
|
||||
socketWithCleanup.cleanup = cleanup;
|
||||
|
||||
return socketWithCleanup;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// WebSocket event listeners (browser events)
|
||||
export const WS_INBOUND_EVENTS = {
|
||||
CONNECTION_OPENED: "open",
|
||||
MESSAGE: "message",
|
||||
ERROR: "error",
|
||||
CONNECTION_CLOSED: "close",
|
||||
} as const;
|
||||
|
||||
// WebSocket message types sent to server
|
||||
export const WS_OUTBOUND_EVENTS = {
|
||||
REGISTER_CLIENT: "register",
|
||||
UNREGISTER_CLIENT: "unregister",
|
||||
} as const;
|
||||
@@ -0,0 +1,16 @@
|
||||
export function registerToCalendars(
|
||||
socket: WebSocket,
|
||||
calendarURIList: string[]
|
||||
): void {
|
||||
if (socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("Cannot register: WebSocket is not open");
|
||||
}
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
register: calendarURIList,
|
||||
})
|
||||
);
|
||||
|
||||
console.log("Registered to calendars", calendarURIList);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function unregisterToCalendars(
|
||||
socket: WebSocket,
|
||||
calendarURIList: string[]
|
||||
): void {
|
||||
if (socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("Cannot unregister: WebSocket is not open");
|
||||
}
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
unregister: calendarURIList,
|
||||
})
|
||||
);
|
||||
|
||||
console.log("Unregistered to calendars", calendarURIList);
|
||||
}
|
||||
Reference in New Issue
Block a user