applesauce-relay 0.12.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,561 @@
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 expected messages to relay", async () => {
51
+ subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
52
+ // Wait for all message to be sent
53
+ await new Promise((resolve) => setTimeout(resolve, 10));
54
+ expect(server.messages).toEqual([["REQ", "sub1", { kinds: [1] }]]);
55
+ });
56
+ it("should not close the REQ when EOSE is received", async () => {
57
+ // Create subscription that completes after first EOSE
58
+ const sub = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
59
+ // Verify REQ was sent
60
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
61
+ // Send EOSE to complete subscription
62
+ server.send(["EVENT", "sub1", mockEvent]);
63
+ server.send(["EOSE", "sub1"]);
64
+ // Verify the subscription did not complete
65
+ expect(sub.receivedComplete()).toBe(false);
66
+ expect(sub.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
67
+ });
68
+ it("should send CLOSE when unsubscribed", async () => {
69
+ // Create subscription that completes after first EOSE
70
+ const sub = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
71
+ // Verify REQ was sent
72
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
73
+ // Complete the subscription
74
+ sub.unsubscribe();
75
+ // Verify CLOSE was sent
76
+ await expect(server).toReceiveMessage(["CLOSE", "sub1"]);
77
+ });
78
+ it("should emit nostr event and EOSE", async () => {
79
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
80
+ await server.connected;
81
+ // Send EVENT message
82
+ server.send(["EVENT", "sub1", mockEvent]);
83
+ // Send EOSE message
84
+ server.send(["EOSE", "sub1"]);
85
+ expect(spy.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
86
+ });
87
+ it("should ignore EVENT and EOSE messages that do not match subscription id", async () => {
88
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
89
+ await server.connected;
90
+ // Send EVENT message with wrong subscription id
91
+ server.send(["EVENT", "wrong_sub", mockEvent]);
92
+ // Send EOSE message with wrong subscription id
93
+ server.send(["EOSE", "wrong_sub"]);
94
+ // Send EVENT message with correct subscription id
95
+ server.send(["EVENT", "sub1", mockEvent]);
96
+ // Send EOSE message with correct subscription id
97
+ server.send(["EOSE", "sub1"]);
98
+ expect(spy.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
99
+ });
100
+ it("should mark events with their source relay", async () => {
101
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
102
+ await server.connected;
103
+ // Send EVENT message
104
+ server.send(["EVENT", "sub1", mockEvent]);
105
+ // Get the received event
106
+ const receivedEvent = spy.getValues()[0];
107
+ // Verify the event was marked as seen from this relay
108
+ expect(getSeenRelays(receivedEvent)).toContain("wss://test");
109
+ });
110
+ it("should error subscription when CLOSED message is received", async () => {
111
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"), { expectErrors: true });
112
+ await server.connected;
113
+ // Send CLOSED message for the subscription
114
+ server.send(["CLOSED", "sub1", "reason"]);
115
+ // Verify the subscription completed
116
+ expect(spy.receivedError()).toBe(true);
117
+ });
118
+ it("should not send multiple REQ messages for multiple subscriptions", async () => {
119
+ const sub = relay.req([{ kinds: [1] }], "sub1");
120
+ sub.subscribe();
121
+ sub.subscribe();
122
+ // Wait for all messages to be sent
123
+ await new Promise((resolve) => setTimeout(resolve, 10));
124
+ expect(server.messages).toEqual([["REQ", "sub1", { kinds: [1] }]]);
125
+ });
126
+ it("should wait for authentication if relay responds with auth-required", async () => {
127
+ // First subscription to trigger auth-required
128
+ const firstSub = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"), { expectErrors: true });
129
+ await server.nextMessage;
130
+ // Send CLOSED message with auth-required reason
131
+ server.send(["CLOSED", "sub1", "auth-required: need to authenticate"]);
132
+ // wait for complete
133
+ await firstSub.onError();
134
+ await server.nextMessage;
135
+ // Create a second subscription that should wait for auth
136
+ const secondSub = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub2"), { expectErrors: true });
137
+ // Verify no REQ message was sent yet (waiting for auth)
138
+ expect(server).not.toHaveReceivedMessages(["REQ", "sub2", { kinds: [1] }]);
139
+ // Simulate successful authentication
140
+ relay.authenticated$.next(true);
141
+ // Now the REQ should be sent
142
+ await expect(server).toReceiveMessage(["REQ", "sub2", { kinds: [1] }]);
143
+ // Send EVENT and EOSE to complete the subscription
144
+ server.send(["EVENT", "sub2", mockEvent]);
145
+ server.send(["EOSE", "sub2"]);
146
+ // Verify the second subscription received the event and EOSE
147
+ expect(secondSub.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
148
+ });
149
+ it("should wait for authentication if relay info document has limitations.auth_required = true", async () => {
150
+ // Mock the fetchInformationDocument method to return a document with auth_required = true
151
+ vi.spyOn(Relay, "fetchInformationDocument").mockImplementation(() => of({
152
+ name: "Auth Required Relay",
153
+ description: "A relay that requires authentication",
154
+ pubkey: "",
155
+ contact: "",
156
+ supported_nips: [1, 2, 4],
157
+ software: "",
158
+ version: "",
159
+ limitation: {
160
+ auth_required: true,
161
+ },
162
+ }));
163
+ // Create a subscription that should wait for auth
164
+ const sub = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
165
+ // Wait 10ms to ensure the information document is fetched
166
+ await new Promise((resolve) => setTimeout(resolve, 10));
167
+ // Verify no REQ message was sent yet (waiting for auth)
168
+ expect(server).not.toHaveReceivedMessages(["REQ", "sub1", { kinds: [1] }]);
169
+ // Simulate successful authentication
170
+ relay.authenticated$.next(true);
171
+ // Now the REQ should be sent
172
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
173
+ // Send EVENT and EOSE to complete the subscription
174
+ server.send(["EVENT", "sub1", mockEvent]);
175
+ server.send(["EOSE", "sub1"]);
176
+ // Verify the subscription received the event and EOSE
177
+ expect(sub.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
178
+ });
179
+ it("should throw error if relay closes connection with error", async () => {
180
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"), { expectErrors: true });
181
+ await server.connected;
182
+ // Send CLOSE message with error
183
+ server.error({
184
+ reason: "error message",
185
+ code: 1000,
186
+ wasClean: false,
187
+ });
188
+ // Verify the subscription completed with an error
189
+ expect(spy.receivedError()).toBe(true);
190
+ });
191
+ it("should not return EOSE while waiting for the relay to be ready", async () => {
192
+ vi.useFakeTimers();
193
+ // @ts-expect-error
194
+ relay.ready$.next(false);
195
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"), { expectErrors: true });
196
+ // Fast-forward time by 20 seconds
197
+ await vi.advanceTimersByTimeAsync(20000);
198
+ expect(spy.receivedComplete()).toBe(false);
199
+ expect(spy.receivedError()).toBe(false);
200
+ expect(spy.receivedNext()).toBe(false);
201
+ });
202
+ it("should wait when relay isn't ready", async () => {
203
+ // @ts-expect-error
204
+ relay.ready$.next(false);
205
+ subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
206
+ // Wait 10ms to ensure the relay didn't receive anything
207
+ await new Promise((resolve) => setTimeout(resolve, 10));
208
+ expect(server.messages.length).toBe(0);
209
+ // @ts-expect-error
210
+ relay.ready$.next(true);
211
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
212
+ });
213
+ });
214
+ describe("event", () => {
215
+ it("should wait for authentication if relay responds with auth-required", async () => {
216
+ // First event to trigger auth-required
217
+ const firstSpy = subscribeSpyTo(relay.event(mockEvent));
218
+ await expect(server).toReceiveMessage(["EVENT", mockEvent]);
219
+ // Send OK with auth-required message
220
+ server.send(["OK", mockEvent.id, false, "auth-required: need to authenticate"]);
221
+ await firstSpy.onComplete();
222
+ // Create a second event that should wait for auth
223
+ const secondSpy = subscribeSpyTo(relay.event(mockEvent));
224
+ // Verify no EVENT message was sent yet (waiting for auth)
225
+ expect(server).not.toHaveReceivedMessages(["EVENT", mockEvent]);
226
+ // Simulate successful authentication
227
+ relay.authenticated$.next(true);
228
+ // Now the EVENT should be sent
229
+ await expect(server).toReceiveMessage(["EVENT", mockEvent]);
230
+ // Send OK response to complete the event
231
+ server.send(["OK", mockEvent.id, true, ""]);
232
+ // Verify the second event completed successfully
233
+ await secondSpy.onComplete();
234
+ expect(secondSpy.receivedComplete()).toBe(true);
235
+ });
236
+ it("should wait for authentication if relay info document has limitations.auth_required = true", async () => {
237
+ // Mock the fetchInformationDocument method to return a document with auth_required = true
238
+ vi.spyOn(Relay, "fetchInformationDocument").mockImplementation(() => of({
239
+ name: "Auth Required Relay",
240
+ description: "A relay that requires authentication",
241
+ pubkey: "",
242
+ contact: "",
243
+ supported_nips: [1, 2, 4],
244
+ software: "",
245
+ version: "",
246
+ limitation: {
247
+ auth_required: true,
248
+ },
249
+ }));
250
+ // Create a subscription that should wait for auth
251
+ const sub = subscribeSpyTo(relay.event(mockEvent));
252
+ // Wait 10ms to ensure the information document is fetched
253
+ await new Promise((resolve) => setTimeout(resolve, 10));
254
+ // Verify no REQ message was sent yet (waiting for auth)
255
+ expect(server).not.toHaveReceivedMessages(["EVENT", mockEvent]);
256
+ // Simulate successful authentication
257
+ relay.authenticated$.next(true);
258
+ // Now the REQ should be sent
259
+ await expect(server).toReceiveMessage(["EVENT", mockEvent]);
260
+ // Send EVENT and EOSE to complete the subscription
261
+ server.send(["OK", mockEvent.id, true, ""]);
262
+ // Verify the subscription completed
263
+ await sub.onComplete();
264
+ expect(sub.receivedComplete()).toBe(true);
265
+ });
266
+ it("should trigger connection to relay", async () => {
267
+ subscribeSpyTo(relay.event(mockEvent));
268
+ // Wait for connection
269
+ await firstValueFrom(relay.connected$.pipe(filter(Boolean)));
270
+ expect(relay.connected).toBe(true);
271
+ });
272
+ it("observable should complete when matching OK response received", async () => {
273
+ const spy = subscribeSpyTo(relay.event(mockEvent));
274
+ // Verify EVENT message was sent
275
+ expect(await server.nextMessage).toEqual(["EVENT", mockEvent]);
276
+ // Send matching OK response
277
+ server.send(["OK", mockEvent.id, true, ""]);
278
+ await spy.onComplete();
279
+ expect(spy.receivedComplete()).toBe(true);
280
+ });
281
+ it("should ignore OK responses for different events", async () => {
282
+ const spy = subscribeSpyTo(relay.event(mockEvent));
283
+ await server.connected;
284
+ // Send non-matching OK response
285
+ server.send(["OK", "different_id", true, ""]);
286
+ expect(spy.receivedComplete()).toBe(false);
287
+ // Send matching OK response
288
+ server.send(["OK", mockEvent.id, true, ""]);
289
+ expect(spy.receivedComplete()).toBe(true);
290
+ });
291
+ it("should send EVENT message to relay", async () => {
292
+ relay.event(mockEvent).subscribe();
293
+ expect(await server.nextMessage).toEqual(["EVENT", mockEvent]);
294
+ });
295
+ it("should complete with error if no OK received within 10s", async () => {
296
+ vi.useFakeTimers();
297
+ const spy = subscribeSpyTo(relay.event(mockEvent));
298
+ // Fast-forward time by 10 seconds
299
+ await vi.advanceTimersByTimeAsync(10000);
300
+ expect(spy.receivedComplete()).toBe(true);
301
+ expect(spy.getLastValue()).toEqual({ ok: false, from: "wss://test", message: "Timeout" });
302
+ });
303
+ it("should throw error if relay closes connection with error", async () => {
304
+ const spy = subscribeSpyTo(relay.event(mockEvent), { expectErrors: true });
305
+ await server.connected;
306
+ // Send CLOSE message with error
307
+ server.error({
308
+ reason: "error message",
309
+ code: 1000,
310
+ wasClean: false,
311
+ });
312
+ // Verify the subscription completed with an error
313
+ expect(spy.receivedError()).toBe(true);
314
+ });
315
+ it("should not throw a timeout error while waiting for the relay to be ready", async () => {
316
+ vi.useFakeTimers();
317
+ // @ts-expect-error
318
+ relay.ready$.next(false);
319
+ const spy = subscribeSpyTo(relay.event(mockEvent), { expectErrors: true });
320
+ // Fast-forward time by 20 seconds
321
+ await vi.advanceTimersByTimeAsync(20000);
322
+ expect(spy.receivedComplete()).toBe(false);
323
+ expect(spy.receivedError()).toBe(false);
324
+ });
325
+ it("should wait when relay isn't ready", async () => {
326
+ // @ts-expect-error
327
+ relay.ready$.next(false);
328
+ subscribeSpyTo(relay.event(mockEvent));
329
+ // Wait 10ms to ensure the relay didn't receive anything
330
+ await new Promise((resolve) => setTimeout(resolve, 10));
331
+ expect(server.messages.length).toBe(0);
332
+ // @ts-expect-error
333
+ relay.ready$.next(true);
334
+ await expect(server).toReceiveMessage(["EVENT", mockEvent]);
335
+ });
336
+ });
337
+ describe("notices$", () => {
338
+ it("should not trigger connection to relay", async () => {
339
+ subscribeSpyTo(relay.notices$);
340
+ expect(relay.connected).toBe(false);
341
+ });
342
+ it("should accumulate notices in notices$ state", async () => {
343
+ subscribeSpyTo(relay.req({ kinds: [1] }));
344
+ // Send multiple NOTICE messages
345
+ server.send(["NOTICE", "Notice 1"]);
346
+ server.send(["NOTICE", "Notice 2"]);
347
+ server.send(["NOTICE", "Notice 3"]);
348
+ // Verify the notices state contains all messages
349
+ expect(relay.notices$.value).toEqual(["Notice 1", "Notice 2", "Notice 3"]);
350
+ });
351
+ it("should ignore non-NOTICE messages", async () => {
352
+ subscribeSpyTo(relay.req({ kinds: [1] }));
353
+ server.send(["NOTICE", "Important notice"]);
354
+ server.send(["OTHER", "other message"]);
355
+ // Verify only NOTICE messages are in the state
356
+ expect(relay.notices$.value).toEqual(["Important notice"]);
357
+ });
358
+ });
359
+ describe("challenge$", () => {
360
+ it("should not trigger connection to relay", async () => {
361
+ subscribeSpyTo(relay.challenge$);
362
+ expect(relay.connected).toBe(false);
363
+ });
364
+ it("should set challenge$ when AUTH message received", async () => {
365
+ subscribeSpyTo(relay.req({ kinds: [1] }));
366
+ // Send AUTH message with challenge string
367
+ server.send(["AUTH", "challenge-string-123"]);
368
+ // Verify challenge$ was set
369
+ expect(relay.challenge$.value).toBe("challenge-string-123");
370
+ });
371
+ it("should ignore non-AUTH messages", async () => {
372
+ subscribeSpyTo(relay.req({ kinds: [1] }));
373
+ server.send(["NOTICE", "Not a challenge"]);
374
+ server.send(["OTHER", "other message"]);
375
+ // Verify challenge$ remains null
376
+ expect(relay.challenge$.value).toBe(null);
377
+ });
378
+ });
379
+ describe("information$", () => {
380
+ it("should fetch information document when information$ is subscribed to", async () => {
381
+ // Mock the fetchInformationDocument method
382
+ const mockInfo = { ...defaultMockInfo, limitation: { auth_required: false } };
383
+ vi.spyOn(Relay, "fetchInformationDocument").mockReturnValue(of(mockInfo));
384
+ // Subscribe to information$
385
+ const sub = subscribeSpyTo(relay.information$);
386
+ // Verify fetchInformationDocument was called with the relay URL
387
+ expect(Relay.fetchInformationDocument).toHaveBeenCalledWith(relay.url);
388
+ // Verify the information was emitted
389
+ expect(sub.getLastValue()).toEqual(mockInfo);
390
+ });
391
+ it("should return null when fetchInformationDocument fails", async () => {
392
+ // Mock the fetchInformationDocument method to throw an error
393
+ vi.spyOn(Relay, "fetchInformationDocument").mockReturnValue(throwError(() => new Error("Failed to fetch")));
394
+ // Subscribe to information$
395
+ const sub = subscribeSpyTo(relay.information$);
396
+ // Verify fetchInformationDocument was called
397
+ expect(Relay.fetchInformationDocument).toHaveBeenCalled();
398
+ // Verify null was emitted
399
+ expect(sub.getLastValue()).toBeNull();
400
+ });
401
+ it("should cache the information document", async () => {
402
+ // Mock the fetchInformationDocument method
403
+ const mockInfo = { ...defaultMockInfo, limitation: { auth_required: true } };
404
+ vi.spyOn(Relay, "fetchInformationDocument").mockReturnValue(of(mockInfo));
405
+ // Subscribe to information$ multiple times
406
+ const sub1 = subscribeSpyTo(relay.information$);
407
+ const sub2 = subscribeSpyTo(relay.information$);
408
+ // Verify fetchInformationDocument was called only once
409
+ expect(Relay.fetchInformationDocument).toHaveBeenCalledTimes(1);
410
+ // Verify both subscriptions received the same information
411
+ expect(sub1.getLastValue()).toEqual(mockInfo);
412
+ expect(sub2.getLastValue()).toEqual(mockInfo);
413
+ // Verify the internal state was updated
414
+ expect(relay.information).toEqual(mockInfo);
415
+ });
416
+ });
417
+ describe("createReconnectTimer", () => {
418
+ it("should create a reconnect timer when relay closes with error", async () => {
419
+ const reconnectTimer = vi.fn().mockReturnValue(timer(1000));
420
+ vi.spyOn(Relay, "createReconnectTimer").mockReturnValue(reconnectTimer);
421
+ relay = new Relay("wss://test");
422
+ const spy = subscribeSpyTo(relay.req([{ kinds: [1] }]), { expectErrors: true });
423
+ // Send CLOSE message with error
424
+ server.error({
425
+ reason: "error message",
426
+ code: 1000,
427
+ wasClean: false,
428
+ });
429
+ // Verify the subscription errored
430
+ expect(spy.receivedError()).toBe(true);
431
+ expect(reconnectTimer).toHaveBeenCalledWith(expect.any(Error), 0);
432
+ });
433
+ it("should set ready$ to false until the reconnect timer completes", async () => {
434
+ vi.useFakeTimers();
435
+ const reconnectTimer = vi.fn().mockReturnValue(timer(1000));
436
+ vi.spyOn(Relay, "createReconnectTimer").mockReturnValue(reconnectTimer);
437
+ relay = new Relay("wss://test");
438
+ subscribeSpyTo(relay.req([{ kinds: [1] }]), { expectErrors: true });
439
+ // Send CLOSE message with error
440
+ server.error({
441
+ reason: "error message",
442
+ code: 1000,
443
+ wasClean: false,
444
+ });
445
+ // @ts-expect-error
446
+ expect(relay.ready$.value).toBe(false);
447
+ // Fast-forward time by 10ms
448
+ await vi.advanceTimersByTimeAsync(5000);
449
+ // @ts-expect-error
450
+ expect(relay.ready$.value).toBe(true);
451
+ });
452
+ });
453
+ describe("publish", () => {
454
+ it("should retry when auth-required is received and authentication is completed", async () => {
455
+ // First attempt to publish
456
+ const spy = subscribeSpyTo(relay.publish(mockEvent));
457
+ // Verify EVENT was sent
458
+ await expect(server).toReceiveMessage(["EVENT", mockEvent]);
459
+ // Send auth-required response
460
+ server.send(["AUTH", "challenge-string"]);
461
+ server.send(["OK", mockEvent.id, false, "auth-required: need to authenticate"]);
462
+ // Send auth event
463
+ const authEvent = { ...mockEvent, id: "auth-id" };
464
+ subscribeSpyTo(relay.auth(authEvent));
465
+ // Verify AUTH was sent
466
+ await expect(server).toReceiveMessage(["AUTH", authEvent]);
467
+ // Send successful auth response
468
+ server.send(["OK", authEvent.id, true, ""]);
469
+ // Wait for the event to be sent again
470
+ await expect(server).toReceiveMessage(["EVENT", mockEvent]);
471
+ // Send successful response for the retried event
472
+ server.send(["OK", mockEvent.id, true, ""]);
473
+ // Verify the final result is successful
474
+ expect(spy.getLastValue()).toEqual({ ok: true, message: "", from: "wss://test" });
475
+ });
476
+ it("should error after max retries", async () => {
477
+ const spy = subscribeSpyTo(relay.publish(mockEvent, { retries: 0 }), { expectErrors: true });
478
+ // Close with error
479
+ server.error({ reason: "error message", code: 1000, wasClean: false });
480
+ // Verify the subscription errored
481
+ expect(spy.receivedError()).toBe(true);
482
+ });
483
+ });
484
+ describe("request", () => {
485
+ it("should retry when auth-required is received and authentication is completed", async () => {
486
+ // First attempt to request
487
+ const spy = subscribeSpyTo(relay.request({ kinds: [1] }, { id: "sub1" }));
488
+ // Verify REQ was sent
489
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
490
+ // Send auth-required response
491
+ server.send(["AUTH", "challenge-string"]);
492
+ server.send(["CLOSED", "sub1", "auth-required: need to authenticate"]);
493
+ // Wait for subscription to close
494
+ await expect(server).toReceiveMessage(["CLOSE", "sub1"]);
495
+ // Send auth event
496
+ const authEvent = { ...mockEvent, id: "auth-id" };
497
+ const authSpy = subscribeSpyTo(relay.auth(authEvent));
498
+ // Verify AUTH was sent
499
+ await expect(server).toReceiveMessage(["AUTH", authEvent]);
500
+ server.send(["OK", authEvent.id, true, ""]);
501
+ // Wait for auth to complete
502
+ await authSpy.onComplete();
503
+ // Wait for retry
504
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
505
+ // Send response
506
+ server.send(["EVENT", "sub1", mockEvent]);
507
+ server.send(["EOSE", "sub1"]);
508
+ // Verify the final result is successful
509
+ expect(spy.getLastValue()).toEqual(expect.objectContaining(mockEvent));
510
+ expect(spy.receivedComplete()).toBe(true);
511
+ });
512
+ });
513
+ describe("subscription", () => {
514
+ it("should retry when auth-required is received and authentication is completed", async () => {
515
+ // First attempt to request
516
+ const spy = subscribeSpyTo(relay.subscription({ kinds: [1] }, { id: "sub1" }));
517
+ // Verify REQ was sent
518
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
519
+ // Send auth-required response
520
+ server.send(["AUTH", "challenge-string"]);
521
+ server.send(["CLOSED", "sub1", "auth-required: need to authenticate"]);
522
+ // Wait for subscription to close
523
+ await expect(server).toReceiveMessage(["CLOSE", "sub1"]);
524
+ // Send auth event
525
+ const authEvent = { ...mockEvent, id: "auth-id" };
526
+ const authSpy = subscribeSpyTo(relay.auth(authEvent));
527
+ // Verify AUTH was sent
528
+ await expect(server).toReceiveMessage(["AUTH", authEvent]);
529
+ server.send(["OK", authEvent.id, true, ""]);
530
+ // Wait for auth to complete
531
+ await authSpy.onComplete();
532
+ // Wait for retry
533
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
534
+ // Send response
535
+ server.send(["EVENT", "sub1", mockEvent]);
536
+ server.send(["EOSE", "sub1"]);
537
+ // Verify the final result is successful
538
+ expect(spy.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
539
+ expect(spy.receivedComplete()).toBe(false);
540
+ });
541
+ });
542
+ // describe("keepAlive", () => {
543
+ // it("should close the socket connection after keepAlive timeout", async () => {
544
+ // vi.useFakeTimers();
545
+ // // Set a short keepAlive timeout for testing
546
+ // relay.keepAlive = 100; // 100ms for quick testing
547
+ // // Subscribe to the relay to ensure it is active
548
+ // const sub = subscribeSpyTo(relay.req([{ kinds: [1] }]));
549
+ // // Wait for connection
550
+ // await server.connected;
551
+ // // Close the subscription
552
+ // sub.unsubscribe();
553
+ // // Fast-forward time by 10ms
554
+ // await vi.advanceTimersByTimeAsync(10);
555
+ // // should still be connected
556
+ // expect(relay.connected).toBe(true);
557
+ // // Wait for the keepAlive timeout to elapse
558
+ // await vi.advanceTimersByTimeAsync(150);
559
+ // expect(relay.connected).toBe(false);
560
+ // });
561
+ // });
@@ -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";