libp2r2p 0.10.20 → 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.
package/README.md CHANGED
@@ -30,7 +30,7 @@ partial results. Its report distinguishes actual relay EOSE from other outcomes:
30
30
  | --- | --- |
31
31
  | `eose` | The relay sent EOSE. |
32
32
  | `satisfied` | The requested limit or IDs were satisfied, so the read closed early. |
33
- | `timeout` | The initial operation deadline elapsed. Also emits an error item. |
33
+ | `timeout` | The initial relay attempt deadline elapsed. Also emits an error item. |
34
34
  | `cutoff` | The grace period after the first qualifying EOSE/early completion elapsed. |
35
35
  | `closed` | The subscription ended without EOSE or an explicit error. |
36
36
  | `error` | Connection or subscription failed. Also emits an error item. |
@@ -71,7 +71,7 @@ tracks currently ready relays and can change after that snapshot. Timeouts and
71
71
  cutoffs are not acknowledgements of readiness. Reconnections do not repeat the
72
72
  initial marker. Both APIs require consuming the iterator to start its work.
73
73
 
74
- Live and feed iterators have a synchronous, idempotent `stopAndDrain()` method.
74
+ Read iterators have a synchronous, idempotent `stopAndDrain()` method.
75
75
  It closes subscription input and cancels reconnections and outstanding historical
76
76
  queries, while retaining items already accepted by receive callbacks. Continue
77
77
  consuming to obtain those items and completion. This includes initial history,
@@ -101,6 +101,72 @@ content-key, private-channel and NIP-46 helpers unwrap pool results internally
101
101
  and retain their higher-level event contracts. Count, publication and disconnect
102
102
  return formats are unchanged.
103
103
 
104
+ ### Read admission and bounded snapshots (0.10.21)
105
+
106
+ Each pool coordinates event reads by normalized connection URL. Constructor
107
+ options are `maxSubscriptionsPerRelay: 28`,
108
+ `maxConcurrentHistoryPerRelay: 2`, and `maxQueuedReadsPerRelay: 256`.
109
+ These are local budgets, not negotiated relay limits. A stricter relay can still
110
+ reject subscriptions. Count and publication contracts are unchanged and do not
111
+ consume event-read slots.
112
+
113
+ Admission is FIFO per connection. A history+live feed reserves two slots
114
+ atomically before either REQ opens; completing history releases only its slot.
115
+ One-shot queries and reconnect gap reads share this budget. Reconnection reserves
116
+ live+history together when recovery is needed. Caller cancellation removes queued
117
+ work; `disconnect(url)` also rejects queued work for that connection. Existing
118
+ live streams retain their reconnect behavior until cancelled by their owner.
119
+
120
+ All event reads accept `queueTimeout: 30000` (milliseconds, `null` disables).
121
+ The network `timeout` starts **after admission for each relay**, including its
122
+ connection setup; time in the queue does not consume it. The first-EOSE grace
123
+ can still cut short other relay attempts. Admission failures are ordinary error
124
+ envelopes/report entries, with `error.code`, `error.relay` and
125
+ `error.phase === 'admission'`: `RELAY_READ_QUEUE_FULL`,
126
+ `RELAY_READ_QUEUE_TIMEOUT`, `RELAY_READ_CAPACITY`, or `RELAY_DISCONNECTED`.
127
+ Cancellation rejects an outstanding direct `getEvents()` call; cancelled iterators
128
+ stop delivering. Failures on one relay continue to allow results from others.
129
+
130
+ Live and feed readers limit each pending live buffer to 1,000 envelopes and
131
+ 8 MiB of serialized UTF-8 data. Options `maxBufferedLiveEvents` and
132
+ `maxBufferedLiveBytes` customize those positive limits. They cover consumer
133
+ backlog and reconnect buffers as well as live events waiting for history. These
134
+ are bounds on queued data, not a measurement of total JavaScript heap usage.
135
+ Overflow fails the attempt with `RELAY_LIVE_BUFFER_FULL` (`phase: 'live-buffer'`),
136
+ closes input and discards pending live data. Callers decide whether to retry;
137
+ events already delivered are not recalled. Ordinary relay failures retain the
138
+ existing partial-result behavior; they do not automatically discard the feed.
139
+
140
+ With `snapshot: true`, a feed waits for its initial live readiness window before
141
+ capturing the historical `until`. `filter.since` defaults to zero; `filter.until`
142
+ can provide an earlier cutoff but cannot extend a snapshot into the future.
143
+ The historical completion includes the actual inclusive bounds:
144
+
145
+ ```js
146
+ const stream = relayPool.getEventsFeedGenerator(
147
+ { authors: [pubkey], kinds: [1], since: lastConfirmed - 600, limit: 200 },
148
+ [relay],
149
+ { snapshot: true, timeoutAfterFirstEose: null, signal }
150
+ )
151
+ // Historical completion:
152
+ // { type: 'eose', relays: [...], snapshot: { since, until } }
153
+ ```
154
+
155
+ Only history is bounded: the live REQ has no `until` in this mode. Events around
156
+ the boundary can appear in both sources and are deduplicated by ID. Empty history
157
+ still reports its bounds. With `live: false`, the cutoff is captured when the
158
+ iterator starts; `limit: 0` remains live-only, without a historical snapshot.
159
+ Default `snapshot: false` retains the existing filter time-range behavior.
160
+
161
+ This is a bounded time interval, not a transactional relay snapshot or proof of
162
+ complete coverage. Check each relay's outcome, exhaust pagination and commit
163
+ received events before checkpointing. `satisfied` can mean a truncated page;
164
+ EOSE can also terminate a limited response. A consumer that rejects a historical
165
+ attempt can call `return()`/abort when it receives its report, before the buffered
166
+ live events are released. Use `stopAndDrain()` only when those pending events
167
+ should be preserved. Slow/failed live readiness is still reported by the live
168
+ stream according to the existing partial-failure rules.
169
+
104
170
  ## Private Messenger
