Compare commits

...

2 commits

Author SHA1 Message Date
7dca1a5da7 Merge pull request 'feat(server,web): first real-time cycle — room create/join + lobby' (#2) from feature/realtime-room-cycle into dev
Reviewed-on: #2
2026-06-08 13:21:48 +00:00
AyoubBenziza
29faa18731 feat(server,web): first real-time cycle — room create/join + lobby
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) <noreply@anthropic.com>
2026-06-08 15:16:26 +02:00
11 changed files with 549 additions and 18 deletions

106
apps/server/src/handlers.ts Normal file
View file

@ -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)
})
}

145
apps/server/src/rooms.ts Normal file
View file

@ -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<string, ServerPlayer>
settings: RoomSettings
scores: Map<string, number>
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<string, ServerRoom>()
/** 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"
}
}

View file

@ -7,6 +7,8 @@ import type {
SocketData, SocketData,
} from "@nerdware/shared" } from "@nerdware/shared"
import { env } from "./env" import { env } from "./env"
import { registerRoomHandlers } from "./handlers"
import { RoomManager } from "./rooms"
/** Serveur Socket.IO entièrement typé via les events partagés. */ /** Serveur Socket.IO entièrement typé via les events partagés. */
export type IoServer = Server< export type IoServer = Server<
@ -16,10 +18,7 @@ export type IoServer = Server<
SocketData SocketData
> >
/** /** Crée et attache le serveur Socket.IO au serveur HTTP de Fastify. */
* 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.
*/
export function createSocketServer(httpServer: HttpServer): IoServer { export function createSocketServer(httpServer: HttpServer): IoServer {
const io: IoServer = new Server(httpServer, { const io: IoServer = new Server(httpServer, {
cors: { cors: {
@ -28,8 +27,11 @@ export function createSocketServer(httpServer: HttpServer): IoServer {
}, },
}) })
const rooms = new RoomManager()
io.on("connection", (socket) => { io.on("connection", (socket) => {
console.log(`[ws] connect ${socket.id}`) console.log(`[ws] connect ${socket.id}`)
registerRoomHandlers(io, socket, rooms)
socket.on("disconnect", (reason) => { socket.on("disconnect", (reason) => {
console.log(`[ws] disconnect ${socket.id} (${reason})`) console.log(`[ws] disconnect ${socket.id} (${reason})`)
}) })

1
apps/web/.env.example Normal file
View file

@ -0,0 +1 @@
VITE_SERVER_URL=http://localhost:3001

View file

@ -12,10 +12,14 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@nerdware/shared": "workspace:*",
"@workspace/ui": "workspace:*", "@workspace/ui": "workspace:*",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"react": "^19.2.6", "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": { "devDependencies": {
"@eslint/js": "^10", "@eslint/js": "^10",

View file

@ -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() { export function App() {
return ( return (
<div className="flex min-h-svh p-6"> <Switch>
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose"> <Route path="/" component={HomePage} />
<div> <Route path="/room/:code">
<h1 className="font-medium">Project ready!</h1> {(params) => <LobbyPage code={params.code.toUpperCase()} />}
<p>You may now add components and start building.</p> </Route>
<p>We&apos;ve already added the button component for you.</p> <Route>
<Button className="mt-2">Button</Button> <div className="flex min-h-svh items-center justify-center p-6">
<p className="text-muted-foreground text-sm">Page introuvable.</p>
</div> </div>
<div className="text-muted-foreground font-mono text-xs"> </Route>
(Press <kbd>d</kbd> to toggle dark mode) </Switch>
</div>
</div>
</div>
) )
} }

View file

@ -0,0 +1,13 @@
import { io, type Socket } from "socket.io-client"
import type {
ClientToServerEvents,
ServerToClientEvents,
} from "@nerdware/shared"
/** Socket typé : <events reçus, events émis>. Désync = erreur de compile. */
export type AppSocket = Socket<ServerToClientEvents, ClientToServerEvents>
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 })

106
apps/web/src/pages/home.tsx Normal file
View file

@ -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<string | null>(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 (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="flex w-full max-w-sm flex-col gap-6">
<header className="text-center">
<h1 className="font-heading text-3xl font-bold tracking-tight">
NerdWare
</h1>
<p className="text-muted-foreground text-sm">
Party game culture geek
</p>
</header>
<input
className={inputClass}
placeholder="Ton pseudo"
value={name}
maxLength={24}
onChange={(e) => setName(e.target.value)}
/>
<Button disabled={!canSubmit} onClick={handleCreate}>
Créer une room
</Button>
<div className="flex items-center gap-2">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground text-xs">ou rejoindre</span>
<div className="bg-border h-px flex-1" />
</div>
<div className="flex gap-2">
<input
className={`${inputClass} uppercase`}
placeholder="Code"
value={code}
maxLength={4}
onChange={(e) => setCode(e.target.value)}
/>
<Button
variant="secondary"
disabled={!canSubmit || code.trim().length === 0}
onClick={handleJoin}
>
Rejoindre
</Button>
</div>
{!connected && (
<p className="text-muted-foreground text-center text-xs">
Connexion au serveur
</p>
)}
{error && (
<p className="text-destructive text-center text-sm">{error}</p>
)}
</div>
</div>
)
}

View file

@ -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 (
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
<p className="text-muted-foreground text-sm">
Room introuvable ou session perdue.
</p>
<Link href="/">
<Button variant="secondary">Retour à l'accueil</Button>
</Link>
</div>
)
}
return (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="flex w-full max-w-md flex-col gap-6">
<header className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-xs uppercase">Code room</p>
<p className="font-heading text-3xl font-bold tracking-widest">
{snapshot.code}
</p>
</div>
<span
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
title={connected ? "Connecté" : "Déconnecté"}
/>
</header>
<section className="flex flex-col gap-2">
<h2 className="text-muted-foreground text-sm font-medium">
Joueurs ({snapshot.players.length})
</h2>
<ul className="flex flex-col gap-1">
{snapshot.players.map((p) => (
<li
key={p.id}
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
>
<span className={p.connected ? "" : "opacity-40"}>
{p.name}
{p.id === playerId && (
<span className="text-muted-foreground"> (toi)</span>
)}
</span>
<span className="text-muted-foreground text-xs">
{p.id === snapshot.hostId ? "Hôte" : ""}
{!p.connected && " · hors ligne"}
</span>
</li>
))}
</ul>
</section>
<p className="text-muted-foreground text-center text-xs">
En attente du lancement de la partie par l'hôte
</p>
</div>
</div>
)
}

View file

@ -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<RoomCreatedPayload>
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
clearError: () => void
reset: () => void
}
export const useRoomStore = create<RoomState>((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 }))

View file

@ -35,10 +35,14 @@
"name": "web", "name": "web",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@nerdware/shared": "workspace:*",
"@workspace/ui": "workspace:*", "@workspace/ui": "workspace:*",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"react": "^19.2.6", "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": { "devDependencies": {
"@eslint/js": "^10", "@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": ["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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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-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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "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=="], "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/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=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],