はじめに
この記事は Ateam LifeDesign Advent Calendar 2023 の 24日目の記事です。
メリークリスマス!
昨年は 試しにRemixをDenoで動かす。せっかくだからPrisma Data Proxy経由で。というニッチ記事だったのですが、今年も相変わらずごく一部の方がいいねしそうなネタをお送りしたいと思います。
Server-sent Events(SSE) の制限を体験する
ChatGPTにも使われている SSE
2023年 ChatGPTの衝撃的な登場とともに、おそらく全人類が必ずは一度触ったであろうといっても過言ではない(過言) Server-sent Events。
あの一文字づつ、いかにも生成してます!って感じで文字が出てくるあれですね。
仕様としてはそれほど目新しいものではないのですが、お恥ずかしながら最近まで認知しておりませんでした。
いっちょ触ってみるか、ということで、MDNのドキュメントを斜め読みしていたところ、気になる文面を見つけました。
HTTP/2 ではない場合に強力な制限が掛かる
テロテロ読み進めて行くと、次のような警告がデカデカと登場します。
警告: HTTP/2 上で使用されていない場合、 SSE は開くことができる接続の最大数に制限を受けます。この制限はブラウザー単位で設定されており、非常に小さい数 (6) に設定されているため、複数のタブを開くと特に痛みを伴う場合があります。この問題は、 Chrome と Firefox で「修正予定なし」と示されています。この制限はブラウザー + ドメインごとに設定されており、www.example1.com への SSE 接続をすべてのタブで 6 つ、 www.example2.com への SSE 接続をさらに 6 つ開くことができることを意味します(Stackoverflow によれば)。 HTTP/2 を使用する場合、同時に使用することができる HTTP ストリームの最大数は、サーバーとクライアントの間で交渉が行われます(既定値は 100 です)。
MDNでStackoverflowの投稿が引用されてるのおもろいですね。
本番環境の大半はhttp/2以降で実装されていることが多いと思うので、この警告の条件で実サービス運営上、困るシーンはそれほどないように思いますが、開発環境までhttp/2化されていることは少ないと思います。知らずにいるとハマりそうですね。
これは一体どういうことなんでしょうか。
HTTP/1.1 と HTTP/2 における同時接続数制限の違い
挙動を確認するためのサンプルプログラム
SSEを利用して1秒毎にサーバーの時刻情報を返し、それをクライアント側で表示する簡単なプログラムを用意しました。
こちらの投稿を参考にし、上記仕様で一旦実装したんですが、記事執筆中に deno_std 側で ServerSentEventStreamが機能追加され deno_std 0.209.0 での破壊的変更により ServerSentEvnet
は削除されてしまいました
その為、ServerSentEventStream
による実装に大きく変更しています。
Denoのセットアップだけして頂ければ、pullして下記で実行できます。
$ deno task start
Task start deno run --unstable -A --watch=static/,routes/ dev.ts
Watcher Process started.
The manifest has been generated for 6 routes and 3 islands.
🍋 Fresh ready `
Local: http://localhost:8000/
実際にやってみた
http://localhost:8000/
にアクセスしてもらえると以下表示になるかと思います。
時刻表示部分が毎秒変化していくのがわかるでしょうか。
この部分のサーバー側処理とクライアント側処理です。
サーバ側
import { Handlers } from "$fresh/server.ts";
import {
type ServerSentEventMessage,
ServerSentEventStream,
} from "https://deno.land/std@0.209.0/http/server_sent_event_stream.ts";
import { sleep } from "https://deno.land/x/sleep/mod.ts";
export const handler: Handlers = {
async GET(_req) {
const timeIterator = (async function*() {
while (true) {
const now = new Date();
yield {
event: 'message',
data: JSON.stringify(now)
};
await sleep(1);
}
})();
const stream = ReadableStream.from<ServerSentEventMessage>(timeIterator).pipeThrough(new ServerSentEventStream());
return new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
"connection": "Keep-Alive",
},
});
}
};
ServerSentEvent
を用いた実装よりも非同期ジェネレータを使ってシンプルに書けるので良いですね。
クライアント側
import { useEffect, useState } from "preact/hooks";
export function SseDemo() {
const [clientValue, updateClientValue] = useState(0);
useEffect(() => {
const sse = new EventSource("/sse/stream");
sse.onerror = (err) => {
console.log("Connection Error");
sse.close();
};
sse.onmessage = (event) => {
const data = JSON.parse(event.data);
updateClientValue(data);
};
}, []);
return (
<>
<h1>Server Pushed Value = {clientValue}</h1>
</>
);
}
クライアント側ではサーバからイベント送信毎に受け取った内容を表示していくだけのシンプルな構成です。
接続上限の実験のためにRepeat
でくるんで表示を繰り返すようにしています。
import { SseDemo } from "../islands/sse-demo.tsx";
export function Repeat({ times }) {
return (
<div>
{Array(times).fill().map((_, index) => (
<SseDemo />
))}
</div>
);
}
5回固定的に繰り返すようにしています。
Chromeの場合、同一ドメインへの同時接続数は 6が上限だったはずです。
同じURLを別タブで開いてみましょう。どうなるでしょうか。
1つ目の表示はできたものの、2つ目以降が表示されませんね。
元タブで5つ、新タブで1つで合計6つに制限されていることがわかります。
ネットワーク的な状態も確認してみましょう。
サーバから見た場合、SSEのコネクションがEstablishな状態で常時接続されることが想定できますね。
$ ss -tanp | grep ESTA.*deno
ESTAB 0 0 127.0.0.1:8000 127.0.0.1:33464 users:(("deno",pid=4451,fd=19))
ESTAB 0 0 127.0.0.1:8000 127.0.0.1:33452 users:(("deno",pid=4451,fd=18))
ESTAB 0 0 127.0.0.1:8000 127.0.0.1:34754 users:(("deno",pid=4451,fd=24))
ESTAB 0 0 127.0.0.1:8000 127.0.0.1:34764 users:(("deno",pid=4451,fd=25))
ESTAB 0 0 127.0.0.1:8000 127.0.0.1:33470 users:(("deno",pid=4451,fd=20))
ESTAB 0 0 127.0.0.1:8000 127.0.0.1:33498 users:(("deno",pid=4451,fd=22))
ESTAB 0 0 127.0.0.1:8000 127.0.0.1:33482 users:(("deno",pid=4451,fd=21))
ESTAB 0 0 127.0.0.1:8000 127.0.0.1:33500 users:(("deno",pid=4451,fd=23))
8つのTCP接続が存在しています。おや? 想定より2つ多いですね。
これはFreshがWebSocketで常時サーバーを監視するalive
という接続をしているためです。
1つの画面表示に1のaliveが維持されるので 6 + 2 = 8 接続というわけです。
このようにSSEをlocalhost, http/1.1で開発する場合は同時接続の影響があることがわかりました。
ではこの状態の場合、ChromeDevTools上ではどの様になっているでしょうか。
なんと・・・localhostが pendingのまま何も起きない 状態になりました。
consoleにwarningでも出るかと思ったんですが、そんなこともなくただ通信が詰まってるような状態です。
これはhttp/1.1特有の制限が効いているかどうか、大変気付きづらいですね。。
HTTP/2 だとどうなるか
deno/fresh で構築したので、そのまま Deno Deploy にdeployしてみましょう。
同じように 2つのタブを開くとどうなるでしょうか。
おー。ちゃんと表示されますね。
まとめ
http/1.1 では6接続以上のSSEは機能しないことが実際の動作で確認できました。
ページ内に何本のSSEコネクションを用意する実装なのかという仕様にもよりますが
ブラウザ毎・ドメイン毎の制限としてはやはり厳しいものがある印象です。
また、Chrome Dev Toolで制限されたかどうかは分かりづらいので気をつける必要があります。
特に開発中タブが気づかないうちに増えるような方はハマりそうです。
実際にサービスで利用する場合はhttp/2が前提となりそうですね。
以上「Server-Sent Events の制限を体感する(Deno / Fresh / ServerSentEventStream)」でした!!