UNPKG

@nostr-dev-kit/ndk

Version:

NDK - Nostr Development Kit. Includes AI Guardrails to catch common mistakes during development.

928 lines (824 loc) 38.5 kB
import type debug from "debug"; import type { NostrEvent } from "../events/index.js"; import { NDKEvent } from "../events/index.js"; import { NDKKind } from "../events/kinds"; import type { NDK, NDKNetDebug } from "../ndk/index.js"; import type { NDKFilter } from "../subscription"; import type { NDKRelay, NDKRelayConnectionStats } from "."; import { NDKRelayStatus } from "."; import { NDKRelayKeepalive, probeRelayConnection } from "./keepalive"; import type { NDKRelaySubscription } from "./subscription"; // Removed MAX_RECONNECT_ATTEMPTS - we retry indefinitely with capped backoff const FLAPPING_THRESHOLD_MS = 1000; import type { NDKCountResult } from "../count/index.js"; import { NDKCountHll } from "../count/index.js"; export type CountResolver = { resolve: (result: NDKCountResult) => void; reject: (err: Error) => void; }; export type EventPublishResolver = { resolve: (reason: string) => void; reject: (err: Error) => void; }; export class NDKRelayConnectivity { private ndkRelay: NDKRelay; private ws?: WebSocket; private _status: NDKRelayStatus; private timeoutMs?: number; private connectedAt?: number; private _connectionStats: NDKRelayConnectionStats = { attempts: 0, success: 0, durations: [], }; private debug: debug.Debugger; public netDebug?: NDKNetDebug; private connectTimeout: ReturnType<typeof setTimeout> | undefined; private reconnectTimeout: ReturnType<typeof setTimeout> | undefined; private ndk?: NDK; public openSubs: Map<string, NDKRelaySubscription> = new Map(); private openCountRequests = new Map<string, CountResolver>(); private openEventPublishes = new Map<string, EventPublishResolver[]>(); private pendingAuthPublishes = new Map<string, NostrEvent>(); private serial = 0; public baseEoseTimeout = 4_400; // Keepalive and monitoring private keepalive?: NDKRelayKeepalive; private wsStateMonitor?: ReturnType<typeof setInterval>; private sleepDetector?: ReturnType<typeof setInterval>; private lastSleepCheck = Date.now(); private lastMessageSent = Date.now(); private wasIdle = false; constructor(ndkRelay: NDKRelay, ndk?: NDK) { this.ndkRelay = ndkRelay; this._status = NDKRelayStatus.DISCONNECTED; const rand = Math.floor(Math.random() * 1000); this.debug = this.ndkRelay.debug.extend(`connectivity${rand}`); this.ndk = ndk; this.setupMonitoring(); } /** * Sets up keepalive, WebSocket state monitoring, and sleep detection */ private setupMonitoring(): void { // Setup keepalive to detect silent relays this.keepalive = new NDKRelayKeepalive(120000, async () => { this.debug("Relay silence detected, probing connection"); const isAlive = await probeRelayConnection({ send: (msg: any[]) => this.send(JSON.stringify(msg)), once: (event: string, handler: () => void) => { const messageHandler = (e: MessageEvent) => { try { const data = JSON.parse(e.data); if (data[0] === "EOSE" || data[0] === "EVENT" || data[0] === "NOTICE") { handler(); this.ws?.removeEventListener("message", messageHandler); } } catch {} }; this.ws?.addEventListener("message", messageHandler); }, }); if (!isAlive) { this.debug("Probe failed, connection is stale"); this.handleStaleConnection(); } }); // Monitor WebSocket readyState every 5 seconds this.wsStateMonitor = setInterval(() => { if (this._status === NDKRelayStatus.CONNECTED) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { this.debug("WebSocket died silently, reconnecting"); this.handleStaleConnection(); } } }, 5000); // Detect system sleep by monitoring time gaps this.sleepDetector = setInterval(() => { const now = Date.now(); const elapsed = now - this.lastSleepCheck; // If more than 15 seconds elapsed (should be 10), system was likely suspended if (elapsed > 15000) { this.debug(`Detected possible sleep/wake (${elapsed}ms gap)`); this.handlePossibleWake(); } this.lastSleepCheck = now; }, 10000); } /** * Handles detection of a stale connection by cleaning up and triggering reconnection. */ private handleStaleConnection(): void { this.wasIdle = true; // Mark as idle to reset backoff // Stop keepalive this.keepalive?.stop(); // Clean up the dead WebSocket if (this.ws) { try { this.ws.close(); } catch (e) { // Ignore errors closing dead socket } this.ws = undefined; } this._status = NDKRelayStatus.DISCONNECTED; this.ndkRelay.emit("disconnect"); // Trigger reconnection for stale connections this.handleReconnection(); } /** * Handles possible system wake event */ private handlePossibleWake(): void { this.debug("System wake detected, checking all connections"); this.wasIdle = true; // Reset backoff for wake scenario // If we think we're connected but might not be, force reconnect if (this._status >= NDKRelayStatus.CONNECTED) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { this.handleStaleConnection(); } else { // Connection seems OK, but probe to be sure probeRelayConnection({ send: (msg: any[]) => this.send(JSON.stringify(msg)), once: (event: string, handler: () => void) => { const messageHandler = (e: MessageEvent) => { try { const data = JSON.parse(e.data); if (data[0] === "EOSE" || data[0] === "EVENT" || data[0] === "NOTICE") { handler(); this.ws?.removeEventListener("message", messageHandler); } } catch {} }; this.ws?.addEventListener("message", messageHandler); }, }).then((isAlive) => { if (!isAlive) { this.handleStaleConnection(); } }); } } } /** * Resets the reconnection state for system-wide events * Used by NDKPool when detecting system sleep/wake */ public resetReconnectionState(): void { this.wasIdle = true; if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = undefined; } } /** * Connects to the NDK relay and handles the connection lifecycle. * * This method attempts to establish a WebSocket connection to the NDK relay specified in the `ndkRelay` object. * If the connection is successful, it updates the connection statistics, sets the connection status to `CONNECTED`, * and emits `connect` and `ready` events on the `ndkRelay` object. * * If the connection attempt fails, it handles the error by either initiating a reconnection attempt or emitting a * `delayed-connect` event on the `ndkRelay` object, depending on the `reconnect` parameter. * * @param timeoutMs - The timeout in milliseconds for the connection attempt. If not provided, the default timeout from the `ndkRelay` object is used. * @param reconnect - Indicates whether a reconnection should be attempted if the connection fails. Defaults to `true`. * @returns A Promise that resolves when the connection is established, or rejects if the connection fails. */ async connect(timeoutMs?: number, reconnect = true): Promise<void> { // Check if WebSocket exists but is not open (stale connection) if (this.ws && this.ws.readyState !== WebSocket.OPEN && this.ws.readyState !== WebSocket.CONNECTING) { this.debug("Cleaning up stale WebSocket connection"); try { this.ws.close(); } catch (e) { // Ignore errors when closing stale connection } this.ws = undefined; this._status = NDKRelayStatus.DISCONNECTED; } if ( (this._status !== NDKRelayStatus.RECONNECTING && this._status !== NDKRelayStatus.DISCONNECTED) || this.reconnectTimeout ) { this.debug( "Relay requested to be connected but was in state %s or it had a reconnect timeout", this._status, ); return; } if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = undefined; } if (this.connectTimeout) { clearTimeout(this.connectTimeout); this.connectTimeout = undefined; } timeoutMs ??= this.timeoutMs; if (!this.timeoutMs && timeoutMs) this.timeoutMs = timeoutMs; if (this.timeoutMs) this.connectTimeout = setTimeout(() => this.onConnectionError(reconnect), this.timeoutMs); try { this.updateConnectionStats.attempt(); if (this._status === NDKRelayStatus.DISCONNECTED) this._status = NDKRelayStatus.CONNECTING; else this._status = NDKRelayStatus.RECONNECTING; this.ws = new WebSocket(this.ndkRelay.url); this.ws.onopen = this.onConnect.bind(this); this.ws.onclose = this.onDisconnect.bind(this); this.ws.onmessage = this.onMessage.bind(this); this.ws.onerror = this.onError.bind(this); } catch (e) { this.debug(`Failed to connect to ${this.ndkRelay.url}`, e); this._status = NDKRelayStatus.DISCONNECTED; if (reconnect) this.handleReconnection(); else this.ndkRelay.emit("delayed-connect", 2 * 24 * 60 * 60 * 1000); throw e; } } /** * Disconnects the WebSocket connection to the NDK relay. * This method sets the connection status to `NDKRelayStatus.DISCONNECTING`, * attempts to close the WebSocket connection, and sets the status to * `NDKRelayStatus.DISCONNECTED` if the disconnect operation fails. */ public disconnect(): void { this._status = NDKRelayStatus.DISCONNECTING; // Clean up monitoring this.keepalive?.stop(); if (this.wsStateMonitor) { clearInterval(this.wsStateMonitor); this.wsStateMonitor = undefined; } if (this.sleepDetector) { clearInterval(this.sleepDetector); this.sleepDetector = undefined; } try { this.ws?.close(); } catch (e) { this.debug("Failed to disconnect", e); this._status = NDKRelayStatus.DISCONNECTED; } } /** * Handles the error that occurred when attempting to connect to the NDK relay. * If `reconnect` is `true`, this method will initiate a reconnection attempt. * Otherwise, it will emit a `delayed-connect` event on the `ndkRelay` object, * indicating that a reconnection should be attempted after a delay. * * @param reconnect - Indicates whether a reconnection should be attempted. */ onConnectionError(reconnect: boolean): void { this.debug(`Error connecting to ${this.ndkRelay.url}`, this.timeoutMs); if (reconnect && !this.reconnectTimeout) { this.handleReconnection(); } } /** * Handles the connection event when the WebSocket connection is established. * This method is called when the WebSocket connection is successfully opened. * It clears any existing connection and reconnection timeouts, updates the connection statistics, * sets the connection status to `CONNECTED`, and emits `connect` and `ready` events on the `ndkRelay` object. */ private onConnect() { this.netDebug?.("connected", this.ndkRelay); if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = undefined; } if (this.connectTimeout) { clearTimeout(this.connectTimeout); this.connectTimeout = undefined; } this.updateConnectionStats.connected(); this._status = NDKRelayStatus.CONNECTED; // Start keepalive monitoring this.keepalive?.start(); this.wasIdle = false; this.ndkRelay.emit("connect"); this.ndkRelay.emit("ready"); } /** * Handles the disconnection event when the WebSocket connection is closed. * This method is called when the WebSocket connection is successfully closed. * It updates the connection statistics, sets the connection status to `DISCONNECTED`, * initiates a reconnection attempt if we didn't disconnect ourselves, * and emits a `disconnect` event on the `ndkRelay` object. */ private onDisconnect() { this.netDebug?.("disconnected", this.ndkRelay); this.updateConnectionStats.disconnected(); // Stop keepalive when disconnected this.keepalive?.stop(); // Clear any pending publish/auth promises to prevent memory leaks this.clearPendingPublishes(new Error(`Relay ${this.ndkRelay.url} disconnected`)); if (this._status === NDKRelayStatus.CONNECTED) { this.handleReconnection(); } this._status = NDKRelayStatus.DISCONNECTED; this.ndkRelay.emit("disconnect"); } /** * Handles incoming messages from the NDK relay WebSocket connection. * This method is called whenever a message is received from the relay. * It parses the message data and dispatches the appropriate handling logic based on the message type. * * @param event - The MessageEvent containing the received message data. */ private onMessage(event: MessageEvent): void { this.netDebug?.(event.data, this.ndkRelay, "recv"); // Record any activity from relay this.keepalive?.recordActivity(); // NOTE: We intentionally DON'T early-return for "already seen" events. // The seenEvents optimization was causing a bug: if subscription A sees event X, // then subscription B is created later and requests event X, the relay sends it // but it was being skipped because it's "already seen" globally. // // Instead, we let dispatchEvent handle routing to ALL matching subscriptions. // Each subscription's eventFirstSeen (checked in eventReceived) will skip // re-validation for events it's already processed, maintaining the performance // benefit while ensuring new subscriptions receive events they need. // // The seenEvents tracking in dispatchEvent() is still used for: // - exclusiveRelay filtering (checking which relays have sent an event) // - Tracking event provenance try { const data = JSON.parse(event.data); const [cmd, id, ..._rest] = data; // Check for registered protocol handlers first const handler = this.ndkRelay.getProtocolHandler(cmd); if (handler) { handler(this.ndkRelay, data); return; } switch (cmd) { case "EVENT": { const so = this.openSubs.get(id); const event = data[2] as NostrEvent; if (!so) { this.debug(`Received event for unknown subscription ${id}`); return; } so.onevent(event); return; } case "COUNT": { const payload = data[2] as { count: number; hll?: string }; const cr = this.openCountRequests.get(id) as CountResolver; if (cr) { const result: NDKCountResult = { count: payload.count }; // Parse HLL if present (NIP-45 HyperLogLog support) if (payload.hll) { try { result.hll = NDKCountHll.fromHex(payload.hll); } catch (e) { this.debug("Failed to parse HLL from COUNT response:", e); } } cr.resolve(result); this.openCountRequests.delete(id); } return; } case "EOSE": { const so = this.openSubs.get(id); if (!so) return; so.oneose(id); return; } case "OK": { const ok: boolean = data[2]; const reason: string = data[3]; const ep = this.openEventPublishes.get(id) as EventPublishResolver[] | undefined; const firstEp = ep?.pop(); if (!ep || !firstEp) { this.debug("Received OK for unknown event publish", id); return; } if (ok) { firstEp.resolve(reason); // Clean up the pending auth publish since it succeeded this.pendingAuthPublishes.delete(id); } else { // Check if this is an auth-required error // Different relays use different error messages for auth requirements const isAuthRequired = reason && (reason.toLowerCase().includes("auth-required") || reason.toLowerCase().includes("not authorized") || reason.toLowerCase().includes("blocked: not authorized")); if (isAuthRequired) { // Get the pending event from pendingAuthPublishes const event = this.pendingAuthPublishes.get(id); if (event) { this.debug("Publish failed due to auth-required, will retry after auth", id); // Don't reject yet - keep the resolver for retry after auth // Put the resolver back so we can resolve/reject it after auth ep.push(firstEp); this.openEventPublishes.set(id, ep); } else { // Event not found in pending, reject normally firstEp.reject(new Error(reason)); } } else { firstEp.reject(new Error(reason)); // Clean up the pending auth publish for non-auth errors this.pendingAuthPublishes.delete(id); } } if (ep.length === 0) { this.openEventPublishes.delete(id); } else if ( !ok && !( reason?.toLowerCase().includes("auth-required") || reason?.toLowerCase().includes("not authorized") || reason?.toLowerCase().includes("blocked: not authorized") ) ) { // Only clear the publish map if it's not auth-required this.openEventPublishes.set(id, ep); } return; } case "CLOSED": { const so = this.openSubs.get(id); if (!so) return; so.onclosed(data[2] as string); return; } case "NOTICE": this.onNotice(data[1] as string); return; case "AUTH": { this.onAuthRequested(data[1] as string); return; } } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.debug(`Error parsing message from ${this.ndkRelay.url}: ${error.message}`, error?.stack); return; } } /** * Handles an authentication request from the NDK relay. * * If an authentication policy is configured, it will be used to authenticate the connection. * Otherwise, the `auth` event will be emitted to allow the application to handle the authentication. * * @param challenge - The authentication challenge provided by the NDK relay. */ private async onAuthRequested(challenge: string) { const authPolicy = this.ndkRelay.authPolicy ?? this.ndk?.relayAuthDefaultPolicy; this.debug("Relay requested authentication", { havePolicy: !!authPolicy, }); if (this._status === NDKRelayStatus.AUTHENTICATING) { this.debug("Already authenticating, ignoring"); return; } this._status = NDKRelayStatus.AUTH_REQUESTED; if (authPolicy) { if (this._status >= NDKRelayStatus.CONNECTED) { this._status = NDKRelayStatus.AUTHENTICATING; let res: boolean | NDKEvent | undefined | undefined; try { res = await authPolicy(this.ndkRelay, challenge); } catch (e) { this.debug("Authentication policy threw an error", e); res = false; } this.debug("Authentication policy returned", !!res); if (res instanceof NDKEvent || res === true) { if (res instanceof NDKEvent) { await this.auth(res); } const authenticate = async () => { if (this._status >= NDKRelayStatus.CONNECTED && this._status < NDKRelayStatus.AUTHENTICATED) { const event = new NDKEvent(this.ndk); event.kind = NDKKind.ClientAuth; event.tags = [ ["relay", this.ndkRelay.url], ["challenge", challenge], ]; await event.sign(); this.auth(event) .then(() => { this._status = NDKRelayStatus.AUTHENTICATED; this.ndkRelay.emit("authed"); this.debug("Authentication successful"); this.retryPendingAuthPublishes(); }) .catch((e) => { this._status = NDKRelayStatus.AUTH_REQUESTED; this.ndkRelay.emit("auth:failed", e); this.debug("Authentication failed", e); this.rejectPendingAuthPublishes(e); }); } else { this.debug("Authentication failed, it changed status, status is %d", this._status); } }; if (res === true) { if (!this.ndk?.signer) { this.debug("No signer available for authentication localhost"); this.ndk?.once("signer:ready", authenticate); } else { authenticate().catch((e) => { console.error("Error authenticating", e); }); } } this._status = NDKRelayStatus.CONNECTED; this.ndkRelay.emit("authed"); } } } else { this.ndkRelay.emit("auth", challenge); } } /** * Handles errors that occur on the WebSocket connection to the relay. * @param error - The error or event that occurred. */ private onError(error: Error | Event): void { this.debug(`WebSocket error on ${this.ndkRelay.url}:`, error); } /** * Gets the current status of the NDK relay connection. * @returns {NDKRelayStatus} The current status of the NDK relay connection. */ get status(): NDKRelayStatus { return this._status; } /** * Checks if the NDK relay connection is currently available. * @returns {boolean} `true` if the relay connection is in the `CONNECTED` status, `false` otherwise. */ public isAvailable(): boolean { return this._status === NDKRelayStatus.CONNECTED; } /** * Checks if the NDK relay connection is flapping, which means the connection is rapidly * disconnecting and reconnecting. This is determined by analyzing the durations of the * last three connection attempts. If the standard deviation of the durations is less * than 1000 milliseconds, the connection is considered to be flapping. * * @returns {boolean} `true` if the connection is flapping, `false` otherwise. */ private isFlapping(): boolean { const durations = this._connectionStats.durations; if (durations.length % 3 !== 0) return false; const sum = durations.reduce((a, b) => a + b, 0); const avg = sum / durations.length; const variance = durations.map((x) => (x - avg) ** 2).reduce((a, b) => a + b, 0) / durations.length; const stdDev = Math.sqrt(variance); const isFlapping = stdDev < FLAPPING_THRESHOLD_MS; return isFlapping; } /** * Handles a notice received from the NDK relay. * If the notice indicates the relay is complaining (e.g. "too many" or "maximum"), * the method disconnects from the relay and attempts to reconnect after a 2-second delay. * A debug message is logged with the relay URL and the notice text. * The "notice" event is emitted on the ndkRelay instance with the notice text. * * @param notice - The notice text received from the NDK relay. */ private async onNotice(notice: string) { this.ndkRelay.emit("notice", notice); } /** * Attempts to reconnect to the NDK relay after a connection is lost. * This function is called recursively to handle multiple reconnection attempts. * It checks if the relay is flapping and emits a "flapping" event if so. * It then calculates a delay before the next reconnection attempt based on the number of previous attempts. * The function sets a timeout to execute the next reconnection attempt after the calculated delay. * If the maximum number of reconnection attempts is reached, a debug message is logged. * * @param attempt - The current attempt number (default is 0). */ private handleReconnection(attempt = 0): void { if (this.reconnectTimeout) return; if (this.isFlapping()) { this.ndkRelay.emit("flapping", this._connectionStats); this._status = NDKRelayStatus.FLAPPING; return; } // Calculate reconnect delay based on whether we were idle let reconnectDelay: number; if (this.wasIdle) { // After idle/sleep, use aggressive reconnection: 0s, 1s, 2s, 5s, 10s, 30s const aggressiveDelays = [0, 1000, 2000, 5000, 10000, 30000]; reconnectDelay = aggressiveDelays[Math.min(attempt, aggressiveDelays.length - 1)]; this.debug(`Using aggressive reconnect after idle, attempt ${attempt}, delay ${reconnectDelay}ms`); } else if (this.connectedAt) { // Recent disconnection, wait before reconnecting reconnectDelay = Math.max(0, 60000 - (Date.now() - this.connectedAt)); } else { // Standard exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max reconnectDelay = Math.min(1000 * 2 ** attempt, 30000); this.debug(`Using standard backoff, attempt ${attempt}, delay ${reconnectDelay}ms`); } this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = undefined; this._status = NDKRelayStatus.RECONNECTING; this.connect().catch(() => { // Always keep retrying with backoff this.handleReconnection(attempt + 1); }); }, reconnectDelay); this.ndkRelay.emit("delayed-connect", reconnectDelay); this.debug("Reconnecting in", reconnectDelay); this._connectionStats.nextReconnectAt = Date.now() + reconnectDelay; } /** * Sends a message to the NDK relay if the connection is in the CONNECTED state and the WebSocket is open. * If the connection is not in the CONNECTED state or the WebSocket is not open, logs a debug message and throws an error. * * @param message - The message to send to the NDK relay. * @throws {Error} If attempting to send on a closed relay connection. */ async send(message: string) { // Check if we've been idle for a while const idleTime = Date.now() - this.lastMessageSent; if (idleTime > 120000) { // 2 minutes this.wasIdle = true; } if (this._status >= NDKRelayStatus.CONNECTED && this.ws?.readyState === WebSocket.OPEN) { this.ws?.send(message); this.netDebug?.(message, this.ndkRelay, "send"); this.lastMessageSent = Date.now(); } else { this.debug(`Not connected to ${this.ndkRelay.url} (%d), not sending message ${message}`, this._status); // If we think we're connected but WebSocket is not open, we have a stale connection if (this._status >= NDKRelayStatus.CONNECTED && this.ws?.readyState !== WebSocket.OPEN) { this.debug(`Stale connection detected, WebSocket state: ${this.ws?.readyState}`); // Force disconnect and reconnect this.handleStaleConnection(); } } } /** * Authenticates the NDK event by sending it to the NDK relay and returning a promise that resolves with the result. * * @param event - The NDK event to authenticate. * @returns A promise that resolves with the authentication result. */ private async auth(event: NDKEvent): Promise<string> { const ret = new Promise<string>((resolve, reject) => { const val = this.openEventPublishes.get(event.id) ?? []; val.push({ resolve, reject }); this.openEventPublishes.set(event.id, val); }); this.send(`["AUTH",${JSON.stringify(event.rawEvent())}]`); return ret; } /** * Clears all pending publish promises by rejecting them with the provided error. * This is called on disconnection to prevent memory leaks and ensure promises * don't hang indefinitely. * @param error The error to reject the promises with */ private clearPendingPublishes(error: Error): void { // Reject any promises waiting for auth-required retry // rejectPendingAuthPublishes handles both pendingAuthPublishes and openEventPublishes this.rejectPendingAuthPublishes(error); // Clear any other outstanding publishes not related to auth for (const [eventId, resolvers] of this.openEventPublishes.entries()) { while (resolvers.length > 0) { const resolver = resolvers.shift(); if (resolver) { resolver.reject(error); } } this.openEventPublishes.delete(eventId); } } /** * Retries all pending publishes that failed due to auth-required. * Called after successful authentication. */ private retryPendingAuthPublishes(): void { if (this.pendingAuthPublishes.size === 0) return; this.debug(`Retrying ${this.pendingAuthPublishes.size} pending publishes after auth`); for (const [eventId, event] of this.pendingAuthPublishes.entries()) { this.debug(`Retrying publish for event ${eventId}`); // Resend the event this.send(`["EVENT",${JSON.stringify(event)}]`); } // Clear the pending publishes - they're now back in openEventPublishes waiting for OK this.pendingAuthPublishes.clear(); } /** * Rejects all pending publishes that failed due to auth-required. * Called when authentication fails. */ private rejectPendingAuthPublishes(error: Error): void { if (this.pendingAuthPublishes.size === 0) return; this.debug(`Rejecting ${this.pendingAuthPublishes.size} pending publishes due to auth failure`); for (const [eventId] of this.pendingAuthPublishes.entries()) { const ep = this.openEventPublishes.get(eventId); if (ep && ep.length > 0) { const resolver = ep.pop(); if (resolver) { resolver.reject(new Error(`Authentication failed: ${error.message}`)); } if (ep.length === 0) { this.openEventPublishes.delete(eventId); } } } this.pendingAuthPublishes.clear(); } /** * Publishes an NDK event to the relay and returns a promise that resolves with the result. * * @param event - The NDK event to publish. * @returns A promise that resolves with the result of the event publication. * @throws {Error} If attempting to publish on a closed relay connection. */ async publish(event: NostrEvent): Promise<string> { const ret = new Promise<string>((resolve, reject) => { const val = this.openEventPublishes.get(event.id!) ?? []; if (val.length > 0) { console.warn(`Duplicate event publishing detected, you are publishing event ${event.id!} twice`); } val.push({ resolve, reject }); this.openEventPublishes.set(event.id!, val); }); // Store the event in case we need to retry after auth this.pendingAuthPublishes.set(event.id!, event); this.send(`["EVENT",${JSON.stringify(event)}]`); return ret; } /** * Counts the number of events that match the provided filters. * * @param filters - The filters to apply to the count request. * @param params - An optional object containing a custom id for the count request. * @returns A promise that resolves with the count result including optional HLL data. * @throws {Error} If attempting to send the count request on a closed relay connection. */ async count(filters: NDKFilter[], params: { id?: string | null }): Promise<NDKCountResult> { this.serial++; const id = params?.id || `count:${this.serial}`; const ret = new Promise<NDKCountResult>((resolve, reject) => { this.openCountRequests.set(id, { resolve, reject }); }); this.send(`["COUNT","${id}",${JSON.stringify(filters).substring(1)}`); return ret; } public close(subId: string, reason?: string): void { this.send(`["CLOSE","${subId}"]`); const sub = this.openSubs.get(subId); this.openSubs.delete(subId); if (sub) sub.onclose(reason); } /** * Subscribes to the NDK relay with the provided filters and parameters. * * @param filters - The filters to apply to the subscription. * @param params - The subscription parameters, including an optional custom id. * @returns A new NDKRelaySubscription instance. */ public req(relaySub: NDKRelaySubscription): void { `${this.send(`["REQ","${relaySub.subId}",${JSON.stringify(relaySub.executeFilters).substring(1)}`)}]`; this.openSubs.set(relaySub.subId, relaySub); } /** * Utility functions to update the connection stats. */ private updateConnectionStats = { connected: () => { this._connectionStats.success++; this._connectionStats.connectedAt = Date.now(); }, disconnected: () => { if (this._connectionStats.connectedAt) { this._connectionStats.durations.push(Date.now() - this._connectionStats.connectedAt); if (this._connectionStats.durations.length > 100) { this._connectionStats.durations.shift(); } } this._connectionStats.connectedAt = undefined; }, attempt: () => { this._connectionStats.attempts++; this._connectionStats.connectedAt = Date.now(); }, }; /** Returns the connection stats. */ get connectionStats(): NDKRelayConnectionStats { return this._connectionStats; } /** Returns the relay URL */ get url(): WebSocket["url"] { return this.ndkRelay.url; } get connected(): boolean { return this._status >= NDKRelayStatus.CONNECTED && this.ws?.readyState === WebSocket.OPEN; } }