背景
socket.ioとは
socket.ioは、クライアント-サーバー間のリアルタイム通信のためのNode.jsサーバーです。
プロトコルがちゃんと定義されており、公式のNode.js実装の他にも多くの言語でクライアント/サーバー向けに実装されています。そのため、多くの人々やソフトウェアがこれを基盤としているようです。例えば私の観察では、今年よく使用したGoogle colabはある古いバージョンのsocket.ioをつかっているっぽいです。
Socket.io は複数のトランスポート(HTTP / WebSocket / WebTransport)の上に、セッション、認証、ネームスペース、ルーム、ブロードキャストなどの高レベル機能を提供します。
なぜこのポートを作ったか
私は以前からSocket.ioを知っており、これを基にした小規模なプロジェクトをいくつか持っています。
最近の使用例は、マルチユーザーのホワイトボードのようなウェブアプリの開発でした。それを作る時期に、ちょうどCloudflare (以下CF) のサーバーレスプロダクトであるWorkerとDurable Object (以下DO) について読んでいたところで、Socket.io(またはその簡略版)をCFで動作させることができるのではないかと気づきました。
数ヶ月が経過し、動作するバージョンができました。最新バージョンはnpmに公開されました:
npm
。現時点ではWebSocketトランスポートと一部の機能(私に必要だった機能)のみをサポートしていますが、動作はします🔥。
コードはGitHub - jokester/socket.io-serverlessに公開しています。
開発
Socket.ioの本来の構造
Socket.io(トップレベルのライブラリ)には2つの主要コンポーネントがあり、npmパッケージのsocket.io
とengine.io
です。
socket.io
パッケージは高レベルの概念:ネームスペース/ルーム/クラスタリング等を扱います。その下に http.Server
インスタンスを保持し、トランスポート関連のロジックを扱うengine.io
が存在します。
Node.jsでは、この2つのコンポーネントは同じプロセス内で実行され、イベントエミッターAPIを通じて動きます。
CF worker / Durable Object のための開発
CF DO/workerでは、JSはNode.jsではない特殊なサーバーレス環境で実行されます。workerdだと思います。
Node.js / webと比較してJS開発者にとって最大の違いは、おそらく状態の揮発性でしょう。
Node.jsプロセスやブラウザタブのような従来の環境では、サーバーがダウンするかタブが閉じるまでメモリがクリアされない、コードもだいたい動き続けます。しかしCFのサーバーレス環境では、非アクティブになるとコードの実行を停止し、JSコードのメモリ内状態を破棄します。
JS の setTimeout
や setInterval
タイマーがある場合はアクティブとみなされます。保留中のHTTPリクエストもアクティブとみなされます。アクティブなWebSocket接続は、使用されるAPIによってアクティブとみなされる場合とそうでない場合があります。
具体的には、DOの場合、メモリ内状態の破棄は実際にはhibernation / 休止と呼ばれています。開発者は提供されるKVストアのようなAPIを使用して、手動で状態の保存/復元を行うことができます。
また、利用可能な標準ライブラリも異なります。
JSの言語APIのみを使用するコードは、だいたいそのまま動作するはずです。Node.js APIを必要とするコードは、Node.js compatibility flag を有効にすればでNode.jsのポリフィルを使用できます。
2024年のある時点から、Node.jsの標準ライブラリのポリフィルはunenvをベースとし、nodejs_compat_v2
フラグの背後にあります。この記事に詳しい説明があります:Cloudflare Workersのnodejs_compat_v2で何が変わったのか
v2以前は、私の非公式な調査によると、nodejs_compat
フラグは最終的にwrangler
CLIが使用するesbuild
が使用する@esbuild-plugins/node-modules-polyfill
が使用するionic-team/rollup-plugin-node-polyfills
に基づいていました。
socket.io-serverlessの構造
魔改造した socket.io
とengine.io
からのコードを実行するために2つのDOを使用しました。
class EngineActor extends DurableObject {...}
はengine.io
コードを実行するDOです。WebSocket接続を受け付け、SocketActor
と実際のWS接続との間で双方向のWSメッセージを転送します。
class SocketActor extends DurableObject {...}
はsocket.io
コードを実行するDOです。EngineActor
からのRPC呼び出しに応答し、Namespace
のようなオブジェクトにメッセージを送信します。アプリケーションコードが engine.io Socket(異なるトランスポートの抽象化)にメッセージを送信すると、そのメッセージはEngineActor
に転送され、WS接続の他端に流れます。
したがって、sio.Namespace
sio.Client
sio.Room
に基づくアプリケーションロジックコードは、制限事項はありますが、オリジナルのSocket.io 使うときと同様に動作できます。
2つのDOの他に、リクエストをEngineActor
に転送する単純なHTTPハンドラーであるworker エントリポイントが必要です。
前述のhibernationを防ぐことは不可能ではありませんが(最初にできたバージョンではそうした)、サーバーレスバージョンではむしろhibernationを活用してエネルギーを節約し、地球を守るべきだと判断して、hibernationあっても動作するように作りました。
EngineActor
SocketActor
内の状態(接続ID、動的に作成される可能性のあるNamespaceなど)は、異なるライフサイクル間で保存/復元されます。
engine.io
socket.io
のコードのほとんどは既にメッセージイベントによって駆動されていて、とくに何もしなくてOK。しかし、ハートビートチェックを駆動するpingタイマーがありました。ここは Alarms APIを代わりに使って、もとの setInterval
実装をスタブにしています。
現在、socket.io-serverlessは各DOクラスのインスタンスを1つだけ作成します。将来、パフォーマンスが問題になった場合は、より多くのDOで負荷を分散できるようにすることが可能のはずです(Socket.ioで使用されているアダプター/クラスター構造と近いイメージ)。
コードの開発とビルド方法
sio-serverlessの開発にはモノレポを使用しました。
socket.ioはオリジナルのTSコードをNPMで公開していないため、socket.io
リポジトリ(現在はモノレポ)をgitサブモジュールとして含めました。これで私のモノレポにはsocket.io-serverless
socket.io/packages/socket.io
socket.io/packages/engine.io
などの子パッケージでできています。
一部のsocket.ioコードは、package.json
のexport map を含めて、パッチを当てる必要がありました。パッチはモノレポに含まれており、Makefileによって適用されます。
esbuild
はsocket.io-serverless
コードを、Socket.ioや他の依存関係と共に、非圧縮バンドルにバンドルします。
依存関係の解決プロセスをカスタマイズするために、esbuild
APIをビルドスクリプトで使用しています。多くのimportはCFでも動く実装(debug
など)に置き換えられるか、単純にスタブ化(node:http
など)されています。
おわりに
これを作るのは楽しかったし、作るためにSocket.ioの内部を完全に理解したと思います。ついに実現できて嬉しく思います。