@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
text/typescript
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;
}
}