libp2r2p 0.10.20 → 0.10.22

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
@@ -21,6 +21,7 @@ never decorated with `meta`; relay provenance belongs to its enclosing item:
21
21
  { type: 'event', event, relay }
22
22
  { type: 'error', relay, error }
23
23
  { type: 'eose', relays: [{ relay, status, error }] } // error is optional
24
+ { type: 'live-progress', relay, epoch, since, until } // live and live-enabled feeds only
24
25
  ```
25
26
 
26
27
  `eose` means the **initial read attempt is complete**, including empty reads and
@@ -30,7 +31,7 @@ partial results. Its report distinguishes actual relay EOSE from other outcomes:
30
31
  | --- | --- |
31
32
  | `eose` | The relay sent EOSE. |
32
33
  | `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. |
34
+ | `timeout` | The initial relay attempt deadline elapsed. Also emits an error item. |
34
35
  | `cutoff` | The grace period after the first qualifying EOSE/early completion elapsed. |
35
36
  | `closed` | The subscription ended without EOSE or an explicit error. |
36
37
  | `error` | Connection or subscription failed. Also emits an error item. |
@@ -71,7 +72,7 @@ tracks currently ready relays and can change after that snapshot. Timeouts and
71
72
  cutoffs are not acknowledgements of readiness. Reconnections do not repeat the
72
73
  initial marker. Both APIs require consuming the iterator to start its work.
73
74
 
74
- Live and feed iterators have a synchronous, idempotent `stopAndDrain()` method.
75
+ Read iterators have a synchronous, idempotent `stopAndDrain()` method.
75
76
  It closes subscription input and cancels reconnections and outstanding historical
76
77
  queries, while retaining items already accepted by receive callbacks. Continue
77
78
  consuming to obtain those items and completion. This includes initial history,
@@ -101,6 +102,117 @@ content-key, private-channel and NIP-46 helpers unwrap pool results internally
101
102
  and retain their higher-level event contracts. Count, publication and disconnect
102
103
  return formats are unchanged.
103
104
 
105
+ ### Live overlap and progress (0.10.22)
106
+
107
+ Every live subscription opens with `since = max(filter.since ?? 0, now - 600, 0)`,
108
+ including its first connection. This accepts events that arrive after opening
109
+ but carry a timestamp up to ten minutes earlier. Explicit filter boundaries still
110
+ apply, and retained events before EOSE remain excluded from strictly-live output.
111
+
112
+ Reconnect recovery overlaps each relay's last received timestamp by 600 seconds,
113
+ without going below the caller's `since` or zero. Empty connections use their
114
+ opening time instead. Cursors advance independently even for events deduplicated
115
+ across relays; future timestamps are capped at their local receipt time. Failed
116
+ or interrupted recoveries retain their earlier baseline for the next reconnect.
117
+
118
+ Live readers automatically enqueue a `live-progress` envelope every 60 seconds
119
+ while ready, including when no events arrive. There is no enabling option.
120
+ `epoch` is a numeric connection-attempt identifier scoped to this iterator;
121
+ compare it together with `relay`, not across separate iterators. `since` is the
122
+ local EOSE time (or the explicit filter lower bound if later), and `until` is the
123
+ local time at the tick, both inclusive Unix seconds. Repeated markers describe
124
+ the same continuous epoch with an increasing `until`.
125
+
126
+ Progress starts only after that relay's EOSE and successful completion of any
127
+ pending recovery. It pauses on disconnection, does not span connection epochs,
128
+ and stops on cancellation, draining or `until`. History-only and empty-relay
129
+ reads do not emit it. Timers never issue extra network queries.
130
+
131
+ Markers share the bounded queue with events and follow all earlier received
132
+ items. A feed preserves that ordering behind its initial history and queued live
133
+ events. Consumers must finish persisting preceding events before persisting
134
+ progress, and must not treat a marker as proof of historical completeness or as
135
+ coverage of a disconnected interval. This is client-observed continuity, not a
136
+ relay acknowledgement; delayed relay delivery or clock skew beyond the overlap
137
+ can still leave gaps. A backward local clock cannot regress an emitted boundary.
138
+
139
+ Every involuntary live close emits an error and still permits automatic recovery.
140
+ A close without an explicit cause uses `error.code === 'RELAY_LIVE_INTERRUPTED'`
141
+ and `category === 'transport'`; a pre-EOSE close retains status `closed` in the
142
+ initial report. Explicit causes are preserved. Caller cancellation, draining and
143
+ expiry of `filter.until` do not manufacture interruption errors.
144
+
145
+ Process Nostr events only under `item.type === 'event'`. Handle controls you need
146
+ and ignore unknown types; a new control is neither an event nor stream completion.
147
+ The internal query, private-channel and NIP-46 consumers retain their existing
148
+ higher-level event-only contracts.
149
+
150
+ ### Read admission and bounded snapshots (0.10.21)
151
+
152
+ Each pool coordinates event reads by normalized connection URL. Constructor
153
+ options are `maxSubscriptionsPerRelay: 28`,
154
+ `maxConcurrentHistoryPerRelay: 2`, and `maxQueuedReadsPerRelay: 256`.
155
+ These are local budgets, not negotiated relay limits. A stricter relay can still
156
+ reject subscriptions. Count and publication contracts are unchanged and do not
157
+ consume event-read slots.
158
+
159
+ Admission is FIFO per connection. A history+live feed reserves two slots
160
+ atomically before either REQ opens; completing history releases only its slot.
161
+ One-shot queries and reconnect gap reads share this budget. Reconnection reserves
162
+ live+history together when recovery is needed. Caller cancellation removes queued
163
+ work; `disconnect(url)` also rejects queued work for that connection. Existing
164
+ live streams retain their reconnect behavior until cancelled by their owner.
165
+
166
+ All event reads accept `queueTimeout: 30000` (milliseconds, `null` disables).
167
+ The network `timeout` starts **after admission for each relay**, including its
168
+ connection setup; time in the queue does not consume it. The first-EOSE grace
169
+ can still cut short other relay attempts. Admission failures are ordinary error
170
+ envelopes/report entries, with `error.code`, `error.relay` and
171
+ `error.phase === 'admission'`: `RELAY_READ_QUEUE_FULL`,
172
+ `RELAY_READ_QUEUE_TIMEOUT`, `RELAY_READ_CAPACITY`, or `RELAY_DISCONNECTED`.
173
+ Cancellation rejects an outstanding direct `getEvents()` call; cancelled iterators
174
+ stop delivering. Failures on one relay continue to allow results from others.
175
+
176
+ Live and feed readers limit each pending live buffer to 1,000 envelopes and
177
+ 8 MiB of serialized UTF-8 data. Options `maxBufferedLiveEvents` and
178
+ `maxBufferedLiveBytes` customize those positive limits. They cover consumer
179
+ backlog and reconnect buffers as well as live events waiting for history. These
180
+ are bounds on queued data, not a measurement of total JavaScript heap usage.
181
+ Overflow fails the attempt with `RELAY_LIVE_BUFFER_FULL` (`phase: 'live-buffer'`),
182
+ closes input and discards pending live data. Callers decide whether to retry;
183
+ events already delivered are not recalled. Ordinary relay failures retain the
184
+ existing partial-result behavior; they do not automatically discard the feed.
185
+
186
+ With `snapshot: true`, a feed waits for its initial live readiness window before
187
+ capturing the historical `until`. `filter.since` defaults to zero; `filter.until`
188
+ can provide an earlier cutoff but cannot extend a snapshot into the future.
189
+ The historical completion includes the actual inclusive bounds:
190
+
191
+ ```js
192
+ const stream = relayPool.getEventsFeedGenerator(
193
+ { authors: [pubkey], kinds: [1], since: lastConfirmed - 600, limit: 200 },
194
+ [relay],
195
+ { snapshot: true, timeoutAfterFirstEose: null, signal }
196
+ )
197
+ // Historical completion:
198
+ // { type: 'eose', relays: [...], snapshot: { since, until } }
199
+ ```
200
+
201
+ Only history is bounded: the live REQ has no `until` in this mode. Events around
202
+ the boundary can appear in both sources and are deduplicated by ID. Empty history
203
+ still reports its bounds. With `live: false`, the cutoff is captured when the
204
+ iterator starts; `limit: 0` remains live-only, without a historical snapshot.
205
+ Default `snapshot: false` retains the existing filter time-range behavior.
206
+
207
+ This is a bounded time interval, not a transactional relay snapshot or proof of
208
+ complete coverage. Check each relay's outcome, exhaust pagination and commit
209
+ received events before checkpointing. `satisfied` can mean a truncated page;
210
+ EOSE can also terminate a limited response. A consumer that rejects a historical
211
+ attempt can call `return()`/abort when it receives its report, before the buffered
212
+ live events are released. Use `stopAndDrain()` only when those pending events
213
+ should be preserved. Slow/failed live readiness is still reported by the live
214
+ stream according to the existing partial-failure rules.
215
+
104
216
  ## Private Messenger
