Open1

自作Nostrクライアント設計メモ

eyemono.moeeyemono.moe

https://streets.eyemono.moe の実装を改善したいので設計について改めてまとめる。

作りたいもの

  • マルチカラムUIのNostrクライアント
    • タイムラインカラムを複数個同時表示しても、WebSocket接続上限やREQ上限に到達しない(しにくい)ような設計にしたい

考察

最小限のカラム

以下のようなコンポーネントを考える(本スクラップのコードは全てSolidJS)

import { type Component, For, createSignal, onCleanup } from "solid-js";

const req = (id: string) => [
  "REQ",
  id,
  {
    authors: [
      "dbe6fc932b40d3f322f3ce716b30b4e58737a5a1110908609565fb146f55f44a",
    ],
    kinds: [1],
    until: Math.floor(Date.now() / 1000),
    limit: 5,
  },
];

const Column: Component = () => {
  const [events, setEvents] = createSignal<
    [string, string, Record<string, unknown> | undefined][]
  >([]);
  const ws = new WebSocket("wss://yabu.me/");

  ws.addEventListener("open", () => {
    ws.send(JSON.stringify(req(crypto.randomUUID())));
  });
  ws.addEventListener("message", (event) => {
    const data = JSON.parse(event.data);
    setEvents((prevEvents) => [...prevEvents, data]);
  });

  onCleanup(() => {
    ws.close();
  });

  return (
    <div class="flex flex-col gap-2 p-4">
      <For each={events()}>
        {(event) => (
          <div class="b-zinc b-1 overflow-x-auto rounded bg-zinc/10">
            <h2>{event[0]}</h2>
            <pre class="text-xs">{JSON.stringify(event[2], null, 2)}</pre>
          </div>
        )}
      </For>
    </div>
  );
};

export default Column;

リクエストする。

最新5件のイベント+EOSEが返ってくる。

マルチカラム

このカラムコンポーネントは内部でnew WebSocket()しているため、単純にカラムを増やすとコネクションが増える。

import type { Component } from "solid-js";
import Column from "./components/Column";

const App: Component = () => {
  return (
    <div class="grid grid-cols-4">
      <Column />
      <Column />
      <Column />
      <Column />
    </div>
  );
};

export default App;

Websocketのコネクション数には上限があるため、この方針では極論500か所とかでデータ取得を試みると壊れる。

Websocket objectは親コンポーネント持ち、同一のリレーに対するWebsocketの接続は1つにまとめてみる。

src/context/Relay.tsx
import {
  type ParentComponent,
  Show,
  createContext,
  createSignal,
  onCleanup,
  useContext,
} from "solid-js";

const RelayContext = createContext<{
  send: (data: unknown) => void;
  subscribe: (callback: (data: string) => void) => void;
}>();

export const RelayProvider: ParentComponent = (props) => {
  const ws = new WebSocket("wss://yabu.me/");

  const [connected, setConnected] = createSignal(false);
  ws.addEventListener("open", () => {
    setConnected(true);
  });

  onCleanup(() => {
    ws.close();
  });

  return (
    <RelayContext.Provider
      value={{
        send: (data) => ws.send(JSON.stringify(data)),
        subscribe: (callback) => {
          ws.addEventListener("message", (event) => {
            callback(event.data);
          });
        },
      }}
    >
      <Show when={connected()}>{props.children}</Show>
    </RelayContext.Provider>
  );
};

