この記事は Nostr Advent Calendar 2025 10日目の記事です。
Nostrはただのプロトコルであり、仕様には特定の中央サーバーというものはありません。サーバーはリレーと呼ばれ、その名の通り保存して仲介するという役割のみを果たします。そのため、ユーザー任意のリレーを選択して書き込みます。書く人もそれを読む人も通常複数のサーバーに同時に接続しに行きます。
この方式は耐障害性や耐検閲性といった利点を与えてくれますが、以下のような欠点もあります。
- クライアントの実装が複雑になる
- クライアントとリレーのやりとりの仕方を定めるNIP-01は結構仕様が緩く、リレーにより解釈が微妙に異なるためリクエストの送り方次第ではクライアント側で追加処理が必要になる
- クライアント側で、複数リレーの内容の違う応答を適切にマージして表示する必要がある
- クライアントに複数のリレーを入力するのが大変
- kind:10002としてリレーリストを保存して取得できるクライアントもあるが、全てのクライアントが対応しているわけではない
- 帯域の消費が増える
- 1つ1つのパケット自体は小さいものの、接続リレーが増えれば増えるほど負荷に繋がる
- 1つのリレーだけ読んでもフォローしている人全員のイベントを読めるわけではない
- 当然ながらそのリレーには、そのリレーに書き込んだ人の投稿しか流れてない
- 1つのリレーにしか接続できないクライアントだと絶対に読めない投稿が発生することがある
そこでローカルでリレーを動かした上で複数リレーのイベントを集約して1本にまとめようと思いました。具体的には以下のことを行います。
- 接続しているリレーのイベントを全て収集してローカルに流す
- 特定のpubkeyを持つユーザー(自分)がイベントを流したら接続しているリレー全てにブロードキャストする
そういうことを行うプログラムは既にありますが、どうせなら自分で作ってみようと思って作りました。クライアントの真似ごとをする事になり結構学びになったと思ったため、過程を書き連ねて行きます。
何を使って作るか
Nostrで名前を聞いたrx-nostrを使うことにしました。作者のスライドが面白かったのと、実際に書いてあることに共感できたこと、更にTypeScriptの経験が多少あったので迷いなく選びました。
ローカルで動かすリレーの方にはDBを立てずに動かせるのと実績の多さからstrfryを選びました。
自身のkind:3の取得
Nostrにおいてフォローはkind:3のtagsに含まれるpubkeyのリストで表現されています。そのためフォロータイムラインを見るには最低限自身のkind:3を取得する必要があります。コードを抜粋すると以下のようになります。
const packet = await lastValueFrom(
remoteRxNostr
.use(createRxOneshotReq({
filters: {
authors: [myHex],
kinds: [3],
limit: 1,
},
}))
.pipe(latest()),
);
localRxNostr.send(packet.event);
rx-nostr及びrxjsの便利関数を組み合わせると、remoteRxNostr(接続してるリレーを登録したRxNostrのインスタンス)に自分のkind:3を1つだけ要求して古いイベントを排除しつつ取得、全て取得できた時点で最新の値を返し、localRxNostr(ローカルのリレーを登録したRxNostrのインスタンス)に送るという処理を流れるように自然に表現できます。
eventの取得
const requestStream = createRxForwardReq();
remoteRxNostr
.use(requestStream)
.pipe(uniq())
.subscribe((packet) => {
localRxNostr.send(packet.event);
});
requestStream.emit({
since: now,
});
先程のkind:3取得に似ていますが、Oneshotの代わりにForwardを使っている、latestの代わりにuniqを使っている、lastValueFromで囲ってawaitする代わりにsubscribeを繋げているなどの違いがあります。
Forwardにすると過去のイベントを取得し終えた時点で停止しなくなります。そのためawaitの代わりにイベントが飛んできた時に処理するようsubscribeに繋ぎます。
latestではなくuniqにしているのは、送った先のリレーが必要かどうかの判断をしてくれるため、リレー間の重複を除去するだけで事足りるからです。(上記のkind:3も実の所uniqで十分ではありますが、置き換え可能イベントであるのが分かりきっているためlatestにしています)
REQを送った時点以降のイベントを要求するように指示すると、リレーからイベントが届く度に鉄パイプ[^pipe]を通ってsubscribeに与えた関数に流れていきます。それをローカルのリレーに送りつけたらstrfryがよしなにやってくれます。
kind:0の取得
普通SNSでは投稿にアイコンや名前が付いていますが、Nostrにおいてはkind:0のイベントとして表現されていて、他のkindのイベントには付いていません。そのため正しく情報を出すためにはREQを送ってそれらの情報を取得してやる必要があります。
そのため新しいpubkeyを見たらkind:0を要求する関数を作ってeventの取得の際に呼び出すようにします。
const seenPubkeys = new Set<string>();
function onPubkey(pubkey: string) {
if (!seenPubkeys.has(pubkey)) {
seenPubkeys.add(pubkey);
remoteRxNostr
.use(createRxOneshotReq({
filters: {
authors: [pubkey],
kinds: [0],
limit: 1,
},
}))
.pipe(latest())
.subscribe((packet) => {
localRxNostr.send(packet.event);
});
}
}
eタグに紐付いたイベントの取得
ここまで来ると大体使えるようになりますが、実際に使ってみると返信先やRepost(kind:6)の中身が表示されません。 NIP-10やNIP-18を参照すると、eタグに参照先のイベントが記録されていることが分かります。filterのidsにeタグの中身を付けてREQを飛ばすと当該イベントを取得できます。
流すイベントをフォロー中の人だけにする
実際に運用していると、接続先リレーにもよりますが大量のイベントが流れてくるため、strfry側のDBが際限なく巨大化します(目安としては海外の著名リレーに繋いでいると大体数日でGB単位まで行く)。また、それに伴うREQも大量に飛びます。そんな大量のイベントを流しても結局ほとんど読みません。
そのため最初に取得したkind:3を保管しておいて、その中に含まれるpubkeyに限定して処理をするとフォロー対象者のイベントしか流れてこなくなるため大幅に格納されるイベントが減ります。
この際自分を対象としたフォロー外からのリアクションもカットされますが、イベントのpタグに自分のpubkeyが含まれる物を処理の対象に入れると取得できるようになります。
ローカルに流れてきたイベントのブロードキャスト
最後に一番肝心な書き込んだイベントのブロードキャストです。今まで散々REQを投げてきましたが、その向きをlocalからremoteに変えるだけです。
接続が切れた時に流れていかないと困るので、再接続した時用にfilterにはlimitを指定しています。
const requestMyEvent = createRxForwardReq();
localRxNostr
.use(requestMyEvent)
.pipe(uniq())
.subscribe((packet) => {
remoteRxNostr.send(packet.event);
});
requestMyEvent.emit({
authors: [myHex],
limit: 10,
});
〆
rx-nostrが鉄パイプの仕分けをいい感じにやってくれるため、やりたいことの記述に集中できました。
このシステムを作ってからずっと使っていますが、使えるクライアントが増えた他、快適にTLの遡りができるため、ますますNostrにのめり込んでいます。
現状の実装だとローカル側にEVENT投げまくってて遅いので改善していければと思っています。
また、今回は受信したイベントをリレーに送信していますが、これをプログラム内に保存して描画に回せばNostrクライアントになるはずです。気が向いたら作りたいです。
[^pipe]: rx-nostrの作者が実装読み会で事あるごとに「源泉からお湯が鉄パイプを通って流れてくる」などの発言を繰り返していた 参考