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
package/dist/cjs/centrifuge.cjs
CHANGED
|
@@ -1857,6 +1857,36 @@ function roundMs(value) {
|
|
|
1857
1857
|
return Math.round(value * 10) / 10;
|
|
1858
1858
|
}
|
|
1859
1859
|
|
|
1860
|
+
// src/core/replay-pruning.ts
|
|
1861
|
+
function pruneReplayHistory(messages, options) {
|
|
1862
|
+
const { maxPerTopic, pruneStrategy, retentionMs, now } = options;
|
|
1863
|
+
const ageEnabled = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
|
|
1864
|
+
if (!ageEnabled) {
|
|
1865
|
+
return messages.length > maxPerTopic ? messages.slice(-maxPerTopic) : messages;
|
|
1866
|
+
}
|
|
1867
|
+
const cutoff = now - retentionMs;
|
|
1868
|
+
let hasExpired = false;
|
|
1869
|
+
let timestamplessCount = 0;
|
|
1870
|
+
for (const message of messages) {
|
|
1871
|
+
if (message.timestamp === void 0) timestamplessCount += 1;
|
|
1872
|
+
else if (message.timestamp < cutoff) hasExpired = true;
|
|
1873
|
+
}
|
|
1874
|
+
let pruned = hasExpired ? messages.filter((message) => message.timestamp === void 0 || message.timestamp >= cutoff) : messages;
|
|
1875
|
+
if (pruneStrategy === PRUNE_STRATEGY.BOTH) {
|
|
1876
|
+
return pruned.length > maxPerTopic ? pruned.slice(-maxPerTopic) : pruned;
|
|
1877
|
+
}
|
|
1878
|
+
if (timestamplessCount <= maxPerTopic) return pruned;
|
|
1879
|
+
let timestamplessToDrop = timestamplessCount - maxPerTopic;
|
|
1880
|
+
pruned = pruned.filter((message) => {
|
|
1881
|
+
if (message.timestamp === void 0 && timestamplessToDrop > 0) {
|
|
1882
|
+
timestamplessToDrop -= 1;
|
|
1883
|
+
return false;
|
|
1884
|
+
}
|
|
1885
|
+
return true;
|
|
1886
|
+
});
|
|
1887
|
+
return pruned;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1860
1890
|
// src/core/replay-manager.ts
|
|
1861
1891
|
var PersistenceRetryCancelledError = class extends Error {
|
|
1862
1892
|
constructor() {
|
|
@@ -1918,17 +1948,15 @@ var ReplayManager = class {
|
|
|
1918
1948
|
this.buffers.set(message.topic, buffer);
|
|
1919
1949
|
}
|
|
1920
1950
|
buffer.push(message);
|
|
1921
|
-
const
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
buffer.shift();
|
|
1931
|
-
}
|
|
1951
|
+
const pruned = pruneReplayHistory(buffer, {
|
|
1952
|
+
maxPerTopic: this.maxPerTopic,
|
|
1953
|
+
pruneStrategy: this.pruneStrategy,
|
|
1954
|
+
retentionMs: this.retentionMs,
|
|
1955
|
+
now: this.now()
|
|
1956
|
+
});
|
|
1957
|
+
if (pruned !== buffer) {
|
|
1958
|
+
buffer = pruned;
|
|
1959
|
+
this.buffers.set(message.topic, buffer);
|
|
1932
1960
|
}
|
|
1933
1961
|
if (!this.persistence) return;
|
|
1934
1962
|
if (this.persistence.appendBatch) {
|
|
@@ -2109,14 +2137,24 @@ var ReplayManager = class {
|
|
|
2109
2137
|
if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
|
|
2110
2138
|
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
|
|
2111
2139
|
}
|
|
2112
|
-
|
|
2140
|
+
const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
|
|
2141
|
+
for (const message of loaded) {
|
|
2113
2142
|
let buffer = this.buffers.get(message.topic);
|
|
2114
2143
|
if (!buffer) {
|
|
2115
2144
|
buffer = [];
|
|
2116
2145
|
this.buffers.set(message.topic, buffer);
|
|
2117
2146
|
}
|
|
2118
2147
|
buffer.push(message);
|
|
2119
|
-
|
|
2148
|
+
}
|
|
2149
|
+
const hydrationNow = this.now();
|
|
2150
|
+
for (const [topic, buffer] of this.buffers) {
|
|
2151
|
+
const pruned = pruneReplayHistory(buffer, {
|
|
2152
|
+
maxPerTopic: this.maxPerTopic,
|
|
2153
|
+
pruneStrategy: this.pruneStrategy,
|
|
2154
|
+
retentionMs: this.retentionMs,
|
|
2155
|
+
now: hydrationNow
|
|
2156
|
+
});
|
|
2157
|
+
if (pruned !== buffer) this.buffers.set(topic, pruned);
|
|
2120
2158
|
}
|
|
2121
2159
|
} catch (error) {
|
|
2122
2160
|
this.onPersistenceError(error);
|
|
@@ -2293,7 +2331,7 @@ var DedupManager = class {
|
|
|
2293
2331
|
};
|
|
2294
2332
|
|
|
2295
2333
|
// src/core/version.ts
|
|
2296
|
-
var SDK_VERSION = true ? "0.20.
|
|
2334
|
+
var SDK_VERSION = true ? "0.20.87" : "";
|
|
2297
2335
|
|
|
2298
2336
|
// src/core/data-bus.ts
|
|
2299
2337
|
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
@@ -2318,6 +2356,11 @@ var CrossTabDataBus = class {
|
|
|
2318
2356
|
started = false;
|
|
2319
2357
|
stopping = false;
|
|
2320
2358
|
transportReady = false;
|
|
2359
|
+
// Whether the installed transport has reported `connected` at least once
|
|
2360
|
+
// since the current open began. A clean `disconnected` after this point is
|
|
2361
|
+
// a lost working connection, not the pre-connect window of a worker-style
|
|
2362
|
+
// backend whose start() resolves before it reports the connection.
|
|
2363
|
+
transportHasConnected = false;
|
|
2321
2364
|
// Last transport failure, retained so ready() can surface it to callers who
|
|
2322
2365
|
// never awaited start() directly. Cleared on the next successful start.
|
|
2323
2366
|
lastError = null;
|
|
@@ -2331,6 +2374,24 @@ var CrossTabDataBus = class {
|
|
|
2331
2374
|
// Gate that serialises start/stop/suspend/resume — only one lifecycle
|
|
2332
2375
|
// transition at a time. Resets to null once the operation settles.
|
|
2333
2376
|
startPromise = null;
|
|
2377
|
+
// Gate for an explicit stop(). Concurrent stop() calls share it, and a
|
|
2378
|
+
// start() received while stopping chains a fresh start after it.
|
|
2379
|
+
stopPromise = null;
|
|
2380
|
+
// A start() requested while an explicit stop() is still settling. Kept
|
|
2381
|
+
// separate from startPromise because stop()'s finally block clears the
|
|
2382
|
+
// ordinary lifecycle gate before the queued start is allowed to run.
|
|
2383
|
+
queuedStart = null;
|
|
2384
|
+
// Lazy readiness view of queuedStart. start() keeps its documented
|
|
2385
|
+
// resolve-on-cancellation contract, while ready() must reject when the
|
|
2386
|
+
// queued intent was superseded by a later stop().
|
|
2387
|
+
queuedStartReady = null;
|
|
2388
|
+
queuedStartReadyToken = 0;
|
|
2389
|
+
// The queued continuation is chained to the stop promise and cannot be
|
|
2390
|
+
// un-scheduled once scheduled. A later stop() therefore invalidates the
|
|
2391
|
+
// current intent by recording its token; a subsequent start() issues a
|
|
2392
|
+
// higher token so the latest lifecycle request still wins.
|
|
2393
|
+
queuedStartToken = 0;
|
|
2394
|
+
canceledQueuedStartToken = 0;
|
|
2334
2395
|
// Timestamp of the last automatic transport recovery attempt.
|
|
2335
2396
|
// Used to avoid a tight retry loop when the transport fails repeatedly.
|
|
2336
2397
|
lastRecoveryAt = 0;
|
|
@@ -2338,6 +2399,19 @@ var CrossTabDataBus = class {
|
|
|
2338
2399
|
// a transport reopen succeeds so traces can correlate repeated failures.
|
|
2339
2400
|
recoveryAttempt = 0;
|
|
2340
2401
|
recoveryExhausted = false;
|
|
2402
|
+
// Gate that holds transport operations issued after a runtime `error` until
|
|
2403
|
+
// the scheduled recovery attempt has actually run. Without it, a dead
|
|
2404
|
+
// transport still has `transportReady === true` during the cooldown, so
|
|
2405
|
+
// publishes/subscribes would be written to the failed connection and lost.
|
|
2406
|
+
recoveryGate = null;
|
|
2407
|
+
recoveryGateRelease = null;
|
|
2408
|
+
recoveryTimer = null;
|
|
2409
|
+
recoveryTimerToken = 0;
|
|
2410
|
+
// Once an automatic attempt fails, an explicit transport operation may
|
|
2411
|
+
// recover immediately instead of waiting for the next paced attempt. The
|
|
2412
|
+
// gate still stays closed so the operation cannot reach the failed
|
|
2413
|
+
// transport; it is released by the successful on-demand reopen.
|
|
2414
|
+
recoveryDemandAllowed = false;
|
|
2341
2415
|
/** Monotonic generation incremented on every successful transport open.
|
|
2342
2416
|
* Stays in lockstep with `lastSuccessAt` so callers can detect that the
|
|
2343
2417
|
* transport has been reopened even if the timestamp window is short. */
|
|
@@ -2353,6 +2427,9 @@ var CrossTabDataBus = class {
|
|
|
2353
2427
|
// surfaces a failure while later opens and automatic recovery wait for the
|
|
2354
2428
|
// stop to settle.
|
|
2355
2429
|
pendingStop = null;
|
|
2430
|
+
// Ownership token for asynchronous transport opens. Every lifecycle
|
|
2431
|
+
// transition invalidates callbacks and failure cleanup from older opens.
|
|
2432
|
+
lifecycleEpoch = 0;
|
|
2356
2433
|
// Minimum interval in ms between automatic recovery attempts.
|
|
2357
2434
|
recoveryCooldownMs;
|
|
2358
2435
|
recoveryMaxAttempts;
|
|
@@ -2465,37 +2542,51 @@ var CrossTabDataBus = class {
|
|
|
2465
2542
|
* Start the DataBus with the given transport config.
|
|
2466
2543
|
*
|
|
2467
2544
|
* The first call starts the cluster and opens the transport. Concurrent calls
|
|
2468
|
-
* during an in-flight
|
|
2469
|
-
*
|
|
2470
|
-
*
|
|
2545
|
+
* during an in-flight open return the same promise. A call received while an
|
|
2546
|
+
* explicit stop() is settling queues one fresh start after cleanup; a later
|
|
2547
|
+
* stop() before that queued start runs cancels it, so the latest lifecycle
|
|
2548
|
+
* intent wins. Once an operation settles (success or failure) its promise
|
|
2549
|
+
* gate is cleared so a subsequent start() or resumeTransport() can open a
|
|
2550
|
+
* fresh lifecycle.
|
|
2471
2551
|
*/
|
|
2472
2552
|
start(config) {
|
|
2473
|
-
if (this.
|
|
2474
|
-
if (this.
|
|
2553
|
+
if (this.queuedStart) return this.queuedStart;
|
|
2554
|
+
if (this.stopping) return this.queueStartAfterStop(config);
|
|
2555
|
+
if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
|
|
2556
|
+
if (this.started) {
|
|
2557
|
+
const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
|
|
2558
|
+
if (!transportDown) return Promise.resolve();
|
|
2559
|
+
this.activeConfig = config;
|
|
2560
|
+
this.resetFailureState();
|
|
2561
|
+
return this.reopenTransport();
|
|
2562
|
+
}
|
|
2475
2563
|
this.started = true;
|
|
2476
2564
|
this.stopping = false;
|
|
2477
2565
|
this.suspended = false;
|
|
2478
2566
|
this.activeConfig = config;
|
|
2479
|
-
this.
|
|
2480
|
-
this.lastFailure = null;
|
|
2481
|
-
this.persistenceFailureCount = 0;
|
|
2482
|
-
this.persistenceLastFailureAt = null;
|
|
2483
|
-
this.persistenceLastErrorMessage = null;
|
|
2567
|
+
this.resetFailureState();
|
|
2484
2568
|
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
|
|
2485
2569
|
this.trace.start();
|
|
2486
2570
|
this.startDedupSweep();
|
|
2487
2571
|
this.replayManager.start();
|
|
2488
2572
|
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
2489
2573
|
this.cluster.start();
|
|
2490
|
-
const
|
|
2574
|
+
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
2575
|
+
const opening = this.openTransport(
|
|
2576
|
+
config,
|
|
2577
|
+
this.pendingStop ?? Promise.resolve(),
|
|
2578
|
+
true,
|
|
2579
|
+
lifecycleEpoch
|
|
2580
|
+
);
|
|
2491
2581
|
this.startPromise = opening;
|
|
2492
2582
|
for (const topic of this.topicHandlers.keys()) {
|
|
2493
2583
|
this.cluster.subscribe(topic);
|
|
2494
2584
|
}
|
|
2495
2585
|
void opening.then(
|
|
2496
2586
|
() => {
|
|
2587
|
+
if (this.startPromise !== opening) return;
|
|
2497
2588
|
this.emitCoordinationTrace();
|
|
2498
|
-
|
|
2589
|
+
this.startPromise = null;
|
|
2499
2590
|
},
|
|
2500
2591
|
() => {
|
|
2501
2592
|
if (this.startPromise === opening) this.startPromise = null;
|
|
@@ -2503,24 +2594,111 @@ var CrossTabDataBus = class {
|
|
|
2503
2594
|
);
|
|
2504
2595
|
return opening;
|
|
2505
2596
|
}
|
|
2597
|
+
/** Return a cancellation-aware readiness view of the current queued start. */
|
|
2598
|
+
getQueuedStartReady() {
|
|
2599
|
+
const queued = this.queuedStart;
|
|
2600
|
+
if (!queued) {
|
|
2601
|
+
return Promise.reject(new Error("No queued start is in flight."));
|
|
2602
|
+
}
|
|
2603
|
+
const token = this.queuedStartToken;
|
|
2604
|
+
if (this.queuedStartReady && this.queuedStartReadyToken === token) {
|
|
2605
|
+
return this.queuedStartReady;
|
|
2606
|
+
}
|
|
2607
|
+
this.queuedStartReadyToken = token;
|
|
2608
|
+
this.queuedStartReady = queued.then(() => {
|
|
2609
|
+
if (token <= this.canceledQueuedStartToken) {
|
|
2610
|
+
throw new Error(
|
|
2611
|
+
"CrossTabDataBus start was canceled by a later stop(); ready() cannot report readiness. Call start() again after stop() resolves."
|
|
2612
|
+
);
|
|
2613
|
+
}
|
|
2614
|
+
if (!this.started || !this.transportReady) {
|
|
2615
|
+
throw new Error("CrossTabDataBus restart completed without a ready transport.");
|
|
2616
|
+
}
|
|
2617
|
+
});
|
|
2618
|
+
return this.queuedStartReady;
|
|
2619
|
+
}
|
|
2620
|
+
/** Queue exactly one fresh start after an in-flight explicit stop settles. */
|
|
2621
|
+
queueStartAfterStop(config) {
|
|
2622
|
+
if (this.queuedStart) return this.queuedStart;
|
|
2623
|
+
const stop = this.stopPromise ?? Promise.resolve();
|
|
2624
|
+
const token = ++this.queuedStartToken;
|
|
2625
|
+
const queued = stop.catch(() => void 0).then(() => {
|
|
2626
|
+
if (this.queuedStart === queued) this.queuedStart = null;
|
|
2627
|
+
if (token <= this.canceledQueuedStartToken) return;
|
|
2628
|
+
return this.start(config);
|
|
2629
|
+
});
|
|
2630
|
+
this.queuedStart = queued;
|
|
2631
|
+
return queued;
|
|
2632
|
+
}
|
|
2633
|
+
/** Release every operation waiting on the scheduled recovery attempt. */
|
|
2634
|
+
releaseRecoveryGate() {
|
|
2635
|
+
const release = this.recoveryGateRelease;
|
|
2636
|
+
this.recoveryGate = null;
|
|
2637
|
+
this.recoveryGateRelease = null;
|
|
2638
|
+
this.recoveryDemandAllowed = false;
|
|
2639
|
+
release?.();
|
|
2640
|
+
}
|
|
2641
|
+
/** Cancel a pending automatic retry when an explicit lifecycle transition
|
|
2642
|
+
* supersedes it. The released gate re-enters runTransport(), which then
|
|
2643
|
+
* follows the newest start/stop/suspend intent. */
|
|
2644
|
+
cancelScheduledRecovery() {
|
|
2645
|
+
this.recoveryTimerToken += 1;
|
|
2646
|
+
if (this.recoveryTimer !== null) {
|
|
2647
|
+
clearTimeout(this.recoveryTimer);
|
|
2648
|
+
this.recoveryTimer = null;
|
|
2649
|
+
}
|
|
2650
|
+
this.releaseRecoveryGate();
|
|
2651
|
+
}
|
|
2652
|
+
/** Keep the recovery gate closed after a failed attempt while allowing the
|
|
2653
|
+
* next explicit transport operation to start an immediate on-demand reopen.
|
|
2654
|
+
* If no gate/successor retry remains, release any waiters. */
|
|
2655
|
+
allowDemandRecovery() {
|
|
2656
|
+
if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
|
|
2657
|
+
this.recoveryDemandAllowed = true;
|
|
2658
|
+
return;
|
|
2659
|
+
}
|
|
2660
|
+
this.releaseRecoveryGate();
|
|
2661
|
+
}
|
|
2662
|
+
/** Reset failure and recovery diagnostics for a new explicit start session. */
|
|
2663
|
+
resetFailureState() {
|
|
2664
|
+
this.cancelScheduledRecovery();
|
|
2665
|
+
this.lastError = null;
|
|
2666
|
+
this.lastErrorAt = null;
|
|
2667
|
+
this.lastFailure = null;
|
|
2668
|
+
this.persistenceFailureCount = 0;
|
|
2669
|
+
this.persistenceLastFailureAt = null;
|
|
2670
|
+
this.persistenceLastErrorMessage = null;
|
|
2671
|
+
this.recoveryAttempt = 0;
|
|
2672
|
+
this.recoveryExhausted = false;
|
|
2673
|
+
this.lastRecoveryAt = 0;
|
|
2674
|
+
}
|
|
2506
2675
|
/**
|
|
2507
2676
|
* Open the transport, chained after `before` to ensure lifecycle ordering.
|
|
2508
2677
|
* When `stopClusterOnFailure` is true (initial start), a transport failure
|
|
2509
2678
|
* tears down the cluster as well.
|
|
2510
2679
|
*/
|
|
2511
|
-
openTransport(config, before, stopClusterOnFailure) {
|
|
2680
|
+
openTransport(config, before, stopClusterOnFailure, lifecycleEpoch) {
|
|
2512
2681
|
this.transportReady = false;
|
|
2513
2682
|
const chainedPendingStop = this.pendingStop;
|
|
2683
|
+
const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
|
|
2514
2684
|
return before.catch(() => void 0).then(() => {
|
|
2515
|
-
if (this.stopping || this.suspended) return;
|
|
2685
|
+
if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
|
|
2516
2686
|
if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
|
|
2687
|
+
this.transportHasConnected = false;
|
|
2517
2688
|
return Promise.resolve(
|
|
2518
2689
|
this.transport.start(config, {
|
|
2519
|
-
onMessage: (message) =>
|
|
2520
|
-
|
|
2521
|
-
|
|
2690
|
+
onMessage: (message) => {
|
|
2691
|
+
if (isCurrentLifecycle()) this.handleTransportMessage(message);
|
|
2692
|
+
},
|
|
2693
|
+
onStatus: (status) => {
|
|
2694
|
+
if (isCurrentLifecycle()) this.updateStatus(status);
|
|
2695
|
+
},
|
|
2696
|
+
onError: (error) => {
|
|
2697
|
+
if (isCurrentLifecycle()) this.reportError(error);
|
|
2698
|
+
}
|
|
2522
2699
|
})
|
|
2523
2700
|
).then(() => {
|
|
2701
|
+
if (!isCurrentLifecycle()) return;
|
|
2524
2702
|
if (this.status === WORKER_STATUS.ERROR) {
|
|
2525
2703
|
throw new Error("Transport failed during startup.");
|
|
2526
2704
|
}
|
|
@@ -2528,33 +2706,51 @@ var CrossTabDataBus = class {
|
|
|
2528
2706
|
this.recoveryGeneration += 1;
|
|
2529
2707
|
this.lastSuccessAt = this.now();
|
|
2530
2708
|
this.transportReady = true;
|
|
2709
|
+
this.releaseRecoveryGate();
|
|
2531
2710
|
}
|
|
2532
2711
|
});
|
|
2533
2712
|
}).catch((error) => {
|
|
2713
|
+
if (!isCurrentLifecycle()) throw error;
|
|
2534
2714
|
if (stopClusterOnFailure) this.started = false;
|
|
2535
2715
|
if (!this.pendingStop) {
|
|
2536
2716
|
this.pendingStop = this.createStopPromise();
|
|
2537
2717
|
}
|
|
2718
|
+
this.transportReady = false;
|
|
2538
2719
|
this.updateStatus(WORKER_STATUS.ERROR);
|
|
2539
2720
|
this.reportError(error);
|
|
2540
|
-
this.lastError = error;
|
|
2541
|
-
this.lastErrorAt = this.now();
|
|
2542
|
-
this.transportReady = false;
|
|
2543
2721
|
if (stopClusterOnFailure) {
|
|
2544
2722
|
this.stopping = true;
|
|
2545
2723
|
this.cluster.stop();
|
|
2546
2724
|
this.stopping = false;
|
|
2547
2725
|
}
|
|
2548
|
-
this.startPromise = null;
|
|
2549
2726
|
throw error;
|
|
2550
2727
|
});
|
|
2551
2728
|
}
|
|
2552
2729
|
/**
|
|
2553
2730
|
* Await the DataBus to be fully started (lazy init when using initialConfig).
|
|
2554
2731
|
* Returns a rejected promise when the transport has failed and no start is in
|
|
2555
|
-
* flight — the caller can retry by calling start() or ready() again.
|
|
2732
|
+
* flight — the caller can retry by calling start() or ready() again. While an
|
|
2733
|
+
* explicit stop() is settling, this rejects unless a restart is queued behind
|
|
2734
|
+
* it; false readiness during teardown is never reported. While the tab is
|
|
2735
|
+
* BFCache-suspended (pagehide without a following pageshow), this also
|
|
2736
|
+
* rejects: the suspended start promise is the transport-stop gate, not a
|
|
2737
|
+
* readiness signal.
|
|
2556
2738
|
*/
|
|
2557
2739
|
ready() {
|
|
2740
|
+
if (this.queuedStart) return this.getQueuedStartReady();
|
|
2741
|
+
if (this.stopping) {
|
|
2742
|
+
return Promise.reject(new Error(
|
|
2743
|
+
"CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
|
|
2744
|
+
));
|
|
2745
|
+
}
|
|
2746
|
+
if (this.suspended) {
|
|
2747
|
+
return Promise.reject(new Error(
|
|
2748
|
+
"CrossTabDataBus is suspended; ready() cannot report readiness until pageshow resumes the transport."
|
|
2749
|
+
));
|
|
2750
|
+
}
|
|
2751
|
+
if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
|
|
2752
|
+
return Promise.reject(this.lastError);
|
|
2753
|
+
}
|
|
2558
2754
|
try {
|
|
2559
2755
|
this.ensureStarted();
|
|
2560
2756
|
} catch (error) {
|
|
@@ -2570,9 +2766,18 @@ var CrossTabDataBus = class {
|
|
|
2570
2766
|
/**
|
|
2571
2767
|
* Register a handler for `topic`. The handler fires on every publication
|
|
2572
2768
|
* delivered to this tab, regardless of which tab published it. Returns an
|
|
2573
|
-
* unsubscribe function for convenience.
|
|
2769
|
+
* unsubscribe function for convenience. During an explicit stop() the
|
|
2770
|
+
* registration is rejected through onError and a no-op cleanup is returned,
|
|
2771
|
+
* so a late subscriber cannot leak into a future restart.
|
|
2574
2772
|
*/
|
|
2575
2773
|
subscribe(topic, handler, options) {
|
|
2774
|
+
if (this.stopping) {
|
|
2775
|
+
this.reportError(new Error(
|
|
2776
|
+
"CrossTabDataBus is stopping; subscribe() was not registered. Wait for stop() to resolve, then call start() before subscribing again."
|
|
2777
|
+
));
|
|
2778
|
+
return () => {
|
|
2779
|
+
};
|
|
2780
|
+
}
|
|
2576
2781
|
this.ensureStarted();
|
|
2577
2782
|
const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
|
|
2578
2783
|
const wasUnused = handlers.size === 0;
|
|
@@ -2626,6 +2831,7 @@ var CrossTabDataBus = class {
|
|
|
2626
2831
|
/** Publish a message to `topic`. The owning Worker delivers it to the transport. */
|
|
2627
2832
|
publish(topic, data, options) {
|
|
2628
2833
|
this.ensureStarted();
|
|
2834
|
+
if (this.rejectPublishDuringStop("publish")) return;
|
|
2629
2835
|
if (!this.cluster.publish(topic, data, options)) {
|
|
2630
2836
|
this.reportError(
|
|
2631
2837
|
new Error("Failed to send the publish control message to the owning worker.")
|
|
@@ -2642,6 +2848,7 @@ var CrossTabDataBus = class {
|
|
|
2642
2848
|
publishBatch(topic, items) {
|
|
2643
2849
|
this.ensureStarted();
|
|
2644
2850
|
if (items.length === 0) return;
|
|
2851
|
+
if (this.rejectPublishDuringStop("publishBatch")) return;
|
|
2645
2852
|
if (items.length === 1) {
|
|
2646
2853
|
const first = items[0];
|
|
2647
2854
|
this.publish(topic, first.data, first.options);
|
|
@@ -2676,11 +2883,15 @@ var CrossTabDataBus = class {
|
|
|
2676
2883
|
getStatus() {
|
|
2677
2884
|
return this.status;
|
|
2678
2885
|
}
|
|
2679
|
-
/** Return the current automatic transport recovery state. `hasError` means a transport error is currently retained. */
|
|
2680
2886
|
/** Return the current automatic transport recovery state plus diagnostics.
|
|
2681
|
-
* `
|
|
2682
|
-
*
|
|
2683
|
-
*
|
|
2887
|
+
* `hasError`/`errorMessage`/`errorAt` describe the most recent retained
|
|
2888
|
+
* *transport* failure — from a transport open or a runtime `onError`. They
|
|
2889
|
+
* share the lifetime of the unified `lastFailure` ledger: a successful
|
|
2890
|
+
* recovery keeps the last failure visible, and only an explicit `start()`
|
|
2891
|
+
* clears it. `generation` increments on every successful transport open
|
|
2892
|
+
* (initial start and every recovery); `lastSuccessAt` is the timestamp of
|
|
2893
|
+
* the most recent successful open, or `null` until the transport reaches
|
|
2894
|
+
* `ready`. */
|
|
2684
2895
|
getRecoveryStats() {
|
|
2685
2896
|
const errorMessage = this.lastError instanceof Error ? this.lastError.message : this.lastError === null ? null : String(this.lastError);
|
|
2686
2897
|
return {
|
|
@@ -2707,7 +2918,7 @@ var CrossTabDataBus = class {
|
|
|
2707
2918
|
* unified failure ledger and recovery context that explains the verdict. */
|
|
2708
2919
|
getHealthSummary() {
|
|
2709
2920
|
const transport = this.transport;
|
|
2710
|
-
const transportDown =
|
|
2921
|
+
const transportDown = this.status !== WORKER_STATUS.CONNECTED;
|
|
2711
2922
|
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;
|
|
2712
2923
|
return {
|
|
2713
2924
|
healthy: state === HEALTH_STATE.HEALTHY,
|
|
@@ -2770,11 +2981,35 @@ var CrossTabDataBus = class {
|
|
|
2770
2981
|
}
|
|
2771
2982
|
/**
|
|
2772
2983
|
* Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
|
|
2773
|
-
* and close the transport.
|
|
2984
|
+
* and close the transport. Concurrent and repeated calls share the in-flight
|
|
2985
|
+
* stop promise. A start() received while stopping runs after this completes,
|
|
2986
|
+
* unless another stop() arrives first and cancels that queued restart.
|
|
2774
2987
|
*/
|
|
2775
|
-
|
|
2776
|
-
if (
|
|
2988
|
+
stop() {
|
|
2989
|
+
if (this.queuedStart) {
|
|
2990
|
+
this.canceledQueuedStartToken = this.queuedStartToken;
|
|
2991
|
+
this.queuedStart = null;
|
|
2992
|
+
}
|
|
2993
|
+
if (this.stopPromise) return this.stopPromise;
|
|
2994
|
+
if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
|
|
2995
|
+
return Promise.resolve();
|
|
2996
|
+
}
|
|
2997
|
+
const stopPromise = this.performStop();
|
|
2998
|
+
this.stopPromise = stopPromise;
|
|
2999
|
+
void stopPromise.then(
|
|
3000
|
+
() => {
|
|
3001
|
+
if (this.stopPromise === stopPromise) this.stopPromise = null;
|
|
3002
|
+
},
|
|
3003
|
+
() => {
|
|
3004
|
+
if (this.stopPromise === stopPromise) this.stopPromise = null;
|
|
3005
|
+
}
|
|
3006
|
+
);
|
|
3007
|
+
return stopPromise;
|
|
3008
|
+
}
|
|
3009
|
+
async performStop() {
|
|
3010
|
+
this.lifecycleEpoch += 1;
|
|
2777
3011
|
this.stopping = true;
|
|
3012
|
+
this.cancelScheduledRecovery();
|
|
2778
3013
|
this.replayManager.suspend();
|
|
2779
3014
|
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
|
|
2780
3015
|
this.trace.stop();
|
|
@@ -2787,6 +3022,8 @@ var CrossTabDataBus = class {
|
|
|
2787
3022
|
const pendingStop = this.pendingStop;
|
|
2788
3023
|
if (pendingStop) await pendingStop.catch(() => void 0);
|
|
2789
3024
|
else await this.transport.stop();
|
|
3025
|
+
} catch (error) {
|
|
3026
|
+
this.reportError(error);
|
|
2790
3027
|
} finally {
|
|
2791
3028
|
this.transportSubscribedTopics.clear();
|
|
2792
3029
|
this.resetDedup();
|
|
@@ -2852,10 +3089,12 @@ var CrossTabDataBus = class {
|
|
|
2852
3089
|
updateStatus(status) {
|
|
2853
3090
|
const previousStatus = this.status;
|
|
2854
3091
|
this.status = status;
|
|
3092
|
+
if (status === WORKER_STATUS.CONNECTED) this.transportHasConnected = true;
|
|
2855
3093
|
if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
|
|
2856
3094
|
this.cluster.setStatus(status);
|
|
2857
3095
|
if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
|
|
2858
3096
|
if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
|
|
3097
|
+
if (this.transportReady) this.releaseRecoveryGate();
|
|
2859
3098
|
for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
|
|
2860
3099
|
}
|
|
2861
3100
|
if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
|
|
@@ -2868,23 +3107,49 @@ var CrossTabDataBus = class {
|
|
|
2868
3107
|
this.recoveryExhausted = true;
|
|
2869
3108
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
|
|
2870
3109
|
}
|
|
3110
|
+
this.releaseRecoveryGate();
|
|
2871
3111
|
return;
|
|
2872
3112
|
}
|
|
2873
3113
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
3114
|
+
if (this.recoveryGate === null) {
|
|
3115
|
+
let release;
|
|
3116
|
+
this.recoveryGate = new Promise((resolve) => {
|
|
3117
|
+
release = resolve;
|
|
3118
|
+
});
|
|
3119
|
+
this.recoveryGateRelease = release;
|
|
3120
|
+
}
|
|
3121
|
+
this.recoveryDemandAllowed = false;
|
|
3122
|
+
const timerToken = ++this.recoveryTimerToken;
|
|
3123
|
+
this.recoveryTimer = setTimeout(() => {
|
|
3124
|
+
if (timerToken !== this.recoveryTimerToken) return;
|
|
3125
|
+
this.recoveryTimer = null;
|
|
3126
|
+
if (this.stopping || !this.started || this.suspended || this.status !== WORKER_STATUS.ERROR) {
|
|
3127
|
+
this.releaseRecoveryGate();
|
|
3128
|
+
return;
|
|
3129
|
+
}
|
|
3130
|
+
this.recoveryDemandAllowed = false;
|
|
3131
|
+
const opening = this.reopenTransport(attempt);
|
|
3132
|
+
void opening.then(
|
|
3133
|
+
() => this.releaseRecoveryGate(),
|
|
3134
|
+
() => this.allowDemandRecovery()
|
|
3135
|
+
);
|
|
2878
3136
|
}, this.recoveryCooldownMs);
|
|
2879
3137
|
}
|
|
3138
|
+
} else if (status === WORKER_STATUS.ERROR) {
|
|
3139
|
+
this.releaseRecoveryGate();
|
|
2880
3140
|
}
|
|
2881
3141
|
this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
|
|
2882
3142
|
}
|
|
2883
3143
|
reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
|
|
3144
|
+
const at = this.now();
|
|
3145
|
+
if (source === FAILURE_SOURCE.TRANSPORT) {
|
|
3146
|
+
this.lastError = error;
|
|
3147
|
+
this.lastErrorAt = at;
|
|
3148
|
+
}
|
|
2884
3149
|
this.lastFailure = {
|
|
2885
3150
|
source,
|
|
2886
3151
|
message: error instanceof Error ? error.message : String(error),
|
|
2887
|
-
at
|
|
3152
|
+
at
|
|
2888
3153
|
};
|
|
2889
3154
|
if (source === FAILURE_SOURCE.PERSISTENCE) {
|
|
2890
3155
|
this.persistenceFailureCount += 1;
|
|
@@ -2965,7 +3230,9 @@ var CrossTabDataBus = class {
|
|
|
2965
3230
|
*/
|
|
2966
3231
|
suspendTransport() {
|
|
2967
3232
|
if (this.stopping) return;
|
|
3233
|
+
this.lifecycleEpoch += 1;
|
|
2968
3234
|
this.suspended = true;
|
|
3235
|
+
this.cancelScheduledRecovery();
|
|
2969
3236
|
this.transportReady = false;
|
|
2970
3237
|
this.transportSubscribedTopics.clear();
|
|
2971
3238
|
this.updateStatus(WORKER_STATUS.DISCONNECTED);
|
|
@@ -3003,19 +3270,23 @@ var CrossTabDataBus = class {
|
|
|
3003
3270
|
this.started = true;
|
|
3004
3271
|
this.suspended = false;
|
|
3005
3272
|
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
3273
|
+
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
3006
3274
|
const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
|
|
3007
|
-
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
|
|
3275
|
+
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
|
|
3008
3276
|
this.startPromise = opening;
|
|
3009
3277
|
void opening.then(
|
|
3010
3278
|
() => {
|
|
3279
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
3280
|
+
if (lifecycleEpoch !== this.lifecycleEpoch) return;
|
|
3011
3281
|
if (traceAttempt !== void 0) {
|
|
3012
3282
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
|
|
3013
3283
|
this.recoveryAttempt = 0;
|
|
3014
3284
|
this.recoveryExhausted = false;
|
|
3015
3285
|
}
|
|
3016
|
-
if (this.startPromise === opening) this.startPromise = null;
|
|
3017
3286
|
},
|
|
3018
3287
|
() => {
|
|
3288
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
3289
|
+
if (lifecycleEpoch !== this.lifecycleEpoch) return;
|
|
3019
3290
|
if (traceAttempt !== void 0) {
|
|
3020
3291
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
|
|
3021
3292
|
}
|
|
@@ -3031,7 +3302,24 @@ var CrossTabDataBus = class {
|
|
|
3031
3302
|
*/
|
|
3032
3303
|
runTransport(operation) {
|
|
3033
3304
|
if (this.suspended) return;
|
|
3034
|
-
if (this.
|
|
3305
|
+
if (this.recoveryGate && !this.stopping) {
|
|
3306
|
+
if (this.recoveryDemandAllowed && this.status === WORKER_STATUS.ERROR && !this.suspended) {
|
|
3307
|
+
this.recoveryDemandAllowed = false;
|
|
3308
|
+
const opening = this.reopenTransport();
|
|
3309
|
+
void opening.then(
|
|
3310
|
+
() => this.releaseRecoveryGate(),
|
|
3311
|
+
() => this.allowDemandRecovery()
|
|
3312
|
+
);
|
|
3313
|
+
}
|
|
3314
|
+
const gate = this.recoveryGate;
|
|
3315
|
+
void gate.then(() => {
|
|
3316
|
+
if (this.stopping || this.suspended) return;
|
|
3317
|
+
this.runTransport(operation);
|
|
3318
|
+
});
|
|
3319
|
+
return;
|
|
3320
|
+
}
|
|
3321
|
+
const droppedAfterConnect = this.transportHasConnected && this.status === WORKER_STATUS.DISCONNECTED;
|
|
3322
|
+
if (this.transportReady && this.status !== WORKER_STATUS.ERROR && !droppedAfterConnect && !this.stopping) {
|
|
3035
3323
|
try {
|
|
3036
3324
|
void Promise.resolve(operation()).catch((error) => this.reportError(error));
|
|
3037
3325
|
} catch (error) {
|
|
@@ -3049,6 +3337,19 @@ var CrossTabDataBus = class {
|
|
|
3049
3337
|
return operation();
|
|
3050
3338
|
}).catch((error) => this.reportError(error));
|
|
3051
3339
|
}
|
|
3340
|
+
/**
|
|
3341
|
+
* Publications started after teardown begins cannot reach any transport.
|
|
3342
|
+
* Surface that as a normal asynchronous API failure instead of letting
|
|
3343
|
+
* runTransport() return silently. Empty publishBatch() calls remain a no-op
|
|
3344
|
+
* and are filtered by the caller before this check.
|
|
3345
|
+
*/
|
|
3346
|
+
rejectPublishDuringStop(operation) {
|
|
3347
|
+
if (!this.stopping) return false;
|
|
3348
|
+
this.reportError(new Error(
|
|
3349
|
+
`CrossTabDataBus is stopping; ${operation}() was not sent. Wait for stop() to resolve, then call start() before publishing again.`
|
|
3350
|
+
));
|
|
3351
|
+
return true;
|
|
3352
|
+
}
|
|
3052
3353
|
/**
|
|
3053
3354
|
* Ensure the DataBus is started, throwing if no initialConfig was provided.
|
|
3054
3355
|
* Called automatically by subscribe/publish/ready when autoStart is true.
|