libp2r2p 0.11.13 → 0.11.14

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
@@ -877,9 +877,42 @@ the first pass routes through up to `maxPerPubkey` shared relays, and missing
877
877
  authors are retried on every remaining relay plus `fallbackRelays` (default:
878
878
  the first three `freeRelays`), excluding what was already queried. It accepts
879
879
  `relaysByPubkey` to reuse a previous discovery (only missing pubkeys are then
880
- discovered) and returns `{ events, byPubkey, relaysByPubkey }` so the merged
880
+ discovered) and returns `{ events, byPubkey, relaysByPubkey, requests }` so the merged
881
881
  relay map can be passed back on later calls.
882
882
 
883
+
884
+ Read diagnostics preserve native failures rather than treating an empty result
885
+ as proof that every relay completed successfully. `requests` contains records
886
+ `{ phase, authors, relays, errors?, error? }`: phase is `discovery`, `primary` or
887
+ `fallback`; `relays` uses the pool's `{ relay, status, error? }` outcomes. An
888
+ operation-wide exception is retained as `error`. Events remain in the existing
889
+ result fields, never in diagnostic records. `onQueryResult(record)` receives each
890
+ record as the underlying query settles, including before a later cancellation.
891
+
892
+ Both discovery and latest-event queries accept `signal`. Cancelling one consumer
893
+ of shared NIP-65 discovery releases only its interest; the underlying query is
894
+ aborted when its last consumer leaves. Discovery's optional `onQueryResult`
895
+ receives the same native report for every joining caller. Incomplete/failed
896
+ lookups can use fallback relays for that call but never negatively cache a
897
+ missing relay list for forty minutes. Actual lists and completely empty EOSE
898
+ responses retain their normal cache behavior.
899
+
900
+ `getLatestEventsByPubkey` accepts `excludeRelaysByPubkey` (object or Map) for both
901
+ passes. `getRelaysByPubkey` accepts `excludeRelays` for seed discovery; pass it
902
+ through `relayListOptions` when using the two-pass query. Different exclusion
903
+ sets do not share in-flight discovery that could contact a refused relay.
904
+
905
+ `isRetryableRelayFailure(error)` identifies connection/transport/timeouts and
906
+ leading `rate-limited:`/`error:` relay failures. `isReplaceableRelayFailure(error)`
907
+ also admits relay policy refusals (`blocked:`, `restricted:`, `auth-required:`,
908
+ `pow:`) for routing to alternatives, preserving the messenger's existing policy.
909
+ Local authentication denials, invalid events and unknown errors qualify for
910
+ neither policy. Predicates classify individual native errors; callers decide
911
+ how to handle mixed per-relay outcomes and application-specific aggregates.
912
+ Existing rate-limit parsing preserves numeric positive `retry_after` seconds as
913
+ `retryAfterMs` and absolute `retryAt`, bounded to five minutes. Schedulers must
914
+ use the later of their backoff deadline and `retryAt`, not add the two delays.
915
+
883
916
  ## Binary encodings
884
917
 
885
918
  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.14",
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)
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
  }