libp2r2p 0.10.19 → 0.10.21

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.
@@ -1,6 +1,7 @@
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 { ReadAdmission } from '../helpers/read-admission.js'
4
5
  import { drainableStream } from '../helpers/drainable-stream.js'
5
6
  import { maybeUnref } from '../helpers/timer.js'
6
7
  import { categorizeRelayError } from '../helpers/error.js'
@@ -91,6 +92,12 @@ function assertReadOptions (filter, timeouts) {
91
92
  }
92
93
  }
93
94
 
95
+ function assertBufferOptions (options) {
96
+ for (const value of [options.maxBufferedLiveEvents === undefined ? 1000 : options.maxBufferedLiveEvents, options.maxBufferedLiveBytes === undefined ? 8 * 1024 * 1024 : options.maxBufferedLiveBytes]) {
97
+ if (!Number.isSafeInteger(value) || value < 1) throw new ValidationError('INVALID_RELAY_BUFFER_CAPACITY')
98
+ }
99
+ }
100
+
94
101
  function normalizedRelayUrls (relays) {
95
102
  const urls = []
96
103
  const seen = new Set()
@@ -127,14 +134,19 @@ export class RelayPool {
127
134
  #liveSubCounts = new Map() // url -> number of active live subscriptions
128
135
  #timeout = 30000 // 30 seconds
129
136
  #createRelay
137
+ #admission
130
138
 
131
- constructor ({ _createRelay = url => new RelayConnection(url) } = {}) {
139
+ constructor ({ _createRelay = url => new RelayConnection(url), ...capacity } = {}) {
132
140
  this.#createRelay = _createRelay
141
+ this.#admission = new ReadAdmission(capacity)
133
142
  }
134
143
 
135
144
  #scheduleIdleDisconnect (url) {
136
145
  clearTimeout(this.#relayTimeouts.get(url))
137
- this.#relayTimeouts.set(url, maybeUnref(setTimeout(() => this.disconnect(url), this.#timeout)))
146
+ this.#relayTimeouts.set(url, maybeUnref(setTimeout(() => {
147
+ if (this.#admission.states.get(url)?.active) this.#scheduleIdleDisconnect(url)
148
+ else this.disconnect(url)
149
+ }, this.#timeout)))
138
150
  }
139
151
 
140
152
  // Opens a normalized pooled connection. Failed connects are evicted so a later
@@ -191,6 +203,7 @@ export class RelayPool {
191
203
  // Disconnect from a relay
192
204
  async disconnect (url) {
193
205
  const normalizedUrl = normalizeRelayUrl(url)
206
+ this.#admission.cancelQueued(normalizedUrl)
194
207
  if (this.#relays.has(normalizedUrl)) {
195
208
  const relay = this.#relays.get(normalizedUrl)
196
209
  if (relay.ws?.readyState < 2) await relay.close()?.catch(console.log)
@@ -202,7 +215,7 @@ export class RelayPool {
202
215
 
203
216
  // Disconnect from all relays
204
217
  async disconnectAll () {
205
- for (const url of [...this.#relays.keys()]) {
218
+ for (const url of new Set([...this.#relays.keys(), ...this.#admission.states.keys()])) {
206
219
  await this.disconnect(url)
207
220
  }
208
221
  }
@@ -352,8 +365,8 @@ export class RelayPool {
352
365
  // grace window; null disables that window so callers wait for every relay or
353
366
  // the operation deadline. Disabling cross-relay deduplication still suppresses
354
367
  // repeated ids from the same relay; callbacks remain immediate in both modes.
355
- async getEvents (filter, relays, { timeout = 5000, timeoutAfterFirstEose = 500, callback, signal, deduplicateAcrossRelays = true } = {}) {
356
- assertReadOptions(filter, { timeout, timeoutAfterFirstEose })
368
+ async getEvents (filter, relays, { timeout = 5000, timeoutAfterFirstEose = 500, callback, signal, deduplicateAcrossRelays = true, queueTimeout = 30000, _admissions } = {}) {
369
+ assertReadOptions(filter, { timeout, timeoutAfterFirstEose, queueTimeout })
357
370
  if (typeof deduplicateAcrossRelays !== 'boolean') throw new ValidationError('INVALID_DEDUPLICATE_ACROSS_RELAYS')
358
371
  if (signal?.aborted) throw new Error('Aborted')
359
372
  const urls = normalizedRelayUrls(relays)
@@ -364,15 +377,21 @@ export class RelayPool {
364
377
  const eventIds = deduplicateAcrossRelays ? new Set() : null
365
378
  let isResolved = false
366
379
  let eoseTimer = null
367
- let timeoutTimer = null
380
+ const timers = new Map()
381
+ const leases = new Map()
382
+ const admissionAbort = new AbortController()
383
+ const admissionSignal = signal ? AbortSignal.any([signal, admissionAbort.signal]) : admissionAbort.signal
368
384
 
369
385
  return await new Promise((resolve, reject) => {
370
386
  const cleanup = () => {
371
- clearTimeout(timeoutTimer)
387
+ admissionAbort.abort()
388
+ for (const timer of timers.values()) clearTimeout(timer)
372
389
  clearTimeout(eoseTimer)
373
390
  signal?.removeEventListener('abort', onAbort)
374
391
  for (const sub of subscriptions.values()) sub.close()
375
392
  subscriptions.clear()
393
+ for (const lease of leases.values()) lease.release()
394
+ leases.clear()
376
395
  }
377
396
  const fail = error => {
378
397
  if (isResolved) return
@@ -386,6 +405,11 @@ export class RelayPool {
386
405
  const settleRelay = (relay, status, error) => {
387
406
  if (isResolved || outcomes.has(relay)) return
388
407
  outcomes.set(relay, { relay, status, ...(error ? { error } : {}) })
408
+ clearTimeout(timers.get(relay))
409
+ subscriptions.get(relay)?.close()
410
+ subscriptions.delete(relay)
411
+ leases.get(relay)?.release()
412
+ leases.delete(relay)
389
413
  if (error) {
390
414
  errors.push({ relay, reason: error })
391
415
  notify({ type: 'error', relay, error })
@@ -412,12 +436,22 @@ export class RelayPool {
412
436
  const finishIfComplete = () => { if (outcomes.size === urls.length) finish() }
413
437
  const onAbort = () => fail(new Error('Aborted'))
414
438
  signal?.addEventListener('abort', onAbort, { once: true })
415
- if (timeout !== null) timeoutTimer = maybeUnref(setTimeout(() => finish('timeout'), timeout))
416
439
  if (!urls.length) { finish(); return }
417
440
 
418
441
  for (const url of urls) {
419
442
  const seenIds = eventIds ?? new Set()
420
- this.#getRelay(url).then(relay => {
443
+ const reservation = _admissions?.get(url) ?? this.#admission.acquire(normalizeRelayUrl(url), { history: true, signal: admissionSignal, queueTimeout })
444
+ reservation.then(async slots => {
445
+ const lease = slots.history
446
+ if (isResolved || outcomes.has(url)) { lease.release(); return }
447
+ leases.set(url, lease)
448
+ if (timeout !== null) {
449
+ timers.set(url, maybeUnref(setTimeout(() => {
450
+ settleRelay(url, 'timeout', getEventsTimeoutError())
451
+ finishIfComplete()
452
+ }, timeout)))
453
+ }
454
+ const relay = await this.#getRelay(url)
421
455
  if (isResolved || outcomes.has(url)) return
422
456
  let hasEvents = false
423
457
  // Subscription callbacks may run before subscribe() returns.
@@ -467,7 +501,11 @@ export class RelayPool {
467
501
  })
468
502
  }
469
503
 
470
- async * getEventsGenerator (filter, relays, options = {}) {
504
+ getEventsGenerator (filter, relays, options = {}) {
505
+ return drainableStream(options => this.#getEventsGenerator(filter, relays, options), options)
506
+ }
507
+
508
+ async * #getEventsGenerator (filter, relays, options = {}) {
471
509
  const queue = []
472
510
  let p = Promise.withResolvers()
473
511
  let isDone = false
@@ -504,7 +542,8 @@ export class RelayPool {
504
542
  // Returns a strictly-live stream. `ready` reports the first initial EOSE window,
505
543
  // while `readyRelays` follows relays that are currently past their own EOSE.
506
544
  getLiveEventsGenerator (filter, relays, options = {}) {
507
- assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose })
545
+ assertBufferOptions(options)
546
+ assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose, queueTimeout: options.queueTimeout === undefined ? 30000 : options.queueTimeout })
508
547
  const ready = Promise.withResolvers()
509
548
  const readyRelays = new Set()
510
549
  const stream = drainableStream(options => this.#getLiveEventsGenerator(filter, relays, options, { ready, readyRelays }), options)
@@ -530,6 +569,10 @@ export class RelayPool {
530
569
  _stopSignal,
531
570
  timeout = 5000,
532
571
  timeoutAfterFirstEose = 500,
572
+ queueTimeout = 30000,
573
+ _admissions,
574
+ maxBufferedLiveEvents = 1000,
575
+ maxBufferedLiveBytes = 8 * 1024 * 1024,
533
576
  timeoutForReconnectGap = 5000,
534
577
  timeoutAfterFirstReconnectGapEose = 500,
535
578
  _gapEventsGenerator = (...args) => this.getEventsGenerator(...args)
@@ -539,12 +582,17 @@ export class RelayPool {
539
582
  let p = Promise.withResolvers()
540
583
  let isDone = false
541
584
  let draining = false
585
+ let failure
586
+ let queuedBytes = 0
587
+ const encoder = new TextEncoder()
542
588
  const gapTasks = new Set()
543
589
  const liveSubs = new Map() // url → live sub
544
590
  const retryTimers = new Map()
545
591
  const initialPending = new Set(urls)
546
592
  const initialOutcomes = new Map()
547
- let readyTimeout = null
593
+ const readyTimeouts = new Map()
594
+ const liveLeases = new Map()
595
+ const initialAdmissions = new Set()
548
596
  let readyTimer = null
549
597
  let isReady = false
550
598
 
@@ -566,16 +614,31 @@ export class RelayPool {
566
614
  const seenIds = new Set()
567
615
 
568
616
  let untilTimer = null
617
+ const overflow = relay => {
618
+ failure = Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), { code: 'RELAY_LIVE_BUFFER_FULL', relay, phase: 'live-buffer' })
619
+ teardown()
620
+ }
569
621
  const enqueue = item => {
570
- queue.push(item)
622
+ const bytes = encoder.encode(JSON.stringify(item)).byteLength
623
+ if (queue.length >= maxBufferedLiveEvents || queuedBytes + bytes > maxBufferedLiveBytes) {
624
+ overflow(item.relay)
625
+ return
626
+ }
627
+ queuedBytes += bytes
628
+ queue.push({ item, bytes })
571
629
  p.resolve()
572
630
  p = Promise.withResolvers()
573
631
  }
632
+ const shift = () => {
633
+ const { item, bytes } = queue.shift()
634
+ queuedBytes -= bytes
635
+ return item
636
+ }
574
637
  const finishReady = (pendingStatus = 'cutoff', emit = true) => {
575
638
  if (isReady) return
576
639
  isReady = true
577
640
  clearTimeout(readyTimer)
578
- clearTimeout(readyTimeout)
641
+ for (const timer of readyTimeouts.values()) clearTimeout(timer)
579
642
  for (const relay of initialPending) {
580
643
  const error = pendingStatus === 'timeout' ? getEventsTimeoutError() : undefined
581
644
  initialOutcomes.set(relay, { relay, status: pendingStatus, ...(error ? { error } : {}) })
@@ -594,7 +657,7 @@ export class RelayPool {
594
657
  if (isDone && (drain || !draining)) return
595
658
  draining = drain
596
659
  isDone = true
597
- if (!drain) queue.length = 0
660
+ if (!drain) { queue.length = 0; queuedBytes = 0 }
598
661
  clearTimeout(untilTimer)
599
662
  finishReady('closed', false)
600
663
  gapAc.abort()
@@ -602,6 +665,8 @@ export class RelayPool {
602
665
  retryTimers.clear()
603
666
  liveSubs.forEach(sub => sub.close())
604
667
  liveSubs.clear()
668
+ for (const lease of liveLeases.values()) lease.release()
669
+ liveLeases.clear()
605
670
  p.resolve()
606
671
  }
607
672
 
@@ -625,13 +690,12 @@ export class RelayPool {
625
690
  signal?.addEventListener('abort', abort, { once: true })
626
691
  _stopSignal?.addEventListener('abort', stop, { once: true })
627
692
 
628
- if (timeout !== null) readyTimeout = maybeUnref(setTimeout(() => finishReady('timeout'), timeout))
629
-
630
693
  const maybeFinishInitialReady = () => {
631
694
  if (initialPending.size === 0) finishReady()
632
695
  }
633
696
 
634
697
  const markInitialEose = (url) => {
698
+ clearTimeout(readyTimeouts.get(url))
635
699
  readyRelays.add(url)
636
700
  if (isReady || !initialPending.delete(url)) return
637
701
  initialOutcomes.set(url, { relay: url, status: 'eose' })
@@ -645,12 +709,14 @@ export class RelayPool {
645
709
  if (isDone) return
646
710
  enqueue({ type: 'error', relay: url, error })
647
711
  if (!isReady && initialPending.delete(url)) {
712
+ clearTimeout(readyTimeouts.get(url))
648
713
  initialOutcomes.set(url, { relay: url, status: 'error', error })
649
714
  maybeFinishInitialReady()
650
715
  }
651
716
  }
652
717
  const reportClosed = url => {
653
718
  if (!isReady && initialPending.delete(url)) {
719
+ clearTimeout(readyTimeouts.get(url))
654
720
  initialOutcomes.set(url, { relay: url, status: 'closed' })
655
721
  maybeFinishInitialReady()
656
722
  }
@@ -674,10 +740,12 @@ export class RelayPool {
674
740
 
675
741
  // Runs a reconnect gap fill for a single relay and returns a promise that resolves
676
742
  // when it completes. now is shared with the live sub so both use the same boundary.
677
- const runReconnectGapFill = (url, gapSince, now) => {
743
+ const runReconnectGapFill = (url, gapSince, now, slots) => {
678
744
  const gapUntil = filterUntil !== null ? Math.min(now, filterUntil) : now
679
745
  const gapFilter = { ...baseFilter, since: gapSince, until: gapUntil }
680
746
  const gapGen = _gapEventsGenerator(gapFilter, [url], {
747
+ _admissions: new Map([[url, Promise.resolve(slots)]]),
748
+ queueTimeout,
681
749
  timeout: timeoutForReconnectGap,
682
750
  timeoutAfterFirstEose: timeoutAfterFirstReconnectGapEose,
683
751
  signal,
@@ -690,20 +758,41 @@ export class RelayPool {
690
758
  }
691
759
  })().catch(err => {
692
760
  reportFailure(url, asError(err))
693
- })
761
+ }).finally(() => slots.history.release())
694
762
  }
695
763
 
696
764
  const subscribeToRelay = (url, gapSince, reconnectDelay = 1000) => {
697
- const now = Math.floor(Date.now() / 1000)
765
+ let now = Math.floor(Date.now() / 1000)
698
766
  // Don't reconnect if we're past the until boundary
699
767
  if (filterUntil !== null && now >= filterUntil) return
700
- this.#getRelay(url).then(relay => {
701
- if (isDone) return
768
+ const reservation = !initialAdmissions.has(url) && _admissions?.get(url)
769
+ const recovering = gapSince !== null && gapSince > 0
770
+ let recoveryLease
771
+ initialAdmissions.add(url)
772
+ Promise.resolve(reservation || this.#admission.acquire(normalizeRelayUrl(url), { live: true, history: recovering, signal: gapAc.signal, queueTimeout })).then(async slots => {
773
+ const lease = slots.live
774
+ recoveryLease = recovering ? slots.history : null
775
+ if (isDone) { lease.release(); recoveryLease?.release(); return }
776
+ liveLeases.set(url, lease)
777
+ if (timeout !== null && initialPending.has(url)) {
778
+ readyTimeouts.set(url, maybeUnref(setTimeout(() => {
779
+ if (!initialPending.delete(url)) return
780
+ const error = getEventsTimeoutError()
781
+ initialOutcomes.set(url, { relay: url, status: 'timeout', error })
782
+ enqueue({ type: 'error', relay: url, error })
783
+ maybeFinishInitialReady()
784
+ }, timeout)))
785
+ }
786
+ const relay = await this.#getRelay(url)
787
+ if (isDone) { recoveryLease?.release(); return }
788
+ now = Math.floor(Date.now() / 1000)
789
+ if (filterUntil !== null && now >= filterUntil) { lease.release(); recoveryLease?.release(); return }
702
790
 
703
791
  // Buffer post-EOSE live events while a reconnect gap fill is running so
704
792
  // the historical gap is yielded before newer socket events.
705
793
  let liveBuffer = (gapSince !== null && gapSince > 0) ? [] : null
706
794
  let liveEose = false
795
+ let bufferedBytes = 0
707
796
 
708
797
  // Open the live sub first so the relay starts buffering incoming events
709
798
  // before we scan its database for the reconnect gap fill.
@@ -715,12 +804,21 @@ export class RelayPool {
715
804
  // A limit:0 relay may still send retained events before EOSE. Do not
716
805
  // expose them from a strictly-live stream.
717
806
  if (isDone || liveSubs.get(url) !== liveSub || !liveEose) return
718
- if (liveBuffer) liveBuffer.push(event)
719
- else pushEvent(event, url)
807
+ if (liveBuffer) {
808
+ const bytes = encoder.encode(JSON.stringify(event)).byteLength
809
+ if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
810
+ overflow(url)
811
+ return
812
+ }
813
+ bufferedBytes += bytes
814
+ liveBuffer.push(event)
815
+ } else pushEvent(event, url)
720
816
  },
721
817
  onclose: error => {
722
818
  if (liveSubs.get(url) === liveSub) liveSubs.delete(url)
723
819
  else if (liveSubs.has(url)) return
820
+ lease.release()
821
+ if (liveLeases.get(url) === lease) liveLeases.delete(url)
724
822
  readyRelays.delete(url)
725
823
  if (!isDone) {
726
824
  if (error !== undefined) reportFailure(url, asError(error))
@@ -735,11 +833,11 @@ export class RelayPool {
735
833
  markInitialEose(url)
736
834
  }
737
835
  })
738
- if (isDone) { liveSub.close(); return }
836
+ if (isDone) { liveSub.close(); recoveryLease?.release(); return }
739
837
  liveSubs.set(url, liveSub)
740
838
 
741
839
  if (gapSince !== null && gapSince > 0) {
742
- const task = runReconnectGapFill(url, gapSince, now).finally(() => {
840
+ const task = runReconnectGapFill(url, gapSince, now, slots).finally(() => {
743
841
  const buf = liveBuffer
744
842
  liveBuffer = null
745
843
  for (const event of buf) pushEvent(event, url, true)
@@ -749,6 +847,9 @@ export class RelayPool {
749
847
  gapTasks.add(task)
750
848
  }
751
849
  }).catch(err => {
850
+ recoveryLease?.release()
851
+ liveLeases.get(url)?.release()
852
+ liveLeases.delete(url)
752
853
  readyRelays.delete(url)
753
854
  const reason = err instanceof Error ? err : new Error(String(err))
754
855
  reportFailure(url, reason)
@@ -759,7 +860,10 @@ export class RelayPool {
759
860
 
760
861
  if (!urls.length) {
761
862
  finishReady()
762
- try { yield queue.shift() } finally {
863
+ try {
864
+ if (failure) throw failure
865
+ yield shift()
866
+ } finally {
763
867
  teardown()
764
868
  signal?.removeEventListener('abort', abort)
765
869
  _stopSignal?.removeEventListener('abort', stop)
@@ -776,9 +880,10 @@ export class RelayPool {
776
880
  // eslint-disable-next-line no-unmodified-loop-condition
777
881
  while (!isDone || (draining && gapTasks.size > 0) || queue.length > 0) {
778
882
  if (signal?.aborted) break
779
- if (queue.length > 0) yield queue.shift()
883
+ if (queue.length > 0) yield shift()
780
884
  else { await p.promise; p = Promise.withResolvers() }
781
885
  }
886
+ if (failure) throw failure
782
887
  } finally {
783
888
  signal?.removeEventListener('abort', abort)
784
889
  _stopSignal?.removeEventListener('abort', stop)
@@ -789,8 +894,8 @@ export class RelayPool {
789
894
 
790
895
  // All-in-one event feed generator. For live:true, handles the full sequence:
791
896
  //
792
- // - live:true (default): unless filter.limit === 0, starts the live sub immediately
793
- // (so no incoming events are missed), runs an initial one-shot fetch of stored events
897
+ // - live:true (default): unless filter.limit === 0, reserves live+history capacity,
898
+ // starts live input, then runs an initial one-shot fetch of stored events
794
899
  // concurrently, yields stored events first, then flushes buffered live events (deduped
795
900
  // against stored ones), then yields live events indefinitely. With limit:0 the relay
796
901
  // sends no stored events, so the fetch is skipped and only the live sub runs.
@@ -798,9 +903,13 @@ export class RelayPool {
798
903
  // short-circuits after the fastest relay with events EOSEs, or waits for all
799
904
  // relays when null.
800
905
  //
906
+ // snapshot:true captures a historical until after initial live readiness;
907
+ // that bound is reported in the history marker and never stops live input.
801
908
  // All underlying generators are injectable for testing.
802
909
  getEventsFeedGenerator (filter, relays, options = {}) {
803
- assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose })
910
+ if (options.snapshot !== undefined && typeof options.snapshot !== 'boolean') throw new ValidationError('INVALID_RELAY_SNAPSHOT')
911
+ assertBufferOptions(options)
912
+ assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose, queueTimeout: options.queueTimeout === undefined ? 30000 : options.queueTimeout })
804
913
  return drainableStream(options => this.#getEventsFeedGenerator(filter, relays, options), options)
805
914
  }
806
915
 
@@ -808,95 +917,138 @@ export class RelayPool {
808
917
  signal,
809
918
  _stopSignal,
810
919
  live = true,
920
+ snapshot = false,
811
921
  timeout = 5000,
812
922
  timeoutAfterFirstEose = 500,
923
+ queueTimeout = 30000,
924
+ maxBufferedLiveEvents = 1000,
925
+ maxBufferedLiveBytes = 8 * 1024 * 1024,
813
926
  _liveGenerator = (...args) => this.getLiveEventsGenerator(...args),
814
927
  _eventsGenerator = (...args) => this.getEventsGenerator(...args)
815
928
  } = {}) {
816
929
  if (signal.aborted || _stopSignal.aborted) return
930
+ const options = { timeout, timeoutAfterFirstEose, queueTimeout, signal, _stopSignal }
817
931
  if (!live) {
818
- const gen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
819
- for await (const item of gen) {
932
+ const bounds = { since: filter.since ?? 0, until: Math.min(filter.until ?? Infinity, Math.floor(Date.now() / 1000)) }
933
+ for await (const item of _eventsGenerator(snapshot ? { ...filter, ...bounds } : filter, relays, options)) {
820
934
  if (signal.aborted) return
821
- yield item
935
+ yield snapshot && item.type === 'eose' ? { ...item, snapshot: bounds } : item
822
936
  }
823
937
  return
824
938
  }
825
939
 
826
- // limit:0 means "no stored events, live only" — skip the initial fetch.
940
+ // limit:0 is a live-only stream and reports that stream's initial window.
827
941
  if (filter.limit === 0) {
828
- for await (const event of _liveGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })) {
829
- if (signal.aborted) return
830
- yield event
831
- }
942
+ yield * _liveGenerator(filter, relays, options)
832
943
  return
833
944
  }
834
945
 
835
- // Start live generator immediately so the relay opens the live sub and starts
836
- // buffering incoming events before we query stored ones.
837
- // Relays always send stored matching events before EOSE (unless limit:0),
838
- // so the initial fetch + buffering is always needed.
839
- const liveGen = _liveGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
946
+ const urls = normalizedRelayUrls(relays)
947
+ const inputAbort = new AbortController()
948
+ const inputSignal = AbortSignal.any([signal, inputAbort.signal])
949
+ const admissionSignal = AbortSignal.any([inputSignal, _stopSignal])
950
+ const reservations = new Map()
951
+ const leases = new Set()
952
+ for (const url of urls) {
953
+ const reservation = this.#admission.acquire(normalizeRelayUrl(url), { history: true, live: true, signal: admissionSignal, queueTimeout }).then(slots => {
954
+ for (const lease of Object.values(slots)) {
955
+ leases.add(lease)
956
+ if (admissionSignal.aborted) lease.release()
957
+ }
958
+ return slots
959
+ })
960
+ // Both readers consume the same attempt, including admission failures.
961
+ reservation.catch(() => {})
962
+ reservations.set(url, reservation)
963
+ }
964
+ const readOptions = { ...options, signal: inputSignal, _admissions: reservations, maxBufferedLiveEvents, maxBufferedLiveBytes }
965
+ const liveFilter = { ...filter }
966
+ if (snapshot) delete liveFilter.until
967
+ const liveGen = _liveGenerator(liveFilter, urls, readOptions)
840
968
  const liveBuffer = []
969
+ const encoder = new TextEncoder()
970
+ let bufferedBytes = 0
841
971
  let liveDone = false
842
972
  let liveFailure
843
973
  let liveWake = Promise.withResolvers()
844
-
974
+ const initialReady = Promise.withResolvers()
975
+ const shift = () => {
976
+ const entry = liveBuffer.shift()
977
+ bufferedBytes -= entry.bytes
978
+ return entry.item
979
+ }
845
980
  const bgLoop = (async () => {
846
981
  try {
847
- for await (const event of liveGen) {
848
- if (signal.aborted) break
849
- if (event.type !== 'eose') liveBuffer.push(event)
982
+ for await (const item of liveGen) {
983
+ if (inputSignal.aborted) break
984
+ if (item.type === 'eose') initialReady.resolve()
985
+ else {
986
+ const bytes = encoder.encode(JSON.stringify(item)).byteLength
987
+ if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
988
+ throw Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), { code: 'RELAY_LIVE_BUFFER_FULL', relay: item.relay, phase: 'live-buffer' })
989
+ }
990
+ liveBuffer.push({ item, bytes })
991
+ bufferedBytes += bytes
992
+ }
850
993
  liveWake.resolve()
851
994
  liveWake = Promise.withResolvers()
852
995
  }
853
996
  } catch (error) {
854
997
  liveFailure = error
998
+ if (error.code === 'RELAY_LIVE_BUFFER_FULL') inputAbort.abort()
855
999
  } finally {
856
1000
  liveDone = true
1001
+ initialReady.resolve()
857
1002
  liveWake.resolve()
858
1003
  }
859
1004
  })()
860
1005
 
861
1006
  try {
862
- // Yield stored events from the initial one-shot fetch
863
- const fetchGen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
864
-
1007
+ if (snapshot) await initialReady.promise
1008
+ if (liveFailure?.code === 'RELAY_LIVE_BUFFER_FULL') throw liveFailure
1009
+ if (signal.aborted) return
1010
+ const bounds = { since: filter.since ?? 0, until: Math.min(filter.until ?? Infinity, Math.floor(Date.now() / 1000)) }
1011
+ const historyFilter = snapshot ? { ...filter, ...bounds } : filter
1012
+ const fetchGen = _eventsGenerator(historyFilter, urls, readOptions)
865
1013
  const seenIds = new Set()
866
1014
  for await (const item of fetchGen) {
1015
+ if (liveFailure?.code === 'RELAY_LIVE_BUFFER_FULL') throw liveFailure
867
1016
  if (signal.aborted) return
868
1017
  if (item?.type === 'event' && !seenIds.has(item.event.id)) {
869
1018
  seenIds.add(item.event.id)
870
1019
  yield item
871
- } else if (item?.type !== 'event') yield item
1020
+ } else if (item?.type !== 'event') {
1021
+ yield snapshot && item.type === 'eose' ? { ...item, snapshot: bounds } : item
1022
+ }
872
1023
  }
1024
+ if (liveFailure?.code === 'RELAY_LIVE_BUFFER_FULL') throw liveFailure
873
1025
 
874
- // Flush buffered live events that arrived during the initial fetch, deduping
875
- // against stored ones (overlap is possible around the fetch's until boundary)
876
1026
  while (liveBuffer.length > 0) {
877
1027
  if (signal.aborted) return
878
- const item = liveBuffer.shift()
1028
+ const item = shift()
879
1029
  if (item.type !== 'event' || !seenIds.has(item.event.id)) {
880
1030
  if (item.type === 'event') seenIds.add(item.event.id)
881
1031
  yield item
882
1032
  }
883
1033
  }
884
-
885
1034
  seenIds.clear()
886
1035
 
887
- // Yield subsequent live events directly — no more overlap with stored events
888
1036
  // eslint-disable-next-line no-unmodified-loop-condition
889
1037
  while (!liveDone || liveBuffer.length > 0) {
1038
+ if (liveFailure?.code === 'RELAY_LIVE_BUFFER_FULL') throw liveFailure
890
1039
  while (liveBuffer.length > 0) {
891
1040
  if (signal.aborted) return
892
- yield liveBuffer.shift()
1041
+ yield shift()
893
1042
  }
894
1043
  if (!liveDone) await liveWake.promise
895
1044
  }
896
1045
  if (liveFailure) throw liveFailure
897
1046
  } finally {
1047
+ inputAbort.abort()
898
1048
  await liveGen.return()
899
1049
  await bgLoop
1050
+ liveBuffer.length = 0
1051
+ for (const lease of leases) lease.release()
900
1052
  }
901
1053
  }
902
1054