libp2r2p 0.11.13 → 0.11.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -136,12 +136,39 @@ coverage of a disconnected interval. This is client-observed continuity, not a
136
136
  relay acknowledgement; delayed relay delivery or clock skew beyond the overlap
137
137
  can still leave gaps. A backward local clock cannot regress an emitted boundary.
138
138
 
139
- Every involuntary live close emits an error and still permits automatic recovery.
139
+ Every involuntary live close emits an error. Recoverable failures permit automatic recovery.
140
140
  A close without an explicit cause uses `error.code === 'RELAY_LIVE_INTERRUPTED'`
141
141
  and `category === 'transport'`; a pre-EOSE close retains status `closed` in the
142
142
  initial report. Explicit causes are preserved. Caller cancellation, draining and
143
143
  expiry of `filter.until` do not manufacture interruption errors.
144
144
 
145
+ ### Failure-aware live recovery (0.11.15)
146
+
147
+ Live readers retry connection/transport/timeouts and structured `rate-limited:`
148
+ or `error:` refusals. Policy refusals (`blocked:`, `restricted:`, `auth-required:`,
149
+ `pow:`), local authentication/validation failures and unknown errors stop that
150
+ relay for this iterator. Other relays continue. When every route has stopped,
151
+ the iterator finishes after draining accepted events and original diagnostics;
152
+ its readiness snapshot never claims a failed route sent EOSE. A new explicit
153
+ reader starts a new operation; refusals do not blacklist a relay across readers.
154
+
155
+ Retry delays are 1, 2, 4... seconds, capped at five minutes without an attempt
156
+ limit. The next attempt waits until max(backoff deadline, relay retryAt). A
157
+ confirmed offline failure spends no backoff step: it waits for the existing
158
+ shared `onOnline` monitor, without holding read-admission slots. Connectivity
159
+ checks are bounded and shared per pool; structured relay responses need no new
160
+ internet probe unless the browser indicates offline. Cancelled readers release
161
+ their own waits without cancelling another reader's recovery.
162
+
163
+ Successful live EOSE and any required reconnect history reset the delay.
164
+ Transient reconnect-history failures retry while retaining their original gap
165
+ baseline; definitive failures stop that route without certifying its gap.
166
+ Accepted history and buffered live remain deliverable. Local queue-full,
167
+ queue-timeout and disconnect admission failures retain local recovery; impossible
168
+ capacity is terminal. Caller cancellation is silent. Native timeout messages stay
169
+ unchanged and now consistently carry category `timeout`. Constructor hooks
170
+ `_isOnline`/`_onOnline` are internal test injections, not an app bridge API.
171
+
145
172
  Process Nostr events only under `item.type === 'event'`. Handle controls you need
146
173
  and ignore unknown types; a new control is neither an event nor stream completion.
147
174
  The internal query, private-channel and NIP-46 consumers retain their existing
@@ -161,7 +188,7 @@ atomically before either REQ opens; completing history releases only its slot.
161
188
  One-shot queries and reconnect gap reads share this budget. Reconnection reserves
162
189
  live+history together when recovery is needed. Caller cancellation removes queued
163
190
  work; `disconnect(url)` also rejects queued work for that connection. Existing
164
- live streams retain their reconnect behavior until cancelled by their owner.
191
+ live streams retain recovery for eligible failures until cancelled by their owner.
165
192
 
166
193
  All event reads accept `queueTimeout: 30000` (milliseconds, `null` disables).
167
194
  The network `timeout` starts **after admission for each relay**, including its
@@ -877,9 +904,42 @@ the first pass routes through up to `maxPerPubkey` shared relays, and missing
877
904
  authors are retried on every remaining relay plus `fallbackRelays` (default:
878
905
  the first three `freeRelays`), excluding what was already queried. It accepts
879
906
  `relaysByPubkey` to reuse a previous discovery (only missing pubkeys are then
880
- discovered) and returns `{ events, byPubkey, relaysByPubkey }` so the merged
907
+ discovered) and returns `{ events, byPubkey, relaysByPubkey, requests }` so the merged
881
908
  relay map can be passed back on later calls.
882
909
 
910
+
911
+ Read diagnostics preserve native failures rather than treating an empty result
912
+ as proof that every relay completed successfully. `requests` contains records
913
+ `{ phase, authors, relays, errors?, error? }`: phase is `discovery`, `primary` or
914
+ `fallback`; `relays` uses the pool's `{ relay, status, error? }` outcomes. An
915
+ operation-wide exception is retained as `error`. Events remain in the existing
916
+ result fields, never in diagnostic records. `onQueryResult(record)` receives each
917
+ record as the underlying query settles, including before a later cancellation.
918
+
919
+ Both discovery and latest-event queries accept `signal`. Cancelling one consumer
920
+ of shared NIP-65 discovery releases only its interest; the underlying query is
921
+ aborted when its last consumer leaves. Discovery's optional `onQueryResult`
922
+ receives the same native report for every joining caller. Incomplete/failed
923
+ lookups can use fallback relays for that call but never negatively cache a
924
+ missing relay list for forty minutes. Actual lists and completely empty EOSE
925
+ responses retain their normal cache behavior.
926
+
927
+ `getLatestEventsByPubkey` accepts `excludeRelaysByPubkey` (object or Map) for both
928
+ passes. `getRelaysByPubkey` accepts `excludeRelays` for seed discovery; pass it
929
+ through `relayListOptions` when using the two-pass query. Different exclusion
930
+ sets do not share in-flight discovery that could contact a refused relay.
931
+
932
+ `isRetryableRelayFailure(error)` identifies connection/transport/timeouts and
933
+ leading `rate-limited:`/`error:` relay failures. `isReplaceableRelayFailure(error)`
934
+ also admits relay policy refusals (`blocked:`, `restricted:`, `auth-required:`,
935
+ `pow:`) for routing to alternatives, preserving the messenger's existing policy.
936
+ Local authentication denials, invalid events and unknown errors qualify for
937
+ neither policy. Predicates classify individual native errors; callers decide
938
+ how to handle mixed per-relay outcomes and application-specific aggregates.
939
+ Existing rate-limit parsing preserves numeric positive `retry_after` seconds as
940
+ `retryAfterMs` and absolute `retryAt`, bounded to five minutes. Schedulers must
941
+ use the later of their backoff deadline and `retryAt`, not add the two delays.
942
+
883
943
  ## Binary encodings
