applesauce-relay 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as exports from "../index.js";
3
+ describe("exports", () => {
4
+ it("should export the expected functions", () => {
5
+ expect(Object.keys(exports).sort()).toMatchInlineSnapshot(`
6
+ [
7
+ "Relay",
8
+ "RelayGroup",
9
+ "RelayPool",
10
+ "ReqCloseError",
11
+ "completeOnEose",
12
+ "markFromRelay",
13
+ "onlyEvents",
14
+ "storeEvents",
15
+ "toEventStore",
16
+ ]
17
+ `);
18
+ });
19
+ });
@@ -1,4 +1,4 @@
1
- import { test, expect, beforeEach, afterEach } from "vitest";
1
+ import { expect, beforeEach, afterEach, describe, it } from "vitest";
2
2
  import { subscribeSpyTo } from "@hirez_io/observer-spy";
3
3
  import { WS } from "vitest-websocket-mock";
4
4
  import { RelayPool } from "../pool.js";
@@ -27,55 +27,70 @@ afterEach(async () => {
27
27
  // Clean up WebSocket mocks
28
28
  await WS.clean();
29
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);
30
+ describe("relay", () => {
31
+ it("should create a new relay", () => {
32
+ const url = "wss://relay1.example.com/";
33
+ const relay = pool.relay(url);
34
+ expect(relay).toBeDefined();
35
+ expect(pool.relays.get(url)).toBe(relay);
36
+ });
37
+ it("should return existing relay connection if already exists", () => {
38
+ const url = "wss://relay1.example.com";
39
+ const relay1 = pool.relay(url);
40
+ const relay2 = pool.relay(url);
41
+ expect(relay1).toBe(relay2);
42
+ expect(pool.relays.size).toBe(1);
43
+ });
44
+ it("should normalize relay urls", () => {
45
+ expect(pool.relay("wss://relay.example.com")).toBe(pool.relay("wss://relay.example.com/"));
46
+ expect(pool.relay("wss://relay.example.com:443")).toBe(pool.relay("wss://relay.example.com/"));
47
+ expect(pool.relay("ws://relay.example.com:80")).toBe(pool.relay("ws://relay.example.com/"));
48
+ });
35
49
  });
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);
50
+ describe("group", () => {
51
+ it("should create a relay group", () => {
52
+ const urls = ["wss://relay1.example.com/", "wss://relay2.example.com/"];
53
+ const group = pool.group(urls);
54
+ expect(group).toBeDefined();
55
+ expect(pool.groups.get(urls.sort().join(","))).toBe(group);
56
+ });
42
57
  });
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);
58
+ describe("req", () => {
59
+ it("should send subscription to multiple relays", async () => {
60
+ const urls = ["wss://relay1.example.com", "wss://relay2.example.com"];
61
+ const filters = { kinds: [1] };
62
+ const spy = subscribeSpyTo(pool.req(urls, filters));
63
+ // Verify REQ was sent to both relays
64
+ const req1 = await mockServer1.nextMessage;
65
+ const req2 = await mockServer2.nextMessage;
66
+ // Both messages should be REQ messages with the same filter
67
+ expect(JSON.parse(req1)[0]).toBe("REQ");
68
+ expect(JSON.parse(req2)[0]).toBe("REQ");
69
+ expect(JSON.parse(req1)[2]).toEqual(filters);
70
+ expect(JSON.parse(req2)[2]).toEqual(filters);
71
+ // Send EVENT from first relay
72
+ mockServer1.send(JSON.stringify(["EVENT", JSON.parse(req1)[1], mockEvent]));
73
+ // Send EOSE from both relays
74
+ mockServer1.send(JSON.stringify(["EOSE", JSON.parse(req1)[1]]));
75
+ mockServer2.send(JSON.stringify(["EOSE", JSON.parse(req2)[1]]));
76
+ expect(spy.getValues()).toContainEqual(expect.objectContaining(mockEvent));
77
+ });
48
78
  });
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: "" });
79
+ describe("event", () => {
80
+ it("should publish to multiple relays", async () => {
81
+ const urls = ["wss://relay1.example.com/", "wss://relay2.example.com/"];
82
+ const spy = subscribeSpyTo(pool.event(urls, mockEvent));
83
+ // Verify EVENT was sent to both relays
84
+ const event1 = await mockServer1.nextMessage;
85
+ const event2 = await mockServer2.nextMessage;
86
+ expect(JSON.parse(event1)).toEqual(["EVENT", mockEvent]);
87
+ expect(JSON.parse(event2)).toEqual(["EVENT", mockEvent]);
88
+ // Send OK responses from both relays
89
+ mockServer1.send(JSON.stringify(["OK", mockEvent.id, true, ""]));
90
+ mockServer2.send(JSON.stringify(["OK", mockEvent.id, true, ""]));
91
+ expect(spy.getValues()).toEqual([
92
+ { ok: true, from: "wss://relay1.example.com/", message: "" },
93
+ { ok: true, from: "wss://relay2.example.com/", message: "" },
94
+ ]);
95
+ });
81
96
  });
