applesauce-relay 0.0.0-next-20250923113611 → 0.0.0-next-20251015123951
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/dist/group.d.ts +30 -10
- package/dist/group.js +35 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/liveness.d.ts +125 -0
- package/dist/liveness.js +327 -0
- package/dist/negentropy.d.ts +22 -6
- package/dist/negentropy.js +10 -3
- package/dist/operators/index.d.ts +1 -0
- package/dist/operators/index.js +1 -0
- package/dist/operators/liveness.d.ts +17 -0
- package/dist/operators/liveness.js +47 -0
- package/dist/pool.d.ts +10 -4
- package/dist/pool.js +11 -3
- package/dist/relay.d.ts +16 -1
- package/dist/relay.js +59 -2
- package/dist/types.d.ts +31 -5
- package/package.json +3 -3
package/dist/group.d.ts
CHANGED
|
@@ -1,27 +1,47 @@
|
|
|
1
|
-
import { type NostrEvent } from "nostr-tools";
|
|
1
|
+
import { Filter, type NostrEvent } from "nostr-tools";
|
|
2
2
|
import { Observable } from "rxjs";
|
|
3
|
-
import { IAsyncEventStoreActions, IEventStoreActions } from "applesauce-core";
|
|
3
|
+
import { IAsyncEventStoreActions, IAsyncEventStoreRead, IEventStoreActions, IEventStoreRead } from "applesauce-core";
|
|
4
|
+
import { NegentropySyncOptions, type ReconcileFunction } from "./negentropy.js";
|
|
4
5
|
import { 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
|
+
};
|
|
5
22
|
export declare class RelayGroup implements IGroup {
|
|
6
23
|
relays: IRelay[];
|
|
7
24
|
constructor(relays: IRelay[]);
|
|
8
25
|
/** Takes an array of observables and only emits EOSE when all observables have emitted EOSE */
|
|
9
|
-
protected mergeEOSE(requests: Observable<SubscriptionResponse>[], eventStore?: IEventStoreActions | IAsyncEventStoreActions): Observable<import("nostr-tools").Event | "EOSE">;
|
|
26
|
+
protected mergeEOSE(requests: Observable<SubscriptionResponse>[], eventStore?: IEventStoreActions | IAsyncEventStoreActions | null): Observable<import("nostr-tools").Event | "EOSE">;
|
|
10
27
|
/**
|
|
11
28
|
* Make a request to all relays
|
|
12
29
|
* @note This does not deduplicate events
|
|
13
30
|
*/
|
|
14
|
-
req(filters: FilterInput, id?: string
|
|
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>;
|
|
15
35
|
/** Send an event to all relays */
|
|
16
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>;
|
|
17
39
|
/** Publish an event to all relays with retries ( default 3 retries ) */
|
|
18
40
|
publish(event: NostrEvent, opts?: PublishOptions): Promise<PublishResponse[]>;
|
|
19
41
|
/** Request events from all relays with retries ( default 3 retries ) */
|
|
20
|
-
request(filters: FilterInput, opts?:
|
|
21
|
-
eventStore?: IEventStoreActions;
|
|
22
|
-
}): Observable<NostrEvent>;
|
|
42
|
+
request(filters: FilterInput, opts?: GroupRequestOptions): Observable<NostrEvent>;
|
|
23
43
|
/** Open a subscription to all relays with retries ( default 3 retries ) */
|
|
24
|
-
subscription(filters: FilterInput, opts?:
|
|
25
|
-
|
|
26
|
-
|
|
44
|
+
subscription(filters: FilterInput, opts?: GroupSubscriptionOptions): Observable<SubscriptionResponse>;
|
|
45
|
+
/** Negentropy sync events with the relays and an event store */
|
|
46
|
+
sync(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, direction?: SyncDirection): Observable<NostrEvent>;
|
|
27
47
|
}
|
package/dist/group.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { nanoid } from "nanoid";
|
|
2
|
-
import { catchError, EMPTY, endWith, identity, ignoreElements, merge, of } from "rxjs";
|
|
3
|
-
import { filterDuplicateEvents } from "applesauce-core";
|
|
2
|
+
import { catchError, defer, EMPTY, endWith, identity, ignoreElements, merge, of, share, switchMap, } from "rxjs";
|
|
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
6
|
export class RelayGroup {
|
|
@@ -9,7 +9,7 @@ export class RelayGroup {
|
|
|
9
9
|
this.relays = relays;
|
|
10
10
|
}
|
|
11
11
|
/** Takes an array of observables and only emits EOSE when all observables have emitted EOSE */
|
|
12
|
-
mergeEOSE(requests, eventStore) {
|
|
12
|
+
mergeEOSE(requests, eventStore = new EventMemory()) {
|
|
13
13
|
// Create stream of events only
|
|
14
14
|
const events = merge(...requests).pipe(
|
|
15
15
|
// Ignore non event responses
|
|
@@ -28,12 +28,12 @@ export class RelayGroup {
|
|
|
28
28
|
* Make a request to all relays
|
|
29
29
|
* @note This does not deduplicate events
|
|
30
30
|
*/
|
|
31
|
-
req(filters, id = nanoid(8)) {
|
|
31
|
+
req(filters, id = nanoid(8), opts) {
|
|
32
32
|
const requests = this.relays.map((relay) => relay.req(filters, id).pipe(
|
|
33
33
|
// Ignore connection errors
|
|
34
34
|
catchError(() => of("EOSE"))));
|
|
35
35
|
// Merge events and the single EOSE stream
|
|
36
|
-
return this.mergeEOSE(requests);
|
|
36
|
+
return this.mergeEOSE(requests, opts?.eventStore);
|
|
37
37
|
}
|
|
38
38
|
/** Send an event to all relays */
|
|
39
39
|
event(event) {
|
|
@@ -41,6 +41,20 @@ export class RelayGroup {
|
|
|
41
41
|
// Catch error and return as PublishResponse
|
|
42
42
|
catchError((err) => of({ ok: false, from: relay.url, message: err?.message || "Unknown error" })))));
|
|
43
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
|
+
}
|
|
44
58
|
/** Publish an event to all relays with retries ( default 3 retries ) */
|
|
45
59
|
publish(event, opts) {
|
|
46
60
|
return Promise.all(this.relays.map((relay) => relay.publish(event, opts).catch(
|
|
@@ -53,7 +67,7 @@ export class RelayGroup {
|
|
|
53
67
|
// Ignore individual connection errors
|
|
54
68
|
catchError(() => EMPTY)))).pipe(
|
|
55
69
|
// If an event store is provided, filter duplicate events
|
|
56
|
-
opts?.eventStore ? filterDuplicateEvents(opts
|
|
70
|
+
opts?.eventStore == null ? identity : filterDuplicateEvents(opts?.eventStore ?? new EventMemory()));
|
|
57
71
|
}
|
|
58
72
|
/** Open a subscription to all relays with retries ( default 3 retries ) */
|
|
59
73
|
subscription(filters, opts) {
|
|
@@ -63,4 +77,19 @@ export class RelayGroup {
|
|
|
63
77
|
// Pass event store so that duplicate events are removed
|
|
64
78
|
opts?.eventStore);
|
|
65
79
|
}
|
|
80
|
+
/** Negentropy sync events with the relays and an event store */
|
|
81
|
+
sync(store, filter, direction) {
|
|
82
|
+
// Get an array of relays that support NIP-77 negentropy sync
|
|
83
|
+
return defer(async () => {
|
|
84
|
+
const supported = await Promise.all(this.relays.map(async (relay) => [relay, await relay.getSupported()]));
|
|
85
|
+
const relays = supported.filter(([_, supported]) => supported?.includes(77)).map(([relay]) => relay);
|
|
86
|
+
if (relays.length === 0)
|
|
87
|
+
throw new Error("No relays support NIP-77 negentropy sync");
|
|
88
|
+
return relays;
|
|
89
|
+
}).pipe(
|
|
90
|
+
// Once relays are selected, sync all the relays in parallel
|
|
91
|
+
switchMap((relays) => merge(...relays.map((relay) => relay.sync(store, filter, direction)))),
|
|
92
|
+
// Only create one upstream subscription
|
|
93
|
+
share());
|
|
94
|
+
}
|
|
66
95
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* State information for a relay's health tracking
|
|
7
|
+
*/
|
|
8
|
+
export interface RelayState {
|
|
9
|
+
/** Current relay health state */
|
|
10
|
+
state: RelayHealthState;
|
|
11
|
+
/** Number of consecutive failures */
|
|
12
|
+
failureCount: number;
|
|
13
|
+
/** Timestamp of last failure */
|
|
14
|
+
lastFailureTime: number;
|
|
15
|
+
/** Timestamp of last success */
|
|
16
|
+
lastSuccessTime: number;
|
|
17
|
+
/** When the backoff period ends (timestamp) */
|
|
18
|
+
backoffUntil?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Storage adapter interface for persisting relay liveness state */
|
|
21
|
+
export interface LivenessStorage {
|
|
22
|
+
/**
|
|
23
|
+
* Get an item from storage
|
|
24
|
+
* @param key The storage key
|
|
25
|
+
* @returns The stored value or null if not found
|
|
26
|
+
*/
|
|
27
|
+
getItem(key: string): Promise<any> | any;
|
|
28
|
+
/**
|
|
29
|
+
* Set an item in storage
|
|
30
|
+
* @param key The storage key
|
|
31
|
+
* @param value The value to store
|
|
32
|
+
*/
|
|
33
|
+
setItem(key: string, value: any): Promise<void> | void;
|
|
34
|
+
}
|
|
35
|
+
/** Configuration options for RelayLiveness */
|
|
36
|
+
export interface LivenessOptions {
|
|
37
|
+
/** Optional async storage adapter for persistence */
|
|
38
|
+
storage?: LivenessStorage;
|
|
39
|
+
/** Maximum failures before moving from offline to dead */
|
|
40
|
+
maxFailuresBeforeDead?: number;
|
|
41
|
+
/** Base delay for exponential backoff (ms) */
|
|
42
|
+
backoffBaseDelay?: number;
|
|
43
|
+
/** Maximum backoff delay (ms) */
|
|
44
|
+
backoffMaxDelay?: number;
|
|
45
|
+
}
|
|
46
|
+
/** Record and manage liveness reports for relays */
|
|
47
|
+
export declare class RelayLiveness {
|
|
48
|
+
private log;
|
|
49
|
+
private readonly options;
|
|
50
|
+
private readonly states$;
|
|
51
|
+
/** Relays that have been seen this session. this should be used when checking dead relays for liveness */
|
|
52
|
+
readonly seen: Set<string>;
|
|
53
|
+
/** Storage adapter for persistence */
|
|
54
|
+
readonly storage?: LivenessStorage;
|
|
55
|
+
/** An observable of all relays that are online */
|
|
56
|
+
online$: Observable<string[]>;
|
|
57
|
+
/** An observable of all relays that are offline */
|
|
58
|
+
offline$: Observable<string[]>;
|
|
59
|
+
/** An observable of all relays that are dead */
|
|
60
|
+
dead$: Observable<string[]>;
|
|
61
|
+
/** An observable of all relays that are online or not in backoff */
|
|
62
|
+
healthy$: Observable<string[]>;
|
|
63
|
+
/** An observable of all relays that are dead or in backoff */
|
|
64
|
+
unhealthy$: Observable<string[]>;
|
|
65
|
+
/** Relays that are known to be online */
|
|
66
|
+
get online(): string[];
|
|
67
|
+
/** Relays that are known to be offline */
|
|
68
|
+
get offline(): string[];
|
|
69
|
+
/** Relays that are known to be dead */
|
|
70
|
+
get dead(): string[];
|
|
71
|
+
/** Relays that are online or not in backoff */
|
|
72
|
+
get healthy(): string[];
|
|
73
|
+
/** Relays that are dead or in backoff */
|
|
74
|
+
get unhealthy(): string[];
|
|
75
|
+
/**
|
|
76
|
+
* Create a new RelayLiveness instance
|
|
77
|
+
* @param options Configuration options for the liveness tracker
|
|
78
|
+
*/
|
|
79
|
+
constructor(options?: LivenessOptions);
|
|
80
|
+
/** Load relay states from storage */
|
|
81
|
+
load(): Promise<void>;
|
|
82
|
+
/** Save all known relays and their states to storage */
|
|
83
|
+
save(): Promise<void>;
|
|
84
|
+
/** Filter relay list, removing dead relays and relays in backoff */
|
|
85
|
+
filter(relays: string[]): string[];
|
|
86
|
+
/** Subscribe to a relays state */
|
|
87
|
+
state(relay: string): Observable<RelayState | undefined>;
|
|
88
|
+
/** Revive a dead relay with the max backoff delay */
|
|
89
|
+
revive(relay: string): void;
|
|
90
|
+
/** Get current relay health state for a relay */
|
|
91
|
+
getState(relay: string): RelayState | undefined;
|
|
92
|
+
/** Check if a relay is currently in backoff period */
|
|
93
|
+
isInBackoff(relay: string): boolean;
|
|
94
|
+
/** Get remaining backoff time for a relay (in ms) */
|
|
95
|
+
getBackoffRemaining(relay: string): number;
|
|
96
|
+
/** Calculate backoff delay based on failure count */
|
|
97
|
+
private calculateBackoffDelay;
|
|
98
|
+
/**
|
|
99
|
+
* Record a successful connection
|
|
100
|
+
* @param relay The relay URL that succeeded
|
|
101
|
+
*/
|
|
102
|
+
recordSuccess(relay: string): void;
|
|
103
|
+
/**
|
|
104
|
+
* Record a failed connection
|
|
105
|
+
* @param relay The relay URL that failed
|
|
106
|
+
*/
|
|
107
|
+
recordFailure(relay: string): void;
|
|
108
|
+
/**
|
|
109
|
+
* Get all seen relays (for debugging/monitoring)
|
|
110
|
+
*/
|
|
111
|
+
getSeenRelays(): string[];
|
|
112
|
+
/**
|
|
113
|
+
* Reset state for one or all relays
|
|
114
|
+
* @param relay Optional specific relay URL to reset, or reset all if not provided
|
|
115
|
+
*/
|
|
116
|
+
reset(relay?: string): void;
|
|
117
|
+
private connections;
|
|
118
|
+
/** Connect to a {@link RelayPool} instance and track relay connections */
|
|
119
|
+
connectToPool(pool: IPool): void;
|
|
120
|
+
/** Disconnect from a {@link RelayPool} instance */
|
|
121
|
+
disconnectFromPool(pool: IPool): void;
|
|
122
|
+
private updateRelayState;
|
|
123
|
+
private saveKnownRelays;
|
|
124
|
+
private saveRelayState;
|
|
125
|
+
}
|
package/dist/liveness.js
ADDED
|
@@ -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
|
+
}
|
package/dist/negentropy.d.ts
CHANGED
|
@@ -1,15 +1,31 @@
|
|
|
1
|
-
import { IEventStoreRead } from "applesauce-core";
|
|
1
|
+
import { IAsyncEventStoreRead, IEventStoreRead } from "applesauce-core";
|
|
2
2
|
import { Filter } from "nostr-tools";
|
|
3
3
|
import { MultiplexWebSocket } from "./types.js";
|
|
4
4
|
import { NegentropyStorageVector } from "./lib/negentropy.js";
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* A function that reconciles the storage vectors with a remote relay
|
|
7
|
+
* @param have - The ids that the local storage has
|
|
8
|
+
* @param need - The ids that the remote relay has
|
|
9
|
+
* @returns A promise that resolves when the reconciliation is complete
|
|
10
|
+
*/
|
|
11
|
+
export type ReconcileFunction = (have: string[], need: string[]) => Promise<void>;
|
|
12
|
+
/** Options for the negentropy sync */
|
|
13
|
+
export type NegentropySyncOptions = {
|
|
14
|
+
frameSizeLimit?: number;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
};
|
|
17
|
+
/** Creates a NegentropyStorageVector from an event store and filter */
|
|
18
|
+
export declare function buildStorageFromFilter(store: IEventStoreRead | IAsyncEventStoreRead, filter: Filter): Promise<NegentropyStorageVector>;
|
|
19
|
+
/** Creates a NegentropyStorageVector from an array of items */
|
|
6
20
|
export declare function buildStorageVector(items: {
|
|
7
21
|
id: string;
|
|
8
22
|
created_at: number;
|
|
9
23
|
}[]): NegentropyStorageVector;
|
|
24
|
+
/**
|
|
25
|
+
* Sync the storage vectors with a remote relay
|
|
26
|
+
* @throws {Error} if the sync fails
|
|
27
|
+
* @returns true if the sync was successful, false if the sync was aborted
|
|
28
|
+
*/
|
|
10
29
|
export declare function negentropySync(storage: NegentropyStorageVector, socket: MultiplexWebSocket & {
|
|
11
30
|
next: (msg: any) => void;
|
|
12
|
-
}, filter: Filter, reconcile:
|
|
13
|
-
frameSizeLimit?: number;
|
|
14
|
-
signal?: AbortSignal;
|
|
15
|
-
}): Promise<boolean>;
|
|
31
|
+
}, filter: Filter, reconcile: ReconcileFunction, opts?: NegentropySyncOptions): Promise<boolean>;
|
package/dist/negentropy.js
CHANGED
|
@@ -3,13 +3,15 @@ import { map, share, firstValueFrom } from "rxjs";
|
|
|
3
3
|
import { nanoid } from "nanoid";
|
|
4
4
|
import { Negentropy, NegentropyStorageVector } from "./lib/negentropy.js";
|
|
5
5
|
const log = logger.extend("negentropy");
|
|
6
|
-
|
|
6
|
+
/** Creates a NegentropyStorageVector from an event store and filter */
|
|
7
|
+
export async function buildStorageFromFilter(store, filter) {
|
|
7
8
|
const storage = new NegentropyStorageVector();
|
|
8
|
-
for (const event of store.getByFilters(filter))
|
|
9
|
+
for (const event of await store.getByFilters(filter))
|
|
9
10
|
storage.insert(event.created_at, event.id);
|
|
10
11
|
storage.seal();
|
|
11
12
|
return storage;
|
|
12
13
|
}
|
|
14
|
+
/** Creates a NegentropyStorageVector from an array of items */
|
|
13
15
|
export function buildStorageVector(items) {
|
|
14
16
|
const storage = new NegentropyStorageVector();
|
|
15
17
|
for (const item of items)
|
|
@@ -17,6 +19,11 @@ export function buildStorageVector(items) {
|
|
|
17
19
|
storage.seal();
|
|
18
20
|
return storage;
|
|
19
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Sync the storage vectors with a remote relay
|
|
24
|
+
* @throws {Error} if the sync fails
|
|
25
|
+
* @returns true if the sync was successful, false if the sync was aborted
|
|
26
|
+
*/
|
|
20
27
|
export async function negentropySync(storage, socket, filter, reconcile, opts) {
|
|
21
28
|
let id = nanoid();
|
|
22
29
|
let ne = new Negentropy(storage, opts?.frameSizeLimit);
|
|
@@ -46,7 +53,7 @@ export async function negentropySync(storage, socket, filter, reconcile, opts) {
|
|
|
46
53
|
return msg[2];
|
|
47
54
|
}), share());
|
|
48
55
|
// keep an additional subscription open while waiting for async operations
|
|
49
|
-
const sub = incoming.subscribe((m) =>
|
|
56
|
+
const sub = incoming.subscribe((m) => log(m));
|
|
50
57
|
try {
|
|
51
58
|
while (msg && opts?.signal?.aborted !== true) {
|
|
52
59
|
const received = await firstValueFrom(incoming);
|
package/dist/operators/index.js
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { MonoTypeOperatorFunction, Observable } from "rxjs";
|
|
2
|
+
type ILivenessTracker = {
|
|
3
|
+
unhealthy$: Observable<string[]>;
|
|
4
|
+
seen?: Set<string>;
|
|
5
|
+
};
|
|
6
|
+
/** Filters out unhealthy relays from an array of pointers */
|
|
7
|
+
export declare function ignoreUnhealthyRelaysOnPointers<T extends {
|
|
8
|
+
relays?: string[];
|
|
9
|
+
}, Tracker extends ILivenessTracker>(liveness: Tracker): MonoTypeOperatorFunction<T[]>;
|
|
10
|
+
/** Filters out unhealthy relays from the inboxes and outboxes */
|
|
11
|
+
export declare function ignoreUnhealthyMailboxes<T extends {
|
|
12
|
+
inboxes?: string[];
|
|
13
|
+
outboxes?: string[];
|
|
14
|
+
}, Tracker extends ILivenessTracker>(liveness: Tracker): MonoTypeOperatorFunction<T>;
|
|
15
|
+
/** Filters out unhealthy relays from an array of relays */
|
|
16
|
+
export declare function ignoreUnhealthyRelays<Tracker extends ILivenessTracker>(liveness: Tracker): MonoTypeOperatorFunction<string[]>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { combineLatestWith, map } from "rxjs";
|
|
2
|
+
function filterRelays(relays, unhealthy, liveness) {
|
|
3
|
+
return (relays &&
|
|
4
|
+
relays.filter((relay) => {
|
|
5
|
+
// Notify the liveness tracker that we've seen this relay
|
|
6
|
+
liveness.seen?.add(relay);
|
|
7
|
+
// Exclude unhealthy relays
|
|
8
|
+
return !unhealthy.includes(relay);
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
11
|
+
/** Filters out unhealthy relays from an array of pointers */
|
|
12
|
+
export function ignoreUnhealthyRelaysOnPointers(liveness) {
|
|
13
|
+
return (source) => source.pipe(
|
|
14
|
+
// Combine with the liveness observable
|
|
15
|
+
combineLatestWith(liveness.unhealthy$),
|
|
16
|
+
// Filters out unhealthy relays from the pointers
|
|
17
|
+
map(([pointers, unhealthy]) => pointers.map((pointer) => {
|
|
18
|
+
if (!pointer.relays)
|
|
19
|
+
return pointer;
|
|
20
|
+
// Exclude unhealthy relays
|
|
21
|
+
return { ...pointer, relays: filterRelays(pointer.relays, unhealthy, liveness) };
|
|
22
|
+
})));
|
|
23
|
+
}
|
|
24
|
+
/** Filters out unhealthy relays from the inboxes and outboxes */
|
|
25
|
+
export function ignoreUnhealthyMailboxes(liveness) {
|
|
26
|
+
return (source) => source.pipe(
|
|
27
|
+
// Combine with the liveness observable
|
|
28
|
+
combineLatestWith(liveness.unhealthy$),
|
|
29
|
+
// Filters out unhealthy relays from the inboxes and outboxes
|
|
30
|
+
map(([mailboxes, unhealthy]) => {
|
|
31
|
+
if (!mailboxes.inboxes && !mailboxes.outboxes)
|
|
32
|
+
return mailboxes;
|
|
33
|
+
return {
|
|
34
|
+
...mailboxes,
|
|
35
|
+
inboxes: filterRelays(mailboxes.inboxes, unhealthy, liveness),
|
|
36
|
+
outboxes: filterRelays(mailboxes.outboxes, unhealthy, liveness),
|
|
37
|
+
};
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
/** Filters out unhealthy relays from an array of relays */
|
|
41
|
+
export function ignoreUnhealthyRelays(liveness) {
|
|
42
|
+
return (source) => source.pipe(
|
|
43
|
+
// Combine with the liveness observable
|
|
44
|
+
combineLatestWith(liveness.unhealthy$),
|
|
45
|
+
// Filters out unhealthy relays from the array
|
|
46
|
+
map(([relays, unhealthy]) => filterRelays(relays, unhealthy, liveness)));
|
|
47
|
+
}
|
package/dist/pool.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { IAsyncEventStoreRead, IEventStoreRead } from "applesauce-core";
|
|
2
|
+
import { Filter, type NostrEvent } from "nostr-tools";
|
|
2
3
|
import { BehaviorSubject, Observable, Subject } from "rxjs";
|
|
3
4
|
import { RelayGroup } from "./group.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import type { NegentropySyncOptions, ReconcileFunction } from "./negentropy.js";
|
|
6
|
+
import { Relay, SyncDirection, type RelayOptions } from "./relay.js";
|
|
7
|
+
import type { FilterInput, IPool, IRelay, PublishResponse, SubscriptionResponse } from "./types.js";
|
|
6
8
|
export declare class RelayPool implements IPool {
|
|
7
9
|
options?: RelayOptions | undefined;
|
|
8
10
|
groups$: BehaviorSubject<Map<string, RelayGroup>>;
|
|
@@ -27,10 +29,14 @@ export declare class RelayPool implements IPool {
|
|
|
27
29
|
req(relays: string[], filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
|
|
28
30
|
/** Send an EVENT message to multiple relays */
|
|
29
31
|
event(relays: string[], event: NostrEvent): Observable<PublishResponse>;
|
|
32
|
+
/** Negentropy sync event ids with the relays and an event store */
|
|
33
|
+
negentropy(relays: string[], store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, reconcile: ReconcileFunction, opts?: NegentropySyncOptions): Promise<boolean>;
|
|
30
34
|
/** Publish an event to multiple relays */
|
|
31
35
|
publish(relays: string[], event: Parameters<RelayGroup["publish"]>[0], opts?: Parameters<RelayGroup["publish"]>[1]): Promise<PublishResponse[]>;
|
|
32
36
|
/** Request events from multiple relays */
|
|
33
37
|
request(relays: string[], filters: Parameters<RelayGroup["request"]>[0], opts?: Parameters<RelayGroup["request"]>[1]): Observable<NostrEvent>;
|
|
34
38
|
/** Open a subscription to multiple relays */
|
|
35
|
-
subscription(relays: string[], filters: Parameters<RelayGroup["subscription"]>[0],
|
|
39
|
+
subscription(relays: string[], filters: Parameters<RelayGroup["subscription"]>[0], options?: Parameters<RelayGroup["subscription"]>[1]): Observable<SubscriptionResponse>;
|
|
40
|
+
/** Negentropy sync events with the relays and an event store */
|
|
41
|
+
sync(relays: string[], store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, direction?: SyncDirection): Observable<NostrEvent>;
|
|
36
42
|
}
|
package/dist/pool.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { BehaviorSubject, Subject } from "rxjs";
|
|
2
1
|
import { normalizeURL } from "applesauce-core/helpers";
|
|
2
|
+
import { BehaviorSubject, Subject } from "rxjs";
|
|
3
3
|
import { RelayGroup } from "./group.js";
|
|
4
4
|
import { Relay } from "./relay.js";
|
|
5
5
|
export class RelayPool {
|
|
@@ -96,6 +96,10 @@ export class RelayPool {
|
|
|
96
96
|
event(relays, event) {
|
|
97
97
|
return this.group(relays).event(event);
|
|
98
98
|
}
|
|
99
|
+
/** Negentropy sync event ids with the relays and an event store */
|
|
100
|
+
negentropy(relays, store, filter, reconcile, opts) {
|
|
101
|
+
return this.group(relays).negentropy(store, filter, reconcile, opts);
|
|
102
|
+
}
|
|
99
103
|
/** Publish an event to multiple relays */
|
|
100
104
|
publish(relays, event, opts) {
|
|
101
105
|
return this.group(relays).publish(event, opts);
|
|
@@ -105,7 +109,11 @@ export class RelayPool {
|
|
|
105
109
|
return this.group(relays).request(filters, opts);
|
|
106
110
|
}
|
|
107
111
|
/** Open a subscription to multiple relays */
|
|
108
|
-
subscription(relays, filters,
|
|
109
|
-
return this.group(relays).subscription(filters,
|
|
112
|
+
subscription(relays, filters, options) {
|
|
113
|
+
return this.group(relays).subscription(filters, options);
|
|
114
|
+
}
|
|
115
|
+
/** Negentropy sync events with the relays and an event store */
|
|
116
|
+
sync(relays, store, filter, direction) {
|
|
117
|
+
return this.group(relays).sync(store, filter, direction);
|
|
110
118
|
}
|
|
111
119
|
}
|
package/dist/relay.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import { logger } from "applesauce-core";
|
|
1
|
+
import { IAsyncEventStoreRead, IEventStoreRead, logger } from "applesauce-core";
|
|
2
2
|
import { type Filter, type NostrEvent } from "nostr-tools";
|
|
3
3
|
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
|
+
import { type NegentropySyncOptions, type ReconcileFunction } from "./negentropy.js";
|
|
6
7
|
import { AuthSigner, FilterInput, IRelay, PublishOptions, PublishResponse, RequestOptions, SubscriptionOptions, SubscriptionResponse } from "./types.js";
|
|
8
|
+
/** Flags for the negentropy sync type */
|
|
9
|
+
export declare enum SyncDirection {
|
|
10
|
+
RECEIVE = 1,
|
|
11
|
+
SEND = 2,
|
|
12
|
+
BOTH = 3
|
|
13
|
+
}
|
|
7
14
|
/** An error that is thrown when a REQ is closed from the relay side */
|
|
8
15
|
export declare class ReqCloseError extends Error {
|
|
9
16
|
}
|
|
@@ -56,6 +63,8 @@ export declare class Relay implements IRelay {
|
|
|
56
63
|
protected _nip11: RelayInformation | null;
|
|
57
64
|
/** An observable that emits the limitations for the relay */
|
|
58
65
|
limitations$: Observable<RelayInformation["limitation"] | null>;
|
|
66
|
+
/** An array of supported NIPs from the NIP-11 information document */
|
|
67
|
+
supported$: Observable<number[] | null>;
|
|
59
68
|
/** An observable that emits when underlying websocket is opened */
|
|
60
69
|
open$: Subject<Event>;
|
|
61
70
|
/** An observable that emits when underlying websocket is closed */
|
|
@@ -99,6 +108,8 @@ export declare class Relay implements IRelay {
|
|
|
99
108
|
event(event: NostrEvent, verb?: "EVENT" | "AUTH"): Observable<PublishResponse>;
|
|
100
109
|
/** send and AUTH message */
|
|
101
110
|
auth(event: NostrEvent): Promise<PublishResponse>;
|
|
111
|
+
/** Negentropy sync event ids with the relay and an event store */
|
|
112
|
+
negentropy(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, reconcile: ReconcileFunction, opts?: NegentropySyncOptions): Promise<boolean>;
|
|
102
113
|
/** Authenticate with the relay using a signer */
|
|
103
114
|
authenticate(signer: AuthSigner): Promise<PublishResponse>;
|
|
104
115
|
/** Internal operator for creating the retry() operator */
|
|
@@ -113,12 +124,16 @@ export declare class Relay implements IRelay {
|
|
|
113
124
|
request(filters: Filter | Filter[], opts?: RequestOptions): Observable<NostrEvent>;
|
|
114
125
|
/** Publishes an event to the relay and retries when relay errors or responds with auth-required ( default 3 retries ) */
|
|
115
126
|
publish(event: NostrEvent, opts?: PublishOptions): Promise<PublishResponse>;
|
|
127
|
+
/** Negentropy sync events with the relay and an event store */
|
|
128
|
+
sync(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, direction?: SyncDirection): Observable<NostrEvent>;
|
|
116
129
|
/** Force close the connection */
|
|
117
130
|
close(): void;
|
|
118
131
|
/** An async method that returns the NIP-11 information document for the relay */
|
|
119
132
|
getInformation(): Promise<RelayInformation | null>;
|
|
120
133
|
/** An async method that returns the NIP-11 limitations for the relay */
|
|
121
134
|
getLimitations(): Promise<RelayInformation["limitation"] | null>;
|
|
135
|
+
/** An async method that returns the supported NIPs for the relay */
|
|
136
|
+
getSupported(): Promise<number[] | null>;
|
|
122
137
|
/** Static method to fetch the NIP-11 information document for a relay */
|
|
123
138
|
static fetchInformationDocument(url: string): Observable<RelayInformation | null>;
|
|
124
139
|
/** Static method to create a reconnection method for each relay */
|
package/dist/relay.js
CHANGED
|
@@ -3,11 +3,18 @@ import { ensureHttpURL } from "applesauce-core/helpers";
|
|
|
3
3
|
import { simpleTimeout } from "applesauce-core/observable";
|
|
4
4
|
import { nanoid } from "nanoid";
|
|
5
5
|
import { nip42 } from "nostr-tools";
|
|
6
|
-
import { BehaviorSubject, catchError, combineLatest, defer, endWith, filter, finalize, firstValueFrom, from, identity, ignoreElements, isObservable, lastValueFrom, map, merge, mergeMap, mergeWith, NEVER, of, repeat, retry, scan, share, shareReplay, Subject, switchMap, take, takeUntil, tap, throwError, timeout, timer, } from "rxjs";
|
|
6
|
+
import { BehaviorSubject, catchError, combineLatest, defer, endWith, filter, finalize, firstValueFrom, from, identity, ignoreElements, isObservable, lastValueFrom, map, merge, mergeMap, mergeWith, NEVER, Observable, of, repeat, retry, scan, share, shareReplay, Subject, switchMap, take, takeUntil, tap, throwError, timeout, timer, } from "rxjs";
|
|
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
10
|
const DEFAULT_RETRY_CONFIG = { count: 10, delay: 1000, resetOnSuccess: true };
|
|
11
|
+
/** Flags for the negentropy sync type */
|
|
12
|
+
export var SyncDirection;
|
|
13
|
+
(function (SyncDirection) {
|
|
14
|
+
SyncDirection[SyncDirection["RECEIVE"] = 1] = "RECEIVE";
|
|
15
|
+
SyncDirection[SyncDirection["SEND"] = 2] = "SEND";
|
|
16
|
+
SyncDirection[SyncDirection["BOTH"] = 3] = "BOTH";
|
|
17
|
+
})(SyncDirection || (SyncDirection = {}));
|
|
11
18
|
/** An error that is thrown when a REQ is closed from the relay side */
|
|
12
19
|
export class ReqCloseError extends Error {
|
|
13
20
|
}
|
|
@@ -48,6 +55,8 @@ export class Relay {
|
|
|
48
55
|
_nip11 = null;
|
|
49
56
|
/** An observable that emits the limitations for the relay */
|
|
50
57
|
limitations$;
|
|
58
|
+
/** An array of supported NIPs from the NIP-11 information document */
|
|
59
|
+
supported$;
|
|
51
60
|
/** An observable that emits when underlying websocket is opened */
|
|
52
61
|
open$ = new Subject();
|
|
53
62
|
/** An observable that emits when underlying websocket is closed */
|
|
@@ -153,7 +162,8 @@ export class Relay {
|
|
|
153
162
|
tap((info) => (this._nip11 = info)),
|
|
154
163
|
// cache the result
|
|
155
164
|
shareReplay(1));
|
|
156
|
-
this.limitations$ = this.information$.pipe(map((info) => info
|
|
165
|
+
this.limitations$ = this.information$.pipe(map((info) => (info ? info.limitation : null)));
|
|
166
|
+
this.supported$ = this.information$.pipe(map((info) => info && Array.isArray(info.supported_nips) ? info.supported_nips.filter((n) => typeof n === "number") : null));
|
|
157
167
|
// Create observables that track if auth is required for REQ or EVENT
|
|
158
168
|
this.authRequiredForRead$ = this.receivedAuthRequiredForReq.pipe(tap((required) => required && this.log("Auth required for REQ")), shareReplay(1));
|
|
159
169
|
this.authRequiredForPublish$ = this.receivedAuthRequiredForEvent.pipe(tap((required) => required && this.log("Auth required for EVENT")), shareReplay(1));
|
|
@@ -352,6 +362,16 @@ export class Relay {
|
|
|
352
362
|
// update authenticated
|
|
353
363
|
tap((result) => this.authenticationResponse$.next(result))));
|
|
354
364
|
}
|
|
365
|
+
/** Negentropy sync event ids with the relay and an event store */
|
|
366
|
+
async negentropy(store, filter, reconcile, opts) {
|
|
367
|
+
// Check relay supports NIP-77 sync
|
|
368
|
+
if ((await this.getSupported())?.includes(77) === false)
|
|
369
|
+
throw new Error("Relay does not support NIP-77");
|
|
370
|
+
// Import negentropy functions dynamically
|
|
371
|
+
const { buildStorageVector, buildStorageFromFilter, negentropySync } = await import("./negentropy.js");
|
|
372
|
+
const storage = Array.isArray(store) ? buildStorageVector(store) : await buildStorageFromFilter(store, filter);
|
|
373
|
+
return negentropySync(storage, this.socket, filter, reconcile, opts);
|
|
374
|
+
}
|
|
355
375
|
/** Authenticate with the relay using a signer */
|
|
356
376
|
authenticate(signer) {
|
|
357
377
|
if (!this.challenge)
|
|
@@ -431,6 +451,39 @@ export class Relay {
|
|
|
431
451
|
// Single subscription
|
|
432
452
|
share()));
|
|
433
453
|
}
|
|
454
|
+
/** Negentropy sync events with the relay and an event store */
|
|
455
|
+
sync(store, filter, direction = SyncDirection.RECEIVE) {
|
|
456
|
+
const getEvents = async (ids) => {
|
|
457
|
+
if (Array.isArray(store))
|
|
458
|
+
return store.filter((event) => ids.includes(event.id));
|
|
459
|
+
else
|
|
460
|
+
return store.getByFilters({ ids });
|
|
461
|
+
};
|
|
462
|
+
return new Observable((observer) => {
|
|
463
|
+
const controller = new AbortController();
|
|
464
|
+
this.negentropy(store, filter, async (have, need) => {
|
|
465
|
+
// NOTE: it may be more efficient to sync all the events later in a single batch
|
|
466
|
+
// Send missing events to the relay
|
|
467
|
+
if (direction & SyncDirection.SEND && have.length > 0) {
|
|
468
|
+
const events = await getEvents(have);
|
|
469
|
+
// Send all events to the relay
|
|
470
|
+
await Promise.allSettled(events.map((event) => lastValueFrom(this.event(event))));
|
|
471
|
+
}
|
|
472
|
+
// Fetch missing events from the relay
|
|
473
|
+
if (direction & SyncDirection.RECEIVE && need.length > 0) {
|
|
474
|
+
await lastValueFrom(this.req({ ids: need }).pipe(completeOnEose(), tap((event) => observer.next(event))));
|
|
475
|
+
}
|
|
476
|
+
}, { signal: controller.signal })
|
|
477
|
+
// Complete the observable when the sync is complete
|
|
478
|
+
.then(() => observer.complete())
|
|
479
|
+
// Error the observable when the sync fails
|
|
480
|
+
.catch((err) => observer.error(err));
|
|
481
|
+
// Cancel the sync when the observable is unsubscribed
|
|
482
|
+
return () => controller.abort();
|
|
483
|
+
}).pipe(
|
|
484
|
+
// Only create one upstream subscription
|
|
485
|
+
share());
|
|
486
|
+
}
|
|
434
487
|
/** Force close the connection */
|
|
435
488
|
close() {
|
|
436
489
|
this.socket.unsubscribe();
|
|
@@ -443,6 +496,10 @@ export class Relay {
|
|
|
443
496
|
async getLimitations() {
|
|
444
497
|
return firstValueFrom(this.limitations$);
|
|
445
498
|
}
|
|
499
|
+
/** An async method that returns the supported NIPs for the relay */
|
|
500
|
+
async getSupported() {
|
|
501
|
+
return firstValueFrom(this.supported$);
|
|
502
|
+
}
|
|
446
503
|
/** Static method to fetch the NIP-11 information document for a relay */
|
|
447
504
|
static fetchInformationDocument(url) {
|
|
448
505
|
return from(fetch(ensureHttpURL(url), { headers: { Accept: "application/nostr+json" } }).then((res) => res.json())).pipe(
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { EventTemplate, Filter, NostrEvent } from "nostr-tools";
|
|
2
|
+
import type { RelayInformation } from "nostr-tools/nip11";
|
|
3
|
+
import type { Observable, repeat, retry } from "rxjs";
|
|
4
|
+
import type { WebSocketSubject } from "rxjs/webSocket";
|
|
5
|
+
import type { NegentropySyncOptions, ReconcileFunction } from "./negentropy.js";
|
|
6
|
+
import type { IAsyncEventStoreRead, IEventStoreRead } from "applesauce-core";
|
|
7
|
+
import type { SyncDirection } from "./relay.js";
|
|
8
|
+
import type { GroupNegentropySyncOptions, GroupRequestOptions, GroupSubscriptionOptions } from "./group.js";
|
|
4
9
|
export type SubscriptionResponse = NostrEvent | "EOSE";
|
|
5
10
|
export type PublishResponse = {
|
|
6
11
|
ok: boolean;
|
|
@@ -60,6 +65,9 @@ export interface IRelay extends MultiplexWebSocket {
|
|
|
60
65
|
challenge$: Observable<string | null>;
|
|
61
66
|
authenticated$: Observable<boolean>;
|
|
62
67
|
notices$: Observable<string[]>;
|
|
68
|
+
open$: Observable<Event>;
|
|
69
|
+
close$: Observable<CloseEvent>;
|
|
70
|
+
closing$: Observable<void>;
|
|
63
71
|
error$: Observable<Error | null>;
|
|
64
72
|
readonly connected: boolean;
|
|
65
73
|
readonly authenticated: boolean;
|
|
@@ -73,6 +81,8 @@ export interface IRelay extends MultiplexWebSocket {
|
|
|
73
81
|
event(event: NostrEvent): Observable<PublishResponse>;
|
|
74
82
|
/** Send an AUTH message */
|
|
75
83
|
auth(event: NostrEvent): Promise<PublishResponse>;
|
|
84
|
+
/** Negentropy sync event ids with the relay and an event store */
|
|
85
|
+
negentropy(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, reconcile: ReconcileFunction, opts?: NegentropySyncOptions): Promise<boolean>;
|
|
76
86
|
/** Authenticate with the relay using a signer */
|
|
77
87
|
authenticate(signer: AuthSigner): Promise<PublishResponse>;
|
|
78
88
|
/** Send an EVENT message with retries */
|
|
@@ -81,18 +91,30 @@ export interface IRelay extends MultiplexWebSocket {
|
|
|
81
91
|
request(filters: FilterInput, opts?: RequestOptions): Observable<NostrEvent>;
|
|
82
92
|
/** Open a subscription with retries */
|
|
83
93
|
subscription(filters: FilterInput, opts?: SubscriptionOptions): Observable<SubscriptionResponse>;
|
|
94
|
+
/** Negentropy sync events with the relay and an event store */
|
|
95
|
+
sync(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, direction?: SyncDirection): Observable<NostrEvent>;
|
|
96
|
+
/** Get the NIP-11 information document for the relay */
|
|
97
|
+
getInformation(): Promise<RelayInformation | null>;
|
|
98
|
+
/** Get the limitations for the relay */
|
|
99
|
+
getLimitations(): Promise<RelayInformation["limitation"] | null>;
|
|
100
|
+
/** Get the supported NIPs for the relay */
|
|
101
|
+
getSupported(): Promise<number[] | null>;
|
|
84
102
|
}
|
|
85
103
|
export interface IGroup {
|
|
86
104
|
/** Send a REQ message */
|
|
87
105
|
req(filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
|
|
88
106
|
/** Send an EVENT message */
|
|
89
107
|
event(event: NostrEvent): Observable<PublishResponse>;
|
|
108
|
+
/** Negentropy sync event ids with the relays and an event store */
|
|
109
|
+
negentropy(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, reconcile: ReconcileFunction, opts?: NegentropySyncOptions): Promise<boolean>;
|
|
90
110
|
/** Send an EVENT message with retries */
|
|
91
111
|
publish(event: NostrEvent, opts?: PublishOptions): Promise<PublishResponse[]>;
|
|
92
112
|
/** Send a REQ message with retries */
|
|
93
|
-
request(filters: FilterInput, opts?:
|
|
113
|
+
request(filters: FilterInput, opts?: GroupRequestOptions): Observable<NostrEvent>;
|
|
94
114
|
/** Open a subscription with retries */
|
|
95
|
-
subscription(filters: FilterInput, opts?:
|
|
115
|
+
subscription(filters: FilterInput, opts?: GroupSubscriptionOptions): Observable<SubscriptionResponse>;
|
|
116
|
+
/** Negentropy sync events with the relay and an event store */
|
|
117
|
+
sync(store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, direction?: SyncDirection): Observable<NostrEvent>;
|
|
96
118
|
}
|
|
97
119
|
/** Signals emitted by the pool */
|
|
98
120
|
export interface IPoolSignals {
|
|
@@ -110,10 +132,14 @@ export interface IPool extends IPoolSignals {
|
|
|
110
132
|
req(relays: string[], filters: FilterInput, id?: string): Observable<SubscriptionResponse>;
|
|
111
133
|
/** Send an EVENT message */
|
|
112
134
|
event(relays: string[], event: NostrEvent): Observable<PublishResponse>;
|
|
135
|
+
/** Negentropy sync event ids with the relays and an event store */
|
|
136
|
+
negentropy(relays: string[], store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, reconcile: ReconcileFunction, opts?: GroupNegentropySyncOptions): Promise<boolean>;
|
|
113
137
|
/** Send an EVENT message to relays with retries */
|
|
114
138
|
publish(relays: string[], event: Parameters<IGroup["publish"]>[0], opts?: Parameters<IGroup["publish"]>[1]): Promise<PublishResponse[]>;
|
|
115
139
|
/** Send a REQ message to relays with retries */
|
|
116
140
|
request(relays: string[], filters: Parameters<IGroup["request"]>[0], opts?: Parameters<IGroup["request"]>[1]): Observable<NostrEvent>;
|
|
117
141
|
/** Open a subscription to relays with retries */
|
|
118
142
|
subscription(relays: string[], filters: Parameters<IGroup["subscription"]>[0], opts?: Parameters<IGroup["subscription"]>[1]): Observable<SubscriptionResponse>;
|
|
143
|
+
/** Negentropy sync events with the relay and an event store */
|
|
144
|
+
sync(relays: string[], store: IEventStoreRead | IAsyncEventStoreRead | NostrEvent[], filter: Filter, direction?: SyncDirection): Observable<NostrEvent>;
|
|
119
145
|
}
|
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-20251015123951",
|
|
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-20251015123951",
|
|
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-20251015123951",
|
|
63
63
|
"rimraf": "^6.0.1",
|
|
64
64
|
"typescript": "^5.7.3",
|
|
65
65
|
"vitest": "^3.2.4",
|