884
944
 
885
945
  Base16, Base36, Base62, Base64/Base64URL, and Base93 helpers are available
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.13",
3
+ "version": "0.11.15",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -1,19 +1,11 @@
1
1
  import { normalizeRelayUrl } from '../../url/index.js'
2
2
  import { ValidationError } from '../../error/index.js'
3
+ import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
3
4
 
4
- const REJECTION_PREFIXES = new Set(['blocked', 'restricted', 'auth-required', 'pow', 'rate-limited', 'error'])
5
- const TRANSPORT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
6
5
  const EXCLUSION_MS = 5 * 60 * 1000
7
6
  const normalized = relay => { try { return normalizeRelayUrl(relay) } catch { return '' } }
8
7
 
9
- // Only machine-readable relay policy/server failures qualify. Invalid events,
10
- // local signer denials and unknown prose must not rotate relays.
11
- export function isReplaceableRelayFailure (error) {
12
- if (error?.name === 'Nip42AuthenticationError') return false
13
- if (TRANSPORT_CATEGORIES.has(error?.category)) return true
14
- if (error?.category && error.category !== 'relay') return false
15
- return REJECTION_PREFIXES.has(/^([a-z-]+):/.exec(error?.message || '')?.[1])
16
- }
8
+ export { isReplaceableRelayFailure }
17
9
 
