Introduction to rx-nostr (without RxJS) / RxJS を学ばずに使う rx-nostr

日本語の記事は後ろにあります。

The article in Japanese follows after in English.

Introduction to rx-nostr (without RxJS)

This article is for Day 11 of Nostr Advent Calendar 2023 (Venue 1, Venue 2). Yesterday's articles were "Math for Schnorr signature - elliptic curve" by Jun-san and "Review of Gijutsu Shoten 15 from the perspective of a sales" by Rira-san.


Hello, Nostr. I'm poman ().

Recently, new major version of my library "rx-nostr" was released. Since this is a good opportunity, I'd like to talk about what rx-nostr is or is not. I hope that this entry let you use my library.

rx-nostr is a library for Nostr client to communicate with relays. In other words, what rx-nostr essentially does is just following:

  • It takes filters, then send them to relays, and return EVENT message subscription.
  • It takes events, then send them to relays, and return OK message subscrition.

rx-nostr doesn't care about kind of events. It doesn't wrap the content of events, just provide messages as-is to users.

It doesn't provide fetch()-like Promise-based handy API. rx-nostr handles communication between client and relays as Observable, not Promise. (If you are familiar with RxJS, you can handle Observable at will. This is a part of the value of the library, but this article talks about the other part.)

Would a library that could do only this be considered useless? The value of rx-nostr lies in the "carefulness" with which these communications are conducted.

"Carefully" communications with relays

The most part of specification about communication on Nostr is in NIP-01. The specification is very simple as summarized into the two sentences:

  • Clients packs what it wants to send into an EVENT, and then sends it.
  • Clients packs what it wants to receive into an REQ, and then sends it.

It is very easy to naively implement the process based on the few rules. This is a short example of REQ:

const ws = new WebSocket("wss://example.com");

ws.onopen = () => {
  ws.send(JSON.stringify(["REQ", { kinds: [1] }]));
};
ws.onmessage = (ev) => {
  console.log(JSON.parse(ev.data));
};

So, is it easy to implement this carefully? No. There are many trivial (but taking time and effort) points in real applications. Does your application do the followings?

  • Does it verify the signatures?
  • Does it ensure that REQ'd events really match the filters' condition?
  • Does it ignore expired events based on NIP-40?
  • Assume there is a REQ that is listening a timeline. When an end-user adds/removes relay config, does your application reflect it to the timeline immediately?
  • Relays have max_subscriptions. When it tries to make REQ subscription more than this, does it queue them?
    • Even if each relay has different max_subscriptions?
  • In addition to relays configured by end-user, your application may access temporary relays, for example to fetch nevent1. In that case...
    • Does it keep only one WebSocket connection even if the temporary relays overlap with the default relays? (NIP-01 says that clients should do so.)
    • When communication on the temporary relays is finished, does your application closes the WebSocket connection?
  • Does it try to reconnect?
    • As RFC says, does it use some form of backoff when trying to reconnect?
    • As NIP-01 says, does it stop reconnection if the close code is 4000?
    • After reconnection, does it send EVENT that was about to be issued during the reconnection attempt?
    • After reconnection, does it (re)construct REQ that was ongoing before reconnection or was about to be issued during the reconnection attempt?
      • If does so, is since/until of reconstructed REQ based on relative time of origin at reconnection?
  • Does it monitor the status of connections?

rx-nostr does all of those automatically!

"Carefully" communications by rx-nostr

All you need to know to use rx-nostr is about Backward and Forward. (Being familiar with RxJS helps you use rx-nostr more useful, but it is not required.) These are rx-nostr's unique but not hard concept. Backward is to CLOSE on receiving EOSE, and Forward is not to CLOSE and to keep REQ permanently. That is all. These distinctions allow rx-nostr to optimize communication.

The example of naive REQ implementation described above is Forward. Let's implement the same with rx-nostr. First of all, we create a RxNostr instance, which manages connections and communications with relays:

const rxNostr = createRxNostr();
rxNostr.setDefaultRelays(["wss://example.com"]);

Next, we create a Forward RxReq, connect it to RxNostr, and define a listener:

const rxReq = createRxForwardReq();

rxNostr
  .use(rxReq)
  .subscribe((packet) => {
    console.log(packet.message);
  });

Finally, we issue REQ through RxReq:

rxReq.emit({ kinds: [1] });

emit() can be called any number of times. Forward RxReq overwrites previous REQ, and Backward RxReq keeps all old REQs and add new one.

The complete code is now as follows:

import { createRxForwardReq, createRxNostr } from "rx-nostr";

const rxNostr = createRxNostr();
rxNostr.setDefaultRelays(["wss://example.com"]);

