4 березня 2024 р.

Server Sent Events

Специфікація Server-Sent Events описує вбудований клас EventSource, який підтримує з’єднання з сервером і дозволяє отримувати від нього події.

Подібно до WebSocket, з’єднання є постійним.

Але є кілька важливих відмінностей:

WebSocket EventSource
Двонаправлений: клієнт і сервер можуть обмінюватися повідомленнями Однонаправлений: дані надсилає лише сервер
Двійкові та текстові дані Тільки текст
WebSocket протокол Звичайний HTTP

EventSource є менш потужним способом зв’язку з сервером, ніж WebSocket.

Навіщо його використовувати?

Основна причина: він простіший. У багатьох програмах потужність WebSocket є дещо занадто великою.

Нам потрібно отримати потік даних із сервера: можливо, повідомлення в чаті чи ринкові ціни, чи що завгодно. Це те, у чому сильний EventSource. Також він підтримує автоматичне повторне з’єднання, що зазвичай потрібно реалізовувати вручну за допомогою WebSocket. Крім того, це звичайний добре відомий HTTP, а не новий протокол.

Отримання повідомлень

Щоб почати отримувати повідомлення, необхідно створити new EventSource(url).

Браузер приєднається до url і залишить з’єднання відкритим, чекаючи на події.

Сервер повинен відповісти статусом 200 і заголовком Content-Type: text/event-stream, а потім зберегти з’єднання та писати повідомлення в спеціальному форматі, наприклад:

data: Повідомлення 1

data: Повідомлення 2

data: Повідомлення 3
data: з двох рядків
  • Текст повідомлення йде після data:, пробіл після двокрапки необов’язковий.
  • Повідомлення розділені подвійними розривами рядків \n\n.
  • Щоб надіслати розрив рядка \n, ми можемо негайно надіслати ще одне data: (3-тє повідомлення вище).

На практиці складні повідомлення зазвичай надсилаються в кодуванні JSON. Розриви рядків у них кодуються як \n, тому багаторядкові повідомлення data: не потрібні.

Наприклад:

data: {"user":"Тарас","message":"Перший рядок\n Другий рядок"}

…Отже, можемо припустити, що одне data: містить рівно одне повідомлення.

Для кожного такого повідомлення генерується подія message:

let eventSource = new EventSource("/events/subscribe");

eventSource.onmessage = function(event) {
  console.log("Нове повідомлення", event.data);
  // буде зареєстровано 3 рази для потоку даних вище
};

// чи eventSource.addEventListener('message', ...)

Запити з перехресних доменів

EventSource підтримує запити між різними джерелами, як-от fetch та будь-які інші мережеві методи. Ми можемо використовувати будь-яку URL-адресу:

let source = new EventSource("https://another-site.com/events");

Віддалений сервер отримає заголовок Origin і повинен відповісти Access-Control-Allow-Origin , щоб продовжити.

Щоб передати облікові дані, ми повинні встановити додатковий параметр withCredentials, наприклад:

let source = new EventSource("https://another-site.com/events", {
  withCredentials: true
});

Будь ласка, перегляньте розділ Fetch: Запити між різними джерелами, щоб дізнатися більше про заголовки з перехресними джерелами.

Повторне з’єднання

Після створення new EventSource приєднується до сервера, і якщо з’єднання розривається – автоматично приєднується знову.

Це дуже зручно, оскільки не потрібно дбати про це додатково.

Між повторними з’єднаннями є невелика затримка, типово кілька секунд.

Сервер може встановити рекомендовану затримку, використовуючи retry: у відповідь (у мілісекундах):

retry: 15000
data: Привіт, я встановив затримку повторного з’єднання на 15 секунд

retry: може надсилатись як разом із деякими даними, так і окремим повідомленням.

Браузер повинен зачекати вказану кількість мілісекунд перед повторним з’єднанням. Або довше, напр. якщо браузер знає (з ОС), що на даний момент немає з’єднання із мережею, він може зачекати, доки з’єднання з’явиться, а потім повторити спробу.

  • Якщо сервер бажає, щоб браузер припинив повторне з’єднання, він повинен відповісти HTTP статусом 204.
  • Якщо браузер хоче закрити з’єднання, він повинен викликати eventSource.close():
let eventSource = new EventSource(...);

eventSource.close();

Крім того, не буде повторного з’єднання, якщо відповідь містить неправильний Content-Type або його статус HTTP відрізняється від 301, 307, 200 і 204. У таких випадках буде створено подію "error", і браузер не підключатиметься повторно.

Будь ласка, зверніть увагу:

Коли з’єднання остаточно закрито, його неможливо відкрити знову. Якщо ми хочемо знову під’єднатися, доведеться створити новий EventSource.

Ідентифікатор повідомлення

Коли з’єднання розривається через проблеми з мережею, жодна сторона не може бути впевнена, які повідомлення були отримані, а які ні.

Щоб правильно відновити з’єднання, кожне повідомлення має мати поле id, наприклад:

data: Повідомлення 1
id: 1

data: Повідомлення 2
id: 2

data: Повідомлення 3
data: з двох рядків
id: 3

Коли браузер отримає повідомлення з id::

  • Встановлюється значення властивості eventSource.lastEventId.
  • Після повторного з’єднання надсилається заголовок Last-Event-ID з цим id, щоб сервер міг повторно надіслати наступні повідомлення.
Зазначайте id: після data:

Зверніть увагу: id додається сервером під повідомленням data , щоб гарантувати, що lastEventId оновлюється після отримання повідомлення.

Статус з’єднання: readyState

Об’єкт EventSource має властивість readyState, яка має одне з трьох значень:

