@nostr-dev-kit/ndk
Version:
NDK - Nostr Development Kit
803 lines (688 loc) • 29.9 kB
text/typescript
import { EventEmitter } from "tseep";
import type { NDKEventId, NostrEvent, NDKSignedEvent } from "../events/index.js";
import { NDKEvent, createSignedEvent } from "../events/index.js";
import type { NDKKind } from "../events/kinds/index.js";
import { verifiedSignatures } from "../events/validation.js";
import { wrapEvent } from "../events/wrap.js";
import type { NDK } from "../ndk/index.js";
import type { NDKRelay } from "../relay";
import type { NDKPool } from "../relay/pool/index.js";
import { calculateRelaySetsFromFilters } from "../relay/sets/calculate";
import { NDKRelaySet } from "../relay/sets/index.js";
import { queryFullyFilled } from "./utils.js";
export type NDKSubscriptionInternalId = string;
export type NDKSubscriptionDelayedType = "at-least" | "at-most";
export type NDKFilter<K extends number = NDKKind> = {
ids?: string[];
kinds?: K[];
authors?: string[];
since?: number;
until?: number;
limit?: number;
search?: string;
[key: `#${string}`]: string[] | undefined;
};
export enum NDKSubscriptionCacheUsage {
// Only use cache, don't subscribe to relays
ONLY_CACHE = "ONLY_CACHE",
// Use cache, if no matches, use relays
CACHE_FIRST = "CACHE_FIRST",
// Use cache in addition to relays
PARALLEL = "PARALLEL",
// Skip cache, don't query it
ONLY_RELAY = "ONLY_RELAY",
}
export interface NDKSubscriptionOptions {
/**
* Whether to close the subscription when all relays have reached the end of the event stream.
* @default false
*/
closeOnEose?: boolean;
cacheUsage?: NDKSubscriptionCacheUsage;
/**
* Whether to skip caching events coming from this subscription
**/
dontSaveToCache?: boolean;
/**
* Groupable subscriptions are created with a slight time
* delayed to allow similar filters to be grouped together.
*/
groupable?: boolean;
/**
* The delay to use when grouping subscriptions, specified in milliseconds.
* @default 100
* @example
* const sub1 = ndk.subscribe({ kinds: [1], authors: ["alice"] }, { groupableDelay: 100 });
* const sub2 = ndk.subscribe({ kinds: [0], authors: ["alice"] }, { groupableDelay: 1000 });
* // sub1 and sub2 will be grouped together and executed 100ms after sub1 was created
*/
groupableDelay?: number;
/**
* Specifies how this delay should be interpreted.
* "at-least" means "wait at least this long before sending the subscription"
* "at-most" means "wait at most this long before sending the subscription"
* @default "at-most"
* @example
* const sub1 = ndk.subscribe({ kinds: [1], authors: ["alice"] }, { groupableDelay: 100, groupableDelayType: "at-least" }); // 3 args
* const sub2 = ndk.subscribe({ kinds: [0], authors: ["alice"] }, { groupableDelay: 1000, groupableDelayType: "at-most" }); // 3 args
* // sub1 and sub2 will be grouped together and executed 1000ms after sub1 was created
*/
groupableDelayType?: NDKSubscriptionDelayedType;
/**
* The subscription ID to use for the subscription.
*/
subId?: string;
/**
* Pool to use
*/
pool?: NDKPool;
/**
* Skip signature verification
* @default false
*/
skipVerification?: boolean;
/**
* Skip event validation. Event validation, checks whether received
* kinds conform to what the expected schema of that kind should look like.rtwle
* @default false
*/
skipValidation?: boolean;
/**
* Skip emitting on events before they are received from a relay. (skip optimistic publish)
* @default false
*/
skipOptimisticPublishEvent?: boolean;
/**
* Remove filter constraints when querying the cache.
*
* This allows setting more aggressive filters that will be removed when hitting the cache.
*
* Useful uses of this include removing `since` or `until` constraints or `limit` filters.
*
* @example
* ndk.subscribe({ kinds: [1], since: 1710000000, limit: 10 }, { cacheUnconstrainFilter: ['since', 'limit'] }); // 3 args
*
* This will hit relays with the since and limit constraints, while loading from the cache without them.
*/
cacheUnconstrainFilter?: (keyof NDKFilter)[];
/**
* Whether to wrap events in kind-specific classes when possible.
* @default false
*/
wrap?: boolean;
/**
* Explicit relay set to use for this subscription instead of calculating it.
* If `relayUrls` is also provided in the options, this `relaySet` takes precedence.
* @since 2.13.0 Moved from `ndk.subscribe` parameter to options.
*/
relaySet?: NDKRelaySet;
/**
* Explicit relay URLs to use for this subscription instead of calculating the relay set.
* An `NDKRelaySet` will be created internally from these URLs.
* If `relaySet` is also provided in the options, the explicit `relaySet` takes precedence over these URLs.
* @since 2.13.0
*/
relayUrls?: string[];
/**
* When set, the cache will be queried first, and, when hitting relays,
* a `since` filter will be added to the subscription that is one second
* after the last event received from the cache.
*
* This option implies cacheUsage: CACHE_FIRST.
*/
addSinceFromCache?: boolean;
}
/**
* Default subscription options.
*/
export const defaultOpts: NDKSubscriptionOptions = {
closeOnEose: false,
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
dontSaveToCache: false,
groupable: true,
groupableDelay: 100,
groupableDelayType: "at-most",
cacheUnconstrainFilter: ["limit", "since", "until"],
};
/**
* Represents a subscription to an NDK event stream.
*
* @emits event
* Emitted when an event is received by the subscription.
* * ({NDKEvent} event - The event received by the subscription,
* * {NDKRelay} relay - The relay that received the event,
* * {NDKSubscription} subscription - The subscription that received the event.)
*
* @emits event:dup
* Emitted when a duplicate event is received by the subscription.
* * {NDKEvent} event - The duplicate event received by the subscription.
* * {NDKRelay} relay - The relay that received the event.
* * {number} timeSinceFirstSeen - The time elapsed since the first time the event was seen.
* * {NDKSubscription} subscription - The subscription that received the event.
*
* @emits cacheEose - Emitted when the cache adapter has reached the end of the events it had.
*
* @emits eose - Emitted when all relays have reached the end of the event stream.
* * {NDKSubscription} subscription - The subscription that received EOSE.
*
* @emits close - Emitted when the subscription is closed.
* * {NDKSubscription} subscription - The subscription that was closed.
*
* @example
* const sub = ndk.subscribe({ kinds: [1] }); // Get all kind:1s
* sub.on("event", (event) => console.log(event.content); // Show the content
* sub.on("eose", () => console.log("All relays have reached the end of the event stream"));
* sub.on("close", () => console.log("Subscription closed"));
* setTimeout(() => sub.stop(), 10000); // Stop the subscription after 10 seconds
*
* @description
* Subscriptions are created using {@link NDK.subscribe}.
*
* # Event validation
* By defaults, subscriptions will validate events to comply with the minimal requirement
* of each known NIP.
* This can be disabled by setting the `skipValidation` option to `true`.
*
* @example
* const sub = ndk.subscribe({ kinds: [1] }, { skipValidation: false });
* sub.on("event", (event) => console.log(event.content); // Only valid events will be received
*/
export class NDKSubscription extends EventEmitter<{
cacheEose: () => void;
eose: (sub: NDKSubscription) => void;
close: (sub: NDKSubscription) => void;
/**
* Emitted when a duplicate event is received by the subscription.
* @param event - The duplicate event received by the subscription.
* @param relay - The relay that received the event.
* @param timeSinceFirstSeen - The time elapsed since the first time the event was seen.
* @param sub - The subscription that received the event.
*/
"event:dup": (
event: NDKEvent | NostrEvent,
relay: NDKRelay | undefined,
timeSinceFirstSeen: number,
sub: NDKSubscription,
fromCache: boolean,
optimisticPublish: boolean,
) => void;
/**
* Emitted when an event is received by the subscription.
* @param event - The event received by the subscription.
* @param relay - The relay that received the event.
* @param sub - The subscription that received the event.
* @param fromCache - Whether the event was received from the cache.
* @param optimisticPublish - Whether the event was received from an optimistic publish.
*/
event: (
event: NDKSignedEvent,
relay: NDKRelay | undefined,
sub: NDKSubscription,
fromCache: boolean,
optimisticPublish: boolean,
) => void;
/**
* Emitted when a relay unilaterally closes the subscription.
* @param relay
* @param reason
* @returns
*/
closed: (relay: NDKRelay, reason: string) => void;
}> {
readonly subId?: string;
readonly filters: NDKFilter[];
readonly opts: NDKSubscriptionOptions;
readonly pool: NDKPool;
readonly skipVerification: boolean = false;
readonly skipValidation: boolean = false;
/**
* Tracks the filters as they are executed on each relay
*/
public relayFilters?: Map<WebSocket["url"], NDKFilter[]>;
public relaySet?: NDKRelaySet;
public ndk: NDK;
public debug: debug.Debugger;
/**
* Events that have been seen by the subscription, with the time they were first seen.
*/
public eventFirstSeen = new Map<NDKEventId, number>();
/**
* Relays that have sent an EOSE.
*/
public eosesSeen = new Set<NDKRelay>();
/**
* The time the last event was received by the subscription.
* This is used to calculate when EOSE should be emitted.
*/
private lastEventReceivedAt: number | undefined;
/**
* The most recent event timestamp from cache results.
* This is used for addSinceFromCache functionality.
*/
private mostRecentCacheEventTimestamp?: number;
public internalId: NDKSubscriptionInternalId;
/**
* Whether the subscription should close when all relays have reached the end of the event stream.
*/
public closeOnEose: boolean;
/**
* Pool monitor callback
*/
private poolMonitor: ((relay: NDKRelay) => void) | undefined;
public skipOptimisticPublishEvent = false;
/**
* Filters to remove when querying the cache.
*/
public cacheUnconstrainFilter?: Array<keyof NDKFilter>;
public constructor(ndk: NDK, filters: NDKFilter | NDKFilter[], opts?: NDKSubscriptionOptions, subId?: string) {
super();
this.ndk = ndk;
this.opts = { ...defaultOpts, ...(opts || {}) };
this.pool = this.opts.pool || ndk.pool;
this.filters = Array.isArray(filters) ? filters : [filters];
this.subId = subId || this.opts.subId;
this.internalId = Math.random().toString(36).substring(7);
this.debug = ndk.debug.extend(`subscription[${this.opts.subId ?? this.internalId}]`);
// Handle relaySet and relayUrls options
if (this.opts.relaySet) {
this.relaySet = this.opts.relaySet;
} else if (this.opts.relayUrls) {
this.relaySet = NDKRelaySet.fromRelayUrls(this.opts.relayUrls, this.ndk);
}
this.skipVerification = this.opts.skipVerification || false;
this.skipValidation = this.opts.skipValidation || false;
this.closeOnEose = this.opts.closeOnEose || false;
this.skipOptimisticPublishEvent = this.opts.skipOptimisticPublishEvent || false;
this.cacheUnconstrainFilter = this.opts.cacheUnconstrainFilter;
}
/**
* Returns the relays that have not yet sent an EOSE.
*/
public relaysMissingEose(): WebSocket["url"][] {
if (!this.relayFilters) return [];
const relaysMissingEose = Array.from(this.relayFilters?.keys()).filter(
(url) => !this.eosesSeen.has(this.pool.getRelay(url, false, false)),
);
return relaysMissingEose;
}
/**
* Provides access to the first filter of the subscription for
* backwards compatibility.
*/
get filter(): NDKFilter {
return this.filters[0];
}
get groupableDelay(): number | undefined {
if (!this.isGroupable()) return undefined;
return this.opts?.groupableDelay;
}
get groupableDelayType(): NDKSubscriptionDelayedType {
return this.opts?.groupableDelayType || "at-most";
}
public isGroupable(): boolean {
return this.opts?.groupable || false;
}
private shouldQueryCache(): boolean {
if (this.opts.addSinceFromCache) return true;
// explicitly told to not query the cache
if (this.opts?.cacheUsage === NDKSubscriptionCacheUsage.ONLY_RELAY) return false;
const hasNonEphemeralKind = this.filters.some((f) => f.kinds?.some((k) => kindIsEphemeral(k)));
if (hasNonEphemeralKind) return true;
return true;
}
private shouldQueryRelays(): boolean {
return this.opts?.cacheUsage !== NDKSubscriptionCacheUsage.ONLY_CACHE;
}
private shouldWaitForCache(): boolean {
if (this.opts.addSinceFromCache) return true;
return (
// Must want to close on EOSE; subscriptions
// that want to receive further updates must
// always hit the relay
!!this.opts.closeOnEose &&
// Cache adapter must claim to be fast
!!this.ndk.cacheAdapter?.locking &&
// If explicitly told to run in parallel, then
// we should not wait for the cache
this.opts.cacheUsage !== NDKSubscriptionCacheUsage.PARALLEL
);
}
/**
* Start the subscription. This is the main method that should be called
* after creating a subscription.
*
* @param emitCachedEvents - Whether to emit events coming from a synchronous cache
*
* When using a synchronous cache, the events will be returned immediately
* by this function. If you will use those returned events, you should
* set emitCachedEvents to false to prevent seeing them as duplicate events.
*/
public start(emitCachedEvents = true): NDKEvent[] | null {
let cacheResult: NDKEvent[] | Promise<NDKEvent[]>;
const updateStateFromCacheResults = (events: NDKEvent[]) => {
if (emitCachedEvents) {
for (const event of events) {
if (
event.created_at &&
(!this.mostRecentCacheEventTimestamp || event.created_at > this.mostRecentCacheEventTimestamp)
) {
this.mostRecentCacheEventTimestamp = event.created_at;
}
this.eventReceived(event, undefined, true, false);
}
} else {
cacheResult = [];
for (const event of events) {
if (
event.created_at &&
(!this.mostRecentCacheEventTimestamp || event.created_at > this.mostRecentCacheEventTimestamp)
) {
this.mostRecentCacheEventTimestamp = event.created_at;
}
event.ndk = this.ndk;
const e = this.opts.wrap ? wrapEvent(event) : event;
if (!e) break;
if (e instanceof Promise) {
// if we get a promise, we emit it
e.then((wrappedEvent) => {
this.emitEvent(false, wrappedEvent, undefined, true, false);
});
break;
}
this.eventFirstSeen.set(e.id, Date.now());
(cacheResult as NDKEvent[]).push(e);
}
}
};
const loadFromRelays = () => {
if (this.shouldQueryRelays()) {
this.startWithRelays();
this.startPoolMonitor();
} else {
this.emit("eose", this);
}
};
if (this.shouldQueryCache()) {
cacheResult = this.startWithCache();
if (cacheResult instanceof Promise) {
// The cache is asynchronous
if (this.shouldWaitForCache()) {
// If we need to wait for it
cacheResult.then((events) => {
// load the results into the subscription state
updateStateFromCacheResults(events);
// if the cache has a hit, return early
if (queryFullyFilled(this)) {
this.emit("eose", this);
return;
}
loadFromRelays();
});
return null;
}
cacheResult.then((events) => {
updateStateFromCacheResults(events);
});
loadFromRelays();
return null;
}
updateStateFromCacheResults(cacheResult);
if (queryFullyFilled(this)) {
this.emit("eose", this);
} else {
loadFromRelays();
}
return cacheResult;
}
loadFromRelays();
return null;
}
/**
* We want to monitor for new relays that are coming online, in case
* they should be part of this subscription.
*/
private startPoolMonitor(): void {
const _d = this.debug.extend("pool-monitor");
this.poolMonitor = (relay: NDKRelay) => {
// check if the pool monitor is already in the relayFilters
if (this.relayFilters?.has(relay.url)) return;
const calc = calculateRelaySetsFromFilters(this.ndk, this.filters, this.pool);
// check if the new relay is included
if (calc.get(relay.url)) {
// add it to the relayFilters
this.relayFilters?.set(relay.url, this.filters);
// d("New relay connected -- adding to subscription", relay.url);
relay.subscribe(this, this.filters);
}
};
this.pool.on("relay:connect", this.poolMonitor);
}
public onStopped?: () => void;
public stop(): void {
this.emit("close", this);
this.poolMonitor && this.pool.off("relay:connect", this.poolMonitor);
this.onStopped?.();
}
/**
* @returns Whether the subscription has an authors filter.
*/
public hasAuthorsFilter(): boolean {
return this.filters.some((f) => f.authors?.length);
}
private startWithCache(): NDKEvent[] | Promise<NDKEvent[]> {
if (this.ndk.cacheAdapter?.query) {
return this.ndk.cacheAdapter.query(this);
}
return [];
}
/**
* Find available relays that should be part of this subscription and execute in them.
*
* Note that this is executed in addition to using the pool monitor, so even if the relay set
* that is computed (i.e. we don't have any relays available), when relays come online, we will
* check if we need to execute in them.
*/
private startWithRelays(): void {
// Create a copy of filters to potentially modify for addSinceFromCache
let filters = this.filters;
// If addSinceFromCache is enabled and we have a timestamp from cache results,
// modify the filters to add a 'since' filter that's one second after the most recent event
if (this.opts.addSinceFromCache && this.mostRecentCacheEventTimestamp) {
const sinceTimestamp = this.mostRecentCacheEventTimestamp + 1;
filters = filters.map((filter) => ({
...filter,
since: Math.max(filter.since || 0, sinceTimestamp),
}));
}
if (!this.relaySet || this.relaySet.relays.size === 0) {
this.relayFilters = calculateRelaySetsFromFilters(this.ndk, filters, this.pool);
} else {
this.relayFilters = new Map();
for (const relay of this.relaySet.relays) {
this.relayFilters.set(relay.url, filters);
}
}
// iterate through the this.relayFilters
for (const [relayUrl, filters] of this.relayFilters) {
const relay = this.pool.getRelay(relayUrl, true, true, filters);
relay.subscribe(this, filters);
}
}
// EVENT handling
/**
* Called when an event is received from a relay or the cache
* @param event
* @param relay
* @param fromCache Whether the event was received from the cache
* @param optimisticPublish Whether this event is coming from an optimistic publish
*/
public eventReceived(
event: NDKEvent | NostrEvent,
relay: NDKRelay | undefined,
fromCache = false,
optimisticPublish = false,
) {
const eventId = event.id! as NDKEventId;
const eventAlreadySeen = this.eventFirstSeen.has(eventId);
let ndkEvent: NDKEvent;
if (event instanceof NDKEvent) ndkEvent = event;
if (!eventAlreadySeen) {
// generate the ndkEvent
ndkEvent ??= new NDKEvent(this.ndk, event);
ndkEvent.ndk = this.ndk;
ndkEvent.relay = relay;
// we don't want to validate/verify events that are either
// coming from the cache or have been published by us from within
// the client
if (!fromCache && !optimisticPublish) {
// validate it
if (!this.skipValidation) {
if (!ndkEvent.isValid) {
this.debug("Event failed validation %s from relay %s", eventId, relay?.url);
return;
}
}
// verify it
if (relay) {
// Check if we need to verify this event based on sampling
const shouldVerify = relay.shouldValidateEvent();
if (shouldVerify && !this.skipVerification) {
// Set the relay on the event for async verification
ndkEvent.relay = relay;
// Attempt verification
if (!this.ndk.asyncSigVerification) {
if (!ndkEvent.verifySignature(true)) {
this.debug("Event failed signature validation", event);
// Report the invalid signature with relay information through the centralized method
this.ndk.reportInvalidSignature(ndkEvent, relay);
return;
}
// Track successful validation
relay.addValidatedEvent();
}
} else {
// We skipped verification for this event
relay.addNonValidatedEvent();
}
}
if (this.ndk.cacheAdapter && !this.opts.dontSaveToCache) {
this.ndk.cacheAdapter.setEvent(ndkEvent, this.filters, relay);
}
}
// emit it
if (!optimisticPublish || this.skipOptimisticPublishEvent !== true) {
this.emitEvent(this.opts?.wrap ?? false, ndkEvent, relay, fromCache, optimisticPublish);
// mark the eventId as seen
this.eventFirstSeen.set(eventId, Date.now());
}
} else {
const timeSinceFirstSeen = Date.now() - (this.eventFirstSeen.get(eventId) || 0);
this.emit("event:dup", event, relay, timeSinceFirstSeen, this, fromCache, optimisticPublish);
if (relay) {
// Check if we've already verified this event id's signature
const signature = verifiedSignatures.get(eventId);
if (signature && typeof signature === "string") {
// If signatures match, we count it as validated
if (event.sig === signature) {
relay.addValidatedEvent();
} else {
// Signatures don't match - this is a malicious relay!
// One invalid signature means the relay is considered evil
const eventToReport = event instanceof NDKEvent ? event : new NDKEvent(this.ndk, event);
this.ndk.reportInvalidSignature(eventToReport, relay);
}
}
}
}
this.lastEventReceivedAt = Date.now();
}
/**
* Optionally wraps, sync or async, and emits the event (if one comes back from the wrapper)
*/
private emitEvent(
wrap: boolean,
evt: NDKEvent,
relay: NDKRelay | undefined,
fromCache: boolean,
optimisticPublish: boolean,
) {
const wrapped = wrap ? wrapEvent(evt) : evt;
if (wrapped instanceof Promise) {
wrapped.then((e) => this.emitEvent(false, e, relay, fromCache, optimisticPublish));
} else if (wrapped) {
// Events from subscriptions are expected to be signed after validation
// We cast to NDKSignedEvent for type safety
this.emit("event", wrapped as NDKSignedEvent, relay, this, fromCache, optimisticPublish);
}
}
public closedReceived(relay: NDKRelay, reason: string): void {
this.emit("closed", relay, reason);
}
// EOSE handling
private eoseTimeout: ReturnType<typeof setTimeout> | undefined;
private eosed = false;
public eoseReceived(relay: NDKRelay): void {
this.debug("EOSE received from %s", relay.url);
this.eosesSeen.add(relay);
let lastEventSeen = this.lastEventReceivedAt ? Date.now() - this.lastEventReceivedAt : undefined;
const hasSeenAllEoses = this.eosesSeen.size === this.relayFilters?.size;
const queryFilled = queryFullyFilled(this);
const performEose = (reason: string) => {
this.debug("Performing EOSE: %s %d", reason, this.eosed);
if (this.eosed) return;
if (this.eoseTimeout) clearTimeout(this.eoseTimeout);
this.emit("eose", this);
this.eosed = true;
if (this.opts?.closeOnEose) this.stop();
};
if (queryFilled || hasSeenAllEoses) {
performEose("query filled or seen all");
} else if (this.relayFilters) {
let timeToWaitForNextEose = 1000;
const connectedRelays = new Set(this.pool.connectedRelays().map((r) => r.url));
const connectedRelaysWithFilters = Array.from(this.relayFilters.keys()).filter((url) =>
connectedRelays.has(url),
);
// if we have no connected relays, wait for all relays to connect
if (connectedRelaysWithFilters.length === 0) {
this.debug(
"No connected relays, waiting for all relays to connect",
Array.from(this.relayFilters.keys()).join(", "),
);
return;
}
// Reduce the number of ms to wait based on the percentage of relays
// that have already sent an EOSE, the more
// relays that have sent an EOSE, the less time we should wait
// for the next one
const percentageOfRelaysThatHaveSentEose = this.eosesSeen.size / connectedRelaysWithFilters.length;
this.debug("Percentage of relays that have sent EOSE", {
subId: this.subId,
percentageOfRelaysThatHaveSentEose,
seen: this.eosesSeen.size,
total: connectedRelaysWithFilters.length,
});
// If less than 2 and 50% of relays have EOSEd don't add a timeout yet
if (this.eosesSeen.size >= 2 && percentageOfRelaysThatHaveSentEose >= 0.5) {
timeToWaitForNextEose = timeToWaitForNextEose * (1 - percentageOfRelaysThatHaveSentEose);
if (timeToWaitForNextEose === 0) {
performEose("time to wait was 0");
return;
}
if (this.eoseTimeout) clearTimeout(this.eoseTimeout);
const sendEoseTimeout = () => {
lastEventSeen = this.lastEventReceivedAt ? Date.now() - this.lastEventReceivedAt : undefined;
// If we have seen an event in the past 20ms don't emit an EOSE due to a timeout, events
// are still being received
if (lastEventSeen !== undefined && lastEventSeen < 20) {
this.eoseTimeout = setTimeout(sendEoseTimeout, timeToWaitForNextEose);
} else {
performEose(`send eose timeout: ${timeToWaitForNextEose}`);
}
};
this.eoseTimeout = setTimeout(sendEoseTimeout, timeToWaitForNextEose);
}
}
}
}
const kindIsEphemeral = (kind: NDKKind) => kind >= 20000 && kind < 30000;