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.
- package/README.md +115 -0
- package/dist/__tests__/auth.test.d.ts +1 -0
- package/dist/__tests__/auth.test.js +111 -0
- package/dist/__tests__/group.test.d.ts +1 -0
- package/dist/__tests__/group.test.js +106 -0
- package/dist/__tests__/pool.test.d.ts +1 -0
- package/dist/__tests__/pool.test.js +81 -0
- package/dist/__tests__/relay.test.d.ts +1 -0
- package/dist/__tests__/relay.test.js +561 -0
- package/dist/group.d.ts +19 -0
- package/dist/group.js +54 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/lib/negentropy.d.ts +61 -0
- package/dist/lib/negentropy.js +533 -0
- package/dist/negentropy.d.ts +15 -0
- package/dist/negentropy.js +68 -0
- package/dist/operators/complete-on-eose.d.ts +6 -0
- package/dist/operators/complete-on-eose.js +7 -0
- package/dist/operators/index.d.ts +4 -1
- package/dist/operators/index.js +4 -1
- package/dist/operators/mark-from-relay.d.ts +1 -1
- package/dist/operators/only-events.d.ts +1 -1
- package/dist/operators/store-events.d.ts +5 -0
- package/dist/operators/store-events.js +7 -0
- package/dist/operators/to-event-store.d.ts +6 -0
- package/dist/operators/to-event-store.js +19 -0
- package/dist/pool.d.ts +18 -5
- package/dist/pool.js +33 -23
- package/dist/relay.d.ts +73 -22
- package/dist/relay.js +278 -59
- package/dist/types.d.ts +104 -0
- package/dist/types.js +1 -0
- package/package.json +28 -6
|
@@ -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
|
+
// });
|
package/dist/group.d.ts
ADDED
|
@@ -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