applesauce-relay 3.1.0 → 4.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.
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,19 +1,49 @@
1
- import { type NostrEvent } from "nostr-tools";
1
+ import { Filter, type NostrEvent } from "nostr-tools";
2
2
  import { Observable } from "rxjs";
3
- import { FilterInput, IGroup, IRelay, PublishOptions, PublishResponse, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
3
+ import { IAsyncEventStoreActions, IAsyncEventStoreRead, IEventStoreActions, IEventStoreRead } from "applesauce-core";
4
+ import { NegentropySyncOptions, type ReconcileFunction } from "./negentropy.js";
5
+ import { CountResponse, FilterInput, IGroup, IRelay, PublishOptions, PublishResponse, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
6
+ import { SyncDirection } from "./relay.js";
7
+ /** Options for negentropy sync on a group of relays */
8
+ export type GroupNegentropySyncOptions = NegentropySyncOptions & {
9
+ /** Whether to sync in parallel (default true) */
10
+ parallel?: boolean;
11
+ };
12
+ /** Options for a subscription on a group of relays */
13
+ export type GroupSubscriptionOptions = SubscriptionOptions & {
14
+ /** Deduplicate events with an event store (default is a temporary instance of EventMemory), null will disable deduplication */
15
+ eventStore?: IEventStoreActions | IAsyncEventStoreActions | null;
16
+ };
17
+ /** Options for a request on a group of relays */
18
+ export type GroupRequestOptions = RequestOptions & {
19
+ /** Deduplicate events with an event store (default is a temporary instance of EventMemory), null will disable deduplication */
20
+ eventStore?: IEventStoreActions | IAsyncEventStoreActions | null;
21
+ };
4
22
  export declare class RelayGroup implements IGroup {
5
23
  relays: IRelay[];
6
24
  constructor(relays: IRelay[]);
7
25
  /** Takes an array of observables and only emits EOSE when all observables have emitted EOSE */
8
- protected mergeEOSE(...requests: Observable<SubscriptionResponse>[]): Observable<import("nostr-tools").Event | "EOSE">;
9
- /** Make a request to all relays */
10
- req(filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
26
+ protected mergeEOSE(requests: Observable<SubscriptionResponse>[], eventStore?: IEventStoreActions | IAsyncEventStoreActions | null): Observable<import("nostr-tools").Event | "EOSE">;
27
+ /**
28
+ * Make a request to all relays
29
+ * @note This does not deduplicate events
30
+ */
31
+ req(filters: FilterInput, id?: string, opts?: {
32
+ /** Deduplicate events with an event store (default is a temporary instance of EventMemory), null will disable deduplication */
33
+ eventStore?: IEventStoreActions | IAsyncEventStoreActions | null;
34
+ }): Observable<SubscriptionResponse>;
11
35
  /** Send an event to all relays */
12
36
  event(event: NostrEvent): Observable<PublishResponse>;
37
+ /** Negentropy sync events with the relays and an event store */
38
+ negentropy(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, reconcile: ReconcileFunction, opts?: GroupNegentropySyncOptions): Promise<boolean>;
13
39
  /** Publish an event to all relays with retries ( default 3 retries ) */
14
40
  publish(event: NostrEvent, opts?: PublishOptions): Promise<PublishResponse[]>;
15
41
  /** Request events from all relays with retries ( default 3 retries ) */
16
- request(filters: FilterInput, opts?: RequestOptions): Observable<NostrEvent>;
42
+ request(filters: FilterInput, opts?: GroupRequestOptions): Observable<NostrEvent>;
17
43
  /** Open a subscription to all relays with retries ( default 3 retries ) */
18
- subscription(filters: FilterInput, opts?: SubscriptionOptions): Observable<SubscriptionResponse>;
44
+ subscription(filters: FilterInput, opts?: GroupSubscriptionOptions): Observable<SubscriptionResponse>;
45
+ /** Count events on all relays in the group */
46
+ count(filters: Filter | Filter[], id?: string): Observable<Record<string, CountResponse>>;
47
+ /** Negentropy sync events with the relays and an event store */
48
+ sync(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, direction?: SyncDirection): Observable<NostrEvent>;
19
49
  }
package/dist/group.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { nanoid } from "nanoid";
2
- import { catchError, EMPTY, endWith, ignoreElements, merge, of } from "rxjs";
2
+ import { catchError, combineLatest, defer, EMPTY, endWith, identity, ignoreElements, merge, of, share, switchMap, } from "rxjs";
3
+ import { EventMemory, filterDuplicateEvents, } from "applesauce-core";
3
4
  import { completeOnEose } from "./operators/complete-on-eose.js";
4
5
  import { onlyEvents } from "./operators/only-events.js";
5
6
  export class RelayGroup {
@@ -8,9 +9,13 @@ export class RelayGroup {
8
9
  this.relays = relays;
9
10
  }
10
11
  /** Takes an array of observables and only emits EOSE when all observables have emitted EOSE */
11
- mergeEOSE(...requests) {
12
+ mergeEOSE(requests, eventStore = new EventMemory()) {
12
13
  // Create stream of events only
13
- const events = merge(...requests).pipe(onlyEvents());
14
+ const events = merge(...requests).pipe(
15
+ // Ignore non event responses
16
+ onlyEvents(),
17
+ // If an event store is provided, filter duplicate events
18
+ eventStore ? filterDuplicateEvents(eventStore) : identity);
14
19
  // Create stream that emits EOSE when all relays have sent EOSE
15
20
  const eose = merge(
16
21
  // Create a new map of requests that only emits EOSE
@@ -19,13 +24,16 @@ export class RelayGroup {
19
24
  endWith("EOSE"));
20
25
  return merge(events, eose);
21
26
  }
22
- /** Make a request to all relays */
23
- req(filters, id = nanoid(8)) {
27
+ /**
28
+ * Make a request to all relays
29
+ * @note This does not deduplicate events
30
+ */
31
+ req(filters, id = nanoid(), opts) {
24
32
  const requests = this.relays.map((relay) => relay.req(filters, id).pipe(
25
33
  // Ignore connection errors
26
34
  catchError(() => of("EOSE"))));
27
35
  // Merge events and the single EOSE stream
28
- return this.mergeEOSE(...requests);
36
+ return this.mergeEOSE(requests, opts?.eventStore);
29
37
  }
30
38
  /** Send an event to all relays */
31
39
  event(event) {
@@ -33,6 +41,20 @@ export class RelayGroup {
33
41
  // Catch error and return as PublishResponse
34
42
  catchError((err) => of({ ok: false, from: relay.url, message: err?.message || "Unknown error" })))));
35
43
  }
44
+ /** Negentropy sync events with the relays and an event store */
45
+ async negentropy(store, filter, reconcile, opts) {
46
+ // Filter out relays that do not support NIP-77 negentropy sync
47
+ const supported = await Promise.all(this.relays.map(async (relay) => [relay, await relay.getSupported()]));
48
+ const relays = supported.filter(([_, supported]) => supported?.includes(77)).map(([relay]) => relay);
49
+ if (relays.length === 0)
50
+ throw new Error("No relays support NIP-77 negentropy sync");
51
+ // Non parallel sync is not supported yet
52
+ if (!opts?.parallel)
53
+ throw new Error("Negentropy sync must be parallel (for now)");
54
+ // Sync all the relays in parallel
55
+ await Promise.allSettled(relays.map((relay) => relay.negentropy(store, filter, reconcile, opts)));
56
+ return true;
57
+ }
36
58
  /** Publish an event to all relays with retries ( default 3 retries ) */
37
59
  publish(event, opts) {
38
60
  return Promise.all(this.relays.map((relay) => relay.publish(event, opts).catch(
@@ -43,12 +65,35 @@ export class RelayGroup {
43
65
  request(filters, opts) {
44
66
  return merge(...this.relays.map((relay) => relay.request(filters, opts).pipe(
45
67
  // Ignore individual connection errors
46
- catchError(() => EMPTY))));
68
+ catchError(() => EMPTY)))).pipe(
69
+ // If an event store is provided, filter duplicate events
70
+ opts?.eventStore == null ? identity : filterDuplicateEvents(opts?.eventStore ?? new EventMemory()));
47
71
  }
48
72
  /** Open a subscription to all relays with retries ( default 3 retries ) */
49
73
  subscription(filters, opts) {
50
- return this.mergeEOSE(...this.relays.map((relay) => relay.subscription(filters, opts).pipe(
74
+ return this.mergeEOSE(this.relays.map((relay) => relay.subscription(filters, opts).pipe(
51
75
  // Ignore individual connection errors
52
- catchError(() => EMPTY))));
76
+ catchError(() => EMPTY))),
77
+ // Pass event store so that duplicate events are removed
78
+ opts?.eventStore);
79
+ }
80
+ /** Count events on all relays in the group */
81
+ count(filters, id = nanoid()) {
82
+ return combineLatest(Object.fromEntries(this.relays.map((relay) => [relay.url, relay.count(filters, id)])));
83
+ }
84
+ /** Negentropy sync events with the relays and an event store */
85
+ sync(store, filter, direction) {
86
+ // Get an array of relays that support NIP-77 negentropy sync
87
+ return defer(async () => {
88
+ const supported = await Promise.all(this.relays.map(async (relay) => [relay, await relay.getSupported()]));
89
+ const relays = supported.filter(([_, supported]) => supported?.includes(77)).map(([relay]) => relay);
90
+ if (relays.length === 0)
91
+ throw new Error("No relays support NIP-77 negentropy sync");
92
+ return relays;
93
+ }).pipe(
94
+ // Once relays are selected, sync all the relays in parallel
95
+ switchMap((relays) => merge(...relays.map((relay) => relay.sync(store, filter, direction)))),
96
+ // Only create one upstream subscription
97
+ share());
53
98
  }
54
99
  }
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";
@@ -38,7 +38,7 @@ declare class Negentropy {
38
38
  constructor(storage: NegentropyStorageVector, frameSizeLimit?: number);
39
39
  _bound(timestamp: number, id?: Uint8Array): {
40
40
  timestamp: number;
41
- id: Uint8Array<ArrayBuffer>;
41
+ id: Uint8Array<ArrayBufferLike>;
42
42
  };
43
43
  initiate<T extends Uint8Array | string>(): Promise<T>;
44
44
  setInitiator(): void;
@@ -55,7 +55,7 @@ declare class Negentropy {
55
55
  encodeBound(key: Item): WrappedBuffer;
56
56
  getMinimalBound(prev: Item, curr: Item): {
57
57
  timestamp: number;
58
- id: Uint8Array<ArrayBuffer>;
58
+ id: Uint8Array<ArrayBufferLike>;
59
59
  };
60
60
  }
61
61
  export { Negentropy, NegentropyStorageVector };
@@ -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
+ }
@@ -0,0 +1,327 @@
1
+ import { logger } from "applesauce-core";
2
+ import { BehaviorSubject, map } from "rxjs";
3
+ /** Record and manage liveness reports for relays */
4
+ export class RelayLiveness {
5
+ log = logger.extend("RelayLiveness");
6
+ options;
7
+ states$ = new BehaviorSubject({});
8
+ /** Relays that have been seen this session. this should be used when checking dead relays for liveness */
9
+ seen = new Set();
10
+ /** Storage adapter for persistence */
11
+ storage;
12
+ /** An observable of all relays that are online */
13
+ online$;
14
+ /** An observable of all relays that are offline */
15
+ offline$;
16
+ /** An observable of all relays that are dead */
17
+ dead$;
18
+ /** An observable of all relays that are online or not in backoff */
19
+ healthy$;
20
+ /** An observable of all relays that are dead or in backoff */
21
+ unhealthy$;
22
+ /** Relays that are known to be online */
23
+ get online() {
24
+ return Object.keys(this.states$.value).filter((relay) => this.states$.value[relay].state === "online");
25
+ }
26
+ /** Relays that are known to be offline */
27
+ get offline() {
28
+ return Object.keys(this.states$.value).filter((relay) => this.states$.value[relay].state === "offline");
29
+ }
30
+ /** Relays that are known to be dead */
31
+ get dead() {
32
+ return Object.keys(this.states$.value).filter((relay) => this.states$.value[relay].state === "dead");
33
+ }
34
+ /** Relays that are online or not in backoff */
35
+ get healthy() {
36
+ return Object.keys(this.states$.value).filter((relay) => {
37
+ const state = this.states$.value[relay];
38
+ return state.state === "online" || (state.state === "offline" && !this.isInBackoff(relay));
39
+ });
40
+ }
41
+ /** Relays that are dead or in backoff */
42
+ get unhealthy() {
43
+ return Object.keys(this.states$.value).filter((relay) => {
44
+ const state = this.states$.value[relay];
45
+ return state.state === "dead" || (state.state === "offline" && this.isInBackoff(relay));
46
+ });
47
+ }
48
+ /**
49
+ * Create a new RelayLiveness instance
50
+ * @param options Configuration options for the liveness tracker
51
+ */
52
+ constructor(options = {}) {
53
+ this.options = {
54
+ maxFailuresBeforeDead: options.maxFailuresBeforeDead ?? 5,
55
+ backoffBaseDelay: options.backoffBaseDelay ?? 30 * 1000, // 30 seconds
56
+ backoffMaxDelay: options.backoffMaxDelay ?? 5 * 60 * 1000, // 5 minutes
57
+ };
58
+ this.storage = options.storage;
59
+ // Create observable interfaces
60
+ this.online$ = this.states$.pipe(map((states) => Object.keys(states).filter((relay) => states[relay].state === "online")));
61
+ this.offline$ = this.states$.pipe(map((states) => Object.keys(states).filter((relay) => states[relay].state === "offline")));
62
+ this.dead$ = this.states$.pipe(map((states) => Object.keys(states).filter((relay) => states[relay].state === "dead")));
63
+ this.healthy$ = this.states$.pipe(map((states) => Object.keys(states).filter((relay) => {
64
+ const state = states[relay];
65
+ return state.state === "online" || (state.state === "offline" && !this.isInBackoff(relay));
66
+ })));
67
+ this.unhealthy$ = this.states$.pipe(map((states) => Object.keys(states).filter((relay) => {
68
+ const state = states[relay];
69
+ return state.state === "dead" || (state.state === "offline" && this.isInBackoff(relay));
70
+ })));
71
+ }
72
+ /** Load relay states from storage */
73
+ async load() {
74
+ if (!this.storage)
75
+ return;
76
+ const known = await this.storage.getItem("known");
77
+ if (!Array.isArray(known))
78
+ return;
79
+ this.log(`Loading states for ${known.length} known relays`);
80
+ const states = {};
81
+ for (const relay of known) {
82
+ try {
83
+ const state = await this.storage.getItem(relay);
84
+ if (state)
85
+ states[relay] = state;
86
+ }
87
+ catch (error) {
88
+ // Ignore relay loading errors
89
+ }
90
+ }
91
+ this.states$.next(states);
92
+ }
93
+ /** Save all known relays and their states to storage */
94
+ async save() {
95
+ await this.saveKnownRelays();
96
+ await Promise.all(Object.entries(this.states$.value).map(([relay, state]) => this.saveRelayState(relay, state)));
97
+ this.log("Relay states saved to storage");
98
+ }
99
+ /** Filter relay list, removing dead relays and relays in backoff */
100
+ filter(relays) {
101
+ const results = [];
102
+ for (const relay of relays) {
103
+ // Track that this relay has been seen
104
+ this.seen.add(relay);
105
+ const state = this.getState(relay);
106
+ // Filter based on state and backoff
107
+ switch (state?.state) {
108
+ case undefined: // unknown state
109
+ case "online":
110
+ results.push(relay);
111
+ break;
112
+ case "offline":
113
+ // Only include if not in backoff
114
+ if (!this.isInBackoff(relay))
115
+ results.push(relay);
116
+ break;
117
+ case "dead":
118
+ default:
119
+ // Don't include dead relays
120
+ break;
121
+ }
122
+ }
123
+ return results;
124
+ }
125
+ /** Subscribe to a relays state */
126
+ state(relay) {
127
+ return this.states$.pipe(map((states) => states[relay]));
128
+ }
129
+ /** Revive a dead relay with the max backoff delay */
130
+ revive(relay) {
131
+ const state = this.getState(relay);
132
+ if (!state)
133
+ return;
134
+ this.updateRelayState(relay, {
135
+ state: "offline",
136
+ failureCount: 0,
137
+ lastFailureTime: 0,
138
+ lastSuccessTime: Date.now(),
139
+ backoffUntil: this.options.backoffMaxDelay,
140
+ });
141
+ this.log(`Relay ${relay} revived to offline state with max backoff delay`);
142
+ }
143
+ /** Get current relay health state for a relay */
144
+ getState(relay) {
145
+ return this.states$.value[relay];
146
+ }
147
+ /** Check if a relay is currently in backoff period */
148
+ isInBackoff(relay) {
149
+ const state = this.getState(relay);
150
+ if (!state?.backoffUntil)
151
+ return false;
152
+ return Date.now() < state.backoffUntil;
153
+ }
154
+ /** Get remaining backoff time for a relay (in ms) */
155
+ getBackoffRemaining(relay) {
156
+ const state = this.getState(relay);
157
+ if (!state?.backoffUntil)
158
+ return 0;
159
+ return Math.max(0, state.backoffUntil - Date.now());
160
+ }
161
+ /** Calculate backoff delay based on failure count */
162
+ calculateBackoffDelay(failureCount) {
163
+ const delay = this.options.backoffBaseDelay * Math.pow(2, failureCount - 1);
164
+ return Math.min(delay, this.options.backoffMaxDelay);
165
+ }
166
+ /**
167
+ * Record a successful connection
168
+ * @param relay The relay URL that succeeded
169
+ */
170
+ recordSuccess(relay) {
171
+ const now = Date.now();
172
+ const state = this.getState(relay);
173
+ // Don't update dead relays
174
+ if (state?.state === "dead") {
175
+ this.log(`Ignoring success for dead relay ${relay}`);
176
+ return;
177
+ }
178
+ // Record new relays
179
+ if (state === undefined) {
180
+ this.seen.add(relay);
181
+ this.saveKnownRelays();
182
+ }
183
+ // TODO: resetting the state back to online might be too aggressive?
184
+ const newState = {
185
+ state: "online",
186
+ failureCount: 0,
187
+ lastFailureTime: 0,
188
+ lastSuccessTime: now,
189
+ };
190
+ this.updateRelayState(relay, newState);
191
+ // Log transition if it's not the first time we've seen the relay
192
+ if (state && state.state !== newState.state)
193
+ this.log(`Relay ${relay} transitioned ${state?.state} -> online`);
194
+ }
195
+ /**
196
+ * Record a failed connection
197
+ * @param relay The relay URL that failed
198
+ */
199
+ recordFailure(relay) {
200
+ const state = this.getState(relay);
201
+ // Don't update dead relays
202
+ if (state?.state === "dead")
203
+ return;
204
+ // Ignore failures during backoff, this should help catch double reporting of failures
205
+ if (this.isInBackoff(relay))
206
+ return;
207
+ const now = Date.now();
208
+ const failureCount = (state?.failureCount || 0) + 1;
209
+ // Record new relays
210
+ if (state === undefined) {
211
+ this.seen.add(relay);
212
+ this.saveKnownRelays();
213
+ }
214
+ // Calculate backoff delay
215
+ const backoffDelay = this.calculateBackoffDelay(failureCount);
216
+ const newState = failureCount >= this.options.maxFailuresBeforeDead ? "dead" : "offline";
217
+ const relayState = {
218
+ state: newState,
219
+ failureCount,
220
+ lastFailureTime: now,
221
+ lastSuccessTime: state?.lastSuccessTime || 0,
222
+ backoffUntil: now + backoffDelay,
223
+ };
224
+ this.updateRelayState(relay, relayState);
225
+ // Log transition if it's not the first time we've seen the relay
226
+ if (newState !== state?.state)
227
+ this.log(`Relay ${relay} transitioned ${state?.state} -> ${newState}`);
228
+ // Set a timeout that will clear the backoff period
229
+ setTimeout(() => {
230
+ const state = this.getState(relay);
231
+ if (!state || state.backoffUntil === undefined)
232
+ return;
233
+ this.updateRelayState(relay, { ...state, backoffUntil: undefined });
234
+ }, backoffDelay);
235
+ }
236
+ /**
237
+ * Get all seen relays (for debugging/monitoring)
238
+ */
239
+ getSeenRelays() {
240
+ return Array.from(this.seen);
241
+ }
242
+ /**
243
+ * Reset state for one or all relays
244
+ * @param relay Optional specific relay URL to reset, or reset all if not provided
245
+ */
246
+ reset(relay) {
247
+ if (relay) {
248
+ const newStates = { ...this.states$.value };
249
+ delete newStates[relay];
250
+ this.states$.next(newStates);
251
+ this.seen.delete(relay);
252
+ }
253
+ else {
254
+ // Reset all relays
255
+ this.states$.next({});
256
+ this.seen.clear();
257
+ }
258
+ }
259
+ // The connected pools and cleanup methods
260
+ connections = new Map();
261
+ /** Connect to a {@link RelayPool} instance and track relay connections */
262
+ connectToPool(pool) {
263
+ // Relay cleanup methods
264
+ const relays = new Map();
265
+ // Listen for relays being added
266
+ const add = pool.add$.subscribe((relay) => {
267
+ // Record seen relays
268
+ this.seen.add(relay.url);
269
+ const open = relay.open$.subscribe(() => {
270
+ this.recordSuccess(relay.url);
271
+ });
272
+ const close = relay.close$.subscribe((event) => {
273
+ if (event.wasClean === false)
274
+ this.recordFailure(relay.url);
275
+ });
276
+ // Register the cleanup method
277
+ relays.set(relay, () => {
278
+ open.unsubscribe();
279
+ close.unsubscribe();
280
+ });
281
+ });
282
+ // Listen for relays being removed
283
+ const remove = pool.remove$.subscribe((relay) => {
284
+ const cleanup = relays.get(relay);
285
+ if (cleanup)
286
+ cleanup();
287
+ relays.delete(relay);
288
+ });
289
+ // register the cleanup method
290
+ this.connections.set(pool, () => {
291
+ add.unsubscribe();
292
+ remove.unsubscribe();
293
+ });
294
+ }
295
+ /** Disconnect from a {@link RelayPool} instance */
296
+ disconnectFromPool(pool) {
297
+ const cleanup = this.connections.get(pool);
298
+ if (cleanup)
299
+ cleanup();
300
+ this.connections.delete(pool);
301
+ }
302
+ updateRelayState(relay, state) {
303
+ this.states$.next({ ...this.states$.value, [relay]: state });
304
+ // Auto-save to storage
305
+ this.saveRelayState(relay, state);
306
+ }
307
+ async saveKnownRelays() {
308
+ if (!this.storage)
309
+ return;
310
+ try {
311
+ await this.storage.setItem("known", Object.keys(this.states$.value));
312
+ }
313
+ catch (error) {
314
+ // Ignore storage errors
315
+ }
316
+ }
317
+ async saveRelayState(relay, state) {
318
+ if (!this.storage)
319
+ return;
320
+ try {
321
+ await this.storage.setItem(relay, state);
322
+ }
323
+ catch (error) {
324
+ // Ignore storage errors
325
+ }
326
+ }
327
+ }