UNPKG

@nostr-dev-kit/ndk

Version:

NDK - Nostr Development Kit

611 lines (550 loc) 24.4 kB
import type { NDKRelay, NDKRelayConnectionStats } from "."; import { NDKRelayStatus } from "."; import { NDKEvent } from "../events/index.js"; import type { NostrEvent } from "../events/index.js"; import { NDKKind } from "../events/kinds"; import type { NDK, NDKNetDebug } from "../ndk/index.js"; import type { NDKFilter } from "../subscription"; import type { NDKRelaySubscription } from "./subscription"; const MAX_RECONNECT_ATTEMPTS = 5; const FLAPPING_THRESHOLD_MS = 1000; export type CountResolver = { resolve: (count: number) => 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 serial = 0; public baseEoseTimeout = 4_400; 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; } /** * 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> { if ( this._status !== NDKRelayStatus.RECONNECTING && this._status !== NDKRelayStatus.DISCONNECTED ) { 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; 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; 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(); 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"); try { const data = JSON.parse(event.data); const [cmd, id, ..._rest] = data; 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 }; const cr = this.openCountRequests.get(id) as CountResolver; if (cr) { cr.resolve(payload.count); 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); else firstEp.reject(new Error(reason)); if (ep.length === 0) { this.openEventPublishes.delete(id); } else { 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"); }) .catch((e) => { this._status = NDKRelayStatus.AUTH_REQUESTED; this.ndkRelay.emit("auth:failed", e); this.debug("Authentication failed", 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; } const reconnectDelay = this.connectedAt ? Math.max(0, 60000 - (Date.now() - this.connectedAt)) : 5000 * (this._connectionStats.attempts + 1); this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = undefined; this._status = NDKRelayStatus.RECONNECTING; // this.debug(`Reconnection attempt #${attempt}`); this.connect().catch((_err) => { // this.debug("Reconnect failed", err); if (attempt < MAX_RECONNECT_ATTEMPTS) { setTimeout( () => { this.handleReconnection(attempt + 1); }, (1000 * (attempt + 1)) ^ 4 ); } else { this.debug("Reconnect failed"); } }); }, 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) { if (this._status >= NDKRelayStatus.CONNECTED && this.ws?.readyState === WebSocket.OPEN) { this.ws?.send(message); this.netDebug?.(message, this.ndkRelay, "send"); } else { this.debug( `Not connected to ${this.ndkRelay.url} (%d), not sending message ${message}`, this._status ); } } /** * 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; } /** * 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); }); 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 number of matching events. * @throws {Error} If attempting to send the count request on a closed relay connection. */ async count(filters: NDKFilter[], params: { id?: string | null }): Promise<number> { this.serial++; const id = params?.id || `count:${this.serial}`; const ret = new Promise<number>((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; } }