feat(multiplayer): replace polling with server-sent events and redis pub/sub
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
+40
-40
@@ -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<number | null>(null);
|
||||
const timeLimitTimerRef = 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 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<string | null>(null);
|
||||
const ssePidRef = useRef<string | null>(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<string | null>(null);
|
||||
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)
|
||||
// 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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+61
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user