105
217
 
106
218
  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.22",
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'
@@ -12,6 +13,8 @@ const COUNT_TIMEOUT_MS = 5000
12
13
  const COUNT_TIMEOUT_AFTER_FIRST_COUNT_MS = 500
13
14
  const SEND_TIMEOUT_UNTIL_FIRST_FULFILLMENT_MS = 3000
14
15
  const SEND_TIMEOUT_MS = 30000
16
+ const LIVE_OVERLAP_SECONDS = 600
17
+ const LIVE_PROGRESS_INTERVAL_MS = 60000
15
18
 
16
19
  // Returns a function that should be called for each received event (valid or invalid).
17
20
  // Calls onSatisfied() and stops counting once the filter is fully satisfied per relay:
@@ -91,6 +94,12 @@ function assertReadOptions (filter, timeouts) {
91
94
  }
92
95
  }
93
96
 
97
+ function assertBufferOptions (options) {
98
+ for (const value of [options.maxBufferedLiveEvents === undefined ? 1000 : options.maxBufferedLiveEvents, options.maxBufferedLiveBytes === undefined ? 8 * 1024 * 1024 : options.maxBufferedLiveBytes]) {
99
+ if (!Number.isSafeInteger(value) || value < 1) throw new ValidationError('INVALID_RELAY_BUFFER_CAPACITY')
100
+ }
101
+ }
102
+
94
103
  function normalizedRelayUrls (relays) {
95
104
  const urls = []
96
105
  const seen = new Set()
@@ -127,14 +136,19 @@ export class RelayPool {
127
136
  #liveSubCounts = new Map() // url -> number of active live subscriptions
128
137
  #timeout = 30000 // 30 seconds
129
138
  #createRelay
139
+ #admission
130
140
 
131
- constructor ({ _createRelay = url => new RelayConnection(url) } = {}) {
141
+ constructor ({ _createRelay = url => new RelayConnection(url), ...capacity } = {}) {
132
142
  this.#createRelay = _createRelay
143
+ this.#admission = new ReadAdmission(capacity)
133
144
  }
134
145
 
135
146
  #scheduleIdleDisconnect (url) {
136
147
  clearTimeout(this.#relayTimeouts.get(url))
137
- this.#relayTimeouts.set(url, maybeUnref(setTimeout(() => this.disconnect(url), this.#timeout)))
148
+ this.#relayTimeouts.set(url, maybeUnref(setTimeout(() => {
149
+ if (this.#admission.states.get(url)?.active) this.#scheduleIdleDisconnect(url)
150
+ else this.disconnect(url)
151
+ }, this.#timeout)))
138
152
  }
139
153
 
140
154
  // Opens a normalized pooled connection. Failed connects are evicted so a later
@@ -191,6 +205,7 @@ export class RelayPool {
191
205
  // Disconnect from a relay
192
206
  async disconnect (url) {
193
207
  const normalizedUrl = normalizeRelayUrl(url)
208
+ this.#admission.cancelQueued(normalizedUrl)
194
209
  if (this.#relays.has(normalizedUrl)) {
195
210
  const relay = this.#relays.get(normalizedUrl)
196
211
  if (relay.ws?.readyState < 2) await relay.close()?.catch(console.log)
@@ -202,7 +217,7 @@ export class RelayPool {
202
217
 
203
218
  // Disconnect from all relays
204
219
  async disconnectAll () {
205
- for (const url of [...this.#relays.keys()]) {
220
+ for (const url of new Set([...this.#relays.keys(), ...this.#admission.states.keys()])) {
206
221
  await this.disconnect(url)
207
222
  }
208
223
  }
@@ -352,8 +367,8 @@ export class RelayPool {
352
367
  // grace window; null disables that window so callers wait for every relay or
353
368
  // the operation deadline. Disabling cross-relay deduplication still suppresses
354
369
  // 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 })
370
+ async getEvents (filter, relays, { timeout = 5000, timeoutAfterFirstEose = 500, callback, signal, deduplicateAcrossRelays = true, queueTimeout = 30000, _admissions } = {}) {
371
+ assertReadOptions(filter, { timeout, timeoutAfterFirstEose, queueTimeout })
357
372
  if (typeof deduplicateAcrossRelays !== 'boolean') throw new ValidationError('INVALID_DEDUPLICATE_ACROSS_RELAYS')
358
373
  if (signal?.aborted) throw new Error('Aborted')
359
374
  const urls = normalizedRelayUrls(relays)
@@ -364,15 +379,21 @@ export class RelayPool {
364
379
  const eventIds = deduplicateAcrossRelays ? new Set() : null
365
380
  let isResolved = false
366
381
  let eoseTimer = null
367
- let timeoutTimer = null
382
+ const timers = new Map()
383
+ const leases = new Map()
384
+ const admissionAbort = new AbortController()
385
+ const admissionSignal = signal ? AbortSignal.any([signal, admissionAbort.signal]) : admissionAbort.signal
368
386
 
369
387
  return await new Promise((resolve, reject) => {
370
388
  const cleanup = () => {
371
- clearTimeout(timeoutTimer)
389
+ admissionAbort.abort()
390
+ for (const timer of timers.values()) clearTimeout(timer)
372
391
  clearTimeout(eoseTimer)
373
392
  signal?.removeEventListener('abort', onAbort)
374
393
  for (const sub of subscriptions.values()) sub.close()
375
394
  subscriptions.clear()
395
+ for (const lease of leases.values()) lease.release()
396
+ leases.clear()
376
397
  }
377
398
  const fail = error => {
378
399
  if (isResolved) return
@@ -386,6 +407,11 @@ export class RelayPool {
386
407
  const settleRelay = (relay, status, error) => {
387
408
  if (isResolved || outcomes.has(relay)) return
388
409
  outcomes.set(relay, { relay, status, ...(error ? { error } : {}) })
410
+ clearTimeout(timers.get(relay))
411
+ subscriptions.get(relay)?.close()
412
+ subscriptions.delete(relay)
413
+ leases.get(relay)?.release()
414
+ leases.delete(relay)
389
415
  if (error) {
390
416
  errors.push({ relay, reason: error })
391
417
  notify({ type: 'error', relay, error })
@@ -412,12 +438,22 @@ export class RelayPool {
412
438
  const finishIfComplete = () => { if (outcomes.size === urls.length) finish() }
413
439
  const onAbort = () => fail(new Error('Aborted'))
414
440
  signal?.addEventListener('abort', onAbort, { once: true })
415
- if (timeout !== null) timeoutTimer = maybeUnref(setTimeout(() => finish('timeout'), timeout))
416
441
  if (!urls.length) { finish(); return }
417
442
 
418
443
  for (const url of urls) {
419
444
  const seenIds = eventIds ?? new Set()
420
- this.#getRelay(url).then(relay => {
445
+ const reservation = _admissions?.get(url) ?? this.#admission.acquire(normalizeRelayUrl(url), { history: true, signal: admissionSignal, queueTimeout })
446
+ reservation.then(async slots => {
447
+ const lease = slots.history
448
+ if (isResolved || outcomes.has(url)) { lease.release(); return }
449
+ leases.set(url, lease)
450
+ if (timeout !== null) {
451
+ timers.set(url, maybeUnref(setTimeout(() => {
452
+ settleRelay(url, 'timeout', getEventsTimeoutError())
453
+ finishIfComplete()
454
+ }, timeout)))
455
+ }
456
+ const relay = await this.#getRelay(url)
421
457
  if (isResolved || outcomes.has(url)) return
422
458
  let hasEvents = false
423
459
  // Subscription callbacks may run before subscribe() returns.
@@ -467,7 +503,11 @@ export class RelayPool {
467
503
  })
468
504
  }
469
505
 
470
- async * getEventsGenerator (filter, relays, options = {}) {
506
+ getEventsGenerator (filter, relays, options = {}) {
507
+ return drainableStream(options => this.#getEventsGenerator(filter, relays, options), options)
508
+ }
509
+
510
+ async * #getEventsGenerator (filter, relays, options = {}) {
471
511
  const queue = []
472
512
  let p = Promise.withResolvers()
473
513
  let isDone = false
@@ -504,7 +544,8 @@ export class RelayPool {
504
544
  // Returns a strictly-live stream. `ready` reports the first initial EOSE window,
505
545
  // while `readyRelays` follows relays that are currently past their own EOSE.
506
546
  getLiveEventsGenerator (filter, relays, options = {}) {
507
- assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose })
547
+ assertBufferOptions(options)
548
+ assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose, queueTimeout: options.queueTimeout === undefined ? 30000 : options.queueTimeout })
508
549
  const ready = Promise.withResolvers()
509
550
  const readyRelays = new Set()
510
551
  const stream = drainableStream(options => this.#getLiveEventsGenerator(filter, relays, options, { ready, readyRelays }), options)
@@ -530,6 +571,10 @@ export class RelayPool {
530
571
  _stopSignal,
531
572
  timeout = 5000,
532
573
  timeoutAfterFirstEose = 500,
574
+ queueTimeout = 30000,
575
+ _admissions,
576
+ maxBufferedLiveEvents = 1000,
577
+ maxBufferedLiveBytes = 8 * 1024 * 1024,
533
578
  timeoutForReconnectGap = 5000,
534
579
  timeoutAfterFirstReconnectGapEose = 500,
535
580
  _gapEventsGenerator = (...args) => this.getEventsGenerator(...args)
@@ -539,12 +584,17 @@ export class RelayPool {
539
584
  let p = Promise.withResolvers()
540
585
  let isDone = false
541
586
  let draining = false
587
+ let failure
588
+ let queuedBytes = 0
589
+ const encoder = new TextEncoder()
542
590
  const gapTasks = new Set()
543
591
  const liveSubs = new Map() // url → live sub
544
592
  const retryTimers = new Map()
545
593
  const initialPending = new Set(urls)
546
594
  const initialOutcomes = new Map()
547
- let readyTimeout = null
595
+ const readyTimeouts = new Map()
596
+ const liveLeases = new Map()
597
+ const initialAdmissions = new Set()
548
598
  let readyTimer = null
549
599
  let isReady = false
550
600
 
@@ -559,23 +609,64 @@ export class RelayPool {
559
609
  // Preserve until for forwarding to the live sub filter and the teardown timer
560
610
  const filterUntil = filter.until > 0 ? filter.until : null
561
611
 
562
- // lastSeenAt: the highest created_at received so far; used as since on reconnect gap fill
563
- let lastSeenAt = (filter.since > 0) ? filter.since : null
612
+ // Recovery uses each relay's received timestamps, or its opening time when empty.
613
+ const lastSeenAt = new Map()
614
+ const openedAt = new Map()
615
+ const pendingGaps = new Map()
616
+ const attempts = new Map()
617
+ let nextEpoch = 0
618
+ const sinceFloor = Math.max(0, filter.since ?? 0)
619
+ const overlappingSince = timestamp => Math.max(sinceFloor, timestamp - LIVE_OVERLAP_SECONDS)
620
+ const observeEvent = (event, url) => {
621
+ // Every relay advances independently, including cross-relay duplicates.
622
+ // Future-dated events cannot hide an offline interval during recovery.
623
+ const timestamp = Math.min(event.created_at, Math.floor(Date.now() / 1000))
624
+ if (Number.isFinite(timestamp) && timestamp > (lastSeenAt.get(url) ?? -Infinity)) lastSeenAt.set(url, timestamp)
625
+ }
626
+ const scheduleProgress = attempt => {
627
+ if (isDone || attempt.closed || attempts.get(attempt.url) !== attempt ||
628
+ attempt.since === null || !attempt.recovered || attempt.timer) return
629
+ attempt.timer = maybeUnref(setTimeout(() => {
630
+ attempt.timer = null
631
+ if (isDone || attempt.closed || attempts.get(attempt.url) !== attempt) return
632
+ const until = Math.min(filterUntil ?? Infinity, Math.floor(Date.now() / 1000))
633
+ if (until > attempt.lastProgress && until >= attempt.since) {
634
+ enqueue({ type: 'live-progress', relay: attempt.url, epoch: attempt.epoch, since: attempt.since, until })
635
+ attempt.lastProgress = until
636
+ }
637
+ scheduleProgress(attempt)
638
+ }, LIVE_PROGRESS_INTERVAL_MS))
639
+ }
564
640
 
565
641
  // Bounded dedup set to handle overlap between reconnect gap fill and live sub.
566
642
  const seenIds = new Set()
567
643
 
568
644
  let untilTimer = null
645
+ const overflow = relay => {
646
+ failure = Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), { code: 'RELAY_LIVE_BUFFER_FULL', relay, phase: 'live-buffer' })
647
+ teardown()
648
+ }
569
649
  const enqueue = item => {
570
- queue.push(item)
650
+ const bytes = encoder.encode(JSON.stringify(item)).byteLength
651
+ if (queue.length >= maxBufferedLiveEvents || queuedBytes + bytes > maxBufferedLiveBytes) {
652
+ overflow(item.relay)
653
+ return
654
+ }
655
+ queuedBytes += bytes
656
+ queue.push({ item, bytes })
571
657
  p.resolve()
572
658
  p = Promise.withResolvers()
573
659
  }
660
+ const shift = () => {
661
+ const { item, bytes } = queue.shift()
662
+ queuedBytes -= bytes
663
+ return item
664
+ }
574
665
  const finishReady = (pendingStatus = 'cutoff', emit = true) => {
575
666
  if (isReady) return
576
667
  isReady = true
577
668
  clearTimeout(readyTimer)
578
- clearTimeout(readyTimeout)
669
+ for (const timer of readyTimeouts.values()) clearTimeout(timer)
579
670
  for (const relay of initialPending) {
580
671
  const error = pendingStatus === 'timeout' ? getEventsTimeoutError() : undefined
581
672
  initialOutcomes.set(relay, { relay, status: pendingStatus, ...(error ? { error } : {}) })
@@ -594,14 +685,20 @@ export class RelayPool {
594
685
  if (isDone && (drain || !draining)) return
595
686
  draining = drain
596
687
  isDone = true
597
- if (!drain) queue.length = 0
688
+ if (!drain) { queue.length = 0; queuedBytes = 0 }
598
689
  clearTimeout(untilTimer)
599
690
  finishReady('closed', false)
600
691
  gapAc.abort()
692
+ for (const attempt of attempts.values()) {
693
+ clearTimeout(attempt.timer)
694
+ attempt.stop.abort()
695
+ }
601
696
  for (const timer of retryTimers.values()) clearTimeout(timer)
602
697
  retryTimers.clear()
603
698
  liveSubs.forEach(sub => sub.close())
604
699
  liveSubs.clear()
700
+ for (const lease of liveLeases.values()) lease.release()
701
+ liveLeases.clear()
605
702
  p.resolve()
606
703
  }
607
704
 
@@ -612,7 +709,6 @@ export class RelayPool {
612
709
  if (seenIds.size >= 500) seenIds.delete(seenIds.values().next().value) // evict oldest
613
710
  seenIds.add(event.id)
614
711
  }
615
- if (event.created_at > (lastSeenAt ?? 0)) lastSeenAt = event.created_at
616
712
  enqueue({ type: 'event', event, relay: url })
617
713
  }
618
714
 
@@ -625,13 +721,12 @@ export class RelayPool {
625
721
  signal?.addEventListener('abort', abort, { once: true })
626
722
  _stopSignal?.addEventListener('abort', stop, { once: true })
627
723
 
628
- if (timeout !== null) readyTimeout = maybeUnref(setTimeout(() => finishReady('timeout'), timeout))
629
-
630
724
  const maybeFinishInitialReady = () => {
631
725
  if (initialPending.size === 0) finishReady()
632
726
  }
633
727
 
634
728
  const markInitialEose = (url) => {
729
+ clearTimeout(readyTimeouts.get(url))
635
730
  readyRelays.add(url)
636
731
  if (isReady || !initialPending.delete(url)) return
637
732
  initialOutcomes.set(url, { relay: url, status: 'eose' })
@@ -645,13 +740,15 @@ export class RelayPool {
645
740
  if (isDone) return
646
741
  enqueue({ type: 'error', relay: url, error })
647
742
  if (!isReady && initialPending.delete(url)) {
743
+ clearTimeout(readyTimeouts.get(url))
648
744
  initialOutcomes.set(url, { relay: url, status: 'error', error })
649
745
  maybeFinishInitialReady()
650
746
  }
651
747
  }
652
- const reportClosed = url => {
748
+ const reportClosed = (url, error) => {
653
749
  if (!isReady && initialPending.delete(url)) {
654
- initialOutcomes.set(url, { relay: url, status: 'closed' })
750
+ clearTimeout(readyTimeouts.get(url))
751
+ initialOutcomes.set(url, { relay: url, status: 'closed', error })
655
752
  maybeFinishInitialReady()
656
753
  }
657
754
  }
@@ -661,7 +758,7 @@ export class RelayPool {
661
758
  const nextDelay = Math.min(reconnectDelay * 2, 5 * 60_000)
662
759
  const timer = maybeUnref(setTimeout(() => {
663
760
  retryTimers.delete(url)
664
- subscribeToRelay(url, lastSeenAt, nextDelay)
761
+ subscribeToRelay(url, pendingGaps.get(url) ?? lastSeenAt.get(url) ?? openedAt.get(url) ?? null, nextDelay)
665
762
  }, reconnectDelay))
666
763
  retryTimers.set(url, timer)
667
764
  }
@@ -674,81 +771,143 @@ export class RelayPool {
674
771
 
675
772
  // Runs a reconnect gap fill for a single relay and returns a promise that resolves
676
773
  // when it completes. now is shared with the live sub so both use the same boundary.
677
- const runReconnectGapFill = (url, gapSince, now) => {
774
+ const runReconnectGapFill = (url, gapSince, now, slots, attempt) => {
678
775
  const gapUntil = filterUntil !== null ? Math.min(now, filterUntil) : now
679
- const gapFilter = { ...baseFilter, since: gapSince, until: gapUntil }
776
+ const gapFilter = { ...baseFilter, since: overlappingSince(gapSince), until: gapUntil }
680
777
  const gapGen = _gapEventsGenerator(gapFilter, [url], {
778
+ _admissions: new Map([[url, Promise.resolve(slots)]]),
779
+ queueTimeout,
681
780
  timeout: timeoutForReconnectGap,
682
781
  timeoutAfterFirstEose: timeoutAfterFirstReconnectGapEose,
683
782
  signal,
684
- _stopSignal: gapAc.signal
783
+ _stopSignal: AbortSignal.any([gapAc.signal, attempt.stop.signal])
685
784
  })
686
785
  return (async () => {
786
+ let completed = false
787
+ let failed = false
687
788
  for await (const item of gapGen) {
688
- if (item?.type === 'event') pushEvent(item.event, url, true)
689
- else if (item?.type === 'error' && !isDone) enqueue(item)
789
+ if (item?.type === 'event') {
790
+ observeEvent(item.event, url)
791
+ pushEvent(item.event, url, true)
792
+ } else if (item?.type === 'error') {
793
+ failed = true
794
+ if (!isDone) enqueue(item)
795
+ } else if (item?.type === 'eose') {
796
+ completed = item.relays?.length === 1 && ['eose', 'satisfied'].includes(item.relays[0].status)
797
+ }
690
798
  }
799
+ return completed && !failed
691
800
  })().catch(err => {
692
801
  reportFailure(url, asError(err))
693
- })
802
+ return false
803
+ }).finally(() => slots.history.release())
694
804
  }
695
805
 
696
806
  const subscribeToRelay = (url, gapSince, reconnectDelay = 1000) => {
697
- const now = Math.floor(Date.now() / 1000)
807
+ let now = Math.floor(Date.now() / 1000)
698
808
  // Don't reconnect if we're past the until boundary
699
809
  if (filterUntil !== null && now >= filterUntil) return
700
- this.#getRelay(url).then(relay => {
701
- if (isDone) return
810
+ const reservation = !initialAdmissions.has(url) && _admissions?.get(url)
811
+ const recovering = gapSince !== null
812
+ if (recovering) pendingGaps.set(url, gapSince)
813
+ const attempt = { url, epoch: ++nextEpoch, since: null, lastProgress: -Infinity, recovered: !recovering, timer: null, closed: false, stop: new AbortController() }
814
+ attempts.set(url, attempt)
815
+ let recoveryLease
816
+ initialAdmissions.add(url)
817
+ Promise.resolve(reservation || this.#admission.acquire(normalizeRelayUrl(url), { live: true, history: recovering, signal: gapAc.signal, queueTimeout })).then(async slots => {
818
+ const lease = slots.live
819
+ recoveryLease = recovering ? slots.history : null
820
+ if (isDone) { lease.release(); recoveryLease?.release(); return }
821
+ liveLeases.set(url, lease)
822
+ if (timeout !== null && initialPending.has(url)) {
823
+ readyTimeouts.set(url, maybeUnref(setTimeout(() => {
824
+ if (!initialPending.delete(url)) return
825
+ const error = getEventsTimeoutError()
826
+ initialOutcomes.set(url, { relay: url, status: 'timeout', error })
827
+ enqueue({ type: 'error', relay: url, error })
828
+ maybeFinishInitialReady()
829
+ }, timeout)))
830
+ }
831
+ const relay = await this.#getRelay(url)
832
+ if (isDone) { recoveryLease?.release(); return }
833
+ now = Math.floor(Date.now() / 1000)
834
+ if (filterUntil !== null && now >= filterUntil) { lease.release(); recoveryLease?.release(); return }
702
835
 
703
836
  // Buffer post-EOSE live events while a reconnect gap fill is running so
704
837
  // the historical gap is yielded before newer socket events.
705
- let liveBuffer = (gapSince !== null && gapSince > 0) ? [] : null
838
+ let liveBuffer = recovering ? [] : null
706
839
  let liveEose = false
840
+ let bufferedBytes = 0
707
841
 
708
842
  // Open the live sub first so the relay starts buffering incoming events
709
843
  // before we scan its database for the reconnect gap fill.
710
844
  // Forward until to the relay so it can enforce the boundary server-side.
711
- const liveFilter = { ...baseFilter, since: now, limit: 0 }
845
+ if (!openedAt.has(url)) openedAt.set(url, now)
846
+ const liveFilter = { ...baseFilter, since: overlappingSince(now), limit: 0 }
712
847
  if (filterUntil !== null) liveFilter.until = filterUntil
713
848
  const liveSub = relay.subscribe([liveFilter], {
714
849
  onevent: (event) => {
715
850
  // A limit:0 relay may still send retained events before EOSE. Do not
716
851
  // expose them from a strictly-live stream.
717
- if (isDone || liveSubs.get(url) !== liveSub || !liveEose) return
718
- if (liveBuffer) liveBuffer.push(event)
719
- else pushEvent(event, url)
852
+ if (isDone || liveSubs.get(url) !== liveSub || attempt.closed || !liveEose) return
853
+ observeEvent(event, url)
854
+ if (liveBuffer) {
855
+ const bytes = encoder.encode(JSON.stringify(event)).byteLength
856
+ if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
857
+ overflow(url)
858
+ return
859
+ }
860
+ bufferedBytes += bytes
861
+ liveBuffer.push(event)
862
+ } else pushEvent(event, url)
720
863
  },
721
864
  onclose: error => {
865
+ if (attempt.closed || attempts.get(url) !== attempt) return
866
+ attempt.closed = true
867
+ clearTimeout(attempt.timer)
868
+ attempt.stop.abort()
722
869
  if (liveSubs.get(url) === liveSub) liveSubs.delete(url)
723
- else if (liveSubs.has(url)) return
870
+ lease.release()
871
+ if (liveLeases.get(url) === lease) liveLeases.delete(url)
724
872
  readyRelays.delete(url)
725
- if (!isDone) {
726
- if (error !== undefined) reportFailure(url, asError(error))
727
- else if (!liveEose) reportClosed(url)
728
- }
729
873
  if (isDone) return
874
+ if (error !== undefined) reportFailure(url, asError(error))
875
+ else {
876
+ const interruption = Object.assign(categorizeRelayError(new Error('RELAY_LIVE_INTERRUPTED'), 'transport'), { code: 'RELAY_LIVE_INTERRUPTED' })
877
+ enqueue({ type: 'error', relay: url, error: interruption })
878
+ reportClosed(url, interruption)
879
+ }
730
880
  scheduleReconnect(url, reconnectDelay)
731
881
  },
732
882
  oneose: () => {
733
- if (isDone || (liveSubs.has(url) && liveSubs.get(url) !== liveSub)) return
883
+ if (isDone || attempt.closed || attempts.get(url) !== attempt || liveEose) return
734
884
  liveEose = true
885
+ attempt.since = Math.max(sinceFloor, Math.floor(Date.now() / 1000))
735
886
  markInitialEose(url)
887
+ scheduleProgress(attempt)
736
888
  }
737
889
  })
738
- if (isDone) { liveSub.close(); return }
890
+ if (isDone) { liveSub.close(); recoveryLease?.release(); return }
739
891
  liveSubs.set(url, liveSub)
740
892
 
741
- if (gapSince !== null && gapSince > 0) {
742
- const task = runReconnectGapFill(url, gapSince, now).finally(() => {
893
+ if (recovering) {
894
+ const task = runReconnectGapFill(url, gapSince, now, slots, attempt).then(recovered => {
895
+ attempt.recovered = recovered
896
+ if (recovered && !attempt.closed && attempts.get(url) === attempt) pendingGaps.delete(url)
897
+ }).finally(() => {
743
898
  const buf = liveBuffer
744
899
  liveBuffer = null
745
900
  for (const event of buf) pushEvent(event, url, true)
746
901
  gapTasks.delete(task)
902
+ scheduleProgress(attempt)
747
903
  p.resolve()
748
904
  })
749
905
  gapTasks.add(task)
750
906
  }
751
907
  }).catch(err => {
908
+ recoveryLease?.release()
909
+ liveLeases.get(url)?.release()
910
+ liveLeases.delete(url)
752
911
  readyRelays.delete(url)
753
912
  const reason = err instanceof Error ? err : new Error(String(err))
754
913
  reportFailure(url, reason)
@@ -759,7 +918,10 @@ export class RelayPool {
759
918
 
760
919
  if (!urls.length) {
761
920
  finishReady()
762
- try { yield queue.shift() } finally {
921
+ try {
922
+ if (failure) throw failure
923
+ yield shift()
924
+ } finally {
763
925
  teardown()
764
926
  signal?.removeEventListener('abort', abort)
765
927
  _stopSignal?.removeEventListener('abort', stop)
@@ -776,9 +938,10 @@ export class RelayPool {
776
938
  // eslint-disable-next-line no-unmodified-loop-condition
777
939
  while (!isDone || (draining && gapTasks.size > 0) || queue.length > 0) {
778
940
  if (signal?.aborted) break
779
- if (queue.length > 0) yield queue.shift()
941
+ if (queue.length > 0) yield shift()
780
942
  else { await p.promise; p = Promise.withResolvers() }
781
943
  }
944
+ if (failure) throw failure
782
945
  } finally {
783
946
  signal?.removeEventListener('abort', abort)
784
947
  _stopSignal?.removeEventListener('abort', stop)
@@ -789,8 +952,8 @@ export class RelayPool {
789
952
 
790
953
  // All-in-one event feed generator. For live:true, handles the full sequence:
791
954
  //
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
955
+ // - live:true (default): unless filter.limit === 0, reserves live+history capacity,
956
+ // starts live input, then runs an initial one-shot fetch of stored events
794
957
  // concurrently, yields stored events first, then flushes buffered live events (deduped
795
958
  // against stored ones), then yields live events indefinitely. With limit:0 the relay
796
959
  // sends no stored events, so the fetch is skipped and only the live sub runs.
@@ -798,9 +961,13 @@ export class RelayPool {
798
961
  // short-circuits after the fastest relay with events EOSEs, or waits for all
799
962
  // relays when null.
800
963
  //
964
+ // snapshot:true captures a historical until after initial live readiness;
965
+ // that bound is reported in the history marker and never stops live input.
801
966
  // All underlying generators are injectable for testing.
802
967
  getEventsFeedGenerator (filter, relays, options = {}) {
803
- assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose })
968
+ if (options.snapshot !== undefined && typeof options.snapshot !== 'boolean') throw new ValidationError('INVALID_RELAY_SNAPSHOT')
969
+ assertBufferOptions(options)
970
+ assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose, queueTimeout: options.queueTimeout === undefined ? 30000 : options.queueTimeout })
804
971
  return drainableStream(options => this.#getEventsFeedGenerator(filter, relays, options), options)
805
972
  }
806
973
 
@@ -808,95 +975,138 @@ export class RelayPool {
808
975
  signal,
809
976
  _stopSignal,
810
977
  live = true,
978
+ snapshot = false,
811
979
  timeout = 5000,
812
980
  timeoutAfterFirstEose = 500,
981
+ queueTimeout = 30000,
982
+ maxBufferedLiveEvents = 1000,
983
+ maxBufferedLiveBytes = 8 * 1024 * 1024,
813
984
  _liveGenerator = (...args) => this.getLiveEventsGenerator(...args),
814
985
  _eventsGenerator = (...args) => this.getEventsGenerator(...args)
815
986
  } = {}) {
816
987
  if (signal.aborted || _stopSignal.aborted) return
988
+ const options = { timeout, timeoutAfterFirstEose, queueTimeout, signal, _stopSignal }
817
989
  if (!live) {
818
- const gen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
819
- for await (const item of gen) {
990
+ const bounds = { since: filter.since ?? 0, until: Math.min(filter.until ?? Infinity, Math.floor(Date.now() / 1000)) }
991
+ for await (const item of _eventsGenerator(snapshot ? { ...filter, ...bounds } : filter, relays, options)) {
820
992
  if (signal.aborted) return
821
- yield item
993
+ yield snapshot && item.type === 'eose' ? { ...item, snapshot: bounds } : item
822
994
  }
823
995
  return
824
996
  }
825
997
 
826
- // limit:0 means "no stored events, live only" — skip the initial fetch.
998
+ // limit:0 is a live-only stream and reports that stream's initial window.
827
999
  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
- }
1000
+ yield * _liveGenerator(filter, relays, { ...options, maxBufferedLiveEvents, maxBufferedLiveBytes })
832
1001
  return
833
1002
  }
834
1003
 
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 })
1004
+ const urls = normalizedRelayUrls(relays)
1005
+ const inputAbort = new AbortController()
1006
+ const inputSignal = AbortSignal.any([signal, inputAbort.signal])
1007
+ const admissionSignal = AbortSignal.any([inputSignal, _stopSignal])
1008
+ const reservations = new Map()
1009
+ const leases = new Set()
1010
+ for (const url of urls) {
1011
+ const reservation = this.#admission.acquire(normalizeRelayUrl(url), { history: true, live: true, signal: admissionSignal, queueTimeout }).then(slots => {
1012
+ for (const lease of Object.values(slots)) {
1013
+ leases.add(lease)
1014
+ if (admissionSignal.aborted) lease.release()
1015
+ }
1016
+ return slots
1017
+ })
1018
+ // Both readers consume the same attempt, including admission failures.
1019
+ reservation.catch(() => {})
1020
+ reservations.set(url, reservation)
1021
+ }
1022
+ const readOptions = { ...options, signal: inputSignal, _admissions: reservations, maxBufferedLiveEvents, maxBufferedLiveBytes }
1023
+ const liveFilter = { ...filter }
1024
+ if (snapshot) delete liveFilter.until
1025
+ const liveGen = _liveGenerator(liveFilter, urls, readOptions)
840
1026
  const liveBuffer = []
1027
+ const encoder = new TextEncoder()
1028
+ let bufferedBytes = 0
841
1029
  let liveDone = false
842
1030
  let liveFailure
843
1031
  let liveWake = Promise.withResolvers()
844
-
1032
+ const initialReady = Promise.withResolvers()
1033
+ const shift = () => {
1034
+ const entry = liveBuffer.shift()
1035
+ bufferedBytes -= entry.bytes
1036
+ return entry.item
1037
+ }
845
1038
  const bgLoop = (async () => {
846
1039
  try {
847
- for await (const event of liveGen) {
848
- if (signal.aborted) break
849
- if (event.type !== 'eose') liveBuffer.push(event)
1040
+ for await (const item of liveGen) {
1041
+ if (inputSignal.aborted) break
1042
+ if (item.type === 'eose') initialReady.resolve()
1043
+ else {
1044
+ const bytes = encoder.encode(JSON.stringify(item)).byteLength
1045
+ if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
1046
+ throw Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), { code: 'RELAY_LIVE_BUFFER_FULL', relay: item.relay, phase: 'live-buffer' })
1047
+ }
1048
+ liveBuffer.push({ item, bytes })
1049
+ bufferedBytes += bytes
1050
+ }
850
1051
  liveWake.resolve()
851
1052
  liveWake = Promise.withResolvers()
852
1053
  }
853
1054
  } catch (error) {
854
1055
  liveFailure = error
1056
+ if (error.code === 'RELAY_LIVE_BUFFER_FULL') inputAbort.abort()
855
1057
  } finally {
856
1058
  liveDone = true
1059
+ initialReady.resolve()
857
1060
  liveWake.resolve()
858
1061
  }
859
1062
  })()
860
1063
 
861
1064
  try {
862
- // Yield stored events from the initial one-shot fetch
863
- const fetchGen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
864
-
1065
+ if (snapshot) await initialReady.promise
1066
+ if (liveFailure?.code === 'RELAY_LIVE_BUFFER_FULL') throw liveFailure
1067
+ if (signal.aborted) return
1068
+ const bounds = { since: filter.since ?? 0, until: Math.min(filter.until ?? Infinity, Math.floor(Date.now() / 1000)) }
1069
+ const historyFilter = snapshot ? { ...filter, ...bounds } : filter
1070
+ const fetchGen = _eventsGenerator(historyFilter, urls, readOptions)
865
1071
  const seenIds = new Set()
866
1072
  for await (const item of fetchGen) {
1073
+ if (liveFailure?.code === 'RELAY_LIVE_BUFFER_FULL') throw liveFailure
867
1074
  if (signal.aborted) return
868
1075
  if (item?.type === 'event' && !seenIds.has(item.event.id)) {
869
1076
  seenIds.add(item.event.id)
870
1077
  yield item
871
- } else if (item?.type !== 'event') yield item
1078
+ } else if (item?.type !== 'event') {
1079
+ yield snapshot && item.type === 'eose' ? { ...item, snapshot: bounds } : item
1080
+ }
872
1081
  }
1082
+ if (liveFailure?.code === 'RELAY_LIVE_BUFFER_FULL') throw liveFailure
873
1083
 
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
1084
  while (liveBuffer.length > 0) {
877
1085
  if (signal.aborted) return
878
- const item = liveBuffer.shift()
1086
+ const item = shift()
879
1087
  if (item.type !== 'event' || !seenIds.has(item.event.id)) {
880
1088
  if (item.type === 'event') seenIds.add(item.event.id)
881
1089
  yield item
882
1090
  }
883
1091
  }
884
-
885
1092
  seenIds.clear()
886
1093
 
887
- // Yield subsequent live events directly — no more overlap with stored events
888
1094
  // eslint-disable-next-line no-unmodified-loop-condition
889
1095
  while (!liveDone || liveBuffer.length > 0) {
1096
+ if (liveFailure?.code === 'RELAY_LIVE_BUFFER_FULL') throw liveFailure
890
1097
  while (liveBuffer.length > 0) {
891
1098
  if (signal.aborted) return
892
- yield liveBuffer.shift()
1099
+ yield shift()
893
1100
  }
894
1101
  if (!liveDone) await liveWake.promise
895
1102
  }
896
1103
  if (liveFailure) throw liveFailure
897
1104
  } finally {
1105
+ inputAbort.abort()
898
1106
  await liveGen.return()
899
1107
  await bgLoop
1108
+ liveBuffer.length = 0
1109
+ for (const lease of leases) lease.release()
900
1110
  }
901
1111
  }
902
1112