web-realtime-sse について
WebアプリでServer-Sent Eventsを実装するための開発スキルです。EventSourceとfetchストリーミングの使い分けに加え、再接続、解析、認証、デプロイ上の注意点を説明します。
主な機能
- 01EventSourceのライフサイクルとイベント処理
- 02fetchによるストリーム解析
- 03再接続とLast-Event-IDによる復元
- 04認証情報とヘッダーの考慮点
- 05型付きメッセージの処理
- 06プロキシとHTTPのデプロイ指針
活用シーン
- 01生成テキストのトークン単位の配信
- 02通知フィードやダッシュボードの構築
- 03切断後のストリーム再開
- 04認証ヘッダーやPOST本文の利用
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-realtime-sseインストール後、Claude Code で/web-realtime-sseを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
metadata.yamlreference.mdexamples/core.mdfetch-streaming.mdreconnection.md詳細
Server-Sent Events(SSE)のパターン
クイックガイド: SSE は通常の HTTP レスポンスを通じてサーバーからクライアントへテキストをプッシュするため、より特殊な通信をブロックするプロキシやファイアウォールを通過できます。
EventSourceは再接続とLast-Event-IDによる再生を標準で提供しますが、GET のみで、ヘッダーを設定できません。fetch ストリーミングはその両方を手放す代わりに、カスタムヘッダー、POST ボディ、AbortControllerを利用できます。判断を左右する事実は、EventSourceはネットワークエラー時には再試行するものの、HTTP エラーステータスでは再試行を完全に停止すること、retry:の単位はミリ秒であること、そしてConnection: keep-aliveは HTTP/2 以降では禁止されていることです。
詳細なリソース:
- examples/core.md — EventSource のライフサイクル、名前付きイベント、認証情報、状態追跡ラッパー、型付きメッセージ、それらを基にした React フック
- examples/fetch-streaming.md — バッファー処理を備えたストリームリーダー、フィールドパーサー、認証ヘッダー、POST ストリーミング、トークン単位の UI
- examples/reconnection.md —
Last-Event-IDによる復旧、指数バックオフ、ヘルスチェック、ページ表示状態に応じた一時停止 - reference.md — メッセージ形式、フィールドの動作、readyState の値、必須のレスポンスヘッダー、EventSource の動作表
どちらの方法を使うか
EventSource— ブラウザーが再接続し、Last-Event-IDを通じてイベントを再生し、ワイヤー形式を解析します。GET のみを送信し、ヘッダーは設定せず、Cookie で認証します(withCredentials: true)。examples/core.md から始めてください。- Fetch ストリーミング — ストリームに
Authorizationヘッダー、POST ボディ、または制御可能なキャンセルが必要な場合に使用します。その場合、再接続、バックオフ、Last-Event-ID、フィールド解析は自分で処理します。examples/fetch-streaming.md を参照してください。
どちらも同じワイヤー形式を使用するため、パーサーとメッセージ型は共通です。
<critical_requirements>
SSE コードを書く前に
利用側が離れたら eventSource.close() を呼び出してください。 開いたストリームはブラウザーのドメインごとの接続数上限を消費し、誰も監視していないハンドラーへの配信を続けます。
onerror 内で readyState を確認してください。 CONNECTING はブラウザーがすでに再試行していることを意味するため、待つのが適切です。CLOSED はブラウザーが再試行を諦めたことを意味するため、再接続は自分で行う必要があります。
サーバーからの各メッセージに id: を付けてください。 ブラウザーは次の接続時に最後の ID を Last-Event-ID として返します。これにより、サーバーは最初からやり直すのではなく、再開できます。
Content-Type: text/event-stream と Cache-Control: no-cache を返してください。 Connection: keep-alive は付けないでください。HTTP/2 以上では禁止されており、Safari はこれを含むレスポンスを拒否します。
一定間隔でコメント行(: keep-alive)を送信してください。 プロキシはアイドル状態と判断したストリームを通常 60~120 秒後に閉じます。コメントはどのハンドラーにも届かず、そのタイマーをリセットします。
</critical_requirements>
自動検出: EventSource、text/event-stream、Last-Event-ID、eventSource.onmessage、eventSource.readyState、EventSource.CONNECTING、withCredentials、addEventListener("message")、retry:、data:、event:、id:、ReadableStream、TextDecoder、response.body.getReader
対象:
- 通知、フィード、ダッシュボードなど、プレーン HTTP によるサーバーからクライアントへのプッシュ
- 生成テキストのトークン単位のストリーミング
- クライアントが受信のみを行うリアルタイムデータフィード
Last-Event-IDによる再開可能なストリームEventSourceを使用できない場合の SSE ワイヤーフォーマットの手動解析
別の項目で扱う内容:
- クライアントからサーバーへの頻繁なメッセージ送信 — SSE には上り方向のチャネルがないため、そのチャネルが必要なクライアントは通常のリクエストとストリームを組み合わせるか、双方向トランスポートを使用します。
- バイナリペイロード — ワイヤーフォーマットは UTF-8 テキストです。バイナリを扱うにはエンコードが必要で、サイズが約 3 分の 1 増加します。
- サーバー側のストリーム実装とリプレイストア。
- 受信後のメッセージの保存先と表示方法。
- ストリームの認証に使うトークンの発行と更新。
SSE は終わることのない HTTP レスポンスです。設計の要点はこれだけで、そこからすべてが決まります。すでに HTTP を運ぶインフラストラクチャーを通じて動作し、ワイヤー上で読み取り可能で、やり直すハンドシェイクがないため、ブラウザーが再接続を担えます。
- 再接続するのはあなたではなくブラウザーです —
EventSourceは独自のスケジュールで再試行し、サーバーはretry:で調整できます。 - リプレイはヘッダーで行います — サーバーは
Last-Event-IDを受け取り、再送する内容を決定します。 - フォーマットは 5 つのフィールドで構成されます —
data:、event:、id:、retry:、そして単独の:コメントです。
CONNECTING (0) → OPEN (1) → messages… → CLOSED (2)
↓ ↓
(error) ← auto-reconnect ← (connection lost)
<decision_framework>
ストリームの認証
EventSource が提供する認証方法は、同一オリジンまたは認証情報付きのクロスオリジンリクエストにおける Cookie のみです。withCredentials: true を設定し、サーバーで CORS の認証情報を許可してください。ベアラートークンを使うには fetch ストリーミングが必要です。トークンは URL ではなく Authorization ヘッダーに入れるためです。短命トークンの場合は、再接続時に新しいトークンを取得する必要もあります。この点も、fetch ストリーミングを選ぶ理由になります。
インフラストラクチャーの背後へのデプロイ
HTTP/1.1 では、ストリームはドメインごとに約 6 つある接続のうち 1 つを占有するため、複数のストリームを同時に開くと、ページのほかの部分で接続が不足します。HTTP/2 は多重化によってこの上限をなくします。リバースプロキシは既定でレスポンスをバッファリングし、バッファーがいっぱいになるまでメッセージを保持します。そのため、対象ルートのバッファリングを無効にし(nginx では X-Accel-Buffering: no)、Cache-Control: no-transform を指定して変換を避けてください。サーバーレスプラットフォームでは、長時間接続のストリームを利用する前に、レスポンスタイムアウトを確認してください。
</decision_framework>
基本パターン
パターン 1:EventSource のライフサイクル
3 つのハンドラーで全体をカバーできます。onerror 内の readyState によって、再試行中なのか、ストリームが終了したのかを判別できます。
const eventSource = new EventSource(SSE_URL);
eventSource.onopen = () => setStatus("open");
eventSource.onmessage = (event: MessageEvent) =>
handle(event.data, event.lastEventId);
eventSource.onerror = () => {
if (eventSource.readyState === EventSource.CLOSED) reconnectManually();
};
全コード:examples/core.md
パターン 2:名前付きイベントの種類
event: フィールドを含むメッセージは、onmessage ではなく、その名前のリスナーに配信されます。
eventSource.addEventListener("notification", (event: MessageEvent) => {
show(JSON.parse(event.data));
});
// messages with no event: field still arrive here
eventSource.onmessage = (event: MessageEvent) => handleDefault(event.data);
全コード:examples/core.md
パターン 3:認証情報とクロスオリジン
withCredentials を使うと、別のオリジンに Cookie が送信されます。CORS の設定に問題がある場合、より具体的な情報はなく、onerror が発生します。
const eventSource = new EventSource(SSE_URL, { withCredentials: true });
全コード:examples/core.md
パターン 4:接続状態と手動再試行
EventSource はネットワークエラーを自動で再試行しますが、HTTP エラーステータスが返されると完全に停止します。状態を追跡しておけば、UI に表示する情報が得られ、そのケースで再試行を行うためのフックも設けられます。
eventSource.onerror = () => {
if (eventSource.readyState === EventSource.CLOSED) {
setStatus("closed");
scheduleRetry(); // the browser will not do this one
} else {
setStatus("error"); // CONNECTING — the browser is already on it
}
};
全コード:examples/core.md
パターン 5:SSE メッセージ形式
フィールドは \n で区切られ、メッセージは \n\n で終わります。フィールドの種類は 5 つあります。data: はペイロード、event: は名前、id: は再開位置、retry: はミリ秒単位の再接続間隔、単独の : はコメントです。
event: notification
data: {"title": "New message"}
id: msg-002
: keep-alive comment (never delivered to a handler)
複数の data: 行は \n で連結されます。id: は後続のメッセージで変更されるまで保持され、retry: は以降のすべての再接続で使われます。
フィールドと動作の全表:reference.md
パターン 6:型付きメッセージの処理
ペイロードに対する判別可能なユニオンを使うと、switch を網羅的にできます。そのため、新しいサーバーメッセージの種類が追加された場合、分岐が黙って無視されるのではなく、コンパイルエラーになります。
type SSEMessage =
| { type: "notification"; title: string; body: string }
| { type: "user-update"; userId: string; action: "joined" | "left" }
| { type: "heartbeat"; serverTime: number };
function handle(message: SSEMessage): void {
switch (message.type) {
case "notification":
return show(message.title, message.body);
case "user-update":
return updatePresence(message.userId, message.action);
case "heartbeat":
return updateServerTime(message.serverTime);
default: {
const exhaustive: never = message;
return exhaustive;
}
}
}
全コード:examples/core.md
<red_flags>
注意すべき点
実行時に問題が発生します:
- コンシューマーのアンマウント時に
close()を呼び出さないと、ストリームが開いたままになり、ドメインごとの接続数上限を消費し、破棄されたハンドラーに対してイベントを発行し続けます。 - 前の
EventSourceを閉じずに新しいものを作成すると、両方が有効なままになり、すべてのメッセージが 2 回届きます。 onerrorを処理しないと、ストリームの失敗と何も起きていない状態を見分けられず、UI に古いデータがいつまでも表示されます。tryを使わずにevent.dataをJSON.parseすると、形式が不正なメッセージが 1 つ届いただけで、それ以降のすべてのメッセージに対するハンドラーが停止します。- URL のクエリ文字列にトークンを含めると、サーバーに記録され、履歴に残り、プロキシからも見えるため、Cookie を使うか、
Authorizationヘッダーを付けた Fetch ストリーミングを使ってください。 - POST が必要な場合に
EventSourceを使うと、GET 以外のリクエストは送信できません。 - Fetch ストリーミングで各チャンクを完全なメッセージとして扱うと、チャンクの境界がメッセージの途中に入るため、バッファーに蓄積して
\n\nで分割してください。 { stream: true }を指定せずにTextDecoderを使うと、マルチバイト文字がチャンク間で分割された場合、文字化けします。- メッセージの内容を検証せずに描画すると、ペイロードは攻撃者の影響を受けるテキストであるため危険です。また、型付きインターフェースはコンパイル時の宣言にすぎず、実行時の保証にはなりません。
予想外の動作:
EventSourceにタイムアウトはありません。ネットワークレベルで接続が切れても、onerrorが発生するまで数分間OPENのままになることがあります。これを検出するために、キープアライブコメントとクライアント側のヘルスチェックがあります。- ネットワークエラーは再試行しますが、HTTP 4xx または 5xx は最終的なエラーとして扱います。そのため、最も再試行が必要になりそうなケースでは再試行されません。
retry:の値はミリ秒です。サーバーがretry: 5を送ると、5 ミリ秒ごとに再接続します。data:\n\nは何も配信しないのではなく、空文字列を配信します。真偽値のチェックでは、実際のメッセージが存在しないものとして扱われます。- 複数行のペイロードは、1 行内のエスケープされた改行ではなく、複数の
data:行として表されます。 - 空の
id:は、以前の値を維持するのではなく、Last-Event-IDをクリアします。 - 再接続時にストリームは再開しますが、コンポーネントの状態は自動でリセットされません。そのため、切断前に蓄積されたデータは、再生された内容と照合する必要があります。
</red_flags>