applesauce-relay 0.0.0-next-20250404095409 → 0.0.0-next-20250411160531
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 +4 -6
- package/dist/__tests__/auth.test.d.ts +1 -0
- package/dist/__tests__/auth.test.js +112 -0
- package/dist/__tests__/faker.d.ts +19 -0
- package/dist/__tests__/faker.js +56 -0
- package/dist/__tests__/group.test.d.ts +1 -0
- package/dist/__tests__/group.test.js +102 -0
- package/dist/__tests__/matchers.d.ts +60 -0
- package/dist/__tests__/matchers.js +177 -0
- package/dist/__tests__/mock.d.ts +84 -0
- package/dist/__tests__/mock.js +181 -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 +145 -0
- package/dist/__tests__/spy.d.ts +7 -0
- package/dist/__tests__/spy.js +65 -0
- package/dist/__tests__/utils.d.ts +3 -0
- package/dist/__tests__/utils.js +8 -0
- package/dist/group.js +7 -4
- package/dist/relay.d.ts +4 -0
- package/dist/relay.js +18 -8
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
`applesauce-relay` is a nostr relay communication framework built on top of [RxJS](https://rxjs.dev/)
|
|
4
4
|
|
|
5
|
-
> ⚠️ **Alpha Software Warning**: This package is in early alpha stage. It contains bugs, is not fully tested, and may undergo significant changes. Use with caution in production environments.
|
|
6
|
-
|
|
7
5
|
## Installation
|
|
8
6
|
|
|
9
7
|
```bash
|
|
@@ -15,10 +13,10 @@ npm install applesauce-relay
|
|
|
15
13
|
- [x] NIP-01
|
|
16
14
|
- [x] Client negentropy sync
|
|
17
15
|
- [x] Relay pool and groups
|
|
18
|
-
- [
|
|
19
|
-
- [ ]
|
|
20
|
-
- [ ]
|
|
21
|
-
- [ ]
|
|
16
|
+
- [x] Write tests
|
|
17
|
+
- [ ] Reconnection logic
|
|
18
|
+
- [ ] NIP-11 limitations
|
|
19
|
+
- [ ] NIP-45 COUNT
|
|
22
20
|
|
|
23
21
|
## Examples
|
|
24
22
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { subscribeSpyTo } from "@hirez_io/observer-spy";
|
|
3
|
+
import { WS } from "vitest-websocket-mock";
|
|
4
|
+
import { Relay } from "../relay.js";
|
|
5
|
+
let mockServer;
|
|
6
|
+
let relay;
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
mockServer = new WS("wss://test");
|
|
9
|
+
relay = new Relay("wss://test");
|
|
10
|
+
// Create a persistent subscription to keep the connection open
|
|
11
|
+
// @ts-expect-error
|
|
12
|
+
subscribeSpyTo(relay.socket);
|
|
13
|
+
});
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
mockServer.close();
|
|
16
|
+
// Wait for server to close to prevent memory leaks
|
|
17
|
+
await WS.clean();
|
|
18
|
+
});
|
|
19
|
+
const mockEvent = {
|
|
20
|
+
kind: 1,
|
|
21
|
+
id: "00007641c9c3e65a71843933a44a18060c7c267a4f9169efa3735ece45c8f621",
|
|
22
|
+
pubkey: "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
|
|
23
|
+
created_at: 1743712795,
|
|
24
|
+
tags: [["nonce", "13835058055282167643", "16"]],
|
|
25
|
+
content: "This is just stupid: https://codestr.fiatjaf.com/",
|
|
26
|
+
sig: "5a57b5a12bba4b7cf0121077b1421cf4df402c5c221376c076204fc4f7519e28ce6508f26ddc132c406ccfe6e62cc6db857b96c788565cdca9674fe9a0710ac2",
|
|
27
|
+
};
|
|
28
|
+
describe("event", () => {
|
|
29
|
+
it("should wait for auth before sending EVENT if auth-required received", async () => {
|
|
30
|
+
// Create first event subscription
|
|
31
|
+
const spy1 = subscribeSpyTo(relay.event(mockEvent));
|
|
32
|
+
// Verify EVENT was sent
|
|
33
|
+
const firstEventMessage = await mockServer.nextMessage;
|
|
34
|
+
expect(JSON.parse(firstEventMessage)).toEqual(["EVENT", mockEvent]);
|
|
35
|
+
// Send auth-required response
|
|
36
|
+
mockServer.send(JSON.stringify(["OK", mockEvent.id, false, "auth-required: need to authenticate"]));
|
|
37
|
+
// Create second event subscription - this should not send EVENT yet
|
|
38
|
+
const spy2 = subscribeSpyTo(relay.event(mockEvent));
|
|
39
|
+
// Should not have received any messages
|
|
40
|
+
expect(mockServer.messages.length).toBe(1);
|
|
41
|
+
// Send AUTH challenge
|
|
42
|
+
mockServer.send(JSON.stringify(["AUTH", "challenge-string"]));
|
|
43
|
+
// Send auth event
|
|
44
|
+
const authEvent = { ...mockEvent, id: "auth-id" };
|
|
45
|
+
subscribeSpyTo(relay.auth(authEvent));
|
|
46
|
+
// Verify AUTH was sent
|
|
47
|
+
const authMessage = await mockServer.nextMessage;
|
|
48
|
+
expect(JSON.parse(authMessage)).toEqual(["AUTH", authEvent]);
|
|
49
|
+
// Send successful auth response
|
|
50
|
+
mockServer.send(JSON.stringify(["OK", authEvent.id, true, ""]));
|
|
51
|
+
// Now the second EVENT should be sent
|
|
52
|
+
const secondEventMessage = await mockServer.nextMessage;
|
|
53
|
+
expect(JSON.parse(secondEventMessage)).toEqual(["EVENT", mockEvent]);
|
|
54
|
+
// Send OK response for second event
|
|
55
|
+
mockServer.send(JSON.stringify(["OK", mockEvent.id, true, ""]));
|
|
56
|
+
expect(spy1.getLastValue()).toEqual({
|
|
57
|
+
ok: false,
|
|
58
|
+
message: "auth-required: need to authenticate",
|
|
59
|
+
from: "wss://test",
|
|
60
|
+
});
|
|
61
|
+
expect(spy2.getLastValue()).toEqual({ ok: true, message: "", from: "wss://test" });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe("req", () => {
|
|
65
|
+
it("should wait for auth before sending REQ if auth-required received", async () => {
|
|
66
|
+
// Create first REQ subscription
|
|
67
|
+
const filters = [{ kinds: [1], limit: 10 }];
|
|
68
|
+
subscribeSpyTo(relay.req(filters, "sub1"));
|
|
69
|
+
// Verify REQ was sent
|
|
70
|
+
const firstReqMessage = await mockServer.nextMessage;
|
|
71
|
+
expect(JSON.parse(firstReqMessage)).toEqual(["REQ", "sub1", ...filters]);
|
|
72
|
+
// Send auth-required response
|
|
73
|
+
mockServer.send(JSON.stringify(["CLOSED", "sub1", "auth-required: need to authenticate"]));
|
|
74
|
+
// Consume the client CLOSE message for sub1
|
|
75
|
+
await mockServer.nextMessage;
|
|
76
|
+
// Create second REQ subscription - this should not send REQ yet
|
|
77
|
+
subscribeSpyTo(relay.req(filters, "sub2"));
|
|
78
|
+
// Should not have received any messages
|
|
79
|
+
expect(mockServer.messages).not.toContain(JSON.stringify(["REQ", "sub2", ...filters]));
|
|
80
|
+
// Send AUTH challenge
|
|
81
|
+
mockServer.send(JSON.stringify(["AUTH", "challenge-string"]));
|
|
82
|
+
// Send auth event
|
|
83
|
+
const authEvent = { ...mockEvent, id: "auth-id" };
|
|
84
|
+
subscribeSpyTo(relay.auth(authEvent));
|
|
85
|
+
// Verify AUTH was sent
|
|
86
|
+
const authMessage = await mockServer.nextMessage;
|
|
87
|
+
expect(JSON.parse(authMessage)).toEqual(["AUTH", authEvent]);
|
|
88
|
+
// Send successful auth response
|
|
89
|
+
mockServer.send(JSON.stringify(["OK", authEvent.id, true, ""]));
|
|
90
|
+
// Now the second REQ should be sent
|
|
91
|
+
const secondReqMessage = await mockServer.nextMessage;
|
|
92
|
+
expect(JSON.parse(secondReqMessage)).toEqual(["REQ", "sub2", ...filters]);
|
|
93
|
+
// Send some events for the second subscription
|
|
94
|
+
mockServer.send(JSON.stringify(["EVENT", "sub2", mockEvent]));
|
|
95
|
+
mockServer.send(JSON.stringify(["EOSE", "sub2"]));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe("auth", () => {
|
|
99
|
+
it("should set authenticated state after successful AUTH challenge response", async () => {
|
|
100
|
+
// Send AUTH challenge
|
|
101
|
+
mockServer.send(JSON.stringify(["AUTH", "challenge-string"]));
|
|
102
|
+
// Send auth event response
|
|
103
|
+
const authEvent = { ...mockEvent, id: "auth-id" };
|
|
104
|
+
subscribeSpyTo(relay.auth(authEvent));
|
|
105
|
+
// Verify AUTH was sent
|
|
106
|
+
const authMessage = await mockServer.nextMessage;
|
|
107
|
+
expect(JSON.parse(authMessage)).toEqual(["AUTH", authEvent]);
|
|
108
|
+
// Send successful auth response
|
|
109
|
+
mockServer.send(JSON.stringify(["OK", authEvent.id, true, ""]));
|
|
110
|
+
expect(relay.authenticated).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as Nostr from "nostr-typedef";
|
|
2
|
+
export declare const faker: {
|
|
3
|
+
filter(filter?: Nostr.Filter): Nostr.Filter;
|
|
4
|
+
filters(): Nostr.Filter[];
|
|
5
|
+
event<const K extends number>(event?: Partial<Nostr.Event<K>>): Nostr.Event<K>;
|
|
6
|
+
AUTH(event?: Partial<Nostr.Event<Nostr.Kind.ClientAuthentication>>): Nostr.ToRelayMessage.AUTH;
|
|
7
|
+
CLOSE(subId: string): Nostr.ToRelayMessage.CLOSE;
|
|
8
|
+
COUNT(subId: string, filters?: Nostr.Filter[]): Nostr.ToRelayMessage.COUNT;
|
|
9
|
+
EVENT(event?: Partial<Nostr.Event>): Nostr.ToRelayMessage.EVENT;
|
|
10
|
+
REQ(subId: string, filters?: Nostr.Filter[]): Nostr.ToRelayMessage.REQ;
|
|
11
|
+
toClientMessage: {
|
|
12
|
+
AUTH(message?: string): Nostr.ToClientMessage.AUTH;
|
|
13
|
+
COUNT(subId: string, count?: number): Nostr.ToClientMessage.COUNT;
|
|
14
|
+
EOSE(subId: string): Nostr.ToClientMessage.EOSE;
|
|
15
|
+
EVENT(subId: string, event?: Partial<Nostr.Event>): Nostr.ToClientMessage.EVENT;
|
|
16
|
+
NOTICE(message?: string): Nostr.ToClientMessage.NOTICE;
|
|
17
|
+
OK(eventId: string, succeeded: boolean, message?: string): Nostr.ToClientMessage.OK;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { kinds } from "nostr-tools";
|
|
2
|
+
export const faker = {
|
|
3
|
+
filter(filter) {
|
|
4
|
+
return filter ?? { kinds: [0] };
|
|
5
|
+
},
|
|
6
|
+
filters() {
|
|
7
|
+
return [faker.filter()];
|
|
8
|
+
},
|
|
9
|
+
event(event) {
|
|
10
|
+
return {
|
|
11
|
+
id: "*",
|
|
12
|
+
content: "*",
|
|
13
|
+
created_at: 0,
|
|
14
|
+
kind: 0,
|
|
15
|
+
pubkey: "*",
|
|
16
|
+
sig: "*",
|
|
17
|
+
tags: [],
|
|
18
|
+
...event,
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
AUTH(event) {
|
|
22
|
+
return ["AUTH", { ...faker.event(event), kind: kinds.ClientAuth }];
|
|
23
|
+
},
|
|
24
|
+
CLOSE(subId) {
|
|
25
|
+
return ["CLOSE", subId];
|
|
26
|
+
},
|
|
27
|
+
COUNT(subId, filters) {
|
|
28
|
+
return ["COUNT", subId, ...(filters ?? faker.filters())];
|
|
29
|
+
},
|
|
30
|
+
EVENT(event) {
|
|
31
|
+
return ["EVENT", faker.event(event)];
|
|
32
|
+
},
|
|
33
|
+
REQ(subId, filters) {
|
|
34
|
+
return ["REQ", subId, ...(filters ?? faker.filters())];
|
|
35
|
+
},
|
|
36
|
+
toClientMessage: {
|
|
37
|
+
AUTH(message) {
|
|
38
|
+
return ["AUTH", message ?? "*"];
|
|
39
|
+
},
|
|
40
|
+
COUNT(subId, count) {
|
|
41
|
+
return ["COUNT", subId, { count: count ?? 0 }];
|
|
42
|
+
},
|
|
43
|
+
EOSE(subId) {
|
|
44
|
+
return ["EOSE", subId];
|
|
45
|
+
},
|
|
46
|
+
EVENT(subId, event) {
|
|
47
|
+
return ["EVENT", subId, faker.event(event)];
|
|
48
|
+
},
|
|
49
|
+
NOTICE(message) {
|
|
50
|
+
return ["NOTICE", message ?? "*"];
|
|
51
|
+
},
|
|
52
|
+
OK(eventId, succeeded, message) {
|
|
53
|
+
return ["OK", eventId, succeeded, message ?? "*"];
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { subscribeSpyTo } from "@hirez_io/observer-spy";
|
|
3
|
+
import { WS } from "vitest-websocket-mock";
|
|
4
|
+
import { Relay } from "../relay.js";
|
|
5
|
+
import { RelayGroup } from "../group.js";
|
|
6
|
+
let mockRelay1;
|
|
7
|
+
let mockRelay2;
|
|
8
|
+
let relay1;
|
|
9
|
+
let relay2;
|
|
10
|
+
let group;
|
|
11
|
+
let mockEvent;
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
mockRelay1 = new WS("wss://relay1.test");
|
|
14
|
+
mockRelay2 = new WS("wss://relay2.test");
|
|
15
|
+
relay1 = new Relay("wss://relay1.test");
|
|
16
|
+
relay2 = new Relay("wss://relay2.test");
|
|
17
|
+
group = new RelayGroup([relay1, relay2]);
|
|
18
|
+
mockEvent = {
|
|
19
|
+
kind: 1,
|
|
20
|
+
id: "test-id",
|
|
21
|
+
pubkey: "test-pubkey",
|
|
22
|
+
created_at: 1234567890,
|
|
23
|
+
tags: [],
|
|
24
|
+
content: "test content",
|
|
25
|
+
sig: "test-sig",
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
mockRelay1.close();
|
|
30
|
+
mockRelay2.close();
|
|
31
|
+
await WS.clean();
|
|
32
|
+
});
|
|
33
|
+
describe("req", () => {
|
|
34
|
+
it("should trigger connections to multiple relays", async () => {
|
|
35
|
+
group.req([{ kinds: [1] }], "test-sub").subscribe();
|
|
36
|
+
const req1 = await mockRelay1.nextMessage;
|
|
37
|
+
const req2 = await mockRelay2.nextMessage;
|
|
38
|
+
expect(JSON.parse(req1)).toEqual(["REQ", "test-sub", { kinds: [1] }]);
|
|
39
|
+
expect(JSON.parse(req2)).toEqual(["REQ", "test-sub", { kinds: [1] }]);
|
|
40
|
+
});
|
|
41
|
+
it("should emit events from all relays", async () => {
|
|
42
|
+
const spy = subscribeSpyTo(group.req([{ kinds: [1] }], "test-sub"));
|
|
43
|
+
mockRelay1.send(JSON.stringify(["EVENT", "test-sub", { ...mockEvent, id: "1" }]));
|
|
44
|
+
mockRelay2.send(JSON.stringify(["EVENT", "test-sub", { ...mockEvent, id: "2" }]));
|
|
45
|
+
expect(spy.getValues()).toEqual([expect.objectContaining({ id: "1" }), expect.objectContaining({ id: "2" })]);
|
|
46
|
+
});
|
|
47
|
+
it("should only emit EOSE once all relays have emitted EOSE", async () => {
|
|
48
|
+
const spy = subscribeSpyTo(group.req([{ kinds: [1] }], "test-sub"));
|
|
49
|
+
mockRelay1.send(JSON.stringify(["EOSE", "test-sub"]));
|
|
50
|
+
expect(spy.getValues()).not.toContain("EOSE");
|
|
51
|
+
mockRelay2.send(JSON.stringify(["EOSE", "test-sub"]));
|
|
52
|
+
expect(spy.getValues()).toContain("EOSE");
|
|
53
|
+
});
|
|
54
|
+
it("should ignore relays that have an error", async () => {
|
|
55
|
+
const spy = subscribeSpyTo(group.req([{ kinds: [1] }], "test-sub"));
|
|
56
|
+
mockRelay1.error();
|
|
57
|
+
mockRelay2.send(JSON.stringify(["EVENT", "test-sub", mockEvent]));
|
|
58
|
+
mockRelay2.send(JSON.stringify(["EOSE", "test-sub"]));
|
|
59
|
+
expect(spy.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
|
|
60
|
+
});
|
|
61
|
+
it("should emit EOSE if all relays error", async () => {
|
|
62
|
+
const spy = subscribeSpyTo(group.req([{ kinds: [1] }], "test-sub"));
|
|
63
|
+
mockRelay1.error();
|
|
64
|
+
mockRelay2.error();
|
|
65
|
+
expect(spy.getValues()).toEqual(["EOSE"]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe("event", () => {
|
|
69
|
+
it("should send EVENT to all relays in the group", async () => {
|
|
70
|
+
group.event(mockEvent).subscribe();
|
|
71
|
+
const event1 = await mockRelay1.nextMessage;
|
|
72
|
+
const event2 = await mockRelay2.nextMessage;
|
|
73
|
+
expect(JSON.parse(event1)).toEqual(["EVENT", mockEvent]);
|
|
74
|
+
expect(JSON.parse(event2)).toEqual(["EVENT", mockEvent]);
|
|
75
|
+
});
|
|
76
|
+
it("should emit OK messages from all relays", async () => {
|
|
77
|
+
const spy = subscribeSpyTo(group.event(mockEvent));
|
|
78
|
+
mockRelay1.send(JSON.stringify(["OK", mockEvent.id, true, ""]));
|
|
79
|
+
mockRelay2.send(JSON.stringify(["OK", mockEvent.id, true, ""]));
|
|
80
|
+
expect(spy.getValues()).toEqual([
|
|
81
|
+
expect.objectContaining({ ok: true, from: "wss://relay1.test", message: "" }),
|
|
82
|
+
expect.objectContaining({ ok: true, from: "wss://relay2.test", message: "" }),
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
it("should complete when all relays have sent OK messages", async () => {
|
|
86
|
+
const spy = subscribeSpyTo(group.event(mockEvent));
|
|
87
|
+
mockRelay1.send(JSON.stringify(["OK", mockEvent.id, true, ""]));
|
|
88
|
+
expect(spy.receivedComplete()).toBe(false);
|
|
89
|
+
mockRelay2.send(JSON.stringify(["OK", mockEvent.id, true, ""]));
|
|
90
|
+
expect(spy.receivedComplete()).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
it("should handle relay errors and still complete", async () => {
|
|
93
|
+
const spy = subscribeSpyTo(group.event(mockEvent));
|
|
94
|
+
mockRelay1.error();
|
|
95
|
+
mockRelay2.send(JSON.stringify(["OK", mockEvent.id, true, ""]));
|
|
96
|
+
expect(spy.getValues()).toEqual([
|
|
97
|
+
expect.objectContaining({ ok: false, from: "wss://relay1.test", message: "Unknown error" }),
|
|
98
|
+
expect.objectContaining({ ok: true, from: "wss://relay2.test", message: "" }),
|
|
99
|
+
]);
|
|
100
|
+
expect(spy.receivedComplete()).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import Nostr from "nostr-typedef";
|
|
2
|
+
declare module "vitest" {
|
|
3
|
+
interface Assertion<T = any> extends CustomMatchers {
|
|
4
|
+
}
|
|
5
|
+
interface AsymmetricMatchersContaining extends CustomMatchers {
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
interface CustomMatchers {
|
|
9
|
+
beToRelayAUTH(): void;
|
|
10
|
+
beToRelayAUTH(event: Partial<Nostr.Event<Nostr.Kind.ClientAuthentication>>): void;
|
|
11
|
+
beToRelayCLOSE(): void;
|
|
12
|
+
beToRelayCLOSE(subId: string): void;
|
|
13
|
+
beToRelayCOUNT(): void;
|
|
14
|
+
beToRelayCOUNT(subId: string): void;
|
|
15
|
+
beToRelayCOUNT(expected: [subId: string, ...filters: Nostr.Filter[]]): void;
|
|
16
|
+
beToRelayEVENT(): void;
|
|
17
|
+
beToRelayEVENT(event: Partial<Nostr.Event>): void;
|
|
18
|
+
beToRelayREQ(): void;
|
|
19
|
+
beToRelayREQ(subId: string): void;
|
|
20
|
+
beToRelayREQ(expected: [subId: string, ...filters: Nostr.Filter[]]): void;
|
|
21
|
+
beToClientAUTH(): void;
|
|
22
|
+
beToClientAUTH(challengeMessage: string): void;
|
|
23
|
+
beToClientCOUNT(): void;
|
|
24
|
+
beToClientCOUNT(subId: string): void;
|
|
25
|
+
beToClientCOUNT(expected: [subId: string, count: number]): void;
|
|
26
|
+
beToClientEOSE(): void;
|
|
27
|
+
beToClientEOSE(subId: string): void;
|
|
28
|
+
beToClientEVENT(): void;
|
|
29
|
+
beToClientEVENT(subId: string): void;
|
|
30
|
+
beToClientEVENT(expected: [subId: string, event: Partial<Nostr.Event>]): void;
|
|
31
|
+
beToClientNOTICE(): void;
|
|
32
|
+
beToClientNOTICE(message: string): void;
|
|
33
|
+
beToClientOK(expected: [eventId: string, succeeded: boolean, message?: string]): void;
|
|
34
|
+
toReceiveAUTH(): Promise<void>;
|
|
35
|
+
toReceiveAUTH(event: Partial<Nostr.Event<Nostr.Kind.ClientAuthentication>>): Promise<void>;
|
|
36
|
+
toReceiveCLOSE(): Promise<void>;
|
|
37
|
+
toReceiveCLOSE(subId: string): Promise<void>;
|
|
38
|
+
toReceiveCOUNT(): Promise<void>;
|
|
39
|
+
toReceiveCOUNT(subId: string): Promise<void>;
|
|
40
|
+
toReceiveCOUNT(expected: [subId: string, ...filters: Nostr.Filter[]]): Promise<void>;
|
|
41
|
+
toReceiveEVENT(): Promise<void>;
|
|
42
|
+
toReceiveEVENT(event: Partial<Nostr.Event>): Promise<void>;
|
|
43
|
+
toReceiveREQ(): Promise<void>;
|
|
44
|
+
toReceiveREQ(subId: string): Promise<void>;
|
|
45
|
+
toReceiveREQ(expected: [subId: string, ...filters: Nostr.Filter[]]): Promise<void>;
|
|
46
|
+
toSeeAUTH(): Promise<void>;
|
|
47
|
+
toSeeAUTH(challengeMessage: string): Promise<void>;
|
|
48
|
+
toSeeCOUNT(): Promise<void>;
|
|
49
|
+
toSeeCOUNT(subId: string): Promise<void>;
|
|
50
|
+
toSeeCOUNT(expected: [subId: string, count: number]): Promise<void>;
|
|
51
|
+
toSeeEOSE(): Promise<void>;
|
|
52
|
+
toSeeEOSE(subId: string): Promise<void>;
|
|
53
|
+
toSeeEVENT(): Promise<void>;
|
|
54
|
+
toSeeEVENT(subId: string): Promise<void>;
|
|
55
|
+
toSeeEVENT(expected: [subId: string, event: Partial<Nostr.Event>]): Promise<void>;
|
|
56
|
+
toSeeNOTICE(): Promise<void>;
|
|
57
|
+
toSeeNOTICE(message: string): Promise<void>;
|
|
58
|
+
toSeeOK(expected: [eventId: string, succeeded: boolean, message?: string]): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { expect } from "vitest";
|
|
2
|
+
import { deriveToReceiveMessage } from "vitest-websocket-mock";
|
|
3
|
+
import { TimeoutError, withTimeout } from "./utils.js";
|
|
4
|
+
const beToRelayAUTH = function (actual, event) {
|
|
5
|
+
const pass = isNostrMessage("AUTH", actual) && (event === undefined || matchEvent(actual[1], event, this.equals.bind(this)));
|
|
6
|
+
return {
|
|
7
|
+
message: message("a to-relay-AUTH message", ["AUTH", event ?? "*"], this, actual, pass),
|
|
8
|
+
pass,
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
const beToRelayCLOSE = function (actual, subId) {
|
|
12
|
+
const pass = isNostrMessage("CLOSE", actual) && (subId === undefined || actual[1] === subId);
|
|
13
|
+
return {
|
|
14
|
+
message: message("a to-relay-CLOSE message", ["CLOSE", subId ?? "*"], this, actual, pass),
|
|
15
|
+
pass,
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
const beToRelayCOUNT = function (actual, pred) {
|
|
19
|
+
const subId = typeof pred === "string" ? pred : pred !== undefined ? pred[0] : null;
|
|
20
|
+
const filters = typeof pred === "object" && pred !== undefined ? pred.slice(1) : null;
|
|
21
|
+
const pass = isNostrMessage("COUNT", actual) &&
|
|
22
|
+
(subId === null || actual[1] === subId) &&
|
|
23
|
+
(filters === null || this.equals(actual.slice(2), filters));
|
|
24
|
+
return {
|
|
25
|
+
message: message("a to-relay-COUNT message", ["COUNT", subId ?? "*", ...(filters === null ? ["*"] : filters)], this, actual, pass),
|
|
26
|
+
pass,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
const beToRelayEVENT = function (actual, event) {
|
|
30
|
+
const pass = isNostrMessage("EVENT", actual) && (event === undefined || matchEvent(actual[1], event, this.equals.bind(this)));
|
|
31
|
+
return {
|
|
32
|
+
message: message("a to-relay-EVENT message", ["EVENT", event ?? "*"], this, actual, pass),
|
|
33
|
+
pass,
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
const beToRelayREQ = function (actual, pred) {
|
|
37
|
+
const subId = typeof pred === "string" ? pred : pred !== undefined ? pred[0] : null;
|
|
38
|
+
const filters = typeof pred === "object" && pred !== undefined ? pred.slice(1) : null;
|
|
39
|
+
const pass = isNostrMessage("REQ", actual) &&
|
|
40
|
+
(subId === null || actual[1] === subId) &&
|
|
41
|
+
(filters === null || this.equals(actual.slice(2), filters));
|
|
42
|
+
return {
|
|
43
|
+
message: message("a to-relay-REQ message", ["REQ", subId ?? "*", ...(filters === null ? ["*"] : filters)], this, actual, pass),
|
|
44
|
+
pass,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const beToClientAUTH = function (actual, challengeMessage) {
|
|
48
|
+
const pass = isNostrMessage("AUTH", actual) && (challengeMessage === undefined || actual[1] === challengeMessage);
|
|
49
|
+
return {
|
|
50
|
+
message: message("a to-client-AUTH message", ["AUTH", challengeMessage ?? "*"], this, actual, pass),
|
|
51
|
+
pass,
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
const beToClientCOUNT = function (actual, pred) {
|
|
55
|
+
const subId = typeof pred === "string" ? pred : pred !== undefined ? pred[0] : null;
|
|
56
|
+
const count = typeof pred === "object" && pred !== undefined ? pred[1] : null;
|
|
57
|
+
const pass = isNostrMessage("COUNT", actual) &&
|
|
58
|
+
(subId === null || actual[1] === subId) &&
|
|
59
|
+
(count === null || this.equals(actual[2], { count }));
|
|
60
|
+
return {
|
|
61
|
+
message: message("a to-client-COUNT message", ["COUNT", subId ?? "*", count === null ? "*" : { count }], this, actual, pass),
|
|
62
|
+
pass,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
const beToClientEOSE = function (actual, subId) {
|
|
66
|
+
const pass = isNostrMessage("EOSE", actual) && (subId === undefined || actual[1] === subId);
|
|
67
|
+
return {
|
|
68
|
+
message: message("a to-client-EOSE message", ["COUNT", subId ?? "*"], this, actual, pass),
|
|
69
|
+
pass,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
const beToClientEVENT = function (actual, pred) {
|
|
73
|
+
const subId = typeof pred === "string" ? pred : pred !== undefined ? pred[0] : null;
|
|
74
|
+
const event = typeof pred === "object" && pred !== undefined ? pred[1] : null;
|
|
75
|
+
const pass = isNostrMessage("EVENT", actual) &&
|
|
76
|
+
(subId === null || actual[1] === subId) &&
|
|
77
|
+
(event === null || matchEvent(actual[2], event, this.equals.bind(this)));
|
|
78
|
+
return {
|
|
79
|
+
message: message("a to-client-EVENT message", ["EVENT", subId ?? "*", event ?? "*"], this, actual, pass),
|
|
80
|
+
pass,
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
const beToClientNOTICE = function (actual, noticeMessage) {
|
|
84
|
+
const pass = isNostrMessage("NOTICE", actual) && (noticeMessage === undefined || actual[1] === noticeMessage);
|
|
85
|
+
return {
|
|
86
|
+
message: message("a to-client-NOTICE message", ["EVENT", noticeMessage ?? "*"], this, actual, pass),
|
|
87
|
+
pass,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
const beToClientOK = function (actual, expected) {
|
|
91
|
+
const pass = isNostrMessage("OK", actual) &&
|
|
92
|
+
(expected === undefined ||
|
|
93
|
+
(actual[1] === expected[0] &&
|
|
94
|
+
actual[2] === expected[1] &&
|
|
95
|
+
(expected[2] === undefined || actual[3] === expected[2])));
|
|
96
|
+
return {
|
|
97
|
+
message: message("a to-client-OK message", ["OK", ...(expected ?? ["*", "*", "*"])], this, actual, pass),
|
|
98
|
+
pass,
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
const toReceiveAUTH = deriveToReceiveMessage("toReceiveAUTH", beToRelayAUTH);
|
|
102
|
+
const toReceiveCLOSE = deriveToReceiveMessage("toReceiveCLOSE", beToRelayCLOSE);
|
|
103
|
+
const toReceiveCOUNT = deriveToReceiveMessage("toReceiveCOUNT", beToRelayCOUNT);
|
|
104
|
+
const toReceiveEVENT = deriveToReceiveMessage("toReceiveEVENT", beToRelayEVENT);
|
|
105
|
+
const toReceiveREQ = deriveToReceiveMessage("toReceiveREQ", beToRelayREQ);
|
|
106
|
+
const toSeeAUTH = clientMathcer("toSeeAUTH", beToClientAUTH);
|
|
107
|
+
const toSeeCOUNT = clientMathcer("toSeeCOUNT", beToClientCOUNT);
|
|
108
|
+
const toSeeEOSE = clientMathcer("toSeeEOSE", beToClientEOSE);
|
|
109
|
+
const toSeeEVENT = clientMathcer("toSeeEVENT", beToClientEVENT);
|
|
110
|
+
const toSeeNOTICE = clientMathcer("toSeeNOTICE", beToClientNOTICE);
|
|
111
|
+
const toSeeOK = clientMathcer("toSeeOK", beToClientOK);
|
|
112
|
+
expect.extend({
|
|
113
|
+
beToRelayAUTH,
|
|
114
|
+
beToRelayCLOSE,
|
|
115
|
+
beToRelayCOUNT,
|
|
116
|
+
beToRelayEVENT,
|
|
117
|
+
beToRelayREQ,
|
|
118
|
+
beToClientAUTH,
|
|
119
|
+
beToClientCOUNT,
|
|
120
|
+
beToClientEOSE,
|
|
121
|
+
beToClientEVENT,
|
|
122
|
+
beToClientNOTICE,
|
|
123
|
+
beToClientOK,
|
|
124
|
+
toReceiveAUTH,
|
|
125
|
+
toReceiveCLOSE,
|
|
126
|
+
toReceiveCOUNT,
|
|
127
|
+
toReceiveEVENT,
|
|
128
|
+
toReceiveREQ,
|
|
129
|
+
toSeeAUTH,
|
|
130
|
+
toSeeCOUNT,
|
|
131
|
+
toSeeEOSE,
|
|
132
|
+
toSeeEVENT,
|
|
133
|
+
toSeeNOTICE,
|
|
134
|
+
toSeeOK,
|
|
135
|
+
});
|
|
136
|
+
function clientMathcer(name, fn) {
|
|
137
|
+
return async function (spy, pred, options) {
|
|
138
|
+
if (spy.__createClientSpy) {
|
|
139
|
+
try {
|
|
140
|
+
return await withTimeout(async () => fn.call(this, await spy.next(), pred, options));
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (err instanceof TimeoutError) {
|
|
144
|
+
return {
|
|
145
|
+
pass: this.isNot,
|
|
146
|
+
message: () => this.utils.matcherHint(`${this.isNot ? ".not" : ""}.${name}`, "ClientSpy", "expected") +
|
|
147
|
+
"\n\n" +
|
|
148
|
+
`Client spy was waiting for the next message from the relay, but timed out.`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
return {
|
|
158
|
+
pass: this.isNot,
|
|
159
|
+
message: () => this.utils.matcherHint(`${this.isNot ? ".not" : ""}.${name}`, "ClientSpy", "expected") +
|
|
160
|
+
"\n\n" +
|
|
161
|
+
`Mathcer \`${name}\` must be used for ClientSpy, but now being used for:\n\n` +
|
|
162
|
+
`${this.utils.printReceived(spy)}\n`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function isNostrMessage(type, message) {
|
|
168
|
+
return Array.isArray(message) && message[0] === type;
|
|
169
|
+
}
|
|
170
|
+
function matchEvent(received, expected, equal) {
|
|
171
|
+
const eventKeys = ["id", "sig", "kind", "tags", "pubkey", "content", "created_at"];
|
|
172
|
+
const isEvent = (x) => typeof x === "object" && x !== null && eventKeys.every((key) => key in x);
|
|
173
|
+
return (isEvent(received) && eventKeys.every((key) => expected[key] === undefined || equal(received[key], expected[key])));
|
|
174
|
+
}
|
|
175
|
+
function message(entityName, expected, state, actual, pass) {
|
|
176
|
+
return () => `It was expected ${pass ? "not " : ""}to be ${entityName}, like this:\n\n${state.utils.printExpected(expected)}\n\nbut got:\n\n${state.utils.printReceived(actual)}\n`;
|
|
177
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { CloseOptions } from "mock-socket";
|
|
2
|
+
import Nostr from "nostr-typedef";
|
|
3
|
+
import { WS } from "vitest-websocket-mock";
|
|
4
|
+
export interface MockServerSocket {
|
|
5
|
+
id: number;
|
|
6
|
+
send(message: unknown): void;
|
|
7
|
+
close(options?: CloseOptions): void;
|
|
8
|
+
}
|
|
9
|
+
export interface MockServerBehavior {
|
|
10
|
+
onOpen?(socket: MockServerSocket): void;
|
|
11
|
+
onMessage?(socket: MockServerSocket, message: string): void;
|
|
12
|
+
onClose?(socket: MockServerSocket): void;
|
|
13
|
+
onError?(socket: MockServerSocket, error: Error): void;
|
|
14
|
+
}
|
|
15
|
+
interface MockServer extends WS {
|
|
16
|
+
getSockets: (count: number, options?: {
|
|
17
|
+
timeout?: number;
|
|
18
|
+
}) => Promise<MockServerSocket[]>;
|
|
19
|
+
getSocket: (index: number) => Promise<MockServerSocket>;
|
|
20
|
+
getSocketsSync: () => MockServerSocket[];
|
|
21
|
+
}
|
|
22
|
+
export declare function createMockServer(url: string, behavior: MockServerBehavior): MockServer;
|
|
23
|
+
export interface MockRelay extends MockServer {
|
|
24
|
+
/**
|
|
25
|
+
* Pop the latest message that has not yet been consumed.
|
|
26
|
+
* If such a message does not yet exist, it waits for the message
|
|
27
|
+
* until it times out.
|
|
28
|
+
*
|
|
29
|
+
* The default timeout is 1000 miliseconds.
|
|
30
|
+
*/
|
|
31
|
+
next: (timeout?: number) => Promise<Nostr.ToRelayMessage.Any>;
|
|
32
|
+
/**
|
|
33
|
+
* Pop the latest `count` messages that have not yet been consumed.
|
|
34
|
+
* If such a message does not yet exist, it waits for the message
|
|
35
|
+
* until it times out.
|
|
36
|
+
*
|
|
37
|
+
* The default timeout is 1000 miliseconds.
|
|
38
|
+
*/
|
|
39
|
+
nexts: (count: number, timeout?: number) => Promise<Nostr.ToRelayMessage.Any[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Send messages of any format from the given socket mock.
|
|
42
|
+
* If `socket` is omitted, it will be sent from all active socket mocks.
|
|
43
|
+
*
|
|
44
|
+
* If the message is a string, as-is string will be sent,
|
|
45
|
+
* otherwise JSON strigify will be attempted.
|
|
46
|
+
*
|
|
47
|
+
* Note that the messages sent by this method are outside
|
|
48
|
+
* the management of the mock relay.
|
|
49
|
+
* This means that if, for example, you use this method to send an OK message,
|
|
50
|
+
* the mock relay will not know about it.
|
|
51
|
+
* Therefore, subsequent calls to emitOK() may send a duplicate OK message.
|
|
52
|
+
* */
|
|
53
|
+
emit(message: unknown, socket?: MockServerSocket): string;
|
|
54
|
+
/**
|
|
55
|
+
* Send COUNT messages from all socket mocks
|
|
56
|
+
* holding active COUNT subscriptions (in other words,
|
|
57
|
+
* subscriptions that have not yet responded with COUNT)
|
|
58
|
+
* with the given subId.
|
|
59
|
+
*/
|
|
60
|
+
emitCOUNT(subId: string, count?: number): Nostr.ToClientMessage.COUNT;
|
|
61
|
+
/**
|
|
62
|
+
* Send EOSE messages from all socket mocks
|
|
63
|
+
* holding active REQ subscriptions (in other words,
|
|
64
|
+
* subscriptions that have not yet been CLOSE'd)
|
|
65
|
+
* with the given subId.
|
|
66
|
+
*/
|
|
67
|
+
emitEOSE(subId: string): Nostr.ToClientMessage.EOSE;
|
|
68
|
+
/**
|
|
69
|
+
* Send EVENT messages from all socket mocks
|
|
70
|
+
* holding active REQ subscriptions (in other words,
|
|
71
|
+
* subscriptions that have not yet been CLOSE'd)
|
|
72
|
+
* with the given subId.
|
|
73
|
+
*/
|
|
74
|
+
emitEVENT(subId: string, event?: Partial<Nostr.Event>): Nostr.ToClientMessage.EVENT;
|
|
75
|
+
/**
|
|
76
|
+
* Send OK messages from all socket mocks
|
|
77
|
+
* holding active EVENT (in other words, an EVENT
|
|
78
|
+
* that has not yet returned an OK message)
|
|
79
|
+
* with the given eventId.
|
|
80
|
+
*/
|
|
81
|
+
emitOK(eventId: string, succeeded: boolean, message?: string): Nostr.ToClientMessage.OK;
|
|
82
|
+
}
|
|
83
|
+
export declare function createMockRelay(url: string): MockRelay;
|
|
84
|
+
export {};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { WS } from "vitest-websocket-mock";
|
|
2
|
+
import { faker } from "./faker.js";
|
|
3
|
+
import { withTimeout } from "./utils.js";
|
|
4
|
+
export function createMockServer(url, behavior) {
|
|
5
|
+
const server = new WS(url, { jsonProtocol: true });
|
|
6
|
+
const sockets = [];
|
|
7
|
+
let resolvers = [];
|
|
8
|
+
let nextId = 1;
|
|
9
|
+
server.on("connection", (_socket) => {
|
|
10
|
+
const socket = {
|
|
11
|
+
id: nextId++,
|
|
12
|
+
send(message) {
|
|
13
|
+
if (_socket.readyState !== WebSocket.OPEN) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
_socket.send(typeof message === "string" ? message : `${JSON.stringify(message)}`);
|
|
17
|
+
},
|
|
18
|
+
close(options) {
|
|
19
|
+
_socket.close(options);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
sockets.push(socket);
|
|
23
|
+
resolvers.filter(([count]) => count <= sockets.length).forEach(([, resolve]) => resolve([...sockets]));
|
|
24
|
+
resolvers = resolvers.filter(([count]) => count > sockets.length);
|
|
25
|
+
behavior.onOpen?.(socket);
|
|
26
|
+
_socket.on("message", (message) => {
|
|
27
|
+
if (typeof message !== "string") {
|
|
28
|
+
throw new Error("Unexpected type message");
|
|
29
|
+
}
|
|
30
|
+
behavior.onMessage?.(socket, message);
|
|
31
|
+
});
|
|
32
|
+
_socket.on("close", () => {
|
|
33
|
+
behavior.onClose?.(socket);
|
|
34
|
+
});
|
|
35
|
+
_socket.on("error", (error) => {
|
|
36
|
+
behavior.onError?.(socket, error);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
const getSockets = async (count, options) => {
|
|
40
|
+
const promise = new Promise((resolve) => {
|
|
41
|
+
if (sockets.length >= count) {
|
|
42
|
+
resolve([...sockets]);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
resolvers.push([count, resolve]);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
return withTimeout(promise, `Mock relay was waiting for ${count} connections to be established, but timed out.`, options?.timeout);
|
|
49
|
+
};
|
|
50
|
+
const getSocket = async (index) => {
|
|
51
|
+
const sockets = await getSockets(index + 1);
|
|
52
|
+
return sockets[index];
|
|
53
|
+
};
|
|
54
|
+
return Object.assign(server, {
|
|
55
|
+
getSockets,
|
|
56
|
+
getSocket,
|
|
57
|
+
getSocketsSync: () => [...sockets],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function relayBehavior() {
|
|
61
|
+
const reqs = new Map();
|
|
62
|
+
const counts = new Map();
|
|
63
|
+
const events = new Map();
|
|
64
|
+
const sockets = new Set();
|
|
65
|
+
return {
|
|
66
|
+
onOpen(socket) {
|
|
67
|
+
sockets.add(socket);
|
|
68
|
+
},
|
|
69
|
+
onMessage(socket, rawMessage) {
|
|
70
|
+
const message = JSON.parse(rawMessage);
|
|
71
|
+
switch (message[0]) {
|
|
72
|
+
case "CLOSE": {
|
|
73
|
+
const subId = message[1];
|
|
74
|
+
reqs.get(socket)?.delete(subId);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case "COUNT": {
|
|
78
|
+
if (!counts.has(socket)) {
|
|
79
|
+
counts.set(socket, new Set());
|
|
80
|
+
}
|
|
81
|
+
const subId = message[1];
|
|
82
|
+
counts.get(socket)?.add(subId);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "EVENT": {
|
|
86
|
+
if (!events.has(socket)) {
|
|
87
|
+
events.set(socket, new Set());
|
|
88
|
+
}
|
|
89
|
+
const eventId = message[1].id;
|
|
90
|
+
events.get(socket)?.add(eventId);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "REQ": {
|
|
94
|
+
if (!reqs.has(socket)) {
|
|
95
|
+
reqs.set(socket, new Set());
|
|
96
|
+
}
|
|
97
|
+
const subId = message[1];
|
|
98
|
+
reqs.get(socket)?.add(subId);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
onClose(socket) {
|
|
104
|
+
reqs.delete(socket);
|
|
105
|
+
counts.delete(socket);
|
|
106
|
+
events.delete(socket);
|
|
107
|
+
sockets.delete(socket);
|
|
108
|
+
},
|
|
109
|
+
emit(message, socket) {
|
|
110
|
+
const toBeSent = typeof message === "string" ? message : `${JSON.stringify(message)}`;
|
|
111
|
+
if (socket) {
|
|
112
|
+
socket.send(toBeSent);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
for (const socket of sockets) {
|
|
116
|
+
socket.send(toBeSent);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return toBeSent;
|
|
120
|
+
},
|
|
121
|
+
emitCOUNT(subId, count) {
|
|
122
|
+
const toBeSent = faker.toClientMessage.COUNT(subId, count);
|
|
123
|
+
for (const [socket, subIds] of counts.entries()) {
|
|
124
|
+
if (subIds.has(subId)) {
|
|
125
|
+
socket.send(toBeSent);
|
|
126
|
+
}
|
|
127
|
+
subIds.delete(subId);
|
|
128
|
+
}
|
|
129
|
+
return toBeSent;
|
|
130
|
+
},
|
|
131
|
+
emitEOSE(subId) {
|
|
132
|
+
const toBeSent = faker.toClientMessage.EOSE(subId);
|
|
133
|
+
for (const [socket, subIds] of reqs.entries()) {
|
|
134
|
+
if (subIds.has(subId)) {
|
|
135
|
+
socket.send(toBeSent);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return toBeSent;
|
|
139
|
+
},
|
|
140
|
+
emitEVENT(subId, event) {
|
|
141
|
+
const toBeSent = faker.toClientMessage.EVENT(subId, event);
|
|
142
|
+
for (const [socket, subIds] of reqs.entries()) {
|
|
143
|
+
if (subIds.has(subId)) {
|
|
144
|
+
socket.send(toBeSent);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return toBeSent;
|
|
148
|
+
},
|
|
149
|
+
emitOK(eventId, succeeded, message) {
|
|
150
|
+
const toBeSent = faker.toClientMessage.OK(eventId, succeeded, message);
|
|
151
|
+
for (const [socket, eventIds] of events.entries()) {
|
|
152
|
+
if (eventIds.has(eventId)) {
|
|
153
|
+
socket.send(toBeSent);
|
|
154
|
+
}
|
|
155
|
+
eventIds.delete(eventId);
|
|
156
|
+
}
|
|
157
|
+
return toBeSent;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
export function createMockRelay(url) {
|
|
162
|
+
const behavior = relayBehavior();
|
|
163
|
+
const server = createMockServer(url, behavior);
|
|
164
|
+
const { emit, emitCOUNT, emitEOSE, emitEVENT, emitOK } = behavior;
|
|
165
|
+
const timeoutMessage = "Mock relay was waiting for the next message from the client, but timed out.";
|
|
166
|
+
return Object.assign(server, {
|
|
167
|
+
next: (timeout) => withTimeout(server.nextMessage, timeoutMessage, timeout),
|
|
168
|
+
nexts: (count, timeout) => withTimeout(async () => {
|
|
169
|
+
const messages = [];
|
|
170
|
+
for (let i = 0; i < count; i++) {
|
|
171
|
+
messages.push((await server.nextMessage));
|
|
172
|
+
}
|
|
173
|
+
return messages;
|
|
174
|
+
}, timeoutMessage, timeout),
|
|
175
|
+
emit,
|
|
176
|
+
emitCOUNT,
|
|
177
|
+
emitEOSE,
|
|
178
|
+
emitEVENT,
|
|
179
|
+
emitOK,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { subscribeSpyTo } from "@hirez_io/observer-spy";
|
|
3
|
+
import { WS } from "vitest-websocket-mock";
|
|
4
|
+
import { RelayPool } from "../pool.js";
|
|
5
|
+
let pool;
|
|
6
|
+
let mockServer1;
|
|
7
|
+
let mockServer2;
|
|
8
|
+
let mockEvent;
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
// Create mock WebSocket servers
|
|
11
|
+
mockServer1 = new WS("wss://relay1.example.com");
|
|
12
|
+
mockServer2 = new WS("wss://relay2.example.com");
|
|
13
|
+
pool = new RelayPool();
|
|
14
|
+
mockEvent = {
|
|
15
|
+
kind: 1,
|
|
16
|
+
id: "test-id",
|
|
17
|
+
pubkey: "test-pubkey",
|
|
18
|
+
created_at: 1743712795,
|
|
19
|
+
tags: [],
|
|
20
|
+
content: "test content",
|
|
21
|
+
sig: "test-sig",
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
mockServer1.close();
|
|
26
|
+
mockServer2.close();
|
|
27
|
+
// Clean up WebSocket mocks
|
|
28
|
+
await WS.clean();
|
|
29
|
+
});
|
|
30
|
+
test("creates new relay connections", () => {
|
|
31
|
+
const url = "wss://relay1.example.com";
|
|
32
|
+
const relay = pool.relay(url);
|
|
33
|
+
expect(relay).toBeDefined();
|
|
34
|
+
expect(pool.relays.get(url)).toBe(relay);
|
|
35
|
+
});
|
|
36
|
+
test("returns existing relay connection if already exists", () => {
|
|
37
|
+
const url = "wss://relay1.example.com";
|
|
38
|
+
const relay1 = pool.relay(url);
|
|
39
|
+
const relay2 = pool.relay(url);
|
|
40
|
+
expect(relay1).toBe(relay2);
|
|
41
|
+
expect(pool.relays.size).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
test("creates relay group with multiple relays", () => {
|
|
44
|
+
const urls = ["wss://relay1.example.com", "wss://relay2.example.com"];
|
|
45
|
+
const group = pool.group(urls);
|
|
46
|
+
expect(group).toBeDefined();
|
|
47
|
+
expect(pool.groups.get(urls.sort().join(","))).toBe(group);
|
|
48
|
+
});
|
|
49
|
+
test("req method sends subscription to multiple relays", async () => {
|
|
50
|
+
const urls = ["wss://relay1.example.com", "wss://relay2.example.com"];
|
|
51
|
+
const filters = { kinds: [1] };
|
|
52
|
+
const spy = subscribeSpyTo(pool.req(urls, filters));
|
|
53
|
+
// Verify REQ was sent to both relays
|
|
54
|
+
const req1 = await mockServer1.nextMessage;
|
|
55
|
+
const req2 = await mockServer2.nextMessage;
|
|
56
|
+
// Both messages should be REQ messages with the same filter
|
|
57
|
+
expect(JSON.parse(req1)[0]).toBe("REQ");
|
|
58
|
+
expect(JSON.parse(req2)[0]).toBe("REQ");
|
|
59
|
+
expect(JSON.parse(req1)[2]).toEqual(filters);
|
|
60
|
+
expect(JSON.parse(req2)[2]).toEqual(filters);
|
|
61
|
+
// Send EVENT from first relay
|
|
62
|
+
mockServer1.send(JSON.stringify(["EVENT", JSON.parse(req1)[1], mockEvent]));
|
|
63
|
+
// Send EOSE from both relays
|
|
64
|
+
mockServer1.send(JSON.stringify(["EOSE", JSON.parse(req1)[1]]));
|
|
65
|
+
mockServer2.send(JSON.stringify(["EOSE", JSON.parse(req2)[1]]));
|
|
66
|
+
expect(spy.getValues()).toContainEqual(expect.objectContaining(mockEvent));
|
|
67
|
+
});
|
|
68
|
+
test("event method publishes to multiple relays", async () => {
|
|
69
|
+
const urls = ["wss://relay1.example.com", "wss://relay2.example.com"];
|
|
70
|
+
const spy = subscribeSpyTo(pool.event(urls, mockEvent));
|
|
71
|
+
// Verify EVENT was sent to both relays
|
|
72
|
+
const event1 = await mockServer1.nextMessage;
|
|
73
|
+
const event2 = await mockServer2.nextMessage;
|
|
74
|
+
expect(JSON.parse(event1)).toEqual(["EVENT", mockEvent]);
|
|
75
|
+
expect(JSON.parse(event2)).toEqual(["EVENT", mockEvent]);
|
|
76
|
+
// Send OK responses from both relays
|
|
77
|
+
mockServer1.send(JSON.stringify(["OK", mockEvent.id, true, ""]));
|
|
78
|
+
mockServer2.send(JSON.stringify(["OK", mockEvent.id, true, ""]));
|
|
79
|
+
expect(spy.getValues()).toContainEqual({ ok: true, from: "wss://relay1.example.com", message: "" });
|
|
80
|
+
expect(spy.getValues()).toContainEqual({ ok: true, from: "wss://relay2.example.com", message: "" });
|
|
81
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
let mockRelay;
|
|
7
|
+
let relay;
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
mockRelay = new WS("wss://test");
|
|
10
|
+
relay = new Relay("wss://test");
|
|
11
|
+
});
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
mockRelay.close();
|
|
14
|
+
// Wait for server to close to prevent memory leaks
|
|
15
|
+
await WS.clean();
|
|
16
|
+
});
|
|
17
|
+
const mockEvent = {
|
|
18
|
+
kind: 1,
|
|
19
|
+
id: "00007641c9c3e65a71843933a44a18060c7c267a4f9169efa3735ece45c8f621",
|
|
20
|
+
pubkey: "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
|
|
21
|
+
created_at: 1743712795,
|
|
22
|
+
tags: [["nonce", "13835058055282167643", "16"]],
|
|
23
|
+
content: "This is just stupid: https://codestr.fiatjaf.com/",
|
|
24
|
+
sig: "5a57b5a12bba4b7cf0121077b1421cf4df402c5c221376c076204fc4f7519e28ce6508f26ddc132c406ccfe6e62cc6db857b96c788565cdca9674fe9a0710ac2",
|
|
25
|
+
};
|
|
26
|
+
describe("req", () => {
|
|
27
|
+
it("should send REQ and CLOSE messages", async () => {
|
|
28
|
+
// Create subscription that completes after first EOSE
|
|
29
|
+
const sub = relay.req([{ kinds: [1] }], "sub1").subscribe();
|
|
30
|
+
// Verify REQ was sent
|
|
31
|
+
const reqMessage = await mockRelay.nextMessage;
|
|
32
|
+
expect(JSON.parse(reqMessage)).toEqual(["REQ", "sub1", { kinds: [1] }]);
|
|
33
|
+
// Send EOSE to complete subscription
|
|
34
|
+
mockRelay.send(JSON.stringify(["EOSE", "sub1"]));
|
|
35
|
+
// Complete the subscription
|
|
36
|
+
sub.unsubscribe();
|
|
37
|
+
// Verify CLOSE was sent
|
|
38
|
+
const closeMessage = await mockRelay.nextMessage;
|
|
39
|
+
expect(JSON.parse(closeMessage)).toEqual(["CLOSE", "sub1"]);
|
|
40
|
+
});
|
|
41
|
+
it("should emit nostr event and EOSE", async () => {
|
|
42
|
+
const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
|
|
43
|
+
// Send EVENT message
|
|
44
|
+
mockRelay.send(JSON.stringify(["EVENT", "sub1", mockEvent]));
|
|
45
|
+
// Send EOSE message
|
|
46
|
+
mockRelay.send(JSON.stringify(["EOSE", "sub1"]));
|
|
47
|
+
expect(spy.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
|
|
48
|
+
});
|
|
49
|
+
it("should ignore EVENT and EOSE messages that do not match subscription id", async () => {
|
|
50
|
+
const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
|
|
51
|
+
// Send EVENT message with wrong subscription id
|
|
52
|
+
mockRelay.send(JSON.stringify(["EVENT", "wrong_sub", mockEvent]));
|
|
53
|
+
// Send EOSE message with wrong subscription id
|
|
54
|
+
mockRelay.send(JSON.stringify(["EOSE", "wrong_sub"]));
|
|
55
|
+
// Send EVENT message with correct subscription id
|
|
56
|
+
mockRelay.send(JSON.stringify(["EVENT", "sub1", mockEvent]));
|
|
57
|
+
// Send EOSE message with correct subscription id
|
|
58
|
+
mockRelay.send(JSON.stringify(["EOSE", "sub1"]));
|
|
59
|
+
expect(spy.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
|
|
60
|
+
});
|
|
61
|
+
it("should mark events with their source relay", async () => {
|
|
62
|
+
const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
|
|
63
|
+
// Send EVENT message
|
|
64
|
+
mockRelay.send(JSON.stringify(["EVENT", "sub1", mockEvent]));
|
|
65
|
+
// Get the received event
|
|
66
|
+
const receivedEvent = spy.getValues()[0];
|
|
67
|
+
// Verify the event was marked as seen from this relay
|
|
68
|
+
expect(getSeenRelays(receivedEvent)).toContain("wss://test");
|
|
69
|
+
});
|
|
70
|
+
it("should complete subscription when CLOSED message is received", async () => {
|
|
71
|
+
const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
|
|
72
|
+
// Send CLOSED message for the subscription
|
|
73
|
+
mockRelay.send(JSON.stringify(["CLOSED", "sub1", "reason"]));
|
|
74
|
+
// Verify the subscription completed
|
|
75
|
+
expect(spy.receivedComplete()).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe("event", () => {
|
|
79
|
+
it("observable should complete when matching OK response received", async () => {
|
|
80
|
+
const spy = subscribeSpyTo(relay.event(mockEvent));
|
|
81
|
+
// Verify EVENT message was sent
|
|
82
|
+
const eventMessage = await mockRelay.nextMessage;
|
|
83
|
+
expect(JSON.parse(eventMessage)).toEqual(["EVENT", mockEvent]);
|
|
84
|
+
// Send matching OK response
|
|
85
|
+
mockRelay.send(JSON.stringify(["OK", mockEvent.id, true, ""]));
|
|
86
|
+
await spy.onComplete();
|
|
87
|
+
expect(spy.receivedComplete()).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
it("should ignore OK responses for different events", async () => {
|
|
90
|
+
const spy = subscribeSpyTo(relay.event(mockEvent));
|
|
91
|
+
// Send non-matching OK response
|
|
92
|
+
mockRelay.send(JSON.stringify(["OK", "different_id", true, ""]));
|
|
93
|
+
expect(spy.receivedComplete()).toBe(false);
|
|
94
|
+
// Send matching OK response
|
|
95
|
+
mockRelay.send(JSON.stringify(["OK", mockEvent.id, true, ""]));
|
|
96
|
+
expect(spy.receivedComplete()).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
it("should send EVENT message to relay", async () => {
|
|
99
|
+
relay.event(mockEvent).subscribe();
|
|
100
|
+
const eventMessage = await mockRelay.nextMessage;
|
|
101
|
+
expect(JSON.parse(eventMessage)).toEqual(["EVENT", mockEvent]);
|
|
102
|
+
});
|
|
103
|
+
it("should complete with error if no OK received within 10s", async () => {
|
|
104
|
+
vi.useFakeTimers();
|
|
105
|
+
const spy = subscribeSpyTo(relay.event(mockEvent));
|
|
106
|
+
// Fast-forward time by 10 seconds
|
|
107
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
108
|
+
expect(spy.receivedComplete()).toBe(true);
|
|
109
|
+
expect(spy.getLastValue()).toEqual({ ok: false, from: "wss://test", message: "Timeout" });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe("notice$", () => {
|
|
113
|
+
it("should accumulate notices in notices$ state", async () => {
|
|
114
|
+
subscribeSpyTo(relay.req({ kinds: [1] }));
|
|
115
|
+
// Send multiple NOTICE messages
|
|
116
|
+
mockRelay.send(JSON.stringify(["NOTICE", "Notice 1"]));
|
|
117
|
+
mockRelay.send(JSON.stringify(["NOTICE", "Notice 2"]));
|
|
118
|
+
mockRelay.send(JSON.stringify(["NOTICE", "Notice 3"]));
|
|
119
|
+
// Verify the notices state contains all messages
|
|
120
|
+
expect(relay.notices$.value).toEqual(["Notice 1", "Notice 2", "Notice 3"]);
|
|
121
|
+
});
|
|
122
|
+
it("should ignore non-NOTICE messages", async () => {
|
|
123
|
+
subscribeSpyTo(relay.req({ kinds: [1] }));
|
|
124
|
+
mockRelay.send(JSON.stringify(["NOTICE", "Important notice"]));
|
|
125
|
+
mockRelay.send(JSON.stringify(["OTHER", "other message"]));
|
|
126
|
+
// Verify only NOTICE messages are in the state
|
|
127
|
+
expect(relay.notices$.value).toEqual(["Important notice"]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe("challenge$", () => {
|
|
131
|
+
it("should set challenge$ when AUTH message received", async () => {
|
|
132
|
+
subscribeSpyTo(relay.req({ kinds: [1] }));
|
|
133
|
+
// Send AUTH message with challenge string
|
|
134
|
+
mockRelay.send(JSON.stringify(["AUTH", "challenge-string-123"]));
|
|
135
|
+
// Verify challenge$ was set
|
|
136
|
+
expect(relay.challenge$.value).toBe("challenge-string-123");
|
|
137
|
+
});
|
|
138
|
+
it("should ignore non-AUTH messages", async () => {
|
|
139
|
+
subscribeSpyTo(relay.req({ kinds: [1] }));
|
|
140
|
+
mockRelay.send(JSON.stringify(["NOTICE", "Not a challenge"]));
|
|
141
|
+
mockRelay.send(JSON.stringify(["OTHER", "other message"]));
|
|
142
|
+
// Verify challenge$ remains null
|
|
143
|
+
expect(relay.challenge$.value).toBe(null);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import Nostr from "nostr-typedef";
|
|
2
|
+
export interface ClientSpy {
|
|
3
|
+
__createClientSpy: true;
|
|
4
|
+
next: () => Promise<Nostr.ToClientMessage.Any>;
|
|
5
|
+
dispose: () => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function createClientSpy(addOnMessageListener: (listener: (message: Nostr.ToClientMessage.Any) => void) => void): ClientSpy;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export function createClientSpy(addOnMessageListener) {
|
|
2
|
+
let queue = { type: "empty" };
|
|
3
|
+
addOnMessageListener((message) => {
|
|
4
|
+
switch (queue.type) {
|
|
5
|
+
case "empty": {
|
|
6
|
+
queue = {
|
|
7
|
+
type: "message",
|
|
8
|
+
items: [message],
|
|
9
|
+
};
|
|
10
|
+
break;
|
|
11
|
+
}
|
|
12
|
+
case "message": {
|
|
13
|
+
queue.items.push(message);
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
case "resolver": {
|
|
17
|
+
const item = queue.items.shift();
|
|
18
|
+
if (item) {
|
|
19
|
+
const resolve = item[0];
|
|
20
|
+
resolve(message);
|
|
21
|
+
}
|
|
22
|
+
if (queue.items.length <= 0) {
|
|
23
|
+
queue = { type: "empty" };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
__createClientSpy: true,
|
|
30
|
+
next: () => new Promise((resolve, reject) => {
|
|
31
|
+
switch (queue.type) {
|
|
32
|
+
case "empty": {
|
|
33
|
+
queue = {
|
|
34
|
+
type: "resolver",
|
|
35
|
+
items: [[resolve, reject]],
|
|
36
|
+
};
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case "message": {
|
|
40
|
+
const msg = queue.items.shift();
|
|
41
|
+
if (msg) {
|
|
42
|
+
resolve(msg);
|
|
43
|
+
}
|
|
44
|
+
if (queue.items.length <= 0) {
|
|
45
|
+
queue = { type: "empty" };
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case "resolver": {
|
|
50
|
+
queue.items.push([resolve, reject]);
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}),
|
|
55
|
+
dispose: () => {
|
|
56
|
+
if (queue.type === "resolver") {
|
|
57
|
+
for (const item of queue.items) {
|
|
58
|
+
const reject = item[1];
|
|
59
|
+
reject(new Error());
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
queue = { type: "empty" };
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export class TimeoutError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export function withTimeout(task, message, timeout = 1000) {
|
|
4
|
+
return Promise.race([
|
|
5
|
+
typeof task === "function" ? task() : task,
|
|
6
|
+
new Promise((_, reject) => setTimeout(() => reject(new TimeoutError(message)), timeout)),
|
|
7
|
+
]);
|
|
8
|
+
}
|
package/dist/group.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { catchError,
|
|
1
|
+
import { catchError, endWith, ignoreElements, merge, of } from "rxjs";
|
|
2
2
|
import { nanoid } from "nanoid";
|
|
3
3
|
import { onlyEvents } from "./operators/only-events.js";
|
|
4
|
+
import { completeOnEose } from "./operators/complete-on-eose.js";
|
|
4
5
|
export class RelayGroup {
|
|
5
6
|
relays;
|
|
6
7
|
constructor(relays) {
|
|
@@ -11,14 +12,16 @@ export class RelayGroup {
|
|
|
11
12
|
...acc,
|
|
12
13
|
[relay.url]: relay.req(filters, id).pipe(
|
|
13
14
|
// Ignore connection errors
|
|
14
|
-
catchError(() =>
|
|
15
|
+
catchError(() => of("EOSE"))),
|
|
15
16
|
}), {});
|
|
16
17
|
// Create stream of events only
|
|
17
18
|
const events = merge(...Object.values(requests)).pipe(onlyEvents());
|
|
18
19
|
// Create stream that emits EOSE when all relays have sent EOSE
|
|
19
|
-
const eose =
|
|
20
|
+
const eose = merge(
|
|
20
21
|
// Create a new map of requests that only emits EOSE
|
|
21
|
-
Object.
|
|
22
|
+
...Object.values(requests).map((observable) => observable.pipe(completeOnEose(), ignoreElements()))).pipe(
|
|
23
|
+
// When all relays have sent EOSE, emit EOSE
|
|
24
|
+
endWith("EOSE"));
|
|
22
25
|
// Merge events and the single EOSE stream
|
|
23
26
|
return merge(events, eose);
|
|
24
27
|
}
|
package/dist/relay.d.ts
CHANGED
|
@@ -22,6 +22,10 @@ export declare class Relay implements IRelay {
|
|
|
22
22
|
get challenge(): string | null;
|
|
23
23
|
get notices(): string[];
|
|
24
24
|
get authenticated(): boolean;
|
|
25
|
+
/** If an EOSE message is not seen in this time, emit one locally */
|
|
26
|
+
eoseTimeout: number;
|
|
27
|
+
/** How long to wait for an OK message from the relay */
|
|
28
|
+
eventTimeout: number;
|
|
25
29
|
protected authRequiredForReq: BehaviorSubject<boolean>;
|
|
26
30
|
protected authRequiredForPublish: BehaviorSubject<boolean>;
|
|
27
31
|
protected resetState(): void;
|
package/dist/relay.js
CHANGED
|
@@ -28,6 +28,10 @@ export class Relay {
|
|
|
28
28
|
get authenticated() {
|
|
29
29
|
return this.authenticated$.value;
|
|
30
30
|
}
|
|
31
|
+
/** If an EOSE message is not seen in this time, emit one locally */
|
|
32
|
+
eoseTimeout = 10_000;
|
|
33
|
+
/** How long to wait for an OK message from the relay */
|
|
34
|
+
eventTimeout = 10_000;
|
|
31
35
|
authRequiredForReq = new BehaviorSubject(false);
|
|
32
36
|
authRequiredForPublish = new BehaviorSubject(false);
|
|
33
37
|
resetState() {
|
|
@@ -116,18 +120,19 @@ export class Relay {
|
|
|
116
120
|
}
|
|
117
121
|
/** Create a REQ observable that emits events | "EOSE" or errors */
|
|
118
122
|
req(filters, id = nanoid()) {
|
|
119
|
-
const request = this.socket
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
const request = this.socket.multiplex(() => (Array.isArray(filters) ? ["REQ", id, ...filters] : ["REQ", id, filters]), () => ["CLOSE", id], (message) => (message[0] === "EVENT" || message[0] === "CLOSED" || message[0] === "EOSE") && message[1] === id);
|
|
124
|
+
// Start the watch tower with the observable
|
|
125
|
+
const withWatchTower = merge(this.watchTower, request);
|
|
126
|
+
const observable = withWatchTower.pipe(
|
|
127
|
+
// listen for CLOSED auth-required
|
|
123
128
|
tap((m) => {
|
|
124
|
-
if (m[0] === "
|
|
129
|
+
if (m[0] === "CLOSED" && m[2] && m[2].startsWith("auth-required") && !this.authRequiredForReq.value) {
|
|
125
130
|
this.log("Auth required for REQ");
|
|
126
131
|
this.authRequiredForReq.next(true);
|
|
127
132
|
}
|
|
128
133
|
}),
|
|
129
134
|
// complete when CLOSE is sent
|
|
130
|
-
takeWhile((m) => m[0] !== "
|
|
135
|
+
takeWhile((m) => m[0] !== "CLOSED"),
|
|
131
136
|
// pick event out of EVENT messages
|
|
132
137
|
map((message) => {
|
|
133
138
|
if (message[0] === "EOSE")
|
|
@@ -140,11 +145,11 @@ export class Relay {
|
|
|
140
145
|
// if no events are seen in 10s, emit EOSE
|
|
141
146
|
// TODO: this should emit EOSE event if events are seen, the timeout should be for only the EOSE message
|
|
142
147
|
timeout({
|
|
143
|
-
first:
|
|
148
|
+
first: this.eoseTimeout,
|
|
144
149
|
with: () => merge(of("EOSE"), NEVER),
|
|
145
150
|
}));
|
|
146
151
|
// Wait for auth if required and make sure to start the watch tower
|
|
147
|
-
return this.waitForAuth(this.authRequiredForReq,
|
|
152
|
+
return this.waitForAuth(this.authRequiredForReq, observable);
|
|
148
153
|
}
|
|
149
154
|
/** send an Event message and always return an observable of PublishResponse that completes or errors */
|
|
150
155
|
event(event, verb = "EVENT") {
|
|
@@ -167,6 +172,11 @@ export class Relay {
|
|
|
167
172
|
this.log("Auth required for publish");
|
|
168
173
|
this.authRequiredForPublish.next(true);
|
|
169
174
|
}
|
|
175
|
+
}),
|
|
176
|
+
// if no message is seen in 10s, emit EOSE
|
|
177
|
+
timeout({
|
|
178
|
+
first: this.eventTimeout,
|
|
179
|
+
with: () => of({ ok: false, from: this.url, message: "Timeout" }),
|
|
170
180
|
}));
|
|
171
181
|
// skip wait for auth if verb is AUTH
|
|
172
182
|
if (verb === "AUTH")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "applesauce-relay",
|
|
3
|
-
"version": "0.0.0-next-
|
|
3
|
+
"version": "0.0.0-next-20250411160531",
|
|
4
4
|
"description": "nostr relay communication framework built on rxjs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -54,16 +54,17 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@noble/hashes": "^1.7.1",
|
|
57
|
-
"applesauce-core": "0.0.0-next-
|
|
57
|
+
"applesauce-core": "0.0.0-next-20250411160531",
|
|
58
58
|
"nanoid": "^5.0.9",
|
|
59
59
|
"nostr-tools": "^2.10.4",
|
|
60
60
|
"rxjs": "^7.8.1"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
+
"@hirez_io/observer-spy": "^2.2.0",
|
|
64
|
+
"@vitest/expect": "^3.1.1",
|
|
63
65
|
"typescript": "^5.7.3",
|
|
64
|
-
"vitest": "^3.
|
|
65
|
-
"vitest-
|
|
66
|
-
"vitest-websocket-mock": "^0.4.0"
|
|
66
|
+
"vitest": "^3.1.1",
|
|
67
|
+
"vitest-websocket-mock": "^0.5.0"
|
|
67
68
|
},
|
|
68
69
|
"funding": {
|
|
69
70
|
"type": "lightning",
|