Skip to content

はじめに

繋ぎ屋 (tsunagiya) は Nostr リレーのモックライブラリです。globalThis.WebSocket を差し替えることで、既存の Nostr クライアントコードを一切変更せずにテストできます。

インストール

Deno:

bash
deno add jsr:@ikuradon/tsunagiya

npm:

bash
npm install @ikuradon/tsunagiya

JSR (Node.js / Bun):

bash
npx jsr add @ikuradon/tsunagiya

基本的な使い方

typescript
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?): MockRelayMockRelay を登録・取得する
install(): voidglobalThis.WebSocket を MockWebSocket に差し替える
uninstall(): void元の WebSocket を復元する
reset(): void全リレーの状態をリセットする
connections: Map<string, number> (readonly)現在のアクティブ接続一覧
installed: boolean (readonly)install 済みかどうか
[Symbol.dispose](): voidusing 構文用。install 済みなら uninstall() を呼ぶ
[Symbol.asyncDispose](): Promise<void>await using 構文用。同上
typescript
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");

pool.install(); // WebSocket差し替え
pool.uninstall(); // 元に戻す
pool.reset(); // 全リレーの状態をリセット
pool.connections; // アクティブ接続一覧 (Map<string, number>)

複数リレーの使い方:

typescript
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 単位で動作する仮想リレー。

プロパティ

プロパティ説明
urlstring (readonly)リレーURL
optionsMockRelayOptions (readonly)リレーオプション
receivedClientMessage[] (readonly)全受信メッセージ
connectionCountnumber (readonly)アクティブ接続数
errorsReadonlyArray<string> (readonly)発生したエラーレスポンスのログ
deletedIdsReadonlySet<string> (readonly)削除済みイベントID (NIP-09)
loggerLogger | null (readonly)ロガーインスタンス
authResultsReadonlyArray<{ eventId: string; accepted: boolean; message: string }> (readonly)AUTH認証結果のログ

サブスクリプション管理

メソッド戻り値説明
getSubscriptions()ReadonlyMap<string, ReadonlyArray<NostrFilter>>アクティブなサブスクリプション一覧
clearOlderThan(timestamp: number)number指定タイムスタンプより古いイベントを削除
broadcast(event: NostrEvent)voidイベントをアクティブなサブスクリプションに配信

NIP-11 リレー情報

メソッドシグネチャ説明
setInfosetInfo(info: Partial<RelayInformation>): voidリレー情報を設定
getInfogetInfo(): RelayInformationリレー情報を取得

使い方

イベントの登録とカスタムハンドラー:

typescript
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, ""];
});

不安定リレーのシミュレート:

typescript
pool.relay("wss://unstable.relay.test", {
  latency: { min: 100, max: 2000 },
  errorRate: 0.3,
  disconnectRate: 0.1,
  connectionTimeout: 5000,
});

エラーケーステスト:

typescript
relay.refuse(); // 接続拒否
relay.disconnect(); // 全接続を即座に切断
relay.disconnectAfter(3000); // 3秒後に切断
relay.close(1006); // 特定クローズコードで切断
relay.sendRaw("not json"); // 不正データ送信
relay.sendNotice("rate-limited"); // NOTICE送信

NIP-42 AUTH:

typescript
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,
  );
});

検証ヘルパー:

typescript
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 からインポートします。

typescript
import {
  assertReceivedREQ,
  EventBuilder,
  FilterBuilder,
  restore,
  snapshot,
  streamEvents,
  waitFor,
} from "@ikuradon/tsunagiya/testing";

EventBuilder の使用例:

typescript
// ビルダーパターンでイベント生成
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 の使用例:

typescript
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" }

アサーションヘルパー:

typescript
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"));

リアルタイムストリーム:

typescript
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();

条件待ちヘルパー:

typescript
import { waitFor } from "@ikuradon/tsunagiya/testing";

// 条件が満たされるまでポーリングで待機(固定 setTimeout の代替)
await waitFor(() => received.length >= 3);

