feature image

2024年12月4日 | ブログ記事

NostrのクライアントをSolidJSで自作してみた

🎄
この記事はtraP Advent Calendar 2024 4日目の記事であり、Nostr Advent Calendar 2024 4日目の記事でもあり、SolidJS Advent Calendar 2024 4日目の記事でもあります。

前回のtraP Advent Calendarの記事:AtCoder環境を持ち運ぶ
前回のNostr Advent Calendarの記事:nostterに画像最適化機能つけたよ(サーバー編)
前回のSolidJS Advent Calendarの記事:solidjsとurqlで実装
もぜひご覧ください

こんにちは、@d_etteiu8383です。最近は@eyemono.moeとも名乗ってます。本記事では、SolidJSを使用して分散SNS Nostrのクライアントを自作した話をします。

実際に出来たもの:https://streets.eyemono.moe

Streets
Column based Nostr client
GitHub - eyemono-moe/streets: Column based Nostr client for web
Column based Nostr client for web. Contribute to eyemono-moe/streets development by creating an account on GitHub.

Nostrとは

Nostr, a simple protocol for decentralizing social media that has a chance of working
A guide to the simplest decentralized protocol that isn’t peer-to-peer, therefore works.

Nostr is a protocol, designed for simplicity, that aims to create a censorship-resistant global social network.
https://nostr.com

Nostr (Notes and Other Stuff Transmitted by Relays) は、分散型ソーシャルメディアプロトコルです。/ˈnɒstʃrə/と発音するようです[1]。日本語にするならナスチュラとかノスチュラかな。

"Nostr"自体はプロトコル、つまり決まり事を指した名前であり、特定アプリの名前とかではありません。
分散型, シンプルな設計, 検閲耐性を特徴としたプロトコルであり、近頃のTwitter離れを受けて注目を集めています。

NostrはRelay(リレー)と呼ばれるデータサーバーとクライアントによって構成されます。

User1がRelay A, Bに投稿したイベントは、Relay A, B, Cを購読しているUser 2に届く。Relay A, Bを購読していないUser 3には届かない。

クライアントはWebSocketを介して複数のリレーに投稿を投げ、複数のリレーから投稿を受信します(この投稿をNostrではイベントと呼びます)。特定のリレーが停止しても他のリレーからイベントを取得できるため、耐障害性/検閲耐性が高くなっているわけです。また、たいていのクライアントでユーザーは好きなリレーを選択して使用できるようになっています。これにより、ある種の棲み分けのような機能も生まれています。

Nostrの詳細仕様はNostr Implementation Possibilities (NIPs)として https://github.com/nostr-protocol/nips で公開されています。重要なのは、これらNIPsは特定の1団体によって管理されているのではなく、コミュニティによって管理されている点です。誰でもNIPに対して変更を提案でき、コミュニティの合意を得れば採用されます。

