applesauce-relay 5.2.0 → 6.0.2

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,19 +1,24 @@
1
1
  import { logger } from "applesauce-core";
2
+ import { addSeenRelay } from "applesauce-core/helpers";
2
3
  import { ensureHttpURL } from "applesauce-core/helpers/url";
3
4
  import { mapEventsToStore, simpleTimeout } from "applesauce-core/observable";
4
5
  import { nanoid } from "nanoid";
5
6
  import { makeAuthEvent } from "nostr-tools/nip42";
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
+ import { BehaviorSubject, catchError, combineLatest, defer, EMPTY, endWith, filter, finalize, firstValueFrom, from, identity, ignoreElements, isObservable, lastValueFrom, map, merge, mergeMap, mergeWith, NEVER, Observable, of, repeat, retry, scan, share, shareReplay, startWith, Subject, switchMap, take, takeUntil, takeWhile, tap, throwError, timeout, timer, } from "rxjs";
7
8
  import { webSocket } from "rxjs/webSocket";
8
- import { completeOnEose } from "./operators/complete-on-eose.js";
9
- import { markFromRelay } from "./operators/mark-from-relay.js";
9
+ import { completeWhen } from "./operators/complete-when.js";
10
10
  const AUTH_REQUIRED_PREFIX = "auth-required:";
11
- /** Default retry config for all methods */
11
+ /** Default reconnect/retry config for request, subscription, and publish. linear backoff */
12
12
  const DEFAULT_RETRY_CONFIG = {
13
13
  count: 3,
14
- delay: 1000,
14
+ delay: (_err, count) => timer(count * 1000),
15
15
  resetOnSuccess: true,
16
16
  };
17
+ function normalizeRetryConfig(config) {
18
+ if (typeof config === "number")
19
+ return { ...DEFAULT_RETRY_CONFIG, count: config };
20
+ return { ...DEFAULT_RETRY_CONFIG, ...(config ?? {}) };
21
+ }
17
22
  /** Flags for the negentropy sync type */
18
23
  export var SyncDirection;
19
24
  (function (SyncDirection) {
@@ -21,8 +26,45 @@ export var SyncDirection;
21
26
  SyncDirection[SyncDirection["SEND"] = 2] = "SEND";
22
27
  SyncDirection[SyncDirection["BOTH"] = 3] = "BOTH";
23
28
  })(SyncDirection || (SyncDirection = {}));
