socket.io をCloudflare Worker に移植してみた

## 背景

### socket.ioとは

[socket.io](https://socket.io/)は、クライアント-サーバー間のリアルタイム通信のためのNode.jsサーバーです。

プロトコルがちゃんと定義されており、公式のNode.js実装の他にも多くの言語でクライアント/サーバー向けに実装されています。そのため、多くの人々やソフトウェアがこれを基盤としているようです。例えば私の観察では、今年よく使用した[Google colab](https://colab.research.google.com/)はある古いバージョンのsocket.ioをつかっているっぽいです。

Socket.io は複数のトランスポート（HTTP / WebSocket / WebTransport）の上に、セッション、認証、ネームスペース、ルーム、ブロードキャストなどの高レベル機能を提供します。

### なぜこのポートを作ったか

私は以前からSocket.ioを知っており、これを基にした小規模なプロジェクトをいくつか持っています。

最近の使用例は、マルチユーザーのホワイトボードのようなウェブアプリの開発でした。それを作る時期に、ちょうどCloudflare (以下CF) のサーバーレスプロダクトであるWorkerとDurable Object (以下DO) について読んでいたところで、Socket.io（またはその簡略版）をCFで動作させることができるのではないかと気づきました。

数ヶ月が経過し、動作するバージョンができました。最新バージョンはnpmに公開されました：
[npm](https://www.npmjs.com/package/socket.io-serverless)
。現時点ではWebSocketトランスポートと一部の機能（私に必要だった機能）のみをサポートしていますが、動作はします🔥。
コードは[GitHub - jokester/socket.io-serverless](https://github.com/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](https://github.com/cloudflare/workerd/tree/main/src/workerd)だと思います。

Node.js / webと比較してJS開発者にとって最大の違いは、おそらく状態の揮発性でしょう。

Node.jsプロセスやブラウザタブのような従来の環境では、サーバーがダウンするかタブが閉じるまでメモリがクリアされない、コードもだいたい動き続けます。しかしCFのサーバーレス環境では、非アクティブになるとコードの実行を停止し、JSコードのメモリ内状態を破棄します。

JS の `setTimeout` や `setInterval` タイマーがある場合はアクティブとみなされます。保留中のHTTPリクエストもアクティブとみなされます。アクティブなWebSocket接続は、使用されるAPIによってアクティブとみなされる場合とそうでない場合があります。

具体的には、DOの場合、メモリ内状態の破棄は実際には[hibernation / 休止](https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server/)と呼ばれています。開発者は提供されるKVストアのようなAPIを使用して、手動で状態の保存/復元を行うことができます。

また、利用可能な標準ライブラリも異なります。

JSの言語APIのみを使用するコードは、だいたいそのまま動作するはずです。Node.js APIを必要とするコードは、[Node.js compatibility flag](https://developers.cloudflare.com/workers/runtime-apis/nodejs/) を有効にすればでNode.jsのポリフィルを使用できます。

2024年のある時点から、Node.jsの標準ライブラリのポリフィルは[unenv](https://github.com/unjs/unenv)をベースとし、`nodejs_compat_v2`フラグの背後にあります。この記事に詳しい説明があります：[Cloudflare Workersのnodejs_compat_v2で何が変わったのか](https://zenn.dev/laiso/articles/8280d026a08de0)

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`に基づくアプリケーションロジックコードは、[制限事項](https://github.com/jokester/socket.io-serverless?tab=readme-ov-file#limitations)はありますが、オリジナルのSocket.io 使うときと同様に動作できます。

2つのDOの他に、リクエストを`EngineActor`に転送する単純なHTTPハンドラーであるworker エントリポイントが必要です。

前述のhibernationを防ぐことは不可能ではありませんが（最初にできたバージョンではそうした）、サーバーレスバージョンではむしろhibernationを活用してエネルギーを節約し、地球を守るべきだと判断して、hibernationあっても動作するように作りました。

`EngineActor` `SocketActor`内の状態（接続ID、動的に作成される可能性のあるNamespaceなど）は、異なるライフサイクル間で保存/復元されます。

`engine.io` `socket.io`のコードのほとんどは既にメッセージイベントによって駆動されていて、とくに何もしなくてOK。しかし、[ハートビートチェック](https://socket.io/docs/v4/engine-io-protocol/#heartbeat)を駆動するpingタイマーがありました。ここは [Alarms API](https://developers.cloudflare.com/durable-objects/examples/alarms-api/)を代わりに使って、もとの `setInterval` 実装をスタブにしています。

現在、socket.io-serverlessは各DOクラスのインスタンスを1つだけ作成します。将来、パフォーマンスが問題になった場合は、より多くのDOで負荷を分散できるようにすることが可能のはずです（Socket.ioで使用されているアダプター/クラスター構造と近いイメージ）。

### コードの開発とビルド方法

sio-serverlessの開発にはモノレポを使用しました。

socket.ioはオリジナルのTSコードをNPMで公開していないため、`socket.io`リポジトリ（[現在はモノレポ](https://github.com/socketio/socket.io/issues/3533)）を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を[ビルドスクリプト](https://github.com/jokester/socket.io-serverless/blob/main/socket.io-serverless/build.mjs)で使用しています。多くのimportはCFでも動く実装（`debug`など）に置き換えられるか、単純にスタブ化（`node:http`など）されています。

### おわりに

これを作るのは楽しかったし、作るためにSocket.ioの内部を完全に理解したと思います。ついに実現できて嬉しく思います。