libp2r2p 0.10.11 → 0.10.12

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
@@ -364,6 +364,26 @@ Low-level relay sockets, subscriptions, message parsing, and serialization are
364
364
  internal implementation details; use `RelayPool` or the `relayPool` singleton
365
365
  from `libp2r2p/relay`.
366
366
 
367
+ `getEvents` and `getEventsGenerator` accept `deduplicateAcrossRelays` (boolean,
368
+ default `true`). With `false`, a matching event is delivered once per relay,
369
+ while repeated IDs from the same relay remain suppressed. Each occurrence owns
370
+ its `meta.relay`; callbacks still run immediately and deadlines, EOSE handling,
371
+ and per-relay filter limits are unchanged. The callback/generator item remains
372
+ `{ type: 'event', event, relay }`, and the completed query remains
373
+ `{ result, errors, success }`. The option does not extend to the live or feed
374
+ generators. Callers that need replication coverage can aggregate the returned
375
+ copies by event ID; missing responses do not prove absence from a relay.
376
+
377
+ Publication errors retain their existing `reason` objects and may expose
378
+ `category`: `connection` (WebSocket establishment), `transport` (socket send or
379
+ close), `relay` (an explicit negative `OK`), or `timeout` (missing confirmation).
380
+ Native messages, codes, nested causes and aggregate errors remain available;
381
+ WebSocket closure details use `closeCode`, `closeReason`, and `wasClean` rather
382
+ than overwriting a native `code`. A timeout can retain a preceding socket error
383
+ as its cause without claiming that the relay rejected the event. Authentication
384
+ wrappers preserve this context. Local failures can remain uncategorized.
385
+ Event metadata is internal and is removed by `sendEvent` before serialization.
386
+
367
387
  The same public subpath exports `getRelaysByPubkey(pubkeys)`, which discovers
368
388
  the latest NIP-65 relay list for every requested pubkey through `seedRelays`,
369
389
  normalizes and deduplicates its public relay URLs, and falls back to the first
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.11",
3
+ "version": "0.10.12",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -1,7 +1,8 @@
1
1
  // Used only to discover users' NIP-65 relay lists (kind:10002).
