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.
- package/CHANGELOG.md +41 -0
- package/dist/centrifuge.js +1 -1
- package/dist/{chunk-VDCLCCMN.js → chunk-X33QQUTG.js} +117 -23
- package/dist/chunk-X33QQUTG.js.map +7 -0
- package/dist/cjs/centrifuge.cjs +116 -22
- package/dist/cjs/centrifuge.cjs.map +2 -2
- package/dist/cjs/index.cjs +116 -22
- package/dist/cjs/index.cjs.map +2 -2
- package/dist/core/cluster.d.ts +64 -3
- package/dist/core/cluster.d.ts.map +1 -1
- package/dist/core/environment.d.ts +23 -1
- package/dist/core/environment.d.ts.map +1 -1
- package/dist/core/types.d.ts +7 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/docs/api.md +3 -1
- package/docs/architecture.md +18 -16
- package/docs/benchmarks.md +8 -8
- package/docs/configuration.md +2 -2
- package/docs/roadmap.md +1 -1
- package/docs/zh/api.md +3 -1
- package/docs/zh/architecture.md +15 -13
- package/docs/zh/benchmarks.md +8 -8
- package/docs/zh/configuration.md +2 -2
- package/docs/zh/roadmap.md +1 -1
- package/package.json +7 -7
- package/dist/chunk-VDCLCCMN.js.map +0 -7
package/dist/cjs/centrifuge.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
1710
|
+
if (!this.isSubscriberRecordLive(record, activeTabIds, activeWorkerIds)) {
|
|
1642
1711
|
this.removeStorage(key);
|
|
1643
1712
|
continue;
|
|
1644
1713
|
}
|
|
1645
|
-
|
|
1714
|
+
tabIds.add(record.tabId);
|
|
1715
|
+
if (record.workerId !== void 0) workerIds.add(record.workerId);
|
|
1646
1716
|
}
|
|
1647
|
-
return
|
|
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
|
|
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 (!
|
|
1801
|
+
if (!this.isSubscriberRecordLive(record, activeTabIds, activeWorkerIds)) this.removeStorage(key);
|
|
1719
1802
|
}
|
|
1720
1803
|
}
|
|
1721
|
-
/** Persist a subscriber record for this
|
|
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.
|
|
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
|
-
|
|
1800
|
-
|
|
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.
|
|
2774
|
+
true ? "0.21.58" : ""
|
|
2681
2775
|
);
|
|
2682
2776
|
|
|
2683
2777
|
// src/core/data-bus.ts
|