applesauce-relay 2.1.1 → 3.0.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.
package/README.md CHANGED
@@ -56,9 +56,8 @@ const event = {
56
56
  // ... other required fields
57
57
  };
58
58
 
59
- relay.event(event).subscribe((response) => {
60
- console.log(`Published:`, response.ok);
61
- });
59
+ const response = await relay.publish(event);
60
+ console.log(`Published:`, response.ok);
62
61
  ```
63
62
 
64
63
  ### Relay Pool
@@ -85,7 +84,8 @@ pool
85
84
  });
86
85
 
87
86
  // Publish to multiple relays
88
- pool.event(relays, event).subscribe((response) => {
87
+ const responses = await pool.publish(relays, event);
88
+ responses.forEach((response) => {
89
89
  console.log(`Published to ${response.from}:`, response.ok);
90
90
  });
91
91
  ```
@@ -112,7 +112,8 @@ group
112
112
  });
113
113
 
114
114
  // Publish to all relays in group
115
- group.event(event).subscribe((response) => {
115
+ const responses = await group.publish(event);
116
+ responses.forEach((response) => {
116
117
  console.log(`Published to ${response.from}:`, response.ok);
117
118
  });
118
119
  ```
package/dist/group.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type NostrEvent } from "nostr-tools";
2
2
  import { Observable } from "rxjs";
3
- import { IGroup, IRelay, PublishResponse, SubscriptionResponse, PublishOptions, RequestOptions, SubscriptionOptions, FilterInput } from "./types.js";
3
+ import { FilterInput, IGroup, IRelay, PublishOptions, PublishResponse, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
4
4
  export declare class RelayGroup implements IGroup {
5
5
  relays: IRelay[];
6
6
  constructor(relays: IRelay[]);
@@ -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): Promise<PublishResponse[]>;
15
15
  /** Request events from all relays with retries ( default 3 retries ) */
16
16
  request(filters: FilterInput, opts?: RequestOptions): Observable<NostrEvent>;
17
17
  /** Open a subscription to all relays with retries ( default 3 retries ) */
package/dist/group.js CHANGED
@@ -35,9 +35,9 @@ export class RelayGroup {
35
35
  }
36
36
  /** Publish an event to all relays with retries ( default 3 retries ) */
37
37
  publish(event, opts) {
38
- return merge(...this.relays.map((relay) => relay.publish(event, opts).pipe(
38
+ return Promise.all(this.relays.map((relay) => relay.publish(event, opts).catch(
39
39
  // Catch error and return as PublishResponse
40
- catchError((err) => of({ ok: false, from: relay.url, message: err?.message || "Unknown error" })))));
40
+ (err) => ({ 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
@@ -2,7 +2,7 @@ import { type NostrEvent } from "nostr-tools";
2
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, FilterInput } from "./types.js";
5
+ import { IPool, PublishResponse, PublishOptions, RequestOptions, SubscriptionOptions, SubscriptionResponse, FilterInput, IRelay } from "./types.js";
6
6
  export declare class RelayPool implements IPool {
7
7
  options?: RelayOptions | undefined;
8
8
  groups$: BehaviorSubject<Map<string, RelayGroup>>;
@@ -17,12 +17,14 @@ export declare class RelayPool implements IPool {
17
17
  relay(url: string): Relay;
18
18
  /** Create a group of relays */
19
19
  group(relays: string[]): RelayGroup;
20
+ /** Removes a relay from the pool and defaults to closing the connection */
21
+ remove(relay: string | IRelay, close?: boolean): void;
20
22
  /** Make a REQ to multiple relays that does not deduplicate events */
21
23
  req(relays: string[], filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
22
24
  /** Send an EVENT message to multiple relays */
23
25
  event(relays: string[], event: NostrEvent): Observable<PublishResponse>;
24
26
  /** Publish an event to multiple relays */
25
- publish(relays: string[], event: NostrEvent, opts?: PublishOptions): Observable<PublishResponse>;
27
+ publish(relays: string[], event: NostrEvent, opts?: PublishOptions): Promise<PublishResponse[]>;
26
28
  /** Request events from multiple relays */
27
29
  request(relays: string[], filters: FilterInput, opts?: RequestOptions): Observable<NostrEvent>;
28
30
  /** Open a subscription to multiple relays */
package/dist/pool.js CHANGED
@@ -33,7 +33,8 @@ export class RelayPool {
33
33
  return relay;
34
34
  // Create a new relay
35
35
  relay = new Relay(url, this.options);
36
- this.relays$.next(this.relays.set(url, relay));
36
+ this.relays.set(url, relay);
37
+ this.relays$.next(this.relays);
37
38
  return relay;
38
39
  }
39
40
  /** Create a group of relays */
@@ -50,6 +51,24 @@ export class RelayPool {
50
51
  this.groups$.next(this.groups.set(key, group));
51
52
  return group;
52
53
  }
54
+ /** Removes a relay from the pool and defaults to closing the connection */
55
+ remove(relay, close = true) {
56
+ let instance;
57
+ if (typeof relay === "string") {
58
+ instance = this.relays.get(relay);
59
+ if (!instance)
60
+ return;
61
+ }
62
+ else if (Array.from(this.relays.values()).some((r) => r === relay)) {
63
+ instance = relay;
64
+ }
65
+ else
66
+ return;
67
+ if (close)
68
+ instance?.close();
69
+ this.relays.delete(instance.url);
70
+ this.relays$.next(this.relays);
71
+ }
53
72
  /** Make a REQ to multiple relays that does not deduplicate events */
54
73
  req(relays, filters, id) {
55
74
  return this.group(relays).req(filters, id);
package/dist/relay.d.ts CHANGED
@@ -1,14 +1,21 @@
1
1
  import { logger } from "applesauce-core";
2
2
  import { type Filter, type NostrEvent } from "nostr-tools";
3
- import { BehaviorSubject, Observable } from "rxjs";
4
- import { WebSocketSubject, WebSocketSubjectConfig } from "rxjs/webSocket";
5
3
  import { RelayInformation } from "nostr-tools/nip11";
4
+ import { BehaviorSubject, MonoTypeOperatorFunction, Observable, RepeatConfig, RetryConfig, Subject } from "rxjs";
5
+ import { WebSocketSubject, WebSocketSubjectConfig } from "rxjs/webSocket";
6
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
  }
10
10
  export type RelayOptions = {
11
+ /** Custom WebSocket implementation */
11
12
  WebSocket?: WebSocketSubjectConfig<any>["WebSocketCtor"];
13
+ /** How long to wait for an EOSE message (default 10s) */
14
+ eoseTimeout?: number;
15
+ /** How long to wait for an OK message from the relay (default 10s) */
16
+ eventTimeout?: number;
17
+ /** How long to keep the connection alive after nothing is subscribed (default 30s) */
18
+ keepAlive?: number;
12
19
  };
13
20
  export declare class Relay implements IRelay {
14
21
  url: string;
@@ -24,8 +31,10 @@ export declare class Relay implements IRelay {
24
31
  connected$: BehaviorSubject<boolean>;
25
32
  /** The authentication challenge string from the relay */
26
33
  challenge$: BehaviorSubject<string | null>;
27
- /** Whether the client is authenticated with the relay */
28
- authenticated$: BehaviorSubject<boolean>;
34
+ /** Boolean authentication state (will be false if auth failed) */
35
+ authenticated$: Observable<boolean>;
36
+ /** The response to the last AUTH message sent to the relay */
37
+ authenticationResponse$: BehaviorSubject<PublishResponse | null>;
29
38
  /** The notices from the relay */
30
39
  notices$: BehaviorSubject<string[]>;
31
40
  /** The last connection error */
@@ -45,21 +54,28 @@ export declare class Relay implements IRelay {
45
54
  protected _nip11: RelayInformation | null;
46
55
  /** An observable that emits the limitations for the relay */
47
56
  limitations$: Observable<RelayInformation["limitation"] | null>;
57
+ /** An observable that emits when underlying websocket is opened */
58
+ open$: Subject<Event>;
59
+ /** An observable that emits when underlying websocket is closed */
60
+ close$: Subject<CloseEvent>;
61
+ /** An observable that emits when underlying websocket is closing due to unsubscription */
62
+ closing$: Subject<void>;
48
63
  get connected(): boolean;
49
64
  get challenge(): string | null;
50
65
  get notices(): string[];
51
66
  get authenticated(): boolean;
67
+ get authenticationResponse(): PublishResponse | null;
52
68
  get information(): RelayInformation | null;
53
- /** If an EOSE message is not seen in this time, emit one locally */
69
+ /** If an EOSE message is not seen in this time, emit one locally (default 10s) */
54
70
  eoseTimeout: number;
55
- /** How long to wait for an OK message from the relay */
71
+ /** How long to wait for an OK message from the relay (default 10s) */
56
72
  eventTimeout: number;
57
- /** How long to keep the connection alive after nothing is subscribed */
73
+ /** How long to keep the connection alive after nothing is subscribed (default 30s) */
58
74
  keepAlive: number;
59
75
  protected receivedAuthRequiredForReq: BehaviorSubject<boolean>;
60
76
  protected receivedAuthRequiredForEvent: BehaviorSubject<boolean>;
61
- protected authRequiredForReq: Observable<boolean>;
62
- protected authRequiredForEvent: Observable<boolean>;
77
+ authRequiredForRead$: Observable<boolean>;
78
+ authRequiredForPublish$: Observable<boolean>;
63
79
  protected resetState(): void;
64
80
  /** An internal observable that is responsible for watching all messages and updating state, subscribing to it will trigger a connection to the relay */
65
81
  protected watchTower: Observable<never>;
@@ -72,21 +88,27 @@ export declare class Relay implements IRelay {
72
88
  protected waitForReady<T extends unknown = unknown>(observable: Observable<T>): Observable<T>;
73
89
  multiplex<T>(open: () => any, close: () => any, filter: (message: any) => boolean): Observable<T>;
74
90
  /** Send a message to the relay */
75
- next(message: any): void;
91
+ send(message: any): void;
76
92
  /** Create a REQ observable that emits events or "EOSE" or errors */
77
93
  req(filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
78
94
  /** Send an EVENT or AUTH message and return an observable of PublishResponse that completes or errors */
79
95
  event(event: NostrEvent, verb?: "EVENT" | "AUTH"): Observable<PublishResponse>;
80
96
  /** send and AUTH message */
81
- auth(event: NostrEvent): Observable<PublishResponse>;
97
+ auth(event: NostrEvent): Promise<PublishResponse>;
82
98
  /** Authenticate with the relay using a signer */
83
- authenticate(signer: AuthSigner): Observable<PublishResponse>;
99
+ authenticate(signer: AuthSigner): Promise<PublishResponse>;
100
+ /** Internal operator for creating the retry() operator */
101
+ protected customRetryOperator<T extends unknown = unknown>(times: number | RetryConfig): MonoTypeOperatorFunction<T>;
102
+ /** Internal operator for creating the repeat() operator */
103
+ protected customRepeatOperator<T extends unknown = unknown>(times: boolean | number | RepeatConfig | undefined): MonoTypeOperatorFunction<T>;
84
104
  /** Creates a REQ that retries when relay errors ( default 3 retries ) */
85
105
  subscription(filters: Filter | Filter[], opts?: SubscriptionOptions): Observable<SubscriptionResponse>;
86
106
  /** Makes a single request that retires on errors and completes on EOSE */
87
107
  request(filters: Filter | Filter[], opts?: RequestOptions): Observable<NostrEvent>;
88
108
  /** Publishes an event to the relay and retries when relay errors or responds with auth-required ( default 3 retries ) */
89
- publish(event: NostrEvent, opts?: PublishOptions): Observable<PublishResponse>;
109
+ publish(event: NostrEvent, opts?: PublishOptions): Promise<PublishResponse>;
110
+ /** Force close the connection */
111
+ close(): void;
90
112
  /** Static method to fetch the NIP-11 information document for a relay */
91
113
  static fetchInformationDocument(url: string): Observable<RelayInformation | null>;
92
114
  /** Static method to create a reconnection method for each relay */
package/dist/relay.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { logger } from "applesauce-core";
2
+ import { ensureHttpURL } from "applesauce-core/helpers";
2
3
  import { simpleTimeout } from "applesauce-core/observable";
3
4
  import { nanoid } from "nanoid";
4
5
  import { nip42 } from "nostr-tools";
5
- import { BehaviorSubject, catchError, combineLatest, defer, endWith, filter, finalize, from, ignoreElements, isObservable, map, merge, mergeMap, mergeWith, NEVER, of, retry, scan, share, shareReplay, Subject, switchMap, take, takeUntil, tap, throwError, timeout, timer, } from "rxjs";
6
+ import { BehaviorSubject, catchError, combineLatest, defer, endWith, filter, finalize, from, identity, ignoreElements, isObservable, lastValueFrom, map, merge, mergeMap, mergeWith, NEVER, of, repeat, retry, scan, share, shareReplay, Subject, switchMap, take, takeUntil, tap, throwError, timeout, timer, } from "rxjs";
6
7
  import { webSocket } from "rxjs/webSocket";
7
- import { ensureHttpURL } from "applesauce-core/helpers";
8
8
  import { completeOnEose } from "./operators/complete-on-eose.js";
9
9
  import { markFromRelay } from "./operators/mark-from-relay.js";
10
10
  /** An error that is thrown when a REQ is closed from the relay side */
@@ -24,8 +24,10 @@ export class Relay {
24
24
  connected$ = new BehaviorSubject(false);
25
25
  /** The authentication challenge string from the relay */
26
26
  challenge$ = new BehaviorSubject(null);
27
- /** Whether the client is authenticated with the relay */
28
- authenticated$ = new BehaviorSubject(false);
27
+ /** Boolean authentication state (will be false if auth failed) */
28
+ authenticated$;
29
+ /** The response to the last AUTH message sent to the relay */
30
+ authenticationResponse$ = new BehaviorSubject(null);
29
31
  /** The notices from the relay */
30
32
  notices$ = new BehaviorSubject([]);
31
33
  /** The last connection error */
@@ -45,6 +47,12 @@ export class Relay {
45
47
  _nip11 = null;
46
48
  /** An observable that emits the limitations for the relay */
47
49
  limitations$;
50
+ /** An observable that emits when underlying websocket is opened */
51
+ open$ = new Subject();
52
+ /** An observable that emits when underlying websocket is closed */
53
+ close$ = new Subject();
54
+ /** An observable that emits when underlying websocket is closing due to unsubscription */
55
+ closing$ = new Subject();
48
56
  // sync state
49
57
  get connected() {
50
58
  return this.connected$.value;
@@ -56,29 +64,32 @@ export class Relay {
56
64
  return this.notices$.value;
57
65
  }
58
66
  get authenticated() {
59
- return this.authenticated$.value;
67
+ return this.authenticationResponse?.ok === true;
68
+ }
69
+ get authenticationResponse() {
70
+ return this.authenticationResponse$.value;
60
71
  }
61
72
  get information() {
62
73
  return this._nip11;
63
74
  }
64
- /** If an EOSE message is not seen in this time, emit one locally */
75
+ /** If an EOSE message is not seen in this time, emit one locally (default 10s) */
65
76
  eoseTimeout = 10_000;
66
- /** How long to wait for an OK message from the relay */
77
+ /** How long to wait for an OK message from the relay (default 10s) */
67
78
  eventTimeout = 10_000;
68
- /** How long to keep the connection alive after nothing is subscribed */
79
+ /** How long to keep the connection alive after nothing is subscribed (default 30s) */
69
80
  keepAlive = 30_000;
70
- // subjects that track if an "auth-required" message has been received for REQ or EVENT
81
+ // Subjects that track if an "auth-required" message has been received for REQ or EVENT
71
82
  receivedAuthRequiredForReq = new BehaviorSubject(false);
72
83
  receivedAuthRequiredForEvent = new BehaviorSubject(false);
73
84
  // Computed observables that track if auth is required for REQ or EVENT
74
- authRequiredForReq;
75
- authRequiredForEvent;
85
+ authRequiredForRead$;
86
+ authRequiredForPublish$;
76
87
  resetState() {
77
88
  // NOTE: only update the values if they need to be changed, otherwise this will cause an infinite loop
78
89
  if (this.challenge$.value !== null)
79
90
  this.challenge$.next(null);
80
- if (this.authenticated$.value)
81
- this.authenticated$.next(false);
91
+ if (this.authenticationResponse$.value)
92
+ this.authenticationResponse$.next(null);
82
93
  if (this.notices$.value.length > 0)
83
94
  this.notices$.next([]);
84
95
  if (this.receivedAuthRequiredForReq.value)
@@ -91,30 +102,39 @@ export class Relay {
91
102
  constructor(url, opts) {
92
103
  this.url = url;
93
104
  this.log = this.log.extend(url);
105
+ // Set common options
106
+ if (opts?.eoseTimeout !== undefined)
107
+ this.eoseTimeout = opts.eoseTimeout;
108
+ if (opts?.eventTimeout !== undefined)
109
+ this.eventTimeout = opts.eventTimeout;
110
+ if (opts?.keepAlive !== undefined)
111
+ this.keepAlive = opts.keepAlive;
112
+ // Create an observable that tracks boolean authentication state
113
+ this.authenticated$ = this.authenticationResponse$.pipe(map((response) => response?.ok === true));
94
114
  /** Use the static method to create a new reconnect method for this relay */
95
115
  this.reconnectTimer = Relay.createReconnectTimer(url);
116
+ // Subscribe to open and close events
117
+ this.open$.subscribe(() => {
118
+ this.log("Connected");
119
+ this.connected$.next(true);
120
+ this.attempts$.next(0);
121
+ this.error$.next(null);
122
+ this.resetState();
123
+ });
124
+ this.close$.subscribe((event) => {
125
+ this.log("Disconnected");
126
+ this.connected$.next(false);
127
+ this.attempts$.next(this.attempts$.value + 1);
128
+ this.resetState();
129
+ // Start the reconnect timer if the connection was not closed cleanly
130
+ if (!event.wasClean)
131
+ this.startReconnectTimer(event);
132
+ });
96
133
  this.socket = webSocket({
97
134
  url,
98
- openObserver: {
99
- next: () => {
100
- this.log("Connected");
101
- this.connected$.next(true);
102
- this.attempts$.next(0);
103
- this.error$.next(null);
104
- this.resetState();
105
- },
106
- },
107
- closeObserver: {
108
- next: (event) => {
109
- this.log("Disconnected");
110
- this.connected$.next(false);
111
- this.attempts$.next(this.attempts$.value + 1);
112
- this.resetState();
113
- // Start the reconnect timer if the connection was not closed cleanly
114
- if (!event.wasClean)
115
- this.startReconnectTimer(event);
116
- },
117
- },
135
+ openObserver: this.open$,
136
+ closeObserver: this.close$,
137
+ closingObserver: this.closing$,
118
138
  WebSocketCtor: opts?.WebSocket,
119
139
  });
120
140
  // Create an observable to fetch the NIP-11 information document
@@ -130,8 +150,8 @@ export class Relay {
130
150
  tap((info) => (this._nip11 = info)));
131
151
  this.limitations$ = this.information$.pipe(map((info) => info?.limitation));
132
152
  // Create observables that track if auth is required for REQ or EVENT
133
- 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));
134
- 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));
153
+ this.authRequiredForRead$ = this.receivedAuthRequiredForReq.pipe(tap((required) => required && this.log("Auth required for REQ")), shareReplay(1));
154
+ this.authRequiredForPublish$ = this.receivedAuthRequiredForEvent.pipe(tap((required) => required && this.log("Auth required for EVENT")), shareReplay(1));
135
155
  // Update the notices state
136
156
  const listenForNotice = this.socket.pipe(
137
157
  // listen for NOTICE messages
@@ -193,7 +213,7 @@ export class Relay {
193
213
  }
194
214
  /** Wait for authentication state, make connection and then wait for authentication if required */
195
215
  waitForAuth(
196
- // NOTE: require BehaviorSubject so it always has a value
216
+ // NOTE: require BehaviorSubject or shareReplay so it always has a value
197
217
  requireAuth, observable) {
198
218
  return combineLatest([requireAuth, this.authenticated$]).pipe(
199
219
  // Once the auth state is known, make a connection and watch for auth challenges
@@ -223,7 +243,7 @@ export class Relay {
223
243
  return this.socket.multiplex(open, close, filter);
224
244
  }
225
245
  /** Send a message to the relay */
226
- next(message) {
246
+ send(message) {
227
247
  this.socket.next(message);
228
248
  }
229
249
  /** Create a REQ observable that emits events or "EOSE" or errors */
@@ -231,21 +251,26 @@ export class Relay {
231
251
  // Convert filters input into an observable, if its a normal value merge it with NEVER so it never completes
232
252
  const input = isObservable(filters) ? filters : merge(of(filters), NEVER);
233
253
  // Create an observable that completes when the upstream observable completes
234
- const complete = input.pipe(ignoreElements(), endWith(null));
254
+ const filtersComplete = input.pipe(ignoreElements(), endWith(null));
235
255
  // Create an observable that filters responses from the relay to just the ones for this REQ
236
- const messages = this.socket.pipe(filter((m) => Array.isArray(m) && (m[0] === "EVENT" || m[0] === "CLOSED" || m[0] === "EOSE") && m[1] === id));
256
+ const messages = this.socket.pipe(filter((m) => Array.isArray(m) && (m[0] === "EVENT" || m[0] === "CLOSED" || m[0] === "EOSE") && m[1] === id),
257
+ // Singleton (prevents the .pipe() operator later from sending two REQ messages )
258
+ share());
237
259
  // Create an observable that controls sending the filters and closing the REQ
238
260
  const control = input.pipe(
239
261
  // Send the filters when they change
240
262
  tap((filters) => this.socket.next(Array.isArray(filters) ? ["REQ", id, ...filters] : ["REQ", id, filters])),
241
- // Close the req when unsubscribed
263
+ // Send the CLOSE message when unsubscribed or input completes
242
264
  finalize(() => this.socket.next(["CLOSE", id])),
243
265
  // Once filters have been sent, switch to listening for messages
244
266
  switchMap(() => messages));
245
267
  // Start the watch tower with the observables
246
268
  const observable = merge(this.watchTower, control).pipe(
269
+ // Complete the subscription when the control observable completes
270
+ // This is to work around the fact that merge() waits for both observables to complete
271
+ takeUntil(messages.pipe(ignoreElements(), endWith(true))),
247
272
  // Complete the subscription when the input is completed
248
- takeUntil(complete),
273
+ takeUntil(filtersComplete),
249
274
  // Map the messages to events, EOSE, or throw an error
250
275
  map((message) => {
251
276
  if (message[0] === "EOSE")
@@ -276,19 +301,24 @@ export class Relay {
276
301
  // Only create one upstream subscription
277
302
  share());
278
303
  // Wait for auth if required and make sure to start the watch tower
279
- return this.waitForReady(this.waitForAuth(this.authRequiredForReq, observable));
304
+ return this.waitForReady(this.waitForAuth(this.authRequiredForRead$, observable));
280
305
  }
281
306
  /** Send an EVENT or AUTH message and return an observable of PublishResponse that completes or errors */
282
307
  event(event, verb = "EVENT") {
283
- const base = defer(() => {
308
+ const messages = defer(() => {
284
309
  // Send event when subscription starts
285
310
  this.socket.next([verb, event]);
286
311
  return this.socket.pipe(filter((m) => m[0] === "OK" && m[1] === event.id),
287
312
  // format OK message
288
313
  map((m) => ({ ok: m[2], message: m[3], from: this.url })));
289
- });
314
+ }).pipe(
315
+ // Singleton (prevents the .pipe() operator later from sending two EVENT messages )
316
+ share());
290
317
  // Start the watch tower and add complete operators
291
- const observable = merge(this.watchTower, base).pipe(
318
+ const observable = merge(this.watchTower, messages).pipe(
319
+ // Complete the subscription when the messages observable completes
320
+ // This is to work around the fact that merge() waits for both observables to complete
321
+ takeUntil(messages.pipe(ignoreElements(), endWith(true))),
292
322
  // complete on first value
293
323
  take(1),
294
324
  // listen for OK auth-required
@@ -309,13 +339,13 @@ export class Relay {
309
339
  if (verb === "AUTH")
310
340
  return this.waitForReady(observable);
311
341
  else
312
- return this.waitForReady(this.waitForAuth(this.authRequiredForEvent, observable));
342
+ return this.waitForReady(this.waitForAuth(this.authRequiredForPublish$, observable));
313
343
  }
314
344
  /** send and AUTH message */
315
345
  auth(event) {
316
- return this.event(event, "AUTH").pipe(
346
+ return lastValueFrom(this.event(event, "AUTH").pipe(
317
347
  // update authenticated
318
- tap((result) => this.authenticated$.next(result.ok)));
348
+ tap((result) => this.authenticationResponse$.next(result))));
319
349
  }
320
350
  /** Authenticate with the relay using a signer */
321
351
  authenticate(signer) {
@@ -323,32 +353,66 @@ export class Relay {
323
353
  throw new Error("Have not received authentication challenge");
324
354
  const p = signer.signEvent(nip42.makeAuthEvent(this.url, this.challenge));
325
355
  const start = p instanceof Promise ? from(p) : of(p);
326
- return start.pipe(switchMap((event) => this.auth(event)));
356
+ return lastValueFrom(start.pipe(switchMap((event) => this.auth(event))));
357
+ }
358
+ /** Internal operator for creating the retry() operator */
359
+ customRetryOperator(times) {
360
+ if (typeof times === "number")
361
+ return retry(times);
362
+ else
363
+ return retry(times);
364
+ }
365
+ /** Internal operator for creating the repeat() operator */
366
+ customRepeatOperator(times) {
367
+ if (times === false || times === undefined)
368
+ return identity;
369
+ else if (times === true)
370
+ return repeat();
371
+ else if (typeof times === "number")
372
+ return repeat(times);
373
+ else
374
+ return repeat(times);
327
375
  }
328
376
  /** Creates a REQ that retries when relay errors ( default 3 retries ) */
329
377
  subscription(filters, opts) {
330
378
  return this.req(filters, opts?.id).pipe(
331
379
  // Retry on connection errors
332
- retry({ count: opts?.retries ?? 3, resetOnSuccess: true }));
380
+ this.customRetryOperator(opts?.retries ?? 3),
381
+ // Create reconnect logic (repeat operator)
382
+ this.customRepeatOperator(opts?.reconnect),
383
+ // Single subscription
384
+ share());
333
385
  }
334
386
  /** Makes a single request that retires on errors and completes on EOSE */
335
387
  request(filters, opts) {
336
388
  return this.req(filters, opts?.id).pipe(
337
389
  // Retry on connection errors
338
- retry(opts?.retries ?? 3),
390
+ this.customRetryOperator(opts?.retries ?? 3),
391
+ // Create reconnect logic (repeat operator)
392
+ this.customRepeatOperator(opts?.reconnect),
339
393
  // Complete when EOSE is received
340
- completeOnEose());
394
+ completeOnEose(),
395
+ // Single subscription
396
+ share());
341
397
  }
342
398
  /** Publishes an event to the relay and retries when relay errors or responds with auth-required ( default 3 retries ) */
343
399
  publish(event, opts) {
344
- return this.event(event).pipe(mergeMap((result) => {
400
+ return lastValueFrom(this.event(event).pipe(mergeMap((result) => {
345
401
  // If the relay responds with auth-required, throw an error for the retry operator to handle
346
402
  if (result.ok === false && result.message?.startsWith("auth-required:"))
347
403
  return throwError(() => new Error(result.message));
348
404
  return of(result);
349
405
  }),
350
406
  // Retry the publish until it succeeds or the number of retries is reached
351
- retry(opts?.retries ?? 3));
407
+ this.customRetryOperator(opts?.retries ?? 3),
408
+ // Create reconnect logic (repeat operator)
409
+ this.customRepeatOperator(opts?.reconnect),
410
+ // Single subscription
411
+ share()));
412
+ }
413
+ /** Force close the connection */
414
+ close() {
415
+ this.socket.unsubscribe();
352
416
  }
353
417
  /** Static method to fetch the NIP-11 information document for a relay */
354
418
  static fetchInformationDocument(url) {
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type EventTemplate, type Filter, type NostrEvent } from "nostr-tools";
2
- import { Observable } from "rxjs";
2
+ import { Observable, repeat, retry } from "rxjs";
3
3
  import { WebSocketSubject } from "rxjs/webSocket";
4
4
  export type SubscriptionResponse = NostrEvent | "EOSE";
5
5
  export type PublishResponse = {
@@ -8,99 +8,97 @@ export type PublishResponse = {
8
8
  from: string;
9
9
  };
10
10
  export type MultiplexWebSocket<T = any> = Pick<WebSocketSubject<T>, "multiplex">;
11
- export interface IRelayState {
12
- connected$: Observable<boolean>;
13
- challenge$: Observable<string | null>;
14
- authenticated$: Observable<boolean>;
15
- notices$: Observable<string[]>;
16
- }
11
+ /** Options for the publish method on the pool and relay */
17
12
  export type PublishOptions = {
18
- retries?: number;
19
- };
20
- export type RequestOptions = {
21
- id?: string;
22
- retries?: number;
13
+ /**
14
+ * Number of times to retry the publish. default is 3
15
+ * @see https://rxjs.dev/api/index/function/retry
16
+ */
17
+ retries?: number | Parameters<typeof retry>[0];
18
+ /**
19
+ * Whether to reconnect when socket is closed. A number of times or true for infinite. default is false
20
+ * @see https://rxjs.dev/api/index/function/repeat
21
+ */
22
+ reconnect?: boolean | Parameters<typeof repeat>[0];
23
23
  };
24
+ /** Options for the request method on the pool and relay */
25
+ export type RequestOptions = SubscriptionOptions;
26
+ /** Options for the subscription method on the pool and relay */
24
27
  export type SubscriptionOptions = {
28
+ /** Custom REQ id for the subscription */
25
29
  id?: string;
26
- retries?: number;
30
+ /**
31
+ * Number of times to retry a request. default is 3
32
+ * @see https://rxjs.dev/api/index/function/retry
33
+ */
34
+ retries?: number | Parameters<typeof retry>[0];
35
+ /**
36
+ * Whether to reconnect when socket is closed. A number of times or true for infinite. default is false
37
+ * @see https://rxjs.dev/api/index/function/repeat
38
+ */
39
+ reconnect?: boolean | Parameters<typeof repeat>[0];
27
40
  };
28
41
  export type AuthSigner = {
29
42
  signEvent: (event: EventTemplate) => NostrEvent | Promise<NostrEvent>;
30
43
  };
31
44
  /** The type of input the REQ method accepts */
32
45
  export type FilterInput = Filter | Filter[] | Observable<Filter | Filter[]>;
33
- export interface Nip01Actions {
34
- /** Send an EVENT message */
35
- event(event: NostrEvent): Observable<PublishResponse>;
36
- /** Send a REQ message */
37
- req(filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
38
- }
39
- export interface IRelay extends MultiplexWebSocket, Nip01Actions, IRelayState {
46
+ export interface IRelay extends MultiplexWebSocket {
40
47
  url: string;
41
48
  message$: Observable<any>;
42
49
  notice$: Observable<string>;
50
+ connected$: Observable<boolean>;
51
+ challenge$: Observable<string | null>;
52
+ authenticated$: Observable<boolean>;
53
+ notices$: Observable<string[]>;
43
54
  readonly connected: boolean;
44
55
  readonly authenticated: boolean;
45
56
  readonly challenge: string | null;
46
57
  readonly notices: string[];
58
+ /** Force close the connection */
59
+ close(): void;
60
+ /** Send a REQ message */
61
+ req(filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
62
+ /** Send an EVENT message */
63
+ event(event: NostrEvent): Observable<PublishResponse>;
47
64
  /** Send an AUTH message */
48
- auth(event: NostrEvent): Observable<{
49
- ok: boolean;
50
- message?: string;
51
- }>;
65
+ auth(event: NostrEvent): Promise<PublishResponse>;
66
+ /** Authenticate with the relay using a signer */
67
+ authenticate(signer: AuthSigner): Promise<PublishResponse>;
52
68
  /** Send an EVENT message with retries */
53
- publish(event: NostrEvent, opts?: {
54
- retries?: number;
55
- }): Observable<PublishResponse>;
69
+ publish(event: NostrEvent, opts?: PublishOptions): Promise<PublishResponse>;
56
70
  /** Send a REQ message with retries */
57
- request(filters: FilterInput, opts?: {
58
- id?: string;
59
- retries?: number;
60
- }): Observable<NostrEvent>;
71
+ request(filters: FilterInput, opts?: RequestOptions): Observable<NostrEvent>;
61
72
  /** Open a subscription with retries */
62
- subscription(filters: FilterInput, opts?: {
63
- id?: string;
64
- retries?: number;
65
- }): Observable<SubscriptionResponse>;
73
+ subscription(filters: FilterInput, opts?: SubscriptionOptions): Observable<SubscriptionResponse>;
66
74
  }
67
- export interface IGroup extends Nip01Actions {
75
+ export interface IGroup {
76
+ /** Send a REQ message */
77
+ req(filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
78
+ /** Send an EVENT message */
79
+ event(event: NostrEvent): Observable<PublishResponse>;
68
80
  /** Send an EVENT message with retries */
69
- publish(event: NostrEvent, opts?: {
70
- retries?: number;
71
- }): Observable<PublishResponse>;
81
+ publish(event: NostrEvent, opts?: PublishOptions): Promise<PublishResponse[]>;
72
82
  /** Send a REQ message with retries */
73
- request(filters: FilterInput, opts?: {
74
- id?: string;
75
- retries?: number;
76
- }): Observable<NostrEvent>;
83
+ request(filters: FilterInput, opts?: RequestOptions): Observable<NostrEvent>;
77
84
  /** Open a subscription with retries */
78
- subscription(filters: FilterInput, opts?: {
79
- id?: string;
80
- retries?: number;
81
- }): Observable<SubscriptionResponse>;
85
+ subscription(filters: FilterInput, opts?: SubscriptionOptions): Observable<SubscriptionResponse>;
82
86
  }
83
87
  export interface IPool {
84
- /** Send an EVENT message */
85
- event(relays: string[], event: NostrEvent): Observable<PublishResponse>;
86
- /** Send a REQ message */
87
- req(relays: string[], filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
88
88
  /** Get or create a relay */
89
89
  relay(url: string): IRelay;
90
90
  /** Create a relay group */
91
91
  group(relays: string[]): IGroup;
92
+ /** Removes a relay from the pool and defaults to closing the connection */
93
+ remove(relay: string | IRelay, close?: boolean): void;
94
+ /** Send a REQ message */
95
+ req(relays: string[], filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
96
+ /** Send an EVENT message */
97
+ event(relays: string[], event: NostrEvent): Observable<PublishResponse>;
92
98
  /** Send an EVENT message to relays with retries */
93
- publish(relays: string[], event: NostrEvent, opts?: {
94
- retries?: number;
95
- }): Observable<PublishResponse>;
99
+ publish(relays: string[], event: NostrEvent, opts?: PublishOptions): Promise<PublishResponse[]>;
96
100
  /** Send a REQ message to relays with retries */
97
- request(relays: string[], filters: FilterInput, opts?: {
98
- id?: string;
99
- retries?: number;
100
- }): Observable<NostrEvent>;
101
+ request(relays: string[], filters: FilterInput, opts?: RequestOptions): Observable<NostrEvent>;
101
102
  /** Open a subscription to relays with retries */
102
- subscription(relays: string[], filters: FilterInput, opts?: {
103
- id?: string;
104
- retries?: number;
105
- }): Observable<SubscriptionResponse>;
103
+ subscription(relays: string[], filters: FilterInput, opts?: SubscriptionOptions): Observable<SubscriptionResponse>;
106
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "applesauce-relay",
3
- "version": "2.1.1",
3
+ "version": "3.0.0",
4
4
  "description": "nostr relay communication framework built on rxjs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -54,14 +54,14 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@noble/hashes": "^1.7.1",
57
- "applesauce-core": "^2.0.0",
57
+ "applesauce-core": "^3.0.0",
58
58
  "nanoid": "^5.0.9",
59
59
  "nostr-tools": "^2.13",
60
60
  "rxjs": "^7.8.1"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@hirez_io/observer-spy": "^2.2.0",
64
- "applesauce-signers": "^2.0.0",
64
+ "applesauce-signers": "^3.0.0",
65
65
  "@vitest/expect": "^3.1.1",
66
66
  "typescript": "^5.7.3",
67
67
  "vitest": "^3.2.3",