Реал-тайм через SSE (push-to-invalidate): живые приглашения/партии, авто-обновление без перезагрузки
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vendored
+37
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user