Thread

unipls

この記事は Nostr Advent Calendar 2025 の 23 日目の記事です。昨日 22 日目の記事は koteitan さんの『モジュラー型 Nostr クライアント mojimoji を作りました』でした。


うにを流しますよ

はろー Nostr, ぽーまんです。

本当はこの記事に合わせて新しいライブラリ作った報告をしたかったんですけど、月を跳ねさせたり宇宙人を燃やしたり位相幾何学に入門したり農家を失業させたりしていたら実装終わらなかったので、本記事は中間報告みたいな位置づけになります。

みなさん WebSocket してますか?してますよね、知っています。面倒ですよね、それも知っています。じゃあその面倒の根源って、Nostr プロトコルと WebSocket と、どちらに由来すると思いますか?

WebSocket 自体も面倒くさい

さて、Nostr アドベントカレンダーに寄稿しておきながら今から Nostr と関係ない話を始めようとしていますが、安心してください、この記事は半分くらい Nostr と関係ありません。

最近 WebSocket を使ったリアルタイムマルチプレイヤーゲームの設計を妄想する機会がありました。ゲームの内容は本題と関係ないのでばっさり省いて、ここでは WebSocket 通信と状態管理だけの抽象的な話をします。

このゲームでは、プレイヤーの行動は適当なフォーマットで (Nostr で例えるならイベントとして) WebSocket を通じてサーバに伝達され、サーバの、言い換えればゲーム盤面の状態を更新しつつ、他のプレイヤーにもブロードキャストされます。他プレイヤーはブロードキャストされたイベントを受け取り、自身がローカルに持っているゲーム盤面の状態を更新します。この仕組みによって、すべてのプレイヤーはリアルタイムに更新される同じ盤面を参照し続けることができます。以下はとってもわかりやすい図: 私はゲームを実装した経験はさほどありませんが、それほど (行動予測などが必要ではない程度には) 遅延にシビアではないリアルタイムのマルチプレイヤーゲームはおおむねどれもこのような仕組みを取っているんじゃないかと想像しています。この、おそらくそれなりにありふれた構成のゲームは、WebSocket が完璧に接続され続けている限りはうまく動作するでしょう。しかしどうでしょう、Nostr にお住まいのみなさんは WebSocket 通信ってどれくらい頑丈だと思います?

残念ながら私は WebSocket のことをそれほど信用していないので、通信が瞬断する可能性を考えたくなりました。ないとは思うんですが、もし仮に、通信が5秒くらい途切れたとしたら、それを復旧するためにゲームはどのような実装を備えていなければならないでしょうか。

(A) まずひとつには、通信が途絶していた間に送られてきていたかもしれないイベントを再収集し、ゲーム盤面の同期 (再初期化) を図らなければなりませんよね。そしてここで重要なのは、その再初期化が終わって初めてプレイヤーはゲームに正しく参加できるようになるという点です。古い盤面を参照しながら入力した内容はプレイヤーの意図に沿わないかもしれないどころか、ゲームへの入力としても不正な値かもしれません。

(B) もうひとつには、通信が途絶していた間に送ろうとしたイベントのうち、その必要があるものについては、再送処理に乗せなければならないということです。もちろん途絶していた間の行動なので、プレイヤーが本当に意図していた行動ではないかもしれませんが、例えば FPS で発砲するアクションがあったとして、入力したのに弾が出ないよりは、遅延の後に弾が出たほうがいくらかマシです。

短くまとめると、WebSocket が再接続した直後には (A) 再初期化 を遂行する必要があって、その完了を待って必要な (B) 再送処理 を行った後、ようやく接続は完全に復旧し、システムは正常系に戻ります。…… Nostr もそうじゃないですか?

Nostr でも再接続した直後には (A) AUTH を再び確立した後、その完了を待って (B) クエリの再送を行い、それでようやく正常系に戻ります。つまり長い脱線を通じて主張したかったのは、Nostr の面倒事のうち少なくとも一部、再初期化と再送処理は、Nostr 特有の面倒事ではなく WebSocket アプリケーションとして一般的な面倒事に分類されるのではないか、ということです。

Nostr 向けの通信ライブラリから Nostr 非依存な部分を抽出できたら、なんか役立ちそうな気がしませんか?

unipls

