Перейти к основному содержимому

Realtime / SSE

MineFlow доставляет live-уведомления через server-sent events (SSE, ADR-0044). Этот рецепт показывает, как подписаться на поток в React, как устроено подключение под капотом (fetch-stream + Bearer вместо EventSource), как ведёт себя авто-reconnect и как сервер фильтрует фид по организации и роли.

Backlog + live-фид

Уведомления — это REST-backlog (то, что накопилось) плюс live SSE (то, что приходит сейчас). Backlog читается useNotifications, live-фид подключается useNotificationFeed. Пропущенное за разрыв соединения клиент добирает рефетчем backlog — сервер гарантирует at-most-once в потоке.

Почему не EventSource

Браузерный EventSource не умеет слать заголовок Authorization, а эндпоинт потока (GET /api/v1/notifications/stream) требует Bearer-токен. Поэтому SDK подключается обычным аутентифицированным fetch и читает text/event-stream из response.body (ReadableStream). Bearer добавляет тот же fetch-слой, что и для REST-запросов — отдельной настройки не нужно.

Это полностью инкапсулировано в subscribeToSse (L1) и хуке useNotificationFeed (L2). В обычном приложении ты работаешь только с хуком.

Быстрый старт (React)

Внутри MineflowProvider (см. гайд client-react) вызови useNotificationFeed один раз — обычно высоко в дереве, рядом с тостами:

import { useNotifications, useNotificationFeed } from '@mineflow/client-react';
import type { Notification } from '@mineflow/client-react';

function NotificationsCenter() {
// backlog: REST, cursor-пагинация, новые сверху
const { data, isLoading } = useNotifications({ limit: 50 });

// live-фид: новые уведомления префиксятся прямо в кэш useNotifications (дедуп по id)
useNotificationFeed({
onNotification: (n: Notification) => toast(`${n.severity}: ${n.title}`),
});

if (isLoading) return <Spinner />;
return (
<ul>
{data?.items.map((n) => (
<li key={n.id}>{n.title}</li>
))}
</ul>
);
}

Ключевая деталь: useNotificationFeed сам мутирует кэш useNotifications — новое уведомление встаёт в начало items (с дедупом по id). Поэтому компонент, который рендерит список из useNotifications, перерисовывается автоматически, без ручной синхронизации. Колбэк onNotification нужен лишь для побочных эффектов (toast, звук, badge).

Вызывай хук фида один раз

useNotificationFeed открывает SSE-соединение на время жизни компонента. Монтируй его в одном месте (корень, layout), а не в каждом экране — иначе получишь несколько параллельных потоков.

Поле уведомления

Notification приходит и в backlog (useNotifications), и в live-фид (onNotification). Тип ре-экспортится из @mineflow/client-react (источник — OpenAPI-схема Notification_Output):

import type { Notification } from '@mineflow/client-react';

// {
// id: string;
// eventType: string;
// severity: 'info' | 'warning' | 'critical';
// title: string;
// body: string;
// targetRoles: ...; // кому адресовано по ролям
// targetUserId: ...; // персональный адресат (если есть)
// resourceType: ...; // тип сущности-источника
// resourceId: ...; // id сущности (для deep-link)
// createdAt: string;
// }

Используй resourceType + resourceId для перехода к сущности-источнику, а severity — для визуального приоритета (critical → красный тост и т.п.).

Серверная фильтрация по орг и роли

Фид уже отфильтрован сервером: клиент видит только уведомления своей организации (ADR-0020, multi-tenancy через organization_id) и только адресованные его ролям/пользователю. Поля targetRoles / targetUserId отражают эту адресацию — фильтровать поток на клиенте по организации/роли не нужно и нельзя (данные чужих тенантов до клиента не доходят).

Смена ролей не реконнектит поток

Транспорт (fetch + REST + SSE) в MineflowProvider строится один раз и НЕ зависит от roles. Адресация считается сервером по токену, поэтому смена набора ролей в UI-контексте не пересоздаёт SSE-соединение.

Reconnect: backoff + jitter

При разрыве соединение восстанавливается автоматически. Задержка растёт экспоненциально с числом подряд идущих неудач и размывается jitter'ом — это разносит во времени переподключения клиентов, которых разорвало одним событием (рестарт API на сотнях клиентов), вместо синхронного залпа (thundering-herd).

Параметры (дефолты subscribeToSse):

ОпцияДефолтНазначение
reconnectDelayMs1000базовая задержка; 0 — полностью отключить reconnect
maxReconnectDelayMs30000потолок задержки
backoffFactor2множитель экспоненциального роста

Счётчик неудач сбрасывается после успешного подключения — здоровый разрыв реконнектится быстро. Через хук настраивается база backoff:

useNotificationFeed({
reconnectDelayMs: 1000, // база; реальная задержка = exp(attempt) с jitter, потолок 30s
onNotification: (n) => toast(n.title),
});
Equal jitter

Формула задержки — «equal jitter»: половина детерминированная, половина случайная. Это даёт и нижнюю границу (не штормить мгновенно), и размывание (не подключаться синхронно). Подробности реализации — reconnectDelay в client-core.

Низкий уровень: subscribeToSse

Для не-React кода (скрипты, воркеры, кастомные интеграции) подписывайся напрямую через subscribeToSse. Нужен аутентифицированный fetch (с Bearer) — его строит провайдер; в обход React можно собрать его через makeAuthenticatedFetch (см. гайд client-core).

import { subscribeToSse } from '@mineflow/client-core';
import type { SseSubscription } from '@mineflow/client-core';

const sub: SseSubscription = subscribeToSse(
`${baseUrl}/api/v1/notifications/stream`,
{
onMessage: (n) => console.log('notification', n),
onOpen: () => console.log('SSE открыт'),
onError: (e) => console.warn('SSE ошибка (будет reconnect)', e),
},
{
fetchImpl: authenticatedFetch, // fetch с Bearer — EventSource не умеет Authorization
reconnectDelayMs: 1000,
maxReconnectDelayMs: 30_000,
backoffFactor: 2,
},
);

// при завершении — обязательно закрыть (abort'ит fetch и останавливает reconnect)
sub.close();

subscribeToSse сам парсит SSE-фреймы (parseSseData), декодирует JSON и игнорирует не-JSON heartbeat/comment-строки. Обработчик onError вызывается на каждой неудаче перед очередной попыткой reconnect — это не терминальная ошибка, а сигнал «соединение упало, переподключаюсь».

Закрывай подписку

subscribeToSse возвращает SseSubscription с методом close(). Без вызова close() поток продолжит реконнектиться в фоне. В React useNotificationFeed делает это за тебя (в cleanup эффекта), но в ручном коде закрытие — на тебе.

React Native

Дефолтный fetch в RN не отдаёт ReadableStream в response.body, поэтому SSE из коробки не заработает. Передай в SDK fetchImpl с потоковой поддержкой (например, react-native-fetch-api) — тогда subscribeToSse / useNotificationFeed будут читать поток так же, как в web. Детали настройки RN-транспорта — в рецепте React Native.

Куда дальше