@nostr-dev-kit/ndk
Version:
NDK - Nostr Development Kit
467 lines (400 loc) • 16.1 kB
text/typescript
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;
}
}