@@ -4,7 +4,7 @@ import { getSeenRelays } from "applesauce-core/helpers";
4
4
  import { WS } from "vitest-websocket-mock";
5
5
  import { Relay } from "../relay.js";
6
6
  import { filter } from "rxjs/operators";
7
- import { firstValueFrom, of, throwError, timer } from "rxjs";
7
+ import { firstValueFrom, of, Subject, throwError, timer } from "rxjs";
8
8
  const defaultMockInfo = {
9
9
  name: "Test Relay",
10
10
  description: "Test Relay Description",
@@ -49,9 +49,7 @@ describe("req", () => {
49
49
  });
50
50
  it("should send expected messages to relay", async () => {
51
51
  subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
52
- // Wait for all message to be sent
53
- await new Promise((resolve) => setTimeout(resolve, 10));
54
- expect(server.messages).toEqual([["REQ", "sub1", { kinds: [1] }]]);
52
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
55
53
  });
56
54
  it("should not close the REQ when EOSE is received", async () => {
57
55
  // Create subscription that completes after first EOSE
@@ -210,6 +208,41 @@ describe("req", () => {
210
208
  relay.ready$.next(true);
211
209
  await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
212
210
  });
211
+ it("should wait for filters if filters are provided as an observable", async () => {
212
+ const filters = new Subject();
213
+ subscribeSpyTo(relay.req(filters, "sub1"));
214
+ // Wait 10sm and ensure no messages were sent yet
215
+ await new Promise((resolve) => setTimeout(resolve, 10));
216
+ expect(server.messagesToConsume.pendingItems.length).toBe(0);
217
+ // Send REQ message with filters
218
+ filters.next([{ kinds: [1] }]);
219
+ // Wait for the REQ message to be sent
220
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
221
+ });
222
+ it("should update filters if filters are provided as an observable", async () => {
223
+ const filters = new Subject();
224
+ subscribeSpyTo(relay.req(filters, "sub1"));
225
+ // Send REQ message with filters
226
+ filters.next([{ kinds: [1] }]);
227
+ // Should send REQ message with new filters
228
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
229
+ // Send REQ message with filters
230
+ filters.next([{ kinds: [2] }]);
231
+ // Should send new REQ message with new filters
232
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [2] }]);
233
+ // It should not send CLOSE message
234
+ await expect(server.messages).not.toContain(["CLOSE", "sub1"]);
235
+ });
236
+ it("should complete if filters are provided as an observable that completes", async () => {
237
+ const filters = new Subject();
238
+ const sub = subscribeSpyTo(relay.req(filters, "sub1"));
239
+ // Send REQ message with filters
240
+ filters.next([{ kinds: [1] }]);
241
+ // Complete the observable
242
+ filters.complete();
243
+ await sub.onComplete();
244
+ expect(sub.receivedComplete()).toBe(true);
245
+ });
213
246
  });
