@thru/replay 0.2.21 → 0.2.22

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/index.cjs CHANGED
@@ -3,20 +3,49 @@
3
3
  var protobuf = require('@bufbuild/protobuf');
4
4
  var connect = require('@connectrpc/connect');
5
5
  var connectNode = require('@connectrpc/connect-node');
6
- var proto = require('@thru/proto');
6
+ var proto = require('@thru/sdk/proto');
7
7
 
8
8
  // src/chain-client.ts
9
9
  var ChainClient = class {
10
10
  constructor(options) {
11
11
  this.options = options;
12
- const transport = options.transport ?? this.createTransport();
13
- this.query = connect.createClient(proto.QueryService, transport);
14
- this.streaming = connect.createClient(proto.StreamingService, transport);
12
+ if (options.transport) {
13
+ this.sessionManager = null;
14
+ this.query = connect.createClient(proto.QueryService, options.transport);
15
+ this.streaming = connect.createClient(proto.StreamingService, options.transport);
16
+ } else {
17
+ const { transport, sessionManager } = this.createOwnedTransport();
18
+ this.sessionManager = sessionManager;
19
+ this.query = connect.createClient(proto.QueryService, transport);
20
+ this.streaming = connect.createClient(proto.StreamingService, transport);
21
+ }
15
22
  this.callOptions = options.callOptions;
16
23
  }
24
+ options;
17
25
  query;
18
26
  streaming;
19
27
  callOptions;
28
+ /**
29
+ * The HTTP/2 session manager owned by this client. Only set when the client
30
+ * created its own gRPC transport (i.e., `options.transport` was not provided).
31
+ * `close()` uses this to tear down the underlying persistent connection.
32
+ */
33
+ sessionManager;
34
+ closed = false;
35
+ /**
36
+ * Close the underlying HTTP/2 session, if this client owns one. Idempotent.
37
+ *
38
+ * Callers are responsible for ensuring that no in-flight RPCs or streams
39
+ * are still being awaited on this client — pending requests will fail.
40
+ *
41
+ * If the client was constructed with an externally-supplied `transport`,
42
+ * `close()` is a no-op; the caller owns the transport's lifecycle.
43
+ */
44
+ close() {
45
+ if (this.closed) return;
46
+ this.closed = true;
47
+ this.sessionManager?.abort();
48
+ }
20
49
  getAccount(request) {
21
50
  return this.query.getAccount(protobuf.create(proto.GetAccountRequestSchema, request), this.callOptions);
22
51
  }
@@ -41,7 +70,7 @@ var ChainClient = class {
41
70
  streamEvents(request) {
42
71
  return this.streaming.streamEvents(protobuf.create(proto.StreamEventsRequestSchema, request), this.callOptions);
43
72
  }
44
- createTransport() {
73
+ createOwnedTransport() {
45
74
  if (!this.options.baseUrl) {
46
75
  throw new Error("ChainClient requires baseUrl when no transport is provided");
47
76
  }
@@ -51,15 +80,19 @@ var ChainClient = class {
51
80
  ...userInterceptors,
52
81
  ...headerInterceptor ? [headerInterceptor] : []
53
82
  ];
54
- return connectNode.createGrpcTransport({
55
- baseUrl: this.options.baseUrl,
56
- useBinaryFormat: this.options.useBinaryFormat ?? true,
57
- interceptors: mergedInterceptors.length ? mergedInterceptors : void 0,
83
+ const sessionManager = new connectNode.Http2SessionManager(this.options.baseUrl, {
58
84
  pingIntervalMs: 3e4,
59
85
  pingIdleConnection: true,
60
86
  pingTimeoutMs: 1e4,
61
87
  idleConnectionTimeoutMs: 0
62
88
  });
89
+ const transport = connectNode.createGrpcTransport({
90
+ baseUrl: this.options.baseUrl,
91
+ useBinaryFormat: this.options.useBinaryFormat ?? true,
92
+ interceptors: mergedInterceptors.length ? mergedInterceptors : void 0,
93
+ sessionManager
94
+ });
95
+ return { transport, sessionManager };
63
96
  }
64
97
  createHeaderInterceptor() {
65
98
  const headers = {};
@@ -77,6 +110,9 @@ var ChainClient = class {
77
110
  getHeight() {
78
111
  return this.query.getHeight(protobuf.create(proto.GetHeightRequestSchema, {}), this.callOptions);
79
112
  }
113
+ getChainInfo(request = {}) {
114
+ return this.query.getChainInfo(protobuf.create(proto.GetChainInfoRequestSchema, request), this.callOptions);
115
+ }
80
116
  };
81
117
 
82
118
  // src/async-queue.ts
@@ -239,15 +275,16 @@ var LivePump = class {
239
275
  buffer;
240
276
  slotOf;
241
277
  keyOf;
242
- source;
278
+ sourceIterator;
243
279
  logger;
244
280
  mode;
245
281
  minSlotSeen = null;
246
282
  maxSlotSeen = null;
247
283
  minEmitSlot = null;
248
284
  pumpPromise;
285
+ closing = false;
249
286
  constructor(options) {
250
- this.source = options.source;
287
+ this.sourceIterator = options.source[Symbol.asyncIterator]();
251
288
  this.slotOf = options.slotOf;
252
289
  this.keyOf = options.keyOf ?? ((item) => options.slotOf(item).toString());
253
290
  this.logger = options.logger ?? NOOP_LOGGER;
@@ -291,12 +328,19 @@ var LivePump = class {
291
328
  return this.queue.next();
292
329
  }
293
330
  async close() {
331
+ this.closing = true;
294
332
  this.queue.close();
295
- await this.pumpPromise;
333
+ await Promise.allSettled([
334
+ this.closeSourceIterator(),
335
+ this.pumpPromise
336
+ ]);
296
337
  }
297
338
  async start() {
298
339
  try {
299
- for await (const item of this.source) {
340
+ while (!this.closing) {
341
+ const next = await this.sourceIterator.next();
342
+ if (next.done || this.closing) break;
343
+ const item = next.value;
300
344
  const slot = this.slotOf(item);
301
345
  if (this.minSlotSeen === null || slot < this.minSlotSeen) this.minSlotSeen = slot;
302
346
  if (this.maxSlotSeen === null || slot > this.maxSlotSeen) this.maxSlotSeen = slot;
@@ -306,9 +350,21 @@ var LivePump = class {
306
350
  this.queue.push(item);
307
351
  }
308
352
  }
309
- this.queue.close();
310
353
  } catch (err) {
311
- this.queue.fail(err);
354
+ if (!this.closing) {
355
+ this.queue.fail(err);
356
+ }
357
+ } finally {
358
+ this.queue.close();
359
+ }
360
+ }
361
+ async closeSourceIterator() {
362
+ if (typeof this.sourceIterator.return !== "function") {
363
+ return;
364
+ }
365
+ try {
366
+ await this.sourceIterator.return();
367
+ } catch {
312
368
  }
313
369
  }
314
370
  };
@@ -328,6 +384,7 @@ function withTimeout(promise, timeoutMs) {
328
384
  const timer = setTimeout(() => {
329
385
  reject(new TimeoutError(`Operation timed out after ${timeoutMs}ms`));
330
386
  }, timeoutMs);
387
+ timer.unref?.();
331
388
  promise.then((value) => {
332
389
  clearTimeout(timer);
333
390
  resolve(value);
@@ -346,6 +403,37 @@ var TimeoutError = class extends Error {
346
403
  function delay(ms) {
347
404
  return new Promise((resolve) => setTimeout(resolve, ms));
348
405
  }
406
+ function abortableDelay(ms, signal) {
407
+ if (ms <= 0 || signal?.aborted) {
408
+ return Promise.resolve();
409
+ }
410
+ const abortSignal = signal;
411
+ return new Promise((resolve) => {
412
+ if (!abortSignal) {
413
+ const timer2 = setTimeout(resolve, ms);
414
+ timer2.unref?.();
415
+ return;
416
+ }
417
+ const onAbort = () => {
418
+ clearTimeout(timer);
419
+ abortSignal.removeEventListener("abort", onAbort);
420
+ resolve();
421
+ };
422
+ const timer = setTimeout(() => {
423
+ abortSignal.removeEventListener("abort", onAbort);
424
+ resolve();
425
+ }, ms);
426
+ timer.unref?.();
427
+ abortSignal.addEventListener("abort", onAbort, { once: true });
428
+ });
429
+ }
430
+ function isAbortError(err) {
431
+ if (!err || typeof err !== "object") return false;
432
+ const name = typeof err.name === "string" ? err.name.toLowerCase() : "";
433
+ const message = typeof err.message === "string" ? err.message.toLowerCase() : "";
434
+ const code = String(err.code ?? "").toLowerCase();
435
+ return name.includes("abort") || name.includes("cancel") || code === "canceled" || code === "cancelled" || message.includes("aborted") || message.includes("canceled") || message.includes("cancelled");
436
+ }
349
437
 
350
438
  // src/replay-stream.ts
351
439
  var DEFAULT_METRICS = {
@@ -403,12 +491,17 @@ var ReplayStream = class {
403
491
  extractKey,
404
492
  safetyMargin,
405
493
  resubscribeOnEnd,
406
- onReconnect
494
+ onReconnect,
495
+ signal,
496
+ dispose
407
497
  } = this.config;
408
498
  const shouldResubscribeOnEnd = resubscribeOnEnd ?? true;
409
499
  const keyOf = extractKey ?? ((item) => extractSlot(item).toString());
500
+ const shouldStop = (err) => signal?.aborted === true || isAbortError(err);
410
501
  let currentSubscribeLive = subscribeLive;
411
502
  let currentFetchBackfill = fetchBackfill;
503
+ let currentDispose = dispose ?? (() => {
504
+ });
412
505
  const createLivePump = (slot, startStreaming = false, emitFloor) => new LivePump({
413
506
  source: currentSubscribeLive(slot),
414
507
  slotOf: extractSlot,
@@ -465,92 +558,78 @@ var ReplayStream = class {
465
558
  };
466
559
  let emptyPageRetries = 0;
467
560
  const MAX_EMPTY_PAGE_RETRIES = 10;
468
- while (!backfillDone) {
469
- const page = await currentFetchBackfill({ startSlot, cursor });
470
- if (!page.items.length && !page.cursor && !page.done) {
471
- emptyPageRetries++;
472
- if (emptyPageRetries > MAX_EMPTY_PAGE_RETRIES) {
473
- this.logger.error(
474
- `backfill returned ${MAX_EMPTY_PAGE_RETRIES} consecutive empty pages; treating as done`
561
+ try {
562
+ while (!backfillDone) {
563
+ if (shouldStop()) return;
564
+ let page;
565
+ try {
566
+ page = await currentFetchBackfill({ startSlot, cursor });
567
+ } catch (err) {
568
+ if (shouldStop(err)) return;
569
+ throw err;
570
+ }
571
+ if (!page.items.length && !page.cursor && !page.done) {
572
+ emptyPageRetries++;
573
+ if (emptyPageRetries > MAX_EMPTY_PAGE_RETRIES) {
574
+ this.logger.error(
575
+ `backfill returned ${MAX_EMPTY_PAGE_RETRIES} consecutive empty pages; treating as done`
576
+ );
577
+ for await (const item of flushPendingBackfill(this)) {
578
+ yield item;
579
+ }
580
+ break;
581
+ }
582
+ const backoffMs = calculateBackoff(emptyPageRetries - 1, DEFAULT_RETRY_CONFIG);
583
+ this.logger.warn(
584
+ `empty backfill page without cursor; retrying in ${backoffMs}ms (${emptyPageRetries}/${MAX_EMPTY_PAGE_RETRIES})`
475
585
  );
586
+ await abortableDelay(backoffMs, signal);
587
+ continue;
588
+ }
589
+ emptyPageRetries = 0;
590
+ assertBackfillPageOrder(pendingOrderedPage, page.items, extractSlot);
591
+ if (pendingOrderedPage !== null) {
476
592
  for await (const item of flushPendingBackfill(this)) {
593
+ if (shouldStop()) return;
477
594
  yield item;
478
595
  }
479
- break;
480
- }
481
- const backoffMs = calculateBackoff(emptyPageRetries - 1, DEFAULT_RETRY_CONFIG);
482
- this.logger.warn(
483
- `empty backfill page without cursor; retrying in ${backoffMs}ms (${emptyPageRetries}/${MAX_EMPTY_PAGE_RETRIES})`
484
- );
485
- await delay(backoffMs);
486
- continue;
487
- }
488
- emptyPageRetries = 0;
489
- assertBackfillPageOrder(pendingOrderedPage, page.items, extractSlot);
490
- if (pendingOrderedPage !== null) {
491
- for await (const item of flushPendingBackfill(this)) {
492
- yield item;
493
- }
494
- }
495
- pendingOrderedPage = [...page.items];
496
- const reachedEnd = page.done || page.cursor === void 0;
497
- if (reachedEnd) {
498
- for await (const item of flushPendingBackfill(this)) {
499
- yield item;
500
596
  }
501
- }
502
- const duplicatesTrimmed = livePump.discardBufferedUpTo(currentSlot);
503
- this.metrics.discardedDuplicates += duplicatesTrimmed;
504
- cursor = page.cursor;
505
- const maxStreamSlot = livePump.maxSlot();
506
- if (maxStreamSlot !== null) {
507
- const catchUpSlot = maxStreamSlot > safetyMargin ? maxStreamSlot - safetyMargin : 0n;
508
- if (currentSlot >= catchUpSlot) {
597
+ pendingOrderedPage = [...page.items];
598
+ const reachedEnd = page.done || page.cursor === void 0;
599
+ if (reachedEnd) {
509
600
  for await (const item of flushPendingBackfill(this)) {
601
+ if (shouldStop()) return;
510
602
  yield item;
511
603
  }
512
- this.logger.info(
513
- `replay reached SWITCHING threshold (currentSlot=${currentSlot}, maxStreamSlot=${maxStreamSlot}, catchUpSlot=${catchUpSlot})`
514
- );
515
- backfillDone = true;
516
604
  }
517
- }
518
- if (reachedEnd) backfillDone = true;
519
- }
520
- this.logger.info(`replay entering SWITCHING state (currentSlot=${currentSlot})`);
521
- const { drained, discarded } = livePump.enableStreaming(currentSlot);
522
- this.metrics.bufferedItems = drained.length;
523
- this.metrics.discardedDuplicates += discarded;
524
- for (const item of drained) {
525
- const slot = extractSlot(item);
526
- const key = keyOf(item);
527
- if (seenItem(slot, key)) {
528
- this.metrics.discardedDuplicates += 1;
529
- continue;
530
- }
531
- currentSlot = slot;
532
- recordEmission(slot, key);
533
- this.metrics.emittedLive += 1;
534
- yield item;
535
- livePump.updateEmitFloor(currentSlot);
536
- }
537
- if (!drained.length) livePump.updateEmitFloor(currentSlot);
538
- this.logger.info("replay entering STREAMING state");
539
- const retryConfig = DEFAULT_RETRY_CONFIG;
540
- let retryAttempt = 0;
541
- while (true) {
542
- try {
543
- const next = await withTimeout(
544
- livePump.next(),
545
- retryConfig.connectionTimeoutMs
546
- );
547
- retryAttempt = 0;
548
- if (next.done) {
549
- if (!shouldResubscribeOnEnd) break;
550
- throw new Error("stream ended");
605
+ const duplicatesTrimmed = livePump.discardBufferedUpTo(currentSlot);
606
+ this.metrics.discardedDuplicates += duplicatesTrimmed;
607
+ cursor = page.cursor;
608
+ const maxStreamSlot = livePump.maxSlot();
609
+ if (maxStreamSlot !== null) {
610
+ const catchUpSlot = maxStreamSlot > safetyMargin ? maxStreamSlot - safetyMargin : 0n;
611
+ if (currentSlot >= catchUpSlot) {
612
+ for await (const item of flushPendingBackfill(this)) {
613
+ if (shouldStop()) return;
614
+ yield item;
615
+ }
616
+ this.logger.info(
617
+ `replay reached SWITCHING threshold (currentSlot=${currentSlot}, maxStreamSlot=${maxStreamSlot}, catchUpSlot=${catchUpSlot})`
618
+ );
619
+ backfillDone = true;
620
+ }
551
621
  }
552
- const slot = extractSlot(next.value);
553
- const key = keyOf(next.value);
622
+ if (reachedEnd) backfillDone = true;
623
+ }
624
+ if (shouldStop()) return;
625
+ this.logger.info(`replay entering SWITCHING state (currentSlot=${currentSlot})`);
626
+ const { drained, discarded } = livePump.enableStreaming(currentSlot);
627
+ this.metrics.bufferedItems = drained.length;
628
+ this.metrics.discardedDuplicates += discarded;
629
+ for (const item of drained) {
630
+ if (shouldStop()) return;
631
+ const slot = extractSlot(item);
632
+ const key = keyOf(item);
554
633
  if (seenItem(slot, key)) {
555
634
  this.metrics.discardedDuplicates += 1;
556
635
  continue;
@@ -558,57 +637,97 @@ var ReplayStream = class {
558
637
  currentSlot = slot;
559
638
  recordEmission(slot, key);
560
639
  this.metrics.emittedLive += 1;
561
- yield next.value;
640
+ yield item;
562
641
  livePump.updateEmitFloor(currentSlot);
563
- } catch (err) {
564
- const errMsg = err instanceof Error ? err.message : String(err);
565
- const backoffMs = calculateBackoff(retryAttempt, retryConfig);
566
- this.logger.warn(
567
- `live stream disconnected (${errMsg}); reconnecting in ${backoffMs}ms from slot ${currentSlot} (attempt ${retryAttempt + 1})`
568
- );
569
- await delay(backoffMs);
570
- await safeClose(livePump);
571
- retryAttempt++;
572
- if (onReconnect) {
573
- try {
574
- const fresh = onReconnect();
575
- currentSubscribeLive = fresh.subscribeLive;
576
- if (fresh.fetchBackfill) {
577
- currentFetchBackfill = fresh.fetchBackfill;
642
+ }
643
+ if (!drained.length) livePump.updateEmitFloor(currentSlot);
644
+ this.logger.info("replay entering STREAMING state");
645
+ const retryConfig = DEFAULT_RETRY_CONFIG;
646
+ let retryAttempt = 0;
647
+ while (true) {
648
+ if (shouldStop()) return;
649
+ try {
650
+ const next = await withTimeout(
651
+ livePump.next(),
652
+ retryConfig.connectionTimeoutMs
653
+ );
654
+ retryAttempt = 0;
655
+ if (next.done) {
656
+ if (shouldStop()) return;
657
+ if (!shouldResubscribeOnEnd) break;
658
+ throw new Error("stream ended");
659
+ }
660
+ const slot = extractSlot(next.value);
661
+ const key = keyOf(next.value);
662
+ if (seenItem(slot, key)) {
663
+ this.metrics.discardedDuplicates += 1;
664
+ continue;
665
+ }
666
+ currentSlot = slot;
667
+ recordEmission(slot, key);
668
+ this.metrics.emittedLive += 1;
669
+ yield next.value;
670
+ livePump.updateEmitFloor(currentSlot);
671
+ } catch (err) {
672
+ if (shouldStop(err)) return;
673
+ const errMsg = err instanceof Error ? err.message : String(err);
674
+ const backoffMs = calculateBackoff(retryAttempt, retryConfig);
675
+ this.logger.warn(
676
+ `live stream disconnected (${errMsg}); reconnecting in ${backoffMs}ms from slot ${currentSlot} (attempt ${retryAttempt + 1})`
677
+ );
678
+ await abortableDelay(backoffMs, signal);
679
+ if (shouldStop()) return;
680
+ currentDispose();
681
+ await safeClose(livePump);
682
+ retryAttempt++;
683
+ if (onReconnect) {
684
+ try {
685
+ const fresh = onReconnect();
686
+ currentSubscribeLive = fresh.subscribeLive;
687
+ if (fresh.fetchBackfill) {
688
+ currentFetchBackfill = fresh.fetchBackfill;
689
+ }
690
+ currentDispose = fresh.dispose ?? (() => {
691
+ });
692
+ this.logger.info("created fresh client for reconnection");
693
+ } catch (factoryErr) {
694
+ this.logger.error(
695
+ `failed to create fresh client: ${factoryErr instanceof Error ? factoryErr.message : String(factoryErr)}; using existing`
696
+ );
578
697
  }
579
- this.logger.info("created fresh client for reconnection");
580
- } catch (factoryErr) {
581
- this.logger.error(
582
- `failed to create fresh client: ${factoryErr instanceof Error ? factoryErr.message : String(factoryErr)}; using existing`
583
- );
584
698
  }
585
- }
586
- if (onReconnect && currentSlot > 0n) {
587
- for await (const item of this.miniBackfill(
588
- currentSlot,
589
- currentFetchBackfill,
590
- extractSlot,
591
- keyOf,
592
- seenItem,
593
- recordEmission
594
- )) {
595
- const itemSlot = extractSlot(item);
596
- if (itemSlot > currentSlot) {
597
- currentSlot = itemSlot;
699
+ if (onReconnect && currentSlot > 0n) {
700
+ for await (const item of this.miniBackfill(
701
+ currentSlot,
702
+ currentFetchBackfill,
703
+ extractSlot,
704
+ keyOf,
705
+ seenItem,
706
+ recordEmission,
707
+ signal
708
+ )) {
709
+ if (shouldStop()) return;
710
+ const itemSlot = extractSlot(item);
711
+ if (itemSlot > currentSlot) {
712
+ currentSlot = itemSlot;
713
+ }
714
+ yield item;
598
715
  }
599
- yield item;
600
716
  }
717
+ const resumeSlot = currentSlot > 0n ? currentSlot : 0n;
718
+ livePump = createLivePump(resumeSlot, true, currentSlot);
601
719
  }
602
- const resumeSlot = currentSlot > 0n ? currentSlot : 0n;
603
- livePump = createLivePump(resumeSlot, true, currentSlot);
604
720
  }
721
+ } finally {
722
+ currentDispose();
723
+ await safeClose(livePump);
605
724
  }
606
725
  }
607
726
  /**
608
727
  * Perform mini-backfill from lastProcessedSlot to catch up after reconnection.
609
728
  * Ensures no data gaps from events that occurred during disconnection.
610
729
  */
611
- async *miniBackfill(fromSlot, fetchBackfill, extractSlot, keyOf, seenItem, recordEmission) {
730
+ async *miniBackfill(fromSlot, fetchBackfill, extractSlot, keyOf, seenItem, recordEmission, signal) {
612
731
  this.logger.info(`mini-backfill starting from slot ${fromSlot}`);
613
732
  const MINI_BACKFILL_TIMEOUT = 3e4;
614
733
  let lastProgressTime = Date.now();
@@ -616,6 +735,7 @@ var ReplayStream = class {
616
735
  let itemsYielded = 0;
617
736
  try {
618
737
  while (true) {
738
+ if (signal?.aborted) return;
619
739
  if (Date.now() - lastProgressTime > MINI_BACKFILL_TIMEOUT) {
620
740
  this.logger.warn(`mini-backfill timed out after ${MINI_BACKFILL_TIMEOUT}ms with no progress`);
621
741
  break;
@@ -626,6 +746,7 @@ var ReplayStream = class {
626
746
  );
627
747
  let pageYielded = 0;
628
748
  for (const item of sorted) {
749
+ if (signal?.aborted) return;
629
750
  const slot = extractSlot(item);
630
751
  const key = keyOf(item);
631
752
  if (seenItem(slot, key)) {
@@ -644,6 +765,9 @@ var ReplayStream = class {
644
765
  }
645
766
  this.logger.info(`mini-backfill complete: ${itemsYielded} items yielded`);
646
767
  } catch (err) {
768
+ if (signal?.aborted || isAbortError(err)) {
769
+ return;
770
+ }
647
771
  this.logger.warn(
648
772
  `mini-backfill failed: ${err instanceof Error ? err.message : String(err)}; proceeding with live stream`
649
773
  );
@@ -651,14 +775,22 @@ var ReplayStream = class {
651
775
  }
652
776
  };
653
777
  async function safeClose(pump) {
778
+ let timeoutId;
654
779
  try {
780
+ const timeout = new Promise((resolve) => {
781
+ timeoutId = setTimeout(() => resolve("timeout"), 5e3);
782
+ });
655
783
  const result = await Promise.race([
656
784
  pump.close().then(() => "closed"),
657
- new Promise((resolve) => setTimeout(() => resolve("timeout"), 5e3))
785
+ timeout
658
786
  ]);
659
787
  if (result === "timeout") {
660
788
  }
661
789
  } catch {
790
+ } finally {
791
+ if (timeoutId !== void 0) {
792
+ clearTimeout(timeoutId);
793
+ }
662
794
  }
663
795
  }
664
796
  function combineFilters(base, user) {
@@ -754,15 +886,38 @@ function createBlockReplay(options) {
754
886
  subscribeLive,
755
887
  extractSlot: (block) => block.header?.slot ?? 0n,
756
888
  logger: options.logger,
757
- resubscribeOnEnd: options.resubscribeOnEnd
889
+ resubscribeOnEnd: options.resubscribeOnEnd,
890
+ signal: options.signal
758
891
  });
759
892
  }
893
+
894
+ // src/types.ts
895
+ function resolveClient(opts, optionsName) {
896
+ if (opts.clientFactory) {
897
+ return opts.clientFactory();
898
+ }
899
+ if (!opts.client) {
900
+ throw new Error(`${optionsName} requires either client or clientFactory`);
901
+ }
902
+ return opts.client;
903
+ }
904
+ function closeIfCloseable(value) {
905
+ if (value && typeof value.close === "function") {
906
+ try {
907
+ value.close();
908
+ } catch {
909
+ }
910
+ }
911
+ }
912
+
913
+ // src/replay/transaction-replay.ts
760
914
  var DEFAULT_PAGE_SIZE2 = 256;
761
915
  var DEFAULT_SAFETY_MARGIN2 = 64n;
762
916
  var PAGE_ORDER_ASC2 = "slot asc";
763
917
  function createTransactionReplay(options) {
764
918
  const safetyMargin = options.safetyMargin ?? DEFAULT_SAFETY_MARGIN2;
765
- const fetchBackfill = async ({
919
+ let currentClient = resolveClient(options, "TransactionReplayOptions");
920
+ const createFetchBackfill = (client) => async ({
766
921
  startSlot,
767
922
  cursor
768
923
  }) => {
@@ -772,7 +927,7 @@ function createTransactionReplay(options) {
772
927
  pageToken: cursor
773
928
  });
774
929
  const mergedFilter = combineFilters(slotLiteralFilter("transaction.slot", startSlot), options.filter);
775
- const response = await options.client.listTransactions(
930
+ const response = await client.listTransactions(
776
931
  protobuf.create(proto.ListTransactionsRequestSchema, {
777
932
  filter: mergedFilter,
778
933
  page,
@@ -782,26 +937,38 @@ function createTransactionReplay(options) {
782
937
  );
783
938
  return backfillPage(response.transactions, response.page);
784
939
  };
785
- const subscribeLive = (startSlot) => {
940
+ const createSubscribeLive = (client) => (startSlot) => {
786
941
  const mergedFilter = combineFilters(slotLiteralFilter("transaction.slot", startSlot), options.filter);
787
942
  const request = protobuf.create(proto.StreamTransactionsRequestSchema, {
788
943
  filter: mergedFilter,
789
944
  minConsensus: options.minConsensus
790
945
  });
791
946
  return mapAsyncIterable(
792
- options.client.streamTransactions(request),
947
+ client.streamTransactions(request),
793
948
  (resp) => resp.transaction
794
949
  );
795
950
  };
951
+ const onReconnect = options.clientFactory ? () => {
952
+ const newClient = options.clientFactory();
953
+ currentClient = newClient;
954
+ return {
955
+ subscribeLive: createSubscribeLive(currentClient),
956
+ fetchBackfill: createFetchBackfill(currentClient),
957
+ dispose: () => closeIfCloseable(newClient)
958
+ };
959
+ } : void 0;
796
960
  return new ReplayStream({
797
961
  startSlot: options.startSlot,
798
962
  safetyMargin,
799
- fetchBackfill,
800
- subscribeLive,
963
+ fetchBackfill: createFetchBackfill(currentClient),
964
+ subscribeLive: createSubscribeLive(currentClient),
801
965
  extractSlot: (tx) => tx.slot ?? 0n,
802
966
  extractKey: transactionKey,
803
967
  logger: options.logger,
804
- resubscribeOnEnd: options.resubscribeOnEnd
968
+ resubscribeOnEnd: options.resubscribeOnEnd,
969
+ signal: options.signal,
970
+ onReconnect,
971
+ dispose: options.clientFactory ? () => closeIfCloseable(currentClient) : void 0
805
972
  });
806
973
  }
807
974
  function transactionKey(tx) {
@@ -816,19 +983,6 @@ function bytesToHex(bytes) {
816
983
  for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
817
984
  return hex;
818
985
  }
819
-
820
- // src/types.ts
821
- function resolveClient(opts, optionsName) {
822
- if (opts.clientFactory) {
823
- return opts.clientFactory();
824
- }
825
- if (!opts.client) {
826
- throw new Error(`${optionsName} requires either client or clientFactory`);
827
- }
828
- return opts.client;
829
- }
830
-
831
- // src/replay/event-replay.ts
832
986
  var DEFAULT_PAGE_SIZE3 = 512;
833
987
  var DEFAULT_SAFETY_MARGIN3 = 64n;
834
988
  var PAGE_ORDER_ASC3 = "slot asc";
@@ -871,10 +1025,12 @@ function createEventReplay(options) {
871
1025
  );
872
1026
  };
873
1027
  const onReconnect = options.clientFactory ? () => {
874
- currentClient = options.clientFactory();
1028
+ const newClient = options.clientFactory();
1029
+ currentClient = newClient;
875
1030
  return {
876
1031
  subscribeLive: createSubscribeLive(currentClient),
877
- fetchBackfill: createFetchBackfill(currentClient)
1032
+ fetchBackfill: createFetchBackfill(currentClient),
1033
+ dispose: () => closeIfCloseable(newClient)
878
1034
  };
879
1035
  } : void 0;
880
1036
  return new ReplayStream({
@@ -886,7 +1042,9 @@ function createEventReplay(options) {
886
1042
  extractKey: eventKey,
887
1043
  logger: options.logger,
888
1044
  resubscribeOnEnd: options.resubscribeOnEnd,
889
- onReconnect
1045
+ signal: options.signal,
1046
+ onReconnect,
1047
+ dispose: options.clientFactory ? () => closeIfCloseable(currentClient) : void 0
890
1048
  });
891
1049
  }
892
1050
  function streamResponseToEvent(resp) {
@@ -1111,6 +1269,15 @@ var PageAssembler = class {
1111
1269
  };
1112
1270
 
1113
1271
  // src/account-replay.ts
1272
+ async function closeAsyncIterator(iterator) {
1273
+ if (!iterator || typeof iterator.return !== "function") {
1274
+ return;
1275
+ }
1276
+ try {
1277
+ await iterator.return();
1278
+ } catch {
1279
+ }
1280
+ }
1114
1281
  function bytesToHex2(bytes) {
1115
1282
  return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
1116
1283
  }
@@ -1185,9 +1352,12 @@ async function* createAccountsByOwnerReplay(options) {
1185
1352
  cleanupInterval = 1e4,
1186
1353
  onBackfillComplete,
1187
1354
  clientFactory,
1188
- logger = NOOP_LOGGER
1355
+ logger = NOOP_LOGGER,
1356
+ signal
1189
1357
  } = options;
1358
+ const shouldStop = (err) => signal?.aborted === true || isAbortError(err);
1190
1359
  let client = resolveClient(options, "AccountsByOwnerReplayOptions");
1360
+ const ownsClient = Boolean(clientFactory);
1191
1361
  const seenFromStream = /* @__PURE__ */ new Set();
1192
1362
  const fetchQueue = [];
1193
1363
  let highestSlotSeen = minUpdatedSlot ?? 0n;
@@ -1197,15 +1367,24 @@ async function* createAccountsByOwnerReplay(options) {
1197
1367
  let streamDone = false;
1198
1368
  let streamError = null;
1199
1369
  let lastActivityTime = Date.now();
1370
+ let activeStreamIterator = null;
1371
+ let activeStreamProcessor = null;
1200
1372
  try {
1373
+ if (shouldStop()) return;
1201
1374
  cleanupTimer = setInterval(() => {
1202
1375
  assembler.cleanup();
1203
1376
  }, cleanupInterval);
1377
+ cleanupTimer.unref?.();
1204
1378
  const streamFilter = buildOwnerFilterWithMinSlot(owner, dataSizes, minUpdatedSlot);
1205
1379
  const stream = client.streamAccountUpdates({ view, filter: streamFilter });
1206
- const streamProcessor = (async () => {
1380
+ const streamIterator = stream[Symbol.asyncIterator]();
1381
+ activeStreamIterator = streamIterator;
1382
+ activeStreamProcessor = (async () => {
1207
1383
  try {
1208
- for await (const response of stream) {
1384
+ while (true) {
1385
+ const next = await streamIterator.next();
1386
+ if (next.done) break;
1387
+ const response = next.value;
1209
1388
  lastActivityTime = Date.now();
1210
1389
  const event = processResponseMulti(response, assembler);
1211
1390
  if (event) {
@@ -1236,6 +1415,7 @@ async function* createAccountsByOwnerReplay(options) {
1236
1415
  const backfillFilter = buildListAccountsOwnerFilter(owner, dataSizes, minUpdatedSlot);
1237
1416
  let pageToken;
1238
1417
  do {
1418
+ if (shouldStop()) return;
1239
1419
  const request = {
1240
1420
  view: proto.AccountView.META_ONLY,
1241
1421
  // Address + metadata only, no data
@@ -1245,7 +1425,13 @@ async function* createAccountsByOwnerReplay(options) {
1245
1425
  pageToken
1246
1426
  })
1247
1427
  };
1248
- const response = await client.listAccounts(request);
1428
+ let response;
1429
+ try {
1430
+ response = await client.listAccounts(request);
1431
+ } catch (err) {
1432
+ if (shouldStop(err)) return;
1433
+ throw err;
1434
+ }
1249
1435
  for (const account of response.accounts) {
1250
1436
  if (account.address?.value) {
1251
1437
  fetchQueue.push(account.address.value);
@@ -1255,6 +1441,7 @@ async function* createAccountsByOwnerReplay(options) {
1255
1441
  yield* yieldStreamBuffer();
1256
1442
  } while (pageToken);
1257
1443
  for (const address of fetchQueue) {
1444
+ if (shouldStop()) return;
1258
1445
  const addressHex = bytesToHex2(address);
1259
1446
  if (seenFromStream.has(addressHex)) {
1260
1447
  continue;
@@ -1272,10 +1459,11 @@ async function* createAccountsByOwnerReplay(options) {
1272
1459
  });
1273
1460
  break;
1274
1461
  } catch (err) {
1462
+ if (shouldStop(err)) return;
1275
1463
  if (attempt === maxRetries - 1) {
1276
1464
  logger.error(`[backfill] failed to fetch account ${addressHex} after ${maxRetries} attempts`, { error: err });
1277
1465
  } else {
1278
- await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
1466
+ await abortableDelay(100 * (attempt + 1), signal);
1279
1467
  }
1280
1468
  }
1281
1469
  }
@@ -1294,13 +1482,13 @@ async function* createAccountsByOwnerReplay(options) {
1294
1482
  }
1295
1483
  const retryConfig = DEFAULT_RETRY_CONFIG;
1296
1484
  let retryAttempt = 0;
1297
- let currentStream = stream;
1298
- let currentStreamProcessor = streamProcessor;
1299
1485
  lastActivityTime = Date.now();
1300
1486
  const createStreamProcessor = () => {
1301
1487
  if (clientFactory) {
1302
1488
  try {
1303
- client = clientFactory();
1489
+ const newClient = clientFactory();
1490
+ closeIfCloseable(client);
1491
+ client = newClient;
1304
1492
  logger.info("[account-stream] created fresh client for reconnection");
1305
1493
  } catch (err) {
1306
1494
  logger.error("[account-stream] failed to create fresh client", { error: err });
@@ -1308,9 +1496,14 @@ async function* createAccountsByOwnerReplay(options) {
1308
1496
  }
1309
1497
  const newStreamFilter = buildOwnerFilterWithMinSlot(owner, dataSizes, highestSlotSeen > 0n ? highestSlotSeen : minUpdatedSlot);
1310
1498
  const newStream = client.streamAccountUpdates({ view, filter: newStreamFilter });
1499
+ const newStreamIterator = newStream[Symbol.asyncIterator]();
1500
+ activeStreamIterator = newStreamIterator;
1311
1501
  const newProcessor = (async () => {
1312
1502
  try {
1313
- for await (const response of newStream) {
1503
+ while (true) {
1504
+ const next = await newStreamIterator.next();
1505
+ if (next.done) break;
1506
+ const response = next.value;
1314
1507
  retryAttempt = 0;
1315
1508
  lastActivityTime = Date.now();
1316
1509
  const event = processResponseMulti(response, assembler);
@@ -1330,9 +1523,10 @@ async function* createAccountsByOwnerReplay(options) {
1330
1523
  streamDone = true;
1331
1524
  }
1332
1525
  })();
1333
- return { stream: newStream, processor: newProcessor };
1526
+ return { iterator: newStreamIterator, processor: newProcessor };
1334
1527
  };
1335
1528
  while (true) {
1529
+ if (shouldStop()) return;
1336
1530
  const hadEvents = streamBuffer.length > 0;
1337
1531
  yield* yieldStreamBuffer();
1338
1532
  if (hadEvents) {
@@ -1347,36 +1541,56 @@ async function* createAccountsByOwnerReplay(options) {
1347
1541
  }
1348
1542
  if (streamDone) {
1349
1543
  if (streamError) {
1544
+ if (shouldStop(streamError)) return;
1350
1545
  const backoffMs = calculateBackoff(retryAttempt, retryConfig);
1351
1546
  logger.warn(
1352
1547
  `[account-stream] disconnected (${streamError.message}); reconnecting in ${backoffMs}ms (attempt ${retryAttempt + 1})`
1353
1548
  );
1354
- await delay(backoffMs);
1549
+ await abortableDelay(backoffMs, signal);
1550
+ if (shouldStop()) return;
1355
1551
  retryAttempt++;
1356
1552
  streamDone = false;
1357
1553
  streamError = null;
1358
1554
  streamBuffer.length = 0;
1359
1555
  lastActivityTime = Date.now();
1360
- const { stream: newStream, processor: newProcessor } = createStreamProcessor();
1361
- currentStream = newStream;
1362
- currentStreamProcessor = newProcessor;
1556
+ await closeAsyncIterator(activeStreamIterator);
1557
+ if (activeStreamProcessor) {
1558
+ await Promise.allSettled([activeStreamProcessor]);
1559
+ }
1560
+ const { iterator: newIterator, processor: newProcessor } = createStreamProcessor();
1561
+ activeStreamIterator = newIterator;
1562
+ activeStreamProcessor = newProcessor;
1363
1563
  continue;
1364
1564
  } else {
1565
+ if (shouldStop()) return;
1365
1566
  logger.warn("[account-stream] stream ended unexpectedly; reconnecting...");
1366
1567
  streamDone = false;
1367
1568
  lastActivityTime = Date.now();
1368
- const { stream: newStream, processor: newProcessor } = createStreamProcessor();
1369
- currentStream = newStream;
1370
- currentStreamProcessor = newProcessor;
1569
+ await closeAsyncIterator(activeStreamIterator);
1570
+ if (activeStreamProcessor) {
1571
+ await Promise.allSettled([activeStreamProcessor]);
1572
+ }
1573
+ const { iterator: newIterator, processor: newProcessor } = createStreamProcessor();
1574
+ activeStreamIterator = newIterator;
1575
+ activeStreamProcessor = newProcessor;
1371
1576
  continue;
1372
1577
  }
1373
1578
  }
1374
- await delay(10);
1579
+ await abortableDelay(10, signal);
1375
1580
  }
1376
1581
  } finally {
1377
1582
  if (cleanupTimer) {
1378
1583
  clearInterval(cleanupTimer);
1379
1584
  }
1585
+ const closeIteratorPromise = closeAsyncIterator(activeStreamIterator);
1586
+ if (ownsClient) {
1587
+ closeIfCloseable(client);
1588
+ }
1589
+ if (activeStreamProcessor) {
1590
+ await Promise.allSettled([closeIteratorPromise, activeStreamProcessor]);
1591
+ } else {
1592
+ await closeIteratorPromise;
1593
+ }
1380
1594
  assembler.clear();
1381
1595
  }
1382
1596
  }
@@ -1412,6 +1626,7 @@ async function* createAccountReplay(options) {
1412
1626
  cleanupTimer = setInterval(() => {
1413
1627
  assembler.cleanup();
1414
1628
  }, cleanupInterval);
1629
+ cleanupTimer.unref?.();
1415
1630
  const filterParams = {
1416
1631
  address: protobuf.create(proto.FilterParamValueSchema, { kind: { case: "bytesValue", value: new Uint8Array(address) } })
1417
1632
  };
@@ -1618,6 +1833,7 @@ var ConsoleSink = class {
1618
1833
  constructor(prefix = "ReplaySink") {
1619
1834
  this.prefix = prefix;
1620
1835
  }
1836
+ prefix;
1621
1837
  open(meta) {
1622
1838
  const suffix = meta?.stream ? ` (${meta.stream})` : "";
1623
1839
  console.info(`${this.prefix}${suffix} opened`, meta?.label ?? "");