libp2r2p 0.0.2 → 0.1.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.
- package/base93/index.js +193 -0
- package/package.json +23 -1
- package/AGENTS.md +0 -20
- package/tests/base16.test.js +0 -19
- package/tests/base64.test.js +0 -18
- package/tests/content-key-event.test.js +0 -48
- package/tests/fixtures/nip44v3-vectors.json +0 -1
- package/tests/helpers/test-signer.js +0 -185
- package/tests/idb-queue.test.js +0 -153
- package/tests/idb.test.js +0 -91
- package/tests/nip44-v3.test.js +0 -73
- package/tests/nip46.test.js +0 -668
- package/tests/private-channel.test.js +0 -1912
- package/tests/private-message.test.js +0 -501
- package/tests/private-messenger.test.js +0 -1737
- package/tests/queries.test.js +0 -101
- package/tests/queue-parity.test.js +0 -105
- package/tests/relay-hll.test.js +0 -32
- package/tests/relay-pool.test.js +0 -2063
- package/tests/temporary-storage.test.js +0 -89
- package/tests/web-storage-queue.test.js +0 -480
package/tests/relay-pool.test.js
DELETED
|
@@ -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
|
-
})
|