applesauce-relay 0.0.0-next-20251231152045 → 0.0.0-next-20260120162357

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
@@ -22,7 +22,7 @@ npm install applesauce-relay
22
22
 
23
23
  ## Examples
24
24
 
25
- Read the [documentation](https://hzrd149.github.io/applesauce/overview/relays.html) for more detailed explanation of all methods
25
+ Read the [documentation](https://applesauce.build/overview/relays.html) for more detailed explanation of all methods
26
26
 
27
27
  ### Single Relay
28
28
 
package/dist/group.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { Filter, NostrEvent } from "applesauce-core/helpers";
3
3
  import { BehaviorSubject, MonoTypeOperatorFunction, Observable } from "rxjs";
4
4
  import { NegentropySyncOptions, type ReconcileFunction } from "./negentropy.js";
5
5
  import { SyncDirection } from "./relay.js";
6
- import { CountResponse, FilterInput, IGroup, IGroupRelayInput, IRelay, NegentropyReadStore, NegentropySyncStore, PublishOptions, PublishResponse, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
6
+ import { CountResponse, FilterInput, IGroup, IGroupRelayInput, IRelay, NegentropyReadStore, NegentropySyncStore, PublishOptions, PublishResponse, RelayStatus, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
7
7
  /** Options for negentropy sync on a group of relays */
8
8
  export type GroupNegentropySyncOptions = NegentropySyncOptions & {
9
9
  /** Whether to sync in parallel (default true) */
@@ -21,6 +21,8 @@ export type GroupRequestOptions = RequestOptions & {
21
21
  };
22
22
  export declare class RelayGroup implements IGroup {
23
23
  protected relays$: BehaviorSubject<IRelay[]> | Observable<IRelay[]>;
24
+ /** Observable of relay status for all relays in the group */
25
+ status$: Observable<Record<string, RelayStatus>>;
24
26
  get relays(): IRelay[];
25
27
  constructor(relays: IGroupRelayInput);
26
28
  /** Whether this group is controlled by an upstream observable */
package/dist/group.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { EventMemory } from "applesauce-core/event-store";
2
2
  import { filterDuplicateEvents } from "applesauce-core/observable";
3
3
  import { nanoid } from "nanoid";
4
- import { BehaviorSubject, catchError, combineLatest, defaultIfEmpty, defer, endWith, filter, from, identity, ignoreElements, lastValueFrom, map, merge, of, scan, share, switchMap, take, takeWhile, toArray, } from "rxjs";
4
+ import { BehaviorSubject, catchError, combineLatest, defaultIfEmpty, defer, endWith, filter, from, identity, ignoreElements, lastValueFrom, map, merge, of, scan, share, shareReplay, startWith, switchMap, take, takeWhile, toArray, } from "rxjs";
5
5
  import { completeOnEose } from "./operators/complete-on-eose.js";
6
6
  import { onlyEvents } from "./operators/only-events.js";
7
7
  import { reverseSwitchMap } from "./operators/reverse-switch-map.js";
@@ -11,6 +11,8 @@ function errorToPublishResponse(relay) {
11
11
  }
12
12
  export class RelayGroup {
13
13
  relays$ = new BehaviorSubject([]);
14
+ /** Observable of relay status for all relays in the group */
15
+ status$;
14
16
  get relays() {
15
17
  if (this.relays$ instanceof BehaviorSubject)
16
18
  return this.relays$.value;
@@ -18,6 +20,23 @@ export class RelayGroup {
18
20
  }
19
21
  constructor(relays) {
20
22
  this.relays$ = Array.isArray(relays) ? new BehaviorSubject(relays) : relays;
23
+ // Initialize status$ observable
24
+ this.status$ = this.relays$.pipe(switchMap((relays) => {
25
+ // If no relays, return empty record
26
+ if (relays.length === 0)
27
+ return of({});
28
+ // Merge all relay status streams
29
+ return merge(...relays.map((relay) => relay.status$)).pipe(
30
+ // Accumulate into a Record
31
+ scan((acc, status) => ({
32
+ ...acc,
33
+ [status.url]: status,
34
+ }), {}),
35
+ // Start with initial empty state
36
+ startWith({}));
37
+ }),
38
+ // Share the subscription
39
+ shareReplay(1));
21
40
  }
22
41
  /** Whether this group is controlled by an upstream observable */
23
42
  get controlled() {
package/dist/pool.d.ts CHANGED
@@ -5,11 +5,13 @@ import { BehaviorSubject, Observable, Subject } from "rxjs";
5
5
  import { RelayGroup } from "./group.js";
6
6
  import type { NegentropySyncOptions, ReconcileFunction } from "./negentropy.js";
7
7
  import { Relay, SyncDirection, type RelayOptions } from "./relay.js";
8
- import type { CountResponse, FilterInput, IPool, IPoolRelayInput, IRelay, NegentropyReadStore, NegentropySyncStore, PublishResponse, SubscriptionResponse } from "./types.js";
8
+ import type { CountResponse, FilterInput, IPool, IPoolRelayInput, IRelay, NegentropyReadStore, NegentropySyncStore, PublishResponse, RelayStatus, SubscriptionResponse } from "./types.js";
9
9
  export declare class RelayPool implements IPool {
10
10
  options?: RelayOptions | undefined;
11
11
  relays$: BehaviorSubject<Map<string, Relay>>;
12
12
  get relays(): Map<string, Relay>;
13
+ /** Observable of relay status for all relays in the pool */
14
+ status$: Observable<Record<string, RelayStatus>>;
13
15
  /** Whether to ignore relays that are ready=false */
14
16
  ignoreOffline: boolean;
15
17
  /** A signal when a relay is added */
package/dist/pool.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { isFilterEqual } from "applesauce-core/helpers/filter";
2
2
  import { createFilterMap } from "applesauce-core/helpers/relay-selection";
3
3
  import { normalizeURL } from "applesauce-core/helpers/url";
4
- import { BehaviorSubject, distinctUntilChanged, isObservable, map, of, Subject } from "rxjs";
4
+ import { BehaviorSubject, distinctUntilChanged, isObservable, map, merge, of, scan, shareReplay, startWith, Subject, switchMap, } from "rxjs";
5
5
  import { RelayGroup } from "./group.js";
6
6
  import { Relay } from "./relay.js";
7
7
  export class RelayPool {
@@ -10,6 +10,8 @@ export class RelayPool {
10
10
  get relays() {
11
11
  return this.relays$.value;
12
12
  }
13
+ /** Observable of relay status for all relays in the pool */
14
+ status$;
13
15
  /** Whether to ignore relays that are ready=false */
14
16
  ignoreOffline = true;
15
17
  /** A signal when a relay is added */
@@ -18,6 +20,19 @@ export class RelayPool {
18
20
  remove$ = new Subject();
19
21
  constructor(options) {
20
22
  this.options = options;
23
+ // Initialize status$ observable
24
+ this.status$ = this.relays$.pipe(
25
+ // Convert Map to array of relays
26
+ map((relayMap) => Array.from(relayMap.values())),
27
+ // Use same pattern as RelayGroup
28
+ switchMap((relays) => {
29
+ if (relays.length === 0)
30
+ return of({});
31
+ return merge(...relays.map((relay) => relay.status$)).pipe(scan((acc, status) => ({
32
+ ...acc,
33
+ [status.url]: status,
34
+ }), {}), startWith({}));
35
+ }), shareReplay(1));
21
36
  }
22
37
  /** Get or create a new relay connection */
23
38
  relay(url) {
package/dist/relay.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { logger } from "applesauce-core";
2
- import { NostrEvent } from "applesauce-core/helpers/event";
2
+ import { KnownEvent, NostrEvent } from "applesauce-core/helpers/event";
3
3
  import { Filter } from "applesauce-core/helpers/filter";
4
4
  import { BehaviorSubject, MonoTypeOperatorFunction, Observable, RepeatConfig, RetryConfig, Subject } from "rxjs";
5
5
  import { WebSocketSubject, WebSocketSubjectConfig } from "rxjs/webSocket";
6
6
  import { type NegentropySyncOptions, type ReconcileFunction } from "./negentropy.js";
7
- import { AuthSigner, CountResponse, FilterInput, IRelay, NegentropyReadStore, NegentropySyncStore, PublishOptions, PublishResponse, RelayInformation, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
7
+ import { AuthSigner, CountResponse, FilterInput, IRelay, NegentropyReadStore, NegentropySyncStore, PublishOptions, PublishResponse, RelayInformation, RelayStatus, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
8
8
  /** Flags for the negentropy sync type */
9
9
  export declare enum SyncDirection {
10
10
  RECEIVE = 1,
@@ -25,6 +25,19 @@ export type RelayOptions = {
25
25
  publishTimeout?: number;
26
26
  /** How long to keep the connection alive after nothing is subscribed (default 30s) */
27
27
  keepAlive?: number;
28
+ /** Enable/disable ping functionality (default false) */
29
+ enablePing?: boolean;
30
+ /** How often to send pings in milliseconds (default 29000) */
31
+ pingFrequency?: number;
32
+ /** How long to wait for EOSE response in milliseconds (default 20000) */
33
+ pingTimeout?: number;
34
+ /** Policy for handling unresponsive connections (default: reconnect) */
35
+ onUnresponsive?: (info: {
36
+ url: string;
37
+ lastMessageAt: number;
38
+ now: number;
39
+ attempts: number;
40
+ }) => "reconnect" | "close" | "ignore";
28
41
  /** Default retry config for subscription() method */
29
42
  subscriptionRetry?: RetryConfig;
30
43
  /** Default retry config for request() method */
@@ -50,6 +63,10 @@ export declare class Relay implements IRelay {
50
63
  challenge$: BehaviorSubject<string | null>;
51
64
  /** Boolean authentication state (will be false if auth failed) */
52
65
  authenticated$: Observable<boolean>;
66
+ /** The pubkey of the authenticated user, or null if not authenticated */
67
+ authenticatedAs$: Observable<string | null>;
68
+ /** The authentication event sent to the relay */
69
+ authentication$: BehaviorSubject<KnownEvent<22242> | null>;
53
70
  /** The response to the last AUTH message sent to the relay */
54
71
  authenticationResponse$: BehaviorSubject<PublishResponse | null>;
55
72
  /** The notices from the relay */
@@ -66,6 +83,13 @@ export declare class Relay implements IRelay {
66
83
  * @note Subscribing to this will not connect to the relay
67
84
  */
68
85
  notice$: Observable<string>;
86
+ /** Timestamp of the last message received from the relay */
87
+ private lastMessageReceivedAt;
88
+ /** Observable of the timestamp when last message was received */
89
+ private _lastMessageAt$;
90
+ lastMessageAt$: Observable<number>;
91
+ /** Observable of relay status (connection, authentication, and ready state) */
92
+ status$: Observable<RelayStatus>;
69
93
  /** An observable that emits the NIP-11 information document for the relay */
70
94
  information$: Observable<RelayInformation | null>;
71
95
  protected _nip11: RelayInformation | null;
@@ -81,13 +105,19 @@ export declare class Relay implements IRelay {
81
105
  close$: Subject<CloseEvent>;
82
106
  /** An observable that emits when underlying websocket is closing due to unsubscription */
83
107
  closing$: Subject<void>;
108
+ /** Tracks active req() operations by subscription ID */
109
+ reqs$: BehaviorSubject<Record<string, Filter[]>>;
84
110
  get ready(): boolean;
85
111
  get connected(): boolean;
86
112
  get challenge(): string | null;
87
113
  get notices(): string[];
88
114
  get authenticated(): boolean;
115
+ get authentication(): KnownEvent<22242> | null;
116
+ get authenticatedAs(): string | null;
89
117
  get authenticationResponse(): PublishResponse | null;
90
118
  get information(): RelayInformation | null;
119
+ get lastMessageAt(): number;
120
+ get reqs(): Record<string, Filter[]>;
91
121
  /** If an EOSE message is not seen in this time, emit one locally (default 10s) */
92
122
  eoseTimeout: number;
93
123
  /** How long to wait for an OK message from the relay (default 10s) */
@@ -96,12 +126,20 @@ export declare class Relay implements IRelay {
96
126
  publishTimeout: number;
97
127
  /** How long to keep the connection alive after nothing is subscribed (default 30s) */
98
128
  keepAlive: number;
129
+ /** Enable/disable ping functionality (default false) */
130
+ enablePing: boolean;
131
+ /** How often to send pings in milliseconds (default 29000) */
132
+ pingFrequency: number;
133
+ /** How long to wait for EOSE response in milliseconds (default 20000) */
134
+ pingTimeout: number;
99
135
  /** Default retry config for subscription() method */
100
136
  protected subscriptionReconnect: RetryConfig;
101
137
  /** Default retry config for request() method */
102
138
  protected requestReconnect: RetryConfig;
103
139
  /** Default retry config for publish() method */
104
140
  protected publishRetry: RetryConfig;
141
+ /** Policy hook for unresponsive connections */
142
+ protected onUnresponsive?: RelayOptions["onUnresponsive"];
105
143
  protected receivedAuthRequiredForReq: BehaviorSubject<boolean>;
106
144
  protected receivedAuthRequiredForEvent: BehaviorSubject<boolean>;
107
145
  authRequiredForRead$: Observable<boolean>;
package/dist/relay.js CHANGED
@@ -24,6 +24,11 @@ export var SyncDirection;
24
24
  /** An error that is thrown when a REQ is closed from the relay side */
25
25
  export class ReqCloseError extends Error {
26
26
  }
27
+ /** A dummy filter that will return empty results */
28
+ const PING_FILTER = {
29
+ ids: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
30
+ limit: 0,
31
+ };
27
32
  export class Relay {
28
33
  url;
29
34
  log = logger.extend("Relay");
@@ -42,6 +47,10 @@ export class Relay {
42
47
  challenge$ = new BehaviorSubject(null);
43
48
  /** Boolean authentication state (will be false if auth failed) */
44
49
  authenticated$;
50
+ /** The pubkey of the authenticated user, or null if not authenticated */
51
+ authenticatedAs$;
52
+ /** The authentication event sent to the relay */
53
+ authentication$ = new BehaviorSubject(null);
45
54
  /** The response to the last AUTH message sent to the relay */
46
55
  authenticationResponse$ = new BehaviorSubject(null);
47
56
  /** The notices from the relay */
@@ -58,6 +67,13 @@ export class Relay {
58
67
  * @note Subscribing to this will not connect to the relay
59
68
  */
60
69
  notice$;
70
+ /** Timestamp of the last message received from the relay */
71
+ lastMessageReceivedAt = 0;
72
+ /** Observable of the timestamp when last message was received */
73
+ _lastMessageAt$ = new BehaviorSubject(0);
74
+ lastMessageAt$ = this._lastMessageAt$.asObservable();
75
+ /** Observable of relay status (connection, authentication, and ready state) */
76
+ status$;
61
77
  /** An observable that emits the NIP-11 information document for the relay */
62
78
  information$;
63
79
  _nip11 = null;
@@ -73,6 +89,8 @@ export class Relay {
73
89
  close$ = new Subject();
74
90
  /** An observable that emits when underlying websocket is closing due to unsubscription */
75
91
  closing$ = new Subject();
92
+ /** Tracks active req() operations by subscription ID */
93
+ reqs$ = new BehaviorSubject({});
76
94
  // sync state
77
95
  get ready() {
78
96
  return this._ready$.value;
@@ -89,12 +107,24 @@ export class Relay {
89
107
  get authenticated() {
90
108
  return this.authenticationResponse?.ok === true;
91
109
  }
110
+ get authentication() {
111
+ return this.authentication$.value;
112
+ }
113
+ get authenticatedAs() {
114
+ return this.authenticated ? (this.authentication?.pubkey ?? null) : null;
115
+ }
92
116
  get authenticationResponse() {
93
117
  return this.authenticationResponse$.value;
94
118
  }
95
119
  get information() {
96
120
  return this._nip11;
97
121
  }
122
+ get lastMessageAt() {
123
+ return this._lastMessageAt$.value;
124
+ }
125
+ get reqs() {
126
+ return this.reqs$.value;
127
+ }
98
128
  /** If an EOSE message is not seen in this time, emit one locally (default 10s) */
99
129
  eoseTimeout = 10_000;
100
130
  /** How long to wait for an OK message from the relay (default 10s) */
@@ -103,12 +133,20 @@ export class Relay {
103
133
  publishTimeout = 30_000;
104
134
  /** How long to keep the connection alive after nothing is subscribed (default 30s) */
105
135
  keepAlive = 30_000;
136
+ /** Enable/disable ping functionality (default false) */
137
+ enablePing = false;
138
+ /** How often to send pings in milliseconds (default 29000) */
139
+ pingFrequency = 29_000;
140
+ /** How long to wait for EOSE response in milliseconds (default 20000) */
141
+ pingTimeout = 20_000;
106
142
  /** Default retry config for subscription() method */
107
143
  subscriptionReconnect;
108
144
  /** Default retry config for request() method */
109
145
  requestReconnect;
110
146
  /** Default retry config for publish() method */
111
147
  publishRetry;
148
+ /** Policy hook for unresponsive connections */
149
+ onUnresponsive;
112
150
  // Subjects that track if an "auth-required" message has been received for REQ or EVENT
113
151
  receivedAuthRequiredForReq = new BehaviorSubject(false);
114
152
  receivedAuthRequiredForEvent = new BehaviorSubject(false);
@@ -121,6 +159,8 @@ export class Relay {
121
159
  this.challenge$.next(null);
122
160
  if (this.authenticationResponse$.value)
123
161
  this.authenticationResponse$.next(null);
162
+ if (this.authentication$.value !== null)
163
+ this.authentication$.next(null);
124
164
  if (this.notices$.value.length > 0)
125
165
  this.notices$.next([]);
126
166
  if (this.receivedAuthRequiredForReq.value)
@@ -142,12 +182,22 @@ export class Relay {
142
182
  this.publishTimeout = opts.publishTimeout;
143
183
  if (opts?.keepAlive !== undefined)
144
184
  this.keepAlive = opts.keepAlive;
185
+ if (opts?.enablePing !== undefined)
186
+ this.enablePing = opts.enablePing;
187
+ if (opts?.pingFrequency !== undefined)
188
+ this.pingFrequency = opts.pingFrequency;
189
+ if (opts?.pingTimeout !== undefined)
190
+ this.pingTimeout = opts.pingTimeout;
191
+ if (opts?.onUnresponsive !== undefined)
192
+ this.onUnresponsive = opts.onUnresponsive;
145
193
  // Set retry configs
146
194
  this.subscriptionReconnect = { ...DEFAULT_RETRY_CONFIG, ...(opts?.subscriptionRetry ?? {}) };
147
195
  this.requestReconnect = { ...DEFAULT_RETRY_CONFIG, ...(opts?.requestRetry ?? {}) };
148
196
  this.publishRetry = { ...DEFAULT_RETRY_CONFIG, ...(opts?.publishRetry ?? {}) };
149
197
  // Create an observable that tracks boolean authentication state
150
198
  this.authenticated$ = this.authenticationResponse$.pipe(map((response) => response?.ok === true));
199
+ // Create an observable that returns the pubkey when authenticated, or null otherwise
200
+ this.authenticatedAs$ = combineLatest([this.authenticated$, this.authentication$]).pipe(map(([authenticated, authEvent]) => (authenticated && authEvent ? authEvent.pubkey : null)));
151
201
  /** Use the static method to create a new reconnect method for this relay */
152
202
  this.reconnectTimer = Relay.createReconnectTimer(url);
153
203
  // Subscribe to open and close events
@@ -206,6 +256,16 @@ export class Relay {
206
256
  this.authRequiredForPublish$
207
257
  .pipe(filter((r) => r === true), take(1))
208
258
  .subscribe(() => this.log("Auth required for EVENT"));
259
+ // Create status$ observable by combining state observables
260
+ this.status$ = combineLatest({
261
+ url: of(this.url),
262
+ connected: this.connected$,
263
+ authenticated: this.authenticated$,
264
+ authenticatedAs: this.authenticatedAs$,
265
+ ready: this._ready$,
266
+ authRequiredForRead: this.authRequiredForRead$,
267
+ authRequiredForPublish: this.authRequiredForPublish$,
268
+ }).pipe(shareReplay(1));
209
269
  // Update the notices state
210
270
  const listenForNotice = this.socket.pipe(
211
271
  // listen for NOTICE messages
@@ -228,7 +288,14 @@ export class Relay {
228
288
  this.challenge$.next(challenge);
229
289
  }));
230
290
  const allMessagesSubject = new Subject();
231
- const listenForAllMessages = this.socket.pipe(tap((message) => allMessagesSubject.next(message)));
291
+ const listenForAllMessages = this.socket.pipe(tap((message) => {
292
+ // Update the last message received at timestamp
293
+ const now = Date.now();
294
+ this.lastMessageReceivedAt = now;
295
+ this._lastMessageAt$.next(now);
296
+ // Pass to the message subject
297
+ allMessagesSubject.next(message);
298
+ }));
232
299
  // Create passive observables for messages and notices
233
300
  this.message$ = allMessagesSubject.asObservable();
234
301
  this.notice$ = this.message$.pipe(
@@ -236,12 +303,70 @@ export class Relay {
236
303
  filter((m) => Array.isArray(m) && m[0] === "NOTICE"),
237
304
  // pick the string out of the message
238
305
  map((m) => m[1]));
306
+ // Create ping health check observable
307
+ const pingHealthCheck = this.connected$.pipe(
308
+ // Switch based on connection state
309
+ switchMap((connected) => {
310
+ // Only run when connected and ping is enabled
311
+ if (!connected || !this.enablePing)
312
+ return NEVER;
313
+ // Start timer that emits periodically
314
+ return timer(this.pingFrequency, this.pingFrequency).pipe(
315
+ // For each ping, create a dummy REQ and wait for EOSE
316
+ mergeMap(() => {
317
+ // Skip ping if we have received a message in the last pingFrequency milliseconds
318
+ if (Date.now() - this.lastMessageReceivedAt < this.pingFrequency)
319
+ return NEVER;
320
+ // Generate unique ping ID for correlation
321
+ const pingId = "ping:" + nanoid();
322
+ this.send(["REQ", pingId, PING_FILTER]);
323
+ // Wait for the EOSE or CLOSED response for this specific ping
324
+ return this.message$.pipe(
325
+ // Wait specifically for response to OUR ping
326
+ filter((m) => Array.isArray(m) && (m[0] === "EOSE" || m[0] === "CLOSED") && m[1] === pingId),
327
+ // Complete after first matching message received
328
+ take(1),
329
+ // Add timeout to detect unresponsive connections
330
+ timeout({
331
+ first: this.pingTimeout,
332
+ with: () => {
333
+ // Determine action via policy hook (default: reconnect)
334
+ const now = Date.now();
335
+ const action = this.onUnresponsive?.({
336
+ url: this.url,
337
+ lastMessageAt: this.lastMessageReceivedAt,
338
+ now,
339
+ attempts: this.attempts$.value,
340
+ }) ?? "reconnect";
341
+ const err = new Error(`Relay ping timeout after ${this.pingTimeout}ms`);
342
+ if (action === "reconnect") {
343
+ this.log("Relay connection has become unresponsive, triggering reconnect");
344
+ this.startReconnectTimer(err);
345
+ }
346
+ else if (action === "close") {
347
+ this.log("Relay connection has become unresponsive, closing connection");
348
+ this.error$.next(err);
349
+ this.socket.complete();
350
+ }
351
+ else {
352
+ // "ignore" - log but don't take action
353
+ this.log("Relay connection has become unresponsive (ignoring per policy)");
354
+ }
355
+ return NEVER;
356
+ },
357
+ }),
358
+ // Close the ping subscription when done
359
+ finalize(() => this.send(["CLOSE", pingId])));
360
+ }));
361
+ }),
362
+ // Catch errors to prevent breaking the watchTower
363
+ catchError(() => NEVER));
239
364
  // Merge all watchers
240
365
  this.watchTower = this.ready$.pipe(switchMap((ready) => {
241
366
  if (!ready)
242
367
  return NEVER;
243
368
  // Only start the watch tower if the relay is ready
244
- return merge(listenForAllMessages, listenForNotice, ListenForChallenge, this.information$).pipe(
369
+ return merge(listenForAllMessages, listenForNotice, ListenForChallenge, this.information$, pingHealthCheck).pipe(
245
370
  // Never emit any values
246
371
  ignoreElements(),
247
372
  // Start the reconnect timer if the connection has an error
@@ -323,9 +448,18 @@ export class Relay {
323
448
  // Create an observable that controls sending the filters and closing the REQ
324
449
  const control = input.pipe(
325
450
  // Send the filters when they change
326
- tap((filters) => this.socket.next(["REQ", id, ...filters])),
451
+ tap((filters) => {
452
+ this.socket.next(["REQ", id, ...filters]);
453
+ // Add to tracking when REQ is sent
454
+ this.reqs$.next({ ...this.reqs$.value, [id]: filters });
455
+ }),
327
456
  // Send the CLOSE message when unsubscribed or input completes
328
- finalize(() => this.socket.next(["CLOSE", id])),
457
+ finalize(() => {
458
+ this.socket.next(["CLOSE", id]);
459
+ // Remove from tracking when REQ closes
460
+ const { [id]: _, ...rest } = this.reqs$.value;
461
+ this.reqs$.next(rest);
462
+ }),
329
463
  // Once filters have been sent, switch to listening for messages
330
464
  switchMap(() => messages));
331
465
  // Start the watch tower with the observables
@@ -425,6 +559,8 @@ export class Relay {
425
559
  }
426
560
  /** send and AUTH message */
427
561
  auth(event) {
562
+ // Save the authentication event
563
+ this.authentication$.next(event);
428
564
  return lastValueFrom(this.event(event, "AUTH").pipe(
429
565
  // update authenticated
430
566
  tap((result) => this.authenticationResponse$.next(result))));
package/dist/types.d.ts CHANGED
@@ -16,6 +16,23 @@ export type PublishResponse = {
16
16
  export type CountResponse = {
17
17
  count: number;
18
18
  };
19
+ /** Status information for a single relay */
20
+ export interface RelayStatus {
21
+ /** Relay URL */
22
+ url: string;
23
+ /** WebSocket connection state (true = socket is open) */
24
+ connected: boolean;
25
+ /** Authentication state (true = successfully authenticated) */
26
+ authenticated: boolean;
27
+ /** The pubkey of the authenticated user, or null if not authenticated */
28
+ authenticatedAs: string | null;
29
+ /** Application-layer ready state (true = safe to use) */
30
+ ready: boolean;
31
+ /** Whether authentication is required for read operations (REQ/COUNT) */
32
+ authRequiredForRead: boolean;
33
+ /** Whether authentication is required for publish operations (EVENT) */
34
+ authRequiredForPublish: boolean;
35
+ }
19
36
  export type MultiplexWebSocket<T = any> = Pick<WebSocketSubject<T>, "multiplex">;
20
37
  /** Options for the publish method on the pool and relay */
21
38
  export type PublishOptions = {
@@ -66,6 +83,7 @@ export interface IRelay extends MultiplexWebSocket {
66
83
  message$: Observable<any>;
67
84
  notice$: Observable<string>;
68
85
  connected$: Observable<boolean>;
86
+ ready$: Observable<boolean>;
69
87
  challenge$: Observable<string | null>;
70
88
  authenticated$: Observable<boolean>;
71
89
  notices$: Observable<string[]>;
@@ -73,10 +91,14 @@ export interface IRelay extends MultiplexWebSocket {
73
91
  close$: Observable<CloseEvent>;
74
92
  closing$: Observable<void>;
75
93
  error$: Observable<Error | null>;
94
+ lastMessageAt$: Observable<number>;
95
+ status$: Observable<RelayStatus>;
76
96
  readonly connected: boolean;
97
+ readonly ready: boolean;
77
98
  readonly authenticated: boolean;
78
99
  readonly challenge: string | null;
79
100
  readonly notices: string[];
101
+ readonly lastMessageAt: number;
80
102
  /** Force close the connection */
81
103
  close(): void;
82
104
  /** Send a REQ message */
@@ -108,6 +130,8 @@ export interface IRelay extends MultiplexWebSocket {
108
130
  }
109
131
  export type IGroupRelayInput = IRelay[] | Observable<IRelay[]>;
110
132
  export interface IGroup {
133
+ /** Observable of relay status for all relays in the group */
134
+ status$: Observable<Record<string, RelayStatus>>;
111
135
  /** Send a REQ message */
112
136
  req(filters: Parameters<IRelay["req"]>[0], id?: string): Observable<SubscriptionResponse>;
113
137
  /** Send an EVENT message */
@@ -138,6 +162,8 @@ export interface IPoolSignals {
138
162
  }
139
163
  export type IPoolRelayInput = string[] | Observable<string[]>;
140
164
  export interface IPool extends IPoolSignals {
165
+ /** Observable of relay status for all relays in the pool */
166
+ status$: Observable<Record<string, RelayStatus>>;
141
167
  /** Get or create a relay */
142
168
  relay(url: string): IRelay;
143
169
  /** Create a relay group */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "applesauce-relay",
3
- "version": "0.0.0-next-20251231152045",
3
+ "version": "0.0.0-next-20260120162357",
4
4
  "description": "nostr relay communication framework built on rxjs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -57,14 +57,14 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@noble/hashes": "^1.7.1",
60
- "applesauce-core": "0.0.0-next-20251231152045",
60
+ "applesauce-core": "0.0.0-next-20260120162357",
61
61
  "nanoid": "^5.0.9",
62
62
  "nostr-tools": "~2.19",
63
63
  "rxjs": "^7.8.1"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@hirez_io/observer-spy": "^2.2.0",
67
- "applesauce-signers": "0.0.0-next-20251231152045",
67
+ "applesauce-signers": "^5.0.0",
68
68
  "rimraf": "^6.0.1",
69
69
  "typescript": "^5.7.3",
70
70
  "vitest": "^4.0.15",