cross-tab-worker-databus 0.20.85 → 0.20.87
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 +29 -0
- package/dist/centrifuge.js +1 -1
- package/dist/{chunk-PW63EWIK.js → chunk-ZNHJ5OMY.js} +356 -54
- package/dist/{chunk-PW63EWIK.js.map → chunk-ZNHJ5OMY.js.map} +3 -3
- package/dist/cjs/centrifuge.cjs +354 -53
- package/dist/cjs/centrifuge.cjs.map +3 -3
- package/dist/cjs/hooks.cjs +2 -2
- package/dist/cjs/hooks.cjs.map +2 -2
- package/dist/cjs/index.cjs +530 -112
- package/dist/cjs/index.cjs.map +3 -3
- package/dist/cjs/vue.cjs +1 -1
- package/dist/cjs/vue.cjs.map +2 -2
- package/dist/core/data-bus.d.ts +71 -15
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/core/replay-manager.d.ts +3 -2
- package/dist/core/replay-manager.d.ts.map +1 -1
- package/dist/core/replay-persistence.d.ts.map +1 -1
- package/dist/core/replay-pruning.d.ts +21 -0
- package/dist/core/replay-pruning.d.ts.map +1 -0
- package/dist/hooks.d.ts +2 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +2 -2
- package/dist/hooks.js.map +2 -2
- package/dist/index.js +178 -60
- package/dist/index.js.map +2 -2
- package/dist/vue.d.ts +2 -1
- package/dist/vue.d.ts.map +1 -1
- package/dist/vue.js +1 -1
- package/dist/vue.js.map +2 -2
- package/dist/websocket.d.ts +24 -2
- package/dist/websocket.d.ts.map +1 -1
- package/docs/api.md +27 -11
- package/docs/architecture.md +18 -3
- package/docs/benchmarks.md +8 -8
- package/docs/configuration.md +2 -2
- package/docs/roadmap.md +15 -1
- package/docs/transports.md +19 -2
- package/docs/zh/api.md +27 -11
- package/docs/zh/architecture.md +18 -3
- package/docs/zh/benchmarks.md +8 -8
- package/docs/zh/configuration.md +2 -2
- package/docs/zh/roadmap.md +15 -1
- package/docs/zh/transports.md +14 -2
- package/package.json +4 -4
|
@@ -1729,6 +1729,36 @@ function roundMs(value) {
|
|
|
1729
1729
|
return Math.round(value * 10) / 10;
|
|
1730
1730
|
}
|
|
1731
1731
|
|
|
1732
|
+
// src/core/replay-pruning.ts
|
|
1733
|
+
function pruneReplayHistory(messages, options) {
|
|
1734
|
+
const { maxPerTopic, pruneStrategy, retentionMs, now } = options;
|
|
1735
|
+
const ageEnabled = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
|
|
1736
|
+
if (!ageEnabled) {
|
|
1737
|
+
return messages.length > maxPerTopic ? messages.slice(-maxPerTopic) : messages;
|
|
1738
|
+
}
|
|
1739
|
+
const cutoff = now - retentionMs;
|
|
1740
|
+
let hasExpired = false;
|
|
1741
|
+
let timestamplessCount = 0;
|
|
1742
|
+
for (const message of messages) {
|
|
1743
|
+
if (message.timestamp === void 0) timestamplessCount += 1;
|
|
1744
|
+
else if (message.timestamp < cutoff) hasExpired = true;
|
|
1745
|
+
}
|
|
1746
|
+
let pruned = hasExpired ? messages.filter((message) => message.timestamp === void 0 || message.timestamp >= cutoff) : messages;
|
|
1747
|
+
if (pruneStrategy === PRUNE_STRATEGY.BOTH) {
|
|
1748
|
+
return pruned.length > maxPerTopic ? pruned.slice(-maxPerTopic) : pruned;
|
|
1749
|
+
}
|
|
1750
|
+
if (timestamplessCount <= maxPerTopic) return pruned;
|
|
1751
|
+
let timestamplessToDrop = timestamplessCount - maxPerTopic;
|
|
1752
|
+
pruned = pruned.filter((message) => {
|
|
1753
|
+
if (message.timestamp === void 0 && timestamplessToDrop > 0) {
|
|
1754
|
+
timestamplessToDrop -= 1;
|
|
1755
|
+
return false;
|
|
1756
|
+
}
|
|
1757
|
+
return true;
|
|
1758
|
+
});
|
|
1759
|
+
return pruned;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1732
1762
|
// src/core/replay-manager.ts
|
|
1733
1763
|
var PersistenceRetryCancelledError = class extends Error {
|
|
1734
1764
|
constructor() {
|
|
@@ -1790,17 +1820,15 @@ var ReplayManager = class {
|
|
|
1790
1820
|
this.buffers.set(message.topic, buffer);
|
|
1791
1821
|
}
|
|
1792
1822
|
buffer.push(message);
|
|
1793
|
-
const
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
buffer.shift();
|
|
1803
|
-
}
|
|
1823
|
+
const pruned = pruneReplayHistory(buffer, {
|
|
1824
|
+
maxPerTopic: this.maxPerTopic,
|
|
1825
|
+
pruneStrategy: this.pruneStrategy,
|
|
1826
|
+
retentionMs: this.retentionMs,
|
|
1827
|
+
now: this.now()
|
|
1828
|
+
});
|
|
1829
|
+
if (pruned !== buffer) {
|
|
1830
|
+
buffer = pruned;
|
|
1831
|
+
this.buffers.set(message.topic, buffer);
|
|
1804
1832
|
}
|
|
1805
1833
|
if (!this.persistence) return;
|
|
1806
1834
|
if (this.persistence.appendBatch) {
|
|
@@ -1981,14 +2009,24 @@ var ReplayManager = class {
|
|
|
1981
2009
|
if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
|
|
1982
2010
|
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
|
|
1983
2011
|
}
|
|
1984
|
-
|
|
2012
|
+
const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
|
|
2013
|
+
for (const message of loaded) {
|
|
1985
2014
|
let buffer = this.buffers.get(message.topic);
|
|
1986
2015
|
if (!buffer) {
|
|
1987
2016
|
buffer = [];
|
|
1988
2017
|
this.buffers.set(message.topic, buffer);
|
|
1989
2018
|
}
|
|
1990
2019
|
buffer.push(message);
|
|
1991
|
-
|
|
2020
|
+
}
|
|
2021
|
+
const hydrationNow = this.now();
|
|
2022
|
+
for (const [topic, buffer] of this.buffers) {
|
|
2023
|
+
const pruned = pruneReplayHistory(buffer, {
|
|
2024
|
+
maxPerTopic: this.maxPerTopic,
|
|
2025
|
+
pruneStrategy: this.pruneStrategy,
|
|
2026
|
+
retentionMs: this.retentionMs,
|
|
2027
|
+
now: hydrationNow
|
|
2028
|
+
});
|
|
2029
|
+
if (pruned !== buffer) this.buffers.set(topic, pruned);
|
|
1992
2030
|
}
|
|
1993
2031
|
} catch (error) {
|
|
1994
2032
|
this.onPersistenceError(error);
|
|
@@ -2165,7 +2203,7 @@ var DedupManager = class {
|
|
|
2165
2203
|
};
|
|
2166
2204
|
|
|
2167
2205
|
// src/core/version.ts
|
|
2168
|
-
var SDK_VERSION = true ? "0.20.
|
|
2206
|
+
var SDK_VERSION = true ? "0.20.87" : "";
|
|
2169
2207
|
|
|
2170
2208
|
// src/core/data-bus.ts
|
|
2171
2209
|
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
@@ -2190,6 +2228,11 @@ var CrossTabDataBus = class {
|
|
|
2190
2228
|
started = false;
|
|
2191
2229
|
stopping = false;
|
|
2192
2230
|
transportReady = false;
|
|
2231
|
+
// Whether the installed transport has reported `connected` at least once
|
|
2232
|
+
// since the current open began. A clean `disconnected` after this point is
|
|
2233
|
+
// a lost working connection, not the pre-connect window of a worker-style
|
|
2234
|
+
// backend whose start() resolves before it reports the connection.
|
|
2235
|
+
transportHasConnected = false;
|
|
2193
2236
|
// Last transport failure, retained so ready() can surface it to callers who
|
|
2194
2237
|
// never awaited start() directly. Cleared on the next successful start.
|
|
2195
2238
|
lastError = null;
|
|
@@ -2203,6 +2246,24 @@ var CrossTabDataBus = class {
|
|
|
2203
2246
|
// Gate that serialises start/stop/suspend/resume — only one lifecycle
|
|
2204
2247
|
// transition at a time. Resets to null once the operation settles.
|
|
2205
2248
|
startPromise = null;
|
|
2249
|
+
// Gate for an explicit stop(). Concurrent stop() calls share it, and a
|
|
2250
|
+
// start() received while stopping chains a fresh start after it.
|
|
2251
|
+
stopPromise = null;
|
|
2252
|
+
// A start() requested while an explicit stop() is still settling. Kept
|
|
2253
|
+
// separate from startPromise because stop()'s finally block clears the
|
|
2254
|
+
// ordinary lifecycle gate before the queued start is allowed to run.
|
|
2255
|
+
queuedStart = null;
|
|
2256
|
+
// Lazy readiness view of queuedStart. start() keeps its documented
|
|
2257
|
+
// resolve-on-cancellation contract, while ready() must reject when the
|
|
2258
|
+
// queued intent was superseded by a later stop().
|
|
2259
|
+
queuedStartReady = null;
|
|
2260
|
+
queuedStartReadyToken = 0;
|
|
2261
|
+
// The queued continuation is chained to the stop promise and cannot be
|
|
2262
|
+
// un-scheduled once scheduled. A later stop() therefore invalidates the
|
|
2263
|
+
// current intent by recording its token; a subsequent start() issues a
|
|
2264
|
+
// higher token so the latest lifecycle request still wins.
|
|
2265
|
+
queuedStartToken = 0;
|
|
2266
|
+
canceledQueuedStartToken = 0;
|
|
2206
2267
|
// Timestamp of the last automatic transport recovery attempt.
|
|
2207
2268
|
// Used to avoid a tight retry loop when the transport fails repeatedly.
|
|
2208
2269
|
lastRecoveryAt = 0;
|
|
@@ -2210,6 +2271,19 @@ var CrossTabDataBus = class {
|
|
|
2210
2271
|
// a transport reopen succeeds so traces can correlate repeated failures.
|
|
2211
2272
|
recoveryAttempt = 0;
|
|
2212
2273
|
recoveryExhausted = false;
|
|
2274
|
+
// Gate that holds transport operations issued after a runtime `error` until
|
|
2275
|
+
// the scheduled recovery attempt has actually run. Without it, a dead
|
|
2276
|
+
// transport still has `transportReady === true` during the cooldown, so
|
|
2277
|
+
// publishes/subscribes would be written to the failed connection and lost.
|
|
2278
|
+
recoveryGate = null;
|
|
2279
|
+
recoveryGateRelease = null;
|
|
2280
|
+
recoveryTimer = null;
|
|
2281
|
+
recoveryTimerToken = 0;
|
|
2282
|
+
// Once an automatic attempt fails, an explicit transport operation may
|
|
2283
|
+
// recover immediately instead of waiting for the next paced attempt. The
|
|
2284
|
+
// gate still stays closed so the operation cannot reach the failed
|
|
2285
|
+
// transport; it is released by the successful on-demand reopen.
|
|
2286
|
+
recoveryDemandAllowed = false;
|
|
2213
2287
|
/** Monotonic generation incremented on every successful transport open.
|
|
2214
2288
|
* Stays in lockstep with `lastSuccessAt` so callers can detect that the
|
|
2215
2289
|
* transport has been reopened even if the timestamp window is short. */
|
|
@@ -2225,6 +2299,9 @@ var CrossTabDataBus = class {
|
|
|
2225
2299
|
// surfaces a failure while later opens and automatic recovery wait for the
|
|
2226
2300
|
// stop to settle.
|
|
2227
2301
|
pendingStop = null;
|
|
2302
|
+
// Ownership token for asynchronous transport opens. Every lifecycle
|
|
2303
|
+
// transition invalidates callbacks and failure cleanup from older opens.
|
|
2304
|
+
lifecycleEpoch = 0;
|
|
2228
2305
|
// Minimum interval in ms between automatic recovery attempts.
|
|
2229
2306
|
recoveryCooldownMs;
|
|
2230
2307
|
recoveryMaxAttempts;
|
|
@@ -2337,37 +2414,51 @@ var CrossTabDataBus = class {
|
|
|
2337
2414
|
* Start the DataBus with the given transport config.
|
|
2338
2415
|
*
|
|
2339
2416
|
* The first call starts the cluster and opens the transport. Concurrent calls
|
|
2340
|
-
* during an in-flight
|
|
2341
|
-
*
|
|
2342
|
-
*
|
|
2417
|
+
* during an in-flight open return the same promise. A call received while an
|
|
2418
|
+
* explicit stop() is settling queues one fresh start after cleanup; a later
|
|
2419
|
+
* stop() before that queued start runs cancels it, so the latest lifecycle
|
|
2420
|
+
* intent wins. Once an operation settles (success or failure) its promise
|
|
2421
|
+
* gate is cleared so a subsequent start() or resumeTransport() can open a
|
|
2422
|
+
* fresh lifecycle.
|
|
2343
2423
|
*/
|
|
2344
2424
|
start(config) {
|
|
2345
|
-
if (this.
|
|
2346
|
-
if (this.
|
|
2425
|
+
if (this.queuedStart) return this.queuedStart;
|
|
2426
|
+
if (this.stopping) return this.queueStartAfterStop(config);
|
|
2427
|
+
if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
|
|
2428
|
+
if (this.started) {
|
|
2429
|
+
const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
|
|
2430
|
+
if (!transportDown) return Promise.resolve();
|
|
2431
|
+
this.activeConfig = config;
|
|
2432
|
+
this.resetFailureState();
|
|
2433
|
+
return this.reopenTransport();
|
|
2434
|
+
}
|
|
2347
2435
|
this.started = true;
|
|
2348
2436
|
this.stopping = false;
|
|
2349
2437
|
this.suspended = false;
|
|
2350
2438
|
this.activeConfig = config;
|
|
2351
|
-
this.
|
|
2352
|
-
this.lastFailure = null;
|
|
2353
|
-
this.persistenceFailureCount = 0;
|
|
2354
|
-
this.persistenceLastFailureAt = null;
|
|
2355
|
-
this.persistenceLastErrorMessage = null;
|
|
2439
|
+
this.resetFailureState();
|
|
2356
2440
|
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
|
|
2357
2441
|
this.trace.start();
|
|
2358
2442
|
this.startDedupSweep();
|
|
2359
2443
|
this.replayManager.start();
|
|
2360
2444
|
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
2361
2445
|
this.cluster.start();
|
|
2362
|
-
const
|
|
2446
|
+
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
2447
|
+
const opening = this.openTransport(
|
|
2448
|
+
config,
|
|
2449
|
+
this.pendingStop ?? Promise.resolve(),
|
|
2450
|
+
true,
|
|
2451
|
+
lifecycleEpoch
|
|
2452
|
+
);
|
|
2363
2453
|
this.startPromise = opening;
|
|
2364
2454
|
for (const topic of this.topicHandlers.keys()) {
|
|
2365
2455
|
this.cluster.subscribe(topic);
|
|
2366
2456
|
}
|
|
2367
2457
|
void opening.then(
|
|
2368
2458
|
() => {
|
|
2459
|
+
if (this.startPromise !== opening) return;
|
|
2369
2460
|
this.emitCoordinationTrace();
|
|
2370
|
-
|
|
2461
|
+
this.startPromise = null;
|
|
2371
2462
|
},
|
|
2372
2463
|
() => {
|
|
2373
2464
|
if (this.startPromise === opening) this.startPromise = null;
|
|
@@ -2375,24 +2466,111 @@ var CrossTabDataBus = class {
|
|
|
2375
2466
|
);
|
|
2376
2467
|
return opening;
|
|
2377
2468
|
}
|
|
2469
|
+
/** Return a cancellation-aware readiness view of the current queued start. */
|
|
2470
|
+
getQueuedStartReady() {
|
|
2471
|
+
const queued = this.queuedStart;
|
|
2472
|
+
if (!queued) {
|
|
2473
|
+
return Promise.reject(new Error("No queued start is in flight."));
|
|
2474
|
+
}
|
|
2475
|
+
const token = this.queuedStartToken;
|
|
2476
|
+
if (this.queuedStartReady && this.queuedStartReadyToken === token) {
|
|
2477
|
+
return this.queuedStartReady;
|
|
2478
|
+
}
|
|
2479
|
+
this.queuedStartReadyToken = token;
|
|
2480
|
+
this.queuedStartReady = queued.then(() => {
|
|
2481
|
+
if (token <= this.canceledQueuedStartToken) {
|
|
2482
|
+
throw new Error(
|
|
2483
|
+
"CrossTabDataBus start was canceled by a later stop(); ready() cannot report readiness. Call start() again after stop() resolves."
|
|
2484
|
+
);
|
|
2485
|
+
}
|
|
2486
|
+
if (!this.started || !this.transportReady) {
|
|
2487
|
+
throw new Error("CrossTabDataBus restart completed without a ready transport.");
|
|
2488
|
+
}
|
|
2489
|
+
});
|
|
2490
|
+
return this.queuedStartReady;
|
|
2491
|
+
}
|
|
2492
|
+
/** Queue exactly one fresh start after an in-flight explicit stop settles. */
|
|
2493
|
+
queueStartAfterStop(config) {
|
|
2494
|
+
if (this.queuedStart) return this.queuedStart;
|
|
2495
|
+
const stop = this.stopPromise ?? Promise.resolve();
|
|
2496
|
+
const token = ++this.queuedStartToken;
|
|
2497
|
+
const queued = stop.catch(() => void 0).then(() => {
|
|
2498
|
+
if (this.queuedStart === queued) this.queuedStart = null;
|
|
2499
|
+
if (token <= this.canceledQueuedStartToken) return;
|
|
2500
|
+
return this.start(config);
|
|
2501
|
+
});
|
|
2502
|
+
this.queuedStart = queued;
|
|
2503
|
+
return queued;
|
|
2504
|
+
}
|
|
2505
|
+
/** Release every operation waiting on the scheduled recovery attempt. */
|
|
2506
|
+
releaseRecoveryGate() {
|
|
2507
|
+
const release = this.recoveryGateRelease;
|
|
2508
|
+
this.recoveryGate = null;
|
|
2509
|
+
this.recoveryGateRelease = null;
|
|
2510
|
+
this.recoveryDemandAllowed = false;
|
|
2511
|
+
release?.();
|
|
2512
|
+
}
|
|
2513
|
+
/** Cancel a pending automatic retry when an explicit lifecycle transition
|
|
2514
|
+
* supersedes it. The released gate re-enters runTransport(), which then
|
|
2515
|
+
* follows the newest start/stop/suspend intent. */
|
|
2516
|
+
cancelScheduledRecovery() {
|
|
2517
|
+
this.recoveryTimerToken += 1;
|
|
2518
|
+
if (this.recoveryTimer !== null) {
|
|
2519
|
+
clearTimeout(this.recoveryTimer);
|
|
2520
|
+
this.recoveryTimer = null;
|
|
2521
|
+
}
|
|
2522
|
+
this.releaseRecoveryGate();
|
|
2523
|
+
}
|
|
2524
|
+
/** Keep the recovery gate closed after a failed attempt while allowing the
|
|
2525
|
+
* next explicit transport operation to start an immediate on-demand reopen.
|
|
2526
|
+
* If no gate/successor retry remains, release any waiters. */
|
|
2527
|
+
allowDemandRecovery() {
|
|
2528
|
+
if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
|
|
2529
|
+
this.recoveryDemandAllowed = true;
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
this.releaseRecoveryGate();
|
|
2533
|
+
}
|
|
2534
|
+
/** Reset failure and recovery diagnostics for a new explicit start session. */
|
|
2535
|
+
resetFailureState() {
|
|
2536
|
+
this.cancelScheduledRecovery();
|
|
2537
|
+
this.lastError = null;
|
|
2538
|
+
this.lastErrorAt = null;
|
|
2539
|
+
this.lastFailure = null;
|
|
2540
|
+
this.persistenceFailureCount = 0;
|
|
2541
|
+
this.persistenceLastFailureAt = null;
|
|
2542
|
+
this.persistenceLastErrorMessage = null;
|
|
2543
|
+
this.recoveryAttempt = 0;
|
|
2544
|
+
this.recoveryExhausted = false;
|
|
2545
|
+
this.lastRecoveryAt = 0;
|
|
2546
|
+
}
|
|
2378
2547
|
/**
|
|
2379
2548
|
* Open the transport, chained after `before` to ensure lifecycle ordering.
|
|
2380
2549
|
* When `stopClusterOnFailure` is true (initial start), a transport failure
|
|
2381
2550
|
* tears down the cluster as well.
|
|
2382
2551
|
*/
|
|
2383
|
-
openTransport(config, before, stopClusterOnFailure) {
|
|
2552
|
+
openTransport(config, before, stopClusterOnFailure, lifecycleEpoch) {
|
|
2384
2553
|
this.transportReady = false;
|
|
2385
2554
|
const chainedPendingStop = this.pendingStop;
|
|
2555
|
+
const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
|
|
2386
2556
|
return before.catch(() => void 0).then(() => {
|
|
2387
|
-
if (this.stopping || this.suspended) return;
|
|
2557
|
+
if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
|
|
2388
2558
|
if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
|
|
2559
|
+
this.transportHasConnected = false;
|
|
2389
2560
|
return Promise.resolve(
|
|
2390
2561
|
this.transport.start(config, {
|
|
2391
|
-
onMessage: (message) =>
|
|
2392
|
-
|
|
2393
|
-
|
|
2562
|
+
onMessage: (message) => {
|
|
2563
|
+
if (isCurrentLifecycle()) this.handleTransportMessage(message);
|
|
2564
|
+
},
|
|
2565
|
+
onStatus: (status) => {
|
|
2566
|
+
if (isCurrentLifecycle()) this.updateStatus(status);
|
|
2567
|
+
},
|
|
2568
|
+
onError: (error) => {
|
|
2569
|
+
if (isCurrentLifecycle()) this.reportError(error);
|
|
2570
|
+
}
|
|
2394
2571
|
})
|
|
2395
2572
|
).then(() => {
|
|
2573
|
+
if (!isCurrentLifecycle()) return;
|
|
2396
2574
|
if (this.status === WORKER_STATUS.ERROR) {
|
|
2397
2575
|
throw new Error("Transport failed during startup.");
|
|
2398
2576
|
}
|
|
@@ -2400,33 +2578,51 @@ var CrossTabDataBus = class {
|
|
|
2400
2578
|
this.recoveryGeneration += 1;
|
|
2401
2579
|
this.lastSuccessAt = this.now();
|
|
2402
2580
|
this.transportReady = true;
|
|
2581
|
+
this.releaseRecoveryGate();
|
|
2403
2582
|
}
|
|
2404
2583
|
});
|
|
2405
2584
|
}).catch((error) => {
|
|
2585
|
+
if (!isCurrentLifecycle()) throw error;
|
|
2406
2586
|
if (stopClusterOnFailure) this.started = false;
|
|
2407
2587
|
if (!this.pendingStop) {
|
|
2408
2588
|
this.pendingStop = this.createStopPromise();
|
|
2409
2589
|
}
|
|
2590
|
+
this.transportReady = false;
|
|
2410
2591
|
this.updateStatus(WORKER_STATUS.ERROR);
|
|
2411
2592
|
this.reportError(error);
|
|
2412
|
-
this.lastError = error;
|
|
2413
|
-
this.lastErrorAt = this.now();
|
|
2414
|
-
this.transportReady = false;
|
|
2415
2593
|
if (stopClusterOnFailure) {
|
|
2416
2594
|
this.stopping = true;
|
|
2417
2595
|
this.cluster.stop();
|
|
2418
2596
|
this.stopping = false;
|
|
2419
2597
|
}
|
|
2420
|
-
this.startPromise = null;
|
|
2421
2598
|
throw error;
|
|
2422
2599
|
});
|
|
2423
2600
|
}
|
|
2424
2601
|
/**
|
|
2425
2602
|
* Await the DataBus to be fully started (lazy init when using initialConfig).
|
|
2426
2603
|
* Returns a rejected promise when the transport has failed and no start is in
|
|
2427
|
-
* flight — the caller can retry by calling start() or ready() again.
|
|
2604
|
+
* flight — the caller can retry by calling start() or ready() again. While an
|
|
2605
|
+
* explicit stop() is settling, this rejects unless a restart is queued behind
|
|
2606
|
+
* it; false readiness during teardown is never reported. While the tab is
|
|
2607
|
+
* BFCache-suspended (pagehide without a following pageshow), this also
|
|
2608
|
+
* rejects: the suspended start promise is the transport-stop gate, not a
|
|
2609
|
+
* readiness signal.
|
|
2428
2610
|
*/
|
|
2429
2611
|
ready() {
|
|
2612
|
+
if (this.queuedStart) return this.getQueuedStartReady();
|
|
2613
|
+
if (this.stopping) {
|
|
2614
|
+
return Promise.reject(new Error(
|
|
2615
|
+
"CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
|
|
2616
|
+
));
|
|
2617
|
+
}
|
|
2618
|
+
if (this.suspended) {
|
|
2619
|
+
return Promise.reject(new Error(
|
|
2620
|
+
"CrossTabDataBus is suspended; ready() cannot report readiness until pageshow resumes the transport."
|
|
2621
|
+
));
|
|
2622
|
+
}
|
|
2623
|
+
if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
|
|
2624
|
+
return Promise.reject(this.lastError);
|
|
2625
|
+
}
|
|
2430
2626
|
try {
|
|
2431
2627
|
this.ensureStarted();
|
|
2432
2628
|
} catch (error) {
|
|
@@ -2442,9 +2638,18 @@ var CrossTabDataBus = class {
|
|
|
2442
2638
|
/**
|
|
2443
2639
|
* Register a handler for `topic`. The handler fires on every publication
|
|
2444
2640
|
* delivered to this tab, regardless of which tab published it. Returns an
|
|
2445
|
-
* unsubscribe function for convenience.
|
|
2641
|
+
* unsubscribe function for convenience. During an explicit stop() the
|
|
2642
|
+
* registration is rejected through onError and a no-op cleanup is returned,
|
|
2643
|
+
* so a late subscriber cannot leak into a future restart.
|
|
2446
2644
|
*/
|
|
2447
2645
|
subscribe(topic, handler, options) {
|
|
2646
|
+
if (this.stopping) {
|
|
2647
|
+
this.reportError(new Error(
|
|
2648
|
+
"CrossTabDataBus is stopping; subscribe() was not registered. Wait for stop() to resolve, then call start() before subscribing again."
|
|
2649
|
+
));
|
|
2650
|
+
return () => {
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2448
2653
|
this.ensureStarted();
|
|
2449
2654
|
const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
|
|
2450
2655
|
const wasUnused = handlers.size === 0;
|
|
@@ -2498,6 +2703,7 @@ var CrossTabDataBus = class {
|
|
|
2498
2703
|
/** Publish a message to `topic`. The owning Worker delivers it to the transport. */
|
|
2499
2704
|
publish(topic, data, options) {
|
|
2500
2705
|
this.ensureStarted();
|
|
2706
|
+
if (this.rejectPublishDuringStop("publish")) return;
|
|
2501
2707
|
if (!this.cluster.publish(topic, data, options)) {
|
|
2502
2708
|
this.reportError(
|
|
2503
2709
|
new Error("Failed to send the publish control message to the owning worker.")
|
|
@@ -2514,6 +2720,7 @@ var CrossTabDataBus = class {
|
|
|
2514
2720
|
publishBatch(topic, items) {
|
|
2515
2721
|
this.ensureStarted();
|
|
2516
2722
|
if (items.length === 0) return;
|
|
2723
|
+
if (this.rejectPublishDuringStop("publishBatch")) return;
|
|
2517
2724
|
if (items.length === 1) {
|
|
2518
2725
|
const first = items[0];
|
|
2519
2726
|
this.publish(topic, first.data, first.options);
|
|
@@ -2548,11 +2755,15 @@ var CrossTabDataBus = class {
|
|
|
2548
2755
|
getStatus() {
|
|
2549
2756
|
return this.status;
|
|
2550
2757
|
}
|
|
2551
|
-
/** Return the current automatic transport recovery state. `hasError` means a transport error is currently retained. */
|
|
2552
2758
|
/** Return the current automatic transport recovery state plus diagnostics.
|
|
2553
|
-
* `
|
|
2554
|
-
*
|
|
2555
|
-
*
|
|
2759
|
+
* `hasError`/`errorMessage`/`errorAt` describe the most recent retained
|
|
2760
|
+
* *transport* failure — from a transport open or a runtime `onError`. They
|
|
2761
|
+
* share the lifetime of the unified `lastFailure` ledger: a successful
|
|
2762
|
+
* recovery keeps the last failure visible, and only an explicit `start()`
|
|
2763
|
+
* clears it. `generation` increments on every successful transport open
|
|
2764
|
+
* (initial start and every recovery); `lastSuccessAt` is the timestamp of
|
|
2765
|
+
* the most recent successful open, or `null` until the transport reaches
|
|
2766
|
+
* `ready`. */
|
|
2556
2767
|
getRecoveryStats() {
|
|
2557
2768
|
const errorMessage = this.lastError instanceof Error ? this.lastError.message : this.lastError === null ? null : String(this.lastError);
|
|
2558
2769
|
return {
|
|
@@ -2579,7 +2790,7 @@ var CrossTabDataBus = class {
|
|
|
2579
2790
|
* unified failure ledger and recovery context that explains the verdict. */
|
|
2580
2791
|
getHealthSummary() {
|
|
2581
2792
|
const transport = this.transport;
|
|
2582
|
-
const transportDown =
|
|
2793
|
+
const transportDown = this.status !== WORKER_STATUS.CONNECTED;
|
|
2583
2794
|
const state = !this.started ? HEALTH_STATE.STOPPED : this.suspended ? HEALTH_STATE.SUSPENDED : transportDown ? this.recoveryExhausted ? HEALTH_STATE.DEGRADED : this.status === WORKER_STATUS.CONNECTING && this.recoveryAttempt === 0 ? HEALTH_STATE.STARTING : HEALTH_STATE.RECOVERING : HEALTH_STATE.HEALTHY;
|
|
2584
2795
|
return {
|
|
2585
2796
|
healthy: state === HEALTH_STATE.HEALTHY,
|
|
@@ -2642,11 +2853,35 @@ var CrossTabDataBus = class {
|
|
|
2642
2853
|
}
|
|
2643
2854
|
/**
|
|
2644
2855
|
* Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
|
|
2645
|
-
* and close the transport.
|
|
2856
|
+
* and close the transport. Concurrent and repeated calls share the in-flight
|
|
2857
|
+
* stop promise. A start() received while stopping runs after this completes,
|
|
2858
|
+
* unless another stop() arrives first and cancels that queued restart.
|
|
2646
2859
|
*/
|
|
2647
|
-
|
|
2648
|
-
if (
|
|
2860
|
+
stop() {
|
|
2861
|
+
if (this.queuedStart) {
|
|
2862
|
+
this.canceledQueuedStartToken = this.queuedStartToken;
|
|
2863
|
+
this.queuedStart = null;
|
|
2864
|
+
}
|
|
2865
|
+
if (this.stopPromise) return this.stopPromise;
|
|
2866
|
+
if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
|
|
2867
|
+
return Promise.resolve();
|
|
2868
|
+
}
|
|
2869
|
+
const stopPromise = this.performStop();
|
|
2870
|
+
this.stopPromise = stopPromise;
|
|
2871
|
+
void stopPromise.then(
|
|
2872
|
+
() => {
|
|
2873
|
+
if (this.stopPromise === stopPromise) this.stopPromise = null;
|
|
2874
|
+
},
|
|
2875
|
+
() => {
|
|
2876
|
+
if (this.stopPromise === stopPromise) this.stopPromise = null;
|
|
2877
|
+
}
|
|
2878
|
+
);
|
|
2879
|
+
return stopPromise;
|
|
2880
|
+
}
|
|
2881
|
+
async performStop() {
|
|
2882
|
+
this.lifecycleEpoch += 1;
|
|
2649
2883
|
this.stopping = true;
|
|
2884
|
+
this.cancelScheduledRecovery();
|
|
2650
2885
|
this.replayManager.suspend();
|
|
2651
2886
|
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
|
|
2652
2887
|
this.trace.stop();
|
|
@@ -2659,6 +2894,8 @@ var CrossTabDataBus = class {
|
|
|
2659
2894
|
const pendingStop = this.pendingStop;
|
|
2660
2895
|
if (pendingStop) await pendingStop.catch(() => void 0);
|
|
2661
2896
|
else await this.transport.stop();
|
|
2897
|
+
} catch (error) {
|
|
2898
|
+
this.reportError(error);
|
|
2662
2899
|
} finally {
|
|
2663
2900
|
this.transportSubscribedTopics.clear();
|
|
2664
2901
|
this.resetDedup();
|
|
@@ -2724,10 +2961,12 @@ var CrossTabDataBus = class {
|
|
|
2724
2961
|
updateStatus(status) {
|
|
2725
2962
|
const previousStatus = this.status;
|
|
2726
2963
|
this.status = status;
|
|
2964
|
+
if (status === WORKER_STATUS.CONNECTED) this.transportHasConnected = true;
|
|
2727
2965
|
if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
|
|
2728
2966
|
this.cluster.setStatus(status);
|
|
2729
2967
|
if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
|
|
2730
2968
|
if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
|
|
2969
|
+
if (this.transportReady) this.releaseRecoveryGate();
|
|
2731
2970
|
for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
|
|
2732
2971
|
}
|
|
2733
2972
|
if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
|
|
@@ -2740,23 +2979,49 @@ var CrossTabDataBus = class {
|
|
|
2740
2979
|
this.recoveryExhausted = true;
|
|
2741
2980
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
|
|
2742
2981
|
}
|
|
2982
|
+
this.releaseRecoveryGate();
|
|
2743
2983
|
return;
|
|
2744
2984
|
}
|
|
2745
2985
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2986
|
+
if (this.recoveryGate === null) {
|
|
2987
|
+
let release;
|
|
2988
|
+
this.recoveryGate = new Promise((resolve) => {
|
|
2989
|
+
release = resolve;
|
|
2990
|
+
});
|
|
2991
|
+
this.recoveryGateRelease = release;
|
|
2992
|
+
}
|
|
2993
|
+
this.recoveryDemandAllowed = false;
|
|
2994
|
+
const timerToken = ++this.recoveryTimerToken;
|
|
2995
|
+
this.recoveryTimer = setTimeout(() => {
|
|
2996
|
+
if (timerToken !== this.recoveryTimerToken) return;
|
|
2997
|
+
this.recoveryTimer = null;
|
|
2998
|
+
if (this.stopping || !this.started || this.suspended || this.status !== WORKER_STATUS.ERROR) {
|
|
2999
|
+
this.releaseRecoveryGate();
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
this.recoveryDemandAllowed = false;
|
|
3003
|
+
const opening = this.reopenTransport(attempt);
|
|
3004
|
+
void opening.then(
|
|
3005
|
+
() => this.releaseRecoveryGate(),
|
|
3006
|
+
() => this.allowDemandRecovery()
|
|
3007
|
+
);
|
|
2750
3008
|
}, this.recoveryCooldownMs);
|
|
2751
3009
|
}
|
|
3010
|
+
} else if (status === WORKER_STATUS.ERROR) {
|
|
3011
|
+
this.releaseRecoveryGate();
|
|
2752
3012
|
}
|
|
2753
3013
|
this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
|
|
2754
3014
|
}
|
|
2755
3015
|
reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
|
|
3016
|
+
const at = this.now();
|
|
3017
|
+
if (source === FAILURE_SOURCE.TRANSPORT) {
|
|
3018
|
+
this.lastError = error;
|
|
3019
|
+
this.lastErrorAt = at;
|
|
3020
|
+
}
|
|
2756
3021
|
this.lastFailure = {
|
|
2757
3022
|
source,
|
|
2758
3023
|
message: error instanceof Error ? error.message : String(error),
|
|
2759
|
-
at
|
|
3024
|
+
at
|
|
2760
3025
|
};
|
|
2761
3026
|
if (source === FAILURE_SOURCE.PERSISTENCE) {
|
|
2762
3027
|
this.persistenceFailureCount += 1;
|
|
@@ -2837,7 +3102,9 @@ var CrossTabDataBus = class {
|
|
|
2837
3102
|
*/
|
|
2838
3103
|
suspendTransport() {
|
|
2839
3104
|
if (this.stopping) return;
|
|
3105
|
+
this.lifecycleEpoch += 1;
|
|
2840
3106
|
this.suspended = true;
|
|
3107
|
+
this.cancelScheduledRecovery();
|
|
2841
3108
|
this.transportReady = false;
|
|
2842
3109
|
this.transportSubscribedTopics.clear();
|
|
2843
3110
|
this.updateStatus(WORKER_STATUS.DISCONNECTED);
|
|
@@ -2875,19 +3142,23 @@ var CrossTabDataBus = class {
|
|
|
2875
3142
|
this.started = true;
|
|
2876
3143
|
this.suspended = false;
|
|
2877
3144
|
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
3145
|
+
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
2878
3146
|
const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
|
|
2879
|
-
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
|
|
3147
|
+
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
|
|
2880
3148
|
this.startPromise = opening;
|
|
2881
3149
|
void opening.then(
|
|
2882
3150
|
() => {
|
|
3151
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
3152
|
+
if (lifecycleEpoch !== this.lifecycleEpoch) return;
|
|
2883
3153
|
if (traceAttempt !== void 0) {
|
|
2884
3154
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
|
|
2885
3155
|
this.recoveryAttempt = 0;
|
|
2886
3156
|
this.recoveryExhausted = false;
|
|
2887
3157
|
}
|
|
2888
|
-
if (this.startPromise === opening) this.startPromise = null;
|
|
2889
3158
|
},
|
|
2890
3159
|
() => {
|
|
3160
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
3161
|
+
if (lifecycleEpoch !== this.lifecycleEpoch) return;
|
|
2891
3162
|
if (traceAttempt !== void 0) {
|
|
2892
3163
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
|
|
2893
3164
|
}
|
|
@@ -2903,7 +3174,24 @@ var CrossTabDataBus = class {
|
|
|
2903
3174
|
*/
|
|
2904
3175
|
runTransport(operation) {
|
|
2905
3176
|
if (this.suspended) return;
|
|
2906
|
-
if (this.
|
|
3177
|
+
if (this.recoveryGate && !this.stopping) {
|
|
3178
|
+
if (this.recoveryDemandAllowed && this.status === WORKER_STATUS.ERROR && !this.suspended) {
|
|
3179
|
+
this.recoveryDemandAllowed = false;
|
|
3180
|
+
const opening = this.reopenTransport();
|
|
3181
|
+
void opening.then(
|
|
3182
|
+
() => this.releaseRecoveryGate(),
|
|
3183
|
+
() => this.allowDemandRecovery()
|
|
3184
|
+
);
|
|
3185
|
+
}
|
|
3186
|
+
const gate = this.recoveryGate;
|
|
3187
|
+
void gate.then(() => {
|
|
3188
|
+
if (this.stopping || this.suspended) return;
|
|
3189
|
+
this.runTransport(operation);
|
|
3190
|
+
});
|
|
3191
|
+
return;
|
|
3192
|
+
}
|
|
3193
|
+
const droppedAfterConnect = this.transportHasConnected && this.status === WORKER_STATUS.DISCONNECTED;
|
|
3194
|
+
if (this.transportReady && this.status !== WORKER_STATUS.ERROR && !droppedAfterConnect && !this.stopping) {
|
|
2907
3195
|
try {
|
|
2908
3196
|
void Promise.resolve(operation()).catch((error) => this.reportError(error));
|
|
2909
3197
|
} catch (error) {
|
|
@@ -2921,6 +3209,19 @@ var CrossTabDataBus = class {
|
|
|
2921
3209
|
return operation();
|
|
2922
3210
|
}).catch((error) => this.reportError(error));
|
|
2923
3211
|
}
|
|
3212
|
+
/**
|
|
3213
|
+
* Publications started after teardown begins cannot reach any transport.
|
|
3214
|
+
* Surface that as a normal asynchronous API failure instead of letting
|
|
3215
|
+
* runTransport() return silently. Empty publishBatch() calls remain a no-op
|
|
3216
|
+
* and are filtered by the caller before this check.
|
|
3217
|
+
*/
|
|
3218
|
+
rejectPublishDuringStop(operation) {
|
|
3219
|
+
if (!this.stopping) return false;
|
|
3220
|
+
this.reportError(new Error(
|
|
3221
|
+
`CrossTabDataBus is stopping; ${operation}() was not sent. Wait for stop() to resolve, then call start() before publishing again.`
|
|
3222
|
+
));
|
|
3223
|
+
return true;
|
|
3224
|
+
}
|
|
2924
3225
|
/**
|
|
2925
3226
|
* Ensure the DataBus is started, throwing if no initialConfig was provided.
|
|
2926
3227
|
* Called automatically by subscribe/publish/ready when autoStart is true.
|
|
@@ -2996,8 +3297,9 @@ export {
|
|
|
2996
3297
|
assertHeartbeatInterval,
|
|
2997
3298
|
assertStructuredCloneable,
|
|
2998
3299
|
WorkerClusterRuntime,
|
|
3300
|
+
pruneReplayHistory,
|
|
2999
3301
|
CrossTabDataBus,
|
|
3000
3302
|
parseDataBusPublication,
|
|
3001
3303
|
selectWorkerBackend
|
|
3002
3304
|
};
|
|
3003
|
-
//# sourceMappingURL=chunk-
|
|
3305
|
+
//# sourceMappingURL=chunk-ZNHJ5OMY.js.map
|