cross-tab-worker-databus 0.20.86 → 0.20.88

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.
@@ -2331,7 +2331,7 @@ var DedupManager = class {
2331
2331
  };
2332
2332
 
2333
2333
  // src/core/version.ts
2334
- var SDK_VERSION = true ? "0.20.86" : "";
2334
+ var SDK_VERSION = true ? "0.20.88" : "";
2335
2335
 
2336
2336
  // src/core/data-bus.ts
2337
2337
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -2356,6 +2356,11 @@ var CrossTabDataBus = class {
2356
2356
  started = false;
2357
2357
  stopping = false;
2358
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;
2359
2364
  // Last transport failure, retained so ready() can surface it to callers who
2360
2365
  // never awaited start() directly. Cleared on the next successful start.
2361
2366
  lastError = null;
@@ -2394,6 +2399,19 @@ var CrossTabDataBus = class {
2394
2399
  // a transport reopen succeeds so traces can correlate repeated failures.
2395
2400
  recoveryAttempt = 0;
2396
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;
2397
2415
  /** Monotonic generation incremented on every successful transport open.
2398
2416
  * Stays in lockstep with `lastSuccessAt` so callers can detect that the
2399
2417
  * transport has been reopened even if the timestamp window is short. */
@@ -2507,10 +2525,7 @@ var CrossTabDataBus = class {
2507
2525
  this.suspendTransport();
2508
2526
  },
2509
2527
  onResume: () => {
2510
- this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
2511
- this.trace.start();
2512
- this.startDedupSweep();
2513
- this.replayManager.start();
2528
+ this.resumeSuspendedResources();
2514
2529
  this.resumeTransport();
2515
2530
  },
2516
2531
  onDiagnostic: (event) => {
@@ -2540,7 +2555,11 @@ var CrossTabDataBus = class {
2540
2555
  if (!transportDown) return Promise.resolve();
2541
2556
  this.activeConfig = config;
2542
2557
  this.resetFailureState();
2543
- return this.reopenTransport();
2558
+ const resumingFromSuspend = this.suspended;
2559
+ if (resumingFromSuspend) this.resumeSuspendedResources();
2560
+ const opening2 = this.reopenTransport();
2561
+ if (resumingFromSuspend) this.cluster.start();
2562
+ return opening2;
2544
2563
  }
2545
2564
  this.started = true;
2546
2565
  this.stopping = false;
@@ -2612,8 +2631,38 @@ var CrossTabDataBus = class {
2612
2631
  this.queuedStart = queued;
2613
2632
  return queued;
2614
2633
  }
2634
+ /** Release every operation waiting on the scheduled recovery attempt. */
2635
+ releaseRecoveryGate() {
2636
+ const release = this.recoveryGateRelease;
2637
+ this.recoveryGate = null;
2638
+ this.recoveryGateRelease = null;
2639
+ this.recoveryDemandAllowed = false;
2640
+ release?.();
2641
+ }
2642
+ /** Cancel a pending automatic retry when an explicit lifecycle transition
2643
+ * supersedes it. The released gate re-enters runTransport(), which then
2644
+ * follows the newest start/stop/suspend intent. */
2645
+ cancelScheduledRecovery() {
2646
+ this.recoveryTimerToken += 1;
2647
+ if (this.recoveryTimer !== null) {
2648
+ clearTimeout(this.recoveryTimer);
2649
+ this.recoveryTimer = null;
2650
+ }
2651
+ this.releaseRecoveryGate();
2652
+ }
2653
+ /** Keep the recovery gate closed after a failed attempt while allowing the
2654
+ * next explicit transport operation to start an immediate on-demand reopen.
2655
+ * If no gate/successor retry remains, release any waiters. */
2656
+ allowDemandRecovery() {
2657
+ if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
2658
+ this.recoveryDemandAllowed = true;
2659
+ return;
2660
+ }
2661
+ this.releaseRecoveryGate();
2662
+ }
2615
2663
  /** Reset failure and recovery diagnostics for a new explicit start session. */
2616
2664
  resetFailureState() {
2665
+ this.cancelScheduledRecovery();
2617
2666
  this.lastError = null;
2618
2667
  this.lastErrorAt = null;
2619
2668
  this.lastFailure = null;
@@ -2633,22 +2682,27 @@ var CrossTabDataBus = class {
2633
2682
  this.transportReady = false;
2634
2683
  const chainedPendingStop = this.pendingStop;
2635
2684
  const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
2685
+ let startupInProgress = true;
2636
2686
  return before.catch(() => void 0).then(() => {
2637
2687
  if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
2638
2688
  if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
2689
+ this.transportHasConnected = false;
2639
2690
  return Promise.resolve(
2640
2691
  this.transport.start(config, {
2641
2692
  onMessage: (message) => {
2642
2693
  if (isCurrentLifecycle()) this.handleTransportMessage(message);
2643
2694
  },
2644
2695
  onStatus: (status) => {
2645
- if (isCurrentLifecycle()) this.updateStatus(status);
2696
+ if (isCurrentLifecycle()) {
2697
+ this.updateStatus(status, status !== WORKER_STATUS.ERROR || !startupInProgress);
2698
+ }
2646
2699
  },
2647
2700
  onError: (error) => {
2648
2701
  if (isCurrentLifecycle()) this.reportError(error);
2649
2702
  }
2650
2703
  })
2651
2704
  ).then(() => {
2705
+ startupInProgress = false;
2652
2706
  if (!isCurrentLifecycle()) return;
2653
2707
  if (this.status === WORKER_STATUS.ERROR) {
2654
2708
  throw new Error("Transport failed during startup.");
@@ -2657,24 +2711,26 @@ var CrossTabDataBus = class {
2657
2711
  this.recoveryGeneration += 1;
2658
2712
  this.lastSuccessAt = this.now();
2659
2713
  this.transportReady = true;
2714
+ this.releaseRecoveryGate();
2660
2715
  }
2661
2716
  });
2662
2717
  }).catch((error) => {
2663
2718
  if (!isCurrentLifecycle()) throw error;
2719
+ startupInProgress = false;
2664
2720
  if (stopClusterOnFailure) this.started = false;
2665
2721
  if (!this.pendingStop) {
2666
2722
  this.pendingStop = this.createStopPromise();
2667
2723
  }
2668
- this.updateStatus(WORKER_STATUS.ERROR);
2669
- this.reportError(error);
2670
- this.lastError = error;
2671
- this.lastErrorAt = this.now();
2672
2724
  this.transportReady = false;
2673
2725
  if (stopClusterOnFailure) {
2674
2726
  this.stopping = true;
2675
2727
  this.cluster.stop();
2676
2728
  this.stopping = false;
2677
2729
  }
2730
+ this.recordError(error);
2731
+ this.startPromise = null;
2732
+ this.updateStatus(WORKER_STATUS.ERROR);
2733
+ this.notifyError(error);
2678
2734
  throw error;
2679
2735
  });
2680
2736
  }
@@ -2683,7 +2739,10 @@ var CrossTabDataBus = class {
2683
2739
  * Returns a rejected promise when the transport has failed and no start is in
2684
2740
  * flight — the caller can retry by calling start() or ready() again. While an
2685
2741
  * explicit stop() is settling, this rejects unless a restart is queued behind
2686
- * it; false readiness during teardown is never reported.
2742
+ * it; false readiness during teardown is never reported. While the tab is
2743
+ * BFCache-suspended (pagehide without a following pageshow), this also
2744
+ * rejects: the suspended start promise is the transport-stop gate, not a
2745
+ * readiness signal.
2687
2746
  */
2688
2747
  ready() {
2689
2748
  if (this.queuedStart) return this.getQueuedStartReady();
@@ -2692,6 +2751,11 @@ var CrossTabDataBus = class {
2692
2751
  "CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
2693
2752
  ));
2694
2753
  }
2754
+ if (this.suspended) {
2755
+ return Promise.reject(new Error(
2756
+ "CrossTabDataBus is suspended; ready() cannot report readiness until pageshow resumes the transport."
2757
+ ));
2758
+ }
2695
2759
  if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
2696
2760
  return Promise.reject(this.lastError);
2697
2761
  }
@@ -2827,11 +2891,15 @@ var CrossTabDataBus = class {
2827
2891
  getStatus() {
2828
2892
  return this.status;
2829
2893
  }
2830
- /** Return the current automatic transport recovery state. `hasError` means a transport error is currently retained. */
2831
2894
  /** Return the current automatic transport recovery state plus diagnostics.
2832
- * `generation` increments on every successful transport open (initial start
2833
- * and every recovery); `lastSuccessAt` is the timestamp of the most recent
2834
- * successful open, or `null` until the transport reaches `ready`. */
2895
+ * `hasError`/`errorMessage`/`errorAt` describe the most recent retained
2896
+ * *transport* failure from a transport open or a runtime `onError`. They
2897
+ * share the lifetime of the unified `lastFailure` ledger: a successful
2898
+ * recovery keeps the last failure visible, and only an explicit `start()`
2899
+ * clears it. `generation` increments on every successful transport open
2900
+ * (initial start and every recovery); `lastSuccessAt` is the timestamp of
2901
+ * the most recent successful open, or `null` until the transport reaches
2902
+ * `ready`. */
2835
2903
  getRecoveryStats() {
2836
2904
  const errorMessage = this.lastError instanceof Error ? this.lastError.message : this.lastError === null ? null : String(this.lastError);
2837
2905
  return {
@@ -2858,7 +2926,7 @@ var CrossTabDataBus = class {
2858
2926
  * unified failure ledger and recovery context that explains the verdict. */
2859
2927
  getHealthSummary() {
2860
2928
  const transport = this.transport;
2861
- const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2929
+ const transportDown = this.status !== WORKER_STATUS.CONNECTED;
2862
2930
  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;
2863
2931
  return {
2864
2932
  healthy: state === HEALTH_STATE.HEALTHY,
@@ -2949,6 +3017,7 @@ var CrossTabDataBus = class {
2949
3017
  async performStop() {
2950
3018
  this.lifecycleEpoch += 1;
2951
3019
  this.stopping = true;
3020
+ this.cancelScheduledRecovery();
2952
3021
  this.replayManager.suspend();
2953
3022
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
2954
3023
  this.trace.stop();
@@ -2961,6 +3030,8 @@ var CrossTabDataBus = class {
2961
3030
  const pendingStop = this.pendingStop;
2962
3031
  if (pendingStop) await pendingStop.catch(() => void 0);
2963
3032
  else await this.transport.stop();
3033
+ } catch (error) {
3034
+ this.reportError(error);
2964
3035
  } finally {
2965
3036
  this.transportSubscribedTopics.clear();
2966
3037
  this.resetDedup();
@@ -3023,13 +3094,15 @@ var CrossTabDataBus = class {
3023
3094
  * Propagate a status change to the cluster, trace, and all registered
3024
3095
  * status handlers. On reconnect, re-subscribe any topics assigned to us.
3025
3096
  */
3026
- updateStatus(status) {
3097
+ updateStatus(status, notifyHandlers = true) {
3027
3098
  const previousStatus = this.status;
3028
3099
  this.status = status;
3100
+ if (status === WORKER_STATUS.CONNECTED) this.transportHasConnected = true;
3029
3101
  if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
3030
3102
  this.cluster.setStatus(status);
3031
3103
  if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
3032
3104
  if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
3105
+ if (this.transportReady) this.releaseRecoveryGate();
3033
3106
  for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
3034
3107
  }
3035
3108
  if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
@@ -3042,23 +3115,49 @@ var CrossTabDataBus = class {
3042
3115
  this.recoveryExhausted = true;
3043
3116
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
3044
3117
  }
3118
+ this.releaseRecoveryGate();
3045
3119
  return;
3046
3120
  }
3047
3121
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
3048
- setTimeout(() => {
3049
- if (this.stopping || !this.started || this.suspended) return;
3050
- if (this.status !== WORKER_STATUS.ERROR) return;
3051
- void this.reopenTransport(attempt);
3122
+ if (this.recoveryGate === null) {
3123
+ let release;
3124
+ this.recoveryGate = new Promise((resolve) => {
3125
+ release = resolve;
3126
+ });
3127
+ this.recoveryGateRelease = release;
3128
+ }
3129
+ this.recoveryDemandAllowed = false;
3130
+ const timerToken = ++this.recoveryTimerToken;
3131
+ this.recoveryTimer = setTimeout(() => {
3132
+ if (timerToken !== this.recoveryTimerToken) return;
3133
+ this.recoveryTimer = null;
3134
+ if (this.stopping || !this.started || this.suspended || this.status !== WORKER_STATUS.ERROR) {
3135
+ this.releaseRecoveryGate();
3136
+ return;
3137
+ }
3138
+ this.recoveryDemandAllowed = false;
3139
+ const opening = this.reopenTransport(attempt);
3140
+ void opening.then(
3141
+ () => this.releaseRecoveryGate(),
3142
+ () => this.allowDemandRecovery()
3143
+ );
3052
3144
  }, this.recoveryCooldownMs);
3053
3145
  }
3146
+ } else if (status === WORKER_STATUS.ERROR) {
3147
+ this.releaseRecoveryGate();
3054
3148
  }
3055
- this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
3149
+ if (notifyHandlers) this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
3056
3150
  }
3057
- reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
3151
+ recordError(error, source = FAILURE_SOURCE.TRANSPORT) {
3152
+ const at = this.now();
3153
+ if (source === FAILURE_SOURCE.TRANSPORT) {
3154
+ this.lastError = error;
3155
+ this.lastErrorAt = at;
3156
+ }
3058
3157
  this.lastFailure = {
3059
3158
  source,
3060
3159
  message: error instanceof Error ? error.message : String(error),
3061
- at: this.now()
3160
+ at
3062
3161
  };
3063
3162
  if (source === FAILURE_SOURCE.PERSISTENCE) {
3064
3163
  this.persistenceFailureCount += 1;
@@ -3069,8 +3168,14 @@ var CrossTabDataBus = class {
3069
3168
  type: TRACE_EVENT_TYPE.ERROR,
3070
3169
  source: source === FAILURE_SOURCE.TRANSPORT ? TRACE_ERROR_SOURCE.TRANSPORT : TRACE_ERROR_SOURCE.OPERATION
3071
3170
  });
3171
+ }
3172
+ notifyError(error) {
3072
3173
  this.invokeHandlers(this.errorHandlers, (handler) => handler(error), INVOKE_LABEL.ERROR_HANDLER);
3073
3174
  }
3175
+ reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
3176
+ this.recordError(error, source);
3177
+ this.notifyError(error);
3178
+ }
3074
3179
  /** Report a persistence failure to the trace and the unified failure ledger,
3075
3180
  * unless it is a {@link PersistenceRetryCancelledError} cancellation from a
3076
3181
  * lifecycle transition (teardown should stay quiet). */
@@ -3133,6 +3238,15 @@ var CrossTabDataBus = class {
3133
3238
  }
3134
3239
  }
3135
3240
  }
3241
+ /** Resume the resources paused by a pagehide suspension. Both the native
3242
+ * pageshow path and explicit start() must run this so an explicit resume
3243
+ * cannot leave trace metrics and periodic cleanup timers permanently off. */
3244
+ resumeSuspendedResources() {
3245
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
3246
+ this.trace.start();
3247
+ this.startDedupSweep();
3248
+ this.replayManager.start();
3249
+ }
3136
3250
  /**
3137
3251
  * Suspend the transport when the tab goes hidden. Stops the transport and
3138
3252
  * clears subscription state so it will be re-established on resume.
@@ -3141,11 +3255,15 @@ var CrossTabDataBus = class {
3141
3255
  if (this.stopping) return;
3142
3256
  this.lifecycleEpoch += 1;
3143
3257
  this.suspended = true;
3258
+ this.cancelScheduledRecovery();
3144
3259
  this.transportReady = false;
3145
3260
  this.transportSubscribedTopics.clear();
3146
3261
  this.updateStatus(WORKER_STATUS.DISCONNECTED);
3147
- if (this.pendingStop) return;
3148
- const pending = this.startPromise ?? Promise.resolve();
3262
+ if (this.pendingStop && (this.startPromise === null || this.startPromise === this.pendingStop)) {
3263
+ this.startPromise = this.pendingStop;
3264
+ return;
3265
+ }
3266
+ const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
3149
3267
  const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
3150
3268
  this.startPromise = stopping;
3151
3269
  this.pendingStop = stopping;
@@ -3210,7 +3328,24 @@ var CrossTabDataBus = class {
3210
3328
  */
3211
3329
  runTransport(operation) {
3212
3330
  if (this.suspended) return;
3213
- if (this.transportReady && !this.stopping) {
3331
+ if (this.recoveryGate && !this.stopping) {
3332
+ if (this.recoveryDemandAllowed && this.status === WORKER_STATUS.ERROR && !this.suspended) {
3333
+ this.recoveryDemandAllowed = false;
3334
+ const opening = this.reopenTransport();
3335
+ void opening.then(
3336
+ () => this.releaseRecoveryGate(),
3337
+ () => this.allowDemandRecovery()
3338
+ );
3339
+ }
3340
+ const gate = this.recoveryGate;
3341
+ void gate.then(() => {
3342
+ if (this.stopping || this.suspended) return;
3343
+ this.runTransport(operation);
3344
+ });
3345
+ return;
3346
+ }
3347
+ const droppedAfterConnect = this.transportHasConnected && this.status === WORKER_STATUS.DISCONNECTED;
3348
+ if (this.transportReady && this.status !== WORKER_STATUS.ERROR && !droppedAfterConnect && !this.stopping) {
3214
3349
  try {
3215
3350
  void Promise.resolve(operation()).catch((error) => this.reportError(error));
3216
3351
  } catch (error) {
@@ -3223,10 +3358,17 @@ var CrossTabDataBus = class {
3223
3358
  ready = this.reopenTransport();
3224
3359
  }
3225
3360
  if (!ready || this.stopping) return;
3226
- void ready.then(() => {
3227
- if (!this.started || this.stopping || this.suspended) return;
3228
- return operation();
3229
- }).catch((error) => this.reportError(error));
3361
+ void ready.then(
3362
+ () => {
3363
+ if (!this.started || this.stopping || this.suspended) return;
3364
+ return operation();
3365
+ },
3366
+ // The opening promise reports its own lifecycle failure through
3367
+ // openTransport(). Swallowing it here prevents a stale startup
3368
+ // rejection from being recorded again after an onStatus/onError
3369
+ // callback has already started and reset the ledger for a retry.
3370
+ () => void 0
3371
+ ).catch((error) => this.reportError(error));
3230
3372
  }
3231
3373
  /**
3232
3374
  * Publications started after teardown begins cannot reach any transport.