EventSource.CONNECTING = 0; // з’єднання або повторне з’єднання
EventSource.OPEN = 1;       // сполучено
EventSource.CLOSED = 2;     // з’єднання закрите

Коли створюється об’єкт або з’єднання розривається EventSource.CONNECTING (дорівнює 0).

Ми можемо запитати цю властивість, щоб дізнатися стан EventSource.

Типи подій

Типово об’єкт EventSource генерує три події:

  • message – отримане повідомлення, доступне як event.data.
  • open – з’єднання відкрите.
  • error – не вдалося приєднатися, напр. сервер повернув статус HTTP 500.

Сервер може вказати інший тип події з event: ... на початку події.

Наприклад:

event: join
data: Боб

data: Привіт

event: leave
data: Боб

Щоб обробляти спеціальні події, ми повинні використовувати addEventListener, а не onmessage:

eventSource.addEventListener('join', event => {
  alert(`Приєднався ${event.data}`);
});

eventSource.addEventListener('message', event => {
  alert(`Сказав: ${event.data}`);
});

eventSource.addEventListener('leave', event => {
  alert(`Вийшов ${event.data}`);
});

Повний приклад

Ось сервер, який надсилає повідомлення з 1, 2, 3, потім bye та розриває з’єднання.

Потім браузер автоматично відновить з’єднання.

Результат
server.js
index.html
let http = require('http');
let url = require('url');
let querystring = require('querystring');
let static = require('node-static');
let fileServer = new static.Server('.');

function onDigits(req, res) {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-cache'
  });

  let i = 0;

  let timer = setInterval(write, 1000);
  write();

  function write() {
    i++;

    if (i == 4) {
      res.write('event: bye\ndata: bye-bye\n\n');
      clearInterval(timer);
      res.end();
      return;
    }

    res.write('data: ' + i + '\n\n');

  }
}

function accept(req, res) {

  if (req.url == '/digits') {
    onDigits(req, res);
    return;
  }

  fileServer.serve(req, res);
}


if (!module.parent) {
  http.createServer(accept).listen(8080);
} else {
  exports.accept = accept;
}
<!DOCTYPE html>
<script>
let eventSource;

function start() { // коли кнопку "Пуск" натиснуто
  if (!window.EventSource) {
    // для IE чи іншого старого браузера
    alert("Браузер не підтримує EventSource.");
    return;
  }

  eventSource = new EventSource('digits');

  eventSource.onopen = function(e) {
    log("Подія: open");
  };

  eventSource.onerror = function(e) {
    log("Подія: error");
    if (this.readyState == EventSource.CONNECTING) {
      log(`Повторне з’єднання (readyState=${this.readyState})...`);
    } else {
      log("Сталася помилка.");
    }
  };

  eventSource.addEventListener('bye', function(e) {
    log("Подія: bye, дані: " + e.data);
  });

  eventSource.onmessage = function(e) {
    log("Подія: message, дані: " + e.data);
  };
}

function stop() { // коли кнопку "Стоп" натиснуто
  eventSource.close();
  log("eventSource.close()");
}

function log(msg) {
  logElem.innerHTML += msg + "<br>";
  document.documentElement.scrollTop = 99999999;
}
</script>

<button onclick="start()">Пуск</button> Натісніть "Пуск" для початку.
<div id="logElem" style="margin: 6px 0"></div>

<button onclick="stop()">Стоп</button> "Стоп" для зупинки.

Підсумки

Об’єкт EventSource автоматично встановлює постійне з’єднання і дозволяє серверу надсилати повідомлення через нього.

Він пропонує:

  • Автоматичне перепідключення, з затримкою retry що налаштовується.
  • Ідентифікатори повідомлень для відновлення подій, останній отриманий id надсилається в заголовку Last-Event-ID після повторного з’єднання.
  • Поточний стан знаходиться у властивості readyState.

Це робить EventSource життєздатною альтернативою WebSocket, оскільки останній є більш низькорівневим і не має таких вбудованих функцій (хоча їх можна реалізувати).

У багатьох реальних програмах потужності EventSource якраз достатньо.

Підтримується у всіх сучасних браузерах (не в IE).

Синтаксис такий:

let source = new EventSource(url, [credentials]);

Другий аргумент має лише один можливий варіант: { withCredentials: true }, він дозволяє надсилати облікові дані між різними джерелами.

Загальна безпека між різними джерелами така ж, як і для fetch та інших мережевих методів.

Властивості об’єкта EventSource

readyState
Поточний стан з’єднання: або EventSource.CONNECTING (=0), EventSource.OPEN (=1) чи EventSource.CLOSED (=2).
lastEventId
Останній отриманний id. Після повторного з’єднання браузер надсилає його в заголовку Last-Event-ID.

Методи

close()
Замикає з’єднання.

Події

message
Повідомлення отримано, дані в event.data.
open
З’єднання встановлено.
error
У разі помилки, включає як втрачене з’єднання (буде автоматично відновлено), так і фатальні помилки. Ми можемо перевірити readyState, щоб побачити, чи робиться спроба повторного з’єднання.

Сервер може встановити спеціальне ім’я події в event:. Такі події слід обробляти за допомогою addEventListener, а не on<event>.

Формат відповіді сервера

Сервер надсилає повідомлення, розділені \n\n.

Повідомлення може мати такі поля:

  • data: – у тілі повідомлення, послідовність кількох data інтерпретується як одне повідомлення з \n між його частинами.
  • id: – поновлює lastEventId, надісланий у Last-Event-ID під час повторного з’єднання.
  • retry: – радить затримку повторного з’єднання у мс. Немає способу встановити його за допомогою JavaScript.
  • event: – ім’я події має передувати data:.

Повідомлення може містити один або кілька рядків у будь-якому порядку, але id: зазвичай йде останнім…

Навчальна карта