diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts index a4eb176..7c4d563 100644 --- a/apps/api/src/controllers/gameController.test.ts +++ b/apps/api/src/controllers/gameController.test.ts @@ -7,7 +7,46 @@ type ErrorResponse = { success: false; error: string }; type GameStartResponse = SuccessResponse; type GameAnswerResponse = SuccessResponse; -vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() })); +vi.mock("@lila/db", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getGameTerms: vi.fn(), getDistractors: vi.fn() }; +}); + +vi.mock("../lib/auth.js", () => ({ + auth: { + api: { + getSession: vi + .fn() + .mockResolvedValue({ + session: { + id: "session-1", + userId: "user-1", + token: "fake-token", + expiresAt: new Date(Date.now() + 1000 * 60 * 60), + createdAt: new Date(), + updatedAt: new Date(), + ipAddress: null, + userAgent: null, + }, + user: { + id: "user-1", + name: "Test User", + email: "test@test.com", + emailVerified: false, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }), + }, + handler: vi.fn(), + }, +})); + +vi.mock("better-auth/node", () => ({ + fromNodeHeaders: vi.fn().mockReturnValue({}), + toNodeHandler: vi.fn().mockReturnValue(vi.fn()), +})); import { getGameTerms, getDistractors } from "@lila/db"; import { createApp } from "../app.js"; diff --git a/apps/api/src/services/lobbyService.test.ts b/apps/api/src/services/lobbyService.test.ts new file mode 100644 index 0000000..c998c12 --- /dev/null +++ b/apps/api/src/services/lobbyService.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@lila/db", () => ({ + createLobby: vi.fn(), + getLobbyByCodeWithPlayers: vi.fn(), + addPlayer: vi.fn(), +})); + +import { + createLobby as createLobbyModel, + getLobbyByCodeWithPlayers, + addPlayer, +} from "@lila/db"; +import { createLobby, joinLobby } from "./lobbyService.js"; + +const mockCreateLobby = vi.mocked(createLobbyModel); +const mockGetLobbyByCodeWithPlayers = vi.mocked(getLobbyByCodeWithPlayers); +const mockAddPlayer = vi.mocked(addPlayer); + +const fakeLobby = { + id: "00000000-0000-4000-8000-000000000001", + code: "ABC123", + hostUserId: "user-1", + status: "waiting" as const, + createdAt: new Date(), +}; + +const fakeLobbyWithPlayers = { + ...fakeLobby, + players: [ + { + lobbyId: fakeLobby.id, + userId: "user-1", + score: 0, + joinedAt: new Date(), + user: { id: "user-1", name: "Alice" }, + }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockCreateLobby.mockResolvedValue(fakeLobby); + mockAddPlayer.mockResolvedValue({ + lobbyId: fakeLobby.id, + userId: "user-1", + score: 0, + joinedAt: new Date(), + }); + mockGetLobbyByCodeWithPlayers.mockResolvedValue(fakeLobbyWithPlayers); +}); + +describe("createLobby", () => { + it("creates a lobby and adds the host as the first player", async () => { + const result = await createLobby("user-1"); + + expect(mockCreateLobby).toHaveBeenCalledOnce(); + expect(mockAddPlayer).toHaveBeenCalledWith( + fakeLobby.id, + "user-1", + expect.any(Number), + ); + expect(result.id).toBe(fakeLobby.id); + }); + + it("retries on unique code collision", async () => { + const uniqueViolation = Object.assign(new Error("unique"), { + code: "23505", + }); + mockCreateLobby + .mockRejectedValueOnce(uniqueViolation) + .mockResolvedValueOnce(fakeLobby); + + const result = await createLobby("user-1"); + + expect(mockCreateLobby).toHaveBeenCalledTimes(2); + expect(result.id).toBe(fakeLobby.id); + }); + + it("throws after max retry attempts", async () => { + const uniqueViolation = Object.assign(new Error("unique"), { + code: "23505", + }); + mockCreateLobby.mockRejectedValue(uniqueViolation); + + await expect(createLobby("user-1")).rejects.toThrow( + "Could not generate a unique lobby code", + ); + }); +}); + +describe("joinLobby", () => { + it("returns lobby with players when join succeeds", async () => { + const fullLobby = { + ...fakeLobbyWithPlayers, + players: [ + ...fakeLobbyWithPlayers.players, + { + lobbyId: fakeLobby.id, + userId: "user-2", + score: 0, + joinedAt: new Date(), + user: { id: "user-2", name: "Bob" }, + }, + ], + }; + mockGetLobbyByCodeWithPlayers + .mockResolvedValueOnce(fakeLobbyWithPlayers) + .mockResolvedValueOnce(fullLobby); + + const result = await joinLobby("ABC123", "user-2"); + + expect(mockAddPlayer).toHaveBeenCalledWith( + fakeLobby.id, + "user-2", + expect.any(Number), + ); + expect(result.players).toHaveLength(2); + }); + + it("throws NotFoundError when lobby does not exist", async () => { + mockGetLobbyByCodeWithPlayers.mockResolvedValue(undefined); + + await expect(joinLobby("XXXXXX", "user-2")).rejects.toThrow( + "Lobby not found", + ); + }); + + it("throws ConflictError when lobby is not waiting", async () => { + mockGetLobbyByCodeWithPlayers.mockResolvedValue({ + ...fakeLobbyWithPlayers, + status: "in_progress" as const, + }); + + await expect(joinLobby("ABC123", "user-2")).rejects.toThrow( + "Game has already started", + ); + }); + + it("returns lobby idempotently when user already joined", async () => { + mockGetLobbyByCodeWithPlayers.mockResolvedValue({ + ...fakeLobbyWithPlayers, + players: [ + { + lobbyId: fakeLobby.id, + userId: "user-1", + score: 0, + joinedAt: new Date(), + user: { id: "user-1", name: "Alice" }, + }, + ], + }); + + const result = await joinLobby("ABC123", "user-1"); + + expect(mockAddPlayer).not.toHaveBeenCalled(); + expect(result.players).toHaveLength(1); + }); + + it("throws ConflictError when lobby is full", async () => { + mockGetLobbyByCodeWithPlayers.mockResolvedValue({ + ...fakeLobbyWithPlayers, + players: Array.from({ length: 4 }, (_, i) => ({ + lobbyId: fakeLobby.id, + userId: `user-${i}`, + score: 0, + joinedAt: new Date(), + user: { id: `user-${i}`, name: `Player ${i}` }, + })), + }); + + await expect(joinLobby("ABC123", "user-5")).rejects.toThrow( + "Lobby is full", + ); + }); +}); diff --git a/apps/api/src/ws/auth.test.ts b/apps/api/src/ws/auth.test.ts new file mode 100644 index 0000000..5866c8e --- /dev/null +++ b/apps/api/src/ws/auth.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { IncomingMessage } from "http"; +import { Duplex } from "stream"; + +vi.mock("better-auth/node", () => ({ + fromNodeHeaders: vi.fn().mockReturnValue({}), + toNodeHandler: vi.fn().mockReturnValue(vi.fn()), +})); + +vi.mock("../lib/auth.js", () => ({ + auth: { api: { getSession: vi.fn() }, handler: vi.fn() }, +})); + +import { auth } from "../lib/auth.js"; +import { handleUpgrade } from "./auth.js"; + +const mockGetSession = vi.mocked(auth.api.getSession); + +const fakeSession = { + session: { + id: "session-1", + userId: "user-1", + token: "fake-token", + expiresAt: new Date(Date.now() + 1000 * 60 * 60), + createdAt: new Date(), + updatedAt: new Date(), + ipAddress: null, + userAgent: null, + }, + user: { + id: "user-1", + name: "Test User", + email: "test@test.com", + emailVerified: false, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + }, +}; + +const makeMockSocket = () => { + const socket = new Duplex(); + socket._read = () => {}; + socket._write = (_chunk, _encoding, callback) => callback(); + const writeSpy = vi.spyOn(socket, "write").mockImplementation(() => true); + const destroySpy = vi + .spyOn(socket, "destroy") + .mockImplementation(() => socket); + return { socket, writeSpy, destroySpy }; +}; + +const makeMockRequest = () => { + const req = new IncomingMessage(null as never); + req.headers = {}; + return req; +}; + +const makeMockWss = () => ({ + handleUpgrade: vi.fn((_req, _socket, _head, cb: (ws: unknown) => void) => { + cb({ send: vi.fn(), on: vi.fn() }); + }), + emit: vi.fn(), +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("handleUpgrade", () => { + it("rejects with 401 when no session exists", async () => { + mockGetSession.mockResolvedValue(null); + const { socket, writeSpy, destroySpy } = makeMockSocket(); + const req = makeMockRequest(); + const wss = makeMockWss(); + await handleUpgrade(req, socket, Buffer.alloc(0), wss as never); + expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("401")); + expect(destroySpy).toHaveBeenCalled(); + expect(wss.handleUpgrade).not.toHaveBeenCalled(); + }); + + it("upgrades connection when session exists", async () => { + mockGetSession.mockResolvedValue(fakeSession); + const { socket, destroySpy } = makeMockSocket(); + const req = makeMockRequest(); + const wss = makeMockWss(); + await handleUpgrade(req, socket, Buffer.alloc(0), wss as never); + expect(wss.handleUpgrade).toHaveBeenCalled(); + expect(wss.emit).toHaveBeenCalledWith( + "connection", + expect.anything(), + req, + fakeSession, + ); + expect(destroySpy).not.toHaveBeenCalled(); + }); + + it("rejects with 500 when getSession throws", async () => { + mockGetSession.mockRejectedValue(new Error("DB error")); + const { socket, writeSpy, destroySpy } = makeMockSocket(); + const req = makeMockRequest(); + const wss = makeMockWss(); + await handleUpgrade(req, socket, Buffer.alloc(0), wss as never); + expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("500")); + expect(destroySpy).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/ws/handlers/lobbyHandlers.ts b/apps/api/src/ws/handlers/lobbyHandlers.ts index 12a59bf..2a7fa3b 100644 --- a/apps/api/src/ws/handlers/lobbyHandlers.ts +++ b/apps/api/src/ws/handlers/lobbyHandlers.ts @@ -6,6 +6,7 @@ import { deleteLobby, removePlayer, updateLobbyStatus, + getLobbyByIdWithPlayers, } from "@lila/db"; import { addConnection, @@ -87,7 +88,7 @@ export const handleLobbyStart = async ( user: User, ): Promise => { // Load lobby and validate - const lobby = await getLobbyByCodeWithPlayers(msg.lobbyId); + const lobby = await getLobbyByIdWithPlayers(msg.lobbyId); if (!lobby) { throw new NotFoundError("Lobby not found"); } diff --git a/apps/api/src/ws/router.test.ts b/apps/api/src/ws/router.test.ts new file mode 100644 index 0000000..7b6d90e --- /dev/null +++ b/apps/api/src/ws/router.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("./handlers/lobbyHandlers.js", () => ({ + handleLobbyJoin: vi.fn(), + handleLobbyLeave: vi.fn(), + handleLobbyStart: vi.fn(), +})); + +vi.mock("./handlers/gameHandlers.js", () => ({ + handleGameAnswer: vi.fn(), + handleGameReady: vi.fn(), +})); + +import { handleMessage } from "./router.js"; +import { + handleLobbyJoin, + handleLobbyLeave, + handleLobbyStart, +} from "./handlers/lobbyHandlers.js"; +import { handleGameAnswer, handleGameReady } from "./handlers/gameHandlers.js"; + +const mockHandleLobbyJoin = vi.mocked(handleLobbyJoin); +const mockHandleLobbyLeave = vi.mocked(handleLobbyLeave); +const mockHandleLobbyStart = vi.mocked(handleLobbyStart); +const mockHandleGameAnswer = vi.mocked(handleGameAnswer); +const mockHandleGameReady = vi.mocked(handleGameReady); + +const fakeWs = { send: vi.fn(), readyState: 1, OPEN: 1 }; + +const FAKE_LOBBY_ID = "00000000-0000-4000-8000-000000000001"; +const FAKE_QUESTION_ID = "00000000-0000-4000-8000-000000000002"; + +const fakeAuth = { + session: { + id: "session-1", + userId: "user-1", + token: "fake-token", + expiresAt: new Date(Date.now() + 1000 * 60 * 60), + createdAt: new Date(), + updatedAt: new Date(), + ipAddress: null, + userAgent: null, + }, + user: { + id: "user-1", + name: "Test User", + email: "test@test.com", + emailVerified: false, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("handleMessage", () => { + it("dispatches lobby:join to handleLobbyJoin", async () => { + const msg = JSON.stringify({ type: "lobby:join", code: "ABC123" }); + await handleMessage(fakeWs as never, msg, fakeAuth); + expect(mockHandleLobbyJoin).toHaveBeenCalledWith( + fakeWs, + { type: "lobby:join", code: "ABC123" }, + fakeAuth.user, + ); + }); + + it("dispatches lobby:leave to handleLobbyLeave", async () => { + const msg = JSON.stringify({ type: "lobby:leave", lobbyId: FAKE_LOBBY_ID }); + await handleMessage(fakeWs as never, msg, fakeAuth); + expect(mockHandleLobbyLeave).toHaveBeenCalled(); + }); + + it("dispatches lobby:start to handleLobbyStart", async () => { + const msg = JSON.stringify({ type: "lobby:start", lobbyId: FAKE_LOBBY_ID }); + await handleMessage(fakeWs as never, msg, fakeAuth); + expect(mockHandleLobbyStart).toHaveBeenCalled(); + }); + + it("dispatches game:answer to handleGameAnswer", async () => { + const msg = JSON.stringify({ + type: "game:answer", + lobbyId: FAKE_LOBBY_ID, + questionId: FAKE_QUESTION_ID, + selectedOptionId: 2, + }); + await handleMessage(fakeWs as never, msg, fakeAuth); + expect(mockHandleGameAnswer).toHaveBeenCalled(); + }); + + it("dispatches game:ready to handleGameReady", async () => { + const msg = JSON.stringify({ type: "game:ready", lobbyId: FAKE_LOBBY_ID }); + await handleMessage(fakeWs as never, msg, fakeAuth); + expect(mockHandleGameReady).toHaveBeenCalled(); + }); + + it("sends error message for invalid JSON", async () => { + await handleMessage(fakeWs as never, "not json", fakeAuth); + expect(fakeWs.send).toHaveBeenCalledWith( + expect.stringContaining("Invalid JSON"), + ); + }); + + it("sends error message for unknown message type", async () => { + const msg = JSON.stringify({ type: "unknown:type" }); + await handleMessage(fakeWs as never, msg, fakeAuth); + expect(fakeWs.send).toHaveBeenCalledWith( + expect.stringContaining("Invalid message format"), + ); + }); + + it("sends error message when handler throws AppError", async () => { + const { AppError } = await import("../errors/AppError.js"); + mockHandleLobbyJoin.mockRejectedValueOnce( + new AppError("Lobby not found", 404), + ); + const msg = JSON.stringify({ type: "lobby:join", code: "ABC123" }); + await handleMessage(fakeWs as never, msg, fakeAuth); + expect(fakeWs.send).toHaveBeenCalledWith( + expect.stringContaining("Lobby not found"), + ); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index b764028..9dc6b4f 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -1,3 +1,9 @@ import { defineConfig } from "vitest/config"; -export default defineConfig({ test: { environment: "node", globals: true } }); +export default defineConfig({ + test: { + environment: "node", + globals: true, + exclude: ["**/dist/**", "**/node_modules/**"], + }, +}); diff --git a/apps/web/src/lib/ws-client.ts b/apps/web/src/lib/ws-client.ts index 2925a58..94c8fd5 100644 --- a/apps/web/src/lib/ws-client.ts +++ b/apps/web/src/lib/ws-client.ts @@ -25,25 +25,27 @@ export class WsClient { public onClose: ((event: CloseEvent) => void) | null = null; connect(apiUrl: string): Promise { - // If already connected or connecting, resolve immediately - if ( - this.ws && - (this.ws.readyState === WebSocket.OPEN || - this.ws.readyState === WebSocket.CONNECTING) - ) { - return Promise.resolve(); - } return new Promise((resolve, reject) => { - if (this.ws) { - this.ws.close(); - this.ws = null; + if ( + this.ws && + (this.ws.readyState === WebSocket.OPEN || + this.ws.readyState === WebSocket.CONNECTING) + ) { + resolve(); + return; } - const wsUrl = apiUrl - .replace(/^https:\/\//, "wss://") - .replace(/^http:\/\//, "ws://"); + let wsUrl: string; + if (!apiUrl) { + wsUrl = "/ws"; + } else { + wsUrl = + apiUrl + .replace(/^https:\/\//, "wss://") + .replace(/^http:\/\//, "ws://") + "/ws"; + } - this.ws = new WebSocket(`${wsUrl}/ws`); + this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { resolve(); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ac22087..06c2243 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,4 +1,3 @@ -import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; import { RouterProvider, createRouter } from "@tanstack/react-router"; import "./index.css"; @@ -20,9 +19,5 @@ declare module "@tanstack/react-router" { const rootElement = document.getElementById("root")!; if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement); - root.render( - - - , - ); + root.render(); } diff --git a/apps/web/src/routes/multiplayer.tsx b/apps/web/src/routes/multiplayer.tsx index cf25437..7adffd6 100644 --- a/apps/web/src/routes/multiplayer.tsx +++ b/apps/web/src/routes/multiplayer.tsx @@ -1,6 +1,13 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; -import { WsProvider } from "../lib/ws-provider.js"; +import { useEffect } from "react"; import { authClient } from "../lib/auth-client.js"; +import { WsProvider } from "../lib/ws-provider.js"; +import { useWsConnect } from "../lib/ws-hooks.js"; + +const wsBaseUrl = + (import.meta.env["VITE_WS_URL"] as string) || + (import.meta.env["VITE_API_URL"] as string) || + ""; export const Route = createFileRoute("/multiplayer")({ component: MultiplayerLayout, @@ -13,9 +20,23 @@ export const Route = createFileRoute("/multiplayer")({ }, }); +function WsConnector() { + const connect = useWsConnect(); + + useEffect(() => { + void connect(wsBaseUrl).catch((err) => { + console.error("WebSocket connection failed:", err); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +} + function MultiplayerLayout() { return ( + ); diff --git a/apps/web/src/routes/multiplayer/game.$code.tsx b/apps/web/src/routes/multiplayer/game.$code.tsx index ebe602c..4d38d79 100644 --- a/apps/web/src/routes/multiplayer/game.$code.tsx +++ b/apps/web/src/routes/multiplayer/game.$code.tsx @@ -1,10 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useEffect, useState, useCallback, useRef } from "react"; -import { useWsClient, useWsConnect } from "../../lib/ws-hooks.js"; +import { useEffect, useState, useCallback } from "react"; +import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js"; import { QuestionCard } from "../../components/game/QuestionCard.js"; import { MultiplayerScoreScreen } from "../../components/multiplayer/MultiplayerScoreScreen.js"; import { GameRouteSearchSchema } from "@lila/shared"; - import type { WsGameQuestion, WsGameAnswerResult, @@ -12,8 +11,6 @@ import type { WsError, } from "@lila/shared"; -const API_URL = (import.meta.env["VITE_API_URL"] as string) || ""; - export const Route = createFileRoute("/multiplayer/game/$code")({ component: GamePage, validateSearch: GameRouteSearchSchema, @@ -25,7 +22,7 @@ function GamePage() { const { session } = Route.useRouteContext(); const currentUserId = session.user.id; const client = useWsClient(); - const connect = useWsConnect(); + const isConnected = useWsConnected(); const [currentQuestion, setCurrentQuestion] = useState( null, @@ -36,7 +33,6 @@ function GamePage() { const [gameFinished, setGameFinished] = useState(null); const [error, setError] = useState(null); const [hasAnswered, setHasAnswered] = useState(false); - const isConnectedRef = useRef(false); const handleGameQuestion = useCallback((msg: WsGameQuestion) => { setCurrentQuestion(msg); @@ -58,25 +54,14 @@ function GamePage() { }, []); useEffect(() => { + if (!isConnected) return; + client.on("game:question", handleGameQuestion); client.on("game:answer_result", handleAnswerResult); client.on("game:finished", handleGameFinished); client.on("error", handleWsError); - if (!client.isConnected()) { - void connect(API_URL) - .then(() => { - client.send({ type: "game:ready", lobbyId }); - isConnectedRef.current = true; - }) - .catch((err) => { - console.error("Failed to connect to WebSocket:", err); - setError("Could not connect to server. Please try again."); - }); - } else { - client.send({ type: "game:ready", lobbyId }); - isConnectedRef.current = true; - } + client.send({ type: "game:ready", lobbyId }); return () => { client.off("game:question", handleGameQuestion); @@ -84,10 +69,8 @@ function GamePage() { client.off("game:finished", handleGameFinished); client.off("error", handleWsError); }; - // stable deps: client, connect, lobbyId is a search param stable for - // this route. handlers wrapped in useCallback with stable deps. // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isConnected]); const handleAnswer = useCallback( (optionId: number) => { @@ -116,11 +99,11 @@ function GamePage() { } // Phase: loading - if (!currentQuestion) { + if (!isConnected || !currentQuestion) { return (

- {error ?? "Loading game..."} + {error ?? (isConnected ? "Loading game..." : "Connecting...")}

); diff --git a/apps/web/src/routes/multiplayer/lobby.$code.tsx b/apps/web/src/routes/multiplayer/lobby.$code.tsx index 57bb098..53bc9d2 100644 --- a/apps/web/src/routes/multiplayer/lobby.$code.tsx +++ b/apps/web/src/routes/multiplayer/lobby.$code.tsx @@ -1,10 +1,6 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useState, useCallback, useRef } from "react"; -import { - useWsClient, - useWsConnect, - useWsDisconnect, -} from "../../lib/ws-hooks.js"; +import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js"; import type { Lobby, WsLobbyState, @@ -12,8 +8,6 @@ import type { WsGameQuestion, } from "@lila/shared"; -const API_URL = (import.meta.env["VITE_API_URL"] as string) || ""; - export const Route = createFileRoute("/multiplayer/lobby/$code")({ component: LobbyPage, }); @@ -24,13 +18,11 @@ function LobbyPage() { const currentUserId = session.user.id; const navigate = useNavigate(); const client = useWsClient(); - const connect = useWsConnect(); - const disconnect = useWsDisconnect(); + const isConnected = useWsConnected(); const [lobby, setLobby] = useState(null); const [error, setError] = useState(null); const [isStarting, setIsStarting] = useState(false); - const lobbyIdRef = useRef(null); const handleLobbyState = useCallback((msg: WsLobbyState) => { @@ -56,31 +48,24 @@ function LobbyPage() { }, []); useEffect(() => { + if (!isConnected) return; + client.on("lobby:state", handleLobbyState); client.on("game:question", handleGameQuestion); client.on("error", handleWsError); - void connect(API_URL) - .then(() => { - client.send({ type: "lobby:join", code }); - }) - .catch((err) => { - console.error("Failed to connect to WebSocket:", err); - setError("Could not connect to server. Please try again."); - }); + client.send({ type: "lobby:join", code }); return () => { client.off("lobby:state", handleLobbyState); client.off("game:question", handleGameQuestion); client.off("error", handleWsError); - client.send({ type: "lobby:leave", lobbyId: lobby?.id ?? "" }); - disconnect(); + if (lobbyIdRef.current) { + client.send({ type: "lobby:leave", lobbyId: lobbyIdRef.current }); + } }; - // Effect runs once on mount. All referenced values are stable: - // client/connect/disconnect from context (useCallback), handlers - // wrapped in useCallback, code is a URL param. lobbyIdRef is a ref. // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isConnected]); const handleStart = useCallback(() => { if (!lobby) return; @@ -88,11 +73,11 @@ function LobbyPage() { client.send({ type: "lobby:start", lobbyId: lobby.id }); }, [lobby, client]); - if (!lobby) { + if (!isConnected || !lobby) { return (

- {error ?? "Connecting..."} + {error ?? (isConnected ? "Joining lobby..." : "Connecting...")}

); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a6e9dbd..00d768a 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,5 +10,22 @@ export default defineConfig({ react(), tailwindcss(), ], - server: { proxy: { "/api": "http://localhost:3000" } }, + server: { + proxy: { + "/ws": { + target: "http://localhost:3000", + ws: true, + rewriteWsOrigin: true, + configure: (proxy) => { + proxy.on("error", (err) => { + console.log("[ws proxy error]", err.message); + }); + proxy.on("proxyReqWs", (_proxyReq, req) => { + console.log("[ws proxy] forwarding", req.url); + }); + }, + }, + "/api": { target: "http://localhost:3000", changeOrigin: true }, + }, + }, }); diff --git a/packages/db/src/models/lobbyModel.ts b/packages/db/src/models/lobbyModel.ts index 7aa02d2..264e527 100644 --- a/packages/db/src/models/lobbyModel.ts +++ b/packages/db/src/models/lobbyModel.ts @@ -37,6 +37,17 @@ export const getLobbyByCodeWithPlayers = async ( }); }; +export const getLobbyByIdWithPlayers = async ( + lobbyId: string, +): Promise => { + return db.query.lobbies.findFirst({ + where: eq(lobbies.id, lobbyId), + with: { + players: { with: { user: { columns: { id: true, name: true } } } }, + }, + }); +}; + export const updateLobbyStatus = async ( lobbyId: string, status: LobbyStatus,