[#451] added websocket connexion (#453)

* [#451] added websocket connexion

* [#451] added tests

* [#451] add missing cleanup for web socket causing memory leaks
This commit is contained in:
Camille Moussu
2026-01-14 15:17:29 +01:00
committed by GitHub
parent 8b3afc1737
commit 2fa8a7f16a
18 changed files with 1204 additions and 4 deletions
+104
View File
@@ -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;
}
+12
View File
@@ -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();
}
+7
View File
@@ -0,0 +1,7 @@
export interface WebSocketTicket {
clientAddress: string;
value: string;
generatedOn: string;
validUntil: string;
username: string;
}
+107
View File
@@ -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;
}
+13
View File
@@ -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;
+16
View File
@@ -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);
}
+16
View File
@@ -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);
}