cross-tab-worker-databus 0.21.56 → 0.21.58

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.
@@ -284,12 +284,13 @@ function canUseStorage(storage, probeKey) {
284
284
  return false;
285
285
  }
286
286
  }
287
- function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY) {
287
+ function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY, options) {
288
288
  const storage = environment.sessionStorage;
289
289
  try {
290
290
  const existing = storage?.getItem(key);
291
291
  const hasOpener = typeof window !== "undefined" && Boolean(window.opener);
292
- if (existing && (!hasOpener || tabIdentityInitialized)) {
292
+ const claimedByPeer = existing !== null && existing !== void 0 && existing !== "" && !tabIdentityInitialized && options?.isIdentityClaimedByPeer?.(existing) === true;
293
+ if (existing && (!hasOpener || tabIdentityInitialized) && !claimedByPeer) {
293
294
  tabIdentityInitialized = true;
294
295
  cachedTabId = existing;
295
296
  return existing;
@@ -845,7 +846,21 @@ var WorkerClusterRuntime = class {
845
846
  this.subscriberPrefix = `${baseKey}:subscriber:`;
846
847
  this.channelName = `${prefix}:bus:${clusterHash}`;
847
848
  this.storage = canUseStorage(this.environment.storage, `${baseKey}:probe`) ? new BatchingStorageWriter(this.environment.storage) : null;
848
- this.tabId = options.tabId ?? getOrCreateTabId(this.environment, `${prefix}:tab-id`);
849
+ this.tabId = options.tabId ?? getOrCreateTabId(this.environment, `${prefix}:tab-id`, {
850
+ // A browser-duplicated tab inherits sessionStorage without an opener, so
851
+ // `getOrCreateTabId()`'s opener guard cannot see it. A live Worker
852
+ // record carrying that id is the evidence the copy cannot carry: it
853
+ // lives in localStorage under a random `workerId`, and this probe runs
854
+ // in the constructor, before this Runtime has written a record of its
855
+ // own — so every match belongs to a different live document. A refresh
856
+ // removes its record in `pause()` and flushes that removal before the
857
+ // document goes away, so the refresh path keeps the stored id. The one
858
+ // case that does not is an abnormal exit, whose record survives for up
859
+ // to `workerTtlMs`; a reload inside that window takes a fresh identity,
860
+ // which costs one observer a changed `tabId` and nothing else — the
861
+ // stale record is pruned by the normal TTL path.
862
+ isIdentityClaimedByPeer: (candidate) => this.isTabIdLiveElsewhere(candidate)
863
+ });
849
864
  this.workerId = options.workerId ?? `worker-${this.tabId}-${this.environment.randomId()}`;
850
865
  const now = this.nowMs();
851
866
  this.currentRecord = {
@@ -1039,12 +1054,12 @@ var WorkerClusterRuntime = class {
1039
1054
  const topicKey = this.releaseSubscription(topic);
1040
1055
  if (topicKey && !this.assignedTopics.has(topicKey)) this.knownTopics.delete(topicKey);
1041
1056
  }
1042
- /** Remove this tab's subscriber record and, when it was the last one, delete
1057
+ /** Remove this Runtime's subscriber record and, when it was the last one, delete
1043
1058
  * the route. Returns the topicKey (so callers like `unsubscribe` can reuse
1044
1059
  * it instead of re-hashing the topic to evict the reverse cache). */
1045
1060
  releaseSubscription(topic, notifyOwner = true) {
1046
1061
  const topicKey = this.rememberTopic(topic);
1047
- this.removeStorage(this.subscriberStorageKey(topicKey, this.tabId));
1062
+ this.removeStorage(this.subscriberStorageKey(topicKey, this.workerId));
1048
1063
  const route = this.readRoute(topicKey);
1049
1064
  if (!route) return topicKey;
1050
1065
  const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
@@ -1486,11 +1501,7 @@ var WorkerClusterRuntime = class {
1486
1501
  ) ?? this.currentRecord;
1487
1502
  recoveryProjectedLoads.set(owner.workerId, (recoveryProjectedLoads.get(owner.workerId) ?? owner.load) + 1);
1488
1503
  if (owner.workerId !== this.workerId) {
1489
- const subscriberTabIds = new Set(this.readSubscriberTabIds(topicKey, workers));
1490
- const ownerSubscribed = workers.some(
1491
- (worker) => worker.workerId === owner.workerId && subscriberTabIds.has(worker.tabId)
1492
- );
1493
- if (ownerSubscribed) continue;
1504
+ if (this.readSubscriberIds(topicKey, workers).workerIds.has(owner.workerId)) continue;
1494
1505
  }
1495
1506
  this.writeRoute(topicKey, owner, void 0, route.generation + 1);
1496
1507
  this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
@@ -1611,6 +1622,38 @@ var WorkerClusterRuntime = class {
1611
1622
  return false;
1612
1623
  }
1613
1624
  }
1625
+ /**
1626
+ * Whether any other live Worker record already carries `tabId`.
1627
+ *
1628
+ * Used once per document, from the constructor, to tell a browser-duplicated
1629
+ * tab from this tab's own refresh: the copy inherits `sessionStorage` but not
1630
+ * the opener, so the identity stored there is the only shared state and the
1631
+ * Worker records are the only evidence that another live document holds it.
1632
+ * Liveness is the same heartbeat window `readWorkers()` prunes by, so a record
1633
+ * left behind by an abnormal exit is reported as live until it expires — the
1634
+ * only false positive, and it re-mints one identity instead of letting two
1635
+ * live tabs share one.
1636
+ *
1637
+ * The `!this.storage` leg is the degraded mode, and it earns its line for two
1638
+ * reasons rather than one. It is what narrows the field for the scan below
1639
+ * (`StorageLike | null` is rejected by `readAllByPrefix`), so the compiler
1640
+ * holds it; and it is *not* what decides the verdict, because `listKeys()`
1641
+ * catches its own failure and returns no records either way — measured by
1642
+ * emptying this consequent, which leaves the suite green. What the inverted
1643
+ * return does prove is the behaviour, not the deletion: returning `true` here
1644
+ * fails 'keeps the inherited id when there is no storage to probe', since
1645
+ * without `localStorage` there is no second document to collide with.
1646
+ */
1647
+ isTabIdLiveElsewhere(tabId) {
1648
+ if (!this.storage) return false;
1649
+ const now = this.nowMs();
1650
+ for (const { value: worker } of readAllByPrefix(this.storage, this.workerPrefix)) {
1651
+ if (worker.tabId !== tabId) continue;
1652
+ if (now - worker.heartbeatAt > this.workerTtlMs) continue;
1653
+ return true;
1654
+ }
1655
+ return false;
1656
+ }
1614
1657
  /** Read all live worker records from storage, pruning stale entries past the TTL. */
1615
1658
  readWorkers() {
1616
1659
  if (!this.storage) return [this.currentRecord];
@@ -1628,23 +1671,61 @@ var WorkerClusterRuntime = class {
1628
1671
  }
1629
1672
  /** Enumerate all tab IDs that have a subscriber record for `topicKey`. */
1630
1673
  readSubscriberTabIds(topicKey, workers) {
1674
+ return Array.from(this.readSubscriberIds(topicKey, workers).tabIds);
1675
+ }
1676
+ /**
1677
+ * One scan of `topicKey`'s subscriber records, answering the two questions
1678
+ * callers ask: which Tabs hold a subscription (emptiness checks, orphan-route
1679
+ * pruning) and which **Runtimes** do (the stranded-handoff re-election).
1680
+ *
1681
+ * The second answer is the finer one and the reason this is one method rather
1682
+ * than two scans: a tab-level answer lets a leftover record from a sibling
1683
+ * Runtime speak for a Worker that never subscribed to the topic, and standing
1684
+ * down on that proof stalls the recovery forever (see the call site).
1685
+ *
1686
+ * A legacy record — written before records carried `workerId` — proves only
1687
+ * that *some* Runtime in its tab subscribes, so it contributes to the tab set
1688
+ * and **not** to the worker set. Treating it as the whole tab, which is what
1689
+ * the tab-level reader effectively did, is the conservative-looking choice
1690
+ * that can stall; the cost of the other direction is one redundant
1691
+ * `CONTROL/SUBSCRIBE` to a peer that is about to confirm anyway.
1692
+ */
1693
+ readSubscriberIds(topicKey, workers) {
1694
+ const tabIds = /* @__PURE__ */ new Set();
1695
+ const workerIds = /* @__PURE__ */ new Set();
1631
1696
  if (!this.storage) {
1632
1697
  const topic = this.knownTopics.get(topicKey);
1633
- return topic && this.subscribedTopics.has(topic) ? [this.tabId] : [];
1698
+ if (topic && this.subscribedTopics.has(topic)) {
1699
+ tabIds.add(this.tabId);
1700
+ workerIds.add(this.workerId);
1701
+ }
1702
+ return { tabIds, workerIds };
1634
1703
  }
1635
1704
  const activeTabIds = new Set(workers.map((worker) => worker.tabId));
1636
- const subscribers = /* @__PURE__ */ new Set();
1705
+ const activeWorkerIds = new Set(workers.map((worker) => worker.workerId));
1637
1706
  for (const { key, value: record } of readAllByPrefix(
1638
1707
  this.storage,
1639
1708
  `${this.subscriberPrefix}${topicKey}:`
1640
1709
  )) {
1641
- if (!activeTabIds.has(record.tabId)) {
1710
+ if (!this.isSubscriberRecordLive(record, activeTabIds, activeWorkerIds)) {
1642
1711
  this.removeStorage(key);
1643
1712
  continue;
1644
1713
  }
1645
- subscribers.add(record.tabId);
1714
+ tabIds.add(record.tabId);
1715
+ if (record.workerId !== void 0) workerIds.add(record.workerId);
1646
1716
  }
1647
- return Array.from(subscribers);
1717
+ return { tabIds, workerIds };
1718
+ }
1719
+ /** Whether a subscriber record still belongs to a live Runtime.
1720
+ *
1721
+ * A record written by this version names its `workerId`, so liveness is the
1722
+ * Worker set the reconcile pass already prunes by — which is what keeps two
1723
+ * Runtimes in one tab independent. A record from an older version carries no
1724
+ * `workerId`; it is honoured while its *tab* lives, the finest identity it
1725
+ * has, and each Runtime replaces its own with a worker-keyed record on its
1726
+ * next write. */
1727
+ isSubscriberRecordLive(record, activeTabIds, activeWorkerIds) {
1728
+ return record.workerId === void 0 ? activeTabIds.has(record.tabId) : activeWorkerIds.has(record.workerId);
1648
1729
  }
1649
1730
  /** Read the current route for `topicKey`. With a storage layer this returns
1650
1731
  * the durable route, or null when none is recorded. Without one (degraded
@@ -1710,19 +1791,31 @@ var WorkerClusterRuntime = class {
1710
1791
  this.removeStorage(key);
1711
1792
  }
1712
1793
  }
1713
- /** Remove subscriber records for tabs that are no longer active. */
1794
+ /** Remove subscriber records whose owning Runtime (or, for a legacy record,
1795
+ * whose tab) is no longer active. */
1714
1796
  cleanupOrphanedSubscribers(workers) {
1715
1797
  if (!this.storage) return;
1716
1798
  const activeTabIds = new Set(workers.map((worker) => worker.tabId));
1799
+ const activeWorkerIds = new Set(workers.map((worker) => worker.workerId));
1717
1800
  for (const { key, value: record } of readAllByPrefix(this.storage, this.subscriberPrefix)) {
1718
- if (!activeTabIds.has(record.tabId)) this.removeStorage(key);
1801
+ if (!this.isSubscriberRecordLive(record, activeTabIds, activeWorkerIds)) this.removeStorage(key);
1719
1802
  }
1720
1803
  }
1721
- /** Persist a subscriber record for this tab on `topicKey`. */
1804
+ /** Persist a subscriber record for this Runtime on `topicKey`.
1805
+ *
1806
+ * Keyed by `workerId`, not `tabId`. A document may run several Runtimes that
1807
+ * share one `tabId` on purpose (the market + notice pattern), and a tab-keyed
1808
+ * record belonged to all of them at once: whichever unsubscribed first deleted
1809
+ * the others' intent, the route then looked orphaned, and the owner dropped a
1810
+ * transport subscription its surviving sibling still needed — a delivery gap
1811
+ * until that sibling's next reconcile tick re-elected an owner. Duplicated
1812
+ * tabs get the same protection even when the identity probe cannot see them,
1813
+ * which is why the fix is here as well as in `isTabIdLiveElsewhere()`. */
1722
1814
  writeSubscriber(topicKey) {
1723
1815
  if (!this.storage) return;
1724
- writeJson(this.storage, this.subscriberStorageKey(topicKey, this.tabId), {
1816
+ writeJson(this.storage, this.subscriberStorageKey(topicKey, this.workerId), {
1725
1817
  tabId: this.tabId,
1818
+ workerId: this.workerId,
1726
1819
  updatedAt: this.nowMs()
1727
1820
  });
1728
1821
  }
@@ -1796,8 +1889,9 @@ var WorkerClusterRuntime = class {
1796
1889
  routeStorageKey(topicKey) {
1797
1890
  return `${this.routePrefix}${topicKey}`;
1798
1891
  }
1799
- subscriberStorageKey(topicKey, tabId) {
1800
- return `${this.subscriberPrefix}${topicKey}:${tabId}`;
1892
+ /** `ownerId` is the Runtime's `workerId`; older versions used the `tabId`. */
1893
+ subscriberStorageKey(topicKey, ownerId) {
1894
+ return `${this.subscriberPrefix}${topicKey}:${ownerId}`;
1801
1895
  }
1802
1896
  removeStorage(key) {
1803
1897
  try {
@@ -2677,7 +2771,7 @@ var SDK_VERSION = (
2677
2771
  // declaration's type is `string | undefined` and the export's is `string`
2678
2772
  // (dropping the conditional is TS2322, measured). Recorded here so the leg reads
2679
2773
  // as a shape the harness cannot reach rather than as a case nobody exercised.
2680
- true ? "0.21.56" : ""
2774
+ true ? "0.21.58" : ""
2681
2775
  );
2682
2776
 
2683
2777
  // src/core/data-bus.ts