From 29faa18731bfc021577c47ba2a4e594791df8b64 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Mon, 8 Jun 2026 15:16:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(server,web):=20first=20real-time=20cycle?= =?UTF-8?q?=20=E2=80=94=20room=20create/join=20+=20lobby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server (@nerdware/server): - RoomManager: in-memory rooms (Map), code generation, create/join, disconnect handling, public snapshot projection (no secrets) - Socket.IO handlers: room:create / room:join with typed acks, room:state broadcast on every change, lobby:updateSettings (host-only), player:left on disconnect Web (web): - typed Socket.IO client + Zustand store mirroring room:state - wouter routing: home (create/join) + minimal lobby (live player list) - @nerdware/shared wired into the client Roadmap V1 step 2. Verified end-to-end (create/join/broadcast/disconnect). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/handlers.ts | 106 +++++++++++++++++++++++++ apps/server/src/rooms.ts | 145 +++++++++++++++++++++++++++++++++++ apps/server/src/socket.ts | 10 ++- apps/web/.env.example | 1 + apps/web/package.json | 6 +- apps/web/src/App.tsx | 26 +++---- apps/web/src/lib/socket.ts | 13 ++++ apps/web/src/pages/home.tsx | 106 +++++++++++++++++++++++++ apps/web/src/pages/lobby.tsx | 71 +++++++++++++++++ apps/web/src/store/room.ts | 63 +++++++++++++++ bun.lock | 20 +++++ 11 files changed, 549 insertions(+), 18 deletions(-) create mode 100644 apps/server/src/handlers.ts create mode 100644 apps/server/src/rooms.ts create mode 100644 apps/web/.env.example create mode 100644 apps/web/src/lib/socket.ts create mode 100644 apps/web/src/pages/home.tsx create mode 100644 apps/web/src/pages/lobby.tsx create mode 100644 apps/web/src/store/room.ts diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts new file mode 100644 index 0000000..8526002 --- /dev/null +++ b/apps/server/src/handlers.ts @@ -0,0 +1,106 @@ +// Handlers Socket.IO du cycle lobby. Enregistrés par socket à la connexion. + +import type { Socket } from "socket.io" +import type { + ClientToServerEvents, + ErrorPayload, + InterServerEvents, + ServerToClientEvents, + SocketData, +} from "@nerdware/shared" +import type { IoServer } from "./socket" +import { RoomError, RoomManager, type ServerRoom } from "./rooms" + +type AppSocket = Socket< + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents, + SocketData +> + +const MAX_NAME_LENGTH = 24 + +function cleanName(raw: unknown): string | null { + if (typeof raw !== "string") { + return null + } + const name = raw.trim().slice(0, MAX_NAME_LENGTH) + return name.length > 0 ? name : null +} + +function toErrorPayload(err: unknown): ErrorPayload { + if (err instanceof RoomError) { + return { code: err.code, message: err.message } + } + return { code: "INTERNAL", message: "Une erreur est survenue." } +} + +export function registerRoomHandlers( + io: IoServer, + socket: AppSocket, + rooms: RoomManager +): void { + const broadcastState = (room: ServerRoom) => { + io.to(room.code).emit("room:state", rooms.toSnapshot(room)) + } + + socket.on("room:create", (payload, ack) => { + const name = cleanName(payload?.playerName) + if (!name) { + ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } }) + return + } + try { + const { room, player } = rooms.create(name, socket.id) + socket.data.playerId = player.id + socket.data.roomCode = room.code + socket.join(room.code) + ack({ ok: true, data: { roomCode: room.code, playerId: player.id } }) + broadcastState(room) + } catch (err) { + ack({ ok: false, error: toErrorPayload(err) }) + } + }) + + socket.on("room:join", (payload, ack) => { + const name = cleanName(payload?.playerName) + const code = typeof payload?.roomCode === "string" ? payload.roomCode.trim().toUpperCase() : "" + if (!name) { + ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } }) + return + } + try { + const { room, player } = rooms.join(code, name, socket.id) + socket.data.playerId = player.id + socket.data.roomCode = room.code + socket.join(room.code) + ack({ ok: true, data: { roomCode: room.code, playerId: player.id } }) + broadcastState(room) + } catch (err) { + ack({ ok: false, error: toErrorPayload(err) }) + } + }) + + socket.on("lobby:updateSettings", (payload) => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || room.hostId !== socket.data.playerId) { + return + } + room.settings = { + blindtestMode: payload.blindtestMode, + roundDuration: payload.roundDuration, + rounds: payload.rounds, + } + broadcastState(room) + }) + + socket.on("disconnect", () => { + const affected = rooms.handleDisconnect(socket.id) + if (!affected) { + return + } + io.to(affected.room.code).emit("player:left", { playerId: affected.playerId }) + broadcastState(affected.room) + }) +} diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts new file mode 100644 index 0000000..c5dd2fd --- /dev/null +++ b/apps/server/src/rooms.ts @@ -0,0 +1,145 @@ +// Gestion des rooms en mémoire. Source de vérité côté serveur. +// Pas de Redis en V1 : une simple Map suffit pour jouer entre potes. + +import { randomUUID } from "node:crypto" +import { + DEFAULT_ROOM_SETTINGS, + type RoomSettings, + type RoomSnapshot, + type RoomStatus, +} from "@nerdware/shared" + +/** Joueur côté serveur : porte le socketId, jamais exposé au client. */ +export interface ServerPlayer { + id: string + name: string + connected: boolean + socketId: string | null +} + +/** Room côté serveur : état runtime complet (le snapshot public en dérive). */ +export interface ServerRoom { + code: string + status: RoomStatus + hostId: string + players: Map + settings: RoomSettings + scores: Map + currentRound: number +} + +/** Codes lisibles, sans caractères ambigus (0/O, 1/I/L). */ +const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" +const CODE_LENGTH = 4 + +export class RoomManager { + private rooms = new Map() + + /** Crée une room et y place le créateur comme hôte. */ + create(playerName: string, socketId: string): { room: ServerRoom; player: ServerPlayer } { + const code = this.generateCode() + const player = this.makePlayer(playerName, socketId) + const room: ServerRoom = { + code, + status: "lobby", + hostId: player.id, + players: new Map([[player.id, player]]), + settings: { ...DEFAULT_ROOM_SETTINGS }, + scores: new Map([[player.id, 0]]), + currentRound: -1, + } + this.rooms.set(code, room) + return { room, player } + } + + /** Ajoute un joueur à une room existante. Lève une erreur si introuvable ou fermée. */ + join( + code: string, + playerName: string, + socketId: string + ): { room: ServerRoom; player: ServerPlayer } { + const room = this.rooms.get(code) + if (!room) { + throw new RoomError("ROOM_NOT_FOUND", "Cette room n'existe pas.") + } + if (room.status !== "lobby") { + throw new RoomError("ROOM_IN_PROGRESS", "La partie a déjà commencé.") + } + const player = this.makePlayer(playerName, socketId) + room.players.set(player.id, player) + room.scores.set(player.id, 0) + return { room, player } + } + + get(code: string): ServerRoom | undefined { + return this.rooms.get(code) + } + + /** + * Marque le joueur d'un socket comme déconnecté. + * Retourne la room affectée et le playerId, ou null si non rattaché. + * La room et le joueur restent en mémoire pour permettre un futur rejoin. + */ + handleDisconnect(socketId: string): { room: ServerRoom; playerId: string } | null { + for (const room of this.rooms.values()) { + for (const player of room.players.values()) { + if (player.socketId === socketId) { + player.connected = false + player.socketId = null + return { room, playerId: player.id } + } + } + } + return null + } + + /** Projette la room vers la vue publique diffusée aux clients (sans secrets). */ + toSnapshot(room: ServerRoom): RoomSnapshot { + return { + code: room.code, + status: room.status, + hostId: room.hostId, + players: [...room.players.values()].map((p) => ({ + id: p.id, + name: p.name, + connected: p.connected, + })), + settings: room.settings, + scores: [...room.scores.entries()].map(([playerId, score]) => ({ + playerId, + score, + })), + currentRound: room.currentRound, + totalRounds: room.settings.rounds.length, + } + } + + private makePlayer(name: string, socketId: string): ServerPlayer { + return { id: randomUUID(), name, connected: true, socketId } + } + + private generateCode(): string { + // Très peu de collisions à cette échelle ; on retire quand même par sûreté. + for (let attempt = 0; attempt < 100; attempt++) { + let code = "" + for (let i = 0; i < CODE_LENGTH; i++) { + code += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)] + } + if (!this.rooms.has(code)) { + return code + } + } + throw new RoomError("CODE_EXHAUSTED", "Impossible de générer un code de room.") + } +} + +/** Erreur métier portant un code stable pour le client. */ +export class RoomError extends Error { + constructor( + public code: string, + message: string + ) { + super(message) + this.name = "RoomError" + } +} diff --git a/apps/server/src/socket.ts b/apps/server/src/socket.ts index a930c93..e03df05 100644 --- a/apps/server/src/socket.ts +++ b/apps/server/src/socket.ts @@ -7,6 +7,8 @@ import type { SocketData, } from "@nerdware/shared" import { env } from "./env" +import { registerRoomHandlers } from "./handlers" +import { RoomManager } from "./rooms" /** Serveur Socket.IO entièrement typé via les events partagés. */ export type IoServer = Server< @@ -16,10 +18,7 @@ export type IoServer = Server< SocketData > -/** - * Crée et attache le serveur Socket.IO au serveur HTTP de Fastify. - * La logique des rooms (room:create / room:join) arrive à l'étape 2. - */ +/** Crée et attache le serveur Socket.IO au serveur HTTP de Fastify. */ export function createSocketServer(httpServer: HttpServer): IoServer { const io: IoServer = new Server(httpServer, { cors: { @@ -28,8 +27,11 @@ export function createSocketServer(httpServer: HttpServer): IoServer { }, }) + const rooms = new RoomManager() + io.on("connection", (socket) => { console.log(`[ws] connect ${socket.id}`) + registerRoomHandlers(io, socket, rooms) socket.on("disconnect", (reason) => { console.log(`[ws] disconnect ${socket.id} (${reason})`) }) diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..26e6ed8 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +VITE_SERVER_URL=http://localhost:3001 diff --git a/apps/web/package.json b/apps/web/package.json index c62537d..2c7c96e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,10 +12,14 @@ "preview": "vite preview" }, "dependencies": { + "@nerdware/shared": "workspace:*", "@workspace/ui": "workspace:*", "lucide-react": "^1.17.0", "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "socket.io-client": "^4.8.1", + "wouter": "^3.7.1", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^10", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 55a08a9..dce07e3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,19 +1,19 @@ -import { Button } from "@workspace/ui/components/button" +import { Route, Switch } from "wouter" +import { HomePage } from "@/pages/home" +import { LobbyPage } from "@/pages/lobby" export function App() { return ( -
-
-
-

