cross-tab-worker-databus 0.20.86 → 0.20.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.20.87] - 2026-09-16
4
+
5
+ ### Fixed
6
+ - Transport operations issued after a runtime `error` no longer reach the connection that just failed. While an automatic or demand-driven reopen is pending, `runTransport()` parks `subscribe()` / `publish()` behind a recovery gate and releases them only after a reopen succeeds (or the transport self-heals to `connected`). `transportReady` deliberately stays true through the error so `ready()` keeps tracking the installed transport, which meant a subscribe or publish inside the cooldown window was written to the dead connection and lost. A failed automatic attempt keeps the gate closed but now lets the next explicit operation start an immediate on-demand reopen instead of waiting out another cooldown, and parked operations flush behind that success; exhausting `recovery.maxAttempts`, or superseding the wait with `stop()` / page hide, releases the gate so the explicit-retry path and the documented suspend-drop semantics are unchanged. A clean `disconnected` still never schedules automatic recovery.
7
+ - An operation issued after a clean transport `disconnected` now reopens the transport on demand instead of being written to the closed connection. `runTransport()` previously exempted only `error`, so a `subscribe()` / `publish()` arriving after a clean `close` took the ready fast path and the backend dropped it (the WebSocket send guard reports dropped frames but never sends them). The fast path is now refused once the transport has actually reached `connected` and then reports `disconnected`: the clean close still schedules no background recovery, but the next explicit operation demands exactly one reopen and flushes the parked operation behind it. A transport that resolves `start()` before its first `connected` (worker-style backends report the connection asynchronously) keeps the previous behaviour, so the pre-connect window does not trigger a redundant reopen.
8
+ - `createWebSocketDataBus()` automatic recovery now actually reopens a failed WebSocket. `WebSocketTransport.start()` previously returned whenever `this.socket` was non-null, and an error/close left that reference in place, so every post-cooldown `reopenTransport()` was a no-op against the dead socket. The transport now tracks whether the current socket is active, replaces the stale connection on the next `start()`, re-sends subscriptions after the replacement opens, and ignores late callbacks from the superseded socket. An error followed by close also keeps the `error` status that schedules recovery.
9
+ - React and Vue `useCrossTabHealth` bindings now apply `intervalMs` changes without recreating the bus. React previously ignored an option change unless the bus identity changed, and the Vue composable ignored changes to a reactive options object; switching to `0` therefore left polling active, while changing a positive cadence kept the old timer. Both adapters now tear down the old listener/timer set and install the new cadence.
10
+ - `stop()` now resolves once the bus is torn down even when the transport's own `stop()` rejects or throws. The failure is routed through the same `onError` / unified `lastFailure` channel that page-hide suspension and open-failure cleanup already use, so the fire-and-forget `void bus.stop()` teardown in the React and Vue adapters can no longer surface as an unhandled rejection. The instance stays restartable either way.
11
+ - A failed transport open is now stamped once: `getRecoveryStats().errorAt` and the `lastFailure.at` of the same failure are equal instead of differing by a clock re-read. The open-failure path called the injected clock twice for one failure, so a consumer correlating the two ledgers (or a test using an advancing clock) saw two timestamps for one event. The open-failure path also clears `transportReady` before notifying status/error handlers, keeping the "transport is not accepting operations" verdict local to the failure block.
12
+ - `ready()` no longer reports a BFCache-suspended bus as ready. After `pagehide`, `suspendTransport()` chains `transport.stop()` and reuses `startPromise` as the stop gate; `ready()` previously returned that gate, so it resolved the moment cleanup finished even though the transport was intentionally stopped and publications were dropped. Readiness now rejects with a clear suspended-state error while hidden; `pageshow` (or an explicit `start()`) clears the flag and installs a real reopen promise, after which `ready()` resolves as before. This restores the documented invariant that `ready()` never resolves for a transport that cannot carry data.
13
+ - A runtime transport failure reported through `onError` now lands in the transport recovery ledger, not only in the unified `lastFailure` record. `getRecoveryStats().hasError` / `errorMessage` / `errorAt` were written exclusively by the transport-*open* failure path, so a failure raised after a successful open produced a self-contradicting health snapshot: `state: 'recovering'` and a retained `lastFailure` alongside `recovery.hasError: false, errorMessage: null`. The recovery ledger now tracks every transport-sourced failure (open or runtime) and still keeps non-transport failures (`persistence`, `dispatch`) out of it, where they remain visible through `lastFailure` and `getPersistenceStats()`.
14
+ - The native WebSocket backend now honors the `DataBusTransport.start()` contract: it resolves only after the socket `open` and rejects when the handshake errors, closes before opening, or exceeds the new `connectTimeoutMs` (default 30s). Previously `start()` returned while the socket was still `CONNECTING`, so `await bus.ready()` resolved before the connection was usable and an immediately following `publish()` was dropped by the not-open guard. A timed-out attempt now closes its half-open socket and ignores a late `open`; an in-place re-open after a successful handshake still re-asserts subscriptions.
15
+ - `getHealthSummary()` now treats a live `connected` transport as healthy even during the brief window before `start()` settles and `transportReady` flips to true. With a handshake-gated WebSocket start, the `connected` status event fires while `transportReady` is still false, so an event-driven `useCrossTabHealth` snapshot with `intervalMs: 0` could remain stuck in `starting`/`recovering`. `transportReady` remains a diagnostic field; operations are queued behind the in-flight start rather than dropped.
16
+
17
+
1
18
  ## [0.20.86] - 2026-09-16
