Skip to content

Getting Started

tsunagiya is a Nostr relay mock library. By replacing globalThis.WebSocket, you can test existing Nostr client code without any modifications.

Installation

Deno:

bash
deno add jsr:@ikuradon/tsunagiya

npm:

bash
npm install @ikuradon/tsunagiya

JSR (Node.js / Bun):

bash
npx jsr add @ikuradon/tsunagiya

Basic Usage

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");
    // ... existing client code runs without modification
  } finally {
    pool.uninstall();
  }
});

Features

  • Full WebSocket interception mock
  • Multiple relay support
  • NIP-01 filter auto-matching + custom handlers
  • Unstable relay simulation (latency, error rate, disconnection)
  • NIP-42 AUTH challenge/response
  • Sent message recording and verification helpers
  • NIP-01 automatic event type handling (Regular/Replaceable/Ephemeral/Addressable)
  • NIP-09 Event Deletion Request
  • NIP-45 COUNT message support
  • NIP-50 search filter support
  • Test helpers (EventBuilder, FilterBuilder, assertions)
  • Real-time streaming and snapshots
  • Logging (console / custom handler)
  • Test framework agnostic
  • Zero external dependencies
  • E2E testing support (nostr-tools, NDK, rx-nostr, nostr-fetch)

MockPool

The main entry point for testing. Manages multiple MockRelay instances and replaces globalThis.WebSocket.

Method/PropertyDescription
relay(url, options?): MockRelayRegister and retrieve a MockRelay
install(): voidReplace globalThis.WebSocket with MockWebSocket
uninstall(): voidRestore the original WebSocket
reset(): voidReset the state of all relays
connections: Map<string, number> (readonly)Current active connections
installed: boolean (readonly)Whether install has been called
[Symbol.dispose](): voidFor using syntax. Calls uninstall() if installed
[Symbol.asyncDispose](): Promise<void>For await using syntax. Same as above
typescript
const pool = new MockPool();
const relay = pool.relay("wss://relay.example.com");

pool.install(); // replace WebSocket
pool.uninstall(); // restore original
pool.reset(); // reset state of all relays
pool.connections; // active connection list (Map<string, number>)

Multiple relay usage:

typescript
const pool = new MockPool();

// register multiple relays (each operates independently)
const relay1 = pool.relay("wss://relay1.example.com");
const relay2 = pool.relay("wss://relay2.example.com");
const relay3 = pool.relay("wss://relay3.example.com");

// register different events per relay
relay1.store(event1);
relay2.store(event2);
relay3.store(event3);

// different settings per relay are also possible
const fastRelay = pool.relay("wss://fast.relay.test", { latency: 10 });
const slowRelay = pool.relay("wss://slow.relay.test", { latency: 500 });

pool.install();
try {
  // client code connecting to multiple relays simultaneously works as-is
  const ws1 = new WebSocket("wss://relay1.example.com");
  const ws2 = new WebSocket("wss://relay2.example.com");
  const ws3 = new WebSocket("wss://relay3.example.com");
  // ... client logic under test
} finally {
  pool.uninstall();
}

Note: Attempting to connect to a URL not registered with pool.relay() will result in a connection failure (error event + close event code:1006). This matches the behavior of failing to connect to a real relay.

MockRelay

A virtual relay that operates per URL.

Properties

PropertyTypeDescription
urlstring (readonly)Relay URL
optionsMockRelayOptions (readonly)Relay options
receivedClientMessage[] (readonly)All received messages
connectionCountnumber (readonly)Number of active connections
errorsReadonlyArray<string> (readonly)Log of error responses that occurred
deletedIdsReadonlySet<string> (readonly)Deleted event IDs (NIP-09)
loggerLogger | null (readonly)Logger instance
authResultsReadonlyArray<{ eventId: string; accepted: boolean; message: string }> (readonly)AUTH authentication result log

Subscription Management

MethodReturn TypeDescription
getSubscriptions()ReadonlyMap<string, ReadonlyArray<NostrFilter>>List of active subscriptions
clearOlderThan(timestamp: number)numberDelete events older than the specified timestamp
broadcast(event: NostrEvent)voidDeliver event to active subscriptions

NIP-11 Relay Information

MethodSignatureDescription
setInfosetInfo(info: Partial<RelayInformation>): voidSet relay information
getInfogetInfo(): RelayInformationGet relay information

Usage

Event registration and custom handlers:

typescript
const relay = pool.relay("wss://relay.example.com");

// pre-register events (auto-matched on REQ)
relay.store(event);

// customize REQ handler
relay.onREQ((subId, filters) => {
  return [customEvent];
});

// customize EVENT handler
relay.onEVENT((event) => {
  return ["OK", event.id, true, ""];
});

Simulating unstable relays:

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

Error case testing:

typescript
relay.refuse(); // refuse connections
relay.disconnect(); // immediately disconnect all connections
relay.disconnectAfter(3000); // disconnect after 3 seconds
relay.close(1006); // disconnect with specific close code
relay.sendRaw("not json"); // send invalid data
relay.sendNotice("rate-limited"); // send NOTICE

NIP-42 AUTH:

typescript
const relay = pool.relay("wss://auth.relay.test", {
  requiresAuth: true,
});

// Default validation (no validator set): auto-checks relay URL match
// Custom validator: access context.relayUrl / context.challenge
relay.requireAuth((authEvent, context) => {
  return authEvent.tags.some(
    (t) => t[0] === "relay" && t[1] === context.relayUrl,
  );
});

Verification helpers:

