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() з четвертого розділу. Звідси два безкоштовні подарунки. Backpressure: повільна вкладка не з'їсть пам'ять сервера. І незалежність від протоколу: той самий обробник працює через 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.

З одним застереженням: канал односторонній. Сервер говорить, браузер слухає. Для індикатора прогресу це ідеально. А для чату підтримки, де пишуть обидві сторони? Для чату знадобиться щось серйозніше.