libp2r2p 0.10.11 → 0.10.13

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
@@ -12,6 +12,35 @@ For remote-work scheduling, see [`libp2r2p/network`](network/README.md):
12
12
  `isOnline` probes connectivity and `onOnline` shares recovery monitoring,
13
13
  including retries when the browser omits its native `online` event.
14
14
 
15
+ ## Relay feed lifecycle
16
+
17
+ `RelayPool.getEventsFeedGenerator(filter, relays, options)` yields events directly,
18
+ including the existing `event.meta.relay` field. By default it combines an initial
19
+ historical query with a live subscription. `live: false` selects history only;
20
+ `filter.limit: 0` selects live only. `getLiveEventsGenerator` exposes the same
21
+ lifecycle controls along with its existing `ready` and `readyRelays` properties.
22
+
23
+ The returned iterator has a synchronous, idempotent `stopAndDrain()` method. It
24
+ closes subscription input and cancels reconnections and outstanding historical
25
+ queries, while retaining events already accepted by the library's receive
26
+ callbacks. Continue consuming the iterator to obtain those events and observe
27
+ completion. This includes initial history, buffered live events, and both history
28
+ and live buffers from reconnect recovery. Calling it before the first `next()`
29
+ prevents subscriptions from opening. No reception timestamp or new event metadata
30
+ is added. Events rejected by ordinary filtering/deduplication remain excluded.
31
+
32
+ Aborting `options.signal`, calling `return()` (including a `for await` break), or
33
+ calling `throw()` cancels input and pending delivery instead. An event already
34
+ delivered to the caller cannot be recalled. These operations also interrupt a
35
+ drain. `stopAndDrain()` does not consume the iterator or return a completion
36
+ promise: completion is the iterator's `{ done: true }` result.
37
+
38
+ ```js
39
+ const stream = relayPool.getEventsFeedGenerator({ authors: [pubkey] }, [relay], { signal })
40
+ // When this relay is removed, call stream.stopAndDrain() from the list handler.
41
+ for await (const event of stream) await store(event)
42
+ ```
43
+
15
44
  ## Private Messenger
16
45
 
17
46
  The main API is `createPrivateMessenger` from `libp2r2p/private-messenger`.
@@ -364,6 +393,26 @@ Low-level relay sockets, subscriptions, message parsing, and serialization are
364
393
  internal implementation details; use `RelayPool` or the `relayPool` singleton
365
394
  from `libp2r2p/relay`.
366
395
 
396
+ `getEvents` and `getEventsGenerator` accept `deduplicateAcrossRelays` (boolean,
397
+ default `true`). With `false`, a matching event is delivered once per relay,
398
+ while repeated IDs from the same relay remain suppressed. Each occurrence owns
399
+ its `meta.relay`; callbacks still run immediately and deadlines, EOSE handling,
400
+ and per-relay filter limits are unchanged. The callback/generator item remains
401
+ `{ type: 'event', event, relay }`, and the completed query remains
402
+ `{ result, errors, success }`. The option does not extend to the live or feed
403
+ generators. Callers that need replication coverage can aggregate the returned
404
+ copies by event ID; missing responses do not prove absence from a relay.
405
+
406
+ Publication errors retain their existing `reason` objects and may expose
407
+ `category`: `connection` (WebSocket establishment), `transport` (socket send or
408
+ close), `relay` (an explicit negative `OK`), or `timeout` (missing confirmation).
409
+ Native messages, codes, nested causes and aggregate errors remain available;
410
+ WebSocket closure details use `closeCode`, `closeReason`, and `wasClean` rather
411
+ than overwriting a native `code`. A timeout can retain a preceding socket error
412
+ as its cause without claiming that the relay rejected the event. Authentication
413
+ wrappers preserve this context. Local failures can remain uncategorized.
414
+ Event metadata is internal and is removed by `sendEvent` before serialization.
415
+
367
416
  The same public subpath exports `getRelaysByPubkey(pubkeys)`, which discovers
368
417
  the latest NIP-65 relay list for every requested pubkey through `seedRelays`,
