cross-tab-worker-databus 0.21.56 → 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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,28 @@
1
+ ## [0.21.57] - 2026-10-30
2
+
3
+ Three coordination fixes found by asking what still depends on a Tab's identity being unique. One is the browser's own **Duplicate tab**, which the opener guard could not see; the other two were already reachable in the documented one-document-many-DataBuses pattern and in a stranded handoff. The public surface gains one *optional* argument on `getOrCreateTabId()` and no new export; the subscriber record's key changes shape, and both directions stay compatible with older peers.
4
+
5
+ ### Fixed
6
+
7
+ - **A browser-duplicated tab took the original's identity.** `getOrCreateTabId()` discarded an inherited `sessionStorage` id only when `window.opener` was present, which covers `window.open()` and not the browser's Duplicate tab — that copy carries the same session and no opener at all. `WorkerClusterRuntime` now answers an optional `isIdentityClaimedByPeer` probe from this cluster's live Worker records, which a duplicated tab cannot copy (they live in `localStorage` under a random `workerId`), and the copy mints and persists a fresh id. The probe runs on a document's first lookup only, so later Runtimes in the same document keep sharing it, and a refresh still keeps its id because `pause()` removes and flushes the departing record. After an abnormal exit that record survives for up to `workerTtlMs`, so a reload inside that window takes a fresh id — recorded at the probe and in both architecture references.
8
+ - **Two Runtimes in one Tab shared one subscriber record.** The documented pattern of several DataBuses in one document (market + notice) means several `workerId`s under one `tabId`, and a tab-keyed subscriber record belonged to all of them: whichever unsubscribed first deleted the others' intent, the route then looked orphaned, and the owner dropped its transport subscription — up to one heartbeat of genuinely lost publications, because the transport was no longer subscribed. Records are now keyed by `workerId` and carry it, and liveness is per Runtime, so a crashed sibling's record is pruned while its tab is still alive.
9
+ - **A stranded handoff could be stalled forever by a leftover record.** `reconcileSubscriptions()` stands down when the elected owner looks subscribed, because an owner with no local subscription never reconciles the topic itself. It asked whether the owner's *tab* had a subscriber record, so a leftover record from a live sibling Runtime in that tab — what a lost removal leaves — read as "the owner subscribes": the peer stood down, nobody ever wrote the route, and the unconfirmed handoff sat there for good. The decision now asks the Runtime set. A legacy tab-keyed record cannot name the Runtime that wrote it, so it does not answer the question at all: sending one redundant `CONTROL/SUBSCRIBE` to a peer that is about to confirm costs a frame, while standing down on that record can cost the topic.
10
+
11
+ ### Changed
12
+
13
+ - **Subscriber records are keyed per Runtime**: `{clusterHash}:subscriber:{topicKey}:{workerId}`, with the record carrying its `workerId`. A record written by an older version carries none; it is honoured by the finest identity it has (its tab) and pruned with it, so a mixed-version cluster keeps working in both directions — old readers enumerate by prefix and read `tabId`, old sweeps keep the new records because that field is still present. The `docs/configuration.md` boundary list and both architecture references state the new shape.
14
+
15
+ ### Documentation
16
+
17
+ - The inner dispatch gate is described accurately in both languages: `hasLocalSubscriber()` reads the in-memory subscription set (wildcard patterns included), not the durable subscriber record, whose job is to tell peers the intent exists.
18
+ - The tab-identity paragraphs state what a shared `tabId` now costs — a false identity in the public snapshot and the diagnostics — instead of the record collision, which per-Runtime keys removed.
19
+
20
+ ### Verification
21
+
22
+ - `pnpm check` — 40 files and **1057 tests**, plus the 5 perf gates; `pnpm lint`; `pnpm typecheck`; `pnpm test:coverage` **99.03 / 97.67 / 99.28 / 99.69**; `pnpm test:e2e` **39 passed**; `pnpm verify:pack` / `verify:compat` / `verify:types`; `pnpm bench` + `bench:compare --fail-above-pct 50`; `pnpm audit --registry=https://registry.npmjs.org` clean; `npm pack --dry-run` 109 files; `git diff --check` clean.
23
+ - The duplicate-tab path is exercised in a real browser: a second page whose init script seeds the first page's stored id before any page script runs takes its own identity, stays a live peer of the original, and a reload of the original keeps its id. Deleting the probe wiring and rebuilding fails exactly that assertion.
24
+ - Every fix was mutation-checked with the mutant's own failing case named, including the one that survived a vacuous assertion first (an expected value minted by the same `randomId` as the code under test) and the one classified rather than repaired (a `!this.storage` leg the compiler holds and `listKeys()`'s catch makes undecidable).
25
+
1
26
  ## [0.21.56] - 2026-10-25
