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