export const useRelay = () => {
  const context = useContext(RelayContext);
  if (!context) {
    throw new Error("useWebsocket must be used within a WebsocketProvider");
  }
  return context;
};
src/components/Column.tsx
-import { type Component, For, createSignal, onCleanup } from "solid-js";
+import { type Component, For, createSignal } from "solid-js";
+import { useRelay } from "../context/Relay";

 const req = (id: string) => [
   "REQ",
   id,
   {
     authors: [
       "dbe6fc932b40d3f322f3ce716b30b4e58737a5a1110908609565fb146f55f44a",
     ],
     kinds: [1],
     until: Math.floor(Date.now() / 1000),
     limit: 5,
   },
 ];

 const Column: Component = () => {
   const [events, setEvents] = createSignal<
     [string, string, Record<string, unknown> | undefined][]
   >([]);
-  const ws = new WebSocket("wss://yabu.me/");
+  const { send, subscribe } = useRelay();

-  ws.addEventListener("open", () => {
-    ws.send(JSON.stringify(req(crypto.randomUUID())));
-  });
-  ws.addEventListener("message", (event) => {
-    const data = JSON.parse(event.data);
+  send(req(crypto.randomUUID()));
+  subscribe((event) => {
+    const data = JSON.parse(event);
     setEvents((prevEvents) => [...prevEvents, data]);
   });

-  onCleanup(() => {
-    ws.close();
-  });
-
   return (
     <div class="flex flex-col gap-2 p-4">
       <For each={events()}>
         {(event) => (
           <div class="b-zinc b-1 overflow-x-auto rounded bg-zinc/10">
             <h2>{event[0]}</h2>
             <pre class="text-xs">{JSON.stringify(event[2], null, 2)}</pre>
           </div>
         )}
       </For>
     </div>
   );
 };

 export default Column;
src/App.tsx
 import type { Component } from "solid-js";
 import Column from "./components/Column";
+import { RelayProvider } from "./context/Relay";

 const App: Component = () => {
   return (
-    <div class="grid grid-cols-4">
-      <Column />
-      <Column />
-      <Column />
-      <Column />
-    </div>
+    <RelayProvider>
+      <div class="grid grid-cols-4">
+        <Column />
+        <Column />
+        <Column />
+        <Column />
+      </div>
+    </RelayProvider>
   );
 };

 export default App;

これで接続は1つになり解決

...ではない。limitを1にするとわかりやすいが、別のコンポーネントでsendされたREQ由来のイベントが取得されてしまっている。

send使用箇所に関係なくすべてのsubscribeに対してイベントを返しているのが原因。REQのIDとcallback関数の組を保持しておき、onMessage時にイベントを振り分ければよい。

src/context/Relay.tsx
 import {
   type ParentComponent,
   Show,
   createContext,
   createSignal,
   onCleanup,
   useContext,
 } from "solid-js";

 const RelayContext = createContext<{
-  send: (data: unknown) => void;
-  subscribe: (callback: (data: string) => void) => void;
+  reqAndSubscribe: (filter: unknown, callback: (data: string) => void) => void;
 }>();

 export const RelayProvider: ParentComponent = (props) => {
+  const callbackMap = new Map<string, (data: string) => void>();
   const ws = new WebSocket("wss://yabu.me/");

   const [connected, setConnected] = createSignal(false);
   ws.addEventListener("open", () => {
     setConnected(true);
   });
+  ws.addEventListener("message", (event) => {
+    const data = JSON.parse(event.data);
+    const id = data[1];
+    const callback = callbackMap.get(id);
+    if (callback) {
+      callback(event.data);
+    }
+  });

   onCleanup(() => {
     ws.close();
   });

+  const reqAndSubscribe = (
+    filter: unknown,
+    callback: (data: string) => void,
+  ) => {
+    const id = crypto.randomUUID();
+    const req = ["REQ", id, filter];
+    ws.send(JSON.stringify(req));
+    callbackMap.set(id, callback);
+  };
+
   return (
     <RelayContext.Provider
       value={{
-        send: (data) => ws.send(JSON.stringify(data)),
-        subscribe: (callback) => {
-          ws.addEventListener("message", (event) => {
-            callback(event.data);
-          });
-        },
+        reqAndSubscribe,
       }}
     >
       <Show when={connected()}>{props.children}</Show>
     </RelayContext.Provider>
   );
 };

 export const useRelay = () => {
   const context = useContext(RelayContext);
   if (!context) {
     throw new Error("useWebsocket must be used within a WebsocketProvider");
   }
   return context;
 };
src/components/Column.tsx
 import { type Component, For, createSignal } from "solid-js";
 import { useRelay } from "../context/Relay";

-const filter = (id: string) => [
-  "REQ",
-  id,
-  {
-    authors: [
-      "dbe6fc932b40d3f322f3ce716b30b4e58737a5a1110908609565fb146f55f44a",
-    ],
-    kinds: [1],
-    until: Math.floor(Date.now() / 1000),
-    limit: 5,
-  },
-];
+const filter = {
+  authors: ["dbe6fc932b40d3f322f3ce716b30b4e58737a5a1110908609565fb146f55f44a"],
+  kinds: [1],
+  until: Math.floor(Date.now() / 1000),
+  limit: 1,
+};

 const Column: Component = () => {
   const [events, setEvents] = createSignal<
     [string, string, Record<string, unknown> | undefined][]
   >([]);
-  const { send, subscribe } = useRelay();
+  const { reqAndSubscribe } = useRelay();

-  send(JSON.stringify(filter(crypto.randomUUID())));
-  subscribe((event) => {
+  reqAndSubscribe(filter, (event) => {
     const data = JSON.parse(event);
     setEvents((prevEvents) => [...prevEvents, data]);
   });

   return (
     <div class="flex flex-col gap-2 p-4">
       <For each={events()}>
         {(event) => (
           <div class="b-zinc b-1 overflow-x-auto rounded bg-zinc/10">
             <h2>{event[0]}</h2>
             <pre class="text-xs">{JSON.stringify(event[2], null, 2)}</pre>
           </div>
         )}
       </For>
     </div>
   );
 };

 export default Column;

subscriptionのclose処理を含むCleanUp処理を追加する。ついでにtanstack queryのようなインターフェースにしておく。

src/context/Relay.tsx
import {
  type ParentComponent,
  Show,
  createContext,
  createSignal,
  onCleanup,
  useContext,
} from "solid-js";

const RelayContext = createContext<{
  reqAndSubscribe: (
    filter: unknown,
    callback: (data: string) => void,
  ) => () => void;
}>();

export const RelayProvider: ParentComponent = (props) => {
  const callbackMap = new Map<string, (data: string) => void>();
  const ws = new WebSocket("wss://yabu.me/");

  const [connected, setConnected] = createSignal(false);
  ws.addEventListener("open", () => {
    setConnected(true);
  });
  ws.addEventListener("message", (event) => {
    const data = JSON.parse(event.data);
    const id = data[1];
    const callback = callbackMap.get(id);
    if (callback) {
      callback(event.data);
    }
  });

  onCleanup(() => {
    ws.close();
  });

  const reqAndSubscribe = (
    filter: unknown,
    callback: (data: string) => void,
  ) => {
    const id = crypto.randomUUID();
    const req = ["REQ", id, filter];
    ws.send(JSON.stringify(req));
    callbackMap.set(id, callback);

    return () => {
      const unsub = ["CLOSE", id];
      ws.send(JSON.stringify(unsub));
      callbackMap.delete(id);
    };
  };

  return (
    <RelayContext.Provider
      value={{
        reqAndSubscribe,
      }}
    >
      <Show when={connected()}>{props.children}</Show>
    </RelayContext.Provider>
  );
};

export const useRelay = () => {
  const context = useContext(RelayContext);
  if (!context) {
    throw new Error("useWebsocket must be used within a WebsocketProvider");
  }
  return context;
};
src/lib/createEvents.ts
import { createEffect, createSignal } from "solid-js";
import { useRelay } from "../context/Relay";

export const createEvents = (filter: () => unknown) => {
  const [events, setEvents] = createSignal<
    [string, string, Record<string, unknown> | undefined][]
  >([]);
  const { reqAndSubscribe } = useRelay();

  createEffect((prev): (() => unknown) => {
    if (prev) {
      prev();
      setEvents([]);
    }

    return reqAndSubscribe(filter(), (event) => {
      const data = JSON.parse(event);
      setEvents((prevEvents) => [...prevEvents, data]);
    });
  });

  return events;
};
src/components/Column.tsx
import { type Component, For, createSignal } from "solid-js";
import { createEvents } from "../lib/createEvents";

const Column: Component = () => {
  const [kind, setKind] = createSignal(1);

  const events = createEvents(() => ({
    authors: [
      "dbe6fc932b40d3f322f3ce716b30b4e58737a5a1110908609565fb146f55f44a",
    ],
    kinds: [kind()],
    until: Math.floor(Date.now() / 1000),
    limit: 4,
  }));

  return (
    <div class="flex flex-col gap-2 p-4">
      <input
        type="number"
        value={kind()}
        onInput={(e) => setKind(e.currentTarget.valueAsNumber)}
        class="b-zinc b-1 rounded px-2"
      />
      <For each={events()}>
        {(event) => (
          <div class="b-zinc b-1 overflow-x-auto rounded bg-zinc/10">
            <h2>{event[0]}</h2>
            <pre class="text-xs">{JSON.stringify(event[2], null, 2)}</pre>
          </div>
        )}
      </For>
    </div>
  );
};

export default Column;

これによりリアクティブに変化するフィルターをサブスクライブすることができるようになった。


以下追記予定

  • REQの圧縮
    • mergeFilter
    • Map<reqId,callback>が機能しなくなる
  • 複数リレーとの送受信
  • イベントストアとキャッシュ
    • RxJSの利用
    • ストアに全部入れ、コンポーネントからの読み取り時にクライアント側でfilterする
    • Optimisticな更新
      • send時にストアに(optimisticマークをつけて)データを入れてしまう