typescript
relay.received; // all received messages
relay.findREQ("sub1"); // find REQ
relay.countREQs(); // count REQs
relay.hasREQ("sub1"); // check if REQ exists
relay.findEvent("id1"); // find EVENT
relay.countEvents(); // count EVENTs
relay.hasEvent("id1"); // check if EVENT exists
relay.findCLOSE("sub1"); // find CLOSE
relay.connectionCount; // number of active connections

Test Helpers

Import from @ikuradon/tsunagiya/testing.

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

EventBuilder usage:

typescript
// builder pattern
const event = EventBuilder.kind1()
  .content("hello world")
  .tag("p", pubkey)
  .build();

// random generation
const random = EventBuilder.random({ kind: 1 });

// broken event
const broken = EventBuilder.kind1()
  .corrupt({ id: true, sig: true })
  .build();

// bulk generation
const events = EventBuilder.bulk(100, { kind: 1 });

// time series data
const timeline = EventBuilder.timeline(50, {
  kind: 1,
  interval: 60,
  startTime: 1700000000,
});

// reply chain
const thread = EventBuilder.thread(5);

// with reactions
const [post, reactions] = EventBuilder.withReactions(3);

// NIP-specific templates
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 usage:

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

Assertion helpers:

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

Real-time stream:

typescript
import { startStream, streamEvents } from "@ikuradon/tsunagiya/testing";

// deliver events with time delay
const handle = streamEvents(relay, events, {
  interval: 100,
  jitter: 50,
});
handle.stop();

// continuous stream
const stream = startStream(relay, {
  eventGenerator: () => EventBuilder.random({ kind: 1 }),
  interval: 1000,
  count: 10,
});
stream.stop();

Condition waiting helper:

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

// Poll until a condition is met (alternative to fixed setTimeout)
await waitFor(() => received.length >= 3);

// Custom timeout and polling interval
await waitFor(() => relay.connectionCount === 0, {
  timeout: 3000,
  interval: 20,
});

Snapshot:

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

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

Using with Vitest

Install as an npm package and use directly with Vitest.

Setup

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

No special vitest.config.ts configuration is needed, but the node environment is recommended:

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

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

About jsdom / happy-dom environments jsdom and happy-dom have

their own WebSocket mocks, which may conflict with pool.install()'s replacement of globalThis.WebSocket. Using environment: 'node' is recommended. :::

Writing Tests

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

Condition Waiting Helper (waitFor)

Fixed setTimeout waits cause flaky tests in CI environments. waitFor polls until a condition is met:

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

// Wait until 3 events are received (instead of fixed setTimeout)
await waitFor(() => received.length >= 3);

// Custom timeout and interval
await waitFor(() => relay.connectionCount === 0, {
  timeout: 3000,
  interval: 20,
});

Waiting for Async Cleanup

Libraries like rx-nostr may close WebSocket connections asynchronously after dispose(). Use waitFor to reliably wait until all connections are closed:

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

afterEach(async () => {
  rxNostr.dispose();
  // Wait until all connections are closed
  await waitFor(() => relay.connectionCount === 0);
  pool.uninstall();
});

This prevents flaky tests caused by async leaks between test cases.

Using Test Helpers

The helpers from @ikuradon/tsunagiya/testing work with Vitest as-is:

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

// Assertion helpers are throw-based, so they're Vitest-compatible
assertReceivedREQ(relay, { kinds: [1] });
assertEventPublished(relay, event.id);

Supported NIPs

NIPDescriptionSupport Status
NIP-01Basic ProtocolEVENT, REQ, CLOSE, EOSE, OK, CLOSED, NOTICE + Event Treatment + Addressable Events
NIP-04Encrypted DM ⚠️ deprecated (→ NIP-17)EventBuilder template (migration to NIP-17 recommended)
NIP-09Event Deletionkind:5 deletion request handling
NIP-10Reply ThreadingEventBuilder e/p tags
NIP-11Relay InformationsetInfo/getInfo + fetch interception
NIP-17Private Direct MessagesEventBuilder templates (chatMessage/seal/giftWrap/dmRelayList)
NIP-18RepostsEventBuilder templates (repost/genericRepost)
NIP-23Long-form ContentEventBuilder templates (longFormContent/longFormDraft)
NIP-25ReactionsEventBuilder withReactions / externalReaction
NIP-29Relay-based GroupsEventBuilder templates
NIP-30Custom EmojiEventBuilder emoji tag
NIP-40Expiration TimestampEventBuilder withExpiration()
NIP-42AUTHChallenge/response
NIP-45COUNTCOUNT message support
NIP-50SearchContent partial-match search
NIP-51ListsEventBuilder templates (muteList/pinList/bookmarks/followSet, etc.)
NIP-52Calendar EventsEventBuilder templates (all 4 types: Date/Time/Collection/RSVP)
NIP-57Lightning ZapsEventBuilder templates
NIP-65Relay List MetadataEventBuilder relayList (kind:10002)

Note: The former NIP-16 (Event Treatment) and NIP-33 (Parameterized Replaceable Events) have been merged into NIP-01. Regular/Replaceable/Ephemeral/Addressable event handling in this library is part of NIP-01 support.

E2E Testing Support

tsunagiya verifies compatibility with the following major Nostr client libraries through E2E tests.

LibraryTest CommandWhat is tested
nostr-toolsdeno task example:nostr-toolsREQ/EVENT processing via SimplePool
NDKdeno task example:ndkEvent fetch/publish via NDK instance
rx-nostrdeno task example:rx-nostrRxNostr Reactive API (createRxNostr / use)
nostr-fetchdeno task example:nostr-fetchEvent fetching via NostrFetcher (fetch / iterator)
bash
deno task example             # Run all E2E tests
deno task test:all            # Unit tests + E2E tests

Next Steps

MIT License