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.
Files changed (63) hide show
  1. package/AGENTS.md +20 -0
  2. package/README.md +129 -0
  3. package/base16/index.js +14 -0
  4. package/base64/index.js +29 -0
  5. package/content-key/event/index.js +85 -0
  6. package/content-key/index.js +1 -0
  7. package/content-key/services/iykc-proof.js +129 -0
  8. package/double-dh/index.js +166 -0
  9. package/ecdh/index.js +8 -0
  10. package/idb/index.js +64 -0
  11. package/idb-queue/index.js +793 -0
  12. package/index.js +21 -0
  13. package/key/index.js +139 -0
  14. package/network/index.js +68 -0
  15. package/nip44-v3/index.js +175 -0
  16. package/nip46/constants/index.js +5 -0
  17. package/nip46/helpers/frame.js +51 -0
  18. package/nip46/helpers/url.js +96 -0
  19. package/nip46/index.js +5 -0
  20. package/nip46/services/bunker-signer.js +49 -0
  21. package/nip46/services/client.js +252 -0
  22. package/nip46/services/server-session.js +225 -0
  23. package/nip46/services/transport.js +265 -0
  24. package/package.json +49 -0
  25. package/private-channel/constants/index.js +5 -0
  26. package/private-channel/helpers/chunk-size.js +61 -0
  27. package/private-channel/helpers/chunks.js +282 -0
  28. package/private-channel/helpers/event.js +68 -0
  29. package/private-channel/index.js +942 -0
  30. package/private-channel/services/received-chunks.js +398 -0
  31. package/private-message/index.js +672 -0
  32. package/private-messenger/constants/index.js +1 -0
  33. package/private-messenger/index.js +1431 -0
  34. package/private-messenger/recovery/index.js +316 -0
  35. package/relay/constants/index.js +18 -0
  36. package/relay/helpers/hll.js +62 -0
  37. package/relay/helpers/publish.js +100 -0
  38. package/relay/helpers/routing.js +36 -0
  39. package/relay/helpers/timer.js +4 -0
  40. package/relay/index.js +4 -0
  41. package/relay/services/query.js +224 -0
  42. package/relay/services/relay-connection.js +156 -0
  43. package/relay/services/relay-pool.js +908 -0
  44. package/temporary-storage/index.js +85 -0
  45. package/tests/base16.test.js +19 -0
  46. package/tests/base64.test.js +18 -0
  47. package/tests/content-key-event.test.js +48 -0
  48. package/tests/fixtures/nip44v3-vectors.json +1 -0
  49. package/tests/helpers/test-signer.js +185 -0
  50. package/tests/idb-queue.test.js +153 -0
  51. package/tests/idb.test.js +91 -0
  52. package/tests/nip44-v3.test.js +73 -0
  53. package/tests/nip46.test.js +668 -0
  54. package/tests/private-channel.test.js +1899 -0
  55. package/tests/private-message.test.js +460 -0
  56. package/tests/private-messenger.test.js +1715 -0
  57. package/tests/queries.test.js +101 -0
  58. package/tests/queue-parity.test.js +105 -0
  59. package/tests/relay-hll.test.js +32 -0
  60. package/tests/relay-pool.test.js +2067 -0
  61. package/tests/temporary-storage.test.js +89 -0
  62. package/tests/web-storage-queue.test.js +480 -0
  63. 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()