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/index.cjs
CHANGED
|
@@ -1855,6 +1855,36 @@ function roundMs(value) {
|
|
|
1855
1855
|
return Math.round(value * 10) / 10;
|
|
1856
1856
|
}
|
|
1857
1857
|
|
|
1858
|
+
// src/core/replay-pruning.ts
|
|
1859
|
+
function pruneReplayHistory(messages, options) {
|
|
1860
|
+
const { maxPerTopic, pruneStrategy, retentionMs, now } = options;
|
|
1861
|
+
const ageEnabled = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
|
|
1862
|
+
if (!ageEnabled) {
|
|
1863
|
+
return messages.length > maxPerTopic ? messages.slice(-maxPerTopic) : messages;
|
|
1864
|
+
}
|
|
1865
|
+
const cutoff = now - retentionMs;
|
|
1866
|
+
let hasExpired = false;
|
|
1867
|
+
let timestamplessCount = 0;
|
|
1868
|
+
for (const message of messages) {
|
|
1869
|
+
if (message.timestamp === void 0) timestamplessCount += 1;
|
|
1870
|
+
else if (message.timestamp < cutoff) hasExpired = true;
|
|
1871
|
+
}
|
|
1872
|
+
let pruned = hasExpired ? messages.filter((message) => message.timestamp === void 0 || message.timestamp >= cutoff) : messages;
|
|
1873
|
+
if (pruneStrategy === PRUNE_STRATEGY.BOTH) {
|
|
1874
|
+
return pruned.length > maxPerTopic ? pruned.slice(-maxPerTopic) : pruned;
|
|
1875
|
+
}
|
|
1876
|
+
if (timestamplessCount <= maxPerTopic) return pruned;
|
|
1877
|
+
let timestamplessToDrop = timestamplessCount - maxPerTopic;
|
|
1878
|
+
pruned = pruned.filter((message) => {
|
|
1879
|
+
if (message.timestamp === void 0 && timestamplessToDrop > 0) {
|
|
1880
|
+
timestamplessToDrop -= 1;
|
|
1881
|
+
return false;
|
|
1882
|
+
}
|
|
1883
|
+
return true;
|
|
1884
|
+
});
|
|
1885
|
+
return pruned;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1858
1888
|
// src/core/replay-manager.ts
|
|
1859
1889
|
var PersistenceRetryCancelledError = class extends Error {
|
|
1860
1890
|
constructor() {
|
|
@@ -1916,17 +1946,15 @@ var ReplayManager = class {
|
|
|
1916
1946
|
this.buffers.set(message.topic, buffer);
|
|
1917
1947
|
}
|
|
1918
1948
|
buffer.push(message);
|
|
1919
|
-
const
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
buffer.shift();
|
|
1929
|
-
}
|
|
1949
|
+
const pruned = pruneReplayHistory(buffer, {
|
|
1950
|
+
maxPerTopic: this.maxPerTopic,
|
|
1951
|
+
pruneStrategy: this.pruneStrategy,
|
|
1952
|
+
retentionMs: this.retentionMs,
|
|
1953
|
+
now: this.now()
|
|
1954
|
+
});
|
|
1955
|
+
if (pruned !== buffer) {
|
|
1956
|
+
buffer = pruned;
|
|
1957
|
+
this.buffers.set(message.topic, buffer);
|
|
1930
1958
|
}
|
|
1931
1959
|
if (!this.persistence) return;
|
|
1932
1960
|
if (this.persistence.appendBatch) {
|
|
@@ -2107,14 +2135,24 @@ var ReplayManager = class {
|
|
|
2107
2135
|
if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
|
|
2108
2136
|
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
|
|
2109
2137
|
}
|
|
2110
|
-
|
|
2138
|
+
const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
|
|
2139
|
+
for (const message of loaded) {
|
|
2111
2140
|
let buffer = this.buffers.get(message.topic);
|
|
2112
2141
|
if (!buffer) {
|
|
2113
2142
|
buffer = [];
|
|
2114
2143
|
this.buffers.set(message.topic, buffer);
|
|
2115
2144
|
}
|
|
2116
2145
|
buffer.push(message);
|
|
2117
|
-
|
|
2146
|
+
}
|
|
2147
|
+
const hydrationNow = this.now();
|
|
2148
|
+
for (const [topic, buffer] of this.buffers) {
|
|
2149
|
+
const pruned = pruneReplayHistory(buffer, {
|
|
2150
|
+
maxPerTopic: this.maxPerTopic,
|
|
2151
|
+
pruneStrategy: this.pruneStrategy,
|
|
2152
|
+
retentionMs: this.retentionMs,
|
|
2153
|
+
now: hydrationNow
|
|
2154
|
+
});
|
|
2155
|
+
if (pruned !== buffer) this.buffers.set(topic, pruned);
|
|
2118
2156
|
}
|
|
2119
2157
|
} catch (error) {
|
|
2120
2158
|
this.onPersistenceError(error);
|
|
@@ -2291,7 +2329,7 @@ var DedupManager = class {
|
|
|
2291
2329
|
};
|
|
2292
2330
|
|
|
2293
2331
|
// src/core/version.ts
|
|
2294
|
-
var SDK_VERSION = true ? "0.20.
|
|
2332
|
+
var SDK_VERSION = true ? "0.20.87" : "";
|
|
2295
2333
|
|
|
2296
2334
|
// src/core/data-bus.ts
|
|
2297
2335
|
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
@@ -2316,6 +2354,11 @@ var CrossTabDataBus = class {
|
|
|
2316
2354
|
started = false;
|
|
2317
2355
|
stopping = false;
|
|
2318
2356
|
transportReady = false;
|
|
2357
|
+
// Whether the installed transport has reported `connected` at least once
|
|
2358
|
+
// since the current open began. A clean `disconnected` after this point is
|
|
2359
|
+
// a lost working connection, not the pre-connect window of a worker-style
|
|
2360
|
+
// backend whose start() resolves before it reports the connection.
|
|
2361
|
+
transportHasConnected = false;
|
|
2319
2362
|
// Last transport failure, retained so ready() can surface it to callers who
|
|
2320
2363
|
// never awaited start() directly. Cleared on the next successful start.
|
|
2321
2364
|
lastError = null;
|
|
@@ -2329,6 +2372,24 @@ var CrossTabDataBus = class {
|
|
|
2329
2372
|
// Gate that serialises start/stop/suspend/resume — only one lifecycle
|
|
2330
2373
|
// transition at a time. Resets to null once the operation settles.
|
|
2331
2374
|
startPromise = null;
|
|
2375
|
+
// Gate for an explicit stop(). Concurrent stop() calls share it, and a
|
|
2376
|
+
// start() received while stopping chains a fresh start after it.
|
|
2377
|
+
stopPromise = null;
|
|
2378
|
+
// A start() requested while an explicit stop() is still settling. Kept
|
|
2379
|
+
// separate from startPromise because stop()'s finally block clears the
|
|
2380
|
+
// ordinary lifecycle gate before the queued start is allowed to run.
|
|
2381
|
+
queuedStart = null;
|
|
2382
|
+
// Lazy readiness view of queuedStart. start() keeps its documented
|
|
2383
|
+
// resolve-on-cancellation contract, while ready() must reject when the
|
|
2384
|
+
// queued intent was superseded by a later stop().
|
|
2385
|
+
queuedStartReady = null;
|
|
2386
|
+
queuedStartReadyToken = 0;
|
|
2387
|
+
// The queued continuation is chained to the stop promise and cannot be
|
|
2388
|
+
// un-scheduled once scheduled. A later stop() therefore invalidates the
|
|
2389
|
+
// current intent by recording its token; a subsequent start() issues a
|
|
2390
|
+
// higher token so the latest lifecycle request still wins.
|
|
2391
|
+
queuedStartToken = 0;
|
|
2392
|
+
canceledQueuedStartToken = 0;
|
|
2332
2393
|
// Timestamp of the last automatic transport recovery attempt.
|
|
2333
2394
|
// Used to avoid a tight retry loop when the transport fails repeatedly.
|
|
2334
2395
|
lastRecoveryAt = 0;
|
|
@@ -2336,6 +2397,19 @@ var CrossTabDataBus = class {
|
|
|
2336
2397
|
// a transport reopen succeeds so traces can correlate repeated failures.
|
|
2337
2398
|
recoveryAttempt = 0;
|
|
2338
2399
|
recoveryExhausted = false;
|
|
2400
|
+
// Gate that holds transport operations issued after a runtime `error` until
|
|
2401
|
+
// the scheduled recovery attempt has actually run. Without it, a dead
|
|
2402
|
+
// transport still has `transportReady === true` during the cooldown, so
|
|
2403
|
+
// publishes/subscribes would be written to the failed connection and lost.
|
|
2404
|
+
recoveryGate = null;
|
|
2405
|
+
recoveryGateRelease = null;
|
|
2406
|
+
recoveryTimer = null;
|
|
2407
|
+
recoveryTimerToken = 0;
|
|
2408
|
+
// Once an automatic attempt fails, an explicit transport operation may
|
|
2409
|
+
// recover immediately instead of waiting for the next paced attempt. The
|
|
2410
|
+
// gate still stays closed so the operation cannot reach the failed
|
|
2411
|
+
// transport; it is released by the successful on-demand reopen.
|
|
2412
|
+
recoveryDemandAllowed = false;
|
|
2339
2413
|
/** Monotonic generation incremented on every successful transport open.
|
|
2340
2414
|
* Stays in lockstep with `lastSuccessAt` so callers can detect that the
|
|
2341
2415
|
* transport has been reopened even if the timestamp window is short. */
|
|
@@ -2351,6 +2425,9 @@ var CrossTabDataBus = class {
|
|
|
2351
2425
|
// surfaces a failure while later opens and automatic recovery wait for the
|
|
2352
2426
|
// stop to settle.
|
|
2353
2427
|
pendingStop = null;
|
|
2428
|
+
// Ownership token for asynchronous transport opens. Every lifecycle
|
|
2429
|
+
// transition invalidates callbacks and failure cleanup from older opens.
|
|
2430
|
+
lifecycleEpoch = 0;
|
|
2354
2431
|
// Minimum interval in ms between automatic recovery attempts.
|
|
2355
2432
|
recoveryCooldownMs;
|
|
2356
2433
|
recoveryMaxAttempts;
|
|
@@ -2463,37 +2540,51 @@ var CrossTabDataBus = class {
|
|
|
2463
2540
|
* Start the DataBus with the given transport config.
|
|
2464
2541
|
*
|
|
2465
2542
|
* The first call starts the cluster and opens the transport. Concurrent calls
|
|
2466
|
-
* during an in-flight
|
|
2467
|
-
*
|
|
2468
|
-
*
|
|
2543
|
+
* during an in-flight open return the same promise. A call received while an
|
|
2544
|
+
* explicit stop() is settling queues one fresh start after cleanup; a later
|
|
2545
|
+
* stop() before that queued start runs cancels it, so the latest lifecycle
|
|
2546
|
+
* intent wins. Once an operation settles (success or failure) its promise
|
|
2547
|
+
* gate is cleared so a subsequent start() or resumeTransport() can open a
|
|
2548
|
+
* fresh lifecycle.
|
|
2469
2549
|
*/
|
|
2470
2550
|
start(config) {
|
|
2471
|
-
if (this.
|
|
2472
|
-
if (this.
|
|
2551
|
+
if (this.queuedStart) return this.queuedStart;
|
|
2552
|
+
if (this.stopping) return this.queueStartAfterStop(config);
|
|
2553
|
+
if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
|
|
2554
|
+
if (this.started) {
|
|
2555
|
+
const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
|
|
2556
|
+
if (!transportDown) return Promise.resolve();
|
|
2557
|
+
this.activeConfig = config;
|
|
2558
|
+
this.resetFailureState();
|
|
2559
|
+
return this.reopenTransport();
|
|
2560
|
+
}
|
|
2473
2561
|
this.started = true;
|
|
2474
2562
|
this.stopping = false;
|
|
2475
2563
|
this.suspended = false;
|
|
2476
2564
|
this.activeConfig = config;
|
|
2477
|
-
this.
|
|
2478
|
-
this.lastFailure = null;
|
|
2479
|
-
this.persistenceFailureCount = 0;
|
|
2480
|
-
this.persistenceLastFailureAt = null;
|
|
2481
|
-
this.persistenceLastErrorMessage = null;
|
|
2565
|
+
this.resetFailureState();
|
|
2482
2566
|
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
|
|
2483
2567
|
this.trace.start();
|
|
2484
2568
|
this.startDedupSweep();
|
|
2485
2569
|
this.replayManager.start();
|
|
2486
2570
|
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
2487
2571
|
this.cluster.start();
|
|
2488
|
-
const
|
|
2572
|
+
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
2573
|
+
const opening = this.openTransport(
|
|
2574
|
+
config,
|
|
2575
|
+
this.pendingStop ?? Promise.resolve(),
|
|
2576
|
+
true,
|
|
2577
|
+
lifecycleEpoch
|
|
2578
|
+
);
|
|
2489
2579
|
this.startPromise = opening;
|
|
2490
2580
|
for (const topic of this.topicHandlers.keys()) {
|
|
2491
2581
|
this.cluster.subscribe(topic);
|
|
2492
2582
|
}
|
|
2493
2583
|
void opening.then(
|
|
2494
2584
|
() => {
|
|
2585
|
+
if (this.startPromise !== opening) return;
|
|
2495
2586
|
this.emitCoordinationTrace();
|
|
2496
|
-
|
|
2587
|
+
this.startPromise = null;
|
|
2497
2588
|
},
|
|
2498
2589
|
() => {
|
|
2499
2590
|
if (this.startPromise === opening) this.startPromise = null;
|
|
@@ -2501,24 +2592,111 @@ var CrossTabDataBus = class {
|
|
|
2501
2592
|
);
|
|
2502
2593
|
return opening;
|
|
2503
2594
|
}
|
|
2595
|
+
/** Return a cancellation-aware readiness view of the current queued start. */
|
|
2596
|
+
getQueuedStartReady() {
|
|
2597
|
+
const queued = this.queuedStart;
|
|
2598
|
+
if (!queued) {
|
|
2599
|
+
return Promise.reject(new Error("No queued start is in flight."));
|
|
2600
|
+
}
|
|
2601
|
+
const token = this.queuedStartToken;
|
|
2602
|
+
if (this.queuedStartReady && this.queuedStartReadyToken === token) {
|
|
2603
|
+
return this.queuedStartReady;
|
|
2604
|
+
}
|
|
2605
|
+
this.queuedStartReadyToken = token;
|
|
2606
|
+
this.queuedStartReady = queued.then(() => {
|
|
2607
|
+
if (token <= this.canceledQueuedStartToken) {
|
|
2608
|
+
throw new Error(
|
|
2609
|
+
"CrossTabDataBus start was canceled by a later stop(); ready() cannot report readiness. Call start() again after stop() resolves."
|
|
2610
|
+
);
|
|
2611
|
+
}
|
|
2612
|
+
if (!this.started || !this.transportReady) {
|
|
2613
|
+
throw new Error("CrossTabDataBus restart completed without a ready transport.");
|
|
2614
|
+
}
|
|
2615
|
+
});
|
|
2616
|
+
return this.queuedStartReady;
|
|
2617
|
+
}
|
|
2618
|
+
/** Queue exactly one fresh start after an in-flight explicit stop settles. */
|
|
2619
|
+
queueStartAfterStop(config) {
|
|
2620
|
+
if (this.queuedStart) return this.queuedStart;
|
|
2621
|
+
const stop = this.stopPromise ?? Promise.resolve();
|
|
2622
|
+
const token = ++this.queuedStartToken;
|
|
2623
|
+
const queued = stop.catch(() => void 0).then(() => {
|
|
2624
|
+
if (this.queuedStart === queued) this.queuedStart = null;
|
|
2625
|
+
if (token <= this.canceledQueuedStartToken) return;
|
|
2626
|
+
return this.start(config);
|
|
2627
|
+
});
|
|
2628
|
+
this.queuedStart = queued;
|
|
2629
|
+
return queued;
|
|
2630
|
+
}
|
|
2631
|
+
/** Release every operation waiting on the scheduled recovery attempt. */
|
|
2632
|
+
releaseRecoveryGate() {
|
|
2633
|
+
const release = this.recoveryGateRelease;
|
|
2634
|
+
this.recoveryGate = null;
|
|
2635
|
+
this.recoveryGateRelease = null;
|
|
2636
|
+
this.recoveryDemandAllowed = false;
|
|
2637
|
+
release?.();
|
|
2638
|
+
}
|
|
2639
|
+
/** Cancel a pending automatic retry when an explicit lifecycle transition
|
|
2640
|
+
* supersedes it. The released gate re-enters runTransport(), which then
|
|
2641
|
+
* follows the newest start/stop/suspend intent. */
|
|
2642
|
+
cancelScheduledRecovery() {
|
|
2643
|
+
this.recoveryTimerToken += 1;
|
|
2644
|
+
if (this.recoveryTimer !== null) {
|
|
2645
|
+
clearTimeout(this.recoveryTimer);
|
|
2646
|
+
this.recoveryTimer = null;
|
|
2647
|
+
}
|
|
2648
|
+
this.releaseRecoveryGate();
|
|
2649
|
+
}
|
|
2650
|
+
/** Keep the recovery gate closed after a failed attempt while allowing the
|
|
2651
|
+
* next explicit transport operation to start an immediate on-demand reopen.
|
|
2652
|
+
* If no gate/successor retry remains, release any waiters. */
|
|
2653
|
+
allowDemandRecovery() {
|
|
2654
|
+
if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
|
|
2655
|
+
this.recoveryDemandAllowed = true;
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
this.releaseRecoveryGate();
|
|
2659
|
+
}
|
|
2660
|
+
/** Reset failure and recovery diagnostics for a new explicit start session. */
|
|
2661
|
+
resetFailureState() {
|
|
2662
|
+
this.cancelScheduledRecovery();
|
|
2663
|
+
this.lastError = null;
|
|
2664
|
+
this.lastErrorAt = null;
|
|
2665
|
+
this.lastFailure = null;
|
|
2666
|
+
this.persistenceFailureCount = 0;
|
|
2667
|
+
this.persistenceLastFailureAt = null;
|
|
2668
|
+
this.persistenceLastErrorMessage = null;
|
|
2669
|
+
this.recoveryAttempt = 0;
|
|
2670
|
+
this.recoveryExhausted = false;
|
|
2671
|
+
this.lastRecoveryAt = 0;
|
|
2672
|
+
}
|
|
2504
2673
|
/**
|
|
2505
2674
|
* Open the transport, chained after `before` to ensure lifecycle ordering.
|
|
2506
2675
|
* When `stopClusterOnFailure` is true (initial start), a transport failure
|
|
2507
2676
|
* tears down the cluster as well.
|
|
2508
2677
|
*/
|
|
2509
|
-
openTransport(config, before, stopClusterOnFailure) {
|
|
2678
|
+
openTransport(config, before, stopClusterOnFailure, lifecycleEpoch) {
|
|
2510
2679
|
this.transportReady = false;
|
|
2511
2680
|
const chainedPendingStop = this.pendingStop;
|
|
2681
|
+
const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
|
|
2512
2682
|
return before.catch(() => void 0).then(() => {
|
|
2513
|
-
if (this.stopping || this.suspended) return;
|
|
2683
|
+
if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
|
|
2514
2684
|
if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
|
|
2685
|
+
this.transportHasConnected = false;
|
|
2515
2686
|
return Promise.resolve(
|
|
2516
2687
|
this.transport.start(config, {
|
|
2517
|
-
onMessage: (message) =>
|
|
2518
|
-
|
|
2519
|
-
|
|
2688
|
+
onMessage: (message) => {
|
|
2689
|
+
if (isCurrentLifecycle()) this.handleTransportMessage(message);
|
|
2690
|
+
},
|
|
2691
|
+
onStatus: (status) => {
|
|
2692
|
+
if (isCurrentLifecycle()) this.updateStatus(status);
|
|
2693
|
+
},
|
|
2694
|
+
onError: (error) => {
|
|
2695
|
+
if (isCurrentLifecycle()) this.reportError(error);
|
|
2696
|
+
}
|
|
2520
2697
|
})
|
|
2521
2698
|
).then(() => {
|
|
2699
|
+
if (!isCurrentLifecycle()) return;
|
|
2522
2700
|
if (this.status === WORKER_STATUS.ERROR) {
|
|
2523
2701
|
throw new Error("Transport failed during startup.");
|
|
2524
2702
|
}
|
|
@@ -2526,33 +2704,51 @@ var CrossTabDataBus = class {
|
|
|
2526
2704
|
this.recoveryGeneration += 1;
|
|
2527
2705
|
this.lastSuccessAt = this.now();
|
|
2528
2706
|
this.transportReady = true;
|
|
2707
|
+
this.releaseRecoveryGate();
|
|
2529
2708
|
}
|
|
2530
2709
|
});
|
|
2531
2710
|
}).catch((error) => {
|
|
2711
|
+
if (!isCurrentLifecycle()) throw error;
|
|
2532
2712
|
if (stopClusterOnFailure) this.started = false;
|
|
2533
2713
|
if (!this.pendingStop) {
|
|
2534
2714
|
this.pendingStop = this.createStopPromise();
|
|
2535
2715
|
}
|
|
2716
|
+
this.transportReady = false;
|
|
2536
2717
|
this.updateStatus(WORKER_STATUS.ERROR);
|
|
2537
2718
|
this.reportError(error);
|
|
2538
|
-
this.lastError = error;
|
|
2539
|
-
this.lastErrorAt = this.now();
|
|
2540
|
-
this.transportReady = false;
|
|
2541
2719
|
if (stopClusterOnFailure) {
|
|
2542
2720
|
this.stopping = true;
|
|
2543
2721
|
this.cluster.stop();
|
|
2544
2722
|
this.stopping = false;
|
|
2545
2723
|
}
|
|
2546
|
-
this.startPromise = null;
|
|
2547
2724
|
throw error;
|
|
2548
2725
|
});
|
|
2549
2726
|
}
|
|
2550
2727
|
/**
|
|
2551
2728
|
* Await the DataBus to be fully started (lazy init when using initialConfig).
|
|
2552
2729
|
* Returns a rejected promise when the transport has failed and no start is in
|
|
2553
|
-
* flight — the caller can retry by calling start() or ready() again.
|
|
2730
|
+
* flight — the caller can retry by calling start() or ready() again. While an
|
|
2731
|
+
* explicit stop() is settling, this rejects unless a restart is queued behind
|
|
2732
|
+
* it; false readiness during teardown is never reported. While the tab is
|
|
2733
|
+
* BFCache-suspended (pagehide without a following pageshow), this also
|
|
2734
|
+
* rejects: the suspended start promise is the transport-stop gate, not a
|
|
2735
|
+
* readiness signal.
|
|
2554
2736
|
*/
|
|
2555
2737
|
ready() {
|
|
2738
|
+
if (this.queuedStart) return this.getQueuedStartReady();
|
|
2739
|
+
if (this.stopping) {
|
|
2740
|
+
return Promise.reject(new Error(
|
|
2741
|
+
"CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
|
|
2742
|
+
));
|
|
2743
|
+
}
|
|
2744
|
+
if (this.suspended) {
|
|
2745
|
+
return Promise.reject(new Error(
|
|
2746
|
+
"CrossTabDataBus is suspended; ready() cannot report readiness until pageshow resumes the transport."
|
|
2747
|
+
));
|
|
2748
|
+
}
|
|
2749
|
+
if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
|
|
2750
|
+
return Promise.reject(this.lastError);
|
|
2751
|
+
}
|
|
2556
2752
|
try {
|
|
2557
2753
|
this.ensureStarted();
|
|
2558
2754
|
} catch (error) {
|
|
@@ -2568,9 +2764,18 @@ var CrossTabDataBus = class {
|
|
|
2568
2764
|
/**
|
|
2569
2765
|
* Register a handler for `topic`. The handler fires on every publication
|
|
2570
2766
|
* delivered to this tab, regardless of which tab published it. Returns an
|
|
2571
|
-
* unsubscribe function for convenience.
|
|
2767
|
+
* unsubscribe function for convenience. During an explicit stop() the
|
|
2768
|
+
* registration is rejected through onError and a no-op cleanup is returned,
|
|
2769
|
+
* so a late subscriber cannot leak into a future restart.
|
|
2572
2770
|
*/
|
|
2573
2771
|
subscribe(topic, handler, options) {
|
|
2772
|
+
if (this.stopping) {
|
|
2773
|
+
this.reportError(new Error(
|
|
2774
|
+
"CrossTabDataBus is stopping; subscribe() was not registered. Wait for stop() to resolve, then call start() before subscribing again."
|
|
2775
|
+
));
|
|
2776
|
+
return () => {
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2574
2779
|
this.ensureStarted();
|
|
2575
2780
|
const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
|
|
2576
2781
|
const wasUnused = handlers.size === 0;
|
|
@@ -2624,6 +2829,7 @@ var CrossTabDataBus = class {
|
|
|
2624
2829
|
/** Publish a message to `topic`. The owning Worker delivers it to the transport. */
|
|
2625
2830
|
publish(topic, data, options) {
|
|
2626
2831
|
this.ensureStarted();
|
|
2832
|
+
if (this.rejectPublishDuringStop("publish")) return;
|
|
2627
2833
|
if (!this.cluster.publish(topic, data, options)) {
|
|
2628
2834
|
this.reportError(
|
|
2629
2835
|
new Error("Failed to send the publish control message to the owning worker.")
|
|
@@ -2640,6 +2846,7 @@ var CrossTabDataBus = class {
|
|
|
2640
2846
|
publishBatch(topic, items) {
|
|
2641
2847
|
this.ensureStarted();
|
|
2642
2848
|
if (items.length === 0) return;
|
|
2849
|
+
if (this.rejectPublishDuringStop("publishBatch")) return;
|
|
2643
2850
|
if (items.length === 1) {
|
|
2644
2851
|
const first = items[0];
|
|
2645
2852
|
this.publish(topic, first.data, first.options);
|
|
@@ -2674,11 +2881,15 @@ var CrossTabDataBus = class {
|
|
|
2674
2881
|
getStatus() {
|
|
2675
2882
|
return this.status;
|
|
2676
2883
|
}
|
|
2677
|
-
/** Return the current automatic transport recovery state. `hasError` means a transport error is currently retained. */
|
|
2678
2884
|
/** Return the current automatic transport recovery state plus diagnostics.
|
|
2679
|
-
* `
|
|
2680
|
-
*
|
|
2681
|
-
*
|
|
2885
|
+
* `hasError`/`errorMessage`/`errorAt` describe the most recent retained
|
|
2886
|
+
* *transport* failure — from a transport open or a runtime `onError`. They
|
|
2887
|
+
* share the lifetime of the unified `lastFailure` ledger: a successful
|
|
2888
|
+
* recovery keeps the last failure visible, and only an explicit `start()`
|
|
2889
|
+
* clears it. `generation` increments on every successful transport open
|
|
2890
|
+
* (initial start and every recovery); `lastSuccessAt` is the timestamp of
|
|
2891
|
+
* the most recent successful open, or `null` until the transport reaches
|
|
2892
|
+
* `ready`. */
|
|
2682
2893
|
getRecoveryStats() {
|
|
2683
2894
|
const errorMessage = this.lastError instanceof Error ? this.lastError.message : this.lastError === null ? null : String(this.lastError);
|
|
2684
2895
|
return {
|
|
@@ -2705,7 +2916,7 @@ var CrossTabDataBus = class {
|
|
|
2705
2916
|
* unified failure ledger and recovery context that explains the verdict. */
|
|
2706
2917
|
getHealthSummary() {
|
|
2707
2918
|
const transport = this.transport;
|
|
2708
|
-
const transportDown =
|
|
2919
|
+
const transportDown = this.status !== WORKER_STATUS.CONNECTED;
|
|
2709
2920
|
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;
|
|
2710
2921
|
return {
|
|
2711
2922
|
healthy: state === HEALTH_STATE.HEALTHY,
|
|
@@ -2768,11 +2979,35 @@ var CrossTabDataBus = class {
|
|
|
2768
2979
|
}
|
|
2769
2980
|
/**
|
|
2770
2981
|
* Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
|
|
2771
|
-
* and close the transport.
|
|
2982
|
+
* and close the transport. Concurrent and repeated calls share the in-flight
|
|
2983
|
+
* stop promise. A start() received while stopping runs after this completes,
|
|
2984
|
+
* unless another stop() arrives first and cancels that queued restart.
|
|
2772
2985
|
*/
|
|
2773
|
-
|
|
2774
|
-
if (
|
|
2986
|
+
stop() {
|
|
2987
|
+
if (this.queuedStart) {
|
|
2988
|
+
this.canceledQueuedStartToken = this.queuedStartToken;
|
|
2989
|
+
this.queuedStart = null;
|
|
2990
|
+
}
|
|
2991
|
+
if (this.stopPromise) return this.stopPromise;
|
|
2992
|
+
if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
|
|
2993
|
+
return Promise.resolve();
|
|
2994
|
+
}
|
|
2995
|
+
const stopPromise = this.performStop();
|
|
2996
|
+
this.stopPromise = stopPromise;
|
|
2997
|
+
void stopPromise.then(
|
|
2998
|
+
() => {
|
|
2999
|
+
if (this.stopPromise === stopPromise) this.stopPromise = null;
|
|
3000
|
+
},
|
|
3001
|
+
() => {
|
|
3002
|
+
if (this.stopPromise === stopPromise) this.stopPromise = null;
|
|
3003
|
+
}
|
|
3004
|
+
);
|
|
3005
|
+
return stopPromise;
|
|
3006
|
+
}
|
|
3007
|
+
async performStop() {
|
|
3008
|
+
this.lifecycleEpoch += 1;
|
|
2775
3009
|
this.stopping = true;
|
|
3010
|
+
this.cancelScheduledRecovery();
|
|
2776
3011
|
this.replayManager.suspend();
|
|
2777
3012
|
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
|
|
2778
3013
|
this.trace.stop();
|
|
@@ -2785,6 +3020,8 @@ var CrossTabDataBus = class {
|
|
|
2785
3020
|
const pendingStop = this.pendingStop;
|
|
2786
3021
|
if (pendingStop) await pendingStop.catch(() => void 0);
|
|
2787
3022
|
else await this.transport.stop();
|
|
3023
|
+
} catch (error) {
|
|
3024
|
+
this.reportError(error);
|
|
2788
3025
|
} finally {
|
|
2789
3026
|
this.transportSubscribedTopics.clear();
|
|
2790
3027
|
this.resetDedup();
|
|
@@ -2850,10 +3087,12 @@ var CrossTabDataBus = class {
|
|
|
2850
3087
|
updateStatus(status) {
|
|
2851
3088
|
const previousStatus = this.status;
|
|
2852
3089
|
this.status = status;
|
|
3090
|
+
if (status === WORKER_STATUS.CONNECTED) this.transportHasConnected = true;
|
|
2853
3091
|
if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
|
|
2854
3092
|
this.cluster.setStatus(status);
|
|
2855
3093
|
if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
|
|
2856
3094
|
if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
|
|
3095
|
+
if (this.transportReady) this.releaseRecoveryGate();
|
|
2857
3096
|
for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
|
|
2858
3097
|
}
|
|
2859
3098
|
if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
|
|
@@ -2866,23 +3105,49 @@ var CrossTabDataBus = class {
|
|
|
2866
3105
|
this.recoveryExhausted = true;
|
|
2867
3106
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
|
|
2868
3107
|
}
|
|
3108
|
+
this.releaseRecoveryGate();
|
|
2869
3109
|
return;
|
|
2870
3110
|
}
|
|
2871
3111
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
3112
|
+
if (this.recoveryGate === null) {
|
|
3113
|
+
let release;
|
|
3114
|
+
this.recoveryGate = new Promise((resolve) => {
|
|
3115
|
+
release = resolve;
|
|
3116
|
+
});
|
|
3117
|
+
this.recoveryGateRelease = release;
|
|
3118
|
+
}
|
|
3119
|
+
this.recoveryDemandAllowed = false;
|
|
3120
|
+
const timerToken = ++this.recoveryTimerToken;
|
|
3121
|
+
this.recoveryTimer = setTimeout(() => {
|
|
3122
|
+
if (timerToken !== this.recoveryTimerToken) return;
|
|
3123
|
+
this.recoveryTimer = null;
|
|
3124
|
+
if (this.stopping || !this.started || this.suspended || this.status !== WORKER_STATUS.ERROR) {
|
|
3125
|
+
this.releaseRecoveryGate();
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
this.recoveryDemandAllowed = false;
|
|
3129
|
+
const opening = this.reopenTransport(attempt);
|
|
3130
|
+
void opening.then(
|
|
3131
|
+
() => this.releaseRecoveryGate(),
|
|
3132
|
+
() => this.allowDemandRecovery()
|
|
3133
|
+
);
|
|
2876
3134
|
}, this.recoveryCooldownMs);
|
|
2877
3135
|
}
|
|
3136
|
+
} else if (status === WORKER_STATUS.ERROR) {
|
|
3137
|
+
this.releaseRecoveryGate();
|
|
2878
3138
|
}
|
|
2879
3139
|
this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
|
|
2880
3140
|
}
|
|
2881
3141
|
reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
|
|
3142
|
+
const at = this.now();
|
|
3143
|
+
if (source === FAILURE_SOURCE.TRANSPORT) {
|
|
3144
|
+
this.lastError = error;
|
|
3145
|
+
this.lastErrorAt = at;
|
|
3146
|
+
}
|
|
2882
3147
|
this.lastFailure = {
|
|
2883
3148
|
source,
|
|
2884
3149
|
message: error instanceof Error ? error.message : String(error),
|
|
2885
|
-
at
|
|
3150
|
+
at
|
|
2886
3151
|
};
|
|
2887
3152
|
if (source === FAILURE_SOURCE.PERSISTENCE) {
|
|
2888
3153
|
this.persistenceFailureCount += 1;
|
|
@@ -2963,7 +3228,9 @@ var CrossTabDataBus = class {
|
|
|
2963
3228
|
*/
|
|
2964
3229
|
suspendTransport() {
|
|
2965
3230
|
if (this.stopping) return;
|
|
3231
|
+
this.lifecycleEpoch += 1;
|
|
2966
3232
|
this.suspended = true;
|
|
3233
|
+
this.cancelScheduledRecovery();
|
|
2967
3234
|
this.transportReady = false;
|
|
2968
3235
|
this.transportSubscribedTopics.clear();
|
|
2969
3236
|
this.updateStatus(WORKER_STATUS.DISCONNECTED);
|
|
@@ -3001,19 +3268,23 @@ var CrossTabDataBus = class {
|
|
|
3001
3268
|
this.started = true;
|
|
3002
3269
|
this.suspended = false;
|
|
3003
3270
|
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
3271
|
+
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
3004
3272
|
const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
|
|
3005
|
-
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
|
|
3273
|
+
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
|
|
3006
3274
|
this.startPromise = opening;
|
|
3007
3275
|
void opening.then(
|
|
3008
3276
|
() => {
|
|
3277
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
3278
|
+
if (lifecycleEpoch !== this.lifecycleEpoch) return;
|
|
3009
3279
|
if (traceAttempt !== void 0) {
|
|
3010
3280
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
|
|
3011
3281
|
this.recoveryAttempt = 0;
|
|
3012
3282
|
this.recoveryExhausted = false;
|
|
3013
3283
|
}
|
|
3014
|
-
if (this.startPromise === opening) this.startPromise = null;
|
|
3015
3284
|
},
|
|
3016
3285
|
() => {
|
|
3286
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
3287
|
+
if (lifecycleEpoch !== this.lifecycleEpoch) return;
|
|
3017
3288
|
if (traceAttempt !== void 0) {
|
|
3018
3289
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
|
|
3019
3290
|
}
|
|
@@ -3029,7 +3300,24 @@ var CrossTabDataBus = class {
|
|
|
3029
3300
|
*/
|
|
3030
3301
|
runTransport(operation) {
|
|
3031
3302
|
if (this.suspended) return;
|
|
3032
|
-
if (this.
|
|
3303
|
+
if (this.recoveryGate && !this.stopping) {
|
|
3304
|
+
if (this.recoveryDemandAllowed && this.status === WORKER_STATUS.ERROR && !this.suspended) {
|
|
3305
|
+
this.recoveryDemandAllowed = false;
|
|
3306
|
+
const opening = this.reopenTransport();
|
|
3307
|
+
void opening.then(
|
|
3308
|
+
() => this.releaseRecoveryGate(),
|
|
3309
|
+
() => this.allowDemandRecovery()
|
|
3310
|
+
);
|
|
3311
|
+
}
|
|
3312
|
+
const gate = this.recoveryGate;
|
|
3313
|
+
void gate.then(() => {
|
|
3314
|
+
if (this.stopping || this.suspended) return;
|
|
3315
|
+
this.runTransport(operation);
|
|
3316
|
+
});
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3319
|
+
const droppedAfterConnect = this.transportHasConnected && this.status === WORKER_STATUS.DISCONNECTED;
|
|
3320
|
+
if (this.transportReady && this.status !== WORKER_STATUS.ERROR && !droppedAfterConnect && !this.stopping) {
|
|
3033
3321
|
try {
|
|
3034
3322
|
void Promise.resolve(operation()).catch((error) => this.reportError(error));
|
|
3035
3323
|
} catch (error) {
|
|
@@ -3047,6 +3335,19 @@ var CrossTabDataBus = class {
|
|
|
3047
3335
|
return operation();
|
|
3048
3336
|
}).catch((error) => this.reportError(error));
|
|
3049
3337
|
}
|
|
3338
|
+
/**
|
|
3339
|
+
* Publications started after teardown begins cannot reach any transport.
|
|
3340
|
+
* Surface that as a normal asynchronous API failure instead of letting
|
|
3341
|
+
* runTransport() return silently. Empty publishBatch() calls remain a no-op
|
|
3342
|
+
* and are filtered by the caller before this check.
|
|
3343
|
+
*/
|
|
3344
|
+
rejectPublishDuringStop(operation) {
|
|
3345
|
+
if (!this.stopping) return false;
|
|
3346
|
+
this.reportError(new Error(
|
|
3347
|
+
`CrossTabDataBus is stopping; ${operation}() was not sent. Wait for stop() to resolve, then call start() before publishing again.`
|
|
3348
|
+
));
|
|
3349
|
+
return true;
|
|
3350
|
+
}
|
|
3050
3351
|
/**
|
|
3051
3352
|
* Ensure the DataBus is started, throwing if no initialConfig was provided.
|
|
3052
3353
|
* Called automatically by subscribe/publish/ready when autoStart is true.
|
|
@@ -3152,35 +3453,29 @@ function createIndexedDbReplayPersistence(options) {
|
|
|
3152
3453
|
grouped.set(message.topic, [...grouped.get(message.topic) ?? [], message]);
|
|
3153
3454
|
}
|
|
3154
3455
|
let hasError = false;
|
|
3456
|
+
const fail = (error) => {
|
|
3457
|
+
if (hasError) return;
|
|
3458
|
+
hasError = true;
|
|
3459
|
+
invalidate(db);
|
|
3460
|
+
reject(error);
|
|
3461
|
+
};
|
|
3155
3462
|
for (const [topic, topicMessages] of grouped) {
|
|
3156
3463
|
const request = store.get(topic);
|
|
3157
3464
|
request.onsuccess = () => {
|
|
3158
3465
|
if (hasError) return;
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
history = history.filter((item) => item.timestamp === void 0 || item.timestamp >= cutoff);
|
|
3164
|
-
}
|
|
3165
|
-
if (pruneStrategy !== PRUNE_STRATEGY.AGE || !ageBounded) history = history.slice(-maxPerTopic);
|
|
3466
|
+
const history = pruneReplayHistory(
|
|
3467
|
+
(request.result?.messages ?? []).concat(topicMessages),
|
|
3468
|
+
{ maxPerTopic, pruneStrategy, retentionMs, now: Date.now() }
|
|
3469
|
+
);
|
|
3166
3470
|
store.put({ topic, messages: history });
|
|
3167
3471
|
};
|
|
3168
|
-
request.onerror = () =>
|
|
3169
|
-
if (hasError) return;
|
|
3170
|
-
hasError = true;
|
|
3171
|
-
invalidate(db);
|
|
3172
|
-
reject(request.error ?? new Error("Failed to read replay history."));
|
|
3173
|
-
};
|
|
3472
|
+
request.onerror = () => fail(request.error ?? new Error("Failed to read replay history."));
|
|
3174
3473
|
}
|
|
3175
3474
|
transaction.oncomplete = () => {
|
|
3176
3475
|
if (!hasError) resolve();
|
|
3177
3476
|
};
|
|
3178
|
-
transaction.onerror = () =>
|
|
3179
|
-
|
|
3180
|
-
hasError = true;
|
|
3181
|
-
invalidate(db);
|
|
3182
|
-
reject(transaction.error ?? new Error("Failed to persist replay history."));
|
|
3183
|
-
};
|
|
3477
|
+
transaction.onerror = () => fail(transaction.error ?? new Error("Failed to persist replay history."));
|
|
3478
|
+
transaction.onabort = () => fail(transaction.error ?? new Error("Failed to persist replay history."));
|
|
3184
3479
|
});
|
|
3185
3480
|
})();
|
|
3186
3481
|
const open = () => {
|
|
@@ -3208,19 +3503,34 @@ function createIndexedDbReplayPersistence(options) {
|
|
|
3208
3503
|
async load() {
|
|
3209
3504
|
const db = await open();
|
|
3210
3505
|
return new Promise((resolve, reject) => {
|
|
3506
|
+
let transaction;
|
|
3211
3507
|
let request;
|
|
3212
3508
|
try {
|
|
3213
|
-
|
|
3509
|
+
transaction = db.transaction(storeName, "readonly");
|
|
3510
|
+
request = transaction.objectStore(storeName).getAll();
|
|
3214
3511
|
} catch (error) {
|
|
3215
3512
|
invalidate(db);
|
|
3216
3513
|
reject(error);
|
|
3217
3514
|
return;
|
|
3218
3515
|
}
|
|
3219
|
-
|
|
3220
|
-
|
|
3516
|
+
let settled = false;
|
|
3517
|
+
const fail = (error) => {
|
|
3518
|
+
if (settled) return;
|
|
3519
|
+
settled = true;
|
|
3221
3520
|
invalidate(db);
|
|
3222
|
-
reject(
|
|
3521
|
+
reject(error);
|
|
3522
|
+
};
|
|
3523
|
+
let records = [];
|
|
3524
|
+
request.onsuccess = () => {
|
|
3525
|
+
records = request.result;
|
|
3223
3526
|
};
|
|
3527
|
+
request.onerror = () => fail(request.error ?? new Error("Failed to load replay history."));
|
|
3528
|
+
transaction.oncomplete = () => {
|
|
3529
|
+
if (settled) return;
|
|
3530
|
+
settled = true;
|
|
3531
|
+
resolve(records.flatMap((record) => record.messages));
|
|
3532
|
+
};
|
|
3533
|
+
transaction.onabort = () => fail(transaction.error ?? new Error("Failed to load replay history."));
|
|
3224
3534
|
});
|
|
3225
3535
|
},
|
|
3226
3536
|
append(message) {
|
|
@@ -3244,12 +3554,20 @@ function createIndexedDbReplayPersistence(options) {
|
|
|
3244
3554
|
reject(error);
|
|
3245
3555
|
return;
|
|
3246
3556
|
}
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3557
|
+
let settled = false;
|
|
3558
|
+
const fail = (error) => {
|
|
3559
|
+
if (settled) return;
|
|
3560
|
+
settled = true;
|
|
3250
3561
|
invalidate(db);
|
|
3251
|
-
reject(
|
|
3562
|
+
reject(error);
|
|
3252
3563
|
};
|
|
3564
|
+
transaction.objectStore(storeName).clear();
|
|
3565
|
+
transaction.oncomplete = () => {
|
|
3566
|
+
settled = true;
|
|
3567
|
+
resolve();
|
|
3568
|
+
};
|
|
3569
|
+
transaction.onerror = () => fail(transaction.error ?? new Error("Failed to clear replay history."));
|
|
3570
|
+
transaction.onabort = () => fail(transaction.error ?? new Error("Failed to clear replay history."));
|
|
3253
3571
|
});
|
|
3254
3572
|
})()
|
|
3255
3573
|
});
|
|
@@ -3268,12 +3586,20 @@ function createIndexedDbReplayPersistence(options) {
|
|
|
3268
3586
|
reject(error);
|
|
3269
3587
|
return;
|
|
3270
3588
|
}
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3589
|
+
let settled = false;
|
|
3590
|
+
const fail = (error) => {
|
|
3591
|
+
if (settled) return;
|
|
3592
|
+
settled = true;
|
|
3274
3593
|
invalidate(db);
|
|
3275
|
-
reject(
|
|
3594
|
+
reject(error);
|
|
3276
3595
|
};
|
|
3596
|
+
transaction.objectStore(storeName).delete(topic);
|
|
3597
|
+
transaction.oncomplete = () => {
|
|
3598
|
+
settled = true;
|
|
3599
|
+
resolve();
|
|
3600
|
+
};
|
|
3601
|
+
transaction.onerror = () => fail(transaction.error ?? new Error("Failed to clear topic replay history."));
|
|
3602
|
+
transaction.onabort = () => fail(transaction.error ?? new Error("Failed to clear topic replay history."));
|
|
3277
3603
|
});
|
|
3278
3604
|
})()
|
|
3279
3605
|
});
|
|
@@ -3292,6 +3618,13 @@ function createIndexedDbReplayPersistence(options) {
|
|
|
3292
3618
|
reject(error);
|
|
3293
3619
|
return;
|
|
3294
3620
|
}
|
|
3621
|
+
let settled = false;
|
|
3622
|
+
const fail = (error) => {
|
|
3623
|
+
if (settled) return;
|
|
3624
|
+
settled = true;
|
|
3625
|
+
invalidate(db);
|
|
3626
|
+
reject(error);
|
|
3627
|
+
};
|
|
3295
3628
|
const store = transaction.objectStore(storeName);
|
|
3296
3629
|
const request = store.getAll();
|
|
3297
3630
|
request.onsuccess = () => {
|
|
@@ -3301,15 +3634,13 @@ function createIndexedDbReplayPersistence(options) {
|
|
|
3301
3634
|
else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
|
|
3302
3635
|
}
|
|
3303
3636
|
};
|
|
3304
|
-
request.onerror = () =>
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
transaction.oncomplete = () => resolve();
|
|
3309
|
-
transaction.onerror = () => {
|
|
3310
|
-
invalidate(db);
|
|
3311
|
-
reject(transaction.error ?? new Error("Failed to prune replay history."));
|
|
3637
|
+
request.onerror = () => fail(request.error ?? new Error("Failed to read replay history."));
|
|
3638
|
+
transaction.oncomplete = () => {
|
|
3639
|
+
settled = true;
|
|
3640
|
+
resolve();
|
|
3312
3641
|
};
|
|
3642
|
+
transaction.onerror = () => fail(transaction.error ?? new Error("Failed to prune replay history."));
|
|
3643
|
+
transaction.onabort = () => fail(transaction.error ?? new Error("Failed to prune replay history."));
|
|
3313
3644
|
});
|
|
3314
3645
|
})()
|
|
3315
3646
|
});
|
|
@@ -3340,6 +3671,7 @@ function parseDataBusPublication(value, fallbackTopic) {
|
|
|
3340
3671
|
}
|
|
3341
3672
|
|
|
3342
3673
|
// src/websocket.ts
|
|
3674
|
+
var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
3343
3675
|
var WS_OPEN = 1;
|
|
3344
3676
|
var WebSocketTransport = class {
|
|
3345
3677
|
constructor(connection) {
|
|
@@ -3349,12 +3681,27 @@ var WebSocketTransport = class {
|
|
|
3349
3681
|
diagnosticsName = "websocket";
|
|
3350
3682
|
diagnosticsBackend = "native-websocket";
|
|
3351
3683
|
socket = null;
|
|
3684
|
+
socketActive = false;
|
|
3352
3685
|
handlers = null;
|
|
3353
3686
|
subscribedTopics = /* @__PURE__ */ new Set();
|
|
3354
|
-
|
|
3355
|
-
|
|
3687
|
+
// Handshake gate for the current start(). Resolves once the socket opens,
|
|
3688
|
+
// rejects when the attempt fails, so the DataBus start Promise — and every
|
|
3689
|
+
// operation parked behind it — settles at the real connection boundary.
|
|
3690
|
+
connectPromise = null;
|
|
3691
|
+
connectResolve = null;
|
|
3692
|
+
connectReject = null;
|
|
3693
|
+
connectTimer = null;
|
|
3694
|
+
/** Open the WebSocket and wire lifecycle listeners. Resolves once the
|
|
3695
|
+
* handshake completes and rejects when the attempt fails, matching the
|
|
3696
|
+
* `DataBusTransport.start` contract ("resolves on connect or rejects on
|
|
3697
|
+
* failure"). A factory failure is reported through `onStatus('error')` so
|
|
3698
|
+
* the DataBus can recover. */
|
|
3356
3699
|
start(config, handlers) {
|
|
3357
|
-
if (this.socket)
|
|
3700
|
+
if (this.socket && this.socketActive) {
|
|
3701
|
+
return this.connectPromise ?? void 0;
|
|
3702
|
+
}
|
|
3703
|
+
this.socket = null;
|
|
3704
|
+
this.socketActive = false;
|
|
3358
3705
|
this.handlers = handlers;
|
|
3359
3706
|
const factory = config.webSocketFactory ?? this.connection.webSocketFactory ?? defaultWebSocketFactory;
|
|
3360
3707
|
const protocols = config.protocols ?? this.connection.protocols;
|
|
@@ -3366,23 +3713,66 @@ var WebSocketTransport = class {
|
|
|
3366
3713
|
handlers.onError(error);
|
|
3367
3714
|
return;
|
|
3368
3715
|
}
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3716
|
+
const opening = new Promise((resolve, reject) => {
|
|
3717
|
+
this.connectResolve = resolve;
|
|
3718
|
+
this.connectReject = reject;
|
|
3719
|
+
let handshakeCompleted = false;
|
|
3720
|
+
let handshakeFailed = false;
|
|
3721
|
+
const timeoutMs = config.connectTimeoutMs ?? this.connection.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
3722
|
+
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
3723
|
+
this.connectTimer = setTimeout(() => {
|
|
3724
|
+
if (this.socket !== socket || this.handlers !== handlers || handshakeCompleted) return;
|
|
3725
|
+
handshakeFailed = true;
|
|
3726
|
+
this.connectTimer = null;
|
|
3727
|
+
this.socketActive = false;
|
|
3728
|
+
const error = new Error(`WebSocket did not open within ${timeoutMs}ms.`);
|
|
3729
|
+
handlers.onStatus(WORKER_STATUS.ERROR);
|
|
3730
|
+
handlers.onError(error);
|
|
3731
|
+
this.failConnect(error);
|
|
3732
|
+
socket.close();
|
|
3733
|
+
}, timeoutMs);
|
|
3373
3734
|
}
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3735
|
+
socket.onopen = () => {
|
|
3736
|
+
if (this.socket !== socket || this.handlers !== handlers || handshakeFailed) return;
|
|
3737
|
+
this.socketActive = true;
|
|
3738
|
+
this.clearConnectTimer();
|
|
3739
|
+
for (const topic of this.subscribedTopics) {
|
|
3740
|
+
this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
|
|
3741
|
+
}
|
|
3742
|
+
handlers.onStatus(WORKER_STATUS.CONNECTED);
|
|
3743
|
+
if (!handshakeCompleted) {
|
|
3744
|
+
handshakeCompleted = true;
|
|
3745
|
+
this.settleConnect();
|
|
3746
|
+
}
|
|
3747
|
+
};
|
|
3748
|
+
socket.onclose = () => {
|
|
3749
|
+
if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;
|
|
3750
|
+
this.socketActive = false;
|
|
3751
|
+
handlers.onStatus(WORKER_STATUS.DISCONNECTED);
|
|
3752
|
+
if (!handshakeCompleted) {
|
|
3753
|
+
handshakeFailed = true;
|
|
3754
|
+
this.failConnect(new Error("WebSocket closed before the handshake completed."));
|
|
3755
|
+
}
|
|
3756
|
+
};
|
|
3757
|
+
socket.onerror = () => {
|
|
3758
|
+
if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;
|
|
3759
|
+
this.socketActive = false;
|
|
3760
|
+
handlers.onStatus(WORKER_STATUS.ERROR);
|
|
3761
|
+
if (!handshakeCompleted) {
|
|
3762
|
+
handshakeFailed = true;
|
|
3763
|
+
this.failConnect(new Error("WebSocket failed to open."));
|
|
3764
|
+
}
|
|
3765
|
+
};
|
|
3766
|
+
socket.onmessage = (event) => {
|
|
3767
|
+
if (this.socket === socket && this.handlers === handlers && this.socketActive) {
|
|
3768
|
+
void this.handleMessage(event.data);
|
|
3769
|
+
}
|
|
3770
|
+
};
|
|
3771
|
+
this.socket = socket;
|
|
3772
|
+
this.socketActive = true;
|
|
3773
|
+
});
|
|
3774
|
+
this.connectPromise = opening;
|
|
3775
|
+
return opening;
|
|
3386
3776
|
}
|
|
3387
3777
|
/** Idempotent: re-subscribing an active topic re-sends the frame but does
|
|
3388
3778
|
* not duplicate the local tracking entry. */
|
|
@@ -3438,10 +3828,38 @@ var WebSocketTransport = class {
|
|
|
3438
3828
|
/** Close the socket and drop all state. Safe to call multiple times. */
|
|
3439
3829
|
stop() {
|
|
3440
3830
|
const socket = this.socket;
|
|
3831
|
+
const shouldClose = this.socketActive;
|
|
3441
3832
|
this.socket = null;
|
|
3833
|
+
this.socketActive = false;
|
|
3442
3834
|
this.handlers = null;
|
|
3443
3835
|
this.subscribedTopics.clear();
|
|
3444
|
-
|
|
3836
|
+
this.settleConnect();
|
|
3837
|
+
this.connectPromise = null;
|
|
3838
|
+
if (shouldClose) socket?.close();
|
|
3839
|
+
}
|
|
3840
|
+
/** Resolve the in-flight handshake gate. Idempotent: once the socket has
|
|
3841
|
+
* opened (or a newer attempt replaced it) later calls are no-ops. */
|
|
3842
|
+
settleConnect() {
|
|
3843
|
+
this.clearConnectTimer();
|
|
3844
|
+
const resolve = this.connectResolve;
|
|
3845
|
+
this.connectResolve = null;
|
|
3846
|
+
this.connectReject = null;
|
|
3847
|
+
resolve?.();
|
|
3848
|
+
}
|
|
3849
|
+
/** Reject the in-flight handshake gate. Idempotent on the same terms as
|
|
3850
|
+
* {@link settleConnect}. */
|
|
3851
|
+
failConnect(error) {
|
|
3852
|
+
this.clearConnectTimer();
|
|
3853
|
+
const reject = this.connectReject;
|
|
3854
|
+
this.connectResolve = null;
|
|
3855
|
+
this.connectReject = null;
|
|
3856
|
+
reject?.(error);
|
|
3857
|
+
}
|
|
3858
|
+
clearConnectTimer() {
|
|
3859
|
+
if (this.connectTimer !== null) {
|
|
3860
|
+
clearTimeout(this.connectTimer);
|
|
3861
|
+
this.connectTimer = null;
|
|
3862
|
+
}
|
|
3445
3863
|
}
|
|
3446
3864
|
/** Send one JSON frame. Frames are dropped with an `onError` report when
|
|
3447
3865
|
* the socket is not open — subscribe frames are re-sent on open, so the
|