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
@@ -1855,6 +1855,36 @@ function roundMs(value) {
1855
1855
  return Math.round(value * 10) / 10;
1856
1856
  }
1857
1857
 
1858
+ // src/core/replay-pruning.ts
1859
+ function pruneReplayHistory(messages, options) {
1860
+ const { maxPerTopic, pruneStrategy, retentionMs, now } = options;
1861
+ const ageEnabled = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
1862
+ if (!ageEnabled) {
1863
+ return messages.length > maxPerTopic ? messages.slice(-maxPerTopic) : messages;
1864
+ }
1865
+ const cutoff = now - retentionMs;
1866
+ let hasExpired = false;
1867
+ let timestamplessCount = 0;
1868
+ for (const message of messages) {
1869
+ if (message.timestamp === void 0) timestamplessCount += 1;
1870
+ else if (message.timestamp < cutoff) hasExpired = true;
1871
+ }
1872
+ let pruned = hasExpired ? messages.filter((message) => message.timestamp === void 0 || message.timestamp >= cutoff) : messages;
1873
+ if (pruneStrategy === PRUNE_STRATEGY.BOTH) {
1874
+ return pruned.length > maxPerTopic ? pruned.slice(-maxPerTopic) : pruned;
1875
+ }
1876
+ if (timestamplessCount <= maxPerTopic) return pruned;
1877
+ let timestamplessToDrop = timestamplessCount - maxPerTopic;
1878
+ pruned = pruned.filter((message) => {
1879
+ if (message.timestamp === void 0 && timestamplessToDrop > 0) {
1880
+ timestamplessToDrop -= 1;
1881
+ return false;
1882
+ }
1883
+ return true;
1884
+ });
1885
+ return pruned;
1886
+ }
1887
+
1858
1888
  // src/core/replay-manager.ts
