feat(chat): онлайн-доставка как в Telegram — watchdog SSE, тихий скролл, персистентный кэш сообщений, невидимое обновление SW

- SSE: серверный heartbeat теперь именованное событие ping (25с) — клиент может отличить живое соединение от мёртвого; клиентский watchdog (45с без активности → принудительный reconnect), reconnect 1с вместо 3с, polling-фолбэк 5с вместо 10с
- Скролл: на скрытой вкладке скролл не трогаем (раньше автоскролл «выбрасывал» пользователя вниз при возврате); возврат на вкладку — pin к низу если был у низа; при чтении истории плавающий индикатор «↓ N новых» (оба чата)
- Персистентный кэш последних 100 сообщений чата в localStorage → initialData: мгновенный рендер после перезагрузки страницы без «Загрузка сообщений...», свежие данные в фоне; gcTime: Infinity
- SW: reload на controllerchange только на скрытой вкладке (обновление незаметно), предыдущая версия кэша сохраняется для ленивых чанков старой страницы; кэш v15
This commit is contained in:
2026-09-21 20:29:13 +03:00
parent f8d78afd2e
commit 34838a302d
9 changed files with 336 additions and 26 deletions

View File

@@ -84,7 +84,7 @@ export interface BufferedEvent extends Event {
export class EventBus {
// Индекс соединений по организации/пользователю — publishEvent не перебирает все соединения
private index = new SseConnectionIndex<SSEConnection>();
private heartbeatInterval = 30000; // 30 seconds
private heartbeatInterval = 25000; // 25 seconds — должен быть заметно меньше client watchdog (45s)
private heartbeatTimer: NodeJS.Timeout | null = null;
private eventIdCounter = 0;
// Буферы последних событий для восстановления после reconnect
@@ -112,7 +112,10 @@ export class EventBus {
this.heartbeatTimer = setInterval(() => {
this.index.forEach((connection) => {
try {
connection.res.write(`: heartbeat\n\n`);
// Именованное событие ping — в отличие от SSE-комментария (": heartbeat"),
// оно доставляется клиенту как обычное событие и позволяет ему отличить
// живое соединение от мёртвого (watchdog в sseManager).
connection.res.write(`event: ping\ndata: {}\n\n`);
const resAny = connection.res as any;
if (typeof resAny.flush === 'function') {
resAny.flush();