applesauce-relay 0.12.0 → 1.0.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.
@@ -0,0 +1,537 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { subscribeSpyTo } from "@hirez_io/observer-spy";
3
+ import { getSeenRelays } from "applesauce-core/helpers";
4
+ import { WS } from "vitest-websocket-mock";
5
+ import { Relay } from "../relay.js";
6
+ import { filter } from "rxjs/operators";
7
+ import { firstValueFrom, of, throwError, timer } from "rxjs";
8
+ const defaultMockInfo = {
9
+ name: "Test Relay",
10
+ description: "Test Relay Description",
11
+ pubkey: "testpubkey",
12
+ contact: "test@example.com",
13
+ supported_nips: [1, 2, 3],
14
+ software: "test-software",
15
+ version: "1.0.0",
16
+ };
17
+ let server;
18
+ let relay;
19
+ beforeEach(async () => {
20
+ // Mock empty information document
21
+ vi.spyOn(Relay, "fetchInformationDocument").mockImplementation(() => of(null));
22
+ // Create mock relay
23
+ server = new WS("wss://test", { jsonProtocol: true });
24
+ // Create relay
25
+ relay = new Relay("wss://test");
26
+ relay.keepAlive = 0;
27
+ });
28
+ // Wait for server to close to prevent memory leaks
29
+ afterEach(async () => {
30
+ await WS.clean();
31
+ vi.clearAllTimers();
32
+ vi.useRealTimers();
33
+ });
34
+ const mockEvent = {
35
+ kind: 1,
36
+ id: "00007641c9c3e65a71843933a44a18060c7c267a4f9169efa3735ece45c8f621",
37
+ pubkey: "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
38
+ created_at: 1743712795,
39
+ tags: [["nonce", "13835058055282167643", "16"]],
40
+ content: "This is just stupid: https://codestr.fiatjaf.com/",
41
+ sig: "5a57b5a12bba4b7cf0121077b1421cf4df402c5c221376c076204fc4f7519e28ce6508f26ddc132c406ccfe6e62cc6db857b96c788565cdca9674fe9a0710ac2",
42
+ };
43
+ describe("req", () => {
44
+ it("should trigger connection to relay", async () => {
45
+ subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
46
+ // Wait for connection
47
+ await firstValueFrom(relay.connected$.pipe(filter(Boolean)));
48
+ expect(relay.connected).toBe(true);
49
+ });
50
+ it("should send REQ and CLOSE messages", async () => {
51
+ // Create subscription that completes after first EOSE
52
+ const sub = relay.req([{ kinds: [1] }], "sub1").subscribe();
53
+ // Verify REQ was sent
54
+ expect(await server.nextMessage).toEqual(["REQ", "sub1", { kinds: [1] }]);
55
+ // Send EOSE to complete subscription
56
+ server.send(["EOSE", "sub1"]);
57
+ // Complete the subscription
58
+ sub.unsubscribe();
59
+ // Verify CLOSE was sent
60
+ expect(await server.nextMessage).toEqual(["CLOSE", "sub1"]);
61
+ });
62
+ it("should emit nostr event and EOSE", async () => {
63
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
64
+ await server.connected;
65
+ // Send EVENT message
66
+ server.send(["EVENT", "sub1", mockEvent]);
67
+ // Send EOSE message
68
+ server.send(["EOSE", "sub1"]);
69
+ expect(spy.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
70
+ });
71
+ it("should ignore EVENT and EOSE messages that do not match subscription id", async () => {
72
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
73
+ await server.connected;
74
+ // Send EVENT message with wrong subscription id
75
+ server.send(["EVENT", "wrong_sub", mockEvent]);
76
+ // Send EOSE message with wrong subscription id
77
+ server.send(["EOSE", "wrong_sub"]);
78
+ // Send EVENT message with correct subscription id
79
+ server.send(["EVENT", "sub1", mockEvent]);
80
+ // Send EOSE message with correct subscription id
81
+ server.send(["EOSE", "sub1"]);
82
+ expect(spy.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
83
+ });
84
+ it("should mark events with their source relay", async () => {
85
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
86
+ await server.connected;
87
+ // Send EVENT message
88
+ server.send(["EVENT", "sub1", mockEvent]);
89
+ // Get the received event
90
+ const receivedEvent = spy.getValues()[0];
91
+ // Verify the event was marked as seen from this relay
92
+ expect(getSeenRelays(receivedEvent)).toContain("wss://test");
93
+ });
94
+ it("should error subscription when CLOSED message is received", async () => {
95
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"), { expectErrors: true });
96
+ await server.connected;
97
+ // Send CLOSED message for the subscription
98
+ server.send(["CLOSED", "sub1", "reason"]);
99
+ // Verify the subscription completed
100
+ expect(spy.receivedError()).toBe(true);
101
+ });
102
+ it("should wait for authentication if relay responds with auth-required", async () => {
103
+ // First subscription to trigger auth-required
104
+ const firstSub = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"), { expectErrors: true });
105
+ await server.nextMessage;
106
+ // Send CLOSED message with auth-required reason
107
+ server.send(["CLOSED", "sub1", "auth-required: need to authenticate"]);
108
+ // wait for complete
109
+ await firstSub.onError();
110
+ await server.nextMessage;
111
+ // Create a second subscription that should wait for auth
112
+ const secondSub = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub2"), { expectErrors: true });
113
+ // Verify no REQ message was sent yet (waiting for auth)
114
+ expect(server).not.toHaveReceivedMessages(["REQ", "sub2", { kinds: [1] }]);
115
+ // Simulate successful authentication
116
+ relay.authenticated$.next(true);
117
+ // Now the REQ should be sent
118
+ await expect(server).toReceiveMessage(["REQ", "sub2", { kinds: [1] }]);
119
+ // Send EVENT and EOSE to complete the subscription
120
+ server.send(["EVENT", "sub2", mockEvent]);
121
+ server.send(["EOSE", "sub2"]);
122
+ // Verify the second subscription received the event and EOSE
123
+ expect(secondSub.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
124
+ });
125
+ it("should wait for authentication if relay info document has limitations.auth_required = true", async () => {
126
+ // Mock the fetchInformationDocument method to return a document with auth_required = true
127
+ vi.spyOn(Relay, "fetchInformationDocument").mockImplementation(() => of({
128
+ name: "Auth Required Relay",
129
+ description: "A relay that requires authentication",
130
+ pubkey: "",
131
+ contact: "",
132
+ supported_nips: [1, 2, 4],
133
+ software: "",
134
+ version: "",
135
+ limitation: {
136
+ auth_required: true,
137
+ },
138
+ }));
139
+ // Create a subscription that should wait for auth
140
+ const sub = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
141
+ // Wait 10ms to ensure the information document is fetched
142
+ await new Promise((resolve) => setTimeout(resolve, 10));
143
+ // Verify no REQ message was sent yet (waiting for auth)
144
+ expect(server).not.toHaveReceivedMessages(["REQ", "sub1", { kinds: [1] }]);
145
+ // Simulate successful authentication
146
+ relay.authenticated$.next(true);
147
+ // Now the REQ should be sent
148
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
149
+ // Send EVENT and EOSE to complete the subscription
150
+ server.send(["EVENT", "sub1", mockEvent]);
151
+ server.send(["EOSE", "sub1"]);
152
+ // Verify the subscription received the event and EOSE
153
+ expect(sub.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
154
+ });
155
+ it("should throw error if relay closes connection with error", async () => {
156
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"), { expectErrors: true });
157
+ await server.connected;
158
+ // Send CLOSE message with error
159
+ server.error({
160
+ reason: "error message",
161
+ code: 1000,
162
+ wasClean: false,
163
+ });
164
+ // Verify the subscription completed with an error
165
+ expect(spy.receivedError()).toBe(true);
166
+ });
167
+ it("should not return EOSE while waiting for the relay to be ready", async () => {
168
+ vi.useFakeTimers();
169
+ // @ts-expect-error
170
+ relay.ready$.next(false);
171
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"), { expectErrors: true });
172
+ // Fast-forward time by 20 seconds
173
+ await vi.advanceTimersByTimeAsync(20000);
174
+ expect(spy.receivedComplete()).toBe(false);
175
+ expect(spy.receivedError()).toBe(false);
176
+ expect(spy.receivedNext()).toBe(false);
177
+ });
178
+ it("should wait when relay isn't ready", async () => {
179
+ // @ts-expect-error
180
+ relay.ready$.next(false);
181
+ subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
182
+ // Wait 10ms to ensure the relay didn't receive anything
183
+ await new Promise((resolve) => setTimeout(resolve, 10));
184
+ expect(server.messages.length).toBe(0);
185
+ // @ts-expect-error
186
+ relay.ready$.next(true);
187
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
188
+ });
189
+ });
190
+ describe("event", () => {
191
+ it("should wait for authentication if relay responds with auth-required", async () => {
192
+ // First event to trigger auth-required
193
+ const firstSpy = subscribeSpyTo(relay.event(mockEvent));
194
+ await expect(server).toReceiveMessage(["EVENT", mockEvent]);
195
+ // Send OK with auth-required message
196
+ server.send(["OK", mockEvent.id, false, "auth-required: need to authenticate"]);
197
+ await firstSpy.onComplete();
198
+ // Create a second event that should wait for auth
199
+ const secondSpy = subscribeSpyTo(relay.event(mockEvent));
200
+ // Verify no EVENT message was sent yet (waiting for auth)
201
+ expect(server).not.toHaveReceivedMessages(["EVENT", mockEvent]);
202
+ // Simulate successful authentication
203
+ relay.authenticated$.next(true);
204
+ // Now the EVENT should be sent
205
+ await expect(server).toReceiveMessage(["EVENT", mockEvent]);
206
+ // Send OK response to complete the event
207
+ server.send(["OK", mockEvent.id, true, ""]);
208
+ // Verify the second event completed successfully
209
+ await secondSpy.onComplete();
210
+ expect(secondSpy.receivedComplete()).toBe(true);
211
+ });
212
+ it("should wait for authentication if relay info document has limitations.auth_required = true", async () => {
213
+ // Mock the fetchInformationDocument method to return a document with auth_required = true
214
+ vi.spyOn(Relay, "fetchInformationDocument").mockImplementation(() => of({
215
+ name: "Auth Required Relay",
216
+ description: "A relay that requires authentication",
217
+ pubkey: "",
218
+ contact: "",
219
+ supported_nips: [1, 2, 4],
220
+ software: "",
221
+ version: "",
222
+ limitation: {
223
+ auth_required: true,
224
+ },
225
+ }));
226
+ // Create a subscription that should wait for auth
227
+ const sub = subscribeSpyTo(relay.event(mockEvent));
228
+ // Wait 10ms to ensure the information document is fetched
229
+ await new Promise((resolve) => setTimeout(resolve, 10));
230
+ // Verify no REQ message was sent yet (waiting for auth)
231
+ expect(server).not.toHaveReceivedMessages(["EVENT", mockEvent]);
232
+ // Simulate successful authentication
233
+ relay.authenticated$.next(true);
234
+ // Now the REQ should be sent
235
+ await expect(server).toReceiveMessage(["EVENT", mockEvent]);
236
+ // Send EVENT and EOSE to complete the subscription
237
+ server.send(["OK", mockEvent.id, true, ""]);
238
+ // Verify the subscription completed
239
+ await sub.onComplete();
240
+ expect(sub.receivedComplete()).toBe(true);
241
+ });
242
+ it("should trigger connection to relay", async () => {
243
+ subscribeSpyTo(relay.event(mockEvent));
244
+ // Wait for connection
245
+ await firstValueFrom(relay.connected$.pipe(filter(Boolean)));
246
+ expect(relay.connected).toBe(true);
247
+ });
248
+ it("observable should complete when matching OK response received", async () => {
249
+ const spy = subscribeSpyTo(relay.event(mockEvent));
250
+ // Verify EVENT message was sent
251
+ expect(await server.nextMessage).toEqual(["EVENT", mockEvent]);
252
+ // Send matching OK response
253
+ server.send(["OK", mockEvent.id, true, ""]);
254
+ await spy.onComplete();
255
+ expect(spy.receivedComplete()).toBe(true);
256
+ });
257
+ it("should ignore OK responses for different events", async () => {
258
+ const spy = subscribeSpyTo(relay.event(mockEvent));
259
+ await server.connected;
260
+ // Send non-matching OK response
261
+ server.send(["OK", "different_id", true, ""]);
262
+ expect(spy.receivedComplete()).toBe(false);
263
+ // Send matching OK response
264
+ server.send(["OK", mockEvent.id, true, ""]);
265
+ expect(spy.receivedComplete()).toBe(true);
266
+ });
267
+ it("should send EVENT message to relay", async () => {
268
+ relay.event(mockEvent).subscribe();
269
+ expect(await server.nextMessage).toEqual(["EVENT", mockEvent]);
270
+ });
271
+ it("should complete with error if no OK received within 10s", async () => {
272
+ vi.useFakeTimers();
273
+ const spy = subscribeSpyTo(relay.event(mockEvent));
274
+ // Fast-forward time by 10 seconds
275
+ await vi.advanceTimersByTimeAsync(10000);
276
+ expect(spy.receivedComplete()).toBe(true);
277
+ expect(spy.getLastValue()).toEqual({ ok: false, from: "wss://test", message: "Timeout" });
278
+ });
279
+ it("should throw error if relay closes connection with error", async () => {
280
+ const spy = subscribeSpyTo(relay.event(mockEvent), { expectErrors: true });
281
+ await server.connected;
282
+ // Send CLOSE message with error
283
+ server.error({
284
+ reason: "error message",
285
+ code: 1000,
286
+ wasClean: false,
287
+ });
288
+ // Verify the subscription completed with an error
289
+ expect(spy.receivedError()).toBe(true);
290
+ });
291
+ it("should not throw a timeout error while waiting for the relay to be ready", async () => {
292
+ vi.useFakeTimers();
293
+ // @ts-expect-error
294
+ relay.ready$.next(false);
295
+ const spy = subscribeSpyTo(relay.event(mockEvent), { expectErrors: true });
296
+ // Fast-forward time by 20 seconds
297
+ await vi.advanceTimersByTimeAsync(20000);
298
+ expect(spy.receivedComplete()).toBe(false);
299
+ expect(spy.receivedError()).toBe(false);
300
+ });
301
+ it("should wait when relay isn't ready", async () => {
302
+ // @ts-expect-error
303
+ relay.ready$.next(false);
304
+ subscribeSpyTo(relay.event(mockEvent));
305
+ // Wait 10ms to ensure the relay didn't receive anything
306
+ await new Promise((resolve) => setTimeout(resolve, 10));
307
+ expect(server.messages.length).toBe(0);
308
+ // @ts-expect-error
309
+ relay.ready$.next(true);
310
+ await expect(server).toReceiveMessage(["EVENT", mockEvent]);
311
+ });
312
+ });
313
+ describe("notices$", () => {
314
+ it("should not trigger connection to relay", async () => {
315
+ subscribeSpyTo(relay.notices$);
316
+ expect(relay.connected).toBe(false);
317
+ });
318
+ it("should accumulate notices in notices$ state", async () => {
319
+ subscribeSpyTo(relay.req({ kinds: [1] }));
320
+ // Send multiple NOTICE messages
321
+ server.send(["NOTICE", "Notice 1"]);
322
+ server.send(["NOTICE", "Notice 2"]);
323
+ server.send(["NOTICE", "Notice 3"]);
324
+ // Verify the notices state contains all messages
325
+ expect(relay.notices$.value).toEqual(["Notice 1", "Notice 2", "Notice 3"]);
326
+ });
327
+ it("should ignore non-NOTICE messages", async () => {
328
+ subscribeSpyTo(relay.req({ kinds: [1] }));
329
+ server.send(["NOTICE", "Important notice"]);
330
+ server.send(["OTHER", "other message"]);
331
+ // Verify only NOTICE messages are in the state
332
+ expect(relay.notices$.value).toEqual(["Important notice"]);
333
+ });
334
+ });
335
+ describe("challenge$", () => {
336
+ it("should not trigger connection to relay", async () => {
337
+ subscribeSpyTo(relay.challenge$);
338
+ expect(relay.connected).toBe(false);
339
+ });
340
+ it("should set challenge$ when AUTH message received", async () => {
341
+ subscribeSpyTo(relay.req({ kinds: [1] }));
342
+ // Send AUTH message with challenge string
343
+ server.send(["AUTH", "challenge-string-123"]);
344
+ // Verify challenge$ was set
345
+ expect(relay.challenge$.value).toBe("challenge-string-123");
346
+ });
347
+ it("should ignore non-AUTH messages", async () => {
348
+ subscribeSpyTo(relay.req({ kinds: [1] }));
349
+ server.send(["NOTICE", "Not a challenge"]);
350
+ server.send(["OTHER", "other message"]);
351
+ // Verify challenge$ remains null
352
+ expect(relay.challenge$.value).toBe(null);
353
+ });
354
+ });
355
+ describe("information$", () => {
356
+ it("should fetch information document when information$ is subscribed to", async () => {
357
+ // Mock the fetchInformationDocument method
358
+ const mockInfo = { ...defaultMockInfo, limitation: { auth_required: false } };
359
+ vi.spyOn(Relay, "fetchInformationDocument").mockReturnValue(of(mockInfo));
360
+ // Subscribe to information$
361
+ const sub = subscribeSpyTo(relay.information$);
362
+ // Verify fetchInformationDocument was called with the relay URL
363
+ expect(Relay.fetchInformationDocument).toHaveBeenCalledWith(relay.url);
364
+ // Verify the information was emitted
365
+ expect(sub.getLastValue()).toEqual(mockInfo);
366
+ });
367
+ it("should return null when fetchInformationDocument fails", async () => {
368
+ // Mock the fetchInformationDocument method to throw an error
369
+ vi.spyOn(Relay, "fetchInformationDocument").mockReturnValue(throwError(() => new Error("Failed to fetch")));
370
+ // Subscribe to information$
371
+ const sub = subscribeSpyTo(relay.information$);
372
+ // Verify fetchInformationDocument was called
373
+ expect(Relay.fetchInformationDocument).toHaveBeenCalled();
374
+ // Verify null was emitted
375
+ expect(sub.getLastValue()).toBeNull();
376
+ });
377
+ it("should cache the information document", async () => {
378
+ // Mock the fetchInformationDocument method
379
+ const mockInfo = { ...defaultMockInfo, limitation: { auth_required: true } };
380
+ vi.spyOn(Relay, "fetchInformationDocument").mockReturnValue(of(mockInfo));
381
+ // Subscribe to information$ multiple times
382
+ const sub1 = subscribeSpyTo(relay.information$);
383
+ const sub2 = subscribeSpyTo(relay.information$);
384
+ // Verify fetchInformationDocument was called only once
385
+ expect(Relay.fetchInformationDocument).toHaveBeenCalledTimes(1);
386
+ // Verify both subscriptions received the same information
387
+ expect(sub1.getLastValue()).toEqual(mockInfo);
388
+ expect(sub2.getLastValue()).toEqual(mockInfo);
389
+ // Verify the internal state was updated
390
+ expect(relay.information).toEqual(mockInfo);
391
+ });
392
+ });
393
+ describe("createReconnectTimer", () => {
394
+ it("should create a reconnect timer when relay closes with error", async () => {
395
+ const reconnectTimer = vi.fn().mockReturnValue(timer(1000));
396
+ vi.spyOn(Relay, "createReconnectTimer").mockReturnValue(reconnectTimer);
397
+ relay = new Relay("wss://test");
398
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }]), { expectErrors: true });
399
+ // Send CLOSE message with error
400
+ server.error({
401
+ reason: "error message",
402
+ code: 1000,
403
+ wasClean: false,
404
+ });
405
+ // Verify the subscription errored
406
+ expect(spy.receivedError()).toBe(true);
407
+ expect(reconnectTimer).toHaveBeenCalledWith(expect.any(Error), 0);
408
+ });
409
+ it("should set ready$ to false until the reconnect timer completes", async () => {
410
+ vi.useFakeTimers();
411
+ const reconnectTimer = vi.fn().mockReturnValue(timer(1000));
412
+ vi.spyOn(Relay, "createReconnectTimer").mockReturnValue(reconnectTimer);
413
+ relay = new Relay("wss://test");
414
+ subscribeSpyTo(relay.req([{ kinds: [1] }]), { expectErrors: true });
415
+ // Send CLOSE message with error
416
+ server.error({
417
+ reason: "error message",
418
+ code: 1000,
419
+ wasClean: false,
420
+ });
421
+ // @ts-expect-error
422
+ expect(relay.ready$.value).toBe(false);
423
+ // Fast-forward time by 10ms
424
+ await vi.advanceTimersByTimeAsync(5000);
425
+ // @ts-expect-error
426
+ expect(relay.ready$.value).toBe(true);
427
+ });
428
+ });
429
+ describe("publish", () => {
430
+ it("should retry when auth-required is received and authentication is completed", async () => {
431
+ // First attempt to publish
432
+ const spy = subscribeSpyTo(relay.publish(mockEvent));
433
+ // Verify EVENT was sent
434
+ await expect(server).toReceiveMessage(["EVENT", mockEvent]);
435
+ // Send auth-required response
436
+ server.send(["AUTH", "challenge-string"]);
437
+ server.send(["OK", mockEvent.id, false, "auth-required: need to authenticate"]);
438
+ // Send auth event
439
+ const authEvent = { ...mockEvent, id: "auth-id" };
440
+ subscribeSpyTo(relay.auth(authEvent));
441
+ // Verify AUTH was sent
442
+ await expect(server).toReceiveMessage(["AUTH", authEvent]);
443
+ // Send successful auth response
444
+ server.send(["OK", authEvent.id, true, ""]);
445
+ // Wait for the event to be sent again
446
+ await expect(server).toReceiveMessage(["EVENT", mockEvent]);
447
+ // Send successful response for the retried event
448
+ server.send(["OK", mockEvent.id, true, ""]);
449
+ // Verify the final result is successful
450
+ expect(spy.getLastValue()).toEqual({ ok: true, message: "", from: "wss://test" });
451
+ });
452
+ it("should error after max retries", async () => {
453
+ const spy = subscribeSpyTo(relay.publish(mockEvent, { retries: 0 }), { expectErrors: true });
454
+ // Close with error
455
+ server.error({ reason: "error message", code: 1000, wasClean: false });
456
+ // Verify the subscription errored
457
+ expect(spy.receivedError()).toBe(true);
458
+ });
459
+ });
460
+ describe("request", () => {
461
+ it("should retry when auth-required is received and authentication is completed", async () => {
462
+ // First attempt to request
463
+ const spy = subscribeSpyTo(relay.request({ kinds: [1] }, { id: "sub1" }));
464
+ // Verify REQ was sent
465
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
466
+ // Send auth-required response
467
+ server.send(["AUTH", "challenge-string"]);
468
+ server.send(["CLOSED", "sub1", "auth-required: need to authenticate"]);
469
+ // Wait for subscription to close
470
+ await expect(server).toReceiveMessage(["CLOSE", "sub1"]);
471
+ // Send auth event
472
+ const authEvent = { ...mockEvent, id: "auth-id" };
473
+ const authSpy = subscribeSpyTo(relay.auth(authEvent));
474
+ // Verify AUTH was sent
475
+ await expect(server).toReceiveMessage(["AUTH", authEvent]);
476
+ server.send(["OK", authEvent.id, true, ""]);
477
+ // Wait for auth to complete
478
+ await authSpy.onComplete();
479
+ // Wait for retry
480
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
481
+ // Send response
482
+ server.send(["EVENT", "sub1", mockEvent]);
483
+ server.send(["EOSE", "sub1"]);
484
+ // Verify the final result is successful
485
+ expect(spy.getLastValue()).toEqual(expect.objectContaining(mockEvent));
486
+ expect(spy.receivedComplete()).toBe(true);
487
+ });
488
+ });
489
+ describe("subscription", () => {
490
+ it("should retry when auth-required is received and authentication is completed", async () => {
491
+ // First attempt to request
492
+ const spy = subscribeSpyTo(relay.subscription({ kinds: [1] }, { id: "sub1" }));
493
+ // Verify REQ was sent
494
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
495
+ // Send auth-required response
496
+ server.send(["AUTH", "challenge-string"]);
497
+ server.send(["CLOSED", "sub1", "auth-required: need to authenticate"]);
498
+ // Wait for subscription to close
499
+ await expect(server).toReceiveMessage(["CLOSE", "sub1"]);
500
+ // Send auth event
501
+ const authEvent = { ...mockEvent, id: "auth-id" };
502
+ const authSpy = subscribeSpyTo(relay.auth(authEvent));
503
+ // Verify AUTH was sent
504
+ await expect(server).toReceiveMessage(["AUTH", authEvent]);
505
+ server.send(["OK", authEvent.id, true, ""]);
506
+ // Wait for auth to complete
507
+ await authSpy.onComplete();
508
+ // Wait for retry
509
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
510
+ // Send response
511
+ server.send(["EVENT", "sub1", mockEvent]);
512
+ server.send(["EOSE", "sub1"]);
513
+ // Verify the final result is successful
514
+ expect(spy.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
515
+ expect(spy.receivedComplete()).toBe(false);
516
+ });
517
+ });
518
+ // describe("keepAlive", () => {
519
+ // it("should close the socket connection after keepAlive timeout", async () => {
520
+ // vi.useFakeTimers();
521
+ // // Set a short keepAlive timeout for testing
522
+ // relay.keepAlive = 100; // 100ms for quick testing
523
+ // // Subscribe to the relay to ensure it is active
524
+ // const sub = subscribeSpyTo(relay.req([{ kinds: [1] }]));
525
+ // // Wait for connection
526
+ // await server.connected;
527
+ // // Close the subscription
528
+ // sub.unsubscribe();
529
+ // // Fast-forward time by 10ms
530
+ // await vi.advanceTimersByTimeAsync(10);
531
+ // // should still be connected
532
+ // expect(relay.connected).toBe(true);
533
+ // // Wait for the keepAlive timeout to elapse
534
+ // await vi.advanceTimersByTimeAsync(150);
535
+ // expect(relay.connected).toBe(false);
536
+ // });
537
+ // });
@@ -0,0 +1,19 @@
1
+ import { Filter, NostrEvent } from "nostr-tools";
2
+ import { Observable } from "rxjs";
3
+ import { IGroup, IRelay, PublishResponse, SubscriptionResponse, PublishOptions, RequestOptions, SubscriptionOptions } from "./types.js";
4
+ export declare class RelayGroup implements IGroup {
5
+ relays: IRelay[];
6
+ constructor(relays: IRelay[]);
7
+ /** Takes an array of observables and only emits EOSE when all observables have emitted EOSE */
8
+ protected mergeEOSE(...requests: Observable<SubscriptionResponse>[]): Observable<import("nostr-tools").Event | "EOSE">;
9
+ /** Make a request to all relays */
10
+ req(filters: Filter | Filter[], id?: string): Observable<SubscriptionResponse>;
11
+ /** Send an event to all relays */
12
+ event(event: NostrEvent): Observable<PublishResponse>;
13
+ /** Publish an event to all relays with retries ( default 3 retries ) */
14
+ publish(event: NostrEvent, opts?: PublishOptions): Observable<PublishResponse[]>;
15
+ /** Request events from all relays with retries ( default 3 retries ) */
16
+ request(filters: Filter | Filter[], opts?: RequestOptions): Observable<NostrEvent>;
17
+ /** Open a subscription to all relays with retries ( default 3 retries ) */
18
+ subscription(filters: Filter | Filter[], opts?: SubscriptionOptions): Observable<SubscriptionResponse>;
19
+ }
package/dist/group.js ADDED
@@ -0,0 +1,54 @@
1
+ import { nanoid } from "nanoid";
2
+ import { catchError, EMPTY, endWith, ignoreElements, merge, of, toArray } from "rxjs";
3
+ import { completeOnEose } from "./operators/complete-on-eose.js";
4
+ import { onlyEvents } from "./operators/only-events.js";
5
+ export class RelayGroup {
6
+ relays;
7
+ constructor(relays) {
8
+ this.relays = relays;
9
+ }
10
+ /** Takes an array of observables and only emits EOSE when all observables have emitted EOSE */
11
+ mergeEOSE(...requests) {
12
+ // Create stream of events only
13
+ const events = merge(...requests).pipe(onlyEvents());
14
+ // Create stream that emits EOSE when all relays have sent EOSE
15
+ const eose = merge(
16
+ // Create a new map of requests that only emits EOSE
17
+ ...requests.map((observable) => observable.pipe(completeOnEose(), ignoreElements()))).pipe(
18
+ // When all relays have sent EOSE, emit EOSE
19
+ endWith("EOSE"));
20
+ return merge(events, eose);
21
+ }
22
+ /** Make a request to all relays */
23
+ req(filters, id = nanoid(8)) {
24
+ const requests = this.relays.map((relay) => relay.req(filters, id).pipe(
25
+ // Ignore connection errors
26
+ catchError(() => of("EOSE"))));
27
+ // Merge events and the single EOSE stream
28
+ return this.mergeEOSE(...requests);
29
+ }
30
+ /** Send an event to all relays */
31
+ event(event) {
32
+ return merge(...this.relays.map((relay) => relay.event(event).pipe(
33
+ // Catch error and return as PublishResponse
34
+ catchError((err) => of({ ok: false, from: relay.url, message: err?.message || "Unknown error" })))));
35
+ }
36
+ /** Publish an event to all relays with retries ( default 3 retries ) */
37
+ publish(event, opts) {
38
+ return merge(...this.relays.map((relay) => relay.publish(event, opts).pipe(
39
+ // Catch error and return as PublishResponse
40
+ catchError((err) => of({ ok: false, from: relay.url, message: err?.message || "Unknown error" }))))).pipe(toArray());
41
+ }
42
+ /** Request events from all relays with retries ( default 3 retries ) */
43
+ request(filters, opts) {
44
+ return merge(...this.relays.map((relay) => relay.request(filters, opts).pipe(
45
+ // Ignore individual connection errors
46
+ catchError(() => EMPTY))));
47
+ }
48
+ /** Open a subscription to all relays with retries ( default 3 retries ) */
49
+ subscription(filters, opts) {
50
+ return this.mergeEOSE(...this.relays.map((relay) => relay.subscription(filters, opts).pipe(
51
+ // Ignore individual connection errors
52
+ catchError(() => EMPTY))));
53
+ }
54
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,5 @@
1
+ export * from "./group.js";
1
2
  export * from "./pool.js";
2
3
  export * from "./relay.js";
4
+ export * from "./types.js";
5
+ export * from "./operators/index.js";
package/dist/index.js CHANGED
@@ -1,2 +1,5 @@
1
+ export * from "./group.js";
1
2
  export * from "./pool.js";
2
3
  export * from "./relay.js";
4
+ export * from "./types.js";
5
+ export * from "./operators/index.js";