applesauce-relay 1.0.0 → 1.1.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.
@@ -47,17 +47,33 @@ describe("req", () => {
47
47
  await firstValueFrom(relay.connected$.pipe(filter(Boolean)));
48
48
  expect(relay.connected).toBe(true);
49
49
  });
50
- it("should send REQ and CLOSE messages", async () => {
50
+ it("should send expected messages to relay", async () => {
51
+ subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
52
+ // Wait for all message to be sent
53
+ await new Promise((resolve) => setTimeout(resolve, 10));
54
+ expect(server.messages).toEqual([["REQ", "sub1", { kinds: [1] }]]);
55
+ });
56
+ it("should not close the REQ when EOSE is received", async () => {
51
57
  // Create subscription that completes after first EOSE
52
- const sub = relay.req([{ kinds: [1] }], "sub1").subscribe();
58
+ const sub = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
53
59
  // Verify REQ was sent
54
- expect(await server.nextMessage).toEqual(["REQ", "sub1", { kinds: [1] }]);
60
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
55
61
  // Send EOSE to complete subscription
62
+ server.send(["EVENT", "sub1", mockEvent]);
56
63
  server.send(["EOSE", "sub1"]);
64
+ // Verify the subscription did not complete
65
+ expect(sub.receivedComplete()).toBe(false);
66
+ expect(sub.getValues()).toEqual([expect.objectContaining(mockEvent), "EOSE"]);
67
+ });
68
+ it("should send CLOSE when unsubscribed", async () => {
69
+ // Create subscription that completes after first EOSE
70
+ const sub = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
71
+ // Verify REQ was sent
72
+ await expect(server).toReceiveMessage(["REQ", "sub1", { kinds: [1] }]);
57
73
  // Complete the subscription
58
74
  sub.unsubscribe();
59
75
  // Verify CLOSE was sent
60
- expect(await server.nextMessage).toEqual(["CLOSE", "sub1"]);
76
+ await expect(server).toReceiveMessage(["CLOSE", "sub1"]);
61
77
  });
62
78
  it("should emit nostr event and EOSE", async () => {
63
79
  const spy = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"));
@@ -99,6 +115,14 @@ describe("req", () => {
99
115
  // Verify the subscription completed
100
116
  expect(spy.receivedError()).toBe(true);
101
117
  });
118
+ it("should not send multiple REQ messages for multiple subscriptions", async () => {
119
+ const sub = relay.req([{ kinds: [1] }], "sub1");
120
+ sub.subscribe();
121
+ sub.subscribe();
122
+ // Wait for all messages to be sent
123
+ await new Promise((resolve) => setTimeout(resolve, 10));
124
+ expect(server.messages).toEqual([["REQ", "sub1", { kinds: [1] }]]);
125
+ });
102
126
  it("should wait for authentication if relay responds with auth-required", async () => {
103
127
  // First subscription to trigger auth-required
104
128
  const firstSub = subscribeSpyTo(relay.req([{ kinds: [1] }], "sub1"), { expectErrors: true });
@@ -332,9 +356,59 @@ describe("notices$", () => {
332
356
  expect(relay.notices$.value).toEqual(["Important notice"]);
333
357
  });
334
358
  });
359
+ describe("notice$", () => {
360
+ it("should not trigger connection to relay", async () => {
361
+ subscribeSpyTo(relay.notice$);
362
+ await new Promise((resolve) => setTimeout(resolve, 10));
363
+ expect(relay.connected).toBe(false);
364
+ });
365
+ it("should emit NOTICE messages when they are received", async () => {
366
+ const spy = subscribeSpyTo(relay.notice$);
367
+ // Start connection
368
+ subscribeSpyTo(relay.req({ kinds: [1] }));
369
+ // Send multiple NOTICE messages
370
+ server.send(["NOTICE", "Notice 1"]);
371
+ server.send(["NOTICE", "Notice 2"]);
372
+ server.send(["NOTICE", "Notice 3"]);
373
+ // Verify the notices state contains all messages
374
+ expect(spy.getValues()).toEqual(["Notice 1", "Notice 2", "Notice 3"]);
375
+ });
376
+ it("should ignore non-NOTICE messages", async () => {
377
+ const spy = subscribeSpyTo(relay.notice$);
378
+ // Start connection
379
+ subscribeSpyTo(relay.req({ kinds: [1] }));
380
+ server.send(["NOTICE", "Important notice"]);
381
+ server.send(["OTHER", "other message"]);
382
+ // Verify only NOTICE messages are in the state
383
+ expect(spy.getValues()).toEqual(["Important notice"]);
384
+ });
385
+ });
386
+ describe("message$", () => {
387
+ it("should not trigger connection to relay", async () => {
388
+ subscribeSpyTo(relay.message$);
389
+ await new Promise((resolve) => setTimeout(resolve, 10));
390
+ expect(relay.connected).toBe(false);
391
+ });
392
+ it("should emit all messages when they are received", async () => {
393
+ const spy = subscribeSpyTo(relay.message$);
394
+ // Start connection
395
+ subscribeSpyTo(relay.req({ kinds: [1] }));
396
+ // Send multiple NOTICE messages
397
+ server.send(["NOTICE", "Notice 1"]);
398
+ server.send(["EVENT", "sub1", mockEvent]);
399
+ server.send(["EOSE", "sub1"]);
400
+ // Verify the notices state contains all messages
401
+ expect(spy.getValues()).toEqual([
402
+ ["NOTICE", "Notice 1"],
403
+ ["EVENT", "sub1", mockEvent],
404
+ ["EOSE", "sub1"],
405
+ ]);
406
+ });
407
+ });
335
408
  describe("challenge$", () => {
336
409
  it("should not trigger connection to relay", async () => {
337
410
  subscribeSpyTo(relay.challenge$);
411
+ await new Promise((resolve) => setTimeout(resolve, 10));
338
412
  expect(relay.connected).toBe(false);
339
413
  });
340
414
  it("should set challenge$ when AUTH message received", async () => {
package/dist/group.d.ts CHANGED
@@ -11,7 +11,7 @@ export declare class RelayGroup implements IGroup {
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
16
  request(filters: Filter | Filter[], opts?: RequestOptions): Observable<NostrEvent>;
17
17
  /** Open a subscription to all relays with retries ( default 3 retries ) */
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) {
package/dist/pool.d.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  import { NostrEvent, type Filter } from "nostr-tools";
2
- import { Observable } from "rxjs";
2
+ import { BehaviorSubject, Observable } from "rxjs";
3
3
  import { RelayGroup } from "./group.js";
4
4
  import { Relay, RelayOptions } from "./relay.js";
5
5
  import { IPool, PublishResponse, PublishOptions, RequestOptions, SubscriptionOptions, SubscriptionResponse } 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 */
@@ -17,7 +22,7 @@ export declare class RelayPool implements IPool {
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
27
  request(relays: string[], filters: Filter | Filter[], opts?: RequestOptions): Observable<NostrEvent>;
23
28
  /** Open a subscription to multiple relays */
package/dist/pool.js CHANGED
@@ -1,31 +1,46 @@
1
+ import { BehaviorSubject } from "rxjs";
1
2
  import { RelayGroup } from "./group.js";
2
3
  import { Relay } from "./relay.js";
3
4
  export class RelayPool {
4
5
  options;
5
- relays = new Map();
6
- groups = new Map();
6
+ groups$ = new BehaviorSubject(new Map());
7
+ get groups() {
8
+ return this.groups$.value;
9
+ }
10
+ relays$ = new BehaviorSubject(new Map());
11
+ get relays() {
12
+ return this.relays$.value;
13
+ }
14
+ /** An array of relays to never connect to */
15
+ blacklist = new Set();
7
16
  constructor(options) {
8
17
  this.options = options;
9
18
  }
19
+ filterBlacklist(urls) {
20
+ return urls.filter((url) => !this.blacklist.has(url));
21
+ }
10
22
  /** Get or create a new relay connection */
11
23
  relay(url) {
24
+ if (this.blacklist.has(url))
25
+ throw new Error("Relay is on blacklist");
12
26
  let relay = this.relays.get(url);
13
27
  if (relay)
14
28
  return relay;
15
29
  else {
16
30
  relay = new Relay(url, this.options);
17
- this.relays.set(url, relay);
31
+ this.relays$.next(this.relays.set(url, relay));
18
32
  return relay;
19
33
  }
20
34
  }
21
35
  /** Create a group of relays */
22
36
  group(relays) {
37
+ relays = this.filterBlacklist(relays);
23
38
  const key = relays.sort().join(",");
24
39
  let group = this.groups.get(key);
25
40
  if (group)
26
41
  return group;
27
42
  group = new RelayGroup(relays.map((url) => this.relay(url)));
28
- this.groups.set(key, group);
43
+ this.groups$.next(this.groups.set(key, group));
29
44
  return group;
30
45
  }
31
46
  /** Make a REQ to multiple relays that does not deduplicate events */
package/dist/relay.d.ts CHANGED
@@ -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[];
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, filter, from, ignoreElements, map, merge, mergeMap, NEVER, of, retry, scan, share, shareReplay, Subject, switchMap, take, 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))
@@ -187,13 +204,17 @@ export class Relay {
187
204
  }
188
205
  /** Wait for the relay to be ready to accept connections */
189
206
  waitForReady(observable) {
190
- return this.ready$.pipe(
191
- // wait for ready to be true
192
- filter((ready) => ready),
193
- // complete after the first value so this does not repeat
194
- take(1),
195
- // switch to the observable
196
- switchMap(() => observable));
207
+ // Don't wait if the relay is already ready
208
+ if (this.ready$.value)
209
+ return observable;
210
+ else
211
+ return this.ready$.pipe(
212
+ // wait for ready to be true
213
+ filter((ready) => ready),
214
+ // complete after the first value so this does not repeat
215
+ take(1),
216
+ // switch to the observable
217
+ switchMap(() => observable));
197
218
  }
198
219
  multiplex(open, close, filter) {
199
220
  return this.socket.multiplex(open, close, filter);
@@ -234,7 +255,9 @@ export class Relay {
234
255
  timeout({
235
256
  first: this.eoseTimeout,
236
257
  with: () => merge(of("EOSE"), NEVER),
237
- }));
258
+ }),
259
+ // Only create one upstream subscription
260
+ share());
238
261
  // Wait for auth if required and make sure to start the watch tower
239
262
  return this.waitForReady(this.waitForAuth(this.authRequiredForReq, observable));
240
263
  }
@@ -264,7 +287,9 @@ export class Relay {
264
287
  timeout({
265
288
  first: this.eventTimeout,
266
289
  with: () => of({ ok: false, from: this.url, message: "Timeout" }),
267
- }));
290
+ }),
291
+ // Only create one upstream subscription
292
+ share());
268
293
  // skip wait for auth if verb is AUTH
269
294
  if (verb === "AUTH")
270
295
  return this.waitForReady(observable);
package/dist/types.d.ts CHANGED
@@ -66,7 +66,7 @@ export interface IGroup extends Nip01Actions {
66
66
  /** Send an EVENT message with retries */
67
67
  publish(event: NostrEvent, opts?: {
68
68
  retries?: number;
69
- }): Observable<PublishResponse[]>;
69
+ }): Observable<PublishResponse>;
70
70
  /** Send a REQ message with retries */
71
71
  request(filters: Filter | Filter[], opts?: {
72
72
  id?: string;
@@ -90,7 +90,7 @@ export interface IPool {
90
90
  /** Send an EVENT message to relays with retries */
91
91
  publish(relays: string[], event: NostrEvent, opts?: {
92
92
  retries?: number;
93
- }): Observable<PublishResponse[]>;
93
+ }): Observable<PublishResponse>;
94
94
  /** Send a REQ message to relays with retries */
95
95
  request(relays: string[], filters: Filter | Filter[], opts?: {
96
96
  id?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "applesauce-relay",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "nostr relay communication framework built on rxjs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",