24
- /** An error that is thrown when a REQ is closed from the relay side */
25
- export class ReqCloseError extends Error {
29
+ /** Base error thrown when a relay closes a REQ or COUNT with a CLOSED message */
30
+ export class RelayClosedError extends Error {
31
+ reason;
32
+ constructor(reason) {
33
+ super(reason);
34
+ this.reason = reason;
35
+ this.name = "RelayClosedError";
36
+ }
37
+ }
38
+ /** Thrown when the relay closes a subscription with an auth-required: prefix */
39
+ export class AuthRequiredError extends RelayClosedError {
40
+ constructor(reason) {
41
+ super(reason);
42
+ this.name = "AuthRequiredError";
43
+ }
44
+ }
45
+ /** NIP-01 machine-readable prefixes that indicate an error condition on CLOSED/OK messages */
46
+ const CLOSED_ERROR_PREFIXES = {
47
+ "auth-required": AuthRequiredError,
48
+ unsupported: RelayClosedError,
49
+ error: RelayClosedError,
50
+ blocked: RelayClosedError,
51
+ restricted: RelayClosedError,
52
+ "rate-limited": RelayClosedError,
53
+ pow: RelayClosedError,
54
+ invalid: RelayClosedError,
55
+ duplicate: RelayClosedError,
56
+ mute: RelayClosedError,
57
+ };
58
+ /**
59
+ * Parse a NIP-01 machine-readable CLOSED reason string into a typed error.
60
+ * Returns null if the reason has no recognized prefix — the relay closed gracefully
61
+ * and the observable should complete rather than error.
62
+ */
63
+ function parseClosedError(reason) {
64
+ const ErrorClass = CLOSED_ERROR_PREFIXES[reason.split(":")[0]];
65
+ if (ErrorClass)
66
+ return new ErrorClass(reason);
67
+ return null;
26
68
  }
27
69
  /** A dummy filter that will return empty results */
28
70
  const PING_FILTER = {
@@ -87,7 +129,7 @@ export class Relay {
87
129
  open$ = new Subject();
88
130
  /** An observable that emits when underlying websocket is closed */
89
131
  close$ = new Subject();
90
- /** An observable that emits when underlying websocket is closing due to unsubscription */
132
+ /** An observable that emits when underlying websocket is closing due to unsubscribe or complete */
91
133
  closing$ = new Subject();
92
134
  /** Tracks active req() operations by subscription ID */
93
135
  reqs$ = new BehaviorSubject({});
@@ -125,8 +167,6 @@ export class Relay {
125
167
  get reqs() {
126
168
  return this.reqs$.value;
127
169
  }
128
- /** If an EOSE message is not seen in this time, emit one locally (default 10s) */
129
- eoseTimeout = 10_000;
130
170
  /** How long to wait for an OK message from the relay (default 10s) */
131
171
  eventTimeout = 10_000;
132
172
  /** How long to wait for a publish to complete (default 30s) */
@@ -139,9 +179,9 @@ export class Relay {
139
179
  pingFrequency = 29_000;
140
180
  /** How long to wait for EOSE response in milliseconds (default 20000) */
141
181
  pingTimeout = 20_000;
142
- /** Default retry config for subscription() method */
182
+ /** Default retry config for subscription() connection errors */
143
183
  subscriptionReconnect;
144
- /** Default retry config for request() method */
184
+ /** Default retry config for request() connection errors */
145
185
  requestReconnect;
146
186
  /** Default retry config for publish() method */
147
187
  publishRetry;
@@ -174,8 +214,6 @@ export class Relay {
174
214
  this.url = url;
175
215
  this.log = this.log.extend(url);
176
216
  // Set common options
177
- if (opts?.eoseTimeout !== undefined)
178
- this.eoseTimeout = opts.eoseTimeout;
179
217
  if (opts?.eventTimeout !== undefined)
180
218
  this.eventTimeout = opts.eventTimeout;
181
219
  if (opts?.publishTimeout !== undefined)
@@ -191,8 +229,8 @@ export class Relay {
191
229
  if (opts?.onUnresponsive !== undefined)
192
230
  this.onUnresponsive = opts.onUnresponsive;
193
231
  // Set retry configs
194
- this.subscriptionReconnect = { ...DEFAULT_RETRY_CONFIG, ...(opts?.subscriptionRetry ?? {}) };
195
- this.requestReconnect = { ...DEFAULT_RETRY_CONFIG, ...(opts?.requestRetry ?? {}) };
232
+ this.subscriptionReconnect = normalizeRetryConfig(opts?.subscriptionReconnect);
233
+ this.requestReconnect = normalizeRetryConfig(opts?.requestReconnect);
196
234
  this.publishRetry = { ...DEFAULT_RETRY_CONFIG, ...(opts?.publishRetry ?? {}) };
197
235
  // Create an observable that tracks boolean authentication state
198
236
  this.authenticated$ = this.authenticationResponse$.pipe(map((response) => response?.ok === true));
@@ -214,7 +252,7 @@ export class Relay {
214
252
  this.log("Disconnected");
215
253
  else
216
254
  this.log("Failed to connect");
217
- // Chnaged the connected state to false
255
+ // Changed the connected state to false
218
256
  if (this.connected$.value)
219
257
  this.connected$.next(false);
220
258
  // Increment the attempts counter
@@ -367,7 +405,7 @@ export class Relay {
367
405
  if (!ready)
368
406
  return NEVER;
369
407
  // Only start the watch tower if the relay is ready
370
- return merge(listenForAllMessages, listenForNotice, ListenForChallenge, this.information$, pingHealthCheck).pipe(
408
+ return merge(listenForAllMessages, listenForNotice, ListenForChallenge, pingHealthCheck).pipe(
371
409
  // Never emit any values
372
410
  ignoreElements(),
373
411
  // Start the reconnect timer if the connection has an error
@@ -428,8 +466,15 @@ export class Relay {
428
466
  send(message) {
429
467
  this.socket.next(message);
430
468
  }
431
- /** Create a REQ observable that emits events or "EOSE" or errors */
432
- req(filters, id = nanoid()) {
469
+ /**
470
+ * Create a REQ observable that emits OPEN, EVENT, EOSE, and CLOSED messages.
471
+ *
472
+ * `resubscribe` only repeats after the relay sends a clean CLOSED message for this REQ.
473
+ * `reconnect` only retries connection errors and does not retry relay CLOSED errors.
474
+ */
475
+ req(filters, opts) {
476
+ const id = opts?.id ?? nanoid();
477
+ const waitForAuth = opts?.waitForAuth ?? true;
433
478
  // Convert filters input into an observable, if its a normal value merge it with NEVER so it never completes
434
479
  let input;
435
480
  // Create input from filters input
@@ -441,84 +486,149 @@ export class Relay {
441
486
  input = (isObservable(filters) ? filters : merge(of(filters), NEVER)).pipe(map((f) => (Array.isArray(f) ? f : [f])));
442
487
  }
443
488
  // Create an observable that completes when the upstream observable completes
444
- const filtersComplete = input.pipe(ignoreElements(), endWith(null));
489
+ const filtersComplete = input.pipe(ignoreElements(), endWith(true));
490
+ // Track whether the relay already sent CLOSED so we skip the redundant client CLOSE
491
+ let relayClosedSub = false;
492
+ let shouldResubscribe = false;
445
493
  // Create an observable that filters responses from the relay to just the ones for this REQ
446
494
  const messages = this.socket.pipe(filter((m) => Array.isArray(m) && (m[0] === "EVENT" || m[0] === "CLOSED" || m[0] === "EOSE") && m[1] === id),
447
- // Singleton (prevents the .pipe() operator later from sending two REQ messages )
495
+ // Map NIP-01 messages to RelayReqMessage
496
+ map((m) => {
497
+ if (m[0] === "EVENT" && typeof m[2] === "object")
498
+ return { type: "EVENT", from: this.url, id: m[1], event: m[2] };
499
+ if (m[0] === "CLOSED")
500
+ return { type: "CLOSED", from: this.url, id: m[1], reason: m[2] ?? "" };
501
+ // EOSE
502
+ return { type: "EOSE", from: this.url, id: m[1] };
503
+ }),
504
+ // Throw typed errors for prefixed CLOSED; mark relay-closed before takeWhile sees it
505
+ tap((m) => {
506
+ if (m.type === "CLOSED") {
507
+ relayClosedSub = true;
508
+ const error = parseClosedError(m.reason);
509
+ if (error)
510
+ throw error;
511
+ shouldResubscribe = true;
512
+ }
513
+ }),
514
+ // Complete the stream on unprefixed CLOSED, emitting the CLOSED message last (inclusive)
515
+ takeWhile((m) => m.type !== "CLOSED", true),
516
+ // Singleton (prevents the .pipe() operator later from sending two REQ messages)
448
517
  share());
449
518
  // Create an observable that controls sending the filters and closing the REQ
450
519
  const control = input.pipe(
451
520
  // Send the filters when they change
452
- tap((filters) => {
521
+ map((filters) => {
522
+ // Reset closed flag on each new REQ (resubscribe cycles)
523
+ relayClosedSub = false;
524
+ shouldResubscribe = false;
453
525
  this.socket.next(["REQ", id, ...filters]);
454
526
  // Add to tracking when REQ is sent
455
527
  this.reqs$.next({ ...this.reqs$.value, [id]: filters });
528
+ return { type: "OPEN", id, filters, from: this.url };
456
529
  }),
457
- // Send the CLOSE message when unsubscribed or input completes
530
+ // Send CLOSE when unsubscribed or input completes, but not if relay already sent CLOSED
458
531
  finalize(() => {
459
- this.socket.next(["CLOSE", id]);
532
+ if (!relayClosedSub)
533
+ this.socket.next(["CLOSE", id]);
460
534
  // Remove from tracking when REQ closes
461
535
  const { [id]: _, ...rest } = this.reqs$.value;
462
536
  this.reqs$.next(rest);
463
537
  }),
464
538
  // Once filters have been sent, switch to listening for messages
465
- switchMap(() => messages));
539
+ switchMap((openMessage) => messages.pipe(
540
+ // Pass along the OPEN message for listeners
541
+ startWith(openMessage))));
466
542
  // Start the watch tower with the observables
467
543
  const observable = merge(this.watchTower, control).pipe(
468
- // Complete the subscription when the control observable completes
469
- // This is to work around the fact that merge() waits for both observables to complete
544
+ // Complete when messages completes (e.g. unprefixed CLOSED = graceful relay close)
470
545
  takeUntil(messages.pipe(ignoreElements(), endWith(true))),
471
546
  // Complete the subscription when the input is completed
472
547
  takeUntil(filtersComplete),
473
- // Map the messages to events, EOSE, or throw an error
474
- map((message) => {
475
- if (message[0] === "EOSE")
476
- return "EOSE";
477
- else if (message[0] === "CLOSED")
478
- throw new ReqCloseError(message[2]);
479
- else
480
- return message[2];
481
- }), this.handleAuthRequiredForReq("REQ"),
482
548
  // mark events as from relays
483
- markFromRelay(this.url),
484
- // if no events are seen in 10s, emit EOSE
485
- // TODO: this timeout should only emit EOSE after the last event is seen and no EOSE has been sent in (timeout ms)
486
- timeout({
487
- first: this.eoseTimeout,
488
- with: () => merge(of("EOSE"), NEVER),
549
+ tap((message) => {
550
+ if (message.type === "EVENT")
551
+ addSeenRelay(message.event, this.url);
552
+ }),
553
+ // Only create one upstream subscription
554
+ share());
555
+ // Wait for auth only when enabled and make sure to start the watch tower
556
+ const reqWithAuthStrategy = waitForAuth ? this.waitForAuth(this.authRequiredForRead$, observable) : observable;
557
+ return defer(() => this.waitForReady(reqWithAuthStrategy)).pipe(
558
+ // Retry only auth-required errors, optionally waiting for authentication first
559
+ retry({
560
+ delay: (error) => {
561
+ // Re-throw non-auth-required errors
562
+ if (!(error instanceof AuthRequiredError))
563
+ return throwError(() => error);
564
+ // Flip the flag to indicate that auth is required
565
+ this.log(`Auth required for REQ`);
566
+ this.receivedAuthRequiredForReq.next(true);
567
+ // If not waiting for auth , re-throw the error
568
+ if (!waitForAuth)
569
+ return throwError(() => error);
570
+ // Wait for authentication before retrying
571
+ return this.authenticated$.pipe(filter((authenticated) => authenticated), take(1));
572
+ },
489
573
  }),
574
+ // Retry connection errors independently from relay CLOSED errors
575
+ this.customConnectionRetryOperator(opts?.reconnect),
576
+ // Resubscribe only after the relay cleanly CLOSED this REQ
577
+ this.customRepeatOperator(opts?.resubscribe, () => shouldResubscribe),
490
578
  // Only create one upstream subscription
491
579
  share());
492
- // Wait for auth if required and make sure to start the watch tower
493
- return this.waitForReady(this.waitForAuth(this.authRequiredForRead$, observable));
494
580
  }
495
581
  /** Create a COUNT observable that emits a single count response */
496
582
  count(filters, id = nanoid()) {
583
+ // Track whether the relay already sent CLOSED so we skip the redundant client CLOSE
584
+ let relayClosedSub = false;
497
585
  // Create an observable that filters responses from the relay to just the ones for this COUNT
498
- const messages = this.socket.pipe(filter((m) => Array.isArray(m) && (m[0] === "COUNT" || m[0] === "CLOSED") && m[1] === id));
586
+ const messages = this.socket.pipe(filter((m) => Array.isArray(m) && (m[0] === "COUNT" || m[0] === "CLOSED") && m[1] === id),
587
+ // Map to typed response, throwing on error-prefixed CLOSED
588
+ map((m) => {
589
+ if (m[0] === "COUNT")
590
+ return m[2];
591
+ else if (m[0] === "CLOSED") {
592
+ relayClosedSub = true;
593
+ const error = parseClosedError(m[2] ?? "");
594
+ if (error)
595
+ throw error;
596
+ }
597
+ return null;
598
+ }),
599
+ // Complete the stream on any CLOSED (including graceful close), emitting COUNT last
600
+ takeWhile((m) => m !== null, true), filter((m) => m !== null),
601
+ // Singleton
602
+ share());
499
603
  // Send the COUNT message and listen for response
500
- const observable = defer(() => {
604
+ const control = defer(() => {
605
+ // Reset closed flag on each new COUNT
606
+ relayClosedSub = false;
501
607
  // Send the COUNT message when subscription starts
502
608
  this.socket.next(Array.isArray(filters) ? ["COUNT", id, ...filters] : ["COUNT", id, filters]);
503
- // Merge with watch tower to keep connection alive
504
- return merge(this.watchTower, messages);
609
+ return messages;
505
610
  }).pipe(
506
- // Map the messages to count responses or throw an error
507
- map((message) => {
508
- if (message[0] === "COUNT")
509
- return message[2];
510
- else
511
- throw new ReqCloseError(message[2]);
512
- }), this.handleAuthRequiredForReq("COUNT"),
611
+ // Send CLOSE when unsubscribed, but not if relay already sent CLOSED
612
+ finalize(() => {
613
+ if (!relayClosedSub)
614
+ this.socket.next(["CLOSE", id]);
615
+ }));
616
+ const observable = merge(this.watchTower, control).pipe(
617
+ // Complete when messages completes (unprefixed CLOSED = graceful relay close)
618
+ takeUntil(messages.pipe(ignoreElements(), endWith(true))),
513
619
  // Complete on first value (COUNT responses are single-shot)
514
620
  take(1),
515
- // Add timeout
516
- timeout({
517
- first: this.eoseTimeout,
518
- with: () => throwError(() => new Error("COUNT timeout")),
519
- }));
621
+ // Set auth required flag when relay closes with auth-required
622
+ catchError((error) => {
623
+ if (error instanceof AuthRequiredError && !this.receivedAuthRequiredForReq.value) {
624
+ this.log(`Auth required for COUNT`);
625
+ this.receivedAuthRequiredForReq.next(true);
626
+ }
627
+ return throwError(() => error);
628
+ }),
629
+ // Throw an error if no COUNT response received within 10 seconds
630
+ timeout({ first: 10_000, with: () => throwError(() => new Error("COUNT timeout")) }));
520
631
  // Start the watch tower and wait for auth if required
521
- // Use share() to prevent multiple subscriptions from creating duplicate COUNT messages
522
632
  return this.waitForReady(this.waitForAuth(this.authRequiredForRead$, observable)).pipe(share());
523
633
  }
524
634
  /** Send an EVENT or AUTH message and return an observable of PublishResponse that completes or errors */
@@ -546,7 +656,7 @@ export class Relay {
546
656
  this.receivedAuthRequiredForEvent.next(true);
547
657
  }
548
658
  }),
549
- // if no message is seen in 10s, emit EOSE
659
+ // if no message is seen in 10s, emit failed publish response
550
660
  timeout({
551
661
  first: this.eventTimeout,
552
662
  with: () => of({ ok: false, from: this.url, message: "Timeout" }),
@@ -584,9 +694,9 @@ export class Relay {
584
694
  const start = p instanceof Promise ? from(p) : of(p);
585
695
  return lastValueFrom(start.pipe(switchMap((event) => this.auth(event))));
586
696
  }
587
- /** Internal operator for creating the retry() operator */
697
+ /** Internal operator for creating a retry() operator */
588
698
  customRetryOperator(times, base) {
589
- if (times === false)
699
+ if (times === false || times === undefined)
590
700
  return identity;
591
701
  else if (typeof times === "number")
592
702
  return retry({ ...base, count: times });
@@ -595,16 +705,45 @@ export class Relay {
595
705
  else
596
706
  return retry({ ...base, ...times });
597
707
  }
598
- /** Internal operator for creating the repeat() operator */
599
- customRepeatOperator(times) {
708
+ /** Internal operator for retrying connection failures without retrying relay CLOSED errors */
709
+ customConnectionRetryOperator(times, base) {
600
710
  if (times === false || times === undefined)
601
711
  return identity;
602
- else if (times === true)
603
- return repeat();
712
+ const config = typeof times === "number" ? { ...base, count: times } : times === true ? (base ?? {}) : { ...base, ...times };
713
+ return retry({
714
+ ...config,
715
+ delay: (error, count) => {
716
+ if (error instanceof RelayClosedError)
717
+ return throwError(() => error);
718
+ if (typeof config.delay === "number")
719
+ return timer(config.delay);
720
+ if (typeof config.delay === "function")
721
+ return config.delay(error, count);
722
+ return of(null);
723
+ },
724
+ });
725
+ }
726
+ /** Internal operator for creating the repeat() operator, optionally gated by a condition */
727
+ customRepeatOperator(times, condition) {
728
+ if (times === false || times === undefined)
729
+ return identity;
730
+ const delay = (repeatCount) => {
731
+ if (condition && !condition())
732
+ return EMPTY;
733
+ if (typeof times === "object") {
734
+ if (typeof times.delay === "number")
735
+ return timer(times.delay);
736
+ if (typeof times.delay === "function")
737
+ return times.delay(repeatCount);
738
+ }
739
+ return of(null);
740
+ };
741
+ if (times === true)
742
+ return repeat({ delay });
604
743
  else if (typeof times === "number")
605
- return repeat(times);
744
+ return repeat({ count: times, delay });
606
745
  else
607
- return repeat(times);
746
+ return repeat({ ...times, delay });
608
747
  }
609
748
  /** Internal operator for creating the timeout() operator */
610
749
  customTimeoutOperator(timeout, defaultTimeout) {
@@ -618,39 +757,36 @@ export class Relay {
618
757
  else
619
758
  return simpleTimeout(timeout ?? defaultTimeout);
620
759
  }
621
- /** Internal operator for handling auth-required errors from REQ/COUNT operations */
622
- handleAuthRequiredForReq(operation) {
623
- return catchError((error) => {
624
- // Set auth required if the operation is closed with auth-required
625
- if (error instanceof ReqCloseError &&
626
- error.message.startsWith(AUTH_REQUIRED_PREFIX) &&
627
- !this.receivedAuthRequiredForReq.value) {
628
- this.log(`Auth required for ${operation}`);
629
- this.receivedAuthRequiredForReq.next(true);
630
- }
631
- // Pass the error through
632
- return throwError(() => error);
633
- });
634
- }
635
- /** Creates a REQ that retries when relay errors ( default 3 retries ) */
760
+ /** Creates a persistent REQ that retries connection errors (default 3 retries) */
636
761
  subscription(filters, opts) {
637
- return this.req(filters, opts?.id).pipe(
638
- // Retry on connection errors
639
- this.customRetryOperator(opts?.reconnect ?? true, this.subscriptionReconnect),
640
- // Create resubscribe logic (repeat operator)
641
- this.customRepeatOperator(opts?.resubscribe),
762
+ return this.req(filters, {
763
+ ...opts,
764
+ reconnect: opts?.reconnect ?? this.subscriptionReconnect,
765
+ }).pipe(
766
+ // Filter for EOSE messages
767
+ filter((message) => message.type === "EOSE" || message.type === "EVENT"),
768
+ // Extract EOSE messages
769
+ map((message) => (message.type === "EOSE" ? "EOSE" : message.event)),
642
770
  // Single subscription
643
771
  share());
644
772
  }
645
- /** Makes a single request that retires on errors and completes on EOSE */
773
+ /** Makes a single request that retries connection errors and completes on EOSE */
646
774
  request(filters, opts) {
647
- return this.req(filters, opts?.id).pipe(
648
- // Retry on connection errors
649
- this.customRetryOperator(opts?.reconnect ?? true, this.requestReconnect),
650
- // Create resubscribe logic (repeat operator)
651
- this.customRepeatOperator(opts?.resubscribe),
775
+ const req = this.req(filters, {
776
+ ...opts,
777
+ reconnect: opts?.reconnect ?? this.requestReconnect,
778
+ });
779
+ return req.pipe(
780
+ // Add completion condition
781
+ opts?.complete ? completeWhen(opts?.complete) : identity,
782
+ // Apply request timeout
783
+ timeout({ first: opts?.timeout ?? 30_000 }),
652
784
  // Complete when EOSE is received
653
- completeOnEose(),
785
+ takeWhile((message) => message.type !== "EOSE"),
786
+ // Filter only for event messages
787
+ filter((message) => message.type === "EVENT"),
788
+ // Extract event messages
789
+ map((message) => message.event),
654
790
  // Single subscription
655
791
  share());
656
792
  }
@@ -659,7 +795,7 @@ export class Relay {
659
795
  return lastValueFrom(this.event(event).pipe(mergeMap((result) => {
660
796
  // If the relay responds with auth-required, throw an error for the retry operator to handle
661
797
  if (result.ok === false && result.message?.startsWith(AUTH_REQUIRED_PREFIX))
662
- return throwError(() => new Error(result.message));
798
+ return throwError(() => new AuthRequiredError(result.message ?? ""));
663
799
  return of(result);
664
800
  }),
665
801
  // Retry the publish until it succeeds or the number of retries is reached
@@ -668,7 +804,7 @@ export class Relay {
668
804
  this.customTimeoutOperator(opts?.timeout, this.publishTimeout)));
669
805
  }
670
806
  /** Negentropy sync events with the relay and an event store */
671
- sync(store, filter, direction = SyncDirection.RECEIVE) {
807
+ sync(store, filters, direction = SyncDirection.RECEIVE) {
672
808
  const getEvents = async (ids) => {
673
809
  if (Array.isArray(store))
674
810
  return store.filter((event) => ids.includes(event.id));
@@ -685,7 +821,7 @@ export class Relay {
685
821
  controller.abort();
686
822
  }
687
823
  };
688
- this.negentropy(store, filter, async (have, need) => {
824
+ this.negentropy(store, filters, async (have, need) => {
689
825
  // NOTE: it may be more efficient to sync all the events later in a single batch
690
826
  // Send missing events to the relay
691
827
  if (direction & SyncDirection.SEND && have.length > 0) {
@@ -697,7 +833,11 @@ export class Relay {
697
833
  if (direction & SyncDirection.RECEIVE && need.length > 0) {
698
834
  await lastValueFrom(this.req({ ids: need }).pipe(
699
835
  // Complete when EOSE is received
700
- completeOnEose(),
836
+ takeWhile((message) => message.type !== "EOSE"),
837
+ // Filter only for event messages
838
+ filter((message) => message.type === "EVENT"),
839
+ // Extract event messages
840
+ map((message) => message.event),
701
841
  // Add events to the store if its writable
702
842
  Reflect.has(store, "add")
703
843
  ? mapEventsToStore(store)
@@ -756,4 +896,28 @@ export class Relay {
756
896
  return timer(delay);
757
897
  };
758
898
  }
899
+ /** A complete condition that waits for the subscription to open */
900
+ static afterOpen(condition) {
901
+ return (source) => source.pipe(
902
+ // Wait for subscription open
903
+ filter((m) => m.type === "OPEN"),
904
+ // Switch to timer for complete
905
+ switchMap(() => source.pipe(condition)));
906
+ }
907
+ /** An OR complete condition, that completes when either condition is truthy */
908
+ static completeOr(...conditions) {
909
+ return (source) => combineLatest(conditions.map((condition) => source.pipe(condition, startWith(false)))).pipe(
910
+ // Return true if any condition is truthy
911
+ map((all) => all.some((v) => !!v)));
912
+ }
913
+ /** An AND complete condition, that completes when all conditions are truthy */
914
+ static completeAnd(...conditions) {
915
+ return (source) => combineLatest(conditions.map((condition) => source.pipe(condition, startWith(false)))).pipe(
916
+ // Return true if all conditions are truthy
917
+ map((all) => all.every((v) => !!v)));
918
+ }
919
+ /** A default complete condition that waits for the subscription to open and then completes after a timeout */
920
+ static defaultComplete(timeout, afterOpen = 10_000) {
921
+ return this.completeOr(() => timer(timeout), this.afterOpen(() => timer(afterOpen)));
922
+ }
759
923
  }