diff --git a/app/api/rooms/[code]/route.ts b/app/api/rooms/[code]/route.ts index 7891f51..f225c98 100644 --- a/app/api/rooms/[code]/route.ts +++ b/app/api/rooms/[code]/route.ts @@ -4,6 +4,7 @@ import { NextRequest } from "next/server"; import type { Room, Player } from "../route"; import { prisma } from "../../../../lib/prisma"; +import { redis, roomChannel } from "../../../../lib/redis"; function dbToRoom(row: { code: string; @@ -498,4 +499,6 @@ async function saveRoom(room: Room) { roundStart: room.roundStart !== null ? BigInt(room.roundStart) : null, }, }); + // Notifie tous les clients SSE abonnés à cette room + await redis.publish(roomChannel(room.code), JSON.stringify(room)); } diff --git a/app/api/rooms/[code]/stream/route.ts b/app/api/rooms/[code]/stream/route.ts new file mode 100644 index 0000000..7f31a11 --- /dev/null +++ b/app/api/rooms/[code]/stream/route.ts @@ -0,0 +1,68 @@ +// GET /api/rooms/[code]/stream — SSE : pousse l'état de la room en temps réel +// Le client s'abonne une seule fois ; chaque saveRoom publie sur Redis et notifie ici. + +import { NextRequest } from "next/server"; +import { createSubscriber, roomChannel } from "../../../../../lib/redis"; + +export const dynamic = "force-dynamic"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ code: string }> }, +) { + const { code } = await params; + const channel = roomChannel(code.toUpperCase()); + + const sub = createSubscriber(); + + const stream = new ReadableStream({ + start(controller) { + const encode = (data: string) => + new TextEncoder().encode(`data: ${data}\n\n`); + + // Ping toutes les 25s pour garder la connexion alive (proxys/Coolify) + const keepAlive = setInterval(() => { + try { + controller.enqueue(new TextEncoder().encode(": ping\n\n")); + } catch { + clearInterval(keepAlive); + } + }, 25_000); + + sub.subscribe(channel, (err) => { + if (err) { + controller.close(); + clearInterval(keepAlive); + sub.disconnect(); + } + }); + + sub.on("message", (_chan: string, message: string) => { + try { + controller.enqueue(encode(message)); + } catch { + // Client déconnecté + } + }); + + sub.on("error", () => { + clearInterval(keepAlive); + try { controller.close(); } catch { /* déjà fermé */ } + sub.disconnect(); + }); + }, + cancel() { + sub.unsubscribe(channel); + sub.disconnect(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", // désactive le buffering nginx/Coolify + }, + }); +} diff --git a/lib/redis.ts b/lib/redis.ts new file mode 100644 index 0000000..0bdd918 --- /dev/null +++ b/lib/redis.ts @@ -0,0 +1,21 @@ +import Redis from "ioredis"; + +const url = process.env.REDIS_URL!; + +// Client global pour publish/get (réutilisé entre les requêtes via le module cache Node.js) +const globalForRedis = globalThis as unknown as { redis: Redis | undefined }; + +export const redis = + globalForRedis.redis ?? + new Redis(url, { lazyConnect: false, maxRetriesPerRequest: 3 }); + +if (process.env.NODE_ENV !== "production") globalForRedis.redis = redis; + +// Crée toujours un nouveau client pour subscribe (ioredis interdit de mixer pub et sub) +export function createSubscriber(): Redis { + return new Redis(url, { lazyConnect: false, maxRetriesPerRequest: 3 }); +} + +export function roomChannel(code: string): string { + return `room:${code}`; +} diff --git a/lib/useMultiGame.ts b/lib/useMultiGame.ts index 17c4bd6..a0539a2 100644 --- a/lib/useMultiGame.ts +++ b/lib/useMultiGame.ts @@ -5,7 +5,6 @@ import { fetchArticle, pickTwoArticles, prefetchArticle, - POLL_INTERVAL, COUNTDOWN_DURATION, } from "./wiki"; import { useTimer } from "./useTimer"; @@ -33,7 +32,7 @@ export function useMultiGame() { const [timeLeft, setTimeLeft] = useState(null); const timeLimitTimerRef = useRef | null>(null); const countdownRef = useRef | null>(null); - const pollRef = useRef | null>(null); + const sseRef = useRef(null); const prevPhaseRef = useRef(null); const prevRoundRef = useRef(0); @@ -75,48 +74,49 @@ export function useMultiGame() { setCountdown(null); } - // Polling + // SSE - function stopPolling() { - if (pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; + const sseCodeRef = useRef(null); + const ssePidRef = useRef(null); + + function stopSSE() { + if (sseRef.current) { + sseRef.current.close(); + sseRef.current = null; } } - async function poll(code: string, pid: string) { - try { - const res = await fetch(`/api/rooms/${code}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "heartbeat", playerId: pid }), - }); - if (res.ok) setRoom(((await res.json()) as { room: Room }).room); - } catch { - /* ignore */ - } + function startSSE(code: string, pid: string) { + stopSSE(); + sseCodeRef.current = code; + ssePidRef.current = pid; + + const es = new EventSource(`/api/rooms/${code}/stream`); + sseRef.current = es; + + es.onmessage = (e) => { + try { + setRoom(JSON.parse(e.data) as Room); + } catch { /* ignore */ } + }; + + es.onerror = () => { + // EventSource reconnecte automatiquement — pas besoin de gérer manuellement + }; } - const pollCodeRef = useRef(null); - const pollPidRef = useRef(null); - - function startPolling(code: string, pid: string) { - stopPolling(); - pollCodeRef.current = code; - pollPidRef.current = pid; - pollRef.current = setInterval(() => poll(code, pid), POLL_INTERVAL); - } - - // Relance le polling quand le tab redevient visible (les setInterval sont throttlés en arrière-plan) + // Quand le tab redevient visible : heartbeat immédiat pour rafraîchir l'état useEffect(() => { function onVisible() { - if ( - document.visibilityState === "visible" && - pollCodeRef.current && - pollPidRef.current - ) { - poll(pollCodeRef.current, pollPidRef.current); - startPolling(pollCodeRef.current, pollPidRef.current); + if (document.visibilityState === "visible" && sseCodeRef.current && ssePidRef.current) { + fetch(`/api/rooms/${sseCodeRef.current}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "heartbeat", playerId: ssePidRef.current }), + }) + .then((r) => r.json()) + .then((d) => { if ((d as { room: Room }).room) setRoom((d as { room: Room }).room); }) + .catch(() => {}); } } document.addEventListener("visibilitychange", onVisible); @@ -287,7 +287,7 @@ export function useMultiGame() { if (!res.ok) return { error: data.error ?? "Erreur" }; setRoom(data.room!); setPlayerId(data.playerId!); - startPolling(data.room!.code, data.playerId!); + startSSE(data.room!.code, data.playerId!); saveSession({ screen: "lobby", multiRoomCode: data.room!.code, @@ -314,7 +314,7 @@ export function useMultiGame() { if (!res.ok) return { error: data.error ?? "Impossible de rejoindre" }; setRoom(data.room!); setPlayerId(data.playerId!); - startPolling(data.room!.code, data.playerId!); + startSSE(data.room!.code, data.playerId!); saveSession({ screen: "lobby", multiRoomCode: data.room!.code, @@ -460,7 +460,7 @@ export function useMultiGame() { } function leave() { - stopPolling(); + stopSSE(); stopCountdown(); timer.stop(); setRoom(null); @@ -487,7 +487,7 @@ export function useMultiGame() { const data = (await res.json()) as { room: Room }; setRoom(data.room); setPlayerId(pid); - startPolling(code, pid); + startSSE(code, pid); return true; } catch { return false; diff --git a/package.json b/package.json index 1e3231f..6dba308 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@prisma/adapter-pg": "^7.7.0", "@prisma/client": "^7.7.0", "bcryptjs": "^3.0.3", + "ioredis": "^5.10.1", "jose": "^6.2.2", "next": "16.2.3", "next-auth": "5.0.0-beta.30", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9ed8a8..a3d2de7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: bcryptjs: specifier: ^3.0.3 version: 3.0.3 + ioredis: + specifier: ^5.10.1 + version: 5.10.1 jose: specifier: ^6.2.2 version: 6.2.2 @@ -383,6 +386,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1080,6 +1086,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1596,6 +1606,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1844,6 +1858,12 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -2215,6 +2235,14 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2358,6 +2386,9 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -2874,6 +2905,8 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@ioredis/commands@1.5.1': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3590,6 +3623,8 @@ snapshots: client-only@0.0.1: {} + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4235,6 +4270,20 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -4463,6 +4512,10 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.merge@4.6.2: {} long@5.3.2: {} @@ -4845,6 +4898,12 @@ snapshots: readdirp@4.1.2: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -5034,6 +5093,8 @@ snapshots: stable-hash@0.0.5: {} + standard-as-callback@2.1.0: {} + std-env@3.10.0: {} stop-iteration-iterator@1.1.0: