cross-tab-worker-databus 0.21.55 → 0.21.57

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.
@@ -288,12 +288,13 @@ function canUseStorage(storage, probeKey) {
288
288
  return false;
289
289
  }
290
290
  }
291
- function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY) {
291
+ function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY, options) {
292
292
  const storage = environment.sessionStorage;
293
293
  try {
294
294
  const existing = storage?.getItem(key);
295
295
  const hasOpener = typeof window !== "undefined" && Boolean(window.opener);
296
- if (existing && (!hasOpener || tabIdentityInitialized)) {
296
+ const claimedByPeer = existing !== null && existing !== void 0 && existing !== "" && !hasOpener && !tabIdentityInitialized && options?.isIdentityClaimedByPeer?.(existing) === true;
297
+ if (existing && (!hasOpener || tabIdentityInitialized) && !claimedByPeer) {
297
298
  tabIdentityInitialized = true;
298
299
  cachedTabId = existing;
299
300
  return existing;
@@ -811,7 +812,21 @@ var WorkerClusterRuntime = class {
811
812
  this.subscriberPrefix = `${baseKey}:subscriber:`;
812
813
  this.channelName = `${prefix}:bus:${clusterHash}`;
813
814
  this.storage = canUseStorage(this.environment.storage, `${baseKey}:probe`) ? new BatchingStorageWriter(this.environment.storage) : null;
814
- this.tabId = options.tabId ?? getOrCreateTabId(this.environment, `${prefix}:tab-id`);
815
+ this.tabId = options.tabId ?? getOrCreateTabId(this.environment, `${prefix}:tab-id`, {
816
+ // A browser-duplicated tab inherits sessionStorage without an opener, so
817
+ // `getOrCreateTabId()`'s opener guard cannot see it. A live Worker
818
+ // record carrying that id is the evidence the copy cannot carry: it
819
+ // lives in localStorage under a random `workerId`, and this probe runs
820
+ // in the constructor, before this Runtime has written a record of its
821
+ // own — so every match belongs to a different live document. A refresh
822
+ // removes its record in `pause()` and flushes that removal before the
823
+ // document goes away, so the refresh path keeps the stored id. The one
824
+ // case that does not is an abnormal exit, whose record survives for up
825
+ // to `workerTtlMs`; a reload inside that window takes a fresh identity,
826
+ // which costs one observer a changed `tabId` and nothing else — the
827
+ // stale record is pruned by the normal TTL path.
828
+ isIdentityClaimedByPeer: (candidate) => this.isTabIdLiveElsewhere(candidate)
829
+ });
815
830
  this.workerId = options.workerId ?? `worker-${this.tabId}-${this.environment.randomId()}`;
816
831
  const now = this.nowMs();
817
832
  this.currentRecord = {
@@ -1005,12 +1020,12 @@ var WorkerClusterRuntime = class {
1005
1020
  const topicKey = this.releaseSubscription(topic);
1006
1021
  if (topicKey && !this.assignedTopics.has(topicKey)) this.knownTopics.delete(topicKey);
1007
1022
  }
1008
- /** Remove this tab's subscriber record and, when it was the last one, delete
1023
+ /** Remove this Runtime's subscriber record and, when it was the last one, delete
1009
1024
  * the route. Returns the topicKey (so callers like `unsubscribe` can reuse
1010
1025
  * it instead of re-hashing the topic to evict the reverse cache). */
1011
1026
  releaseSubscription(topic, notifyOwner = true) {
1012
1027
  const topicKey = this.rememberTopic(topic);
1013
- this.removeStorage(this.subscriberStorageKey(topicKey, this.tabId));
1028
+ this.removeStorage(this.subscriberStorageKey(topicKey, this.workerId));
1014
1029
  const route = this.readRoute(topicKey);
1015
1030
  if (!route) return topicKey;
1016
1031
  const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
@@ -1452,11 +1467,7 @@ var WorkerClusterRuntime = class {
1452
1467
  ) ?? this.currentRecord;
1453
1468
  recoveryProjectedLoads.set(owner.workerId, (recoveryProjectedLoads.get(owner.workerId) ?? owner.load) + 1);
1454
1469
  if (owner.workerId !== this.workerId) {
1455
- const subscriberTabIds = new Set(this.readSubscriberTabIds(topicKey, workers));
1456
- const ownerSubscribed = workers.some(
1457
- (worker) => worker.workerId === owner.workerId && subscriberTabIds.has(worker.tabId)
1458
- );
1459
- if (ownerSubscribed) continue;
1470
+ if (this.readSubscriberIds(topicKey, workers).workerIds.has(owner.workerId)) continue;
1460
1471
  }
1461
1472
  this.writeRoute(topicKey, owner, void 0, route.generation + 1);
1462
1473
  this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
@@ -1577,6 +1588,38 @@ var WorkerClusterRuntime = class {
1577
1588
  return false;
1578
1589
  }
1579
1590
  }
1591
+ /**
1592
+ * Whether any other live Worker record already carries `tabId`.
1593
+ *
1594
+ * Used once per document, from the constructor, to tell a browser-duplicated
1595
+ * tab from this tab's own refresh: the copy inherits `sessionStorage` but not
1596
+ * the opener, so the identity stored there is the only shared state and the
1597
+ * Worker records are the only evidence that another live document holds it.
1598
+ * Liveness is the same heartbeat window `readWorkers()` prunes by, so a record
1599
+ * left behind by an abnormal exit is reported as live until it expires — the
1600
+ * only false positive, and it re-mints one identity instead of letting two
1601
+ * live tabs share one.
1602
+ *
1603
+ * The `!this.storage` leg is the degraded mode, and it earns its line for two
1604
+ * reasons rather than one. It is what narrows the field for the scan below
1605
+ * (`StorageLike | null` is rejected by `readAllByPrefix`), so the compiler
1606
+ * holds it; and it is *not* what decides the verdict, because `listKeys()`
1607
+ * catches its own failure and returns no records either way — measured by
1608
+ * emptying this consequent, which leaves the suite green. What the inverted
1609
+ * return does prove is the behaviour, not the deletion: returning `true` here
1610
+ * fails 'keeps the inherited id when there is no storage to probe', since
1611
+ * without `localStorage` there is no second document to collide with.
1612
+ */
1613
+ isTabIdLiveElsewhere(tabId) {
1614
+ if (!this.storage) return false;
1615
+ const now = this.nowMs();
1616
+ for (const { value: worker } of readAllByPrefix(this.storage, this.workerPrefix)) {
1617
+ if (worker.tabId !== tabId) continue;
1618
+ if (now - worker.heartbeatAt > this.workerTtlMs) continue;
1619
+ return true;
1620
+ }
1621
+ return false;
1622
+ }
1580
1623
  /** Read all live worker records from storage, pruning stale entries past the TTL. */
1581
1624
  readWorkers() {
1582
1625
  if (!this.storage) return [this.currentRecord];
@@ -1594,23 +1637,61 @@ var WorkerClusterRuntime = class {
1594
1637
  }
1595
1638
  /** Enumerate all tab IDs that have a subscriber record for `topicKey`. */
1596
1639
  readSubscriberTabIds(topicKey, workers) {
1640
+ return Array.from(this.readSubscriberIds(topicKey, workers).tabIds);
1641
+ }
1642
+ /**
1643
+ * One scan of `topicKey`'s subscriber records, answering the two questions
1644
+ * callers ask: which Tabs hold a subscription (emptiness checks, orphan-route
1645
+ * pruning) and which **Runtimes** do (the stranded-handoff re-election).
1646
+ *
1647
+ * The second answer is the finer one and the reason this is one method rather
1648
+ * than two scans: a tab-level answer lets a leftover record from a sibling
1649
+ * Runtime speak for a Worker that never subscribed to the topic, and standing
1650
+ * down on that proof stalls the recovery forever (see the call site).
1651
+ *
1652
+ * A legacy record — written before records carried `workerId` — proves only
1653
+ * that *some* Runtime in its tab subscribes, so it contributes to the tab set
1654
+ * and **not** to the worker set. Treating it as the whole tab, which is what
1655
+ * the tab-level reader effectively did, is the conservative-looking choice
1656
+ * that can stall; the cost of the other direction is one redundant
1657
+ * `CONTROL/SUBSCRIBE` to a peer that is about to confirm anyway.
1658
+ */
1659
+ readSubscriberIds(topicKey, workers) {
1660
+ const tabIds = /* @__PURE__ */ new Set();
1661
+ const workerIds = /* @__PURE__ */ new Set();
1597
1662
  if (!this.storage) {
1598
1663
  const topic = this.knownTopics.get(topicKey);
1599
- return topic && this.subscribedTopics.has(topic) ? [this.tabId] : [];
1664
+ if (topic && this.subscribedTopics.has(topic)) {
1665
+ tabIds.add(this.tabId);
1666
+ workerIds.add(this.workerId);
1667
+ }
1668
+ return { tabIds, workerIds };
1600
1669
  }
1601
1670
  const activeTabIds = new Set(workers.map((worker) => worker.tabId));
1602
- const subscribers = /* @__PURE__ */ new Set();
1671
+ const activeWorkerIds = new Set(workers.map((worker) => worker.workerId));
1603
1672
  for (const { key, value: record } of readAllByPrefix(
1604
1673
  this.storage,
1605
1674
  `${this.subscriberPrefix}${topicKey}:`
1606
1675
  )) {
1607
- if (!activeTabIds.has(record.tabId)) {
1676
+ if (!this.isSubscriberRecordLive(record, activeTabIds, activeWorkerIds)) {
1608
1677
  this.removeStorage(key);
1609
1678
  continue;
1610
1679
  }
1611
- subscribers.add(record.tabId);
1680
+ tabIds.add(record.tabId);
1681
+ if (record.workerId !== void 0) workerIds.add(record.workerId);
1612
1682
  }
1613
- return Array.from(subscribers);
1683
+ return { tabIds, workerIds };
1684
+ }
1685
+ /** Whether a subscriber record still belongs to a live Runtime.
1686
+ *
1687
+ * A record written by this version names its `workerId`, so liveness is the
1688
+ * Worker set the reconcile pass already prunes by — which is what keeps two
1689
+ * Runtimes in one tab independent. A record from an older version carries no
1690
+ * `workerId`; it is honoured while its *tab* lives, the finest identity it
1691
+ * has, and each Runtime replaces its own with a worker-keyed record on its
1692
+ * next write. */
1693
+ isSubscriberRecordLive(record, activeTabIds, activeWorkerIds) {
1694
+ return record.workerId === void 0 ? activeTabIds.has(record.tabId) : activeWorkerIds.has(record.workerId);
1614
1695
  }
1615
1696
  /** Read the current route for `topicKey`. With a storage layer this returns
1616
1697
  * the durable route, or null when none is recorded. Without one (degraded
@@ -1676,19 +1757,31 @@ var WorkerClusterRuntime = class {
1676
1757
  this.removeStorage(key);
1677
1758
  }
1678
1759
  }
1679
- /** Remove subscriber records for tabs that are no longer active. */
1760
+ /** Remove subscriber records whose owning Runtime (or, for a legacy record,
1761
+ * whose tab) is no longer active. */
1680
1762
  cleanupOrphanedSubscribers(workers) {
1681
1763
  if (!this.storage) return;
1682
1764
  const activeTabIds = new Set(workers.map((worker) => worker.tabId));
1765
+ const activeWorkerIds = new Set(workers.map((worker) => worker.workerId));
1683
1766
  for (const { key, value: record } of readAllByPrefix(this.storage, this.subscriberPrefix)) {
1684
- if (!activeTabIds.has(record.tabId)) this.removeStorage(key);
1767
+ if (!this.isSubscriberRecordLive(record, activeTabIds, activeWorkerIds)) this.removeStorage(key);
1685
1768
  }
1686
1769
  }
1687
- /** Persist a subscriber record for this tab on `topicKey`. */
1770
+ /** Persist a subscriber record for this Runtime on `topicKey`.
1771
+ *
1772
+ * Keyed by `workerId`, not `tabId`. A document may run several Runtimes that
1773
+ * share one `tabId` on purpose (the market + notice pattern), and a tab-keyed
1774
+ * record belonged to all of them at once: whichever unsubscribed first deleted
1775
+ * the others' intent, the route then looked orphaned, and the owner dropped a
1776
+ * transport subscription its surviving sibling still needed — a delivery gap
1777
+ * until that sibling's next reconcile tick re-elected an owner. Duplicated
1778
+ * tabs get the same protection even when the identity probe cannot see them,
1779
+ * which is why the fix is here as well as in `isTabIdLiveElsewhere()`. */
1688
1780
  writeSubscriber(topicKey) {
1689
1781
  if (!this.storage) return;
1690
- writeJson(this.storage, this.subscriberStorageKey(topicKey, this.tabId), {
1782
+ writeJson(this.storage, this.subscriberStorageKey(topicKey, this.workerId), {
1691
1783
  tabId: this.tabId,
1784
+ workerId: this.workerId,
1692
1785
  updatedAt: this.nowMs()
1693
1786
  });
1694
1787
  }
@@ -1762,8 +1855,9 @@ var WorkerClusterRuntime = class {
1762
1855
  routeStorageKey(topicKey) {
1763
1856
  return `${this.routePrefix}${topicKey}`;
1764
1857
  }
1765
- subscriberStorageKey(topicKey, tabId) {
1766
- return `${this.subscriberPrefix}${topicKey}:${tabId}`;
1858
+ /** `ownerId` is the Runtime's `workerId`; older versions used the `tabId`. */
1859
+ subscriberStorageKey(topicKey, ownerId) {
1860
+ return `${this.subscriberPrefix}${topicKey}:${ownerId}`;
1767
1861
  }
1768
1862
  removeStorage(key) {
1769
1863
  try {
@@ -2643,7 +2737,7 @@ var SDK_VERSION = (
2643
2737
  // declaration's type is `string | undefined` and the export's is `string`
2644
2738
  // (dropping the conditional is TS2322, measured). Recorded here so the leg reads
2645
2739
  // as a shape the harness cannot reach rather than as a case nobody exercised.
2646
- true ? "0.21.55" : ""
2740
+ true ? "0.21.57" : ""
2647
2741
  );
2648
2742
 
2649
2743
  // src/core/data-bus.ts