Project ready!

-

You may now add components and start building.

-

We've already added the button component for you.

- + + + + {(params) => } + + +
+

Page introuvable.

-
- (Press d to toggle dark mode) -
-
-
+ + ) } diff --git a/apps/web/src/lib/socket.ts b/apps/web/src/lib/socket.ts new file mode 100644 index 0000000..354fed9 --- /dev/null +++ b/apps/web/src/lib/socket.ts @@ -0,0 +1,13 @@ +import { io, type Socket } from "socket.io-client" +import type { + ClientToServerEvents, + ServerToClientEvents, +} from "@nerdware/shared" + +/** Socket typé : . Désync = erreur de compile. */ +export type AppSocket = Socket + +const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001" + +// Une seule connexion partagée pour toute l'app (reconnexion auto par défaut). +export const socket: AppSocket = io(SERVER_URL, { autoConnect: true }) diff --git a/apps/web/src/pages/home.tsx b/apps/web/src/pages/home.tsx new file mode 100644 index 0000000..c9d305c --- /dev/null +++ b/apps/web/src/pages/home.tsx @@ -0,0 +1,106 @@ +import { useState } from "react" +import { useLocation } from "wouter" +import { Button } from "@workspace/ui/components/button" +import { useRoomStore } from "@/store/room" + +const inputClass = + "border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" + +export function HomePage() { + const [, navigate] = useLocation() + const createRoom = useRoomStore((s) => s.createRoom) + const joinRoom = useRoomStore((s) => s.joinRoom) + const connected = useRoomStore((s) => s.connected) + + const [name, setName] = useState("") + const [code, setCode] = useState("") + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + const canSubmit = name.trim().length > 0 && connected && !busy + + async function handleCreate() { + setError(null) + setBusy(true) + try { + const { roomCode } = await createRoom(name.trim()) + navigate(`/room/${roomCode}`) + } catch (err) { + setError((err as { message?: string }).message ?? "Erreur") + } finally { + setBusy(false) + } + } + + async function handleJoin() { + setError(null) + setBusy(true) + try { + const { roomCode } = await joinRoom(code.trim().toUpperCase(), name.trim()) + navigate(`/room/${roomCode}`) + } catch (err) { + setError((err as { message?: string }).message ?? "Erreur") + } finally { + setBusy(false) + } + } + + return ( +
+
+
+

