@ikuradon/tsunagiya
v0.4.0
Published
Nostr relay mock library for testing. Intercepts WebSocket to test existing Nostr clients without code changes.
Maintainers
Readme
繋ぎ屋 (tsunagiya)
Nostr relay mock library for Deno/TypeScript.
globalThis.WebSocket
を差し替えることで、既存のNostrクライアントコードを一切変更せずにテストできます。
インストール
Deno:
deno add jsr:@ikuradon/tsunagiyanpm:
npm install @ikuradon/tsunagiya基本的な使い方
import { MockPool } from "@ikuradon/tsunagiya";
Deno.test("fetch events from relay", async () => {
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
relay.store({
id: "abc123",
pubkey: "pubkey1",
kind: 1,
content: "hello nostr",
created_at: 1700000000,
tags: [],
sig: "sig1",
});
pool.install();
try {
const ws = new WebSocket("wss://relay.example.com");
// ... テスト対象のクライアントコードがそのまま動く
} finally {
pool.uninstall();
}
});機能
- WebSocket 完全乗っ取り型モック
- 複数リレー同時対応
- NIP-01 フィルター自動マッチング + カスタムハンドラー
- 不安定リレーのシミュレート(レイテンシ、エラー率、切断)
- NIP-42 AUTH チャレンジ/レスポンス
- 送信メッセージの記録・検証ヘルパー
- NIP-01 イベント種別自動処理(Regular/Replaceable/Ephemeral/Addressable)
- NIP-09 Event Deletion Request
- NIP-11 リレー情報ドキュメント(setInfo/getInfo + fetch インターセプト)
- NIP-17 Private Direct Messages(EventBuilder chatMessage/seal/giftWrap/dmRelayList/privateDM)
- NIP-18 Reposts(EventBuilder repost/genericRepost)
- NIP-23 Long-form Content(EventBuilder longFormContent/longFormDraft)
- NIP-25 Reactions(EventBuilder withReactions/externalReaction)
- NIP-40 Expiration Timestamp(EventBuilder
withExpiration()) - NIP-51 Lists(EventBuilder muteList/pinList/bookmarks/followSet/relaySet/emojiSet)
- NIP-65 Relay List Metadata(EventBuilder relayList)
- NIP-45 COUNT メッセージ対応
- NIP-50 検索フィルター対応
- テスト支援ヘルパー(EventBuilder, FilterBuilder, assertions)
- リアルタイムストリーム・スナップショット
- ログ機能(console / カスタムハンドラー)
- テストフレームワーク非依存
- 外部依存ゼロ
- E2Eテスト対応(nostr-tools, NDK, rx-nostr, nostr-fetch)
MockPool
テストのエントリポイント。複数の MockRelay を管理し、globalThis.WebSocket
を差し替える。
基本的な使い方
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
pool.install(); // WebSocket差し替え
pool.uninstall(); // 元に戻す
pool.reset(); // 全リレーの状態をリセット
pool.connections; // アクティブ接続一覧 (Map<string, number>)複数リレーの使い方
const pool = new MockPool();
// 複数のリレーを登録(それぞれ独立して動作)
const relay1 = pool.relay("wss://relay1.example.com");
const relay2 = pool.relay("wss://relay2.example.com");
const relay3 = pool.relay("wss://relay3.example.com");
// 各リレーに異なるイベントを登録
relay1.store(event1);
relay2.store(event2);
relay3.store(event3);
// 各リレーに異なる設定も可能
const fastRelay = pool.relay("wss://fast.relay.test", { latency: 10 });
const slowRelay = pool.relay("wss://slow.relay.test", { latency: 500 });
pool.install();
try {
// 複数リレーに同時接続するクライアントコードがそのまま動く
const ws1 = new WebSocket("wss://relay1.example.com");
const ws2 = new WebSocket("wss://relay2.example.com");
const ws3 = new WebSocket("wss://relay3.example.com");
// ... テスト対象のクライアントロジック
} finally {
pool.uninstall();
}注意: pool.relay()
で登録していないURLに接続しようとすると、接続失敗として扱われます(エラーイベント +
クローズイベント
code:1006)。これは実際のリレーに接続できなかった場合と同じ動作です。
MockRelay
URL単位で動作する仮想リレー。
イベントの登録とカスタムハンドラー
const relay = pool.relay("wss://relay.example.com");
// イベントを事前登録(REQ受信時に自動マッチング)
relay.store(event);
// REQハンドラーのカスタマイズ
relay.onREQ((subId, filters) => {
return [customEvent];
});
// EVENTハンドラーのカスタマイズ
relay.onEVENT((event) => {
return ["OK", event.id, true, ""];
});不安定リレーのシミュレート
pool.relay("wss://unstable.relay.test", {
latency: { min: 100, max: 2000 },
errorRate: 0.3,
disconnectRate: 0.1,
connectionTimeout: 5000,
});エラーケーステスト
relay.refuse(); // 接続拒否
relay.disconnect(); // 全接続を即座に切断
relay.disconnectAfter(3000); // 3秒後に切断
relay.close(1006); // 特定クローズコードで切断
relay.sendRaw("not json"); // 不正データ送信
relay.sendNotice("rate-limited"); // NOTICE送信NIP-42 AUTH
const relay = pool.relay("wss://auth.relay.test", {
requiresAuth: true,
});
relay.requireAuth((authEvent) => {
return authEvent.tags.some(
(t) => t[0] === "relay" && t[1] === "wss://auth.relay.test",
);
});検証ヘルパー
relay.received; // 全受信メッセージ
relay.findREQ("sub1"); // REQ検索
relay.countREQs(); // REQ数
relay.hasREQ("sub1"); // REQ存在確認
relay.findEvent("id1"); // EVENT検索
relay.countEvents(); // EVENT数
relay.hasEvent("id1"); // EVENT存在確認
relay.findCLOSE("sub1"); // CLOSE検索
relay.connectionCount; // アクティブ接続数NIP-01 イベント種別(旧 NIP-16/33)
import {
classifyEvent,
isEphemeral,
isParameterizedReplaceable,
isReplaceable,
} from "@ikuradon/tsunagiya";
classifyEvent(10000); // "replaceable"
classifyEvent(20000); // "ephemeral"
classifyEvent(30000); // "parameterized_replaceable"NIP-09 削除リクエスト
import { EventBuilder } from "@ikuradon/tsunagiya/testing";
// kind:5 削除リクエストイベントを生成
const deletion = EventBuilder.deletion(["event-id-1", "event-id-2"])
.content("spam")
.build();
relay.store(deletion);
// ストア内の対象イベントが自動的に削除されるNIP-45 COUNT
// COUNTハンドラーのカスタマイズ
relay.onCOUNT((subId, filters) => {
return { count: 42 };
});
// クライアントから COUNT メッセージを送信
ws.send(JSON.stringify(["COUNT", "sub1", { kinds: [1] }]));
// => ["COUNT", "sub1", {"count": 42}]スナップショット
const snap = relay.snapshot();
relay.store(event2);
relay.store(event3);
relay.restore(snap); // event2, event3 追加前の状態に戻るログ機能
// console出力
pool.relay("wss://relay.example.com", { logging: true });
// カスタムハンドラー
const logs: LogEntry[] = [];
pool.relay("wss://relay.example.com", {
logging: (entry) => logs.push(entry),
});テスト支援ヘルパー
@ikuradon/tsunagiya/testing からインポートする。
import {
assertReceivedREQ,
EventBuilder,
FilterBuilder,
restore,
snapshot,
streamEvents,
} from "@ikuradon/tsunagiya/testing";EventBuilder
// ビルダーパターンでイベント生成
const event = EventBuilder.kind1()
.content("hello world")
.tag("p", pubkey)
.build();
// ランダム生成
const random = EventBuilder.random({ kind: 1 });
// 壊れたイベント
const broken = EventBuilder.kind1()
.corrupt({ id: true, sig: true })
.build();
// バルク生成
const events = EventBuilder.bulk(100, { kind: 1 });
// 時系列データ
const timeline = EventBuilder.timeline(50, {
kind: 1,
interval: 60,
startTime: 1700000000,
});
// リプライチェーン
const thread = EventBuilder.thread(5);
// リアクション付き
const [post, reactions] = EventBuilder.withReactions(3);
// NIP別テンプレート
EventBuilder.metadata({ name: "Alice", about: "Nostr user" });
EventBuilder.contacts(["pub1", "pub2"]);
EventBuilder.dm("recipient", "secret message");
EventBuilder.groupMessage("group-id").content("hello");
EventBuilder.zapRequest({
amount: 1000,
relays: ["wss://r.test"],
lnurl: "...",
});FilterBuilder
FilterBuilder.timeline({ limit: 20 });
// => { kinds: [1], limit: 20 }
FilterBuilder.profile("pubkey");
// => { kinds: [0], authors: ["pubkey"] }
FilterBuilder.mentions("pubkey");
// => { kinds: [1], "#p": ["pubkey"] }
FilterBuilder.reactions("eventId");
// => { kinds: [7], "#e": ["eventId"] }
FilterBuilder.search("nostr");
// => { search: "nostr" }アサーションヘルパー
import {
assertAuthCompleted,
assertClosed,
assertEventPublished,
assertNoErrors,
assertReceived,
assertReceivedREQ,
} from "@ikuradon/tsunagiya/testing";
assertReceivedREQ(relay, { kinds: [1] });
assertEventPublished(relay, "event-id");
assertNoErrors(relay);
assertAuthCompleted(relay);
assertClosed(relay, "sub1");
assertReceived(relay, (messages) => messages.some((m) => m[0] === "REQ"));条件待ちヘルパー
import { waitFor } from "@ikuradon/tsunagiya/testing";
// 条件が満たされるまでポーリングで待機(固定 setTimeout の代替)
await waitFor(() => received.length >= 3);
await waitFor(() => relay.connectionCount === 0, { timeout: 3000 });リアルタイムストリーム
import { startStream, streamEvents } from "@ikuradon/tsunagiya/testing";
// 時間差でイベント配信
const handle = streamEvents(relay, events, {
interval: 100,
jitter: 50,
});
handle.stop();
// 継続的ストリーム
const stream = startStream(relay, {
eventGenerator: () => EventBuilder.random({ kind: 1 }),
interval: 1000,
count: 10,
});
stream.stop();スナップショット(テスト支援)
import { restore, snapshot } from "@ikuradon/tsunagiya/testing";
const snap = snapshot(relay);
// ... 操作 ...
restore(relay, snap);Vitest での使い方
npm パッケージとしてインストールすれば、Vitest でそのまま使えます。
import { afterEach, describe, expect, it } from "vitest";
import { MockPool } from "@ikuradon/tsunagiya";
describe("Nostr client", () => {
let pool: MockPool;
afterEach(() => pool?.uninstall());
it("should fetch events from relay", async () => {
pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
relay.store({
id: "abc123",
pubkey: "pubkey1",
kind: 1,
content: "hello nostr",
created_at: 1700000000,
tags: [],
sig: "sig1",
});
pool.install();
const ws = new WebSocket("wss://relay.example.com");
await new Promise<void>((resolve) => {
ws.onopen = () => resolve();
});
const messages: string[] = [];
ws.onmessage = (ev) => messages.push(ev.data as string);
ws.send(JSON.stringify(["REQ", "sub1", { kinds: [1] }]));
await new Promise((r) => setTimeout(r, 50));
expect(messages.some((m) => m.includes("abc123"))).toBe(true);
ws.close();
});
});Note: Vitest のデフォルト環境 (
environment: 'node') で動作します。jsdomやhappy-dom環境では WebSocket の競合が起きる可能性があるため、environment: 'node'を推奨します。
E2Eテスト対応
繋ぎ屋(tsunagiya) は以下の主要 Nostr クライアントライブラリとの互換性を E2E テストで検証しています。
| ライブラリ | テストコマンド | 検証内容 |
| ----------- | ------------------------------- | --------------------------------------------------- |
| nostr-tools | deno task example:nostr-tools | SimplePool での REQ/EVENT 処理 |
| NDK | deno task example:ndk | NDK インスタンス経由のイベント取得・投稿 |
| rx-nostr | deno task example:rx-nostr | RxNostr の Reactive API(createRxNostr / use) |
| nostr-fetch | deno task example:nostr-fetch | NostrFetcher によるイベント取得(fetch / iterator) |
全ライブラリで正規の BIP-340 Schnorr 署名を使用し、署名検証を無効化せずにテストを実施しています。
E2E テストの実行:
deno task example # 全 E2E テスト実行
deno task test:all # ユニットテスト + E2E テスト対応NIP
| NIP | 内容 | 対応状況 |
| ------ | ------------------------------------- | ---------------------------------------------------------------------------------- |
| NIP-01 | Basic Protocol | EVENT, REQ, CLOSE, EOSE, OK, CLOSED, NOTICE + Event Treatment + Addressable Events |
| NIP-04 | Encrypted DM ⚠️ deprecated (→ NIP-17) | EventBuilder テンプレート(NIP-17 への移行推奨) |
| NIP-09 | Event Deletion | kind:5 削除リクエスト処理 |
| NIP-10 | Reply Threading | EventBuilder e/p タグ |
| NIP-11 | Relay Information | setInfo/getInfo + fetch インターセプト |
| NIP-17 | Private Direct Messages | EventBuilder テンプレート(chatMessage/seal/giftWrap/dmRelayList) |
| NIP-18 | Reposts | EventBuilder テンプレート(repost/genericRepost) |
| NIP-23 | Long-form Content | EventBuilder テンプレート(longFormContent/longFormDraft) |
| NIP-25 | Reactions | EventBuilder withReactions / externalReaction |
| NIP-29 | Relay-based Groups | EventBuilder テンプレート |
| NIP-30 | Custom Emoji | EventBuilder emoji タグ |
| NIP-40 | Expiration Timestamp | EventBuilder withExpiration() |
| NIP-42 | AUTH | チャレンジ/レスポンス |
| NIP-45 | COUNT | COUNT メッセージ対応 |
| NIP-50 | Search | content 部分一致検索 |
| NIP-51 | Lists | EventBuilder テンプレート(muteList/pinList/bookmarks/followSet等) |
| NIP-52 | Calendar Events | EventBuilder テンプレート(全4種対応) |
| NIP-57 | Lightning Zaps | EventBuilder テンプレート |
| NIP-65 | Relay List Metadata | EventBuilder relayList(kind:10002) |
Note: 旧 NIP-16 (Event Treatment) および旧 NIP-33 (Parameterized Replaceable Events) は現在 NIP-01 に統合されています。本ライブラリの Regular/Replaceable/Ephemeral/Addressable イベント処理は NIP-01 対応の一部です。
ドキュメント
| ドキュメント | 内容 | | ----------------------------------------------------------------------------------- | -------------------------- | | API リファレンス | 全クラス・関数・型の詳細 | | チュートリアル | ステップバイステップガイド | | 使用例集 | 実践的な使用例(14例) | | テストパターン | よくあるテストシナリオ | | ベストプラクティス | テスト設計の指針 | | トラブルシューティング | よくあるエラーと解決方法 | | FAQ | よくある質問(17問) | | NIP 対応状況 | NIP ごとの対応・使用例 | | パフォーマンス | 大量データの最適化 |
ライセンス
MIT
