applesauce-relay 0.0.0-next-20251015123951 → 0.0.0-next-20251027164028
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 +2 -3
- package/dist/group.d.ts +20 -7
- package/dist/group.js +140 -38
- package/dist/liveness.d.ts +1 -3
- package/dist/negentropy.js +41 -8
- package/dist/operators/index.d.ts +2 -1
- package/dist/operators/index.js +2 -1
- package/dist/operators/reverse-switch-map.d.ts +9 -0
- package/dist/operators/reverse-switch-map.js +46 -0
- package/dist/pool.d.ts +16 -14
- package/dist/pool.js +33 -38
- package/dist/relay.d.ts +7 -3
- package/dist/relay.js +78 -19
- package/dist/types.d.ts +32 -15
- package/package.json +3 -3
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
|
-
- [
|
|
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,10 +20,21 @@ export type GroupRequestOptions = RequestOptions & {
|
|
|
20
20
|
eventStore?: IEventStoreActions | IAsyncEventStoreActions | null;
|
|
21
21
|
};
|
|
22
22
|
export declare class RelayGroup implements IGroup {
|
|
23
|
-
relays
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
@@ -38,10 +49,12 @@ export declare class RelayGroup implements IGroup {
|
|
|
38
49
|
negentropy(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, reconcile: ReconcileFunction, opts?: GroupNegentropySyncOptions): Promise<boolean>;
|
|
39
50
|
/** Publish an event to all relays with retries ( default 3 retries ) */
|
|
40
51
|
publish(event: NostrEvent, opts?: PublishOptions): Promise<PublishResponse[]>;
|
|
41
|
-
/** Request events from all relays
|
|
52
|
+
/** Request events from all relays and complete on EOSE */
|
|
42
53
|
request(filters: FilterInput, opts?: GroupRequestOptions): Observable<NostrEvent>;
|
|
43
54
|
/** Open a subscription to all relays with retries ( default 3 retries ) */
|
|
44
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>>;
|
|
45
58
|
/** Negentropy sync events with the relays and an event store */
|
|
46
59
|
sync(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, direction?: SyncDirection): Observable<NostrEvent>;
|
|
47
60
|
}
|
package/dist/group.js
CHANGED
|
@@ -1,45 +1,143 @@
|
|
|
1
1
|
import { nanoid } from "nanoid";
|
|
2
|
-
import { catchError,
|
|
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
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
//
|
|
18
|
-
|
|
19
|
-
// Create
|
|
20
|
-
const eose =
|
|
21
|
-
//
|
|
22
|
-
|
|
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(
|
|
32
|
-
|
|
33
|
-
// Ignore connection errors
|
|
34
|
-
catchError(() => of("EOSE"))));
|
|
35
|
-
// Merge events and the single EOSE stream
|
|
36
|
-
return this.mergeEOSE(requests, opts?.eventStore);
|
|
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
|
|
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
|
|
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
|
|
160
|
+
/** Request events from all relays and complete on EOSE */
|
|
65
161
|
request(filters, opts) {
|
|
66
|
-
return
|
|
67
|
-
//
|
|
68
|
-
|
|
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.
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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/liveness.d.ts
CHANGED
|
@@ -2,9 +2,7 @@ import { Observable } from "rxjs";
|
|
|
2
2
|
import { IPool } from "./types.js";
|
|
3
3
|
/** Relay health states for liveness tracking */
|
|
4
4
|
export type RelayHealthState = "online" | "offline" | "dead";
|
|
5
|
-
/**
|
|
6
|
-
* State information for a relay's health tracking
|
|
7
|
-
*/
|
|
5
|
+
/** State information for a relay's health tracking */
|
|
8
6
|
export interface RelayState {
|
|
9
7
|
/** Current relay health state */
|
|
10
8
|
state: RelayHealthState;
|
package/dist/negentropy.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { logger } from "applesauce-core";
|
|
2
|
-
import { map, share, firstValueFrom } from "rxjs";
|
|
2
|
+
import { map, share, firstValueFrom, race, Observable } from "rxjs";
|
|
3
3
|
import { nanoid } from "nanoid";
|
|
4
4
|
import { Negentropy, NegentropyStorageVector } from "./lib/negentropy.js";
|
|
5
5
|
const log = logger.extend("negentropy");
|
|
@@ -52,16 +52,49 @@ export async function negentropySync(storage, socket, filter, reconcile, opts) {
|
|
|
52
52
|
throw new Error(msg[2]);
|
|
53
53
|
return msg[2];
|
|
54
54
|
}), share());
|
|
55
|
+
// Check if already aborted before starting sync
|
|
56
|
+
if (opts?.signal?.aborted)
|
|
57
|
+
return false;
|
|
58
|
+
// Create an observable that emits when abort signal is triggered
|
|
59
|
+
const abortSignal$ = new Observable((observer) => {
|
|
60
|
+
if (opts?.signal?.aborted) {
|
|
61
|
+
observer.next("abort");
|
|
62
|
+
observer.complete();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const onAbort = () => {
|
|
66
|
+
observer.next("abort");
|
|
67
|
+
observer.complete();
|
|
68
|
+
};
|
|
69
|
+
opts?.signal?.addEventListener("abort", onAbort);
|
|
70
|
+
return () => opts?.signal?.removeEventListener("abort", onAbort);
|
|
71
|
+
});
|
|
55
72
|
// keep an additional subscription open while waiting for async operations
|
|
56
|
-
const sub = incoming.subscribe(
|
|
73
|
+
const sub = incoming.subscribe({
|
|
74
|
+
next: (m) => log(m),
|
|
75
|
+
error: () => { }, // Ignore errors here, they'll be caught by firstValueFrom
|
|
76
|
+
});
|
|
57
77
|
try {
|
|
58
78
|
while (msg && opts?.signal?.aborted !== true) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
79
|
+
// Race between incoming message and abort signal
|
|
80
|
+
try {
|
|
81
|
+
const received = await firstValueFrom(race(incoming.pipe(map((m) => ({ type: "message", data: m }))), abortSignal$.pipe(map(() => ({ type: "abort" })))));
|
|
82
|
+
if (received.type === "abort" || opts?.signal?.aborted) {
|
|
83
|
+
sub.unsubscribe();
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const [newMsg, have, need] = await ne.reconcile(received.data);
|
|
87
|
+
await reconcile(have, need);
|
|
88
|
+
msg = newMsg;
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
// Check if aborted during reconcile or message processing
|
|
92
|
+
if (opts?.signal?.aborted) {
|
|
93
|
+
sub.unsubscribe();
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
65
98
|
}
|
|
66
99
|
}
|
|
67
100
|
catch (err) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from "./complete-on-eose.js";
|
|
2
|
+
export * from "./liveness.js";
|
|
2
3
|
export * from "./mark-from-relay.js";
|
|
3
4
|
export * from "./only-events.js";
|
|
5
|
+
export * from "./reverse-switch-map.js";
|
|
4
6
|
export * from "./store-events.js";
|
|
5
7
|
export * from "./to-event-store.js";
|
|
6
|
-
export * from "./liveness.js";
|
package/dist/operators/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from "./complete-on-eose.js";
|
|
2
|
+
export * from "./liveness.js";
|
|
2
3
|
export * from "./mark-from-relay.js";
|
|
3
4
|
export * from "./only-events.js";
|
|
5
|
+
export * from "./reverse-switch-map.js";
|
|
4
6
|
export * from "./store-events.js";
|
|
5
7
|
export * from "./to-event-store.js";
|
|
6
|
-
export * from "./liveness.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { OperatorFunction, ObservableInput, ObservedValueOf } from "rxjs";
|
|
2
|
+
/**
|
|
3
|
+
* Like switchMap, but subscribes to the new observable before unsubscribing from the old one.
|
|
4
|
+
* This prevents gaps in subscription coverage.
|
|
5
|
+
*
|
|
6
|
+
* @param project A function that, when applied to an item emitted by the source Observable,
|
|
7
|
+
* returns an Observable.
|
|
8
|
+
*/
|
|
9
|
+
export declare function reverseSwitchMap<T, O extends ObservableInput<any>>(project: (value: T, index: number) => O): OperatorFunction<T, ObservedValueOf<O>>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { from } from "rxjs";
|
|
2
|
+
import { createOperatorSubscriber } from "rxjs/internal/operators/OperatorSubscriber";
|
|
3
|
+
import { operate } from "rxjs/internal/util/lift";
|
|
4
|
+
/**
|
|
5
|
+
* Like switchMap, but subscribes to the new observable before unsubscribing from the old one.
|
|
6
|
+
* This prevents gaps in subscription coverage.
|
|
7
|
+
*
|
|
8
|
+
* @param project A function that, when applied to an item emitted by the source Observable,
|
|
9
|
+
* returns an Observable.
|
|
10
|
+
*/
|
|
11
|
+
export function reverseSwitchMap(project) {
|
|
12
|
+
return operate((source, subscriber) => {
|
|
13
|
+
let innerSubscriber = null;
|
|
14
|
+
let index = 0;
|
|
15
|
+
// Whether or not the source subscription has completed
|
|
16
|
+
let isComplete = false;
|
|
17
|
+
// We only complete the result if the source is complete AND we don't have an active inner subscription.
|
|
18
|
+
// This is called both when the source completes and when the inners complete.
|
|
19
|
+
const checkComplete = () => {
|
|
20
|
+
if (isComplete && !innerSubscriber)
|
|
21
|
+
subscriber.complete();
|
|
22
|
+
};
|
|
23
|
+
source.subscribe(createOperatorSubscriber(subscriber, (value) => {
|
|
24
|
+
const outerIndex = index++;
|
|
25
|
+
const oldSubscriber = innerSubscriber;
|
|
26
|
+
// Create the new inner subscription FIRST
|
|
27
|
+
// Immediately assign the new subscriber because observables can emit and complete synchronously
|
|
28
|
+
const self = (innerSubscriber = createOperatorSubscriber(subscriber, (innerValue) => subscriber.next(innerValue), () => {
|
|
29
|
+
// The inner has completed. Null out the inner subscriber to
|
|
30
|
+
// free up memory and to signal that we have no inner subscription
|
|
31
|
+
// currently. Only do this if this is still the active inner subscriber.
|
|
32
|
+
if (innerSubscriber === self || innerSubscriber === null) {
|
|
33
|
+
innerSubscriber = null;
|
|
34
|
+
checkComplete();
|
|
35
|
+
}
|
|
36
|
+
}));
|
|
37
|
+
// Subscribe to the new observable FIRST
|
|
38
|
+
from(project(value, outerIndex)).subscribe(innerSubscriber);
|
|
39
|
+
// THEN unsubscribe from the previous inner subscription
|
|
40
|
+
oldSubscriber?.unsubscribe();
|
|
41
|
+
}, () => {
|
|
42
|
+
isComplete = true;
|
|
43
|
+
checkComplete();
|
|
44
|
+
}));
|
|
45
|
+
});
|
|
46
|
+
}
|
package/dist/pool.d.ts
CHANGED
|
@@ -1,42 +1,44 @@
|
|
|
1
1
|
import type { IAsyncEventStoreRead, IEventStoreRead } from "applesauce-core";
|
|
2
|
+
import { FilterMap, OutboxMap } from "applesauce-core/helpers";
|
|
2
3
|
import { Filter, type NostrEvent } from "nostr-tools";
|
|
3
4
|
import { BehaviorSubject, Observable, Subject } from "rxjs";
|
|
4
5
|
import { RelayGroup } from "./group.js";
|
|
5
6
|
import type { NegentropySyncOptions, ReconcileFunction } from "./negentropy.js";
|
|
6
7
|
import { Relay, SyncDirection, type RelayOptions } from "./relay.js";
|
|
7
|
-
import type { FilterInput, IPool, IRelay, PublishResponse, SubscriptionResponse } from "./types.js";
|
|
8
|
+
import type { CountResponse, FilterInput, IPool, IPoolRelayInput, IRelay, PublishResponse, SubscriptionResponse } from "./types.js";
|
|
8
9
|
export declare class RelayPool implements IPool {
|
|
9
10
|
options?: RelayOptions | undefined;
|
|
10
|
-
groups$: BehaviorSubject<Map<string, RelayGroup>>;
|
|
11
|
-
get groups(): Map<string, RelayGroup>;
|
|
12
11
|
relays$: BehaviorSubject<Map<string, Relay>>;
|
|
13
12
|
get relays(): Map<string, Relay>;
|
|
14
13
|
/** A signal when a relay is added */
|
|
15
14
|
add$: Subject<IRelay>;
|
|
16
15
|
/** A signal when a relay is removed */
|
|
17
16
|
remove$: Subject<IRelay>;
|
|
18
|
-
/** An array of relays to never connect to */
|
|
19
|
-
blacklist: Set<string>;
|
|
20
17
|
constructor(options?: RelayOptions | undefined);
|
|
21
|
-
protected filterBlacklist(urls: string[]): string[];
|
|
22
18
|
/** Get or create a new relay connection */
|
|
23
19
|
relay(url: string): Relay;
|
|
24
20
|
/** Create a group of relays */
|
|
25
|
-
group(relays:
|
|
21
|
+
group(relays: IPoolRelayInput): RelayGroup;
|
|
26
22
|
/** Removes a relay from the pool and defaults to closing the connection */
|
|
27
23
|
remove(relay: string | IRelay, close?: boolean): void;
|
|
28
24
|
/** Make a REQ to multiple relays that does not deduplicate events */
|
|
29
|
-
req(relays:
|
|
25
|
+
req(relays: IPoolRelayInput, filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
|
|
30
26
|
/** Send an EVENT message to multiple relays */
|
|
31
|
-
event(relays:
|
|
27
|
+
event(relays: IPoolRelayInput, event: NostrEvent): Observable<PublishResponse>;
|
|
32
28
|
/** Negentropy sync event ids with the relays and an event store */
|
|
33
|
-
negentropy(relays:
|
|
29
|
+
negentropy(relays: IPoolRelayInput, store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, reconcile: ReconcileFunction, opts?: NegentropySyncOptions): Promise<boolean>;
|
|
34
30
|
/** Publish an event to multiple relays */
|
|
35
|
-
publish(relays:
|
|
31
|
+
publish(relays: IPoolRelayInput, event: Parameters<RelayGroup["publish"]>[0], opts?: Parameters<RelayGroup["publish"]>[1]): Promise<PublishResponse[]>;
|
|
36
32
|
/** Request events from multiple relays */
|
|
37
|
-
request(relays:
|
|
33
|
+
request(relays: IPoolRelayInput, filters: FilterInput, opts?: Parameters<RelayGroup["request"]>[1]): Observable<NostrEvent>;
|
|
38
34
|
/** Open a subscription to multiple relays */
|
|
39
|
-
subscription(relays:
|
|
35
|
+
subscription(relays: IPoolRelayInput, filters: FilterInput, options?: Parameters<RelayGroup["subscription"]>[1]): Observable<SubscriptionResponse>;
|
|
36
|
+
/** Open a subscription for a map of relays and filters */
|
|
37
|
+
subscriptionMap(relays: FilterMap | Observable<FilterMap>, options?: Parameters<RelayGroup["subscription"]>[1]): Observable<SubscriptionResponse>;
|
|
38
|
+
/** Open a subscription for an {@link OutboxMap} and filter */
|
|
39
|
+
outboxSubscription(outboxes: OutboxMap | Observable<OutboxMap>, filter: Omit<Filter, "authors">, options?: Parameters<RelayGroup["subscription"]>[1]): Observable<SubscriptionResponse>;
|
|
40
|
+
/** Count events on multiple relays */
|
|
41
|
+
count(relays: IPoolRelayInput, filters: Filter | Filter[], id?: string): Observable<Record<string, CountResponse>>;
|
|
40
42
|
/** Negentropy sync events with the relays and an event store */
|
|
41
|
-
sync(relays:
|
|
43
|
+
sync(relays: IPoolRelayInput, store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, direction?: SyncDirection): Observable<NostrEvent>;
|
|
42
44
|
}
|
package/dist/pool.js
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import { normalizeURL } from "applesauce-core/helpers";
|
|
2
|
-
import { BehaviorSubject, Subject } from "rxjs";
|
|
1
|
+
import { createFilterMap, isFilterEqual, normalizeURL } from "applesauce-core/helpers";
|
|
2
|
+
import { BehaviorSubject, distinctUntilChanged, isObservable, map, of, Subject } from "rxjs";
|
|
3
3
|
import { RelayGroup } from "./group.js";
|
|
4
4
|
import { Relay } from "./relay.js";
|
|
5
5
|
export class RelayPool {
|
|
6
6
|
options;
|
|
7
|
-
groups$ = new BehaviorSubject(new Map());
|
|
8
|
-
get groups() {
|
|
9
|
-
return this.groups$.value;
|
|
10
|
-
}
|
|
11
7
|
relays$ = new BehaviorSubject(new Map());
|
|
12
8
|
get relays() {
|
|
13
9
|
return this.relays$.value;
|
|
@@ -16,34 +12,13 @@ export class RelayPool {
|
|
|
16
12
|
add$ = new Subject();
|
|
17
13
|
/** A signal when a relay is removed */
|
|
18
14
|
remove$ = new Subject();
|
|
19
|
-
/** An array of relays to never connect to */
|
|
20
|
-
blacklist = new Set();
|
|
21
15
|
constructor(options) {
|
|
22
16
|
this.options = options;
|
|
23
|
-
// Listen for relays being added and removed to emit connect / disconnect signals
|
|
24
|
-
// const listeners = new Map<IRelay, Subscription>();
|
|
25
|
-
// this.add$.subscribe((relay) =>
|
|
26
|
-
// listeners.set(
|
|
27
|
-
// relay,
|
|
28
|
-
// relay.connected$.subscribe((conn) => (conn ? this.connect$.next(relay) : this.disconnect$.next(relay))),
|
|
29
|
-
// ),
|
|
30
|
-
// );
|
|
31
|
-
// this.remove$.subscribe((relay) => {
|
|
32
|
-
// const listener = listeners.get(relay);
|
|
33
|
-
// if (listener) listener.unsubscribe();
|
|
34
|
-
// listeners.delete(relay);
|
|
35
|
-
// });
|
|
36
|
-
}
|
|
37
|
-
filterBlacklist(urls) {
|
|
38
|
-
return urls.filter((url) => !this.blacklist.has(url));
|
|
39
17
|
}
|
|
40
18
|
/** Get or create a new relay connection */
|
|
41
19
|
relay(url) {
|
|
42
20
|
// Normalize the url
|
|
43
21
|
url = normalizeURL(url);
|
|
44
|
-
// Check if the url is blacklisted
|
|
45
|
-
if (this.blacklist.has(url))
|
|
46
|
-
throw new Error("Relay is on blacklist");
|
|
47
22
|
// Check if the relay already exists
|
|
48
23
|
let relay = this.relays.get(url);
|
|
49
24
|
if (relay)
|
|
@@ -57,17 +32,9 @@ export class RelayPool {
|
|
|
57
32
|
}
|
|
58
33
|
/** Create a group of relays */
|
|
59
34
|
group(relays) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
relays = this.filterBlacklist(relays);
|
|
64
|
-
const key = relays.sort().join(",");
|
|
65
|
-
let group = this.groups.get(key);
|
|
66
|
-
if (group)
|
|
67
|
-
return group;
|
|
68
|
-
group = new RelayGroup(relays.map((url) => this.relay(url)));
|
|
69
|
-
this.groups$.next(this.groups.set(key, group));
|
|
70
|
-
return group;
|
|
35
|
+
return new RelayGroup(Array.isArray(relays)
|
|
36
|
+
? relays.map((url) => this.relay(url))
|
|
37
|
+
: relays.pipe(map((urls) => urls.map((url) => this.relay(url)))));
|
|
71
38
|
}
|
|
72
39
|
/** Removes a relay from the pool and defaults to closing the connection */
|
|
73
40
|
remove(relay, close = true) {
|
|
@@ -112,6 +79,34 @@ export class RelayPool {
|
|
|
112
79
|
subscription(relays, filters, options) {
|
|
113
80
|
return this.group(relays).subscription(filters, options);
|
|
114
81
|
}
|
|
82
|
+
/** Open a subscription for a map of relays and filters */
|
|
83
|
+
subscriptionMap(relays, options) {
|
|
84
|
+
// Convert input to observable
|
|
85
|
+
const relays$ = isObservable(relays) ? relays : of(relays);
|
|
86
|
+
return this.group(
|
|
87
|
+
// Create a group with an observable of dynamic relay urls
|
|
88
|
+
relays$.pipe(map((dir) => Object.keys(dir)))).subscription((relay) => {
|
|
89
|
+
// Return observable to subscribe to the relays unique filters
|
|
90
|
+
return relays$.pipe(
|
|
91
|
+
// Select the relays filters
|
|
92
|
+
map((dir) => dir[relay.url]),
|
|
93
|
+
// Don't send duplicate filters
|
|
94
|
+
distinctUntilChanged(isFilterEqual));
|
|
95
|
+
}, options);
|
|
96
|
+
}
|
|
97
|
+
/** Open a subscription for an {@link OutboxMap} and filter */
|
|
98
|
+
outboxSubscription(outboxes, filter, options) {
|
|
99
|
+
const filterMap = isObservable(outboxes)
|
|
100
|
+
? outboxes.pipe(
|
|
101
|
+
// Project outbox map to filter map
|
|
102
|
+
map((outboxes) => createFilterMap(outboxes, filter)))
|
|
103
|
+
: createFilterMap(outboxes, filter);
|
|
104
|
+
return this.subscriptionMap(filterMap, options);
|
|
105
|
+
}
|
|
106
|
+
/** Count events on multiple relays */
|
|
107
|
+
count(relays, filters, id) {
|
|
108
|
+
return this.group(relays).count(filters, id);
|
|
109
|
+
}
|
|
115
110
|
/** Negentropy sync events with the relays and an event store */
|
|
116
111
|
sync(relays, store, filter, direction) {
|
|
117
112
|
return this.group(relays).sync(store, filter, direction);
|
package/dist/relay.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { RelayInformation } from "nostr-tools/nip11";
|
|
|
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, FilterInput, IRelay, PublishOptions, PublishResponse, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
|
|
7
|
+
import { AuthSigner, CountResponse, FilterInput, IRelay, PublishOptions, PublishResponse, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
|
|
8
8
|
/** Flags for the negentropy sync type */
|
|
9
9
|
export declare enum SyncDirection {
|
|
10
10
|
RECEIVE = 1,
|
|
@@ -104,6 +104,8 @@ export declare class Relay implements IRelay {
|
|
|
104
104
|
send(message: any): void;
|
|
105
105
|
/** Create a REQ observable that emits events or "EOSE" or errors */
|
|
106
106
|
req(filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
|
|
107
|
+
/** Create a COUNT observable that emits a single count response */
|
|
108
|
+
count(filters: Filter | Filter[], id?: string): Observable<CountResponse>;
|
|
107
109
|
/** Send an EVENT or AUTH message and return an observable of PublishResponse that completes or errors */
|
|
108
110
|
event(event: NostrEvent, verb?: "EVENT" | "AUTH"): Observable<PublishResponse>;
|
|
109
111
|
/** send and AUTH message */
|
|
@@ -118,10 +120,12 @@ export declare class Relay implements IRelay {
|
|
|
118
120
|
protected customRepeatOperator<T extends unknown = unknown>(times: undefined | boolean | number | RepeatConfig | undefined): MonoTypeOperatorFunction<T>;
|
|
119
121
|
/** Internal operator for creating the timeout() operator */
|
|
120
122
|
protected customTimeoutOperator<T extends unknown = unknown>(timeout: undefined | boolean | number, defaultTimeout: number): MonoTypeOperatorFunction<T>;
|
|
123
|
+
/** Internal operator for handling auth-required errors from REQ/COUNT operations */
|
|
124
|
+
protected handleAuthRequiredForReq(operation: "REQ" | "COUNT"): MonoTypeOperatorFunction<any>;
|
|
121
125
|
/** Creates a REQ that retries when relay errors ( default 3 retries ) */
|
|
122
|
-
subscription(filters:
|
|
126
|
+
subscription(filters: FilterInput, opts?: SubscriptionOptions): Observable<SubscriptionResponse>;
|
|
123
127
|
/** Makes a single request that retires on errors and completes on EOSE */
|
|
124
|
-
request(filters:
|
|
128
|
+
request(filters: FilterInput, opts?: RequestOptions): Observable<NostrEvent>;
|
|
125
129
|
/** Publishes an event to the relay and retries when relay errors or responds with auth-required ( default 3 retries ) */
|
|
126
130
|
publish(event: NostrEvent, opts?: PublishOptions): Promise<PublishResponse>;
|
|
127
131
|
/** Negentropy sync events with the relay and an event store */
|
package/dist/relay.js
CHANGED
|
@@ -7,6 +7,7 @@ import { BehaviorSubject, catchError, combineLatest, defer, endWith, filter, fin
|
|
|
7
7
|
import { webSocket } from "rxjs/webSocket";
|
|
8
8
|
import { completeOnEose } from "./operators/complete-on-eose.js";
|
|
9
9
|
import { markFromRelay } from "./operators/mark-from-relay.js";
|
|
10
|
+
const AUTH_REQUIRED_PREFIX = "auth-required:";
|
|
10
11
|
const DEFAULT_RETRY_CONFIG = { count: 10, delay: 1000, resetOnSuccess: true };
|
|
11
12
|
/** Flags for the negentropy sync type */
|
|
12
13
|
export var SyncDirection;
|
|
@@ -264,7 +265,15 @@ export class Relay {
|
|
|
264
265
|
/** Create a REQ observable that emits events or "EOSE" or errors */
|
|
265
266
|
req(filters, id = nanoid()) {
|
|
266
267
|
// Convert filters input into an observable, if its a normal value merge it with NEVER so it never completes
|
|
267
|
-
|
|
268
|
+
let input;
|
|
269
|
+
// Create input from filters input
|
|
270
|
+
if (typeof filters === "function") {
|
|
271
|
+
const result = filters(this);
|
|
272
|
+
input = (isObservable(result) ? result : merge(of(result), NEVER)).pipe(map((f) => (Array.isArray(f) ? f : [f])));
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
input = (isObservable(filters) ? filters : merge(of(filters), NEVER)).pipe(map((f) => (Array.isArray(f) ? f : [f])));
|
|
276
|
+
}
|
|
268
277
|
// Create an observable that completes when the upstream observable completes
|
|
269
278
|
const filtersComplete = input.pipe(ignoreElements(), endWith(null));
|
|
270
279
|
// Create an observable that filters responses from the relay to just the ones for this REQ
|
|
@@ -274,7 +283,7 @@ export class Relay {
|
|
|
274
283
|
// Create an observable that controls sending the filters and closing the REQ
|
|
275
284
|
const control = input.pipe(
|
|
276
285
|
// Send the filters when they change
|
|
277
|
-
tap((filters) => this.socket.next(
|
|
286
|
+
tap((filters) => this.socket.next(["REQ", id, ...filters])),
|
|
278
287
|
// Send the CLOSE message when unsubscribed or input completes
|
|
279
288
|
finalize(() => this.socket.next(["CLOSE", id])),
|
|
280
289
|
// Once filters have been sent, switch to listening for messages
|
|
@@ -294,21 +303,11 @@ export class Relay {
|
|
|
294
303
|
throw new ReqCloseError(message[2]);
|
|
295
304
|
else
|
|
296
305
|
return message[2];
|
|
297
|
-
}),
|
|
298
|
-
// Set REQ auth required if the REQ is closed with auth-required
|
|
299
|
-
if (error instanceof ReqCloseError &&
|
|
300
|
-
error.message.startsWith("auth-required") &&
|
|
301
|
-
!this.receivedAuthRequiredForReq.value) {
|
|
302
|
-
this.log("Auth required for REQ");
|
|
303
|
-
this.receivedAuthRequiredForReq.next(true);
|
|
304
|
-
}
|
|
305
|
-
// Pass the error through
|
|
306
|
-
return throwError(() => error);
|
|
307
|
-
}),
|
|
306
|
+
}), this.handleAuthRequiredForReq("REQ"),
|
|
308
307
|
// mark events as from relays
|
|
309
308
|
markFromRelay(this.url),
|
|
310
309
|
// if no events are seen in 10s, emit EOSE
|
|
311
|
-
// TODO: this should emit EOSE
|
|
310
|
+
// TODO: this timeout should only emit EOSE after the last event is seen and no EOSE has been sent in (timeout ms)
|
|
312
311
|
timeout({
|
|
313
312
|
first: this.eoseTimeout,
|
|
314
313
|
with: () => merge(of("EOSE"), NEVER),
|
|
@@ -318,6 +317,38 @@ export class Relay {
|
|
|
318
317
|
// Wait for auth if required and make sure to start the watch tower
|
|
319
318
|
return this.waitForReady(this.waitForAuth(this.authRequiredForRead$, observable));
|
|
320
319
|
}
|
|
320
|
+
/** Create a COUNT observable that emits a single count response */
|
|
321
|
+
count(filters, id = nanoid()) {
|
|
322
|
+
// Create an observable that filters responses from the relay to just the ones for this COUNT
|
|
323
|
+
const messages = this.socket.pipe(filter((m) => Array.isArray(m) && (m[0] === "COUNT" || m[0] === "CLOSED") && m[1] === id),
|
|
324
|
+
// Singleton (prevents duplicate subscriptions)
|
|
325
|
+
share());
|
|
326
|
+
// Send the COUNT message and listen for response
|
|
327
|
+
const observable = defer(() => {
|
|
328
|
+
// Send the COUNT message when subscription starts
|
|
329
|
+
this.socket.next(Array.isArray(filters) ? ["COUNT", id, ...filters] : ["COUNT", id, filters]);
|
|
330
|
+
// Merge with watch tower to keep connection alive
|
|
331
|
+
return merge(this.watchTower, messages);
|
|
332
|
+
}).pipe(
|
|
333
|
+
// Map the messages to count responses or throw an error
|
|
334
|
+
map((message) => {
|
|
335
|
+
if (message[0] === "COUNT")
|
|
336
|
+
return message[2];
|
|
337
|
+
else
|
|
338
|
+
throw new ReqCloseError(message[2]);
|
|
339
|
+
}), this.handleAuthRequiredForReq("COUNT"),
|
|
340
|
+
// Complete on first value (COUNT responses are single-shot)
|
|
341
|
+
take(1),
|
|
342
|
+
// Add timeout
|
|
343
|
+
timeout({
|
|
344
|
+
first: this.eoseTimeout,
|
|
345
|
+
with: () => throwError(() => new Error("COUNT timeout")),
|
|
346
|
+
}),
|
|
347
|
+
// Only create one upstream subscription
|
|
348
|
+
share());
|
|
349
|
+
// Start the watch tower and wait for auth if required
|
|
350
|
+
return this.waitForReady(this.waitForAuth(this.authRequiredForRead$, observable));
|
|
351
|
+
}
|
|
321
352
|
/** Send an EVENT or AUTH message and return an observable of PublishResponse that completes or errors */
|
|
322
353
|
event(event, verb = "EVENT") {
|
|
323
354
|
const messages = defer(() => {
|
|
@@ -338,7 +369,7 @@ export class Relay {
|
|
|
338
369
|
take(1),
|
|
339
370
|
// listen for OK auth-required
|
|
340
371
|
tap(({ ok, message }) => {
|
|
341
|
-
if (ok === false && message?.startsWith(
|
|
372
|
+
if (ok === false && message?.startsWith(AUTH_REQUIRED_PREFIX) && !this.receivedAuthRequiredForEvent.value) {
|
|
342
373
|
this.log("Auth required for publish");
|
|
343
374
|
this.receivedAuthRequiredForEvent.next(true);
|
|
344
375
|
}
|
|
@@ -414,6 +445,20 @@ export class Relay {
|
|
|
414
445
|
else
|
|
415
446
|
return simpleTimeout(timeout ?? defaultTimeout);
|
|
416
447
|
}
|
|
448
|
+
/** Internal operator for handling auth-required errors from REQ/COUNT operations */
|
|
449
|
+
handleAuthRequiredForReq(operation) {
|
|
450
|
+
return catchError((error) => {
|
|
451
|
+
// Set auth required if the operation is closed with auth-required
|
|
452
|
+
if (error instanceof ReqCloseError &&
|
|
453
|
+
error.message.startsWith(AUTH_REQUIRED_PREFIX) &&
|
|
454
|
+
!this.receivedAuthRequiredForReq.value) {
|
|
455
|
+
this.log(`Auth required for ${operation}`);
|
|
456
|
+
this.receivedAuthRequiredForReq.next(true);
|
|
457
|
+
}
|
|
458
|
+
// Pass the error through
|
|
459
|
+
return throwError(() => error);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
417
462
|
/** Creates a REQ that retries when relay errors ( default 3 retries ) */
|
|
418
463
|
subscription(filters, opts) {
|
|
419
464
|
return this.req(filters, opts?.id).pipe(
|
|
@@ -440,7 +485,7 @@ export class Relay {
|
|
|
440
485
|
publish(event, opts) {
|
|
441
486
|
return lastValueFrom(this.event(event).pipe(mergeMap((result) => {
|
|
442
487
|
// If the relay responds with auth-required, throw an error for the retry operator to handle
|
|
443
|
-
if (result.ok === false && result.message?.startsWith(
|
|
488
|
+
if (result.ok === false && result.message?.startsWith(AUTH_REQUIRED_PREFIX))
|
|
444
489
|
return throwError(() => new Error(result.message));
|
|
445
490
|
return of(result);
|
|
446
491
|
}),
|
|
@@ -461,6 +506,14 @@ export class Relay {
|
|
|
461
506
|
};
|
|
462
507
|
return new Observable((observer) => {
|
|
463
508
|
const controller = new AbortController();
|
|
509
|
+
let cleanupCalled = false;
|
|
510
|
+
// Store reference to cleanup the negentropy properly
|
|
511
|
+
const cleanup = () => {
|
|
512
|
+
if (!cleanupCalled) {
|
|
513
|
+
cleanupCalled = true;
|
|
514
|
+
controller.abort();
|
|
515
|
+
}
|
|
516
|
+
};
|
|
464
517
|
this.negentropy(store, filter, async (have, need) => {
|
|
465
518
|
// NOTE: it may be more efficient to sync all the events later in a single batch
|
|
466
519
|
// Send missing events to the relay
|
|
@@ -475,11 +528,17 @@ export class Relay {
|
|
|
475
528
|
}
|
|
476
529
|
}, { signal: controller.signal })
|
|
477
530
|
// Complete the observable when the sync is complete
|
|
478
|
-
.then(() =>
|
|
531
|
+
.then(() => {
|
|
532
|
+
if (!cleanupCalled)
|
|
533
|
+
observer.complete();
|
|
534
|
+
})
|
|
479
535
|
// Error the observable when the sync fails
|
|
480
|
-
.catch((err) =>
|
|
536
|
+
.catch((err) => {
|
|
537
|
+
if (!cleanupCalled)
|
|
538
|
+
observer.error(err);
|
|
539
|
+
});
|
|
481
540
|
// Cancel the sync when the observable is unsubscribed
|
|
482
|
-
return
|
|
541
|
+
return cleanup;
|
|
483
542
|
}).pipe(
|
|
484
543
|
// Only create one upstream subscription
|
|
485
544
|
share());
|
package/dist/types.d.ts
CHANGED
|
@@ -12,6 +12,9 @@ export type PublishResponse = {
|
|
|
12
12
|
message?: string;
|
|
13
13
|
from: string;
|
|
14
14
|
};
|
|
15
|
+
export type CountResponse = {
|
|
16
|
+
count: number;
|
|
17
|
+
};
|
|
15
18
|
export type MultiplexWebSocket<T = any> = Pick<WebSocketSubject<T>, "multiplex">;
|
|
16
19
|
/** Options for the publish method on the pool and relay */
|
|
17
20
|
export type PublishOptions = {
|
|
@@ -55,8 +58,8 @@ export type SubscriptionOptions = {
|
|
|
55
58
|
export type AuthSigner = {
|
|
56
59
|
signEvent: (event: EventTemplate) => NostrEvent | Promise<NostrEvent>;
|
|
57
60
|
};
|
|
58
|
-
/**
|
|
59
|
-
export type FilterInput = Filter | Filter[] | Observable<Filter | Filter[]
|
|
61
|
+
/** Filters that can be passed to request methods on the pool or relay */
|
|
62
|
+
export type FilterInput = Filter | Filter[] | Observable<Filter | Filter[]> | ((relay: IRelay) => Filter | Filter[] | Observable<Filter | Filter[]>);
|
|
60
63
|
export interface IRelay extends MultiplexWebSocket {
|
|
61
64
|
url: string;
|
|
62
65
|
message$: Observable<any>;
|
|
@@ -77,6 +80,8 @@ export interface IRelay extends MultiplexWebSocket {
|
|
|
77
80
|
close(): void;
|
|
78
81
|
/** Send a REQ message */
|
|
79
82
|
req(filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
|
|
83
|
+
/** Send a COUNT message */
|
|
84
|
+
count(filters: Filter | Filter[], id?: string): Observable<CountResponse>;
|
|
80
85
|
/** Send an EVENT message */
|
|
81
86
|
event(event: NostrEvent): Observable<PublishResponse>;
|
|
82
87
|
/** Send an AUTH message */
|
|
@@ -100,19 +105,28 @@ export interface IRelay extends MultiplexWebSocket {
|
|
|
100
105
|
/** Get the supported NIPs for the relay */
|
|
101
106
|
getSupported(): Promise<number[] | null>;
|
|
102
107
|
}
|
|
108
|
+
export type IGroupRelayInput = IRelay[] | Observable<IRelay[]>;
|
|
103
109
|
export interface IGroup {
|
|
104
110
|
/** Send a REQ message */
|
|
105
|
-
req(filters:
|
|
111
|
+
req(filters: Parameters<IRelay["req"]>[0], id?: string): Observable<SubscriptionResponse>;
|
|
106
112
|
/** Send an EVENT message */
|
|
107
|
-
event(event:
|
|
113
|
+
event(event: Parameters<IRelay["event"]>[0]): Observable<PublishResponse>;
|
|
108
114
|
/** Negentropy sync event ids with the relays and an event store */
|
|
109
115
|
negentropy(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, reconcile: ReconcileFunction, opts?: NegentropySyncOptions): Promise<boolean>;
|
|
116
|
+
/** Add a relay to the group */
|
|
117
|
+
add(relay: IRelay): void;
|
|
118
|
+
/** Remove a relay from the group */
|
|
119
|
+
remove(relay: IRelay): void;
|
|
120
|
+
/** Check if a relay is in the group */
|
|
121
|
+
has(relay: IRelay | string): boolean;
|
|
110
122
|
/** Send an EVENT message with retries */
|
|
111
|
-
publish(event:
|
|
123
|
+
publish(event: Parameters<IRelay["event"]>[0], opts?: PublishOptions): Promise<PublishResponse[]>;
|
|
112
124
|
/** Send a REQ message with retries */
|
|
113
|
-
request(filters:
|
|
125
|
+
request(filters: Parameters<IRelay["request"]>[0], opts?: GroupRequestOptions): Observable<NostrEvent>;
|
|
114
126
|
/** Open a subscription with retries */
|
|
115
|
-
subscription(filters:
|
|
127
|
+
subscription(filters: Parameters<IRelay["subscription"]>[0], opts?: GroupSubscriptionOptions): Observable<SubscriptionResponse>;
|
|
128
|
+
/** Count events on the relays and an event store */
|
|
129
|
+
count(filters: Filter | Filter[], id?: string): Observable<Record<string, CountResponse>>;
|
|
116
130
|
/** Negentropy sync events with the relay and an event store */
|
|
117
131
|
sync(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, direction?: SyncDirection): Observable<NostrEvent>;
|
|
118
132
|
}
|
|
@@ -121,25 +135,28 @@ export interface IPoolSignals {
|
|
|
121
135
|
add$: Observable<IRelay>;
|
|
122
136
|
remove$: Observable<IRelay>;
|
|
123
137
|
}
|
|
138
|
+
export type IPoolRelayInput = string[] | Observable<string[]>;
|
|
124
139
|
export interface IPool extends IPoolSignals {
|
|
125
140
|
/** Get or create a relay */
|
|
126
141
|
relay(url: string): IRelay;
|
|
127
142
|
/** Create a relay group */
|
|
128
|
-
group(relays:
|
|
143
|
+
group(relays: IPoolRelayInput): IGroup;
|
|
129
144
|
/** Removes a relay from the pool and defaults to closing the connection */
|
|
130
145
|
remove(relay: string | IRelay, close?: boolean): void;
|
|
131
146
|
/** Send a REQ message */
|
|
132
|
-
req(relays:
|
|
147
|
+
req(relays: IPoolRelayInput, filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
|
|
133
148
|
/** Send an EVENT message */
|
|
134
|
-
event(relays:
|
|
149
|
+
event(relays: IPoolRelayInput, event: NostrEvent): Observable<PublishResponse>;
|
|
135
150
|
/** Negentropy sync event ids with the relays and an event store */
|
|
136
|
-
negentropy(relays:
|
|
151
|
+
negentropy(relays: IPoolRelayInput, store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, reconcile: ReconcileFunction, opts?: GroupNegentropySyncOptions): Promise<boolean>;
|
|
137
152
|
/** Send an EVENT message to relays with retries */
|
|
138
|
-
publish(relays:
|
|
153
|
+
publish(relays: IPoolRelayInput, event: Parameters<IGroup["publish"]>[0], opts?: Parameters<IGroup["publish"]>[1]): Promise<PublishResponse[]>;
|
|
139
154
|
/** Send a REQ message to relays with retries */
|
|
140
|
-
request(relays:
|
|
155
|
+
request(relays: IPoolRelayInput, filters: Parameters<IGroup["request"]>[0], opts?: Parameters<IGroup["request"]>[1]): Observable<NostrEvent>;
|
|
141
156
|
/** Open a subscription to relays with retries */
|
|
142
|
-
subscription(relays:
|
|
157
|
+
subscription(relays: IPoolRelayInput, filters: Parameters<IGroup["subscription"]>[0], opts?: Parameters<IGroup["subscription"]>[1]): Observable<SubscriptionResponse>;
|
|
158
|
+
/** Count events on the relays and an event store */
|
|
159
|
+
count(relays: IPoolRelayInput, filters: Filter | Filter[], id?: string): Observable<Record<string, CountResponse>>;
|
|
143
160
|
/** Negentropy sync events with the relay and an event store */
|
|
144
|
-
sync(relays:
|
|
161
|
+
sync(relays: IPoolRelayInput, store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, direction?: SyncDirection): Observable<NostrEvent>;
|
|
145
162
|
}
|
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-20251027164028",
|
|
4
4
|
"description": "nostr relay communication framework built on rxjs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -52,14 +52,14 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@noble/hashes": "^1.7.1",
|
|
55
|
-
"applesauce-core": "0.0.0-next-
|
|
55
|
+
"applesauce-core": "0.0.0-next-20251027164028",
|
|
56
56
|
"nanoid": "^5.0.9",
|
|
57
57
|
"nostr-tools": "~2.17",
|
|
58
58
|
"rxjs": "^7.8.1"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@hirez_io/observer-spy": "^2.2.0",
|
|
62
|
-
"applesauce-signers": "0.0.0-next-
|
|
62
|
+
"applesauce-signers": "0.0.0-next-20251027164028",
|
|
63
63
|
"rimraf": "^6.0.1",
|
|
64
64
|
"typescript": "^5.7.3",
|
|
65
65
|
"vitest": "^3.2.4",
|