applesauce-relay 0.11.0 → 1.0.0

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,275 @@
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());
166
+ }
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));
55
175
  }
56
- waitForAuth(requireAuth, observable) {
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
+ return this.ready$.pipe(
191
+ // wait for ready to be true
192
+ filter((ready) => ready),
193
+ // complete after the first value so this does not repeat
194
+ take(1),
195
+ // switch to the observable
196
+ switchMap(() => observable));
197
+ }
198
+ multiplex(open, close, filter) {
199
+ return this.socket.multiplex(open, close, filter);
65
200
  }
201
+ /** Send a message to the relay */
202
+ next(message) {
203
+ this.socket.next(message);
204
+ }
205
+ /** Create a REQ observable that emits events or "EOSE" or errors */
66
206
  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
207
+ 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);
208
+ // Start the watch tower with the observable
209
+ const withWatchTower = merge(this.watchTower, request);
210
+ const observable = withWatchTower.pipe(
211
+ // Map the messages to events, EOSE, or throw an error
79
212
  map((message) => {
80
213
  if (message[0] === "EOSE")
81
214
  return "EOSE";
215
+ else if (message[0] === "CLOSED")
216
+ throw new ReqCloseError(message[2]);
82
217
  else
83
218
  return message[2];
219
+ }), catchError((error) => {
220
+ // Set REQ auth required if the REQ is closed with auth-required
221
+ if (error instanceof ReqCloseError &&
222
+ error.message.startsWith("auth-required") &&
223
+ !this.receivedAuthRequiredForReq.value) {
224
+ this.log("Auth required for REQ");
225
+ this.receivedAuthRequiredForReq.next(true);
226
+ }
227
+ // Pass the error through
228
+ return throwError(() => error);
84
229
  }),
85
230
  // mark events as from relays
86
231
  markFromRelay(this.url),
87
232
  // if no events are seen in 10s, emit EOSE
233
+ // TODO: this should emit EOSE event if events are seen, the timeout should be for only the EOSE message
88
234
  timeout({
89
- first: 10_000,
235
+ first: this.eoseTimeout,
90
236
  with: () => merge(of("EOSE"), NEVER),
91
- })));
237
+ }));
238
+ // Wait for auth if required and make sure to start the watch tower
239
+ return this.waitForReady(this.waitForAuth(this.authRequiredForReq, observable));
92
240
  }
93
- /** send an Event message */
241
+ /** Send an EVENT or AUTH message and return an observable of PublishResponse that completes or errors */
94
242
  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 })),
243
+ const base = defer(() => {
244
+ // Send event when subscription starts
245
+ this.socket.next([verb, event]);
246
+ return this.socket.pipe(filter((m) => m[0] === "OK" && m[1] === event.id),
247
+ // format OK message
248
+ map((m) => ({ ok: m[2], message: m[3], from: this.url })));
249
+ });
250
+ // Start the watch tower with the observable
251
+ const withWatchTower = merge(this.watchTower, base);
252
+ // Add complete operators
253
+ const observable = withWatchTower.pipe(
100
254
  // complete on first value
101
255
  take(1),
102
256
  // listen for OK auth-required
103
257
  tap(({ ok, message }) => {
104
- if (ok === false && message.startsWith("auth-required") && !this.authRequiredForPublish.value) {
105
- this.authRequiredForPublish.next(true);
258
+ if (ok === false && message?.startsWith("auth-required") && !this.receivedAuthRequiredForEvent.value) {
259
+ this.log("Auth required for publish");
260
+ this.receivedAuthRequiredForEvent.next(true);
106
261
  }
262
+ }),
263
+ // if no message is seen in 10s, emit EOSE
264
+ timeout({
265
+ first: this.eventTimeout,
266
+ with: () => of({ ok: false, from: this.url, message: "Timeout" }),
107
267
  }));
108
268
  // skip wait for auth if verb is AUTH
109
269
  if (verb === "AUTH")
110
- return observable;
270
+ return this.waitForReady(observable);
111
271
  else
112
- return this.waitForAuth(this.authRequiredForPublish, observable);
272
+ return this.waitForReady(this.waitForAuth(this.authRequiredForEvent, observable));
113
273
  }
114
274
  /** send and AUTH message */
115
275
  auth(event) {
@@ -117,4 +277,55 @@ export class Relay {
117
277
  // update authenticated
118
278
  tap((result) => this.authenticated$.next(result.ok)));
119
279
  }
280
+ /** Authenticate with the relay using a signer */
281
+ authenticate(signer) {
282
+ if (!this.challenge)
283
+ throw new Error("Have not received authentication challenge");
284
+ const p = signer.signEvent(nip42.makeAuthEvent(this.url, this.challenge));
285
+ const start = p instanceof Promise ? from(p) : of(p);
286
+ return start.pipe(switchMap((event) => this.auth(event)));
287
+ }
288
+ /** Creates a REQ that retries when relay errors ( default 3 retries ) */
289
+ subscription(filters, opts) {
290
+ return this.req(filters, opts?.id).pipe(
291
+ // Retry on connection errors
292
+ retry({ count: opts?.retries ?? 3, resetOnSuccess: true }));
293
+ }
294
+ /** Makes a single request that retires on errors and completes on EOSE */
295
+ request(filters, opts) {
296
+ return this.req(filters, opts?.id).pipe(
297
+ // Retry on connection errors
298
+ retry(opts?.retries ?? 3),
299
+ // Complete when EOSE is received
300
+ completeOnEose());
301
+ }
302
+ /** Publishes an event to the relay and retries when relay errors or responds with auth-required ( default 3 retries ) */
303
+ publish(event, opts) {
304
+ return this.event(event).pipe(mergeMap((result) => {
305
+ // If the relay responds with auth-required, throw an error for the retry operator to handle
306
+ if (result.ok === false && result.message?.startsWith("auth-required:"))
307
+ return throwError(() => new Error(result.message));
308
+ return of(result);
309
+ }),
310
+ // Retry the publish until it succeeds or the number of retries is reached
311
+ retry(opts?.retries ?? 3));
312
+ }
313
+ /** Static method to fetch the NIP-11 information document for a relay */
314
+ static fetchInformationDocument(url) {
315
+ return from(fetch(url, { headers: { Accept: "application/nostr+json" } }).then((res) => res.json())).pipe(
316
+ // if the fetch fails, return null
317
+ catchError(() => of(null)),
318
+ // timeout after 10s
319
+ simpleTimeout(10_000));
320
+ }
321
+ /** Static method to create a reconnection method for each relay */
322
+ static createReconnectTimer(_relay) {
323
+ return (_error, tries = 0) => {
324
+ // Calculate delay with exponential backoff: 2^attempts * 1000ms
325
+ // with a maximum delay of 5 minutes (300000ms)
326
+ const delay = Math.min(Math.pow(1.5, tries) * 1000, 300000);
327
+ // Return a timer that will emit after the calculated delay
328
+ return timer(delay);
329
+ };
330
+ }
120
331
  }
@@ -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.11.0",
4
- "description": "A collection of observable based loaders built on rx-nostr",
3
+ "version": "1.0.0",
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": {
56
+ "@noble/hashes": "^1.7.1",
57
+ "applesauce-core": "^1.0.0",
36
58
  "nanoid": "^5.0.9",
37
59
  "nostr-tools": "^2.10.4",
38
- "rxjs": "^7.8.1",
39
- "applesauce-core": "^0.11.0"
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",