UNPKG

nostr-relaypool

Version:

A Nostr RelayPool implementation in TypeScript using only nostr-tools library as a dependency.

790 lines (746 loc) 21.7 kB
import type {Filter, Event} from "nostr-tools"; import {mergeSimilarAndRemoveEmptyFilters} from "./merge-similar-filters"; import {type Relay, relayInit, type Sub} from "./relay"; import type {OnEventObject, OnEvent} from "./on-event-filters"; import {EventCache} from "./event-cache"; import {EventObject} from "./event"; import { batchFiltersByRelay, groupFiltersByRelayAndEmitCacheHits, } from "./group-filters-by-relay"; import type {CallbackReplayer} from "./callback-replayer"; import {NewestEventCache} from "./newest-event-cache"; const unique = (arr: string[]) => [...new Set(arr)]; export {type OnEvent, type OnEventObject} from "./on-event-filters"; export type OnEose = (relayUrl: string, minCreatedAt: number) => void; export type OnEventAndMetadata = (event: Event, metadata: Event) => void; export type FilterToSubscribe = [ onEvent: OnEvent, filtersByRelay: Map<string, Filter[]>, unsub: {unsubcb?: () => void}, unsubscribeOnEose?: boolean, subscriptionCacheKey?: string, maxDelayms?: number ]; export type SubscriptionOptions = { allowDuplicateEvents?: boolean; allowOlderEvents?: boolean; logAllEvents?: boolean; unsubscribeOnEose?: boolean; defaultRelays?: string[]; dontSendOtherFilters?: boolean; }; function parseJSON(json: string | undefined) { if (json) { return JSON.parse(json); } } export class RelayPool { relayByUrl: Map<string, Relay> = new Map(); noticecbs: Array<(url: string, msg: string) => void> = []; errorcbs: Array<(url: string, err: string) => void> = []; authcbs: Array<(relay: Relay, challenge: string) => void> = []; eventCache?: EventCache; minMaxDelayms: number = Infinity; filtersToSubscribe: FilterToSubscribe[] = []; timer?: ReturnType<typeof setTimeout>; externalGetEventById?: (id: string) => Event | undefined; logSubscriptions?: boolean = false; autoReconnect?: boolean = false; startTime: number = new Date().getTime(); deleteSignatures?: boolean; subscriptionCache?: Map< string, CallbackReplayer<[Event, boolean, string | undefined], OnEvent> >; skipVerification?: boolean; writeRelays: NewestEventCache; metadataCache: NewestEventCache; contactListCache: NewestEventCache; logErrorsAndNotices?: boolean; errorsAndNoticesInterval: any; constructor( relays?: string[], options: { useEventCache?: boolean; externalGetEventById?: (id: string) => Event | undefined; logSubscriptions?: boolean; autoReconnect?: boolean; subscriptionCache?: boolean; deleteSignatures?: boolean; skipVerification?: boolean; logErrorsAndNotices?: boolean; } = {} ) { this.externalGetEventById = options.externalGetEventById; this.logSubscriptions = options.logSubscriptions; this.autoReconnect = options.autoReconnect; this.deleteSignatures = options.deleteSignatures; this.skipVerification = options.skipVerification; this.writeRelays = new NewestEventCache(10003, this, undefined, true); this.metadataCache = new NewestEventCache(0, this); this.contactListCache = new NewestEventCache(3, this); if (options.useEventCache) { this.eventCache = new EventCache(); } if (options.subscriptionCache) { this.subscriptionCache = new Map(); } if (relays) { for (const relay of unique(relays)) { this.addOrGetRelay(relay); } } this.logErrorsAndNotices = options.logErrorsAndNotices ?? true; this.onnotice((url, msg) => { this.errorsAndNotices.push({ type: "notice", url, msg, time: Date.now() - this.startTime, }); }); this.onerror((url, msg) => { this.errorsAndNotices.push({ type: "error", url, msg, time: Date.now() - this.startTime, }); }); this.errorsAndNoticesInterval = setInterval( () => this.#maybeLogErrorsAndNotices(), 1000 * 10 ); } errorsAndNotices: { type: string; url: string; msg: string | Error; time: number; }[] = []; #maybeLogErrorsAndNotices() { if (!this.errorsAndNotices.length) { return; } if (!this.logErrorsAndNotices) { this.errorsAndNotices = []; return; } if (this.errorsAndNotices.length > 5) { console.groupCollapsed( "RelayPool errors and notices with " + this.errorsAndNotices.length + " entries" ); } else { console.group("RelayPool errors and notices"); } console.table(this.errorsAndNotices.map((e) => ({...e, msg: e.msg}))); console.groupEnd(); this.errorsAndNotices = []; } addOrGetRelay(relay: string): Relay { const origRelayInstance = this.relayByUrl.get(relay); if (origRelayInstance) { return origRelayInstance; } const relayInstance = relayInit( relay, this.externalGetEventById ? this.externalGetEventById : this.eventCache ? (id) => this.eventCache?.getEventById(id) : undefined, this.autoReconnect ); this.relayByUrl.set(relay, relayInstance); relayInstance.connect().then( (onfulfilled) => { relayInstance?.on("notice", (msg: string) => { this.noticecbs.forEach((cb) => cb(relay, msg)); }); relayInstance?.on("auth", (msg: string) => { this.authcbs.forEach((cb) => cb(relayInstance, msg)); }); }, (onrejected) => { this.errorcbs.forEach((cb) => cb(relay, onrejected)); } ); return relayInstance; } async close() { const promises = []; for (const relayInstance of this.relayByUrl.values()) { promises.push(relayInstance.close()); } this.relayByUrl.clear(); clearInterval(this.errorsAndNoticesInterval); return Promise.all(promises); } removeRelay(url: string) { const relay = this.relayByUrl.get(url); if (relay) { relay.close(); this.relayByUrl.delete(url); } } #subscribeRelay( relay: string, filters: Filter[], onEvent: OnEvent, onEose?: OnEose, eventIds?: Set<string> ): Sub | undefined { const mergedAndRemovedEmptyFilters = mergeSimilarAndRemoveEmptyFilters(filters); if (mergedAndRemovedEmptyFilters.length === 0) { return; } const instance = this.addOrGetRelay(relay); const sub = instance.sub(mergedAndRemovedEmptyFilters, { skipVerification: this.skipVerification, eventIds, }); let afterEose = false; let minCreatedAt = Infinity; sub.on("event", (nostrEvent: Event) => { if (nostrEvent.created_at < minCreatedAt) { minCreatedAt = nostrEvent.created_at; } let event = nostrEvent; if (!this.deleteSignatures) { event.sig = nostrEvent.sig; } this.eventCache?.addEvent(event); onEvent(event, afterEose, relay); }); sub.on("eose", () => { onEose?.(relay, minCreatedAt); afterEose = true; }); return sub; } #mergeAndRemoveEmptyFiltersByRelay( filtersByRelay: Map<string, Filter[]> ): Map<string, Filter[]> { const mergedAndRemovedEmptyFiltersByRelay = new Map(); for (const [relay, filters] of filtersByRelay) { const mergedAndRemovedEmptyFilters = mergeSimilarAndRemoveEmptyFilters( mergeSimilarAndRemoveEmptyFilters(filters) ); if (mergedAndRemovedEmptyFilters.length > 0) { mergedAndRemovedEmptyFiltersByRelay.set( relay, mergedAndRemovedEmptyFilters ); } } return mergedAndRemovedEmptyFiltersByRelay; } #subscribeRelays( filtersByRelay: Map<string, Filter[]>, onEvent: OnEvent, onEose?: OnEose, unsub: {unsubcb?: () => void; unsuboneosecb?: () => void} = {}, minMaxDelayms?: number ): () => void { if (filtersByRelay.size === 0) { return () => {}; } // Merging here is done to make logging more readable. filtersByRelay = this.#mergeAndRemoveEmptyFiltersByRelay(filtersByRelay); if (this.logSubscriptions) { // console.log( // "RelayPool subscribeRelays: at time", // new Date().getTime() - this.startTime, // "ms, minMaxDelayms=", // minMaxDelayms, // ", filtersByRelay: ", // filtersByRelay // ); console.group("RelayPool subscribeRelays"); console.log( "at time", new Date().getTime() - this.startTime, "ms, minMaxDelayms=", minMaxDelayms ); const flattenedFilters: any = {}; for (const [relay, filters] of filtersByRelay) { let i = 0; for (const filter of filters) { const filter2: any = {...filter}; if (filter2.authors) { filter2.authors = filter2.authors.join(); filter2["authors.length"] = filter?.authors?.length; } if (filter2.kinds) { filter2.kinds = filter2.kinds.join(); } if (filter2.ids) { filter2.ids = filter2.ids.join(); filter2["ids.length"] = filter?.ids?.length; } if (filter2["#e"]) { filter2["#e"] = filter2["#e"].join(); filter2["#e.length"] = filter["#e"]!.length; } if (filter2["#p"]) { filter2["#p"] = filter2["#p"].join(); filter2["#p.length"] = filter["#p"]!.length; } flattenedFilters[relay + " " + i] = filter2; i++; } } if (Object.keys(flattenedFilters).length > 3) { console.groupCollapsed( Object.keys(flattenedFilters).length + " filters to " + filtersByRelay.size + " relays" ); } console.table(flattenedFilters); if (Object.keys(flattenedFilters).length > 3) { console.groupEnd(); } console.groupEnd(); } const subs: Sub[] = []; let unsuboneosecbcalled = false; let eoseSubs: Sub[] = []; unsub.unsuboneosecb = () => { unsuboneosecbcalled = true; eoseSubs.forEach((sub) => sub.unsub()); }; for (const [relay, filters] of filtersByRelay) { let subHolder: {sub?: Sub} = {}; const subOnEose: OnEose = (url, minCreatedAt) => { if (onEose) { onEose(url, minCreatedAt); } if (unsuboneosecbcalled) { subHolder.sub?.unsub(); } else { if (subHolder.sub) { eoseSubs.push(subHolder.sub); } } }; const eventIds = new Set<string>(); const sub = this.#subscribeRelay( relay, filters, onEvent, subOnEose, eventIds ); if (sub) { subHolder.sub = sub; subs.push(sub); } } const allUnsub = () => subs.forEach((sub) => sub.unsub()); unsub.unsubcb = () => { allUnsub(); delete unsub.unsubcb; }; return allUnsub; } sendSubscriptions(onEose?: OnEose, filtersToSubscribe?: FilterToSubscribe[]) { clearTimeout(this.timer); this.timer = undefined; let minMaxDelayms = this.minMaxDelayms; this.minMaxDelayms = Infinity; const [onEvent, filtersByRelay, unsub]: [ OnEvent, Map<string, Filter[]>, {unsubcb?: () => void; unsuboneosecb?: () => void} ] = batchFiltersByRelay( filtersToSubscribe || this.filtersToSubscribe, this.subscriptionCache ); let allUnsub = this.#subscribeRelays( filtersByRelay, onEvent, onEose, unsub, minMaxDelayms // For logging ); return allUnsub; } #resetTimer(maxDelayms: number) { if (this.minMaxDelayms > maxDelayms) { this.minMaxDelayms = maxDelayms; } clearTimeout(this.timer); this.timer = undefined; if (this.minMaxDelayms !== Infinity) { this.timer = setTimeout(() => { this.sendSubscriptions(); }, this.minMaxDelayms); } } async #getRelaysAndSubscribe( filters: (Filter & {relay?: string; noCache?: boolean})[], onEvent: OnEvent, maxDelayms?: number, onEose?: OnEose, options: SubscriptionOptions = {} ) { const allAuthors: Set<string> = new Set(); for (const filter of filters) { if (filter.authors) { for (const author of filter.authors) { allAuthors.add(author); } } else { if (!options.defaultRelays) { throw new Error( "Authors must be specified if no relays are subscribed and no default relays are specified." ); } } } const promises = []; const allAuthorsArray = []; for (const author of allAuthors) { promises.push( this.writeRelays ?.get(author) .then((event) => parseJSON(event?.content)) .catch(() => options?.defaultRelays || []) ); allAuthorsArray.push(author); } const allRelays: Set<string> = new Set(); let i = 0; for (const promise of promises) { const author = allAuthorsArray[i]; i += 1; let relays = await promise; if (!Array.isArray(relays)) { console.error("Couldn't load relays for author ", author); continue; } for (let relay of relays) { allRelays.add(relay); } } let allRelaysArray = Array.from(allRelays); if (allRelaysArray.length === 0) { if (options.defaultRelays) { allRelaysArray = options.defaultRelays; } } // if (this.logSubscriptions) { // console.log( // "getRelaysAndSubscribe", // "filters=", // filters, // "allRelaysArray=", // allRelaysArray, // "maxDelayms=", // maxDelayms, // "options=", // options // ); // } return this.subscribe( filters, allRelaysArray, onEvent, maxDelayms, onEose, options ); } subscribeEventObject( filters: (Filter & {relay?: string; noCache?: boolean})[], relays: string[] | undefined, onEventObject: OnEventObject, maxDelayms?: number, onEose?: OnEose, options: SubscriptionOptions = {} ): () => void { return this.subscribe(filters, relays, (event, afterEose, url) => onEventObject(new EventObject(event, this, relays), afterEose, url) ); } subscribe( filters: (Filter & {relay?: string; noCache?: boolean})[], relays: string[] | undefined, onEvent: OnEvent, maxDelayms?: number, onEose?: OnEose, options: SubscriptionOptions = {} ): () => void { if (maxDelayms !== undefined && onEose) { throw new Error("maxDelayms and onEose cannot be used together"); } if (relays === undefined) { const promise = this.#getRelaysAndSubscribe( filters, onEvent, maxDelayms, onEose, options ); return () => { promise.then((x) => { x(); }); }; } let subscriptionCacheKey: string | undefined; if (options.unsubscribeOnEose && !onEose) { subscriptionCacheKey = JSON.stringify([filters, relays]); const cachedSubscription = this.subscriptionCache?.get(subscriptionCacheKey); if (cachedSubscription) { return cachedSubscription.sub(onEvent); } } const [dedupedOnEvent, filtersByRelay] = groupFiltersByRelayAndEmitCacheHits( filters, relays, onEvent, options, this.eventCache ); let unsub: {unsubcb?: () => void} = {unsubcb: () => {}}; if ( maxDelayms === undefined && onEose && this.filtersToSubscribe.length > 0 && !options.dontSendOtherFilters ) { this.sendSubscriptions(); // onEose is not yet supported for batched subscriptions } const newFilters: FilterToSubscribe = [ dedupedOnEvent, filtersByRelay, unsub, options.unsubscribeOnEose, subscriptionCacheKey, maxDelayms, ]; if (options.dontSendOtherFilters) { return this.sendSubscriptions(onEose, [newFilters]); } this.filtersToSubscribe.push(newFilters); if (maxDelayms === undefined) { return this.sendSubscriptions(onEose); } else { this.#resetTimer(maxDelayms); return () => { unsub.unsubcb?.(); delete unsub.unsubcb; }; } } async getEventObjectById( id: string, relays: string[], maxDelayms: number ): Promise<EventObject> { return this.getEventById(id, relays, maxDelayms).then( (event) => new EventObject(event, this, relays) ); } async getEventById( id: string, relays: string[], maxDelayms: number ): Promise<Event> { return new Promise((resolve, reject) => { this.subscribe( [{ids: [id]}], relays, (event) => { resolve(event); }, maxDelayms, undefined // {unsubscribeOnEose: true} ); }); } publish(event: Event, relays: string[]) { for (const relay of unique(relays)) { const instance = this.addOrGetRelay(relay); instance.publish(event); } } onnotice(cb: (url: string, msg: string) => void) { this.noticecbs.push(cb); } onerror(cb: (url: string, msg: string) => void) { this.relayByUrl.forEach((relay: Relay, url: string) => relay.on("error", (msg: string) => cb(url, msg)) ); this.errorcbs.push(cb); } ondisconnect(cb: (url: string, msg: string) => void) { this.relayByUrl.forEach((relay: Relay, url: string) => relay.on("disconnect", (msg: string) => cb(url, msg)) ); } onauth(cb: (relay: Relay, challenge: string) => void) { this.authcbs.push(cb); } getRelayStatuses(): [url: string, staus: number][] { return Array.from(this.relayByUrl.entries()) .map( ([url, relay]: [string, Relay]) => [url, relay.status] as [string, number] ) .sort(); } setWriteRelaysForPubKey( pubkey: string, writeRelays: string[], created_at: number ) { const event: Event = { created_at, pubkey: "", id: "", sig: "", content: JSON.stringify(writeRelays), // @ts-ignore kind: 10003, tags: [["p", pubkey]], }; this.writeRelays.data.set(pubkey, event); } setCachedMetadata(pubkey: string, metadata: Event) { this.metadataCache.data.set(pubkey, metadata); } setCachedContactList(pubkey: string, contactList: Event) { this.contactListCache.data.set(pubkey, contactList); } subscribeReferencedEvents( event: Event, onEvent: OnEvent, maxDelayms?: number, onEose?: OnEose, options: SubscriptionOptions = {} ): () => void { let ids: string[] = []; let authors: string[] = []; for (const tag of event.tags) { if (tag[0] === "p") { const pubkey = tag[1]; if (pubkey.length !== 64) { console.log("bad pubkey", pubkey, tag); continue; } authors.push(pubkey); } if (tag[0] === "e") { const id = tag[1]; ids.push(id); } } if (ids.length === 0) { return () => {}; } if (authors.length === 0) { if (options.defaultRelays) { return this.subscribe( [{ids}], options.defaultRelays, onEvent, maxDelayms, onEose, options ); } else { console.error("No authors for ids in event", event); return () => {}; } } if (this.logSubscriptions) { // console.log("subscribeReferencedEvents0: ", ids, authors); } return this.subscribe( [{ids, authors}], undefined, onEvent, maxDelayms, onEose, options ); } fetchAndCacheMetadata(pubkey: string): Promise<Event> { return this.metadataCache.get(pubkey).catch((e) => { this.errorsAndNotices.push({ type: "error", msg: `Error fetching metadata for ${pubkey}: ${e}`, time: Date.now() - this.startTime, url: "", }); throw new Error(`Error fetching metadata for ${pubkey}: ${e}`); }); } fetchAndCacheContactList(pubkey: string): Promise<Event> { return this.contactListCache.get(pubkey).catch((e) => { this.errorsAndNotices.push({ type: "error", msg: `Error fetching contact list for ${pubkey}: ${e}`, time: Date.now() - this.startTime, url: "", }); throw new Error(`Error fetching contact list for ${pubkey}: ${e}`); }); } subscribeReferencedEventsAndPrefetchMetadata( event: Event, onEvent: OnEvent, maxDelayms?: number, onEose?: OnEose, options: SubscriptionOptions = {} ): () => void { for (const tag of event.tags) { if (tag[0] === "p") { const pubkey = tag[1]; if (pubkey.length !== 64) { console.log("bad pubkey", pubkey, tag); continue; } this.fetchAndCacheMetadata(pubkey).catch((e) => { this.errorsAndNotices.push({ type: "error", url: "", msg: `Error fetching metadata for ${pubkey}: ${e}`, time: Date.now() - this.startTime, }); }); } } return this.subscribeReferencedEvents( event, onEvent, maxDelayms, onEose, options ); } reconnect() { this.relayByUrl.forEach((relay: Relay) => { relay.connect().catch((e) => { this.errorsAndNotices.push({ type: "error", msg: `Error reconnecting to ${relay.url}: ${e}`, time: Date.now() - this.startTime, url: relay.url, }); }); }); } }