Опрос раз в пять секунд — это двенадцать запросов в минуту на каждую открытую вкладку. Сорок человек с открытой доской задач дают почти пятьсот запросов в минуту, из которых 99 процентов возвращают «ничего не изменилось». И при этом задержка всё равно до пяти секунд.
Ушёл на Mercure. Опишу, что там устроено не так, как ожидается.
Что такое хаб
Mercure — это отдельный сервис между приложением и браузером. Браузер подписывается на топики через обычный поток серверных событий, приложение публикует в хаб обычным HTTP-запросом, хаб рассылает подписчикам.
Схема выглядит так:
браузер ──SSE──▶ nginx ──▶ хаб Mercure
▲
приложение ──POST /.well-known/mercure──┘Принципиальное отличие от WebSocket: поток односторонний. Сервер шлёт браузеру, браузер серверу — нет, для этого есть обычные запросы. Для интерфейса, где действия и так идут формами, это ровно то, что нужно: не приходится поддерживать отдельный двусторонний протокол.
Приятная мелочь: браузер сам переподключается при обрыве. Если сервер передаёт событиям поле id, EventSource отправит при переподключении Last-Event-ID. Повтор пропущенных событий при этом зависит уже от журнала хаба.
Два ключа и почему их путают
Хаб проверяет права по токену. Ключей два, и они разные по назначению.
Ключ издателя подписывает токен приложения, которым оно доказывает право публиковать. Ключ подписчика подписывает токен браузера, которым он доказывает право читать приватные топики.
mercure:
environment:
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_PUBLISHER_JWT_KEY}
MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_SUBSCRIBER_JWT_KEY}Что происходит при путанице. Если приложение подписывает токен ключом подписчика, хаб отвечает 401 на публикацию — ошибка видна сразу, лечится за минуту.
Хуже другой вариант: одинаковые ключи для издателя и подписчика. Сам по себе subscriber-токен от этого не получает право публикации — для неё по-прежнему нужен claim mercure.publish. Проблема в другом: компрометация одного общего секрета сразу ломает обе границы доверия и позволяет выпускать любые токены.
Одинаковые ключи встречаются во многих примерах для разработки. Я держу их разными даже локально — просто чтобы не перенести привычку на прод.
Приватные топики
Топик — это строка, обычно похожая на URI:
https://app.example/projects/018f2a.../issuesПубличные топики читает кто угодно. Приватные требуют токена подписчика, в котором перечислены разрешённые топики:
public function subscriberToken(Member $member): string
{
$topics = [
"https://app.example/orgs/{$member->getOrganization()->getKey()}/notifications",
];
foreach ($member->accessibleProjectIds() as $projectId) {
$topics[] = "https://app.example/projects/{$projectId}/issues";
}
return $this->jwt->create(['mercure' => ['subscribe' => $topics]]);
}Токен кладётся в куку с флагами HttpOnly и Secure. Для хаба на другом origin подписка создаётся с withCredentials, а SameSite, домен и путь куки должны соответствовать схеме развёртывания. В JavaScript токен не попадает вовсе.
Ловушка: список топиков зафиксирован в момент выдачи токена. Пользователя добавили в новый проект — он не получит обновления, пока не обновит страницу и не получит новую куку. Я выдаю токен на час и обновляю его на каждой полной загрузке страницы.
Где публиковать
Не в контроллере. Это главное правило, и я нарушил его в первой версии.
Контроллер публикует событие, потом сохранение падает по нарушению ограничения, транзакция откатывается. Всем участникам уже улетело «задача перемещена», а на сервере она не перемещена. Расхождение, которое чинится только перезагрузкой.
В моём случае публикация происходит после завершения основного запроса. Это best-effort-вариант: он не отправит событие до обычного сохранения Doctrine, но не даёт транзакционной гарантии и может потерять обновление при аварийном завершении процесса.
Фрагмент обработчика:
#[AsEventListener(event: 'kernel.terminate')]
public function flushPending(): void
{
foreach ($this->pending as $update) {
try {
$this->hub->publish($update);
} catch (\Throwable $e) {
$this->logger->warning('Не удалось опубликовать', ['error' => $e->getMessage()]);
}
}
$this->pending = [];
}Сервисы складывают обновления в очередь внутри запроса, а отправка идёт после того, как ответ уже ушёл пользователю. Заодно это убирает задержку: обращение к хабу не влияет на время ответа. Там, где потеря события недопустима, нужен transactional outbox: запись события в той же транзакции и отдельная доставка из очереди.
Перехват исключения обязателен. Недоступный хаб не должен ронять запрос — real-time это улучшение, а не обязательное условие работы.
Событие или готовый фрагмент
Событие — это JSON вида «задача 018f2a перешла в статус done». Клиент решает, что с этим делать: обновить счётчик, подсветить карточку, проигнорировать, если фильтр её не показывает. Компактно, гибко, требует клиентской логики, дублирующей серверную.
Готовый фрагмент — это HTML карточки, отрендеренный сервером. Клиент вставляет его на место. Никакой логики, никакого дублирования, но фрагмент собран для конкретного контекста, а у разных пользователей контекст разный.
Я выбрал фрагменты и не жалею. Причина простая: с событиями пришлось бы держать на клиенте знание о том, как выглядит карточка задачи. Оно уже есть в шаблоне на сервере, и второй экземпляр этого знания разъедется в первый же месяц.
Проблема разного контекста решилась тем, что фрагмент не зависит от пользовательских UI-фильтров. Проверка прав остаётся на сервере: в поток не попадают поля, которых подписчик не должен видеть. Клиент решает только, подходит ли уже разрешённая карточка под открытый фильтр.
Turbo Streams поверх потока
Turbo умеет слушать поток событий напрямую, если в них лежит его разметка:
<turbo-stream-source
src="{{ mercure(['https://app.example/projects/' ~ project.id ~ '/issues'])|escape('html_attr') }}">
</turbo-stream-source>Один тег в шаблоне. Дальше сервер публикует:
$this->pending[] = new Update(
"https://app.example/projects/{$projectId}/issues",
$this->twig->render('tasks/stream/issue_updated.stream.html.twig', ['issue' => $issue]),
private: true,
);А шаблон потока — это описание операции над DOM:
<turbo-stream action="replace" target="issue_{{ issue.id }}">
<template>{% include 'tasks/_issue_card.html.twig' %}</template>
</turbo-stream>Своего обработчика на клиенте нет вообще. Ноль строк JavaScript на всю функцию обновления доски в реальном времени.
Переподключение и потерянные события
Браузер переподключается сам, но события, случившиеся во время обрыва, к нему не приедут — если хаб не настроен на хранение истории.
Mercure умеет отдавать пропущенное по идентификатору последнего события, для этого нужен журнал. В актуальной конфигурации используется директива transport; старые transport_url и MERCURE_TRANSPORT_URL оставлены только для совместимости. Ниже фрагмент Caddyfile:
mercure {
transport bolt {
path /data/mercure.db
size 1000
cleanup_frequency 0.3
}
}Тысяча событий в буфере — это примерно час активности на моём проекте. Дольше хранить смысла нет: если вкладка была закрыта дольше, проще перезагрузить страницу.
Отдельный случай — возврат на вкладку после долгого сна. Браузер восстанавливает соединение, догоняет буфер, но если событий было больше тысячи, картина будет неполной. Я вешаю на видимость вкладки простую проверку:
document.addEventListener('visibilitychange', () => {
if (!document.hidden && Date.now() - this.lastEventAt > 15 * 60 * 1000) {
Turbo.visit(window.location.href, { action: 'replace' });
}
});Пятнадцать минут без событий и возврат на вкладку — просто перезагружаем содержимое. Грубо и надёжно.
Отладка
Первое, что нужно уметь, — посмотреть поток руками:
curl -N -H "Authorization: Bearer $TOKEN" \
"https://app.test/.well-known/mercure?topic=https://app.example/projects/018f2a/issues"Флаг -N отключает буферизацию. Без него ничего не увидите и решите, что поток не работает.
Дальше по порядку исключения: если в curl события есть, а в браузере нет — проблема в токене подписчика или в куке. Если в curl нет — приложение не публикует или публикует в другой топик.
Самая частая моя ошибка — расхождение в строке топика на один символ. Топики нигде не регистрируются, опечатка не вызывает ошибки: подписка на несуществующий топик совершенно легальна и просто молчит. Я вынес построение топиков в один класс с методами, и опечатки кончились.
Чего это стоило
Один сервис в compose, две переменные с ключами, секция проксирования в nginx с отключённой буферизацией. Примерно час первичной настройки и ещё день на всё остальное, включая описанные грабли.
Взамен — пропали пятьсот бесполезных запросов в минуту, задержка обновления упала с пяти секунд до сотен миллисекунд, а кода на клиенте стало меньше, чем было с опросом.