npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ikuradon/tsunagiya

v0.4.0

Published

Nostr relay mock library for testing. Intercepts WebSocket to test existing Nostr clients without code changes.

Readme

繋ぎ屋 (tsunagiya)

Nostr relay mock library for Deno/TypeScript.

globalThis.WebSocket を差し替えることで、既存のNostrクライアントコードを一切変更せずにテストできます。

インストール

Deno:

deno add jsr:@ikuradon/tsunagiya

npm:

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') で動作します。 jsdomhappy-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