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.
@@ -2329,7 +2329,7 @@ var DedupManager = class {
2329
2329
  };
2330
2330
 
2331
2331
  // src/core/version.ts
2332
- var SDK_VERSION = true ? "0.20.86" : "";
2332
+ var SDK_VERSION = true ? "0.20.88" : "";
2333
2333
 
2334
2334
  // src/core/data-bus.ts
2335
2335
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -2354,6 +2354,11 @@ var CrossTabDataBus = class {
2354
2354
  started = false;
2355
2355
  stopping = false;
2356
2356
  transportReady = false;
2357
+ // Whether the installed transport has reported `connected` at least once
2358
+ // since the current open began. A clean `disconnected` after this point is
2359
+ // a lost working connection, not the pre-connect window of a worker-style
2360
+ // backend whose start() resolves before it reports the connection.
2361
+ transportHasConnected = false;
2357
2362
  // Last transport failure, retained so ready() can surface it to callers who
2358
2363
  // never awaited start() directly. Cleared on the next successful start.
2359
2364
  lastError = null;
@@ -2392,6 +2397,19 @@ var CrossTabDataBus = class {
2392
2397
  // a transport reopen succeeds so traces can correlate repeated failures.
2393
2398
  recoveryAttempt = 0;
2394
2399
  recoveryExhausted = false;
2400
+ // Gate that holds transport operations issued after a runtime `error` until
2401
+ // the scheduled recovery attempt has actually run. Without it, a dead
2402
+ // transport still has `transportReady === true` during the cooldown, so
2403
+ // publishes/subscribes would be written to the failed connection and lost.
2404
+ recoveryGate = null;
2405
+ recoveryGateRelease = null;
2406
+ recoveryTimer = null;
2407
+ recoveryTimerToken = 0;
2408
+ // Once an automatic attempt fails, an explicit transport operation may
2409
+ // recover immediately instead of waiting for the next paced attempt. The
2410
+ // gate still stays closed so the operation cannot reach the failed
2411
+ // transport; it is released by the successful on-demand reopen.
2412
+ recoveryDemandAllowed = false;
2395
2413
  /** Monotonic generation incremented on every successful transport open.
2396
2414
  * Stays in lockstep with `lastSuccessAt` so callers can detect that the
2397
2415
  * transport has been reopened even if the timestamp window is short. */
@@ -2505,10 +2523,7 @@ var CrossTabDataBus = class {
2505
2523
  this.suspendTransport();
2506
2524
  },
2507
2525
  onResume: () => {
2508
- this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
2509
- this.trace.start();
2510
- this.startDedupSweep();
2511
- this.replayManager.start();
2526
+ this.resumeSuspendedResources();
2512
2527
  this.resumeTransport();
2513
2528
  },
2514
2529
  onDiagnostic: (event) => {
@@ -2538,7 +2553,11 @@ var CrossTabDataBus = class {
2538
2553
  if (!transportDown) return Promise.resolve();
2539
2554
  this.activeConfig = config;
2540
2555
  this.resetFailureState();
2541
- return this.reopenTransport();
2556
+ const resumingFromSuspend = this.suspended;
2557
+ if (resumingFromSuspend) this.resumeSuspendedResources();
2558
+ const opening2 = this.reopenTransport();
2559
+ if (resumingFromSuspend) this.cluster.start();
2560
+ return opening2;
2542
2561
  }
2543
2562
  this.started = true;
2544
2563
  this.stopping = false;
@@ -2610,8 +2629,38 @@ var CrossTabDataBus = class {
2610
2629
  this.queuedStart = queued;
2611
2630
  return queued;
2612
2631
  }
2632
+ /** Release every operation waiting on the scheduled recovery attempt. */
2633
+ releaseRecoveryGate() {
2634
+ const release = this.recoveryGateRelease;
2635
+ this.recoveryGate = null;
2636
+ this.recoveryGateRelease = null;
2637
+ this.recoveryDemandAllowed = false;
2638
+ release?.();
2639
+ }
2640
+ /** Cancel a pending automatic retry when an explicit lifecycle transition
2641
+ * supersedes it. The released gate re-enters runTransport(), which then
2642
+ * follows the newest start/stop/suspend intent. */
2643
+ cancelScheduledRecovery() {
2644
+ this.recoveryTimerToken += 1;
2645
+ if (this.recoveryTimer !== null) {
2646
+ clearTimeout(this.recoveryTimer);
2647
+ this.recoveryTimer = null;
2648
+ }
2649
+ this.releaseRecoveryGate();
2650
+ }
2651
+ /** Keep the recovery gate closed after a failed attempt while allowing the
2652
+ * next explicit transport operation to start an immediate on-demand reopen.
2653
+ * If no gate/successor retry remains, release any waiters. */
2654
+ allowDemandRecovery() {
2655
+ if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
2656
+ this.recoveryDemandAllowed = true;
2657
+ return;
2658
+ }
2659
+ this.releaseRecoveryGate();
2660
+ }
2613
2661
  /** Reset failure and recovery diagnostics for a new explicit start session. */
2614
2662
  resetFailureState() {
2663
+ this.cancelScheduledRecovery();
2615
2664
  this.lastError = null;
2616
2665
  this.lastErrorAt = null;
2617
2666
  this.lastFailure = null;
@@ -2631,22 +2680,27 @@ var CrossTabDataBus = class {
2631
2680
  this.transportReady = false;
2632
2681
  const chainedPendingStop = this.pendingStop;
2633
2682
  const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
2683
+ let startupInProgress = true;
2634
2684
  return before.catch(() => void 0).then(() => {
2635
2685
  if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
2636
2686
  if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
2687
+ this.transportHasConnected = false;
2637
2688
  return Promise.resolve(
2638
2689
  this.transport.start(config, {
2639
2690
  onMessage: (message) => {
2640
2691
  if (isCurrentLifecycle()) this.handleTransportMessage(message);
2641
2692
  },
2642
2693
  onStatus: (status) => {
2643
- if (isCurrentLifecycle()) this.updateStatus(status);
2694
+ if (isCurrentLifecycle()) {
2695
+ this.updateStatus(status, status !== WORKER_STATUS.ERROR || !startupInProgress);
2696
+ }
2644
2697
  },
2645
2698
  onError: (error) => {
2646
2699
  if (isCurrentLifecycle()) this.reportError(error);
2647
2700
  }
2648
2701
  })
2649
2702
  ).then(() => {
2703
+ startupInProgress = false;
2650
2704
  if (!isCurrentLifecycle()) return;
2651
2705
  if (this.status === WORKER_STATUS.ERROR) {
2652
2706
  throw new Error("Transport failed during startup.");
@@ -2655,24 +2709,26 @@ var CrossTabDataBus = class {
2655
2709
  this.recoveryGeneration += 1;
2656
2710
  this.lastSuccessAt = this.now();
2657
2711
  this.transportReady = true;
2712
+ this.releaseRecoveryGate();
2658
2713
  }
2659
2714
  });
2660
2715
  }).catch((error) => {
2661
2716
  if (!isCurrentLifecycle()) throw error;
2717
+ startupInProgress = false;
2662
2718
  if (stopClusterOnFailure) this.started = false;
2663
2719
  if (!this.pendingStop) {
2664
2720
  this.pendingStop = this.createStopPromise();
2665
2721
  }
2666
- this.updateStatus(WORKER_STATUS.ERROR);
2667
- this.reportError(error);
2668
- this.lastError = error;
2669
- this.lastErrorAt = this.now();
2670
2722
  this.transportReady = false;
2671
2723
  if (stopClusterOnFailure) {
2672
2724
  this.stopping = true;
2673
2725
  this.cluster.stop();
2674
2726
  this.stopping = false;
2675
2727
  }
2728
+ this.recordError(error);
2729
+ this.startPromise = null;
2730
+ this.updateStatus(WORKER_STATUS.ERROR);
2731
+ this.notifyError(error);
2676
2732
  throw error;
2677
2733
  });
2678
2734
  }
@@ -2681,7 +2737,10 @@ var CrossTabDataBus = class {
2681
2737
  * Returns a rejected promise when the transport has failed and no start is in
2682
2738
  * flight — the caller can retry by calling start() or ready() again. While an
2683
2739
  * explicit stop() is settling, this rejects unless a restart is queued behind
2684
- * it; false readiness during teardown is never reported.
2740
+ * it; false readiness during teardown is never reported. While the tab is
2741
+ * BFCache-suspended (pagehide without a following pageshow), this also
2742
+ * rejects: the suspended start promise is the transport-stop gate, not a
2743
+ * readiness signal.
2685
2744
  */
2686
2745
  ready() {
2687
2746
  if (this.queuedStart) return this.getQueuedStartReady();
@@ -2690,6 +2749,11 @@ var CrossTabDataBus = class {
2690
2749
  "CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
2691
2750
  ));
2692
2751
  }
2752
+ if (this.suspended) {
2753
+ return Promise.reject(new Error(
2754
+ "CrossTabDataBus is suspended; ready() cannot report readiness until pageshow resumes the transport."
2755
+ ));
2756
+ }
2693
2757
  if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
2694
2758
  return Promise.reject(this.lastError);
2695
2759
  }
@@ -2825,11 +2889,15 @@ var CrossTabDataBus = class {
2825
2889
  getStatus() {
2826
2890
  return this.status;
2827
2891
  }
2828
- /** Return the current automatic transport recovery state. `hasError` means a transport error is currently retained. */
2829
2892
  /** Return the current automatic transport recovery state plus diagnostics.
2830
- * `generation` increments on every successful transport open (initial start
2831
- * and every recovery); `lastSuccessAt` is the timestamp of the most recent
2832
- * successful open, or `null` until the transport reaches `ready`. */
2893
+ * `hasError`/`errorMessage`/`errorAt` describe the most recent retained
2894
+ * *transport* failure — from a transport open or a runtime `onError`. They
2895
+ * share the lifetime of the unified `lastFailure` ledger: a successful
2896
+ * recovery keeps the last failure visible, and only an explicit `start()`
2897
+ * clears it. `generation` increments on every successful transport open
2898
+ * (initial start and every recovery); `lastSuccessAt` is the timestamp of
2899
+ * the most recent successful open, or `null` until the transport reaches
2900
+ * `ready`. */
2833
2901
  getRecoveryStats() {
2834
2902
  const errorMessage = this.lastError instanceof Error ? this.lastError.message : this.lastError === null ? null : String(this.lastError);
2835
2903
  return {
@@ -2856,7 +2924,7 @@ var CrossTabDataBus = class {
2856
2924
  * unified failure ledger and recovery context that explains the verdict. */
2857
2925
  getHealthSummary() {
2858
2926
  const transport = this.transport;
2859
- const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2927
+ const transportDown = this.status !== WORKER_STATUS.CONNECTED;
2860
2928
  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;
2861
2929
  return {
2862
2930
  healthy: state === HEALTH_STATE.HEALTHY,
@@ -2947,6 +3015,7 @@ var CrossTabDataBus = class {
2947
3015
  async performStop() {
2948
3016
  this.lifecycleEpoch += 1;
2949
3017
  this.stopping = true;
3018
+ this.cancelScheduledRecovery();
2950
3019
  this.replayManager.suspend();
2951
3020
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
2952
3021
  this.trace.stop();
@@ -2959,6 +3028,8 @@ var CrossTabDataBus = class {
2959
3028
  const pendingStop = this.pendingStop;
2960
3029
  if (pendingStop) await pendingStop.catch(() => void 0);
2961
3030
  else await this.transport.stop();
3031
+ } catch (error) {
3032
+ this.reportError(error);
2962
3033
  } finally {
2963
3034
  this.transportSubscribedTopics.clear();
2964
3035
  this.resetDedup();
@@ -3021,13 +3092,15 @@ var CrossTabDataBus = class {
3021
3092
  * Propagate a status change to the cluster, trace, and all registered
3022
3093
  * status handlers. On reconnect, re-subscribe any topics assigned to us.
3023
3094
  */
3024
- updateStatus(status) {
3095
+ updateStatus(status, notifyHandlers = true) {
3025
3096
  const previousStatus = this.status;
3026
3097
  this.status = status;
3098
+ if (status === WORKER_STATUS.CONNECTED) this.transportHasConnected = true;
3027
3099
  if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
3028
3100
  this.cluster.setStatus(status);
3029
3101
  if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
3030
3102
  if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
3103
+ if (this.transportReady) this.releaseRecoveryGate();
3031
3104
  for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
3032
3105
  }
3033
3106
  if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
@@ -3040,23 +3113,49 @@ var CrossTabDataBus = class {
3040
3113
  this.recoveryExhausted = true;
3041
3114
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
3042
3115
  }
3116
+ this.releaseRecoveryGate();
3043
3117
  return;
3044
3118
  }
3045
3119
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
3046
- setTimeout(() => {
3047
- if (this.stopping || !this.started || this.suspended) return;
3048
- if (this.status !== WORKER_STATUS.ERROR) return;
3049
- void this.reopenTransport(attempt);
3120
+ if (this.recoveryGate === null) {
3121
+ let release;
3122
+ this.recoveryGate = new Promise((resolve) => {
3123
+ release = resolve;
3124
+ });
3125
+ this.recoveryGateRelease = release;
3126
+ }
3127
+ this.recoveryDemandAllowed = false;
3128
+ const timerToken = ++this.recoveryTimerToken;
3129
+ this.recoveryTimer = setTimeout(() => {
3130
+ if (timerToken !== this.recoveryTimerToken) return;
3131
+ this.recoveryTimer = null;
3132
+ if (this.stopping || !this.started || this.suspended || this.status !== WORKER_STATUS.ERROR) {
3133
+ this.releaseRecoveryGate();
3134
+ return;
3135
+ }
3136
+ this.recoveryDemandAllowed = false;
3137
+ const opening = this.reopenTransport(attempt);
3138
+ void opening.then(
3139
+ () => this.releaseRecoveryGate(),
3140
+ () => this.allowDemandRecovery()
3141
+ );
3050
3142
  }, this.recoveryCooldownMs);
3051
3143
  }
3144
+ } else if (status === WORKER_STATUS.ERROR) {
3145
+ this.releaseRecoveryGate();
3052
3146
  }
3053
- this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
3147
+ if (notifyHandlers) this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
3054
3148
  }
3055
- reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
3149
+ recordError(error, source = FAILURE_SOURCE.TRANSPORT) {
3150
+ const at = this.now();
3151
+ if (source === FAILURE_SOURCE.TRANSPORT) {
3152
+ this.lastError = error;
3153
+ this.lastErrorAt = at;
3154
+ }
3056
3155
  this.lastFailure = {
3057
3156
  source,
3058
3157
  message: error instanceof Error ? error.message : String(error),
3059
- at: this.now()
3158
+ at
3060
3159
  };
3061
3160
  if (source === FAILURE_SOURCE.PERSISTENCE) {
3062
3161
  this.persistenceFailureCount += 1;
@@ -3067,8 +3166,14 @@ var CrossTabDataBus = class {
3067
3166
  type: TRACE_EVENT_TYPE.ERROR,
3068
3167
  source: source === FAILURE_SOURCE.TRANSPORT ? TRACE_ERROR_SOURCE.TRANSPORT : TRACE_ERROR_SOURCE.OPERATION
3069
3168
  });
3169
+ }
3170
+ notifyError(error) {
3070
3171
  this.invokeHandlers(this.errorHandlers, (handler) => handler(error), INVOKE_LABEL.ERROR_HANDLER);
3071
3172
  }
3173
+ reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
3174
+ this.recordError(error, source);
3175
+ this.notifyError(error);
3176
+ }
3072
3177
  /** Report a persistence failure to the trace and the unified failure ledger,
3073
3178
  * unless it is a {@link PersistenceRetryCancelledError} cancellation from a
3074
3179
  * lifecycle transition (teardown should stay quiet). */
@@ -3131,6 +3236,15 @@ var CrossTabDataBus = class {
3131
3236
  }
3132
3237
  }
3133
3238
  }
3239
+ /** Resume the resources paused by a pagehide suspension. Both the native
3240
+ * pageshow path and explicit start() must run this so an explicit resume
3241
+ * cannot leave trace metrics and periodic cleanup timers permanently off. */
3242
+ resumeSuspendedResources() {
3243
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
3244
+ this.trace.start();
3245
+ this.startDedupSweep();
3246
+ this.replayManager.start();
3247
+ }
3134
3248
  /**
3135
3249
  * Suspend the transport when the tab goes hidden. Stops the transport and
3136
3250
  * clears subscription state so it will be re-established on resume.
@@ -3139,11 +3253,15 @@ var CrossTabDataBus = class {
3139
3253
  if (this.stopping) return;
3140
3254
  this.lifecycleEpoch += 1;
3141
3255
  this.suspended = true;
3256
+ this.cancelScheduledRecovery();
3142
3257
  this.transportReady = false;
3143
3258
  this.transportSubscribedTopics.clear();
3144
3259
  this.updateStatus(WORKER_STATUS.DISCONNECTED);
3145
- if (this.pendingStop) return;
3146
- const pending = this.startPromise ?? Promise.resolve();
3260
+ if (this.pendingStop && (this.startPromise === null || this.startPromise === this.pendingStop)) {
3261
+ this.startPromise = this.pendingStop;
3262
+ return;
3263
+ }
3264
+ const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
3147
3265
  const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
3148
3266
  this.startPromise = stopping;
3149
3267
  this.pendingStop = stopping;
@@ -3208,7 +3326,24 @@ var CrossTabDataBus = class {
3208
3326
  */
3209
3327
  runTransport(operation) {
3210
3328
  if (this.suspended) return;
3211
- if (this.transportReady && !this.stopping) {
3329
+ if (this.recoveryGate && !this.stopping) {
3330
+ if (this.recoveryDemandAllowed && this.status === WORKER_STATUS.ERROR && !this.suspended) {
3331
+ this.recoveryDemandAllowed = false;
3332
+ const opening = this.reopenTransport();
3333
+ void opening.then(
3334
+ () => this.releaseRecoveryGate(),
3335
+ () => this.allowDemandRecovery()
3336
+ );
3337
+ }
3338
+ const gate = this.recoveryGate;
3339
+ void gate.then(() => {
3340
+ if (this.stopping || this.suspended) return;
3341
+ this.runTransport(operation);
3342
+ });
3343
+ return;
3344
+ }
3345
+ const droppedAfterConnect = this.transportHasConnected && this.status === WORKER_STATUS.DISCONNECTED;
3346
+ if (this.transportReady && this.status !== WORKER_STATUS.ERROR && !droppedAfterConnect && !this.stopping) {
3212
3347
  try {
3213
3348
  void Promise.resolve(operation()).catch((error) => this.reportError(error));
3214
3349
  } catch (error) {
@@ -3221,10 +3356,17 @@ var CrossTabDataBus = class {
3221
3356
  ready = this.reopenTransport();
3222
3357
  }
3223
3358
  if (!ready || this.stopping) return;
3224
- void ready.then(() => {
3225
- if (!this.started || this.stopping || this.suspended) return;
3226
- return operation();
3227
- }).catch((error) => this.reportError(error));
3359
+ void ready.then(
3360
+ () => {
3361
+ if (!this.started || this.stopping || this.suspended) return;
3362
+ return operation();
3363
+ },
3364
+ // The opening promise reports its own lifecycle failure through
3365
+ // openTransport(). Swallowing it here prevents a stale startup
3366
+ // rejection from being recorded again after an onStatus/onError
3367
+ // callback has already started and reset the ledger for a retry.
3368
+ () => void 0
3369
+ ).catch((error) => this.reportError(error));
3228
3370
  }
3229
3371
  /**
3230
3372
  * Publications started after teardown begins cannot reach any transport.
@@ -3562,6 +3704,7 @@ function parseDataBusPublication(value, fallbackTopic) {
3562
3704
  }
3563
3705
 
3564
3706
  // src/websocket.ts
3707
+ var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
3565
3708
  var WS_OPEN = 1;
3566
3709
  var WebSocketTransport = class {
3567
3710
  constructor(connection) {
@@ -3571,12 +3714,27 @@ var WebSocketTransport = class {
3571
3714
  diagnosticsName = "websocket";
3572
3715
  diagnosticsBackend = "native-websocket";
3573
3716
  socket = null;
3717
+ socketActive = false;
3574
3718
  handlers = null;
3575
3719
  subscribedTopics = /* @__PURE__ */ new Set();
3576
- /** Open the WebSocket and wire lifecycle listeners. A factory failure is
3577
- * reported through `onStatus('error')` so the DataBus can recover. */
3720
+ // Handshake gate for the current start(). Resolves once the socket opens,
3721
+ // rejects when the attempt fails, so the DataBus start Promise — and every
3722
+ // operation parked behind it — settles at the real connection boundary.
3723
+ connectPromise = null;
3724
+ connectResolve = null;
3725
+ connectReject = null;
3726
+ connectTimer = null;
3727
+ /** Open the WebSocket and wire lifecycle listeners. Resolves once the
3728
+ * handshake completes and rejects when the attempt fails, matching the
3729
+ * `DataBusTransport.start` contract ("resolves on connect or rejects on
3730
+ * failure"). A factory failure is reported through `onStatus('error')` so
3731
+ * the DataBus can recover. */
3578
3732
  start(config, handlers) {
3579
- if (this.socket) return;
3733
+ if (this.socket && this.socketActive) {
3734
+ return this.connectPromise ?? void 0;
3735
+ }
3736
+ this.socket = null;
3737
+ this.socketActive = false;
3580
3738
  this.handlers = handlers;
3581
3739
  const factory = config.webSocketFactory ?? this.connection.webSocketFactory ?? defaultWebSocketFactory;
3582
3740
  const protocols = config.protocols ?? this.connection.protocols;
@@ -3588,23 +3746,66 @@ var WebSocketTransport = class {
3588
3746
  handlers.onError(error);
3589
3747
  return;
3590
3748
  }
3591
- socket.onopen = () => {
3592
- if (this.socket !== socket || this.handlers !== handlers) return;
3593
- for (const topic of this.subscribedTopics) {
3594
- this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
3749
+ const opening = new Promise((resolve, reject) => {
3750
+ this.connectResolve = resolve;
3751
+ this.connectReject = reject;
3752
+ let handshakeCompleted = false;
3753
+ let handshakeFailed = false;
3754
+ const timeoutMs = config.connectTimeoutMs ?? this.connection.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
3755
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
3756
+ this.connectTimer = setTimeout(() => {
3757
+ if (this.socket !== socket || this.handlers !== handlers || handshakeCompleted) return;
3758
+ handshakeFailed = true;
3759
+ this.connectTimer = null;
3760
+ this.socketActive = false;
3761
+ const error = new Error(`WebSocket did not open within ${timeoutMs}ms.`);
3762
+ handlers.onStatus(WORKER_STATUS.ERROR);
3763
+ handlers.onError(error);
3764
+ this.failConnect(error);
3765
+ socket.close();
3766
+ }, timeoutMs);
3595
3767
  }
3596
- handlers.onStatus(WORKER_STATUS.CONNECTED);
3597
- };
3598
- socket.onclose = () => {
3599
- if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.DISCONNECTED);
3600
- };
3601
- socket.onerror = () => {
3602
- if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.ERROR);
3603
- };
3604
- socket.onmessage = (event) => {
3605
- if (this.socket === socket && this.handlers === handlers) void this.handleMessage(event.data);
3606
- };
3607
- this.socket = socket;
3768
+ socket.onopen = () => {
3769
+ if (this.socket !== socket || this.handlers !== handlers || handshakeFailed) return;
3770
+ this.socketActive = true;
3771
+ this.clearConnectTimer();
3772
+ for (const topic of this.subscribedTopics) {
3773
+ this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
3774
+ }
3775
+ handlers.onStatus(WORKER_STATUS.CONNECTED);
3776
+ if (!handshakeCompleted) {
3777
+ handshakeCompleted = true;
3778
+ this.settleConnect();
3779
+ }
3780
+ };
3781
+ socket.onclose = () => {
3782
+ if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;
3783
+ this.socketActive = false;
3784
+ handlers.onStatus(WORKER_STATUS.DISCONNECTED);
3785
+ if (!handshakeCompleted) {
3786
+ handshakeFailed = true;
3787
+ this.failConnect(new Error("WebSocket closed before the handshake completed."));
3788
+ }
3789
+ };
3790
+ socket.onerror = () => {
3791
+ if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;
3792
+ this.socketActive = false;
3793
+ handlers.onStatus(WORKER_STATUS.ERROR);
3794
+ if (!handshakeCompleted) {
3795
+ handshakeFailed = true;
3796
+ this.failConnect(new Error("WebSocket failed to open."));
3797
+ }
3798
+ };
3799
+ socket.onmessage = (event) => {
3800
+ if (this.socket === socket && this.handlers === handlers && this.socketActive) {
3801
+ void this.handleMessage(event.data);
3802
+ }
3803
+ };
3804
+ this.socket = socket;
3805
+ this.socketActive = true;
3806
+ });
3807
+ this.connectPromise = opening;
3808
+ return opening;
3608
3809
  }
3609
3810
  /** Idempotent: re-subscribing an active topic re-sends the frame but does
3610
3811
  * not duplicate the local tracking entry. */
@@ -3660,10 +3861,38 @@ var WebSocketTransport = class {
3660
3861
  /** Close the socket and drop all state. Safe to call multiple times. */
3661
3862
  stop() {
3662
3863
  const socket = this.socket;
3864
+ const shouldClose = this.socketActive;
3663
3865
  this.socket = null;
3866
+ this.socketActive = false;
3664
3867
  this.handlers = null;
3665
3868
  this.subscribedTopics.clear();
3666
- socket?.close();
3869
+ this.settleConnect();
3870
+ this.connectPromise = null;
3871
+ if (shouldClose) socket?.close();
3872
+ }
3873
+ /** Resolve the in-flight handshake gate. Idempotent: once the socket has
3874
+ * opened (or a newer attempt replaced it) later calls are no-ops. */
3875
+ settleConnect() {
3876
+ this.clearConnectTimer();
3877
+ const resolve = this.connectResolve;
3878
+ this.connectResolve = null;
3879
+ this.connectReject = null;
3880
+ resolve?.();
3881
+ }
3882
+ /** Reject the in-flight handshake gate. Idempotent on the same terms as
3883
+ * {@link settleConnect}. */
3884
+ failConnect(error) {
3885
+ this.clearConnectTimer();
3886
+ const reject = this.connectReject;
3887
+ this.connectResolve = null;
3888
+ this.connectReject = null;
3889
+ reject?.(error);
3890
+ }
3891
+ clearConnectTimer() {
3892
+ if (this.connectTimer !== null) {
3893
+ clearTimeout(this.connectTimer);
3894
+ this.connectTimer = null;
3895
+ }
3667
3896
  }
3668
3897
  /** Send one JSON frame. Frames are dropped with an `onError` report when
3669
3898
  * the socket is not open — subscribe frames are re-sent on open, so the