18
10
  export function normalizeFallbackRelays (value = []) {
19
11
  if (!Array.isArray(value)) throw new ValidationError('INVALID_FALLBACK_RELAYS')
@@ -0,0 +1,15 @@
1
+ const TRANSPORT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
2
+ const RETRY_PREFIXES = new Set(['rate-limited', 'error'])
3
+ const REPLACEMENT_PREFIXES = new Set(['blocked', 'restricted', 'auth-required', 'pow', ...RETRY_PREFIXES])
4
+
5
+ // Retry and replacement are different policies: a refusal can justify another
6
+ // relay without making the same operation retryable on the refusing relay.
7
+ function matches (error, prefixes) {
8
+ if (error?.name === 'Nip42AuthenticationError') return false
9
+ if (TRANSPORT_CATEGORIES.has(error?.category)) return true
10
+ if (error?.category && error.category !== 'relay') return false
11
+ return prefixes.has(/^([a-z-]+):/.exec(error?.message || '')?.[1])
12
+ }
13
+
14
+ export const isRetryableRelayFailure = error => matches(error, RETRY_PREFIXES)
15
+ export const isReplaceableRelayFailure = error => matches(error, REPLACEMENT_PREFIXES)
@@ -2,7 +2,7 @@ import { ValidationError } from '../../error/index.js'
2
2
  import { maybeUnref } from './timer.js'
3
3
 
4
4
  export function admissionError (code, relay) {
5
- return Object.assign(new Error(code), { code, relay, phase: 'admission' })
5
+ return Object.assign(new Error(code), { code, relay, phase: 'admission', ...(code === 'RELAY_READ_QUEUE_TIMEOUT' ? { category: 'timeout' } : {}) })
6
6
  }
7
7
 
8
8
  // One FIFO per pooled connection. A feed reserves both REQs atomically; its
@@ -0,0 +1,106 @@
1
+ import { isOnline, onOnline } from '../../network/index.js'
2
+ import { isRetryableRelayFailure } from './failure.js'
3
+ import { maybeUnref } from './timer.js'
4
+
5
+ const LOCAL_RETRY_CODES = new Set(['RELAY_READ_QUEUE_FULL', 'RELAY_READ_QUEUE_TIMEOUT', 'RELAY_DISCONNECTED'])
6
+
7
+ export function isRetryableReadFailure (error) {
8
+ if (error?.name === 'ValidationError' || error?.name === 'Nip42AuthenticationError') return false
9
+ if (error?.phase === 'admission') return LOCAL_RETRY_CODES.has(error.code)
10
+ return isRetryableRelayFailure(error)
11
+ }
12
+
13
+ export function needsConnectivityCheck (error) {
14
+ return globalThis.navigator?.onLine === false || (error?.phase !== 'admission' && ['connection', 'transport', 'timeout'].includes(error?.category))
15
+ }
16
+
17
+ export function waitUntil (deadline, signal) {
18
+ signal.throwIfAborted()
19
+ if (deadline <= Date.now()) return Promise.resolve()
20
+ return new Promise((resolve, reject) => {
21
+ const finish = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); resolve() }
22
+ const abort = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(signal.reason) }
23
+ const timer = maybeUnref(setTimeout(finish, Math.max(0, deadline - Date.now())))
24
+ signal.addEventListener('abort', abort, { once: true })
25
+ })
26
+ }
27
+
28
+ function waitFor (promise, signal) {
29
+ signal.throwIfAborted()
30
+ return new Promise((resolve, reject) => {
31
+ const abort = () => { signal.removeEventListener('abort', abort); reject(signal.reason) }
32
+ signal.addEventListener('abort', abort, { once: true })
33
+ promise.then(value => { signal.removeEventListener('abort', abort); resolve(value) }, error => {
34
+ signal.removeEventListener('abort', abort); reject(error)
35
+ })
36
+ })
37
+ }
38
+
39
+ // One connectivity check/online subscription per pool, with independent waits.
40
+ // Ending one reader must never cancel recovery still needed by another reader.
41
+ export class ReadRetry {
42
+ #checkOnline
43
+ #watchOnline
44
+ #probe
45
+ #monitor
46
+ #waiters = new Set()
47
+
48
+ constructor ({ checkOnline = isOnline, watchOnline = onOnline } = {}) {
49
+ this.#checkOnline = checkOnline
50
+ this.#watchOnline = watchOnline
51
+ }
52
+
53
+ async confirmOnline (signal) {
54
+ signal.throwIfAborted()
55
+ if (globalThis.navigator?.onLine === false) return false
56
+ if (!this.#probe || this.#probe.controller.signal.aborted) {
57
+ const controller = new AbortController()
58
+ const record = { controller, users: 0, finished: false }
59
+ const stopped = new Promise(resolve => controller.signal.addEventListener('abort', () => resolve(false), { once: true }))
60
+ const timer = maybeUnref(setTimeout(() => controller.abort(), 6000))
61
+ record.promise = Promise.race([
62
+ Promise.resolve().then(() => this.#checkOnline({ signal: controller.signal })).catch(() => false), stopped
63
+ ]).then(value => value === true).finally(() => {
64
+ record.finished = true
65
+ clearTimeout(timer)
66
+ controller.abort()
67
+ if (this.#probe === record) this.#probe = null
68
+ })
69
+ this.#probe = record
70
+ }
71
+ const record = this.#probe
72
+ record.users++
73
+ try { return await waitFor(record.promise, signal) } finally {
74
+ if (--record.users === 0 && !record.finished) record.controller.abort()
75
+ }
76
+ }
77
+
78
+ waitUntilOnline (signal) {
79
+ signal.throwIfAborted()
80
+ const ready = Promise.withResolvers()
81
+ this.#waiters.add(ready)
82
+ if (!this.#monitor) {
83
+ const monitor = { stop: () => {}, ended: false }
84
+ this.#monitor = monitor
85
+ monitor.stop = this.#watchOnline(() => {
86
+ if (this.#monitor !== monitor) return
87
+ for (const waiter of this.#waiters) waiter.resolve()
88
+ this.#waiters.clear()
89
+ this.#stopIfIdle()
90
+ })
91
+ if (monitor.ended) monitor.stop()
92
+ }
93
+ return waitFor(ready.promise, signal).finally(() => {
94
+ this.#waiters.delete(ready)
95
+ this.#stopIfIdle()
96
+ })
97
+ }
98
+
99
+ #stopIfIdle () {
100
+ if (this.#waiters.size || !this.#monitor) return
101
+ const monitor = this.#monitor
102
+ this.#monitor = null
103
+ monitor.ended = true
104
+ monitor.stop()
105
+ }
106
+ }
package/relay/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { freeRelays, nappRelays, seedRelays } from './constants/index.js'
2
+ export { isRetryableRelayFailure, isReplaceableRelayFailure } from './helpers/failure.js'
2
3
  export { pickRelaysForPubkeys } from './helpers/routing.js'
3
4
  export { RelayPool, relayPool } from './services/relay-pool.js'
4
5
  export { getLatestEventsByPubkey } from './services/events.js'
@@ -45,7 +45,7 @@ function getSelectedRelaysByPubkey (pubkeys, relayToAuthors) {
45
45
  }
46
46
 
47
47
  // Fetches batched events and keeps the newest event per address.
48
- async function fetchLatestEventsByRelay (relayToAuthors, { kinds, dTagsByPubkey, getEvents }) {
48
+ async function fetchLatestEventsByRelay (relayToAuthors, { kinds, dTagsByPubkey, getEvents, report, phase, signal }) {
49
49
  const requests = []
50
50
  for (const [relay, authors] of relayToAuthors) {
51
51
  const authorsByD = new Map()
@@ -57,8 +57,17 @@ async function fetchLatestEventsByRelay (relayToAuthors, { kinds, dTagsByPubkey,
57
57
  for (const [d, dAuthors] of authorsByD) {
58
58
  const filter = { kinds, authors: dAuthors }
59
59
  if (d) filter['#d'] = [d]
60
- requests.push(getEvents(filter, [relay])
61
- .then(response => ({ requested: new Set(dAuthors), events: (response.result || []).map(({ event }) => event) })))
60
+ requests.push(Promise.resolve().then(async () => {
61
+ signal?.throwIfAborted()
62
+ try {
63
+ const response = await getEvents(filter, [relay], signal ? { signal } : {})
64
+ report({ phase, authors: dAuthors, relays: response.relays || [], errors: response.errors || [] })
65
+ return { requested: new Set(dAuthors), events: (response.result || []).map(({ event }) => event) }
66
+ } catch (error) {
67
+ report({ phase, authors: dAuthors, relays: [{ relay, status: 'error', error }], error })
68
+ throw error
69
+ }
70
+ }))
62
71
  }
63
72
  }
64
73
 
@@ -95,11 +104,20 @@ export async function getLatestEventsByPubkey (pubkeys, {
95
104
  fallbackRelays = freeRelays.slice(0, DEFAULT_FALLBACK_RELAY_COUNT),
96
105
  relaysByPubkey,
97
106
  relayListOptions,
107
+ excludeRelaysByPubkey,
108
+ signal,
109
+ onQueryResult,
98
110
  _getRelaysByPubkey = getRelaysByPubkey,
99
111
  _getEvents = getEvents
100
112
  } = {}) {
113
+ signal?.throwIfAborted()
114
+ const requests = []
115
+ const report = request => { requests.push(request); onQueryResult?.(request) }
116
+ const excluded = pubkey => excludeRelaysByPubkey instanceof Map
117
+ ? excludeRelaysByPubkey.get(pubkey) || []
118
+ : excludeRelaysByPubkey?.[pubkey] || []
101
119
  const authors = [...new Set(pubkeys || [])].filter(Boolean)
102
- if (!authors.length) return { events: [], byPubkey: {}, relaysByPubkey: {} }
120
+ if (!authors.length) return { events: [], byPubkey: {}, relaysByPubkey: {}, requests }
103
121
  if (!Array.isArray(kinds) || kinds.length === 0) {
104
122
  throw new ValidationError('MISSING_EVENT_KINDS', { message: 'Missing kinds' })
105
123
  }
@@ -110,9 +128,16 @@ export async function getLatestEventsByPubkey (pubkeys, {
110
128
  if (missingRelayAuthors.length) {
111
129
  let discovered
112
130
  try {
113
- discovered = await _getRelaysByPubkey(missingRelayAuthors, relayListOptions)
131
+ discovered = await _getRelaysByPubkey(missingRelayAuthors, {
132
+ ...relayListOptions,
133
+ ...(signal ? { signal } : {}),
134
+ onQueryResult: request => { report({ ...request, phase: 'discovery' }); relayListOptions?.onQueryResult?.(request) }
135
+ })
114
136
  } catch (error) {
115
- console.error('Failed to discover publisher relays:', error)
137
+ signal?.throwIfAborted()
138
+ if (!requests.some(request => request.phase === 'discovery' && request.error === error)) {
139
+ report({ phase: 'discovery', authors: missingRelayAuthors, relays: [], error })
140
+ }
116
141
  discovered = Object.fromEntries(missingRelayAuthors.map(pubkey => [pubkey, { read: [], write: [] }]))
117
142
  }
118
143
  for (const pubkey of missingRelayAuthors) {
@@ -120,14 +145,17 @@ export async function getLatestEventsByPubkey (pubkeys, {
120
145
  }
121
146
  }
122
147
 
148
+ signal?.throwIfAborted()
123
149
  const primaryAuthors = authors.filter(pubkey => relaysByAuthor[pubkey]?.[type]?.length)
124
- const primaryRoutes = pickRelaysForPubkeys(primaryAuthors, relaysByAuthor, { maxPerPubkey, relayType })
150
+ const primaryRoutes = pickRelaysForPubkeys(primaryAuthors, relaysByAuthor, { maxPerPubkey, relayType, excludeRelaysByPubkey, emptyRelaysFallback: [] })
125
151
  const selectedRelays = getSelectedRelaysByPubkey(authors, primaryRoutes)
152
+ for (const pubkey of authors) for (const relay of excluded(pubkey)) selectedRelays[pubkey].add(relay)
126
153
  const latestByAddress = await fetchLatestEventsByRelay(primaryRoutes, {
127
154
  kinds,
128
155
  dTagsByPubkey,
129
- getEvents: _getEvents
156
+ getEvents: _getEvents, report, phase: 'primary', signal
130
157
  })
158
+ signal?.throwIfAborted()
131
159
  const foundPubkeys = new Set(Object.values(latestByAddress).map(event => event.pubkey))
132
160
  const missingAuthors = authors.filter(pubkey => !foundPubkeys.has(pubkey))
133
161
 
@@ -145,13 +173,14 @@ export async function getLatestEventsByPubkey (pubkeys, {
145
173
  await fetchLatestEventsByRelay(fallbackRoutes, {
146
174
  kinds,
147
175
  dTagsByPubkey,
148
- getEvents: _getEvents
176
+ getEvents: _getEvents, report, phase: 'fallback', signal
149
177
  })
150
178
  ))
151
179
  }
152
180
 
181
+ signal?.throwIfAborted()
153
182
  const events = Object.values(latestByAddress)
154
183
  const byPubkey = {}
155
184
  for (const event of events) byPubkey[event.pubkey] = event
156
- return { events, byPubkey, relaysByPubkey: relaysByAuthor }
185
+ return { events, byPubkey, relaysByPubkey: relaysByAuthor, requests }
157
186
  }
@@ -224,28 +224,27 @@ export function subscribeRelayListUpdates (pubkeys, {
224
224
  }
225
225
 
226
226
  async function loadMissingRelays (missingPubkeys, {
227
- getEvents,
228
- cacheMs,
229
- timeout = RELAY_LIST_QUERY_TIMEOUT_MS,
230
- timeoutAfterFirstEose = RELAY_LIST_QUERY_TIMEOUT_AFTER_FIRST_EOSE_MS,
231
- relayUrlPolicy,
232
- emptyRelaysFallback = freeRelays.slice(0, 2)
227
+ getEvents, cacheMs, timeout, timeoutAfterFirstEose, relayUrlPolicy,
228
+ emptyRelaysFallback, excludeRelays, signal
233
229
  }) {
234
- const { result: events } = await getEvents({
235
- kinds: [10002],
236
- authors: missingPubkeys,
237
- limit: missingPubkeys.length
238
- }, seedRelays, {
239
- timeout,
240
- timeoutAfterFirstEose
241
- })
242
-
230
+ let response
231
+ try {
232
+ response = await getEvents({ kinds: [10002], authors: missingPubkeys, limit: missingPubkeys.length },
233
+ seedRelays.filter(relay => !excludeRelays.includes(relay)), { timeout, timeoutAfterFirstEose, signal })
234
+ signal.throwIfAborted()
235
+ } catch (error) {
236
+ return { entries: {}, report: { authors: missingPubkeys, relays: [], error } }
237
+ }
238
+ const report = { authors: missingPubkeys, relays: response.relays || [], errors: response.errors || [] }
239
+ const complete = response.relays
240
+ ? response.relays.length > 0 && response.relays.every(item => item.status === 'eose')
241
+ : !response.errors?.length
243
242
  const latestByPubkey = {}
244
- for (const { event } of events || []) {
243
+ for (const { event } of response.result || []) {
245
244
  if (!missingPubkeys.includes(event.pubkey)) continue
246
245
  if (isNewerRelayListEvent(event, latestByPubkey[event.pubkey])) latestByPubkey[event.pubkey] = event
247
246
  }
248
-
247
+ const entries = {}
249
248
  for (const pubkey of missingPubkeys) {
250
249
  const fetchedEvent = latestByPubkey[pubkey]
251
250
  const cachedEvent = relayCacheEventByPubkey[pubkey] || null
@@ -253,9 +252,29 @@ async function loadMissingRelays (missingPubkeys, {
253
252
  const relays = event
254
253
  ? parseRelayListEvent(event, relayUrlPolicy)
255
254
  : { read: [...emptyRelaysFallback], write: [...emptyRelaysFallback] }
256
- setCachedRelays(pubkey, relays, event, cacheMs)
255
+ entries[pubkey] = { ...relays, event }
256
+ // A failed/incomplete lookup may use fallbacks for this call, but must not
257
+ // turn that failure into a forty-minute negative discovery cache entry.
258
+ if (fetchedEvent || complete) setCachedRelays(pubkey, relays, event, cacheMs)
257
259
  }
258
260
  pruneRelayCache()
261
+ return { entries, report }
262
+ }
263
+
264
+ // Each caller owns its wait, not another caller's shared discovery request.
265
+ function waitForDiscovery (record, signal) {
266
+ signal?.throwIfAborted()
267
+ record.users++
268
+ return new Promise((resolve, reject) => {
269
+ const abort = () => reject(signal.reason)
270
+ signal?.addEventListener('abort', abort, { once: true })
271
+ record.promise.then(resolve, reject).finally(() => signal?.removeEventListener('abort', abort))
272
+ }).finally(() => {
273
+ if (--record.users === 0 && !record.finished) {
274
+ record.controller.abort()
275
+ record.remove()
276
+ }
277
+ })
259
278
  }
260
279
 
261
280
  export async function getRelaysByPubkey (pubkeys, {
@@ -266,40 +285,48 @@ export async function getRelaysByPubkey (pubkeys, {
266
285
  timeout = RELAY_LIST_QUERY_TIMEOUT_MS,
267
286
  timeoutAfterFirstEose = RELAY_LIST_QUERY_TIMEOUT_AFTER_FIRST_EOSE_MS,
268
287
  relayUrlPolicy,
269
- emptyRelaysFallback = freeRelays.slice(0, 2)
288
+ emptyRelaysFallback = freeRelays.slice(0, 2),
289
+ excludeRelays = [],
290
+ signal,
291
+ onQueryResult
270
292
  } = {}) {
293
+ signal?.throwIfAborted()
271
294
  const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
272
295
  if (!pubkeyList.length) return {}
273
-
274
- const loadPubkeys = forceRefresh
275
- ? pubkeyList
276
- : pubkeyList.filter(pubkey => !hasCachedKey(relaysByPubkey, pubkey))
277
- const pubkeysToLoad = loadPubkeys.filter(pubkey => !relayRequestsByPubkey.has(pubkey))
296
+ // Different exclusions cannot share a request that contacts a refused relay.
297
+ const scope = JSON.stringify([...new Set(excludeRelays)].sort())
298
+ const key = pubkey => `${pubkey}:${scope}`
299
+ const loadPubkeys = forceRefresh ? pubkeyList : pubkeyList.filter(pubkey => !hasCachedKey(relaysByPubkey, pubkey))
300
+ const pubkeysToLoad = loadPubkeys.filter(pubkey => !relayRequestsByPubkey.has(key(pubkey)))
278
301
  if (pubkeysToLoad.length) {
279
- const request = loadMissingRelays(pubkeysToLoad, {
280
- getEvents: _getEvents,
281
- cacheMs,
282
- timeout,
283
- timeoutAfterFirstEose,
284
- relayUrlPolicy,
285
- emptyRelaysFallback
286
- }).finally(() => {
302
+ const record = { controller: new AbortController(), users: 0, finished: false }
303
+ record.remove = () => {
287
304
  for (const pubkey of pubkeysToLoad) {
288
- if (relayRequestsByPubkey.get(pubkey) === request) relayRequestsByPubkey.delete(pubkey)
305
+ if (relayRequestsByPubkey.get(key(pubkey)) === record) relayRequestsByPubkey.delete(key(pubkey))
289
306
  }
290
- })
291
- for (const pubkey of pubkeysToLoad) relayRequestsByPubkey.set(pubkey, request)
307
+ }
308
+ record.promise = loadMissingRelays(pubkeysToLoad, {
309
+ getEvents: _getEvents, cacheMs, timeout, timeoutAfterFirstEose, relayUrlPolicy,
310
+ emptyRelaysFallback, excludeRelays, signal: record.controller.signal
311
+ }).finally(() => { record.finished = true; record.remove() })
312
+ for (const pubkey of pubkeysToLoad) relayRequestsByPubkey.set(key(pubkey), record)
292
313
  }
293
-
294
- await Promise.all([...new Set(
295
- loadPubkeys.map(pubkey => relayRequestsByPubkey.get(pubkey)).filter(Boolean)
296
- )])
297
-
298
- return Object.fromEntries(pubkeyList
299
- .filter(pubkey => hasCachedKey(relaysByPubkey, pubkey))
300
- .map(pubkey => {
301
- const entry = cloneRelays(relaysByPubkey[pubkey])
302
- if (includeEvents) entry.event = cloneRelayListEvent(relayCacheEventByPubkey[pubkey])
303
- return [pubkey, entry]
304
- }))
314
+ const entries = {}
315
+ const records = [...new Set(loadPubkeys.map(pubkey => relayRequestsByPubkey.get(key(pubkey))).filter(Boolean))]
316
+ await Promise.all(records.map(async record => {
317
+ const result = await waitForDiscovery(record, signal)
318
+ signal?.throwIfAborted()
319
+ // Deliver the same native diagnostics to every interested caller, including
320
+ // callers that joined an already running query.
321
+ onQueryResult?.(result.report)
322
+ if (result.report.error) throw result.report.error
323
+ Object.assign(entries, result.entries)
324
+ }))
325
+ signal?.throwIfAborted()
326
+ return Object.fromEntries(pubkeyList.map(pubkey => {
327
+ const source = hasCachedKey(relaysByPubkey, pubkey) ? relaysByPubkey[pubkey] : entries[pubkey]
328
+ const entry = cloneRelays(source || { read: emptyRelaysFallback, write: emptyRelaysFallback })
329
+ if (includeEvents) entry.event = cloneRelayListEvent(relayCacheEventByPubkey[pubkey] || entries[pubkey]?.event)
330
+ return [pubkey, entry]
331
+ }))
305
332
  }
@@ -5,7 +5,8 @@ import { createPublishSettlements, firstFulfillment, publishSummary } from '../h
5
5
  import { ReadAdmission } from '../helpers/read-admission.js'
6
6
  import { drainableStream } from '../helpers/drainable-stream.js'
7
7
  import { maybeUnref } from '../helpers/timer.js'
8
- import { categorizeRelayError } from '../helpers/error.js'
8
+ import { categorizeRelayError, relayTimeoutError } from '../helpers/error.js'
9
+ import { ReadRetry, isRetryableReadFailure, needsConnectivityCheck, waitUntil } from '../helpers/read-retry.js'
9
10
  import { normalizeRelayUrl } from '../../url/index.js'
10
11
  import { RelayConnection } from './relay-connection.js'
11
12
 
@@ -76,11 +77,11 @@ function countResponseError () {
76
77
  }
77
78
 
78
79
  function countTimeoutError () {
79
- return new Error('COUNT_TIMEOUT')
80
+ return relayTimeoutError('COUNT_TIMEOUT')
80
81
  }
81
82
 
82
83
  function getEventsTimeoutError () {
83
- return new Error('GET_EVENTS_TIMEOUT')
84
+ return relayTimeoutError('GET_EVENTS_TIMEOUT')
84
85
  }
85
86
 
86
87
  function asError (error) {
@@ -138,11 +139,13 @@ export class RelayPool {
138
139
  #createRelay
139
140
  #WebSocket
140
141
  #admission
142
+ #readRetry
141
143
 
142
- constructor ({ _createRelay, WebSocket: WebSocketImpl, ...capacity } = {}) {
144
+ constructor ({ _createRelay, _isOnline, _onOnline, WebSocket: WebSocketImpl, ...capacity } = {}) {
143
145
  this.#WebSocket = WebSocketImpl
144
146
  this.#createRelay = _createRelay ?? (url => new RelayConnection(url, this.#WebSocket ? { WebSocket: this.#WebSocket } : undefined))
145
147
  this.#admission = new ReadAdmission(capacity)
148
+ this.#readRetry = new ReadRetry({ checkOnline: _isOnline, watchOnline: _onOnline })
146
149
  }
147
150
 
148
151
  // Injects the WebSocket implementation used by new pooled connections (e.g.
@@ -600,6 +603,9 @@ export class RelayPool {
600
603
  const gapTasks = new Set()
601
604
  const liveSubs = new Map() // url → live sub
602
605
  const retryTimers = new Map()
606
+ const retryDelays = new Map()
607
+ const stoppedRelays = new Set()
608
+ const retainedRelays = new Set()
603
609
  const initialPending = new Set(urls)
604
610
  const initialOutcomes = new Map()
605
611
  const readyTimeouts = new Map()
@@ -708,7 +714,7 @@ export class RelayPool {
708
714
  clearTimeout(attempt.timer)
709
715
  attempt.stop.abort()
710
716
  }
711
- for (const timer of retryTimers.values()) clearTimeout(timer)
717
+ for (const retry of retryTimers.values()) retry.abort()
712
718
  retryTimers.clear()
713
719
  liveSubs.forEach(sub => sub.close())
714
720
  liveSubs.clear()
@@ -768,14 +774,61 @@ export class RelayPool {
768
774
  }
769
775
  }
770
776
 
771
- const scheduleReconnect = (url, reconnectDelay) => {
772
- if (isDone || retryTimers.has(url)) return
773
- const nextDelay = Math.min(reconnectDelay * 2, 5 * 60_000)
774
- const timer = maybeUnref(setTimeout(() => {
775
- retryTimers.delete(url)
776
- subscribeToRelay(url, pendingGaps.get(url) ?? lastSeenAt.get(url) ?? openedAt.get(url) ?? null, nextDelay)
777
- }, reconnectDelay))
778
- retryTimers.set(url, timer)
777
+ const releaseRelay = url => {
778
+ if (retainedRelays.delete(url)) this.#decrementLiveSub(url)
779
+ }
780
+ const closeAttempt = (url, attempt) => {
781
+ if (!attempt || attempt.closed) return
782
+ attempt.closed = true
783
+ clearTimeout(attempt.timer)
784
+ attempt.stop.abort()
785
+ const sub = liveSubs.get(url)
786
+ liveSubs.delete(url)
787
+ sub?.close()
788
+ liveLeases.get(url)?.release()
789
+ liveLeases.delete(url)
790
+ readyRelays.delete(url)
791
+ }
792
+ const stopRelay = url => {
793
+ stoppedRelays.add(url)
794
+ retryTimers.get(url)?.abort()
795
+ retryTimers.delete(url)
796
+ closeAttempt(url, attempts.get(url))
797
+ releaseRelay(url)
798
+ if (stoppedRelays.size === urls.length) { finishReady(); teardown(true) }
799
+ }
800
+ const scheduleReconnect = (url, error) => {
801
+ if (isDone || stoppedRelays.has(url) || retryTimers.has(url)) return
802
+ if (!isRetryableReadFailure(error)) { stopRelay(url); return }
803
+ const retry = new AbortController()
804
+ const retrySignal = AbortSignal.any([gapAc.signal, retry.signal])
805
+ const failedAt = Date.now()
806
+ const delay = retryDelays.get(url) ?? 1000
807
+ const retryAt = Number.isFinite(error?.retryAt)
808
+ ? error.retryAt
809
+ : Number.isFinite(error?.retryAfterMs) && error.retryAfterMs > 0 ? failedAt + Math.min(error.retryAfterMs, 300000) : 0
810
+ retryTimers.set(url, retry)
811
+ ;(async () => {
812
+ const online = !needsConnectivityCheck(error) || await this.#readRetry.confirmOnline(retrySignal)
813
+ retrySignal.throwIfAborted()
814
+ const deadline = Math.max(failedAt + (online ? delay : 0), retryAt)
815
+ if (online) retryDelays.set(url, Math.min(delay * 2, 300000))
816
+ else await this.#readRetry.waitUntilOnline(retrySignal)
817
+ while (!retrySignal.aborted) {
818
+ await waitUntil(deadline, retrySignal)
819
+ if (needsConnectivityCheck(error) && !await this.#readRetry.confirmOnline(retrySignal)) {
820
+ await this.#readRetry.waitUntilOnline(retrySignal)
821
+ continue
822
+ }
823
+ retrySignal.throwIfAborted()
824
+ if (isDone || stoppedRelays.has(url) || retryTimers.get(url) !== retry) return
825
+ retryTimers.delete(url)
826
+ subscribeToRelay(url, pendingGaps.get(url) ?? lastSeenAt.get(url) ?? openedAt.get(url) ?? null)
827
+ return
828
+ }
829
+ })().catch(failure => {
830
+ if (!retrySignal.aborted && !isDone) { reportFailure(url, asError(failure)); stopRelay(url) }
831
+ })
779
832
  }
780
833
 
781
834
  // Schedule teardown when the wall clock reaches filter.until
@@ -799,26 +852,36 @@ export class RelayPool {
799
852
  })
800
853
  return (async () => {
801
854
  let completed = false
802
- let failed = false
855
+ let error
803
856
  for await (const item of gapGen) {
804
857
  if (item?.type === 'event') {
805
858
  observeEvent(item.event, url)
806
859
  pushEvent(item.event, url, true)
807
860
  } else if (item?.type === 'error') {
808
- failed = true
861
+ if (!error || !isRetryableReadFailure(item.error)) error = item.error
809
862
  if (!isDone) enqueue(item)
810
863
  } else if (item?.type === 'eose') {
811
864
  completed = item.relays?.length === 1 && ['eose', 'satisfied'].includes(item.relays[0].status)
865
+ if (!completed && !error) {
866
+ error = item.relays?.[0]?.error || categorizeRelayError(new Error('RELAY_RECONNECT_GAP_INCOMPLETE'), 'timeout')
867
+ if (!isDone) enqueue({ type: 'error', relay: url, error })
868
+ }
812
869
  }
813
870
  }
814
- return completed && !failed
871
+ if (!completed && !error && !attempt.stop.signal.aborted && !isDone) {
872
+ error = new Error('RELAY_RECONNECT_GAP_INCOMPLETE')
873
+ reportFailure(url, error)
874
+ }
875
+ return { recovered: completed && !error, error }
815
876
  })().catch(err => {
816
- reportFailure(url, asError(err))
817
- return false
877
+ const error = asError(err)
878
+ if (!attempt.stop.signal.aborted) reportFailure(url, error)
879
+ return { recovered: false, error }
818
880
  }).finally(() => slots.history.release())
819
881
  }
820
882
 
821
- const subscribeToRelay = (url, gapSince, reconnectDelay = 1000) => {
883
+ const subscribeToRelay = (url, gapSince) => {
884
+ if (isDone || stoppedRelays.has(url)) return
822
885
  let now = Math.floor(Date.now() / 1000)
823
886
  // Don't reconnect if we're past the until boundary
824
887
  if (filterUntil !== null && now >= filterUntil) return
@@ -829,10 +892,10 @@ export class RelayPool {
829
892
  attempts.set(url, attempt)
830
893
  let recoveryLease
831
894
  initialAdmissions.add(url)
832
- Promise.resolve(reservation || this.#admission.acquire(normalizeRelayUrl(url), { live: true, history: recovering, signal: gapAc.signal, queueTimeout })).then(async slots => {
895
+ Promise.resolve(reservation || this.#admission.acquire(normalizeRelayUrl(url), { live: true, history: recovering, signal: AbortSignal.any([gapAc.signal, attempt.stop.signal]), queueTimeout })).then(async slots => {
833
896
  const lease = slots.live
834
897
  recoveryLease = recovering ? slots.history : null
835
- if (isDone) { lease.release(); recoveryLease?.release(); return }
898
+ if (isDone || attempt.closed) { lease.release(); recoveryLease?.release(); return }
836
899
  liveLeases.set(url, lease)
837
900
  if (timeout !== null && initialPending.has(url)) {
838
901
  readyTimeouts.set(url, maybeUnref(setTimeout(() => {
@@ -844,7 +907,7 @@ export class RelayPool {
844
907
  }, timeout)))
845
908
  }
846
909
  const relay = await this.#getRelay(url)
847
- if (isDone) { recoveryLease?.release(); return }
910
+ if (isDone || attempt.closed) { lease.release(); recoveryLease?.release(); return }
848
911
  now = Math.floor(Date.now() / 1000)
849
912
  if (filterUntil !== null && now >= filterUntil) { lease.release(); recoveryLease?.release(); return }
850
913
 
@@ -878,37 +941,39 @@ export class RelayPool {
878
941
  },
879
942
  onclose: error => {
880
943
  if (attempt.closed || attempts.get(url) !== attempt) return
881
- attempt.closed = true
882
- clearTimeout(attempt.timer)
883
- attempt.stop.abort()
884
- if (liveSubs.get(url) === liveSub) liveSubs.delete(url)
885
- lease.release()
886
- if (liveLeases.get(url) === lease) liveLeases.delete(url)
887
- readyRelays.delete(url)
944
+ closeAttempt(url, attempt)
888
945
  if (isDone) return
889
- if (error !== undefined) reportFailure(url, asError(error))
890
- else {
891
- const interruption = Object.assign(categorizeRelayError(new Error('RELAY_LIVE_INTERRUPTED'), 'transport'), { code: 'RELAY_LIVE_INTERRUPTED' })
892
- enqueue({ type: 'error', relay: url, error: interruption })
893
- reportClosed(url, interruption)
946
+ if (error !== undefined) { error = asError(error); reportFailure(url, error) } else {
947
+ error = Object.assign(categorizeRelayError(new Error('RELAY_LIVE_INTERRUPTED'), 'transport'), { code: 'RELAY_LIVE_INTERRUPTED' })
948
+ enqueue({ type: 'error', relay: url, error })
949
+ reportClosed(url, error)
894
950
  }
895
- scheduleReconnect(url, reconnectDelay)
951
+ scheduleReconnect(url, error)
896
952
  },
897
953
  oneose: () => {
898
954
  if (isDone || attempt.closed || attempts.get(url) !== attempt || liveEose) return
899
955
  liveEose = true
900
956
  attempt.since = Math.max(sinceFloor, Math.floor(Date.now() / 1000))
901
957
  markInitialEose(url)
958
+ if (attempt.recovered) retryDelays.delete(url)
902
959
  scheduleProgress(attempt)
903
960
  }
904
961
  })
905
- if (isDone) { liveSub.close(); recoveryLease?.release(); return }
962
+ if (isDone || attempt.closed) { liveSub.close(); lease.release(); recoveryLease?.release(); return }
906
963
  liveSubs.set(url, liveSub)
907
964
 
908
965
  if (recovering) {
909
- const task = runReconnectGapFill(url, gapSince, now, slots, attempt).then(recovered => {
966
+ const task = runReconnectGapFill(url, gapSince, now, slots, attempt).then(({ recovered, error }) => {
910
967
  attempt.recovered = recovered
911
- if (recovered && !attempt.closed && attempts.get(url) === attempt) pendingGaps.delete(url)
968
+ if (!attempt.closed && attempts.get(url) === attempt) {
969
+ if (recovered) {
970
+ pendingGaps.delete(url)
971
+ if (liveEose) retryDelays.delete(url)
972
+ } else if (error && !isDone) {
973
+ closeAttempt(url, attempt)
974
+ scheduleReconnect(url, error)
975
+ }
976
+ }
912
977
  }).finally(() => {
913
978
  const buf = liveBuffer
914
979
  liveBuffer = null
@@ -925,9 +990,10 @@ export class RelayPool {
925
990
  liveLeases.delete(url)
926
991
  readyRelays.delete(url)
927
992
  const reason = err instanceof Error ? err : new Error(String(err))
993
+ if (isDone || attempt.closed) return
994
+ closeAttempt(url, attempt)
928
995
  reportFailure(url, reason)
929
- if (isDone) return
930
- scheduleReconnect(url, reconnectDelay)
996
+ scheduleReconnect(url, reason)
931
997
  })
932
998
  }
933
999
 
@@ -946,6 +1012,7 @@ export class RelayPool {
946
1012
 
947
1013
  for (const url of urls) {
948
1014
  this.#incrementLiveSub(url)
1015
+ retainedRelays.add(url)
949
1016
  subscribeToRelay(url, null) // no initial gap fill — that's getEventsFeedGenerator's job
950
1017
  }
951
1018
 
@@ -960,7 +1027,7 @@ export class RelayPool {
960
1027
  } finally {
961
1028
  signal?.removeEventListener('abort', abort)
962
1029
  _stopSignal?.removeEventListener('abort', stop)
963
- for (const url of urls) this.#decrementLiveSub(url)
1030
+ for (const url of [...retainedRelays]) releaseRelay(url)
964
1031
  teardown()
965
1032
  }
966
1033
  }