applesauce-relay 4.0.0 → 4.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.
package/README.md CHANGED
@@ -10,16 +10,15 @@ npm install applesauce-relay
10
10
 
11
11
  ## Features
12
12
 
13
- - [x] NIP-01
13
+ - [x] NIP-01 `REQ` and `EVENT` messages
14
14
  - [x] Relay pool and groups
15
- - [x] Fetch NIP-11 information before connecting
16
15
  - [x] NIP-11 `auth_required` limitation
17
16
  - [ ] NIP-11 `max_subscriptions` limitation
18
17
  - [x] Client negentropy sync
19
18
  - [x] Reconnection backoff logic
20
19
  - [x] republish event on reconnect and auth-required
21
20
  - [x] Resubscribe on reconnect and auth-required
22
- - [ ] NIP-45 COUNT
21
+ - [x] NIP-45 COUNT
23
22
 
24
23
  ## Examples
25
24
 
package/dist/group.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { Filter, type NostrEvent } from "nostr-tools";
2
- import { Observable } from "rxjs";
2
+ import { BehaviorSubject, MonoTypeOperatorFunction, Observable } from "rxjs";
3
3
  import { IAsyncEventStoreActions, IAsyncEventStoreRead, IEventStoreActions, IEventStoreRead } from "applesauce-core";
4
4
  import { NegentropySyncOptions, type ReconcileFunction } from "./negentropy.js";
