@thru/replay 0.2.21 → 0.2.23

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