cross-tab-worker-databus 0.20.90 → 0.20.92
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-session.d.ts.map +1 -1
- package/dist/centrifuge.js +7 -9
- package/dist/centrifuge.js.map +2 -2
- package/dist/centrifuge.shared.worker.js +6 -8
- package/dist/centrifuge.shared.worker.js.map +2 -2
- package/dist/centrifuge.worker.js +6 -8
- package/dist/centrifuge.worker.js.map +2 -2
- package/dist/{chunk-L24ETFVK.js → chunk-QB74C4EH.js} +249 -91
- package/dist/chunk-QB74C4EH.js.map +7 -0
- package/dist/cjs/centrifuge.cjs +254 -98
- package/dist/cjs/centrifuge.cjs.map +2 -2
- package/dist/cjs/index.cjs +258 -91
- package/dist/cjs/index.cjs.map +2 -2
- package/dist/core/cluster.d.ts +8 -5
- package/dist/core/cluster.d.ts.map +1 -1
- package/dist/core/data-bus.d.ts +21 -1
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/core/replay-manager.d.ts +17 -3
- package/dist/core/replay-manager.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +2 -2
- package/dist/websocket.d.ts +3 -0
- package/dist/websocket.d.ts.map +1 -1
- package/docs/architecture.md +11 -6
- package/docs/benchmarks.md +8 -8
- package/docs/capabilities.md +3 -1
- package/docs/release-checklist.md +2 -2
- package/docs/roadmap.md +19 -1
- package/docs/zh/architecture.md +11 -6
- package/docs/zh/benchmarks.md +8 -8
- package/docs/zh/capabilities.md +3 -1
- package/docs/zh/release-checklist.md +2 -2
- package/docs/zh/roadmap.md +19 -1
- package/package.json +1 -1
- package/dist/chunk-L24ETFVK.js.map +0 -7
package/dist/cjs/centrifuge.cjs
CHANGED
|
@@ -740,6 +740,9 @@ var WorkerClusterRuntime = class {
|
|
|
740
740
|
started = false;
|
|
741
741
|
suspended = false;
|
|
742
742
|
lifecycleListening = false;
|
|
743
|
+
/** Invalidates an in-flight pageshow resume when a synchronous onResume
|
|
744
|
+
* callback stops or pauses the cluster before activate() is reached. */
|
|
745
|
+
lifecycleGeneration = 0;
|
|
743
746
|
currentRecord;
|
|
744
747
|
constructor(options) {
|
|
745
748
|
assertClusterOptions(options);
|
|
@@ -776,8 +779,14 @@ var WorkerClusterRuntime = class {
|
|
|
776
779
|
/** Start the cluster: register, listen for lifecycle events, and begin heartbeats. */
|
|
777
780
|
start() {
|
|
778
781
|
if (this.started) return;
|
|
779
|
-
this.
|
|
782
|
+
this.lifecycleGeneration += 1;
|
|
780
783
|
this.addLifecycleListeners();
|
|
784
|
+
if (this.environment.getVisibilityState() === TAB_VISIBILITY.HIDDEN) {
|
|
785
|
+
this.suspended = true;
|
|
786
|
+
this.handlers.onSuspend?.();
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
this.suspended = false;
|
|
781
790
|
this.activate();
|
|
782
791
|
}
|
|
783
792
|
/**
|
|
@@ -788,6 +797,7 @@ var WorkerClusterRuntime = class {
|
|
|
788
797
|
* stop() path where callers expect every Set/Map to be empty afterwards.
|
|
789
798
|
*/
|
|
790
799
|
stop() {
|
|
800
|
+
this.lifecycleGeneration += 1;
|
|
791
801
|
if (!this.started && !this.suspended) return;
|
|
792
802
|
this.pause();
|
|
793
803
|
this.flushStorage();
|
|
@@ -834,7 +844,11 @@ var WorkerClusterRuntime = class {
|
|
|
834
844
|
* to other workers, remove our worker record, and close the channel.
|
|
835
845
|
*/
|
|
836
846
|
pause() {
|
|
837
|
-
|
|
847
|
+
this.lifecycleGeneration += 1;
|
|
848
|
+
if (!this.started) {
|
|
849
|
+
if (this.lifecycleListening) this.suspended = true;
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
838
852
|
this.started = false;
|
|
839
853
|
this.suspended = true;
|
|
840
854
|
if (this.heartbeatHandle !== null) this.environment.clearInterval(this.heartbeatHandle);
|
|
@@ -851,10 +865,12 @@ var WorkerClusterRuntime = class {
|
|
|
851
865
|
const channel = this.channel;
|
|
852
866
|
this.channel = null;
|
|
853
867
|
this.handlers.onSuspend?.();
|
|
854
|
-
if (
|
|
855
|
-
globalThis.setTimeout
|
|
856
|
-
|
|
857
|
-
|
|
868
|
+
if (channel) {
|
|
869
|
+
if (typeof globalThis.setTimeout === "function") {
|
|
870
|
+
globalThis.setTimeout(() => channel.close(), 0);
|
|
871
|
+
} else {
|
|
872
|
+
channel.close();
|
|
873
|
+
}
|
|
858
874
|
}
|
|
859
875
|
}
|
|
860
876
|
/** Update the worker's connection status and persist the change. */
|
|
@@ -1147,8 +1163,10 @@ var WorkerClusterRuntime = class {
|
|
|
1147
1163
|
handlePageHide = () => this.pause();
|
|
1148
1164
|
handlePageShow = () => {
|
|
1149
1165
|
if (!this.suspended) return;
|
|
1166
|
+
const generation = ++this.lifecycleGeneration;
|
|
1150
1167
|
this.suspended = false;
|
|
1151
1168
|
this.handlers.onResume?.();
|
|
1169
|
+
if (generation !== this.lifecycleGeneration) return;
|
|
1152
1170
|
this.activate();
|
|
1153
1171
|
};
|
|
1154
1172
|
handleVisibilityChange = () => {
|
|
@@ -1203,10 +1221,16 @@ var WorkerClusterRuntime = class {
|
|
|
1203
1221
|
if (message.targetWorkerId !== this.workerId) return;
|
|
1204
1222
|
this.rememberTopic(message.topic);
|
|
1205
1223
|
switch (message.action) {
|
|
1206
|
-
case CONTROL_ACTION.SUBSCRIBE:
|
|
1224
|
+
case CONTROL_ACTION.SUBSCRIBE: {
|
|
1225
|
+
const route = this.readRoute(message.topicKey);
|
|
1226
|
+
if (route && route.workerId !== this.workerId) return;
|
|
1227
|
+
if (route?.workerId === this.workerId && route.handoffFromWorkerId !== void 0 && route.confirmedAt === void 0) {
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1207
1230
|
this.assignedTopics.set(message.topicKey, message.topic);
|
|
1208
1231
|
this.confirmRoute(message.topicKey);
|
|
1209
1232
|
break;
|
|
1233
|
+
}
|
|
1210
1234
|
case CONTROL_ACTION.UNSUBSCRIBE:
|
|
1211
1235
|
if (this.releaseHandoffOnUnsubscribe(message)) return;
|
|
1212
1236
|
break;
|
|
@@ -1267,8 +1291,8 @@ var WorkerClusterRuntime = class {
|
|
|
1267
1291
|
}
|
|
1268
1292
|
/**
|
|
1269
1293
|
* Accept a graceful handoff only when the route still points to this worker,
|
|
1270
|
-
* the release comes from the recorded previous owner, and the generation
|
|
1271
|
-
*
|
|
1294
|
+
* the release comes from the recorded previous owner, and the generation
|
|
1295
|
+
* exactly matches ours. Any other ROUTE_RELEASED is stale and dropped.
|
|
1272
1296
|
*/
|
|
1273
1297
|
handleRouteReleasedMessage(message) {
|
|
1274
1298
|
if (message.targetWorkerId !== this.workerId) return;
|
|
@@ -1281,11 +1305,11 @@ var WorkerClusterRuntime = class {
|
|
|
1281
1305
|
}
|
|
1282
1306
|
/** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
|
|
1283
1307
|
* points to us, the release comes from the recorded previous owner, and
|
|
1284
|
-
* the release generation
|
|
1285
|
-
* earlier handoff round
|
|
1286
|
-
*
|
|
1308
|
+
* the release generation exactly matches ours. A delayed ACK from either an
|
|
1309
|
+
* earlier or later handoff round belongs to a different route and must not
|
|
1310
|
+
* confirm the current round. */
|
|
1287
1311
|
isStaleRouteRelease(route, message) {
|
|
1288
|
-
return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation
|
|
1312
|
+
return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation !== route.generation;
|
|
1289
1313
|
}
|
|
1290
1314
|
/** True when an unconfirmed handoff route has been stuck longer than a
|
|
1291
1315
|
* worker TTL. The ACK for a live handoff is posted synchronously with the
|
|
@@ -1912,7 +1936,7 @@ var ReplayManager = class {
|
|
|
1912
1936
|
this.trace = deps.trace;
|
|
1913
1937
|
this.onPersistenceError = deps.onPersistenceError;
|
|
1914
1938
|
this.onDispatchError = deps.onDispatchError;
|
|
1915
|
-
|
|
1939
|
+
void this.requestHydration();
|
|
1916
1940
|
}
|
|
1917
1941
|
deps;
|
|
1918
1942
|
buffers;
|
|
@@ -1931,7 +1955,18 @@ var ReplayManager = class {
|
|
|
1931
1955
|
retryGeneration = 0;
|
|
1932
1956
|
pendingReplayPersistence = [];
|
|
1933
1957
|
persistenceFlushScheduled = false;
|
|
1934
|
-
hydration
|
|
1958
|
+
/** Current hydration operation, if one belongs to the active lifecycle. */
|
|
1959
|
+
hydration = null;
|
|
1960
|
+
/** Invalidates a load from a superseded suspend/reset lifecycle. */
|
|
1961
|
+
hydrationEpoch = 0;
|
|
1962
|
+
/** Whether the active lifecycle has finished (or deliberately skipped) hydration. */
|
|
1963
|
+
hydrationComplete = false;
|
|
1964
|
+
/** A failed load is retried by the next explicit start(), not every replay request. */
|
|
1965
|
+
hydrationFailed = false;
|
|
1966
|
+
/** Mutations that must be applied to a load already in flight. */
|
|
1967
|
+
hydrationClearAll = false;
|
|
1968
|
+
hydrationClearedTopics = /* @__PURE__ */ new Set();
|
|
1969
|
+
hydrationClearBefore = null;
|
|
1935
1970
|
/** Coalesced retention cleanup: the newest cutoff wins while one is running. */
|
|
1936
1971
|
retentionCleanup = null;
|
|
1937
1972
|
retentionCutoff = null;
|
|
@@ -1985,7 +2020,7 @@ var ReplayManager = class {
|
|
|
1985
2020
|
if (!this.buffers) return;
|
|
1986
2021
|
const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
|
|
1987
2022
|
if (this.persistence) {
|
|
1988
|
-
void this.
|
|
2023
|
+
void this.requestHydration().then(() => {
|
|
1989
2024
|
if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
|
|
1990
2025
|
});
|
|
1991
2026
|
return;
|
|
@@ -1997,22 +2032,24 @@ var ReplayManager = class {
|
|
|
1997
2032
|
* clearTopic), and prune durable history. */
|
|
1998
2033
|
onTopicUnsubscribed(topic) {
|
|
1999
2034
|
if (!this.buffers) return;
|
|
2035
|
+
const clearing = this.persistence?.clearTopic ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)) : null;
|
|
2036
|
+
this.hydrationClearedTopics.add(topic);
|
|
2000
2037
|
this.buffers.delete(topic);
|
|
2001
2038
|
this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
|
|
2002
|
-
if (this.
|
|
2003
|
-
void this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)).catch((error) => this.onPersistenceError(error));
|
|
2004
|
-
}
|
|
2039
|
+
if (clearing) void clearing.catch((error) => this.onPersistenceError(error));
|
|
2005
2040
|
}
|
|
2006
2041
|
/** Clear all in-memory replay buffers and, when supported, durable history.
|
|
2007
2042
|
* Reports persistence failures and rethrows, mirroring the public API
|
|
2008
2043
|
* contract that callers can observe a failed clear. */
|
|
2009
2044
|
async clearAll() {
|
|
2010
2045
|
if (!this.buffers) return;
|
|
2046
|
+
const clearing = this.persistence?.clear ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear()) : null;
|
|
2047
|
+
this.hydrationClearAll = true;
|
|
2011
2048
|
this.buffers.clear();
|
|
2012
2049
|
this.pendingReplayPersistence = [];
|
|
2013
|
-
if (
|
|
2050
|
+
if (clearing) {
|
|
2014
2051
|
try {
|
|
2015
|
-
await
|
|
2052
|
+
await clearing;
|
|
2016
2053
|
} catch (error) {
|
|
2017
2054
|
this.onPersistenceError(error);
|
|
2018
2055
|
throw error;
|
|
@@ -2022,11 +2059,13 @@ var ReplayManager = class {
|
|
|
2022
2059
|
/** Clear replay history for one exact topic, including durable storage. */
|
|
2023
2060
|
async clearTopic(topic) {
|
|
2024
2061
|
if (!this.buffers) return;
|
|
2062
|
+
const clearing = this.persistence?.clearTopic ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)) : null;
|
|
2063
|
+
this.hydrationClearedTopics.add(topic);
|
|
2025
2064
|
this.buffers.delete(topic);
|
|
2026
2065
|
this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
|
|
2027
|
-
if (
|
|
2066
|
+
if (clearing) {
|
|
2028
2067
|
try {
|
|
2029
|
-
await
|
|
2068
|
+
await clearing;
|
|
2030
2069
|
} catch (error) {
|
|
2031
2070
|
this.onPersistenceError(error);
|
|
2032
2071
|
throw error;
|
|
@@ -2036,7 +2075,9 @@ var ReplayManager = class {
|
|
|
2036
2075
|
/** Remove replay entries older than an epoch-millisecond cutoff. */
|
|
2037
2076
|
async clearBefore(timestamp) {
|
|
2038
2077
|
if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
|
|
2078
|
+
const clearing = this.persistence?.clearBefore ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp)) : null;
|
|
2039
2079
|
if (this.buffers) {
|
|
2080
|
+
this.hydrationClearBefore = this.hydrationClearBefore === null ? timestamp : Math.max(this.hydrationClearBefore, timestamp);
|
|
2040
2081
|
for (const [topic, messages] of this.buffers) {
|
|
2041
2082
|
const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
|
|
2042
2083
|
if (kept.length) this.buffers.set(topic, kept);
|
|
@@ -2046,9 +2087,9 @@ var ReplayManager = class {
|
|
|
2046
2087
|
this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
|
|
2047
2088
|
(message) => message.timestamp === void 0 || message.timestamp >= timestamp
|
|
2048
2089
|
);
|
|
2049
|
-
if (
|
|
2090
|
+
if (clearing) {
|
|
2050
2091
|
try {
|
|
2051
|
-
await
|
|
2092
|
+
await clearing;
|
|
2052
2093
|
} catch (error) {
|
|
2053
2094
|
this.onPersistenceError(error);
|
|
2054
2095
|
throw error;
|
|
@@ -2058,6 +2099,11 @@ var ReplayManager = class {
|
|
|
2058
2099
|
/** Start the periodic retention sweep. No-op when no durable retention
|
|
2059
2100
|
* config makes it necessary. */
|
|
2060
2101
|
start() {
|
|
2102
|
+
if (this.hydrationFailed) {
|
|
2103
|
+
this.hydrationComplete = false;
|
|
2104
|
+
this.hydrationFailed = false;
|
|
2105
|
+
}
|
|
2106
|
+
void this.requestHydration();
|
|
2061
2107
|
if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
|
|
2062
2108
|
this.retentionTimer = setInterval(() => {
|
|
2063
2109
|
this.scheduleRetentionCleanup(this.now() - this.retentionMs);
|
|
@@ -2072,13 +2118,25 @@ var ReplayManager = class {
|
|
|
2072
2118
|
* or stopped bus does not keep hammering the store) and stop the sweep. */
|
|
2073
2119
|
suspend() {
|
|
2074
2120
|
this.retryGeneration += 1;
|
|
2121
|
+
if (this.hydration) {
|
|
2122
|
+
this.hydrationEpoch += 1;
|
|
2123
|
+
this.hydration = null;
|
|
2124
|
+
this.hydrationComplete = false;
|
|
2125
|
+
}
|
|
2075
2126
|
this.pendingReplayPersistence = [];
|
|
2076
2127
|
this.retentionCutoff = null;
|
|
2077
2128
|
this.stop();
|
|
2078
2129
|
}
|
|
2079
|
-
/** Drop all in-memory buffers
|
|
2130
|
+
/** Drop all in-memory buffers and require hydration for the next lifecycle. */
|
|
2080
2131
|
resetBuffers() {
|
|
2081
2132
|
this.buffers?.clear();
|
|
2133
|
+
this.hydrationEpoch += 1;
|
|
2134
|
+
this.hydration = null;
|
|
2135
|
+
this.hydrationComplete = false;
|
|
2136
|
+
this.hydrationFailed = false;
|
|
2137
|
+
this.hydrationClearAll = false;
|
|
2138
|
+
this.hydrationClearedTopics.clear();
|
|
2139
|
+
this.hydrationClearBefore = null;
|
|
2082
2140
|
}
|
|
2083
2141
|
/** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
|
|
2084
2142
|
* payload footprint (same heuristic as adaptive load weighting), computed on
|
|
@@ -2130,38 +2188,78 @@ var ReplayManager = class {
|
|
|
2130
2188
|
void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
|
|
2131
2189
|
});
|
|
2132
2190
|
}
|
|
2133
|
-
/**
|
|
2191
|
+
/** Start the active lifecycle's one-shot hydration, if it has not completed
|
|
2192
|
+
* or deliberately skipped hydration already. */
|
|
2193
|
+
requestHydration() {
|
|
2194
|
+
if (!this.buffers || !this.persistence) {
|
|
2195
|
+
this.hydrationComplete = true;
|
|
2196
|
+
return Promise.resolve();
|
|
2197
|
+
}
|
|
2198
|
+
if (this.hydration) return this.hydration;
|
|
2199
|
+
if (this.hydrationComplete) return Promise.resolve();
|
|
2200
|
+
const epoch = this.hydrationEpoch;
|
|
2201
|
+
const generation = this.retryGeneration;
|
|
2202
|
+
const operation = this.hydrate(epoch, generation);
|
|
2203
|
+
this.hydration = operation;
|
|
2204
|
+
return operation;
|
|
2205
|
+
}
|
|
2206
|
+
/** Load durable history into the in-memory rings once per lifecycle, pruning
|
|
2134
2207
|
* entries past the retention window first. Failures are reported but do not
|
|
2135
2208
|
* block startup — the bus runs with whatever survived. */
|
|
2136
|
-
async hydrate() {
|
|
2209
|
+
async hydrate(epoch, generation) {
|
|
2137
2210
|
if (!this.buffers || !this.persistence) {
|
|
2138
2211
|
return;
|
|
2139
2212
|
}
|
|
2140
2213
|
try {
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
if (
|
|
2148
|
-
|
|
2149
|
-
|
|
2214
|
+
try {
|
|
2215
|
+
if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
|
|
2216
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
|
|
2217
|
+
}
|
|
2218
|
+
const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
|
|
2219
|
+
if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
|
|
2220
|
+
if (epoch !== this.hydrationEpoch) return;
|
|
2221
|
+
const loadedByTopic = /* @__PURE__ */ new Map();
|
|
2222
|
+
for (const message of loaded) {
|
|
2223
|
+
if (this.hydrationClearAll || this.hydrationClearedTopics.has(message.topic) || this.hydrationClearBefore !== null && message.timestamp !== void 0 && message.timestamp < this.hydrationClearBefore) continue;
|
|
2224
|
+
let buffer = loadedByTopic.get(message.topic);
|
|
2225
|
+
if (!buffer) {
|
|
2226
|
+
buffer = [];
|
|
2227
|
+
loadedByTopic.set(message.topic, buffer);
|
|
2228
|
+
}
|
|
2229
|
+
buffer.push(message);
|
|
2150
2230
|
}
|
|
2151
|
-
|
|
2231
|
+
for (const [topic, durableBuffer] of loadedByTopic) {
|
|
2232
|
+
const liveBuffer = this.buffers.get(topic);
|
|
2233
|
+
this.buffers.set(topic, liveBuffer ? [...durableBuffer, ...liveBuffer] : durableBuffer);
|
|
2234
|
+
}
|
|
2235
|
+
const hydrationNow = this.now();
|
|
2236
|
+
for (const [topic, buffer] of this.buffers) {
|
|
2237
|
+
const pruned = pruneReplayHistory(buffer, {
|
|
2238
|
+
maxPerTopic: this.maxPerTopic,
|
|
2239
|
+
pruneStrategy: this.pruneStrategy,
|
|
2240
|
+
retentionMs: this.retentionMs,
|
|
2241
|
+
now: hydrationNow
|
|
2242
|
+
});
|
|
2243
|
+
if (pruned !== buffer) this.buffers.set(topic, pruned);
|
|
2244
|
+
}
|
|
2245
|
+
this.hydrationComplete = true;
|
|
2246
|
+
this.hydrationFailed = false;
|
|
2247
|
+
} catch (error) {
|
|
2248
|
+
if (generation !== this.retryGeneration) {
|
|
2249
|
+
this.onPersistenceError(
|
|
2250
|
+
error instanceof PersistenceRetryCancelledError ? error : new PersistenceRetryCancelledError()
|
|
2251
|
+
);
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
if (epoch !== this.hydrationEpoch) return;
|
|
2255
|
+
this.onPersistenceError(error);
|
|
2256
|
+
this.hydrationComplete = true;
|
|
2257
|
+
this.hydrationFailed = true;
|
|
2152
2258
|
}
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
maxPerTopic: this.maxPerTopic,
|
|
2157
|
-
pruneStrategy: this.pruneStrategy,
|
|
2158
|
-
retentionMs: this.retentionMs,
|
|
2159
|
-
now: hydrationNow
|
|
2160
|
-
});
|
|
2161
|
-
if (pruned !== buffer) this.buffers.set(topic, pruned);
|
|
2259
|
+
} finally {
|
|
2260
|
+
if (this.hydrationEpoch === epoch && this.retryGeneration === generation) {
|
|
2261
|
+
this.hydration = null;
|
|
2162
2262
|
}
|
|
2163
|
-
} catch (error) {
|
|
2164
|
-
this.onPersistenceError(error);
|
|
2165
2263
|
}
|
|
2166
2264
|
}
|
|
2167
2265
|
/** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
|
|
@@ -2185,7 +2283,7 @@ var ReplayManager = class {
|
|
|
2185
2283
|
}
|
|
2186
2284
|
})().finally(() => {
|
|
2187
2285
|
this.retentionCleanup = null;
|
|
2188
|
-
if (this.retentionCutoff !== null
|
|
2286
|
+
if (this.retentionCutoff !== null) {
|
|
2189
2287
|
this.scheduleRetentionCleanup(this.retentionCutoff);
|
|
2190
2288
|
}
|
|
2191
2289
|
});
|
|
@@ -2338,7 +2436,7 @@ var DedupManager = class {
|
|
|
2338
2436
|
};
|
|
2339
2437
|
|
|
2340
2438
|
// src/core/version.ts
|
|
2341
|
-
var SDK_VERSION = true ? "0.20.
|
|
2439
|
+
var SDK_VERSION = true ? "0.20.92" : "";
|
|
2342
2440
|
|
|
2343
2441
|
// src/core/data-bus.ts
|
|
2344
2442
|
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
@@ -2414,11 +2512,21 @@ var CrossTabDataBus = class {
|
|
|
2414
2512
|
recoveryGateRelease = null;
|
|
2415
2513
|
recoveryTimer = null;
|
|
2416
2514
|
recoveryTimerToken = 0;
|
|
2515
|
+
// Invalidates operations parked on the recovery gate when a hide/stop
|
|
2516
|
+
// supersedes the recovery cycle. Their microtask may run after an immediate
|
|
2517
|
+
// explicit start has cleared `suspended`, so a state check alone is not
|
|
2518
|
+
// enough to keep stale work from reaching the replacement transport.
|
|
2519
|
+
recoveryCancellationToken = 0;
|
|
2417
2520
|
// Once an automatic attempt fails, an explicit transport operation may
|
|
2418
2521
|
// recover immediately instead of waiting for the next paced attempt. The
|
|
2419
2522
|
// gate still stays closed so the operation cannot reach the failed
|
|
2420
2523
|
// transport; it is released by the successful on-demand reopen.
|
|
2421
2524
|
recoveryDemandAllowed = false;
|
|
2525
|
+
// Number of transport operations currently parked behind `recoveryGate`.
|
|
2526
|
+
// When an automatic attempt fails, these already-parked operations are
|
|
2527
|
+
// themselves demand: the failure path starts an on-demand reopen instead of
|
|
2528
|
+
// stranding them until some unrelated future operation arrives.
|
|
2529
|
+
recoveryWaiters = 0;
|
|
2422
2530
|
/** Monotonic generation incremented on every successful transport open.
|
|
2423
2531
|
* Stays in lockstep with `lastSuccessAt` so callers can detect that the
|
|
2424
2532
|
* transport has been reopened even if the timestamp window is short. */
|
|
@@ -2520,6 +2628,7 @@ var CrossTabDataBus = class {
|
|
|
2520
2628
|
// own broadcastEvent call, which always posts a DataBusMessage.
|
|
2521
2629
|
onEvent: (eventType, payload, _sourceWorkerId, originTabId) => {
|
|
2522
2630
|
if (eventType !== PUBLICATION_EVENT) return;
|
|
2631
|
+
if (typeof payload !== "object" || payload === null || typeof payload.topic !== "string") return;
|
|
2523
2632
|
const incoming = payload;
|
|
2524
2633
|
const message = incoming.originTabId !== void 0 ? incoming : originTabId !== void 0 ? { ...incoming, originTabId } : incoming;
|
|
2525
2634
|
if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
|
|
@@ -2532,7 +2641,7 @@ var CrossTabDataBus = class {
|
|
|
2532
2641
|
this.suspendTransport();
|
|
2533
2642
|
},
|
|
2534
2643
|
onResume: () => {
|
|
2535
|
-
this.resumeSuspendedResources();
|
|
2644
|
+
if (!this.resumeSuspendedResources()) return;
|
|
2536
2645
|
this.resumeTransport();
|
|
2537
2646
|
},
|
|
2538
2647
|
onDiagnostic: (event) => {
|
|
@@ -2561,11 +2670,13 @@ var CrossTabDataBus = class {
|
|
|
2561
2670
|
const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
|
|
2562
2671
|
if (!transportDown) return Promise.resolve();
|
|
2563
2672
|
this.activeConfig = config;
|
|
2564
|
-
this.resetFailureState();
|
|
2673
|
+
this.resetFailureState(true);
|
|
2565
2674
|
const resumingFromSuspend = this.suspended;
|
|
2566
|
-
if (resumingFromSuspend
|
|
2675
|
+
if (resumingFromSuspend && !this.resumeSuspendedResources()) {
|
|
2676
|
+
return this.stopPromise ?? Promise.resolve();
|
|
2677
|
+
}
|
|
2567
2678
|
const opening2 = this.reopenTransport();
|
|
2568
|
-
if (resumingFromSuspend) this.cluster.start();
|
|
2679
|
+
if (resumingFromSuspend && !this.stopping && !this.suspended) this.cluster.start();
|
|
2569
2680
|
return opening2;
|
|
2570
2681
|
}
|
|
2571
2682
|
this.started = true;
|
|
@@ -2573,12 +2684,6 @@ var CrossTabDataBus = class {
|
|
|
2573
2684
|
this.suspended = false;
|
|
2574
2685
|
this.activeConfig = config;
|
|
2575
2686
|
this.resetFailureState();
|
|
2576
|
-
this.trace.start();
|
|
2577
|
-
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
|
|
2578
|
-
this.startDedupSweep();
|
|
2579
|
-
this.replayManager.start();
|
|
2580
|
-
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
2581
|
-
this.cluster.start();
|
|
2582
2687
|
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
2583
2688
|
const opening = this.openTransport(
|
|
2584
2689
|
config,
|
|
@@ -2587,7 +2692,17 @@ var CrossTabDataBus = class {
|
|
|
2587
2692
|
lifecycleEpoch
|
|
2588
2693
|
);
|
|
2589
2694
|
this.startPromise = opening;
|
|
2695
|
+
this.trace.start();
|
|
2696
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
|
|
2697
|
+
if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
|
|
2698
|
+
this.startDedupSweep();
|
|
2699
|
+
this.replayManager.start();
|
|
2700
|
+
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
2701
|
+
if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
|
|
2702
|
+
this.cluster.start();
|
|
2703
|
+
if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
|
|
2590
2704
|
for (const topic of this.topicHandlers.keys()) {
|
|
2705
|
+
if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) break;
|
|
2591
2706
|
this.cluster.subscribe(topic);
|
|
2592
2707
|
}
|
|
2593
2708
|
void opening.then(
|
|
@@ -2649,27 +2764,49 @@ var CrossTabDataBus = class {
|
|
|
2649
2764
|
/** Cancel a pending automatic retry when an explicit lifecycle transition
|
|
2650
2765
|
* supersedes it. The released gate re-enters runTransport(), which then
|
|
2651
2766
|
* follows the newest start/stop/suspend intent. */
|
|
2652
|
-
cancelScheduledRecovery() {
|
|
2767
|
+
cancelScheduledRecovery(invalidateParkedOperations = false, releaseGate = true) {
|
|
2653
2768
|
this.recoveryTimerToken += 1;
|
|
2769
|
+
if (invalidateParkedOperations) this.recoveryCancellationToken += 1;
|
|
2654
2770
|
if (this.recoveryTimer !== null) {
|
|
2655
2771
|
clearTimeout(this.recoveryTimer);
|
|
2656
2772
|
this.recoveryTimer = null;
|
|
2657
2773
|
}
|
|
2658
|
-
this.releaseRecoveryGate();
|
|
2774
|
+
if (releaseGate) this.releaseRecoveryGate();
|
|
2659
2775
|
}
|
|
2660
2776
|
/** Keep the recovery gate closed after a failed attempt while allowing the
|
|
2661
2777
|
* next explicit transport operation to start an immediate on-demand reopen.
|
|
2662
|
-
* If no gate/successor retry remains, release any waiters.
|
|
2663
|
-
|
|
2778
|
+
* If no gate/successor retry remains, release any waiters.
|
|
2779
|
+
*
|
|
2780
|
+
* `kickParkedWaiters` is set only when the failure is an automatic attempt: an
|
|
2781
|
+
* operation that was already parked on the gate is itself demand, so it must
|
|
2782
|
+
* not wait for some unrelated future operation. A failed *on-demand* reopen
|
|
2783
|
+
* passes `false`, so it re-arms the flag for a later operation instead of
|
|
2784
|
+
* looping on its own failure. */
|
|
2785
|
+
allowDemandRecovery(kickParkedWaiters = false) {
|
|
2664
2786
|
if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
|
|
2665
2787
|
this.recoveryDemandAllowed = true;
|
|
2788
|
+
if (kickParkedWaiters && this.recoveryWaiters > 0) this.startDemandRecovery();
|
|
2666
2789
|
return;
|
|
2667
2790
|
}
|
|
2668
2791
|
this.releaseRecoveryGate();
|
|
2669
2792
|
}
|
|
2793
|
+
/** Start one on-demand reopen if a failed attempt has left parked operations
|
|
2794
|
+
* and enabled demand recovery. Consumes the demand token so at most one
|
|
2795
|
+
* reopen is issued; every waiter stays behind the gate until it succeeds. */
|
|
2796
|
+
startDemandRecovery() {
|
|
2797
|
+
if (!this.recoveryDemandAllowed) return;
|
|
2798
|
+
if (this.status !== WORKER_STATUS.ERROR || this.suspended || this.stopping) return;
|
|
2799
|
+
this.recoveryDemandAllowed = false;
|
|
2800
|
+
const opening = this.reopenTransport();
|
|
2801
|
+
void opening.then(
|
|
2802
|
+
() => this.releaseRecoveryGate(),
|
|
2803
|
+
() => this.allowDemandRecovery()
|
|
2804
|
+
);
|
|
2805
|
+
}
|
|
2670
2806
|
/** Reset failure and recovery diagnostics for a new explicit start session. */
|
|
2671
|
-
resetFailureState() {
|
|
2672
|
-
this.cancelScheduledRecovery();
|
|
2807
|
+
resetFailureState(preserveRecoveryGate = false) {
|
|
2808
|
+
this.cancelScheduledRecovery(false, !preserveRecoveryGate);
|
|
2809
|
+
if (preserveRecoveryGate) this.recoveryDemandAllowed = false;
|
|
2673
2810
|
this.lastError = null;
|
|
2674
2811
|
this.lastErrorAt = null;
|
|
2675
2812
|
this.lastFailure = null;
|
|
@@ -3009,22 +3146,36 @@ var CrossTabDataBus = class {
|
|
|
3009
3146
|
if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
|
|
3010
3147
|
return Promise.resolve();
|
|
3011
3148
|
}
|
|
3149
|
+
let resolveGate;
|
|
3150
|
+
const stopGate = new Promise((resolve) => {
|
|
3151
|
+
resolveGate = resolve;
|
|
3152
|
+
});
|
|
3153
|
+
this.stopPromise = stopGate;
|
|
3154
|
+
this.beginStop();
|
|
3012
3155
|
const stopPromise = this.performStop();
|
|
3013
|
-
this.stopPromise = stopPromise;
|
|
3014
3156
|
void stopPromise.then(
|
|
3015
3157
|
() => {
|
|
3016
|
-
if (this.stopPromise ===
|
|
3158
|
+
if (this.stopPromise === stopGate) this.stopPromise = null;
|
|
3159
|
+
resolveGate();
|
|
3017
3160
|
},
|
|
3018
3161
|
() => {
|
|
3019
|
-
if (this.stopPromise ===
|
|
3162
|
+
if (this.stopPromise === stopGate) this.stopPromise = null;
|
|
3163
|
+
resolveGate();
|
|
3020
3164
|
}
|
|
3021
3165
|
);
|
|
3022
|
-
return
|
|
3166
|
+
return stopGate;
|
|
3023
3167
|
}
|
|
3024
|
-
|
|
3168
|
+
/**
|
|
3169
|
+
* Synchronous teardown prelude. Flips `stopping` (the authoritative
|
|
3170
|
+
* in-flight signal), cancels scheduled work, releases handlers, and emits the
|
|
3171
|
+
* observable STOP lifecycle event. stop() calls it after the shared gate is
|
|
3172
|
+
* installed but in the same tick, so the stop still takes effect immediately
|
|
3173
|
+
* while a re-entrant stop() from the STOP event shares the one teardown.
|
|
3174
|
+
*/
|
|
3175
|
+
beginStop() {
|
|
3025
3176
|
this.lifecycleEpoch += 1;
|
|
3026
3177
|
this.stopping = true;
|
|
3027
|
-
this.cancelScheduledRecovery();
|
|
3178
|
+
this.cancelScheduledRecovery(true);
|
|
3028
3179
|
this.replayManager.suspend();
|
|
3029
3180
|
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
|
|
3030
3181
|
this.trace.stop();
|
|
@@ -3032,6 +3183,8 @@ var CrossTabDataBus = class {
|
|
|
3032
3183
|
this.topicHandlers.clear();
|
|
3033
3184
|
this.replayManager.resetBuffers();
|
|
3034
3185
|
this.cluster.stop();
|
|
3186
|
+
}
|
|
3187
|
+
async performStop() {
|
|
3035
3188
|
try {
|
|
3036
3189
|
await this.startPromise?.catch(() => void 0);
|
|
3037
3190
|
const pendingStop = this.pendingStop;
|
|
@@ -3048,8 +3201,6 @@ var CrossTabDataBus = class {
|
|
|
3048
3201
|
this.transportReady = false;
|
|
3049
3202
|
this.startPromise = null;
|
|
3050
3203
|
this.pendingStop = null;
|
|
3051
|
-
this.lastError = null;
|
|
3052
|
-
this.lastErrorAt = null;
|
|
3053
3204
|
this.activeConfig = void 0;
|
|
3054
3205
|
this.recoveryAttempt = 0;
|
|
3055
3206
|
this.recoveryExhausted = false;
|
|
@@ -3146,7 +3297,9 @@ var CrossTabDataBus = class {
|
|
|
3146
3297
|
const opening = this.reopenTransport(attempt);
|
|
3147
3298
|
void opening.then(
|
|
3148
3299
|
() => this.releaseRecoveryGate(),
|
|
3149
|
-
|
|
3300
|
+
// Operations already parked on the gate are demand: run one
|
|
3301
|
+
// on-demand reopen now instead of waiting for an unrelated event.
|
|
3302
|
+
() => this.allowDemandRecovery(true)
|
|
3150
3303
|
);
|
|
3151
3304
|
}, this.recoveryCooldownMs);
|
|
3152
3305
|
}
|
|
@@ -3249,10 +3402,13 @@ var CrossTabDataBus = class {
|
|
|
3249
3402
|
* pageshow path and explicit start() must run this so an explicit resume
|
|
3250
3403
|
* cannot leave trace metrics and periodic cleanup timers permanently off. */
|
|
3251
3404
|
resumeSuspendedResources() {
|
|
3405
|
+
const lifecycleEpoch = this.lifecycleEpoch;
|
|
3252
3406
|
this.trace.start();
|
|
3253
3407
|
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
|
|
3408
|
+
if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return false;
|
|
3254
3409
|
this.startDedupSweep();
|
|
3255
3410
|
this.replayManager.start();
|
|
3411
|
+
return lifecycleEpoch === this.lifecycleEpoch && !this.stopping;
|
|
3256
3412
|
}
|
|
3257
3413
|
/**
|
|
3258
3414
|
* Suspend the transport when the tab goes hidden. Stops the transport and
|
|
@@ -3260,12 +3416,13 @@ var CrossTabDataBus = class {
|
|
|
3260
3416
|
*/
|
|
3261
3417
|
suspendTransport() {
|
|
3262
3418
|
if (this.stopping) return;
|
|
3263
|
-
this.lifecycleEpoch
|
|
3419
|
+
const suspensionEpoch = ++this.lifecycleEpoch;
|
|
3264
3420
|
this.suspended = true;
|
|
3265
|
-
this.cancelScheduledRecovery();
|
|
3421
|
+
this.cancelScheduledRecovery(true);
|
|
3266
3422
|
this.transportReady = false;
|
|
3267
3423
|
this.transportSubscribedTopics.clear();
|
|
3268
3424
|
this.updateStatus(WORKER_STATUS.DISCONNECTED);
|
|
3425
|
+
if (suspensionEpoch !== this.lifecycleEpoch || this.stopping || !this.suspended) return;
|
|
3269
3426
|
if (this.pendingStop && (this.startPromise === null || this.startPromise === this.pendingStop)) {
|
|
3270
3427
|
this.startPromise = this.pendingStop;
|
|
3271
3428
|
return;
|
|
@@ -3300,13 +3457,18 @@ var CrossTabDataBus = class {
|
|
|
3300
3457
|
if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
|
|
3301
3458
|
const config = this.activeConfig;
|
|
3302
3459
|
const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
|
|
3303
|
-
this.started = true;
|
|
3304
|
-
this.suspended = false;
|
|
3305
|
-
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
3306
3460
|
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
3307
3461
|
const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
|
|
3308
3462
|
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
|
|
3309
3463
|
this.startPromise = opening;
|
|
3464
|
+
this.started = true;
|
|
3465
|
+
this.suspended = false;
|
|
3466
|
+
this.transportReady = false;
|
|
3467
|
+
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
3468
|
+
if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping || this.suspended) {
|
|
3469
|
+
void opening.catch(() => void 0);
|
|
3470
|
+
return opening;
|
|
3471
|
+
}
|
|
3310
3472
|
void opening.then(
|
|
3311
3473
|
() => {
|
|
3312
3474
|
if (this.startPromise === opening) this.startPromise = null;
|
|
@@ -3336,17 +3498,13 @@ var CrossTabDataBus = class {
|
|
|
3336
3498
|
runTransport(operation) {
|
|
3337
3499
|
if (this.suspended) return;
|
|
3338
3500
|
if (this.recoveryGate && !this.stopping) {
|
|
3339
|
-
if (this.recoveryDemandAllowed && this.status === WORKER_STATUS.ERROR && !this.suspended) {
|
|
3340
|
-
this.recoveryDemandAllowed = false;
|
|
3341
|
-
const opening = this.reopenTransport();
|
|
3342
|
-
void opening.then(
|
|
3343
|
-
() => this.releaseRecoveryGate(),
|
|
3344
|
-
() => this.allowDemandRecovery()
|
|
3345
|
-
);
|
|
3346
|
-
}
|
|
3347
3501
|
const gate = this.recoveryGate;
|
|
3502
|
+
const cancellationToken = this.recoveryCancellationToken;
|
|
3503
|
+
this.startDemandRecovery();
|
|
3504
|
+
this.recoveryWaiters += 1;
|
|
3348
3505
|
void gate.then(() => {
|
|
3349
|
-
|
|
3506
|
+
this.recoveryWaiters -= 1;
|
|
3507
|
+
if (this.stopping || this.suspended || cancellationToken !== this.recoveryCancellationToken) return;
|
|
3350
3508
|
this.runTransport(operation);
|
|
3351
3509
|
});
|
|
3352
3510
|
return;
|
|
@@ -3528,8 +3686,8 @@ var CentrifugeSession = class {
|
|
|
3528
3686
|
// Route credential fetches back to the main thread, where the
|
|
3529
3687
|
// application's credentialProvider lives (config must stay
|
|
3530
3688
|
// structured-cloneable, so functions cannot cross the boundary).
|
|
3531
|
-
getToken: () => this.requestToken("token"),
|
|
3532
|
-
getChannelToken: (channel) => this.requestToken("channelToken", channel)
|
|
3689
|
+
getToken: () => this.requestToken("token", lifecycle),
|
|
3690
|
+
getChannelToken: (channel) => this.requestToken("channelToken", lifecycle, channel)
|
|
3533
3691
|
} : config;
|
|
3534
3692
|
const client = new import_centrifuge.Centrifuge(url, clientOptions);
|
|
3535
3693
|
this.client = client;
|
|
@@ -3648,15 +3806,13 @@ var CentrifugeSession = class {
|
|
|
3648
3806
|
/** Issue a credential request to the main thread and await the response.
|
|
3649
3807
|
* Used as Centrifuge's `getToken` / `getChannelToken` when token bridging is
|
|
3650
3808
|
* enabled; resolved or rejected by a matching TOKEN_RESPONSE / TOKEN_ERROR. */
|
|
3651
|
-
requestToken(kind, channel) {
|
|
3652
|
-
|
|
3809
|
+
requestToken(kind, lifecycle, channel) {
|
|
3810
|
+
if (this.lifecycle !== lifecycle) {
|
|
3811
|
+
return Promise.reject(new Error("Centrifuge client lifecycle has ended before the credential was requested."));
|
|
3812
|
+
}
|
|
3653
3813
|
const requestId = this.nextRequestId;
|
|
3654
3814
|
this.nextRequestId += 1;
|
|
3655
3815
|
return new Promise((resolve, reject) => {
|
|
3656
|
-
if (this.lifecycle !== lifecycle) {
|
|
3657
|
-
reject(new Error("Centrifuge session stopped before the credential was requested."));
|
|
3658
|
-
return;
|
|
3659
|
-
}
|
|
3660
3816
|
this.pendingTokenRequests.set(requestId, { resolve, reject });
|
|
3661
3817
|
this.post({
|
|
3662
3818
|
type: CENTRIFUGE_OUTPUT_TYPE.TOKEN_REQUEST,
|