web-realtime-websockets について
リアルタイムWeb機能を開発する人向けに、ブラウザーのWebSocket実装パターンを解説します。接続復旧、メッセージ処理、バイナリ転送、ページのライフサイクルを扱います。
主な機能
- 01ジッターと上限付きの指数再接続
- 02ハートビートによる接続確認
- 03切断中のメッセージキューイング
- 04型付きメッセージとバイナリフレーム
- 05Reactフックと共有接続
- 06認証とbfcacheのライフサイクル対応
活用シーン
- 01チャットや共同編集機能の構築
- 02ネットワーク切断後の接続復旧
- 03進捗表示付きの分割ファイル転送
- 04複数のUIコンポーネントで接続を共有
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-realtime-websocketsインストール後、Claude Code で/web-realtime-websocketsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
metadata.yamlreference.mdexamples/binary.mdcore.mdpresence.mdstate-machine.md詳細
WebSocket リアルタイム通信のパターン
クイックガイド: ネイティブの WebSocket API が提供するのは全二重チャネルだけです。再接続、死活監視、切断中のメッセージ配信、メッセージの型付けはすべて自分で実装する必要があり、このスキルではそれぞれの実装パターンを説明します。対応を左右する重要な事実は次のとおりです。API ではカスタムヘッダーを受け付けないため、認証情報はヘッダーではなく最初のメッセージとして送信します。
onerrorの後には必ずoncloseが発生するため、復旧処理は一か所にまとめます。接続が開いたままだとブラウザーの戻る/進むキャッシュが無効になります。また、readyStateの変化は、それを引き起こす呼び出しと同期して発生するわけではありません。
詳細なリソース:
- examples/core.md — ライフサイクル、ジッターを加えたバックオフ、ハートビート、キューイング、型付きメッセージ、バイナリプロトコル、認証、ルーム、
useWebSocket、共有接続、bfcache - examples/state-machine.md — 接続状態をリデューサーで管理する方法。真偽値だけでは不十分になる場面を説明します
- examples/binary.md — 進捗表示付きのチャンク分割ファイルアップロード
- examples/presence.md — プレゼンスと離席検出
- reference.md — 再接続の指針を含むクローズコードの表、readyState の値、コード付きのアンチパターン、デプロイのチェックリスト
どの方法を選ぶか
- 1 つのコンポーネントが接続を管理する場合 — フックでソケットを作成し、バックオフとハートビートのタイマーを保持して、アンマウント時に接続を閉じます。examples/core.md から始めてください。
- 複数のコンポーネントで 1 つの接続を共有する場合 — プロバイダーでソケットを管理し、
subscribe(type, handler)API を通じてメッセージの種類ごとに振り分けます。この API は独自の購読解除関数を返します。コンポーネントごとにソケットを開くと、同じサーバーに対するハンドシェイクとハートビートが増えます。こちらも examples/core.md を参照してください。
接続の状態が、試行回数を伴う再接続や恒久的な失敗など、区別すべきものが 2、3 個を超えたら、真偽値のフラグではうまく組み合わせられなくなります。その場合は、それらを置き換えるパターンとして examples/state-machine.md を参照してください。
<critical_requirements>
WebSocket コードを書く前に
指数バックオフにジッターを加え、遅延時間と試行回数の両方に上限を設けます。 サーバーの再起動で切断されたクライアントはすべて同時に再接続しようとします。ジッターを加えることで再接続を分散し、障害が再発するのを防ぎます。
すべてのメッセージに type フィールドを持たせ、判別可能なユニオンとしてモデル化します。 デフォルト分岐で never を代入すれば、新しいサーバーメッセージが追加されたときに、値が処理をすり抜けるのではなくコンパイルエラーになります。
ソケットが OPEN でない間に送信しようとしたメッセージをキューに入れ、再接続時にフラッシュします。 閉じたソケットに対する send() は状態によって例外を発生させたり、メッセージを破棄したりします。上限付きキューを使えば、2 秒間の切断をデータ損失ではなく遅延にできます。
ハートビートを実行し、応答がない場合は接続が切断されたものと見なします。 プロキシや NAT はクローズフレームを送信せずにアイドル状態の接続を切断するため、ソケットが何も運ばなくなってから数分間、状態が OPEN のままになることがあります。
ページが HTTPS で配信されている場合は、必ず wss:// を使います。 localhost を除き、ブラウザーはセキュアなオリジンからの非セキュアなスキームをブロックします。
pagehide で接続を閉じ、event.persisted が真の場合は pageshow で再接続します。 開いたままのソケットがあるとページは戻る/進むキャッシュの対象外になるため、これにより戻る操作を即座に行えるようになります。
</critical_requirements>
自動検出: new WebSocket、wss://、ws://、socket.onmessage、socket.onopen、socket.onclose、socket.onerror、readyState、WebSocket.OPEN、bufferedAmount、binaryType、CloseEvent、event.code、pagehide、pageshow、event.persisted、WebSocketStream
対象:
- チャット、共同編集、ライブダッシュボードなど、低遅延の双方向メッセージング
- バックオフ、ジッター、再試行上限を用いた再接続
- ハートビートとクローズコードの解釈による稼働状態の検出
- 上限付きキューによる短時間の切断をまたいだ配信
- 型付きメッセージ契約と網羅的な処理
- 進捗表示を伴うチャンク転送を含むバイナリフレーム
- コンポーネントツリー全体での接続の共有
別の項目で扱うもの:
- クライアントが送信を行わない場合のサーバープッシュ — 双方向チャネルとその再接続機構を導入しても意味はなく、長時間の HTTP レスポンスのほうが軽量な選択肢です。
- プロトコルレイヤーが提供するルーム、名前空間、確認応答、トランスポートのフォールバック — ここでのルームパターンは生の API を使って自前で実装するものであり、これらを提供するライブラリは、異なるワイヤーフォーマットを扱う別のテーマです。
- サーバー側の接続レジストリ、ブロードキャストのファンアウト、ルームのメンバーシップ。
- 受信したメッセージの保存先と表示方法。
- 最初のメッセージに含めるトークンの発行と更新。
WebSocket は、フレーミングを重ねた状態で開いたまま維持する TCP 接続です。HTTP が無料で提供していたものはすべてなくなり、代わりに自分で担うことになるものが、このスキルの対象です。
- ネットワークは接続を切断します。 再接続は例外的なケースではないため、バックオフとジッターは後からではなく、最初のバージョンから組み込みます。
- 切断された接続は、通信がないだけの接続と見分けがつきません。 中間機器によって切断された接続はクローズフレームを送信しないため、それを見分けられるのはハートビートだけです。
- 切断中に送信したメッセージは失われます。 上限付きキューを使えば、これを遅延に変えられます。
- フレームは型のない文字列です。 型安全性は、その上に自分でモデル化するものです。
- 接続を開いたままにすると、帯域幅以外のコストも発生します。 ページが bfcache の対象外になり、エラーではなく戻る操作の遅さとして表れます。
CONNECTING -> OPEN <-> (messages) -> CLOSING -> CLOSED
| |
(error) <- reconnect <- (close)
<decision_framework>
クローズイベントの読み取り
event.code は再接続が妥当かどうかを示します。1000 は正常かつ意図的なクローズで、再接続するとユーザーの意図に反します。1006 はフレームを伴わない異常クローズで、通常のネットワーク切断にあたり、バックオフが必要になるケースです。1012 と 1013 は、サーバーがより長い待機時間を求めています。100x のプロトコルエラーとデータエラーはクライアント側の問題を示し、再試行しても同じエラーが再現します。再接続列を含む完全な表は reference.md にあります。
意図的なクローズは、コードとは別に追跡してください。ユーザーが切断を押した場合もサーバーが 1000 を送った場合も、どちらも 1000 として届きますが、再試行ループを完全に停止すべきなのは一方だけです。
メッセージ形式の選択
判別可能なユニオンを使った JSON がデフォルトです。その大きな理由は、通信上で読みやすいことです。バイナリフレームを使うのは、測定値からペイロードサイズが実際に問題だと分かった場合に限り、混在形式を維持してください。つまり、制御メッセージには JSON、ペイロードにはバイナリを使い、すべてを一方の形式にエンコードしないようにします。
大きなペイロードの送信
send() はあらゆるデータを受け付け、ネットワークがまだ送信していないデータをバッファリングするため、高速なプロデューサーは bufferedAmount を際限なく増加させます。大きなデータを送信する前に値を確認し、ペイロードをチャンクに分割してください。これにより進捗も報告できるようになります。この数値以外に、組み込みのバックプレッシャー通知はありません。
</decision_framework>
基本パターン
パターン 1:基本的な接続
4 つのハンドラーには、それぞれ役割があります。onerror には有用な詳細情報がなく、必ず後続の onclose が発生するため、復旧処理は onclose だけに記述します。
const socket = new WebSocket(WS_URL);
socket.onopen = () => flushQueue();
socket.onmessage = (event: MessageEvent) => handle(event.data);
socket.onerror = () => markUnhealthy(); // no detail available, onclose follows
socket.onclose = (event: CloseEvent) => maybeReconnect(event.code);
全コード:examples/core.md
パターン 2:ジッターを加えた指数バックオフ
重要なのはジッターです。ジッターがないと、同時に切断されたすべてのクライアントが、再び同時に戻ってきてしまいます。
function calculateBackoff(attempt: number): number {
const exponential = Math.min(
INITIAL_BACKOFF_MS * Math.pow(BACKOFF_MULTIPLIER, attempt),
MAX_BACKOFF_MS,
);
const jitter = exponential * JITTER_FACTOR * (Math.random() * 2 - 1);
return Math.floor(exponential + jitter);
}
全コード:examples/core.md
パターン 3:ハートビートと ping-pong
一定間隔で送信し、それより短いタイムアウトを設定して、応答があれば解除します。タイムアウトが発生した場合、readyState がどれほど正常に見えても、接続は切れています。
const ping = setInterval(() => {
socket.send(JSON.stringify({ type: "ping" }));
pongTimer = setTimeout(
() => socket.close(4000, "heartbeat timeout"),
HEARTBEAT_TIMEOUT_MS,
);
}, HEARTBEAT_INTERVAL_MS);
// on receiving { type: "pong" }: clearTimeout(pongTimer)
全コード:examples/core.md
パターン 4:切断中のメッセージキューイング
readyState は周囲の呼び出しとは独立して変化するため、送信する前に毎回確認してください。
public send(data: unknown): void {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(data));
} else {
this.queueMessage(data); // bounded — oldest dropped at MAX_QUEUE_SIZE
}
}
全コード:examples/core.md
パターン 5:判別可能なユニオンによる型安全なメッセージ
方向ごとにユニオンを分け、受信側では網羅性を確認します。
type ServerMessage =
| { type: "subscribed"; channel: string; members: string[] }
| { type: "message"; channel: string; content: string; sender: string }
| { type: "error"; code: number; message: string };
function handleServerMessage(message: ServerMessage): void {
switch (message.type) {
case "subscribed":
return onSubscribed(message);
case "message":
return onMessage(message);
case "error":
return onError(message);
default: {
const exhaustive: never = message;
return exhaustive;
}
}
}
全コード:examples/core.md
パターン 6:バイナリデータの処理
binaryType = "arraybuffer" を設定すると、フレームを DataView で同期的に読み取れます。デフォルトの Blob では、メッセージごとに非同期で読み取る必要があります。
socket.binaryType = "arraybuffer";
socket.onmessage = (event: MessageEvent) => {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data);
const messageType = view.getUint8(0);
} else {
handleJson(JSON.parse(event.data));
}
};
全コード:examples/core.md · チャンク分割アップロード:examples/binary.md
パターン 7:WebSocket 経由の認証
この API ではカスタムヘッダーを設定できないため、トークンは最初のフレームに含めます。ログに記録される URL には含めません。それ以外の処理は、認証結果が出るまで待機させます。
socket.onopen = () => {
socket.send(JSON.stringify({ type: "auth", token }));
};
// queue all other sends until { type: "auth_result", success: true } arrives
全コード:examples/core.md
パターン 8:ルームとチャネル
プロトコルにルームの概念はありません。ルームはメッセージの規約とローカルなメンバーシップ状態の組み合わせであり、参加していないルームへの送信を防ぐ仕組みがあるからこそ、その状態を保持する価値があります。
public joinRoom(roomId: string): void {
if (this.rooms.has(roomId)) return;
this.rooms.set(roomId, { id: roomId, members: new Set(), joined: false });
this.send({ type: "join_room", roomId });
}
// the server's room_joined reply flips joined to true and seeds members
全コード:examples/core.md
パターン 9:カスタム React フック
ソケット、バックオフタイマー、ハートビート、キューを 1 つのフックで管理し、状態とアクションを公開します。
const { status, send, close, reconnect } = useWebSocket(WS_URL, {
onMessage: handleServerMessage,
heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
});
全コード:examples/core.md
パターン 10:コンテキスト経由で接続を共有
プロバイダーがソケットを管理し、メッセージタイプに応じて振り分けます。各サブスクライバーは、エフェクトから返すための独自の購読解除関数を受け取ります。
const { status, send, subscribe } = useWebSocketContext();
useEffect(() => subscribe("notification", handleNotification), [subscribe]);
全コード:examples/core.md
パターン 11:bfcache との互換性
ページがキャッシュの対象であり続けるように pagehide で閉じ、キャッシュから実際に復元された場合にのみ pageshow で再接続します。
window.addEventListener("pagehide", () => socket?.close(1000, "Page hidden"));
window.addEventListener("pageshow", (event: PageTransitionEvent) => {
if (event.persisted) connect();
});
コード全体:examples/core.md
<red_flags>
注意すべき問題
実行時に問題が発生します:
- HTTPS ページでの
ws://— localhost 以外では、ブラウザーによって拒否されます。 readyStateを確認せずにsend()を呼び出す — 状態は周囲のコードとは独立して変化するため、メッセージが失われるか、例外が発生します。- アンマウント時のクリーンアップがない — ソケット、ハートビートのインターバル、再試行タイマーがすべてコンポーネントより長く存続します。
- バックオフなしで再接続する — 1 回の再起動で切断されたクライアントが一斉に戻り、障害が繰り返されます。
tryを使わずにevent.dataに対してJSON.parseを呼び出す — 不正なフレームが 1 つあると、その後のすべてのフレームでハンドラーが動作しなくなります。event.dataがテキストだと決めつける — バイナリデータを扱う場合は、まずinstanceof ArrayBufferを確認してください。- 接続 URL にトークンを含める — サーバーとプロキシのログに記録されます。最初のフレームとして送信してください。
- クリーンアップに
beforeunloadを使う — 登録するだけでページが bfcache の対象外になるため、pagehideを使ってください。 - クローズコード
1000で再接続する — これはユーザーが要求した場合も含め、正常かつ意図的なクローズを示します。
予想外の動作:
onerrorには診断情報が含まれず、必ずoncloseが続きます。両方に復旧処理を書くと、2 回実行されます。- 中継機器によって接続が切断されてもクローズフレームは送信されないため、
readyStateが数分間OPENのままになることがあります。異常を検知できるのはハートビートだけです。 - バックプレッシャーのシグナルは
bufferedAmountだけです。高速なプロデューサーがネットワークの処理能力を上回っても、例外は発生しません。 - デフォルトの
binaryTypeはBlobで、メッセージごとに非同期の読み取りが必要になります。最初のバイナリフレームが届く前にarraybufferを設定してください。 - クローズコード
1006がワイヤー上に現れることはありません。異常終了時にブラウザーが合成するため、サーバー側の理由は含まれません。 - 実際のバックプレッシャーを実現できる
WebSocketStreamは、Chromium でしか利用できません。
</red_flags>