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