applesauce-relay 0.0.0-next-20250327153627 → 0.0.0-next-20250330153313

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,97 @@
1
1
  # Applesauce Relay
2
2
 
3
3
  `applesauce-relay` is a nostr relay communication framework built on top of [RxJS](https://rxjs.dev/)
4
+
5
+ ## Examples
6
+
7
+ ### Single Relay
8
+
9
+ ```typescript
10
+ import { Relay } from "./relay";
11
+
12
+ // Connect to a single relay
13
+ const relay = new Relay("wss://relay.example.com");
14
+
15
+ // Subscribe to events
16
+ relay
17
+ .req({
18
+ kinds: [1],
19
+ limit: 10,
20
+ })
21
+ .subscribe((response) => {
22
+ if (response === "EOSE") {
23
+ console.log("End of stored events");
24
+ } else {
25
+ console.log("Received event:", response);
26
+ }
27
+ });
28
+
29
+ // Publish an event
30
+ const event = {
31
+ kind: 1,
32
+ content: "Hello Nostr!",
33
+ created_at: Math.floor(Date.now() / 1000),
34
+ tags: [],
35
+ // ... other required fields
36
+ };
37
+
38
+ relay.event(event).subscribe((response) => {
39
+ console.log(`Published:`, response.ok);
40
+ });
41
+ ```
42
+
43
+ ### Relay Pool
44
+
45
+ ```typescript
46
+ import { RelayPool } from "./pool";
47
+
48
+ // Create a pool and connect to multiple relays
49
+ const pool = new RelayPool();
50
+ const relays = ["wss://relay1.example.com", "wss://relay2.example.com"];
51
+
52
+ // Subscribe to events from multiple relays
53
+ pool
54
+ .req(relays, {
55
+ kinds: [1],
56
+ limit: 10,
57
+ })
58
+ .subscribe((response) => {
59
+ if (response === "EOSE") {
60
+ console.log("End of stored events");
61
+ } else {
62
+ console.log("Received event:", response);
63
+ }
64
+ });
65
+
66
+ // Publish to multiple relays
67
+ pool.event(relays, event).subscribe((response) => {
68
+ console.log(`Published to ${response.from}:`, response.ok);
69
+ });
70
+ ```
71
+
72
+ ### Relay Group
73
+
74
+ ```typescript
75
+ import { RelayPool } from "./pool";
76
+
77
+ const pool = new RelayPool();
78
+ const relays = ["wss://relay1.example.com", "wss://relay2.example.com"];
79
+
80
+ // Create a group (automatically deduplicates events)
81
+ const group = pool.group(relays);
82
+
83
+ // Subscribe to events
84
+ group
85
+ .req({
86
+ kinds: [1],
87
+ limit: 10,
88
+ })
89
+ .subscribe((response) => {
90
+ console.log("Received:", response);
91
+ });
92
+
93
+ // Publish to all relays in group
94
+ group.event(event).subscribe((response) => {
95
+ console.log(`Published to ${response.from}:`, response.ok);
96
+ });
97
+ ```
@@ -0,0 +1,10 @@
1
+ import { NostrEvent } from "nostr-tools";
2
+ import { Observable } from "rxjs";
3
+ import { Filter } from "nostr-tools";
4
+ import { IRelay, Nip01Actions, PublishResponse, SubscriptionResponse } from "./types.js";
5
+ export declare class RelayGroup implements Nip01Actions {
6
+ relays: IRelay[];
7
+ constructor(relays: IRelay[]);
8
+ req(filters: Filter | Filter[], id?: string): Observable<SubscriptionResponse>;
9
+ event(event: NostrEvent): Observable<PublishResponse>;
10
+ }
package/dist/group.js ADDED
@@ -0,0 +1,23 @@
1
+ import { combineLatest, filter, map, merge } from "rxjs";
2
+ import { onlyEvents } from "./operators/only-events.js";
3
+ import { nanoid } from "nanoid";
4
+ export class RelayGroup {
5
+ relays;
6
+ constructor(relays) {
7
+ this.relays = relays;
8
+ }
9
+ req(filters, id = nanoid()) {
10
+ const requests = this.relays.reduce((acc, r) => ({ ...acc, [r.url]: r.req(filters, id) }), {});
11
+ // Create stream of events only
12
+ const events = merge(...Object.values(requests)).pipe(onlyEvents());
13
+ // Create stream that emits EOSE when all relays have sent EOSE
14
+ const eose = combineLatest(
15
+ // Create a new map of requests that only emits EOSE
16
+ Object.fromEntries(Object.entries(requests).map(([url, observable]) => [url, observable.pipe(filter((m) => m === "EOSE"))]))).pipe(map(() => "EOSE"));
17
+ // Merge events and the single EOSE stream
18
+ return merge(events, eose);
19
+ }
20
+ event(event) {
21
+ return merge(...this.relays.map((r) => r.event(event)));
22
+ }
23
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,4 @@
1
+ export * from "./group.js";
1
2
  export * from "./pool.js";
2
3
  export * from "./relay.js";
4
+ export * from "./types.js";
package/dist/index.js CHANGED
@@ -1,2 +1,4 @@
1
+ export * from "./group.js";
1
2
  export * from "./pool.js";
2
3
  export * from "./relay.js";
4
+ export * from "./types.js";
@@ -1,4 +1,4 @@
1
1
  import { MonoTypeOperatorFunction } from "rxjs";
2
- import { SubscriptionResponse } from "../relay.js";
2
+ import { SubscriptionResponse } from "../types.js";
3
3
  /** Marks all events as from the relay */
4
4
  export declare function markFromRelay(relay: string): MonoTypeOperatorFunction<SubscriptionResponse>;
@@ -1,5 +1,5 @@
1
1
  import { OperatorFunction } from "rxjs";
2
2
  import { NostrEvent } from "nostr-tools";
3
- import { SubscriptionResponse } from "../relay.js";
3
+ import { SubscriptionResponse } from "../types.js";
4
4
  /** Filter subscription responses and only return the events */
5
5
  export declare function onlyEvents(): OperatorFunction<SubscriptionResponse, NostrEvent>;
package/dist/pool.d.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import { NostrEvent, type Filter } from "nostr-tools";
2
2
  import { Observable } from "rxjs";
3
- import { Relay, PublishResponse, SubscriptionResponse } from "./relay.js";
3
+ import { Relay, RelayOptions } from "./relay.js";
4
+ import { PublishResponse, SubscriptionResponse } from "./types.js";
5
+ import { RelayGroup } from "./group.js";
4
6
  export declare class RelayPool {
7
+ options?: RelayOptions | undefined;
5
8
  relays: Map<string, Relay>;
9
+ groups: Map<string, RelayGroup>;
10
+ constructor(options?: RelayOptions | undefined);
6
11
  /** Get or create a new relay connection */
7
12
  relay(url: string): Relay;
8
- /** Make a REQ to multiple relays but does not deduplicate events */
9
- req(relays: string[], filters: Filter[], id?: string): Observable<SubscriptionResponse>;
13
+ /** Create a group of relays */
14
+ group(relays: string[]): RelayGroup;
15
+ /** Make a REQ to multiple relays that does not deduplicate events */
16
+ req(relays: string[], filters: Filter | Filter[], id?: string): Observable<SubscriptionResponse>;
10
17
  /** Send an EVENT message to multiple relays */
11
- event(relays: string[], event: NostrEvent): Observable<PublishResponse[]>;
18
+ event(relays: string[], event: NostrEvent): Observable<PublishResponse>;
12
19
  }
package/dist/pool.js CHANGED
@@ -1,41 +1,39 @@
1
- import { combineLatest, endWith, ignoreElements, merge, takeWhile } from "rxjs";
2
- import { nanoid } from "nanoid";
3
1
  import { Relay } from "./relay.js";
4
- import { onlyEvents } from "./operators/only-events.js";
2
+ import { RelayGroup } from "./group.js";
5
3
  export class RelayPool {
4
+ options;
6
5
  relays = new Map();
6
+ groups = new Map();
7
+ constructor(options) {
8
+ this.options = options;
9
+ }
7
10
  /** Get or create a new relay connection */
8
11
  relay(url) {
9
12
  let relay = this.relays.get(url);
10
13
  if (relay)
11
14
  return relay;
12
15
  else {
13
- relay = new Relay(url);
16
+ relay = new Relay(url, this.options);
14
17
  this.relays.set(url, relay);
15
18
  return relay;
16
19
  }
17
20
  }
18
- /** Make a REQ to multiple relays but does not deduplicate events */
19
- req(relays, filters, id = nanoid()) {
20
- // create a REQ observable for each relay
21
- const requests = relays.map((url) => this.relay(url).req(filters, id));
22
- // create an observable that completes when all relays send EOSE
23
- const eose = merge(
24
- // create an array of observables for each relay that completes when EOSE
25
- ...requests.map((o) => o.pipe(
26
- // complete on EOSE message
27
- takeWhile((m) => m !== "EOSE")))).pipe(
28
- // ignore all messages
29
- ignoreElements(),
30
- // emit EOSE on complete
31
- endWith("EOSE"));
32
- // create a stream that only emits events
33
- const events = merge(...requests).pipe(onlyEvents());
34
- // merge events and single EOSE streams
35
- return merge(events, eose);
21
+ /** Create a group of relays */
22
+ group(relays) {
23
+ const key = relays.sort().join(",");
24
+ let group = this.groups.get(key);
25
+ if (group)
26
+ return group;
27
+ group = new RelayGroup(relays.map((url) => this.relay(url)));
28
+ this.groups.set(key, group);
29
+ return group;
30
+ }
31
+ /** Make a REQ to multiple relays that does not deduplicate events */
32
+ req(relays, filters, id) {
33
+ return this.group(relays).req(filters, id);
36
34
  }
37
35
  /** Send an EVENT message to multiple relays */
38
36
  event(relays, event) {
39
- return combineLatest(relays.map((url) => this.relay(url).event(event)));
37
+ return this.group(relays).event(event);
40
38
  }
41
39
  }
package/dist/relay.d.ts CHANGED
@@ -2,29 +2,35 @@ import { BehaviorSubject, Observable } from "rxjs";
2
2
  import { WebSocketSubject, WebSocketSubjectConfig } from "rxjs/webSocket";
3
3
  import { type Filter, type NostrEvent } from "nostr-tools";
4
4
  import { logger } from "applesauce-core";
5
- export type SubscriptionResponse = "EOSE" | NostrEvent;
6
- export type PublishResponse = {
7
- ok: boolean;
8
- message?: string;
9
- from: string;
10
- };
5
+ import { IRelay, PublishResponse, SubscriptionResponse } from "./types.js";
11
6
  export type RelayOptions = {
12
7
  WebSocket?: WebSocketSubjectConfig<any>["WebSocketCtor"];
13
8
  };
14
- export declare class Relay {
9
+ export declare class Relay implements IRelay {
15
10
  url: string;
16
- log: typeof logger;
17
- socket$: WebSocketSubject<any>;
11
+ protected log: typeof logger;
12
+ protected socket: WebSocketSubject<any>;
18
13
  connected$: BehaviorSubject<boolean>;
19
- challenge$: Observable<string>;
14
+ challenge$: BehaviorSubject<string | null>;
20
15
  authenticated$: BehaviorSubject<boolean>;
21
- notices$: Observable<string>;
16
+ notices$: BehaviorSubject<string[]>;
17
+ /** An observable of all messages from the relay */
18
+ message$: Observable<any>;
19
+ /** An observable of NOTICE messages from the relay */
20
+ notice$: Observable<string>;
21
+ get connected(): boolean;
22
+ get challenge(): string | null;
23
+ get notices(): string[];
24
+ get authenticated(): boolean;
22
25
  protected authRequiredForReq: BehaviorSubject<boolean>;
23
26
  protected authRequiredForPublish: BehaviorSubject<boolean>;
24
- protected reset(): void;
27
+ protected resetState(): void;
28
+ /** An internal observable that is responsible for watching all messages and updating state */
29
+ protected watchTower: Observable<never>;
25
30
  constructor(url: string, opts?: RelayOptions);
26
31
  protected waitForAuth<T extends unknown = unknown>(requireAuth: Observable<boolean>, observable: Observable<T>): Observable<T>;
27
- req(filters: Filter[], id?: string): Observable<SubscriptionResponse>;
32
+ multiplex<T>(open: () => any, close: () => any, filter: (message: any) => boolean): Observable<T>;
33
+ req(filters: Filter | Filter[], id?: string): Observable<SubscriptionResponse>;
28
34
  /** send an Event message */
29
35
  event(event: NostrEvent, verb?: "EVENT" | "AUTH"): Observable<PublishResponse>;
30
36
  /** send and AUTH message */
package/dist/relay.js CHANGED
@@ -1,75 +1,122 @@
1
- import { BehaviorSubject, combineLatest, EMPTY, filter, map, merge, NEVER, of, shareReplay, switchMap, take, takeWhile, tap, timeout, } from "rxjs";
1
+ import { BehaviorSubject, combineLatest, filter, ignoreElements, map, merge, NEVER, of, scan, share, switchMap, take, takeWhile, tap, timeout, } from "rxjs";
2
2
  import { webSocket } from "rxjs/webSocket";
3
3
  import { nanoid } from "nanoid";
4
4
  import { logger } from "applesauce-core";
5
5
  import { markFromRelay } from "./operators/mark-from-relay.js";
6
6
  export class Relay {
7
7
  url;
8
- log = logger.extend("Bakery");
9
- socket$;
8
+ log = logger.extend("Relay");
9
+ socket;
10
10
  connected$ = new BehaviorSubject(false);
11
- challenge$;
11
+ challenge$ = new BehaviorSubject(null);
12
12
  authenticated$ = new BehaviorSubject(false);
13
- notices$;
13
+ notices$ = new BehaviorSubject([]);
14
+ /** An observable of all messages from the relay */
15
+ message$;
16
+ /** An observable of NOTICE messages from the relay */
17
+ notice$;
18
+ // sync state
19
+ get connected() {
20
+ return this.connected$.value;
21
+ }
22
+ get challenge() {
23
+ return this.challenge$.value;
24
+ }
25
+ get notices() {
26
+ return this.notices$.value;
27
+ }
28
+ get authenticated() {
29
+ return this.authenticated$.value;
30
+ }
14
31
  authRequiredForReq = new BehaviorSubject(false);
15
32
  authRequiredForPublish = new BehaviorSubject(false);
16
- reset() {
17
- this.authenticated$.next(false);
18
- this.authRequiredForReq.next(false);
19
- this.authRequiredForPublish.next(false);
33
+ resetState() {
34
+ // NOTE: only update the values if they need to be changed, otherwise this will cause an infinite loop
35
+ if (this.challenge$.value !== null)
36
+ this.challenge$.next(null);
37
+ if (this.authenticated$.value)
38
+ this.authenticated$.next(false);
39
+ if (this.notices$.value.length > 0)
40
+ this.notices$.next([]);
41
+ if (this.authRequiredForReq.value)
42
+ this.authRequiredForReq.next(false);
43
+ if (this.authRequiredForPublish.value)
44
+ this.authRequiredForPublish.next(false);
20
45
  }
46
+ /** An internal observable that is responsible for watching all messages and updating state */
47
+ watchTower;
21
48
  constructor(url, opts) {
22
49
  this.url = url;
23
50
  this.log = this.log.extend(url);
24
- this.socket$ = webSocket({
51
+ this.socket = webSocket({
25
52
  url,
26
53
  openObserver: {
27
54
  next: () => {
28
55
  this.log("Connected");
29
56
  this.connected$.next(true);
30
- this.reset();
57
+ this.resetState();
31
58
  },
32
59
  },
33
60
  closeObserver: {
34
61
  next: () => {
35
62
  this.log("Disconnected");
36
63
  this.connected$.next(false);
37
- this.reset();
64
+ this.resetState();
38
65
  },
39
66
  },
40
67
  WebSocketCtor: opts?.WebSocket,
41
68
  });
42
- // create an observable for listening for AUTH
43
- this.challenge$ = this.socket$.pipe(
44
- // listen for AUTH messages
45
- filter((message) => message[0] === "AUTH"),
46
- // pick the challenge string out
47
- map((m) => m[1]),
48
- // cache and share the challenge
49
- shareReplay(1));
50
- this.notices$ = this.socket$.pipe(
69
+ this.message$ = this.socket.asObservable();
70
+ this.notice$ = this.message$.pipe(
51
71
  // listen for NOTICE messages
52
72
  filter((m) => m[0] === "NOTICE"),
53
73
  // pick the string out of the message
54
74
  map((m) => m[1]));
75
+ // Update the notices state
76
+ const notice = this.notice$.pipe(
77
+ // Track all notices
78
+ scan((acc, notice) => [...acc, notice], []),
79
+ // Update the notices state
80
+ tap((notices) => this.notices$.next(notices)));
81
+ // Update the challenge state
82
+ const challenge = this.message$.pipe(
83
+ // listen for AUTH messages
84
+ filter((message) => message[0] === "AUTH"),
85
+ // pick the challenge string out
86
+ map((m) => m[1]),
87
+ // Update the challenge state
88
+ tap((challenge) => {
89
+ this.log("Received AUTH challenge", challenge);
90
+ this.challenge$.next(challenge);
91
+ }));
92
+ // Merge all watchers
93
+ this.watchTower = merge(notice, challenge).pipe(
94
+ // Never emit any values
95
+ ignoreElements(),
96
+ // There should only be a single watch tower
97
+ share());
55
98
  }
56
99
  waitForAuth(requireAuth, observable) {
57
100
  return combineLatest([requireAuth, this.authenticated$]).pipe(
58
101
  // return EMPTY if auth is required and not authenticated
59
102
  switchMap(([required, authenticated]) => {
60
103
  if (required && !authenticated)
61
- return EMPTY;
104
+ return NEVER;
62
105
  else
63
106
  return observable;
64
107
  }));
65
108
  }
109
+ multiplex(open, close, filter) {
110
+ return this.socket.multiplex(open, close, filter);
111
+ }
66
112
  req(filters, id = nanoid()) {
67
- return this.waitForAuth(this.authRequiredForReq, this.socket$
68
- .multiplex(() => ["REQ", id, ...filters], () => ["CLOSE", id], (message) => (message[0] === "EVENT" || message[0] === "CLOSE" || message[0] === "EOSE") && message[1] === id)
113
+ const request = this.socket
114
+ .multiplex(() => (Array.isArray(filters) ? ["REQ", id, ...filters] : ["REQ", id, filters]), () => ["CLOSE", id], (message) => (message[0] === "EVENT" || message[0] === "CLOSE" || message[0] === "EOSE") && message[1] === id)
69
115
  .pipe(
70
116
  // listen for CLOSE auth-required
71
117
  tap((m) => {
72
118
  if (m[0] === "CLOSE" && m[1].startsWith("auth-required") && !this.authRequiredForReq.value) {
119
+ this.log("Auth required for REQ");
73
120
  this.authRequiredForReq.next(true);
74
121
  }
75
122
  }),
@@ -85,14 +132,17 @@ export class Relay {
85
132
  // mark events as from relays
86
133
  markFromRelay(this.url),
87
134
  // if no events are seen in 10s, emit EOSE
135
+ // TODO: this should emit EOSE event if events are seen, the timeout should be for only the EOSE message
88
136
  timeout({
89
137
  first: 10_000,
90
138
  with: () => merge(of("EOSE"), NEVER),
91
- })));
139
+ }));
140
+ // Wait for auth if required and make sure to start the watch tower
141
+ return this.waitForAuth(this.authRequiredForReq, merge(this.watchTower, request));
92
142
  }
93
143
  /** send an Event message */
94
144
  event(event, verb = "EVENT") {
95
- const observable = this.socket$
145
+ const observable = this.socket
96
146
  .multiplex(() => [verb, event], () => void 0, (m) => m[0] === "OK" && m[1] === event.id)
97
147
  .pipe(
98
148
  // format OK message
@@ -102,14 +152,16 @@ export class Relay {
102
152
  // listen for OK auth-required
103
153
  tap(({ ok, message }) => {
104
154
  if (ok === false && message.startsWith("auth-required") && !this.authRequiredForPublish.value) {
155
+ this.log("Auth required for publish");
105
156
  this.authRequiredForPublish.next(true);
106
157
  }
107
158
  }));
159
+ const withWatchTower = merge(this.watchTower, observable);
108
160
  // skip wait for auth if verb is AUTH
109
161
  if (verb === "AUTH")
110
- return observable;
162
+ return withWatchTower;
111
163
  else
112
- return this.waitForAuth(this.authRequiredForPublish, observable);
164
+ return this.waitForAuth(this.authRequiredForPublish, withWatchTower);
113
165
  }
114
166
  /** send and AUTH message */
115
167
  auth(event) {
@@ -0,0 +1,42 @@
1
+ import { Filter, NostrEvent } from "nostr-tools";
2
+ import { Observable } from "rxjs";
3
+ import { WebSocketSubject } from "rxjs/webSocket";
4
+ export type SubscriptionResponse = "EOSE" | NostrEvent;
5
+ export type PublishResponse = {
6
+ ok: boolean;
7
+ message?: string;
8
+ from: string;
9
+ };
10
+ export type MultiplexWebSocket<T = any> = Pick<WebSocketSubject<T>, "multiplex">;
11
+ export interface IRelayState {
12
+ connected$: Observable<boolean>;
13
+ challenge$: Observable<string | null>;
14
+ authenticated$: Observable<boolean>;
15
+ notices$: Observable<string[]>;
16
+ }
17
+ export interface Nip01Actions {
18
+ /** Send an EVENT message */
19
+ event(event: NostrEvent): Observable<PublishResponse>;
20
+ /** Send a REQ message */
21
+ req(filters: Filter | Filter[], id?: string): Observable<SubscriptionResponse>;
22
+ }
23
+ export interface IRelay extends MultiplexWebSocket, Nip01Actions, IRelayState {
24
+ url: string;
25
+ message$: Observable<any>;
26
+ notice$: Observable<string>;
27
+ readonly connected: boolean;
28
+ readonly authenticated: boolean;
29
+ readonly challenge: string | null;
30
+ readonly notices: string[];
31
+ /** Send an AUTH message */
32
+ auth(event: NostrEvent): Observable<{
33
+ ok: boolean;
34
+ message?: string;
35
+ }>;
36
+ }
37
+ export interface IPoolActions {
38
+ /** Send an EVENT message */
39
+ event(relays: string[], event: NostrEvent): Observable<PublishResponse[]>;
40
+ /** Send a REQ message */
41
+ req(relays: string[], filters: Filter | Filter[]): Observable<SubscriptionResponse[]>;
42
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "applesauce-relay",
3
- "version": "0.0.0-next-20250327153627",
3
+ "version": "0.0.0-next-20250330153313",
4
4
  "description": "A collection of observable based loaders built on rx-nostr",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,6 +21,21 @@
21
21
  "require": "./dist/index.js",
22
22
  "types": "./dist/index.d.ts"
23
23
  },
24
+ "./pool": {
25
+ "import": "./dist/pool.js",
26
+ "require": "./dist/pool.js",
27
+ "types": "./dist/pool.d.ts"
28
+ },
29
+ "./relay": {
30
+ "import": "./dist/relay.js",
31
+ "require": "./dist/relay.js",
32
+ "types": "./dist/relay.d.ts"
33
+ },
34
+ "./types": {
35
+ "import": "./dist/types.js",
36
+ "require": "./dist/types.js",
37
+ "types": "./dist/types.d.ts"
38
+ },
24
39
  "./operators": {
25
40
  "import": "./dist/operators/index.js",
26
41
  "require": "./dist/operators/index.js",
@@ -33,7 +48,7 @@
33
48
  }
34
49
  },
35
50
  "dependencies": {
36
- "applesauce-core": "0.0.0-next-20250327153627",
51
+ "applesauce-core": "0.0.0-next-20250330153313",
37
52
  "nanoid": "^5.0.9",
38
53
  "nostr-tools": "^2.10.4",
39
54
  "rxjs": "^7.8.1"