Реал-тайм через SSE (push-to-invalidate): живые приглашения/партии, авто-обновление без перезагрузки
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"""In-memory SSE-шина (pub/sub) для пушей «данные изменились».
|
||||
|
||||
Рассчитана на один процесс (uvicorn `--workers 1`). При переходе на несколько воркеров
|
||||
шину нужно вынести во внешний брокер (например, Redis pub/sub).
|
||||
|
||||
Публикация вызывается из СИНХРОННЫХ роутеров (FastAPI выполняет их в threadpool), а очереди
|
||||
подписчиков живут в event-loop'е — поэтому публикация перекидывается в loop через
|
||||
`call_soon_threadsafe`, а весь доступ к подпискам происходит на потоке loop'а.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
class EventHub:
|
||||
def __init__(self) -> None:
|
||||
self._subs: dict[int, set[asyncio.Queue]] = defaultdict(set)
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self._loop = loop
|
||||
|
||||
def subscribe(self, user_id: int) -> asyncio.Queue:
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
self._subs[user_id].add(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, user_id: int, queue: asyncio.Queue) -> None:
|
||||
subs = self._subs.get(user_id)
|
||||
if subs is not None:
|
||||
subs.discard(queue)
|
||||
if not subs:
|
||||
self._subs.pop(user_id, None)
|
||||
|
||||
def publish(self, user_ids: Iterable[int], event: dict[str, Any]) -> None:
|
||||
"""Доставить событие подписчикам с указанными user_id (потокобезопасно)."""
|
||||
loop = self._loop
|
||||
if loop is None:
|
||||
return
|
||||
ids = [uid for uid in user_ids if uid is not None]
|
||||
if ids:
|
||||
loop.call_soon_threadsafe(self._fanout, ids, event)
|
||||
|
||||
def _fanout(self, ids: list[int], event: dict[str, Any]) -> None:
|
||||
# Выполняется на потоке loop'а — доступ к _subs безопасен.
|
||||
for uid in ids:
|
||||
for queue in list(self._subs.get(uid, ())):
|
||||
try:
|
||||
queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
pass # медленный клиент — пропускаем (догонит при reconnect/focus)
|
||||
|
||||
|
||||
hub = EventHub()
|
||||
+9
-1
@@ -19,6 +19,7 @@ from app.routers import (
|
||||
achievements,
|
||||
admin,
|
||||
auth,
|
||||
events,
|
||||
groups,
|
||||
invitations,
|
||||
matches,
|
||||
@@ -63,6 +64,13 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app: FastAPI):
|
||||
# SSE-шина публикует из sync-роутеров в этот event-loop — сохраняем ссылку (все окружения).
|
||||
import asyncio
|
||||
|
||||
from app.core.events import hub
|
||||
|
||||
hub.bind_loop(asyncio.get_running_loop())
|
||||
|
||||
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
||||
# (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
||||
@@ -118,7 +126,7 @@ def create_app() -> FastAPI:
|
||||
# API-роутеры под /api.
|
||||
api_routers = [auth.router, users.router, groups.router, invitations.router,
|
||||
matches.router, reference.router, stats.router, achievements.router,
|
||||
admin.router]
|
||||
events.router, admin.router]
|
||||
for r in api_routers:
|
||||
app.include_router(r, prefix="/api")
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.services import (
|
||||
audit_service,
|
||||
faction_service,
|
||||
match_service,
|
||||
notify,
|
||||
user_service,
|
||||
)
|
||||
|
||||
@@ -240,6 +241,7 @@ def update_match(
|
||||
ip=request.client.host if request.client else None,
|
||||
)
|
||||
session.commit()
|
||||
notify.match_changed(session, match)
|
||||
return build_match_read(session, match, can_modify=True)
|
||||
|
||||
|
||||
@@ -289,12 +291,14 @@ def delete_match(
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.OkResponse:
|
||||
group_id = match_service.get_match(session, match_id).group_id # для уведомления
|
||||
admin_service.delete_match(session, match_id)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
||||
ip=request.client.host if request.client else None,
|
||||
)
|
||||
session.commit()
|
||||
notify.match_removed(session, match_id, group_id)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
@@ -330,6 +334,7 @@ def admin_add_attachment(
|
||||
att = attachment_service.add_photo(
|
||||
session, match, admin, content, ext, user_service.avatar_media_type(ext)
|
||||
)
|
||||
notify.match_changed(session, match)
|
||||
return attachment_read(att, f"/api/admin/matches/{match_id}")
|
||||
|
||||
|
||||
@@ -342,6 +347,7 @@ def admin_delete_attachment(
|
||||
) -> s.OkResponse:
|
||||
match = match_service.get_match(session, match_id)
|
||||
attachment_service.delete(session, match, attachment_id)
|
||||
notify.match_changed(session, match)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""SSE: поток событий «данные изменились» для текущего пользователя.
|
||||
|
||||
Клиент (EventSource) держит одно соединение и по событию точечно перезапрашивает данные
|
||||
(push-to-invalidate). Мутации остаются обычным REST.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.core.events import hub
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(tags=["events"])
|
||||
|
||||
_HEARTBEAT_SECONDS = 25
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def events(request: Request, user: User = Depends(get_current_user)) -> StreamingResponse:
|
||||
queue = hub.subscribe(user.id) # type: ignore[arg-type]
|
||||
|
||||
async def stream():
|
||||
try:
|
||||
yield ": connected\n\n"
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=_HEARTBEAT_SECONDS)
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
yield ": ping\n\n" # heartbeat против idle-таймаутов прокси/туннеля
|
||||
finally:
|
||||
hub.unsubscribe(user.id, queue) # type: ignore[arg-type]
|
||||
|
||||
return StreamingResponse(
|
||||
stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
@@ -14,6 +14,7 @@ from app.services import (
|
||||
group_service,
|
||||
invitation_service,
|
||||
membership_service,
|
||||
notify,
|
||||
stats_service,
|
||||
)
|
||||
|
||||
@@ -153,6 +154,7 @@ def invite_member(
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.invitations_changed(invited.id) # type: ignore[arg-type] # живое появление у получателя
|
||||
return s.InvitationRead(
|
||||
id=inv.id, # type: ignore[arg-type]
|
||||
group_id=group_id,
|
||||
@@ -174,6 +176,7 @@ def remove_member(
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
membership_service.remove_member(session, group, user_id)
|
||||
notify.group_changed(session, group_id, extra_user_ids=[user_id]) # + удалённому
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.auth.deps import get_current_user
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas import api as s
|
||||
from app.services import invitation_service
|
||||
from app.services import invitation_service, notify
|
||||
|
||||
router = APIRouter(prefix="/invitations", tags=["invitations"])
|
||||
|
||||
@@ -27,7 +27,9 @@ def accept_invitation(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.OkResponse:
|
||||
invitation_service.accept(session, user, invitation_id)
|
||||
group = invitation_service.accept(session, user, invitation_id)
|
||||
notify.invitations_changed(user.id) # type: ignore[arg-type]
|
||||
notify.group_changed(session, group.id) # type: ignore[arg-type] # новый участник
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
@@ -38,4 +40,5 @@ def decline_invitation(
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.OkResponse:
|
||||
invitation_service.decline(session, user, invitation_id)
|
||||
notify.invitations_changed(user.id) # type: ignore[arg-type]
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.services import (
|
||||
audit_service,
|
||||
group_service,
|
||||
match_service,
|
||||
notify,
|
||||
user_service,
|
||||
)
|
||||
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
|
||||
@@ -119,6 +120,7 @@ def start_match(
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_changed(session, match)
|
||||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||
|
||||
|
||||
@@ -157,6 +159,7 @@ def finish_match(
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_changed(session, match)
|
||||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||
|
||||
|
||||
@@ -214,6 +217,7 @@ def update_match(
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_changed(session, match)
|
||||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||
|
||||
|
||||
@@ -243,6 +247,7 @@ def add_attachment(
|
||||
att = attachment_service.add_photo(
|
||||
session, match, user, content, ext, user_service.avatar_media_type(ext)
|
||||
)
|
||||
notify.match_changed(session, match)
|
||||
return attachment_read(att, f"/api/matches/{match_id}")
|
||||
|
||||
|
||||
@@ -256,6 +261,7 @@ def delete_attachment(
|
||||
match = match_service.get_match(session, match_id)
|
||||
_assert_can_attach(session, match, user)
|
||||
attachment_service.delete(session, match, attachment_id)
|
||||
notify.match_changed(session, match)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
@@ -301,4 +307,5 @@ def delete_match(
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_removed(session, match_id_val, group_id_val) # type: ignore[arg-type]
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Публикация SSE-событий «данные изменились» нужным пользователям.
|
||||
|
||||
Тонкая прослойка над core.events.hub — держит роутеры чистыми. Событие несёт лишь тип и id,
|
||||
клиент по нему точечно перезапрашивает данные.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.events import hub
|
||||
from app.models import GroupMember, Match
|
||||
|
||||
|
||||
def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
||||
return list(
|
||||
session.exec(select(GroupMember.user_id).where(GroupMember.group_id == group_id)).all()
|
||||
)
|
||||
|
||||
|
||||
def match_changed(session: Session, match: Match) -> None:
|
||||
"""Партия изменилась — уведомить всех участников её группы."""
|
||||
hub.publish(
|
||||
_group_member_ids(session, match.group_id),
|
||||
{"type": "match", "match_id": match.id, "group_id": match.group_id},
|
||||
)
|
||||
|
||||
|
||||
def match_removed(session: Session, match_id: int, group_id: int) -> None:
|
||||
"""Партия удалена — уведомить участников группы (обновить списки)."""
|
||||
hub.publish(
|
||||
_group_member_ids(session, group_id),
|
||||
{"type": "match", "match_id": match_id, "group_id": group_id},
|
||||
)
|
||||
|
||||
|
||||
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
|
||||
"""Состав/данные группы изменились — участникам (+ доп. адресатам, напр. удалённому)."""
|
||||
ids = _group_member_ids(session, group_id)
|
||||
if extra_user_ids:
|
||||
ids = ids + extra_user_ids
|
||||
hub.publish(ids, {"type": "group", "group_id": group_id})
|
||||
|
||||
|
||||
def invitations_changed(user_id: int) -> None:
|
||||
"""У пользователя изменился список приглашений."""
|
||||
hub.publish([user_id], {"type": "invitations"})
|
||||
@@ -0,0 +1,43 @@
|
||||
"""SSE-шина: доставка событий подписчику; эндпойнт /api/events требует вход."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_hub_delivers_to_subscriber():
|
||||
from app.core.events import EventHub
|
||||
|
||||
async def run():
|
||||
hub = EventHub()
|
||||
hub.bind_loop(asyncio.get_running_loop())
|
||||
q = hub.subscribe(1)
|
||||
hub.publish([1, 2], {"type": "match", "match_id": 5})
|
||||
event = await asyncio.wait_for(q.get(), timeout=1)
|
||||
assert event == {"type": "match", "match_id": 5}
|
||||
hub.unsubscribe(1, q)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_hub_isolates_users():
|
||||
from app.core.events import EventHub
|
||||
|
||||
async def run():
|
||||
hub = EventHub()
|
||||
hub.bind_loop(asyncio.get_running_loop())
|
||||
q1 = hub.subscribe(1)
|
||||
q2 = hub.subscribe(2)
|
||||
hub.publish([2], {"type": "invitations"}) # только пользователю 2
|
||||
got2 = await asyncio.wait_for(q2.get(), timeout=1)
|
||||
assert got2 == {"type": "invitations"}
|
||||
assert q1.empty() # пользователю 1 ничего не пришло
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_events_requires_auth(client: TestClient):
|
||||
# Без сессии SSE-эндпойнт не отдаёт поток (401 на зависимости get_current_user).
|
||||
r = client.get("/api/events")
|
||||
assert r.status_code == 401
|
||||
@@ -45,3 +45,15 @@ HTTPS твоими сертификатами и проксирует трафи
|
||||
|
||||
> Telegram-вход требует HTTPS-домен: у BotFather `/setdomain` укажи оба домена
|
||||
> (`forbiddenstars.ru` и `forbidden-stars.ru`).
|
||||
|
||||
## Реал-тайм (SSE)
|
||||
|
||||
Приложение шлёт события «данные изменились» через SSE: `GET /api/events`
|
||||
(`text/event-stream`). Это обычный HTTP — проходит через Caddy и SSH-туннель без доп.
|
||||
настроек, КРОМЕ одного: для пути `/api/events` в `Caddyfile` выделен отдельный `handle`
|
||||
**без `encode`** и с **`flush_interval -1`** (иначе сжатие/буферизация задержат поток).
|
||||
После правки `Caddyfile` на VPS: `sudo systemctl reload caddy` (или `restart`).
|
||||
|
||||
Шина событий — **in-memory**, рассчитана на один процесс (uvicorn `--workers 1`, как в
|
||||
контейнере). Если когда-нибудь поднимешь несколько воркеров/реплик — шину нужно вынести во
|
||||
внешний брокер (Redis pub/sub), иначе события увидит только тот воркер, что принял мутацию.
|
||||
|
||||
+23
-4
@@ -8,15 +8,34 @@
|
||||
# приложению добавляет X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
||||
# приложение это учитывает (uvicorn --proxy-headers). Положи файл в /etc/caddy/Caddyfile.
|
||||
# Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом).
|
||||
#
|
||||
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
|
||||
# буферизация задержат поток событий (реал-тайм перестанет работать).
|
||||
|
||||
forbiddenstars.ru {
|
||||
tls /etc/caddy/certs/forbiddenstars.ru/fullchain.pem /etc/caddy/certs/forbiddenstars.ru/privkey.pem
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:9000
|
||||
@sse path /api/events
|
||||
handle @sse {
|
||||
reverse_proxy 127.0.0.1:9000 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
handle {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:9000
|
||||
}
|
||||
}
|
||||
|
||||
forbidden-stars.ru {
|
||||
tls /etc/caddy/certs/forbidden-stars.ru/fullchain.pem /etc/caddy/certs/forbidden-stars.ru/privkey.pem
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:9001
|
||||
@sse path /api/events
|
||||
handle @sse {
|
||||
reverse_proxy 127.0.0.1:9001 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
handle {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:9001
|
||||
}
|
||||
}
|
||||
|
||||
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