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