1859
1889
  var PersistenceRetryCancelledError = class extends Error {
1860
1890
  constructor() {
@@ -1916,17 +1946,15 @@ var ReplayManager = class {
1916
1946
  this.buffers.set(message.topic, buffer);
1917
1947
  }
1918
1948
  buffer.push(message);
1919
- const ageBounded = this.pruneStrategy !== PRUNE_STRATEGY.COUNT && this.retentionMs !== void 0;
1920
- if (this.pruneStrategy !== PRUNE_STRATEGY.AGE || !ageBounded) {
1921
- while (buffer.length > this.maxPerTopic) buffer.shift();
1922
- }
1923
- if (ageBounded) {
1924
- const cutoff = this.now() - this.retentionMs;
1925
- while (buffer.length > 0) {
1926
- const first = buffer[0];
1927
- if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
1928
- buffer.shift();
1929
- }
1949
+ const pruned = pruneReplayHistory(buffer, {
1950
+ maxPerTopic: this.maxPerTopic,
1951
+ pruneStrategy: this.pruneStrategy,
1952
+ retentionMs: this.retentionMs,
1953
+ now: this.now()
1954
+ });
1955
+ if (pruned !== buffer) {
1956
+ buffer = pruned;
1957
+ this.buffers.set(message.topic, buffer);
1930
1958
  }
1931
1959
  if (!this.persistence) return;
1932
1960
  if (this.persistence.appendBatch) {
@@ -2107,14 +2135,24 @@ var ReplayManager = class {
2107
2135
  if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
2108
2136
  await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
2109
2137
  }
2110
- for (const message of await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load())) {
2138
+ const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
2139
+ for (const message of loaded) {
2111
2140
  let buffer = this.buffers.get(message.topic);
2112
2141
  if (!buffer) {
2113
2142
  buffer = [];
2114
2143
  this.buffers.set(message.topic, buffer);
2115
2144
  }
2116
2145
  buffer.push(message);
2117
- if (buffer.length > this.maxPerTopic) buffer.shift();
2146
+ }
2147
+ const hydrationNow = this.now();
2148
+ for (const [topic, buffer] of this.buffers) {
2149
+ const pruned = pruneReplayHistory(buffer, {
2150
+ maxPerTopic: this.maxPerTopic,
2151
+ pruneStrategy: this.pruneStrategy,
2152
+ retentionMs: this.retentionMs,
2153
+ now: hydrationNow
2154
+ });
2155
+ if (pruned !== buffer) this.buffers.set(topic, pruned);
2118
2156
  }
2119
2157
  } catch (error) {
2120
2158
  this.onPersistenceError(error);
@@ -2291,7 +2329,7 @@ var DedupManager = class {
2291
2329
  };
2292
2330
 
2293
2331
  // src/core/version.ts
2294
- var SDK_VERSION = true ? "0.20.85" : "";
2332
+ var SDK_VERSION = true ? "0.20.87" : "";
2295
2333
 
2296
2334
  // src/core/data-bus.ts
2297
2335
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -2316,6 +2354,11 @@ var CrossTabDataBus = class {
2316
2354
  started = false;
2317
2355
  stopping = false;
2318
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;
2319
2362
  // Last transport failure, retained so ready() can surface it to callers who
2320
2363
  // never awaited start() directly. Cleared on the next successful start.
2321
2364
  lastError = null;
@@ -2329,6 +2372,24 @@ var CrossTabDataBus = class {
2329
2372
  // Gate that serialises start/stop/suspend/resume — only one lifecycle
2330
2373
  // transition at a time. Resets to null once the operation settles.
2331
2374
  startPromise = null;
2375
+ // Gate for an explicit stop(). Concurrent stop() calls share it, and a
2376
+ // start() received while stopping chains a fresh start after it.
2377
+ stopPromise = null;
2378
+ // A start() requested while an explicit stop() is still settling. Kept
2379
+ // separate from startPromise because stop()'s finally block clears the
2380
+ // ordinary lifecycle gate before the queued start is allowed to run.
2381
+ queuedStart = null;
2382
+ // Lazy readiness view of queuedStart. start() keeps its documented
2383
+ // resolve-on-cancellation contract, while ready() must reject when the
2384
+ // queued intent was superseded by a later stop().
2385
+ queuedStartReady = null;
2386
+ queuedStartReadyToken = 0;
2387
+ // The queued continuation is chained to the stop promise and cannot be
2388
+ // un-scheduled once scheduled. A later stop() therefore invalidates the
2389
+ // current intent by recording its token; a subsequent start() issues a
2390
+ // higher token so the latest lifecycle request still wins.
2391
+ queuedStartToken = 0;
2392
+ canceledQueuedStartToken = 0;
2332
2393
  // Timestamp of the last automatic transport recovery attempt.
2333
2394
  // Used to avoid a tight retry loop when the transport fails repeatedly.
2334
2395
  lastRecoveryAt = 0;
@@ -2336,6 +2397,19 @@ var CrossTabDataBus = class {
2336
2397
  // a transport reopen succeeds so traces can correlate repeated failures.
2337
2398
  recoveryAttempt = 0;
2338
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;
2339
2413
  /** Monotonic generation incremented on every successful transport open.
2340
2414
  * Stays in lockstep with `lastSuccessAt` so callers can detect that the
2341
2415
  * transport has been reopened even if the timestamp window is short. */
@@ -2351,6 +2425,9 @@ var CrossTabDataBus = class {
2351
2425
  // surfaces a failure while later opens and automatic recovery wait for the
2352
2426
  // stop to settle.
2353
2427
  pendingStop = null;
2428
+ // Ownership token for asynchronous transport opens. Every lifecycle
2429
+ // transition invalidates callbacks and failure cleanup from older opens.
2430
+ lifecycleEpoch = 0;
2354
2431
  // Minimum interval in ms between automatic recovery attempts.
2355
2432
  recoveryCooldownMs;
2356
2433
  recoveryMaxAttempts;
@@ -2463,37 +2540,51 @@ var CrossTabDataBus = class {
2463
2540
  * Start the DataBus with the given transport config.
2464
2541
  *
2465
2542
  * The first call starts the cluster and opens the transport. Concurrent calls
2466
- * during an in-flight start return the same promise. Once the operation
2467
- * settles (success or failure) the promise gate is cleared so a subsequent
2468
- * start() or resumeTransport() can open a fresh lifecycle.
2543
+ * during an in-flight open return the same promise. A call received while an
2544
+ * explicit stop() is settling queues one fresh start after cleanup; a later
2545
+ * stop() before that queued start runs cancels it, so the latest lifecycle
2546
+ * intent wins. Once an operation settles (success or failure) its promise
2547
+ * gate is cleared so a subsequent start() or resumeTransport() can open a
2548
+ * fresh lifecycle.
2469
2549
  */
2470
2550
  start(config) {
2471
- if (this.startPromise) return this.startPromise;
2472
- if (this.started) return Promise.resolve();
2551
+ if (this.queuedStart) return this.queuedStart;
2552
+ if (this.stopping) return this.queueStartAfterStop(config);
2553
+ if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
2554
+ if (this.started) {
2555
+ const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2556
+ if (!transportDown) return Promise.resolve();
2557
+ this.activeConfig = config;
2558
+ this.resetFailureState();
2559
+ return this.reopenTransport();
2560
+ }
2473
2561
  this.started = true;
2474
2562
  this.stopping = false;
2475
2563
  this.suspended = false;
2476
2564
  this.activeConfig = config;
2477
- this.lastError = null;
2478
- this.lastFailure = null;
2479
- this.persistenceFailureCount = 0;
2480
- this.persistenceLastFailureAt = null;
2481
- this.persistenceLastErrorMessage = null;
2565
+ this.resetFailureState();
2482
2566
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
2483
2567
  this.trace.start();
2484
2568
  this.startDedupSweep();
2485
2569
  this.replayManager.start();
2486
2570
  this.updateStatus(WORKER_STATUS.CONNECTING);
2487
2571
  this.cluster.start();
2488
- const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
2572
+ const lifecycleEpoch = ++this.lifecycleEpoch;
2573
+ const opening = this.openTransport(
2574
+ config,
2575
+ this.pendingStop ?? Promise.resolve(),
2576
+ true,
2577
+ lifecycleEpoch
2578
+ );
2489
2579
  this.startPromise = opening;
2490
2580
  for (const topic of this.topicHandlers.keys()) {
2491
2581
  this.cluster.subscribe(topic);
2492
2582
  }
2493
2583
  void opening.then(
2494
2584
  () => {
2585
+ if (this.startPromise !== opening) return;
2495
2586
  this.emitCoordinationTrace();
2496
- if (this.startPromise === opening) this.startPromise = null;
2587
+ this.startPromise = null;
2497
2588
  },
2498
2589
  () => {
2499
2590
  if (this.startPromise === opening) this.startPromise = null;
@@ -2501,24 +2592,111 @@ var CrossTabDataBus = class {
2501
2592
  );
2502
2593
  return opening;
2503
2594
  }
2595
+ /** Return a cancellation-aware readiness view of the current queued start. */
2596
+ getQueuedStartReady() {
2597
+ const queued = this.queuedStart;
2598
+ if (!queued) {
2599
+ return Promise.reject(new Error("No queued start is in flight."));
2600
+ }
2601
+ const token = this.queuedStartToken;
2602
+ if (this.queuedStartReady && this.queuedStartReadyToken === token) {
2603
+ return this.queuedStartReady;
2604
+ }
2605
+ this.queuedStartReadyToken = token;
2606
+ this.queuedStartReady = queued.then(() => {
2607
+ if (token <= this.canceledQueuedStartToken) {
2608
+ throw new Error(
2609
+ "CrossTabDataBus start was canceled by a later stop(); ready() cannot report readiness. Call start() again after stop() resolves."
2610
+ );
2611
+ }
2612
+ if (!this.started || !this.transportReady) {
2613
+ throw new Error("CrossTabDataBus restart completed without a ready transport.");
2614
+ }
2615
+ });
2616
+ return this.queuedStartReady;
2617
+ }
2618
+ /** Queue exactly one fresh start after an in-flight explicit stop settles. */
2619
+ queueStartAfterStop(config) {
2620
+ if (this.queuedStart) return this.queuedStart;
2621
+ const stop = this.stopPromise ?? Promise.resolve();
2622
+ const token = ++this.queuedStartToken;
2623
+ const queued = stop.catch(() => void 0).then(() => {
2624
+ if (this.queuedStart === queued) this.queuedStart = null;
2625
+ if (token <= this.canceledQueuedStartToken) return;
2626
+ return this.start(config);
2627
+ });
2628
+ this.queuedStart = queued;
2629
+ return queued;
2630
+ }
2631
+ /** Release every operation waiting on the scheduled recovery attempt. */
2632
+ releaseRecoveryGate() {
2633
+ const release = this.recoveryGateRelease;
2634
+ this.recoveryGate = null;
2635
+ this.recoveryGateRelease = null;
2636
+ this.recoveryDemandAllowed = false;
2637
+ release?.();
2638
+ }
2639
+ /** Cancel a pending automatic retry when an explicit lifecycle transition
2640
+ * supersedes it. The released gate re-enters runTransport(), which then
2641
+ * follows the newest start/stop/suspend intent. */
2642
+ cancelScheduledRecovery() {
2643
+ this.recoveryTimerToken += 1;
2644
+ if (this.recoveryTimer !== null) {
2645
+ clearTimeout(this.recoveryTimer);
2646
+ this.recoveryTimer = null;
2647
+ }
2648
+ this.releaseRecoveryGate();
2649
+ }
2650
+ /** Keep the recovery gate closed after a failed attempt while allowing the
2651
+ * next explicit transport operation to start an immediate on-demand reopen.
2652
+ * If no gate/successor retry remains, release any waiters. */
2653
+ allowDemandRecovery() {
2654
+ if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
2655
+ this.recoveryDemandAllowed = true;
2656
+ return;
2657
+ }
2658
+ this.releaseRecoveryGate();
2659
+ }
2660
+ /** Reset failure and recovery diagnostics for a new explicit start session. */
2661
+ resetFailureState() {
2662
+ this.cancelScheduledRecovery();
2663
+ this.lastError = null;
2664
+ this.lastErrorAt = null;
2665
+ this.lastFailure = null;
2666
+ this.persistenceFailureCount = 0;
2667
+ this.persistenceLastFailureAt = null;
2668
+ this.persistenceLastErrorMessage = null;
2669
+ this.recoveryAttempt = 0;
2670
+ this.recoveryExhausted = false;
2671
+ this.lastRecoveryAt = 0;
2672
+ }
2504
2673
  /**
2505
2674
  * Open the transport, chained after `before` to ensure lifecycle ordering.
2506
2675
  * When `stopClusterOnFailure` is true (initial start), a transport failure
2507
2676
  * tears down the cluster as well.
2508
2677
  */
2509
- openTransport(config, before, stopClusterOnFailure) {
2678
+ openTransport(config, before, stopClusterOnFailure, lifecycleEpoch) {
2510
2679
  this.transportReady = false;
2511
2680
  const chainedPendingStop = this.pendingStop;
2681
+ const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
2512
2682
  return before.catch(() => void 0).then(() => {
2513
- if (this.stopping || this.suspended) return;
2683
+ if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
2514
2684
  if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
2685
+ this.transportHasConnected = false;
2515
2686
  return Promise.resolve(
2516
2687
  this.transport.start(config, {
2517
- onMessage: (message) => this.handleTransportMessage(message),
2518
- onStatus: (status) => this.updateStatus(status),
2519
- onError: (error) => this.reportError(error)
2688
+ onMessage: (message) => {
2689
+ if (isCurrentLifecycle()) this.handleTransportMessage(message);
2690
+ },
2691
+ onStatus: (status) => {
2692
+ if (isCurrentLifecycle()) this.updateStatus(status);
2693
+ },
2694
+ onError: (error) => {
2695
+ if (isCurrentLifecycle()) this.reportError(error);
2696
+ }
2520
2697
  })
2521
2698
  ).then(() => {
2699
+ if (!isCurrentLifecycle()) return;
2522
2700
  if (this.status === WORKER_STATUS.ERROR) {
2523
2701
  throw new Error("Transport failed during startup.");
2524
2702
  }
@@ -2526,33 +2704,51 @@ var CrossTabDataBus = class {
2526
2704
  this.recoveryGeneration += 1;
2527
2705
  this.lastSuccessAt = this.now();
2528
2706
  this.transportReady = true;
2707
+ this.releaseRecoveryGate();
2529
2708
  }
2530
2709
  });
2531
2710
  }).catch((error) => {
2711
+ if (!isCurrentLifecycle()) throw error;
2532
2712
  if (stopClusterOnFailure) this.started = false;
2533
2713
  if (!this.pendingStop) {
2534
2714
  this.pendingStop = this.createStopPromise();
2535
2715
  }
2716
+ this.transportReady = false;
2536
2717
  this.updateStatus(WORKER_STATUS.ERROR);
2537
2718
  this.reportError(error);
2538
- this.lastError = error;
2539
- this.lastErrorAt = this.now();
2540
- this.transportReady = false;
2541
2719
  if (stopClusterOnFailure) {
2542
2720
  this.stopping = true;
2543
2721
  this.cluster.stop();
2544
2722
  this.stopping = false;
2545
2723
  }
2546
- this.startPromise = null;
2547
2724
  throw error;
2548
2725
  });
2549
2726
  }
2550
2727
  /**
2551
2728
  * Await the DataBus to be fully started (lazy init when using initialConfig).
2552
2729
  * Returns a rejected promise when the transport has failed and no start is in
2553
- * flight — the caller can retry by calling start() or ready() again.
2730
+ * flight — the caller can retry by calling start() or ready() again. While an
2731
+ * explicit stop() is settling, this rejects unless a restart is queued behind
2732
+ * it; false readiness during teardown is never reported. While the tab is
2733
+ * BFCache-suspended (pagehide without a following pageshow), this also
2734
+ * rejects: the suspended start promise is the transport-stop gate, not a
2735
+ * readiness signal.
2554
2736
  */
2555
2737
  ready() {
2738
+ if (this.queuedStart) return this.getQueuedStartReady();
2739
+ if (this.stopping) {
2740
+ return Promise.reject(new Error(
2741
+ "CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
2742
+ ));
2743
+ }
2744
+ if (this.suspended) {
2745
+ return Promise.reject(new Error(
2746
+ "CrossTabDataBus is suspended; ready() cannot report readiness until pageshow resumes the transport."
2747
+ ));
2748
+ }
2749
+ if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
2750
+ return Promise.reject(this.lastError);
2751
+ }
2556
2752
  try {
2557
2753
  this.ensureStarted();
2558
2754
  } catch (error) {
@@ -2568,9 +2764,18 @@ var CrossTabDataBus = class {
2568
2764
  /**
2569
2765
  * Register a handler for `topic`. The handler fires on every publication
2570
2766
  * delivered to this tab, regardless of which tab published it. Returns an
2571
- * unsubscribe function for convenience.
2767
+ * unsubscribe function for convenience. During an explicit stop() the
2768
+ * registration is rejected through onError and a no-op cleanup is returned,
2769
+ * so a late subscriber cannot leak into a future restart.
2572
2770
  */
2573
2771
  subscribe(topic, handler, options) {
2772
+ if (this.stopping) {
2773
+ this.reportError(new Error(
2774
+ "CrossTabDataBus is stopping; subscribe() was not registered. Wait for stop() to resolve, then call start() before subscribing again."
2775
+ ));
2776
+ return () => {
2777
+ };
2778
+ }
2574
2779
  this.ensureStarted();
2575
2780
  const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
2576
2781
  const wasUnused = handlers.size === 0;
@@ -2624,6 +2829,7 @@ var CrossTabDataBus = class {
2624
2829
  /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
2625
2830
  publish(topic, data, options) {
2626
2831
  this.ensureStarted();
2832
+ if (this.rejectPublishDuringStop("publish")) return;
2627
2833
  if (!this.cluster.publish(topic, data, options)) {
2628
2834
  this.reportError(
2629
2835
  new Error("Failed to send the publish control message to the owning worker.")
@@ -2640,6 +2846,7 @@ var CrossTabDataBus = class {
2640
2846
  publishBatch(topic, items) {
2641
2847
  this.ensureStarted();
2642
2848
  if (items.length === 0) return;
2849
+ if (this.rejectPublishDuringStop("publishBatch")) return;
2643
2850
  if (items.length === 1) {
2644
2851
  const first = items[0];
2645
2852
  this.publish(topic, first.data, first.options);
@@ -2674,11 +2881,15 @@ var CrossTabDataBus = class {
2674
2881
  getStatus() {
2675
2882
  return this.status;
2676
2883
  }
2677
- /** Return the current automatic transport recovery state. `hasError` means a transport error is currently retained. */
2678
2884
  /** Return the current automatic transport recovery state plus diagnostics.
2679
- * `generation` increments on every successful transport open (initial start
2680
- * and every recovery); `lastSuccessAt` is the timestamp of the most recent
2681
- * successful open, or `null` until the transport reaches `ready`. */
2885
+ * `hasError`/`errorMessage`/`errorAt` describe the most recent retained
2886
+ * *transport* failure from a transport open or a runtime `onError`. They
2887
+ * share the lifetime of the unified `lastFailure` ledger: a successful
2888
+ * recovery keeps the last failure visible, and only an explicit `start()`
2889
+ * clears it. `generation` increments on every successful transport open
2890
+ * (initial start and every recovery); `lastSuccessAt` is the timestamp of
2891
+ * the most recent successful open, or `null` until the transport reaches
2892
+ * `ready`. */
2682
2893
  getRecoveryStats() {
2683
2894
  const errorMessage = this.lastError instanceof Error ? this.lastError.message : this.lastError === null ? null : String(this.lastError);
2684
2895
  return {
@@ -2705,7 +2916,7 @@ var CrossTabDataBus = class {
2705
2916
  * unified failure ledger and recovery context that explains the verdict. */
2706
2917
  getHealthSummary() {
2707
2918
  const transport = this.transport;
2708
- const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2919
+ const transportDown = this.status !== WORKER_STATUS.CONNECTED;
2709
2920
  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;
2710
2921
  return {
2711
2922
  healthy: state === HEALTH_STATE.HEALTHY,
@@ -2768,11 +2979,35 @@ var CrossTabDataBus = class {
2768
2979
  }
2769
2980
  /**
2770
2981
  * Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
2771
- * and close the transport. Idempotent.
2982
+ * and close the transport. Concurrent and repeated calls share the in-flight
2983
+ * stop promise. A start() received while stopping runs after this completes,
2984
+ * unless another stop() arrives first and cancels that queued restart.
2772
2985
  */
2773
- async stop() {
2774
- if (!this.started) return;
2986
+ stop() {
2987
+ if (this.queuedStart) {
2988
+ this.canceledQueuedStartToken = this.queuedStartToken;
2989
+ this.queuedStart = null;
2990
+ }
2991
+ if (this.stopPromise) return this.stopPromise;
2992
+ if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
2993
+ return Promise.resolve();
2994
+ }
2995
+ const stopPromise = this.performStop();
2996
+ this.stopPromise = stopPromise;
2997
+ void stopPromise.then(
2998
+ () => {
2999
+ if (this.stopPromise === stopPromise) this.stopPromise = null;
3000
+ },
3001
+ () => {
3002
+ if (this.stopPromise === stopPromise) this.stopPromise = null;
3003
+ }
3004
+ );
3005
+ return stopPromise;
3006
+ }
3007
+ async performStop() {
3008
+ this.lifecycleEpoch += 1;
2775
3009
  this.stopping = true;
3010
+ this.cancelScheduledRecovery();
2776
3011
  this.replayManager.suspend();
2777
3012
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
2778
3013
  this.trace.stop();
@@ -2785,6 +3020,8 @@ var CrossTabDataBus = class {
2785
3020
  const pendingStop = this.pendingStop;
2786
3021
  if (pendingStop) await pendingStop.catch(() => void 0);
2787
3022
  else await this.transport.stop();
3023
+ } catch (error) {
3024
+ this.reportError(error);
2788
3025
  } finally {
2789
3026
  this.transportSubscribedTopics.clear();
2790
3027
  this.resetDedup();
@@ -2850,10 +3087,12 @@ var CrossTabDataBus = class {
2850
3087
  updateStatus(status) {
2851
3088
  const previousStatus = this.status;
2852
3089
  this.status = status;
3090
+ if (status === WORKER_STATUS.CONNECTED) this.transportHasConnected = true;
2853
3091
  if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
2854
3092
  this.cluster.setStatus(status);
2855
3093
  if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
2856
3094
  if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
3095
+ if (this.transportReady) this.releaseRecoveryGate();
2857
3096
  for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
2858
3097
  }
2859
3098
  if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
@@ -2866,23 +3105,49 @@ var CrossTabDataBus = class {
2866
3105
  this.recoveryExhausted = true;
2867
3106
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
2868
3107
  }
3108
+ this.releaseRecoveryGate();
2869
3109
  return;
2870
3110
  }
2871
3111
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
2872
- setTimeout(() => {
2873
- if (this.stopping || !this.started || this.suspended) return;
2874
- if (this.status !== WORKER_STATUS.ERROR) return;
2875
- void this.reopenTransport(attempt);
3112
+ if (this.recoveryGate === null) {
3113
+ let release;
3114
+ this.recoveryGate = new Promise((resolve) => {
3115
+ release = resolve;
3116
+ });
3117
+ this.recoveryGateRelease = release;
3118
+ }
3119
+ this.recoveryDemandAllowed = false;
3120
+ const timerToken = ++this.recoveryTimerToken;
3121
+ this.recoveryTimer = setTimeout(() => {
3122
+ if (timerToken !== this.recoveryTimerToken) return;
3123
+ this.recoveryTimer = null;
3124
+ if (this.stopping || !this.started || this.suspended || this.status !== WORKER_STATUS.ERROR) {
3125
+ this.releaseRecoveryGate();
3126
+ return;
3127
+ }
3128
+ this.recoveryDemandAllowed = false;
3129
+ const opening = this.reopenTransport(attempt);
3130
+ void opening.then(
3131
+ () => this.releaseRecoveryGate(),
3132
+ () => this.allowDemandRecovery()
3133
+ );
2876
3134
  }, this.recoveryCooldownMs);
2877
3135
  }
3136
+ } else if (status === WORKER_STATUS.ERROR) {
3137
+ this.releaseRecoveryGate();
2878
3138
  }
2879
3139
  this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
2880
3140
  }
2881
3141
  reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
3142
+ const at = this.now();
3143
+ if (source === FAILURE_SOURCE.TRANSPORT) {
3144
+ this.lastError = error;
3145
+ this.lastErrorAt = at;
3146
+ }
2882
3147
  this.lastFailure = {
2883
3148
  source,
2884
3149
  message: error instanceof Error ? error.message : String(error),
2885
- at: this.now()
3150
+ at
2886
3151
  };
2887
3152
  if (source === FAILURE_SOURCE.PERSISTENCE) {
2888
3153
  this.persistenceFailureCount += 1;
@@ -2963,7 +3228,9 @@ var CrossTabDataBus = class {
2963
3228
  */
2964
3229
  suspendTransport() {
2965
3230
  if (this.stopping) return;
3231
+ this.lifecycleEpoch += 1;
2966
3232
  this.suspended = true;
3233
+ this.cancelScheduledRecovery();
2967
3234
  this.transportReady = false;
2968
3235
  this.transportSubscribedTopics.clear();
2969
3236
  this.updateStatus(WORKER_STATUS.DISCONNECTED);
@@ -3001,19 +3268,23 @@ var CrossTabDataBus = class {
3001
3268
  this.started = true;
3002
3269
  this.suspended = false;
3003
3270
  this.updateStatus(WORKER_STATUS.CONNECTING);
3271
+ const lifecycleEpoch = ++this.lifecycleEpoch;
3004
3272
  const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
3005
- const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
3273
+ const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
3006
3274
  this.startPromise = opening;
3007
3275
  void opening.then(
3008
3276
  () => {
3277
+ if (this.startPromise === opening) this.startPromise = null;
3278
+ if (lifecycleEpoch !== this.lifecycleEpoch) return;
3009
3279
  if (traceAttempt !== void 0) {
3010
3280
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
3011
3281
  this.recoveryAttempt = 0;
3012
3282
  this.recoveryExhausted = false;
3013
3283
  }
3014
- if (this.startPromise === opening) this.startPromise = null;
3015
3284
  },
3016
3285
  () => {
3286
+ if (this.startPromise === opening) this.startPromise = null;
3287
+ if (lifecycleEpoch !== this.lifecycleEpoch) return;
3017
3288
  if (traceAttempt !== void 0) {
3018
3289
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
3019
3290
  }
@@ -3029,7 +3300,24 @@ var CrossTabDataBus = class {
3029
3300
  */
3030
3301
  runTransport(operation) {
3031
3302
  if (this.suspended) return;
3032
- if (this.transportReady && !this.stopping) {
3303
+ if (this.recoveryGate && !this.stopping) {
3304
+ if (this.recoveryDemandAllowed && this.status === WORKER_STATUS.ERROR && !this.suspended) {
3305
+ this.recoveryDemandAllowed = false;
3306
+ const opening = this.reopenTransport();
3307
+ void opening.then(
3308
+ () => this.releaseRecoveryGate(),
3309
+ () => this.allowDemandRecovery()
3310
+ );
3311
+ }
3312
+ const gate = this.recoveryGate;
3313
+ void gate.then(() => {
3314
+ if (this.stopping || this.suspended) return;
3315
+ this.runTransport(operation);
3316
+ });
3317
+ return;
3318
+ }
3319
+ const droppedAfterConnect = this.transportHasConnected && this.status === WORKER_STATUS.DISCONNECTED;
3320
+ if (this.transportReady && this.status !== WORKER_STATUS.ERROR && !droppedAfterConnect && !this.stopping) {
3033
3321
  try {
3034
3322
  void Promise.resolve(operation()).catch((error) => this.reportError(error));
3035
3323
  } catch (error) {
@@ -3047,6 +3335,19 @@ var CrossTabDataBus = class {
3047
3335
  return operation();
3048
3336
  }).catch((error) => this.reportError(error));
3049
3337
  }
3338
+ /**
3339
+ * Publications started after teardown begins cannot reach any transport.
3340
+ * Surface that as a normal asynchronous API failure instead of letting
3341
+ * runTransport() return silently. Empty publishBatch() calls remain a no-op
3342
+ * and are filtered by the caller before this check.
3343
+ */
3344
+ rejectPublishDuringStop(operation) {
3345
+ if (!this.stopping) return false;
3346
+ this.reportError(new Error(
3347
+ `CrossTabDataBus is stopping; ${operation}() was not sent. Wait for stop() to resolve, then call start() before publishing again.`
3348
+ ));
3349
+ return true;
3350
+ }
3050
3351
  /**
3051
3352
  * Ensure the DataBus is started, throwing if no initialConfig was provided.
3052
3353
  * Called automatically by subscribe/publish/ready when autoStart is true.
@@ -3152,35 +3453,29 @@ function createIndexedDbReplayPersistence(options) {
3152
3453
  grouped.set(message.topic, [...grouped.get(message.topic) ?? [], message]);
3153
3454
  }
3154
3455
  let hasError = false;
3456
+ const fail = (error) => {
3457
+ if (hasError) return;
3458
+ hasError = true;
3459
+ invalidate(db);
3460
+ reject(error);
3461
+ };
3155
3462
  for (const [topic, topicMessages] of grouped) {
3156
3463
  const request = store.get(topic);
3157
3464
  request.onsuccess = () => {
3158
3465
  if (hasError) return;
3159
- let history = (request.result?.messages ?? []).concat(topicMessages);
3160
- const ageBounded = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
3161
- if (ageBounded) {
3162
- const cutoff = Date.now() - retentionMs;
3163
- history = history.filter((item) => item.timestamp === void 0 || item.timestamp >= cutoff);
3164
- }
3165
- if (pruneStrategy !== PRUNE_STRATEGY.AGE || !ageBounded) history = history.slice(-maxPerTopic);
3466
+ const history = pruneReplayHistory(
3467
+ (request.result?.messages ?? []).concat(topicMessages),
3468
+ { maxPerTopic, pruneStrategy, retentionMs, now: Date.now() }
3469
+ );
3166
3470
  store.put({ topic, messages: history });
3167
3471
  };
3168
- request.onerror = () => {
3169
- if (hasError) return;
3170
- hasError = true;
3171
- invalidate(db);
3172
- reject(request.error ?? new Error("Failed to read replay history."));
3173
- };
3472
+ request.onerror = () => fail(request.error ?? new Error("Failed to read replay history."));
3174
3473
  }
3175
3474
  transaction.oncomplete = () => {
3176
3475
  if (!hasError) resolve();
3177
3476
  };
3178
- transaction.onerror = () => {
3179
- if (hasError) return;
3180
- hasError = true;
3181
- invalidate(db);
3182
- reject(transaction.error ?? new Error("Failed to persist replay history."));
3183
- };
3477
+ transaction.onerror = () => fail(transaction.error ?? new Error("Failed to persist replay history."));
3478
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to persist replay history."));
3184
3479
  });
3185
3480
  })();
3186
3481
  const open = () => {
@@ -3208,19 +3503,34 @@ function createIndexedDbReplayPersistence(options) {
3208
3503
  async load() {
3209
3504
  const db = await open();
3210
3505
  return new Promise((resolve, reject) => {
3506
+ let transaction;
3211
3507
  let request;
3212
3508
  try {
3213
- request = db.transaction(storeName, "readonly").objectStore(storeName).getAll();
3509
+ transaction = db.transaction(storeName, "readonly");
3510
+ request = transaction.objectStore(storeName).getAll();
3214
3511
  } catch (error) {
3215
3512
  invalidate(db);
3216
3513
  reject(error);
3217
3514
  return;
3218
3515
  }
3219
- request.onsuccess = () => resolve(request.result.flatMap((record) => record.messages));
3220
- request.onerror = () => {
3516
+ let settled = false;
3517
+ const fail = (error) => {
3518
+ if (settled) return;
3519
+ settled = true;
3221
3520
  invalidate(db);
3222
- reject(request.error ?? new Error("Failed to load replay history."));
3521
+ reject(error);
3522
+ };
3523
+ let records = [];
3524
+ request.onsuccess = () => {
3525
+ records = request.result;
3223
3526
  };
3527
+ request.onerror = () => fail(request.error ?? new Error("Failed to load replay history."));
3528
+ transaction.oncomplete = () => {
3529
+ if (settled) return;
3530
+ settled = true;
3531
+ resolve(records.flatMap((record) => record.messages));
3532
+ };
3533
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to load replay history."));
3224
3534
  });
3225
3535
  },
3226
3536
  append(message) {
@@ -3244,12 +3554,20 @@ function createIndexedDbReplayPersistence(options) {
3244
3554
  reject(error);
3245
3555
  return;
3246
3556
  }
3247
- transaction.objectStore(storeName).clear();
3248
- transaction.oncomplete = () => resolve();
3249
- transaction.onerror = () => {
3557
+ let settled = false;
3558
+ const fail = (error) => {
3559
+ if (settled) return;
3560
+ settled = true;
3250
3561
  invalidate(db);
3251
- reject(transaction.error ?? new Error("Failed to clear replay history."));
3562
+ reject(error);
3252
3563
  };
3564
+ transaction.objectStore(storeName).clear();
3565
+ transaction.oncomplete = () => {
3566
+ settled = true;
3567
+ resolve();
3568
+ };
3569
+ transaction.onerror = () => fail(transaction.error ?? new Error("Failed to clear replay history."));
3570
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to clear replay history."));
3253
3571
  });
3254
3572
  })()
3255
3573
  });
@@ -3268,12 +3586,20 @@ function createIndexedDbReplayPersistence(options) {
3268
3586
  reject(error);
3269
3587
  return;
3270
3588
  }
3271
- transaction.objectStore(storeName).delete(topic);
3272
- transaction.oncomplete = () => resolve();
3273
- transaction.onerror = () => {
3589
+ let settled = false;
3590
+ const fail = (error) => {
3591
+ if (settled) return;
3592
+ settled = true;
3274
3593
  invalidate(db);
3275
- reject(transaction.error ?? new Error("Failed to clear topic replay history."));
3594
+ reject(error);
3276
3595
  };
3596
+ transaction.objectStore(storeName).delete(topic);
3597
+ transaction.oncomplete = () => {
3598
+ settled = true;
3599
+ resolve();
3600
+ };
3601
+ transaction.onerror = () => fail(transaction.error ?? new Error("Failed to clear topic replay history."));
3602
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to clear topic replay history."));
3277
3603
  });
3278
3604
  })()
3279
3605
  });
@@ -3292,6 +3618,13 @@ function createIndexedDbReplayPersistence(options) {
3292
3618
  reject(error);
3293
3619
  return;
3294
3620
  }
3621
+ let settled = false;
3622
+ const fail = (error) => {
3623
+ if (settled) return;
3624
+ settled = true;
3625
+ invalidate(db);
3626
+ reject(error);
3627
+ };
3295
3628
  const store = transaction.objectStore(storeName);
3296
3629
  const request = store.getAll();
3297
3630
  request.onsuccess = () => {
@@ -3301,15 +3634,13 @@ function createIndexedDbReplayPersistence(options) {
3301
3634
  else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
3302
3635
  }
3303
3636
  };
3304
- request.onerror = () => {
3305
- invalidate(db);
3306
- reject(request.error ?? new Error("Failed to read replay history."));
3307
- };
3308
- transaction.oncomplete = () => resolve();
3309
- transaction.onerror = () => {
3310
- invalidate(db);
3311
- reject(transaction.error ?? new Error("Failed to prune replay history."));
3637
+ request.onerror = () => fail(request.error ?? new Error("Failed to read replay history."));
3638
+ transaction.oncomplete = () => {
3639
+ settled = true;
3640
+ resolve();
3312
3641
  };
3642
+ transaction.onerror = () => fail(transaction.error ?? new Error("Failed to prune replay history."));
3643
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to prune replay history."));
3313
3644
  });
3314
3645
  })()
3315
3646
  });
@@ -3340,6 +3671,7 @@ function parseDataBusPublication(value, fallbackTopic) {
3340
3671
  }
3341
3672
 
3342
3673
  // src/websocket.ts
3674
+ var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
3343
3675
  var WS_OPEN = 1;
3344
3676
  var WebSocketTransport = class {
3345
3677
  constructor(connection) {
@@ -3349,12 +3681,27 @@ var WebSocketTransport = class {
3349
3681
  diagnosticsName = "websocket";
3350
3682
  diagnosticsBackend = "native-websocket";
3351
3683
  socket = null;
3684
+ socketActive = false;
3352
3685
  handlers = null;
3353
3686
  subscribedTopics = /* @__PURE__ */ new Set();
3354
- /** Open the WebSocket and wire lifecycle listeners. A factory failure is
3355
- * reported through `onStatus('error')` so the DataBus can recover. */
3687
+ // Handshake gate for the current start(). Resolves once the socket opens,
3688
+ // rejects when the attempt fails, so the DataBus start Promise — and every
3689
+ // operation parked behind it — settles at the real connection boundary.
3690
+ connectPromise = null;
3691
+ connectResolve = null;
3692
+ connectReject = null;
3693
+ connectTimer = null;
3694
+ /** Open the WebSocket and wire lifecycle listeners. Resolves once the
3695
+ * handshake completes and rejects when the attempt fails, matching the
3696
+ * `DataBusTransport.start` contract ("resolves on connect or rejects on
3697
+ * failure"). A factory failure is reported through `onStatus('error')` so
3698
+ * the DataBus can recover. */
3356
3699
  start(config, handlers) {
3357
- if (this.socket) return;
3700
+ if (this.socket && this.socketActive) {
3701
+ return this.connectPromise ?? void 0;
3702
+ }
3703
+ this.socket = null;
3704
+ this.socketActive = false;
3358
3705
  this.handlers = handlers;
3359
3706
  const factory = config.webSocketFactory ?? this.connection.webSocketFactory ?? defaultWebSocketFactory;
3360
3707
  const protocols = config.protocols ?? this.connection.protocols;
@@ -3366,23 +3713,66 @@ var WebSocketTransport = class {
3366
3713
  handlers.onError(error);
3367
3714
  return;
3368
3715
  }
3369
- socket.onopen = () => {
3370
- if (this.socket !== socket || this.handlers !== handlers) return;
3371
- for (const topic of this.subscribedTopics) {
3372
- this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
3716
+ const opening = new Promise((resolve, reject) => {
3717
+ this.connectResolve = resolve;
3718
+ this.connectReject = reject;
3719
+ let handshakeCompleted = false;
3720
+ let handshakeFailed = false;
3721
+ const timeoutMs = config.connectTimeoutMs ?? this.connection.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
3722
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
3723
+ this.connectTimer = setTimeout(() => {
3724
+ if (this.socket !== socket || this.handlers !== handlers || handshakeCompleted) return;
3725
+ handshakeFailed = true;
3726
+ this.connectTimer = null;
3727
+ this.socketActive = false;
3728
+ const error = new Error(`WebSocket did not open within ${timeoutMs}ms.`);
3729
+ handlers.onStatus(WORKER_STATUS.ERROR);
3730
+ handlers.onError(error);
3731
+ this.failConnect(error);
3732
+ socket.close();
3733
+ }, timeoutMs);
3373
3734
  }
3374
- handlers.onStatus(WORKER_STATUS.CONNECTED);
3375
- };
3376
- socket.onclose = () => {
3377
- if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.DISCONNECTED);
3378
- };
3379
- socket.onerror = () => {
3380
- if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.ERROR);
3381
- };
3382
- socket.onmessage = (event) => {
3383
- if (this.socket === socket && this.handlers === handlers) void this.handleMessage(event.data);
3384
- };
3385
- this.socket = socket;
3735
+ socket.onopen = () => {
3736
+ if (this.socket !== socket || this.handlers !== handlers || handshakeFailed) return;
3737
+ this.socketActive = true;
3738
+ this.clearConnectTimer();
3739
+ for (const topic of this.subscribedTopics) {
3740
+ this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
3741
+ }
3742
+ handlers.onStatus(WORKER_STATUS.CONNECTED);
3743
+ if (!handshakeCompleted) {
3744
+ handshakeCompleted = true;
3745
+ this.settleConnect();
3746
+ }
3747
+ };
3748
+ socket.onclose = () => {
3749
+ if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;
3750
+ this.socketActive = false;
3751
+ handlers.onStatus(WORKER_STATUS.DISCONNECTED);
3752
+ if (!handshakeCompleted) {
3753
+ handshakeFailed = true;
3754
+ this.failConnect(new Error("WebSocket closed before the handshake completed."));
3755
+ }
3756
+ };
3757
+ socket.onerror = () => {
3758
+ if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;
3759
+ this.socketActive = false;
3760
+ handlers.onStatus(WORKER_STATUS.ERROR);
3761
+ if (!handshakeCompleted) {
3762
+ handshakeFailed = true;
3763
+ this.failConnect(new Error("WebSocket failed to open."));
3764
+ }
3765
+ };
3766
+ socket.onmessage = (event) => {
3767
+ if (this.socket === socket && this.handlers === handlers && this.socketActive) {
3768
+ void this.handleMessage(event.data);
3769
+ }
3770
+ };
3771
+ this.socket = socket;
3772
+ this.socketActive = true;
3773
+ });
3774
+ this.connectPromise = opening;
3775
+ return opening;
3386
3776
  }
3387
3777
  /** Idempotent: re-subscribing an active topic re-sends the frame but does
3388
3778
  * not duplicate the local tracking entry. */
@@ -3438,10 +3828,38 @@ var WebSocketTransport = class {
3438
3828
  /** Close the socket and drop all state. Safe to call multiple times. */
3439
3829
  stop() {
3440
3830
  const socket = this.socket;
3831
+ const shouldClose = this.socketActive;
3441
3832
  this.socket = null;
3833
+ this.socketActive = false;
3442
3834
  this.handlers = null;
3443
3835
  this.subscribedTopics.clear();
3444
- socket?.close();
3836
+ this.settleConnect();
3837
+ this.connectPromise = null;
3838
+ if (shouldClose) socket?.close();
3839
+ }
3840
+ /** Resolve the in-flight handshake gate. Idempotent: once the socket has
3841
+ * opened (or a newer attempt replaced it) later calls are no-ops. */
3842
+ settleConnect() {
3843
+ this.clearConnectTimer();
3844
+ const resolve = this.connectResolve;
3845
+ this.connectResolve = null;
3846
+ this.connectReject = null;
3847
+ resolve?.();
3848
+ }
3849
+ /** Reject the in-flight handshake gate. Idempotent on the same terms as
3850
+ * {@link settleConnect}. */
3851
+ failConnect(error) {
3852
+ this.clearConnectTimer();
3853
+ const reject = this.connectReject;
3854
+ this.connectResolve = null;
3855
+ this.connectReject = null;
3856
+ reject?.(error);
3857
+ }
3858
+ clearConnectTimer() {
3859
+ if (this.connectTimer !== null) {
3860
+ clearTimeout(this.connectTimer);
3861
+ this.connectTimer = null;
3862
+ }
3445
3863
  }
3446
3864
  /** Send one JSON frame. Frames are dropped with an `onError` report when
3447
3865
  * the socket is not open — subscribe frames are re-sent on open, so the