const rxReq = createRxForwardReq();

rxNostr
  .use(rxReq)
  .subscribe((packet) => {
    console.log(packet.message);
  });

rxReq.emit({ kinds: [1] });

Now we resolve all trivial points described above. For instance, when end-user changes default relay configuration, we do:

rxNostr.setDefaultRelays(["wss://example.com", "wss://second.example.com"]);

It will update the ongoing { kinds: [1] } subscription, and we will get events on wss://second.example.com.

We can use use()'s option for temporary relays. Use Backward RxReq to get some events from temporary relay as following:

const rxReq = createRxBackwardReq();

rxNostr
  .use(rxReq, { relays: "wss://temp.example.com" })
  .subscribe((packet) => {
    console.log(packet.message);
  });

rxReq.emit({ ids: [EVENT_ID] });

It will get EVENT_ID event from wss://temp.example.com and close the WebSocket connection automatically. (I know that relays option should be available in emit(). Please wait for future updates.)

In truth, you can do much more. For more detailed usage, plese see the official documentation... ah sorry, documentation for v2.x is not yet.

Summary

I explained what rx-nostr can do without assuming knowledge of RxJS. If I have a chance, I would like to write about what can be achieved by combining it with RxJS.

Tomorrow's article is by クリプト彼氏@仮想通貨擬人化-san and Lokuyow-san! Foo!

(The following is a Japanese article of the same.)

RxJS を学ばずに使う rx-nostr

この記事は Nostr Advent Calendar 2023 (第一会場, 第二会場) の第一会場 11 日目の記事です。昨日 10 日目の記事は Jun さんの『Schnorr 署名に使われる数学 - 楕円曲線』と りらさんの『営業目線で振り返る技術書典15』でした。


はろー Nostr、です。

先日、拙作ライブラリ rx-nostr の 2.0.0 がリリースされました。いい機会なので、rx-nostr とは何であるのか、あるいは何ではないのかという話をしようと思います。あわよくばこれを読んだ皆様に使って頂こうという魂胆でございます。

rx-nostr はクライアントがリレー群と通信するためのライブラリです。言い換えると、rx-nostr が本質的に行うことはたったこれだけです:

  • フィルターを受け取ると、それをリレーに送信し、EVENT の購読を返します。
  • イベントを受け取ると、それをリレーに送信し、OK の購読を返します。

実際に送受信されている情報の種類 (kind) には頓着しません。つまり rx-nostr は受信したコンテンツを何かでラップすることはなく、送られてきたものをただただそのままユーザに提示するだけです。

fetch() のような Promise ベースの手軽な API の提供もしません。rx-nostr は通信をあるがままに扱うので、返されるものは扱いやすい Promise ではなく Observable です。(RxJS と連携すれば Observable も自由自在に取り回せるようになります。これが rx-nostr が提供する価値のうちの半分ですが、この記事で主張したいのはもう半分の方です。)

たったこれだけのライブラリは無用なものだと思われるでしょうか? rx-nostr の価値はこれらの通信が「丁寧に」行われることにこそあります。

リレーと「丁寧に」通信する

リレーとの通信にかかる仕様はそのほとんどすべてが NIP-01 に記述されています。その仕様はいたってシンプルで、要旨は次の 2 点に集約されると言ってもいいでしょう:

  • クライアントは情報を送信したければ、送信したいものに EVENT と書いてリレーに送信せよ。
  • クライアントは情報を受信したければ、受信したいものに REQ と書いてリレーに送信せよ。

このわずかな規約に基づく送受信処理を素朴に実装するのは実に簡単です。受信処理に限って例を挙げると、次のようにたった数行で書くことができます。

const ws = new WebSocket("wss://example.com");

ws.onopen = () => {
  ws.send(JSON.stringify(["REQ", { kinds: [1] }]));
};
ws.onmessage = (ev) => {
  console.log(JSON.parse(ev.data));
};

