コンテンツにスキップ

@insession/ws-resilient-transport

依存ゼロの小さな(約130行)WebSocket トランスポート。本番デプロイが実際に必要とするやり方で 再接続します。

世の再接続 WebSocket ライブラリの多くは、汎用的なバックオフを提供します。しかし本番で実際に 刺さるのはそこではありません。デプロイのたびに、全クライアントが同じ瞬間に切断されるという 点です。それらのクライアントには即座に戻ってきてほしい(新しいサーバーインスタンスは ヘルスチェックの裏で既に起動している)。しかし数千のクライアントが同じミリ秒に再接続して 新インスタンスを踏み潰すのは困ります。そしてサーバーが接続を完全に閉じたなら、再接続は 本当に止まってほしい。

このトランスポートはまさにそこを扱います:

  • サービス再起動時の高速再接続 — 設定した close code(RFC 6455 の 1012 Service Restart が 慣例)なら、バックオフを待たずに短い固定遅延で再接続します。
  • それ以外はジッター付き指数バックオフ(上限あり)。すべての待ち時間に ±jitterRatio の ランダム性が乗るので、同時に切られた集団がばらけます(thundering herd を防ぐ)。
  • terminal な close code — 再接続を完全に止める code の集合(サーバーが「この接続は二度と 受け付けない」と言った場合)。
  • 再開のシグナル — サービス再起動後の最初の再接続だけ、ハンドshake に resumedFromServiceRestart: true が渡ります。サーバー側で再入室の副作用(プレゼンスの再配信 など)を抑制できます。

メッセージ型に対してジェネリックで、既定は JSON。WebSocket 実装・タイマー・乱数はすべて注入 できるので、Node でも決定論的なテストでも動きます。

Terminal window
npm install @insession/ws-resilient-transport

ビルド済み ESM パッケージ(dist/index.js + dist/index.d.ts)として配布され、ランタイム依存は ありません。

import { createResilientWebSocket } from '@insession/ws-resilient-transport';
type ClientMsg = { type: string; [k: string]: unknown };
type ServerMsg = { type: string; [k: string]: unknown };
let alive = true;
const transport = createResilientWebSocket<ClientMsg, ServerMsg>({
url: 'wss://example.com/ws',
// 接続が開くたび、最初に送られる(認証 / 入室のハンドシェイク)。
buildOpenMessage: async ({ resumedFromServiceRestart }) => {
const token = await getIdToken();
return { type: 'join', token, resume: resumedFromServiceRestart };
},
onMessage: (msg) => handle(msg),
onReconnecting: () => showStatus('再接続中…'),
isActive: () => alive, // 後片付け時に false を返すと、すべて止まる
// デプロイの取り決め: RFC 6455 の 1012 = 高速再接続、4001 = terminal。
serviceRestartCode: 1012,
terminalCloseCodes: [4001],
});
transport.connect();
transport.send({ type: 'chat', text: 'hi' });
// 後片付け:
alive = false;
transport.close();

インストールするものはありません — これは close code の取り決めにすぎません。グレースフル シャットダウン時に、各ソケットを自分の serviceRestartCode で閉じれば、クライアントは高速な 経路を通ります:

for (const ws of sockets) ws.close(1012, 'server-restart');

新しいインスタンスが接続を受け付けられるようになってから ready を返すヘルスチェックと組み合わせて ください。高速再接続が生きたサーバーに着地するようにするためです。

createResilientWebSocket<TSend, TRecv>(options){ connect, send, close, socket }

オプション既定値意味
url接続先エンドポイント。
onMessage(msg)パース済みの受信メッセージごとに呼ばれる。
buildOpenMessage(ctx)接続が開いたときの最初のメッセージを作る。ctx.resumedFromServiceRestarttrue になるのは、サービス再起動後の高速再接続のときだけ。何も送らないなら null を返すか throw する。
onReconnecting()再接続がスケジュールされる直前に呼ばれる。
isActive()() => trueメッセージ配送前と再接続前に確認されるゲート。
reconnectDelay500通常の初回再接続のベース待ち時間(ms)。
maxReconnectDelay15000バックオフの上限(ms)。
serviceRestartCodenull「すぐ戻ってこい」を意味する close code。null で高速経路を無効化。
serviceRestartDelay250高速経路の待ち時間(ms)。
terminalCloseCodes[]再接続を止める close code。
jitterRatio0.3すべての待ち時間に乗るジッターの割合(±)。
serialize / deserializeJSON.stringify / JSON.parseワイヤのコーデック。
WebSocketglobalThis.WebSocket使う実装(Node なら ws など)。
timersグローバルの set/clearTimeoutテスト用に注入可能。
randomMath.random決定論的なテストのために注入可能な乱数源。

n 回目(1始まり)のバックオフは min(reconnectDelay · 2^(n−1), maxReconnectDelay) にジッターを乗せたものです。ただし serviceRestartCode 後の最初の再接続だけは serviceRestartDelay を使います。

Terminal window
node --test

テストは偽の WebSocket と、注入したタイマー・乱数を使うので完全に決定論的です(実ソケットも 実時間の待ちもありません)。

InSession のリアルタイム同期層から切り出したものです。そこでは同期 再生のウォッチパーティをデプロイを跨いで生かし続けています。汎用化にあたっては、プロダクトの プロトコル型をジェネリクスに置き換え、ハードコードされていた close code を設定へ移しました。

MIT