cross-tab-worker-databus 0.20.85 → 0.20.86
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 +13 -1
- package/dist/centrifuge.js +1 -1
- package/dist/{chunk-PW63EWIK.js → chunk-SDOV3UHG.js} +233 -40
- package/dist/{chunk-PW63EWIK.js.map → chunk-SDOV3UHG.js.map} +3 -3
- package/dist/cjs/centrifuge.cjs +231 -39
- package/dist/cjs/centrifuge.cjs.map +3 -3
- package/dist/cjs/index.cjs +300 -78
- package/dist/cjs/index.cjs.map +3 -3
- package/dist/core/data-bus.d.ts +44 -11
- 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/index.js +71 -40
- package/dist/index.js.map +2 -2
- package/docs/api.md +15 -4
- package/docs/architecture.md +12 -1
- package/docs/benchmarks.md +8 -8
- package/docs/configuration.md +2 -2
- package/docs/roadmap.md +8 -1
- package/docs/zh/api.md +15 -4
- package/docs/zh/architecture.md +12 -1
- package/docs/zh/benchmarks.md +8 -8
- package/docs/zh/configuration.md +2 -2
- package/docs/zh/roadmap.md +8 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
## [
|
|
1
|
+
## [0.20.86] - 2026-09-16
|
|
2
2
|
|
|
3
3
|
### Added
|
|
4
4
|
- The configuration reference now documents the full public option surface: `replay.pruneStrategy` (previously absent entirely) and the replay options table (`maxPerTopic`, `persistence`, `retentionMs`, `pruneStrategy`, `retentionSweepMs`, `persistenceRetry`), plus a deduplication options table (`maxEntries`, `ttlMs`, `sweepMs`, `now`, `adaptiveTtl`) — in both languages. A guard derives the field list from the built declarations and fails when a `DataBusReplayOptions`/`DataBusDedupOptions` field is undocumented in either configuration reference. The `pruneStrategy` JSDoc no longer claims a default of `'both'` (the actual default is `'count'`).
|
|
5
5
|
- Property invariants for `selectActiveWorkers` / `selectRebalanceTarget`: the returned worker set is always a subset of the input, active selection stays within `maxActiveWorkers` and non-empty for a non-empty input, and neither helper throws on arbitrary corrupt worker records.
|
|
6
6
|
|
|
7
|
+
### Fixed
|
|
8
|
+
- `ready()` no longer reports readiness for a restart that a later `stop()` canceled. The queued `start()` promise retains its documented resolve-on-cancellation behavior, but readiness now rejects with a clear lifecycle error instead of resolving against a stopped bus.
|
|
9
|
+
- A failed restart queued behind an in-flight `stop()` now remains observable through `ready()`. Previously the failure cleared `started`, so a later `ready()` without `initialConfig` was masked by the generic "requires initialConfig" error; it now resurfaces the actual transport startup failure and preserves the explicit manual-retry path.
|
|
10
|
+
- `publish()` and non-empty `publishBatch()` calls issued while `stop()` is in flight now report through `onError` instead of silently returning once `runTransport()` refuses to touch the stopping transport. Empty batches remain no-ops, publications already queued behind an in-flight open are still canceled by the stop (latest lifecycle intent wins), and page-hide suspension keeps its documented drop-without-defer behavior.
|
|
11
|
+
- `subscribe()` and `ready()` now honor the explicit-stop barrier. A subscription requested while `stop()` is settling reports through `onError` and returns a no-op cleanup instead of being erased by teardown or leaking into a later restart; `ready()` rejects rather than resolving against the stopping transport. A `start()` already queued behind that stop remains the newest lifecycle intent, so `ready()` continues to follow its restart promise.
|
|
12
|
+
- A `stop()` that arrives before a restart queued behind an earlier in-flight `stop()` could run now cancels that queued restart instead of being swallowed by the in-flight stop gate. The queued continuation is invalidated with a monotonic token, so the latest lifecycle intent always wins: `stop → start → stop` ends stopped with no extra transport open, while a later `start()` still queues a fresh restart with a higher token.
|
|
13
|
+
- Superseded asynchronous transport opens can no longer tear down or detach a newer page-hide/pageshow reopen. Each open now carries a lifecycle epoch, so callbacks, failures, success telemetry, and cleanup from an older open are ignored once a newer suspend/resume/stop transition owns the lifecycle; `stop()` also waits for any pending open or reopen even when `started` has already been cleared. Previously an initial open failing after a queued resume reset `started` to false and nulled `startPromise`, making `stop()` resolve immediately while the queued transport could still start afterwards.
|
|
14
|
+
- `start()` now serialises correctly with both page-hide suspension and an in-flight explicit stop. A hidden bus whose asynchronous `transport.stop()` was still settling returned that cleanup promise from `start()` and never reopened the transport; an in-flight `stop()` likewise allowed `start()` to observe the old ready state and resolve as a no-op before the bus finished stopping. Both paths now queue one fresh start after cleanup, share concurrent start/stop promises, and preserve the existing degraded manual-recovery behavior.
|
|
15
|
+
- Explicit `start()` now performs a manual transport recovery when the bus has already started but its automatic recovery budget is exhausted; it preserves the cluster, subscriptions, and replay state while resetting the failure/recovery ledger before reopening. Previously `start()` returned a resolved no-op in the degraded state even though the public health contract documents it as the manual retry path. Covered by a regression that exhausts automatic recovery, succeeds through `start()`, and verifies the health summary returns to healthy.
|
|
16
|
+
- IndexedDB replay persistence now settles every mutation when a transaction aborts, including connection-loss aborts that fire `onabort` without a preceding request error. Previously the serialized mutation queue could remain blocked forever, preventing later appends and clears from running. Loads now resolve only after `transaction.oncomplete`, so a request that succeeds before a later abort cannot be reported as a successful read. Request/transaction failures without an `error` object use operation-specific fallback messages.
|
|
17
|
+
- Replay age pruning is now position-independent and uses one shared policy for the in-memory rings and IndexedDB adapter. Previously an expired timestamped entry after a timestamp-less legacy entry (or after a non-expired entry) was never removed, and hydrated history was always truncated to `maxPerTopic` even when `pruneStrategy: 'age'` was configured. Timestamp-less entries are still preserved for compatibility, but are now capped by `maxPerTopic` under AGE so they cannot grow without bound; timestamped entries remain bounded by the retention window. Covered across live recording, hydration without a `clearBefore` adapter, and durable append paths.
|
|
18
|
+
|
|
7
19
|
## [0.20.85] - 2026-09-13
|
|
8
20
|
|
|
9
21
|
### Added
|
package/dist/centrifuge.js
CHANGED
|
@@ -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.86" : "";
|
|
2169
2207
|
|
|
2170
2208
|
// src/core/data-bus.ts
|
|
2171
2209
|
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
@@ -2203,6 +2241,24 @@ var CrossTabDataBus = class {
|
|
|
2203
2241
|
// Gate that serialises start/stop/suspend/resume — only one lifecycle
|
|
2204
2242
|
// transition at a time. Resets to null once the operation settles.
|
|
2205
2243
|
startPromise = null;
|
|
2244
|
+
// Gate for an explicit stop(). Concurrent stop() calls share it, and a
|
|
2245
|
+
// start() received while stopping chains a fresh start after it.
|
|
2246
|
+
stopPromise = null;
|
|
2247
|
+
// A start() requested while an explicit stop() is still settling. Kept
|
|
2248
|
+
// separate from startPromise because stop()'s finally block clears the
|
|
2249
|
+
// ordinary lifecycle gate before the queued start is allowed to run.
|
|
2250
|
+
queuedStart = null;
|
|
2251
|
+
// Lazy readiness view of queuedStart. start() keeps its documented
|
|
2252
|
+
// resolve-on-cancellation contract, while ready() must reject when the
|
|
2253
|
+
// queued intent was superseded by a later stop().
|
|
2254
|
+
queuedStartReady = null;
|
|
2255
|
+
queuedStartReadyToken = 0;
|
|
2256
|
+
// The queued continuation is chained to the stop promise and cannot be
|
|
2257
|
+
// un-scheduled once scheduled. A later stop() therefore invalidates the
|
|
2258
|
+
// current intent by recording its token; a subsequent start() issues a
|
|
2259
|
+
// higher token so the latest lifecycle request still wins.
|
|
2260
|
+
queuedStartToken = 0;
|
|
2261
|
+
canceledQueuedStartToken = 0;
|
|
2206
2262
|
// Timestamp of the last automatic transport recovery attempt.
|
|
2207
2263
|
// Used to avoid a tight retry loop when the transport fails repeatedly.
|
|
2208
2264
|
lastRecoveryAt = 0;
|
|
@@ -2225,6 +2281,9 @@ var CrossTabDataBus = class {
|
|
|
2225
2281
|
// surfaces a failure while later opens and automatic recovery wait for the
|
|
2226
2282
|
// stop to settle.
|
|
2227
2283
|
pendingStop = null;
|
|
2284
|
+
// Ownership token for asynchronous transport opens. Every lifecycle
|
|
2285
|
+
// transition invalidates callbacks and failure cleanup from older opens.
|
|
2286
|
+
lifecycleEpoch = 0;
|
|
2228
2287
|
// Minimum interval in ms between automatic recovery attempts.
|
|
2229
2288
|
recoveryCooldownMs;
|
|
2230
2289
|
recoveryMaxAttempts;
|
|
@@ -2337,37 +2396,51 @@ var CrossTabDataBus = class {
|
|
|
2337
2396
|
* Start the DataBus with the given transport config.
|
|
2338
2397
|
*
|
|
2339
2398
|
* The first call starts the cluster and opens the transport. Concurrent calls
|
|
2340
|
-
* during an in-flight
|
|
2341
|
-
*
|
|
2342
|
-
*
|
|
2399
|
+
* during an in-flight open return the same promise. A call received while an
|
|
2400
|
+
* explicit stop() is settling queues one fresh start after cleanup; a later
|
|
2401
|
+
* stop() before that queued start runs cancels it, so the latest lifecycle
|
|
2402
|
+
* intent wins. Once an operation settles (success or failure) its promise
|
|
2403
|
+
* gate is cleared so a subsequent start() or resumeTransport() can open a
|
|
2404
|
+
* fresh lifecycle.
|
|
2343
2405
|
*/
|
|
2344
2406
|
start(config) {
|
|
2345
|
-
if (this.
|
|
2346
|
-
if (this.
|
|
2407
|
+
if (this.queuedStart) return this.queuedStart;
|
|
2408
|
+
if (this.stopping) return this.queueStartAfterStop(config);
|
|
2409
|
+
if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
|
|
2410
|
+
if (this.started) {
|
|
2411
|
+
const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
|
|
2412
|
+
if (!transportDown) return Promise.resolve();
|
|
2413
|
+
this.activeConfig = config;
|
|
2414
|
+
this.resetFailureState();
|
|
2415
|
+
return this.reopenTransport();
|
|
2416
|
+
}
|
|
2347
2417
|
this.started = true;
|
|
2348
2418
|
this.stopping = false;
|
|
2349
2419
|
this.suspended = false;
|
|
2350
2420
|
this.activeConfig = config;
|
|
2351
|
-
this.
|
|
2352
|
-
this.lastFailure = null;
|
|
2353
|
-
this.persistenceFailureCount = 0;
|
|
2354
|
-
this.persistenceLastFailureAt = null;
|
|
2355
|
-
this.persistenceLastErrorMessage = null;
|
|
2421
|
+
this.resetFailureState();
|
|
2356
2422
|
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
|
|
2357
2423
|
this.trace.start();
|
|
2358
2424
|
this.startDedupSweep();
|
|
2359
2425
|
this.replayManager.start();
|
|
2360
2426
|
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
2361
2427
|
this.cluster.start();
|
|
2362
|
-
const
|
|
2428
|
+
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
2429
|
+
const opening = this.openTransport(
|
|
2430
|
+
config,
|
|
2431
|
+
this.pendingStop ?? Promise.resolve(),
|
|
2432
|
+
true,
|
|
2433
|
+
lifecycleEpoch
|
|
2434
|
+
);
|
|
2363
2435
|
this.startPromise = opening;
|
|
2364
2436
|
for (const topic of this.topicHandlers.keys()) {
|
|
2365
2437
|
this.cluster.subscribe(topic);
|
|
2366
2438
|
}
|
|
2367
2439
|
void opening.then(
|
|
2368
2440
|
() => {
|
|
2441
|
+
if (this.startPromise !== opening) return;
|
|
2369
2442
|
this.emitCoordinationTrace();
|
|
2370
|
-
|
|
2443
|
+
this.startPromise = null;
|
|
2371
2444
|
},
|
|
2372
2445
|
() => {
|
|
2373
2446
|
if (this.startPromise === opening) this.startPromise = null;
|
|
@@ -2375,24 +2448,80 @@ var CrossTabDataBus = class {
|
|
|
2375
2448
|
);
|
|
2376
2449
|
return opening;
|
|
2377
2450
|
}
|
|
2451
|
+
/** Return a cancellation-aware readiness view of the current queued start. */
|
|
2452
|
+
getQueuedStartReady() {
|
|
2453
|
+
const queued = this.queuedStart;
|
|
2454
|
+
if (!queued) {
|
|
2455
|
+
return Promise.reject(new Error("No queued start is in flight."));
|
|
2456
|
+
}
|
|
2457
|
+
const token = this.queuedStartToken;
|
|
2458
|
+
if (this.queuedStartReady && this.queuedStartReadyToken === token) {
|
|
2459
|
+
return this.queuedStartReady;
|
|
2460
|
+
}
|
|
2461
|
+
this.queuedStartReadyToken = token;
|
|
2462
|
+
this.queuedStartReady = queued.then(() => {
|
|
2463
|
+
if (token <= this.canceledQueuedStartToken) {
|
|
2464
|
+
throw new Error(
|
|
2465
|
+
"CrossTabDataBus start was canceled by a later stop(); ready() cannot report readiness. Call start() again after stop() resolves."
|
|
2466
|
+
);
|
|
2467
|
+
}
|
|
2468
|
+
if (!this.started || !this.transportReady) {
|
|
2469
|
+
throw new Error("CrossTabDataBus restart completed without a ready transport.");
|
|
2470
|
+
}
|
|
2471
|
+
});
|
|
2472
|
+
return this.queuedStartReady;
|
|
2473
|
+
}
|
|
2474
|
+
/** Queue exactly one fresh start after an in-flight explicit stop settles. */
|
|
2475
|
+
queueStartAfterStop(config) {
|
|
2476
|
+
if (this.queuedStart) return this.queuedStart;
|
|
2477
|
+
const stop = this.stopPromise ?? Promise.resolve();
|
|
2478
|
+
const token = ++this.queuedStartToken;
|
|
2479
|
+
const queued = stop.catch(() => void 0).then(() => {
|
|
2480
|
+
if (this.queuedStart === queued) this.queuedStart = null;
|
|
2481
|
+
if (token <= this.canceledQueuedStartToken) return;
|
|
2482
|
+
return this.start(config);
|
|
2483
|
+
});
|
|
2484
|
+
this.queuedStart = queued;
|
|
2485
|
+
return queued;
|
|
2486
|
+
}
|
|
2487
|
+
/** Reset failure and recovery diagnostics for a new explicit start session. */
|
|
2488
|
+
resetFailureState() {
|
|
2489
|
+
this.lastError = null;
|
|
2490
|
+
this.lastErrorAt = null;
|
|
2491
|
+
this.lastFailure = null;
|
|
2492
|
+
this.persistenceFailureCount = 0;
|
|
2493
|
+
this.persistenceLastFailureAt = null;
|
|
2494
|
+
this.persistenceLastErrorMessage = null;
|
|
2495
|
+
this.recoveryAttempt = 0;
|
|
2496
|
+
this.recoveryExhausted = false;
|
|
2497
|
+
this.lastRecoveryAt = 0;
|
|
2498
|
+
}
|
|
2378
2499
|
/**
|
|
2379
2500
|
* Open the transport, chained after `before` to ensure lifecycle ordering.
|
|
2380
2501
|
* When `stopClusterOnFailure` is true (initial start), a transport failure
|
|
2381
2502
|
* tears down the cluster as well.
|
|
2382
2503
|
*/
|
|
2383
|
-
openTransport(config, before, stopClusterOnFailure) {
|
|
2504
|
+
openTransport(config, before, stopClusterOnFailure, lifecycleEpoch) {
|
|
2384
2505
|
this.transportReady = false;
|
|
2385
2506
|
const chainedPendingStop = this.pendingStop;
|
|
2507
|
+
const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
|
|
2386
2508
|
return before.catch(() => void 0).then(() => {
|
|
2387
|
-
if (this.stopping || this.suspended) return;
|
|
2509
|
+
if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
|
|
2388
2510
|
if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
|
|
2389
2511
|
return Promise.resolve(
|
|
2390
2512
|
this.transport.start(config, {
|
|
2391
|
-
onMessage: (message) =>
|
|
2392
|
-
|
|
2393
|
-
|
|
2513
|
+
onMessage: (message) => {
|
|
2514
|
+
if (isCurrentLifecycle()) this.handleTransportMessage(message);
|
|
2515
|
+
},
|
|
2516
|
+
onStatus: (status) => {
|
|
2517
|
+
if (isCurrentLifecycle()) this.updateStatus(status);
|
|
2518
|
+
},
|
|
2519
|
+
onError: (error) => {
|
|
2520
|
+
if (isCurrentLifecycle()) this.reportError(error);
|
|
2521
|
+
}
|
|
2394
2522
|
})
|
|
2395
2523
|
).then(() => {
|
|
2524
|
+
if (!isCurrentLifecycle()) return;
|
|
2396
2525
|
if (this.status === WORKER_STATUS.ERROR) {
|
|
2397
2526
|
throw new Error("Transport failed during startup.");
|
|
2398
2527
|
}
|
|
@@ -2403,6 +2532,7 @@ var CrossTabDataBus = class {
|
|
|
2403
2532
|
}
|
|
2404
2533
|
});
|
|
2405
2534
|
}).catch((error) => {
|
|
2535
|
+
if (!isCurrentLifecycle()) throw error;
|
|
2406
2536
|
if (stopClusterOnFailure) this.started = false;
|
|
2407
2537
|
if (!this.pendingStop) {
|
|
2408
2538
|
this.pendingStop = this.createStopPromise();
|
|
@@ -2417,16 +2547,26 @@ var CrossTabDataBus = class {
|
|
|
2417
2547
|
this.cluster.stop();
|
|
2418
2548
|
this.stopping = false;
|
|
2419
2549
|
}
|
|
2420
|
-
this.startPromise = null;
|
|
2421
2550
|
throw error;
|
|
2422
2551
|
});
|
|
2423
2552
|
}
|
|
2424
2553
|
/**
|
|
2425
2554
|
* Await the DataBus to be fully started (lazy init when using initialConfig).
|
|
2426
2555
|
* 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.
|
|
2556
|
+
* flight — the caller can retry by calling start() or ready() again. While an
|
|
2557
|
+
* explicit stop() is settling, this rejects unless a restart is queued behind
|
|
2558
|
+
* it; false readiness during teardown is never reported.
|
|
2428
2559
|
*/
|
|
2429
2560
|
ready() {
|
|
2561
|
+
if (this.queuedStart) return this.getQueuedStartReady();
|
|
2562
|
+
if (this.stopping) {
|
|
2563
|
+
return Promise.reject(new Error(
|
|
2564
|
+
"CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
|
|
2565
|
+
));
|
|
2566
|
+
}
|
|
2567
|
+
if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
|
|
2568
|
+
return Promise.reject(this.lastError);
|
|
2569
|
+
}
|
|
2430
2570
|
try {
|
|
2431
2571
|
this.ensureStarted();
|
|
2432
2572
|
} catch (error) {
|
|
@@ -2442,9 +2582,18 @@ var CrossTabDataBus = class {
|
|
|
2442
2582
|
/**
|
|
2443
2583
|
* Register a handler for `topic`. The handler fires on every publication
|
|
2444
2584
|
* delivered to this tab, regardless of which tab published it. Returns an
|
|
2445
|
-
* unsubscribe function for convenience.
|
|
2585
|
+
* unsubscribe function for convenience. During an explicit stop() the
|
|
2586
|
+
* registration is rejected through onError and a no-op cleanup is returned,
|
|
2587
|
+
* so a late subscriber cannot leak into a future restart.
|
|
2446
2588
|
*/
|
|
2447
2589
|
subscribe(topic, handler, options) {
|
|
2590
|
+
if (this.stopping) {
|
|
2591
|
+
this.reportError(new Error(
|
|
2592
|
+
"CrossTabDataBus is stopping; subscribe() was not registered. Wait for stop() to resolve, then call start() before subscribing again."
|
|
2593
|
+
));
|
|
2594
|
+
return () => {
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2448
2597
|
this.ensureStarted();
|
|
2449
2598
|
const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
|
|
2450
2599
|
const wasUnused = handlers.size === 0;
|
|
@@ -2498,6 +2647,7 @@ var CrossTabDataBus = class {
|
|
|
2498
2647
|
/** Publish a message to `topic`. The owning Worker delivers it to the transport. */
|
|
2499
2648
|
publish(topic, data, options) {
|
|
2500
2649
|
this.ensureStarted();
|
|
2650
|
+
if (this.rejectPublishDuringStop("publish")) return;
|
|
2501
2651
|
if (!this.cluster.publish(topic, data, options)) {
|
|
2502
2652
|
this.reportError(
|
|
2503
2653
|
new Error("Failed to send the publish control message to the owning worker.")
|
|
@@ -2514,6 +2664,7 @@ var CrossTabDataBus = class {
|
|
|
2514
2664
|
publishBatch(topic, items) {
|
|
2515
2665
|
this.ensureStarted();
|
|
2516
2666
|
if (items.length === 0) return;
|
|
2667
|
+
if (this.rejectPublishDuringStop("publishBatch")) return;
|
|
2517
2668
|
if (items.length === 1) {
|
|
2518
2669
|
const first = items[0];
|
|
2519
2670
|
this.publish(topic, first.data, first.options);
|
|
@@ -2642,10 +2793,33 @@ var CrossTabDataBus = class {
|
|
|
2642
2793
|
}
|
|
2643
2794
|
/**
|
|
2644
2795
|
* Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
|
|
2645
|
-
* and close the transport.
|
|
2796
|
+
* and close the transport. Concurrent and repeated calls share the in-flight
|
|
2797
|
+
* stop promise. A start() received while stopping runs after this completes,
|
|
2798
|
+
* unless another stop() arrives first and cancels that queued restart.
|
|
2646
2799
|
*/
|
|
2647
|
-
|
|
2648
|
-
if (
|
|
2800
|
+
stop() {
|
|
2801
|
+
if (this.queuedStart) {
|
|
2802
|
+
this.canceledQueuedStartToken = this.queuedStartToken;
|
|
2803
|
+
this.queuedStart = null;
|
|
2804
|
+
}
|
|
2805
|
+
if (this.stopPromise) return this.stopPromise;
|
|
2806
|
+
if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
|
|
2807
|
+
return Promise.resolve();
|
|
2808
|
+
}
|
|
2809
|
+
const stopPromise = this.performStop();
|
|
2810
|
+
this.stopPromise = stopPromise;
|
|
2811
|
+
void stopPromise.then(
|
|
2812
|
+
() => {
|
|
2813
|
+
if (this.stopPromise === stopPromise) this.stopPromise = null;
|
|
2814
|
+
},
|
|
2815
|
+
() => {
|
|
2816
|
+
if (this.stopPromise === stopPromise) this.stopPromise = null;
|
|
2817
|
+
}
|
|
2818
|
+
);
|
|
2819
|
+
return stopPromise;
|
|
2820
|
+
}
|
|
2821
|
+
async performStop() {
|
|
2822
|
+
this.lifecycleEpoch += 1;
|
|
2649
2823
|
this.stopping = true;
|
|
2650
2824
|
this.replayManager.suspend();
|
|
2651
2825
|
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
|
|
@@ -2837,6 +3011,7 @@ var CrossTabDataBus = class {
|
|
|
2837
3011
|
*/
|
|
2838
3012
|
suspendTransport() {
|
|
2839
3013
|
if (this.stopping) return;
|
|
3014
|
+
this.lifecycleEpoch += 1;
|
|
2840
3015
|
this.suspended = true;
|
|
2841
3016
|
this.transportReady = false;
|
|
2842
3017
|
this.transportSubscribedTopics.clear();
|
|
@@ -2875,19 +3050,23 @@ var CrossTabDataBus = class {
|
|
|
2875
3050
|
this.started = true;
|
|
2876
3051
|
this.suspended = false;
|
|
2877
3052
|
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
3053
|
+
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
2878
3054
|
const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
|
|
2879
|
-
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
|
|
3055
|
+
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
|
|
2880
3056
|
this.startPromise = opening;
|
|
2881
3057
|
void opening.then(
|
|
2882
3058
|
() => {
|
|
3059
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
3060
|
+
if (lifecycleEpoch !== this.lifecycleEpoch) return;
|
|
2883
3061
|
if (traceAttempt !== void 0) {
|
|
2884
3062
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
|
|
2885
3063
|
this.recoveryAttempt = 0;
|
|
2886
3064
|
this.recoveryExhausted = false;
|
|
2887
3065
|
}
|
|
2888
|
-
if (this.startPromise === opening) this.startPromise = null;
|
|
2889
3066
|
},
|
|
2890
3067
|
() => {
|
|
3068
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
3069
|
+
if (lifecycleEpoch !== this.lifecycleEpoch) return;
|
|
2891
3070
|
if (traceAttempt !== void 0) {
|
|
2892
3071
|
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
|
|
2893
3072
|
}
|
|
@@ -2921,6 +3100,19 @@ var CrossTabDataBus = class {
|
|
|
2921
3100
|
return operation();
|
|
2922
3101
|
}).catch((error) => this.reportError(error));
|
|
2923
3102
|
}
|
|
3103
|
+
/**
|
|
3104
|
+
* Publications started after teardown begins cannot reach any transport.
|
|
3105
|
+
* Surface that as a normal asynchronous API failure instead of letting
|
|
3106
|
+
* runTransport() return silently. Empty publishBatch() calls remain a no-op
|
|
3107
|
+
* and are filtered by the caller before this check.
|
|
3108
|
+
*/
|
|
3109
|
+
rejectPublishDuringStop(operation) {
|
|
3110
|
+
if (!this.stopping) return false;
|
|
3111
|
+
this.reportError(new Error(
|
|
3112
|
+
`CrossTabDataBus is stopping; ${operation}() was not sent. Wait for stop() to resolve, then call start() before publishing again.`
|
|
3113
|
+
));
|
|
3114
|
+
return true;
|
|
3115
|
+
}
|
|
2924
3116
|
/**
|
|
2925
3117
|
* Ensure the DataBus is started, throwing if no initialConfig was provided.
|
|
2926
3118
|
* Called automatically by subscribe/publish/ready when autoStart is true.
|
|
@@ -2996,8 +3188,9 @@ export {
|
|
|
2996
3188
|
assertHeartbeatInterval,
|
|
2997
3189
|
assertStructuredCloneable,
|
|
2998
3190
|
WorkerClusterRuntime,
|
|
3191
|
+
pruneReplayHistory,
|
|
2999
3192
|
CrossTabDataBus,
|
|
3000
3193
|
parseDataBusPublication,
|
|
3001
3194
|
selectWorkerBackend
|
|
3002
3195
|
};
|
|
3003
|
-
//# sourceMappingURL=chunk-
|
|
3196
|
+
//# sourceMappingURL=chunk-SDOV3UHG.js.map
|