214
247
  describe("event", () => {
215
248
  it("should wait for authentication if relay responds with auth-required", async () => {
@@ -356,9 +389,59 @@ describe("notices$", () => {
356
389
  expect(relay.notices$.value).toEqual(["Important notice"]);
357
390
  });
358
391
  });
392
+ describe("notice$", () => {
393
+ it("should not trigger connection to relay", async () => {
394
+ subscribeSpyTo(relay.notice$);
395
+ await new Promise((resolve) => setTimeout(resolve, 10));
396
+ expect(relay.connected).toBe(false);
397
+ });
398
+ it("should emit NOTICE messages when they are received", async () => {
399
+ const spy = subscribeSpyTo(relay.notice$);
400
+ // Start connection
401
+ subscribeSpyTo(relay.req({ kinds: [1] }));
402
+ // Send multiple NOTICE messages
403
+ server.send(["NOTICE", "Notice 1"]);
404
+ server.send(["NOTICE", "Notice 2"]);
405
+ server.send(["NOTICE", "Notice 3"]);
406
+ // Verify the notices state contains all messages
407
+ expect(spy.getValues()).toEqual(["Notice 1", "Notice 2", "Notice 3"]);
408
+ });
409
+ it("should ignore non-NOTICE messages", async () => {
410
+ const spy = subscribeSpyTo(relay.notice$);
411
+ // Start connection
412
+ subscribeSpyTo(relay.req({ kinds: [1] }));
413
+ server.send(["NOTICE", "Important notice"]);
414
+ server.send(["OTHER", "other message"]);
415
+ // Verify only NOTICE messages are in the state
416
+ expect(spy.getValues()).toEqual(["Important notice"]);
417
+ });
418
+ });
419
+ describe("message$", () => {
420
+ it("should not trigger connection to relay", async () => {
421
+ subscribeSpyTo(relay.message$);
422
+ await new Promise((resolve) => setTimeout(resolve, 10));
423
+ expect(relay.connected).toBe(false);
424
+ });
425
+ it("should emit all messages when they are received", async () => {
426
+ const spy = subscribeSpyTo(relay.message$);
427
+ // Start connection
428
+ subscribeSpyTo(relay.req({ kinds: [1] }));
429
+ // Send multiple NOTICE messages
430
+ server.send(["NOTICE", "Notice 1"]);
431
+ server.send(["EVENT", "sub1", mockEvent]);
432
+ server.send(["EOSE", "sub1"]);
433
+ // Verify the notices state contains all messages
434
+ expect(spy.getValues()).toEqual([
435
+ ["NOTICE", "Notice 1"],
436
+ ["EVENT", "sub1", mockEvent],
437
+ ["EOSE", "sub1"],
438
+ ]);
439
+ });
440
+ });
359
441
  describe("challenge$", () => {
360
442
  it("should not trigger connection to relay", async () => {
361
443
  subscribeSpyTo(relay.challenge$);
444
+ await new Promise((resolve) => setTimeout(resolve, 10));
362
445
  expect(relay.connected).toBe(false);
363
446
  });
364
447
  it("should set challenge$ when AUTH message received", async () => {
package/dist/group.d.ts CHANGED
@@ -1,19 +1,19 @@
1
- import { Filter, NostrEvent } from "nostr-tools";
1
+ import { type NostrEvent } from "nostr-tools";
2
2
  import { Observable } from "rxjs";
3
- import { IGroup, IRelay, PublishResponse, SubscriptionResponse, PublishOptions, RequestOptions, SubscriptionOptions } from "./types.js";
3
+ import { IGroup, IRelay, PublishResponse, SubscriptionResponse, PublishOptions, RequestOptions, SubscriptionOptions, FilterInput } from "./types.js";
4
4
  export declare class RelayGroup implements IGroup {
5
5
  relays: IRelay[];
6
6
  constructor(relays: IRelay[]);
7
7
  /** Takes an array of observables and only emits EOSE when all observables have emitted EOSE */
8
8
  protected mergeEOSE(...requests: Observable<SubscriptionResponse>[]): Observable<import("nostr-tools").Event | "EOSE">;
9
9
  /** Make a request to all relays */
10
- req(filters: Filter | Filter[], id?: string): Observable<SubscriptionResponse>;
10
+ req(filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
11
11
  /** Send an event to all relays */
12
12
  event(event: NostrEvent): Observable<PublishResponse>;
13
13
  /** Publish an event to all relays with retries ( default 3 retries ) */
14
- publish(event: NostrEvent, opts?: PublishOptions): Observable<PublishResponse[]>;
14
+ publish(event: NostrEvent, opts?: PublishOptions): Observable<PublishResponse>;
15
15
  /** Request events from all relays with retries ( default 3 retries ) */
16
- request(filters: Filter | Filter[], opts?: RequestOptions): Observable<NostrEvent>;
16
+ request(filters: FilterInput, opts?: RequestOptions): Observable<NostrEvent>;
17
17
  /** Open a subscription to all relays with retries ( default 3 retries ) */
18
- subscription(filters: Filter | Filter[], opts?: SubscriptionOptions): Observable<SubscriptionResponse>;
18
+ subscription(filters: FilterInput, opts?: SubscriptionOptions): Observable<SubscriptionResponse>;
19
19
  }
package/dist/group.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { nanoid } from "nanoid";
2
- import { catchError, EMPTY, endWith, ignoreElements, merge, of, toArray } from "rxjs";
2
+ import { catchError, EMPTY, endWith, ignoreElements, merge, of } from "rxjs";
3
3
  import { completeOnEose } from "./operators/complete-on-eose.js";
4
4
  import { onlyEvents } from "./operators/only-events.js";
5
5
  export class RelayGroup {
@@ -37,7 +37,7 @@ export class RelayGroup {
37
37
  publish(event, opts) {
38
38
  return merge(...this.relays.map((relay) => relay.publish(event, opts).pipe(
39
39
  // Catch error and return as PublishResponse
40
- catchError((err) => of({ ok: false, from: relay.url, message: err?.message || "Unknown error" }))))).pipe(toArray());
40
+ catchError((err) => of({ ok: false, from: relay.url, message: err?.message || "Unknown error" })))));
41
41
  }
42
42
  /** Request events from all relays with retries ( default 3 retries ) */
43
43
  request(filters, opts) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as exports from "../index.js";
3
+ describe("exports", () => {
4
+ it("should export the expected functions", () => {
5
+ expect(Object.keys(exports).sort()).toMatchInlineSnapshot(`
6
+ [
7
+ "completeOnEose",
8
+ "markFromRelay",
9
+ "onlyEvents",
10
+ "storeEvents",
11
+ "toEventStore",
12
+ ]
13
+ `);
14
+ });
15
+ });
package/dist/pool.d.ts CHANGED
@@ -1,25 +1,30 @@
1
- import { NostrEvent, type Filter } from "nostr-tools";
2
- import { Observable } from "rxjs";
1
+ import { type NostrEvent } from "nostr-tools";
2
+ import { BehaviorSubject, Observable } from "rxjs";
3
3
  import { RelayGroup } from "./group.js";
4
4
  import { Relay, RelayOptions } from "./relay.js";
5
- import { IPool, PublishResponse, PublishOptions, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
5
+ import { IPool, PublishResponse, PublishOptions, RequestOptions, SubscriptionOptions, SubscriptionResponse, FilterInput } from "./types.js";
6
6
  export declare class RelayPool implements IPool {
7
7
  options?: RelayOptions | undefined;
8
- relays: Map<string, Relay>;
9
- groups: Map<string, RelayGroup>;
8
+ groups$: BehaviorSubject<Map<string, RelayGroup>>;
9
+ get groups(): Map<string, RelayGroup>;
10
+ relays$: BehaviorSubject<Map<string, Relay>>;
11
+ get relays(): Map<string, Relay>;
12
+ /** An array of relays to never connect to */
13
+ blacklist: Set<string>;
10
14
  constructor(options?: RelayOptions | undefined);
15
+ protected filterBlacklist(urls: string[]): string[];
11
16
  /** Get or create a new relay connection */
12
17
  relay(url: string): Relay;
13
18
  /** Create a group of relays */
14
19
  group(relays: string[]): RelayGroup;
15
20
  /** Make a REQ to multiple relays that does not deduplicate events */
16
- req(relays: string[], filters: Filter | Filter[], id?: string): Observable<SubscriptionResponse>;
21
+ req(relays: string[], filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
17
22
  /** Send an EVENT message to multiple relays */
18
23
  event(relays: string[], event: NostrEvent): Observable<PublishResponse>;
19
24
  /** Publish an event to multiple relays */
20
- publish(relays: string[], event: NostrEvent, opts?: PublishOptions): Observable<PublishResponse[]>;
25
+ publish(relays: string[], event: NostrEvent, opts?: PublishOptions): Observable<PublishResponse>;
21
26
  /** Request events from multiple relays */
22
- request(relays: string[], filters: Filter | Filter[], opts?: RequestOptions): Observable<NostrEvent>;
27
+ request(relays: string[], filters: FilterInput, opts?: RequestOptions): Observable<NostrEvent>;
23
28
  /** Open a subscription to multiple relays */
24
- subscription(relays: string[], filters: Filter | Filter[], opts?: SubscriptionOptions): Observable<SubscriptionResponse>;
29
+ subscription(relays: string[], filters: FilterInput, opts?: SubscriptionOptions): Observable<SubscriptionResponse>;
25
30
  }
package/dist/pool.js CHANGED
@@ -1,31 +1,53 @@
1
+ import { BehaviorSubject } from "rxjs";
1
2
  import { RelayGroup } from "./group.js";
2
3
  import { Relay } from "./relay.js";
4
+ import { normalizeURL } from "applesauce-core/helpers";
3
5
  export class RelayPool {
4
6
  options;
5
- relays = new Map();
6
- groups = new Map();
7
+ groups$ = new BehaviorSubject(new Map());
8
+ get groups() {
9
+ return this.groups$.value;
10
+ }
11
+ relays$ = new BehaviorSubject(new Map());
12
+ get relays() {
13
+ return this.relays$.value;
14
+ }
15
+ /** An array of relays to never connect to */
16
+ blacklist = new Set();
7
17
  constructor(options) {
8
18
  this.options = options;
9
19
  }
20
+ filterBlacklist(urls) {
21
+ return urls.filter((url) => !this.blacklist.has(url));
22
+ }
10
23
  /** Get or create a new relay connection */
11
24
  relay(url) {
25
+ // Normalize the url
26
+ url = normalizeURL(url);
27
+ // Check if the url is blacklisted
28
+ if (this.blacklist.has(url))
29
+ throw new Error("Relay is on blacklist");
30
+ // Check if the relay already exists
12
31
  let relay = this.relays.get(url);
13
32
  if (relay)
14
33
  return relay;
15
- else {
16
- relay = new Relay(url, this.options);
17
- this.relays.set(url, relay);
18
- return relay;
19
- }
34
+ // Create a new relay
35
+ relay = new Relay(url, this.options);
36
+ this.relays$.next(this.relays.set(url, relay));
37
+ return relay;
20
38
  }
21
39
  /** Create a group of relays */
22
40
  group(relays) {
41
+ // Normalize all urls
42
+ relays = relays.map((url) => normalizeURL(url));
43
+ // Filter out any blacklisted relays
44
+ relays = this.filterBlacklist(relays);
23
45
  const key = relays.sort().join(",");
24
46
  let group = this.groups.get(key);
25
47
  if (group)
26
48
  return group;
27
49
  group = new RelayGroup(relays.map((url) => this.relay(url)));
28
- this.groups.set(key, group);
50
+ this.groups$.next(this.groups.set(key, group));
29
51
  return group;
30
52
  }
31
53
  /** Make a REQ to multiple relays that does not deduplicate events */
package/dist/relay.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type Filter, type NostrEvent } from "nostr-tools";
3
3
  import { BehaviorSubject, Observable } from "rxjs";
4
4
  import { WebSocketSubject, WebSocketSubjectConfig } from "rxjs/webSocket";
5
5
  import { RelayInformation } from "nostr-tools/nip11";
6
- import { AuthSigner, IRelay, PublishOptions, PublishResponse, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
6
+ import { AuthSigner, FilterInput, IRelay, PublishOptions, PublishResponse, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
7
7
  /** An error that is thrown when a REQ is closed from the relay side */
8
8
  export declare class ReqCloseError extends Error {
9
9
  }
@@ -28,15 +28,23 @@ export declare class Relay implements IRelay {
28
28
  authenticated$: BehaviorSubject<boolean>;
29
29
  /** The notices from the relay */
30
30
  notices$: BehaviorSubject<string[]>;
31
+ /** The last connection error */
32
+ error$: BehaviorSubject<Error | null>;
33
+ /**
34
+ * A passive observable of all messages from the relay
35
+ * @note Subscribing to this will not connect to the relay
36
+ */
37
+ message$: Observable<any>;
38
+ /**
39
+ * A passive observable of NOTICE messages from the relay
40
+ * @note Subscribing to this will not connect to the relay
41
+ */
42
+ notice$: Observable<string>;
31
43
  /** An observable that emits the NIP-11 information document for the relay */
32
44
  information$: Observable<RelayInformation | null>;
33
45
  protected _nip11: RelayInformation | null;
34
46
  /** An observable that emits the limitations for the relay */
35
47
  limitations$: Observable<RelayInformation["limitation"] | null>;
36
- /** An observable of all messages from the relay */
37
- message$: Observable<any>;
38
- /** An observable of NOTICE messages from the relay */
39
- notice$: Observable<string>;
40
48
  get connected(): boolean;
41
49
  get challenge(): string | null;
42
50
  get notices(): string[];
@@ -66,7 +74,7 @@ export declare class Relay implements IRelay {
66
74
  /** Send a message to the relay */
67
75
  next(message: any): void;
68
76
  /** Create a REQ observable that emits events or "EOSE" or errors */
69
- req(filters: Filter | Filter[], id?: string): Observable<SubscriptionResponse>;
77
+ req(filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
70
78
  /** Send an EVENT or AUTH message and return an observable of PublishResponse that completes or errors */
71
79
  event(event: NostrEvent, verb?: "EVENT" | "AUTH"): Observable<PublishResponse>;
72
80
  /** send and AUTH message */
package/dist/relay.js CHANGED
@@ -2,7 +2,7 @@ import { logger } from "applesauce-core";
2
2
  import { simpleTimeout } from "applesauce-core/observable";
3
3
  import { nanoid } from "nanoid";
4
4
  import { nip42 } from "nostr-tools";
5
- import { BehaviorSubject, catchError, combineLatest, defer, filter, from, ignoreElements, map, merge, mergeMap, NEVER, of, retry, scan, share, shareReplay, switchMap, take, tap, throwError, timeout, timer, } from "rxjs";
5
+ import { BehaviorSubject, catchError, combineLatest, defer, endWith, filter, finalize, from, ignoreElements, isObservable, map, merge, mergeMap, NEVER, of, retry, scan, share, shareReplay, Subject, switchMap, take, takeUntil, tap, throwError, timeout, timer, } from "rxjs";
6
6
  import { webSocket } from "rxjs/webSocket";
7
7
  import { completeOnEose } from "./operators/complete-on-eose.js";
8
8
  import { markFromRelay } from "./operators/mark-from-relay.js";
@@ -27,15 +27,23 @@ export class Relay {
27
27
  authenticated$ = new BehaviorSubject(false);
28
28
  /** The notices from the relay */
29
29
  notices$ = new BehaviorSubject([]);
30
+ /** The last connection error */
31
+ error$ = new BehaviorSubject(null);
32
+ /**
33
+ * A passive observable of all messages from the relay
34
+ * @note Subscribing to this will not connect to the relay
35
+ */
36
+ message$;
37
+ /**
38
+ * A passive observable of NOTICE messages from the relay
39
+ * @note Subscribing to this will not connect to the relay
40
+ */
41
+ notice$;
30
42
  /** An observable that emits the NIP-11 information document for the relay */
31
43
  information$;
32
44
  _nip11 = null;
33
45
  /** An observable that emits the limitations for the relay */
34
46
  limitations$;
35
- /** An observable of all messages from the relay */
36
- message$;
37
- /** An observable of NOTICE messages from the relay */
38
- notice$;
39
47
  // sync state
40
48
  get connected() {
41
49
  return this.connected$.value;
@@ -91,6 +99,7 @@ export class Relay {
91
99
  this.log("Connected");
92
100
  this.connected$.next(true);
93
101
  this.attempts$.next(0);
102
+ this.error$.next(null);
94
103
  this.resetState();
95
104
  },
96
105
  },
@@ -107,7 +116,6 @@ export class Relay {
107
116
  },
108
117
  WebSocketCtor: opts?.WebSocket,
109
118
  });
110
- this.message$ = this.socket.asObservable();
111
119
  // Create an observable to fetch the NIP-11 information document
112
120
  this.information$ = defer(() => {
113
121
  this.log("Fetching NIP-11 information document");
@@ -123,19 +131,18 @@ export class Relay {
123
131
  // Create observables that track if auth is required for REQ or EVENT
124
132
  this.authRequiredForReq = combineLatest([this.receivedAuthRequiredForReq, this.limitations$]).pipe(map(([received, limitations]) => received || limitations?.auth_required === true), tap((required) => required && this.log("Auth required for REQ")), shareReplay(1));
125
133
  this.authRequiredForEvent = combineLatest([this.receivedAuthRequiredForEvent, this.limitations$]).pipe(map(([received, limitations]) => received || limitations?.auth_required === true), tap((required) => required && this.log("Auth required for EVENT")), shareReplay(1));
126
- this.notice$ = this.message$.pipe(
134
+ // Update the notices state
135
+ const listenForNotice = this.socket.pipe(
127
136
  // listen for NOTICE messages
128
- filter((m) => m[0] === "NOTICE"),
137
+ filter((m) => Array.isArray(m) && m[0] === "NOTICE"),
129
138
  // pick the string out of the message
130
- map((m) => m[1]));
131
- // Update the notices state
132
- const notice = this.notice$.pipe(
139
+ map((m) => m[1]),
133
140
  // Track all notices
134
141
  scan((acc, notice) => [...acc, notice], []),
135
142
  // Update the notices state
136
143
  tap((notices) => this.notices$.next(notices)));
137
144
  // Update the challenge state
138
- const challenge = this.message$.pipe(
145
+ const ListenForChallenge = this.socket.pipe(
139
146
  // listen for AUTH messages
140
147
  filter((message) => message[0] === "AUTH"),
141
148
  // pick the challenge string out
@@ -145,12 +152,21 @@ export class Relay {
145
152
  this.log("Received AUTH challenge", challenge);
146
153
  this.challenge$.next(challenge);
147
154
  }));
155
+ const allMessagesSubject = new Subject();
156
+ const listenForAllMessages = this.socket.pipe(tap((message) => allMessagesSubject.next(message)));
157
+ // Create passive observables for messages and notices
158
+ this.message$ = allMessagesSubject.asObservable();
159
+ this.notice$ = this.message$.pipe(
160
+ // listen for NOTICE messages
161
+ filter((m) => Array.isArray(m) && m[0] === "NOTICE"),
162
+ // pick the string out of the message
163
+ map((m) => m[1]));
148
164
  // Merge all watchers
149
165
  this.watchTower = this.ready$.pipe(switchMap((ready) => {
150
166
  if (!ready)
151
167
  return NEVER;
152
168
  // Only start the watch tower if the relay is ready
153
- return merge(notice, challenge, this.information$).pipe(
169
+ return merge(listenForAllMessages, listenForNotice, ListenForChallenge, this.information$).pipe(
154
170
  // Never emit any values
155
171
  ignoreElements(),
156
172
  // Start the reconnect timer if the connection has an error
@@ -168,6 +184,7 @@ export class Relay {
168
184
  startReconnectTimer(error) {
169
185
  if (!this.ready$.value)
170
186
  return;
187
+ this.error$.next(error instanceof Error ? error : new Error("Connection error"));
171
188
  this.ready$.next(false);
172
189
  this.reconnectTimer(error, this.attempts$.value)
173
190
  .pipe(take(1))
@@ -208,10 +225,24 @@ export class Relay {
208
225
  }
209
226
  /** Create a REQ observable that emits events or "EOSE" or errors */
210
227
  req(filters, id = nanoid()) {
211
- 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);
212
- // Start the watch tower with the observable
213
- const withWatchTower = merge(this.watchTower, request);
214
- const observable = withWatchTower.pipe(
228
+ // Convert filters input into an observable, if its a normal value merge it with NEVER so it never completes
229
+ const input = isObservable(filters) ? filters : merge(of(filters), NEVER);
230
+ // Create an observable that completes when the upstream observable completes
231
+ const complete = input.pipe(ignoreElements(), endWith(null));
232
+ // Create an observable that filters responses from the relay to just the ones for this REQ
233
+ const messages = this.socket.pipe(filter((m) => Array.isArray(m) && (m[0] === "EVENT" || m[0] === "CLOSED" || m[0] === "EOSE") && m[1] === id));
234
+ // Create an observable that controls sending the filters and closing the REQ
235
+ const control = input.pipe(
236
+ // Send the filters when they change
237
+ tap((filters) => this.socket.next(Array.isArray(filters) ? ["REQ", id, ...filters] : ["REQ", id, filters])),
238
+ // Close the req when unsubscribed
239
+ finalize(() => this.socket.next(["CLOSE", id])),
240
+ // Once filters have been sent, switch to listening for messages
241
+ switchMap(() => messages));
242
+ // Start the watch tower with the observables
243
+ const observable = merge(this.watchTower, control).pipe(
244
+ // Complete the subscription when the input is completed
245
+ takeUntil(complete),
215
246
  // Map the messages to events, EOSE, or throw an error
216
247
  map((message) => {
217
248
  if (message[0] === "EOSE")
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { EventTemplate, Filter, NostrEvent } from "nostr-tools";
1
+ import { type EventTemplate, type Filter, type NostrEvent } from "nostr-tools";
2
2
  import { Observable } from "rxjs";
3
3
  import { WebSocketSubject } from "rxjs/webSocket";
4
4
  export type SubscriptionResponse = NostrEvent | "EOSE";
@@ -28,11 +28,13 @@ export type SubscriptionOptions = {
28
28
  export type AuthSigner = {
29
29
  signEvent: (event: EventTemplate) => NostrEvent | Promise<NostrEvent>;
30
30
  };
31
+ /** The type of input the REQ method accepts */
32
+ export type FilterInput = Filter | Filter[] | Observable<Filter | Filter[]>;
31
33
  export interface Nip01Actions {
32
34
  /** Send an EVENT message */
33
35
  event(event: NostrEvent): Observable<PublishResponse>;
34
36
  /** Send a REQ message */
35
- req(filters: Filter | Filter[], id?: string): Observable<SubscriptionResponse>;
37
+ req(filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
36
38
  }
37
39
  export interface IRelay extends MultiplexWebSocket, Nip01Actions, IRelayState {
38
40
  url: string;
@@ -52,12 +54,12 @@ export interface IRelay extends MultiplexWebSocket, Nip01Actions, IRelayState {
52
54
  retries?: number;
53
55
  }): Observable<PublishResponse>;
54
56
  /** Send a REQ message with retries */
55
- request(filters: Filter | Filter[], opts?: {
57
+ request(filters: FilterInput, opts?: {
56
58
  id?: string;
57
59
  retries?: number;
58
60
  }): Observable<NostrEvent>;
59
61
  /** Open a subscription with retries */
60
- subscription(filters: Filter | Filter[], opts?: {
62
+ subscription(filters: FilterInput, opts?: {
61
63
  id?: string;
62
64
  retries?: number;
63
65
  }): Observable<SubscriptionResponse>;
@@ -66,14 +68,14 @@ export interface IGroup extends Nip01Actions {
66
68
  /** Send an EVENT message with retries */
67
69
  publish(event: NostrEvent, opts?: {
68
70
  retries?: number;
69
- }): Observable<PublishResponse[]>;
71
+ }): Observable<PublishResponse>;
70
72
  /** Send a REQ message with retries */
71
- request(filters: Filter | Filter[], opts?: {
73
+ request(filters: FilterInput, opts?: {
72
74
  id?: string;
73
75
  retries?: number;
74
76
  }): Observable<NostrEvent>;
75
77
  /** Open a subscription with retries */
76
- subscription(filters: Filter | Filter[], opts?: {
78
+ subscription(filters: FilterInput, opts?: {
77
79
  id?: string;
78
80
  retries?: number;
79
81
  }): Observable<SubscriptionResponse>;
@@ -82,7 +84,7 @@ export interface IPool {
82
84
  /** Send an EVENT message */
83
85
  event(relays: string[], event: NostrEvent): Observable<PublishResponse>;
84
86
  /** Send a REQ message */
85
- req(relays: string[], filters: Filter | Filter[], id?: string): Observable<SubscriptionResponse>;
87
+ req(relays: string[], filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
86
88
  /** Get or create a relay */
87
89
  relay(url: string): IRelay;
88
90
  /** Create a relay group */
@@ -90,14 +92,14 @@ export interface IPool {
90
92
  /** Send an EVENT message to relays with retries */
91
93
  publish(relays: string[], event: NostrEvent, opts?: {
92
94
  retries?: number;
93
- }): Observable<PublishResponse[]>;
95
+ }): Observable<PublishResponse>;
94
96
  /** Send a REQ message to relays with retries */
95
- request(relays: string[], filters: Filter | Filter[], opts?: {
97
+ request(relays: string[], filters: FilterInput, opts?: {
96
98
  id?: string;
97
99
  retries?: number;
98
100
  }): Observable<NostrEvent>;
99
101
  /** Open a subscription to relays with retries */
100
- subscription(relays: string[], filters: Filter | Filter[], opts?: {
102
+ subscription(relays: string[], filters: FilterInput, opts?: {
101
103
  id?: string;
102
104
  retries?: number;
103
105
  }): Observable<SubscriptionResponse>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "applesauce-relay",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "nostr relay communication framework built on rxjs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@noble/hashes": "^1.7.1",
57
- "applesauce-core": "^1.0.0",
57
+ "applesauce-core": "^1.2.0",
58
58
  "nanoid": "^5.0.9",
59
59
  "nostr-tools": "^2.10.4",
60
60
  "rxjs": "^7.8.1"