NIPsに沿ってさえいればアプリの実装は自由です。有名なクライアントとしては、web向けとしてnostterSnort, Rabbitが、モバイル向けとしてはAmethystDamusがあります。
さらに、イベント削除ツールNostr Event Deletionや絵文字管理に特化したemojitoなど、特定機能を提供するアプリも多く存在します。「特定イベントを扱うクライアントを紹介するためのイベント」もNIPで定義されており(NIP-89)、これを実装した「Nostrアプリケーションを紹介・検索・レビューできるNostrアプリケーション」も存在しています(https://nostrapp.link)。

ユーザーは好きなクライアントで、好きなリレーを選んで、好きなようにNostrを利用できるわけです。

Nostrの詳しい仕様やその利点/欠点, 思想については、下記の記事が詳細なためそちらを参照ください。

Nostrではじめる、分散型アプリケーション開発
Nostr の面白さをエンジニア目線で解説してみる

なぜ自作クライアント?

NostrのSNSとしての機能のほとんどすべては、イベントの送受信によって実現されています。(Twitterにおける)ツイート, フォロー, いいね, リツイート, 引用リツイートなどはすべてイベントとして表現されており、その実体はWebSocketで送受信されるJSONです(参考:Events - nostr.com)。サーバー(リレー)側におけるロジックは(ほとんど)無く、Nostrアプリケーションがどのような形になるかは、クライアントがイベントをどのように表示するかに依存しています。

フロントエンドエンジニアからすると、これ以上に楽しい環境は無いでしょう。自分の好きなようにデザインし、好きなように機能を追加できるのですから。
ということで、Nostrを知ってすぐに自作クライアントを作り始めました。

本記事では、Nostrクライアント作成においてどのような技術を使い、どのような問題に直面し、どのように解決したかを紹介します。

使用ライブラリ

GitHub - eyemono-moe/streets: Column based Nostr client for web
Column based Nostr client for web. Contribute to eyemono-moe/streets development by creating an account on GitHub.

SolidJS

Solid Docs

フレームワークとしてはSolidJSを採用しています。
昨年のアドベントカレンダーで投稿した記事 2024年はSolid.jsを使いませんか?【部内PaaS基盤 NeoShowcase フロント開発ログ】 でもSolidJSを紹介させていただきましたが、その後お使いになられましたでしょうか。

今年は去年以上に、SolidJSと周辺ライブラリの成長が目覚ましかったように思います。去年の紹介記事で、SolidJSの唯一の不満点として<Show />コンポーネントでtype narrowingができないことを挙げていましたが、SolidJS Advent Calendar 2024 1日目の記事 実はsolidjsの型のサポートが強化されてる話 #TypeScript - Qiitaで紹介されているように、この問題も現在は解決されています。もうSolidJSに不満ないよ俺。

UnoCSS

UnoCSS
The instant on-demand Atomic CSS engine

スタイリングにはUnoCSSを採用しています。コンポーネントライブラリとしてKobalteArk UIといったheadless UIライブラリを採用したのですが、utility-firstなスタイリングライブラリはこういったheadless UIライブラリとの相性が良くて使いやすいですね。私の最近の個人開発ではSolidJS+Kobalte+unocssが定番になっています。

Ark UI

Home | Ark UI
A headless UI library with over 45+ components designed to build reusable, scalable Design Systems that works for a wide range of JS frameworks.

今回コンポーネントライブラリとして主にKobalteを使用しているのですが、一部箇所ではArk UIも使用しています。Ark UIはKobalteと同じくheadless UIライブラリですが、より多くのコンポーネントが提供されており、Kobalteに無いものを補完するために使用しました。

全部Ark UIで良いじゃないかと思われるかもしれませんが、Kobalteのドキュメントにあるようなスタイリング例がArk UIのドキュメントに無かったり、Kobalteのよりシンプルな設計が好みだったりするため、Kobalteをメインに使用しています。

本クライアントにおいて、Ark UIは画像アップロードフォーム部分で使用しています。Kobalteに画像アップロードコンポーネントが無かったのが当初の採用理由でしたが[2]、Ark UIのcontextとRootProviderの仕組みが使いやすかったのでそのまま使い続けています。

Ark UIでは以下の例のように、直接コンポーネントを使用する方法に加えて、

import { FileUpload } from '@ark-ui/solid/file-upload'
import { For } from 'solid-js'

export const Basic = () => (
  <FileUpload.Root maxFiles={5}>
    <FileUpload.Label>File Upload</FileUpload.Label>
    <FileUpload.Dropzone>Drag your file(s) here</FileUpload.Dropzone>
    <FileUpload.Trigger>Choose file(s)</FileUpload.Trigger>
    <FileUpload.ItemGroup>
      <FileUpload.Context>
        {(context) => (
          <For each={context().acceptedFiles}>
            {(file) => (
              <FileUpload.Item file={file}>
                ...
              </FileUpload.Item>
            )}
          </For>
        )}
      </FileUpload.Context>
    </FileUpload.ItemGroup>
    <FileUpload.HiddenInput />
  </FileUpload.Root>
)

useXXXフックを使用して、そのコンポーネントのcontextを取得する方法も提供されています。

import { FileUpload, useFileUpload } from '@ark-ui/solid/file-upload'
import { For } from 'solid-js'

export const RootProvider = () => {
  // contextを取得
  const fileUpload = useFileUpload({ maxFiles: 5 })

  const handleClear = () => {
    // 外から操作できる
    fileUpload.clearFiles()
  }

  return (
    <>
      <button onClick={handleClear}>Clear</button>

      {/* contextを渡す */}
      <FileUpload.RootProvider value={fileUpload}>
        <FileUpload.Label>File Upload</FileUpload.Label>
        <FileUpload.Dropzone>Drag your file(s)here</FileUpload.Dropzone>
        <FileUpload.Trigger>Choose file(s)</FileUpload.Trigger>
        <FileUpload.ItemGroup>
          <FileUpload.Context>
            {(context) => (
              <For each={context().acceptedFiles}>
                {(file) => (
                  <FileUpload.Item file={file}>
                    ...
                  </FileUpload.Item>
                )}
              </For>
            )}
          </FileUpload.Context>
        </FileUpload.ItemGroup>
        <FileUpload.HiddenInput />
      </FileUpload.RootProvider>
    </>
  )
}

これにより、FileUploadコンポーネントの外から、その内部状態を取得/操作することができます。本クライアントでは画像入力後の圧縮処理等を行うのに非常に便利でした。

Valibot

Valibot: The modular and type safe schema library
Validate unknown data with Valibot, the open source schema library with bundle size, type safety and developer experience in mind.

プロフィール設定ページなど各種入力欄のバリデーションにはValibotを使用しています。メソッドチェーンの代わりに独立してexportされた関数群を使用してスキーマを記述するため、tree-shakingが効きバンドルサイズを減らせることが特徴です。

SolidJSに対応したフォームライブラリがそもそも多くなく、おもにModular Formsが使用されることが多いのですが、ValibotはこのModular Formsと同じ作者が作成したライブラリであり、相性が良いため採用しました。Reactでフォームを実装するとどうしても再レンダリングに悩まされますが、ModularFormsでは何も考えなくても最適化されたフォームが作れてうれしい(SolidJSネイティブのフォームライブラリなのでSolidJS側の最適化がフルに効いている:Guide: Philosophy (SolidJS) | Modular Forms)。

開発で便利だったツール

ローカルリレーサーバー

本番環境においては第三者によって運用されているリレーに接続してイベントを取得/送信しますが、開発環境でこれらのリレーに接続するのは避けたいところです。

本クライアントの開発環境では、Dockerを使ってローカルにリレーとファイルアップロードサーバーを立て、これに接続して動作確認を行っています。

リレーサーバーにはnostr-rs-relayを、ファイルアップロードサーバーにはNostrcheck serverを採用しています。いずれも公式のcompose.ymlが用意されていたため、これをほぼそのまま使用することができました(nostrcheckのtraefikの設定がごちゃごちゃしてるのだけ何とかしたい)。

i18n Ally

i18n Ally - Visual Studio Marketplace
Extension for Visual Studio Code - 🌍 All in one i18n extension for VS Code

i18n AllyはVSCodeの拡張機能で、i18n対応のコードを書く際に非常に便利なツールです。

機能の一つinline annotationにより、keyから自動で言語ソースを読み込み、翻訳語文字列を表示してくれます。これにより、翻訳結果の確認が非常に楽になります。

特に便利なのがカーソルホバー時に表示されるdirect actionsで、翻訳語の追加や編集、自動翻訳文生成を一瞬で行うことができます。

🌏をクリックするとベース言語(ここではja)に入力した文から自動で翻訳した文章が生成される

SolidJSのi18n対応でよく使用される@solid-primitives/i18n自体はi18n Allyに対応されていないのですが(対応フレームワークはhttps://github.com/lokalise/i18n-ally/wiki/Supported-Frameworksで確認可能)、Custom Framework機能により、.vscode/i18n-ally-custom-framework.ymlを作成するだけでたいていのフレームワークで使用可能になります。

@solid-primitives/i18nで使用する場合、以下のような設定を追加します。usageMatchRegexさえちゃんと設定できれば大体動きます。

# .vscode/i18n-ally-custom-framework.yml

# An array of strings which contain Language Ids defined by VS Code
# You can check avaliable language ids here: https://code.visualstudio.com/docs/languages/overview#_language-id
languageIds:
  - javascript
  - typescript
  - javascriptreact
  - typescriptreact

# An array of RegExes to find the key usage. **The key should be captured in the first match group**.
# You should unescape RegEx strings in order to fit in the YAML file
# To help with this, you can use https://www.freeformatter.com/json-escape.html
usageMatchRegex:
  # The following example shows how to detect `t("your.i18n.keys")`
  # the `{key}` will be placed by a proper keypath matching regex,
  # you can ignore it and use your own matching rules as well
  #- "[^\\w\\d]t\\(['\"`]({key})['\"`]"
  - "\\Wt\\(\\s*['\"`]({key})['\"`]"

# An array of strings containing refactor templates.
# The "$1" will be replaced by the keypath specified.
# Optional: uncomment the following two lines to use

# refactorTemplates:
#  - i18n.get("$1")

# If set to true, only enables this custom framework (will disable all built-in frameworks)
monopoly: true

StreetsでのNostrイベント取得

ここから実際のクライアント(以下今回のクライアント名Streetsと呼びます)での実装例を紹介します。

Nostrでのイベント取得

そもそもNostrのプロトコルでイベントの送受信がどのように規定されているか簡単に説明します。原文を確認したい方はnostr-protocol/nipsのNIP-01を参照してください。

イベント

Nostrのイベントは以下のようなJSONで表現されます。

{
  "id": <シリアライズしたイベントのsha256>,
  "kind": <0-35535の整数でイベント種別を表現>,
  "tags": <後述, string[][]>,
  "content": <イベントの内容, string>,
  "created_at": <イベント作成時刻のunix timestamp(秒)>,
  "pubkey": <ユーザーID>,
  "sig": <署名>
}

kindはイベントの種別を表す整数で、例えばユーザープロフィールにはkind 0が、ショートテキスト(ツイートのようなもの)にはkind 1が割り振られています。このほかのkind一覧はhttps://github.com/nostr-protocol/nips/tree/master?tab=readme-ov-file#event-kindsで確認できます。

tagsはイベントに付与される 文字列の配列の配列 で、主に付加的な情報や配列で表したい情報を格納します。kindによってここに入ってくるデータも変わってくるため詳しくは省略しますが、例えばkind 1のショートテキストイベントでは、メンションされたユーザーのIDだったり、添付画像の情報だったりが入ってきます。

contentはイベントの内容を表す文字列です。

pubkey, sigはNostrをNostrたらしめている面白い部分なのですが、本記事では割愛します。ざっくりユーザーIDと署名だと思ってください(是非前述したNostr の面白さをエンジニア目線で解説してみるをお読みください)。

Nostrではこのイベントを、WebSocketを介してリレーに送信/受信します。

送信

クライアントで組み立てたイベントをリレーに送信する際は、以下のようなmessageを送信します。

["EVENT", <イベントのJSON>]

非常にシンプルですね

受信

リレーに存在するイベントを取得する際は、以下のようなmessageを送信します。

["REQ", <subscription id>, <フィルター1>, <フィルター2>, ...]

<subscription id>はそのリクエストを識別するためのIDです(後述)。

ここでフィルターという概念が登場します。フィルターはJSONで表現された、取得したいイベントの条件を指定するものです。

{
  "ids": <取得したいイベントのIDリスト, string[]>,
  "authors": <取得したいイベントのpubkey(=作者)のリスト, string[]>,
  "kinds": <取得したいイベントのkindのリスト, number[]>,
  "#<single-letter (a-zA-Z)>": <タグに含まれる値のリスト>,
  "since": <ここで指定したunix timestamp以降のイベントを取得>,
  "until": <ここで指定したunix timestamp以前のイベントを取得>,
  "limit": <初回リクエスト時に取得するイベントの最大数>
}

例えば私のショートテキスト(kind 1)一覧を最大10件取得したい場合、以下のようなフィルターを送信します。(私のpubkeyはdbe6fc932b40d3f322f3ce716b30b4e58737a5a1110908609565fb146f55f44a)

{
  "authors": ["dbe6fc932b40d3f322f3ce716b30b4e58737a5a1110908609565fb146f55f44a"],
  "kinds": [1],
  "limit": 10
}
["REQ", 443048:0, {"authors":["dbe6fc932b40d3f322f3ce716b30b4e58737a5a1110908609565fb146f55f44a"],"kinds":[1],"limit":10}]

するとリレーから以下のようなmessageが11件返ってきます。

image-4

["EVENT", <subscription id>, <イベント1>]
["EVENT", <subscription id>, <イベント2>]
...
["EOSE", <subscription id>]

["EVENT", ...]メッセージの中にイベントが入っており、これが10件届いています。
重要なのは最後の["EOSE", ...]メッセージで、これは"リレーに保存されていた条件を満たしたイベントが全て送信された"ことを示しています。

非同期Nリクエスト×Mレスポンス×Lリレー

"リレーに保存されていた条件を満たしたイベントが全て送信された"なんて変な言い回しをしていますが、実はこの部分がNostrイベント送受信の面白い部分であり、難しい部分です。

例えば先ほどの例と全く同じフィルターを使ってリクエストmessageを送信し、["EOSE",...]messageを受け取った後に(WebSocketのconnectionを閉じないまま)適当なクライアントで僕のユーザーIDを使ってショートテキストを投稿すると、そのイベントが同じリクエストIDで送信されてきます。実際に送受信されるmessageの例は以下のようになります。

↑ ["REQ", "test_req_id", {"authors":["dbe6fc932b40d3f322f3ce716b30b4e58737a5a1110908609565fb146f55f44a"],"kinds":[1],"limit":10}]
↓ ["EVENT", "test_req_id", <イベント1>]
↓ ["EVENT", "test_req_id", <イベント2>]
...
↓ ["EVENT", "test_req_id", <イベント10>]
↓ ["EOSE", "test_req_id"] ← 元々リレーに保存されていたイベント(の最新10件)がすべて送信された

-- ここで適当なクライアントでショートテキストを投稿 --

↓ ["EVENT", "test_req_id", <イベント11>] ← 新しいイベントを受信!

この仕様はSNSとして、最新のイベントをユーザーがページをリロードすることなく簡単に表示可能にする便利な仕様でもあり、同時にクライアントでのイベント送受信処理を考えさせられる難しい部分でもあります。
特にsubscription上限の存在により話がややこしくなっており、リレーに同時送信可能な(開きっぱなしの)REQの推奨上限数が決められており、これを超えるとリレーから「REQ多すぎ!!!!」と怒られてしまいます。これを避けるためには、クライアント側で複数REQのフィルターをマージするなどして、REQ数を減らす必要があります。(CLOSEリクエストやキャッシュも頑張ろうとすると爆発する)

この送受信処理を行う相手が単一のリレーであれば話はまだ簡単なのですが、分散型SNSであるNostrでは複数のリレーに接続するのが基本です。N個のREQと、それに対する非同期のM個のレスポンスが、L個のリレーに対して発生します

この辺りの詳細はぽーまん氏によりNostr REQ with Rx / Rx で REQ する Nostrで詳しく解説されていますのでぜひご覧ください。

最初のアプローチ Tanstack Query

当初、Streetsのイベント取得処理では、TanStack Querynostr-toolsのpoolを使った実装をしていました。
ちなみにTnastack系ライブラリは大体SolidJSにも対応しています。ありがたい。

このころの実装は、Rabbitの実装を参考にさせていただき、EOSEまでのイベントをPromise化して、TanStack QueryのcreateQueryのqueryFnに渡す形にしていました。EOSE後に受信したイベントはqueryClient.setQueryData()を使ってキャッシュに追加しており、リアルタイムのイベント更新も可能でした。

この実装はRabbit作者のしゅうすい氏によるRabbitのキャッシュ・バッチ化の仕組みで紹介されていたものです。

この実装方針はTanStack Queryのキャッシュ機能を活かすことができ、非常に効率的なものでした。TnstackQueryはDevtoolも非常に優秀で、キャッシュの状態をリアルタイムで確認できるため、開発環境でのバグ発見にも大いに役立ちました。

実際の実装例は https://github.com/eyemono-moe/streets/blob/580298af5cbbdd47e21eb5cbb15a56de93a04b23/src/libs/query.tshttps://github.com/eyemono-moe/streets/blob/580298af5cbbdd47e21eb5cbb15a56de93a04b23/src/libs/batchClient.ts にあります。

しかし、そもそもNostrのイベント送受信形態とPromiseとの相性があまりよくなく、Streetsの規模が大きくなるにつれて、Promiseを使うことを前提としているTanstackQueryで扱うのが難しくなってきました。

このあたりの処理で苦しんでいたところ、Nostter作者の雪猫氏から次のような助言(?)をいただきました。

なるほど確かに、TanstackQueryベースの実装ではPromiseが値を返す(呼び出し元resolverにイベントを渡す)必要があり、相性が悪かったですが、そもそも呼び出し元という概念を捨てることで、この問題を解決できるかもしれません。

クソデカイベントストアとRxJS(rx-nostr)の使用

そこで、Promiseを使用しない独自のイベントストアを作成し、これを中心としたイベント取得の仕組みを構築しました。

主要メソッドのシグネチャは以下のようになっています。

type CacheKey = (string | number | boolean | undefined)[];

const get = (props: () => { queryKey: CacheKey, emitter: () => void }): () => unknown => {
  // ストアからイベント(のaccessor)を取得
};

const set = (
  queryKey: CacheKey,
  data: unknown | ((prev: unknown) => unknown),
): void => {
  // ストアにイベントを追加
};

簡単に説明するとTanstackQueryにおけるcreateQueryqueryClient.setQueryDataに相当するものをそれぞれgetsetとして実装しています。
TanstackQueryがNostr実装において扱いづらかったのは、queryFnとしてPromiseを返す関数を渡す必要があった(REQ送信とイベント受信を一か所で行う必要があった)ためですが、この実装ではイベント取得リクエスト部分とストアへの保存部分を分離することを意図しています。

emitterでは実際に各リレーに対してREQを送信することを想定しており、このREQに対して受信したイベントを別箇所においてsetでストアに追加します。emitter

に実行されるようにしています。TanstackQueryを使うのをやめたとはいえ、やはりリクエスト数を減らすためにも最低限のキャッシュ処理を実装しています。

ストア自体はSolidJS標準のcreateStoreを使用して作成しており、invalidate等はcreateEffectで行うことで簡単に実装できました。

実際の実装はhttps://github.com/eyemono-moe/streets/blob/f73a46013ceeb3a3d7a14c0cdca8b9b34d6efe4c/src/context/eventCache.tsx#L61辺りにあります。

じゃあ実際に「受信したイベントを別箇所においてsetでストアに追加します」をどこでやっているのかというと、rx-nostrを使用して一か所でまとめて行っています。

rx-nostr
rx-nostr documentation

rx-nostrはRxJSを使用してREQ/受信イベントを扱うライブラリです。ReactiveXが使用されている理由や実際の処理内容等は前述のNostr REQ with Rx / Rx で REQ する Nostrに詳しくまとめられていますので、ここでは実際の実装例を紹介します(コメントは簡単のため一部厳密でない部分があります)。

import {
  batch,
  createRxBackwardReq,
  createRxNostr,
  uniq,
} from "rx-nostr";

// REQサブスクリプションを管理するオブジェクト
const rxNostr = createRxNostr({
  ...
});

// REQメッセージの作成を行うオブジェクト
const rxBackwardReq = createRxBackwardReq();
// REQメッセージを発行する関数
const emit = rxBackwardReq.emit;
// キャッシュにイベントを追加する関数(さっきの`set`に相当)
const setter = eventCacheSetter();

rxNostr
  .use(
    // REQメッセージをrxNostrに送出
    rxBackwardReq.pipe(
      bufferWhen(() => interval(1000)), // 1秒ごとにREQメッセージをまとめる
      batch((a, b) => mergeSimilarAndRemoveEmptyFilters([...a, ...b])), // フィルターをマージしてREQを減らす
    ),
  )
  .pipe(uniq()) // 重複を除去
  .subscribe({
    next: (e) => {
      // キャッシュにイベントを追加
      cacheAndEmitRelatedEvent(e, emit, setter);
    },
  });

やっていることとしては、

  1. REQサブスクリプションを管理するオブジェクトを作成
  2. REQメッセージの作成を行うオブジェクトを作成
  3. 1秒ごとにREQメッセージをまとめる
  4. フィルターをマージしてREQを減らす
  5. 重複を除去
  6. キャッシュにイベントを追加

といった感じです。

この実装はrx-nostrのドキュメントのhttps://penpenpng.github.io/rx-nostr/ja/v3/req-packet-operators.html#batchでのコードサンプルを参考にしています。また、実際のコードはhttps://github.com/eyemono-moe/streets/blob/main/src/context/rxNostr.tsxにあります。

6. において、ただキャッシュにイベントを保存するだけでなく、なにやらcacheAndEmitRelatedEventという関数を呼んでいますが、ここでは受信したイベントをキャッシュに保存するだけでなく、そのイベントに関連するイベントを再帰的に取得(emit)しています。

実際のStreetsの画面では、テキストイベントを一つ表示するだけでも、同時にそのイベントの投稿者の情報, リアクション一覧, リポスト, リプライ等の情報が必要となるため、これらの情報をいち早く取得するためにこのような仕組みを導入しました。

実際のイベント表示例:これらの関連イベントをcacheAndEmitRelatedEventの中で一括して取得している

自作のストアを使用することで、TanstackQueryのようなPromiseを使用することを前提とした実装から解放され、より柔軟なイベント取得処理を実現することができました。

課題: isFetchingが取得できていない

PromiseとTanstackQueryを使用していた時に問題になった部分とまた同じような問題になるのですが、特定のフィルターを使ったREQのloading状態(EOSEが届いたかどうか)を取得しようとすると、"その特定フィルターがマージされた結果のフィルターが使用されているREQ"のloading状態を取得する必要があり、結局またフィルターのマージ前後の組み合わせをどこかで保持する必要があり、これがまだ実装できていません...というか実装したらまたバグの温床になりそうです。

ストア部分はまだまだ改良の余地があるため、今後も改善を続けていきたいです。

12/08 追記

fetchingを取得できない問題について、rx-nostrの作者のぽーまん氏から次のような助言をいただきました。

たしかに。そもそもisFetchingを取得するのがめんどくさかったのはfilterのマージをしているからなので、EOSEが必要なreqはマージをあきらめる(都度createRxBackwardReq()する)ことにしました。

// EOSE時にresolveするemit
const emitWithEOSE = (...props: Parameters<typeof emit>) => {
  return new Promise<void>((resolve) => {
    const backwardReq = createRxBackwardReq();
    rxNostr.use(backwardReq.pipe(batchReq)).subscribe({
      complete() {
        resolve();
      },
    });
    backwardReq.emit(...props);
    backwardReq.over();
  });
};

これで一旦は解決とします。また良い実装が思いついたら改修するということで...

そのほか困ったことなど

画像サイズがデカすぎる

Nostrで投稿に画像を添付する場合、基本的にはただのURLを本文に入れるだけになります。つまり実際にURLから画像をダウンロードするまでファイルサイズがわからず、サムネイルなんて便利なものもありません。
画像のメタデータを定義できるimetaタグもありますが(NIP-92)、オプショナルであり、実際あまり使用されていないようです。
そのため、スマホで撮影した超高画質の画像が特に圧縮されずに流れてくることも多く、ユーザーのギガに大ダメージを与えています。

Streetsでは少しでも投稿画像のサイズを小さくするため、browser-image-compressionを使用して画像アップロード前にクライアント側で圧縮処理をするようにしています。

GitHub - Donaldcwl/browser-image-compression: Image compression in web browser
Image compression in web browser. Contribute to Donaldcwl/browser-image-compression development by creating an account on GitHub.
ブラウザで画像圧縮が出来るbrowser-image-compressionとその内部実装についてと他ライブラリとの比較

ただあくまでもStreetsから投稿された画像に対してのみ有効であるため、すべての画像を画像最適化プロキシ経由で取得するなどの対策が必要そうです。そんな便利なプロキシどこに...

_人人人人人人人人人人人人人人人人人人人人人_
nostterに画像最適化機能つけたよ(サーバー編)
 ̄YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY^Y ̄

ということで今後こちらを使用させていただくかもしれません。

vs CORS

StreetsではURLリンクを含む投稿で、そのリンク先のOGPを表示するようにしています。しかし、Streetsにサーバーは存在しません。クライアント側でリンク先にfetchをしてmetaタグを取得してやる必要があります。
こうなると問題となるのがCORSです。リンク先のサーバーがCORSを許可していない場合、リンク先の情報を取得することができません。

現状の実装ではhttps://corsproxy.ioを使用してCORSを回避していますが、これはあくまで一時的なものであり、将来的には自前のプロキシサーバーを立ててみようと考えています(ここでついでに画像最適化プロキシも立てていいかも)。
corsproxy.ioのrate limitが結構厳しいようで、すでに私の環境では制限に引っかかってしまっているため、早急に対策したいところです。

イベントを信じない

例えばユーザープロフィール(kind 0)のcontentはNIP-01で次のように定められています。

the content is set to a stringified JSON object {name: <username>, about: <string>, picture: <url, string>} describing the user who created the event.

一応NIP-01はmandatory(実装必須)のNIPとされていて、kind 0のイベントはこの形式であるべきですが、実際にはこの形式になっていないことが多々あります。具体的には、そもそもnameプロパティが存在しないイベントが何件かあり、パースに失敗することがありました。

リレー側にイベント内容のバリデーションをする義務は無く(署名の検証程度)、結構中身がめちゃくちゃなイベントが流れてきます。NIPでMUSTとされているプロパティも信用せず、基本nullableとしてパースするようにしています。

投稿をclick可能にする

Streetsではタイムライン上の投稿をクリックすると、その投稿の返信先やリプライなどが一覧表示されるスレッドビューに遷移するようにしています。

これを実装するためには投稿にonClickを付ける必要がありますが、これがなかなかめんどくさかったです。
<div>onClickを付けるな!!!とよく言われますが、<button>の中に画像やらリンクやらを入れる場合も、「リンクをクリックして開きたかったのにbuttonが反応してしまう」という問題が生じます。

結局Streetsではbuttonを使用し、onClick内でクリックされた要素がリンクかどうかや、ネストしたbuttonがクリックされたかどうかなどを愚直に確認するようにしました。

他のSNSでの実装例やベストプラクティスがあれば教えていただきたいです。

traQが偉すぎる

traQは弊サークル内で使用されている部内製のSNSです。これのクライアントもまた部員によって開発されているのですが、クオリティが高すぎて、Streetsのクライアントを作る上でかなり参考になりました。SNSクライアントの実装って意外と探しても見つからないので、近くに良い実装があってとても助かりました。

GitHub - traPtitech/traQ_S-UI: traQ S - traP Internal Messenger Application Frontend
traQ S - traP Internal Messenger Application Frontend - traPtitech/traQ_S-UI

リリースフローの整備

StreetsのデプロイはVercelで行っているのですが、Githubと連携しただけのデフォルト設定だとmainに変更が入るたびに本番環境にデプロイされます。個人的にはこの設定があまり好きでなかった(リリース状態と最新のコードの状態を分離できるようにしたかった)ため、mainブランチとは別にreleaseブランチを生やしてこちらをproduction環境用ブランチに設定しました。デフォルト設定のままdevブランチとか生やしてそこで開発を進めるのでもいいかもですね。

また、ただreleaseブランチにPRを自分で出すのではなく、github actionsを使用して、tagの作成時にreleaseブランチへのPRを出すようにしています。そのPRでどんな変更が入るかを機械的にリストアップし、影響範囲を確認しやすくすることを目的としています。

実際の設定ファイルはhttps://github.com/eyemono-moe/streets/blob/main/.github/workflows/createReleaseBranch.yamlにあります。このactionsはGitHub Actions を使ってリリース時のあれこれを自動化するを参考に作成させていただきました。

GitHub Actions を使ってリリース時のあれこれを自動化する

これによりpreview環境での動作確認が楽になりました。

まとめ

Nostrを使用したSNSクライアントの実装について、Streetsの実装例を紹介しました。
他にない特殊なイベントの送受信形態に苦労しましたが、†フロントエンド力†を鍛えるいい機会になりました。
UI/UXについても学びが多く、今後別のプロジェクトにも生かせるいい経験ができたと思います。
今後もStreetsの開発を続け、より使いやすいクライアントを目指していきたいです。
また、Streetsの開発を通して改めてSolidJSの開発体験の良さを感じました。stateに関する制約が特にないのが気持ちいい。
SolidJS・Nostrに興味を持った方はぜひ一度触ってみてください。そしてクライアントを作りましょう。俺もやったんだからさ


最後までお読みいただきありがとうございました。
明日のtraP Advent Calendar 2024の担当者は@kenken, @comavius, @haruka1012さんです
明日のNostr Advent Calendar 2024の担当者はatasintiさんです
明日のSolidJS Advent Calendar 2024の担当者は@no_job_swanさんです

お楽しみに~


  1. 特に公式に決まった発音は無かったため、日本語では主に"ノストラ"や"ノスター"と読まれていたようですが、つい最近になって/ˈnɒstʃrə/のIPA表示が https://nostr.com に記載されるようになりました。個人的には"ノストラ"の方がかわいくてすき ↩︎

  2. 実はクライアント自作を初めて半月後ぐらいにKobalteの方にもFileUploadコンポーネントが追加されました ↩︎

d_etteiu8383 icon
この記事を書いた人
d_etteiu8383

グラフィック班とゲーム班とSysAd班所属 いろいろ活動しています

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2021年5月19日
CPCTF2021を実現させたスコアサーバー
xxpoxx icon xxpoxx
2022年9月26日
競プロしかシラン人間が web アプリ QK Judge を作った話
tqk icon tqk
2024年12月24日
クリスマスを充実して過ごすためのたった一つの方法
Naru820 icon Naru820
2024年12月11日
Nixで実行環境のライセンス違反を予防する話
comavius icon comavius
2024年4月14日
Spotifyのクライアントを自作しよう
d_etteiu8383 icon d_etteiu8383
2024年3月15日
個人開発として2週間でWebサービスを作ってみた話 〜「LABEL」の紹介〜
Natsuki icon Natsuki
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記