2
19
 
3
20
  ### Added
@@ -5,7 +5,7 @@ import {
5
5
  parseDataBusPublication,
6
6
  publicationMetadata,
7
7
  selectWorkerBackend
8
- } from "./chunk-SDOV3UHG.js";
8
+ } from "./chunk-ZNHJ5OMY.js";
9
9
  import {
10
10
  CENTRIFUGE_INPUT_TYPE,
11
11
  CENTRIFUGE_OUTPUT_TYPE,
@@ -2203,7 +2203,7 @@ var DedupManager = class {
2203
2203
  };
2204
2204
 
2205
2205
  // src/core/version.ts
2206
- var SDK_VERSION = true ? "0.20.86" : "";
2206
+ var SDK_VERSION = true ? "0.20.87" : "";
2207
2207
 
2208
2208
  // src/core/data-bus.ts
2209
2209
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -2228,6 +2228,11 @@ var CrossTabDataBus = class {
2228
2228
  started = false;
2229
2229
  stopping = false;
2230
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;
2231
2236
  // Last transport failure, retained so ready() can surface it to callers who
2232
2237
  // never awaited start() directly. Cleared on the next successful start.
2233
2238
  lastError = null;
@@ -2266,6 +2271,19 @@ var CrossTabDataBus = class {
2266
2271
  // a transport reopen succeeds so traces can correlate repeated failures.
2267
2272
  recoveryAttempt = 0;
2268
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;
2269
2287
  /** Monotonic generation incremented on every successful transport open.
2270
2288
  * Stays in lockstep with `lastSuccessAt` so callers can detect that the
2271
2289
  * transport has been reopened even if the timestamp window is short. */
@@ -2484,8 +2502,38 @@ var CrossTabDataBus = class {
2484
2502
  this.queuedStart = queued;
2485
2503
  return queued;
2486
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
+ }
2487
2534
  /** Reset failure and recovery diagnostics for a new explicit start session. */
2488
2535
  resetFailureState() {
2536
+ this.cancelScheduledRecovery();
2489
2537
  this.lastError = null;
2490
2538
  this.lastErrorAt = null;
2491
2539
  this.lastFailure = null;
@@ -2508,6 +2556,7 @@ var CrossTabDataBus = class {
2508
2556
  return before.catch(() => void 0).then(() => {
2509
2557
  if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
2510
2558
  if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
2559
+ this.transportHasConnected = false;
2511
2560
  return Promise.resolve(
2512
2561
  this.transport.start(config, {
2513
2562
  onMessage: (message) => {
@@ -2529,6 +2578,7 @@ var CrossTabDataBus = class {
2529
2578
  this.recoveryGeneration += 1;
2530
2579
  this.lastSuccessAt = this.now();
2531
2580
  this.transportReady = true;
2581
+ this.releaseRecoveryGate();
2532
2582
  }
2533
2583
  });
2534
2584
  }).catch((error) => {
@@ -2537,11 +2587,9 @@ var CrossTabDataBus = class {
2537
2587
  if (!this.pendingStop) {
2538
2588
  this.pendingStop = this.createStopPromise();
2539
2589
  }
2590
+ this.transportReady = false;
2540
2591
  this.updateStatus(WORKER_STATUS.ERROR);
2541
2592
  this.reportError(error);
2542
- this.lastError = error;
2543
- this.lastErrorAt = this.now();
2544
- this.transportReady = false;
2545
2593
  if (stopClusterOnFailure) {
2546
2594
  this.stopping = true;
2547
2595
  this.cluster.stop();
@@ -2555,7 +2603,10 @@ var CrossTabDataBus = class {
2555
2603
  * Returns a rejected promise when the transport has failed and no start is in
2556
2604
  * flight — the caller can retry by calling start() or ready() again. While an
2557
2605
  * explicit stop() is settling, this rejects unless a restart is queued behind
2558
- * it; false readiness during teardown is never reported.
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.
2559
2610
  */
2560
2611
  ready() {
2561
2612
  if (this.queuedStart) return this.getQueuedStartReady();
@@ -2564,6 +2615,11 @@ var CrossTabDataBus = class {
2564
2615
  "CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
2565
2616
  ));
2566
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
+ }
2567
2623
  if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
2568
2624
  return Promise.reject(this.lastError);
2569
2625
  }
@@ -2699,11 +2755,15 @@ var CrossTabDataBus = class {
2699
2755
  getStatus() {
2700
2756
  return this.status;
2701
2757
  }
2702
- /** Return the current automatic transport recovery state. `hasError` means a transport error is currently retained. */
2703
2758
  /** Return the current automatic transport recovery state plus diagnostics.
2704
- * `generation` increments on every successful transport open (initial start
2705
- * and every recovery); `lastSuccessAt` is the timestamp of the most recent
2706
- * 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`. */
2707
2767
  getRecoveryStats() {
2708
2768
  const errorMessage = this.lastError instanceof Error ? this.lastError.message : this.lastError === null ? null : String(this.lastError);
2709
2769
  return {
@@ -2730,7 +2790,7 @@ var CrossTabDataBus = class {
2730
2790
  * unified failure ledger and recovery context that explains the verdict. */
2731
2791
  getHealthSummary() {
2732
2792
  const transport = this.transport;
2733
- const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2793
+ const transportDown = this.status !== WORKER_STATUS.CONNECTED;
2734
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;
2735
2795
  return {
2736
2796
  healthy: state === HEALTH_STATE.HEALTHY,
@@ -2821,6 +2881,7 @@ var CrossTabDataBus = class {
2821
2881
  async performStop() {
2822
2882
  this.lifecycleEpoch += 1;
2823
2883
  this.stopping = true;
2884
+ this.cancelScheduledRecovery();
2824
2885
  this.replayManager.suspend();
2825
2886
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
2826
2887
  this.trace.stop();
@@ -2833,6 +2894,8 @@ var CrossTabDataBus = class {
2833
2894
  const pendingStop = this.pendingStop;
2834
2895
  if (pendingStop) await pendingStop.catch(() => void 0);
2835
2896
  else await this.transport.stop();
2897
+ } catch (error) {
2898
+ this.reportError(error);
2836
2899
  } finally {
2837
2900
  this.transportSubscribedTopics.clear();
2838
2901
  this.resetDedup();
@@ -2898,10 +2961,12 @@ var CrossTabDataBus = class {
2898
2961
  updateStatus(status) {
2899
2962
  const previousStatus = this.status;
2900
2963
  this.status = status;
2964
+ if (status === WORKER_STATUS.CONNECTED) this.transportHasConnected = true;
2901
2965
  if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
2902
2966
  this.cluster.setStatus(status);
2903
2967
  if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
2904
2968
  if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
2969
+ if (this.transportReady) this.releaseRecoveryGate();
2905
2970
  for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
2906
2971
  }
2907
2972
  if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
@@ -2914,23 +2979,49 @@ var CrossTabDataBus = class {
2914
2979
  this.recoveryExhausted = true;
2915
2980
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
2916
2981
  }
2982
+ this.releaseRecoveryGate();
2917
2983
  return;
2918
2984
  }
2919
2985
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
2920
- setTimeout(() => {
2921
- if (this.stopping || !this.started || this.suspended) return;
2922
- if (this.status !== WORKER_STATUS.ERROR) return;
2923
- 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
+ );
2924
3008
  }, this.recoveryCooldownMs);
2925
3009
  }
3010
+ } else if (status === WORKER_STATUS.ERROR) {
3011
+ this.releaseRecoveryGate();
2926
3012
  }
2927
3013
  this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
2928
3014
  }
2929
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
+ }
2930
3021
  this.lastFailure = {
2931
3022
  source,
2932
3023
  message: error instanceof Error ? error.message : String(error),
2933
- at: this.now()
3024
+ at
2934
3025
  };
2935
3026
  if (source === FAILURE_SOURCE.PERSISTENCE) {
2936
3027
  this.persistenceFailureCount += 1;
@@ -3013,6 +3104,7 @@ var CrossTabDataBus = class {
3013
3104
  if (this.stopping) return;
3014
3105
  this.lifecycleEpoch += 1;
3015
3106
  this.suspended = true;
3107
+ this.cancelScheduledRecovery();
3016
3108
  this.transportReady = false;
3017
3109
  this.transportSubscribedTopics.clear();
3018
3110
  this.updateStatus(WORKER_STATUS.DISCONNECTED);
@@ -3082,7 +3174,24 @@ var CrossTabDataBus = class {
3082
3174
  */
3083
3175
  runTransport(operation) {
3084
3176
  if (this.suspended) return;
3085
- 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) {
3086
3195
  try {
3087
3196
  void Promise.resolve(operation()).catch((error) => this.reportError(error));
3088
3197
  } catch (error) {
@@ -3193,4 +3302,4 @@ export {
3193
3302
  parseDataBusPublication,
3194
3303
  selectWorkerBackend
3195
3304
  };
3196
- //# sourceMappingURL=chunk-SDOV3UHG.js.map
3305
+ //# sourceMappingURL=chunk-ZNHJ5OMY.js.map