cross-tab-worker-databus 0.20.58 → 0.20.60

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.
@@ -613,6 +613,67 @@ var WorkerClusterRuntime = class {
613
613
  }
614
614
  this.wildcardPublishCache.set(topic, null);
615
615
  }
616
+ return this.sendControl(this.resolvePublishTarget(topic, topicKey), "PUBLISH", topic, topicKey, data, metadata);
617
+ }
618
+ /**
619
+ * Burst-friendly variant of `publish()`: packs up to N items into a single
620
+ * BroadcastChannel postMessage so the receiving owner dispatches them all in
621
+ * one tick. Per-item dedup / replay / dispatch ordering is preserved; items
622
+ * may carry their own messageId/timestamp. Empty batch is a no-op,
623
+ * single-item batch delegates to `publish()`.
624
+ */
625
+ publishBatch(topic, items) {
626
+ if (items.length === 0) return true;
627
+ if (items.length === 1) {
628
+ const single = items[0];
629
+ const metadata = single.messageId !== void 0 || single.timestamp !== void 0 ? {
630
+ ...single.messageId !== void 0 ? { messageId: single.messageId } : {},
631
+ ...single.timestamp !== void 0 ? { timestamp: single.timestamp } : {}
632
+ } : void 0;
633
+ return this.publish(topic, single.data, metadata);
634
+ }
635
+ const topicKey = this.rememberTopic(topic);
636
+ if (this.assignedTopics.has(topicKey)) {
637
+ for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
638
+ return true;
639
+ }
640
+ const cachedPattern = this.wildcardPublishCache.get(topic);
641
+ if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
642
+ for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
643
+ return true;
644
+ }
645
+ if (cachedPattern === void 0) {
646
+ for (const pattern of this.assignedTopics.values()) {
647
+ if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
648
+ this.wildcardPublishCache.set(topic, pattern);
649
+ for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
650
+ return true;
651
+ }
652
+ }
653
+ this.wildcardPublishCache.set(topic, null);
654
+ }
655
+ const target = this.resolvePublishTarget(topic, topicKey);
656
+ if (target === this.workerId) {
657
+ for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
658
+ return true;
659
+ }
660
+ return this.send({
661
+ type: "CONTROL",
662
+ sourceWorkerId: this.workerId,
663
+ targetWorkerId: target,
664
+ action: "PUBLISH",
665
+ topic,
666
+ topicKey,
667
+ items: items.map((item) => ({
668
+ data: item.data,
669
+ ...item.messageId !== void 0 ? { messageId: item.messageId } : {},
670
+ ...item.timestamp !== void 0 ? { timestamp: item.timestamp } : {}
671
+ }))
672
+ });
673
+ }
674
+ /** Resolve which worker should receive a PUBLISH for `topic`. Centralises the
675
+ * route-owner cache lookup so `publish()` and `publishBatch()` share one path. */
676
+ resolvePublishTarget(topic, topicKey) {
616
677
  const workers = this.readWorkers();
617
678
  const route = this.readRoute(topicKey);
618
679
  const cached = this.routeOwnerCache.get(topicKey);
@@ -620,9 +681,19 @@ var WorkerClusterRuntime = class {
620
681
  if (cachedLive) this.routeOwnerCacheHits += 1;
621
682
  else this.routeOwnerCacheMisses += 1;
622
683
  const target = cachedLive ? cached.workerId : this.routeOwnerIsLive(route, workers) ? route?.workerId ?? this.workerId : this.workerId;
623
- if (route && target === route.workerId) this.touchRouteOwnerCache(topicKey, { workerId: route.workerId, generation: route.generation });
624
- else this.routeOwnerCache.delete(topicKey);
625
- return this.sendControl(target, "PUBLISH", topic, topicKey, data, metadata);
684
+ if (route && target === route.workerId) {
685
+ this.touchRouteOwnerCache(topicKey, { workerId: route.workerId, generation: route.generation });
686
+ } else {
687
+ this.routeOwnerCache.delete(topicKey);
688
+ }
689
+ return target;
690
+ }
691
+ /** Fan out a single batched item to the local onControl path. */
692
+ dispatchLocalPublish(topic, topicKey, data, messageId, timestamp) {
693
+ void topicKey;
694
+ const meta = publicationMetadata(messageId, timestamp);
695
+ if (meta) this.handlers.onControl("PUBLISH", topic, data, meta.messageId, meta.timestamp);
696
+ else this.handlers.onControl("PUBLISH", topic, data);
626
697
  }
627
698
  /** True when `route` exists and its owner worker is among `workers`.
628
699
  * Shared by subscribe (skip re-assignment) and publish (route to owner).
@@ -742,6 +813,15 @@ var WorkerClusterRuntime = class {
742
813
  if (this.releaseHandoffOnUnsubscribe(message)) return;
743
814
  break;
744
815
  case "PUBLISH":
816
+ if (message.items && message.items.length > 0) {
817
+ for (const item of message.items) {
818
+ const itemMeta = publicationMetadata(item.messageId, item.timestamp);
819
+ if (itemMeta) this.handlers.onControl("PUBLISH", message.topic, item.data, itemMeta.messageId, itemMeta.timestamp);
820
+ else this.handlers.onControl("PUBLISH", message.topic, item.data);
821
+ }
822
+ return;
823
+ }
824
+ break;
745
825
  default:
746
826
  break;
747
827
  }
@@ -1357,6 +1437,13 @@ var CrossTabDataBus = class {
1357
1437
  // a transport reopen succeeds so traces can correlate repeated failures.
1358
1438
  recoveryAttempt = 0;
1359
1439
  recoveryExhausted = false;
1440
+ /** Monotonic generation incremented on every successful transport open.
1441
+ * Stays in lockstep with `lastSuccessAt` so callers can detect that the
1442
+ * transport has been reopened even if the timestamp window is short. */
1443
+ recoveryGeneration = 0;
1444
+ /** Timestamp of the most recent successful transport open. Null until the
1445
+ * transport has reached the `ready` state at least once. */
1446
+ lastSuccessAt = null;
1360
1447
  // True while the tab is hidden so an in-flight transport start does not mark
1361
1448
  // the transport ready after suspendTransport() has stopped it.
1362
1449
  suspended = false;
@@ -1550,7 +1637,11 @@ var CrossTabDataBus = class {
1550
1637
  if (this.status === "error") {
1551
1638
  throw new Error("Transport failed during startup.");
1552
1639
  }
1553
- if (!this.suspended && !this.stopping) this.transportReady = true;
1640
+ if (!this.suspended && !this.stopping) {
1641
+ this.recoveryGeneration += 1;
1642
+ this.lastSuccessAt = this.now();
1643
+ this.transportReady = true;
1644
+ }
1554
1645
  });
