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 +114 -2
- package/package.json +1 -1
- package/relay/helpers/read-admission.js +92 -0
- package/relay/services/relay-pool.js +288 -78
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
|
|
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
|
-
|
|
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
|
@@ -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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
563
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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')
|
|
689
|
-
|
|
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
|
-
|
|
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
|
-
|
|
701
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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 ||
|
|
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 (
|
|
742
|
-
const task = runReconnectGapFill(url, gapSince, now).
|
|
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 {
|
|
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
|
|
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,
|
|
793
|
-
//
|
|
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
|
-
|
|
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
|
|
819
|
-
for await (const item of
|
|
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
|
|
998
|
+
// limit:0 is a live-only stream and reports that stream's initial window.
|
|
827
999
|
if (filter.limit === 0) {
|
|
828
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
const
|
|
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
|
|
848
|
-
if (
|
|
849
|
-
if (
|
|
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
|
-
|
|
863
|
-
|
|
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')
|
|
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 =
|
|
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
|
|
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
|
|