はじめに
繋ぎ屋 (tsunagiya) は Nostr リレーのモックライブラリです。globalThis.WebSocket を差し替えることで、既存の Nostr クライアントコードを一切変更せずにテストできます。
インストール
Deno:
deno add jsr:@ikuradon/tsunagiyanpm:
npm install @ikuradon/tsunagiyaJSR (Node.js / Bun):
npx jsr add @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-45 COUNT メッセージ対応
- NIP-50 検索フィルター対応
- テスト支援ヘルパー(EventBuilder, FilterBuilder, assertions)
- リアルタイムストリーム・スナップショット
- ログ機能(console / カスタムハンドラー)
- テストフレームワーク非依存
- 外部依存ゼロ
- E2Eテスト対応(nostr-tools, NDK, rx-nostr, nostr-fetch)
MockPool
テストのエントリポイント。複数の MockRelay を管理し、globalThis.WebSocket を差し替えます。
| メソッド/プロパティ | 説明 |
|---|---|
relay(url, options?): MockRelay | MockRelay を登録・取得する |
install(): void | globalThis.WebSocket を MockWebSocket に差し替える |
uninstall(): void | 元の WebSocket を復元する |
reset(): void | 全リレーの状態をリセットする |
connections: Map<string, number> (readonly) | 現在のアクティブ接続一覧 |
installed: boolean (readonly) | install 済みかどうか |
[Symbol.dispose](): void | using 構文用。install 済みなら uninstall() を呼ぶ |
[Symbol.asyncDispose](): Promise<void> | await using 構文用。同上 |
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 単位で動作する仮想リレー。
プロパティ
| プロパティ | 型 | 説明 |
|---|---|---|
url | string (readonly) | リレーURL |
options | MockRelayOptions (readonly) | リレーオプション |
received | ClientMessage[] (readonly) | 全受信メッセージ |
connectionCount | number (readonly) | アクティブ接続数 |
errors | ReadonlyArray<string> (readonly) | 発生したエラーレスポンスのログ |
deletedIds | ReadonlySet<string> (readonly) | 削除済みイベントID (NIP-09) |
logger | Logger | null (readonly) | ロガーインスタンス |
authResults | ReadonlyArray<{ eventId: string; accepted: boolean; message: string }> (readonly) | AUTH認証結果のログ |
サブスクリプション管理
| メソッド | 戻り値 | 説明 |
|---|---|---|
getSubscriptions() | ReadonlyMap<string, ReadonlyArray<NostrFilter>> | アクティブなサブスクリプション一覧 |
clearOlderThan(timestamp: number) | number | 指定タイムスタンプより古いイベントを削除 |
broadcast(event: NostrEvent) | void | イベントをアクティブなサブスクリプションに配信 |
NIP-11 リレー情報
| メソッド | シグネチャ | 説明 |
|---|---|---|
setInfo | setInfo(info: Partial<RelayInformation>): void | リレー情報を設定 |
getInfo | getInfo(): RelayInformation | リレー情報を取得 |
使い方
イベントの登録とカスタムハンドラー:
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 URL 一致を自動確認
// カスタムバリデーター: context.relayUrl / context.challenge を参照可能
relay.requireAuth((authEvent, context) => {
return authEvent.tags.some(
(t) => t[0] === "relay" && t[1] === context.relayUrl,
);
});検証ヘルパー:
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; // アクティブ接続数テスト支援ヘルパー
@ikuradon/tsunagiya/testing からインポートします。
import {
assertReceivedREQ,
EventBuilder,
FilterBuilder,
restore,
snapshot,
streamEvents,
waitFor,
} 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 { 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 { waitFor } from "@ikuradon/tsunagiya/testing";
// 条件が満たされるまでポーリングで待機(固定 setTimeout の代替)
await waitFor(() => received.length >= 3);
// タイムアウト・ポーリング間隔のカスタマイズ
await waitFor(() => relay.connectionCount === 0, {
timeout: 3000,
interval: 20,
});スナップショット:
import { restore, snapshot } from "@ikuradon/tsunagiya/testing";
const snap = snapshot(relay);
// ... 操作 ...
restore(relay, snap);Vitest での使い方
npm パッケージとしてインストールすれば、Vitest でそのまま使えます。
セットアップ
npm install -D vitest
npm install @ikuradon/tsunagiyavitest.config.ts は特別な設定不要ですが、環境は node を推奨します:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});jsdom / happy-dom 環境について jsdom や happy-dom は独自の
WebSocket モックを持つため、pool.install() による globalThis.WebSocket の差し替えと競合する可能性があります。environment: 'node' の使用を推奨します。
テストの書き方
import { afterEach, describe, expect, it } from "vitest";
import { MockPool } from "@ikuradon/tsunagiya";
import { EventBuilder } from "@ikuradon/tsunagiya/testing";
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");
const event = EventBuilder.kind1().content("hello nostr").build();
relay.store(event);
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("hello nostr"))).toBe(true);
ws.close();
});
it("should publish events", async () => {
pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");
pool.install();
const ws = new WebSocket("wss://relay.example.com");
await new Promise<void>((resolve) => {
ws.onopen = () => resolve();
});
const event = EventBuilder.kind1().content("test post").build();
ws.send(JSON.stringify(["EVENT", event]));
await new Promise((r) => setTimeout(r, 50));
expect(relay.hasEvent(event.id)).toBe(true);
ws.close();
});
});条件待ちヘルパー(waitFor)
固定時間の setTimeout 待ちは CI 環境でフレーキーテストの原因になります。waitFor はポーリングベースで条件が満たされるまで待機します:
import { waitFor } from "@ikuradon/tsunagiya/testing";
// 3件のイベントが届くまで待つ(固定 setTimeout の代わりに)
await waitFor(() => received.length >= 3);
// タイムアウト・間隔のカスタマイズ
await waitFor(() => relay.connectionCount === 0, {
timeout: 3000,
interval: 20,
});非同期クリーンアップの待機
rx-nostr 等のライブラリは dispose() 後も内部で非同期的に WebSocket を閉じることがあります。waitFor で全接続が閉じるまで確実に待機できます:
import { waitFor } from "@ikuradon/tsunagiya/testing";
afterEach(async () => {
rxNostr.dispose();
// 全接続が閉じるまで待つ
await waitFor(() => relay.connectionCount === 0);
pool.uninstall();
});これにより、テスト間の非同期リークによるフレーキーテストを防止できます。
テスト支援ヘルパーの活用
@ikuradon/tsunagiya/testing のヘルパーも Vitest でそのまま使えます:
import {
assertEventPublished,
assertReceivedREQ,
} from "@ikuradon/tsunagiya/testing";
// アサーションヘルパー(throw Error ベースなので Vitest 互換)
assertReceivedREQ(relay, { kinds: [1] });
assertEventPublished(relay, event.id);対応 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種対応: Date/Time/Collection/RSVP) |
| 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 対応の一部です。
E2Eテスト対応
繋ぎ屋は以下の主要 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) |
deno task example # 全 E2E テスト実行
deno task test:all # ユニットテスト + E2E テスト次のステップ
- チュートリアル — ステップバイステップガイド
- 使用例集 — 実践的な使用例(14例)
- API リファレンス — 全 API の詳細