import { Channel } from "@nostr-fetch/kernel/channel"; import type { EnsureRelaysOptions, FetchTillEoseOptions, NostrFetcherBackend, NostrFetcherCommonOptions, } from "@nostr-fetch/kernel/fetcherBackend"; import { type Filter, type NostrEvent, generateSubId } from "@nostr-fetch/kernel/nostr"; import { emptyAsyncGen, normalizeRelayUrl, normalizeRelayUrlSet, withTimeout, } from "@nostr-fetch/kernel/utils"; import { type FakeEventsSpec, generateFakeEvents } from "@nostr-fetch/testutil/fakeEvent"; import { NostrFetcher } from "../fetcher"; import { type RelayCapabilityChecker, compareNostrEvents } from "../fetcherHelper"; import { setTimeout as delay } from "node:timers/promises"; /** * Faking NostrFetcherBackend and RelayCapabilityChecker for testing NostrFetcher */ export type FakeRelaySpec = { eventsSpec: FakeEventsSpec[]; connectable?: boolean; connectDurMs?: number; supportedNips?: number[]; sendEventInterval?: number; }; const matchEvent = (ev: NostrEvent, f: Filter): boolean => { if (f.ids !== undefined && !f.ids.includes(ev.id)) { return false; } if (f.authors !== undefined && !f.authors.includes(ev.pubkey)) { return false; } if (f.kinds !== undefined && !f.kinds.includes(ev.kind)) { return false; } if (f.search !== undefined && !ev.content.includes(f.search)) { return false; } if (f.since !== undefined && ev.created_at < f.since) { return false; } if (f.until !== undefined && ev.created_at > f.until) { return false; } return true; }; class FakeRelay { #spec: Required; #events: NostrEvent[] = []; #subs: Set = new Set(); constructor(spec: FakeRelaySpec) { this.#spec = { ...{ connectable: true, connectDurMs: 0, supportedNips: [], sendEventInterval: 0, exclusiveInterval: false, }, ...spec, }; const evs = spec.eventsSpec.flatMap((spec) => generateFakeEvents(spec)); this.#events = evs.sort(compareNostrEvents); } async connect(): Promise { if (this.#spec.connectDurMs > 0) { await delay(this.#spec.connectDurMs); } if (this.#spec.connectable) { return Promise.resolve(); } return Promise.reject(Error("failed to connect to the relay")); } req( filter: Filter, subId: string, onEvent: (ev: NostrEvent) => Promise, onEose: () => void, ): () => void { this.#subs.add(subId); (async () => { let n = 0; for (const ev of this.#events) { if (!this.#subs.has(subId)) { return; } if (n >= (filter.limit ?? 500)) { onEose(); return; } if (filter.since !== undefined && ev.created_at < filter.since) { onEose(); return; } if (matchEvent(ev, filter)) { if (this.#spec.sendEventInterval > 0) { await delay(this.#spec.sendEventInterval); } await onEvent(ev); n++; } } // events exhausted onEose(); })(); return () => { this.#subs.delete(subId); }; } close(subId: string): void { this.#subs.delete(subId); } } class FakeFetcherBackend implements NostrFetcherBackend { #mapFakeRelay: Map = new Map(); #calledShutdown = false; constructor(relaySpecs: Map) { this.#mapFakeRelay = new Map( [...relaySpecs.entries()].map(([rurl, spec]) => [rurl, new FakeRelay(spec)]), ); } async ensureRelays(relayUrls: string[], options: EnsureRelaysOptions): Promise { const connected: string[] = []; const normalizedUrls = normalizeRelayUrlSet(relayUrls); await Promise.all( normalizedUrls.map(async (rurl) => { const relay = this.#mapFakeRelay.get(rurl); if (relay === undefined) { console.error("relay not found"); return; } try { await withTimeout(relay.connect(), options.connectTimeoutMs, "connection timed out"); connected.push(rurl); } catch (err) { console.error(err); } }), ); return connected; } fetchTillEose( relayUrl: string, filter: Filter, options: FetchTillEoseOptions, ): AsyncIterable { const relay = this.#mapFakeRelay.get(normalizeRelayUrl(relayUrl)); if (relay === undefined) { return emptyAsyncGen(); } const [tx, iter] = Channel.make(); const onEvent = async (ev: NostrEvent) => { if (options.skipVerification || (await options.eventVerifier(ev))) { tx.send(ev); } }; const onEose = () => { tx.close(); }; const unsub = relay.req(filter, generateSubId(), onEvent, onEose); const abortSub = () => { unsub(); tx.close(); }; // auto abortion let subAutoAbortTimer: ReturnType | undefined; const clearTimer = () => { if (subAutoAbortTimer !== undefined) { clearTimeout(subAutoAbortTimer); subAutoAbortTimer = undefined; } }; const resetTimer = () => { if (subAutoAbortTimer !== undefined) { clearTimeout(subAutoAbortTimer); subAutoAbortTimer = undefined; } subAutoAbortTimer = setTimeout(() => abortSub(), options.abortSubBeforeEoseTimeoutMs); }; resetTimer(); // initiate subscription auto abortion timer // handle abortion by AbortController if (options.signal?.aborted) { abortSub(); clearTimer(); } options.signal?.addEventListener("abort", () => { abortSub(); clearTimer(); }); return iter; } shutdown(): void { this.#calledShutdown = true; } get calledShutdown(): boolean { return this.#calledShutdown; } } class FakeRelayCapChecker implements RelayCapabilityChecker { #mapSupportedNips: Map>; constructor(relaySpecs: Map) { this.#mapSupportedNips = new Map(); for (const [rurl, spec] of relaySpecs) { this.#mapSupportedNips.set(rurl, new Set(spec.supportedNips ?? [])); } } async relaySupportsNips(relayUrl: string, requiredNips: number[]): Promise { const supportSet = this.#mapSupportedNips.get(normalizeRelayUrl(relayUrl)); return supportSet !== undefined && requiredNips.every((nip) => supportSet.has(nip)); } } export class FakedFetcherBuilder { #mapFakeRelaySpec: Map = new Map(); addRelay(relayUrl: string, spec: FakeRelaySpec): FakedFetcherBuilder { this.#mapFakeRelaySpec.set(normalizeRelayUrl(relayUrl), spec); return this; } buildFetcher(opts: NostrFetcherCommonOptions = {}): NostrFetcher { const backend = () => new FakeFetcherBackend(this.#mapFakeRelaySpec); const capChecker = () => new FakeRelayCapChecker(this.#mapFakeRelaySpec); return NostrFetcher.withCustomPool(backend, opts, capChecker); } }