2
27
 
3
28
  A benchmark-trend release that also carries one shipped-comment correction. No behavior, API shape, export, default, frame or storage key changed, and the coverage floors and the zero-count arm ledger are `0.21.55`'s.
@@ -7,7 +7,7 @@ import {
7
7
  publicationMetadata,
8
8
  selectWorkerBackend,
9
9
  serializeError
10
- } from "./chunk-VDCLCCMN.js";
10
+ } from "./chunk-ZH7VP3IY.js";
11
11
  import {
12
12
  CENTRIFUGE_INPUT_TYPE,
13
13
  CENTRIFUGE_OUTPUT_TYPE,
@@ -144,12 +144,13 @@ function canUseStorage(storage, probeKey) {
144
144
  return false;
145
145
  }
146
146
  }
147
- function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY) {
147
+ function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY, options) {
148
148
  const storage = environment.sessionStorage;
149
149
  try {
150
150
  const existing = storage?.getItem(key);
151
151
  const hasOpener = typeof window !== "undefined" && Boolean(window.opener);
152
- if (existing && (!hasOpener || tabIdentityInitialized)) {
152
+ const claimedByPeer = existing !== null && existing !== void 0 && existing !== "" && !hasOpener && !tabIdentityInitialized && options?.isIdentityClaimedByPeer?.(existing) === true;
153
+ if (existing && (!hasOpener || tabIdentityInitialized) && !claimedByPeer) {
153
154
  tabIdentityInitialized = true;
154
155
  cachedTabId = existing;
155
156
  return existing;
@@ -716,7 +717,21 @@ var WorkerClusterRuntime = class {
716
717
  this.subscriberPrefix = `${baseKey}:subscriber:`;
717
718
  this.channelName = `${prefix}:bus:${clusterHash}`;
718
719
  this.storage = canUseStorage(this.environment.storage, `${baseKey}:probe`) ? new BatchingStorageWriter(this.environment.storage) : null;
719
- this.tabId = options.tabId ?? getOrCreateTabId(this.environment, `${prefix}:tab-id`);
720
+ this.tabId = options.tabId ?? getOrCreateTabId(this.environment, `${prefix}:tab-id`, {
721
+ // A browser-duplicated tab inherits sessionStorage without an opener, so
722
+ // `getOrCreateTabId()`'s opener guard cannot see it. A live Worker
723
+ // record carrying that id is the evidence the copy cannot carry: it
724
+ // lives in localStorage under a random `workerId`, and this probe runs
725
+ // in the constructor, before this Runtime has written a record of its
726
+ // own — so every match belongs to a different live document. A refresh
727
+ // removes its record in `pause()` and flushes that removal before the
728
+ // document goes away, so the refresh path keeps the stored id. The one
729
+ // case that does not is an abnormal exit, whose record survives for up
730
+ // to `workerTtlMs`; a reload inside that window takes a fresh identity,
731
+ // which costs one observer a changed `tabId` and nothing else — the
732
+ // stale record is pruned by the normal TTL path.
733
+ isIdentityClaimedByPeer: (candidate) => this.isTabIdLiveElsewhere(candidate)
734
+ });
720
735
  this.workerId = options.workerId ?? `worker-${this.tabId}-${this.environment.randomId()}`;
721
736
  const now = this.nowMs();
722
737
  this.currentRecord = {
@@ -910,12 +925,12 @@ var WorkerClusterRuntime = class {
910
925
  const topicKey = this.releaseSubscription(topic);
911
926
  if (topicKey && !this.assignedTopics.has(topicKey)) this.knownTopics.delete(topicKey);
912
927
  }
913
- /** Remove this tab's subscriber record and, when it was the last one, delete
928
+ /** Remove this Runtime's subscriber record and, when it was the last one, delete
914
929
  * the route. Returns the topicKey (so callers like `unsubscribe` can reuse
915
930
  * it instead of re-hashing the topic to evict the reverse cache). */
916
931
  releaseSubscription(topic, notifyOwner = true) {
917
932
  const topicKey = this.rememberTopic(topic);
918
- this.removeStorage(this.subscriberStorageKey(topicKey, this.tabId));
933
+ this.removeStorage(this.subscriberStorageKey(topicKey, this.workerId));
919
934
  const route = this.readRoute(topicKey);
920
935
  if (!route) return topicKey;
921
936
  const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
@@ -1357,11 +1372,7 @@ var WorkerClusterRuntime = class {
1357
1372
  ) ?? this.currentRecord;
1358
1373
  recoveryProjectedLoads.set(owner.workerId, (recoveryProjectedLoads.get(owner.workerId) ?? owner.load) + 1);
1359
1374
  if (owner.workerId !== this.workerId) {
1360
- const subscriberTabIds = new Set(this.readSubscriberTabIds(topicKey, workers));
1361
- const ownerSubscribed = workers.some(
1362
- (worker) => worker.workerId === owner.workerId && subscriberTabIds.has(worker.tabId)
1363
- );
1364
- if (ownerSubscribed) continue;
1375
+ if (this.readSubscriberIds(topicKey, workers).workerIds.has(owner.workerId)) continue;
1365
1376
  }
1366
1377
  this.writeRoute(topicKey, owner, void 0, route.generation + 1);
1367
1378
  this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
@@ -1482,6 +1493,38 @@ var WorkerClusterRuntime = class {
1482
1493
  return false;
1483
1494
  }
1484
1495
  }
1496
+ /**
1497
+ * Whether any other live Worker record already carries `tabId`.
1498
+ *
1499
+ * Used once per document, from the constructor, to tell a browser-duplicated
1500
+ * tab from this tab's own refresh: the copy inherits `sessionStorage` but not
1501
+ * the opener, so the identity stored there is the only shared state and the
1502
+ * Worker records are the only evidence that another live document holds it.
1503
+ * Liveness is the same heartbeat window `readWorkers()` prunes by, so a record
1504
+ * left behind by an abnormal exit is reported as live until it expires — the
1505
+ * only false positive, and it re-mints one identity instead of letting two
1506
+ * live tabs share one.
1507
+ *
1508
+ * The `!this.storage` leg is the degraded mode, and it earns its line for two
1509
+ * reasons rather than one. It is what narrows the field for the scan below
1510
+ * (`StorageLike | null` is rejected by `readAllByPrefix`), so the compiler
1511
+ * holds it; and it is *not* what decides the verdict, because `listKeys()`
1512
+ * catches its own failure and returns no records either way — measured by
1513
+ * emptying this consequent, which leaves the suite green. What the inverted
1514
+ * return does prove is the behaviour, not the deletion: returning `true` here
1515
+ * fails 'keeps the inherited id when there is no storage to probe', since
1516
+ * without `localStorage` there is no second document to collide with.
1517
+ */
1518
+ isTabIdLiveElsewhere(tabId) {
1519
+ if (!this.storage) return false;
1520
+ const now = this.nowMs();
1521
+ for (const { value: worker } of readAllByPrefix(this.storage, this.workerPrefix)) {
1522
+ if (worker.tabId !== tabId) continue;
1523
+ if (now - worker.heartbeatAt > this.workerTtlMs) continue;
1524
+ return true;
1525
+ }
1526
+ return false;
1527
+ }
1485
1528
  /** Read all live worker records from storage, pruning stale entries past the TTL. */
1486
1529
  readWorkers() {
1487
1530
  if (!this.storage) return [this.currentRecord];
@@ -1499,23 +1542,61 @@ var WorkerClusterRuntime = class {
1499
1542
  }
1500
1543
  /** Enumerate all tab IDs that have a subscriber record for `topicKey`. */
1501
1544
  readSubscriberTabIds(topicKey, workers) {
1545
+ return Array.from(this.readSubscriberIds(topicKey, workers).tabIds);
1546
+ }
1547
+ /**
1548
+ * One scan of `topicKey`'s subscriber records, answering the two questions
1549
+ * callers ask: which Tabs hold a subscription (emptiness checks, orphan-route
1550
+ * pruning) and which **Runtimes** do (the stranded-handoff re-election).
1551
+ *
1552
+ * The second answer is the finer one and the reason this is one method rather
1553
+ * than two scans: a tab-level answer lets a leftover record from a sibling
1554
+ * Runtime speak for a Worker that never subscribed to the topic, and standing
1555
+ * down on that proof stalls the recovery forever (see the call site).
1556
+ *
1557
+ * A legacy record — written before records carried `workerId` — proves only
1558
+ * that *some* Runtime in its tab subscribes, so it contributes to the tab set
1559
+ * and **not** to the worker set. Treating it as the whole tab, which is what
1560
+ * the tab-level reader effectively did, is the conservative-looking choice
1561
+ * that can stall; the cost of the other direction is one redundant
1562
+ * `CONTROL/SUBSCRIBE` to a peer that is about to confirm anyway.
1563
+ */
1564
+ readSubscriberIds(topicKey, workers) {
1565
+ const tabIds = /* @__PURE__ */ new Set();
1566
+ const workerIds = /* @__PURE__ */ new Set();
1502
1567
  if (!this.storage) {
1503
1568
  const topic = this.knownTopics.get(topicKey);
1504
- return topic && this.subscribedTopics.has(topic) ? [this.tabId] : [];
1569
+ if (topic && this.subscribedTopics.has(topic)) {
1570
+ tabIds.add(this.tabId);
1571
+ workerIds.add(this.workerId);
1572
+ }
1573
+ return { tabIds, workerIds };
1505
1574
  }
1506
1575
  const activeTabIds = new Set(workers.map((worker) => worker.tabId));
1507
- const subscribers = /* @__PURE__ */ new Set();
1576
+ const activeWorkerIds = new Set(workers.map((worker) => worker.workerId));
1508
1577
  for (const { key, value: record } of readAllByPrefix(
1509
1578
  this.storage,
1510
1579
  `${this.subscriberPrefix}${topicKey}:`
1511
1580
  )) {
1512
- if (!activeTabIds.has(record.tabId)) {
1581
+ if (!this.isSubscriberRecordLive(record, activeTabIds, activeWorkerIds)) {
1513
1582
  this.removeStorage(key);
1514
1583
  continue;
1515
1584
  }
1516
- subscribers.add(record.tabId);
1585
+ tabIds.add(record.tabId);
1586
+ if (record.workerId !== void 0) workerIds.add(record.workerId);
1517
1587
  }
1518
- return Array.from(subscribers);
1588
+ return { tabIds, workerIds };
1589
+ }
1590
+ /** Whether a subscriber record still belongs to a live Runtime.
1591
+ *
1592
+ * A record written by this version names its `workerId`, so liveness is the
1593
+ * Worker set the reconcile pass already prunes by — which is what keeps two
1594
+ * Runtimes in one tab independent. A record from an older version carries no
1595
+ * `workerId`; it is honoured while its *tab* lives, the finest identity it
1596
+ * has, and each Runtime replaces its own with a worker-keyed record on its
1597
+ * next write. */
1598
+ isSubscriberRecordLive(record, activeTabIds, activeWorkerIds) {
1599
+ return record.workerId === void 0 ? activeTabIds.has(record.tabId) : activeWorkerIds.has(record.workerId);
1519
1600
  }
1520
1601
  /** Read the current route for `topicKey`. With a storage layer this returns
1521
1602
  * the durable route, or null when none is recorded. Without one (degraded
@@ -1581,19 +1662,31 @@ var WorkerClusterRuntime = class {
1581
1662
  this.removeStorage(key);
1582
1663
  }
1583
1664
  }
1584
- /** Remove subscriber records for tabs that are no longer active. */
1665
+ /** Remove subscriber records whose owning Runtime (or, for a legacy record,
1666
+ * whose tab) is no longer active. */
1585
1667
  cleanupOrphanedSubscribers(workers) {
1586
1668
  if (!this.storage) return;
1587
1669
  const activeTabIds = new Set(workers.map((worker) => worker.tabId));
1670
+ const activeWorkerIds = new Set(workers.map((worker) => worker.workerId));
1588
1671
  for (const { key, value: record } of readAllByPrefix(this.storage, this.subscriberPrefix)) {
1589
- if (!activeTabIds.has(record.tabId)) this.removeStorage(key);
1672
+ if (!this.isSubscriberRecordLive(record, activeTabIds, activeWorkerIds)) this.removeStorage(key);
1590
1673
  }
1591
1674
  }
1592
- /** Persist a subscriber record for this tab on `topicKey`. */
1675
+ /** Persist a subscriber record for this Runtime on `topicKey`.
1676
+ *
1677
+ * Keyed by `workerId`, not `tabId`. A document may run several Runtimes that
1678
+ * share one `tabId` on purpose (the market + notice pattern), and a tab-keyed
1679
+ * record belonged to all of them at once: whichever unsubscribed first deleted
1680
+ * the others' intent, the route then looked orphaned, and the owner dropped a
1681
+ * transport subscription its surviving sibling still needed — a delivery gap
1682
+ * until that sibling's next reconcile tick re-elected an owner. Duplicated
1683
+ * tabs get the same protection even when the identity probe cannot see them,
1684
+ * which is why the fix is here as well as in `isTabIdLiveElsewhere()`. */
1593
1685
  writeSubscriber(topicKey) {
1594
1686
  if (!this.storage) return;
1595
- writeJson(this.storage, this.subscriberStorageKey(topicKey, this.tabId), {
1687
+ writeJson(this.storage, this.subscriberStorageKey(topicKey, this.workerId), {
1596
1688
  tabId: this.tabId,
1689
+ workerId: this.workerId,
1597
1690
  updatedAt: this.nowMs()
1598
1691
  });
1599
1692
  }
@@ -1667,8 +1760,9 @@ var WorkerClusterRuntime = class {
1667
1760
  routeStorageKey(topicKey) {
1668
1761
  return `${this.routePrefix}${topicKey}`;
1669
1762
  }
1670
- subscriberStorageKey(topicKey, tabId) {
1671
- return `${this.subscriberPrefix}${topicKey}:${tabId}`;
1763
+ /** `ownerId` is the Runtime's `workerId`; older versions used the `tabId`. */
1764
+ subscriberStorageKey(topicKey, ownerId) {
1765
+ return `${this.subscriberPrefix}${topicKey}:${ownerId}`;
1672
1766
  }
1673
1767
  removeStorage(key) {
1674
1768
  try {
@@ -2548,7 +2642,7 @@ var SDK_VERSION = (
2548
2642
  // declaration's type is `string | undefined` and the export's is `string`
2549
2643
  // (dropping the conditional is TS2322, measured). Recorded here so the leg reads
2550
2644
  // as a shape the harness cannot reach rather than as a case nobody exercised.
2551
- true ? "0.21.56" : ""
2645
+ true ? "0.21.57" : ""
2552
2646
  );
2553
2647
 
2554
2648
  // src/core/data-bus.ts
@@ -3801,4 +3895,4 @@ export {
3801
3895
  parseDataBusPublication,
3802
3896
  selectWorkerBackend
3803
3897
  };
3804
- //# sourceMappingURL=chunk-VDCLCCMN.js.map
3898
+ //# sourceMappingURL=chunk-ZH7VP3IY.js.map