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/index.cjs
CHANGED
|
@@ -738,6 +738,9 @@ var WorkerClusterRuntime = class {
|
|
|
738
738
|
started = false;
|
|
739
739
|
suspended = false;
|
|
740
740
|
lifecycleListening = false;
|
|
741
|
+
/** Invalidates an in-flight pageshow resume when a synchronous onResume
|
|
742
|
+
* callback stops or pauses the cluster before activate() is reached. */
|
|
743
|
+
lifecycleGeneration = 0;
|
|
741
744
|
currentRecord;
|
|
742
745
|
constructor(options) {
|
|
743
746
|
assertClusterOptions(options);
|
|
@@ -774,8 +777,14 @@ var WorkerClusterRuntime = class {
|
|
|
774
777
|
/** Start the cluster: register, listen for lifecycle events, and begin heartbeats. */
|
|
775
778
|
start() {
|
|
776
779
|
if (this.started) return;
|
|
777
|
-
this.
|
|
780
|
+
this.lifecycleGeneration += 1;
|
|
778
781
|
this.addLifecycleListeners();
|
|
782
|
+
if (this.environment.getVisibilityState() === TAB_VISIBILITY.HIDDEN) {
|
|
783
|
+
this.suspended = true;
|
|
784
|
+
this.handlers.onSuspend?.();
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
this.suspended = false;
|
|
779
788
|
this.activate();
|
|
780
789
|
}
|
|
781
790
|
/**
|
|
@@ -786,6 +795,7 @@ var WorkerClusterRuntime = class {
|
|
|
786
795
|
* stop() path where callers expect every Set/Map to be empty afterwards.
|
|
787
796
|
*/
|
|
788
797
|
stop() {
|
|
798
|
+
this.lifecycleGeneration += 1;
|
|
789
799
|
if (!this.started && !this.suspended) return;
|
|
790
800
|
this.pause();
|
|
791
801
|
this.flushStorage();
|
|
@@ -832,7 +842,11 @@ var WorkerClusterRuntime = class {
|
|
|
832
842
|
* to other workers, remove our worker record, and close the channel.
|
|
833
843
|
*/
|
|
834
844
|
pause() {
|
|
835
|
-
|
|
845
|
+
this.lifecycleGeneration += 1;
|
|
846
|
+
if (!this.started) {
|
|
847
|
+
if (this.lifecycleListening) this.suspended = true;
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
836
850
|
this.started = false;
|
|
837
851
|
this.suspended = true;
|
|
838
852
|
if (this.heartbeatHandle !== null) this.environment.clearInterval(this.heartbeatHandle);
|
|
@@ -849,10 +863,12 @@ var WorkerClusterRuntime = class {
|
|
|
849
863
|
const channel = this.channel;
|
|
850
864
|
this.channel = null;
|
|
851
865
|
this.handlers.onSuspend?.();
|
|
852
|
-
if (
|
|
853
|
-
globalThis.setTimeout
|
|
854
|
-
|
|
855
|
-
|
|
866
|
+
if (channel) {
|
|
867
|
+
if (typeof globalThis.setTimeout === "function") {
|
|
868
|
+
globalThis.setTimeout(() => channel.close(), 0);
|
|
869
|
+
} else {
|
|
870
|
+
channel.close();
|
|
871
|
+
}
|
|
856
872
|
}
|
|
857
873
|
}
|
|
858
874
|
/** Update the worker's connection status and persist the change. */
|
|
@@ -1145,8 +1161,10 @@ var WorkerClusterRuntime = class {
|
|
|
1145
1161
|
handlePageHide = () => this.pause();
|
|
1146
1162
|
handlePageShow = () => {
|
|
1147
1163
|
if (!this.suspended) return;
|
|
1164
|
+
const generation = ++this.lifecycleGeneration;
|
|
1148
1165
|
this.suspended = false;
|
|
1149
1166
|
this.handlers.onResume?.();
|
|
1167
|
+
if (generation !== this.lifecycleGeneration) return;
|
|
1150
1168
|
this.activate();
|
|
1151
1169
|
};
|
|
1152
1170
|
handleVisibilityChange = () => {
|
|
@@ -1201,10 +1219,16 @@ var WorkerClusterRuntime = class {
|
|
|
1201
1219
|
if (message.targetWorkerId !== this.workerId) return;
|
|
1202
1220
|
this.rememberTopic(message.topic);
|
|
1203
1221
|
switch (message.action) {
|
|
1204
|
-
case CONTROL_ACTION.SUBSCRIBE:
|
|
1222
|
+
case CONTROL_ACTION.SUBSCRIBE: {
|
|
1223
|
+
const route = this.readRoute(message.topicKey);
|
|
1224
|
+
if (route && route.workerId !== this.workerId) return;
|
|
1225
|
+
if (route?.workerId === this.workerId && route.handoffFromWorkerId !== void 0 && route.confirmedAt === void 0) {
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1205
1228
|
this.assignedTopics.set(message.topicKey, message.topic);
|
|
1206
1229
|
this.confirmRoute(message.topicKey);
|
|
1207
1230
|
break;
|
|
1231
|
+
}
|
|
1208
1232
|
case CONTROL_ACTION.UNSUBSCRIBE:
|
|
1209
1233
|
if (this.releaseHandoffOnUnsubscribe(message)) return;
|
|
1210
1234
|
break;
|
|
@@ -1265,8 +1289,8 @@ var WorkerClusterRuntime = class {
|
|
|
1265
1289
|
}
|
|
1266
1290
|
/**
|
|
1267
1291
|
* Accept a graceful handoff only when the route still points to this worker,
|
|
1268
|
-
* the release comes from the recorded previous owner, and the generation
|
|
1269
|
-
*
|
|
1292
|
+
* the release comes from the recorded previous owner, and the generation
|
|
1293
|
+
* exactly matches ours. Any other ROUTE_RELEASED is stale and dropped.
|
|
1270
1294
|
*/
|
|
1271
1295
|
handleRouteReleasedMessage(message) {
|
|
1272
1296
|
if (message.targetWorkerId !== this.workerId) return;
|
|
@@ -1279,11 +1303,11 @@ var WorkerClusterRuntime = class {
|
|
|
1279
1303
|
}
|
|
1280
1304
|
/** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
|
|
1281
1305
|
* points to us, the release comes from the recorded previous owner, and
|
|
1282
|
-
* the release generation
|
|
1283
|
-
* earlier handoff round
|
|
1284
|
-
*
|
|
1306
|
+
* the release generation exactly matches ours. A delayed ACK from either an
|
|
1307
|
+
* earlier or later handoff round belongs to a different route and must not
|
|
1308
|
+
* confirm the current round. */
|
|
1285
1309
|
isStaleRouteRelease(route, message) {
|
|
1286
|
-
return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation
|
|
1310
|
+
return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation !== route.generation;
|
|
1287
1311
|
}
|
|
1288
1312
|
/** True when an unconfirmed handoff route has been stuck longer than a
|
|
1289
1313
|
* worker TTL. The ACK for a live handoff is posted synchronously with the
|
|
@@ -1910,7 +1934,7 @@ var ReplayManager = class {
|
|
|
1910
1934
|
this.trace = deps.trace;
|
|
1911
1935
|
this.onPersistenceError = deps.onPersistenceError;
|
|
1912
1936
|
this.onDispatchError = deps.onDispatchError;
|
|
1913
|
-
|
|
1937
|
+
void this.requestHydration();
|
|
1914
1938
|
}
|
|
1915
1939
|
deps;
|
|
1916
1940
|
buffers;
|
|
@@ -1929,7 +1953,18 @@ var ReplayManager = class {
|
|
|
1929
1953
|
retryGeneration = 0;
|
|
1930
1954
|
pendingReplayPersistence = [];
|
|
1931
1955
|
persistenceFlushScheduled = false;
|
|
1932
|
-
hydration
|
|
1956
|
+
/** Current hydration operation, if one belongs to the active lifecycle. */
|
|
1957
|
+
hydration = null;
|
|
1958
|
+
/** Invalidates a load from a superseded suspend/reset lifecycle. */
|
|
1959
|
+
hydrationEpoch = 0;
|
|
1960
|
+
/** Whether the active lifecycle has finished (or deliberately skipped) hydration. */
|
|
1961
|
+
hydrationComplete = false;
|
|
1962
|
+
/** A failed load is retried by the next explicit start(), not every replay request. */
|
|
1963
|
+
hydrationFailed = false;
|
|
1964
|
+
/** Mutations that must be applied to a load already in flight. */
|
|
1965
|
+
hydrationClearAll = false;
|
|
1966
|
+
hydrationClearedTopics = /* @__PURE__ */ new Set();
|
|
1967
|
+
hydrationClearBefore = null;
|
|
1933
1968
|
/** Coalesced retention cleanup: the newest cutoff wins while one is running. */
|
|
1934
1969
|
retentionCleanup = null;
|
|
1935
1970
|
retentionCutoff = null;
|
|
@@ -1983,7 +2018,7 @@ var ReplayManager = class {
|
|
|
1983
2018
|
if (!this.buffers) return;
|
|
1984
2019
|
const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
|
|
1985
2020
|
if (this.persistence) {
|
|
1986
|
-
void this.
|
|
2021
|
+
void this.requestHydration().then(() => {
|
|
1987
2022
|
if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
|
|
1988
2023
|
});
|
|
1989
2024
|
return;
|
|
@@ -1995,22 +2030,24 @@ var ReplayManager = class {
|
|
|
1995
2030
|
* clearTopic), and prune durable history. */
|
|
1996
2031
|
onTopicUnsubscribed(topic) {
|
|
1997
2032
|
if (!this.buffers) return;
|
|
2033
|
+
const clearing = this.persistence?.clearTopic ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)) : null;
|
|
2034
|
+
this.hydrationClearedTopics.add(topic);
|
|
1998
2035
|
this.buffers.delete(topic);
|
|
1999
2036
|
this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
|
|
2000
|
-
if (this.
|
|
2001
|
-
void this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)).catch((error) => this.onPersistenceError(error));
|
|
2002
|
-
}
|
|
2037
|
+
if (clearing) void clearing.catch((error) => this.onPersistenceError(error));
|
|
2003
2038
|
}
|
|
2004
2039
|
/** Clear all in-memory replay buffers and, when supported, durable history.
|
|
2005
2040
|
* Reports persistence failures and rethrows, mirroring the public API
|
|
2006
2041
|
* contract that callers can observe a failed clear. */
|
|
2007
2042
|
async clearAll() {
|
|
2008
2043
|
if (!this.buffers) return;
|
|
2044
|
+
const clearing = this.persistence?.clear ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear()) : null;
|
|
2045
|
+
this.hydrationClearAll = true;
|
|
2009
2046
|
this.buffers.clear();
|
|
2010
2047
|
this.pendingReplayPersistence = [];
|
|
2011
|
-
if (
|
|
2048
|
+
if (clearing) {
|
|
2012
2049
|
try {
|
|
2013
|
-
await
|
|
2050
|
+
await clearing;
|
|
2014
2051
|
} catch (error) {
|
|
2015
2052
|
this.onPersistenceError(error);
|
|
2016
2053
|
throw error;
|
|
@@ -2020,11 +2057,13 @@ var ReplayManager = class {
|
|
|
2020
2057
|
/** Clear replay history for one exact topic, including durable storage. */
|
|
2021
2058
|
async clearTopic(topic) {
|
|
2022
2059
|
if (!this.buffers) return;
|
|
2060
|
+
const clearing = this.persistence?.clearTopic ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)) : null;
|
|
2061
|
+
this.hydrationClearedTopics.add(topic);
|
|
2023
2062
|
this.buffers.delete(topic);
|
|
2024
2063
|
this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
|
|
2025
|
-
if (
|
|
2064
|
+
if (clearing) {
|
|
2026
2065
|
try {
|
|
2027
|
-
await
|
|
2066
|
+
await clearing;
|
|
2028
2067
|
} catch (error) {
|
|
2029
2068
|
this.onPersistenceError(error);
|
|
2030
2069
|
throw error;
|
|
@@ -2034,7 +2073,9 @@ var ReplayManager = class {
|
|
|
2034
2073
|
/** Remove replay entries older than an epoch-millisecond cutoff. */
|
|
2035
2074
|
async clearBefore(timestamp) {
|
|
2036
2075
|
if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
|
|
2076
|
+
const clearing = this.persistence?.clearBefore ? this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp)) : null;
|
|
2037
2077
|
if (this.buffers) {
|
|
2078
|
+
this.hydrationClearBefore = this.hydrationClearBefore === null ? timestamp : Math.max(this.hydrationClearBefore, timestamp);
|
|
2038
2079
|
for (const [topic, messages] of this.buffers) {
|
|
2039
2080
|
const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
|
|
2040
2081
|
if (kept.length) this.buffers.set(topic, kept);
|
|
@@ -2044,9 +2085,9 @@ var ReplayManager = class {
|
|
|
2044
2085
|
this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
|
|
2045
2086
|
(message) => message.timestamp === void 0 || message.timestamp >= timestamp
|
|
2046
2087
|
);
|
|
2047
|
-
if (
|
|
2088
|
+
if (clearing) {
|
|
2048
2089
|
try {
|
|
2049
|
-
await
|
|
2090
|
+
await clearing;
|
|
2050
2091
|
} catch (error) {
|
|
2051
2092
|
this.onPersistenceError(error);
|
|
2052
2093
|
throw error;
|
|
@@ -2056,6 +2097,11 @@ var ReplayManager = class {
|
|
|
2056
2097
|
/** Start the periodic retention sweep. No-op when no durable retention
|
|
2057
2098
|
* config makes it necessary. */
|
|
2058
2099
|
start() {
|
|
2100
|
+
if (this.hydrationFailed) {
|
|
2101
|
+
this.hydrationComplete = false;
|
|
2102
|
+
this.hydrationFailed = false;
|
|
2103
|
+
}
|
|
2104
|
+
void this.requestHydration();
|
|
2059
2105
|
if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
|
|
2060
2106
|
this.retentionTimer = setInterval(() => {
|
|
2061
2107
|
this.scheduleRetentionCleanup(this.now() - this.retentionMs);
|
|
@@ -2070,13 +2116,25 @@ var ReplayManager = class {
|
|
|
2070
2116
|
* or stopped bus does not keep hammering the store) and stop the sweep. */
|
|
2071
2117
|
suspend() {
|
|
2072
2118
|
this.retryGeneration += 1;
|
|
2119
|
+
if (this.hydration) {
|
|
2120
|
+
this.hydrationEpoch += 1;
|
|
2121
|
+
this.hydration = null;
|
|
2122
|
+
this.hydrationComplete = false;
|
|
2123
|
+
}
|
|
2073
2124
|
this.pendingReplayPersistence = [];
|
|
2074
2125
|
this.retentionCutoff = null;
|
|
2075
2126
|
this.stop();
|
|
2076
2127
|
}
|
|
2077
|
-
/** Drop all in-memory buffers
|
|
2128
|
+
/** Drop all in-memory buffers and require hydration for the next lifecycle. */
|
|
2078
2129
|
resetBuffers() {
|
|
2079
2130
|
this.buffers?.clear();
|
|
2131
|
+
this.hydrationEpoch += 1;
|
|
2132
|
+
this.hydration = null;
|
|
2133
|
+
this.hydrationComplete = false;
|
|
2134
|
+
this.hydrationFailed = false;
|
|
2135
|
+
this.hydrationClearAll = false;
|
|
2136
|
+
this.hydrationClearedTopics.clear();
|
|
2137
|
+
this.hydrationClearBefore = null;
|
|
2080
2138
|
}
|
|
2081
2139
|
/** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
|
|
2082
2140
|
* payload footprint (same heuristic as adaptive load weighting), computed on
|
|
@@ -2128,38 +2186,78 @@ var ReplayManager = class {
|
|
|
2128
2186
|
void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
|
|
2129
2187
|
});
|
|
2130
2188
|
}
|
|
2131
|
-
/**
|
|
2189
|
+
/** Start the active lifecycle's one-shot hydration, if it has not completed
|
|
2190
|
+
* or deliberately skipped hydration already. */
|
|
2191
|
+
requestHydration() {
|
|
2192
|
+
if (!this.buffers || !this.persistence) {
|
|
2193
|
+
this.hydrationComplete = true;
|
|
2194
|
+
return Promise.resolve();
|
|
2195
|
+
}
|
|
2196
|
+
if (this.hydration) return this.hydration;
|
|
2197
|
+
if (this.hydrationComplete) return Promise.resolve();
|
|
2198
|
+
const epoch = this.hydrationEpoch;
|
|
2199
|
+
const generation = this.retryGeneration;
|
|
2200
|
+
const operation = this.hydrate(epoch, generation);
|
|
2201
|
+
this.hydration = operation;
|
|
2202
|
+
return operation;
|
|
2203
|
+
}
|
|
2204
|
+
/** Load durable history into the in-memory rings once per lifecycle, pruning
|
|
2132
2205
|
* entries past the retention window first. Failures are reported but do not
|
|
2133
2206
|
* block startup — the bus runs with whatever survived. */
|
|
2134
|
-
async hydrate() {
|
|
2207
|
+
async hydrate(epoch, generation) {
|
|
2135
2208
|
if (!this.buffers || !this.persistence) {
|
|
2136
2209
|
return;
|
|
2137
2210
|
}
|
|
2138
2211
|
try {
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
if (
|
|
2146
|
-
|
|
2147
|
-
|
|
2212
|
+
try {
|
|
2213
|
+
if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
|
|
2214
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
|
|
2215
|
+
}
|
|
2216
|
+
const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
|
|
2217
|
+
if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
|
|
2218
|
+
if (epoch !== this.hydrationEpoch) return;
|
|
2219
|
+
const loadedByTopic = /* @__PURE__ */ new Map();
|
|
2220
|
+
for (const message of loaded) {
|
|
2221
|
+
if (this.hydrationClearAll || this.hydrationClearedTopics.has(message.topic) || this.hydrationClearBefore !== null && message.timestamp !== void 0 && message.timestamp < this.hydrationClearBefore) continue;
|
|
2222
|
+
let buffer = loadedByTopic.get(message.topic);
|
|
2223
|
+
if (!buffer) {
|
|
2224
|
+
buffer = [];
|
|
2225
|
+
loadedByTopic.set(message.topic, buffer);
|
|
2226
|
+
}
|
|
2227
|
+
buffer.push(message);
|
|
2148
2228
|
}
|
|
2149
|
-
|
|
2229
|
+
for (const [topic, durableBuffer] of loadedByTopic) {
|
|
2230
|
+
const liveBuffer = this.buffers.get(topic);
|
|
2231
|
+
this.buffers.set(topic, liveBuffer ? [...durableBuffer, ...liveBuffer] : durableBuffer);
|
|
2232
|
+
}
|
|
2233
|
+
const hydrationNow = this.now();
|
|
2234
|
+
for (const [topic, buffer] of this.buffers) {
|
|
2235
|
+
const pruned = pruneReplayHistory(buffer, {
|
|
2236
|
+
maxPerTopic: this.maxPerTopic,
|
|
2237
|
+
pruneStrategy: this.pruneStrategy,
|
|
2238
|
+
retentionMs: this.retentionMs,
|
|
2239
|
+
now: hydrationNow
|
|
2240
|
+
});
|
|
2241
|
+
if (pruned !== buffer) this.buffers.set(topic, pruned);
|
|
2242
|
+
}
|
|
2243
|
+
this.hydrationComplete = true;
|
|
2244
|
+
this.hydrationFailed = false;
|
|
2245
|
+
} catch (error) {
|
|
2246
|
+
if (generation !== this.retryGeneration) {
|
|
2247
|
+
this.onPersistenceError(
|
|
2248
|
+
error instanceof PersistenceRetryCancelledError ? error : new PersistenceRetryCancelledError()
|
|
2249
|
+
);
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
if (epoch !== this.hydrationEpoch) return;
|
|
2253
|
+
this.onPersistenceError(error);
|
|
2254
|
+
this.hydrationComplete = true;
|
|
2255
|
+
this.hydrationFailed = true;
|
|
2150
2256
|
}
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
maxPerTopic: this.maxPerTopic,
|
|
2155
|
-
pruneStrategy: this.pruneStrategy,
|
|
2156
|
-
retentionMs: this.retentionMs,
|
|
2157
|
-
now: hydrationNow
|
|
2158
|
-
});
|
|
2159
|
-
if (pruned !== buffer) this.buffers.set(topic, pruned);
|
|
2257
|
+
} finally {
|
|
2258
|
+
if (this.hydrationEpoch === epoch && this.retryGeneration === generation) {
|
|
2259
|
+
this.hydration = null;
|
|
2160
2260
|
}
|
|
2161
|
-
} catch (error) {
|
|
2162
|
-
this.onPersistenceError(error);
|
|
2163
2261
|
}
|
|
2164
2262
|
}
|
|
2165
2263
|
/** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
|
|
@@ -2183,7 +2281,7 @@ var ReplayManager = class {
|
|
|
2183
2281
|
}
|
|
2184
2282
|
})().finally(() => {
|
|
2185
2283
|
this.retentionCleanup = null;
|
|
2186
|
-
if (this.retentionCutoff !== null
|
|
2284
|
+
if (this.retentionCutoff !== null) {
|
|
2187
2285
|
this.scheduleRetentionCleanup(this.retentionCutoff);
|
|
2188
2286
|
}
|
|
2189
2287
|
});
|
|
@@ -2336,7 +2434,7 @@ var DedupManager = class {
|
|
|
2336
2434
|
};
|
|
2337
2435
|
|
|
2338
2436
|
// src/core/version.ts
|
|
2339
|
-
var SDK_VERSION = true ? "0.20.
|
|
2437
|
+
var SDK_VERSION = true ? "0.20.92" : "";
|
|
2340
2438
|
|
|
2341
2439
|
// src/core/data-bus.ts
|
|
2342
2440
|
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
@@ -2412,11 +2510,21 @@ var CrossTabDataBus = class {
|
|
|
2412
2510
|
recoveryGateRelease = null;
|
|
2413
2511
|
recoveryTimer = null;
|
|
2414
2512
|
recoveryTimerToken = 0;
|
|
2513
|
+
// Invalidates operations parked on the recovery gate when a hide/stop
|
|
2514
|
+
// supersedes the recovery cycle. Their microtask may run after an immediate
|
|
2515
|
+
// explicit start has cleared `suspended`, so a state check alone is not
|
|
2516
|
+
// enough to keep stale work from reaching the replacement transport.
|
|
2517
|
+
recoveryCancellationToken = 0;
|
|
2415
2518
|
// Once an automatic attempt fails, an explicit transport operation may
|
|
2416
2519
|
// recover immediately instead of waiting for the next paced attempt. The
|
|
2417
2520
|
// gate still stays closed so the operation cannot reach the failed
|
|
2418
2521
|
// transport; it is released by the successful on-demand reopen.
|
|
2419
2522
|
recoveryDemandAllowed = false;
|
|
2523
|
+
// Number of transport operations currently parked behind `recoveryGate`.
|
|
2524
|
+
// When an automatic attempt fails, these already-parked operations are
|
|
2525
|
+
// themselves demand: the failure path starts an on-demand reopen instead of
|
|
2526
|
+
// stranding them until some unrelated future operation arrives.
|
|
2527
|
+
recoveryWaiters = 0;
|
|
2420
2528
|
/** Monotonic generation incremented on every successful transport open.
|
|
2421
2529
|
* Stays in lockstep with `lastSuccessAt` so callers can detect that the
|
|
2422
2530
|
* transport has been reopened even if the timestamp window is short. */
|
|
@@ -2518,6 +2626,7 @@ var CrossTabDataBus = class {
|
|
|
2518
2626
|
// own broadcastEvent call, which always posts a DataBusMessage.
|
|
2519
2627
|
onEvent: (eventType, payload, _sourceWorkerId, originTabId) => {
|
|
2520
2628
|
if (eventType !== PUBLICATION_EVENT) return;
|
|
2629
|
+
if (typeof payload !== "object" || payload === null || typeof payload.topic !== "string") return;
|
|
2521
2630
|
const incoming = payload;
|
|
2522
2631
|
const message = incoming.originTabId !== void 0 ? incoming : originTabId !== void 0 ? { ...incoming, originTabId } : incoming;
|
|
2523
2632
|
if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
|
|
@@ -2530,7 +2639,7 @@ var CrossTabDataBus = class {
|
|
|
2530
2639
|
this.suspendTransport();
|
|
2531
2640
|
},
|
|
2532
2641
|
onResume: () => {
|
|
2533
|
-
this.resumeSuspendedResources();
|
|
2642
|
+
if (!this.resumeSuspendedResources()) return;
|
|
2534
2643
|
this.resumeTransport();
|
|
2535
2644
|
},
|
|
2536
2645
|
onDiagnostic: (event) => {
|
|
@@ -2559,11 +2668,13 @@ var CrossTabDataBus = class {
|
|
|
2559
2668
|
const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
|
|
2560
2669
|
if (!transportDown) return Promise.resolve();
|
|
2561
2670
|
this.activeConfig = config;
|
|
2562
|
-
this.resetFailureState();
|
|
2671
|
+
this.resetFailureState(true);
|
|
2563
2672
|
const resumingFromSuspend = this.suspended;
|
|
2564
|
-
if (resumingFromSuspend
|
|
2673
|
+
if (resumingFromSuspend && !this.resumeSuspendedResources()) {
|
|
2674
|
+
return this.stopPromise ?? Promise.resolve();
|
|
2675
|
+
}
|
|
2565
2676
|
const opening2 = this.reopenTransport();
|
|
2566
|
-
if (resumingFromSuspend) this.cluster.start();
|
|
2677
|
+
if (resumingFromSuspend && !this.stopping && !this.suspended) this.cluster.start();
|
|
2567
2678
|
return opening2;
|
|
2568
2679
|
}
|
|
2569
2680
|
this.started = true;
|
|
@@ -2571,12 +2682,6 @@ var CrossTabDataBus = class {
|
|
|
2571
2682
|
this.suspended = false;
|
|
2572
2683
|
this.activeConfig = config;
|
|
2573
2684
|
this.resetFailureState();
|
|
2574
|
-
this.trace.start();
|
|
2575
|
-
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
|
|
2576
|
-
this.startDedupSweep();
|
|
2577
|
-
this.replayManager.start();
|
|
2578
|
-
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
2579
|
-
this.cluster.start();
|
|
2580
2685
|
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
2581
2686
|
const opening = this.openTransport(
|
|
2582
2687
|
config,
|
|
@@ -2585,7 +2690,17 @@ var CrossTabDataBus = class {
|
|
|
2585
2690
|
lifecycleEpoch
|
|
2586
2691
|
);
|
|
2587
2692
|
this.startPromise = opening;
|
|
2693
|
+
this.trace.start();
|
|
2694
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
|
|
2695
|
+
if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
|
|
2696
|
+
this.startDedupSweep();
|
|
2697
|
+
this.replayManager.start();
|
|
2698
|
+
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
2699
|
+
if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
|
|
2700
|
+
this.cluster.start();
|
|
2701
|
+
if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return opening;
|
|
2588
2702
|
for (const topic of this.topicHandlers.keys()) {
|
|
2703
|
+
if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) break;
|
|
2589
2704
|
this.cluster.subscribe(topic);
|
|
2590
2705
|
}
|
|
2591
2706
|
void opening.then(
|
|
@@ -2647,27 +2762,49 @@ var CrossTabDataBus = class {
|
|
|
2647
2762
|
/** Cancel a pending automatic retry when an explicit lifecycle transition
|
|
2648
2763
|
* supersedes it. The released gate re-enters runTransport(), which then
|
|
2649
2764
|
* follows the newest start/stop/suspend intent. */
|
|
2650
|
-
cancelScheduledRecovery() {
|
|
2765
|
+
cancelScheduledRecovery(invalidateParkedOperations = false, releaseGate = true) {
|
|
2651
2766
|
this.recoveryTimerToken += 1;
|
|
2767
|
+
if (invalidateParkedOperations) this.recoveryCancellationToken += 1;
|
|
2652
2768
|
if (this.recoveryTimer !== null) {
|
|
2653
2769
|
clearTimeout(this.recoveryTimer);
|
|
2654
2770
|
this.recoveryTimer = null;
|
|
2655
2771
|
}
|
|
2656
|
-
this.releaseRecoveryGate();
|
|
2772
|
+
if (releaseGate) this.releaseRecoveryGate();
|
|
2657
2773
|
}
|
|
2658
2774
|
/** Keep the recovery gate closed after a failed attempt while allowing the
|
|
2659
2775
|
* next explicit transport operation to start an immediate on-demand reopen.
|
|
2660
|
-
* If no gate/successor retry remains, release any waiters.
|
|
2661
|
-
|
|
2776
|
+
* If no gate/successor retry remains, release any waiters.
|
|
2777
|
+
*
|
|
2778
|
+
* `kickParkedWaiters` is set only when the failure is an automatic attempt: an
|
|
2779
|
+
* operation that was already parked on the gate is itself demand, so it must
|
|
2780
|
+
* not wait for some unrelated future operation. A failed *on-demand* reopen
|
|
2781
|
+
* passes `false`, so it re-arms the flag for a later operation instead of
|
|
2782
|
+
* looping on its own failure. */
|
|
2783
|
+
allowDemandRecovery(kickParkedWaiters = false) {
|
|
2662
2784
|
if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
|
|
2663
2785
|
this.recoveryDemandAllowed = true;
|
|
2786
|
+
if (kickParkedWaiters && this.recoveryWaiters > 0) this.startDemandRecovery();
|
|
2664
2787
|
return;
|
|
2665
2788
|
}
|
|
2666
2789
|
this.releaseRecoveryGate();
|
|
2667
2790
|
}
|
|
2791
|
+
/** Start one on-demand reopen if a failed attempt has left parked operations
|
|
2792
|
+
* and enabled demand recovery. Consumes the demand token so at most one
|
|
2793
|
+
* reopen is issued; every waiter stays behind the gate until it succeeds. */
|
|
2794
|
+
startDemandRecovery() {
|
|
2795
|
+
if (!this.recoveryDemandAllowed) return;
|
|
2796
|
+
if (this.status !== WORKER_STATUS.ERROR || this.suspended || this.stopping) return;
|
|
2797
|
+
this.recoveryDemandAllowed = false;
|
|
2798
|
+
const opening = this.reopenTransport();
|
|
2799
|
+
void opening.then(
|
|
2800
|
+
() => this.releaseRecoveryGate(),
|
|
2801
|
+
() => this.allowDemandRecovery()
|
|
2802
|
+
);
|
|
2803
|
+
}
|
|
2668
2804
|
/** Reset failure and recovery diagnostics for a new explicit start session. */
|
|
2669
|
-
resetFailureState() {
|
|
2670
|
-
this.cancelScheduledRecovery();
|
|
2805
|
+
resetFailureState(preserveRecoveryGate = false) {
|
|
2806
|
+
this.cancelScheduledRecovery(false, !preserveRecoveryGate);
|
|
2807
|
+
if (preserveRecoveryGate) this.recoveryDemandAllowed = false;
|
|
2671
2808
|
this.lastError = null;
|
|
2672
2809
|
this.lastErrorAt = null;
|
|
2673
2810
|
this.lastFailure = null;
|
|
@@ -3007,22 +3144,36 @@ var CrossTabDataBus = class {
|
|
|
3007
3144
|
if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
|
|
3008
3145
|
return Promise.resolve();
|
|
3009
3146
|
}
|
|
3147
|
+
let resolveGate;
|
|
3148
|
+
const stopGate = new Promise((resolve) => {
|
|
3149
|
+
resolveGate = resolve;
|
|
3150
|
+
});
|
|
3151
|
+
this.stopPromise = stopGate;
|
|
3152
|
+
this.beginStop();
|
|
3010
3153
|
const stopPromise = this.performStop();
|
|
3011
|
-
this.stopPromise = stopPromise;
|
|
3012
3154
|
void stopPromise.then(
|
|
3013
3155
|
() => {
|
|
3014
|
-
if (this.stopPromise ===
|
|
3156
|
+
if (this.stopPromise === stopGate) this.stopPromise = null;
|
|
3157
|
+
resolveGate();
|
|
3015
3158
|
},
|
|
3016
3159
|
() => {
|
|
3017
|
-
if (this.stopPromise ===
|
|
3160
|
+
if (this.stopPromise === stopGate) this.stopPromise = null;
|
|
3161
|
+
resolveGate();
|
|
3018
3162
|
}
|
|
3019
3163
|
);
|
|
3020
|
-
return
|
|
3164
|
+
return stopGate;
|
|
3021
3165
|
}
|
|
3022
|
-
|
|
3166
|
+
/**
|
|
3167
|
+
* Synchronous teardown prelude. Flips `stopping` (the authoritative
|
|
3168
|
+
* in-flight signal), cancels scheduled work, releases handlers, and emits the
|
|
3169
|
+
* observable STOP lifecycle event. stop() calls it after the shared gate is
|
|
3170
|
+
* installed but in the same tick, so the stop still takes effect immediately
|
|
3171
|
+
* while a re-entrant stop() from the STOP event shares the one teardown.
|
|
3172
|
+
*/
|
|
3173
|
+
beginStop() {
|
|
3023
3174
|
this.lifecycleEpoch += 1;
|
|
3024
3175
|
this.stopping = true;
|
|
3025
|
-
this.cancelScheduledRecovery();
|
|
3176
|
+
this.cancelScheduledRecovery(true);
|
|
3026
3177
|
this.replayManager.suspend();
|
|
3027
3178
|
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
|
|
3028
3179
|
this.trace.stop();
|
|
@@ -3030,6 +3181,8 @@ var CrossTabDataBus = class {
|
|
|
3030
3181
|
this.topicHandlers.clear();
|
|
3031
3182
|
this.replayManager.resetBuffers();
|
|
3032
3183
|
this.cluster.stop();
|
|
3184
|
+
}
|
|
3185
|
+
async performStop() {
|
|
3033
3186
|
try {
|
|
3034
3187
|
await this.startPromise?.catch(() => void 0);
|
|
3035
3188
|
const pendingStop = this.pendingStop;
|
|
@@ -3046,8 +3199,6 @@ var CrossTabDataBus = class {
|
|
|
3046
3199
|
this.transportReady = false;
|
|
3047
3200
|
this.startPromise = null;
|
|
3048
3201
|
this.pendingStop = null;
|
|
3049
|
-
this.lastError = null;
|
|
3050
|
-
this.lastErrorAt = null;
|
|
3051
3202
|
this.activeConfig = void 0;
|
|
3052
3203
|
this.recoveryAttempt = 0;
|
|
3053
3204
|
this.recoveryExhausted = false;
|
|
@@ -3144,7 +3295,9 @@ var CrossTabDataBus = class {
|
|
|
3144
3295
|
const opening = this.reopenTransport(attempt);
|
|
3145
3296
|
void opening.then(
|
|
3146
3297
|
() => this.releaseRecoveryGate(),
|
|
3147
|
-
|
|
3298
|
+
// Operations already parked on the gate are demand: run one
|
|
3299
|
+
// on-demand reopen now instead of waiting for an unrelated event.
|
|
3300
|
+
() => this.allowDemandRecovery(true)
|
|
3148
3301
|
);
|
|
3149
3302
|
}, this.recoveryCooldownMs);
|
|
3150
3303
|
}
|
|
@@ -3247,10 +3400,13 @@ var CrossTabDataBus = class {
|
|
|
3247
3400
|
* pageshow path and explicit start() must run this so an explicit resume
|
|
3248
3401
|
* cannot leave trace metrics and periodic cleanup timers permanently off. */
|
|
3249
3402
|
resumeSuspendedResources() {
|
|
3403
|
+
const lifecycleEpoch = this.lifecycleEpoch;
|
|
3250
3404
|
this.trace.start();
|
|
3251
3405
|
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
|
|
3406
|
+
if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping) return false;
|
|
3252
3407
|
this.startDedupSweep();
|
|
3253
3408
|
this.replayManager.start();
|
|
3409
|
+
return lifecycleEpoch === this.lifecycleEpoch && !this.stopping;
|
|
3254
3410
|
}
|
|
3255
3411
|
/**
|
|
3256
3412
|
* Suspend the transport when the tab goes hidden. Stops the transport and
|
|
@@ -3258,12 +3414,13 @@ var CrossTabDataBus = class {
|
|
|
3258
3414
|
*/
|
|
3259
3415
|
suspendTransport() {
|
|
3260
3416
|
if (this.stopping) return;
|
|
3261
|
-
this.lifecycleEpoch
|
|
3417
|
+
const suspensionEpoch = ++this.lifecycleEpoch;
|
|
3262
3418
|
this.suspended = true;
|
|
3263
|
-
this.cancelScheduledRecovery();
|
|
3419
|
+
this.cancelScheduledRecovery(true);
|
|
3264
3420
|
this.transportReady = false;
|
|
3265
3421
|
this.transportSubscribedTopics.clear();
|
|
3266
3422
|
this.updateStatus(WORKER_STATUS.DISCONNECTED);
|
|
3423
|
+
if (suspensionEpoch !== this.lifecycleEpoch || this.stopping || !this.suspended) return;
|
|
3267
3424
|
if (this.pendingStop && (this.startPromise === null || this.startPromise === this.pendingStop)) {
|
|
3268
3425
|
this.startPromise = this.pendingStop;
|
|
3269
3426
|
return;
|
|
@@ -3298,13 +3455,18 @@ var CrossTabDataBus = class {
|
|
|
3298
3455
|
if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
|
|
3299
3456
|
const config = this.activeConfig;
|
|
3300
3457
|
const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
|
|
3301
|
-
this.started = true;
|
|
3302
|
-
this.suspended = false;
|
|
3303
|
-
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
3304
3458
|
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
3305
3459
|
const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
|
|
3306
3460
|
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
|
|
3307
3461
|
this.startPromise = opening;
|
|
3462
|
+
this.started = true;
|
|
3463
|
+
this.suspended = false;
|
|
3464
|
+
this.transportReady = false;
|
|
3465
|
+
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
3466
|
+
if (lifecycleEpoch !== this.lifecycleEpoch || this.stopping || this.suspended) {
|
|
3467
|
+
void opening.catch(() => void 0);
|
|
3468
|
+
return opening;
|
|
3469
|
+
}
|
|
3308
3470
|
void opening.then(
|
|
3309
3471
|
() => {
|
|
3310
3472
|
if (this.startPromise === opening) this.startPromise = null;
|
|
@@ -3334,17 +3496,13 @@ var CrossTabDataBus = class {
|
|
|
3334
3496
|
runTransport(operation) {
|
|
3335
3497
|
if (this.suspended) return;
|
|
3336
3498
|
if (this.recoveryGate && !this.stopping) {
|
|
3337
|
-
if (this.recoveryDemandAllowed && this.status === WORKER_STATUS.ERROR && !this.suspended) {
|
|
3338
|
-
this.recoveryDemandAllowed = false;
|
|
3339
|
-
const opening = this.reopenTransport();
|
|
3340
|
-
void opening.then(
|
|
3341
|
-
() => this.releaseRecoveryGate(),
|
|
3342
|
-
() => this.allowDemandRecovery()
|
|
3343
|
-
);
|
|
3344
|
-
}
|
|
3345
3499
|
const gate = this.recoveryGate;
|
|
3500
|
+
const cancellationToken = this.recoveryCancellationToken;
|
|
3501
|
+
this.startDemandRecovery();
|
|
3502
|
+
this.recoveryWaiters += 1;
|
|
3346
3503
|
void gate.then(() => {
|
|
3347
|
-
|
|
3504
|
+
this.recoveryWaiters -= 1;
|
|
3505
|
+
if (this.stopping || this.suspended || cancellationToken !== this.recoveryCancellationToken) return;
|
|
3348
3506
|
this.runTransport(operation);
|
|
3349
3507
|
});
|
|
3350
3508
|
return;
|
|
@@ -3769,7 +3927,7 @@ var WebSocketTransport = class {
|
|
|
3769
3927
|
handlers.onStatus(WORKER_STATUS.ERROR);
|
|
3770
3928
|
handlers.onError(error);
|
|
3771
3929
|
this.failConnect(error);
|
|
3772
|
-
|
|
3930
|
+
this.abortSocket(socket);
|
|
3773
3931
|
}, timeoutMs);
|
|
3774
3932
|
}
|
|
3775
3933
|
socket.onopen = () => {
|
|
@@ -3802,6 +3960,7 @@ var WebSocketTransport = class {
|
|
|
3802
3960
|
handshakeFailed = true;
|
|
3803
3961
|
this.failConnect(new Error("WebSocket failed to open."));
|
|
3804
3962
|
}
|
|
3963
|
+
this.abortSocket(socket);
|
|
3805
3964
|
};
|
|
3806
3965
|
socket.onmessage = (event) => {
|
|
3807
3966
|
if (this.socket === socket && this.handlers === handlers && this.socketActive) {
|
|
@@ -3895,6 +4054,14 @@ var WebSocketTransport = class {
|
|
|
3895
4054
|
this.connectReject = null;
|
|
3896
4055
|
reject?.(error);
|
|
3897
4056
|
}
|
|
4057
|
+
/** Best-effort close for a socket that can no longer carry transport data.
|
|
4058
|
+
* The error that invalidated it has already been reported by the caller. */
|
|
4059
|
+
abortSocket(socket) {
|
|
4060
|
+
try {
|
|
4061
|
+
socket.close();
|
|
4062
|
+
} catch {
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
3898
4065
|
clearConnectTimer() {
|
|
3899
4066
|
if (this.connectTimer !== null) {
|
|
3900
4067
|
clearTimeout(this.connectTimer);
|