2019年10月16日

Server Sent Events

Server-Sent Events の仕様では、サーバとの接続を維持し、サーバからイベントを受けとれるようにする組み込みのクラス EventSource について説明されています。

WebSocket と同様に接続は永続的ですが、重要な違いがいくつかあります:

WebSocket EventSource
双方向: クライアントとサーバ両方がメッセージのやり取りをすることができます 単方向: サーバのみがデータを送信します
バリナリデータとテキストデータ テキストのみ
WebSocket プロトコル 通常の HTTP

EventSourceWebSocket ほど強力ではない、サーバと通信する方法です。

なぜこれを使う必要があるのでしょう?

主たる理由はより簡単だからです。多くのアプリケーションでは、WebSocket は少し強力すぎます。

サーバからデータストリームを受信する必要のある、例えばチャットメッセージやマーケットプレイスなどが EventSource の得意なところです。また、WebSocket では手動で何らかの実装が必要な自動再接続もサポートしています。その上、新しいプロトコルではなく通常の HTTP です。

メッセージの取得

メッセージの受信を開始するには、new EventSource(url) を作成するだけです。

ブラウザは url に接続し、接続を開いたままにしてイベントを待ちます。

サーバはステータス 200 とヘッダ Content-Type: text/event-stream で応答し、接続を維持しつつ次のような特別な形式でメッセージを書き込みます。:

data: Message 1

data: Message 2

data: Message 3
data: of two lines
  • メッセージテキストは data: の後に続きます。コロンの後のスペースは任意です。
  • メッセージは2つの改行 \n\n で区切られます。
  • 改行 \n を送るには、すぐにもう一つ data: を指定します(上の3番目のメッセージ)。

実際には、複雑なメッセージは通常 JSON エンコードされて送信されます。改行は \n としてエンコードされるため、複数の data: メッセージは必要ありません。

例:

data: {"user":"John","message":"First line\n Second line"}

…そのため、1つの data: が1つのメッセージを持つと想定できます。

このようなメッセージごとに、message イベントが生成されます。:

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

eventSource.onmessage = function(event) {
  console.log("New message", event.data);
  // 上のデータストリームでは、3回ログが出力されます
};

// or eventSource.addEventListener('message', ...)

クロスオリジンリクエスト

EventSourcefetch など他のネットワークメソッドのように、クロスオリジンリクエストをサポートします。任意の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: クロスオリジン(Cross-Origin) リクエスト を参照してください。

再接続

作成時に new EventSource はサーバに接続し、接続が切れた場合は再接続します。

気にする必要がないのでとても便利です。

再接続の間にはわずかな遅延があり、デフォルトでは数秒程度です。

サーバは応答の中で retry を使用して推奨する遅延を設定することができます(ミリ秒単位)。

retry: 15000
data: Hello, I set the reconnection delay to 15 seconds

retry: は他のデータを一緒に来ることもあれば、単独のメッセージとしてくることもあります。

ブラウザは再接続する前に、指定されたミリ秒またはそれ以上待つ必要があります。e.g. ブラウザが現時点ではネットワーク接続がないことを知っている場合は、接続されるまで待ってからリトライします。

  • サーバがブラウザが再接続するのを止めてほしい場合は、HTTP ステータス 204 で応答します。
  • ブラウザが接続を閉じたい場合は、eventSource.close() を呼び出します。
let eventSource = new EventSource(...);

eventSource.close();

また、応答の Content-Type が正しくない、あるいは 301, 307, 200, 204 以外の HTTP ステータスである場合、再接続は行われません。接続では "error" イベントが発生し、ブラウザは再接続を行いません。

注意:

接続が最終的に閉じられると、それを “再オープン” することはできません。再び接続したい場合は新たに EventSource を作成します。

Message id

ネットワークの問題により接続が切れた場合、どちらの側もどのメッセージを受信しており、どのメッセージを受信していないのかが確認できません。

そのため接続を正しく再開するには各メッセージに次のような id フィールドが必要です。:

data: Message 1
id: 1

data: Message 2
id: 2

data: Message 3
data: of two lines
id: 3

id を持つメッセージを受け取ると、ブラウザは次のことを行います:

  • プロパティ eventSource.lastEventId にその値を設定します。
  • 再接続でその id が設定されたヘッダ Last-Event-ID を送信し、サーバが次のメッセージを再送信できるようにします。