369
418
  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.13",
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,17 @@
1
+ // Stopping closes network input but keeps accepted events available to next().
2
+ // Returning/throwing, or aborting the caller signal, cancels consumption as well.
3
+ export function drainableStream (create, options = {}) {
4
+ const cancel = new AbortController()
5
+ const stop = new AbortController()
6
+ const signal = options.signal ? AbortSignal.any([options.signal, cancel.signal]) : cancel.signal
7
+ const stopSignal = options._stopSignal ? AbortSignal.any([options._stopSignal, stop.signal]) : stop.signal
8
+ const stream = create({ ...options, signal, _stopSignal: stopSignal })
9
+ const returnStream = stream.return.bind(stream)
10
+ const throwStream = stream.throw.bind(stream)
11
+ Object.defineProperties(stream, {
12
+ stopAndDrain: { value: () => stop.abort() },
13
+ return: { value: value => { cancel.abort(); return returnStream(value) } },
14
+ throw: { value: error => { cancel.abort(); return throwStream(error) } }
15
+ })
16
+ return stream
17
+ }
@@ -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?.()
@@ -1,7 +1,9 @@
1
1
  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
+ import { drainableStream } from '../helpers/drainable-stream.js'
4
5
  import { maybeUnref } from '../helpers/timer.js'
6
+ import { categorizeRelayError } from '../helpers/error.js'
5
7
  import { normalizeRelayUrl } from '../../url/index.js'
6
8
  import { RelayConnection } from './relay-connection.js'
7
9
 
@@ -102,6 +104,8 @@ class Nip42AuthenticationError extends Error {
102
104
  constructor (reason) {
103
105
  super(reason.message, { cause: reason })
104
106
  this.name = 'Nip42AuthenticationError'
107
+ if (reason.category) this.category = reason.category
108
+ if (reason.code !== undefined) this.code = reason.code
105
109
  }
106
110
  }
107
111
 
@@ -143,7 +147,7 @@ export class RelayPool {
143
147
  try {
144
148
  await relay.close()
145
149
  } catch {}
146
- throw error
150
+ throw categorizeRelayError(error, error?.category ?? 'connection')
147
151
  }
148
152
 
149
153
  // Only reset idle timeout when no live subscriptions are holding this relay open.