ではこの送受信処理を丁寧に実装するのは簡単でしょうか?答えは No です。現実のアプリケーションには考慮しなければならない些事 (ただし大変に面倒くさい些事) が実に多くつきまといます。例えばあなたのアプリケーションは……

  • 署名の検証はしていますか?
  • REQ の結果リレーから返されたイベントが本当にフィルターの条件に合致するか確認していますか?
  • NIP-40 に基づいて期限切れのイベントは無視していますか?
  • タイムラインを取得している REQ があったとして、エンドユーザが途中でリレーを追加・削除したとき、タイムラインはそれを即座に反映できますか?
  • リレーには max_subscriptions が定められています。これを超える量の REQ を発行しようとするとき、キューイングはされますか?
    • リレーごとに max_subscriptions の値が異なるとしても、それは動作しますか?
  • エンドユーザがクライアントに設定しているデフォルトのリレーに加えて、一時的なリレーにアクセスする必要に迫られることがあります (nevent1 を取得するときなど)。
    • 一時的なリレーがデフォルトのリレーと重複するとしても WebSocket 接続は 1 つに抑えられていますか? (NIP-01 はすべての通信を単一の WebSocket 接続の上で行うよう定めています。)
    • 一時的なリレーとの通信が終了したとき、WebSocket を切断していますか?
  • WebSocket が瞬断したとき、再接続していますか?
    • 再接続は RFC が定めるように バックオフ戦略を採用していますか?
    • NIP-01 が定めるように、コード 4000 で終了した場合には再接続を行わないよう実装していますか?
    • 再接続試行中に送信されようとしていた EVENT を再接続成功時に送信していますか?
    • 再接続前に発行されていた、あるいは再接続試行中に発行しようとしていた REQ を再接続成功時に復元していますか?
      • 復元していたとして、復元後の REQsince/until は再接続時起点の相対時刻に基づくことができますか?
  • 各リレーとの WebSocket の接続状況をモニタリングしていますか?

rx-nostr はこれらのすべてを肩代わりします!

rx-nostr で「丁寧に」通信する

rx-nostr を使う際に覚えておかなければならないのは BackwardForward の概念だけです (RxJS についても詳しい方が便利ではありますが、必須ではないです)。これは rx-nostr 独自の概念ですがさほど難しくはありません。EOSE を受信したときに自動で CLOSE するべき REQ のことを Backward、EOSE を無視して永続する REQ のことを Forward と呼んでいるだけです。これらの使い分けは rx-nostr が通信を最適化するために利用されます。

先程の素朴な REQ の実装は Forward です。これと同じコードを rx-nostr で書いてみましょう。まずは RxNostr インスタンスを用意します。これはリレーとの通信を管理してくれるオブジェクトです。

const rxNostr = createRxNostr();
rxNostr.setDefaultRelays(["wss://example.com"]);

次に Forward な RxReq を用意して RxNostr につなげると同時に、メッセージがやってきたときに何をするのかを定義します。

const rxReq = createRxForwardReq();

rxNostr
  .use(rxReq)
  .subscribe((packet) => {
    console.log(packet.message);
  });

最後に RxReq を通じて REQ を発行します。

rxReq.emit({ kinds: [1] });

emit() は何回でも呼び出すことができます。Forward な RxReq では以前に emit() した REQ を上書きしますが、Backward な RxReq では前回までの REQ を保ったまま新しい REQ を加えます。

完全なコードは次の通りになりました:

import { createRxForwardReq, createRxNostr } from "rx-nostr";

const rxNostr = createRxNostr();
rxNostr.setDefaultRelays(["wss://example.com"]);

const rxReq = createRxForwardReq();

rxNostr
  .use(rxReq)
  .subscribe((packet) => {
    console.log(packet.message);
  });

rxReq.emit({ kinds: [1] });

これだけで前節であげた「些事」はすべて解決されています。例えば、エンドユーザがデフォルトのリレーを追加した場合には次のようにします:

rxNostr.setDefaultRelays(["wss://example.com", "wss://second.example.com"]);

すると既に発行されて継続中の { kinds: [1] }wss://second.example.com の上でも購読されるようになります。

一時的なリレーを使いたい場合は use() のオプションが利用できます。例えば、ある event を特定のリレーから取得したくなった場合、Backward な RxReq とともに次のように書きます:

const rxReq = createRxBackwardReq();

rxNostr
  .use(rxReq, { relays: "wss://temp.example.com" })
  .subscribe((packet) => {
    console.log(packet.message);
  });

rxReq.emit({ ids: [EVENT_ID] });

すると { ids: [EVENT_ID] }wss://temp.example.com から取得され、取得され次第 WebSocket 接続は自動で閉じられます。 (本当は relays オプションが emit() の中で利用できた方がいいと思っています。今後のアップデートをお待ち下さい。)

実際にはもっと色々なことができます。より詳しい使い方は公式ドキュメントをご覧ください……と言いたいところなのですが、v2.x に対応するドキュメントはまだありません。書きます……。

おわりに

RxJS の知識を前提としない範囲で rx-nostr に何ができるのかを解説しました。今度機会があれば RxJS と組み合わせることで何が実現できるかも書けたらいいと思っています。

明日 12 日のアドベントカレンダーは クリプト彼氏@仮想通貨擬人化 さんと Lokuyow さんの記事です!わいわい!