Files
KnockOutWhist-Frontend/src/services/ws.ts
Janis 64d528bf5c fix: FRO-29 Websocket Communication (#7)
Reviewed-on: #7
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-12-10 09:43:28 +01:00

237 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const api = window.__RUNTIME_CONFIG__?.API_URL;
// ---------- Types ---------------------------------------------------------
export type ServerMessage<T = any> = {
id?: string;
event?: string;
status?: "success" | "error";
data?: T;
error?: string;
};
export type ClientMessage<T = any> = {
id: string;
event: string;
data: T;
};
export type HandlerFn<T = any> = (data: T) => unknown | Promise<unknown>;
interface PendingEntry {
resolve: (data: any) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
let ws: WebSocket | null = null;
const pending = new Map<string, PendingEntry>();
const handlers = new Map<string, HandlerFn>();
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
function uuid(): string {
return crypto.randomUUID();
}
function failAllPending(reason: string) {
for (const [, entry] of pending.entries()) {
clearTimeout(entry.timer);
entry.reject(new Error(reason));
}
pending.clear();
}
function startHeartbeat() {
stopHeartbeat();
heartbeatTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
sendEventAndWait("ping", {}, 4000)
.then(() => console.debug("[WS] Heartbeat OK"))
.catch((err) => console.warn("[WS] Heartbeat failed:", err.message));
}
}, 5000);
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
function setupSocketHandlers(socket: WebSocket) {
socket.onmessage = async (raw) => {
console.debug("[WS] MESSAGE:", raw.data);
let msg: ServerMessage;
try {
msg = JSON.parse(raw.data);
} catch (err) {
console.warn("[WS] Bad JSON:", raw.data, err);
return;
}
const { id, event, status, data } = msg;
// RPC response branch
if (id && status) {
const entry = pending.get(id);
if (!entry) return;
pending.delete(id);
clearTimeout(entry.timer);
if (status === "success") {
entry.resolve(data ?? {});
} else {
entry.reject(new Error(msg.error || "Server returned error"));
}
return;
}
// Server event → handler branch
if (id && event) {
const handler = handlers.get(event);
const reply = (status: "success" | "error", error?: string) => {
if (socket.readyState !== WebSocket.OPEN) return;
const resp = { id, event, status, error };
socket.send(JSON.stringify(resp));
};
if (!handler) {
console.warn("[WS] No handler for event:", event);
reply("error", `No handler for '${event}'`);
return;
}
try {
await handler(data ?? {});
reply("success");
} catch (err) {
reply("error", (err as Error).message);
}
}
};
socket.onerror = (err) => {
console.error("[WS] ERROR:", err);
stopHeartbeat();
failAllPending("WebSocket error");
};
socket.onclose = (ev) => {
stopHeartbeat();
failAllPending("WebSocket closed");
if (ev.wasClean) {
console.log(`[WS] Closed cleanly: code=${ev.code} reason=${ev.reason}`);
} else {
console.warn("[WS] Connection died");
}
// You redirect here — if you dont want auto reconnect, keep as is.
location.href = "/mainmenu";
};
}
export function connectWebSocket(url?: string): Promise<void> {
if (!url) {
const loc = window.location;
const protocol = loc.protocol === "https:" ? "wss:" : "ws:";
url = `${protocol}//${api}/websocket`;
}
if (ws && ws.readyState === WebSocket.OPEN) {
return Promise.resolve();
}
if (ws && ws.readyState === WebSocket.CONNECTING) {
return new Promise((resolve, reject) => {
const prevOnOpen = ws!.onopen;
const prevOnError = ws!.onerror;
ws!.onopen = (ev) => {
if (prevOnOpen) prevOnOpen.call(ws!, ev);
resolve();
};
ws!.onerror = (err) => {
if (prevOnError) prevOnError.call(ws!, err);
reject(err);
};
});
}
// New connection
ws = new WebSocket(url);
setupSocketHandlers(ws);
return new Promise((resolve, reject) => {
ws!.onopen = () => {
console.log("[WS] Connected");
startHeartbeat();
resolve();
};
ws!.onerror = (err) => reject(err);
});
}
export function disconnectWebSocket(code = 1000, reason = "Client disconnect") {
stopHeartbeat();
failAllPending("Disconnected");
if (ws) {
try {
ws.close(code, reason);
} catch {}
ws = null;
}
}
export function sendEvent(event: string, data: any) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.warn("[WS] Send failed: not open");
return;
}
const message: ClientMessage = { id: uuid(), event, data };
console.debug("[WS] SEND:", message);
ws.send(JSON.stringify(message));
}
export function sendEventAndWait(
event: string,
data: any,
timeoutMs = 10000
): Promise<any> {
if (!ws || ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("WebSocket is not open"));
}
const id = uuid();
const message: ClientMessage = { id, event, data };
const promise = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`Timeout waiting for '${event}'`));
}, timeoutMs);
pending.set(id, { resolve, reject, timer });
});
console.debug("[WS] SEND (await):", message);
ws.send(JSON.stringify(message));
return promise;
}
export function onEvent(event: string, handler: HandlerFn) {
handlers.set(event, handler);
}
export const isWebSocketConnected = () =>
!!ws && ws.readyState === WebSocket.OPEN;