Реал-тайм через SSE (push-to-invalidate): живые приглашения/партии, авто-обновление без перезагрузки

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 22:52:41 +03:00
co-authored by Claude Opus 4.8
parent 16452f7bbe
commit 2500ca575b
15 changed files with 357 additions and 9 deletions
+37
View File
@@ -645,6 +645,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Events */
get: operations["events_api_events_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/login": {
parameters: {
query?: never;
@@ -3195,6 +3212,26 @@ export interface operations {
};
};
};
events_api_events_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
admin_login_api_admin_auth_login_post: {
parameters: {
query?: never;
+5 -2
View File
@@ -4,8 +4,11 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
staleTime: 30_000,
// Свежесть: возврат во вкладку/восстановление сети → перезапрос (на случай, если
// SSE-соединение временно отвалилось). Точечные апдейты приходят пушем (useServerEvents).
refetchOnWindowFocus: true,
refetchOnReconnect: true,
staleTime: 15_000,
},
},
});
+4
View File
@@ -1,5 +1,7 @@
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useMe } from "../hooks/auth";
import { useServerEvents } from "../hooks/useServerEvents";
import { BottomBar } from "./BottomBar";
const TITLES: Record<string, string> = {
@@ -14,6 +16,8 @@ const TITLES: Record<string, string> = {
export function AppShell() {
const location = useLocation();
const navigate = useNavigate();
const { data: me } = useMe();
useServerEvents(!!me); // живые обновления (SSE) только при наличии сессии
const title =
TITLES[location.pathname] ??
(location.pathname.startsWith("/u/") ? "Профиль игрока" : "Forbidden Stars");
+56
View File
@@ -0,0 +1,56 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { qk } from "../api/queryKeys";
interface ServerEvent {
type: "match" | "group" | "invitations";
match_id?: number;
group_id?: number;
}
/**
* Подписка на SSE-поток `/api/events` (push-to-invalidate): по событию с сервера точечно
* инвалидируем нужные запросы, и TanStack Query сам их перезапрашивает. EventSource
* переподключается автоматически. Открываем одно соединение, только когда авторизованы.
*/
export function useServerEvents(enabled: boolean) {
const qc = useQueryClient();
useEffect(() => {
if (!enabled) return;
const base = import.meta.env.VITE_API_BASE_URL || "";
const es = new EventSource(`${base}/api/events`, { withCredentials: true });
es.onmessage = (e) => {
let ev: ServerEvent;
try {
ev = JSON.parse(e.data) as ServerEvent;
} catch {
return;
}
if (ev.type === "invitations") {
qc.invalidateQueries({ queryKey: qk.invitations });
} else if (ev.type === "match") {
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
if (ev.group_id != null) {
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
}
qc.invalidateQueries({ queryKey: qk.home });
qc.invalidateQueries({ queryKey: qk.leaderboard });
} else if (ev.type === "group") {
if (ev.group_id != null) {
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
qc.invalidateQueries({ queryKey: qk.groupMembers(ev.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
}
qc.invalidateQueries({ queryKey: qk.me });
qc.invalidateQueries({ queryKey: qk.home });
qc.invalidateQueries({ queryKey: qk.invitations });
}
};
// onerror не логируем: EventSource переподключается сам.
return () => es.close();
}, [enabled, qc]);
}