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 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
- - [ ] Handle reconnects
19
- - [ ] Write tests
20
- - [ ] Handle NIP-11 limitations
21
- - [ ] Add documentation to docs
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,3 @@
1
+ export declare class TimeoutError extends Error {
2
+ }
3
+ export declare function withTimeout<T>(task: Promise<T> | (() => Promise<T>), message?: string, timeout?: number): Promise<T>;
@@ -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, combineLatest, EMPTY, filter, map, merge, of } from "rxjs";
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(() => EMPTY)),
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 = combineLatest(
20
+ const eose = merge(
20
21
  // Create a new map of requests that only emits EOSE
21
- Object.fromEntries(Object.entries(requests).map(([url, observable]) => [url, observable.pipe(filter((m) => m === "EOSE"))]))).pipe(map(() => "EOSE"));
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
- .multiplex(() => (Array.isArray(filters) ? ["REQ", id, ...filters] : ["REQ", id, filters]), () => ["CLOSE", id], (message) => (message[0] === "EVENT" || message[0] === "CLOSE" || message[0] === "EOSE") && message[1] === id)
121
- .pipe(
122
- // listen for CLOSE auth-required
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] === "CLOSE" && m[1].startsWith("auth-required") && !this.authRequiredForReq.value) {
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] !== "CLOSE"),
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: 10_000,
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, merge(this.watchTower, request));
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-20250404095409",
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-20250404095409",
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.0.5",
65
- "vitest-nostr": "^0.4.1",
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",