+ NerdWare +

+

+ Party game culture geek +

+
+ + setName(e.target.value)} + /> + + + +
+
+ ou rejoindre +
+
+ +
+ setCode(e.target.value)} + /> + +
+ + {!connected && ( +

+ Connexion au serveur… +

+ )} + {error && ( +

{error}

+ )} +
+
+ ) +} diff --git a/apps/web/src/pages/lobby.tsx b/apps/web/src/pages/lobby.tsx new file mode 100644 index 0000000..4e763ba --- /dev/null +++ b/apps/web/src/pages/lobby.tsx @@ -0,0 +1,71 @@ +import { Link } from "wouter" +import { Button } from "@workspace/ui/components/button" +import { useRoomStore } from "@/store/room" + +export function LobbyPage({ code }: { code: string }) { + const snapshot = useRoomStore((s) => s.snapshot) + const playerId = useRoomStore((s) => s.playerId) + const connected = useRoomStore((s) => s.connected) + + // Pas de snapshot pour ce code (accès direct / refresh) : on renvoie à l'accueil. + if (!snapshot || snapshot.code !== code) { + return ( +
+

+ Room introuvable ou session perdue. +

+ + + +
+ ) + } + + return ( +
+
+
+
+

Code room

+

+ {snapshot.code} +

+
+ +
+ +
+

+ Joueurs ({snapshot.players.length}) +

+
    + {snapshot.players.map((p) => ( +
  • + + {p.name} + {p.id === playerId && ( + (toi) + )} + + + {p.id === snapshot.hostId ? "Hôte" : ""} + {!p.connected && " · hors ligne"} + +
  • + ))} +
+
+ +

+ En attente du lancement de la partie par l'hôte… +

+
+
+ ) +} diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts new file mode 100644 index 0000000..09ba400 --- /dev/null +++ b/apps/web/src/store/room.ts @@ -0,0 +1,63 @@ +import { create } from "zustand" +import type { + ErrorPayload, + RoomCreatedPayload, + RoomSnapshot, +} from "@nerdware/shared" +import { socket } from "@/lib/socket" + +interface RoomState { + connected: boolean + /** Identité locale, persistée pour reconnaître "moi" dans le snapshot. */ + playerId: string | null + roomCode: string | null + snapshot: RoomSnapshot | null + lastError: ErrorPayload | null + createRoom: (playerName: string) => Promise + joinRoom: (roomCode: string, playerName: string) => Promise + clearError: () => void + reset: () => void +} + +export const useRoomStore = create((set) => ({ + connected: socket.connected, + playerId: null, + roomCode: null, + snapshot: null, + lastError: null, + + createRoom: (playerName) => + new Promise((resolve, reject) => { + socket.emit("room:create", { playerName }, (res) => { + if (res.ok) { + set({ playerId: res.data.playerId, roomCode: res.data.roomCode }) + resolve(res.data) + } else { + set({ lastError: res.error }) + reject(res.error) + } + }) + }), + + joinRoom: (roomCode, playerName) => + new Promise((resolve, reject) => { + socket.emit("room:join", { roomCode, playerName }, (res) => { + if (res.ok) { + set({ playerId: res.data.playerId, roomCode: res.data.roomCode }) + resolve(res.data) + } else { + set({ lastError: res.error }) + reject(res.error) + } + }) + }), + + clearError: () => set({ lastError: null }), + reset: () => set({ roomCode: null, snapshot: null, lastError: null }), +})) + +// Listeners socket → on pousse l'état serveur dans le store (source de vérité). +socket.on("connect", () => useRoomStore.setState({ connected: true })) +socket.on("disconnect", () => useRoomStore.setState({ connected: false })) +socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot })) +socket.on("error", (error) => useRoomStore.setState({ lastError: error })) diff --git a/bun.lock b/bun.lock index c176aca..515f883 100644 --- a/bun.lock +++ b/bun.lock @@ -35,10 +35,14 @@ "name": "web", "version": "0.0.1", "dependencies": { + "@nerdware/shared": "workspace:*", "@workspace/ui": "workspace:*", "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "socket.io-client": "^4.8.1", + "wouter": "^3.7.1", + "zustand": "^5.0.8", }, "devDependencies": { "@eslint/js": "^10", @@ -752,6 +756,8 @@ "engine.io": ["engine.io@6.6.8", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1" } }, "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g=="], + "engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="], + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], "enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="], @@ -1068,6 +1074,8 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="], @@ -1194,6 +1202,8 @@ "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "regexparam": ["regexparam@3.0.0", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1260,6 +1270,8 @@ "socket.io-adapter": ["socket.io-adapter@2.5.7", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.20.1" } }, "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg=="], + "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="], "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], @@ -1354,6 +1366,8 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], @@ -1370,6 +1384,8 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wouter": ["wouter@3.10.0", "", { "dependencies": { "mitt": "^3.0.1", "regexparam": "^3.0.0", "use-sync-external-store": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-zTfddD80zc2/J5l8JKcdvzOK6AwP0kpyHEI3DxRN2bn8U1oJPnrSVm8v+X3WwDamvLAOxTO7ZvkxkpRWlyeJ1Q=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -1378,6 +1394,8 @@ "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1400,6 +1418,8 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],