data: の後に id: を置きます

注意: id はサーバによりメッセージ data の下に追加されます。これは、メッセージを受信した後に lastEventId が更新されるようにするためです。

接続ステータス: readyState

EventSource オブジェクトには readyState プロパティがあり、次のいずれかの値を取ります:

EventSource.CONNECTING = 0; // 接続中 or 再接続中
EventSource.OPEN = 1;       // 接続済み
EventSource.CLOSED = 2;     // 接続終わり

オブジェクトが作成されるか、接続がダウンしたときは常に EventSource.CONNECTING (= 0) になります。

このプロパティを確認することで、EventSource の状態を知ることができます。

イベントタイプ

デフォルトでは EventSource オブジェクトは 3つのイベントを生成します:

  • message – メッセージを受信。 メッセージ自体は event.data で利用できます。
  • open – 接続が開いた.
  • error – 接続が確立できなかった e.g. サーバが HTTP ステータス 500 を返した。

サーバはイベント開始時に event: ... で別の種類のイベントを指定することもできます。

例:

event: join
data: Bob

data: Hello

event: leave
data: Bob

カスタムイベントを扱うには、onmessage ではなく addEventListener を利用する必要があります:

eventSource.addEventListener('join', event => {
  alert(`Joined ${event.data}`);
});

eventSource.addEventListener('message', event => {
  alert(`Said: ${event.data}`);
});

eventSource.addEventListener('leave', event => {
  alert(`Left ${event.data}`);
});

完全な例

これは、1, 2, 3, 次に bye を送信し、その後接続を切断するサーバです。

その後、ブラウザは自動的に再接続します。

結果
server.js
index.html
let http = require('http');
let url = require('url');
let querystring = require('querystring');

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() { // when "Start" button pressed
  if (!window.EventSource) {
    // IE or an old browser
    alert("The browser doesn't support EventSource.");
    return;
  }

  eventSource = new EventSource('digits');

  eventSource.onopen = function(e) {
    log("Event: open");
  };

  eventSource.onerror = function(e) {
    log("Event: error");
    if (this.readyState == EventSource.CONNECTING) {
      log(`Reconnecting (readyState=${this.readyState})...`);
    } else {
      log("Error has occured.");
    }
  };

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

  eventSource.onmessage = function(e) {
    log("Event: message, data: " + e.data);
  };
}

function stop() { // when "Stop" button pressed
  eventSource.close();
  log("eventSource.close()");
}

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

<button onclick="start()">Start</button> Press the "Start" to begin.
<div id="logElem" style="margin: 6px 0"></div>

<button onclick="stop()">Stop</button> "Stop" to finish.

サマリ

EventSource オブジェクトは自動的に永続的な接続を確立し、サーバがメッセージを送信できるようにします。

次のものを提供します:

  • 自動再接続。retry で再試行タイムアウトの調整が可能です。
  • イベントを再開するためのメッセージID。最後に受信した識別子は、再接続する際に Last-Event-ID ヘッダで送信されます。
  • 現在のステータスは readyState で見ることができます。

これにより、EventSourceWebSocket ( WebSocket はより低レベルで、このような組み込み機能はありません(もちろん、実装するこはできます))の代替手段になります。

多くの実際のアプリケーションでは、EventSource の機能で十分です。

すべてのモダンブラウザー(IEは除く)でサポートされています。

構文は次の通りです:

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

2番目の引数には、可能なオプションが1つだけあります: { 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: でカスタムイベント名を設定することができます。このようなイベントは on<event> ではなく、addEventListener で処理する必要があります。

サーバレスポンスフォーマット

サーバは \n\n で区切られたメッセージを送信します。

メッセージには次のフィールドが含まれます:

  • data: – メッセージ本文。複数の一連の data は各パート間に \n を含む単一のメッセージとして解釈されます。
  • id: – 再接続時に Last-Event-ID で送信される lastEventId を更新します。
  • retry: – 再接続のリトライ遅延をミリ秒で指定します。JavaScript からこの値を設定することはできません。
  • event: – イベント名。data: の前になければなりません。

メッセージには任意の順序で1つ以上のフィールドを含めることができますが、id: は通常最後にあります。

チュートリアルマップ