diff --git a/README.md b/README.md index b3bc34b..e2867b1 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,14 @@ import WebSocket from 'ws' useWebSocketImplementation(WebSocket) ``` +You can enable regular pings of connected relays with the `enablePing` option. This will set up a heartbeat that closes the websocket if it doesn't receive a response in time. Some platforms don't report websocket disconnections due to network issues, and enabling this can increase reliability. + +```js +import { SimplePool } from 'nostr-tools/pool' + +const pool = new SimplePool({ enablePing: true }) +``` + ### Parsing references (mentions) from a content based on NIP-27 ```js diff --git a/abstract-pool.ts b/abstract-pool.ts index 85e6e69..87cd3d9 100644 --- a/abstract-pool.ts +++ b/abstract-pool.ts @@ -32,6 +32,7 @@ export class AbstractSimplePool { public trackRelays: boolean = false public verifyEvent: Nostr['verifyEvent'] + public enablePing: boolean | undefined public trustedRelayURLs: Set = new Set() private _WebSocket?: typeof WebSocket @@ -39,6 +40,7 @@ export class AbstractSimplePool { constructor(opts: AbstractPoolConstructorOptions) { this.verifyEvent = opts.verifyEvent this._WebSocket = opts.websocketImplementation + this.enablePing = opts.enablePing } async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise { @@ -49,6 +51,7 @@ export class AbstractSimplePool { relay = new AbstractRelay(url, { verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent, websocketImplementation: this._WebSocket, + enablePing: this.enablePing, }) relay.onclose = () => { this.relays.delete(url) diff --git a/abstract-relay.ts b/abstract-relay.ts index 33a68d6..1d93833 100644 --- a/abstract-relay.ts +++ b/abstract-relay.ts @@ -15,6 +15,7 @@ type RelayWebSocket = WebSocket & { export type AbstractRelayConstructorOptions = { verifyEvent: Nostr['verifyEvent'] websocketImplementation?: typeof WebSocket + enablePing?: boolean } export class SendingOnClosedConnection extends Error { @@ -34,7 +35,10 @@ export class AbstractRelay { public baseEoseTimeout: number = 4400 public connectionTimeout: number = 4400 public publishTimeout: number = 4400 + public pingFrequency: number = 20000 + public pingTimeout: number = 20000 public openSubs: Map = new Map() + public enablePing: boolean | undefined private connectionTimeoutHandle: ReturnType | undefined private connectionPromise: Promise | undefined @@ -54,9 +58,7 @@ export class AbstractRelay { this.url = normalizeURL(url) this.verifyEvent = opts.verifyEvent this._WebSocket = opts.websocketImplementation || WebSocket - // this.pingHeartBeat = opts.pingHeartBeat - // this.pingFrequency = opts.pingFrequency - // this.pingTimeout = opts.pingTimeout + this.enablePing = opts.enablePing } static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise { @@ -110,8 +112,7 @@ export class AbstractRelay { this.ws.onopen = () => { clearTimeout(this.connectionTimeoutHandle) this._connected = true - if (this.ws && this.ws.ping) { - // && this.pingHeartBeat + if (this.enablePing) { this.pingpong() } resolve() @@ -145,9 +146,26 @@ export class AbstractRelay { return this.connectionPromise } - private async receivePong() { + private async waitForPingPong() { return new Promise((res, err) => { + // listen for pong ;(this.ws && this.ws.on && this.ws.on('pong', () => res(true))) || err("ws can't listen for pong") + // send a ping + this.ws && this.ws.ping && this.ws.ping() + }) + } + + private async waitForDummyReq() { + return new Promise((res, err) => { + // make a dummy request with expected empty eose reply + // ["REQ", "_", {"ids":["aaaa...aaaa"]}] + const sub = this.subscribe([{ ids: ['a'.repeat(64)] }], { + oneose: () => { + sub.close() + res(true) + }, + eoseTimeout: this.pingTimeout + 1000, + }) }) } @@ -155,21 +173,22 @@ export class AbstractRelay { // in browsers it's done automatically. see https://github.com/nbd-wtf/nostr-tools/issues/491 private async pingpong() { // if the websocket is connected - if (this.ws?.readyState == 1) { - // send a ping - this.ws && this.ws.ping && this.ws.ping() - // wait for either a pong or a timeout + if (this.ws?.readyState === 1) { + // wait for either a ping-pong reply or a timeout const result = await Promise.any([ - this.receivePong(), - new Promise(res => setTimeout(() => res(false), 10000)), // TODO: opts.pingTimeout + // browsers don't have ping so use a dummy req + this.ws && this.ws.ping && this.ws.on ? this.waitForPingPong() : this.waitForDummyReq(), + new Promise(res => setTimeout(() => res(false), this.pingTimeout)), ]) - console.error('pingpong result', result) if (result) { // schedule another pingpong - setTimeout(() => this.pingpong(), 10000) // TODO: opts.pingFrequency + setTimeout(() => this.pingpong(), this.pingFrequency) } else { // pingpong closing socket - this.ws && this.ws.close() + this.closeAllSubscriptions('pingpong timed out') + this._connected = false + this.ws?.close() + this.onclose?.() } } } diff --git a/nip19.test.ts b/nip19.test.ts index d0399c0..7648d69 100644 --- a/nip19.test.ts +++ b/nip19.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +// prettier-ignore import { decode, naddrEncode, diff --git a/pool.ts b/pool.ts index 37c970e..fb457e8 100644 --- a/pool.ts +++ b/pool.ts @@ -14,8 +14,8 @@ export function useWebSocketImplementation(websocketImplementation: any) { } export class SimplePool extends AbstractSimplePool { - constructor() { - super({ verifyEvent, websocketImplementation: _WebSocket }) + constructor(options?: { enablePing?: boolean }) { + super({ verifyEvent, websocketImplementation: _WebSocket, ...options }) } }