applesauce-relay 0.12.0 → 1.0.1

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/relay.js CHANGED
@@ -1,115 +1,283 @@
1
- import { BehaviorSubject, combineLatest, EMPTY, filter, map, merge, NEVER, of, shareReplay, switchMap, take, takeWhile, tap, timeout, } from "rxjs";
2
- import { webSocket } from "rxjs/webSocket";
3
- import { nanoid } from "nanoid";
4
1
  import { logger } from "applesauce-core";
2
+ import { simpleTimeout } from "applesauce-core/observable";
3
+ import { nanoid } from "nanoid";
4
+ import { nip42 } from "nostr-tools";
5
+ import { BehaviorSubject, catchError, combineLatest, defer, filter, from, ignoreElements, map, merge, mergeMap, NEVER, of, retry, scan, share, shareReplay, switchMap, take, tap, throwError, timeout, timer, } from "rxjs";
6
+ import { webSocket } from "rxjs/webSocket";
7
+ import { completeOnEose } from "./operators/complete-on-eose.js";
5
8
  import { markFromRelay } from "./operators/mark-from-relay.js";
9
+ /** An error that is thrown when a REQ is closed from the relay side */
10
+ export class ReqCloseError extends Error {
11
+ }
6
12
  export class Relay {
7
13
  url;
8
- log = logger.extend("Bakery");
9
- socket$;
14
+ log = logger.extend("Relay");
15
+ socket;
16
+ /** Whether the relay is ready for subscriptions or event publishing. setting this to false will cause all .req and .event observables to hang until the relay is ready */
17
+ ready$ = new BehaviorSubject(true);
18
+ /** A method that returns an Observable that emits when the relay should reconnect */
19
+ reconnectTimer;
20
+ /** How many times the relay has tried to reconnect */
21
+ attempts$ = new BehaviorSubject(0);
22
+ /** Whether the relay is connected */
10
23
  connected$ = new BehaviorSubject(false);
11
- challenge$;
24
+ /** The authentication challenge string from the relay */
25
+ challenge$ = new BehaviorSubject(null);
26
+ /** Whether the client is authenticated with the relay */
12
27
  authenticated$ = new BehaviorSubject(false);
13
- notices$;
14
- authRequiredForReq = new BehaviorSubject(false);
15
- authRequiredForPublish = new BehaviorSubject(false);
16
- reset() {
17
- this.authenticated$.next(false);
18
- this.authRequiredForReq.next(false);
19
- this.authRequiredForPublish.next(false);
28
+ /** The notices from the relay */
29
+ notices$ = new BehaviorSubject([]);
30
+ /** An observable that emits the NIP-11 information document for the relay */
31
+ information$;
32
+ _nip11 = null;
33
+ /** An observable that emits the limitations for the relay */
34
+ limitations$;
35
+ /** An observable of all messages from the relay */
36
+ message$;
37
+ /** An observable of NOTICE messages from the relay */
38
+ notice$;
39
+ // sync state
40
+ get connected() {
41
+ return this.connected$.value;
42
+ }
43
+ get challenge() {
44
+ return this.challenge$.value;
20
45
  }
46
+ get notices() {
47
+ return this.notices$.value;
48
+ }
49
+ get authenticated() {
50
+ return this.authenticated$.value;
51
+ }
52
+ get information() {
53
+ return this._nip11;
54
+ }
55
+ /** If an EOSE message is not seen in this time, emit one locally */
56
+ eoseTimeout = 10_000;
57
+ /** How long to wait for an OK message from the relay */
58
+ eventTimeout = 10_000;
59
+ /** How long to keep the connection alive after nothing is subscribed */
60
+ keepAlive = 30_000;
61
+ // subjects that track if an "auth-required" message has been received for REQ or EVENT
62
+ receivedAuthRequiredForReq = new BehaviorSubject(false);
63
+ receivedAuthRequiredForEvent = new BehaviorSubject(false);
64
+ // Computed observables that track if auth is required for REQ or EVENT
65
+ authRequiredForReq;
66
+ authRequiredForEvent;
67
+ resetState() {
68
+ // NOTE: only update the values if they need to be changed, otherwise this will cause an infinite loop
69
+ if (this.challenge$.value !== null)
70
+ this.challenge$.next(null);
71
+ if (this.authenticated$.value)
72
+ this.authenticated$.next(false);
73
+ if (this.notices$.value.length > 0)
74
+ this.notices$.next([]);
75
+ if (this.receivedAuthRequiredForReq.value)
76
+ this.receivedAuthRequiredForReq.next(false);
77
+ if (this.receivedAuthRequiredForEvent.value)
78
+ this.receivedAuthRequiredForEvent.next(false);
79
+ }
80
+ /** An internal observable that is responsible for watching all messages and updating state */
81
+ watchTower;
21
82
  constructor(url, opts) {
22
83
  this.url = url;
23
84
  this.log = this.log.extend(url);
24
- this.socket$ = webSocket({
85
+ /** Use the static method to create a new reconnect method for this relay */
86
+ this.reconnectTimer = Relay.createReconnectTimer(url);
87
+ this.socket = webSocket({
25
88
  url,
26
89
  openObserver: {
27
90
  next: () => {
28
91
  this.log("Connected");
29
92
  this.connected$.next(true);
30
- this.reset();
93
+ this.attempts$.next(0);
94
+ this.resetState();
31
95
  },
32
96
  },
33
97
  closeObserver: {
34
- next: () => {
98
+ next: (event) => {
35
99
  this.log("Disconnected");
36
100
  this.connected$.next(false);
37
- this.reset();
101
+ this.attempts$.next(this.attempts$.value + 1);
102
+ this.resetState();
103
+ // Start the reconnect timer if the connection was not closed cleanly
104
+ if (!event.wasClean)
105
+ this.startReconnectTimer(event);
38
106
  },
39
107
  },
40
108
  WebSocketCtor: opts?.WebSocket,
41
109
  });
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(
110
+ this.message$ = this.socket.asObservable();
111
+ // Create an observable to fetch the NIP-11 information document
112
+ this.information$ = defer(() => {
113
+ this.log("Fetching NIP-11 information document");
114
+ return Relay.fetchInformationDocument(this.url);
115
+ }).pipe(
116
+ // if the fetch fails, return null
117
+ catchError(() => of(null)),
118
+ // cache the result
119
+ shareReplay(1),
120
+ // update the internal state
121
+ tap((info) => (this._nip11 = info)));
122
+ this.limitations$ = this.information$.pipe(map((info) => info?.limitation));
123
+ // Create observables that track if auth is required for REQ or EVENT
124
+ this.authRequiredForReq = combineLatest([this.receivedAuthRequiredForReq, this.limitations$]).pipe(map(([received, limitations]) => received || limitations?.auth_required === true), tap((required) => required && this.log("Auth required for REQ")), shareReplay(1));
125
+ this.authRequiredForEvent = combineLatest([this.receivedAuthRequiredForEvent, this.limitations$]).pipe(map(([received, limitations]) => received || limitations?.auth_required === true), tap((required) => required && this.log("Auth required for EVENT")), shareReplay(1));
126
+ this.notice$ = this.message$.pipe(
51
127
  // listen for NOTICE messages
52
128
  filter((m) => m[0] === "NOTICE"),
53
129
  // pick the string out of the message
54
130
  map((m) => m[1]));
131
+ // Update the notices state
132
+ const notice = this.notice$.pipe(
133
+ // Track all notices
134
+ scan((acc, notice) => [...acc, notice], []),
135
+ // Update the notices state
136
+ tap((notices) => this.notices$.next(notices)));
137
+ // Update the challenge state
138
+ const challenge = this.message$.pipe(
139
+ // listen for AUTH messages
140
+ filter((message) => message[0] === "AUTH"),
141
+ // pick the challenge string out
142
+ map((m) => m[1]),
143
+ // Update the challenge state
144
+ tap((challenge) => {
145
+ this.log("Received AUTH challenge", challenge);
146
+ this.challenge$.next(challenge);
147
+ }));
148
+ // Merge all watchers
149
+ this.watchTower = this.ready$.pipe(switchMap((ready) => {
150
+ if (!ready)
151
+ return NEVER;
152
+ // Only start the watch tower if the relay is ready
153
+ return merge(notice, challenge, this.information$).pipe(
154
+ // Never emit any values
155
+ ignoreElements(),
156
+ // Start the reconnect timer if the connection has an error
157
+ catchError((error) => {
158
+ this.startReconnectTimer(error instanceof Error ? error : new Error("Connection error"));
159
+ return NEVER;
160
+ }),
161
+ // Add keep alive timer to the connection
162
+ share({ resetOnRefCountZero: () => timer(this.keepAlive) }));
163
+ }),
164
+ // There should only be a single watch tower
165
+ share());
55
166
  }
56
- waitForAuth(requireAuth, observable) {
167
+ /** Set ready = false and start the reconnect timer */
168
+ startReconnectTimer(error) {
169
+ if (!this.ready$.value)
170
+ return;
171
+ this.ready$.next(false);
172
+ this.reconnectTimer(error, this.attempts$.value)
173
+ .pipe(take(1))
174
+ .subscribe(() => this.ready$.next(true));
175
+ }
176
+ /** Wait for ready and authenticated */
177
+ waitForAuth(
178
+ // NOTE: require BehaviorSubject so it always has a value
179
+ requireAuth, observable) {
57
180
  return combineLatest([requireAuth, this.authenticated$]).pipe(
58
- // return EMPTY if auth is required and not authenticated
59
- switchMap(([required, authenticated]) => {
60
- if (required && !authenticated)
61
- return EMPTY;
62
- else
63
- return observable;
64
- }));
181
+ // wait for auth not required or authenticated
182
+ filter(([required, authenticated]) => !required || authenticated),
183
+ // complete after the first value so this does not repeat
184
+ take(1),
185
+ // switch to the observable
186
+ switchMap(() => observable));
187
+ }
188
+ /** Wait for the relay to be ready to accept connections */
189
+ waitForReady(observable) {
190
+ // Don't wait if the relay is already ready
191
+ if (this.ready$.value)
192
+ return observable;
193
+ else
194
+ return this.ready$.pipe(
195
+ // wait for ready to be true
196
+ filter((ready) => ready),
197
+ // complete after the first value so this does not repeat
198
+ take(1),
199
+ // switch to the observable
200
+ switchMap(() => observable));
201
+ }
202
+ multiplex(open, close, filter) {
203
+ return this.socket.multiplex(open, close, filter);
204
+ }
205
+ /** Send a message to the relay */
206
+ next(message) {
207
+ this.socket.next(message);
65
208
  }
209
+ /** Create a REQ observable that emits events or "EOSE" or errors */
66
210
  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)
69
- .pipe(
70
- // listen for CLOSE auth-required
71
- tap((m) => {
72
- if (m[0] === "CLOSE" && m[1].startsWith("auth-required") && !this.authRequiredForReq.value) {
73
- this.authRequiredForReq.next(true);
74
- }
75
- }),
76
- // complete when CLOSE is sent
77
- takeWhile((m) => m[0] !== "CLOSE"),
78
- // pick event out of EVENT messages
211
+ const request = this.socket.multiplex(() => (Array.isArray(filters) ? ["REQ", id, ...filters] : ["REQ", id, filters]), () => ["CLOSE", id], (message) => (message[0] === "EVENT" || message[0] === "CLOSED" || message[0] === "EOSE") && message[1] === id);
212
+ // Start the watch tower with the observable
213
+ const withWatchTower = merge(this.watchTower, request);
214
+ const observable = withWatchTower.pipe(
215
+ // Map the messages to events, EOSE, or throw an error
79
216
  map((message) => {
80
217
  if (message[0] === "EOSE")
81
218
  return "EOSE";
219
+ else if (message[0] === "CLOSED")
220
+ throw new ReqCloseError(message[2]);
82
221
  else
83
222
  return message[2];
223
+ }), catchError((error) => {
224
+ // Set REQ auth required if the REQ is closed with auth-required
225
+ if (error instanceof ReqCloseError &&
226
+ error.message.startsWith("auth-required") &&
227
+ !this.receivedAuthRequiredForReq.value) {
228
+ this.log("Auth required for REQ");
229
+ this.receivedAuthRequiredForReq.next(true);
230
+ }
231
+ // Pass the error through
232
+ return throwError(() => error);
84
233
  }),
85
234
  // mark events as from relays
86
235
  markFromRelay(this.url),
87
236
  // if no events are seen in 10s, emit EOSE
237
+ // TODO: this should emit EOSE event if events are seen, the timeout should be for only the EOSE message
88
238
  timeout({
89
- first: 10_000,
239
+ first: this.eoseTimeout,
90
240
  with: () => merge(of("EOSE"), NEVER),
91
- })));
241
+ }),
242
+ // Only create one upstream subscription
243
+ share());
244
+ // Wait for auth if required and make sure to start the watch tower
245
+ return this.waitForReady(this.waitForAuth(this.authRequiredForReq, observable));
92
246
  }
93
- /** send an Event message */
247
+ /** Send an EVENT or AUTH message and return an observable of PublishResponse that completes or errors */
94
248
  event(event, verb = "EVENT") {
95
- const observable = this.socket$
96
- .multiplex(() => [verb, event], () => void 0, (m) => m[0] === "OK" && m[1] === event.id)
97
- .pipe(
98
- // format OK message
99
- map((m) => ({ ok: m[2], message: m[3], from: this.url })),
249
+ const base = defer(() => {
250
+ // Send event when subscription starts
251
+ this.socket.next([verb, event]);
252
+ return this.socket.pipe(filter((m) => m[0] === "OK" && m[1] === event.id),
253
+ // format OK message
254
+ map((m) => ({ ok: m[2], message: m[3], from: this.url })));
255
+ });
256
+ // Start the watch tower with the observable
257
+ const withWatchTower = merge(this.watchTower, base);
258
+ // Add complete operators
259
+ const observable = withWatchTower.pipe(
100
260
  // complete on first value
101
261
  take(1),
102
262
  // listen for OK auth-required
103
263
  tap(({ ok, message }) => {
104
- if (ok === false && message.startsWith("auth-required") && !this.authRequiredForPublish.value) {
105
- this.authRequiredForPublish.next(true);
264
+ if (ok === false && message?.startsWith("auth-required") && !this.receivedAuthRequiredForEvent.value) {
265
+ this.log("Auth required for publish");
266
+ this.receivedAuthRequiredForEvent.next(true);
106
267
  }
107
- }));
268
+ }),
269
+ // if no message is seen in 10s, emit EOSE
270
+ timeout({
271
+ first: this.eventTimeout,
272
+ with: () => of({ ok: false, from: this.url, message: "Timeout" }),
273
+ }),
274
+ // Only create one upstream subscription
275
+ share());
108
276
  // skip wait for auth if verb is AUTH
109
277
  if (verb === "AUTH")
110
- return observable;
278
+ return this.waitForReady(observable);
111
279
  else
112
- return this.waitForAuth(this.authRequiredForPublish, observable);
280
+ return this.waitForReady(this.waitForAuth(this.authRequiredForEvent, observable));
113
281
  }
