Server Side Events -- события с сервера

Сразу заметим, что на текущий момент этот способ поддерживают все современные браузеры, кроме IE.

Современный стандарт Server-Sent Events позволяет браузеру создавать специальный объект EventSource, который сам обеспечивает соединение с сервером, делает пересоединение в случае обрыва и генерирует события при поступлении данных.

Он, по дизайну, может меньше, чем WebSocket’ы.

С другой стороны, Server Side Events проще в реализации, работают по обычному протоколу HTTP и сразу поддерживают ряд возможностей, которые для WebSocket ещё надо реализовать.

Поэтому в тех случаях, когда нужна преимущественно односторонняя передача данных от сервера к браузеру, они могут быть удачным выбором.

Получение сообщений

При создании объекта new EventSource(src) браузер автоматически подключается к адресу src и начинает получать с него события:

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

eventSource.onmessage = function(e) {
  console.log("Пришло сообщение: " + e.data);
};

Чтобы соединение успешно открылось, сервер должен ответить с заголовком Content-Type: text/event-stream, а затем оставить соединение висящим и писать в него сообщения в специальном формате:

data: Сообщение 1

data: Сообщение 2

data: Сообщение 3
data: из двух строк
  • Каждое сообщение пишется после data:. Если после двоеточия есть пробел, то он игнорируется.

  • Сообщения разделяются двумя строками \n\n.

  • Если нужно переслать перевод строки, то сообщение разделяется. Каждая следующая строка пересылается отдельным data:.

    В частности, две последние строки в примере выше составляют одно сообщение: "Сообщение 3\nиз двух строк".

Здесь все очень просто и удобно, кроме разделения сообщения при переводе строки. Но, если подумать – это не так уж страшно: на практике сложные сообщения обычно передаются в формате JSON. А перевод строки в нём кодируется как \n.

Соответственно, многострочные данные будут пересылаться так:

data: {"user":"Вася","message":"Сообщение 3\n из двух строк"}

…То есть, строка data: будет одна, и никаких проблем с разделением сообщения нет.

Восстановление соединения

При создании объекта браузер автоматически подключается к серверу, а при обрыве – пытается его возобновить.

Это очень удобно, никакой другой транспорт не обладает такой встроенной способностью.

Как серверу полностью закрыть соединение?

При любом закрытии соединения, в том числе если сервер ответит на запрос и закроет соединение сам – браузер через короткое время повторит свой запрос.

Есть лишь два способа, которыми сервер может «отшить» надоедливый EventSource:

  • Ответить со статусом не 200.
  • Ответить с Content-Type, не совпадающим с text/event-stream.

Между попытками возобновить соединение будет пауза, начальное значение которой зависит от браузера (1-3 секунды) и может быть изменено сервером через указание retry: в ответе:

retry: 15000
data: Поставлена задержка 15 секунд

Браузер, со своей стороны, может закрыть соединение вызовом close():

var eventSource = new EventSource(...);

eventSource.close();

При этом дальнейших попыток соединения не будет. Открыть обратно этот объект тоже нельзя, можно создать новый EventSource.

Идентификатор id

Для того, чтобы продолжить получение событий с места разрыва, стандарт предусматривает идентификацию событий через id.

Сервер может указать его в ответе:

data: Сообщение 1
id: 1

data: Сообщение 2
id: 2

data: Сообщение 3
data: из двух строк
id: 3

При получении id: браузер:

  • Устанавливает свойство eventSource.lastEventId в его значение.
  • При пересоединении пошлёт заголовок Last-Event-ID с этим id, так что сервер сможет переслать последующие, пропущенные, сообщения.

Обратим внимание: id шлётся не перед сообщением, а после него, чтобы обновление lastEventId произошло, когда браузер всё уже точно получил.

Статус соединения readyState

У объекта EventSource есть свойство readyState, которое содержит одно из значений (выдержка из стандарта):

const unsigned short CONNECTING = 0; // в процессе (пере-)соединения
const unsigned short OPEN = 1;       // соединение установлено
const unsigned short CLOSED = 2;     // соединение закрыто

При создании объекта и при разрыве оно автоматически равно CONNECTING.

События

Событий всего три:

  • onmessage – пришло сообщение, доступно как event.data
  • onopen – при успешном установлении соединения
  • onerror – при ошибке соединения.

Например:

var eventSource = new EventSource('digits');

eventSource.onopen = function(e) {
  console.log("Соединение открыто");
};

eventSource.onerror = function(e) {
  if (this.readyState == EventSource.CONNECTING) {
    console.log("Соединение порвалось, пересоединяемся...");
  } else {
    console.log("Ошибка, состояние: " + this.readyState);
  }
};

eventSource.onmessage = function(e) {
  console.log("Пришли данные: " + e.data);
};

Своё имя события: event

По умолчанию на события срабатывает обработчик onmessage, но можно сделать и свои события. Для этого сервер должен указать перед событием его имя после event:.

Например:

event: join
data: Вася

data: Привет

event: leave
data: Вася

Сообщение по умолчанию имеет имя message.

Для обработки своих имён событий необходимо ставить обработчик при помощи addEventListener.

Пример кода для обработки:

eventSource.addEventListener('join', function(e) {
  alert( 'Пришёл ' + e.data );
});

eventSource.addEventListener('message', function(e) {
  alert( 'Сообщение ' + e.data );
});

