libp2r2p 0.0.2 → 0.2.0

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.
@@ -1,2063 +0,0 @@
1
- import { describe, it, beforeEach, mock, test } from 'node:test'
2
- import assert from 'node:assert/strict'
3
-
4
- // ─── Fake Relay infrastructure ────────────────────────────────────────────────
5
-
6
- // Keyed by URL; populated by FakeRelay constructor, cleared in beforeEach.
7
- const relayRegistry = new Map()
8
-
9
- // Per-URL connect overrides: throw to simulate error or hang to simulate timeout.
10
- const connectOverrides = new Map()
11
-
12
- // Per-URL publish overrides: throw to simulate error or hang to simulate timeout.
13
- const publishOverrides = new Map()
14
-
15
- // Per-URL send overrides are used for control replies.
16
- const sendOverrides = new Map()
17
- let autoEoseForLiveSubscriptions = true
18
-
19
- function overrideFor (overrides, url) {
20
- return overrides.get(url) ?? overrides.get(url.endsWith('/') ? url.slice(0, -1) : `${url}/`)
21
- }
22
-
23
- class FakeRelay {
24
- constructor (url) {
25
- this.url = url
26
- this.subscriptions = []
27
- this.ws = { readyState: 1 }
28
- this.publishTimeout = 100
29
- relayRegistry.set(url, this)
30
- // RelayPool canonicalizes URLs; retain the test's terse lookup spelling.
31
- if (url.endsWith('/')) relayRegistry.set(url.slice(0, -1), this)
32
- }
33
-
34
- async connect (options) {
35
- this.connectOptions = options
36
- const fn = overrideFor(connectOverrides, this.url)
37
- if (fn) await fn()
38
- }
39
-
40
- subscribe (filters, handlers) {
41
- const sub = {
42
- filters,
43
- handlers,
44
- isClosed: false,
45
- close (reason = 'closed by caller') {
46
- if (this.isClosed) return
47
- this.isClosed = true
48
- handlers.onclose?.(reason)
49
- }
50
- }
51
- this.subscriptions.push(sub)
52
- if (autoEoseForLiveSubscriptions && filters[0]?.limit === 0) {
53
- queueMicrotask(() => handlers.oneose?.())
54
- }
55
- return sub
56
- }
57
-
58
- async publish (event) {
59
- this.lastPublishedEvent = event
60
- const fn = overrideFor(publishOverrides, this.url)
61
- if (fn) await fn(event)
62
- }
63
-
64
- async send (message) {
65
- this.sentMessages ??= []
66
- this.sentMessages.push(message)
67
- const fn = overrideFor(sendOverrides, this.url)
68
- if (fn) await fn(message, this)
69
- }
70
-
71
- _onmessage (_message) {}
72
-
73
- async close () {
74
- this.ws.readyState = 3
75
- this.onclose?.()
76
- }
77
- }
78
-
79
- mock.module('nostr-tools/relay', {
80
- namedExports: {
81
- Relay: FakeRelay
82
- }
83
- })
84
-
85
- // Dynamic import AFTER mock.module so the module picks up FakeRelay
86
- const { RelayPool, relayPool } = await import('../relay/index.js')
87
-
88
- // ─── Helpers ──────────────────────────────────────────────────────────────────
89
-
90
- // Drain pending microtasks + one I/O turn — enough for async relay setup to settle
91
- const tick = () => new Promise(resolve => setImmediate(resolve))
92
-
93
- function deferred () {
94
- let resolve
95
- let reject
96
- const promise = new Promise((res, rej) => {
97
- resolve = res
98
- reject = rej
99
- })
100
- return { promise, resolve, reject }
101
- }
102
-
103
- // Start consuming a generator concurrently; returns the collected array and a
104
- // promise that resolves when the generator ends.
105
- function startCollecting (gen) {
106
- const events = []
107
- const promise = (async () => {
108
- for await (const e of gen) events.push(e)
109
- })()
110
- return { events, promise }
111
- }
112
-
113
- let _nextId = 1
114
- function makeEvent ({ id, kind = 0, created_at = 100 } = {}) {
115
- return { id: id ?? String(_nextId++), kind, created_at, tags: [], content: '' }
116
- }
117
-
118
- function receiveRelayMessage (relay, message) {
119
- relay._onmessage({ data: JSON.stringify(message) })
120
- }
121
-
122
- function countRequest (relay) {
123
- const message = relay.sentMessages?.map(JSON.parse).find(message => message[0] === 'COUNT')
124
- assert.ok(message, 'expected a COUNT request')
125
- return message
126
- }
127
-
128
- function receiveCount (relay, payload) {
129
- const [, id] = countRequest(relay)
130
- receiveRelayMessage(relay, ['COUNT', id, payload])
131
- }
132
-
133
- function closeCount (relay, reason) {
134
- const [, id] = countRequest(relay)
135
- receiveRelayMessage(relay, ['CLOSED', id, reason])
136
- }
137
-
138
- function hll (entries = {}) {
139
- const registers = new Uint8Array(256)
140
- for (const [index, value] of Object.entries(entries)) registers[Number(index)] = value
141
- return [...registers].map(value => value.toString(16).padStart(2, '0')).join('')
142
- }
143
-
144
- // ─── Tests ────────────────────────────────────────────────────────────────────
145
-
146
- test('relay exports one shared RelayPool singleton', () => {
147
- assert.ok(relayPool instanceof RelayPool)
148
- })
149
-
150
- describe('RelayPool.getLiveEventsGenerator', () => {
151
- let nostr
152
-
153
- beforeEach(() => {
154
- _nextId = 1
155
- relayRegistry.clear()
156
- connectOverrides.clear()
157
- autoEoseForLiveSubscriptions = true
158
- nostr = new RelayPool()
159
- })
160
-
161
- it('yields live events and runs until aborted', async () => {
162
- const ac = new AbortController()
163
- const gen = nostr.getLiveEventsGenerator(
164
- { kinds: [0] },
165
- ['wss://r1'],
166
- { signal: ac.signal }
167
- )
168
- const { events, promise } = startCollecting(gen)
169
-
170
- await tick()
171
- const liveSub = relayRegistry.get('wss://r1').subscriptions[0]
172
- liveSub.handlers.onevent(makeEvent({ id: 'e1', created_at: 100 }))
173
- liveSub.handlers.onevent(makeEvent({ id: 'e2', created_at: 200 }))
174
- await tick()
175
-
176
- ac.abort()
177
- await promise
178
- assert.equal(events.length, 2)
179
- assert.equal(events[0].id, 'e1')
180
- assert.equal(events[1].id, 'e2')
181
- })
182
-
183
- it('discards pre-EOSE events per relay without holding another relay back', async () => {
184
- autoEoseForLiveSubscriptions = false
185
- const ac = new AbortController()
186
- const { events, promise } = startCollecting(
187
- nostr.getLiveEventsGenerator({ kinds: [0] }, ['wss://r1', 'wss://r2'], { signal: ac.signal })
188
- )
189
-
190
- await tick()
191
- const first = relayRegistry.get('wss://r1').subscriptions[0]
192
- const second = relayRegistry.get('wss://r2').subscriptions[0]
193
- first.handlers.onevent(makeEvent({ id: 'retained-first' }))
194
- first.handlers.oneose()
195
- first.handlers.onevent(makeEvent({ id: 'between-eoses' }))
196
- await tick()
197
- assert.deepEqual(events.map(event => event.id), ['between-eoses'])
198
-
199
- second.handlers.onevent(makeEvent({ id: 'retained-second' }))
200
- second.handlers.oneose()
201
- second.handlers.onevent(makeEvent({ id: 'live-after-eose' }))
202
- await tick()
203
-
204
- ac.abort()
205
- await promise
206
- assert.deepEqual(events.map(event => event.id), ['between-eoses', 'live-after-eose'])
207
- })
208
-
209
- it('reports relays that reach EOSE during the first-EOSE grace period', async () => {
210
- autoEoseForLiveSubscriptions = false
211
- const ac = new AbortController()
212
- const stream = nostr.getLiveEventsGenerator(
213
- { kinds: [0] },
214
- ['wss://r1', 'wss://r2', 'wss://r3'],
215
- { signal: ac.signal, timeoutAfterFirstEose: 20 }
216
- )
217
- const { promise } = startCollecting(stream)
218
-
219
- await tick()
220
- relayRegistry.get('wss://r1').subscriptions[0].handlers.oneose()
221
- await new Promise(resolve => setTimeout(resolve, 5))
222
- relayRegistry.get('wss://r2').subscriptions[0].handlers.oneose()
223
-
224
- const report = await stream.ready
225
- assert.deepEqual(report.relays, ['wss://r1', 'wss://r2'])
226
- assert.deepEqual(report.errors, [])
227
- assert.deepEqual(stream.readyRelays, ['wss://r1', 'wss://r2'])
228
-
229
- ac.abort()
230
- await promise
231
- })
232
-
233
- it('waits for every initial relay when the first-EOSE grace is null', async () => {
234
- autoEoseForLiveSubscriptions = false
235
- const ac = new AbortController()
236
- const stream = nostr.getLiveEventsGenerator(
237
- { kinds: [0] },
238
- ['wss://r1', 'wss://r2'],
239
- { signal: ac.signal, timeoutAfterFirstEose: null }
240
- )
241
- const { promise } = startCollecting(stream)
242
- let resolved = false
243
- stream.ready.then(() => { resolved = true })
244
-
245
- await tick()
246
- relayRegistry.get('wss://r1').subscriptions[0].handlers.oneose()
247
- await tick()
248
- assert.equal(resolved, false)
249
-
250
- relayRegistry.get('wss://r2').subscriptions[0].handlers.oneose()
251
- const report = await stream.ready
252
- assert.deepEqual(report.relays, ['wss://r1', 'wss://r2'])
253
-
254
- ac.abort()
255
- await promise
256
- })
257
-
258
- it('opens only a live sub (limit:0, since:now) — no initial fetch', async () => {
259
- const ac = new AbortController()
260
- startCollecting(nostr.getLiveEventsGenerator(
261
- { kinds: [0], since: 500 }, // since is set but should NOT trigger initial fetch
262
- ['wss://r1'],
263
- { signal: ac.signal }
264
- ))
265
-
266
- await tick()
267
- const relay = relayRegistry.get('wss://r1')
268
-
269
- assert.equal(relay.subscriptions.length, 1, 'only live sub — no initial gap fill')
270
- assert.equal(relay.subscriptions[0].filters[0].limit, 0)
271
- assert.ok(relay.subscriptions[0].filters[0].since > 0)
272
-
273
- ac.abort()
274
- })
275
-
276
- it('uses the private three-second deadline while opening a relay connection', async () => {
277
- const ac = new AbortController()
278
- startCollecting(nostr.getLiveEventsGenerator({ kinds: [0] }, ['wss://r1'], { signal: ac.signal }))
279
- await tick()
280
- assert.equal(relayRegistry.get('wss://r1').connectOptions.timeout, 3000)
281
- ac.abort()
282
- })
283
-
284
- it('sets event.meta.relay to the relay URL', async () => {
285
- const ac = new AbortController()
286
- const { events, promise } = startCollecting(
287
- nostr.getLiveEventsGenerator({ kinds: [0] }, ['wss://r1'], { signal: ac.signal })
288
- )
289
-
290
- await tick()
291
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onevent(makeEvent({ id: 'e1' }))
292
- await tick()
293
-
294
- ac.abort()
295
- await promise
296
- assert.equal(events[0].meta.relay, 'wss://r1')
297
- })
298
-
299
- it('abort closes the live sub', async () => {
300
- const ac = new AbortController()
301
- const { promise } = startCollecting(
302
- nostr.getLiveEventsGenerator({ kinds: [0] }, ['wss://r1'], { signal: ac.signal })
303
- )
304
-
305
- await tick()
306
- const sub = relayRegistry.get('wss://r1').subscriptions[0]
307
-
308
- ac.abort()
309
- await promise
310
- assert.ok(sub.isClosed)
311
- })
312
-
313
- it('reconnects after live sub disconnects', async () => {
314
- const ac = new AbortController()
315
- const { promise } = startCollecting(
316
- nostr.getLiveEventsGenerator({ kinds: [0] }, ['wss://r1'], { signal: ac.signal })
317
- )
318
-
319
- await tick()
320
- const relay = relayRegistry.get('wss://r1')
321
- relay.subscriptions[0].handlers.onclose()
322
-
323
- await new Promise(resolve => setTimeout(resolve, 1100))
324
- await tick()
325
-
326
- assert.equal(relay.subscriptions.length, 2, 'new live sub opened after reconnect')
327
-
328
- ac.abort()
329
- await promise
330
- })
331
-
332
- it('removes a disconnected relay from readyRelays until its replacement reaches EOSE', async () => {
333
- autoEoseForLiveSubscriptions = false
334
- const ac = new AbortController()
335
- const stream = nostr.getLiveEventsGenerator(
336
- { kinds: [0] },
337
- ['wss://r1'],
338
- { signal: ac.signal, timeoutAfterFirstEose: 0 }
339
- )
340
- const { promise } = startCollecting(stream)
341
-
342
- await tick()
343
- const relay = relayRegistry.get('wss://r1')
344
- relay.subscriptions[0].handlers.oneose()
345
- await stream.ready
346
- assert.deepEqual(stream.readyRelays, ['wss://r1'])
347
-
348
- relay.subscriptions[0].handlers.onclose()
349
- assert.deepEqual(stream.readyRelays, [])
350
- await new Promise(resolve => setTimeout(resolve, 1100))
351
- await tick()
352
-
353
- relay.subscriptions[1].handlers.oneose()
354
- assert.deepEqual(stream.readyRelays, ['wss://r1'])
355
-
356
- ac.abort()
357
- await promise
358
- })
359
-
360
- it('retries a failed initial connection with a fresh relay instance', async () => {
361
- let attempts = 0
362
- const originalConsoleError = console.error
363
- connectOverrides.set('wss://r1', () => {
364
- attempts++
365
- if (attempts === 1) throw new Error('connection failed')
366
- })
367
- console.error = () => {}
368
- try {
369
- const ac = new AbortController()
370
- const { promise } = startCollecting(
371
- nostr.getLiveEventsGenerator({ kinds: [0] }, ['wss://r1'], { signal: ac.signal })
372
- )
373
-
374
- await new Promise(resolve => setTimeout(resolve, 1100))
375
- await tick()
376
- assert.equal(attempts, 2)
377
- assert.equal(relayRegistry.get('wss://r1').subscriptions.length, 1)
378
-
379
- ac.abort()
380
- await promise
381
- } finally {
382
- console.error = originalConsoleError
383
- }
384
- })
385
-
386
- it('evicts a failed connection and cancels its retry when the stream stops', async () => {
387
- let attempts = 0
388
- const originalConsoleError = console.error
389
- connectOverrides.set('wss://r1', () => {
390
- attempts++
391
- throw new Error('connection failed')
392
- })
393
- console.error = () => {}
394
- try {
395
- const ac = new AbortController()
396
- const { promise } = startCollecting(
397
- nostr.getLiveEventsGenerator({ kinds: [0] }, ['wss://r1'], { signal: ac.signal })
398
- )
399
-
400
- await tick()
401
- assert.equal(attempts, 1)
402
- ac.abort()
403
- await promise
404
- await new Promise(resolve => setTimeout(resolve, 1100))
405
- assert.equal(attempts, 1)
406
- } finally {
407
- console.error = originalConsoleError
408
- }
409
- })
410
-
411
- it('reconnect opens a gap fill sub using lastSeenAt as since', async () => {
412
- const ac = new AbortController()
413
- let capturedArgs
414
- async function * mockGapEvents (f, r, o) {
415
- capturedArgs = { f, r, o }
416
- }
417
-
418
- const { promise } = startCollecting(nostr.getLiveEventsGenerator(
419
- { kinds: [0] },
420
- ['wss://r1'],
421
- { signal: ac.signal, _gapEventsGenerator: mockGapEvents }
422
- ))
423
-
424
- await tick()
425
- const relay = relayRegistry.get('wss://r1')
426
-
427
- // Receive an event so lastSeenAt = 750
428
- relay.subscriptions[0].handlers.onevent(makeEvent({ id: 'e1', created_at: 750 }))
429
- await tick()
430
-
431
- // Disconnect → reconnect
432
- relay.subscriptions[0].handlers.onclose()
433
- await new Promise(resolve => setTimeout(resolve, 1100))
434
- await tick()
435
-
436
- assert.ok(capturedArgs, '_gapEventsGenerator should have been called on reconnect')
437
- assert.equal(capturedArgs.f.since, 750, 'reconnect gap fill uses lastSeenAt as since')
438
- assert.ok(capturedArgs.f.until > 0)
439
- assert.deepEqual(capturedArgs.r, ['wss://r1'])
440
-
441
- ac.abort()
442
- await promise
443
- })
444
-
445
- it('reconnect uses filter.since as gap baseline when no events have been seen', async () => {
446
- const ac = new AbortController()
447
- let capturedSince
448
- async function * mockGapEvents (f) { capturedSince = f.since }
449
-
450
- const { promise } = startCollecting(nostr.getLiveEventsGenerator(
451
- { kinds: [0], since: 500 },
452
- ['wss://r1'],
453
- { signal: ac.signal, _gapEventsGenerator: mockGapEvents }
454
- ))
455
-
456
- await tick()
457
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onclose()
458
-
459
- await new Promise(resolve => setTimeout(resolve, 1100))
460
- await tick()
461
-
462
- assert.equal(capturedSince, 500)
463
-
464
- ac.abort()
465
- await promise
466
- })
467
-
468
- describe('filter.until', () => {
469
- it('teardown fires when the wall clock reaches until', async () => {
470
- const until = Math.floor(Date.now() / 1000) + 1 // 1 second from now
471
- const { events, promise } = startCollecting(
472
- nostr.getLiveEventsGenerator({ kinds: [0], until }, ['wss://r1'])
473
- )
474
-
475
- await tick()
476
- const liveSub = relayRegistry.get('wss://r1').subscriptions[0]
477
- liveSub.handlers.onevent(makeEvent({ id: 'e1' }))
478
-
479
- await promise // resolves naturally when until timer fires
480
- assert.equal(events.length, 1)
481
- assert.equal(events[0].id, 'e1')
482
- })
483
-
484
- it('teardown fires immediately when until is already in the past', async () => {
485
- const until = Math.floor(Date.now() / 1000) - 10
486
- const { promise } = startCollecting(
487
- nostr.getLiveEventsGenerator({ kinds: [0], until }, ['wss://r1'])
488
- )
489
- await promise // should resolve on next tick
490
- // no assertion needed — just verifying it completes without hanging
491
- })
492
-
493
- it('forwards until to the live sub filter', async () => {
494
- const until = Math.floor(Date.now() / 1000) + 60
495
- const ac = new AbortController()
496
- startCollecting(nostr.getLiveEventsGenerator(
497
- { kinds: [0], until },
498
- ['wss://r1'],
499
- { signal: ac.signal }
500
- ))
501
-
502
- await tick()
503
- const liveSub = relayRegistry.get('wss://r1').subscriptions[0]
504
- assert.equal(liveSub.filters[0].until, until)
505
-
506
- ac.abort()
507
- })
508
-
509
- it('does not include until in the live sub filter when not set', async () => {
510
- const ac = new AbortController()
511
- startCollecting(nostr.getLiveEventsGenerator(
512
- { kinds: [0] },
513
- ['wss://r1'],
514
- { signal: ac.signal }
515
- ))
516
-
517
- await tick()
518
- const liveSub = relayRegistry.get('wss://r1').subscriptions[0]
519
- assert.equal(liveSub.filters[0].until, undefined)
520
-
521
- ac.abort()
522
- })
523
-
524
- it('caps reconnect gap fill until at filter.until', async () => {
525
- const until = Math.floor(Date.now() / 1000) + 60
526
- const ac = new AbortController()
527
- let capturedUntil
528
- async function * mockGapEvents (f) { capturedUntil = f.until }
529
-
530
- const { promise } = startCollecting(nostr.getLiveEventsGenerator(
531
- { kinds: [0], since: 100, until },
532
- ['wss://r1'],
533
- { signal: ac.signal, _gapEventsGenerator: mockGapEvents }
534
- ))
535
-
536
- await tick()
537
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onclose()
538
-
539
- await new Promise(resolve => setTimeout(resolve, 1100))
540
- await tick()
541
-
542
- assert.ok(capturedUntil <= until, 'reconnect gap fill until should be capped at filter.until')
543
-
544
- ac.abort()
545
- await promise
546
- })
547
-
548
- it('does not reconnect after filter.until has passed', async () => {
549
- const until = Math.floor(Date.now() / 1000) - 1 // already in the past
550
- const { promise } = startCollecting(
551
- nostr.getLiveEventsGenerator({ kinds: [0], until }, ['wss://r1'])
552
- )
553
- await promise
554
-
555
- const countAfter = relayRegistry.get('wss://r1')?.subscriptions.length ?? 0
556
-
557
- await new Promise(resolve => setTimeout(resolve, 200))
558
- await tick()
559
-
560
- assert.equal(
561
- relayRegistry.get('wss://r1')?.subscriptions.length ?? 0,
562
- countAfter,
563
- 'no reconnect after until has passed'
564
- )
565
- })
566
- })
567
-
568
- it('does not reconnect after signal is aborted', async () => {
569
- const ac = new AbortController()
570
- const { promise } = startCollecting(
571
- nostr.getLiveEventsGenerator({ kinds: [0] }, ['wss://r1'], { signal: ac.signal })
572
- )
573
-
574
- await tick()
575
- ac.abort()
576
- await promise
577
-
578
- const countAfterAbort = relayRegistry.get('wss://r1').subscriptions.length
579
- await new Promise(resolve => setTimeout(resolve, 200))
580
- await tick()
581
-
582
- assert.equal(relayRegistry.get('wss://r1').subscriptions.length, countAfterAbort)
583
- })
584
-
585
- describe('reconnect gap fill routing — injectable generators', () => {
586
- it('forwards renamed reconnect timeouts to _gapEventsGenerator', async () => {
587
- const ac = new AbortController()
588
- let asapCalled = false
589
- let options
590
- async function * mockGapEvents (_filter, _relays, nextOptions) {
591
- asapCalled = true
592
- options = nextOptions
593
- }
594
-
595
- const { promise } = startCollecting(nostr.getLiveEventsGenerator(
596
- { kinds: [0], since: 100 },
597
- ['wss://r1'],
598
- {
599
- signal: ac.signal,
600
- timeoutForReconnectGap: 4321,
601
- timeoutAfterFirstReconnectGapEose: 321,
602
- _gapEventsGenerator: mockGapEvents
603
- }
604
- ))
605
-
606
- await tick()
607
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onclose()
608
- await new Promise(resolve => setTimeout(resolve, 1100))
609
- await tick()
610
-
611
- assert.ok(asapCalled)
612
- assert.equal(options.timeout, 4321)
613
- assert.equal(options.timeoutAfterFirstEose, 321)
614
- ac.abort()
615
- await promise
616
- })
617
-
618
- it('forwards null EOSE grace to _gapEventsGenerator', async () => {
619
- const ac = new AbortController()
620
- let options
621
- async function * mockGapEvents (_filter, _relays, nextOptions) { options = nextOptions }
622
-
623
- const { promise } = startCollecting(nostr.getLiveEventsGenerator(
624
- { kinds: [0], since: 100 },
625
- ['wss://r1'],
626
- { signal: ac.signal, timeoutAfterFirstReconnectGapEose: null, _gapEventsGenerator: mockGapEvents }
627
- ))
628
-
629
- await tick()
630
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onclose()
631
- await new Promise(resolve => setTimeout(resolve, 1100))
632
- await tick()
633
-
634
- assert.equal(options.timeoutAfterFirstEose, null)
635
- ac.abort()
636
- await promise
637
- })
638
-
639
- it('buffers live events during reconnect gap fill, yields gap events first', async () => {
640
- const ac = new AbortController()
641
- const liveEvent = makeEvent({ id: 'live1', created_at: 200 })
642
- const gapEvent = makeEvent({ id: 'gap1', created_at: 50 })
643
- let resolveGap
644
-
645
- async function * mockGapEvents () {
646
- yield { type: 'event', event: gapEvent, relay: 'wss://r1' }
647
- await new Promise(resolve => { resolveGap = resolve })
648
- }
649
-
650
- const { events, promise } = startCollecting(nostr.getLiveEventsGenerator(
651
- { kinds: [0], since: 1 },
652
- ['wss://r1'],
653
- { signal: ac.signal, _gapEventsGenerator: mockGapEvents }
654
- ))
655
-
656
- await tick()
657
- const relay = relayRegistry.get('wss://r1')
658
-
659
- // Disconnect → reconnect (gap fill opens)
660
- relay.subscriptions[0].handlers.onclose()
661
- await new Promise(resolve => setTimeout(resolve, 1100))
662
- await tick()
663
-
664
- // Live event arrives during reconnect gap fill → buffered
665
- const newLiveSub = relay.subscriptions[1]
666
- newLiveSub.handlers.onevent(liveEvent)
667
- await tick()
668
- assert.ok(!events.find(e => e.id === 'live1'), 'live event should be buffered during gap fill')
669
-
670
- // Complete gap fill → buffer flushed
671
- resolveGap()
672
- await tick()
673
- await tick()
674
-
675
- assert.equal(events[events.length - 2]?.id, 'gap1', 'gap event comes first')
676
- assert.equal(events[events.length - 1]?.id, 'live1', 'live event comes after')
677
-
678
- ac.abort()
679
- await promise
680
- })
681
-
682
- it('deduplicates events between reconnect gap fill and live sub', async () => {
683
- const ac = new AbortController()
684
- const dupEvent = makeEvent({ id: 'dup', created_at: 150 })
685
- let resolveGap
686
-
687
- async function * mockGapEvents () {
688
- yield { type: 'event', event: dupEvent, relay: 'wss://r1' }
689
- await new Promise(resolve => { resolveGap = resolve })
690
- }
691
-
692
- const { events, promise } = startCollecting(nostr.getLiveEventsGenerator(
693
- { kinds: [0], since: 1 },
694
- ['wss://r1'],
695
- { signal: ac.signal, _gapEventsGenerator: mockGapEvents }
696
- ))
697
-
698
- await tick()
699
- const relay = relayRegistry.get('wss://r1')
700
-
701
- relay.subscriptions[0].handlers.onclose()
702
- await new Promise(resolve => setTimeout(resolve, 1100))
703
- await tick()
704
-
705
- relay.subscriptions[1].handlers.onevent(dupEvent) // same event from live sub
706
- resolveGap()
707
- await tick()
708
- await tick()
709
-
710
- const dupCount = events.filter(e => e.id === 'dup').length
711
- assert.equal(dupCount, 1)
712
-
713
- ac.abort()
714
- await promise
715
- })
716
- })
717
- })
718
-
719
- describe('RelayPool.getEventsFeedGenerator', () => {
720
- let nostr
721
-
722
- beforeEach(() => {
723
- _nextId = 1
724
- relayRegistry.clear()
725
- connectOverrides.clear()
726
- nostr = new RelayPool()
727
- })
728
-
729
- // ── live:true ────────────────────────────────────────────────────────────────
730
-
731
- describe('live:true — always does initial fetch + live', () => {
732
- it('starts _liveGenerator immediately and runs initial fetch concurrently', async () => {
733
- const callOrder = []
734
- let resolveFetch
735
-
736
- async function * mockLive () {
737
- callOrder.push('live')
738
- // stays open
739
- await new Promise(resolve => { resolveFetch = resolve })
740
- }
741
- async function * mockEvents () {
742
- callOrder.push('fetch')
743
- }
744
-
745
- const ac = new AbortController()
746
- const { promise } = startCollecting(
747
- nostr.getEventsFeedGenerator({ since: 100 }, ['wss://r1'], {
748
- signal: ac.signal,
749
- _liveGenerator: mockLive,
750
- _eventsGenerator: mockEvents
751
- })
752
- )
753
-
754
- await tick()
755
- assert.deepEqual(callOrder, ['live', 'fetch'], 'live generator should start before fetch')
756
-
757
- resolveFetch()
758
- ac.abort()
759
- await promise
760
- })
761
-
762
- it('yields stored events before buffered live events', async () => {
763
- const storedEvent = makeEvent({ id: 'stored', created_at: 50 })
764
- const liveEvent = makeEvent({ id: 'live', created_at: 200 })
765
- let resolveFetch
766
-
767
- async function * mockLive () {
768
- // Simulates a live event arriving during the fetch
769
- await new Promise(resolve => { resolveFetch = resolve })
770
- yield liveEvent
771
- }
772
- async function * mockEvents () {
773
- yield { type: 'event', event: storedEvent, relay: 'wss://r1' }
774
- }
775
-
776
- const ac = new AbortController()
777
- const { events, promise } = startCollecting(
778
- nostr.getEventsFeedGenerator({ since: 1 }, ['wss://r1'], {
779
- signal: ac.signal,
780
- _liveGenerator: mockLive,
781
- _eventsGenerator: mockEvents
782
- })
783
- )
784
-
785
- await tick()
786
- // Unblock live generator after fetch has yielded the stored event
787
- resolveFetch()
788
- ac.abort()
789
- await promise
790
-
791
- assert.equal(events[0].id, 'stored', 'stored event should come first')
792
- assert.equal(events[1].id, 'live', 'live event should come after')
793
- })
794
-
795
- it('deduplicates live events that overlap with initial fetch events', async () => {
796
- const sharedEvent = makeEvent({ id: 'shared', created_at: 100 })
797
- let resolveLive
798
-
799
- // Live generator yields the shared event immediately (simulates it arriving while
800
- // the fetch is still running), then waits to keep the generator open
801
- async function * mockLive () {
802
- yield sharedEvent
803
- await new Promise(resolve => { resolveLive = resolve })
804
- }
805
- // Fetch also returns the same event (overlap around the time boundary)
806
- async function * mockEvents () {
807
- yield { type: 'event', event: sharedEvent, relay: 'wss://r1' }
808
- }
809
-
810
- const ac = new AbortController()
811
- const { events, promise } = startCollecting(
812
- nostr.getEventsFeedGenerator({ since: 1 }, ['wss://r1'], {
813
- signal: ac.signal,
814
- _liveGenerator: mockLive,
815
- _eventsGenerator: mockEvents
816
- })
817
- )
818
-
819
- await tick()
820
- resolveLive()
821
- ac.abort()
822
- await promise
823
-
824
- assert.equal(events.filter(e => e.id === 'shared').length, 1, 'duplicate should appear once')
825
- })
826
-
827
- it('uses _eventsGenerator for initial fetch with either EOSE grace setting', async () => {
828
- const calls = []
829
- async function * mockLive () { await new Promise(() => {}) }
830
- async function * mockEvents (_filter, _relays, options) { calls.push(options) }
831
-
832
- const asapAbort = new AbortController()
833
- startCollecting(nostr.getEventsFeedGenerator({ since: 100 }, ['wss://r1'], {
834
- signal: asapAbort.signal,
835
- timeoutAfterFirstEose: 500,
836
- _liveGenerator: mockLive,
837
- _eventsGenerator: mockEvents
838
- }))
839
- await tick()
840
- asapAbort.abort()
841
-
842
- const fullAbort = new AbortController()
843
- startCollecting(nostr.getEventsFeedGenerator({ since: 100 }, ['wss://r1'], {
844
- signal: fullAbort.signal,
845
- timeoutAfterFirstEose: null,
846
- _liveGenerator: mockLive,
847
- _eventsGenerator: mockEvents
848
- }))
849
- await tick()
850
- assert.deepEqual(calls.map(call => call.timeoutAfterFirstEose), [500, null])
851
- fullAbort.abort()
852
- })
853
-
854
- it('skips initial fetch and delegates directly to _liveGenerator when filter.limit === 0', async () => {
855
- let fetchCalled = false
856
- let liveCalled = false
857
- async function * mockLive () { liveCalled = true; yield makeEvent({ id: 'e1' }) }
858
- async function * mockEvents () { fetchCalled = true }
859
-
860
- const ac = new AbortController()
861
- const { events, promise } = startCollecting(
862
- nostr.getEventsFeedGenerator({ limit: 0 }, ['wss://r1'], {
863
- signal: ac.signal,
864
- _liveGenerator: mockLive,
865
- _eventsGenerator: mockEvents
866
- })
867
- )
868
-
869
- await promise
870
- assert.ok(liveCalled)
871
- assert.ok(!fetchCalled, 'no initial fetch when limit:0')
872
- assert.equal(events.length, 1)
873
- })
874
-
875
- it('triggers initial fetch even with no since and no limit', async () => {
876
- let fetchCalled = false
877
- async function * mockLive () { await new Promise(() => {}) }
878
- async function * mockEvents () { fetchCalled = true }
879
-
880
- const ac = new AbortController()
881
- startCollecting(nostr.getEventsFeedGenerator({}, ['wss://r1'], {
882
- signal: ac.signal,
883
- _liveGenerator: mockLive,
884
- _eventsGenerator: mockEvents
885
- }))
886
-
887
- await tick()
888
- assert.ok(fetchCalled, 'initial fetch should always run for live:true')
889
- ac.abort()
890
- })
891
-
892
- it('triggers initial fetch for filter.limit > 0', async () => {
893
- let fetchCalled = false
894
- async function * mockLive () { await new Promise(() => {}) }
895
- async function * mockEvents () { fetchCalled = true }
896
-
897
- const ac = new AbortController()
898
- startCollecting(nostr.getEventsFeedGenerator({ limit: 3 }, ['wss://r1'], {
899
- signal: ac.signal,
900
- _liveGenerator: mockLive,
901
- _eventsGenerator: mockEvents
902
- }))
903
-
904
- await tick()
905
- assert.ok(fetchCalled)
906
- ac.abort()
907
- })
908
-
909
- it('passes timeout and timeoutAfterFirstEose to _eventsGenerator', async () => {
910
- let capturedOpts
911
- async function * mockLive () { await new Promise(() => {}) }
912
- async function * mockEvents (_f, _r, o) { capturedOpts = o }
913
-
914
- const ac = new AbortController()
915
- startCollecting(nostr.getEventsFeedGenerator({ since: 100 }, ['wss://r1'], {
916
- signal: ac.signal,
917
- timeout: 3000,
918
- timeoutAfterFirstEose: 200,
919
- _liveGenerator: mockLive,
920
- _eventsGenerator: mockEvents
921
- }))
922
-
923
- await tick()
924
- assert.equal(capturedOpts.timeout, 3000)
925
- assert.equal(capturedOpts.timeoutAfterFirstEose, 200)
926
- ac.abort()
927
- })
928
- })
929
-
930
- // ── live:false ──────────────────────────────────────────────────────────────
931
-
932
- describe('live:false', () => {
933
- it('delegates to _eventsGenerator with regular EOSE grace', async () => {
934
- const ac = new AbortController()
935
- let capturedArgs
936
- async function * mockEvents (f, r, o) {
937
- capturedArgs = { f, r, o }
938
- yield { type: 'event', event: makeEvent({ id: 'e1' }), relay: 'wss://r1' }
939
- }
940
-
941
- const { events, promise } = startCollecting(
942
- nostr.getEventsFeedGenerator({ kinds: [0], since: 100 }, ['wss://r1'], {
943
- live: false, timeout: 3000, timeoutAfterFirstEose: 200,
944
- signal: ac.signal, _eventsGenerator: mockEvents
945
- })
946
- )
947
-
948
- await promise
949
- assert.equal(capturedArgs.o.timeout, 3000)
950
- assert.equal(capturedArgs.o.timeoutAfterFirstEose, 200)
951
- assert.equal(capturedArgs.o.signal, ac.signal)
952
- assert.equal(events.length, 1)
953
- assert.equal(events[0].id, 'e1')
954
- })
955
-
956
- it('skips non-event items', async () => {
957
- async function * mockEvents () {
958
- yield { type: 'error', error: new Error('oops'), relay: 'wss://r1' }
959
- yield { type: 'event', event: makeEvent({ id: 'e1' }), relay: 'wss://r1' }
960
- }
961
- const { events, promise } = startCollecting(
962
- nostr.getEventsFeedGenerator({}, ['wss://r1'], {
963
- live: false, timeoutAfterFirstEose: 500, _eventsGenerator: mockEvents
964
- })
965
- )
966
- await promise
967
- assert.equal(events.length, 1)
968
- assert.equal(events[0].id, 'e1')
969
- })
970
- it('forwards null EOSE grace to _eventsGenerator', async () => {
971
- const ac = new AbortController()
972
- let capturedArgs
973
- async function * mockEvents (f, r, o) {
974
- capturedArgs = { f, r, o }
975
- yield { type: 'event', event: makeEvent({ id: 'e1' }), relay: 'wss://r1' }
976
- }
977
-
978
- const { events, promise } = startCollecting(
979
- nostr.getEventsFeedGenerator({ kinds: [0] }, ['wss://r1'], {
980
- live: false, timeout: 4000, timeoutAfterFirstEose: null,
981
- signal: ac.signal, _eventsGenerator: mockEvents
982
- })
983
- )
984
-
985
- await promise
986
- assert.equal(capturedArgs.o.timeout, 4000)
987
- assert.equal(capturedArgs.o.timeoutAfterFirstEose, null)
988
- assert.equal(capturedArgs.o.signal, ac.signal)
989
- assert.equal(events.length, 1)
990
- })
991
-
992
- it('skips non-event items', async () => {
993
- async function * mockEvents () {
994
- yield { type: 'error', error: new Error('oops'), relay: 'wss://r1' }
995
- yield { type: 'event', event: makeEvent({ id: 'e1' }), relay: 'wss://r1' }
996
- }
997
- const { events, promise } = startCollecting(
998
- nostr.getEventsFeedGenerator({}, ['wss://r1'], {
999
- live: false, timeoutAfterFirstEose: null, _eventsGenerator: mockEvents
1000
- })
1001
- )
1002
- await promise
1003
- assert.equal(events.length, 1)
1004
- })
1005
- })
1006
- })
1007
-
1008
- describe('RelayPool.getEvents', () => {
1009
- let nostr
1010
-
1011
- beforeEach(() => {
1012
- _nextId = 1
1013
- relayRegistry.clear()
1014
- connectOverrides.clear()
1015
- nostr = new RelayPool()
1016
- })
1017
-
1018
- it('collects events and resolves on EOSE', async () => {
1019
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'])
1020
- await tick()
1021
- const sub = relayRegistry.get('wss://r1').subscriptions[0]
1022
- sub.handlers.onevent(makeEvent({ id: 'e1', created_at: 100 }))
1023
- sub.handlers.onevent(makeEvent({ id: 'e2', created_at: 200 }))
1024
- sub.handlers.oneose()
1025
- const { result, errors, success } = await resultPromise
1026
- assert.equal(result.length, 2)
1027
- assert.equal(result[0].id, 'e1')
1028
- assert.equal(result[1].id, 'e2')
1029
- assert.equal(errors.length, 0)
1030
- assert.ok(success)
1031
- })
1032
-
1033
- it('sets event.meta.relay', async () => {
1034
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'])
1035
- await tick()
1036
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onevent(makeEvent({ id: 'e1' }))
1037
- relayRegistry.get('wss://r1').subscriptions[0].handlers.oneose()
1038
- const { result } = await resultPromise
1039
- assert.equal(result[0].meta.relay, 'wss://r1')
1040
- })
1041
-
1042
- it('adds timeout errors for relays still pending at the overall deadline', async () => {
1043
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'], { timeout: 30 })
1044
- const { result, errors, success } = await resultPromise
1045
- assert.equal(result.length, 0)
1046
- assert.equal(errors.length, 1)
1047
- assert.equal(errors[0].reason.message, 'GET_EVENTS_TIMEOUT')
1048
- assert.ok(!success)
1049
- })
1050
-
1051
- it('retains completed relay success while reporting only pending timeout errors', async () => {
1052
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1', 'wss://r2'], {
1053
- timeout: 30,
1054
- timeoutAfterFirstEose: null
1055
- })
1056
- await tick()
1057
- relayRegistry.get('wss://r1').subscriptions[0].handlers.oneose()
1058
-
1059
- const { errors, success } = await resultPromise
1060
- assert.ok(success)
1061
- assert.deepEqual(errors.map(({ relay, reason }) => [relay, reason.message]), [
1062
- ['wss://r2', 'GET_EVENTS_TIMEOUT']
1063
- ])
1064
- })
1065
-
1066
- it('does not create a deadline when timeout is null', async () => {
1067
- let resolved = false
1068
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'], {
1069
- timeout: null,
1070
- timeoutAfterFirstEose: null
1071
- })
1072
- resultPromise.then(() => { resolved = true })
1073
-
1074
- await tick()
1075
- await new Promise(resolve => setTimeout(resolve, 10))
1076
- assert.ok(!resolved, 'null should not coerce to an immediate deadline')
1077
-
1078
- relayRegistry.get('wss://r1').subscriptions[0].handlers.oneose()
1079
- assert.ok((await resultPromise).success)
1080
- })
1081
-
1082
- it('ignores events that arrive after the result has timed out', async () => {
1083
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'], { timeout: 10 })
1084
- await tick()
1085
- const sub = relayRegistry.get('wss://r1').subscriptions[0]
1086
- const result = await resultPromise
1087
-
1088
- sub.handlers.onevent(makeEvent({ id: 'late' }))
1089
- await tick()
1090
- assert.equal(result.result.length, 0)
1091
- assert.equal(result.errors[0].reason.message, 'GET_EVENTS_TIMEOUT')
1092
- })
1093
-
1094
- it('adds relay error when relay closes with an error', async () => {
1095
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'])
1096
- await tick()
1097
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onclose(new Error('connection dropped'))
1098
- const { errors, success } = await resultPromise
1099
- assert.equal(errors.length, 1)
1100
- assert.ok(errors[0].reason.message.includes('connection dropped'))
1101
- assert.ok(!success)
1102
- })
1103
-
1104
- it('collects events from multiple relays', async () => {
1105
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1', 'wss://r2'])
1106
- await tick()
1107
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onevent(makeEvent({ id: 'e1' }))
1108
- relayRegistry.get('wss://r1').subscriptions[0].handlers.oneose()
1109
- relayRegistry.get('wss://r2').subscriptions[0].handlers.onevent(makeEvent({ id: 'e2' }))
1110
- relayRegistry.get('wss://r2').subscriptions[0].handlers.oneose()
1111
- const { result, errors } = await resultPromise
1112
- assert.equal(result.length, 2)
1113
- assert.equal(errors.length, 0)
1114
- })
1115
-
1116
- it('deduplicates matching event ids across relays without changing relay completion', async () => {
1117
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1', 'wss://r2'], {
1118
- timeoutAfterFirstEose: null
1119
- })
1120
- await tick()
1121
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onevent(makeEvent({ id: 'same-id' }))
1122
- relayRegistry.get('wss://r1').subscriptions[0].handlers.oneose()
1123
- relayRegistry.get('wss://r2').subscriptions[0].handlers.onevent(makeEvent({ id: 'same-id' }))
1124
- relayRegistry.get('wss://r2').subscriptions[0].handlers.oneose()
1125
-
1126
- const { result, errors, success } = await resultPromise
1127
- assert.deepEqual(result.map(event => event.id), ['same-id'])
1128
- assert.deepEqual(errors, [])
1129
- assert.ok(success)
1130
- })
1131
-
1132
- it('success:true when at least one relay succeeds', async () => {
1133
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1', 'wss://r2'])
1134
- await tick()
1135
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onevent(makeEvent({ id: 'e1' }))
1136
- relayRegistry.get('wss://r1').subscriptions[0].handlers.oneose()
1137
- relayRegistry.get('wss://r2').subscriptions[0].handlers.onclose(new Error('boom'))
1138
- const { success } = await resultPromise
1139
- assert.ok(success)
1140
- })
1141
-
1142
- it('success:false when all relays error', async () => {
1143
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1', 'wss://r2'])
1144
- await tick()
1145
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onclose(new Error('err1'))
1146
- relayRegistry.get('wss://r2').subscriptions[0].handlers.onclose(new Error('err2'))
1147
- const { success, errors } = await resultPromise
1148
- assert.ok(!success)
1149
- assert.equal(errors.length, 2)
1150
- })
1151
-
1152
- it('calls callback with event items', async () => {
1153
- const items = []
1154
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'], {
1155
- callback: item => items.push(item)
1156
- })
1157
- await tick()
1158
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onevent(makeEvent({ id: 'e1' }))
1159
- relayRegistry.get('wss://r1').subscriptions[0].handlers.oneose()
1160
- await resultPromise
1161
- assert.ok(items.some(i => i.type === 'event' && i.event.id === 'e1' && i.relay === 'wss://r1'))
1162
- })
1163
-
1164
- it('rejects when signal is aborted', async () => {
1165
- const ac = new AbortController()
1166
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'], { signal: ac.signal })
1167
- await tick()
1168
- ac.abort()
1169
- await assert.rejects(resultPromise, /Aborted/)
1170
- })
1171
-
1172
- it('returns immediately with an unsuccessful empty result when no relays are given', async () => {
1173
- assert.deepEqual(
1174
- await nostr.getEvents({ kinds: [0] }, []),
1175
- { result: [], errors: [], success: false }
1176
- )
1177
- })
1178
-
1179
- describe('early close', () => {
1180
- it('resolves after filter.limit events without waiting for EOSE', async () => {
1181
- const resultPromise = nostr.getEvents({ kinds: [0], limit: 2 }, ['wss://r1'])
1182
- await tick()
1183
- const sub = relayRegistry.get('wss://r1').subscriptions[0]
1184
- sub.handlers.onevent(makeEvent({ id: 'e1' }))
1185
- sub.handlers.onevent(makeEvent({ id: 'e2' }))
1186
- const { result } = await resultPromise
1187
- assert.equal(result.length, 2)
1188
- assert.ok(sub.isClosed)
1189
- })
1190
-
1191
- it('counts oninvalidevent toward limit', async () => {
1192
- const resultPromise = nostr.getEvents({ kinds: [0], limit: 2 }, ['wss://r1'])
1193
- await tick()
1194
- const sub = relayRegistry.get('wss://r1').subscriptions[0]
1195
- sub.handlers.oninvalidevent(makeEvent({ id: 'bad' })) // count: 1
1196
- sub.handlers.onevent(makeEvent({ id: 'e1' })) // count: 2 → closes
1197
- const { result } = await resultPromise
1198
- assert.equal(result.length, 1)
1199
- assert.ok(sub.isClosed)
1200
- })
1201
-
1202
- it('closes when all filter.ids have been seen', async () => {
1203
- const e1 = makeEvent({ id: 'aaa' })
1204
- const e2 = makeEvent({ id: 'bbb' })
1205
- const resultPromise = nostr.getEvents({ ids: ['aaa', 'bbb'] }, ['wss://r1'])
1206
- await tick()
1207
- const sub = relayRegistry.get('wss://r1').subscriptions[0]
1208
- sub.handlers.onevent(e1)
1209
- sub.handlers.onevent(e2)
1210
- const { result } = await resultPromise
1211
- assert.equal(result.length, 2)
1212
- assert.ok(sub.isClosed)
1213
- })
1214
- })
1215
- })
1216
-
1217
- describe('RelayPool.getEvents EOSE grace', () => {
1218
- let nostr
1219
-
1220
- beforeEach(() => {
1221
- _nextId = 1
1222
- relayRegistry.clear()
1223
- connectOverrides.clear()
1224
- nostr = new RelayPool()
1225
- })
1226
-
1227
- it('collects events and resolves when all relay subs close', async () => {
1228
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'])
1229
- await tick()
1230
- const sub = relayRegistry.get('wss://r1').subscriptions[0]
1231
- sub.handlers.onevent(makeEvent({ id: 'e1' }))
1232
- sub.handlers.oneose()
1233
- const { result, errors, success } = await resultPromise
1234
- assert.equal(result.length, 1)
1235
- assert.equal(result[0].id, 'e1')
1236
- assert.equal(errors.length, 0)
1237
- assert.ok(success)
1238
- })
1239
-
1240
- it('sets event.meta.relay', async () => {
1241
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'])
1242
- await tick()
1243
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onevent(makeEvent({ id: 'e1' }))
1244
- relayRegistry.get('wss://r1').subscriptions[0].handlers.oneose()
1245
- const { result } = await resultPromise
1246
- assert.equal(result[0].meta.relay, 'wss://r1')
1247
- })
1248
-
1249
- it('starts short timer after first relay with events EOSEs, finalizes before second relay', async () => {
1250
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1', 'wss://r2'], {
1251
- timeoutAfterFirstEose: 50
1252
- })
1253
- await tick()
1254
- const sub1 = relayRegistry.get('wss://r1').subscriptions[0]
1255
- sub1.handlers.onevent(makeEvent({ id: 'e1' }))
1256
- sub1.handlers.oneose() // has events → starts 50ms timer; r2 still pending
1257
- const { result, success } = await resultPromise
1258
- assert.equal(result.length, 1)
1259
- assert.ok(success)
1260
- })
1261
-
1262
- it('does not start short timer when first EOSE has no events', async () => {
1263
- let resolved = false
1264
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1', 'wss://r2'], {
1265
- timeoutAfterFirstEose: 50,
1266
- timeout: 500
1267
- })
1268
- resultPromise.then(() => { resolved = true })
1269
- await tick()
1270
- relayRegistry.get('wss://r1').subscriptions[0].handlers.oneose() // no events
1271
- await new Promise(resolve => setTimeout(resolve, 80))
1272
- assert.ok(!resolved, 'should not resolve early when first EOSE had no events')
1273
- // clean up: trigger second relay to let promise resolve
1274
- relayRegistry.get('wss://r2').subscriptions[0].handlers.oneose()
1275
- await resultPromise
1276
- })
1277
-
1278
- it('waits for every relay when timeoutAfterFirstEose is null', async () => {
1279
- let resolved = false
1280
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1', 'wss://r2'], {
1281
- timeout: 100,
1282
- timeoutAfterFirstEose: null
1283
- })
1284
- resultPromise.then(() => { resolved = true })
1285
-
1286
- await tick()
1287
- const first = relayRegistry.get('wss://r1').subscriptions[0]
1288
- first.handlers.onevent(makeEvent({ id: 'e1' }))
1289
- first.handlers.oneose()
1290
- await new Promise(resolve => setTimeout(resolve, 10))
1291
- assert.ok(!resolved, 'null should disable the post-EOSE grace timer')
1292
-
1293
- relayRegistry.get('wss://r2').subscriptions[0].handlers.oneose()
1294
- const { result } = await resultPromise
1295
- assert.equal(result.length, 1)
1296
- })
1297
-
1298
- it('returns terminal timeout errors on overall timeout', async () => {
1299
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'], { timeout: 30 })
1300
- const { result, errors, success } = await resultPromise
1301
- assert.equal(result.length, 0)
1302
- assert.equal(errors[0].reason.message, 'GET_EVENTS_TIMEOUT')
1303
- assert.ok(!success)
1304
- })
1305
-
1306
- it('rejects when signal is aborted', async () => {
1307
- const ac = new AbortController()
1308
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'], { signal: ac.signal })
1309
- await tick()
1310
- ac.abort()
1311
- await assert.rejects(resultPromise, /Aborted/)
1312
- })
1313
-
1314
- it('early close: resolves after filter.limit events without EOSE', async () => {
1315
- const resultPromise = nostr.getEvents({ kinds: [0], limit: 1 }, ['wss://r1'])
1316
- await tick()
1317
- const sub = relayRegistry.get('wss://r1').subscriptions[0]
1318
- sub.handlers.onevent(makeEvent({ id: 'e1' }))
1319
- const { result } = await resultPromise
1320
- assert.equal(result.length, 1)
1321
- assert.ok(sub.isClosed)
1322
- })
1323
-
1324
- it('early close via limit/ids triggers timeoutAfterFirstEose for remaining relays', async () => {
1325
- // With 2 relays: r1 satisfies limit:1 → handleEose runs → 50ms timer → finalize
1326
- const resultPromise = nostr.getEvents({ kinds: [0], limit: 1 }, ['wss://r1', 'wss://r2'], {
1327
- timeoutAfterFirstEose: 50
1328
- })
1329
- await tick()
1330
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onevent(makeEvent({ id: 'e1' }))
1331
- // r2 still pending — should resolve after short timer, not after overall timeout
1332
- const { result, success } = await resultPromise
1333
- assert.equal(result.length, 1)
1334
- assert.ok(success)
1335
- })
1336
-
1337
- it('single relay: resolves immediately on EOSE without waiting for timeoutAfterFirstEose', async () => {
1338
- let resolved = false
1339
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'], {
1340
- timeoutAfterFirstEose: 500
1341
- })
1342
- resultPromise.then(() => { resolved = true })
1343
- await tick()
1344
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onevent(makeEvent({ id: 'e1' }))
1345
- relayRegistry.get('wss://r1').subscriptions[0].handlers.oneose()
1346
- await tick() // microtasks flush — .then() should have run
1347
- assert.ok(resolved, 'single relay should not wait for timeoutAfterFirstEose')
1348
- await resultPromise
1349
- })
1350
-
1351
- it('single relay: early close also resolves immediately', async () => {
1352
- let resolved = false
1353
- const resultPromise = nostr.getEvents({ kinds: [0], limit: 1 }, ['wss://r1'], {
1354
- timeoutAfterFirstEose: 500
1355
- })
1356
- resultPromise.then(() => { resolved = true })
1357
- await tick()
1358
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onevent(makeEvent({ id: 'e1' }))
1359
- await tick()
1360
- assert.ok(resolved, 'single relay early close should not wait for timeoutAfterFirstEose')
1361
- await resultPromise
1362
- })
1363
-
1364
- it('adds errors when relay closes with an error', async () => {
1365
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'])
1366
- await tick()
1367
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onclose(new Error('dropped'))
1368
- const { errors, success } = await resultPromise
1369
- assert.equal(errors.length, 1)
1370
- assert.ok(errors[0].reason.message.includes('dropped'))
1371
- assert.ok(!success) // all relays errored
1372
- })
1373
- })
1374
-
1375
- describe('RelayPool.countEvents', () => {
1376
- let nostr
1377
-
1378
- beforeEach(() => {
1379
- relayRegistry.clear()
1380
- connectOverrides.clear()
1381
- sendOverrides.clear()
1382
- nostr = new RelayPool()
1383
- })
1384
-
1385
- it('sends one NIP-45 filter and returns a single relay count immediately', async () => {
1386
- const filter = { kinds: [1], '#p': ['pubkey'] }
1387
- let resolved = false
1388
- const resultPromise = nostr.countEvents(filter, ['wss://r1'], { timeoutAfterFirstCount: 500 })
1389
- resultPromise.then(() => { resolved = true })
1390
-
1391
- await tick()
1392
- const relay = relayRegistry.get('wss://r1')
1393
- const request = countRequest(relay)
1394
- assert.equal(request[0], 'COUNT')
1395
- assert.match(request[1], /^p2r2p-count:\d+$/)
1396
- assert.deepEqual(request.slice(2), [filter])
1397
-
1398
- receiveCount(relay, { count: 4, approximate: true })
1399
- await tick()
1400
- assert.ok(resolved, 'a sole relay should not wait for the grace timer')
1401
- assert.deepEqual(await resultPromise, {
1402
- count: 4,
1403
- approximate: true,
1404
- errors: [],
1405
- success: true
1406
- })
1407
- })
1408
-
1409
- it('keeps zero as a valid count', async () => {
1410
- const resultPromise = nostr.countEvents({ kinds: [1] }, ['wss://r1'])
1411
- await tick()
1412
- receiveCount(relayRegistry.get('wss://r1'), { count: 0 })
1413
-
1414
- assert.deepEqual(await resultPromise, {
1415
- count: 0,
1416
- approximate: false,
1417
- errors: [],
1418
- success: true
1419
- })
1420
- })
1421
-
1422
- it('waits after a plain first count for a higher count and later HLL', async () => {
1423
- let resolved = false
1424
- const resultPromise = nostr.countEvents({ kinds: [1] }, ['wss://r1', 'wss://r2'], {
1425
- timeout: 500,
1426
- timeoutAfterFirstCount: 100
1427
- })
1428
- resultPromise.then(() => { resolved = true })
1429
-
1430
- await tick()
1431
- receiveCount(relayRegistry.get('wss://r1'), { count: 4 })
1432
- await tick()
1433
- assert.ok(!resolved, 'the first plain count should open the grace window')
1434
-
1435
- receiveCount(relayRegistry.get('wss://r2'), { count: 7, hll: hll({ 3: 1 }) })
1436
- const result = await resultPromise
1437
- assert.equal(result.count, 7)
1438
- assert.equal(result.approximate, false)
1439
- assert.equal(result.hll, hll({ 3: 1 }))
1440
- assert.equal(result.hllCount, 1)
1441
- assert.equal(result.errors.length, 0)
1442
- assert.ok(result.success)
1443
- })
1444
-
1445
- it('waits after an invalid HLL but does not return it', async () => {
1446
- const resultPromise = nostr.countEvents({ kinds: [1] }, ['wss://r1', 'wss://r2'], {
1447
- timeout: 500,
1448
- timeoutAfterFirstCount: 20
1449
- })
1450
- await tick()
1451
- receiveCount(relayRegistry.get('wss://r1'), { count: 2, hll: '' })
1452
-
1453
- const result = await resultPromise
1454
- assert.equal(result.count, 2)
1455
- assert.ok(!('hll' in result))
1456
- assert.ok(!('hllCount' in result))
1457
- assert.equal(result.errors.length, 0)
1458
- })
1459
-
1460
- it('uses only the overall timeout when timeoutAfterFirstCount is null', async () => {
1461
- let resolved = false
1462
- const resultPromise = nostr.countEvents({ kinds: [1] }, ['wss://r1', 'wss://r2'], {
1463
- timeout: 30,
1464
- timeoutAfterFirstCount: null
1465
- })
1466
- resultPromise.then(() => { resolved = true })
1467
-
1468
- await tick()
1469
- receiveCount(relayRegistry.get('wss://r1'), { count: 4 })
1470
- await new Promise(resolve => setTimeout(resolve, 10))
1471
- assert.ok(!resolved, 'null should not coerce to a zero-millisecond grace timer')
1472
-
1473
- const result = await resultPromise
1474
- assert.equal(result.count, 4)
1475
- assert.ok(result.success)
1476
- assert.deepEqual(result.errors.map(({ relay, reason }) => [relay, reason.message]), [
1477
- ['wss://r2', 'COUNT_TIMEOUT']
1478
- ])
1479
- })
1480
-
1481
- it('does not create an overall COUNT timer when timeout is null', async () => {
1482
- let resolved = false
1483
- const resultPromise = nostr.countEvents({ kinds: [1] }, ['wss://r1', 'wss://r2'], {
1484
- timeout: null,
1485
- timeoutAfterFirstCount: null
1486
- })
1487
- resultPromise.then(() => { resolved = true })
1488
-
1489
- await tick()
1490
- await new Promise(resolve => setTimeout(resolve, 10))
1491
- assert.ok(!resolved, 'null should not coerce to an immediate overall timeout')
1492
-
1493
- receiveCount(relayRegistry.get('wss://r1'), { count: 4 })
1494
- receiveCount(relayRegistry.get('wss://r2'), { count: 7 })
1495
- assert.equal((await resultPromise).count, 7)
1496
- })
1497
-
1498
- it('merges HLL replies and returns as soon as all relays settle', async () => {
1499
- let resolved = false
1500
- const resultPromise = nostr.countEvents({ kinds: [1] }, ['wss://r1', 'wss://r2'], {
1501
- timeoutAfterFirstCount: 500
1502
- })
1503
- resultPromise.then(() => { resolved = true })
1504
-
1505
- await tick()
1506
- receiveCount(relayRegistry.get('wss://r1'), { count: 5, approximate: true, hll: hll({ 0: 1, 1: 2 }) })
1507
- receiveCount(relayRegistry.get('wss://r2'), { count: 5, hll: hll({ 0: 4, 2: 3 }) })
1508
- await tick()
1509
- assert.ok(resolved, 'all relay replies should finish before the grace timer')
1510
-
1511
- const result = await resultPromise
1512
- assert.equal(result.count, 5)
1513
- assert.equal(result.approximate, false)
1514
- assert.equal(result.hll, hll({ 0: 4, 1: 2, 2: 3 }))
1515
- assert.equal(result.hllCount, 3)
1516
- })
1517
-
1518
- it('reports malformed COUNT payloads as relay errors', async () => {
1519
- const resultPromise = nostr.countEvents({ kinds: [1] }, ['wss://r1'])
1520
- await tick()
1521
- receiveCount(relayRegistry.get('wss://r1'), { count: 'four' })
1522
-
1523
- const result = await resultPromise
1524
- assert.equal(result.count, null)
1525
- assert.equal(result.success, false)
1526
- assert.equal(result.errors.length, 1)
1527
- assert.equal(result.errors[0].reason.message, 'INVALID_COUNT_RESPONSE')
1528
- })
1529
-
1530
- it('reports connection failures and COUNT refusals without authenticating', async () => {
1531
- connectOverrides.set('wss://r1', () => { throw new Error('connection failed') })
1532
- const failedConnection = await nostr.countEvents({ kinds: [1] }, ['wss://r1'])
1533
- assert.equal(failedConnection.errors[0].reason.message, 'connection failed')
1534
-
1535
- const refusalPromise = nostr.countEvents({ kinds: [1] }, ['wss://r2'])
1536
- await tick()
1537
- const relay = relayRegistry.get('wss://r2')
1538
- closeCount(relay, 'auth-required: cannot count private events')
1539
- const refusal = await refusalPromise
1540
- assert.equal(refusal.errors[0].reason.message, 'auth-required: cannot count private events')
1541
- assert.deepEqual(relay.sentMessages.map(JSON.parse).map(message => message[0]), ['COUNT'])
1542
- })
1543
-
1544
- it('reports unresolved relays at the overall timeout', async () => {
1545
- const result = await nostr.countEvents({ kinds: [1] }, ['wss://r1', 'wss://r2'], { timeout: 20 })
1546
-
1547
- assert.equal(result.count, null)
1548
- assert.equal(result.success, false)
1549
- assert.deepEqual(result.errors.map(({ relay, reason }) => [relay, reason.message]), [
1550
- ['wss://r1', 'COUNT_TIMEOUT'],
1551
- ['wss://r2', 'COUNT_TIMEOUT']
1552
- ])
1553
- })
1554
-
1555
- it('rejects caller aborts and ignores late COUNT replies', async () => {
1556
- const ac = new AbortController()
1557
- const aborted = nostr.countEvents({ kinds: [1] }, ['wss://r1'], { signal: ac.signal })
1558
- await tick()
1559
- ac.abort()
1560
- await assert.rejects(aborted, /Aborted/)
1561
-
1562
- const resultPromise = nostr.countEvents({ kinds: [1] }, ['wss://r2', 'wss://r3'], {
1563
- timeoutAfterFirstCount: 20
1564
- })
1565
- await tick()
1566
- const r2 = relayRegistry.get('wss://r2')
1567
- const r3 = relayRegistry.get('wss://r3')
1568
- const [, r3RequestId] = countRequest(r3)
1569
- receiveCount(r2, { count: 3 })
1570
- const result = await resultPromise
1571
-
1572
- receiveRelayMessage(r3, ['COUNT', r3RequestId, { count: 99 }])
1573
- await tick()
1574
- assert.equal(result.count, 3)
1575
- })
1576
-
1577
- it('requires exactly one filter', async () => {
1578
- await assert.rejects(
1579
- nostr.countEvents([{ kinds: [1] }], ['wss://r1']),
1580
- /COUNT_FILTER_REQUIRED/
1581
- )
1582
- })
1583
- })
1584
-
1585
- describe('RelayPool.getEventsGenerator', () => {
1586
- let nostr
1587
-
1588
- beforeEach(() => {
1589
- _nextId = 1
1590
- relayRegistry.clear()
1591
- connectOverrides.clear()
1592
- nostr = new RelayPool()
1593
- })
1594
-
1595
- it('yields event items', async () => {
1596
- const { events: items, promise } = startCollecting(
1597
- nostr.getEventsGenerator({ kinds: [0] }, ['wss://r1'])
1598
- )
1599
- await tick()
1600
- const sub = relayRegistry.get('wss://r1').subscriptions[0]
1601
- sub.handlers.onevent(makeEvent({ id: 'e1' }))
1602
- sub.handlers.onevent(makeEvent({ id: 'e2' }))
1603
- sub.handlers.oneose()
1604
- await promise
1605
- assert.equal(items.length, 2)
1606
- assert.equal(items[0].type, 'event')
1607
- assert.equal(items[0].event.id, 'e1')
1608
- assert.equal(items[0].relay, 'wss://r1')
1609
- })
1610
-
1611
- it('yields error items when relay closes with error', async () => {
1612
- const { events: items, promise } = startCollecting(
1613
- nostr.getEventsGenerator({ kinds: [0] }, ['wss://r1'])
1614
- )
1615
- await tick()
1616
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onclose(new Error('boom'))
1617
- await promise
1618
- assert.ok(items.some(i => i.type === 'error' && i.relay === 'wss://r1'))
1619
- })
1620
-
1621
- it('completes once getEvents resolves', async () => {
1622
- const { events: items, promise } = startCollecting(
1623
- nostr.getEventsGenerator({ kinds: [0] }, ['wss://r1'])
1624
- )
1625
- await tick()
1626
- relayRegistry.get('wss://r1').subscriptions[0].handlers.onevent(makeEvent({ id: 'e1' }))
1627
- relayRegistry.get('wss://r1').subscriptions[0].handlers.oneose()
1628
- await promise
1629
- assert.equal(items.length, 1)
1630
- })
1631
-
1632
- it('completes when getEvents reaches its overall timeout', async () => {
1633
- const { events: items, promise } = startCollecting(
1634
- nostr.getEventsGenerator({ kinds: [0] }, ['wss://r1'], { timeout: 30 })
1635
- )
1636
- await promise
1637
- assert.equal(items.length, 0)
1638
- })
1639
- })
1640
-
1641
- describe('RelayPool.sendEvent', () => {
1642
- let nostr
1643
-
1644
- beforeEach(() => {
1645
- _nextId = 1
1646
- relayRegistry.clear()
1647
- connectOverrides.clear()
1648
- publishOverrides.clear()
1649
- sendOverrides.clear()
1650
- nostr = new RelayPool()
1651
- })
1652
-
1653
- it('returns after the first accepted relay and keeps a full settlement promise', async () => {
1654
- const delayed = deferred()
1655
- const relayResults = []
1656
- publishOverrides.set('wss://r2', () => delayed.promise)
1657
- const event = { id: 'ev1', kind: 1, created_at: 100, tags: [], content: '' }
1658
- const early = await nostr.sendEvent(event, ['wss://r1', 'wss://r2'], {
1659
- timeoutUntilFirstFulfillment: 100,
1660
- timeout: 1000,
1661
- onRelayResult: result => relayResults.push(result)
1662
- })
1663
-
1664
- assert.equal(early.total, 2)
1665
- assert.equal(early.success, true)
1666
- assert.deepEqual(relayResults, [{
1667
- relay: 'wss://r1',
1668
- success: true,
1669
- outcome: 'published'
1670
- }])
1671
-
1672
- delayed.reject(new Error('relay failed'))
1673
- const full = await early.promise
1674
- assert.equal(full.success, true)
1675
- assert.equal(full.total, 2)
1676
- assert.equal(full.fulfilled, 1)
1677
- assert.deepEqual(full.succeededRelays, ['wss://r1'])
1678
- assert.equal(full.errors.length, 1)
1679
- assert.deepEqual(relayResults.map(({ reason, ...result }) => result), [{
1680
- relay: 'wss://r1',
1681
- success: true,
1682
- outcome: 'published'
1683
- }, {
1684
- relay: 'wss://r2',
1685
- success: false,
1686
- outcome: 'failed'
1687
- }])
1688
- assert.equal(relayResults[1].reason.message, 'relay failed')
1689
- })
1690
-
1691
- it('strips event.meta before publishing', async () => {
1692
- let published
1693
- publishOverrides.set('wss://r1', e => { published = e })
1694
- const event = { id: 'ev1', kind: 1, created_at: 100, tags: [], content: '', meta: { relay: 'wss://old' } }
1695
- const early = await nostr.sendEvent(event, ['wss://r1'])
1696
- await early.promise
1697
- assert.ok(!('meta' in published), 'meta should be stripped before publish')
1698
- })
1699
-
1700
- it('treats duplicate: error as success', async () => {
1701
- const relayResults = []
1702
- publishOverrides.set('wss://r1', () => { throw new Error('duplicate: already have this event') })
1703
- const event = { id: 'ev1', kind: 1, created_at: 100, tags: [], content: '' }
1704
- const early = await nostr.sendEvent(event, ['wss://r1'], {
1705
- onRelayResult: result => relayResults.push(result)
1706
- })
1707
- const full = await early.promise
1708
- assert.ok(early.success)
1709
- assert.deepEqual(full.succeededRelays, ['wss://r1'])
1710
- assert.equal(full.errors.length, 0)
1711
- assert.deepEqual(relayResults, [{
1712
- relay: 'wss://r1',
1713
- success: true,
1714
- outcome: 'duplicate'
1715
- }])
1716
- })
1717
-
1718
- it('treats mute: error as success', async () => {
1719
- const relayResults = []
1720
- publishOverrides.set('wss://r1', () => { throw new Error('mute: author blocked') })
1721
- const event = { id: 'ev1', kind: 1, created_at: 100, tags: [], content: '' }
1722
- const early = await nostr.sendEvent(event, ['wss://r1'], {
1723
- onRelayResult: result => relayResults.push(result)
1724
- })
1725
- const full = await early.promise
1726
- assert.ok(early.success)
1727
- assert.deepEqual(full.succeededRelays, ['wss://r1'])
1728
- assert.equal(full.errors.length, 0)
1729
- assert.deepEqual(relayResults, [{
1730
- relay: 'wss://r1',
1731
- success: true,
1732
- outcome: 'muted'
1733
- }])
1734
- })
1735
-
1736
- it('retries auth-required publishes after sending the caller AUTH event', async () => {
1737
- const authRequests = []
1738
- let publishCount = 0
1739
- publishOverrides.set('wss://r1', () => {
1740
- publishCount++
1741
- if (publishCount === 1) {
1742
- receiveRelayMessage(relayRegistry.get('wss://r1'), ['AUTH', 'challenge-one'])
1743
- throw new Error('auth-required: sign in first')
1744
- }
1745
- })
1746
- sendOverrides.set('wss://r1', (message, relay) => {
1747
- const [type, authEvent] = JSON.parse(message)
1748
- assert.equal(type, 'AUTH')
1749
- receiveRelayMessage(relay, ['OK', authEvent.id, true, ''])
1750
- })
1751
-
1752
- const early = await nostr.sendEvent(makeEvent({ id: 'publish-one' }), ['wss://r1'], {
1753
- getAuthEvent: request => {
1754
- authRequests.push(request)
1755
- return { id: 'auth-one', kind: 22242, pubkey: 'alice' }
1756
- }
1757
- })
1758
- const full = await early.promise
1759
-
1760
- assert.equal(publishCount, 2)
1761
- assert.deepEqual(authRequests, [{ relay: 'wss://r1/', challenge: 'challenge-one' }])
1762
- assert.deepEqual(full.succeededRelays, ['wss://r1'])
1763
- })
1764
-
1765
- it('leaves auth-required publishes failed when no getAuthEvent is supplied', async () => {
1766
- let publishCount = 0
1767
- publishOverrides.set('wss://r1', () => {
1768
- publishCount++
1769
- receiveRelayMessage(relayRegistry.get('wss://r1'), ['AUTH', 'challenge-one'])
1770
- throw new Error('auth-required: sign in first')
1771
- })
1772
-
1773
- const early = await nostr.sendEvent(makeEvent({ id: 'publish-one' }), ['wss://r1'])
1774
- const full = await early.promise
1775
-
1776
- assert.equal(publishCount, 1)
1777
- assert.equal(relayRegistry.get('wss://r1').sentMessages?.length ?? 0, 0)
1778
- assert.equal(full.errors[0].reason.message, 'auth-required: sign in first')
1779
- })
1780
-
1781
- it('authenticates once after restricted so the current caller can retry', async () => {
1782
- let publishCount = 0
1783
- publishOverrides.set('wss://r1', () => {
1784
- publishCount++
1785
- if (publishCount === 1) {
1786
- receiveRelayMessage(relayRegistry.get('wss://r1'), ['AUTH', 'shared-challenge'])
1787
- throw new Error('restricted: another identity is not allowed')
1788
- }
1789
- })
1790
- sendOverrides.set('wss://r1', (message, relay) => {
1791
- const [, authEvent] = JSON.parse(message)
1792
- receiveRelayMessage(relay, ['OK', authEvent.id, true, ''])
1793
- })
1794
-
1795
- const early = await nostr.sendEvent(makeEvent({ id: 'publish-one' }), ['wss://r1'], {
1796
- getAuthEvent: ({ relay, challenge }) => ({
1797
- id: 'auth-current-caller', kind: 22242, pubkey: 'current', relay, challenge
1798
- })
1799
- })
1800
- const full = await early.promise
1801
-
1802
- assert.equal(publishCount, 2)
1803
- assert.deepEqual(full.succeededRelays, ['wss://r1'])
1804
- })
1805
-
1806
- it('does not retry again when a post-auth publish remains restricted', async () => {
1807
- let publishCount = 0
1808
- let authCount = 0
1809
- publishOverrides.set('wss://r1', () => {
1810
- publishCount++
1811
- if (publishCount === 1) receiveRelayMessage(relayRegistry.get('wss://r1'), ['AUTH', 'challenge-one'])
1812
- throw new Error('restricted: still not allowed')
1813
- })
1814
- sendOverrides.set('wss://r1', (message, relay) => {
1815
- authCount++
1816
- const [, authEvent] = JSON.parse(message)
1817
- receiveRelayMessage(relay, ['OK', authEvent.id, true, ''])
1818
- })
1819
-
1820
- const early = await nostr.sendEvent(makeEvent({ id: 'publish-one' }), ['wss://r1'], {
1821
- getAuthEvent: () => ({ id: 'auth-one', kind: 22242, pubkey: 'alice' })
1822
- })
1823
- const full = await early.promise
1824
-
1825
- assert.equal(publishCount, 2)
1826
- assert.equal(authCount, 1)
1827
- assert.equal(full.errors[0].reason.message, 'restricted: still not allowed')
1828
- })
1829
-
1830
- it('keeps multiple caller auth events on the same relay connection', async () => {
1831
- const authenticatedPubkeys = new Set()
1832
- const authEventIds = []
1833
- publishOverrides.set('wss://r1', event => {
1834
- if (authenticatedPubkeys.has(event.pubkey)) return
1835
- receiveRelayMessage(relayRegistry.get('wss://r1'), ['AUTH', 'shared-challenge'])
1836
- throw new Error('auth-required: sign in first')
1837
- })
1838
- sendOverrides.set('wss://r1', (message, relay) => {
1839
- const [, authEvent] = JSON.parse(message)
1840
- authEventIds.push(authEvent.id)
1841
- authenticatedPubkeys.add(authEvent.pubkey)
1842
- receiveRelayMessage(relay, ['OK', authEvent.id, true, ''])
1843
- })
1844
-
1845
- const first = await nostr.sendEvent({ ...makeEvent({ id: 'publish-alice' }), pubkey: 'alice' }, ['wss://r1'], {
1846
- getAuthEvent: () => ({ id: 'auth-alice', kind: 22242, pubkey: 'alice' })
1847
- })
1848
- await first.promise
1849
- const relay = relayRegistry.get('wss://r1')
1850
-
1851
- const second = await nostr.sendEvent({ ...makeEvent({ id: 'publish-bob' }), pubkey: 'bob' }, ['wss://r1'], {
1852
- getAuthEvent: () => ({ id: 'auth-bob', kind: 22242, pubkey: 'bob' })
1853
- })
1854
- await second.promise
1855
-
1856
- assert.equal(relayRegistry.get('wss://r1'), relay)
1857
- assert.deepEqual(authEventIds, ['auth-alice', 'auth-bob'])
1858
- })
1859
-
1860
- it('fails auth cleanly when no relay challenge was received', async () => {
1861
- let authCalls = 0
1862
- publishOverrides.set('wss://r1', () => { throw new Error('restricted: not allowed') })
1863
-
1864
- const early = await nostr.sendEvent(makeEvent({ id: 'publish-one' }), ['wss://r1'], {
1865
- getAuthEvent: () => {
1866
- authCalls++
1867
- return { id: 'auth-one', kind: 22242 }
1868
- }
1869
- })
1870
- const full = await early.promise
1871
-
1872
- assert.equal(authCalls, 0)
1873
- assert.equal(full.errors[0].reason.message, 'AUTH_CHALLENGE_MISSING')
1874
- })
1875
-
1876
- it('does not retry the event when the relay rejects its AUTH event', async () => {
1877
- let publishCount = 0
1878
- publishOverrides.set('wss://r1', () => {
1879
- publishCount++
1880
- receiveRelayMessage(relayRegistry.get('wss://r1'), ['AUTH', 'challenge-one'])
1881
- throw new Error('auth-required: sign in first')
1882
- })
1883
- sendOverrides.set('wss://r1', (message, relay) => {
1884
- const [, authEvent] = JSON.parse(message)
1885
- receiveRelayMessage(relay, ['OK', authEvent.id, false, 'restricted: AUTH is not allowed'])
1886
- })
1887
-
1888
- const early = await nostr.sendEvent(makeEvent({ id: 'publish-one' }), ['wss://r1'], {
1889
- getAuthEvent: () => ({ id: 'auth-one', kind: 22242, pubkey: 'alice' })
1890
- })
1891
- const full = await early.promise
1892
-
1893
- assert.equal(publishCount, 1)
1894
- assert.equal(full.errors[0].reason.message, 'restricted: AUTH is not allowed')
1895
- })
1896
-
1897
- it('does not mistake an AUTH rejection for a duplicate published event', async () => {
1898
- let publishCount = 0
1899
- publishOverrides.set('wss://r1', () => {
1900
- publishCount++
1901
- receiveRelayMessage(relayRegistry.get('wss://r1'), ['AUTH', 'challenge-one'])
1902
- throw new Error('auth-required: sign in first')
1903
- })
1904
- sendOverrides.set('wss://r1', (message, relay) => {
1905
- const [, authEvent] = JSON.parse(message)
1906
- receiveRelayMessage(relay, ['OK', authEvent.id, false, 'duplicate: auth already exists'])
1907
- })
1908
-
1909
- const early = await nostr.sendEvent(makeEvent({ id: 'publish-one' }), ['wss://r1'], {
1910
- getAuthEvent: () => ({ id: 'auth-one', kind: 22242, pubkey: 'alice' })
1911
- })
1912
- const full = await early.promise
1913
-
1914
- assert.equal(publishCount, 1)
1915
- assert.equal(full.success, false)
1916
- assert.equal(full.errors[0].reason.message, 'duplicate: auth already exists')
1917
- })
1918
-
1919
- it('does not authenticate while reading relay events', async () => {
1920
- const resultPromise = nostr.getEvents({ kinds: [0] }, ['wss://r1'])
1921
- await tick()
1922
- const relay = relayRegistry.get('wss://r1')
1923
- receiveRelayMessage(relay, ['AUTH', 'read-challenge'])
1924
- relay.subscriptions[0].handlers.oneose()
1925
- await resultPromise
1926
-
1927
- assert.equal(relay.sentMessages?.length ?? 0, 0)
1928
- })
1929
-
1930
- it('reports failed relays with their reasons', async () => {
1931
- publishOverrides.set('wss://r1', () => { throw new Error('invalid: bad event') })
1932
- const event = { id: 'ev1', kind: 1, created_at: 100, tags: [], content: '' }
1933
- const early = await nostr.sendEvent(event, ['wss://r1'])
1934
- const full = await early.promise
1935
- assert.ok(!early.success)
1936
- assert.equal(full.success, false)
1937
- assert.deepEqual(full.succeededRelays, [])
1938
- assert.equal(full.errors.length, 1)
1939
- assert.equal(full.errors[0].relay, 'wss://r1')
1940
- assert.ok(full.errors[0].reason.message.includes('invalid'))
1941
- })
1942
-
1943
- it('includes both accepted and failed relays in the final report', async () => {
1944
- publishOverrides.set('wss://r2', () => { throw new Error('invalid: bad event') })
1945
- const event = { id: 'ev1', kind: 1, created_at: 100, tags: [], content: '' }
1946
- const early = await nostr.sendEvent(event, ['wss://r1', 'wss://r2'])
1947
- const full = await early.promise
1948
- assert.ok(early.success)
1949
- assert.equal(full.success, true)
1950
- assert.equal(full.fulfilled, 1)
1951
- assert.deepEqual(full.succeededRelays, ['wss://r1'])
1952
- assert.equal(full.errors.length, 1)
1953
- assert.equal(full.errors[0].relay, 'wss://r2')
1954
- })
1955
-
1956
- it('turns an unsuccessful first-fulfillment timeout into an operation timeout', async () => {
1957
- const delayed = deferred()
1958
- publishOverrides.set('wss://r1', () => delayed.promise)
1959
- const event = { id: 'ev1', kind: 1, created_at: 100, tags: [], content: '' }
1960
- const early = await nostr.sendEvent(event, ['wss://r1'], {
1961
- timeoutUntilFirstFulfillment: 10,
1962
- timeout: 1000
1963
- })
1964
-
1965
- assert.equal(early.success, false)
1966
- const full = await early.promise
1967
- assert.equal(full.success, false)
1968
- assert.deepEqual(full.succeededRelays, [])
1969
- assert.equal(full.errors[0].reason.message, 'PUBLISH_TIMEOUT')
1970
-
1971
- delayed.resolve()
1972
- })
1973
-
1974
- it('uses the overall timeout when timeoutUntilFirstFulfillment is null', async () => {
1975
- const relayResults = []
1976
- publishOverrides.set('wss://r1', () => new Promise(() => {}))
1977
- const pending = nostr.sendEvent(makeEvent({ id: 'ev1' }), ['wss://r1'], {
1978
- timeout: 20,
1979
- timeoutUntilFirstFulfillment: null,
1980
- onRelayResult: result => relayResults.push(result)
1981
- })
1982
- let returned = false
1983
- pending.then(() => { returned = true })
1984
-
1985
- await new Promise(resolve => setTimeout(resolve, 10))
1986
- assert.ok(!returned, 'null should disable only the early fulfillment timer')
1987
-
1988
- const early = await pending
1989
- const full = await early.promise
1990
- assert.equal(early.success, false)
1991
- assert.equal(full.success, false)
1992
- assert.equal(relayResults.length, 1)
1993
- assert.equal(relayResults[0].outcome, 'timed-out')
1994
- })
1995
-
1996
- it('allows an overall timeout to be disabled', async () => {
1997
- const delayed = deferred()
1998
- publishOverrides.set('wss://r1', () => delayed.promise)
1999
- const pending = nostr.sendEvent(makeEvent({ id: 'ev1' }), ['wss://r1'], {
2000
- timeout: null,
2001
- timeoutUntilFirstFulfillment: null
2002
- })
2003
- let returned = false
2004
- pending.then(() => { returned = true })
2005
-
2006
- await tick()
2007
- assert.ok(!returned, 'disabled timers should not be coerced to zero')
2008
- delayed.resolve()
2009
-
2010
- const early = await pending
2011
- const full = await early.promise
2012
- assert.equal(early.success, true)
2013
- assert.equal(full.success, true)
2014
- })
2015
-
2016
- it('records an operation timeout without cancelling the underlying publish', async () => {
2017
- const relayResults = []
2018
- const first = deferred()
2019
- const second = deferred()
2020
- publishOverrides.set('wss://r1', () => first.promise)
2021
- publishOverrides.set('wss://r2', () => second.promise)
2022
- const event = { id: 'ev1', kind: 1, created_at: 100, tags: [], content: '' }
2023
- const early = await nostr.sendEvent(event, ['wss://r1', 'wss://r2'], {
2024
- timeoutUntilFirstFulfillment: null,
2025
- timeout: 10,
2026
- onRelayResult: result => relayResults.push(result)
2027
- })
2028
- const full = await early.promise
2029
-
2030
- assert.equal(early.success, false)
2031
- assert.equal(full.success, false)
2032
- assert.deepEqual(full.errors.map(({ relay, reason }) => [relay, reason.message]), [
2033
- ['wss://r1', 'PUBLISH_TIMEOUT'],
2034
- ['wss://r2', 'PUBLISH_TIMEOUT']
2035
- ])
2036
- assert.deepEqual(relayResults.map(({ relay, outcome }) => [relay, outcome]), [
2037
- ['wss://r1', 'timed-out'],
2038
- ['wss://r2', 'timed-out']
2039
- ])
2040
-
2041
- first.resolve()
2042
- second.reject(new Error('late failure'))
2043
- await tick()
2044
- assert.equal(relayResults.length, 2, 'late outcomes must not alter the finalized report')
2045
- })
2046
-
2047
- it('returns an immediately settled failure report when given no relays', async () => {
2048
- const early = await nostr.sendEvent({ id: 'ev1' }, [])
2049
- const full = await early.promise
2050
- assert.deepEqual(early, {
2051
- total: 0,
2052
- success: false,
2053
- promise: early.promise
2054
- })
2055
- assert.deepEqual(full, {
2056
- success: false,
2057
- total: 0,
2058
- fulfilled: 0,
2059
- errors: [],
2060
- succeededRelays: []
2061
- })
2062
- })
2063
- })