libp2r2p 0.10.18 → 0.10.20

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.
@@ -13,7 +13,7 @@ channels, keyPath "pubkey"
13
13
  pubkey watched channel public key
14
14
  value evolving channel-recovery state, including last-seen/watched times,
15
15
  mode, relays, seeders, requested offline-recovery retention, offline
16
- ranges, active offline-range start,
16
+ ranges, active offline-range start, recoveredThrough (last successful scan end),
17
17
  per-seeder activity, and sent/received content-key usage
18
18
 
19
19
  Changed channel records are upserted individually and explicit removals are
@@ -58,7 +58,7 @@ async function fetchLatestEventsByRelay (relayToAuthors, { kinds, dTagsByPubkey,
58
58
  const filter = { kinds, authors: dAuthors }
59
59
  if (d) filter['#d'] = [d]
60
60
  requests.push(getEvents(filter, [relay])
61
- .then(response => ({ requested: new Set(dAuthors), events: response.result || [] })))
61
+ .then(response => ({ requested: new Set(dAuthors), events: (response.result || []).map(({ event }) => event) })))
62
62
  }
63
63
  }
64
64
 
@@ -191,7 +191,7 @@ export function subscribeRelayListUpdates (pubkeys, {
191
191
 
192
192
  async function consumeRelayListUpdates () {
193
193
  try {
194
- for await (const event of _eventsFeedGenerator({
194
+ for await (const item of _eventsFeedGenerator({
195
195
  kinds: [10002],
196
196
  authors
197
197
  }, relays, {
@@ -199,6 +199,9 @@ export function subscribeRelayListUpdates (pubkeys, {
199
199
  timeout: 5000,
200
200
  timeoutAfterFirstEose: null
201
201
  })) {
202
+ if (item.type === 'error') { console.error('relay-list watch failed:', item.error); continue }
203
+ if (item.type !== 'event') continue
204
+ const { event } = item
202
205
  if (closed || !authors.includes(event.pubkey)) continue
203
206
  const update = cacheRelayListEvent(event, { cacheMs, relayUrlPolicy })
204
207
  if (!update || !relayTypeChanged(update.changes, relayType)) continue
@@ -238,7 +241,7 @@ async function loadMissingRelays (missingPubkeys, {
238
241
  })
239
242
 
240
243
  const latestByPubkey = {}
241
- for (const event of events || []) {
244
+ for (const { event } of events || []) {
242
245
  if (!missingPubkeys.includes(event.pubkey)) continue
243
246
  if (isNewerRelayListEvent(event, latestByPubkey[event.pubkey])) latestByPubkey[event.pubkey] = event
244
247
  }
@@ -80,6 +80,17 @@ function getEventsTimeoutError () {
80
80
  return new Error('GET_EVENTS_TIMEOUT')
81
81
  }
82
82
 
83
+ function asError (error) {
84
+ return error instanceof Error ? error : new Error(String(error))
85
+ }
86
+
87
+ function assertReadOptions (filter, timeouts) {
88
+ if (!filter || typeof filter !== 'object' || Array.isArray(filter)) throw new ValidationError('INVALID_FILTER')
89
+ for (const value of Object.values(timeouts)) {
90
+ if (value !== null && (!Number.isFinite(value) || value < 0)) throw new ValidationError('INVALID_RELAY_TIMEOUT')
91
+ }
92
+ }
93
+
83
94
  function normalizedRelayUrls (relays) {
84
95
  const urls = []
85
96
  const seen = new Set()
@@ -88,7 +99,7 @@ function normalizedRelayUrls (relays) {
88
99
  const normalizedUrl = normalizeRelayUrl(relay)
89
100
  if (seen.has(normalizedUrl)) continue
90
101
  seen.add(normalizedUrl)
91
- // Keep the caller's first spelling in reports and metadata while using the
102
+ // Keep the caller's first spelling in reports and envelopes while using the
92
103
  // canonical spelling for pooled connection ownership.
93
104
  urls.push(relay)
94
105
  }
@@ -342,127 +353,115 @@ export class RelayPool {
342
353
  // the operation deadline. Disabling cross-relay deduplication still suppresses
343
354
  // repeated ids from the same relay; callbacks remain immediate in both modes.
344
355
  async getEvents (filter, relays, { timeout = 5000, timeoutAfterFirstEose = 500, callback, signal, deduplicateAcrossRelays = true } = {}) {
356
+ assertReadOptions(filter, { timeout, timeoutAfterFirstEose })
345
357
  if (typeof deduplicateAcrossRelays !== 'boolean') throw new ValidationError('INVALID_DEDUPLICATE_ACROSS_RELAYS')
346
- const urls = normalizedRelayUrls(relays)
347
- if (!urls.length) return { result: [], errors: [], success: false }
348
358
  if (signal?.aborted) throw new Error('Aborted')
349
-
359
+ const urls = normalizedRelayUrls(relays)
350
360
  const subscriptions = new Map()
351
- const pending = new Set(urls)
352
- const normalCloseUrls = new Set()
361
+ const outcomes = new Map()
353
362
  const errors = []
354
363
  const events = []
355
364
  const eventIds = deduplicateAcrossRelays ? new Set() : null
356
- let completed = 0
357
365
  let isResolved = false
358
366
  let eoseTimer = null
359
367
  let timeoutTimer = null
360
368
 
361
369
  return await new Promise((resolve, reject) => {
362
- const closeSubscriptions = () => {
363
- for (const sub of subscriptions.values()) sub.close()
364
- subscriptions.clear()
365
- }
366
-
367
370
  const cleanup = () => {
368
371
  clearTimeout(timeoutTimer)
369
372
  clearTimeout(eoseTimer)
370
373
  signal?.removeEventListener('abort', onAbort)
371
- closeSubscriptions()
374
+ for (const sub of subscriptions.values()) sub.close()
375
+ subscriptions.clear()
372
376
  }
373
-
374
- const finish = () => {
377
+ const fail = error => {
375
378
  if (isResolved) return
376
379
  isResolved = true
377
380
  cleanup()
378
- resolve({
379
- result: events,
380
- errors,
381
- success: events.length > 0 || completed > 0
382
- })
381
+ reject(error)
383
382
  }
384
-
385
- const finishIfComplete = () => {
386
- if (pending.size === 0) finish()
383
+ const notify = item => {
384
+ try { callback?.(item) } catch (error) { fail(error) }
387
385
  }
388
-
389
- const settleRelay = (url, reason) => {
390
- if (isResolved || !pending.delete(url)) return
391
- subscriptions.delete(url)
392
- if (reason) {
393
- errors.push({ reason, relay: url })
394
- if (callback) callback({ type: 'error', error: reason, relay: url })
395
- } else {
396
- completed++
386
+ const settleRelay = (relay, status, error) => {
387
+ if (isResolved || outcomes.has(relay)) return
388
+ outcomes.set(relay, { relay, status, ...(error ? { error } : {}) })
389
+ if (error) {
390
+ errors.push({ relay, reason: error })
391
+ notify({ type: 'error', relay, error })
397
392
  }
398
- finishIfComplete()
399
393
  }
400
-
401
- const onAbort = () => {
394
+ const finish = (pendingStatus = 'cutoff') => {
395
+ if (isResolved) return
396
+ for (const url of urls) {
397
+ if (!outcomes.has(url)) settleRelay(url, pendingStatus, pendingStatus === 'timeout' ? getEventsTimeoutError() : undefined)
398
+ }
399
+ if (isResolved) return
400
+ const relays = urls.map(url => outcomes.get(url))
401
+ notify({ type: 'eose', relays })
402
402
  if (isResolved) return
403
403
  isResolved = true
404
404
  cleanup()
405
- reject(new Error('Aborted'))
406
- }
407
-
408
- const timeoutPending = () => {
409
- if (isResolved) return
410
- for (const url of pending) {
411
- errors.push({ reason: getEventsTimeoutError(), relay: url })
412
- }
413
- finish()
405
+ resolve({
406
+ result: events,
407
+ errors,
408
+ success: events.length > 0 || relays.some(({ status }) => ['eose', 'satisfied', 'closed'].includes(status)),
409
+ relays
410
+ })
414
411
  }
415
-
412
+ const finishIfComplete = () => { if (outcomes.size === urls.length) finish() }
413
+ const onAbort = () => fail(new Error('Aborted'))
416
414
  signal?.addEventListener('abort', onAbort, { once: true })
417
- if (timeout !== null) timeoutTimer = maybeUnref(setTimeout(timeoutPending, timeout))
415
+ if (timeout !== null) timeoutTimer = maybeUnref(setTimeout(() => finish('timeout'), timeout))
416
+ if (!urls.length) { finish(); return }
418
417
 
419
418
  for (const url of urls) {
420
419
  const seenIds = eventIds ?? new Set()
421
420
  this.#getRelay(url).then(relay => {
422
- if (isResolved || !pending.has(url)) return
421
+ if (isResolved || outcomes.has(url)) return
423
422
  let hasEvents = false
423
+ // Subscription callbacks may run before subscribe() returns.
424
424
  // eslint-disable-next-line prefer-const
425
425
  let sub
426
-
427
- // Actual EOSE and filter satisfaction share the same graceful close path.
428
- const handleEose = () => {
429
- if (isResolved || !pending.has(url)) return
430
- normalCloseUrls.add(url)
431
- sub.close()
426
+ const complete = status => {
427
+ if (isResolved || outcomes.has(url)) return
428
+ settleRelay(url, status)
429
+ sub?.close()
430
+ subscriptions.delete(url)
432
431
  if (hasEvents && timeoutAfterFirstEose !== null && !eoseTimer && !isResolved) {
433
- eoseTimer = maybeUnref(setTimeout(finish, timeoutAfterFirstEose))
432
+ eoseTimer = maybeUnref(setTimeout(() => finish('cutoff'), timeoutAfterFirstEose))
434
433
  }
434
+ finishIfComplete()
435
435
  }
436
-
437
- const checkEarlyClose = makeEarlyCloseChecker(filter, handleEose)
436
+ const checkEarlyClose = makeEarlyCloseChecker(filter, () => complete('satisfied'))
438
437
  sub = relay.subscribe([filter], {
439
- onevent: (event) => {
440
- if (isResolved || !pending.has(url)) return
438
+ onevent: event => {
439
+ if (isResolved || outcomes.has(url)) return
441
440
  hasEvents = true
442
441
  if (!event?.id || !seenIds.has(event.id)) {
443
442
  if (event?.id) seenIds.add(event.id)
444
- event.meta = { relay: url }
445
- events.push(event)
446
- if (callback) callback({ type: 'event', event, relay: url })
443
+ events.push({ event, relay: url })
444
+ notify({ type: 'event', event, relay: url })
447
445
  }
448
446
  checkEarlyClose(event)
449
447
  },
450
448
  oninvalidevent: () => {
451
- if (!isResolved && pending.has(url)) checkEarlyClose()
449
+ if (!isResolved && !outcomes.has(url)) checkEarlyClose()
452
450
  },
453
451
  onclose: error => {
454
- const reason = normalCloseUrls.delete(url) || error === undefined
455
- ? null
456
- : error instanceof Error ? error : new Error(String(error))
457
- settleRelay(url, reason)
452
+ if (isResolved || outcomes.has(url)) return
453
+ const reason = error === undefined ? undefined : asError(error)
454
+ settleRelay(url, reason ? 'error' : 'closed', reason)
455
+ subscriptions.delete(url)
456
+ finishIfComplete()
458
457
  },
459
- oneose: handleEose
458
+ oneose: () => complete('eose')
460
459
  })
461
- if (isResolved || !pending.has(url)) sub.close()
460
+ if (isResolved || outcomes.has(url)) sub.close()
462
461
  else subscriptions.set(url, sub)
463
462
  }).catch(error => {
464
- const reason = error instanceof Error ? error : new Error(String(error))
465
- settleRelay(url, reason)
463
+ settleRelay(url, 'error', asError(error))
464
+ finishIfComplete()
466
465
  })
467
466
  }
468
467
  })
@@ -472,39 +471,40 @@ export class RelayPool {
472
471
  const queue = []
473
472
  let p = Promise.withResolvers()
474
473
  let isDone = false
475
-
476
- const userCallback = options.callback
474
+ let failure
475
+ const controller = new AbortController()
477
476
  const callback = item => {
478
477
  queue.push(item)
479
- if (userCallback) userCallback(item)
480
478
  p.resolve()
481
479
  p = Promise.withResolvers()
480
+ options.callback?.(item)
482
481
  }
483
-
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 })
489
- .catch(err => { if (err?.message !== 'Aborted') console.error('Error in getEvents:', err) })
490
- .finally(() => {
491
- isDone = true
492
- p.resolve()
493
- })
494
-
495
- // eslint-disable-next-line no-unmodified-loop-condition
496
- while (!isDone || queue.length > 0) {
497
- if (options.signal?.aborted) break
498
- if (queue.length > 0) yield queue.shift()
499
- else await p.promise
482
+ const signal = AbortSignal.any([controller.signal, ...[options.signal, options._stopSignal].filter(Boolean)])
483
+ // Attach rejection handling immediately, but propagate general failures to
484
+ // the consumer after accepted deliveries instead of swallowing them.
485
+ const methodPromise = this.getEvents(filter, relays, { ...options, signal, callback })
486
+ .catch(error => { if (!signal.aborted) failure = error })
487
+ .finally(() => { isDone = true; p.resolve() })
488
+ try {
489
+ // eslint-disable-next-line no-unmodified-loop-condition
490
+ while (!isDone || queue.length > 0) {
491
+ if (options.signal?.aborted) return
492
+ if (queue.length > 0) yield queue.shift()
493
+ else await p.promise
494
+ }
495
+ const report = await methodPromise
496
+ if (failure) throw failure
497
+ return report
498
+ } finally {
499
+ controller.abort()
500
+ queue.length = 0
500
501
  }
501
-
502
- return await methodPromise
503
502
  }
504
503
 
505
504
  // Returns a strictly-live stream. `ready` reports the first initial EOSE window,
506
505
  // while `readyRelays` follows relays that are currently past their own EOSE.
507
506
  getLiveEventsGenerator (filter, relays, options = {}) {
507
+ assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose })
508
508
  const ready = Promise.withResolvers()
509
509
  const readyRelays = new Set()
510
510
  const stream = drainableStream(options => this.#getLiveEventsGenerator(filter, relays, options, { ready, readyRelays }), options)
@@ -528,6 +528,7 @@ export class RelayPool {
528
528
  async * #getLiveEventsGenerator (filter, relays, {
529
529
  signal,
530
530
  _stopSignal,
531
+ timeout = 5000,
531
532
  timeoutAfterFirstEose = 500,
532
533
  timeoutForReconnectGap = 5000,
533
534
  timeoutAfterFirstReconnectGapEose = 500,
@@ -542,7 +543,8 @@ export class RelayPool {
542
543
  const liveSubs = new Map() // url → live sub
543
544
  const retryTimers = new Map()
544
545
  const initialPending = new Set(urls)
545
- const initialErrors = []
546
+ const initialOutcomes = new Map()
547
+ let readyTimeout = null
546
548
  let readyTimer = null
547
549
  let isReady = false
548
550
 
@@ -564,14 +566,28 @@ export class RelayPool {
564
566
  const seenIds = new Set()
565
567
 
566
568
  let untilTimer = null
567
- const finishReady = () => {
569
+ const enqueue = item => {
570
+ queue.push(item)
571
+ p.resolve()
572
+ p = Promise.withResolvers()
573
+ }
574
+ const finishReady = (pendingStatus = 'cutoff', emit = true) => {
568
575
  if (isReady) return
569
576
  isReady = true
570
577
  clearTimeout(readyTimer)
578
+ clearTimeout(readyTimeout)
579
+ for (const relay of initialPending) {
580
+ const error = pendingStatus === 'timeout' ? getEventsTimeoutError() : undefined
581
+ initialOutcomes.set(relay, { relay, status: pendingStatus, ...(error ? { error } : {}) })
582
+ if (error && emit) enqueue({ type: 'error', relay, error })
583
+ }
584
+ initialPending.clear()
585
+ const relays = urls.map(url => initialOutcomes.get(url))
571
586
  ready.resolve(Object.freeze({
572
587
  relays: Object.freeze([...readyRelays]),
573
- errors: Object.freeze([...initialErrors])
588
+ errors: Object.freeze(relays.filter(item => item.error).map(({ relay, error }) => ({ relay, reason: error })))
574
589
  }))
590
+ if (emit) enqueue({ type: 'eose', relays })
575
591
  }
576
592
 
577
593
  const teardown = (drain = false) => {
@@ -580,7 +596,7 @@ export class RelayPool {
580
596
  isDone = true
581
597
  if (!drain) queue.length = 0
582
598
  clearTimeout(untilTimer)
583
- finishReady()
599
+ finishReady('closed', false)
584
600
  gapAc.abort()
585
601
  for (const timer of retryTimers.values()) clearTimeout(timer)
586
602
  retryTimers.clear()
@@ -597,14 +613,11 @@ export class RelayPool {
597
613
  seenIds.add(event.id)
598
614
  }
599
615
  if (event.created_at > (lastSeenAt ?? 0)) lastSeenAt = event.created_at
600
- event.meta = { relay: url }
601
- queue.push(event)
602
- p.resolve()
603
- p = Promise.withResolvers()
616
+ enqueue({ type: 'event', event, relay: url })
604
617
  }
605
618
 
606
619
  if (signal?.aborted || _stopSignal?.aborted) {
607
- finishReady()
620
+ finishReady('closed', false)
608
621
  return
609
622
  }
610
623
  const abort = () => teardown()
@@ -612,24 +625,35 @@ export class RelayPool {
612
625
  signal?.addEventListener('abort', abort, { once: true })
613
626
  _stopSignal?.addEventListener('abort', stop, { once: true })
614
627
 
628
+ if (timeout !== null) readyTimeout = maybeUnref(setTimeout(() => finishReady('timeout'), timeout))
629
+
615
630
  const maybeFinishInitialReady = () => {
616
631
  if (initialPending.size === 0) finishReady()
617
632
  }
618
633
 
619
634
  const markInitialEose = (url) => {
620
635
  readyRelays.add(url)
621
- if (isReady) return
622
- initialPending.delete(url)
636
+ if (isReady || !initialPending.delete(url)) return
637
+ initialOutcomes.set(url, { relay: url, status: 'eose' })
623
638
  if (timeoutAfterFirstEose !== null && !readyTimer) {
624
- readyTimer = maybeUnref(setTimeout(finishReady, timeoutAfterFirstEose))
639
+ readyTimer = maybeUnref(setTimeout(() => finishReady('cutoff'), timeoutAfterFirstEose))
625
640
  }
626
641
  maybeFinishInitialReady()
627
642
  }
628
643
 
629
- const markInitialError = (url, reason) => {
630
- if (!initialPending.delete(url)) return
631
- initialErrors.push({ relay: url, reason })
632
- maybeFinishInitialReady()
644
+ const reportFailure = (url, error) => {
645
+ if (isDone) return
646
+ enqueue({ type: 'error', relay: url, error })
647
+ if (!isReady && initialPending.delete(url)) {
648
+ initialOutcomes.set(url, { relay: url, status: 'error', error })
649
+ maybeFinishInitialReady()
650
+ }
651
+ }
652
+ const reportClosed = url => {
653
+ if (!isReady && initialPending.delete(url)) {
654
+ initialOutcomes.set(url, { relay: url, status: 'closed' })
655
+ maybeFinishInitialReady()
656
+ }
633
657
  }
634
658
 
635
659
  const scheduleReconnect = (url, reconnectDelay) => {
@@ -645,7 +669,7 @@ export class RelayPool {
645
669
  // Schedule teardown when the wall clock reaches filter.until
646
670
  if (filterUntil !== null) {
647
671
  const msUntil = filterUntil * 1000 - Date.now()
648
- untilTimer = maybeUnref(setTimeout(() => teardown(true), Math.max(0, msUntil)))
672
+ untilTimer = maybeUnref(setTimeout(() => { finishReady('closed'); teardown(true) }, Math.max(0, msUntil)))
649
673
  }
650
674
 
651
675
  // Runs a reconnect gap fill for a single relay and returns a promise that resolves
@@ -662,9 +686,10 @@ export class RelayPool {
662
686
  return (async () => {
663
687
  for await (const item of gapGen) {
664
688
  if (item?.type === 'event') pushEvent(item.event, url, true)
689
+ else if (item?.type === 'error' && !isDone) enqueue(item)
665
690
  }
666
691
  })().catch(err => {
667
- if (!isDone) console.error(`Reconnect gap fill error for ${url}:`, err)
692
+ reportFailure(url, asError(err))
668
693
  })
669
694
  }
670
695
 
@@ -697,11 +722,9 @@ export class RelayPool {
697
722
  if (liveSubs.get(url) === liveSub) liveSubs.delete(url)
698
723
  else if (liveSubs.has(url)) return
699
724
  readyRelays.delete(url)
700
- if (!liveEose && !isDone) {
701
- const reason = error instanceof Error
702
- ? error
703
- : new Error(error ? String(error) : 'LIVE_SUBSCRIPTION_CLOSED')
704
- markInitialError(url, reason)
725
+ if (!isDone) {
726
+ if (error !== undefined) reportFailure(url, asError(error))
727
+ else if (!liveEose) reportClosed(url)
705
728
  }
706
729
  if (isDone) return
707
730
  scheduleReconnect(url, reconnectDelay)
@@ -728,15 +751,19 @@ export class RelayPool {
728
751
  }).catch(err => {
729
752
  readyRelays.delete(url)
730
753
  const reason = err instanceof Error ? err : new Error(String(err))
731
- markInitialError(url, reason)
754
+ reportFailure(url, reason)
732
755
  if (isDone) return
733
- console.error(`Live subscription error at ${url}:`, reason)
734
756
  scheduleReconnect(url, reconnectDelay)
735
757
  })
736
758
  }
737
759
 
738
760
  if (!urls.length) {
739
761
  finishReady()
762
+ try { yield queue.shift() } finally {
763
+ teardown()
764
+ signal?.removeEventListener('abort', abort)
765
+ _stopSignal?.removeEventListener('abort', stop)
766
+ }
740
767
  return
741
768
  }
742
769
 
@@ -773,6 +800,7 @@ export class RelayPool {
773
800
  //
774
801
  // All underlying generators are injectable for testing.
775
802
  getEventsFeedGenerator (filter, relays, options = {}) {
803
+ assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose })
776
804
  return drainableStream(options => this.#getEventsFeedGenerator(filter, relays, options), options)
777
805
  }
778
806
 
@@ -790,14 +818,14 @@ export class RelayPool {
790
818
  const gen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
791
819
  for await (const item of gen) {
792
820
  if (signal.aborted) return
793
- if (item?.type === 'event') yield item.event
821
+ yield item
794
822
  }
795
823
  return
796
824
  }
797
825
 
798
826
  // limit:0 means "no stored events, live only" — skip the initial fetch.
799
827
  if (filter.limit === 0) {
800
- for await (const event of _liveGenerator(filter, relays, { signal, _stopSignal })) {
828
+ for await (const event of _liveGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })) {
801
829
  if (signal.aborted) return
802
830
  yield event
803
831
  }
@@ -808,19 +836,22 @@ export class RelayPool {
808
836
  // buffering incoming events before we query stored ones.
809
837
  // Relays always send stored matching events before EOSE (unless limit:0),
810
838
  // so the initial fetch + buffering is always needed.
811
- const liveGen = _liveGenerator(filter, relays, { signal, _stopSignal })
839
+ const liveGen = _liveGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
812
840
  const liveBuffer = []
813
841
  let liveDone = false
842
+ let liveFailure
814
843
  let liveWake = Promise.withResolvers()
815
844
 
816
845
  const bgLoop = (async () => {
817
846
  try {
818
847
  for await (const event of liveGen) {
819
848
  if (signal.aborted) break
820
- liveBuffer.push(event)
849
+ if (event.type !== 'eose') liveBuffer.push(event)
821
850
  liveWake.resolve()
822
851
  liveWake = Promise.withResolvers()
823
852
  }
853
+ } catch (error) {
854
+ liveFailure = error
824
855
  } finally {
825
856
  liveDone = true
826
857
  liveWake.resolve()
@@ -836,21 +867,23 @@ export class RelayPool {
836
867
  if (signal.aborted) return
837
868
  if (item?.type === 'event' && !seenIds.has(item.event.id)) {
838
869
  seenIds.add(item.event.id)
839
- yield item.event
840
- }
870
+ yield item
871
+ } else if (item?.type !== 'event') yield item
841
872
  }
842
873
 
843
874
  // Flush buffered live events that arrived during the initial fetch, deduping
844
875
  // against stored ones (overlap is possible around the fetch's until boundary)
845
876
  while (liveBuffer.length > 0) {
846
877
  if (signal.aborted) return
847
- const event = liveBuffer.shift()
848
- if (!seenIds.has(event.id)) {
849
- seenIds.add(event.id)
850
- yield event
878
+ const item = liveBuffer.shift()
879
+ if (item.type !== 'event' || !seenIds.has(item.event.id)) {
880
+ if (item.type === 'event') seenIds.add(item.event.id)
881
+ yield item
851
882
  }
852
883
  }
853
884
 
885
+ seenIds.clear()
886
+
854
887
  // Yield subsequent live events directly — no more overlap with stored events
855
888
  // eslint-disable-next-line no-unmodified-loop-condition
856
889
  while (!liveDone || liveBuffer.length > 0) {
@@ -860,6 +893,7 @@ export class RelayPool {
860
893
  }
861
894
  if (!liveDone) await liveWake.promise
862
895
  }
896
+ if (liveFailure) throw liveFailure
863
897
  } finally {
864
898
  await liveGen.return()
865
899
  await bgLoop