UNPKG

@nostr-dev-kit/ndk

Version:

NDK - Nostr Development Kit

467 lines (400 loc) 16.1 kB
import type { NostrEvent } from "../events"; import type { NDKFilter, NDKSubscription, NDKSubscriptionDelayedType, NDKSubscriptionInternalId, } from "../subscription"; import type { NDKFilterFingerprint } from "../subscription/grouping"; import { mergeFilters } from "../subscription/grouping"; import type { NDKSubscriptionManager } from "../subscription/manager"; import type { NDKRelay } from "."; import { NDKRelayStatus } from "."; type Item = { subscription: NDKSubscription; filters: NDKFilter[]; }; export enum NDKRelaySubscriptionStatus { INITIAL = 0, /** * The subscription is pending execution. */ PENDING = 1, /** * The subscription is waiting for the relay to be ready. */ WAITING = 2, /** * The subscription is currently running. */ RUNNING = 3, CLOSED = 4, } /** * Groups together a number of NDKSubscriptions (as created by the user), * filters (as computed internally), executed, or to be executed, within * a single specific relay. */ export class NDKRelaySubscription { public fingerprint: NDKFilterFingerprint; public items: Map<NDKSubscriptionInternalId, Item> = new Map(); public topSubManager: NDKSubscriptionManager; public debug: debug.Debugger; /** * Tracks the status of this REQ. */ public status: NDKRelaySubscriptionStatus = NDKRelaySubscriptionStatus.INITIAL; public onClose?: (sub: NDKRelaySubscription) => void; private relay: NDKRelay; /** * Whether this subscription has reached EOSE. */ private eosed = false; /** * Timeout at which this subscription will * start executing. */ private executionTimer?: NodeJS.Timeout | number; /** * Track the time at which this subscription will fire. */ private fireTime?: number; /** * The delay type that the current fireTime was calculated with. */ private delayType?: NDKSubscriptionDelayedType; /** * The filters that have been executed. */ public executeFilters?: NDKFilter[]; readonly id = Math.random().toString(36).substring(7); /** * * @param fingerprint The fingerprint of this subscription. */ constructor( relay: NDKRelay, fingerprint: NDKFilterFingerprint | null, topSubManager: NDKSubscriptionManager ) { this.relay = relay; this.topSubManager = topSubManager; this.debug = relay.debug.extend(`sub[${this.id}]`); this.fingerprint = fingerprint || Math.random().toString(36).substring(7); } private _subId?: string; get subId(): string { if (this._subId) return this._subId; this._subId = this.fingerprint.slice(0, 15); return this._subId; } private subIdParts = new Set<string>(); private addSubIdPart(part: string) { this.subIdParts.add(part); } public addItem(subscription: NDKSubscription, filters: NDKFilter[]) { this.debug("Adding item", { filters, internalId: subscription.internalId, status: this.status, fingerprint: this.fingerprint, id: this.subId, items: this.items, itemsSize: this.items.size, }); if (this.items.has(subscription.internalId)) return; subscription.on("close", this.removeItem.bind(this, subscription)); this.items.set(subscription.internalId, { subscription, filters }); if (this.status !== NDKRelaySubscriptionStatus.RUNNING) { // if we have an explicit subId in this subscription, append it to the subId if (subscription.subId && (!this._subId || this._subId.length < 48)) { if ( this.status === NDKRelaySubscriptionStatus.INITIAL || this.status === NDKRelaySubscriptionStatus.PENDING ) { this.addSubIdPart(subscription.subId); } } } switch (this.status) { case NDKRelaySubscriptionStatus.INITIAL: this.evaluateExecutionPlan(subscription); break; case NDKRelaySubscriptionStatus.RUNNING: break; case NDKRelaySubscriptionStatus.PENDING: // this subscription is already scheduled to be executed // we need to evaluate whether this new NDKSubscription // modifies our execution plan this.evaluateExecutionPlan(subscription); break; case NDKRelaySubscriptionStatus.CLOSED: this.debug( "Subscription is closed, cannot add new items %o (%o)", subscription, filters ); throw new Error("Cannot add new items to a closed subscription"); } } /** * A subscription has been closed, remove it from the list of items. * @param subscription */ public removeItem(subscription: NDKSubscription) { // this.debug("Removing item", { filters: subscription.filters, internalId: subscription.internalId, status: this.status, id: this.subId, fingerprint: this.fingerprint, items: this.items, itemsSize: this.items.size }); this.items.delete(subscription.internalId); if (this.items.size === 0) { // if we haven't received an EOSE yet, don't close, relays don't like that // rather, when we EOSE and we have 0 items we will close there. if (!this.eosed) return; // no more items, close the subscription this.close(); this.cleanup(); } } private close() { if (this.status === NDKRelaySubscriptionStatus.CLOSED) return; const prevStatus = this.status; this.status = NDKRelaySubscriptionStatus.CLOSED; if (prevStatus === NDKRelaySubscriptionStatus.RUNNING) { try { this.relay.close(this.subId); } catch (e) { this.debug("Error closing subscription", e, this); } } else { this.debug("Subscription wanted to close but it wasn't running, this is probably ok", { subId: this.subId, prevStatus, sub: this, }); } this.cleanup(); } public cleanup() { // remove delayed execution if (this.executionTimer) clearTimeout(this.executionTimer as NodeJS.Timeout); // remove callback from relay this.relay.off("ready", this.executeOnRelayReady); this.relay.off("authed", this.reExecuteAfterAuth); // callback if (this.onClose) this.onClose(this); } private evaluateExecutionPlan(subscription: NDKSubscription) { if (!subscription.isGroupable()) { // execute immediately this.status = NDKRelaySubscriptionStatus.PENDING; this.execute(); return; } // if the subscription is adding a limit filter we want to make sure // we are not adding too many, since limit filters concatenate filters instead of merging them // (as merging them would change the meaning) if (subscription.filters.find((filter) => !!filter.limit)) { // compile the filter this.executeFilters = this.compileFilters(); // if we have 10 filters, we execute immediately, as most relays don't want more than 10 if (this.executeFilters.length >= 10) { this.status = NDKRelaySubscriptionStatus.PENDING; this.execute(); return; } } const delay = subscription.groupableDelay; const delayType = subscription.groupableDelayType; if (!delay) throw new Error("Cannot group a subscription without a delay"); if (this.status === NDKRelaySubscriptionStatus.INITIAL) { this.schedule(delay, delayType); } else { // we already scheduled it, do we need to change it? const existingDelayType = this.delayType; const timeUntilFire = this.fireTime! - Date.now(); if (existingDelayType === "at-least" && delayType === "at-least") { if (timeUntilFire < delay) { // extend the timeout to the bigger timeout if (this.executionTimer) clearTimeout(this.executionTimer as NodeJS.Timeout); this.schedule(delay, delayType); } } else if (existingDelayType === "at-least" && delayType === "at-most") { if (timeUntilFire > delay) { if (this.executionTimer) clearTimeout(this.executionTimer as NodeJS.Timeout); this.schedule(delay, delayType); } } else if (existingDelayType === "at-most" && delayType === "at-most") { if (timeUntilFire > delay) { if (this.executionTimer) clearTimeout(this.executionTimer as NodeJS.Timeout); this.schedule(delay, delayType); } } else if (existingDelayType === "at-most" && delayType === "at-least") { if (timeUntilFire > delay) { if (this.executionTimer) clearTimeout(this.executionTimer as NodeJS.Timeout); this.schedule(delay, delayType); } } else { throw new Error(`Unknown delay type combination ${existingDelayType} ${delayType}`); } } } private schedule(delay: number, delayType: NDKSubscriptionDelayedType) { this.status = NDKRelaySubscriptionStatus.PENDING; const currentTime = Date.now(); this.fireTime = currentTime + delay; this.delayType = delayType; const timer = setTimeout(this.execute.bind(this), delay); /** * We only store the execution timer if it's an "at-least" delay, * since "at-most" delays should not be cancelled. */ if (delayType === "at-least") { this.executionTimer = timer; } } private executeOnRelayReady = () => { if (this.status !== NDKRelaySubscriptionStatus.WAITING) return; if (this.items.size === 0) { this.debug( "No items to execute; this relay was probably too slow to respond and the caller gave up", { status: this.status, fingerprint: this.fingerprint, items: this.items, itemsSize: this.items.size, id: this.id, subId: this.subId, } ); this.cleanup(); return; } this.debug("Executing on relay ready", { status: this.status, fingerprint: this.fingerprint, items: this.items, itemsSize: this.items.size, }); this.status = NDKRelaySubscriptionStatus.PENDING; this.execute(); }; private finalizeSubId() { // if we have subId parts, join those if (this.subIdParts.size > 0) { this._subId = Array.from(this.subIdParts).join("-"); } else { this._subId = this.fingerprint.slice(0, 15); } this._subId += `-${Math.random().toString(36).substring(2, 7)}`; } // we do it this way so that we can remove the listener private reExecuteAfterAuth = (() => { const oldSubId = this.subId; this.debug("Re-executing after auth", this.items.size); if (this.eosed) { // we already received eose, so we can immediately close the old subscription // to create the new one this.relay.close(this.subId); } else { // relays don't like to have the subscription close before they eose back, // so wait until we eose before closing the old subscription this.debug( "We are abandoning an opened subscription, once it EOSE's, the handler will close it", { oldSubId, } ); } this._subId = undefined; this.status = NDKRelaySubscriptionStatus.PENDING; this.execute(); this.debug("Re-executed after auth %s 👉 %s", oldSubId, this.subId); }).bind(this); private execute() { if (this.status !== NDKRelaySubscriptionStatus.PENDING) { // Because we might schedule this execution multiple times, // ensure we only execute once return; } // check on the relay connectivity status if (!this.relay.connected) { this.status = NDKRelaySubscriptionStatus.WAITING; this.debug("Waiting for relay to be ready", { status: this.status, id: this.subId, fingerprint: this.fingerprint, items: this.items, itemsSize: this.items.size, }); this.relay.once("ready", this.executeOnRelayReady); return; } if (this.relay.status < NDKRelayStatus.AUTHENTICATED) { this.relay.once("authed", this.reExecuteAfterAuth); } this.status = NDKRelaySubscriptionStatus.RUNNING; this.finalizeSubId(); this.executeFilters = this.compileFilters(); this.relay.req(this); } public onstart() {} public onevent(event: NostrEvent) { this.topSubManager.dispatchEvent(event, this.relay); } public oneose(subId: string) { this.eosed = true; // if this is a different subId, then it belongs to a previously // created subscription we have abandoned; we can clean it up here if (subId !== this.subId) { this.debug("Received EOSE for an abandoned subscription", subId, this.subId); this.relay.close(subId); return; } // if we don't have any items left, this is a subscription in a slow // relay and the subscriptions have been EOSEd due to a timeout, we can // close this subscription if (this.items.size === 0) { this.close(); } for (const { subscription } of this.items.values()) { subscription.eoseReceived(this.relay); if (subscription.closeOnEose) { this.debug("Removing item because of EOSE", { filters: subscription.filters, internalId: subscription.internalId, status: this.status, fingerprint: this.fingerprint, items: this.items, itemsSize: this.items.size, }); this.removeItem(subscription); } } } public onclose(_reason?: string) { this.status = NDKRelaySubscriptionStatus.CLOSED; } public onclosed(reason?: string) { if (!reason) return; for (const { subscription } of this.items.values()) { subscription.closedReceived(this.relay, reason); } } /** * Grabs the filters from all the subscriptions * and merges them into a single filter. */ private compileFilters(): NDKFilter[] { const mergedFilters: NDKFilter[] = []; const filters = Array.from(this.items.values()).map((item) => item.filters); if (!filters[0]) { this.debug("👀 No filters to merge", this.items); console.error("BUG: No filters to merge!", this.items); return []; } const filterCount = filters[0].length; for (let i = 0; i < filterCount; i++) { const allFiltersAtIndex = filters.map((filter) => filter[i]); mergedFilters.push(...mergeFilters(allFiltersAtIndex)); } return mergedFilters; } }