Server-Sent Events(SSE)をPHPで使うときの自分用まとめ

SSEを自分なりに調べました、間違いなどありましたらご指摘お願いいたします:bow:

# Server-Sent Events とは

- [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html)

Google翻訳↓

> この仕様は、DOMイベントの形式でサーバーからプッシュ通知を受信するためのHTTP接続を開くためのAPIを定義します。 このAPIは、Push SMSなどの他のプッシュ通知スキームと連携するように拡張できるように設計されています。

よくwebsocketと比較される。websocketはブラウザとサーバーで双方向通信できるが、SSEはサーバーからブラウザへの一方向の通信。

# SSEのすぐ動かせるサンプル

- [Using server-sent events - Web APIs | MDN](https://developer.mozilla.org/ja/docs/Server-sent_events/Using_server-sent_events)

↑のページのコードを参考にさせていただきました

```php:events.php
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-store');
while(true) {
    printf("data: %s\n\n", json_encode([
        'time' => (new DateTime('now', new DateTimeZone('Asia/Tokyo')))->format('H:i:s'),
        'word' => 'abcあいう😀😁😂',
    ]));
    ob_end_flush();
    flush();
    sleep(1);
}
```

```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <script>
    document.addEventListener('DOMContentLoaded', e => {
        const es = new EventSource('./events.php');
        es.addEventListener('message', e => {
            const { time, word } = JSON.parse(e.data);
            sample.appendChild(document.createElement('li')).textContent  = `${time} ${word}`;
        });
    });
    </script>
</head>
<body>
    <ul id="sample"></ul>
</body>
</html>
```

![jP6D7HvW9e.gif](https://qiita-image-store.s3.amazonaws.com/0/142910/15627b1b-f394-0b4d-3b1d-4132af71155e.gif)

このとき、events.phpは以下のように文字列を返しています

![Screen Shot 2019-02-03 at 6.05.35.png](https://qiita-image-store.s3.amazonaws.com/0/142910/91c675b7-3389-c75d-a1ac-045b35ddc0c3.png)

# フィールドごとに調べたこと

`data`、`event`、`id`、`retry` のそれぞれのフィールドについて調べました

## data

基本的に送信するデータは、`'data: ホゲホゲ'`という文字列を`echo`、`print`などで出力すると良い、javascriptで`message`イベントで`e.data`から`ホゲホゲ`という文字列を取得できる

```php
<?php
header('Content-Type: text/event-stream');
echo 'data: ホゲホゲ';
echo "\n\n";
```

```javascript
const es = new EventSource('./events.php');
es.addEventListener('message', e => {
  console.log(e.data); // ホゲホゲ
});
```

---

`events.php`の出力内容が、空白行ごとに区切られてjsのmessageイベントが実行されていきます。送信したい単位で1行空白行を入れることを忘れないようにしましょう。

例1

```:events.phpのレスポンス内容
data: ホゲホゲ

data: フガフガ

data: ピヨピヨ

```

↑だと `message` イベントは3回実行されることになります

---

例2

空白行を入れずに複数行データを送信することもできます

```:events.phpのレスポンス内容
data: ホゲホゲ

data: フガフガ
data: ピヨピヨ

```

↑だと`ホゲホゲ`と`フガフガ\nピヨピヨ`で `message` イベントは2回実行されます



### NG例

- 大文字小文字は区別される

```php:php
<?php
header('Content-Type: text/event-stream;');
echo 'Data: 送信データ'; // Dが大文字なので無視される
echo "\n\n";
```

- 送信したい単位で空白行を1行入れないと送信されない

```php:php
<?php
header('Content-Type: text/event-stream;');
echo 'data: A'; // A が送信される
echo "\n\n";
echo 'data: B';
echo "\n";
echo 'data: C'; // B\nC が送信される
echo "\n\n";
echo 'data: D'; // この行の後に空白行が続いていないため送信されない
```

書き方が間違っていてもエラーになるわけではなく、その行が無視されるだけなのでそこが個人的にやりにくかったです

### 複数データの送信

phpでJSON形式の文字列を送信し、jsで`JSON.parse(e.data)` とする

```php:php
<?php
header('Content-Type: text/event-stream');
printf("data: %s\n\n", json_encode([
    'hoge' => 1,
    'fuga' => 'A',
    'piyo' => false,
]));
```

```javascript:javascript
const es = new EventSource('./events.php');
es.addEventListener('message', e => {
    ({hoge, fuga, piyo} = JSON.parse(e.data));
    console.log(hoge, fuga, piyo); // 1 "A" false
});
```

### JSONをプリティプリントする

送信するJSONの文字列が長くなってしまうとデバッグがしずらくなってしまいます

```php
<?php
header('Content-Type: text/event-stream');
echo 'data:'.json_encode($_SERVER)."\n\n";
```

![Screen Shot 2019-02-03 at 6.58.56.png](https://qiita-image-store.s3.amazonaws.com/0/142910/c1e3ce12-3515-42ee-56c6-2eecff7cf356.png)

私にはこのようなやり方しか思いつきませんでしたが、とりあえずこれで人間の目にも優しく、EventSourceとしてもJSONとして扱えるように出力することができました

```php
<?php
header('Content-Type: text/event-stream');

// echo 'data:'.json_encode($_SERVER)."\n\n";
echo implode("\n", preg_replace('/^/', 'data:', explode("\n", json_encode($_SERVER, JSON_PRETTY_PRINT))))."\n\n";
```

![Screen Shot 2019-02-03 at 7.02.54.png](https://qiita-image-store.s3.amazonaws.com/0/142910/f1254fa8-fabf-62dd-a2cc-9fbc2b6892b9.png)

## event

自分でイベント名を設定することができる

```php:php
header('Content-Type: text/event-stream');

echo "event: add\n";
echo "data: 追加\n\n"; // addイベントで受け取れる

echo "data: ホゲ\n\n"; // event名を設定していないので、messageイベントで受け取れる

echo "event: update\n";
echo "data: 更新\n\n"; // updateイベントで受け取れる
```

```:phpのレスポンス内容
event: add
data: 追加

data: ホゲ

event: update
data: 更新
```

```js:js
const es = new EventSource('./events.php');
es.addEventListener('message', e => {
    console.log('message -> '+e.data); // ホゲ
});
es.addEventListener('add', e => {
    console.log('add -> '+e.data); // 追加
});
es.addEventListener('update', e => {
    console.log('update -> '+e.data); // 更新
});
```

![Screen Shot 2019-02-03 at 7.22.37.png](https://qiita-image-store.s3.amazonaws.com/0/142910/192bbcf7-d3d5-f0cf-7680-9a928502d456.png)

## id

`data`と一緒に一意のIDの文字列を送ることができます。
`e.lastEventId`で取得できるようでした。


```js:js
const es = new EventSource('./events.php');
es.addEventListener('message', e => {
    console.log(e.lastEventId, e.data);
});
```

```php:php
<?php
header('Content-Type: text/event-stream');

echo "id: 1\n";
echo "data:hoge\n\n";

echo "id: 2\n";
echo "data:fuga\n\n";

echo "id: 3\n";
echo "data:piyo\n\n";
```

![Screen Shot 2019-02-03 at 7.53.30.png](https://qiita-image-store.s3.amazonaws.com/0/142910/65498b48-dfc5-17c0-ea2e-9118b9ef9e41.png)

---

`EventSource`ですが、接続先とエラーで接続が途切れた場合、自動で再接続しに行きます。
再接続の際にリクエストヘッダーで`Last-Event-ID`を一緒に送信するようです

※events.phpの処理が正常終了した場合もエラー扱いなようで、`error`イベントが実行されるようでした。

![Screen Shot 2019-02-03 at 75926 copy (1) (1).png](https://qiita-image-store.s3.amazonaws.com/0/142910/0e3c8333-9f7f-a1bc-10d4-7e056662963a.png)

```php:php
echo "data:".$_SERVER['HTTP_LAST_EVENT_ID']."\n\n"; // 3
```

## retry

再接続しに行くまでの秒数を設定できます。
デフォルトが何秒なのかを調べましたが、見つけることができませんでした。感覚的には3秒ぐらいなようです。

```php:php
<?php
header('Content-Type: text/event-stream');

echo "retry:10000\n"; // 10秒に設定
echo "data: hoge\n\n";
```

---

# 接続時のイベント

```js:js
const es = new EventSource('./events.php');
es.addEventListener('open', e => {
    console.log('open');
});
```

# エラー処理

`error`イベントで、接続できなかった場合、接続が途切れた場合などの処理を書ける

```js:js
const es = new EventSource('./events.php');
es.addEventListener('error', e => {
    console.log('error');
    es.close(); // エラーが起きても再接続する必要がない場合はclose()を実行する
})
```

---

phpの方でも接続が切れた場合の処理を作成してみる。

```php
<?php
ignore_user_abort(true);
header('Content-Type: text/event-stream');

// 接続中の間はループ
while(!connection_aborted()) {
    echo "data: ホゲ\n\n";
    ob_end_flush();
    flush();
    sleep(1);
}

// 接続が切れた場合の処理
// ...
```

`connection_aborted()`でクライアントとの接続が切れているかわかるようです。
ただphpのデフォルトではクライアントが接続を破棄した後はスクリプトは終了してしまうため、whileを抜けた後の処理が実行されません。
なので`ignore_user_abort(true);`、または`ini_set('ignore_user_abort', 1);`などとすることで接続が切れた後も途中終了せず処理を実行してくれるようです。

参考

- [PHP: ignore_user_abort - Manual](http://php.net/manual/ja/function.ignore-user-abort.php)
- [PHP: 実行時設定 - Manual](http://php.net/manual/ja/misc.configuration.php#ini.ignore-user-abort)

# 接続状態の確認

![Screen Shot 2019-02-03 at 9.54.58.png](https://qiita-image-store.s3.amazonaws.com/0/142910/f3130e44-980a-4266-6e48-7bba1d0ed7a5.png)

- 引用：https://www.w3.org/TR/eventsource/   ※google翻訳↓
    - 0：接続はまだ確立されていないか、または切断されてユーザーエージェントが再接続しています。
    - 1：ユーザーエージェントはオープン接続を持っており、それを受け取るとイベントを送出しています。
    - 2：接続は開かれておらず、ユーザーエージェントは再接続しようとしていません。致命的なエラーが発生したか、close（）メソッドが呼び出されました。

```js:js
   const es = new EventSource('./events.php');
   console.log('init -> '+es.readyState);
   es.addEventListener('open', e => {
       console.log('open -> '+es.readyState);
   });
   es.addEventListener('message', e => {
       console.log('message -> '+es.readyState);
   });
   es.addEventListener('error', e => {
       console.log('error -> '+es.readyState);
       es.close();
       console.log('error -> '+es.readyState);
   })
```

以下は一度接続し、接続中にサーバをシャットダウンした時の動作です。`es.readyState`で正しく状態が取得できるのが確認できました。

![Screen Shot 2019-02-03 at 9.59.58.png](https://qiita-image-store.s3.amazonaws.com/0/142910/bc79ae29-d54c-5035-75d6-eb3d09cd1b39.png)

# クロスオリジン

ajaxと同じです。
urlを別のドメインに設定すると以下のようにエラーになります

![Screen Shot 2019-02-03 at 10.47.40.png](https://qiita-image-store.s3.amazonaws.com/0/142910/eaef6a4c-9052-89c4-2016-a6b4e58847d1.png)

あまりやったことがないので、以下を参考にしつつ行いました。
間違っていたらご指摘お願いいたしますm(_ _)m

- [CORSリクエストでクレデンシャル(≒クッキー)を必要とする場合の注意点 - Qiita](https://qiita.com/kawaz/items/1e51c374b7a13c21b7e2)
- [jquery - CORS not working php - Stack Overflow](https://stackoverflow.com/questions/18382740/cors-not-working-php)
- [php - While loops for server-sent events are causing page to freeze - Stack Overflow](https://stackoverflow.com/questions/29480791/while-loops-for-server-sent-events-are-causing-page-to-freeze/)

別ドメインのphpを読み込む

```js:js
// http://192.168.33.10 から http://localhost:8888 を読み込みに行く
const es = new EventSource('http://localhost:8888/events.php');
es.addEventListener('message', e => {
    console.log(e.data);
});
```

```php:http&#58;//localhost&#58;8888/events.php
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header("Access-Control-Allow-Origin: *");

echo "data:{$_SERVER["HTTP_HOST"]}{$_SERVER["REQUEST_URI"]}\n\n";
```

別ドメインからデータを取得できました

![Screen Shot 2019-02-03 at 11.39.59.png](https://qiita-image-store.s3.amazonaws.com/0/142910/d92adafb-2757-f905-b33d-95d4a172746b.png)

---

### cookieを使う

```js:js
// http://192.168.33.10 から http://localhost:8888 を読み込みに行く
const es = new EventSource('http://localhost:8888/events.php', { withCredentials: true });
es.addEventListener('message', e => {
    console.log(e.data);
});
```

```php:http&#58;//localhost&#58;8888/events.php
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header("Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}");
header('Access-Control-Allow-Credentials: true');

setcookie('user', 'Jhon');

echo "data: {$_SERVER["HTTP_HOST"]}{$_SERVER["REQUEST_URI"]}\n";
echo "data: ".json_encode($_COOKIE)."\n\n";
```

クロスオリジンでcookieを設定できました

![Cv0k3HxAPg.gif](https://qiita-image-store.s3.amazonaws.com/0/142910/99da571a-b3e0-ff51-e253-6d5deb17be5c.gif)

# 文字化けした

文字化けした時にheaderで`utf-8`と明示すると文字化けしなくなりました

```php:php
<?php
header('Content-Type: text/event-stream; charset=utf-8');
```


# nginxで実行

apacheだと特に問題なく動いたのですが、nginxだとうまく行きませんでした、
下のコードだと1秒おきに出力して欲しいのですが、バッファされているようだけのようでタイムアウトしてしまいました。

↓これだとnginxではうまく動きませんでした。

```php:php
<?php
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-store');
 
while(true) {
    printf("data: %s\n\n", json_encode([
        'time' => (new DateTime())->format(DateTime::RFC3339),
    ]));
    ob_end_flush();
    flush();
    sleep(1);
}
```

`X-Accel-Buffering: no`を追加するとうまく行きました

```diff
<?php
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-store');
+ header('X-Accel-Buffering: no');
 
while(true) {
    printf("data: %s\n\n", json_encode([
        'time' => (new DateTime())->format(DateTime::RFC3339),
    ]));
    ob_end_flush();
    flush();
    sleep(1);
}
```

![c4zW9j3qGT.gif](https://qiita-image-store.s3.amazonaws.com/0/142910/c2724607-ef35-92b3-fc18-c4a606143590.gif)

---

nginxの設定ファイルをいじるという方法でも大丈夫なようです

- [How do I enable PHP’s flush() with nginx+PHP-FPM? - Server Fault](https://serverfault.com/questions/488767/how-do-i-enable-php-s-flush-with-nginxphp-fpm)


# ブラウザ対応状況

https://caniuse.com/#search=server-sent%20events

2019/2/3現在

![Screen Shot 2019-02-03 at 18.16.07.png](https://qiita-image-store.s3.amazonaws.com/0/142910/018885ad-ce3e-b4ca-66eb-e3bc74efe5a3.png)

IE、Edgeでは使えないようなのでpolifillを使うと良いようです

- [Yaffle/EventSource: a polyfill for http://www.w3.org/TR/eventsource/](https://github.com/Yaffle/EventSource)


# その他自分が疑問に思った部分

### `ob_end_flush()`と`flush()`の両方を実行しないといけないのはなぜ?

@7of9 さんから情報いただきました、ありがとうございますm(_ _)m

- [PHP buffer ob_flush() vs. flush() - Stack Overflow](https://stackoverflow.com/questions/4191385/php-buffer-ob-flush-vs-flush)


### `ob_end_flush()`と`ob_flush()`はどちらが良い?

sseで使うのにどちらを使ったほうがいいのかはわかりませんでした。
解説ページによって、`ob_flush()`だったり`ob_end_flush()`だったりしていて、どちらでも動きとしては問題なさそうでした。
情報お持ちの方おりましたらよろしくお願いいたします


---

参考

- [Server-sent events - Wikipedia](https://en.wikipedia.org/wiki/Server-sent_events)
- [Server-Sent Events](https://www.w3.org/TR/eventsource/)
- [Stream Updates with Server-Sent Events - HTML5 Rocks](https://www.html5rocks.com/en/tutorials/eventsource/basics/)
- [キャッシュについて整理 - Qiita](https://qiita.com/anchoor/items/2dc6ab8347c940ea4648)
- [ExpressでServer Sent Event (SSE) を簡単に扱ってみる - Qiita](https://qiita.com/taqm/items/881abcf1df226fb17a15)
- [WebSocketとServer-Sent Eventの違いとリアルタイムWebアプリの作り方 - WPJ](https://www.webprofessional.jp/real-time-apps-websockets-server-sent-events/)
- [十三章第二回　Server-Sent Events — JavaScript初級者から中級者になろう — uhyohyo.net](https://uhyohyo.net/javascript/13_2.html)
- [サーバPUSHざっくりまとめ](https://www.slideshare.net/mawarimichi/push-37869433)
- [Server Sent Events(SSE)の使いどころと使い方 | GREE Engineers&#039; Blog](https://labs.gree.jp/blog/2014/08/11070/)


最後まで読んでいただいてありがとうございましたm(_ _)m