ところで、筆者は rx-nostr という Nostr 向けの通信ライブラリを開発しています。現在のステータスは新しいメジャーバージョン v4 を開発中ということになっていますが、実際には先の観察に基づいた汎用 WebSocket クライアントライブラリ unipls に取り組んでいます (本当はリリースまでやりたかったんですけど、ちょっと年内では難しい気がしてます)。v4 は unipls に依存する形で設計される予定です。ただ、rx-nostr のユーザに直接 unipls が露出する形にはならないと思います。

もちろん unipls はそれ自身が汎用 WebSocket クライアントなので、これを直接使って Nostr アプリケーションを作ることはできます。が、複数リレー接続、REQ キューイングなどの Nostr ドメインに関連する機能はありませんし、RxJS サポートも受けられません。unipls 自体は依存をひとつも持たないので、より軽量な rx-nostr 代替ライブラリとしては運用できるかもしれません。RxJS は少々重たいので……。

unipls はネイティブの WebSocket よりはいくらか高レベルの API のみを提供し、低レベル API は隠しています。API のイメージとしては reconnectable-websocket と WebSocket multiplexer をうねうねしてプロビジョニングの概念をえいやとした感じです…… と説明するよりは、コードを見せた方がコアコンセプトが伝わりやすいかと思うので、今考えている API でのコード例を示して記事を締めくくろうと思います。

const unipls = new Unipls({ url: '...' });

// unipls では open() が呼ばれてから close() が明示的に呼ばれるまで、
// 通信が繋がっていると **みなされます** 。
// つまりこの間は再接続の努力が裏で行われており、外からは常時正常接続されているものと
// みなしながら各種の **メッセージングメソッド** を呼ぶことができます。
await unipls.open({
    // provisioner は初期化および再初期化のための関数です。
    // メッセージングメソッドによる通信および再送が実際に始まるのは、
    // 初期化処理が完了した後です。
    provisioner: async (unipls) => {
        // 例えば Nostr では AUTH が完了してからなんやかんやを始めた方が
        // 都合がいいことが多いので、このあたりでうにゃうにゃして
        // AUTH を終わらせておきます
    }
});

// unipls には send() はなく、代わりにメッセージングメソッドがあります。
// メッセージングメソッドは入出力の数に対応して4種類があり、
// subscribe() は 入力1:出力N を取るためのメッセージングメソッドです。
const unsubscribe = unipls.subscribe({
    // query は入力です。まずこれがサーバに送られます
    query: ['REQ', 'sub:1', { kinds: [1], limit: 30 }],
    // selector はどのメッセージが入力に対する応答なのかを判定するための関数です
    selector: (msg) => msg[0] === 'EVENT' && msg[1] === 'sub:1',
    // terminator はどのメッセージが出力の終わりであるかを判定する関数です
    // 省略時には () => false とみなされ、購読を永続させることができます
    terminator: (msg) => msg[0] === 'EOSE' && msg[1] === 'sub:1',
    onMessage: (msg) => {
        console.log(msg);
    },
    onTerminated: () => {
        console.log('EOSE!')
    },
    // retry は再接続時の再送手続きを定義します。
    // 're-request' は再初期化の後、query をそのまま再送する戦略です。
    // Nostr では REQ はコネクションごとの状態ですが、
    // 一般的なアプリケーションでは状態のスコープはセッションであったり
    // グローバルであったりするので、再送はせずにただメッセージを継続して待ち続けるのが
    // 期待される場合もあるでしょう。
    retry: 're-request',
});

// 他のメッセージングメソッドは
// - `listen()`: 入力0:出力N
// - `cast()`: 入力1:出力0
// - `request()`: 入力1:出力1
// が用意されています。
// 意味のある通信はこれらとその組み合わせで尽くせるんじゃないかな~と予想しています。

API の設計レビューなどあればぜひお寄せください~

終わりに

「Nostr 面倒くさい」の一部は「WebSocket 面倒くさい」なのではないかという仮説を示し、WebSocket アプリケーションの一般的な課題をハンドリングする部分を rx-nostr から分離して汎用ライブラリとする構想について書きました。

明日24日のアドベントカレンダーはちょめじさんの『今年の事とかクリスマスのこととか』です!よろしくお願いします~!

Replies (0)

No replies yet. Be the first to leave a comment!