nostr-fetch
Version:
A utility library that allows JS/TS apps to effortlessly fetch past events from Nostr relays
263 lines (226 loc) • 7.01 kB
text/typescript
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<FakeRelaySpec>;
#events: NostrEvent[] = [];
#subs: Set<string> = 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<void> {
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<void>,
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<string, FakeRelay> = new Map();
#calledShutdown = false;
constructor(relaySpecs: Map<string, FakeRelaySpec>) {
this.#mapFakeRelay = new Map(
[...relaySpecs.entries()].map(([rurl, spec]) => [rurl, new FakeRelay(spec)]),
);
}
async ensureRelays(relayUrls: string[], options: EnsureRelaysOptions): Promise<string[]> {
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<NostrEvent> {
const relay = this.#mapFakeRelay.get(normalizeRelayUrl(relayUrl));
if (relay === undefined) {
return emptyAsyncGen();
}
const [tx, iter] = Channel.make<NostrEvent>();
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<typeof setTimeout> | 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<string, Set<number>>;
constructor(relaySpecs: Map<string, FakeRelaySpec>) {
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<boolean> {
const supportSet = this.#mapSupportedNips.get(normalizeRelayUrl(relayUrl));
return supportSet !== undefined && requiredNips.every((nip) => supportSet.has(nip));
}
}
export class FakedFetcherBuilder {
#mapFakeRelaySpec: Map<string, FakeRelaySpec> = 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);
}
}