feat(multiplayer): replace polling with server-sent events and redis pub/sub

This commit is contained in:
jessy-david-dev
2026-04-14 16:00:09 +02:00
parent 7ecf268191
commit 90b14919c5
6 changed files with 194 additions and 40 deletions
+3
View File
@@ -4,6 +4,7 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import type { Room, Player } from "../route"; import type { Room, Player } from "../route";
import { prisma } from "../../../../lib/prisma"; import { prisma } from "../../../../lib/prisma";
import { redis, roomChannel } from "../../../../lib/redis";
function dbToRoom(row: { function dbToRoom(row: {
code: string; code: string;
@@ -498,4 +499,6 @@ async function saveRoom(room: Room) {
roundStart: room.roundStart !== null ? BigInt(room.roundStart) : null, 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));
} }
+68
View File
@@ -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
},
});
}
+21
View File
@@ -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}`;
}
+39 -39
View File
@@ -5,7 +5,6 @@ import {
fetchArticle, fetchArticle,
pickTwoArticles, pickTwoArticles,
prefetchArticle, prefetchArticle,
POLL_INTERVAL,
COUNTDOWN_DURATION, COUNTDOWN_DURATION,
} from "./wiki"; } from "./wiki";
import { useTimer } from "./useTimer"; import { useTimer } from "./useTimer";
@@ -33,7 +32,7 @@ export function useMultiGame() {
const [timeLeft, setTimeLeft] = useState<number | null>(null); const [timeLeft, setTimeLeft] = useState<number | null>(null);
const timeLimitTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timeLimitTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null); const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const sseRef = useRef<EventSource | null>(null);
const prevPhaseRef = useRef<string | null>(null); const prevPhaseRef = useRef<string | null>(null);
const prevRoundRef = useRef(0); const prevRoundRef = useRef(0);
@@ -75,48 +74,49 @@ export function useMultiGame() {
setCountdown(null); setCountdown(null);
} }
// Polling // SSE
function stopPolling() { const sseCodeRef = useRef<string | null>(null);
if (pollRef.current) { const ssePidRef = useRef<string | null>(null);
clearInterval(pollRef.current);
pollRef.current = null; function stopSSE() {
if (sseRef.current) {
sseRef.current.close();
sseRef.current = null;
} }
} }
async function poll(code: string, pid: string) { 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 { try {
const res = await fetch(`/api/rooms/${code}`, { setRoom(JSON.parse(e.data) as Room);
method: "PATCH", } catch { /* ignore */ }
headers: { "Content-Type": "application/json" }, };
body: JSON.stringify({ action: "heartbeat", playerId: pid }),
}); es.onerror = () => {
if (res.ok) setRoom(((await res.json()) as { room: Room }).room); // EventSource reconnecte automatiquement — pas besoin de gérer manuellement
} catch { };
/* ignore */
}
} }
const pollCodeRef = useRef<string | null>(null); // Quand le tab redevient visible : heartbeat immédiat pour rafraîchir l'état
const pollPidRef = useRef<string | null>(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)
useEffect(() => { useEffect(() => {
function onVisible() { function onVisible() {
if ( if (document.visibilityState === "visible" && sseCodeRef.current && ssePidRef.current) {
document.visibilityState === "visible" && fetch(`/api/rooms/${sseCodeRef.current}`, {
pollCodeRef.current && method: "PATCH",
pollPidRef.current headers: { "Content-Type": "application/json" },
) { body: JSON.stringify({ action: "heartbeat", playerId: ssePidRef.current }),
poll(pollCodeRef.current, pollPidRef.current); })
startPolling(pollCodeRef.current, pollPidRef.current); .then((r) => r.json())
.then((d) => { if ((d as { room: Room }).room) setRoom((d as { room: Room }).room); })
.catch(() => {});
} }
} }
document.addEventListener("visibilitychange", onVisible); document.addEventListener("visibilitychange", onVisible);
@@ -287,7 +287,7 @@ export function useMultiGame() {
if (!res.ok) return { error: data.error ?? "Erreur" }; if (!res.ok) return { error: data.error ?? "Erreur" };
setRoom(data.room!); setRoom(data.room!);
setPlayerId(data.playerId!); setPlayerId(data.playerId!);
startPolling(data.room!.code, data.playerId!); startSSE(data.room!.code, data.playerId!);
saveSession({ saveSession({
screen: "lobby", screen: "lobby",
multiRoomCode: data.room!.code, multiRoomCode: data.room!.code,
@@ -314,7 +314,7 @@ export function useMultiGame() {
if (!res.ok) return { error: data.error ?? "Impossible de rejoindre" }; if (!res.ok) return { error: data.error ?? "Impossible de rejoindre" };
setRoom(data.room!); setRoom(data.room!);
setPlayerId(data.playerId!); setPlayerId(data.playerId!);
startPolling(data.room!.code, data.playerId!); startSSE(data.room!.code, data.playerId!);
saveSession({ saveSession({
screen: "lobby", screen: "lobby",
multiRoomCode: data.room!.code, multiRoomCode: data.room!.code,
@@ -460,7 +460,7 @@ export function useMultiGame() {
} }
function leave() { function leave() {
stopPolling(); stopSSE();
stopCountdown(); stopCountdown();
timer.stop(); timer.stop();
setRoom(null); setRoom(null);
@@ -487,7 +487,7 @@ export function useMultiGame() {
const data = (await res.json()) as { room: Room }; const data = (await res.json()) as { room: Room };
setRoom(data.room); setRoom(data.room);
setPlayerId(pid); setPlayerId(pid);
startPolling(code, pid); startSSE(code, pid);
return true; return true;
} catch { } catch {
return false; return false;
+1
View File
@@ -18,6 +18,7 @@
"@prisma/adapter-pg": "^7.7.0", "@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0", "@prisma/client": "^7.7.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"ioredis": "^5.10.1",
"jose": "^6.2.2", "jose": "^6.2.2",
"next": "16.2.3", "next": "16.2.3",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
+61
View File
@@ -20,6 +20,9 @@ importers:
bcryptjs: bcryptjs:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
ioredis:
specifier: ^5.10.1
version: 5.10.1
jose: jose:
specifier: ^6.2.2 specifier: ^6.2.2
version: 6.2.2 version: 6.2.2
@@ -383,6 +386,9 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1080,6 +1086,10 @@ packages:
client-only@0.0.1: client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} 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: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -1596,6 +1606,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} 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: is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1844,6 +1858,12 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} 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: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -2215,6 +2235,14 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} 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: reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2358,6 +2386,9 @@ packages:
stable-hash@0.0.5: stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
std-env@3.10.0: std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
@@ -2874,6 +2905,8 @@ snapshots:
'@img/sharp-win32-x64@0.34.5': '@img/sharp-win32-x64@0.34.5':
optional: true optional: true
'@ioredis/commands@1.5.1': {}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -3590,6 +3623,8 @@ snapshots:
client-only@0.0.1: {} client-only@0.0.1: {}
cluster-key-slot@1.1.2: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -4235,6 +4270,20 @@ snapshots:
hasown: 2.0.2 hasown: 2.0.2
side-channel: 1.1.0 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: is-array-buffer@3.0.5:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
@@ -4463,6 +4512,10 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
long@5.3.2: {} long@5.3.2: {}
@@ -4845,6 +4898,12 @@ snapshots:
readdirp@4.1.2: {} 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: reflect.getprototypeof@1.0.10:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
@@ -5034,6 +5093,8 @@ snapshots:
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
standard-as-callback@2.1.0: {}
std-env@3.10.0: {} std-env@3.10.0: {}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0: