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

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;
- マウント時にリレー(ここではyabu.me)に接続し、
- npub1m0n0eyetgrflxghneeckkv95ukrn0fdpzyysscy4vha3gm64739qxn23skの
- kind 1の
- 現在までの
- 最新イベント5件を
リクエストする。
最新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マークをつけて)データを入れてしまう