eventSource.addEventListener('leave', function(e) {
  alert( 'Ушёл ' + e.data );
});

Демо

В примере ниже сервер посылает в соединение числа от 1 до 3, а затем – событие bye и закрывает соединение. Браузер автоматически откроет его заново.

Результат
server.js
index.html
var http = require('http');
var url = require('url');
var querystring = require('querystring');

var fileServer = new(require('node-static')).Server('.');

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

  var i = 0;

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

  function write() {
    i++;

    if (i == 4) {
      res.write('event: bye\ndata: до свидания\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);


}



// ----- запуск accept как сервера из консоли или как модуля ------

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

<head>
  <meta charset="utf-8">
  <script>
    var eventSource;

    function start() { // при нажатии на Старт

      if (!window.EventSource) {
        alert('В этом браузере нет поддержки EventSource.');
        return;
      }

      eventSource = new EventSource('digits');

      eventSource.onopen = function(e) {
        log("Соединение открыто");
      };

      eventSource.onerror = function(e) {
        if (this.readyState == EventSource.CONNECTING) {
          log("Соединение порвалось, пересоединяемся...");
        } else {
          log("Ошибка, состояние: " + this.readyState);
        }
      };

      eventSource.addEventListener('bye', function(e) {
        log("Bye: " + e.data);
      }, false);

      eventSource.onmessage = function(e) {
        console.log(e);
        log(e.data);
      };
    }

    function stop() { // при нажатии на Стоп
      eventSource.close();
      log("Соединение завершено");
    }

    function log(msg) {
      logElem.innerHTML += msg + "<br>";
    }
  </script>
</head>

<body>

  <button onclick="start()">Старт</button>
  <button onclick="stop()">Стоп</button>

  Нажмите "Старт" для начала.
  <div id="logElem"></div>


</body>

</html>

Кросс-доменность

EventSource поддерживает кросс-доменные запросы, аналогично XMLHttpRequest. Для этого у конструктора есть второй аргумент – объект, который нужно передать так:

var source = new EventSource("http://pupkin.ru/stream", {
  withCredentials: true
});

Второй аргумент сделан объектом с расчётом на будущее. Пока что никаких других свойств там не поддерживается, только withCredentials.

Сервер при этом получит заголовок Origin с доменом запроса и должен ответить с заголовком Access-Control-Allow-OriginAccess-Control-Allow-Credentials, если стоит withCredentials), в точности как в главе XMLHttpRequest: кросс-доменные запросы.

При кросс-доменных запросах у событий event также появится дополнительное свойство origin, содержащее адрес источника, откуда пришли данные. Его можно использовать для дополнительной проверки со стороны браузера:

eventSource.addEventListener('message', function(e) {
  if (e.origin != 'http://javascript.ru') return;
  alert( 'Сообщение ' + e.data );
});

Итого

Объект EventSource предназначен для передачи текстовых сообщений с сервера, используя обычный протокол HTTP.

Он предлагает не только передачу сообщений, но и встроенную поддержку важных вспомогательных функций:

  • События event.
  • Автоматическое пересоединение, с настраиваемой задержкой retry.
  • Проверка текущего состояния подключения по readyState.
  • Идентификаторы сообщений id для точного возобновления потока данных, последний полученный идентификатор передаётся в заголовке Last-Event-ID.
  • Кросс-доменность CORS.

Этот набор функций делает EventSource достойной альтернативой WebSocket, которые хоть и потенциально мощнее, но требуют реализации всех этих функций на клиенте и сервере, поверх протокола.

Поддержка – все браузеры, кроме IE.

  • Синтаксис:

    var source = new EventSource(src[, credentials]); // src - адрес с любого домена

    Второй необязательный аргумент, если указан в виде { withCredentials: true }, инициирует отправку Cookie и данных авторизации при кросс-доменных запросах.

    Безопасность при кросс-доменных запросах обеспечивается аналогично XMLHttpRequest.

  • Свойства объекта:

    readyState
    Текущее состояние соединения, одно из EventSource.CONNECTING (=0), EventSource.OPEN (=1) или EventSource.CLOSED (=2).
    lastEventId
    Последнее полученное id, если есть. При возобновлении соединения браузер указывает это значение в заголовке Last-Event-ID.
    url, withCredentials
    Параметры, переданные при создании объекта. Менять их нельзя.
  • Методы:

    close()
    Закрывает соединение.
  • События:

    onmessage
    При сообщении, данные – в event.data.
    onopen
    При установлении соединения.
    onerror
    При ошибке, в том числе – закрытии соединения по инициативе сервера.

    Эти события можно ставить напрямую через свойство: source.onmessage = ....

    Если сервер присылает имя события в event:, то такие события нужно обрабатывать через addEventListener.

  • Формат ответа сервера:

    Сервер присылает пустые строки, либо строки, начинающиеся с:

    • data: – сообщение, несколько таких строк подряд склеиваются и образуют одно сообщение.
    • id: – обновляет lastEventId.
    • retry: – указывает паузу между пересоединениями, в миллисекундах. JavaScript не может указать это значение, только сервер.
    • event: – имя события, должен быть перед data:.
Карта учебника

Комментарии

перед тем как писать…
  • Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них.
  • Для одной строки кода используйте тег <code>, для нескольких строк кода — тег <pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)
  • Если что-то непонятно в статье — пишите, что именно и с какого места.