TrueAsync ServerServer-Sent Events

Server-Sent Events

Итак, импорт адресов теперь запускается кнопкой на странице. Пользователь нажал, файл уехал, обработка пошла. Идёт она минут десять. Что всё это время видит пользователь?

Правильно, крутящийся спиннер. Десять минут. Без единого признака жизни. Примерно на третьей минуте он решит, что всё зависло, обновит страницу и запустит импорт второй раз. Нам нужна полоска прогресса.

Классическое решение — опрос: браузер раз в секунду дёргает /import/progress, сервер отвечает числом. Работает? Работает. Но посчитайте: шестьсот запросов за десять минут, с заголовками, с полным циклом обработки, и в каждом из них полезной нагрузки — одно число. А хочется наоборот: браузер подключился один раз, и дальше сервер сам присылает число, когда оно изменилось.

Ровно это умеет SSE, Server-Sent Events. Никакого нового протокола. Обычный HTTP-ответ, который сервер не закрывает, а дописывает в него события. На стороне браузера их принимает встроенный EventSource, без единой библиотеки.

Прогресс в браузер

php
use function Async\delay;

$server->addHttpHandler(function (HttpRequest $req, HttpResponse $res) {
    if ($req->getPath() !== '/import/progress') { /* ... роутинг ... */ }

    $import = currentImport(); // тот самый ImportService из первой серии

    $res->sseStart();
    $res->sseRetry(3000); // если стрим оборвётся, браузер вернётся через 3 секунды

    while (!$import->isFinished()) {
        $res->sseEvent(
            data:  json_encode(['done' => $import->counter(), 'total' => $import->total()]),
            event: 'progress',
        );

        if (!$res->sendable()) {
            break; // вкладку закрыли, некому рисовать
        }

        delay(1000);
    }

    $res->sseEvent(event: 'finished', data: 'ok');
    $res->end();
});

А в браузере:

js
const es = new EventSource('/import/progress');
es.addEventListener('progress', e => {
    const {done, total} = JSON.parse(e.data);
    bar.style.width = (100 * done / total) + '%';
});
es.addEventListener('finished', () => es.close());

Присмотритесь к структуре обработчика. Цикл. Снятие показаний. delay(1000). Ничего не напоминает? Это же корутина прогресса из самой первой главы, буква в букву. Поменялась только последняя строчка: вместо echo в терминал — sseEvent() в открытый ответ. Пятнадцать глав спустя полоска прогресса добралась до браузера, а код почти не изменился. И, как и тогда, прогресс ничего не знает про импорт, импорт ничего не знает про прогресс.

Под капотом магии тоже нет: SSE-методы — тонкое форматирование поверх send() из четвёртой главы. Отсюда два бесплатных подарка. Обратное давление: медленная вкладка не съест память сервера. И независимость от протокола: тот же обработчик работает поверх HTTP/1.1, HTTP/2 и HTTP/3, а выбирает браузер.

Долгая тишина и heartbeat

SSE-соединение живёт минутами и часами. У долгой жизни, как обычно, свои болезни. Здесь их две.

Первая: таймаут на запись. Сервер по умолчанию ограничивает время отправки ответа, и это правильно. Но SSE-ответ «отправляется» вечно, такова его природа. Для сервиса со стримами лимит отключают:

php
$config->setWriteTimeout(0);

Вторая болезнь тоньше. Предположим, импорт надолго встал на месте: считает что-то тяжёлое, события не идут. Для нас пауза, а для какого-нибудь прокси между сервером и браузером — мёртвое соединение, которое пора зарезать. Он и зарежет, у nginx на это по умолчанию шестьдесят секунд.

Лекарство смешное в своей простоте:

php
$res->sseComment(); // на проводе ":\n\n"

Комментарий. Событие, которое браузер молча игнорирует, но которое проходит через провод и убеждает всех посредников, что соединение живо. Отправляйте по таймеру в паузах, и стрим переживёт любую тишину.

Обрыв и восстановление

Мобильная сеть моргнула, стрим оборвался. Что делать? Ничего. Серьёзно: EventSource восстанавливает соединение сам, подождав интервал из sseRetry().

Единственное, чем стоит ему помочь, это не начинать с чистого листа. Присвойте событиям номера:

php
$res->sseEvent(data: $update, id: (string) $sequence);

При переподключении браузер пришлёт заголовок Last-Event-ID с последним полученным номером, и обработчик продолжит ровно с того места:

php
$since = (int) ($req->getHeader('last-event-id') ?? 0);
foreach (updatesSince($since) as $sequence => $update) {
    $res->sseEvent(data: $update, id: (string) $sequence);
}

Доставка, переподключение, докачка пропущенного. Честный канал уведомлений, и всё это поверх самого обычного HTTP.

С одной оговоркой: канал односторонний. Сервер говорит, браузер слушает. Для полоски прогресса — идеально. А для чата поддержки, где пишут оба? Вот для чата понадобится кое-что посерьёзнее.