@thru/replay 0.2.20 → 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 = {
@@ -359,6 +447,27 @@ function compareBigint(a, b) {
359
447
  if (a === b) return 0;
360
448
  return a < b ? -1 : 1;
361
449
  }
450
+ function isNonDecreasing(items, extractSlot) {
451
+ for (let idx = 1; idx < items.length; idx += 1) {
452
+ if (extractSlot(items[idx]) < extractSlot(items[idx - 1])) return false;
453
+ }
454
+ return true;
455
+ }
456
+ function assertBackfillPageOrder(previousPage, currentPage, extractSlot) {
457
+ if (!isNonDecreasing(currentPage, extractSlot)) {
458
+ throw new Error(
459
+ "backfill source returned a page that is not ordered by ascending slot"
460
+ );
461
+ }
462
+ if (!previousPage?.length || !currentPage.length) return;
463
+ const previousMaxSlot = extractSlot(previousPage[previousPage.length - 1]);
464
+ const currentMinSlot = extractSlot(currentPage[0]);
465
+ if (currentMinSlot < previousMaxSlot) {
466
+ throw new Error(
467
+ `backfill source returned pages out of ascending slot order: page minimum slot ${currentMinSlot} is before previous page maximum slot ${previousMaxSlot}`
468
+ );
469
+ }
470
+ }
362
471
  var ReplayStream = class {
363
472
  config;
364
473
  logger;
@@ -382,12 +491,17 @@ var ReplayStream = class {
382
491
  extractKey,
383
492
  safetyMargin,
384
493
  resubscribeOnEnd,
385
- onReconnect
494
+ onReconnect,
495
+ signal,
496
+ dispose
386
497
  } = this.config;
387
498
  const shouldResubscribeOnEnd = resubscribeOnEnd ?? true;
388
499
  const keyOf = extractKey ?? ((item) => extractSlot(item).toString());
500
+ const shouldStop = (err) => signal?.aborted === true || isAbortError(err);
389
501
  let currentSubscribeLive = subscribeLive;
390
502
  let currentFetchBackfill = fetchBackfill;
503
+ let currentDispose = dispose ?? (() => {
504
+ });
391
505
  const createLivePump = (slot, startStreaming = false, emitFloor) => new LivePump({
392
506
  source: currentSubscribeLive(slot),
393
507
  slotOf: extractSlot,
@@ -419,91 +533,103 @@ var ReplayStream = class {
419
533
  this.logger.info(
420
534
  `replay entering BACKFILLING state (startSlot=${startSlot}, safetyMargin=${safetyMargin})`
421
535
  );
422
- let emptyPageRetries = 0;
423
- const MAX_EMPTY_PAGE_RETRIES = 10;
424
- while (!backfillDone) {
425
- const page = await currentFetchBackfill({ startSlot, cursor });
426
- if (!page.items.length && !page.cursor && !page.done) {
427
- emptyPageRetries++;
428
- if (emptyPageRetries > MAX_EMPTY_PAGE_RETRIES) {
429
- this.logger.error(
430
- `backfill returned ${MAX_EMPTY_PAGE_RETRIES} consecutive empty pages; treating as done`
431
- );
432
- break;
433
- }
434
- const backoffMs = calculateBackoff(emptyPageRetries - 1, DEFAULT_RETRY_CONFIG);
435
- this.logger.warn(
436
- `empty backfill page without cursor; retrying in ${backoffMs}ms (${emptyPageRetries}/${MAX_EMPTY_PAGE_RETRIES})`
437
- );
438
- await delay(backoffMs);
439
- continue;
440
- }
441
- emptyPageRetries = 0;
442
- const sorted = [...page.items].sort(
443
- (a, b) => compareBigint(extractSlot(a), extractSlot(b))
444
- );
445
- for (const item of sorted) {
536
+ let pendingOrderedPage = null;
537
+ const emitBackfillItems = async function* (self, items) {
538
+ for (const item of items) {
446
539
  const slot = extractSlot(item);
447
540
  const key = keyOf(item);
448
541
  if (slot < startSlot) continue;
449
542
  if (seenItem(slot, key)) {
450
- this.metrics.discardedDuplicates += 1;
543
+ self.metrics.discardedDuplicates += 1;
451
544
  continue;
452
545
  }
453
546
  currentSlot = slot;
454
547
  recordEmission(slot, key);
455
- this.metrics.emittedBackfill += 1;
548
+ self.metrics.emittedBackfill += 1;
549
+ yield item;
550
+ }
551
+ };
552
+ const flushPendingBackfill = async function* (self) {
553
+ if (!pendingOrderedPage) return;
554
+ for await (const item of emitBackfillItems(self, pendingOrderedPage)) {
456
555
  yield item;
457
556
  }
458
- const duplicatesTrimmed = livePump.discardBufferedUpTo(currentSlot);
459
- this.metrics.discardedDuplicates += duplicatesTrimmed;
460
- cursor = page.cursor;
461
- const maxStreamSlot = livePump.maxSlot();
462
- if (maxStreamSlot !== null) {
463
- const catchUpSlot = maxStreamSlot > safetyMargin ? maxStreamSlot - safetyMargin : 0n;
464
- if (currentSlot >= catchUpSlot) {
465
- this.logger.info(
466
- `replay reached SWITCHING threshold (currentSlot=${currentSlot}, maxStreamSlot=${maxStreamSlot}, catchUpSlot=${catchUpSlot})`
557
+ pendingOrderedPage = null;
558
+ };
559
+ let emptyPageRetries = 0;
560
+ const MAX_EMPTY_PAGE_RETRIES = 10;
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})`
467
585
  );
468
- backfillDone = true;
586
+ await abortableDelay(backoffMs, signal);
587
+ continue;
469
588
  }
470
- }
471
- if (page.done || cursor === void 0) backfillDone = true;
472
- }
473
- this.logger.info(`replay entering SWITCHING state (currentSlot=${currentSlot})`);
474
- const { drained, discarded } = livePump.enableStreaming(currentSlot);
475
- this.metrics.bufferedItems = drained.length;
476
- this.metrics.discardedDuplicates += discarded;
477
- for (const item of drained) {
478
- const slot = extractSlot(item);
479
- const key = keyOf(item);
480
- if (seenItem(slot, key)) {
481
- this.metrics.discardedDuplicates += 1;
482
- continue;
483
- }
484
- currentSlot = slot;
485
- recordEmission(slot, key);
486
- this.metrics.emittedLive += 1;
487
- yield item;
488
- livePump.updateEmitFloor(currentSlot);
489
- }
490
- if (!drained.length) livePump.updateEmitFloor(currentSlot);
491
- this.logger.info("replay entering STREAMING state");
492
- const retryConfig = DEFAULT_RETRY_CONFIG;
493
- let retryAttempt = 0;
494
- while (true) {
495
- try {
496
- const next = await withTimeout(
497
- livePump.next(),
498
- retryConfig.connectionTimeoutMs
499
- );
500
- retryAttempt = 0;
501
- if (next.done) {
502
- if (!shouldResubscribeOnEnd) break;
503
- throw new Error("stream ended");
589
+ emptyPageRetries = 0;
590
+ assertBackfillPageOrder(pendingOrderedPage, page.items, extractSlot);
591
+ if (pendingOrderedPage !== null) {
592
+ for await (const item of flushPendingBackfill(this)) {
593
+ if (shouldStop()) return;
594
+ yield item;
595
+ }
596
+ }
597
+ pendingOrderedPage = [...page.items];
598
+ const reachedEnd = page.done || page.cursor === void 0;
599
+ if (reachedEnd) {
600
+ for await (const item of flushPendingBackfill(this)) {
601
+ if (shouldStop()) return;
602
+ yield item;
603
+ }
504
604
  }
505
- const slot = extractSlot(next.value);
506
- const key = keyOf(next.value);
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
+ }
621
+ }
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);
507
633
  if (seenItem(slot, key)) {
508
634
  this.metrics.discardedDuplicates += 1;
509
635
  continue;
@@ -511,57 +637,97 @@ var ReplayStream = class {
511
637
  currentSlot = slot;
512
638
  recordEmission(slot, key);
513
639
  this.metrics.emittedLive += 1;
514
- yield next.value;
640
+ yield item;
515
641
  livePump.updateEmitFloor(currentSlot);
516
- } catch (err) {
517
- const errMsg = err instanceof Error ? err.message : String(err);
518
- const backoffMs = calculateBackoff(retryAttempt, retryConfig);
519
- this.logger.warn(
520
- `live stream disconnected (${errMsg}); reconnecting in ${backoffMs}ms from slot ${currentSlot} (attempt ${retryAttempt + 1})`
521
- );
522
- await delay(backoffMs);
523
- await safeClose(livePump);
524
- retryAttempt++;
525
- if (onReconnect) {
526
- try {
527
- const fresh = onReconnect();
528
- currentSubscribeLive = fresh.subscribeLive;
529
- if (fresh.fetchBackfill) {
530
- 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
+ );
531
697
  }
532
- this.logger.info("created fresh client for reconnection");
533
- } catch (factoryErr) {
534
- this.logger.error(
535
- `failed to create fresh client: ${factoryErr instanceof Error ? factoryErr.message : String(factoryErr)}; using existing`
536
- );
537
698
  }
538
- }
539
- if (onReconnect && currentSlot > 0n) {
540
- for await (const item of this.miniBackfill(
541
- currentSlot,
542
- currentFetchBackfill,
543
- extractSlot,
544
- keyOf,
545
- seenItem,
546
- recordEmission
547
- )) {
548
- const itemSlot = extractSlot(item);
549
- if (itemSlot > currentSlot) {
550
- 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;
551
715
  }
552
- yield item;
553
716
  }
717
+ const resumeSlot = currentSlot > 0n ? currentSlot : 0n;
718
+ livePump = createLivePump(resumeSlot, true, currentSlot);
554
719
  }
555
- const resumeSlot = currentSlot > 0n ? currentSlot : 0n;
556
- livePump = createLivePump(resumeSlot, true, currentSlot);
557
720
  }
721
+ } finally {
722
+ currentDispose();
723
+ await safeClose(livePump);
558
724
  }
559
725
  }
560
726
  /**
561
727
  * Perform mini-backfill from lastProcessedSlot to catch up after reconnection.
562
728
  * Ensures no data gaps from events that occurred during disconnection.
563
729
  */
564
- async *miniBackfill(fromSlot, fetchBackfill, extractSlot, keyOf, seenItem, recordEmission) {
730
+ async *miniBackfill(fromSlot, fetchBackfill, extractSlot, keyOf, seenItem, recordEmission, signal) {
565
731
  this.logger.info(`mini-backfill starting from slot ${fromSlot}`);
566
732
  const MINI_BACKFILL_TIMEOUT = 3e4;
567
733
  let lastProgressTime = Date.now();
@@ -569,6 +735,7 @@ var ReplayStream = class {
569
735
  let itemsYielded = 0;
570
736
  try {
571
737
  while (true) {
738
+ if (signal?.aborted) return;
572
739
  if (Date.now() - lastProgressTime > MINI_BACKFILL_TIMEOUT) {
573
740
  this.logger.warn(`mini-backfill timed out after ${MINI_BACKFILL_TIMEOUT}ms with no progress`);
574
741
  break;
@@ -579,6 +746,7 @@ var ReplayStream = class {
579
746
  );
580
747
  let pageYielded = 0;
581
748
  for (const item of sorted) {
749
+ if (signal?.aborted) return;
582
750
  const slot = extractSlot(item);
583
751
  const key = keyOf(item);
584
752
  if (seenItem(slot, key)) {
@@ -597,6 +765,9 @@ var ReplayStream = class {
597
765
  }
598
766
  this.logger.info(`mini-backfill complete: ${itemsYielded} items yielded`);
599
767
  } catch (err) {
768
+ if (signal?.aborted || isAbortError(err)) {
769
+ return;
770
+ }
600
771
  this.logger.warn(
601
772
  `mini-backfill failed: ${err instanceof Error ? err.message : String(err)}; proceeding with live stream`
602
773
  );
@@ -604,14 +775,22 @@ var ReplayStream = class {
604
775
  }
605
776
  };
606
777
  async function safeClose(pump) {
778
+ let timeoutId;
607
779
  try {
780
+ const timeout = new Promise((resolve) => {
781
+ timeoutId = setTimeout(() => resolve("timeout"), 5e3);
782
+ });
608
783
  const result = await Promise.race([
609
784
  pump.close().then(() => "closed"),
610
- new Promise((resolve) => setTimeout(() => resolve("timeout"), 5e3))
785
+ timeout
611
786
  ]);
612
787
  if (result === "timeout") {
613
788
  }
614
789
  } catch {
790
+ } finally {
791
+ if (timeoutId !== void 0) {
792
+ clearTimeout(timeoutId);
793
+ }
615
794
  }
616
795
  }
617
796
  function combineFilters(base, user) {
@@ -707,15 +886,38 @@ function createBlockReplay(options) {
707
886
  subscribeLive,
708
887
  extractSlot: (block) => block.header?.slot ?? 0n,
709
888
  logger: options.logger,
710
- resubscribeOnEnd: options.resubscribeOnEnd
889
+ resubscribeOnEnd: options.resubscribeOnEnd,
890
+ signal: options.signal
711
891
  });
712
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
713
914
  var DEFAULT_PAGE_SIZE2 = 256;
714
915
  var DEFAULT_SAFETY_MARGIN2 = 64n;
715
916
  var PAGE_ORDER_ASC2 = "slot asc";
716
917
  function createTransactionReplay(options) {
717
918
  const safetyMargin = options.safetyMargin ?? DEFAULT_SAFETY_MARGIN2;
718
- const fetchBackfill = async ({
919
+ let currentClient = resolveClient(options, "TransactionReplayOptions");
920
+ const createFetchBackfill = (client) => async ({
719
921
  startSlot,
720
922
  cursor
721
923
  }) => {
@@ -725,7 +927,7 @@ function createTransactionReplay(options) {
725
927
  pageToken: cursor
726
928
  });
727
929
  const mergedFilter = combineFilters(slotLiteralFilter("transaction.slot", startSlot), options.filter);
728
- const response = await options.client.listTransactions(
930
+ const response = await client.listTransactions(
729
931
  protobuf.create(proto.ListTransactionsRequestSchema, {
730
932
  filter: mergedFilter,
731
933
  page,
@@ -735,26 +937,38 @@ function createTransactionReplay(options) {
735
937
  );
736
938
  return backfillPage(response.transactions, response.page);
737
939
  };
738
- const subscribeLive = (startSlot) => {
940
+ const createSubscribeLive = (client) => (startSlot) => {
739
941
  const mergedFilter = combineFilters(slotLiteralFilter("transaction.slot", startSlot), options.filter);
740
942
  const request = protobuf.create(proto.StreamTransactionsRequestSchema, {
741
943
  filter: mergedFilter,
742
944
  minConsensus: options.minConsensus
743
945
  });
744
946
  return mapAsyncIterable(
745
- options.client.streamTransactions(request),
947
+ client.streamTransactions(request),
746
948
  (resp) => resp.transaction
747
949
  );
748
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;
749
960
  return new ReplayStream({
750
961
  startSlot: options.startSlot,
751
962
  safetyMargin,
752
- fetchBackfill,
753
- subscribeLive,
963
+ fetchBackfill: createFetchBackfill(currentClient),
964
+ subscribeLive: createSubscribeLive(currentClient),
754
965
  extractSlot: (tx) => tx.slot ?? 0n,
755
966
  extractKey: transactionKey,
756
967
  logger: options.logger,
757
- resubscribeOnEnd: options.resubscribeOnEnd
968
+ resubscribeOnEnd: options.resubscribeOnEnd,
969
+ signal: options.signal,
970
+ onReconnect,
971
+ dispose: options.clientFactory ? () => closeIfCloseable(currentClient) : void 0
758
972
  });
759
973
  }
760
974
  function transactionKey(tx) {
@@ -769,19 +983,6 @@ function bytesToHex(bytes) {
769
983
  for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
770
984
  return hex;
771
985
  }
772
-
773
- // src/types.ts
774
- function resolveClient(opts, optionsName) {
775
- if (opts.clientFactory) {
776
- return opts.clientFactory();
777
- }
778
- if (!opts.client) {
779
- throw new Error(`${optionsName} requires either client or clientFactory`);
780
- }
781
- return opts.client;
782
- }
783
-
784
- // src/replay/event-replay.ts
785
986
  var DEFAULT_PAGE_SIZE3 = 512;
786
987
  var DEFAULT_SAFETY_MARGIN3 = 64n;
787
988
  var PAGE_ORDER_ASC3 = "slot asc";
@@ -824,10 +1025,12 @@ function createEventReplay(options) {
824
1025
  );
825
1026
  };
826
1027
  const onReconnect = options.clientFactory ? () => {
827
- currentClient = options.clientFactory();
1028
+ const newClient = options.clientFactory();
1029
+ currentClient = newClient;
828
1030
  return {
829
1031
  subscribeLive: createSubscribeLive(currentClient),
830
- fetchBackfill: createFetchBackfill(currentClient)
1032
+ fetchBackfill: createFetchBackfill(currentClient),
1033
+ dispose: () => closeIfCloseable(newClient)
831
1034
  };
832
1035
  } : void 0;
833
1036
  return new ReplayStream({
@@ -839,7 +1042,9 @@ function createEventReplay(options) {
839
1042
  extractKey: eventKey,
840
1043
  logger: options.logger,
841
1044
  resubscribeOnEnd: options.resubscribeOnEnd,
842
- onReconnect
1045
+ signal: options.signal,
1046
+ onReconnect,
1047
+ dispose: options.clientFactory ? () => closeIfCloseable(currentClient) : void 0
843
1048
  });
844
1049
  }
845
1050
  function streamResponseToEvent(resp) {
@@ -864,7 +1069,7 @@ function eventBackfillFilter(startSlot, resumeAfter) {
864
1069
  return slotLiteralFilter("event.slot", startSlot);
865
1070
  }
866
1071
  return protobuf.create(proto.FilterSchema, {
867
- expression: `event.slot > uint(${boundary.slot.toString()}) || (event.slot == uint(${boundary.slot.toString()}) && (event.block_offset > uint(${boundary.blockOffset.toString()}) || (event.block_offset == uint(${boundary.blockOffset.toString()}) && event.call_idx > uint(${boundary.callIdx.toString()}))))`
1072
+ expression: `event.slot > uint(${boundary.slot.toString()}) || (event.slot == uint(${boundary.slot.toString()}) && (event.block_offset > uint(${boundary.blockOffset.toString()}) || (event.block_offset == uint(${boundary.blockOffset.toString()}) && event.call_idx >= uint(${boundary.callIdx.toString()}))))`
868
1073
  });
869
1074
  }
870
1075
  function parseEventId(resumeAfter) {
@@ -873,23 +1078,27 @@ function parseEventId(resumeAfter) {
873
1078
  }
874
1079
  function parseCanonicalEventId(eventId, expectedSlot) {
875
1080
  if (!eventId) return null;
876
- const match = /^ts(\d+)_(\d+)_(\d+)$/.exec(eventId);
877
- if (!match) return null;
878
- const [slotPart, blockOffsetPart, callIdxPart] = match.slice(1);
879
- const slot = BigInt(slotPart);
880
- if (slot !== expectedSlot) return null;
881
- return {
882
- slot,
883
- blockOffset: BigInt(blockOffsetPart),
884
- callIdx: BigInt(callIdxPart)
885
- };
1081
+ const parts = eventId.split(":");
1082
+ if (parts.length === 5) {
1083
+ const [, slotPart, blockOffsetPart, callIdxPart, eventIdxPart] = parts;
1084
+ const slot = BigInt(slotPart);
1085
+ if (slot !== expectedSlot) return null;
1086
+ return {
1087
+ slot,
1088
+ blockOffset: BigInt(blockOffsetPart),
1089
+ callIdx: BigInt(callIdxPart),
1090
+ eventIdx: BigInt(eventIdxPart)
1091
+ };
1092
+ }
1093
+ return null;
886
1094
  }
887
1095
  function isAfterBoundary(event, boundary) {
888
1096
  if (event.slot !== boundary.slot) return event.slot > boundary.slot;
889
1097
  if (event.blockOffset !== boundary.blockOffset) {
890
1098
  return event.blockOffset > boundary.blockOffset;
891
1099
  }
892
- return event.callIdx > boundary.callIdx;
1100
+ if (event.callIdx !== boundary.callIdx) return event.callIdx > boundary.callIdx;
1101
+ return event.eventIdx > boundary.eventIdx;
893
1102
  }
894
1103
  function shouldEmitLiveEvent(event, startSlot, resumeAfter) {
895
1104
  const boundary = parseEventId(resumeAfter);
@@ -1060,6 +1269,15 @@ var PageAssembler = class {
1060
1269
  };
1061
1270
 
1062
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
+ }
1063
1281
  function bytesToHex2(bytes) {
1064
1282
  return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
1065
1283
  }
@@ -1134,9 +1352,12 @@ async function* createAccountsByOwnerReplay(options) {
1134
1352
  cleanupInterval = 1e4,
1135
1353
  onBackfillComplete,
1136
1354
  clientFactory,
1137
- logger = NOOP_LOGGER
1355
+ logger = NOOP_LOGGER,
1356
+ signal
1138
1357
  } = options;
1358
+ const shouldStop = (err) => signal?.aborted === true || isAbortError(err);
1139
1359
  let client = resolveClient(options, "AccountsByOwnerReplayOptions");
1360
+ const ownsClient = Boolean(clientFactory);
1140
1361
  const seenFromStream = /* @__PURE__ */ new Set();
1141
1362
  const fetchQueue = [];
1142
1363
  let highestSlotSeen = minUpdatedSlot ?? 0n;
@@ -1146,15 +1367,24 @@ async function* createAccountsByOwnerReplay(options) {
1146
1367
  let streamDone = false;
1147
1368
  let streamError = null;
1148
1369
  let lastActivityTime = Date.now();
1370
+ let activeStreamIterator = null;
1371
+ let activeStreamProcessor = null;
1149
1372
  try {
1373
+ if (shouldStop()) return;
1150
1374
  cleanupTimer = setInterval(() => {
1151
1375
  assembler.cleanup();
1152
1376
  }, cleanupInterval);
1377
+ cleanupTimer.unref?.();
1153
1378
  const streamFilter = buildOwnerFilterWithMinSlot(owner, dataSizes, minUpdatedSlot);
1154
1379
  const stream = client.streamAccountUpdates({ view, filter: streamFilter });
1155
- const streamProcessor = (async () => {
1380
+ const streamIterator = stream[Symbol.asyncIterator]();
1381
+ activeStreamIterator = streamIterator;
1382
+ activeStreamProcessor = (async () => {
1156
1383
  try {
1157
- 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;
1158
1388
  lastActivityTime = Date.now();
1159
1389
  const event = processResponseMulti(response, assembler);
1160
1390
  if (event) {
@@ -1185,6 +1415,7 @@ async function* createAccountsByOwnerReplay(options) {
1185
1415
  const backfillFilter = buildListAccountsOwnerFilter(owner, dataSizes, minUpdatedSlot);
1186
1416
  let pageToken;
1187
1417
  do {
1418
+ if (shouldStop()) return;
1188
1419
  const request = {
1189
1420
  view: proto.AccountView.META_ONLY,
1190
1421
  // Address + metadata only, no data
@@ -1194,7 +1425,13 @@ async function* createAccountsByOwnerReplay(options) {
1194
1425
  pageToken
1195
1426
  })
1196
1427
  };
1197
- 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
+ }
1198
1435
  for (const account of response.accounts) {
1199
1436
  if (account.address?.value) {
1200
1437
  fetchQueue.push(account.address.value);
@@ -1204,6 +1441,7 @@ async function* createAccountsByOwnerReplay(options) {
1204
1441
  yield* yieldStreamBuffer();
1205
1442
  } while (pageToken);
1206
1443
  for (const address of fetchQueue) {
1444
+ if (shouldStop()) return;
1207
1445
  const addressHex = bytesToHex2(address);
1208
1446
  if (seenFromStream.has(addressHex)) {
1209
1447
  continue;
@@ -1221,10 +1459,11 @@ async function* createAccountsByOwnerReplay(options) {
1221
1459
  });
1222
1460
  break;
1223
1461
  } catch (err) {
1462
+ if (shouldStop(err)) return;
1224
1463
  if (attempt === maxRetries - 1) {
1225
1464
  logger.error(`[backfill] failed to fetch account ${addressHex} after ${maxRetries} attempts`, { error: err });
1226
1465
  } else {
1227
- await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
1466
+ await abortableDelay(100 * (attempt + 1), signal);
1228
1467
  }
1229
1468
  }
1230
1469
  }
@@ -1243,13 +1482,13 @@ async function* createAccountsByOwnerReplay(options) {
1243
1482
  }
1244
1483
  const retryConfig = DEFAULT_RETRY_CONFIG;
1245
1484
  let retryAttempt = 0;
1246
- let currentStream = stream;
1247
- let currentStreamProcessor = streamProcessor;
1248
1485
  lastActivityTime = Date.now();
1249
1486
  const createStreamProcessor = () => {
1250
1487
  if (clientFactory) {
1251
1488
  try {
1252
- client = clientFactory();
1489
+ const newClient = clientFactory();
1490
+ closeIfCloseable(client);
1491
+ client = newClient;
1253
1492
  logger.info("[account-stream] created fresh client for reconnection");
1254
1493
  } catch (err) {
1255
1494
  logger.error("[account-stream] failed to create fresh client", { error: err });
@@ -1257,9 +1496,14 @@ async function* createAccountsByOwnerReplay(options) {
1257
1496
  }
1258
1497
  const newStreamFilter = buildOwnerFilterWithMinSlot(owner, dataSizes, highestSlotSeen > 0n ? highestSlotSeen : minUpdatedSlot);
1259
1498
  const newStream = client.streamAccountUpdates({ view, filter: newStreamFilter });
1499
+ const newStreamIterator = newStream[Symbol.asyncIterator]();
1500
+ activeStreamIterator = newStreamIterator;
1260
1501
  const newProcessor = (async () => {
1261
1502
  try {
1262
- 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;
1263
1507
  retryAttempt = 0;
1264
1508
  lastActivityTime = Date.now();
1265
1509
  const event = processResponseMulti(response, assembler);
@@ -1279,9 +1523,10 @@ async function* createAccountsByOwnerReplay(options) {
1279
1523
  streamDone = true;
1280
1524
  }
1281
1525
  })();
1282
- return { stream: newStream, processor: newProcessor };
1526
+ return { iterator: newStreamIterator, processor: newProcessor };
1283
1527
  };
1284
1528
  while (true) {
1529
+ if (shouldStop()) return;
1285
1530
  const hadEvents = streamBuffer.length > 0;
1286
1531
  yield* yieldStreamBuffer();
1287
1532
  if (hadEvents) {
@@ -1296,36 +1541,56 @@ async function* createAccountsByOwnerReplay(options) {
1296
1541
  }
1297
1542
  if (streamDone) {
1298
1543
  if (streamError) {
1544
+ if (shouldStop(streamError)) return;
1299
1545
  const backoffMs = calculateBackoff(retryAttempt, retryConfig);
1300
1546
  logger.warn(
1301
1547
  `[account-stream] disconnected (${streamError.message}); reconnecting in ${backoffMs}ms (attempt ${retryAttempt + 1})`
1302
1548
  );
1303
- await delay(backoffMs);
1549
+ await abortableDelay(backoffMs, signal);
1550
+ if (shouldStop()) return;
1304
1551
  retryAttempt++;
1305
1552
  streamDone = false;
1306
1553
  streamError = null;
1307
1554
  streamBuffer.length = 0;
1308
1555
  lastActivityTime = Date.now();
1309
- const { stream: newStream, processor: newProcessor } = createStreamProcessor();
1310
- currentStream = newStream;
1311
- 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;
1312
1563
  continue;
1313
1564
  } else {
1565
+ if (shouldStop()) return;
1314
1566
  logger.warn("[account-stream] stream ended unexpectedly; reconnecting...");
1315
1567
  streamDone = false;
1316
1568
  lastActivityTime = Date.now();
1317
- const { stream: newStream, processor: newProcessor } = createStreamProcessor();
1318
- currentStream = newStream;
1319
- 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;
1320
1576
  continue;
1321
1577
  }
1322
1578
  }
1323
- await delay(10);
1579
+ await abortableDelay(10, signal);
1324
1580
  }
1325
1581
  } finally {
1326
1582
  if (cleanupTimer) {
1327
1583
  clearInterval(cleanupTimer);
1328
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
+ }
1329
1594
  assembler.clear();
1330
1595
  }
1331
1596
  }
@@ -1361,6 +1626,7 @@ async function* createAccountReplay(options) {
1361
1626
  cleanupTimer = setInterval(() => {
1362
1627
  assembler.cleanup();
1363
1628
  }, cleanupInterval);
1629
+ cleanupTimer.unref?.();
1364
1630
  const filterParams = {
1365
1631
  address: protobuf.create(proto.FilterParamValueSchema, { kind: { case: "bytesValue", value: new Uint8Array(address) } })
1366
1632
  };
@@ -1567,6 +1833,7 @@ var ConsoleSink = class {
1567
1833
  constructor(prefix = "ReplaySink") {
1568
1834
  this.prefix = prefix;
1569
1835
  }
1836
+ prefix;
1570
1837
  open(meta) {
1571
1838
  const suffix = meta?.stream ? ` (${meta.stream})` : "";
1572
1839
  console.info(`${this.prefix}${suffix} opened`, meta?.label ?? "");