From 8f3b355773cf8fd7c54a858a62ff63fada669270 Mon Sep 17 00:00:00 2001 From: Janis Date: Wed, 10 Dec 2025 09:42:17 +0100 Subject: [PATCH] fix: FRO-29 Websocket Communication --- src/composables/useWebsocket.ts | 55 ++++++++ src/services/ws.ts | 236 ++++++++++++++++++++++++++++++++ src/types/GameSubTypes.ts | 47 +++++++ src/types/GameTypes.ts | 47 +++++++ 4 files changed, 385 insertions(+) create mode 100644 src/composables/useWebsocket.ts create mode 100644 src/services/ws.ts create mode 100644 src/types/GameSubTypes.ts create mode 100644 src/types/GameTypes.ts diff --git a/src/composables/useWebsocket.ts b/src/composables/useWebsocket.ts new file mode 100644 index 0000000..8de496d --- /dev/null +++ b/src/composables/useWebsocket.ts @@ -0,0 +1,55 @@ +import { ref, onMounted, onBeforeUnmount } from "vue"; +import { + connectWebSocket, + disconnectWebSocket, + sendEvent, + sendEventAndWait, + onEvent, + isWebSocketConnected, +} from "@/services/ws"; + +export function useWebSocket() { + const isConnected = ref(isWebSocketConnected()); + const lastMessage = ref(null); + + const lastError = ref(null); + + async function safeConnect(url?: string) { + return connectWebSocket(url) + .then(() => { + isConnected.value = true; + }) + .catch((err) => { + lastError.value = err?.message ?? "Unknown WS error"; + throw err; + }); + } + + function useEvent(event: string, handler: (data: T) => void) { + const wrapped = (data: T) => { + lastMessage.value = { event, data }; + handler(data); + }; + + onMounted(() => { + onEvent(event, wrapped); + }); + + onBeforeUnmount(() => { + onEvent(event, () => {}); + }); + } + + return { + isConnected, + lastMessage, + lastError, + + connect: safeConnect, + disconnect: disconnectWebSocket, + send: sendEvent, + sendAndWait: sendEventAndWait, + + useEvent, + }; +} diff --git a/src/services/ws.ts b/src/services/ws.ts new file mode 100644 index 0000000..220aa72 --- /dev/null +++ b/src/services/ws.ts @@ -0,0 +1,236 @@ +const api = window.__RUNTIME_CONFIG__?.API_URL; + +// ---------- Types --------------------------------------------------------- + +export type ServerMessage = { + id?: string; + event?: string; + status?: "success" | "error"; + data?: T; + error?: string; +}; + +export type ClientMessage = { + id: string; + event: string; + data: T; +}; + +export type HandlerFn = (data: T) => unknown | Promise; + +interface PendingEntry { + resolve: (data: any) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +let ws: WebSocket | null = null; + +const pending = new Map(); +const handlers = new Map(); + +let heartbeatTimer: ReturnType | 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 don’t want auto reconnect, keep as is. + location.href = "/mainmenu"; + }; +} + +export function connectWebSocket(url?: string): Promise { + 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 { + 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; diff --git a/src/types/GameSubTypes.ts b/src/types/GameSubTypes.ts new file mode 100644 index 0000000..be51be6 --- /dev/null +++ b/src/types/GameSubTypes.ts @@ -0,0 +1,47 @@ +type Card = { + identifier: string + path: string + idx: number | null +} + +type Hand = { + cards: Card[] +} + +type Player = { + id: string + name: string + dogLife: string +} + +type PlayerQueue = { + currentPlayer: Player | null + players: Player[] +} + +type PodiumPlayer = { + player: Player + position: number + roundsWon: number + tricksWon: number +} + +type Round = { + trumpSuit: Card + firstRound: boolean + trickList: Trick[] +} + +type Trick = { + cards: { [player: string]: Card } + firstCard: Card | null + winner: Player | null +} + +type User = { + id: string + username: string + host: boolean +} + +export type { Card, Hand, Player, PlayerQueue, PodiumPlayer, Round, Trick, User } diff --git a/src/types/GameTypes.ts b/src/types/GameTypes.ts new file mode 100644 index 0000000..782461b --- /dev/null +++ b/src/types/GameTypes.ts @@ -0,0 +1,47 @@ +import type { + Hand, + Player, + PlayerQueue, + PodiumPlayer, + Round, + Trick, + User +} from "@/types/GameSubTypes.ts"; + + +type GameInfo = { + gameId: string + self: Player | null + hand: Hand | null + playerQueue: PlayerQueue + currentTrick: Trick | null + currentRound: Round | null +} + +type LobbyInfo = { + gameId: string + users: User[] + self: User + maxPlayers: number +} + +type TieInfo = { + gameId: string + currentPlayer: Player | null + self: Player | null + tiedPlayers: Player[] + highestAmount: number +} + +type TrumpInfo = { + gameId: string + chooser: Player | null + self: Player | null + selfHand: Hand | null +} + +type WonInfo = { + gameId: string + winner: PodiumPlayer | null + allPlayers: PodiumPlayer[] +}