5
- import { FilterInput, IGroup, IRelay, PublishOptions, PublishResponse, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
6
5
  import { SyncDirection } from "./relay.js";
6
+ import { CountResponse, FilterInput, IGroup, IGroupRelayInput, IRelay, PublishOptions, PublishResponse, 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) */
@@ -20,25 +20,41 @@ export type GroupRequestOptions = RequestOptions & {
20
20
  eventStore?: IEventStoreActions | IAsyncEventStoreActions | null;
21
21
  };
22
22
  export declare class RelayGroup implements IGroup {
23
- relays: IRelay[];
24
- constructor(relays: IRelay[]);
25
- /** Takes an array of observables and only emits EOSE when all observables have emitted EOSE */
26
- protected mergeEOSE(requests: Observable<SubscriptionResponse>[], eventStore?: IEventStoreActions | IAsyncEventStoreActions | null): Observable<import("nostr-tools").Event | "EOSE">;
23
+ protected relays$: BehaviorSubject<IRelay[]> | Observable<IRelay[]>;
24
+ get relays(): IRelay[];
25
+ constructor(relays: IGroupRelayInput);
26
+ /** Whether this group is controlled by an upstream observable */
27
+ private get controlled();
28
+ /** Check if a relay is in the group */
29
+ has(relay: IRelay | string): boolean;
30
+ /** Add a relay to the group */
31
+ add(relay: IRelay): void;
32
+ /** Remove a relay from the group */
33
+ remove(relay: IRelay): void;
34
+ /** Internal logic for handling requests to multiple relays */
35
+ protected internalSubscription(project: (relay: IRelay) => Observable<SubscriptionResponse>, eventOperator?: MonoTypeOperatorFunction<NostrEvent>): Observable<SubscriptionResponse>;
36
+ /** Internal logic for handling publishes to multiple relays */
37
+ protected internalPublish(project: (relay: IRelay) => Observable<PublishResponse>): Observable<PublishResponse>;
27
38
  /**
28
39
  * Make a request to all relays
29
40
  * @note This does not deduplicate events
30
41
  */
31
- req(filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
42
+ req(filters: FilterInput, id?: string, opts?: {
43
+ /** Deduplicate events with an event store (default is a temporary instance of EventMemory), null will disable deduplication */
44
+ eventStore?: IEventStoreActions | IAsyncEventStoreActions | null;
45
+ }): Observable<SubscriptionResponse>;
32
46
  /** Send an event to all relays */
33
47
  event(event: NostrEvent): Observable<PublishResponse>;
34
48
  /** Negentropy sync events with the relays and an event store */
35
49
  negentropy(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, reconcile: ReconcileFunction, opts?: GroupNegentropySyncOptions): Promise<boolean>;
36
50
  /** Publish an event to all relays with retries ( default 3 retries ) */
37
51
  publish(event: NostrEvent, opts?: PublishOptions): Promise<PublishResponse[]>;
38
- /** Request events from all relays with retries ( default 3 retries ) */
52
+ /** Request events from all relays and complete on EOSE */
39
53
  request(filters: FilterInput, opts?: GroupRequestOptions): Observable<NostrEvent>;
40
54
  /** Open a subscription to all relays with retries ( default 3 retries ) */
41
55
  subscription(filters: FilterInput, opts?: GroupSubscriptionOptions): Observable<SubscriptionResponse>;
56
+ /** Count events on all relays in the group */
57
+ count(filters: Filter | Filter[], id?: string): Observable<Record<string, CountResponse>>;
42
58
  /** Negentropy sync events with the relays and an event store */
43
59
  sync(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, direction?: SyncDirection): Observable<NostrEvent>;
44
60
  }
package/dist/group.js CHANGED
@@ -1,45 +1,143 @@
1
1
  import { nanoid } from "nanoid";
2
- import { catchError, defer, EMPTY, endWith, identity, ignoreElements, merge, of, share, switchMap, } from "rxjs";
2
+ import { BehaviorSubject, catchError, combineLatest, defaultIfEmpty, defer, endWith, filter, from, identity, ignoreElements, lastValueFrom, map, merge, of, scan, share, switchMap, take, takeWhile, toArray, } from "rxjs";
3
3
  import { EventMemory, filterDuplicateEvents, } from "applesauce-core";
4
4
  import { completeOnEose } from "./operators/complete-on-eose.js";
5
5
  import { onlyEvents } from "./operators/only-events.js";
6
+ import { reverseSwitchMap } from "./operators/reverse-switch-map.js";
7
+ /** Convert an error to a PublishResponse */
8
+ function errorToPublishResponse(relay) {
9
+ return catchError((err) => of({ ok: false, from: relay.url, message: err?.message || "Unknown error" }));
10
+ }
6
11
  export class RelayGroup {
7
- relays;
12
+ relays$ = new BehaviorSubject([]);
13
+ get relays() {
14
+ if (this.relays$ instanceof BehaviorSubject)
15
+ return this.relays$.value;
16
+ throw new Error("This group was created with an observable, relays are not available");
17
+ }
8
18
  constructor(relays) {
9
- this.relays = relays;
19
+ this.relays$ = Array.isArray(relays) ? new BehaviorSubject(relays) : relays;
20
+ }
21
+ /** Whether this group is controlled by an upstream observable */
22
+ get controlled() {
23
+ return this.relays$ instanceof BehaviorSubject === false;
24
+ }
25
+ /** Check if a relay is in the group */
26
+ has(relay) {
27
+ if (this.controlled)
28
+ throw new Error("This group was created with an observable, relays are not available");
29
+ if (typeof relay === "string")
30
+ return this.relays.some((r) => r.url === relay);
31
+ return this.relays.includes(relay);
32
+ }
33
+ /** Add a relay to the group */
34
+ add(relay) {
35
+ if (this.has(relay))
36
+ return;
37
+ this.relays$.next([...this.relays, relay]);
10
38
  }
11
- /** Takes an array of observables and only emits EOSE when all observables have emitted EOSE */
12
- mergeEOSE(requests, eventStore = new EventMemory()) {
13
- // Create stream of events only
14
- const events = merge(...requests).pipe(
15
- // Ignore non event responses
39
+ /** Remove a relay from the group */
40
+ remove(relay) {
41
+ if (!this.has(relay))
42
+ return;
43
+ this.relays$.next(this.relays.filter((r) => r !== relay));
44
+ }
45
+ /** Internal logic for handling requests to multiple relays */
46
+ internalSubscription(project, eventOperator = identity) {
47
+ // Keep a cache of upstream observables for each relay
48
+ const upstream = new WeakMap();
49
+ // Subscribe to the group relays
50
+ const main = this.relays$.pipe(
51
+ // Every time they change switch to a new observable
52
+ // Using reverseSwitchMap to subscribe to the new relays before unsubscribing from the old ones
53
+ // This avoids sending duplicate REQ messages to the relays
54
+ reverseSwitchMap((relays) => {
55
+ const observables = [];
56
+ for (const relay of relays) {
57
+ // If an upstream observable exists for this relay, use it
58
+ if (upstream.has(relay)) {
59
+ observables.push(upstream.get(relay));
60
+ continue;
61
+ }
62
+ const observable = project(relay).pipe(
63
+ // Catch connection errors and return EOSE
64
+ catchError(() => of("EOSE")),
65
+ // Map values into tuple of relay and value
66
+ map((value) => [relay, value]));
67
+ observables.push(observable);
68
+ upstream.set(relay, observable);
69
+ }
70
+ return merge(...observables);
71
+ }),
72
+ // Only create one upstream subscription
73
+ share());
74
+ // Create an observable that only emits the events from the relays
75
+ const events = main.pipe(
76
+ // Pick the value from the tuple
77
+ map(([_, value]) => value),
78
+ // Only return events
16
79
  onlyEvents(),
17
- // If an event store is provided, filter duplicate events
18
- eventStore ? filterDuplicateEvents(eventStore) : identity);
19
- // Create stream that emits EOSE when all relays have sent EOSE
20
- const eose = merge(
21
- // Create a new map of requests that only emits EOSE
22
- ...requests.map((observable) => observable.pipe(completeOnEose(), ignoreElements()))).pipe(
80
+ // Add event operations
81
+ eventOperator);
82
+ // Create an observable that emits EOSE when all relays have sent EOSE
83
+ const eose = this.relays$.pipe(
84
+ // When the relays change, switch to a new observable
85
+ switchMap((relays) =>
86
+ // Subscribe to the events, and wait for EOSE from all relays
87
+ main.pipe(
88
+ // Only select EOSE messages
89
+ filter(([_, value]) => value === "EOSE"),
90
+ // Track the relays that have sent EOSE
91
+ scan((received, [relay]) => [...received, relay], []),
92
+ // Keep the observable open while there are relays that have not sent EOSE
93
+ takeWhile((received) => relays.some((r) => !received.includes(r))),
94
+ // Ignore all values
95
+ ignoreElements(),
23
96
  // When all relays have sent EOSE, emit EOSE
24
- endWith("EOSE"));
25
- return merge(events, eose);
97
+ endWith("EOSE"))));
98
+ return merge(events, eose).pipe(
99
+ // Ensure a single upstream
100
+ share());
101
+ }
102
+ /** Internal logic for handling publishes to multiple relays */
103
+ internalPublish(project) {
104
+ // Keep a cache of upstream observables for each relay
105
+ const upstream = new WeakMap();
106
+ // Subscribe to the group relays
107
+ return this.relays$.pipe(
108
+ // Take a snapshot of relays (no updates yet...)
109
+ take(1),
110
+ // Every time they change switch to a new observable
111
+ switchMap((relays) => {
112
+ const observables = [];
113
+ for (const relay of relays) {
114
+ // If an upstream observable exists for this relay, use it
115
+ if (upstream.has(relay)) {
116
+ observables.push(upstream.get(relay));
117
+ continue;
118
+ }
119
+ // Create a new upstream observable for this relay
120
+ const observable = project(relay).pipe(
121
+ // Catch error and return as PublishResponse
122
+ errorToPublishResponse(relay));
123
+ observables.push(observable);
124
+ upstream.set(relay, observable);
125
+ }
126
+ return merge(...observables);
127
+ }),
128
+ // Ensure a single upstream
129
+ share());
26
130
  }
27
131
  /**
28
132
  * Make a request to all relays
29
133
  * @note This does not deduplicate events
30
134
  */
31
- req(filters, id = nanoid(8)) {
32
- const requests = this.relays.map((relay) => relay.req(filters, id).pipe(
33
- // Ignore connection errors
34
- catchError(() => of("EOSE"))));
35
- // Merge events and the single EOSE stream
36
- return this.mergeEOSE(requests);
135
+ req(filters, id = nanoid(), opts) {
136
+ return this.internalSubscription((relay) => relay.req(filters, id), opts?.eventStore ? filterDuplicateEvents(opts?.eventStore) : identity);
37
137
  }
38
138
  /** Send an event to all relays */
39
139
  event(event) {
40
- return merge(...this.relays.map((relay) => relay.event(event).pipe(
41
- // Catch error and return as PublishResponse
42
- catchError((err) => of({ ok: false, from: relay.url, message: err?.message || "Unknown error" })))));
140
+ return this.internalPublish((relay) => relay.event(event));
43
141
  }
44
142
  /** Negentropy sync events with the relays and an event store */
45
143
  async negentropy(store, filter, reconcile, opts) {
@@ -57,25 +155,29 @@ export class RelayGroup {
57
155
  }
58
156
  /** Publish an event to all relays with retries ( default 3 retries ) */
59
157
  publish(event, opts) {
60
- return Promise.all(this.relays.map((relay) => relay.publish(event, opts).catch(
61
- // Catch error and return as PublishResponse
62
- (err) => ({ ok: false, from: relay.url, message: err?.message || "Unknown error" }))));
158
+ return lastValueFrom(this.internalPublish((relay) => from(relay.publish(event, opts))).pipe(toArray(), defaultIfEmpty([])));
63
159
  }
64
- /** Request events from all relays with retries ( default 3 retries ) */
160
+ /** Request events from all relays and complete on EOSE */
65
161
  request(filters, opts) {
66
- return merge(...this.relays.map((relay) => relay.request(filters, opts).pipe(
67
- // Ignore individual connection errors
68
- catchError(() => EMPTY)))).pipe(
162
+ return this.internalSubscription((relay) => relay.request(filters, opts).pipe(
163
+ // Simulate EOSE on completion
164
+ endWith("EOSE")),
69
165
  // If an event store is provided, filter duplicate events
70
- opts?.eventStore == null ? identity : filterDuplicateEvents(opts?.eventStore ?? new EventMemory()));
166
+ opts?.eventStore == null ? identity : filterDuplicateEvents(opts?.eventStore ?? new EventMemory())).pipe(
167
+ // Complete when all relays have sent EOSE
168
+ completeOnEose());
71
169
  }
72
170
  /** Open a subscription to all relays with retries ( default 3 retries ) */
73
171
  subscription(filters, opts) {
74
- return this.mergeEOSE(this.relays.map((relay) => relay.subscription(filters, opts).pipe(
75
- // Ignore individual connection errors
76
- catchError(() => EMPTY))),
77
- // Pass event store so that duplicate events are removed
78
- opts?.eventStore);
172
+ return this.internalSubscription((relay) => relay.subscription(filters, opts),
173
+ // If an event store is provided, filter duplicate events
174
+ opts?.eventStore == null ? identity : filterDuplicateEvents(opts?.eventStore ?? new EventMemory()));
175
+ }
176
+ /** Count events on all relays in the group */
177
+ count(filters, id = nanoid()) {
178
+ return this.relays$.pipe(switchMap((relays) => combineLatest(Object.fromEntries(relays.map((relay) => [relay.url, relay.count(filters, id)])))),
179
+ // Ensure a single upstream
180
+ share());
79
181
  }
80
182
  /** Negentropy sync events with the relays and an event store */
81
183
  sync(store, filter, direction) {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./group.js";
2
+ export * from "./liveness.js";
2
3
  export * from "./pool.js";
3
4
  export * from "./relay.js";
4
5
  export * from "./types.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./group.js";
2
+ export * from "./liveness.js";
2
3
  export * from "./pool.js";
3
4
  export * from "./relay.js";
4
5
  export * from "./types.js";
@@ -0,0 +1,123 @@
1
+ import { Observable } from "rxjs";
2
+ import { IPool } from "./types.js";
3
+ /** Relay health states for liveness tracking */
4
+ export type RelayHealthState = "online" | "offline" | "dead";
5
+ /** State information for a relay's health tracking */
6
+ export interface RelayState {
7
+ /** Current relay health state */
8
+ state: RelayHealthState;
9
+ /** Number of consecutive failures */
10
+ failureCount: number;
11
+ /** Timestamp of last failure */
12
+ lastFailureTime: number;
13
+ /** Timestamp of last success */
14
+ lastSuccessTime: number;
15
+ /** When the backoff period ends (timestamp) */
16
+ backoffUntil?: number;
17
+ }
18
+ /** Storage adapter interface for persisting relay liveness state */
19
+ export interface LivenessStorage {
20
+ /**
21
+ * Get an item from storage
22
+ * @param key The storage key
23
+ * @returns The stored value or null if not found
24
+ */
25
+ getItem(key: string): Promise<any> | any;
26
+ /**
27
+ * Set an item in storage
28
+ * @param key The storage key
29
+ * @param value The value to store
30
+ */
31
+ setItem(key: string, value: any): Promise<void> | void;
32
+ }
33
+ /** Configuration options for RelayLiveness */
34
+ export interface LivenessOptions {
35
+ /** Optional async storage adapter for persistence */
36
+ storage?: LivenessStorage;
37
+ /** Maximum failures before moving from offline to dead */
38
+ maxFailuresBeforeDead?: number;
39
+ /** Base delay for exponential backoff (ms) */
40
+ backoffBaseDelay?: number;
41
+ /** Maximum backoff delay (ms) */
42
+ backoffMaxDelay?: number;
43
+ }
44
+ /** Record and manage liveness reports for relays */
45
+ export declare class RelayLiveness {
46
+ private log;
47
+ private readonly options;
48
+ private readonly states$;
49
+ /** Relays that have been seen this session. this should be used when checking dead relays for liveness */
50
+ readonly seen: Set<string>;
51
+ /** Storage adapter for persistence */
52
+ readonly storage?: LivenessStorage;
53
+ /** An observable of all relays that are online */
54
+ online$: Observable<string[]>;
55
+ /** An observable of all relays that are offline */
56
+ offline$: Observable<string[]>;
57
+ /** An observable of all relays that are dead */
58
+ dead$: Observable<string[]>;
59
+ /** An observable of all relays that are online or not in backoff */
60
+ healthy$: Observable<string[]>;
61
+ /** An observable of all relays that are dead or in backoff */
62
+ unhealthy$: Observable<string[]>;
63
+ /** Relays that are known to be online */
64
+ get online(): string[];
65
+ /** Relays that are known to be offline */
66
+ get offline(): string[];
67
+ /** Relays that are known to be dead */
68
+ get dead(): string[];
69
+ /** Relays that are online or not in backoff */
70
+ get healthy(): string[];
71
+ /** Relays that are dead or in backoff */
72
+ get unhealthy(): string[];
73
+ /**
74
+ * Create a new RelayLiveness instance
75
+ * @param options Configuration options for the liveness tracker
76
+ */
77
+ constructor(options?: LivenessOptions);
78
+ /** Load relay states from storage */
79
+ load(): Promise<void>;
80
+ /** Save all known relays and their states to storage */
81
+ save(): Promise<void>;
82
+ /** Filter relay list, removing dead relays and relays in backoff */
83
+ filter(relays: string[]): string[];
84
+ /** Subscribe to a relays state */
85
+ state(relay: string): Observable<RelayState | undefined>;
86
+ /** Revive a dead relay with the max backoff delay */
87
+ revive(relay: string): void;
88
+ /** Get current relay health state for a relay */
89
+ getState(relay: string): RelayState | undefined;
90
+ /** Check if a relay is currently in backoff period */
91
+ isInBackoff(relay: string): boolean;
92
+ /** Get remaining backoff time for a relay (in ms) */
93
+ getBackoffRemaining(relay: string): number;
94
+ /** Calculate backoff delay based on failure count */
95
+ private calculateBackoffDelay;
96
+ /**
97
+ * Record a successful connection
98
+ * @param relay The relay URL that succeeded
99
+ */
100
+ recordSuccess(relay: string): void;
101
+ /**
102
+ * Record a failed connection
103
+ * @param relay The relay URL that failed
104
+ */
105
+ recordFailure(relay: string): void;
106
+ /**
107
+ * Get all seen relays (for debugging/monitoring)
108
+ */
109
+ getSeenRelays(): string[];
110
+ /**
111
+ * Reset state for one or all relays
112
+ * @param relay Optional specific relay URL to reset, or reset all if not provided
113
+ */
114
+ reset(relay?: string): void;
115
+ private connections;
116
+ /** Connect to a {@link RelayPool} instance and track relay connections */
117
+ connectToPool(pool: IPool): void;
118
+ /** Disconnect from a {@link RelayPool} instance */
119
+ disconnectFromPool(pool: IPool): void;
120
+ private updateRelayState;
121
+ private saveKnownRelays;
122
+ private saveRelayState;
123
+ }