114
282
  /** send and AUTH message */
115
283
  auth(event) {
@@ -117,4 +285,55 @@ export class Relay {
117
285
  // update authenticated
118
286
  tap((result) => this.authenticated$.next(result.ok)));
119
287
  }
288
+ /** Authenticate with the relay using a signer */
289
+ authenticate(signer) {
290
+ if (!this.challenge)
291
+ throw new Error("Have not received authentication challenge");
292
+ const p = signer.signEvent(nip42.makeAuthEvent(this.url, this.challenge));
293
+ const start = p instanceof Promise ? from(p) : of(p);
294
+ return start.pipe(switchMap((event) => this.auth(event)));
295
+ }
296
+ /** Creates a REQ that retries when relay errors ( default 3 retries ) */
297
+ subscription(filters, opts) {
298
+ return this.req(filters, opts?.id).pipe(
299
+ // Retry on connection errors
300
+ retry({ count: opts?.retries ?? 3, resetOnSuccess: true }));
301
+ }
302
+ /** Makes a single request that retires on errors and completes on EOSE */
303
+ request(filters, opts) {
304
+ return this.req(filters, opts?.id).pipe(
305
+ // Retry on connection errors
306
+ retry(opts?.retries ?? 3),
307
+ // Complete when EOSE is received
308
+ completeOnEose());
309
+ }
310
+ /** Publishes an event to the relay and retries when relay errors or responds with auth-required ( default 3 retries ) */
311
+ publish(event, opts) {
312
+ return this.event(event).pipe(mergeMap((result) => {
313
+ // If the relay responds with auth-required, throw an error for the retry operator to handle
314
+ if (result.ok === false && result.message?.startsWith("auth-required:"))
315
+ return throwError(() => new Error(result.message));
316
+ return of(result);
317
+ }),
318
+ // Retry the publish until it succeeds or the number of retries is reached
319
+ retry(opts?.retries ?? 3));
320
+ }
321
+ /** Static method to fetch the NIP-11 information document for a relay */
322
+ static fetchInformationDocument(url) {
323
+ return from(fetch(url, { headers: { Accept: "application/nostr+json" } }).then((res) => res.json())).pipe(
324
+ // if the fetch fails, return null
325
+ catchError(() => of(null)),
326
+ // timeout after 10s
327
+ simpleTimeout(10_000));
328
+ }
329
+ /** Static method to create a reconnection method for each relay */
330
+ static createReconnectTimer(_relay) {
331
+ return (_error, tries = 0) => {
332
+ // Calculate delay with exponential backoff: 2^attempts * 1000ms
333
+ // with a maximum delay of 5 minutes (300000ms)
334
+ const delay = Math.min(Math.pow(1.5, tries) * 1000, 300000);
335
+ // Return a timer that will emit after the calculated delay
336
+ return timer(delay);
337
+ };
338
+ }
120
339
  }