1555
1646
  }).catch((error) => {
1556
1647
  if (stopClusterOnFailure) this.started = false;
@@ -1700,6 +1791,32 @@ var CrossTabDataBus = class {
1700
1791
  );
1701
1792
  }
1702
1793
  }
1794
+ /**
1795
+ * Burst-friendly variant of `publish()`: delivers `items` in a single
1796
+ * BroadcastChannel postMessage so the receiving owner can dispatch them all
1797
+ * in one tick. Per-item dedup / replay / ordering is preserved; each item
1798
+ * may carry its own `messageId` / `timestamp` via `options`. Empty array is
1799
+ * a no-op; single-item array delegates to `publish()`.
1800
+ */
1801
+ publishBatch(topic, items) {
1802
+ this.ensureStarted();
1803
+ if (items.length === 0) return;
1804
+ if (items.length === 1) {
1805
+ const first = items[0];
1806
+ this.publish(topic, first.data, first.options);
1807
+ return;
1808
+ }
1809
+ const mapped = items.map((item) => ({
1810
+ data: item.data,
1811
+ ...item.options?.messageId !== void 0 ? { messageId: item.options.messageId } : {},
1812
+ ...item.options?.timestamp !== void 0 ? { timestamp: item.options.timestamp } : {}
1813
+ }));
1814
+ if (!this.cluster.publishBatch(topic, mapped)) {
1815
+ this.reportError(
1816
+ new Error("Failed to send the batched publish control message to the owning worker.")
1817
+ );
1818
+ }
1819
+ }
1703
1820
  /** Register a handler that fires on every transport status change. Immediately invoked with the current status. */
1704
1821
  onStatus(handler) {
1705
1822
  this.statusHandlers.add(handler);
@@ -1720,9 +1837,22 @@ var CrossTabDataBus = class {
1720
1837
  return this.status;
1721
1838
  }
1722
1839
  /** Return the current automatic transport recovery state. `hasError` means a transport error is currently retained. */
1840
+ /** Return the current automatic transport recovery state plus diagnostics.
1841
+ * `generation` increments on every successful transport open (initial start
1842
+ * and every recovery); `lastSuccessAt` is the timestamp of the most recent
1843
+ * successful open, or `null` until the transport reaches `ready`. */
1723
1844
  getRecoveryStats() {
1724
1845
  const errorMessage = this.lastError instanceof Error ? this.lastError.message : this.lastError === null ? null : String(this.lastError);
1725
- return { attempt: this.recoveryAttempt, exhausted: this.recoveryExhausted, maxAttempts: this.recoveryMaxAttempts, hasError: this.lastError !== null, errorMessage, errorAt: this.lastErrorAt };
1846
+ return {
1847
+ attempt: this.recoveryAttempt,
1848
+ exhausted: this.recoveryExhausted,
1849
+ maxAttempts: this.recoveryMaxAttempts,
1850
+ hasError: this.lastError !== null,
1851
+ errorMessage,
1852
+ errorAt: this.lastErrorAt,
1853
+ generation: this.recoveryGeneration,
1854
+ lastSuccessAt: this.lastSuccessAt
1855
+ };
1726
1856
  }
1727
1857
  /** Snapshot of the cluster state (workers, routes, assignments).
1728
1858
  * For diagnostics only — the returned object is a shallow copy but