2
2
  export const seedRelays = [
3
3
  'wss://relay.44billion.net',
4
- 'wss://purplepag.es',
4
+ // Disabled 2026-09-08: offline for some days
5
+ // 'wss://purplepag.es',
5
6
  'wss://user.kindpag.es',
6
7
  'wss://relay.nos.social',
7
8
  // Disabled 2026-08-05: accepted kind:10002 with OK but did not broadcast it
@@ -15,6 +16,7 @@ export const seedRelays = [
15
16
  export const freeRelays = [
16
17
  'wss://relay.44billion.net',
17
18
  'wss://nos.lol',
19
+ 'wss://relay.dreamith.to',
18
20
  'wss://relay.primal.net'
19
21
  ]
20
22
 
@@ -0,0 +1,30 @@
1
+ // Adds transport context without replacing native messages, codes or causes.
2
+ export function categorizeRelayError (reason, category, fallback = 'RELAY_OPERATION_FAILED') {
3
+ const error = reason instanceof Error ? reason : new Error(String(reason || fallback))
4
+ try {
5
+ Object.defineProperty(error, 'category', { value: category, enumerable: true, configurable: true })
6
+ return error
7
+ } catch {
8
+ const wrapped = error instanceof AggregateError
9
+ ? new AggregateError(error.errors, error.message, { cause: error })
10
+ : new Error(error.message, { cause: error })
11
+ wrapped.name = error.name
12
+ if (error.code !== undefined) wrapped.code = error.code
13
+ wrapped.category = category
14
+ return wrapped
15
+ }
16
+ }
17
+
18
+ // A timeout indicates missing confirmation, even when a socket error preceded it.
19
+ export function relayTimeoutError (message, cause) {
20
+ return categorizeRelayError(new Error(message, cause ? { cause } : undefined), 'timeout')
21
+ }
22
+
23
+ // WebSocket close codes are distinct from native system error codes.
24
+ export function relayCloseError (event, category, cause) {
25
+ const error = new Error(event?.reason || 'CONNECTION_CLOSED', cause ? { cause } : undefined)
26
+ if (event?.code !== undefined) error.closeCode = event.code
27
+ if (event?.reason !== undefined) error.closeReason = event.reason
28
+ if (event?.wasClean !== undefined) error.wasClean = event.wasClean
29
+ return categorizeRelayError(error, category)
30
+ }
@@ -1,7 +1,8 @@
1
1
  import { maybeUnref } from './timer.js'
2
+ import { relayTimeoutError } from './error.js'
2
3
 
3
4
  function publishTimeoutError () {
4
- return new Error('PUBLISH_TIMEOUT')
5
+ return relayTimeoutError('PUBLISH_TIMEOUT')
5
6
  }
6
7
 
7
8
  // Resolves once any relay accepts the event, all relays reject, an optional
@@ -1,6 +1,7 @@
1
1
  import { isValidEvent } from '../../event/index.js'
2
2
  import { ValidationError } from '../../error/index.js'
3
3
  import { maybeUnref } from '../helpers/timer.js'
4
+ import { categorizeRelayError, relayCloseError, relayTimeoutError } from '../helpers/error.js'
4
5
 
5
6
  const DEFAULT_CONNECT_TIMEOUT = 3000
6
7
  const DEFAULT_OPERATION_TIMEOUT = 30000
@@ -44,6 +45,7 @@ export class RelayConnection {
44
45
  #connectPromise = null
45
46
  #challenge = null
46
47
  #serial = 0
48
+ #lastTransportError = null
47
49
  #subscriptions = new Map()
48
50
  #publishes = new Map()
49
51
  #authentications = new Map()
@@ -60,14 +62,22 @@ export class RelayConnection {
60
62
  this.onauth = null
61
63
  }
62
64
 
65
+ // Exposes socket context to the pool's operation-wide publication deadline.
66
+ get lastTransportError () { return this.#lastTransportError }
67
+
63
68
  async connect ({ timeout = DEFAULT_CONNECT_TIMEOUT, signal } = {}) {
64
69
  if (this.ws?.readyState === 1) return
65
70
  if (this.#connectPromise) return await this.#connectPromise
66
71
  if (signal?.aborted) throw new Error('CONNECT_ABORTED')
67
- if (typeof this.#WebSocket !== 'function') throw new Error('WEBSOCKET_UNAVAILABLE')
72
+ if (typeof this.#WebSocket !== 'function') throw categorizeRelayError(new Error('WEBSOCKET_UNAVAILABLE'), 'connection')
68
73
 
74
+ this.#lastTransportError = null
69
75
  this.#connectPromise = new Promise((resolve, reject) => {
70
- const socket = new this.#WebSocket(this.url)
76
+ let socket
77
+ try { socket = new this.#WebSocket(this.url) } catch (error) {
78
+ reject(categorizeRelayError(error, 'connection'))
79
+ return
80
+ }
71
81
  this.ws = socket
72
82
  let settled = false
73
83
  const finish = (reason) => {
@@ -81,18 +91,21 @@ export class RelayConnection {
81
91
  } else resolve()
82
92
  }
83
93
  const onAbort = () => finish(new Error('CONNECT_ABORTED'))
84
- const timer = timeout === null ? null : maybeUnref(setTimeout(() => finish(new Error('CONNECT_TIMEOUT')), timeout))
94
+ const timer = timeout === null ? null : maybeUnref(setTimeout(() => finish(relayTimeoutError('CONNECT_TIMEOUT')), timeout))
85
95
  signal?.addEventListener('abort', onAbort, { once: true })
86
96
 
87
97
  socket.onopen = () => finish()
88
98
  socket.onerror = event => {
89
- const reason = errorFrom(event?.error, 'CONNECTION_ERROR')
99
+ const reason = categorizeRelayError(event?.error, settled ? 'transport' : 'connection', 'CONNECTION_ERROR')
90
100
  if (!settled) finish(reason)
91
- else this.onerror?.(reason)
101
+ else {
102
+ this.#lastTransportError = reason
103
+ this.onerror?.(reason)
104
+ }
92
105
  }
93
106
  socket.onmessage = event => { this.#handleMessage(event).catch(reason => this.onerror?.(reason)) }
94
107
  socket.onclose = event => {
95
- if (!settled) finish(new Error('CONNECTION_CLOSED'))
108
+ if (!settled) finish(relayCloseError(event, 'connection'))
96
109
  if (this.ws === socket) this.ws = null
97
110
  this.#handleClose(event)
98
111
  }
@@ -101,8 +114,11 @@ export class RelayConnection {
101
114
  }
102
115
 
103
116
  send (message) {
104
- if (this.ws?.readyState !== 1) throw new Error('CONNECTION_CLOSED')
105
- this.ws.send(message)
117
+ if (this.ws?.readyState !== 1) throw relayCloseError(null, 'transport', this.#lastTransportError)
118
+ try { this.ws.send(message) } catch (error) {
119
+ this.#lastTransportError = categorizeRelayError(error, 'transport')
120
+ throw this.#lastTransportError
121
+ }
106
122
  }
107
123
 
108
124
  subscribe (filters, handlers = {}) {
@@ -141,7 +157,7 @@ export class RelayConnection {
141
157
  #sendEventOperation (type, event, map, timeoutCode) {
142
158
  if (map.has(event.id)) return map.get(event.id).promise
143
159
  const deferred = Promise.withResolvers()
144
- const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, new Error(timeoutCode)), this.publishTimeout))
160
+ const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, relayTimeoutError(timeoutCode, this.#lastTransportError)), this.publishTimeout))
145
161
  map.set(event.id, { ...deferred, timer, promise: deferred.promise })
146
162
  try { this.send(JSON.stringify([type, event])) } catch (error) {
147
163
  this.#settleEvent(map, event.id, error)
@@ -210,7 +226,7 @@ export class RelayConnection {
210
226
  return
211
227
  }
212
228
  if (data[0] === 'OK') {
213
- const reason = data[2] === true ? null : errorFrom(data[3], 'EVENT_REJECTED')
229
+ const reason = data[2] === true ? null : categorizeRelayError(data[3], 'relay', 'EVENT_REJECTED')
214
230
  this.#settleEvent(this.#publishes, data[1], reason, data[3])
215
231
  this.#settleEvent(this.#authentications, data[1], reason, data[3])
216
232
  return
@@ -229,7 +245,7 @@ export class RelayConnection {
229
245
 
230
246
  #handleClose (event) {
231
247
  this.#challenge = null
232
- const reason = errorFrom(event?.reason, 'CONNECTION_CLOSED')
248
+ const reason = relayCloseError(event, 'transport', this.#lastTransportError)
233
249
  for (const [id, subscription] of this.#subscriptions) {
234
250
  this.#subscriptions.delete(id)
235
251
  subscription.handlers.onclose?.(reason)
@@ -244,7 +260,7 @@ export class RelayConnection {
244
260
  const socket = this.ws
245
261
  this.ws = null
246
262
  this.#challenge = null
247
- const reason = new Error('CONNECTION_CLOSED')
263
+ const reason = relayCloseError(null, 'transport', this.#lastTransportError)
248
264
  for (const [id, subscription] of this.#subscriptions) {
249
265
  this.#subscriptions.delete(id)
250
266
  subscription.handlers.onclose?.()
@@ -2,6 +2,7 @@ import { ValidationError } from '../../error/index.js'
2
2
  import { decodeHll, encodeHll, estimateHllCount, mergeHll } from '../helpers/hll.js'
3
3
  import { createPublishSettlements, firstFulfillment, publishSummary } from '../helpers/publish.js'
4
4
  import { maybeUnref } from '../helpers/timer.js'
5
+ import { categorizeRelayError } from '../helpers/error.js'
5
6
  import { normalizeRelayUrl } from '../../url/index.js'
6
7
  import { RelayConnection } from './relay-connection.js'
7
8
 
@@ -102,6 +103,8 @@ class Nip42AuthenticationError extends Error {
102
103
  constructor (reason) {
103
104
  super(reason.message, { cause: reason })
104
105
  this.name = 'Nip42AuthenticationError'
106
+ if (reason.category) this.category = reason.category
107
+ if (reason.code !== undefined) this.code = reason.code
105
108
  }
106
109
  }
107
110
 
@@ -143,7 +146,7 @@ export class RelayPool {
143
146
  try {
144
147
  await relay.close()
145
148
  } catch {}
146
- throw error
149
+ throw categorizeRelayError(error, error?.category ?? 'connection')
147
150
  }
148
151
 
149
152
  // Only reset idle timeout when no live subscriptions are holding this relay open.
@@ -335,8 +338,10 @@ export class RelayPool {
335
338
 
336
339
  // Collects a one-shot relay read. The first EOSE with events opens a short
337
340
  // grace window; null disables that window so callers wait for every relay or
338
- // the operation deadline. Event ids are deduplicated across relay responses.
339
- async getEvents (filter, relays, { timeout = 5000, timeoutAfterFirstEose = 500, callback, signal } = {}) {
341
+ // the operation deadline. Disabling cross-relay deduplication still suppresses
342
+ // repeated ids from the same relay; callbacks remain immediate in both modes.
343
+ async getEvents (filter, relays, { timeout = 5000, timeoutAfterFirstEose = 500, callback, signal, deduplicateAcrossRelays = true } = {}) {
344
+ if (typeof deduplicateAcrossRelays !== 'boolean') throw new ValidationError('INVALID_DEDUPLICATE_ACROSS_RELAYS')
340
345
  const urls = normalizedRelayUrls(relays)
341
346
  if (!urls.length) return { result: [], errors: [], success: false }
342
347
  if (signal?.aborted) throw new Error('Aborted')
@@ -346,7 +351,7 @@ export class RelayPool {
346
351
  const normalCloseUrls = new Set()
347
352
  const errors = []
348
353
  const events = []
349
- const eventIds = new Set()
354
+ const eventIds = deduplicateAcrossRelays ? new Set() : null
350
355
  let completed = 0
351
356
  let isResolved = false
352
357
  let eoseTimer = null
@@ -411,6 +416,7 @@ export class RelayPool {
411
416
  if (timeout !== null) timeoutTimer = maybeUnref(setTimeout(timeoutPending, timeout))
412
417
 
413
418
  for (const url of urls) {
419
+ const seenIds = eventIds ?? new Set()
414
420
  this.#getRelay(url).then(relay => {
415
421
  if (isResolved || !pending.has(url)) return
416
422
  let hasEvents = false
@@ -432,10 +438,8 @@ export class RelayPool {
432
438
  onevent: (event) => {
433
439
  if (isResolved || !pending.has(url)) return
434
440
  hasEvents = true
435
- // Keep filter-limit accounting per relay, but only expose one copy of
436
- // a matching event when several relays return the same id.
437
- if (!event?.id || !eventIds.has(event.id)) {
438
- if (event?.id) eventIds.add(event.id)
441
+ if (!event?.id || !seenIds.has(event.id)) {
442
+ if (event?.id) seenIds.add(event.id)
439
443
  event.meta = { relay: url }
440
444
  events.push(event)
441
445
  if (callback) callback({ type: 'event', event, relay: url })
@@ -859,6 +863,10 @@ export class RelayPool {
859
863
  // Starts before connection work so every relay shares one real deadline.
860
864
  const settlement = createPublishSettlements(sendPromises, timeout, {
861
865
  onSettled: (settlement, index) => {
866
+ if (settlement.reason?.category === 'timeout' && !settlement.reason.cause) {
867
+ const relay = this.#relays.get(normalizeRelayUrl(urls[index]))
868
+ if (relay?.lastTransportError) settlement.reason.cause = relay.lastTransportError
869
+ }
862
870
  notifyRelayResult(onRelayResult, relayResultForSettlement(urls[index], settlement))
863
871
  }
864
872
  })