diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts index 7c4d563..a4eb176 100644 --- a/apps/api/src/controllers/gameController.test.ts +++ b/apps/api/src/controllers/gameController.test.ts @@ -7,46 +7,7 @@ type ErrorResponse = { success: false; error: string }; type GameStartResponse = SuccessResponse; type GameAnswerResponse = SuccessResponse; -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()), -})); +vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() })); import { getGameTerms, getDistractors } from "@lila/db"; import { createApp } from "../app.js"; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 2cdeb0e..c2b6d34 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -9,6 +9,6 @@ const server = createServer(app); setupWebSocket(server); -server.listen(PORT, () => { +app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); }); diff --git a/apps/api/src/services/lobbyService.test.ts b/apps/api/src/services/lobbyService.test.ts deleted file mode 100644 index c998c12..0000000 --- a/apps/api/src/services/lobbyService.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -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/services/lobbyService.ts b/apps/api/src/services/lobbyService.ts index 985b776..3e307ef 100644 --- a/apps/api/src/services/lobbyService.ts +++ b/apps/api/src/services/lobbyService.ts @@ -28,9 +28,7 @@ export const createLobby = async (hostUserId: string): Promise => { for (let i = 0; i < MAX_CODE_ATTEMPTS; i++) { const code = generateLobbyCode(); try { - const lobby = await createLobbyModel(code, hostUserId); - await addPlayer(lobby.id, hostUserId, MAX_LOBBY_PLAYERS); - return lobby; + return await createLobbyModel(code, hostUserId); } catch (err) { if (isUniqueViolation(err)) continue; throw err; diff --git a/apps/api/src/ws/auth.test.ts b/apps/api/src/ws/auth.test.ts deleted file mode 100644 index 5866c8e..0000000 --- a/apps/api/src/ws/auth.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -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 2a7fa3b..12a59bf 100644 --- a/apps/api/src/ws/handlers/lobbyHandlers.ts +++ b/apps/api/src/ws/handlers/lobbyHandlers.ts @@ -6,7 +6,6 @@ import { deleteLobby, removePlayer, updateLobbyStatus, - getLobbyByIdWithPlayers, } from "@lila/db"; import { addConnection, @@ -88,7 +87,7 @@ export const handleLobbyStart = async ( user: User, ): Promise => { // Load lobby and validate - const lobby = await getLobbyByIdWithPlayers(msg.lobbyId); + const lobby = await getLobbyByCodeWithPlayers(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 deleted file mode 100644 index 7b6d90e..0000000 --- a/apps/api/src/ws/router.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -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 9dc6b4f..b764028 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -1,9 +1,3 @@ import { defineConfig } from "vitest/config"; -export default defineConfig({ - test: { - environment: "node", - globals: true, - exclude: ["**/dist/**", "**/node_modules/**"], - }, -}); +export default defineConfig({ test: { environment: "node", globals: true } }); diff --git a/apps/web/src/lib/ws-client.ts b/apps/web/src/lib/ws-client.ts index 94c8fd5..f1da6cc 100644 --- a/apps/web/src/lib/ws-client.ts +++ b/apps/web/src/lib/ws-client.ts @@ -26,26 +26,16 @@ export class WsClient { connect(apiUrl: string): Promise { return new Promise((resolve, reject) => { - if ( - this.ws && - (this.ws.readyState === WebSocket.OPEN || - this.ws.readyState === WebSocket.CONNECTING) - ) { - resolve(); - return; + if (this.ws) { + this.ws.close(); + this.ws = null; } - let wsUrl: string; - if (!apiUrl) { - wsUrl = "/ws"; - } else { - wsUrl = - apiUrl - .replace(/^https:\/\//, "wss://") - .replace(/^http:\/\//, "ws://") + "/ws"; - } + const wsUrl = apiUrl + .replace(/^https:\/\//, "wss://") + .replace(/^http:\/\//, "ws://"); - this.ws = new WebSocket(wsUrl); + this.ws = new WebSocket(`${wsUrl}/ws`); this.ws.onopen = () => { resolve(); diff --git a/apps/web/src/lib/ws-provider.tsx b/apps/web/src/lib/ws-provider.tsx index b4a56d3..1b34bd6 100644 --- a/apps/web/src/lib/ws-provider.tsx +++ b/apps/web/src/lib/ws-provider.tsx @@ -9,8 +9,6 @@ export const WsProvider = ({ children }: { children: ReactNode }) => { const [isConnected, setIsConnected] = useState(false); const connect = useCallback(async (url: string): Promise => { - if (wsClient.isConnected()) return; - wsClient.onClose = () => setIsConnected(false); wsClient.onError = () => setIsConnected(false); try { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 06c2243..ac22087 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,3 +1,4 @@ +import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; import { RouterProvider, createRouter } from "@tanstack/react-router"; import "./index.css"; @@ -19,5 +20,9 @@ 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 7adffd6..cf25437 100644 --- a/apps/web/src/routes/multiplayer.tsx +++ b/apps/web/src/routes/multiplayer.tsx @@ -1,13 +1,6 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; -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) || - ""; +import { authClient } from "../lib/auth-client.js"; export const Route = createFileRoute("/multiplayer")({ component: MultiplayerLayout, @@ -20,23 +13,9 @@ 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 4d38d79..ebe602c 100644 --- a/apps/web/src/routes/multiplayer/game.$code.tsx +++ b/apps/web/src/routes/multiplayer/game.$code.tsx @@ -1,9 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useEffect, useState, useCallback } from "react"; -import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js"; +import { useEffect, useState, useCallback, useRef } from "react"; +import { useWsClient, useWsConnect } 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, @@ -11,6 +12,8 @@ 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, @@ -22,7 +25,7 @@ function GamePage() { const { session } = Route.useRouteContext(); const currentUserId = session.user.id; const client = useWsClient(); - const isConnected = useWsConnected(); + const connect = useWsConnect(); const [currentQuestion, setCurrentQuestion] = useState( null, @@ -33,6 +36,7 @@ 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); @@ -54,14 +58,25 @@ 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); - client.send({ type: "game:ready", lobbyId }); + 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; + } return () => { client.off("game:question", handleGameQuestion); @@ -69,8 +84,10 @@ 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) => { @@ -99,11 +116,11 @@ function GamePage() { } // Phase: loading - if (!isConnected || !currentQuestion) { + if (!currentQuestion) { return (

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

); diff --git a/apps/web/src/routes/multiplayer/lobby.$code.tsx b/apps/web/src/routes/multiplayer/lobby.$code.tsx index 53bc9d2..57bb098 100644 --- a/apps/web/src/routes/multiplayer/lobby.$code.tsx +++ b/apps/web/src/routes/multiplayer/lobby.$code.tsx @@ -1,6 +1,10 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useState, useCallback, useRef } from "react"; -import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js"; +import { + useWsClient, + useWsConnect, + useWsDisconnect, +} from "../../lib/ws-hooks.js"; import type { Lobby, WsLobbyState, @@ -8,6 +12,8 @@ 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, }); @@ -18,11 +24,13 @@ function LobbyPage() { const currentUserId = session.user.id; const navigate = useNavigate(); const client = useWsClient(); - const isConnected = useWsConnected(); + const connect = useWsConnect(); + const disconnect = useWsDisconnect(); const [lobby, setLobby] = useState(null); const [error, setError] = useState(null); const [isStarting, setIsStarting] = useState(false); + const lobbyIdRef = useRef(null); const handleLobbyState = useCallback((msg: WsLobbyState) => { @@ -48,24 +56,31 @@ function LobbyPage() { }, []); useEffect(() => { - if (!isConnected) return; - client.on("lobby:state", handleLobbyState); client.on("game:question", handleGameQuestion); client.on("error", handleWsError); - client.send({ type: "lobby:join", code }); + 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."); + }); return () => { client.off("lobby:state", handleLobbyState); client.off("game:question", handleGameQuestion); client.off("error", handleWsError); - if (lobbyIdRef.current) { - client.send({ type: "lobby:leave", lobbyId: lobbyIdRef.current }); - } + client.send({ type: "lobby:leave", lobbyId: lobby?.id ?? "" }); + disconnect(); }; + // 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; @@ -73,11 +88,11 @@ function LobbyPage() { client.send({ type: "lobby:start", lobbyId: lobby.id }); }, [lobby, client]); - if (!isConnected || !lobby) { + if (!lobby) { return (

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

); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 00d768a..a6e9dbd 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,22 +10,5 @@ export default defineConfig({ react(), tailwindcss(), ], - 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 }, - }, - }, + server: { proxy: { "/api": "http://localhost:3000" } }, }); diff --git a/packages/db/src/models/lobbyModel.ts b/packages/db/src/models/lobbyModel.ts index 264e527..7aa02d2 100644 --- a/packages/db/src/models/lobbyModel.ts +++ b/packages/db/src/models/lobbyModel.ts @@ -37,17 +37,6 @@ 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,