@@ -335,8 +339,10 @@ export class RelayPool {
335
339
 
336
340
  // Collects a one-shot relay read. The first EOSE with events opens a short
337
341
  // 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 } = {}) {
342
+ // the operation deadline. Disabling cross-relay deduplication still suppresses
343
+ // repeated ids from the same relay; callbacks remain immediate in both modes.
344
+ async getEvents (filter, relays, { timeout = 5000, timeoutAfterFirstEose = 500, callback, signal, deduplicateAcrossRelays = true } = {}) {
345
+ if (typeof deduplicateAcrossRelays !== 'boolean') throw new ValidationError('INVALID_DEDUPLICATE_ACROSS_RELAYS')
340
346
  const urls = normalizedRelayUrls(relays)
341
347
  if (!urls.length) return { result: [], errors: [], success: false }
342
348
  if (signal?.aborted) throw new Error('Aborted')
@@ -346,7 +352,7 @@ export class RelayPool {
346
352
  const normalCloseUrls = new Set()
347
353
  const errors = []
348
354
  const events = []
349
- const eventIds = new Set()
355
+ const eventIds = deduplicateAcrossRelays ? new Set() : null
350
356
  let completed = 0
351
357
  let isResolved = false
352
358
  let eoseTimer = null
@@ -411,6 +417,7 @@ export class RelayPool {
411
417
  if (timeout !== null) timeoutTimer = maybeUnref(setTimeout(timeoutPending, timeout))
412
418
 
413
419
  for (const url of urls) {
420
+ const seenIds = eventIds ?? new Set()
414
421
  this.#getRelay(url).then(relay => {
415
422
  if (isResolved || !pending.has(url)) return
416
423
  let hasEvents = false
@@ -432,10 +439,8 @@ export class RelayPool {
432
439
  onevent: (event) => {
433
440
  if (isResolved || !pending.has(url)) return
434
441
  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)
442
+ if (!event?.id || !seenIds.has(event.id)) {
443
+ if (event?.id) seenIds.add(event.id)
439
444
  event.meta = { relay: url }
440
445
  events.push(event)
441
446
  if (callback) callback({ type: 'event', event, relay: url })
@@ -476,7 +481,11 @@ export class RelayPool {
476
481
  p = Promise.withResolvers()
477
482
  }
478
483
 
479
- const methodPromise = this.getEvents(filter, relays, { ...options, callback })
484
+ // A drain stops receive callbacks without aborting consumption of this queue.
485
+ const networkSignal = options._stopSignal
486
+ ? AbortSignal.any([options._stopSignal, ...(options.signal ? [options.signal] : [])])
487
+ : options.signal
488
+ const methodPromise = this.getEvents(filter, relays, { ...options, signal: networkSignal, callback })
480
489
  .catch(err => { if (err?.message !== 'Aborted') console.error('Error in getEvents:', err) })
481
490
  .finally(() => {
482
491
  isDone = true
@@ -485,6 +494,7 @@ export class RelayPool {
485
494
 
486
495
  // eslint-disable-next-line no-unmodified-loop-condition
487
496
  while (!isDone || queue.length > 0) {
497
+ if (options.signal?.aborted) break
488
498
  if (queue.length > 0) yield queue.shift()
489
499
  else await p.promise
490
500
  }
@@ -497,7 +507,7 @@ export class RelayPool {
497
507
  getLiveEventsGenerator (filter, relays, options = {}) {
498
508
  const ready = Promise.withResolvers()
499
509
  const readyRelays = new Set()
500
- const stream = this.#getLiveEventsGenerator(filter, relays, options, { ready, readyRelays })
510
+ const stream = drainableStream(options => this.#getLiveEventsGenerator(filter, relays, options, { ready, readyRelays }), options)
501
511
 
502
512
  Object.defineProperties(stream, {
503
513
  ready: {
@@ -517,6 +527,7 @@ export class RelayPool {
517
527
  // suppressing already-live events from another relay.
518
528
  async * #getLiveEventsGenerator (filter, relays, {
519
529
  signal,
530
+ _stopSignal,
520
531
  timeoutAfterFirstEose = 500,
521
532
  timeoutForReconnectGap = 5000,
522
533
  timeoutAfterFirstReconnectGapEose = 500,
@@ -526,6 +537,8 @@ export class RelayPool {
526
537
  const queue = []
527
538
  let p = Promise.withResolvers()
528
539
  let isDone = false
540
+ let draining = false
541
+ const gapTasks = new Set()
529
542
  const liveSubs = new Map() // url → live sub
530
543
  const retryTimers = new Map()
531
544
  const initialPending = new Set(urls)
@@ -533,7 +546,7 @@ export class RelayPool {
533
546
  let readyTimer = null
534
547
  let isReady = false
535
548
 
536
- // Internal abort controller to cancel any in-flight reconnect gap fills on teardown
549
+ // Stop recovery input on teardown, preserving its accepted history for a drain.
537
550
  const gapAc = new AbortController()
538
551
 
539
552
  // Strip time-range fields — we manage them internally
@@ -561,9 +574,11 @@ export class RelayPool {
561
574
  }))
562
575
  }
563
576
 
564
- const teardown = () => {
565
- if (isDone) return
577
+ const teardown = (drain = false) => {
578
+ if (isDone && (drain || !draining)) return
579
+ draining = drain
566
580
  isDone = true
581
+ if (!drain) queue.length = 0
567
582
  clearTimeout(untilTimer)
568
583
  finishReady()
569
584
  gapAc.abort()
@@ -574,8 +589,9 @@ export class RelayPool {
574
589
  p.resolve()
575
590
  }
576
591
 
577
- const pushEvent = (event, url) => {
578
- if (isDone || (event.id && seenIds.has(event.id))) return
592
+ const pushEvent = (event, url, accepted = false) => {
593
+ // Recovery queues accepted these events before input was stopped.
594
+ if ((isDone && !(draining && accepted)) || (event.id && seenIds.has(event.id))) return
579
595
  if (event.id) {
580
596
  if (seenIds.size >= 500) seenIds.delete(seenIds.values().next().value) // evict oldest
581
597
  seenIds.add(event.id)
@@ -587,11 +603,14 @@ export class RelayPool {
587
603
  p = Promise.withResolvers()
588
604
  }
589
605
 
590
- if (signal?.aborted) {
606
+ if (signal?.aborted || _stopSignal?.aborted) {
591
607
  finishReady()
592
608
  return
593
609
  }
594
- signal?.addEventListener('abort', teardown, { once: true })
610
+ const abort = () => teardown()
611
+ const stop = () => teardown(true)
612
+ signal?.addEventListener('abort', abort, { once: true })
613
+ _stopSignal?.addEventListener('abort', stop, { once: true })
595
614
 
596
615
  const maybeFinishInitialReady = () => {
597
616
  if (initialPending.size === 0) finishReady()
@@ -626,7 +645,7 @@ export class RelayPool {
626
645
  // Schedule teardown when the wall clock reaches filter.until
627
646
  if (filterUntil !== null) {
628
647
  const msUntil = filterUntil * 1000 - Date.now()
629
- untilTimer = maybeUnref(setTimeout(teardown, Math.max(0, msUntil)))
648
+ untilTimer = maybeUnref(setTimeout(() => teardown(true), Math.max(0, msUntil)))
630
649
  }
631
650
 
632
651
  // Runs a reconnect gap fill for a single relay and returns a promise that resolves
@@ -637,11 +656,12 @@ export class RelayPool {
637
656
  const gapGen = _gapEventsGenerator(gapFilter, [url], {
638
657
  timeout: timeoutForReconnectGap,
639
658
  timeoutAfterFirstEose: timeoutAfterFirstReconnectGapEose,
640
- signal: gapAc.signal
659
+ signal,
660
+ _stopSignal: gapAc.signal
641
661
  })
642
662
  return (async () => {
643
663
  for await (const item of gapGen) {
644
- if (item?.type === 'event') pushEvent(item.event, url)
664
+ if (item?.type === 'event') pushEvent(item.event, url, true)
645
665
  }
646
666
  })().catch(err => {
647
667
  if (!isDone) console.error(`Reconnect gap fill error for ${url}:`, err)
@@ -669,7 +689,7 @@ export class RelayPool {
669
689
  onevent: (event) => {
670
690
  // A limit:0 relay may still send retained events before EOSE. Do not
671
691
  // expose them from a strictly-live stream.
672
- if (!liveEose) return
692
+ if (isDone || liveSubs.get(url) !== liveSub || !liveEose) return
673
693
  if (liveBuffer) liveBuffer.push(event)
674
694
  else pushEvent(event, url)
675
695
  },
@@ -696,12 +716,14 @@ export class RelayPool {
696
716
  liveSubs.set(url, liveSub)
697
717
 
698
718
  if (gapSince !== null && gapSince > 0) {
699
- runReconnectGapFill(url, gapSince, now).then(() => {
700
- if (isDone) return
719
+ const task = runReconnectGapFill(url, gapSince, now).finally(() => {
701
720
  const buf = liveBuffer
702
721
  liveBuffer = null
703
- for (const event of buf) pushEvent(event, url)
722
+ for (const event of buf) pushEvent(event, url, true)
723
+ gapTasks.delete(task)
724
+ p.resolve()
704
725
  })
726
+ gapTasks.add(task)
705
727
  }
706
728
  }).catch(err => {
707
729
  readyRelays.delete(url)
@@ -725,12 +747,14 @@ export class RelayPool {
725
747
 
726
748
  try {
727
749
  // eslint-disable-next-line no-unmodified-loop-condition
728
- while (!isDone || queue.length > 0) {
750
+ while (!isDone || (draining && gapTasks.size > 0) || queue.length > 0) {
751
+ if (signal?.aborted) break
729
752
  if (queue.length > 0) yield queue.shift()
730
- else await p.promise
753
+ else { await p.promise; p = Promise.withResolvers() }
731
754
  }
732
755
  } finally {
733
- signal?.removeEventListener('abort', teardown)
756
+ signal?.removeEventListener('abort', abort)
757
+ _stopSignal?.removeEventListener('abort', stop)
734
758
  for (const url of urls) this.#decrementLiveSub(url)
735
759
  teardown()
736
760
  }
@@ -748,17 +772,24 @@ export class RelayPool {
748
772
  // relays when null.
749
773
  //
750
774
  // All underlying generators are injectable for testing.
751
- async * getEventsFeedGenerator (filter, relays, {
775
+ getEventsFeedGenerator (filter, relays, options = {}) {
776
+ return drainableStream(options => this.#getEventsFeedGenerator(filter, relays, options), options)
777
+ }
778
+
779
+ async * #getEventsFeedGenerator (filter, relays, {
752
780
  signal,
781
+ _stopSignal,
753
782
  live = true,
754
783
  timeout = 5000,
755
784
  timeoutAfterFirstEose = 500,
756
785
  _liveGenerator = (...args) => this.getLiveEventsGenerator(...args),
757
786
  _eventsGenerator = (...args) => this.getEventsGenerator(...args)
758
787
  } = {}) {
788
+ if (signal.aborted || _stopSignal.aborted) return
759
789
  if (!live) {
760
- const gen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal })
790
+ const gen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
761
791
  for await (const item of gen) {
792
+ if (signal.aborted) return
762
793
  if (item?.type === 'event') yield item.event
763
794
  }
764
795
  return
@@ -766,7 +797,8 @@ export class RelayPool {
766
797
 
767
798
  // limit:0 means "no stored events, live only" — skip the initial fetch.
768
799
  if (filter.limit === 0) {
769
- for await (const event of _liveGenerator(filter, relays, { signal })) {
800
+ for await (const event of _liveGenerator(filter, relays, { signal, _stopSignal })) {
801
+ if (signal.aborted) return
770
802
  yield event
771
803
  }
772
804
  return
@@ -776,7 +808,7 @@ export class RelayPool {
776
808
  // buffering incoming events before we query stored ones.
777
809
  // Relays always send stored matching events before EOSE (unless limit:0),
778
810
  // so the initial fetch + buffering is always needed.
779
- const liveGen = _liveGenerator(filter, relays, { signal })
811
+ const liveGen = _liveGenerator(filter, relays, { signal, _stopSignal })
780
812
  const liveBuffer = []
781
813
  let liveDone = false
782
814
  let liveWake = Promise.withResolvers()
@@ -784,6 +816,7 @@ export class RelayPool {
784
816
  const bgLoop = (async () => {
785
817
  try {
786
818
  for await (const event of liveGen) {
819
+ if (signal.aborted) break
787
820
  liveBuffer.push(event)
788
821
  liveWake.resolve()
789
822
  liveWake = Promise.withResolvers()
@@ -796,10 +829,11 @@ export class RelayPool {
796
829
 
797
830
  try {
798
831
  // Yield stored events from the initial one-shot fetch
799
- const fetchGen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal })
832
+ const fetchGen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
800
833
 
801
834
  const seenIds = new Set()
802
835
  for await (const item of fetchGen) {
836
+ if (signal.aborted) return
803
837
  if (item?.type === 'event' && !seenIds.has(item.event.id)) {
804
838
  seenIds.add(item.event.id)
805
839
  yield item.event
@@ -809,6 +843,7 @@ export class RelayPool {
809
843
  // Flush buffered live events that arrived during the initial fetch, deduping
810
844
  // against stored ones (overlap is possible around the fetch's until boundary)
811
845
  while (liveBuffer.length > 0) {
846
+ if (signal.aborted) return
812
847
  const event = liveBuffer.shift()
813
848
  if (!seenIds.has(event.id)) {
814
849
  seenIds.add(event.id)
@@ -819,11 +854,14 @@ export class RelayPool {
819
854
  // Yield subsequent live events directly — no more overlap with stored events
820
855
  // eslint-disable-next-line no-unmodified-loop-condition
821
856
  while (!liveDone || liveBuffer.length > 0) {
822
- while (liveBuffer.length > 0) yield liveBuffer.shift()
857
+ while (liveBuffer.length > 0) {
858
+ if (signal.aborted) return
859
+ yield liveBuffer.shift()
860
+ }
823
861
  if (!liveDone) await liveWake.promise
824
862
  }
825
863
  } finally {
826
- liveGen.return()
864
+ await liveGen.return()
827
865
  await bgLoop
828
866
  }
829
867
  }
@@ -859,6 +897,10 @@ export class RelayPool {
859
897
  // Starts before connection work so every relay shares one real deadline.
860
898
  const settlement = createPublishSettlements(sendPromises, timeout, {
861
899
  onSettled: (settlement, index) => {
900
+ if (settlement.reason?.category === 'timeout' && !settlement.reason.cause) {
901
+ const relay = this.#relays.get(normalizeRelayUrl(urls[index]))
902
+ if (relay?.lastTransportError) settlement.reason.cause = relay.lastTransportError
903
+ }
862
904
  notifyRelayResult(onRelayResult, relayResultForSettlement(urls[index], settlement))
863
905
  }
864
906
  })