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:
deno add jsr:@ikuradon/tsunagiyanpm:
npm install @ikuradon/tsunagiyaJSR (Node.js / Bun):
npx jsr add @ikuradon/tsunagiyaBasic Usage
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/Property | Description |
|---|---|
relay(url, options?): MockRelay | Register and retrieve a MockRelay |
install(): void | Replace globalThis.WebSocket with MockWebSocket |
uninstall(): void | Restore the original WebSocket |
reset(): void | Reset the state of all relays |
connections: Map<string, number> (readonly) | Current active connections |
installed: boolean (readonly) | Whether install has been called |
[Symbol.dispose](): void | For using syntax. Calls uninstall() if installed |
[Symbol.asyncDispose](): Promise<void> | For await using syntax. Same as above |
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:
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
| Property | Type | Description |
|---|---|---|
url | string (readonly) | Relay URL |
options | MockRelayOptions (readonly) | Relay options |
received | ClientMessage[] (readonly) | All received messages |
connectionCount | number (readonly) | Number of active connections |
errors | ReadonlyArray<string> (readonly) | Log of error responses that occurred |
deletedIds | ReadonlySet<string> (readonly) | Deleted event IDs (NIP-09) |
logger | Logger | null (readonly) | Logger instance |
authResults | ReadonlyArray<{ eventId: string; accepted: boolean; message: string }> (readonly) | AUTH authentication result log |
Subscription Management
| Method | Return Type | Description |
|---|---|---|
getSubscriptions() | ReadonlyMap<string, ReadonlyArray<NostrFilter>> | List of active subscriptions |
clearOlderThan(timestamp: number) | number | Delete events older than the specified timestamp |
broadcast(event: NostrEvent) | void | Deliver event to active subscriptions |
NIP-11 Relay Information
| Method | Signature | Description |
|---|---|---|
setInfo | setInfo(info: Partial<RelayInformation>): void | Set relay information |
getInfo | getInfo(): RelayInformation | Get relay information |
Usage
Event registration and custom handlers:
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:
pool.relay("wss://unstable.relay.test", {
latency: { min: 100, max: 2000 },
errorRate: 0.3,
disconnectRate: 0.1,
connectionTimeout: 5000,
});Error case testing:
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 NOTICENIP-42 AUTH:
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:
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 connectionsTest Helpers
Import from @ikuradon/tsunagiya/testing.
import {
assertReceivedREQ,
EventBuilder,
FilterBuilder,
restore,
snapshot,
streamEvents,
waitFor,
} from "@ikuradon/tsunagiya/testing";EventBuilder usage:
// 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:
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:
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:
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:
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:
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
npm install -D vitest
npm install @ikuradon/tsunagiyaNo special vitest.config.ts configuration is needed, but the node environment is recommended:
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
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:
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:
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:
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
| NIP | Description | Support Status |
|---|---|---|
| NIP-01 | Basic Protocol | EVENT, REQ, CLOSE, EOSE, OK, CLOSED, NOTICE + Event Treatment + Addressable Events |
| NIP-04 | Encrypted DM ⚠️ deprecated (→ NIP-17) | EventBuilder template (migration to NIP-17 recommended) |
| NIP-09 | Event Deletion | kind:5 deletion request handling |
| NIP-10 | Reply Threading | EventBuilder e/p tags |
| NIP-11 | Relay Information | setInfo/getInfo + fetch interception |
| NIP-17 | Private Direct Messages | EventBuilder templates (chatMessage/seal/giftWrap/dmRelayList) |
| NIP-18 | Reposts | EventBuilder templates (repost/genericRepost) |
| NIP-23 | Long-form Content | EventBuilder templates (longFormContent/longFormDraft) |
| NIP-25 | Reactions | EventBuilder withReactions / externalReaction |
| NIP-29 | Relay-based Groups | EventBuilder templates |
| NIP-30 | Custom Emoji | EventBuilder emoji tag |
| NIP-40 | Expiration Timestamp | EventBuilder withExpiration() |
| NIP-42 | AUTH | Challenge/response |
| NIP-45 | COUNT | COUNT message support |
| NIP-50 | Search | Content partial-match search |
| NIP-51 | Lists | EventBuilder templates (muteList/pinList/bookmarks/followSet, etc.) |
| NIP-52 | Calendar Events | EventBuilder templates (all 4 types: Date/Time/Collection/RSVP) |
| NIP-57 | Lightning Zaps | EventBuilder templates |
| NIP-65 | Relay List Metadata | EventBuilder 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.
| Library | Test Command | What is tested |
|---|---|---|
| nostr-tools | deno task example:nostr-tools | REQ/EVENT processing via SimplePool |
| NDK | deno task example:ndk | Event fetch/publish via NDK instance |
| rx-nostr | deno task example:rx-nostr | RxNostr Reactive API (createRxNostr / use) |
| nostr-fetch | deno task example:nostr-fetch | Event fetching via NostrFetcher (fetch / iterator) |
deno task example # Run all E2E tests
deno task test:all # Unit tests + E2E testsNext Steps
- Tutorial — Step-by-step guide
- Examples — Practical usage examples (14 examples)
- API Reference — Full API details