libp2r2p 0.10.11 → 0.10.13
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 +49 -0
- package/package.json +1 -1
- package/relay/constants/index.js +3 -1
- package/relay/helpers/drainable-stream.js +17 -0
- package/relay/helpers/error.js +30 -0
- package/relay/helpers/publish.js +2 -1
- package/relay/services/relay-connection.js +28 -12
- package/relay/services/relay-pool.js +76 -34
package/README.md
CHANGED
|
@@ -12,6 +12,35 @@ For remote-work scheduling, see [`libp2r2p/network`](network/README.md):
|
|
|
12
12
|
`isOnline` probes connectivity and `onOnline` shares recovery monitoring,
|
|
13
13
|
including retries when the browser omits its native `online` event.
|
|
14
14
|
|
|
15
|
+
## Relay feed lifecycle
|
|
16
|
+
|
|
17
|
+
`RelayPool.getEventsFeedGenerator(filter, relays, options)` yields events directly,
|
|
18
|
+
including the existing `event.meta.relay` field. By default it combines an initial
|
|
19
|
+
historical query with a live subscription. `live: false` selects history only;
|
|
20
|
+
`filter.limit: 0` selects live only. `getLiveEventsGenerator` exposes the same
|
|
21
|
+
lifecycle controls along with its existing `ready` and `readyRelays` properties.
|
|
22
|
+
|
|
23
|
+
The returned iterator has a synchronous, idempotent `stopAndDrain()` method. It
|
|
24
|
+
closes subscription input and cancels reconnections and outstanding historical
|
|
25
|
+
queries, while retaining events already accepted by the library's receive
|
|
26
|
+
callbacks. Continue consuming the iterator to obtain those events and observe
|
|
27
|
+
completion. This includes initial history, buffered live events, and both history
|
|
28
|
+
and live buffers from reconnect recovery. Calling it before the first `next()`
|
|
29
|
+
prevents subscriptions from opening. No reception timestamp or new event metadata
|
|
30
|
+
is added. Events rejected by ordinary filtering/deduplication remain excluded.
|
|
31
|
+
|
|
32
|
+
Aborting `options.signal`, calling `return()` (including a `for await` break), or
|
|
33
|
+
calling `throw()` cancels input and pending delivery instead. An event already
|
|
34
|
+
delivered to the caller cannot be recalled. These operations also interrupt a
|
|
35
|
+
drain. `stopAndDrain()` does not consume the iterator or return a completion
|
|
36
|
+
promise: completion is the iterator's `{ done: true }` result.
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
const stream = relayPool.getEventsFeedGenerator({ authors: [pubkey] }, [relay], { signal })
|
|
40
|
+
// When this relay is removed, call stream.stopAndDrain() from the list handler.
|
|
41
|
+
for await (const event of stream) await store(event)
|
|
42
|
+
```
|
|
43
|
+
|
|
15
44
|
## Private Messenger
|
|
16
45
|
|
|
17
46
|
The main API is `createPrivateMessenger` from `libp2r2p/private-messenger`.
|
|
@@ -364,6 +393,26 @@ Low-level relay sockets, subscriptions, message parsing, and serialization are
|
|
|
364
393
|
internal implementation details; use `RelayPool` or the `relayPool` singleton
|
|
365
394
|
from `libp2r2p/relay`.
|
|
366
395
|
|
|
396
|
+
`getEvents` and `getEventsGenerator` accept `deduplicateAcrossRelays` (boolean,
|
|
397
|
+
default `true`). With `false`, a matching event is delivered once per relay,
|
|
398
|
+
while repeated IDs from the same relay remain suppressed. Each occurrence owns
|
|
399
|
+
its `meta.relay`; callbacks still run immediately and deadlines, EOSE handling,
|
|
400
|
+
and per-relay filter limits are unchanged. The callback/generator item remains
|
|
401
|
+
`{ type: 'event', event, relay }`, and the completed query remains
|
|
402
|
+
`{ result, errors, success }`. The option does not extend to the live or feed
|
|
403
|
+
generators. Callers that need replication coverage can aggregate the returned
|
|
404
|
+
copies by event ID; missing responses do not prove absence from a relay.
|
|
405
|
+
|
|
406
|
+
Publication errors retain their existing `reason` objects and may expose
|
|
407
|
+
`category`: `connection` (WebSocket establishment), `transport` (socket send or
|
|
408
|
+
close), `relay` (an explicit negative `OK`), or `timeout` (missing confirmation).
|
|
409
|
+
Native messages, codes, nested causes and aggregate errors remain available;
|
|
410
|
+
WebSocket closure details use `closeCode`, `closeReason`, and `wasClean` rather
|
|
411
|
+
than overwriting a native `code`. A timeout can retain a preceding socket error
|
|
412
|
+
as its cause without claiming that the relay rejected the event. Authentication
|
|
413
|
+
wrappers preserve this context. Local failures can remain uncategorized.
|
|
414
|
+
Event metadata is internal and is removed by `sendEvent` before serialization.
|
|
415
|
+
|
|
367
416
|
The same public subpath exports `getRelaysByPubkey(pubkeys)`, which discovers
|
|
368
417
|
the latest NIP-65 relay list for every requested pubkey through `seedRelays`,
|
|
369
418
|
normalizes and deduplicates its public relay URLs, and falls back to the first
|
package/package.json
CHANGED
package/relay/constants/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Used only to discover users' NIP-65 relay lists (kind:10002).
|
|
2
2
|
export const seedRelays = [
|
|
3
3
|
'wss://relay.44billion.net',
|
|
4
|
-
|
|
4
|
+
// Disabled 2026-09-08: offline for some days
|
|
5
|
+
// 'wss://purplepag.es',
|
|
5
6
|
'wss://user.kindpag.es',
|
|
6
7
|
'wss://relay.nos.social',
|
|
7
8
|
// Disabled 2026-08-05: accepted kind:10002 with OK but did not broadcast it
|
|
@@ -15,6 +16,7 @@ export const seedRelays = [
|
|
|
15
16
|
export const freeRelays = [
|
|
16
17
|
'wss://relay.44billion.net',
|
|
17
18
|
'wss://nos.lol',
|
|
19
|
+
'wss://relay.dreamith.to',
|
|
18
20
|
'wss://relay.primal.net'
|
|
19
21
|
]
|
|
20
22
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Stopping closes network input but keeps accepted events available to next().
|
|
2
|
+
// Returning/throwing, or aborting the caller signal, cancels consumption as well.
|
|
3
|
+
export function drainableStream (create, options = {}) {
|
|
4
|
+
const cancel = new AbortController()
|
|
5
|
+
const stop = new AbortController()
|
|
6
|
+
const signal = options.signal ? AbortSignal.any([options.signal, cancel.signal]) : cancel.signal
|
|
7
|
+
const stopSignal = options._stopSignal ? AbortSignal.any([options._stopSignal, stop.signal]) : stop.signal
|
|
8
|
+
const stream = create({ ...options, signal, _stopSignal: stopSignal })
|
|
9
|
+
const returnStream = stream.return.bind(stream)
|
|
10
|
+
const throwStream = stream.throw.bind(stream)
|
|
11
|
+
Object.defineProperties(stream, {
|
|
12
|
+
stopAndDrain: { value: () => stop.abort() },
|
|
13
|
+
return: { value: value => { cancel.abort(); return returnStream(value) } },
|
|
14
|
+
throw: { value: error => { cancel.abort(); return throwStream(error) } }
|
|
15
|
+
})
|
|
16
|
+
return stream
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Adds transport context without replacing native messages, codes or causes.
|
|
2
|
+
export function categorizeRelayError (reason, category, fallback = 'RELAY_OPERATION_FAILED') {
|
|
3
|
+
const error = reason instanceof Error ? reason : new Error(String(reason || fallback))
|
|
4
|
+
try {
|
|
5
|
+
Object.defineProperty(error, 'category', { value: category, enumerable: true, configurable: true })
|
|
6
|
+
return error
|
|
7
|
+
} catch {
|
|
8
|
+
const wrapped = error instanceof AggregateError
|
|
9
|
+
? new AggregateError(error.errors, error.message, { cause: error })
|
|
10
|
+
: new Error(error.message, { cause: error })
|
|
11
|
+
wrapped.name = error.name
|
|
12
|
+
if (error.code !== undefined) wrapped.code = error.code
|
|
13
|
+
wrapped.category = category
|
|
14
|
+
return wrapped
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A timeout indicates missing confirmation, even when a socket error preceded it.
|
|
19
|
+
export function relayTimeoutError (message, cause) {
|
|
20
|
+
return categorizeRelayError(new Error(message, cause ? { cause } : undefined), 'timeout')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// WebSocket close codes are distinct from native system error codes.
|
|
24
|
+
export function relayCloseError (event, category, cause) {
|
|
25
|
+
const error = new Error(event?.reason || 'CONNECTION_CLOSED', cause ? { cause } : undefined)
|
|
26
|
+
if (event?.code !== undefined) error.closeCode = event.code
|
|
27
|
+
if (event?.reason !== undefined) error.closeReason = event.reason
|
|
28
|
+
if (event?.wasClean !== undefined) error.wasClean = event.wasClean
|
|
29
|
+
return categorizeRelayError(error, category)
|
|
30
|
+
}
|
package/relay/helpers/publish.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { maybeUnref } from './timer.js'
|
|
2
|
+
import { relayTimeoutError } from './error.js'
|
|
2
3
|
|
|
3
4
|
function publishTimeoutError () {
|
|
4
|
-
return
|
|
5
|
+
return relayTimeoutError('PUBLISH_TIMEOUT')
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
// Resolves once any relay accepts the event, all relays reject, an optional
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isValidEvent } from '../../event/index.js'
|
|
2
2
|
import { ValidationError } from '../../error/index.js'
|
|
3
3
|
import { maybeUnref } from '../helpers/timer.js'
|
|
4
|
+
import { categorizeRelayError, relayCloseError, relayTimeoutError } from '../helpers/error.js'
|
|
4
5
|
|
|
5
6
|
const DEFAULT_CONNECT_TIMEOUT = 3000
|
|
6
7
|
const DEFAULT_OPERATION_TIMEOUT = 30000
|
|
@@ -44,6 +45,7 @@ export class RelayConnection {
|
|
|
44
45
|
#connectPromise = null
|
|
45
46
|
#challenge = null
|
|
46
47
|
#serial = 0
|
|
48
|
+
#lastTransportError = null
|
|
47
49
|
#subscriptions = new Map()
|
|
48
50
|
#publishes = new Map()
|
|
49
51
|
#authentications = new Map()
|
|
@@ -60,14 +62,22 @@ export class RelayConnection {
|
|
|
60
62
|
this.onauth = null
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
// Exposes socket context to the pool's operation-wide publication deadline.
|
|
66
|
+
get lastTransportError () { return this.#lastTransportError }
|
|
67
|
+
|
|
63
68
|
async connect ({ timeout = DEFAULT_CONNECT_TIMEOUT, signal } = {}) {
|
|
64
69
|
if (this.ws?.readyState === 1) return
|
|
65
70
|
if (this.#connectPromise) return await this.#connectPromise
|
|
66
71
|
if (signal?.aborted) throw new Error('CONNECT_ABORTED')
|
|
67
|
-
if (typeof this.#WebSocket !== 'function') throw new Error('WEBSOCKET_UNAVAILABLE')
|
|
72
|
+
if (typeof this.#WebSocket !== 'function') throw categorizeRelayError(new Error('WEBSOCKET_UNAVAILABLE'), 'connection')
|
|
68
73
|
|
|
74
|
+
this.#lastTransportError = null
|
|
69
75
|
this.#connectPromise = new Promise((resolve, reject) => {
|
|
70
|
-
|
|
76
|
+
let socket
|
|
77
|
+
try { socket = new this.#WebSocket(this.url) } catch (error) {
|
|
78
|
+
reject(categorizeRelayError(error, 'connection'))
|
|
79
|
+
return
|
|
80
|
+
}
|
|
71
81
|
this.ws = socket
|
|
72
82
|
let settled = false
|
|
73
83
|
const finish = (reason) => {
|
|
@@ -81,18 +91,21 @@ export class RelayConnection {
|
|
|
81
91
|
} else resolve()
|
|
82
92
|
}
|
|
83
93
|
const onAbort = () => finish(new Error('CONNECT_ABORTED'))
|
|
84
|
-
const timer = timeout === null ? null : maybeUnref(setTimeout(() => finish(
|
|
94
|
+
const timer = timeout === null ? null : maybeUnref(setTimeout(() => finish(relayTimeoutError('CONNECT_TIMEOUT')), timeout))
|
|
85
95
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
86
96
|
|
|
87
97
|
socket.onopen = () => finish()
|
|
88
98
|
socket.onerror = event => {
|
|
89
|
-
const reason =
|
|
99
|
+
const reason = categorizeRelayError(event?.error, settled ? 'transport' : 'connection', 'CONNECTION_ERROR')
|
|
90
100
|
if (!settled) finish(reason)
|
|
91
|
-
else
|
|
101
|
+
else {
|
|
102
|
+
this.#lastTransportError = reason
|
|
103
|
+
this.onerror?.(reason)
|
|
104
|
+
}
|
|
92
105
|
}
|
|
93
106
|
socket.onmessage = event => { this.#handleMessage(event).catch(reason => this.onerror?.(reason)) }
|
|
94
107
|
socket.onclose = event => {
|
|
95
|
-
if (!settled) finish(
|
|
108
|
+
if (!settled) finish(relayCloseError(event, 'connection'))
|
|
96
109
|
if (this.ws === socket) this.ws = null
|
|
97
110
|
this.#handleClose(event)
|
|
98
111
|
}
|
|
@@ -101,8 +114,11 @@ export class RelayConnection {
|
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
send (message) {
|
|
104
|
-
if (this.ws?.readyState !== 1) throw
|
|
105
|
-
this.ws.send(message)
|
|
117
|
+
if (this.ws?.readyState !== 1) throw relayCloseError(null, 'transport', this.#lastTransportError)
|
|
118
|
+
try { this.ws.send(message) } catch (error) {
|
|
119
|
+
this.#lastTransportError = categorizeRelayError(error, 'transport')
|
|
120
|
+
throw this.#lastTransportError
|
|
121
|
+
}
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
subscribe (filters, handlers = {}) {
|
|
@@ -141,7 +157,7 @@ export class RelayConnection {
|
|
|
141
157
|
#sendEventOperation (type, event, map, timeoutCode) {
|
|
142
158
|
if (map.has(event.id)) return map.get(event.id).promise
|
|
143
159
|
const deferred = Promise.withResolvers()
|
|
144
|
-
const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id,
|
|
160
|
+
const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, relayTimeoutError(timeoutCode, this.#lastTransportError)), this.publishTimeout))
|
|
145
161
|
map.set(event.id, { ...deferred, timer, promise: deferred.promise })
|
|
146
162
|
try { this.send(JSON.stringify([type, event])) } catch (error) {
|
|
147
163
|
this.#settleEvent(map, event.id, error)
|
|
@@ -210,7 +226,7 @@ export class RelayConnection {
|
|
|
210
226
|
return
|
|
211
227
|
}
|
|
212
228
|
if (data[0] === 'OK') {
|
|
213
|
-
const reason = data[2] === true ? null :
|
|
229
|
+
const reason = data[2] === true ? null : categorizeRelayError(data[3], 'relay', 'EVENT_REJECTED')
|
|
214
230
|
this.#settleEvent(this.#publishes, data[1], reason, data[3])
|
|
215
231
|
this.#settleEvent(this.#authentications, data[1], reason, data[3])
|
|
216
232
|
return
|
|
@@ -229,7 +245,7 @@ export class RelayConnection {
|
|
|
229
245
|
|
|
230
246
|
#handleClose (event) {
|
|
231
247
|
this.#challenge = null
|
|
232
|
-
const reason =
|
|
248
|
+
const reason = relayCloseError(event, 'transport', this.#lastTransportError)
|
|
233
249
|
for (const [id, subscription] of this.#subscriptions) {
|
|
234
250
|
this.#subscriptions.delete(id)
|
|
235
251
|
subscription.handlers.onclose?.(reason)
|
|
@@ -244,7 +260,7 @@ export class RelayConnection {
|
|
|
244
260
|
const socket = this.ws
|
|
245
261
|
this.ws = null
|
|
246
262
|
this.#challenge = null
|
|
247
|
-
const reason =
|
|
263
|
+
const reason = relayCloseError(null, 'transport', this.#lastTransportError)
|
|
248
264
|
for (const [id, subscription] of this.#subscriptions) {
|
|
249
265
|
this.#subscriptions.delete(id)
|
|
250
266
|
subscription.handlers.onclose?.()
|
|
@@ -1,7 +1,9 @@
|
|
|
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 { drainableStream } from '../helpers/drainable-stream.js'
|
|
4
5
|
import { maybeUnref } from '../helpers/timer.js'
|
|
6
|
+
import { categorizeRelayError } from '../helpers/error.js'
|
|
5
7
|
import { normalizeRelayUrl } from '../../url/index.js'
|
|
6
8
|
import { RelayConnection } from './relay-connection.js'
|
|
7
9
|
|
|
@@ -102,6 +104,8 @@ class Nip42AuthenticationError extends Error {
|
|
|
102
104
|
constructor (reason) {
|
|
103
105
|
super(reason.message, { cause: reason })
|
|
104
106
|
this.name = 'Nip42AuthenticationError'
|
|
107
|
+
if (reason.category) this.category = reason.category
|
|
108
|
+
if (reason.code !== undefined) this.code = reason.code
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
|
|
@@ -143,7 +147,7 @@ export class RelayPool {
|
|
|
143
147
|
try {
|
|
144
148
|
await relay.close()
|
|
145
149
|
} catch {}
|
|
146
|
-
throw error
|
|
150
|
+
throw categorizeRelayError(error, error?.category ?? 'connection')
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
// Only reset idle timeout when no live subscriptions are holding this relay open.
|
|
@@ -335,8 +339,10 @@ export class RelayPool {
|
|
|
335
339
|
|
|
336
340
|
// Collects a one-shot relay read. The first EOSE with events opens a short
|
|
337
341
|
// grace window; null disables that window so callers wait for every relay or
|
|
338
|
-
// the operation deadline.
|
|
339
|
-
|
|
342
|
+
// the operation deadline. Disabling cross-relay deduplication still suppresses
|
|
343
|
+
// repeated ids from the same relay; callbacks remain immediate in both modes.
|
|
344
|
+
async getEvents (filter, relays, { timeout = 5000, timeoutAfterFirstEose = 500, callback, signal, deduplicateAcrossRelays = true } = {}) {
|
|
345
|
+
if (typeof deduplicateAcrossRelays !== 'boolean') throw new ValidationError('INVALID_DEDUPLICATE_ACROSS_RELAYS')
|
|
340
346
|
const urls = normalizedRelayUrls(relays)
|
|
341
347
|
if (!urls.length) return { result: [], errors: [], success: false }
|
|
342
348
|
if (signal?.aborted) throw new Error('Aborted')
|
|
@@ -346,7 +352,7 @@ export class RelayPool {
|
|
|
346
352
|
const normalCloseUrls = new Set()
|
|
347
353
|
const errors = []
|
|
348
354
|
const events = []
|
|
349
|
-
const eventIds = new Set()
|
|
355
|
+
const eventIds = deduplicateAcrossRelays ? new Set() : null
|
|
350
356
|
let completed = 0
|
|
351
357
|
let isResolved = false
|
|
352
358
|
let eoseTimer = null
|
|
@@ -411,6 +417,7 @@ export class RelayPool {
|
|
|
411
417
|
if (timeout !== null) timeoutTimer = maybeUnref(setTimeout(timeoutPending, timeout))
|
|
412
418
|
|
|
413
419
|
for (const url of urls) {
|
|
420
|
+
const seenIds = eventIds ?? new Set()
|
|
414
421
|
this.#getRelay(url).then(relay => {
|
|
415
422
|
if (isResolved || !pending.has(url)) return
|
|
416
423
|
let hasEvents = false
|
|
@@ -432,10 +439,8 @@ export class RelayPool {
|
|
|
432
439
|
onevent: (event) => {
|
|
433
440
|
if (isResolved || !pending.has(url)) return
|
|
434
441
|
hasEvents = true
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (!event?.id || !eventIds.has(event.id)) {
|
|
438
|
-
if (event?.id) eventIds.add(event.id)
|
|
442
|
+
if (!event?.id || !seenIds.has(event.id)) {
|
|
443
|
+
if (event?.id) seenIds.add(event.id)
|
|
439
444
|
event.meta = { relay: url }
|
|
440
445
|
events.push(event)
|
|
441
446
|
if (callback) callback({ type: 'event', event, relay: url })
|
|
@@ -476,7 +481,11 @@ export class RelayPool {
|
|
|
476
481
|
p = Promise.withResolvers()
|
|
477
482
|
}
|
|
478
483
|
|
|
479
|
-
|
|
484
|
+
// A drain stops receive callbacks without aborting consumption of this queue.
|
|
485
|
+
const networkSignal = options._stopSignal
|
|
486
|
+
? AbortSignal.any([options._stopSignal, ...(options.signal ? [options.signal] : [])])
|
|
487
|
+
: options.signal
|
|
488
|
+
const methodPromise = this.getEvents(filter, relays, { ...options, signal: networkSignal, callback })
|
|
480
489
|
.catch(err => { if (err?.message !== 'Aborted') console.error('Error in getEvents:', err) })
|
|
481
490
|
.finally(() => {
|
|
482
491
|
isDone = true
|
|
@@ -485,6 +494,7 @@ export class RelayPool {
|
|
|
485
494
|
|
|
486
495
|
// eslint-disable-next-line no-unmodified-loop-condition
|
|
487
496
|
while (!isDone || queue.length > 0) {
|
|
497
|
+
if (options.signal?.aborted) break
|
|
488
498
|
if (queue.length > 0) yield queue.shift()
|
|
489
499
|
else await p.promise
|
|
490
500
|
}
|
|
@@ -497,7 +507,7 @@ export class RelayPool {
|
|
|
497
507
|
getLiveEventsGenerator (filter, relays, options = {}) {
|
|
498
508
|
const ready = Promise.withResolvers()
|
|
499
509
|
const readyRelays = new Set()
|
|
500
|
-
const stream = this.#getLiveEventsGenerator(filter, relays, options, { ready, readyRelays })
|
|
510
|
+
const stream = drainableStream(options => this.#getLiveEventsGenerator(filter, relays, options, { ready, readyRelays }), options)
|
|
501
511
|
|
|
502
512
|
Object.defineProperties(stream, {
|
|
503
513
|
ready: {
|
|
@@ -517,6 +527,7 @@ export class RelayPool {
|
|
|
517
527
|
// suppressing already-live events from another relay.
|
|
518
528
|
async * #getLiveEventsGenerator (filter, relays, {
|
|
519
529
|
signal,
|
|
530
|
+
_stopSignal,
|
|
520
531
|
timeoutAfterFirstEose = 500,
|
|
521
532
|
timeoutForReconnectGap = 5000,
|
|
522
533
|
timeoutAfterFirstReconnectGapEose = 500,
|
|
@@ -526,6 +537,8 @@ export class RelayPool {
|
|
|
526
537
|
const queue = []
|
|
527
538
|
let p = Promise.withResolvers()
|
|
528
539
|
let isDone = false
|
|
540
|
+
let draining = false
|
|
541
|
+
const gapTasks = new Set()
|
|
529
542
|
const liveSubs = new Map() // url → live sub
|
|
530
543
|
const retryTimers = new Map()
|
|
531
544
|
const initialPending = new Set(urls)
|
|
@@ -533,7 +546,7 @@ export class RelayPool {
|
|
|
533
546
|
let readyTimer = null
|
|
534
547
|
let isReady = false
|
|
535
548
|
|
|
536
|
-
//
|
|
549
|
+
// Stop recovery input on teardown, preserving its accepted history for a drain.
|
|
537
550
|
const gapAc = new AbortController()
|
|
538
551
|
|
|
539
552
|
// Strip time-range fields — we manage them internally
|
|
@@ -561,9 +574,11 @@ export class RelayPool {
|
|
|
561
574
|
}))
|
|
562
575
|
}
|
|
563
576
|
|
|
564
|
-
const teardown = () => {
|
|
565
|
-
if (isDone) return
|
|
577
|
+
const teardown = (drain = false) => {
|
|
578
|
+
if (isDone && (drain || !draining)) return
|
|
579
|
+
draining = drain
|
|
566
580
|
isDone = true
|
|
581
|
+
if (!drain) queue.length = 0
|
|
567
582
|
clearTimeout(untilTimer)
|
|
568
583
|
finishReady()
|
|
569
584
|
gapAc.abort()
|
|
@@ -574,8 +589,9 @@ export class RelayPool {
|
|
|
574
589
|
p.resolve()
|
|
575
590
|
}
|
|
576
591
|
|
|
577
|
-
const pushEvent = (event, url) => {
|
|
578
|
-
|
|
592
|
+
const pushEvent = (event, url, accepted = false) => {
|
|
593
|
+
// Recovery queues accepted these events before input was stopped.
|
|
594
|
+
if ((isDone && !(draining && accepted)) || (event.id && seenIds.has(event.id))) return
|
|
579
595
|
if (event.id) {
|
|
580
596
|
if (seenIds.size >= 500) seenIds.delete(seenIds.values().next().value) // evict oldest
|
|
581
597
|
seenIds.add(event.id)
|
|
@@ -587,11 +603,14 @@ export class RelayPool {
|
|
|
587
603
|
p = Promise.withResolvers()
|
|
588
604
|
}
|
|
589
605
|
|
|
590
|
-
if (signal?.aborted) {
|
|
606
|
+
if (signal?.aborted || _stopSignal?.aborted) {
|
|
591
607
|
finishReady()
|
|
592
608
|
return
|
|
593
609
|
}
|
|
594
|
-
|
|
610
|
+
const abort = () => teardown()
|
|
611
|
+
const stop = () => teardown(true)
|
|
612
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
613
|
+
_stopSignal?.addEventListener('abort', stop, { once: true })
|
|
595
614
|
|
|
596
615
|
const maybeFinishInitialReady = () => {
|
|
597
616
|
if (initialPending.size === 0) finishReady()
|
|
@@ -626,7 +645,7 @@ export class RelayPool {
|
|
|
626
645
|
// Schedule teardown when the wall clock reaches filter.until
|
|
627
646
|
if (filterUntil !== null) {
|
|
628
647
|
const msUntil = filterUntil * 1000 - Date.now()
|
|
629
|
-
untilTimer = maybeUnref(setTimeout(teardown, Math.max(0, msUntil)))
|
|
648
|
+
untilTimer = maybeUnref(setTimeout(() => teardown(true), Math.max(0, msUntil)))
|
|
630
649
|
}
|
|
631
650
|
|
|
632
651
|
// Runs a reconnect gap fill for a single relay and returns a promise that resolves
|
|
@@ -637,11 +656,12 @@ export class RelayPool {
|
|
|
637
656
|
const gapGen = _gapEventsGenerator(gapFilter, [url], {
|
|
638
657
|
timeout: timeoutForReconnectGap,
|
|
639
658
|
timeoutAfterFirstEose: timeoutAfterFirstReconnectGapEose,
|
|
640
|
-
signal
|
|
659
|
+
signal,
|
|
660
|
+
_stopSignal: gapAc.signal
|
|
641
661
|
})
|
|
642
662
|
return (async () => {
|
|
643
663
|
for await (const item of gapGen) {
|
|
644
|
-
if (item?.type === 'event') pushEvent(item.event, url)
|
|
664
|
+
if (item?.type === 'event') pushEvent(item.event, url, true)
|
|
645
665
|
}
|
|
646
666
|
})().catch(err => {
|
|
647
667
|
if (!isDone) console.error(`Reconnect gap fill error for ${url}:`, err)
|
|
@@ -669,7 +689,7 @@ export class RelayPool {
|
|
|
669
689
|
onevent: (event) => {
|
|
670
690
|
// A limit:0 relay may still send retained events before EOSE. Do not
|
|
671
691
|
// expose them from a strictly-live stream.
|
|
672
|
-
if (!liveEose) return
|
|
692
|
+
if (isDone || liveSubs.get(url) !== liveSub || !liveEose) return
|
|
673
693
|
if (liveBuffer) liveBuffer.push(event)
|
|
674
694
|
else pushEvent(event, url)
|
|
675
695
|
},
|
|
@@ -696,12 +716,14 @@ export class RelayPool {
|
|
|
696
716
|
liveSubs.set(url, liveSub)
|
|
697
717
|
|
|
698
718
|
if (gapSince !== null && gapSince > 0) {
|
|
699
|
-
runReconnectGapFill(url, gapSince, now).
|
|
700
|
-
if (isDone) return
|
|
719
|
+
const task = runReconnectGapFill(url, gapSince, now).finally(() => {
|
|
701
720
|
const buf = liveBuffer
|
|
702
721
|
liveBuffer = null
|
|
703
|
-
for (const event of buf) pushEvent(event, url)
|
|
722
|
+
for (const event of buf) pushEvent(event, url, true)
|
|
723
|
+
gapTasks.delete(task)
|
|
724
|
+
p.resolve()
|
|
704
725
|
})
|
|
726
|
+
gapTasks.add(task)
|
|
705
727
|
}
|
|
706
728
|
}).catch(err => {
|
|
707
729
|
readyRelays.delete(url)
|
|
@@ -725,12 +747,14 @@ export class RelayPool {
|
|
|
725
747
|
|
|
726
748
|
try {
|
|
727
749
|
// eslint-disable-next-line no-unmodified-loop-condition
|
|
728
|
-
while (!isDone || queue.length > 0) {
|
|
750
|
+
while (!isDone || (draining && gapTasks.size > 0) || queue.length > 0) {
|
|
751
|
+
if (signal?.aborted) break
|
|
729
752
|
if (queue.length > 0) yield queue.shift()
|
|
730
|
-
else await p.promise
|
|
753
|
+
else { await p.promise; p = Promise.withResolvers() }
|
|
731
754
|
}
|
|
732
755
|
} finally {
|
|
733
|
-
signal?.removeEventListener('abort',
|
|
756
|
+
signal?.removeEventListener('abort', abort)
|
|
757
|
+
_stopSignal?.removeEventListener('abort', stop)
|
|
734
758
|
for (const url of urls) this.#decrementLiveSub(url)
|
|
735
759
|
teardown()
|
|
736
760
|
}
|
|
@@ -748,17 +772,24 @@ export class RelayPool {
|
|
|
748
772
|
// relays when null.
|
|
749
773
|
//
|
|
750
774
|
// All underlying generators are injectable for testing.
|
|
751
|
-
|
|
775
|
+
getEventsFeedGenerator (filter, relays, options = {}) {
|
|
776
|
+
return drainableStream(options => this.#getEventsFeedGenerator(filter, relays, options), options)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async * #getEventsFeedGenerator (filter, relays, {
|
|
752
780
|
signal,
|
|
781
|
+
_stopSignal,
|
|
753
782
|
live = true,
|
|
754
783
|
timeout = 5000,
|
|
755
784
|
timeoutAfterFirstEose = 500,
|
|
756
785
|
_liveGenerator = (...args) => this.getLiveEventsGenerator(...args),
|
|
757
786
|
_eventsGenerator = (...args) => this.getEventsGenerator(...args)
|
|
758
787
|
} = {}) {
|
|
788
|
+
if (signal.aborted || _stopSignal.aborted) return
|
|
759
789
|
if (!live) {
|
|
760
|
-
const gen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal })
|
|
790
|
+
const gen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
|
|
761
791
|
for await (const item of gen) {
|
|
792
|
+
if (signal.aborted) return
|
|
762
793
|
if (item?.type === 'event') yield item.event
|
|
763
794
|
}
|
|
764
795
|
return
|
|
@@ -766,7 +797,8 @@ export class RelayPool {
|
|
|
766
797
|
|
|
767
798
|
// limit:0 means "no stored events, live only" — skip the initial fetch.
|
|
768
799
|
if (filter.limit === 0) {
|
|
769
|
-
for await (const event of _liveGenerator(filter, relays, { signal })) {
|
|
800
|
+
for await (const event of _liveGenerator(filter, relays, { signal, _stopSignal })) {
|
|
801
|
+
if (signal.aborted) return
|
|
770
802
|
yield event
|
|
771
803
|
}
|
|
772
804
|
return
|
|
@@ -776,7 +808,7 @@ export class RelayPool {
|
|
|
776
808
|
// buffering incoming events before we query stored ones.
|
|
777
809
|
// Relays always send stored matching events before EOSE (unless limit:0),
|
|
778
810
|
// so the initial fetch + buffering is always needed.
|
|
779
|
-
const liveGen = _liveGenerator(filter, relays, { signal })
|
|
811
|
+
const liveGen = _liveGenerator(filter, relays, { signal, _stopSignal })
|
|
780
812
|
const liveBuffer = []
|
|
781
813
|
let liveDone = false
|
|
782
814
|
let liveWake = Promise.withResolvers()
|
|
@@ -784,6 +816,7 @@ export class RelayPool {
|
|
|
784
816
|
const bgLoop = (async () => {
|
|
785
817
|
try {
|
|
786
818
|
for await (const event of liveGen) {
|
|
819
|
+
if (signal.aborted) break
|
|
787
820
|
liveBuffer.push(event)
|
|
788
821
|
liveWake.resolve()
|
|
789
822
|
liveWake = Promise.withResolvers()
|
|
@@ -796,10 +829,11 @@ export class RelayPool {
|
|
|
796
829
|
|
|
797
830
|
try {
|
|
798
831
|
// Yield stored events from the initial one-shot fetch
|
|
799
|
-
const fetchGen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal })
|
|
832
|
+
const fetchGen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal, _stopSignal })
|
|
800
833
|
|
|
801
834
|
const seenIds = new Set()
|
|
802
835
|
for await (const item of fetchGen) {
|
|
836
|
+
if (signal.aborted) return
|
|
803
837
|
if (item?.type === 'event' && !seenIds.has(item.event.id)) {
|
|
804
838
|
seenIds.add(item.event.id)
|
|
805
839
|
yield item.event
|
|
@@ -809,6 +843,7 @@ export class RelayPool {
|
|
|
809
843
|
// Flush buffered live events that arrived during the initial fetch, deduping
|
|
810
844
|
// against stored ones (overlap is possible around the fetch's until boundary)
|
|
811
845
|
while (liveBuffer.length > 0) {
|
|
846
|
+
if (signal.aborted) return
|
|
812
847
|
const event = liveBuffer.shift()
|
|
813
848
|
if (!seenIds.has(event.id)) {
|
|
814
849
|
seenIds.add(event.id)
|
|
@@ -819,11 +854,14 @@ export class RelayPool {
|
|
|
819
854
|
// Yield subsequent live events directly — no more overlap with stored events
|
|
820
855
|
// eslint-disable-next-line no-unmodified-loop-condition
|
|
821
856
|
while (!liveDone || liveBuffer.length > 0) {
|
|
822
|
-
while (liveBuffer.length > 0)
|
|
857
|
+
while (liveBuffer.length > 0) {
|
|
858
|
+
if (signal.aborted) return
|
|
859
|
+
yield liveBuffer.shift()
|
|
860
|
+
}
|
|
823
861
|
if (!liveDone) await liveWake.promise
|
|
824
862
|
}
|
|
825
863
|
} finally {
|
|
826
|
-
liveGen.return()
|
|
864
|
+
await liveGen.return()
|
|
827
865
|
await bgLoop
|
|
828
866
|
}
|
|
829
867
|
}
|
|
@@ -859,6 +897,10 @@ export class RelayPool {
|
|
|
859
897
|
// Starts before connection work so every relay shares one real deadline.
|
|
860
898
|
const settlement = createPublishSettlements(sendPromises, timeout, {
|
|
861
899
|
onSettled: (settlement, index) => {
|
|
900
|
+
if (settlement.reason?.category === 'timeout' && !settlement.reason.cause) {
|
|
901
|
+
const relay = this.#relays.get(normalizeRelayUrl(urls[index]))
|
|
902
|
+
if (relay?.lastTransportError) settlement.reason.cause = relay.lastTransportError
|
|
903
|
+
}
|
|
862
904
|
notifyRelayResult(onRelayResult, relayResultForSettlement(urls[index], settlement))
|
|
863
905
|
}
|
|
864
906
|
})
|