// タイムアウト・ポーリング間隔のカスタマイズ
await waitFor(() => relay.connectionCount === 0, {
  timeout: 3000,
  interval: 20,
});

スナップショット:

typescript
import { restore, snapshot } from "@ikuradon/tsunagiya/testing";

const snap = snapshot(relay);
// ... 操作 ...
restore(relay, snap);

Vitest での使い方

npm パッケージとしてインストールすれば、Vitest でそのまま使えます。

セットアップ

bash
npm install -D vitest
npm install @ikuradon/tsunagiya

vitest.config.ts は特別な設定不要ですが、環境は node を推奨します:

typescript
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
  },
});

jsdom / happy-dom 環境について jsdomhappy-dom は独自の

WebSocket モックを持つため、pool.install() による globalThis.WebSocket の差し替えと競合する可能性があります。environment: 'node' の使用を推奨します。

テストの書き方

typescript
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 はポーリングベースで条件が満たされるまで待機します:

typescript
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 で全接続が閉じるまで確実に待機できます:

typescript
import { waitFor } from "@ikuradon/tsunagiya/testing";

afterEach(async () => {
  rxNostr.dispose();
  // 全接続が閉じるまで待つ
  await waitFor(() => relay.connectionCount === 0);
  pool.uninstall();
});

これにより、テスト間の非同期リークによるフレーキーテストを防止できます。

テスト支援ヘルパーの活用

@ikuradon/tsunagiya/testing のヘルパーも Vitest でそのまま使えます:

typescript
import {
  assertEventPublished,
  assertReceivedREQ,
} from "@ikuradon/tsunagiya/testing";

// アサーションヘルパー(throw Error ベースなので Vitest 互換)
assertReceivedREQ(relay, { kinds: [1] });
assertEventPublished(relay, event.id);

対応 NIP

NIP内容対応状況
NIP-01Basic ProtocolEVENT, REQ, CLOSE, EOSE, OK, CLOSED, NOTICE + Event Treatment + Addressable Events
NIP-04Encrypted DM ⚠️ deprecated (→ NIP-17)EventBuilder テンプレート(NIP-17 への移行推奨)
NIP-09Event Deletionkind:5 削除リクエスト処理
NIP-10Reply ThreadingEventBuilder e/p タグ
NIP-11Relay InformationsetInfo/getInfo + fetch インターセプト
NIP-17Private Direct MessagesEventBuilder テンプレート(chatMessage/seal/giftWrap/dmRelayList)
NIP-18RepostsEventBuilder テンプレート(repost/genericRepost)
NIP-23Long-form ContentEventBuilder テンプレート(longFormContent/longFormDraft)
NIP-25ReactionsEventBuilder withReactions / externalReaction
NIP-29Relay-based GroupsEventBuilder テンプレート
NIP-30Custom EmojiEventBuilder emoji タグ
NIP-40Expiration TimestampEventBuilder withExpiration()
NIP-42AUTHチャレンジ/レスポンス
NIP-45COUNTCOUNT メッセージ対応
NIP-50Searchcontent 部分一致検索
NIP-51ListsEventBuilder テンプレート(muteList/pinList/bookmarks/followSet等)
NIP-52Calendar EventsEventBuilder テンプレート(全4種対応: Date/Time/Collection/RSVP)
NIP-57Lightning ZapsEventBuilder テンプレート
NIP-65Relay List MetadataEventBuilder 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-toolsdeno task example:nostr-toolsSimplePool での REQ/EVENT 処理
NDKdeno task example:ndkNDK インスタンス経由のイベント取得・投稿
rx-nostrdeno task example:rx-nostrRxNostr の Reactive API(createRxNostr / use)
nostr-fetchdeno task example:nostr-fetchNostrFetcher によるイベント取得(fetch / iterator)
bash
deno task example             # 全 E2E テスト実行
deno task test:all            # ユニットテスト + E2E テスト

次のステップ

MIT License