From 2ebe07b36b97fa3fa078e23964e7e790b8ca53c2 Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sat, 27 Sep 2025 11:48:29 +0800 Subject: [PATCH 01/20] WIP Initial exponential backoff reconnect implementation. --- README.md | 26 ++++++++++++++++++++ abstract-relay.ts | 60 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5fdc50e..9ef124e 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,32 @@ import { SimplePool } from 'nostr-tools/pool' const pool = new SimplePool({ enablePing: true }) ``` +You can also enable automatic reconnection with the `enableReconnect` option. This will make the pool try to reconnect to relays with an exponential backoff delay if the connection is lost unexpectedly. + +```js +import { SimplePool } from 'nostr-tools/pool' + +const pool = new SimplePool({ enableReconnect: true }) +``` + +On Node.js, it is recommended to also use `enablePing: true` to ensure network disconnections are properly detected. + +```js +// on Node.js +const pool = new SimplePool({ enablePing: true, enableReconnect: true }) +``` + +The `enableReconnect` option can also be a callback function that receives the current subscription filters and returns a new set of filters. This is useful if you want to modify the subscription on reconnect, for example, to update the `since` parameter to fetch only new events. + +```js +const pool = new SimplePool({ + enableReconnect: (filters) => { + const newSince = Math.floor(Date.now() / 1000) + return filters.map(filter => ({ ...filter, since: newSince })) + } +}) +``` + ### Parsing references (mentions) from a content based on NIP-27 ```js diff --git a/abstract-relay.ts b/abstract-relay.ts index 4e09c5e..b1638a0 100644 --- a/abstract-relay.ts +++ b/abstract-relay.ts @@ -7,6 +7,8 @@ import { Queue, normalizeURL } from './utils.ts' import { makeAuthEvent } from './nip42.ts' import { yieldThread } from './helpers.ts' +const resubscribeBackoff = [10000, 10000, 10000, 20000, 20000, 30000, 60000] + type RelayWebSocket = WebSocket & { ping?(): void on?(event: 'pong', listener: () => void): any @@ -16,6 +18,7 @@ export type AbstractRelayConstructorOptions = { verifyEvent: Nostr['verifyEvent'] websocketImplementation?: typeof WebSocket enablePing?: boolean + enableReconnect?: boolean | ((filters: Filter[]) => Filter[]) } export class SendingOnClosedConnection extends Error { @@ -39,7 +42,11 @@ export class AbstractRelay { public pingTimeout: number = 20000 public openSubs: Map = new Map() public enablePing: boolean | undefined + public enableReconnect: boolean | ((filters: Filter[]) => Filter[]) private connectionTimeoutHandle: ReturnType | undefined + private reconnectTimeoutHandle: ReturnType | undefined + private reconnectAttempts: number = 0 + private closedIntentionally: boolean = false private connectionPromise: Promise | undefined private openCountRequests = new Map() @@ -59,6 +66,7 @@ export class AbstractRelay { this.verifyEvent = opts.verifyEvent this._WebSocket = opts.websocketImplementation || WebSocket this.enablePing = opts.enablePing + this.enableReconnect = opts.enableReconnect || false } static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise { @@ -88,6 +96,35 @@ export class AbstractRelay { return this._connected } + private async reconnect(): Promise { + const backoff = resubscribeBackoff[Math.min(this.reconnectAttempts, resubscribeBackoff.length - 1)] + this.reconnectAttempts++ + + this.reconnectTimeoutHandle = setTimeout(async () => { + try { + await this.connect() + } catch (err) { + // this will be called again through onclose/onerror + } + }, backoff) + } + + private handleHardClose(reason: string) { + this._connected = false + this.connectionPromise = undefined + + const wasIntentional = this.closedIntentionally + this.closedIntentionally = false // reset for next time + + if (this.enableReconnect && !wasIntentional) { + this.reconnect() + this.onclose?.() + } else { + this.onclose?.() + this.closeAllSubscriptions(reason) + } + } + public async connect(): Promise { if (this.connectionPromise) return this.connectionPromise @@ -112,6 +149,17 @@ export class AbstractRelay { this.ws.onopen = () => { clearTimeout(this.connectionTimeoutHandle) this._connected = true + this.reconnectAttempts = 0 + + // resubscribe to all open subscriptions + for (const sub of this.openSubs.values()) { + sub.eosed = false + if (typeof this.enableReconnect === 'function') { + sub.filters = this.enableReconnect(sub.filters) + } + sub.fire() + } + if (this.enablePing) { this.pingpong() } @@ -121,19 +169,13 @@ export class AbstractRelay { this.ws.onerror = ev => { clearTimeout(this.connectionTimeoutHandle) reject((ev as any).message || 'websocket error') - this._connected = false - this.connectionPromise = undefined - this.onclose?.() - this.closeAllSubscriptions('relay connection errored') + this.handleHardClose('relay connection errored') } this.ws.onclose = ev => { clearTimeout(this.connectionTimeoutHandle) reject((ev as any).message || 'websocket closed') - this._connected = false - this.connectionPromise = undefined - this.onclose?.() - this.closeAllSubscriptions('relay connection closed') + this.handleHardClose('relay connection closed') } this.ws.onmessage = this._onmessage.bind(this) @@ -372,6 +414,8 @@ export class AbstractRelay { } public close() { + this.closedIntentionally = true + if (this.reconnectTimeoutHandle) clearTimeout(this.reconnectTimeoutHandle) this.closeAllSubscriptions('relay connection closed by us') this._connected = false this.onclose?.() From 65f9642915738a3f70083cbdd2b78fad412aade0 Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 08:57:42 +0800 Subject: [PATCH 02/20] Simplify onclose reconnect. --- abstract-relay.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/abstract-relay.ts b/abstract-relay.ts index b1638a0..5199ff8 100644 --- a/abstract-relay.ts +++ b/abstract-relay.ts @@ -116,11 +116,11 @@ export class AbstractRelay { const wasIntentional = this.closedIntentionally this.closedIntentionally = false // reset for next time + this.onclose?.() + if (this.enableReconnect && !wasIntentional) { this.reconnect() - this.onclose?.() } else { - this.onclose?.() this.closeAllSubscriptions(reason) } } From 6e028f2d10d4190eaa906e33a02d86c6b6d54f4c Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 09:08:34 +0800 Subject: [PATCH 03/20] Allow Relay() to be instantiated with enablePing. --- relay.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/relay.ts b/relay.ts index 7539022..be6bcdb 100644 --- a/relay.ts +++ b/relay.ts @@ -14,12 +14,12 @@ export function useWebSocketImplementation(websocketImplementation: any) { } export class Relay extends AbstractRelay { - constructor(url: string) { - super(url, { verifyEvent, websocketImplementation: _WebSocket }) + constructor(url: string, options?: { enablePing?: boolean }) { + super(url, { verifyEvent, websocketImplementation: _WebSocket, ...options }) } - static async connect(url: string): Promise { - const relay = new Relay(url) + static async connect(url: string, options?: { enablePing?: boolean }): Promise { + const relay = new Relay(url, options) await relay.connect() return relay } From b178f3d5f5e70b65a2b2149eb21bded7865b2c38 Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 09:14:06 +0800 Subject: [PATCH 04/20] Allow enbleReconnect to be passed through. --- abstract-pool.ts | 3 +++ pool.ts | 4 ++-- relay.ts | 9 ++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/abstract-pool.ts b/abstract-pool.ts index 19a60b2..ef221bb 100644 --- a/abstract-pool.ts +++ b/abstract-pool.ts @@ -33,6 +33,7 @@ export class AbstractSimplePool { public verifyEvent: Nostr['verifyEvent'] public enablePing: boolean | undefined + public enableReconnect: boolean | ((filters: Filter[]) => Filter[]) | undefined public trustedRelayURLs: Set = new Set() private _WebSocket?: typeof WebSocket @@ -41,6 +42,7 @@ export class AbstractSimplePool { this.verifyEvent = opts.verifyEvent this._WebSocket = opts.websocketImplementation this.enablePing = opts.enablePing + this.enableReconnect = opts.enableReconnect } async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise { @@ -52,6 +54,7 @@ export class AbstractSimplePool { verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent, websocketImplementation: this._WebSocket, enablePing: this.enablePing, + enableReconnect: this.enableReconnect, }) relay.onclose = () => { this.relays.delete(url) diff --git a/pool.ts b/pool.ts index fb457e8..8c1d3eb 100644 --- a/pool.ts +++ b/pool.ts @@ -1,7 +1,7 @@ /* global WebSocket */ import { verifyEvent } from './pure.ts' -import { AbstractSimplePool } from './abstract-pool.ts' +import { AbstractSimplePool, type AbstractPoolConstructorOptions } from './abstract-pool.ts' var _WebSocket: typeof WebSocket @@ -14,7 +14,7 @@ export function useWebSocketImplementation(websocketImplementation: any) { } export class SimplePool extends AbstractSimplePool { - constructor(options?: { enablePing?: boolean }) { + constructor(options?: Pick) { super({ verifyEvent, websocketImplementation: _WebSocket, ...options }) } } diff --git a/relay.ts b/relay.ts index be6bcdb..6561cac 100644 --- a/relay.ts +++ b/relay.ts @@ -1,7 +1,7 @@ /* global WebSocket */ import { verifyEvent } from './pure.ts' -import { AbstractRelay } from './abstract-relay.ts' +import { AbstractRelay, type AbstractRelayConstructorOptions } from './abstract-relay.ts' var _WebSocket: typeof WebSocket @@ -14,11 +14,14 @@ export function useWebSocketImplementation(websocketImplementation: any) { } export class Relay extends AbstractRelay { - constructor(url: string, options?: { enablePing?: boolean }) { + constructor(url: string, options?: Pick) { super(url, { verifyEvent, websocketImplementation: _WebSocket, ...options }) } - static async connect(url: string, options?: { enablePing?: boolean }): Promise { + static async connect( + url: string, + options?: Pick, + ): Promise { const relay = new Relay(url, options) await relay.connect() return relay From 10904ab392ff2ab1bf657f917649a8eb7ed7f069 Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 09:22:40 +0800 Subject: [PATCH 05/20] Tweak enablePing and enableReconnect in doc. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9ef124e..8a0c996 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,9 @@ 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. +#### enablePing + +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, like Node.js, don't report websocket disconnections due to network issues, and enabling this can increase the reliability of the `onclose` event. ```js import { SimplePool } from 'nostr-tools/pool' @@ -141,6 +143,8 @@ import { SimplePool } from 'nostr-tools/pool' const pool = new SimplePool({ enablePing: true }) ``` +#### enableReconnect + You can also enable automatic reconnection with the `enableReconnect` option. This will make the pool try to reconnect to relays with an exponential backoff delay if the connection is lost unexpectedly. ```js From 2545932e9a3a5738f74a680ad602f0ce19eeab99 Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 09:30:17 +0800 Subject: [PATCH 06/20] Make resubscribeBackoff public/configurable. --- abstract-relay.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/abstract-relay.ts b/abstract-relay.ts index 5199ff8..3e75c6a 100644 --- a/abstract-relay.ts +++ b/abstract-relay.ts @@ -7,8 +7,6 @@ import { Queue, normalizeURL } from './utils.ts' import { makeAuthEvent } from './nip42.ts' import { yieldThread } from './helpers.ts' -const resubscribeBackoff = [10000, 10000, 10000, 20000, 20000, 30000, 60000] - type RelayWebSocket = WebSocket & { ping?(): void on?(event: 'pong', listener: () => void): any @@ -40,6 +38,7 @@ export class AbstractRelay { public publishTimeout: number = 4400 public pingFrequency: number = 20000 public pingTimeout: number = 20000 + public resubscribeBackoff: number[] = [10000, 10000, 10000, 20000, 20000, 30000, 60000] public openSubs: Map = new Map() public enablePing: boolean | undefined public enableReconnect: boolean | ((filters: Filter[]) => Filter[]) @@ -97,7 +96,7 @@ export class AbstractRelay { } private async reconnect(): Promise { - const backoff = resubscribeBackoff[Math.min(this.reconnectAttempts, resubscribeBackoff.length - 1)] + const backoff = this.resubscribeBackoff[Math.min(this.reconnectAttempts, this.resubscribeBackoff.length - 1)] this.reconnectAttempts++ this.reconnectTimeoutHandle = setTimeout(async () => { From ca18534a46411a803319a00f0d94f04b91f52dbd Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 10:10:40 +0800 Subject: [PATCH 07/20] Simulate unresponsive relay in MockRelay. --- test-helpers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test-helpers.ts b/test-helpers.ts index 9c7267e..73c509e 100644 --- a/test-helpers.ts +++ b/test-helpers.ts @@ -26,6 +26,7 @@ export class MockRelay { public url: string public secretKeys: Uint8Array[] public preloadedEvents: Event[] + public unresponsive: boolean = false constructor(url?: string | undefined) { serial++ @@ -48,6 +49,7 @@ export class MockRelay { let subs: { [subId: string]: { conn: any; filters: Filter[] } } = {} conn.on('message', (message: string) => { + if (this.unresponsive) return const data = JSON.parse(message) switch (data[0]) { From 48ccd6ca39d14922cffb75e64acc9d1c3f4340b2 Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 10:43:21 +0800 Subject: [PATCH 08/20] Add tests for enablePing. --- pool.test.ts | 31 +++++++++++++++++++++++++++++++ relay.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/pool.test.ts b/pool.test.ts index 57ebe83..4575c5c 100644 --- a/pool.test.ts +++ b/pool.test.ts @@ -210,6 +210,37 @@ test('get()', async () => { expect(event).toHaveProperty('id', ids[0]) }) +test('ping-pong timeout in pool', async () => { + const mockRelay = mockRelays[0] + pool = new SimplePool({ enablePing: true }) + const relay = await pool.ensureRelay(mockRelay.url) + relay.pingTimeout = 50 + relay.pingFrequency = 50 + + let closed = false + const closedPromise = new Promise(resolve => { + relay.onclose = () => { + closed = true + resolve() + } + }) + + expect(relay.connected).toBeTrue() + + // wait for the first ping to succeed + await new Promise(resolve => setTimeout(resolve, 75)) + expect(closed).toBeFalse() + + // now make it unresponsive + mockRelay.unresponsive = true + + // wait for the second ping to fail + await closedPromise + + expect(relay.connected).toBeFalse() + expect(closed).toBeTrue() +}) + test('track relays when publishing', async () => { let event1 = finalizeEvent( { diff --git a/relay.test.ts b/relay.test.ts index 0522297..844b4c5 100644 --- a/relay.test.ts +++ b/relay.test.ts @@ -117,3 +117,34 @@ test('publish timeout', async () => { ), ).rejects.toThrow('publish timed out') }) + +test('ping-pong timeout', async () => { + const mockRelay = new MockRelay() + const relay = new Relay(mockRelay.url, { enablePing: true }) + relay.pingTimeout = 50 + relay.pingFrequency = 50 + + let closed = false + const closedPromise = new Promise(resolve => { + relay.onclose = () => { + closed = true + resolve() + } + }) + + await relay.connect() + expect(relay.connected).toBeTrue() + + // wait for the first ping to succeed + await new Promise(resolve => setTimeout(resolve, 75)) + expect(closed).toBeFalse() + + // now make it unresponsive + mockRelay.unresponsive = true + + // wait for the second ping to fail + await closedPromise + + expect(relay.connected).toBeFalse() + expect(closed).toBeTrue() +}) From 0b1d3702f45b423628228be1ec545fb4828dd1cc Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 10:57:24 +0800 Subject: [PATCH 09/20] Linter fixes. --- pool.test.ts | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/pool.test.ts b/pool.test.ts index 4575c5c..07c9a79 100644 --- a/pool.test.ts +++ b/pool.test.ts @@ -59,16 +59,24 @@ test('same with double subs', async () => { let priv = generateSecretKey() let pub = getPublicKey(priv) - pool.subscribeMany(relayURLs, { authors: [pub] }, { - onevent(event) { - received.push(event) + pool.subscribeMany( + relayURLs, + { authors: [pub] }, + { + onevent(event) { + received.push(event) + }, }, - }) - pool.subscribeMany(relayURLs, { authors: [pub] }, { - onevent(event) { - received.push(event) + ) + pool.subscribeMany( + relayURLs, + { authors: [pub] }, + { + onevent(event) { + received.push(event) + }, }, - }) + ) let received: Event[] = [] @@ -172,12 +180,16 @@ test('query a bunch of events and cancel on eose', async () => { let events = new Set() await new Promise(resolve => { - pool.subscribeManyEose(relayURLs, { kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }, { - onevent(event) { - events.add(event.id) + pool.subscribeManyEose( + relayURLs, + { kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }, + { + onevent(event) { + events.add(event.id) + }, + onclose: resolve as any, }, - onclose: resolve as any, - }) + ) }) expect(events.size).toBeGreaterThan(50) From e7e71294d42a70b67794ac08a8fe49a72b83c64d Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 12:05:24 +0800 Subject: [PATCH 10/20] Remove redundant forced close since ws.close triggers it. --- abstract-relay.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/abstract-relay.ts b/abstract-relay.ts index 3e75c6a..c763076 100644 --- a/abstract-relay.ts +++ b/abstract-relay.ts @@ -223,8 +223,6 @@ export class AbstractRelay { } else { // pingpong closing socket this.closeAllSubscriptions('pingpong timed out') - this._connected = false - this.onclose?.() this.ws?.close() } } From 234c1c2514f744e3985e1c963076b6f1fedd5813 Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 13:22:31 +0800 Subject: [PATCH 11/20] Remove redundant closeAllSubscriptions - ws close triggers it. --- abstract-relay.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/abstract-relay.ts b/abstract-relay.ts index c763076..887b332 100644 --- a/abstract-relay.ts +++ b/abstract-relay.ts @@ -222,7 +222,6 @@ export class AbstractRelay { setTimeout(() => this.pingpong(), this.pingFrequency) } else { // pingpong closing socket - this.closeAllSubscriptions('pingpong timed out') this.ws?.close() } } From 0da7c75fb3c6ee6c6a7bf11768f35e6d8e3eb1a4 Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 13:25:53 +0800 Subject: [PATCH 12/20] Tests for enableReconnect exponential backoff. --- pool.test.ts | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++ relay.test.ts | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) diff --git a/pool.test.ts b/pool.test.ts index 07c9a79..bb96b32 100644 --- a/pool.test.ts +++ b/pool.test.ts @@ -253,6 +253,120 @@ test('ping-pong timeout in pool', async () => { expect(closed).toBeTrue() }) +test('reconnect on disconnect in pool', async () => { + const mockRelay = mockRelays[0] + pool = new SimplePool({ enablePing: true, enableReconnect: true }) + const relay = await pool.ensureRelay(mockRelay.url) + relay.pingTimeout = 50 + relay.pingFrequency = 50 + relay.resubscribeBackoff = [50, 100] + + let closes = 0 + relay.onclose = () => { + closes++ + } + + expect(relay.connected).toBeTrue() + + // wait for the first ping to succeed + await new Promise(resolve => setTimeout(resolve, 75)) + expect(closes).toBe(0) + + // now make it unresponsive + mockRelay.unresponsive = true + + // wait for the second ping to fail, which will trigger a close + await new Promise(resolve => { + const interval = setInterval(() => { + if (closes > 0) { + clearInterval(interval) + resolve(null) + } + }, 10) + }) + expect(closes).toBe(1) + expect(relay.connected).toBeFalse() + + // now make it responsive again + mockRelay.unresponsive = false + + // wait for reconnect + await new Promise(resolve => { + const interval = setInterval(() => { + if (relay.connected) { + clearInterval(interval) + resolve(null) + } + }, 10) + }) + + expect(relay.connected).toBeTrue() + expect(closes).toBe(1) +}) + +test('reconnect with filter update in pool', async () => { + const mockRelay = mockRelays[0] + const newSince = Math.floor(Date.now() / 1000) + pool = new SimplePool({ + enablePing: true, + enableReconnect: filters => { + return filters.map(f => ({ ...f, since: newSince })) + }, + }) + const relay = await pool.ensureRelay(mockRelay.url) + relay.pingTimeout = 50 + relay.pingFrequency = 50 + relay.resubscribeBackoff = [50, 100] + + let closes = 0 + relay.onclose = () => { + closes++ + } + + expect(relay.connected).toBeTrue() + + const sub = relay.subscribe([{ kinds: [1], since: 0 }], {}) + expect(sub.filters[0].since).toBe(0) + + // wait for the first ping to succeed + await new Promise(resolve => setTimeout(resolve, 75)) + expect(closes).toBe(0) + + // now make it unresponsive + mockRelay.unresponsive = true + + // wait for the second ping to fail, which will trigger a close + await new Promise(resolve => { + const interval = setInterval(() => { + if (closes > 0) { + clearInterval(interval) + resolve(null) + } + }, 10) + }) + expect(closes).toBe(1) + expect(relay.connected).toBeFalse() + + // now make it responsive again + mockRelay.unresponsive = false + + // wait for reconnect + await new Promise(resolve => { + const interval = setInterval(() => { + if (relay.connected) { + clearInterval(interval) + resolve(null) + } + }, 10) + }) + + expect(relay.connected).toBeTrue() + expect(closes).toBe(1) + + // check if filter was updated + expect(sub.filters[0].since).toBe(newSince) +}) + test('track relays when publishing', async () => { let event1 = finalizeEvent( { diff --git a/relay.test.ts b/relay.test.ts index 844b4c5..187d529 100644 --- a/relay.test.ts +++ b/relay.test.ts @@ -148,3 +148,117 @@ test('ping-pong timeout', async () => { expect(relay.connected).toBeFalse() expect(closed).toBeTrue() }) + +test('reconnect on disconnect', async () => { + const mockRelay = new MockRelay() + const relay = new Relay(mockRelay.url, { enablePing: true, enableReconnect: true }) + relay.pingTimeout = 50 + relay.pingFrequency = 50 + relay.resubscribeBackoff = [50, 100] // short backoff for testing + + let closes = 0 + relay.onclose = () => { + closes++ + } + + await relay.connect() + expect(relay.connected).toBeTrue() + + // wait for the first ping to succeed + await new Promise(resolve => setTimeout(resolve, 75)) + expect(closes).toBe(0) + + // now make it unresponsive + mockRelay.unresponsive = true + + // wait for the second ping to fail, which will trigger a close + await new Promise(resolve => { + const interval = setInterval(() => { + if (closes > 0) { + clearInterval(interval) + resolve(null) + } + }, 10) + }) + expect(closes).toBe(1) + expect(relay.connected).toBeFalse() + + // now make it responsive again + mockRelay.unresponsive = false + + // wait for reconnect + await new Promise(resolve => { + const interval = setInterval(() => { + if (relay.connected) { + clearInterval(interval) + resolve(null) + } + }, 10) + }) + + expect(relay.connected).toBeTrue() + expect(closes).toBe(1) // should not have closed again +}) + +test('reconnect with filter update', async () => { + const mockRelay = new MockRelay() + const newSince = Math.floor(Date.now() / 1000) + const relay = new Relay(mockRelay.url, { + enablePing: true, + enableReconnect: filters => { + return filters.map(f => ({ ...f, since: newSince })) + }, + }) + relay.pingTimeout = 50 + relay.pingFrequency = 50 + relay.resubscribeBackoff = [50, 100] + + let closes = 0 + relay.onclose = () => { + closes++ + } + + await relay.connect() + expect(relay.connected).toBeTrue() + + const sub = relay.subscribe([{ kinds: [1], since: 0 }], {}) + expect(sub.filters[0].since).toBe(0) + + // wait for the first ping to succeed + await new Promise(resolve => setTimeout(resolve, 75)) + expect(closes).toBe(0) + + // now make it unresponsive + mockRelay.unresponsive = true + + // wait for the second ping to fail, which will trigger a close + await new Promise(resolve => { + const interval = setInterval(() => { + if (closes > 0) { + clearInterval(interval) + resolve(null) + } + }, 10) + }) + expect(closes).toBe(1) + expect(relay.connected).toBeFalse() + + // now make it responsive again + mockRelay.unresponsive = false + + // wait for reconnect + await new Promise(resolve => { + const interval = setInterval(() => { + if (relay.connected) { + clearInterval(interval) + resolve(null) + } + }, 10) + }) + + expect(relay.connected).toBeTrue() + expect(closes).toBe(1) + + // check if filter was updated + expect(sub.filters[0].since).toBe(newSince) +}) From ce1c6067b82d9a6a813ea73e7418dbbfaadcada5 Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 14:33:02 +0800 Subject: [PATCH 13/20] Suppress uncaught onevent in reconnect test. --- pool.test.ts | 2 +- relay.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pool.test.ts b/pool.test.ts index bb96b32..4942182 100644 --- a/pool.test.ts +++ b/pool.test.ts @@ -325,7 +325,7 @@ test('reconnect with filter update in pool', async () => { expect(relay.connected).toBeTrue() - const sub = relay.subscribe([{ kinds: [1], since: 0 }], {}) + const sub = relay.subscribe([{ kinds: [1], since: 0 }], { onevent: () => {} }) expect(sub.filters[0].since).toBe(0) // wait for the first ping to succeed diff --git a/relay.test.ts b/relay.test.ts index 187d529..886dedf 100644 --- a/relay.test.ts +++ b/relay.test.ts @@ -221,7 +221,7 @@ test('reconnect with filter update', async () => { await relay.connect() expect(relay.connected).toBeTrue() - const sub = relay.subscribe([{ kinds: [1], since: 0 }], {}) + const sub = relay.subscribe([{ kinds: [1], since: 0 }], { onevent: () => {} }) expect(sub.filters[0].since).toBe(0) // wait for the first ping to succeed From 7e1e0244ab125db0c0454f12a6fcd048ae4568e3 Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 15:29:19 +0800 Subject: [PATCH 14/20] Use safer ternary in pinpong. --- abstract-relay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/abstract-relay.ts b/abstract-relay.ts index 887b332..9401625 100644 --- a/abstract-relay.ts +++ b/abstract-relay.ts @@ -186,7 +186,7 @@ export class AbstractRelay { 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") + ;(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() }) From d74d8b53685d0f1bc08a96b6c97ae50cb201bc5e Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 15:34:40 +0800 Subject: [PATCH 15/20] Pingpong tests simluating node and browser. --- relay.test.ts | 110 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 89 insertions(+), 21 deletions(-) diff --git a/relay.test.ts b/relay.test.ts index 886dedf..3620185 100644 --- a/relay.test.ts +++ b/relay.test.ts @@ -118,35 +118,103 @@ test('publish timeout', async () => { ).rejects.toThrow('publish timed out') }) -test('ping-pong timeout', async () => { +test('ping-pong timeout (with native ping)', async () => { const mockRelay = new MockRelay() - const relay = new Relay(mockRelay.url, { enablePing: true }) - relay.pingTimeout = 50 - relay.pingFrequency = 50 + let pingCalled = false - let closed = false - const closedPromise = new Promise(resolve => { - relay.onclose = () => { - closed = true - resolve() + // mock a native ping/pong mechanism + ;(MockWebSocketClient.prototype as any).ping = function (this: any) { + pingCalled = true + if (!mockRelay.unresponsive) { + this.dispatchEvent(new Event('pong')) } - }) + } + ;(MockWebSocketClient.prototype as any).on = function (this: any, event: string, listener: () => void) { + if (event === 'pong') { + this.addEventListener('pong', listener) + } + } - await relay.connect() - expect(relay.connected).toBeTrue() + try { + const relay = new Relay(mockRelay.url, { enablePing: true }) + relay.pingTimeout = 50 + relay.pingFrequency = 50 - // wait for the first ping to succeed - await new Promise(resolve => setTimeout(resolve, 75)) - expect(closed).toBeFalse() + let closed = false + const closedPromise = new Promise(resolve => { + relay.onclose = () => { + closed = true + resolve() + } + }) - // now make it unresponsive - mockRelay.unresponsive = true + await relay.connect() + expect(relay.connected).toBeTrue() - // wait for the second ping to fail - await closedPromise + // wait for the first ping to succeed + await new Promise(resolve => setTimeout(resolve, 75)) + expect(pingCalled).toBeTrue() + expect(closed).toBeFalse() - expect(relay.connected).toBeFalse() - expect(closed).toBeTrue() + // now make it unresponsive + mockRelay.unresponsive = true + + // wait for the second ping to fail + await closedPromise + + expect(relay.connected).toBeFalse() + expect(closed).toBeTrue() + } finally { + delete (MockWebSocketClient.prototype as any).ping + delete (MockWebSocketClient.prototype as any).on + } +}) + +test('ping-pong timeout (no-ping browser environment)', async () => { + // spy on send to ensure the fallback dummy REQ is used, since MockWebSocketClient has no ping + const originalSend = MockWebSocketClient.prototype.send + let dummyReqSent = false + + try { + MockWebSocketClient.prototype.send = function (message: string) { + if (message.includes('REQ') && message.includes('a'.repeat(64))) { + dummyReqSent = true + } + originalSend.call(this, message) + } + + const mockRelay = new MockRelay() + const relay = new Relay(mockRelay.url, { enablePing: true }) + relay.pingTimeout = 50 + relay.pingFrequency = 50 + + let closed = false + const closedPromise = new Promise(resolve => { + relay.onclose = () => { + closed = true + resolve() + } + }) + + await relay.connect() + expect(relay.connected).toBeTrue() + + // wait for the first ping to succeed + await new Promise(resolve => setTimeout(resolve, 75)) + expect(dummyReqSent).toBeTrue() + expect(closed).toBeFalse() + + // now make it unresponsive + mockRelay.unresponsive = true + + // wait for the second ping to fail + await closedPromise + + expect(relay.connected).toBeFalse() + expect(closed).toBeTrue() + } finally { + MockWebSocketClient.prototype.send = originalSend + } }) test('reconnect on disconnect', async () => { From 61dc0afcba4226fe4532e688bd8449dc4d158451 Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 17:16:35 +0800 Subject: [PATCH 16/20] Fix reconnect loops and pool reconnect tracking. --- abstract-pool.ts | 4 +++- abstract-relay.ts | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/abstract-pool.ts b/abstract-pool.ts index ef221bb..ab13c0c 100644 --- a/abstract-pool.ts +++ b/abstract-pool.ts @@ -57,7 +57,9 @@ export class AbstractSimplePool { enableReconnect: this.enableReconnect, }) relay.onclose = () => { - this.relays.delete(url) + if (relay && !relay.enableReconnect) { + this.relays.delete(url) + } } if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout this.relays.set(url, relay) diff --git a/abstract-relay.ts b/abstract-relay.ts index 9401625..3d57a72 100644 --- a/abstract-relay.ts +++ b/abstract-relay.ts @@ -146,6 +146,10 @@ export class AbstractRelay { } this.ws.onopen = () => { + if (this.reconnectTimeoutHandle) { + clearTimeout(this.reconnectTimeoutHandle) + this.reconnectTimeoutHandle = undefined + } clearTimeout(this.connectionTimeoutHandle) this._connected = true this.reconnectAttempts = 0 From 5f71a5291f7782f1580986b5e18d7c5da12f405c Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 17:32:52 +0800 Subject: [PATCH 17/20] Properly track pingTimeout across disconnects. --- abstract-relay.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/abstract-relay.ts b/abstract-relay.ts index 3d57a72..31f7e1c 100644 --- a/abstract-relay.ts +++ b/abstract-relay.ts @@ -44,6 +44,7 @@ export class AbstractRelay { public enableReconnect: boolean | ((filters: Filter[]) => Filter[]) private connectionTimeoutHandle: ReturnType | undefined private reconnectTimeoutHandle: ReturnType | undefined + private pingTimeoutHandle: ReturnType | undefined private reconnectAttempts: number = 0 private closedIntentionally: boolean = false @@ -109,6 +110,11 @@ export class AbstractRelay { } private handleHardClose(reason: string) { + if (this.pingTimeoutHandle) { + clearTimeout(this.pingTimeoutHandle) + this.pingTimeoutHandle = undefined + } + this._connected = false this.connectionPromise = undefined @@ -223,7 +229,7 @@ export class AbstractRelay { ]) if (result) { // schedule another pingpong - setTimeout(() => this.pingpong(), this.pingFrequency) + this.pingTimeoutHandle = setTimeout(() => this.pingpong(), this.pingFrequency) } else { // pingpong closing socket this.ws?.close() @@ -415,7 +421,14 @@ export class AbstractRelay { public close() { this.closedIntentionally = true - if (this.reconnectTimeoutHandle) clearTimeout(this.reconnectTimeoutHandle) + if (this.reconnectTimeoutHandle) { + clearTimeout(this.reconnectTimeoutHandle) + this.reconnectTimeoutHandle = undefined + } + if (this.pingTimeoutHandle) { + clearTimeout(this.pingTimeoutHandle) + this.pingTimeoutHandle = undefined + } this.closeAllSubscriptions('relay connection closed by us') this._connected = false this.onclose?.() From b5a357aa9444b8f70982f5e4083ea4b9e3e12772 Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 18:08:02 +0800 Subject: [PATCH 18/20] Prevent spurious closed socket warning. --- abstract-relay.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/abstract-relay.ts b/abstract-relay.ts index 31f7e1c..4ec4cf2 100644 --- a/abstract-relay.ts +++ b/abstract-relay.ts @@ -232,7 +232,9 @@ export class AbstractRelay { this.pingTimeoutHandle = setTimeout(() => this.pingpong(), this.pingFrequency) } else { // pingpong closing socket - this.ws?.close() + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws?.close() + } } } } @@ -432,7 +434,9 @@ export class AbstractRelay { this.closeAllSubscriptions('relay connection closed by us') this._connected = false this.onclose?.() - this.ws?.close() + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws?.close() + } } // this is the function assigned to this.ws.onmessage From 7af50518e1e887d9a92684b6d80af6cc82a524c1 Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 18:19:26 +0800 Subject: [PATCH 19/20] Tweak docs. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8a0c996..97ba24e 100644 --- a/README.md +++ b/README.md @@ -153,14 +153,14 @@ import { SimplePool } from 'nostr-tools/pool' const pool = new SimplePool({ enableReconnect: true }) ``` -On Node.js, it is recommended to also use `enablePing: true` to ensure network disconnections are properly detected. +Using both `enablePing: true` and `enableReconnect: true` is recommended as it will improve the reliability and timeliness of the reconnection (at the expense of slighly higher bandwidth due to the ping messages). ```js // on Node.js const pool = new SimplePool({ enablePing: true, enableReconnect: true }) ``` -The `enableReconnect` option can also be a callback function that receives the current subscription filters and returns a new set of filters. This is useful if you want to modify the subscription on reconnect, for example, to update the `since` parameter to fetch only new events. +The `enableReconnect` option can also be a callback function which will receive the current subscription filters and should return a new set of filters. This is useful if you want to modify the subscription on reconnect, for example, to update the `since` parameter to fetch only new events. ```js const pool = new SimplePool({ From fee26002e6fe8cf95494989701fde67b555b9cfd Mon Sep 17 00:00:00 2001 From: Chris McCormick Date: Sun, 28 Sep 2025 18:42:12 +0800 Subject: [PATCH 20/20] Linter fix. --- abstract-relay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/abstract-relay.ts b/abstract-relay.ts index 4ec4cf2..0e36e7d 100644 --- a/abstract-relay.ts +++ b/abstract-relay.ts @@ -196,7 +196,7 @@ export class AbstractRelay { 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") + 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() })