105
171
 
106
172
  The main API is `createPrivateMessenger` from `libp2r2p/private-messenger`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.20",
3
+ "version": "0.10.21",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -0,0 +1,92 @@
1
+ import { ValidationError } from '../../error/index.js'
2
+ import { maybeUnref } from './timer.js'
3
+
4
+ export function admissionError (code, relay) {
5
+ return Object.assign(new Error(code), { code, relay, phase: 'admission' })
6
+ }
7
+
8
+ // One FIFO per pooled connection. A feed reserves both REQs atomically; its
9
+ // history and live leases can subsequently be released independently.
10
+ export class ReadAdmission {
11
+ constructor ({ maxSubscriptionsPerRelay = 28, maxConcurrentHistoryPerRelay = 2, maxQueuedReadsPerRelay = 256 } = {}) {
12
+ for (const value of [maxSubscriptionsPerRelay, maxConcurrentHistoryPerRelay, maxQueuedReadsPerRelay]) {
13
+ if (!Number.isSafeInteger(value) || value < 1) throw new ValidationError('INVALID_RELAY_READ_CAPACITY')
14
+ }
15
+ Object.assign(this, { maxSubscriptionsPerRelay, maxConcurrentHistoryPerRelay, maxQueuedReadsPerRelay })
16
+ this.states = new Map()
17
+ }
18
+
19
+ acquire (relay, { history = false, live = false, signal, queueTimeout = 30000 } = {}) {
20
+ if (queueTimeout !== null && (!Number.isFinite(queueTimeout) || queueTimeout < 0)) throw new ValidationError('INVALID_RELAY_TIMEOUT')
21
+ const weight = Number(history) + Number(live)
22
+ if (!weight || weight > this.maxSubscriptionsPerRelay) return Promise.reject(admissionError('RELAY_READ_CAPACITY', relay))
23
+ if (signal?.aborted) return Promise.reject(admissionError('RELAY_READ_CANCELLED', relay))
24
+ let state = this.states.get(relay)
25
+ if (!state) {
26
+ state = { active: 0, history: 0, queue: [] }
27
+ this.states.set(relay, state)
28
+ }
29
+ const deferred = Promise.withResolvers()
30
+ let timer
31
+ const job = {
32
+ history, weight, grant: () => {
33
+ cleanup()
34
+ state.active += weight
35
+ state.history += Number(history)
36
+ const leases = {}
37
+ for (const kind of ['history', 'live']) {
38
+ if (!(kind === 'history' ? history : live)) continue
39
+ let released = false
40
+ leases[kind] = {
41
+ release: () => {
42
+ if (released) return
43
+ released = true
44
+ state.active--
45
+ if (kind === 'history') state.history--
46
+ this.drain(relay, state)
47
+ }
48
+ }
49
+ }
50
+ deferred.resolve(leases)
51
+ }, cancel: code => {
52
+ const index = state.queue.indexOf(job)
53
+ if (index < 0) return
54
+ state.queue.splice(index, 1)
55
+ cleanup()
56
+ deferred.reject(admissionError(code, relay))
57
+ this.drain(relay, state)
58
+ }
59
+ }
60
+ const abort = () => job.cancel('RELAY_READ_CANCELLED')
61
+ const cleanup = () => {
62
+ clearTimeout(timer)
63
+ signal?.removeEventListener('abort', abort)
64
+ }
65
+ if (state.queue.length >= this.maxQueuedReadsPerRelay) return Promise.reject(admissionError('RELAY_READ_QUEUE_FULL', relay))
66
+ state.queue.push(job)
67
+ signal?.addEventListener('abort', abort, { once: true })
68
+ if (queueTimeout !== null) timer = maybeUnref(setTimeout(() => job.cancel('RELAY_READ_QUEUE_TIMEOUT'), queueTimeout))
69
+ this.drain(relay, state)
70
+ return deferred.promise
71
+ }
72
+
73
+ drain (relay, state) {
74
+ while (state.queue.length) {
75
+ const job = state.queue[0]
76
+ if (state.active + job.weight > this.maxSubscriptionsPerRelay || (job.history && state.history >= this.maxConcurrentHistoryPerRelay)) break
77
+ state.queue.shift().grant()
78
+ }
79
+ if (!state.active && !state.queue.length && this.states.get(relay) === state) this.states.delete(relay)
80
+ }
81
+
82
+ cancelQueued (relay) {
83
+ const state = this.states.get(relay)
84
+ if (!state) return
85
+ // Remove the entire queue first: cancelling one entry must not admit another.
86
+ const jobs = state.queue.splice(0)
87
+ for (const job of jobs) {
88
+ state.queue.push(job)
89
+ job.cancel('RELAY_DISCONNECTED')
90
+ }
91
+ }
92
+ }
@@ -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