@@ -0,0 +1,104 @@
1
+ import { EventTemplate, Filter, NostrEvent } from "nostr-tools";
2
+ import { Observable } from "rxjs";
3
+ import { WebSocketSubject } from "rxjs/webSocket";
4
+ export type SubscriptionResponse = NostrEvent | "EOSE";
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 type PublishOptions = {
18
+ retries?: number;
19
+ };
20
+ export type RequestOptions = {
21
+ id?: string;
22
+ retries?: number;
23
+ };
24
+ export type SubscriptionOptions = {
25
+ id?: string;
26
+ retries?: number;
27
+ };
28
+ export type AuthSigner = {
29
+ signEvent: (event: EventTemplate) => NostrEvent | Promise<NostrEvent>;
30
+ };
31
+ export interface Nip01Actions {
32
+ /** Send an EVENT message */
33
+ event(event: NostrEvent): Observable<PublishResponse>;
34
+ /** Send a REQ message */
35
+ req(filters: Filter | Filter[], id?: string): Observable<SubscriptionResponse>;
36
+ }
37
+ export interface IRelay extends MultiplexWebSocket, Nip01Actions, IRelayState {
38
+ url: string;
39
+ message$: Observable<any>;
40
+ notice$: Observable<string>;
41
+ readonly connected: boolean;
42
+ readonly authenticated: boolean;
43
+ readonly challenge: string | null;
44
+ readonly notices: string[];
45
+ /** Send an AUTH message */
46
+ auth(event: NostrEvent): Observable<{
47
+ ok: boolean;
48
+ message?: string;
49
+ }>;
50
+ /** Send an EVENT message with retries */
51
+ publish(event: NostrEvent, opts?: {
52
+ retries?: number;
53
+ }): Observable<PublishResponse>;
54
+ /** Send a REQ message with retries */
55
+ request(filters: Filter | Filter[], opts?: {
56
+ id?: string;
57
+ retries?: number;
58
+ }): Observable<NostrEvent>;
59
+ /** Open a subscription with retries */
60
+ subscription(filters: Filter | Filter[], opts?: {
61
+ id?: string;
62
+ retries?: number;
63
+ }): Observable<SubscriptionResponse>;
64
+ }
65
+ export interface IGroup extends Nip01Actions {
66
+ /** Send an EVENT message with retries */
67
+ publish(event: NostrEvent, opts?: {
68
+ retries?: number;
69
+ }): Observable<PublishResponse[]>;
70
+ /** Send a REQ message with retries */
71
+ request(filters: Filter | Filter[], opts?: {
72
+ id?: string;
73
+ retries?: number;
74
+ }): Observable<NostrEvent>;
75
+ /** Open a subscription with retries */
76
+ subscription(filters: Filter | Filter[], opts?: {
77
+ id?: string;
78
+ retries?: number;
79
+ }): Observable<SubscriptionResponse>;
80
+ }
81
+ export interface IPool {
82
+ /** Send an EVENT message */
83
+ event(relays: string[], event: NostrEvent): Observable<PublishResponse>;
84
+ /** Send a REQ message */
85
+ req(relays: string[], filters: Filter | Filter[], id?: string): Observable<SubscriptionResponse>;
86
+ /** Get or create a relay */
87
+ relay(url: string): IRelay;
88
+ /** Create a relay group */
89
+ group(relays: string[]): IGroup;
90
+ /** Send an EVENT message to relays with retries */
91
+ publish(relays: string[], event: NostrEvent, opts?: {
92
+ retries?: number;
93
+ }): Observable<PublishResponse[]>;
94
+ /** Send a REQ message to relays with retries */
95
+ request(relays: string[], filters: Filter | Filter[], opts?: {
96
+ id?: string;
97
+ retries?: number;
98
+ }): Observable<NostrEvent>;
99
+ /** Open a subscription to relays with retries */
100
+ subscription(relays: string[], filters: Filter | Filter[], opts?: {
101
+ id?: string;
102
+ retries?: number;
103
+ }): Observable<SubscriptionResponse>;
104
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "applesauce-relay",
3
- "version": "0.12.0",
4
- "description": "A collection of observable based loaders built on rx-nostr",
3
+ "version": "1.0.1",
4
+ "description": "nostr relay communication framework built on rxjs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -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",
@@ -30,19 +45,26 @@
30
45
  "import": "./dist/operators/*.js",
31
46
  "require": "./dist/operators/*.js",
32
47
  "types": "./dist/operators/*.d.ts"
48
+ },
49
+ "./negentropy": {
50
+ "import": "./dist/negentropy.js",
51
+ "require": "./dist/negentropy.js",
52
+ "types": "./dist/negentropy.d.ts"
33
53
  }
34
54
  },
35
55
  "dependencies": {
36
- "applesauce-core": "^0.12.0",
56
+ "@noble/hashes": "^1.7.1",
57
+ "applesauce-core": "^1.0.0",
37
58
  "nanoid": "^5.0.9",
38
59
  "nostr-tools": "^2.10.4",
39
60
  "rxjs": "^7.8.1"
40
61
  },
41
62
  "devDependencies": {
63
+ "@hirez_io/observer-spy": "^2.2.0",
64
+ "@vitest/expect": "^3.1.1",
42
65
  "typescript": "^5.7.3",
43
- "vitest": "^3.0.5",
44
- "vitest-nostr": "^0.4.1",
45
- "vitest-websocket-mock": "^0.4.0"
66
+ "vitest": "^3.1.1",
67
+ "vitest-websocket-mock": "^0.5.0"
46
68
  },
47
69
  "funding": {
48
70
  "type": "lightning",