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 +1 -1
- package/dist/group.d.ts +3 -1
- package/dist/group.js +20 -1
- package/dist/pool.d.ts +3 -1
- package/dist/pool.js +16 -1
- package/dist/relay.d.ts +40 -2
- package/dist/relay.js +140 -4
- package/dist/types.d.ts +26 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ npm install applesauce-relay
|
|
|
22
22
|
|
|
23
23
|
## Examples
|
|
24
24
|
|
|
25
|
-
Read the [documentation](https://
|
|
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) =>
|
|
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
|
|
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) =>
|
|
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(() =>
|
|
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-
|
|
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-
|
|
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": "
|
|
67
|
+
"applesauce-signers": "^5.0.0",
|
|
68
68
|
"rimraf": "^6.0.1",
|
|
69
69
|
"typescript": "^5.7.3",
|
|
70
70
|
"vitest": "^4.0.15",
|