libp2r2p 0.0.1
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/AGENTS.md +20 -0
- package/README.md +129 -0
- package/base16/index.js +14 -0
- package/base64/index.js +29 -0
- package/content-key/event/index.js +85 -0
- package/content-key/index.js +1 -0
- package/content-key/services/iykc-proof.js +129 -0
- package/double-dh/index.js +166 -0
- package/ecdh/index.js +8 -0
- package/idb/index.js +64 -0
- package/idb-queue/index.js +793 -0
- package/index.js +21 -0
- package/key/index.js +139 -0
- package/network/index.js +68 -0
- package/nip44-v3/index.js +175 -0
- package/nip46/constants/index.js +5 -0
- package/nip46/helpers/frame.js +51 -0
- package/nip46/helpers/url.js +96 -0
- package/nip46/index.js +5 -0
- package/nip46/services/bunker-signer.js +49 -0
- package/nip46/services/client.js +252 -0
- package/nip46/services/server-session.js +225 -0
- package/nip46/services/transport.js +265 -0
- package/package.json +49 -0
- package/private-channel/constants/index.js +5 -0
- package/private-channel/helpers/chunk-size.js +61 -0
- package/private-channel/helpers/chunks.js +282 -0
- package/private-channel/helpers/event.js +68 -0
- package/private-channel/index.js +942 -0
- package/private-channel/services/received-chunks.js +398 -0
- package/private-message/index.js +672 -0
- package/private-messenger/constants/index.js +1 -0
- package/private-messenger/index.js +1431 -0
- package/private-messenger/recovery/index.js +316 -0
- package/relay/constants/index.js +18 -0
- package/relay/helpers/hll.js +62 -0
- package/relay/helpers/publish.js +100 -0
- package/relay/helpers/routing.js +36 -0
- package/relay/helpers/timer.js +4 -0
- package/relay/index.js +4 -0
- package/relay/services/query.js +224 -0
- package/relay/services/relay-connection.js +156 -0
- package/relay/services/relay-pool.js +908 -0
- package/temporary-storage/index.js +85 -0
- package/tests/base16.test.js +19 -0
- package/tests/base64.test.js +18 -0
- package/tests/content-key-event.test.js +48 -0
- package/tests/fixtures/nip44v3-vectors.json +1 -0
- package/tests/helpers/test-signer.js +185 -0
- package/tests/idb-queue.test.js +153 -0
- package/tests/idb.test.js +91 -0
- package/tests/nip44-v3.test.js +73 -0
- package/tests/nip46.test.js +668 -0
- package/tests/private-channel.test.js +1899 -0
- package/tests/private-message.test.js +460 -0
- package/tests/private-messenger.test.js +1715 -0
- package/tests/queries.test.js +101 -0
- package/tests/queue-parity.test.js +105 -0
- package/tests/relay-hll.test.js +32 -0
- package/tests/relay-pool.test.js +2067 -0
- package/tests/temporary-storage.test.js +89 -0
- package/tests/web-storage-queue.test.js +480 -0
- package/web-storage-queue/index.js +652 -0
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
import { decodeHll, encodeHll, estimateHllCount, mergeHll } from '../helpers/hll.js'
|
|
2
|
+
import { createPublishSettlements, firstFulfillment, publishSummary } from '../helpers/publish.js'
|
|
3
|
+
import { maybeUnref } from '../helpers/timer.js'
|
|
4
|
+
import { normalizeURL } from 'nostr-tools/utils'
|
|
5
|
+
import { RelayConnection } from './relay-connection.js'
|
|
6
|
+
|
|
7
|
+
const CONNECTION_TIMEOUT_MS = 3000
|
|
8
|
+
const COUNT_TIMEOUT_MS = 5000
|
|
9
|
+
const COUNT_TIMEOUT_AFTER_FIRST_COUNT_MS = 500
|
|
10
|
+
const SEND_TIMEOUT_UNTIL_FIRST_FULFILLMENT_MS = 3000
|
|
11
|
+
const SEND_TIMEOUT_MS = 30000
|
|
12
|
+
|
|
13
|
+
// Returns a function that should be called for each received event (valid or invalid).
|
|
14
|
+
// Calls onSatisfied() and stops counting once the filter is fully satisfied per relay:
|
|
15
|
+
// - limit: close after that many events have been received (counting invalid ones too,
|
|
16
|
+
// since the relay counts them toward its own limit)
|
|
17
|
+
// - ids: close once all requested ids have been seen
|
|
18
|
+
// Both conditions are independent; whichever triggers first wins.
|
|
19
|
+
function makeEarlyCloseChecker (filter, onSatisfied) {
|
|
20
|
+
let count = 0
|
|
21
|
+
const remainingIds = (filter.ids?.length > 0) ? new Set(filter.ids) : null
|
|
22
|
+
const limit = filter.limit > 0 ? filter.limit : null
|
|
23
|
+
let satisfied = false
|
|
24
|
+
|
|
25
|
+
return (event) => {
|
|
26
|
+
if (satisfied) return
|
|
27
|
+
count++
|
|
28
|
+
if (remainingIds && event?.id) remainingIds.delete(event.id)
|
|
29
|
+
if ((limit !== null && count >= limit) || (remainingIds !== null && remainingIds.size === 0)) {
|
|
30
|
+
satisfied = true
|
|
31
|
+
onSatisfied()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function relayResultForSettlement (relay, settlement) {
|
|
37
|
+
if (settlement.status === 'fulfilled') {
|
|
38
|
+
return {
|
|
39
|
+
relay,
|
|
40
|
+
success: true,
|
|
41
|
+
outcome: settlement.value || 'published'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
relay,
|
|
47
|
+
success: false,
|
|
48
|
+
outcome: settlement.outcome || 'failed',
|
|
49
|
+
reason: settlement.reason
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function notifyRelayResult (onRelayResult, result) {
|
|
54
|
+
if (!onRelayResult) return
|
|
55
|
+
try {
|
|
56
|
+
Promise.resolve(onRelayResult(result)).catch(error => {
|
|
57
|
+
console.error('RelayPool onRelayResult failed:', error)
|
|
58
|
+
})
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error('RelayPool onRelayResult failed:', error)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function requiresNip42Auth (reason) {
|
|
65
|
+
return reason.message.startsWith('auth-required:') || reason.message.startsWith('restricted:')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function countResponseError () {
|
|
69
|
+
return new Error('INVALID_COUNT_RESPONSE')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function countTimeoutError () {
|
|
73
|
+
return new Error('COUNT_TIMEOUT')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getEventsTimeoutError () {
|
|
77
|
+
return new Error('GET_EVENTS_TIMEOUT')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizedRelayUrls (relays) {
|
|
81
|
+
const urls = []
|
|
82
|
+
const seen = new Set()
|
|
83
|
+
|
|
84
|
+
for (const relay of relays || []) {
|
|
85
|
+
const normalizedUrl = normalizeURL(relay)
|
|
86
|
+
if (seen.has(normalizedUrl)) continue
|
|
87
|
+
seen.add(normalizedUrl)
|
|
88
|
+
// Keep the caller's first spelling in reports and metadata while using the
|
|
89
|
+
// canonical spelling for pooled connection ownership.
|
|
90
|
+
urls.push(relay)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return urls
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isCountResponse (payload) {
|
|
97
|
+
return Number.isSafeInteger(payload?.count) && payload.count >= 0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class Nip42AuthenticationError extends Error {
|
|
101
|
+
constructor (reason) {
|
|
102
|
+
super(reason.message, { cause: reason })
|
|
103
|
+
this.name = 'Nip42AuthenticationError'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Interacts with Nostr relays
|
|
108
|
+
export class RelayPool {
|
|
109
|
+
#relays = new Map()
|
|
110
|
+
#relayTimeouts = new Map()
|
|
111
|
+
#liveSubCounts = new Map() // url -> number of active live subscriptions
|
|
112
|
+
#timeout = 30000 // 30 seconds
|
|
113
|
+
|
|
114
|
+
#scheduleIdleDisconnect (url) {
|
|
115
|
+
clearTimeout(this.#relayTimeouts.get(url))
|
|
116
|
+
this.#relayTimeouts.set(url, maybeUnref(setTimeout(() => this.disconnect(url), this.#timeout)))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Opens a normalized pooled connection. Failed connects are evicted so a later
|
|
120
|
+
// retry creates a fresh RelayConnection instead of reusing broken socket state.
|
|
121
|
+
async #getRelay (url) {
|
|
122
|
+
const normalizedUrl = normalizeURL(url)
|
|
123
|
+
let relay = this.#relays.get(normalizedUrl)
|
|
124
|
+
if (!relay) {
|
|
125
|
+
relay = new RelayConnection(normalizedUrl)
|
|
126
|
+
this.#relays.set(normalizedUrl, relay)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
// nostr-tools cancels its socket attempt when this deadline elapses.
|
|
131
|
+
await relay.connect({ timeout: CONNECTION_TIMEOUT_MS })
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (this.#relays.get(normalizedUrl) === relay) {
|
|
134
|
+
this.#relays.delete(normalizedUrl)
|
|
135
|
+
clearTimeout(this.#relayTimeouts.get(normalizedUrl))
|
|
136
|
+
this.#relayTimeouts.delete(normalizedUrl)
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
await relay.close()
|
|
140
|
+
} catch {}
|
|
141
|
+
throw error
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Only reset idle timeout when no live subscriptions are holding this relay open.
|
|
145
|
+
if (!this.#liveSubCounts.get(normalizedUrl)) this.#scheduleIdleDisconnect(normalizedUrl)
|
|
146
|
+
return relay
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#incrementLiveSub (url) {
|
|
150
|
+
const normalizedUrl = normalizeURL(url)
|
|
151
|
+
this.#liveSubCounts.set(normalizedUrl, (this.#liveSubCounts.get(normalizedUrl) ?? 0) + 1)
|
|
152
|
+
// Cancel any pending idle timeout — this relay must stay open
|
|
153
|
+
clearTimeout(this.#relayTimeouts.get(normalizedUrl))
|
|
154
|
+
this.#relayTimeouts.delete(normalizedUrl)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#decrementLiveSub (url) {
|
|
158
|
+
const normalizedUrl = normalizeURL(url)
|
|
159
|
+
const next = (this.#liveSubCounts.get(normalizedUrl) ?? 1) - 1
|
|
160
|
+
if (next <= 0) {
|
|
161
|
+
this.#liveSubCounts.delete(normalizedUrl)
|
|
162
|
+
// No more live subscriptions — start the idle timer if the relay is still pooled
|
|
163
|
+
if (this.#relays.has(normalizedUrl)) {
|
|
164
|
+
this.#scheduleIdleDisconnect(normalizedUrl)
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
this.#liveSubCounts.set(normalizedUrl, next)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Disconnect from a relay
|
|
172
|
+
async disconnect (url) {
|
|
173
|
+
const normalizedUrl = normalizeURL(url)
|
|
174
|
+
if (this.#relays.has(normalizedUrl)) {
|
|
175
|
+
const relay = this.#relays.get(normalizedUrl)
|
|
176
|
+
if (relay.ws?.readyState < 2) await relay.close()?.catch(console.log)
|
|
177
|
+
this.#relays.delete(normalizedUrl)
|
|
178
|
+
clearTimeout(this.#relayTimeouts.get(normalizedUrl))
|
|
179
|
+
this.#relayTimeouts.delete(normalizedUrl)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Disconnect from all relays
|
|
184
|
+
async disconnectAll () {
|
|
185
|
+
for (const url of [...this.#relays.keys()]) {
|
|
186
|
+
await this.disconnect(url)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// NIP-42 retries happen inside one relay attempt, so sendEvent still reports
|
|
191
|
+
// exactly one terminal outcome for each relay URL.
|
|
192
|
+
async #publishEvent (relay, event, getAuthEvent) {
|
|
193
|
+
try {
|
|
194
|
+
await relay.publish(event)
|
|
195
|
+
return 'published'
|
|
196
|
+
} catch (error) {
|
|
197
|
+
const reason = error instanceof Error ? error : new Error(String(error))
|
|
198
|
+
if (!getAuthEvent || !requiresNip42Auth(reason)) throw reason
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
await relay.authenticate(getAuthEvent)
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const authReason = error instanceof Error ? error : new Error(String(error))
|
|
204
|
+
throw new Nip42AuthenticationError(authReason)
|
|
205
|
+
}
|
|
206
|
+
await relay.publish(event)
|
|
207
|
+
return 'published'
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Collects COUNT replies only until they are useful: the first usable reply
|
|
212
|
+
// opens a short window for a higher count or a mergeable HLL from peers.
|
|
213
|
+
// null disables either timer: no grace waits for all relays or the deadline.
|
|
214
|
+
async countEvents (filter, relays, {
|
|
215
|
+
timeout = COUNT_TIMEOUT_MS,
|
|
216
|
+
timeoutAfterFirstCount = COUNT_TIMEOUT_AFTER_FIRST_COUNT_MS,
|
|
217
|
+
signal
|
|
218
|
+
} = {}) {
|
|
219
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
|
|
220
|
+
throw new Error('COUNT_FILTER_REQUIRED')
|
|
221
|
+
}
|
|
222
|
+
if (signal?.aborted) throw new Error('Aborted')
|
|
223
|
+
|
|
224
|
+
const urls = normalizedRelayUrls(relays)
|
|
225
|
+
if (!urls.length) {
|
|
226
|
+
return { count: null, approximate: false, errors: [], success: false }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const countController = new AbortController()
|
|
230
|
+
const pending = new Set(urls)
|
|
231
|
+
const errors = []
|
|
232
|
+
let count = null
|
|
233
|
+
let approximate = false
|
|
234
|
+
let registers = null
|
|
235
|
+
let isResolved = false
|
|
236
|
+
let graceTimer = null
|
|
237
|
+
let timeoutTimer = null
|
|
238
|
+
|
|
239
|
+
return await new Promise((resolve, reject) => {
|
|
240
|
+
const cleanup = () => {
|
|
241
|
+
clearTimeout(timeoutTimer)
|
|
242
|
+
clearTimeout(graceTimer)
|
|
243
|
+
signal?.removeEventListener('abort', onAbort)
|
|
244
|
+
countController.abort()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const finish = ({ timedOut = false, aborted = false } = {}) => {
|
|
248
|
+
if (isResolved) return
|
|
249
|
+
isResolved = true
|
|
250
|
+
|
|
251
|
+
if (timedOut) {
|
|
252
|
+
for (const relay of pending) errors.push({ relay, reason: countTimeoutError() })
|
|
253
|
+
}
|
|
254
|
+
cleanup()
|
|
255
|
+
if (aborted) {
|
|
256
|
+
reject(new Error('Aborted'))
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const result = {
|
|
261
|
+
count,
|
|
262
|
+
approximate,
|
|
263
|
+
errors,
|
|
264
|
+
success: count !== null
|
|
265
|
+
}
|
|
266
|
+
if (registers) {
|
|
267
|
+
result.hll = encodeHll(registers)
|
|
268
|
+
result.hllCount = estimateHllCount(registers)
|
|
269
|
+
}
|
|
270
|
+
resolve(result)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const onAbort = () => finish({ aborted: true })
|
|
274
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
275
|
+
if (timeout !== null) {
|
|
276
|
+
timeoutTimer = maybeUnref(setTimeout(() => finish({ timedOut: true }), timeout))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const settleRelay = (relay) => pending.delete(relay)
|
|
280
|
+
const finishIfComplete = () => {
|
|
281
|
+
if (pending.size === 0) finish()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const handleResponse = (relay, payload) => {
|
|
285
|
+
if (isResolved || !settleRelay(relay)) return
|
|
286
|
+
if (!isCountResponse(payload)) {
|
|
287
|
+
errors.push({ relay, reason: countResponseError() })
|
|
288
|
+
finishIfComplete()
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Prefer an exact count when equal relay counts disagree on approximate.
|
|
293
|
+
if (count === null || payload.count > count || (payload.count === count && approximate && payload.approximate !== true)) {
|
|
294
|
+
count = payload.count
|
|
295
|
+
approximate = payload.approximate === true
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const hll = decodeHll(payload.hll)
|
|
299
|
+
if (hll) {
|
|
300
|
+
if (!registers) registers = new Uint8Array(hll.length)
|
|
301
|
+
mergeHll(registers, hll)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (count !== null && timeoutAfterFirstCount !== null && !graceTimer && pending.size > 0) {
|
|
305
|
+
graceTimer = maybeUnref(setTimeout(finish, timeoutAfterFirstCount))
|
|
306
|
+
}
|
|
307
|
+
finishIfComplete()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const handleError = (relay, error) => {
|
|
311
|
+
if (isResolved || !settleRelay(relay)) return
|
|
312
|
+
const reason = error instanceof Error ? error : new Error(String(error))
|
|
313
|
+
errors.push({ relay, reason })
|
|
314
|
+
finishIfComplete()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const relay of urls) {
|
|
318
|
+
this.#getRelay(relay)
|
|
319
|
+
.then(async connection => {
|
|
320
|
+
if (isResolved) return null
|
|
321
|
+
return await connection.countWithHll([filter], { signal: countController.signal })
|
|
322
|
+
})
|
|
323
|
+
.then(
|
|
324
|
+
payload => { if (payload !== null) handleResponse(relay, payload) },
|
|
325
|
+
error => handleError(relay, error)
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Collects a one-shot relay read. The first EOSE with events opens a short
|
|
332
|
+
// grace window; null disables that window so callers wait for every relay or
|
|
333
|
+
// the operation deadline. Event ids are deduplicated across relay responses.
|
|
334
|
+
async getEvents (filter, relays, { timeout = 5000, timeoutAfterFirstEose = 500, callback, signal } = {}) {
|
|
335
|
+
const urls = normalizedRelayUrls(relays)
|
|
336
|
+
if (!urls.length) return { result: [], errors: [], success: false }
|
|
337
|
+
if (signal?.aborted) throw new Error('Aborted')
|
|
338
|
+
|
|
339
|
+
const subscriptions = new Map()
|
|
340
|
+
const pending = new Set(urls)
|
|
341
|
+
const normalCloseUrls = new Set()
|
|
342
|
+
const errors = []
|
|
343
|
+
const events = []
|
|
344
|
+
const eventIds = new Set()
|
|
345
|
+
let completed = 0
|
|
346
|
+
let isResolved = false
|
|
347
|
+
let eoseTimer = null
|
|
348
|
+
let timeoutTimer = null
|
|
349
|
+
|
|
350
|
+
return await new Promise((resolve, reject) => {
|
|
351
|
+
const closeSubscriptions = () => {
|
|
352
|
+
for (const sub of subscriptions.values()) sub.close()
|
|
353
|
+
subscriptions.clear()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const cleanup = () => {
|
|
357
|
+
clearTimeout(timeoutTimer)
|
|
358
|
+
clearTimeout(eoseTimer)
|
|
359
|
+
signal?.removeEventListener('abort', onAbort)
|
|
360
|
+
closeSubscriptions()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const finish = () => {
|
|
364
|
+
if (isResolved) return
|
|
365
|
+
isResolved = true
|
|
366
|
+
cleanup()
|
|
367
|
+
resolve({
|
|
368
|
+
result: events,
|
|
369
|
+
errors,
|
|
370
|
+
success: events.length > 0 || completed > 0
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const finishIfComplete = () => {
|
|
375
|
+
if (pending.size === 0) finish()
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const settleRelay = (url, reason) => {
|
|
379
|
+
if (isResolved || !pending.delete(url)) return
|
|
380
|
+
subscriptions.delete(url)
|
|
381
|
+
if (reason) {
|
|
382
|
+
errors.push({ reason, relay: url })
|
|
383
|
+
if (callback) callback({ type: 'error', error: reason, relay: url })
|
|
384
|
+
} else {
|
|
385
|
+
completed++
|
|
386
|
+
}
|
|
387
|
+
finishIfComplete()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const onAbort = () => {
|
|
391
|
+
if (isResolved) return
|
|
392
|
+
isResolved = true
|
|
393
|
+
cleanup()
|
|
394
|
+
reject(new Error('Aborted'))
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const timeoutPending = () => {
|
|
398
|
+
if (isResolved) return
|
|
399
|
+
for (const url of pending) {
|
|
400
|
+
errors.push({ reason: getEventsTimeoutError(), relay: url })
|
|
401
|
+
}
|
|
402
|
+
finish()
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
406
|
+
if (timeout !== null) timeoutTimer = maybeUnref(setTimeout(timeoutPending, timeout))
|
|
407
|
+
|
|
408
|
+
for (const url of urls) {
|
|
409
|
+
this.#getRelay(url).then(relay => {
|
|
410
|
+
if (isResolved || !pending.has(url)) return
|
|
411
|
+
let hasEvents = false
|
|
412
|
+
let sub
|
|
413
|
+
|
|
414
|
+
// Actual EOSE and filter satisfaction share the same graceful close path.
|
|
415
|
+
const handleEose = () => {
|
|
416
|
+
if (isResolved || !pending.has(url)) return
|
|
417
|
+
// nostr-tools reports "closed by caller" through onclose(). This is a
|
|
418
|
+
// successful local completion, not a relay failure.
|
|
419
|
+
normalCloseUrls.add(url)
|
|
420
|
+
sub.close()
|
|
421
|
+
if (hasEvents && timeoutAfterFirstEose !== null && !eoseTimer && !isResolved) {
|
|
422
|
+
eoseTimer = maybeUnref(setTimeout(finish, timeoutAfterFirstEose))
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const checkEarlyClose = makeEarlyCloseChecker(filter, handleEose)
|
|
427
|
+
sub = relay.subscribe([filter], {
|
|
428
|
+
onevent: (event) => {
|
|
429
|
+
if (isResolved || !pending.has(url)) return
|
|
430
|
+
hasEvents = true
|
|
431
|
+
// Keep filter-limit accounting per relay, but only expose one copy of
|
|
432
|
+
// a matching event when several relays return the same id.
|
|
433
|
+
if (!event?.id || !eventIds.has(event.id)) {
|
|
434
|
+
if (event?.id) eventIds.add(event.id)
|
|
435
|
+
event.meta = { relay: url }
|
|
436
|
+
events.push(event)
|
|
437
|
+
if (callback) callback({ type: 'event', event, relay: url })
|
|
438
|
+
}
|
|
439
|
+
checkEarlyClose(event)
|
|
440
|
+
},
|
|
441
|
+
oninvalidevent: () => {
|
|
442
|
+
if (!isResolved && pending.has(url)) checkEarlyClose()
|
|
443
|
+
},
|
|
444
|
+
onclose: error => {
|
|
445
|
+
const reason = normalCloseUrls.delete(url) || error === undefined
|
|
446
|
+
? null
|
|
447
|
+
: error instanceof Error ? error : new Error(String(error))
|
|
448
|
+
settleRelay(url, reason)
|
|
449
|
+
},
|
|
450
|
+
oneose: handleEose
|
|
451
|
+
})
|
|
452
|
+
if (isResolved || !pending.has(url)) sub.close()
|
|
453
|
+
else subscriptions.set(url, sub)
|
|
454
|
+
}).catch(error => {
|
|
455
|
+
const reason = error instanceof Error ? error : new Error(String(error))
|
|
456
|
+
settleRelay(url, reason)
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async * getEventsGenerator (filter, relays, options = {}) {
|
|
463
|
+
const queue = []
|
|
464
|
+
let p = Promise.withResolvers()
|
|
465
|
+
let isDone = false
|
|
466
|
+
|
|
467
|
+
const userCallback = options.callback
|
|
468
|
+
const callback = item => {
|
|
469
|
+
queue.push(item)
|
|
470
|
+
if (userCallback) userCallback(item)
|
|
471
|
+
p.resolve()
|
|
472
|
+
p = Promise.withResolvers()
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const methodPromise = this.getEvents(filter, relays, { ...options, callback })
|
|
476
|
+
.catch(err => { if (err?.message !== 'Aborted') console.error('Error in getEvents:', err) })
|
|
477
|
+
.finally(() => {
|
|
478
|
+
isDone = true
|
|
479
|
+
p.resolve()
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
// eslint-disable-next-line no-unmodified-loop-condition
|
|
483
|
+
while (!isDone || queue.length > 0) {
|
|
484
|
+
if (queue.length > 0) yield queue.shift()
|
|
485
|
+
else await p.promise
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return await methodPromise
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Returns a strictly-live stream. `ready` reports the first initial EOSE window,
|
|
492
|
+
// while `readyRelays` follows relays that are currently past their own EOSE.
|
|
493
|
+
getLiveEventsGenerator (filter, relays, options = {}) {
|
|
494
|
+
const ready = Promise.withResolvers()
|
|
495
|
+
const readyRelays = new Set()
|
|
496
|
+
const stream = this.#getLiveEventsGenerator(filter, relays, options, { ready, readyRelays })
|
|
497
|
+
|
|
498
|
+
Object.defineProperties(stream, {
|
|
499
|
+
ready: {
|
|
500
|
+
enumerable: false,
|
|
501
|
+
value: ready.promise
|
|
502
|
+
},
|
|
503
|
+
readyRelays: {
|
|
504
|
+
enumerable: false,
|
|
505
|
+
get: () => Object.freeze([...readyRelays])
|
|
506
|
+
}
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
return stream
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Each relay becomes live after its own EOSE. This prevents a slow peer from
|
|
513
|
+
// suppressing already-live events from another relay.
|
|
514
|
+
async * #getLiveEventsGenerator (filter, relays, {
|
|
515
|
+
signal,
|
|
516
|
+
timeoutAfterFirstEose = 500,
|
|
517
|
+
timeoutForReconnectGap = 5000,
|
|
518
|
+
timeoutAfterFirstReconnectGapEose = 500,
|
|
519
|
+
_gapEventsGenerator = (...args) => this.getEventsGenerator(...args)
|
|
520
|
+
} = {}, { ready, readyRelays }) {
|
|
521
|
+
const urls = normalizedRelayUrls(relays)
|
|
522
|
+
const queue = []
|
|
523
|
+
let p = Promise.withResolvers()
|
|
524
|
+
let isDone = false
|
|
525
|
+
const liveSubs = new Map() // url → live sub
|
|
526
|
+
const retryTimers = new Map()
|
|
527
|
+
const initialPending = new Set(urls)
|
|
528
|
+
const initialErrors = []
|
|
529
|
+
let readyTimer = null
|
|
530
|
+
let isReady = false
|
|
531
|
+
|
|
532
|
+
// Internal abort controller to cancel any in-flight reconnect gap fills on teardown
|
|
533
|
+
const gapAc = new AbortController()
|
|
534
|
+
|
|
535
|
+
// Strip time-range fields — we manage them internally
|
|
536
|
+
const baseFilter = { ...filter }
|
|
537
|
+
delete baseFilter.since
|
|
538
|
+
delete baseFilter.until
|
|
539
|
+
|
|
540
|
+
// Preserve until for forwarding to the live sub filter and the teardown timer
|
|
541
|
+
const filterUntil = filter.until > 0 ? filter.until : null
|
|
542
|
+
|
|
543
|
+
// lastSeenAt: the highest created_at received so far; used as since on reconnect gap fill
|
|
544
|
+
let lastSeenAt = (filter.since > 0) ? filter.since : null
|
|
545
|
+
|
|
546
|
+
// Bounded dedup set to handle overlap between reconnect gap fill and live sub.
|
|
547
|
+
const seenIds = new Set()
|
|
548
|
+
|
|
549
|
+
let untilTimer = null
|
|
550
|
+
const finishReady = () => {
|
|
551
|
+
if (isReady) return
|
|
552
|
+
isReady = true
|
|
553
|
+
clearTimeout(readyTimer)
|
|
554
|
+
ready.resolve(Object.freeze({
|
|
555
|
+
relays: Object.freeze([...readyRelays]),
|
|
556
|
+
errors: Object.freeze([...initialErrors])
|
|
557
|
+
}))
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const teardown = () => {
|
|
561
|
+
if (isDone) return
|
|
562
|
+
isDone = true
|
|
563
|
+
clearTimeout(untilTimer)
|
|
564
|
+
finishReady()
|
|
565
|
+
gapAc.abort()
|
|
566
|
+
for (const timer of retryTimers.values()) clearTimeout(timer)
|
|
567
|
+
retryTimers.clear()
|
|
568
|
+
liveSubs.forEach(sub => sub.close())
|
|
569
|
+
liveSubs.clear()
|
|
570
|
+
p.resolve()
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const pushEvent = (event, url) => {
|
|
574
|
+
if (isDone || (event.id && seenIds.has(event.id))) return
|
|
575
|
+
if (event.id) {
|
|
576
|
+
if (seenIds.size >= 500) seenIds.delete(seenIds.values().next().value) // evict oldest
|
|
577
|
+
seenIds.add(event.id)
|
|
578
|
+
}
|
|
579
|
+
if (event.created_at > (lastSeenAt ?? 0)) lastSeenAt = event.created_at
|
|
580
|
+
event.meta = { relay: url }
|
|
581
|
+
queue.push(event)
|
|
582
|
+
p.resolve()
|
|
583
|
+
p = Promise.withResolvers()
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (signal?.aborted) {
|
|
587
|
+
finishReady()
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
signal?.addEventListener('abort', teardown, { once: true })
|
|
591
|
+
|
|
592
|
+
const maybeFinishInitialReady = () => {
|
|
593
|
+
if (initialPending.size === 0) finishReady()
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const markInitialEose = (url) => {
|
|
597
|
+
readyRelays.add(url)
|
|
598
|
+
if (isReady) return
|
|
599
|
+
initialPending.delete(url)
|
|
600
|
+
if (timeoutAfterFirstEose !== null && !readyTimer) {
|
|
601
|
+
readyTimer = maybeUnref(setTimeout(finishReady, timeoutAfterFirstEose))
|
|
602
|
+
}
|
|
603
|
+
maybeFinishInitialReady()
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const markInitialError = (url, reason) => {
|
|
607
|
+
if (!initialPending.delete(url)) return
|
|
608
|
+
initialErrors.push({ relay: url, reason })
|
|
609
|
+
maybeFinishInitialReady()
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const scheduleReconnect = (url, reconnectDelay) => {
|
|
613
|
+
if (isDone || retryTimers.has(url)) return
|
|
614
|
+
const nextDelay = Math.min(reconnectDelay * 2, 5 * 60_000)
|
|
615
|
+
const timer = maybeUnref(setTimeout(() => {
|
|
616
|
+
retryTimers.delete(url)
|
|
617
|
+
subscribeToRelay(url, lastSeenAt, nextDelay)
|
|
618
|
+
}, reconnectDelay))
|
|
619
|
+
retryTimers.set(url, timer)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Schedule teardown when the wall clock reaches filter.until
|
|
623
|
+
if (filterUntil !== null) {
|
|
624
|
+
const msUntil = filterUntil * 1000 - Date.now()
|
|
625
|
+
untilTimer = maybeUnref(setTimeout(teardown, Math.max(0, msUntil)))
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Runs a reconnect gap fill for a single relay and returns a promise that resolves
|
|
629
|
+
// when it completes. now is shared with the live sub so both use the same boundary.
|
|
630
|
+
const runReconnectGapFill = (url, gapSince, now) => {
|
|
631
|
+
const gapUntil = filterUntil !== null ? Math.min(now, filterUntil) : now
|
|
632
|
+
const gapFilter = { ...baseFilter, since: gapSince, until: gapUntil }
|
|
633
|
+
const gapGen = _gapEventsGenerator(gapFilter, [url], {
|
|
634
|
+
timeout: timeoutForReconnectGap,
|
|
635
|
+
timeoutAfterFirstEose: timeoutAfterFirstReconnectGapEose,
|
|
636
|
+
signal: gapAc.signal
|
|
637
|
+
})
|
|
638
|
+
return (async () => {
|
|
639
|
+
for await (const item of gapGen) {
|
|
640
|
+
if (item?.type === 'event') pushEvent(item.event, url)
|
|
641
|
+
}
|
|
642
|
+
})().catch(err => {
|
|
643
|
+
if (!isDone) console.error(`Reconnect gap fill error for ${url}:`, err)
|
|
644
|
+
})
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const subscribeToRelay = (url, gapSince, reconnectDelay = 1000) => {
|
|
648
|
+
const now = Math.floor(Date.now() / 1000)
|
|
649
|
+
// Don't reconnect if we're past the until boundary
|
|
650
|
+
if (filterUntil !== null && now >= filterUntil) return
|
|
651
|
+
this.#getRelay(url).then(relay => {
|
|
652
|
+
if (isDone) return
|
|
653
|
+
|
|
654
|
+
// Buffer post-EOSE live events while a reconnect gap fill is running so
|
|
655
|
+
// the historical gap is yielded before newer socket events.
|
|
656
|
+
let liveBuffer = (gapSince !== null && gapSince > 0) ? [] : null
|
|
657
|
+
let liveEose = false
|
|
658
|
+
let liveSub
|
|
659
|
+
|
|
660
|
+
// Open the live sub first so the relay starts buffering incoming events
|
|
661
|
+
// before we scan its database for the reconnect gap fill.
|
|
662
|
+
// Forward until to the relay so it can enforce the boundary server-side.
|
|
663
|
+
const liveFilter = { ...baseFilter, since: now, limit: 0 }
|
|
664
|
+
if (filterUntil !== null) liveFilter.until = filterUntil
|
|
665
|
+
liveSub = relay.subscribe([liveFilter], {
|
|
666
|
+
onevent: (event) => {
|
|
667
|
+
// A limit:0 relay may still send retained events before EOSE. Do not
|
|
668
|
+
// expose them from a strictly-live stream.
|
|
669
|
+
if (!liveEose) return
|
|
670
|
+
if (liveBuffer) liveBuffer.push(event)
|
|
671
|
+
else pushEvent(event, url)
|
|
672
|
+
},
|
|
673
|
+
onclose: error => {
|
|
674
|
+
if (liveSubs.get(url) === liveSub) liveSubs.delete(url)
|
|
675
|
+
else if (liveSubs.has(url)) return
|
|
676
|
+
readyRelays.delete(url)
|
|
677
|
+
if (!liveEose && !isDone) {
|
|
678
|
+
const reason = error instanceof Error
|
|
679
|
+
? error
|
|
680
|
+
: new Error(error ? String(error) : 'LIVE_SUBSCRIPTION_CLOSED')
|
|
681
|
+
markInitialError(url, reason)
|
|
682
|
+
}
|
|
683
|
+
if (isDone) return
|
|
684
|
+
scheduleReconnect(url, reconnectDelay)
|
|
685
|
+
},
|
|
686
|
+
oneose: () => {
|
|
687
|
+
if (isDone || (liveSubs.has(url) && liveSubs.get(url) !== liveSub)) return
|
|
688
|
+
liveEose = true
|
|
689
|
+
markInitialEose(url)
|
|
690
|
+
}
|
|
691
|
+
})
|
|
692
|
+
if (isDone) { liveSub.close(); return }
|
|
693
|
+
liveSubs.set(url, liveSub)
|
|
694
|
+
|
|
695
|
+
if (gapSince !== null && gapSince > 0) {
|
|
696
|
+
runReconnectGapFill(url, gapSince, now).then(() => {
|
|
697
|
+
if (isDone) return
|
|
698
|
+
const buf = liveBuffer
|
|
699
|
+
liveBuffer = null
|
|
700
|
+
for (const event of buf) pushEvent(event, url)
|
|
701
|
+
})
|
|
702
|
+
}
|
|
703
|
+
}).catch(err => {
|
|
704
|
+
readyRelays.delete(url)
|
|
705
|
+
const reason = err instanceof Error ? err : new Error(String(err))
|
|
706
|
+
markInitialError(url, reason)
|
|
707
|
+
if (isDone) return
|
|
708
|
+
console.error(`Live subscription error at ${url}:`, reason)
|
|
709
|
+
scheduleReconnect(url, reconnectDelay)
|
|
710
|
+
})
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (!urls.length) {
|
|
714
|
+
finishReady()
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
for (const url of urls) {
|
|
719
|
+
this.#incrementLiveSub(url)
|
|
720
|
+
subscribeToRelay(url, null) // no initial gap fill — that's getEventsFeedGenerator's job
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
// eslint-disable-next-line no-unmodified-loop-condition
|
|
725
|
+
while (!isDone || queue.length > 0) {
|
|
726
|
+
if (queue.length > 0) yield queue.shift()
|
|
727
|
+
else await p.promise
|
|
728
|
+
}
|
|
729
|
+
} finally {
|
|
730
|
+
signal?.removeEventListener('abort', teardown)
|
|
731
|
+
for (const url of urls) this.#decrementLiveSub(url)
|
|
732
|
+
teardown()
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// All-in-one event feed generator. For live:true, handles the full sequence:
|
|
737
|
+
//
|
|
738
|
+
// - live:true (default): unless filter.limit === 0, starts the live sub immediately
|
|
739
|
+
// (so no incoming events are missed), runs an initial one-shot fetch of stored events
|
|
740
|
+
// concurrently, yields stored events first, then flushes buffered live events (deduped
|
|
741
|
+
// against stored ones), then yields live events indefinitely. With limit:0 the relay
|
|
742
|
+
// sends no stored events, so the fetch is skipped and only the live sub runs.
|
|
743
|
+
// - live:false: one-shot fetch via getEventsGenerator. timeoutAfterFirstEose
|
|
744
|
+
// short-circuits after the fastest relay with events EOSEs, or waits for all
|
|
745
|
+
// relays when null.
|
|
746
|
+
//
|
|
747
|
+
// All underlying generators are injectable for testing.
|
|
748
|
+
async * getEventsFeedGenerator (filter, relays, {
|
|
749
|
+
signal,
|
|
750
|
+
live = true,
|
|
751
|
+
timeout = 5000,
|
|
752
|
+
timeoutAfterFirstEose = 500,
|
|
753
|
+
_liveGenerator = (...args) => this.getLiveEventsGenerator(...args),
|
|
754
|
+
_eventsGenerator = (...args) => this.getEventsGenerator(...args)
|
|
755
|
+
} = {}) {
|
|
756
|
+
if (!live) {
|
|
757
|
+
const gen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal })
|
|
758
|
+
for await (const item of gen) {
|
|
759
|
+
if (item?.type === 'event') yield item.event
|
|
760
|
+
}
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// limit:0 means "no stored events, live only" — skip the initial fetch.
|
|
765
|
+
if (filter.limit === 0) {
|
|
766
|
+
for await (const event of _liveGenerator(filter, relays, { signal })) {
|
|
767
|
+
yield event
|
|
768
|
+
}
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Start live generator immediately so the relay opens the live sub and starts
|
|
773
|
+
// buffering incoming events before we query stored ones.
|
|
774
|
+
// Relays always send stored matching events before EOSE (unless limit:0),
|
|
775
|
+
// so the initial fetch + buffering is always needed.
|
|
776
|
+
const liveGen = _liveGenerator(filter, relays, { signal })
|
|
777
|
+
const liveBuffer = []
|
|
778
|
+
let liveDone = false
|
|
779
|
+
let liveWake = Promise.withResolvers()
|
|
780
|
+
|
|
781
|
+
const bgLoop = (async () => {
|
|
782
|
+
try {
|
|
783
|
+
for await (const event of liveGen) {
|
|
784
|
+
liveBuffer.push(event)
|
|
785
|
+
liveWake.resolve()
|
|
786
|
+
liveWake = Promise.withResolvers()
|
|
787
|
+
}
|
|
788
|
+
} finally {
|
|
789
|
+
liveDone = true
|
|
790
|
+
liveWake.resolve()
|
|
791
|
+
}
|
|
792
|
+
})()
|
|
793
|
+
|
|
794
|
+
try {
|
|
795
|
+
// Yield stored events from the initial one-shot fetch
|
|
796
|
+
const fetchGen = _eventsGenerator(filter, relays, { timeout, timeoutAfterFirstEose, signal })
|
|
797
|
+
|
|
798
|
+
const seenIds = new Set()
|
|
799
|
+
for await (const item of fetchGen) {
|
|
800
|
+
if (item?.type === 'event' && !seenIds.has(item.event.id)) {
|
|
801
|
+
seenIds.add(item.event.id)
|
|
802
|
+
yield item.event
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Flush buffered live events that arrived during the initial fetch, deduping
|
|
807
|
+
// against stored ones (overlap is possible around the fetch's until boundary)
|
|
808
|
+
while (liveBuffer.length > 0) {
|
|
809
|
+
const event = liveBuffer.shift()
|
|
810
|
+
if (!seenIds.has(event.id)) {
|
|
811
|
+
seenIds.add(event.id)
|
|
812
|
+
yield event
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Yield subsequent live events directly — no more overlap with stored events
|
|
817
|
+
// eslint-disable-next-line no-unmodified-loop-condition
|
|
818
|
+
while (!liveDone || liveBuffer.length > 0) {
|
|
819
|
+
while (liveBuffer.length > 0) yield liveBuffer.shift()
|
|
820
|
+
if (!liveDone) await liveWake.promise
|
|
821
|
+
}
|
|
822
|
+
} finally {
|
|
823
|
+
liveGen.return()
|
|
824
|
+
await bgLoop
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Returns after the first acknowledgement window. timeout is one deadline for
|
|
829
|
+
// the whole operation, while timeoutUntilFirstFulfillment controls only this
|
|
830
|
+
// initial return and closes pending reports when it fails. null disables either
|
|
831
|
+
// timer independently. onRelayResult receives one
|
|
832
|
+
// { relay, success, outcome, reason? } result per relay as it settles; outcome
|
|
833
|
+
// is published, duplicate, muted, failed, or timed-out. getAuthEvent is used
|
|
834
|
+
// only after auth-required or restricted publish failures, then retries once.
|
|
835
|
+
// Await `promise` for the complete report, including every relay outcome.
|
|
836
|
+
async sendEvent (event, relays, {
|
|
837
|
+
timeout = SEND_TIMEOUT_MS,
|
|
838
|
+
timeoutUntilFirstFulfillment = SEND_TIMEOUT_UNTIL_FIRST_FULFILLMENT_MS,
|
|
839
|
+
getAuthEvent,
|
|
840
|
+
onRelayResult
|
|
841
|
+
} = {}) {
|
|
842
|
+
const urls = normalizedRelayUrls(relays)
|
|
843
|
+
if (!urls.length) {
|
|
844
|
+
const promise = Promise.resolve(publishSummary([], urls, {
|
|
845
|
+
result: null,
|
|
846
|
+
includeSucceededRelays: true
|
|
847
|
+
}))
|
|
848
|
+
return { result: null, total: 0, success: false, promise }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const eventToSend = event.meta ? { ...event } : event
|
|
852
|
+
if (eventToSend.meta) delete eventToSend.meta
|
|
853
|
+
|
|
854
|
+
const sendDeferreds = urls.map(() => Promise.withResolvers())
|
|
855
|
+
const sendPromises = sendDeferreds.map(({ promise }) => promise)
|
|
856
|
+
|
|
857
|
+
// Starts before connection work so every relay shares one real deadline.
|
|
858
|
+
const settlement = createPublishSettlements(sendPromises, timeout, {
|
|
859
|
+
onSettled: (settlement, index) => {
|
|
860
|
+
notifyRelayResult(onRelayResult, relayResultForSettlement(urls[index], settlement))
|
|
861
|
+
}
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
// Resolves after every relay settles (or reaches the operation timeout) as
|
|
865
|
+
// { result: null, success, total, fulfilled, succeededRelays, errors },
|
|
866
|
+
// where errors contains { relay, reason } entries for failed relays.
|
|
867
|
+
const promise = settlement.promise
|
|
868
|
+
.then(settlements => publishSummary(settlements, urls, {
|
|
869
|
+
result: null,
|
|
870
|
+
includeSucceededRelays: true
|
|
871
|
+
}))
|
|
872
|
+
|
|
873
|
+
urls.forEach((url, index) => {
|
|
874
|
+
const deferred = sendDeferreds[index]
|
|
875
|
+
;(async () => {
|
|
876
|
+
try {
|
|
877
|
+
const relay = await this.#getRelay(url)
|
|
878
|
+
return await this.#publishEvent(relay, eventToSend, getAuthEvent)
|
|
879
|
+
} catch (err) {
|
|
880
|
+
const reason = err instanceof Error ? err : new Error(String(err))
|
|
881
|
+
if (reason instanceof Nip42AuthenticationError) throw reason
|
|
882
|
+
if (reason.message.startsWith('duplicate:')) return 'duplicate'
|
|
883
|
+
if (reason.message.startsWith('mute:')) {
|
|
884
|
+
console.info([url, reason.message].filter(Boolean).join(' - '))
|
|
885
|
+
return 'muted'
|
|
886
|
+
}
|
|
887
|
+
throw reason
|
|
888
|
+
}
|
|
889
|
+
})().then(deferred.resolve, deferred.reject)
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
const success = await firstFulfillment(sendPromises, timeoutUntilFirstFulfillment, {
|
|
893
|
+
fallback: promise.then(report => report.success)
|
|
894
|
+
})
|
|
895
|
+
if (!success) settlement.timeout()
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
result: null,
|
|
899
|
+
total: urls.length,
|
|
900
|
+
success,
|
|
901
|
+
promise
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// NIP-42 permits multiple pubkeys to authenticate on one connection, so callers
|
|
907
|
+
// can share a RelayPool without splitting connections by authenticated identity.
|
|
908
|
+
export const relayPool = new RelayPool()
|