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
@@ -38,6 +38,13 @@ export interface WebSocketDataBusConfig {
38
38
  /** Custom socket factory. Defaults to the global `WebSocket`; injectable
39
39
  * for tests and non-browser runtimes. */
40
40
  webSocketFactory?: (url: string, protocols?: string | string[]) => WebSocketLike;
41
+ /** Milliseconds to wait for the handshake before reporting `error` and
42
+ * failing the start. Defaults to 30000 ms; pass
43
+ * `0` or `Infinity` to wait indefinitely. The timeout exists because
44
+ * `start()` resolves on connect, so a socket that never opens and never
45
+ * errors would otherwise leave the DataBus start gate (and every operation
46
+ * queued behind it) pending forever. */
47
+ connectTimeoutMs?: number;
41
48
  }
42
49
  /** Options for creating a fully-configured CrossTabDataBus with a WebSocket transport. */
43
50
  export interface CreateWebSocketDataBusOptions<TData = unknown> extends Omit<CrossTabDataBusOptions<WebSocketDataBusConfig, TData>, 'autoStart' | 'clusterKey' | 'initialConfig' | 'transport'> {
@@ -56,11 +63,19 @@ export declare class WebSocketTransport<TData = unknown> implements DataBusTrans
56
63
  readonly diagnosticsName = "websocket";
57
64
  readonly diagnosticsBackend = "native-websocket";
58
65
  private socket;
66
+ private socketActive;
59
67
  private handlers;
60
68
  private readonly subscribedTopics;
69
+ private connectPromise;
70
+ private connectResolve;
71
+ private connectReject;
72
+ private connectTimer;
61
73
  constructor(connection: WebSocketDataBusConfig);
62
- /** Open the WebSocket and wire lifecycle listeners. A factory failure is
63
- * reported through `onStatus('error')` so the DataBus can recover. */
74
+ /** Open the WebSocket and wire lifecycle listeners. Resolves once the
75
+ * handshake completes and rejects when the attempt fails, matching the
76
+ * `DataBusTransport.start` contract ("resolves on connect or rejects on
77
+ * failure"). A factory failure is reported through `onStatus('error')` so
78
+ * the DataBus can recover. */
64
79
  start(config: WebSocketDataBusConfig, handlers: DataBusTransportHandlers<TData>): MaybePromise<void>;
65
80
  /** Idempotent: re-subscribing an active topic re-sends the frame but does
66
81
  * not duplicate the local tracking entry. */
@@ -75,6 +90,13 @@ export declare class WebSocketTransport<TData = unknown> implements DataBusTrans
75
90
  publishBatch(topic: string, items: ReadonlyArray<DataBusPublicationItem>): MaybePromise<void>;
76
91
  /** Close the socket and drop all state. Safe to call multiple times. */
77
92
  stop(): MaybePromise<void>;
93
+ /** Resolve the in-flight handshake gate. Idempotent: once the socket has
94
+ * opened (or a newer attempt replaced it) later calls are no-ops. */
95
+ private settleConnect;
96
+ /** Reject the in-flight handshake gate. Idempotent on the same terms as
97
+ * {@link settleConnect}. */
98
+ private failConnect;
99
+ private clearConnectTimer;
78
100
  /** Send one JSON frame. Frames are dropped with an `onError` report when
79
101
  * the socket is not open — subscribe frames are re-sent on open, so the
80
102
  * only real loss is a publish during a disconnect window. */
@@ -1 +1 @@
1
- {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"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"}
package/docs/api.md CHANGED
@@ -56,7 +56,7 @@ Creates a DataBus. When `initialConfig` is provided, it starts automatically by
56
56
  start(config: TConfig): Promise<void>
57
57
  ```
58
58
 
59
- Starts cluster coordination and transport. The first call actually starts the transport; concurrent calls during startup share the same start Promise without creating a duplicate transport. After startup succeeds or fails, the internal gate resets: subsequent calls are no-ops on an already-started instance (immediately resolve) and do not restart; after `stop()`, it can be called again to restart.
59
+ Starts cluster coordination and transport. The first call actually starts the transport; concurrent calls during an in-flight open share the same start Promise without creating a duplicate transport. A call made on a healthy started instance is an immediate no-op. If the transport is down, `start()` acts as an explicit manual recovery: it preserves the cluster, subscriptions, and replay buffers, resets the failure/recovery ledger, and reopens the transport. If an explicit `stop()` is still settling, `start()` queues one fresh start behind that cleanup and returns a Promise that settles with the restart. That queued restart belongs to the latest lifecycle intent: a `stop()` arriving before it can run cancels it (resolving the queued start Promise without opening a transport), and a `start()` issued after that cancellation queues a fresh restart. After `stop()` has completed, `start()` can be called normally to restart.
60
60
 
61
61
  ### `ready()`
62
62
 
@@ -66,9 +66,17 @@ ready(): Promise<void>
66
66
 
67
67
  Waits for the current transport's `start` to complete. The Promise rejects when auto-start fails; calling again can trigger a retry based on `initialConfig`.
68
68
 
69
+ While an explicit `stop()` is settling, `ready()` rejects unless a `start()` has queued a restart behind that stop. It never resolves against a transport that is already being torn down. Wait for `stop()` to settle, then call `start()` before awaiting `ready()` again.
70
+
71
+ If a later `stop()` cancels that queued restart, the queued `start()` Promise still resolves without opening a transport, but `ready()` rejects with a lifecycle error rather than reporting a stopped bus as ready.
72
+
73
+ While the tab is BFCache-suspended (after `pagehide` and before `pageshow`), `ready()` rejects with a suspended-state error. The suspend path reuses `startPromise` as the asynchronous `transport.stop()` gate, so returning it would resolve readiness against a deliberately stopped transport. `pageshow` or an explicit `start()` clears the suspension and installs a real reopen promise, after which `ready()` resolves normally once the transport is ready.
74
+
75
+ If that queued restart fails during transport startup, `ready()` rejects with the underlying startup error even when no `initialConfig` was supplied. The failure is retained for explicit recovery rather than being replaced by the generic missing-configuration error.
76
+
69
77
  When no `initialConfig` is provided and `start(config)` has not been called, `ready()` returns a rejected Promise instead of throwing synchronously, so callers can attach `.catch` and decide whether to start explicitly.
70
78
 
71
- `ready()` is not equivalent to the server being connected; protocol connection status is obtained via `onStatus`.
79
+ `ready()` resolves when the current transport satisfies its `start()` contract; it is not a guarantee that the remote server is ready to serve application traffic. For the built-in WebSocket backend, `start()` waits for the socket handshake and rejects on a pre-open `error`, a pre-open `close`, or `connectTimeoutMs` expiry, so `ready()` cannot resolve against a `CONNECTING` socket. Protocol-level connection status remains available through `onStatus`.
72
80
 
73
81
  ### `subscribe(topic, handler)`
74
82
 
@@ -84,9 +92,10 @@ Registers a local subscription and returns a cleanup function.
84
92
  - Multiple handlers for the same topic use reference counting.
85
93
  - The first handler in the current tab registers a cluster subscription.
86
94
  - The current tab only leaves the topic after the last handler is released.
87
- - Subscriptions are automatically queued when the transport is not yet ready.
95
+ - Subscriptions are automatically queued when the transport is not yet ready, including while a transport recovery is pending: they are held behind the recovery gate and issued once the reopen succeeds instead of being written to the connection that just reported `error`.
96
+ - A subscription requested while an explicit `stop()` is settling is not registered: `subscribe()` reports the rejection through `onError` and returns a no-op cleanup function. Wait for `stop()` to settle, then call `start()` before subscribing again.
88
97
  - Wildcard subscriptions: a topic ending in `.*` (`chat.*`) matches any remainder, and `*` matches everything. The pattern is routed, owned, and transport-subscribed as a literal channel; publications tagged with a matching concrete topic (or with the pattern itself) are delivered to wildcard handlers. See `topicMatchesPattern` below.
89
- - Replay (opt-in): construct the bus with `replay: { maxPerTopic }` and pass `{ replay: true | n }` as the third `subscribe()` argument. `maxPerTopic` must be a positive safe integer. The new handler immediately receives the buffered history (up to `n`, capped by `maxPerTopic`, default 100) with `message.replayed: true`, so late joiners do not miss earlier publications. Only dispatched publications are buffered (a topic with no local subscriber drops them as unowned); buffers are in-memory and cleared when the last handler for the topic unsubscribes. Wildcard subscriptions replay across every buffered topic matching the pattern. For reload/BFCache persistence, pass an optional `persistence` created by `createIndexedDbReplayPersistence({ maxPerTopic })`; persistence is asynchronous and failures are reported through `onError` without breaking live delivery. Set `retentionMs` to automatically prune durable history via `clearBefore` during hydration and after appends. Set `persistenceRetry: { maxAttempts, backoffMs }` to retry transient persistence failures; defaults preserve one-attempt behavior. Set `pruneStrategy` to `'count'` (default), `'age'`, or `'both'` to cap by `maxPerTopic`, prune by `retentionMs`, or apply both.
98
+ - Replay (opt-in): construct the bus with `replay: { maxPerTopic }` and pass `{ replay: true | n }` as the third `subscribe()` argument. `maxPerTopic` must be a positive safe integer. The new handler immediately receives the buffered history (up to `n`, capped by `maxPerTopic`, default 100) with `message.replayed: true`, so late joiners do not miss earlier publications. Only dispatched publications are buffered (a topic with no local subscriber drops them as unowned); buffers are in-memory and cleared when the last handler for the topic unsubscribes. Wildcard subscriptions replay across every buffered topic matching the pattern. For reload/BFCache persistence, pass an optional `persistence` created by `createIndexedDbReplayPersistence({ maxPerTopic })`; persistence is asynchronous and failures are reported through `onError` without breaking live delivery. Set `retentionMs` to prune expired producer-timestamped history in memory and to sweep adapters that implement `clearBefore` during hydration and after appends. Set `persistenceRetry: { maxAttempts, backoffMs }` to retry transient persistence failures; defaults preserve one-attempt behavior. Set `pruneStrategy` to `'count'` (default), `'age'`, or `'both'` to cap by `maxPerTopic`, prune timestamped history by `retentionMs`, or apply both. Under `age`, timestamp-less legacy entries are retained but capped by `maxPerTopic`; timestamped entries are bounded by the retention window.
90
99
  When tracing is enabled, retries emit `reliability` events with `operation: 'persistence_retry'`, a bounded `persistenceOperation`, and `attempt`.
91
100
 
92
101
  The WebSocket transport accepts binary publications delivered as either `ArrayBuffer` or browser `Blob` frames.
@@ -118,6 +127,10 @@ Published data must satisfy the serialization constraints of the underlying tran
118
127
 
119
128
  When the owning Worker is a remote Tab and the publish control message cannot be posted (for example the BroadcastChannel fails to clone the payload), `publish()` reports the failure through `onError` instead of silently dropping it.
120
129
 
130
+ Calling `publish()` while `stop()` is still settling reports through `onError` and routes nothing; the message is not deferred until a later start. Publications issued earlier and still queued behind an in-flight transport open are canceled by the stop.
131
+
132
+ A publication issued after a runtime transport `error` is held behind the recovery gate and sent once the transport is ready again, so it is never written to the connection that just failed. If the recovery budget is exhausted or the wait is superseded by `stop()` / page hide, the publication is dropped rather than deferred indefinitely (page suspension keeps its documented drop-without-defer semantics). A clean `disconnected` status does not schedule a background DataBus reopen, but it no longer swallows later operations either: a `subscribe()` / `publish()` issued after the close demands one on-demand reopen, is held until the replacement connects, and then flushes. Call `start()` (or send an operation) to reopen explicitly.
133
+
121
134
  Incoming messages may include a caller/server supplied `messageId`. Enable bounded duplicate suppression with `dedup: { maxEntries, ttlMs }`; repeated IDs within the window are ignored. This is disabled by default and does not provide an exactly-once server guarantee. Tests and hosts with a custom time source may provide `dedup.now`. A full `stop()` clears the remembered ID window; a later `start()` begins a fresh dedup session.
122
135
 
123
136
  When supplied, `options.messageId` and `options.timestamp` are propagated through cross-tab routing, Worker boundaries, and supported transports. The server must echo or otherwise preserve them for inbound deduplication and replay retention.
@@ -145,6 +158,8 @@ Publishes many items to one topic as a single unit of work. The bundled WebSocke
145
158
 
146
159
  Per-item `messageId` and `timestamp` survive the wire frame, and dedup, replay, and ordering apply per item in source order. An empty batch is a no-op; a single-item batch delegates to `publish()`. `WorkerClusterRuntime` exposes the same method for callers that coordinate directly.
147
160
 
161
+ A non-empty batch issued while `stop()` is still settling reports through `onError` and sends nothing; an empty batch remains a no-op.
162
+
148
163
  ### `clearReplay()`
149
164
 
150
165
  ```ts
@@ -195,7 +210,7 @@ Compact readiness verdict for dashboards, readiness probes, and support bundles.
195
210
 
196
211
  ```ts
197
212
  interface DataBusHealthSummary {
198
- healthy: boolean; // started, not suspended, transport ready
213
+ healthy: boolean; // started, not suspended, live transport status is 'connected'
199
214
  state: 'stopped' | 'starting' | 'healthy' | 'recovering' | 'suspended' | 'degraded';
200
215
  status: WorkerStatus;
201
216
  sdkVersion: string;
@@ -210,7 +225,7 @@ interface DataBusHealthSummary {
210
225
  }
211
226
  ```
212
227
 
213
- `state` semantics: `stopped` (not started), `starting` (initial open in flight), `recovering` (automatic transport recovery in progress), `suspended` (tab hidden, resumes on pageshow), `degraded` (automatic recovery exhausted — call `start()` or subscribe again to recover manually), `healthy`. `lastFailure` is a unified ledger across all failure sources and resets on every explicit `start()`.
228
+ `state` semantics: `stopped` (not started), `starting` (initial open in flight), `recovering` (automatic transport recovery in progress), `suspended` (tab hidden, resumes on pageshow), `degraded` (automatic recovery exhausted — call `start()` or subscribe again to recover manually), `healthy`. Calling `start()` again while degraded keeps the cluster, subscriptions, and replay buffers intact, resets the failure/recovery ledger, and reopens the transport; subscribe and publish also trigger the same reopen path. `lastFailure` is a unified ledger across all failure sources and resets on every explicit `start()`. The `healthy` verdict follows the live transport status; `transport.ready` is diagnostic and can remain `false` for the brief window between a transport reporting `connected` and its `start()` Promise settling, during which operations are queued behind that in-flight start rather than dropped.
214
229
 
215
230
  ### `getRecoveryStats()` / `getPersistenceStats()`
216
231
 
@@ -219,7 +234,7 @@ getRecoveryStats(): { attempt; exhausted; maxAttempts; hasError; errorMessage; e
219
234
  getPersistenceStats(): { failures; lastFailureAt; lastErrorMessage }
220
235
  ```
221
236
 
222
- `recovery.generation` increments on every successful transport open (initial start and each recovery); `lastSuccessAt` is the timestamp of that open (`null` before the first one). Persistence counters cover the optional replay persistence backend only.
237
+ `recovery.generation` increments on every successful transport open (initial start and each recovery); `lastSuccessAt` is the timestamp of that open (`null` before the first one). `recovery.hasError` / `errorMessage` / `errorAt` describe the most recent retained *transport* failure — from a transport open or a runtime `onError` — and share the lifetime of the unified `lastFailure` ledger: a successful recovery keeps the last failure visible, and only an explicit `start()` clears it. Non-transport failures (`persistence`, `dispatch`) never flip the recovery ledger; they stay visible through `lastFailure` (and `getPersistenceStats()` for the replay backend). A transport failure is stamped once, so `recovery.errorAt` and the `lastFailure.at` of that same failure are equal. Persistence counters cover the optional replay persistence backend only.
223
238
 
224
239
  ### `getDiagnostics()`
225
240
 
@@ -290,7 +305,7 @@ Low-frequency event types include `lifecycle`, `status`, `subscription`, `coordi
290
305
  stop(): Promise<void>
291
306
  ```
292
307
 
293
- Permanently destroys the current instance: cleans up handlers, cluster registration, routes, Workers, and transport. Normal page hide and restore do not require calling this method.
308
+ Permanently destroys the current instance: cleans up handlers, cluster registration, routes, Workers, and transport. If a transport open or reopen is still settling, `stop()` waits for it and invalidates its result so it cannot become ready after the stop. Normal page hide and restore do not require calling this method. Teardown is fault-tolerant: if the transport's own `stop()` rejects (or throws), `stop()` still resolves once the bus is destroyed and reports the failure through `onError` and the unified `lastFailure` record instead of rejecting, so the fire-and-forget unmount path in the React and Vue adapters cannot produce an unhandled rejection. The instance remains restartable afterwards.
294
309
 
295
310
  ## `DataBusTransport<TConfig, TData>`
296
311
 
@@ -387,13 +402,14 @@ const bus = createWebSocketDataBus({
387
402
  new WebSocketTransport<TData>(connection: WebSocketDataBusConfig)
388
403
  ```
389
404
 
390
- Implements `DataBusTransport`. Connection lifecycle maps to the DataBus status vocabulary: socket `open` → `connected`, `close` → `disconnected`, `error` → `error` (which triggers DataBus auto-recovery). Subscriptions are re-asserted when a socket reopens in place. Frames dropped while the socket is not open are reported via `handlers.onError`; reopening re-sends subscribe frames.
405
+ Implements `DataBusTransport`. `start()` settles only after the socket handshake completes: it resolves on `open` and rejects when the attempt errors, closes before opening, or exceeds `connectTimeoutMs`. Connection lifecycle maps to the DataBus status vocabulary: socket `open` → `connected`, `close` → `disconnected`, `error` → `error` (which triggers DataBus auto-recovery). Subscriptions are re-asserted when a socket reopens in place. When the bus reopens after a failed socket — automatic recovery after `error`, or an explicit `start()` / page restore / later operation after `close` — `start()` creates a replacement socket and ignores late lifecycle or message callbacks from the superseded one, including a late `open` from a timed-out attempt. Frames dropped while the socket is not open are reported via `handlers.onError`; the replacement re-sends subscribe frames.
391
406
 
392
407
  `WebSocketDataBusConfig` fields:
393
408
 
394
409
  - `url` — WebSocket endpoint.
395
410
  - `protocols` — optional subprotocol(s) for the handshake.
396
411
  - `webSocketFactory` — optional factory `(url, protocols) => WebSocketLike` for tests and non-browser runtimes (defaults to the global `WebSocket`).
412
+ - `connectTimeoutMs` — optional handshake budget in milliseconds. Defaults to `30000`; `0` or `Infinity` waits indefinitely. On expiry the attempt reports `error` and rejects `start()` (and therefore `ready()`), then closes the half-open socket.
397
413
 
398
414
  ### Wire protocol
399
415
 
@@ -427,7 +443,7 @@ Mirrors `bus.onStatus()` into React state and reads the current value synchronou
427
443
 
428
444
  ### `useCrossTabHealth(bus, options?)`
429
445
 
430
- Mirrors `bus.getHealthSummary()` into React state (`DataBusHealthSummary | null`). Because the summary is a snapshot, the hook polls it on an interval (default 1000 ms; pass `{ intervalMs: 0 }` for event-driven refreshes only) and refreshes immediately on status changes and errors. Returns `null` while the bus has not been created yet.
446
+ Mirrors `bus.getHealthSummary()` into React state (`DataBusHealthSummary | null`). Because the summary is a snapshot, the hook polls it on an interval (default 1000 ms; pass `{ intervalMs: 0 }` for event-driven refreshes only) and refreshes immediately on status changes and errors. An `intervalMs` change replaces the polling timer without recreating the bus. Returns `null` while the bus has not been created yet.
431
447
 
432
448
  ## Vue Composables (`cross-tab-worker-databus/vue`)
433
449
 
@@ -443,7 +459,7 @@ useVueCrossTabSubscription(bus, 'chat.*', message => console.log(message.data));
443
459
 
444
460
  ### `useVueCrossTabHealth(bus, options?)`
445
461
 
446
- The Vue binding of `useCrossTabHealth`: mirrors `bus.getHealthSummary()` into a `Ref<DataBusHealthSummary | null>`. Because the summary is a snapshot rather than an event stream, the composable polls it on an interval (default 1000 ms; pass `{ intervalMs: 0 }` for event-driven refreshes only) and refreshes immediately on status changes and errors. Returns `null` while the bus has not been created yet.
462
+ The Vue binding of `useCrossTabHealth`: mirrors `bus.getHealthSummary()` into a `Ref<DataBusHealthSummary | null>`. Because the summary is a snapshot rather than an event stream, the composable polls it on an interval (default 1000 ms; pass `{ intervalMs: 0 }` for event-driven refreshes only) and refreshes immediately on status changes and errors. A reactive `intervalMs` change replaces the polling timer without rebuilding the bus. Returns `null` while the bus has not been created yet.
447
463
 
448
464
  ## `WorkerClusterRuntime`
449
465
 
@@ -538,7 +538,7 @@ These invariants are pinned by regression tests (see `tests/stability.test.ts` a
538
538
  - **Handoff ACK validity.** A `ROUTE_RELEASED` is accepted only when the route still points at the receiver, the release comes from the recorded `handoffFromWorkerId`, and the ACK generation is at least as new as the stored route generation. Replayed ACKs from an earlier handoff round (e.g. an a↔b ping-pong) carry an older generation and are dropped.
539
539
  - **Replay persistence cleanup ordering.** A batched persistence flush queued behind the current task is filtered against the cleanup that wins the race: `unsubscribe` and `clearReplayTopic` drop the topic's pending entries, `clearReplayBefore` drops entries older than the cutoff. Cleared history is never re-appended by an in-flight flush.
540
540
  - **Storage write recovery.** Coalesced writes retry with exponential backoff (50 ms → 1.6 s cap). A structurally failing key is dropped after 5 attempts (with a `console.warn`) without permanently blocking other queued keys, and the backoff delay resets once the queue fully drains or `clear()` cancels the retries.
541
- - **Transport recovery budget.** Automatic recovery is paced by a cooldown, bounded by `recovery.maxAttempts`, and reports `exhausted` when the budget is spent. A successful reopen resets the attempt counter and the exhausted flag; explicit `subscribe` on a down transport can still recover manually.
541
+ - **Transport recovery budget.** Automatic recovery is paced by a cooldown, bounded by `recovery.maxAttempts`, and reports `exhausted` when the budget is spent. A successful reopen resets the attempt counter and the exhausted flag; explicit `subscribe` on a down transport can still recover manually. Scheduling alone does not reopen a connection: the backend must release the invalid connection before a retry can create or re-open one. `WebSocketTransport` resolves `start()` only after the socket opens, rejects a pre-open `error`/`close` or a `connectTimeoutMs` expiry, marks the socket active only until error/close, and drops the stale reference before the next `start()` invokes the factory, so late callbacks from the superseded socket are ignored.
542
542
  - **BFCache suspension.** Hiding the tab stops the transport, bumps the persistence-retry generation (cancelling in-flight persistence retries without surfacing errors), and gates dispatch; pageshow reopens the transport and re-establishes subscriptions exactly once per cycle.
543
543
  - **Handoff channel close ordering.** `pause()` defers the physical `channel.close()` by one task. Closing synchronously would discard messages still queued for delivery — including the handoff's `ROUTE_RELEASED` — stranding the handoff target with an unconfirmed route.
544
544
  - **Stranded-handoff recovery.** If the previous owner is gone and its `ROUTE_RELEASED` never arrives (dropped channel message under load, or a crash between the route write and the ACK), the reconcile loop re-elects a live owner once the unconfirmed handoff has been stuck longer than a worker TTL (10 s default): the route is rewritten with a fresh generation and the handoff marker cleared, so the normal confirmation path completes (pinned by regression). The age gate matters — a fresh unconfirmed route may simply be waiting out its confirmation flush — and while the previous owner is still alive the new owner keeps waiting, so the strict handoff keeps its no-overlap guarantee.
@@ -551,6 +551,8 @@ DataBus separates "business subscription intent" from "transport current subscri
551
551
 
552
552
  The built-in Centrifuge transport also retains its own Subscriptions and performs protocol-level reconnection. Both layers of recovery require `subscribe` / `unsubscribe` to be idempotent.
553
553
 
554
+ A runtime `error` intentionally keeps `transportReady` true: the flag records that the installed transport opened for this session, so `ready()` keeps tracking the transport instead of flapping with the protocol connection. Transport *operations* are gated separately by the recovery gate. While an automatic or demand-driven reopen is pending, `runTransport()` parks new `subscribe` / `publish` calls behind that gate rather than writing them to the connection that just reported `error`; the gate is released only once a reopen succeeds (or the transport self-heals to `connected`), and every parked operation then runs against the live transport. A failed automatic attempt keeps the gate closed but lets the next explicit operation drive an immediate on-demand reopen instead of waiting out another cooldown; once the recovery budget is exhausted, or the wait is superseded by `stop()` / page-hide, the gate is released so the documented explicit-retry path stays reachable. `disconnected` is a clean close rather than a recoverable failure: it never schedules a background DataBus reopen, and only an explicit `start()`, a page restore, the transport's own reconnection, or a later transport operation returns it to `connected`. That last path matters because the ready fast path is refused once the transport has actually reached `connected` and then reports `disconnected`; a `subscribe()` / `publish()` arriving after such a clean close is parked behind the same recovery gate and drives exactly one on-demand reopen, then flushes against the replacement connection instead of being written to the closed one. A transport that resolved `start()` before its first `connected` (worker-style backends report the connection asynchronously) is still handed operations directly, because its `disconnected` status means "not connected yet" rather than "a working connection was lost".
555
+
554
556
  ## Lifecycle State Machine
555
557
 
556
558
  `CrossTabDataBus` uses several boolean flags and promise gates to serialize lifecycle transitions. The interaction between them is the most complex part of the DataBus layer.
@@ -562,9 +564,14 @@ The built-in Centrifuge transport also retains its own Subscriptions and perform
562
564
  | `started` | `boolean` | `start()` has been called and no `stop()` has completed since |
563
565
  | `stopping` | `boolean` | `stop()` is in progress; prevents new operations |
564
566
  | `suspended` | `boolean` | Tab is hidden; transport is intentionally stopped |
565
- | `transportReady` | `boolean` | Transport has reported `connected` and is accepting operations |
567
+ | `transportReady` | `boolean` | Transport opened successfully for the current session; retained through a runtime `error` so `ready()` keeps tracking the installed transport (pending operations are held by the recovery gate, not by this flag) |
566
568
  | `startPromise` | `Promise \| null` | Gate for concurrent `start()` calls; cleared after settle |
569
+ | `stopPromise` | `Promise \| null` | Shared gate for an explicit `stop()` and any restart queued behind it |
570
+ | `queuedStart` | `Promise \| null` | One fresh start waiting for an in-flight explicit stop to settle |
571
+ | `queuedStartToken` | `number` | Monotonic token issued to each queued restart so a cancellation cannot be mistaken for a later one |
572
+ | `canceledQueuedStartToken` | `number` | Highest queued-restart token invalidated by `stop()`; a continuation at or below it resolves without opening |
567
573
  | `pendingStop` | `Promise \| null` | Gate for async `transport.stop()`; shared by suspend and failure paths |
574
+ | `lifecycleEpoch` | `number` | Monotonic ownership token; invalidates callbacks and cleanup from superseded opens |
568
575
 
569
576
  ### State transitions
570
577
 
@@ -594,9 +601,17 @@ The built-in Centrifuge transport also retains its own Subscriptions and perform
594
601
 
595
602
  **Key behaviors:**
596
603
 
597
- - **Concurrent start**: If `start()` is called while `startPromise` is non-null, the second call returns the same promise. Only one transport open is in flight at a time.
604
+ - **Concurrent start**: If `start()` is called while a real transport opening is in flight, the second call returns the same promise. Only one transport open is in flight at a time. A page-hide stop can also occupy `startPromise`; `start()` recognizes that `startPromise === pendingStop` and queues a reopen behind the stop rather than returning the cleanup promise as if it were a successful start.
605
+ - **Start during explicit stop**: `stop()` publishes a shared `stopPromise` for concurrent callers. A `start()` received while it is settling stores one `queuedStart`; after the stop's `finally` clears the lifecycle state, the queued start performs a fresh lifecycle with the new config. Repeated calls during that window share both the stop and queued-start promises.
606
+ - **Stop cancels a queued restart**: The queued continuation is chained to the stop promise and cannot be un-scheduled, so a second `stop()` before it runs invalidates it instead. Each queued restart carries a monotonic token; `stop()` records the current token and releases the single queue slot, and the continuation resolves without opening a transport when its own token is no longer newer. The queued `start()` promise retains that resolve-on-cancellation contract, while a separate readiness view makes `ready()` reject for the canceled intent. Because a later `start()` issues a higher token, `stop → start → stop → start` still ends running while `stop → start → stop` ends stopped with no extra transport open.
607
+ - **Stop-time publication rejection**: Once `stop()` sets `stopping`, new `publish()` and non-empty `publishBatch()` calls cannot reach a transport. They surface an error through `onError` rather than letting `runTransport()` return silently; empty batches stay no-ops. A publication already queued behind an in-flight open is canceled by the stop (latest intent wins), while page-hide suspension keeps its documented drop-without-defer semantics.
608
+ - **Stop-time lifecycle-operation rejection**: The `stopping` gate also covers `subscribe()` and `ready()`. A late `subscribe()` is reported through `onError` and returns a no-op cleanup, preventing a handler from being erased by `topicHandlers.clear()` or leaking into a later restart without its handler. `ready()` rejects instead of resolving against the stopping transport. If `start()` has already queued a restart behind the stop, `ready()` returns that queued-start promise because it is the newest lifecycle intent.
609
+ - **Queued-restart failure retention**: A queued restart that fails during transport startup clears `started` but retains its actual error for later `ready()` calls. Without `initialConfig`, those calls reject with the startup failure instead of the generic configuration error, while an explicit `start(config)` remains a clean manual retry with a fresh failure ledger.
598
610
  - **Suspend during start**: If `pagehide` fires while `openTransport` is in flight, `suspendTransport()` sets `suspended = true` and chains a `transport.stop()` after the in-flight start. The `openTransport` catch path detects `suspended` and abandons the open without treating it as a failure.
611
+ - **Readiness during suspend**: A suspended bus sets `suspended = true` and reuses `startPromise`/`pendingStop` for the chained `transport.stop()`, so that promise proves cleanup completed rather than readiness. `ready()` checks `suspended` after the `stopping` gate and rejects with a suspended-state error instead of returning the stop gate. `pageshow`/`reopenTransport()` and an explicit `start()` clear the flag and install a real reopen promise, so `ready()` follows the newest lifecycle intent. `getHealthSummary()` already reported `{ healthy: false, state: 'suspended' }`; rejection keeps that verdict consistent with `ready()`.
612
+ - **Superseded open invalidation**: Every fresh start, reopen, suspend, and stop advances `lifecycleEpoch`. An open captures its epoch, ignores stale status/message/error callbacks, and neither marks the transport ready nor performs failure cleanup after a newer transition owns the lifecycle. `stop()` therefore waits for pending opens/reopens and prevents a superseded open from becoming ready after the stop completes.
599
613
  - **Recovery cooldown**: When the transport reports `error` while `started` is true and `stopping` is false, `updateStatus` schedules an automatic `reopenTransport()` after `RECOVERY_COOLDOWN_MS` (1000 ms). A second error within the cooldown window is suppressed to prevent a tight retry loop.
614
+ - **Transport recovery gate**: Scheduling a reopen also arms a recovery gate, so `subscribe` / `publish` issued during the cooldown cannot reach the failed connection; they are released after the reopen succeeds. The gate deliberately survives a failed automatic attempt: the next explicit operation starts an immediate on-demand reopen instead of waiting for the next paced attempt, and parked operations flush behind that success. Exhausting `recovery.maxAttempts`, or superseding the wait with `stop()` / `suspendTransport()`, releases the gate so the explicit-retry path and the documented suspend-drop semantics are preserved. A runtime `error` does not clear `transportReady`, because clearing it would let caller traffic reopen the transport outside the cooldown and report readiness against a connection that is not carrying data.
600
615
  - **Stop during suspend**: `stop()` sets `stopping = true`, which prevents `suspendTransport()` from running. The cleanup awaits `startPromise` and `pendingStop` to ensure any in-flight open or stop completes before the final `transport.stop()`.
601
616
 
602
617
  ## Degradation
@@ -2,20 +2,20 @@
2
2
 
3
3
  # Browser Benchmark Trend
4
4
 
5
- > Data through 2026-09-11, from the 12 archived `bench-results/browser-*.json` reports (run `pnpm bench:browser` to add one; regenerate this doc with `node scripts/bench-trend.mjs`).
5
+ > Data through 2026-09-12, from the 14 archived `bench-results/browser-*.json` reports (run `pnpm bench:browser` to add one; regenerate this doc with `node scripts/bench-trend.mjs`).
6
6
 
7
7
  The comparison baseline for release gating is `pnpm bench:compare --fail-above-pct 50` between the two most recent reports (50% ceiling absorbs shared-runner noise). This doc records the long-run picture: values are per-metric latencies where lower is better, and the all-time best marks the healthiest observed run on this machine.
8
8
 
9
9
  <!-- BENCH-TREND:BEGIN (machine-generated table) -->
10
10
  | Metric | Previous (ms) | Latest (ms) | Δ | All-time best (ms) |
11
11
  |---|---|---|---|---|
12
- | publish per-message (ms, lower is better) — dedicated | 43.8388 | 40.8227 | -3.02 | 40.8227 |
13
- | publish per-message (ms, lower is better) — shared | 34.2105 | 35.4339 | +1.22 | 33.8206 |
14
- | wildcard dispatch ×1000 (ms, lower is better) | 6.5 | 6.7 | +0.20 | 0.1 |
15
- | publishBatch ×1000 (ms, lower is better) | 4.2 | 4 | -0.20 | 0.4 |
16
- | dedup ×1000 (ms, lower is better) | 15.5 | 13.9 | -1.60 | 0 |
17
- | trace + publish ×1000 (ms, lower is better) | 5.8 | 5.3 | -0.50 | 4.8 |
18
- | first-packet cold dispatch (ms, lower is better) | 0 | 0 | +0.00 | 0 |
12
+ | publish per-message (ms, lower is better) — dedicated | 61.1276 | 58.888 | -2.24 | 40.8227 |
13
+ | publish per-message (ms, lower is better) — shared | 38.5784 | 45.0451 | +6.47 | 33.8206 |
14
+ | wildcard dispatch ×1000 (ms, lower is better) | 6.5 | 7.5 | +1.00 | 0.1 |
15
+ | publishBatch ×1000 (ms, lower is better) | 4.5 | 5.1 | +0.60 | 0.4 |
16
+ | dedup ×1000 (ms, lower is better) | 13.4 | 14.5 | +1.10 | 0 |
17
+ | trace + publish ×1000 (ms, lower is better) | 5.3 | 5.2 | -0.10 | 4.8 |
18
+ | first-packet cold dispatch (ms, lower is better) | 0.1 | 0 | -0.10 | 0 |
19
19
  <!-- BENCH-TREND:END -->
20
20
 
21
21
  Notes:
@@ -60,10 +60,10 @@ When `replay.retentionMs` is enabled, automatic durable cleanup is coalesced dur
60
60
 
61
61
  | Config | Type | Default | Description |
62
62
  |---|---|---|---|
63
- | `maxPerTopic` | `number` | `100` | Maximum buffered publications per topic; oldest are evicted first (positive safe integer) |
63
+ | `maxPerTopic` | `number` | `100` | Maximum buffered publications per topic under `count`/`both`; oldest are evicted first. Under `age`, timestamped entries are retention-bounded and timestamp-less legacy entries are capped by this value (positive safe integer) |
64
64
  | `persistence` | `DataBusReplayPersistence` | — | Optional durable backend (`createIndexedDbReplayPersistence`); omitted keeps history in memory only |
65
65
  | `retentionMs` | `number` | — | Producer-timestamp retention window; history older than the cutoff is pruned through the adapter's `clearBefore` |
66
- | `pruneStrategy` | `'count' \| 'age' \| 'both'` | `'count'` | `count` caps each topic at `maxPerTopic`; `age` prunes by `retentionMs`; `both` applies both. `age` without `retentionMs` has nothing to prune by and falls back to the count cap |
66
+ | `pruneStrategy` | `'count' \| 'age' \| 'both'` | `'count'` | `count` caps each topic at `maxPerTopic`; `age` prunes timestamped history by `retentionMs` and caps timestamp-less legacy entries by `maxPerTopic`; `both` applies both. `age` without `retentionMs` has nothing to prune by and falls back to the count cap |
67
67
  | `retentionSweepMs` | `number` | — | Periodic durable-retention sweep for quiet topics; requires `retentionMs` and a `clearBefore` adapter |
68
68
  | `persistenceRetry` | `{ maxAttempts, backoffMs }` | `1` / `50` | Bounded retry for transient persistence failures; delays grow exponentially and are capped |
69
69
 
package/docs/roadmap.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Roadmap
2
2
 
3
- 0.20.85 is the current development line. The project is intentionally continuing through reliability-focused minor releases before a 1.0.0 stability freeze.
3
+ 0.20.87 is the current development line. The project is intentionally continuing through reliability-focused minor releases before a 1.0.0 stability freeze.
4
+
5
+ ## 0.20.87 delivered scope
6
+
7
+ - Transport recovery/readiness hardening: the native WebSocket backend now honors the `DataBusTransport.start()` contract (resolves only after `open`, rejects on a failed handshake or `connectTimeoutMs`), automatic recovery actually creates a replacement socket after a failure, and `getHealthSummary()` follows the live transport status instead of the `transportReady` diagnostic flag.
8
+ - No operation is written to a connection that is gone. A recovery gate parks `subscribe()` / `publish()` through automatic and on-demand reopens (including an automatic attempt that failed but left the budget open), and a clean `disconnected` after a real connection now demands exactly one on-demand reopen instead of being handed to a closed socket — while a worker-style backend that reports the connection asynchronously keeps its pre-connect window unreopened.
9
+ - Lifecycle/`ready()` boundary fixes: `ready()` rejects while the tab is BFCache-suspended, `stop()` resolves even when the transport's own `stop()` rejects or throws, a failed open is stamped once across both recovery ledgers, runtime transport errors land in the recovery ledger, and superseded asynchronous opens can no longer tear down a newer suspend/resume transition.
10
+ - Adapter and toolchain: React/Vue `useCrossTabHealth` apply `intervalMs` changes without recreating the bus, and `vitest` and its coverage-v8 provider moved to the 5.0.1 patch.
11
+
12
+ ## 0.20.86 delivered scope
13
+
14
+ - Lifecycle hardening across explicit stop/start boundaries: queued restarts are serialized with in-flight stops, canceled by a newer stop, and observable through `ready()`; superseded asynchronous opens cannot tear down newer suspend/resume transitions; stop-time `subscribe()` and non-empty `publish()`/`publishBatch()` calls now report through `onError` instead of mutating teardown state or being silently dropped.
15
+ - Explicit `start()` now performs the documented manual recovery after automatic recovery exhaustion, while preserving cluster state, subscriptions, and replay history.
16
+ - IndexedDB replay persistence settles all mutations on transaction abort (including connection-loss aborts) so the serialized queue cannot remain blocked, and replay age pruning now uses one shared, position-independent policy for in-memory and persisted history.
17
+ - The configuration reference documents the full replay/dedup public option surface in both languages, with declaration-derived documentation guards; seeded property invariants cover active-worker selection and rebalance targets.
4
18
 
5
19
  ## 0.20.85 delivered scope
6
20
 
@@ -35,6 +35,12 @@ connection state changes; call `onMessage` for each inbound publication; call
35
35
  `onError` for non-fatal errors (the DataBus applies a recovery cooldown so a
36
36
  flapping connection does not retry-loop).
37
37
 
38
+ `start()` MUST settle its returned promise only once the backend is connected,
39
+ and reject it when the attempt fails. The DataBus uses that settlement as its
40
+ readiness and recovery boundary: a `CONNECTING` socket is not ready, and queued
41
+ operations must not be released until the handshake succeeds. A backend that
42
+ can stall should enforce its own handshake timeout and reject.
43
+
38
44
  ## Architectural layers
39
45
 
40
46
  ```
@@ -154,10 +160,21 @@ without metadata keep their original shape. Metadata-bearing publishes use
154
160
  `{ data, messageId?, timestamp? }`, while inbound publications additionally
155
161
  accept the canonical nested `DataBusPublicationEnvelope`.
156
162
 
163
+ `start()` resolves only after `open` and rejects when the handshake errors,
164
+ closes before opening, or exceeds `connectTimeoutMs` (default `30000` ms; `0`
165
+ or `Infinity` waits indefinitely). A timed-out socket is closed and a late
166
+ `open` from that attempt is ignored.
167
+
157
168
  Lifecycle mapping: `open` → `connected`, `close` → `disconnected`,
158
169
  `error` → `error` (DataBus auto-recovery). Subscribe frames are re-sent when
159
- the socket reopens in place. A pattern-aware server may tag publications with
160
- the concrete topic see wildcard subscriptions in [api.md](./api.md).
170
+ the socket reopens in place. A successful reopen can either reuse the same
171
+ socket object or create a replacement through the factory; callbacks from the
172
+ superseded socket are ignored, so a late close or message from the failed
173
+ connection cannot pollute the recovered one. A clean `disconnected` schedules no
174
+ background recovery, but the next `subscribe()` / `publish()` demands one reopen
175
+ and flushes behind it, so a post-close operation is never sent to the closed
176
+ socket. A pattern-aware server may tag publications with the concrete topic —
177
+ see wildcard subscriptions in [api.md](./api.md).
161
178
 
162
179
  ## Factory entry point
163
180
 
package/docs/zh/api.md CHANGED
@@ -56,7 +56,7 @@ new CrossTabDataBus<TConfig, TData>(options)
56
56
  start(config: TConfig): Promise<void>
57
57
  ```
58
58
 
59
- 启动集群协调和 transport。首次调用真正启动 transport;启动过程中并发调用共享同一个启动 Promise,不重复创建 transport。启动成功或失败后,内部 gate 会重置:之后再次调用是已启动的空操作(立即 resolve),不会重复启动;`stop()` 之后可重新调用再次启动。
59
+ 启动集群协调和 transport。首次调用真正启动 transport;打开过程尚未结束时,并发调用共享同一个启动 Promise,不重复创建 transport。对健康且已启动的实例调用是立即 resolve 的空操作。若 transport 已断开,`start()` 作为显式手动恢复:保留 cluster、订阅和 replay 缓冲区,重置失败/恢复账本并重新打开 transport。若显式 `stop()` 尚未完成,`start()` 会在清理之后排队一次全新启动,并返回随重启完成而 settle 的 Promise。该排队重启归属于最近一次生命周期意图:若它在真正执行前又收到 `stop()`,则会被取消(排队 start 的 Promise resolve,但不会打开 transport);取消之后再调用 `start()` 会以更高令牌重新排队。`stop()` 完成后也可正常再次调用 `start()` 重启。
60
60
 
61
61
  ### `ready()`
62
62
 
@@ -66,9 +66,17 @@ ready(): Promise<void>
66
66
 
67
67
  等待当前 transport 的 `start` 完成。自动启动失败时 Promise 会 reject;再次调用可以触发基于 `initialConfig` 的重试。
68
68
 
69
+ 显式 `stop()` 尚未 settle 时,`ready()` 会 reject,除非此前已有 `start()` 在该 stop 之后排队重启;它绝不会针对正在拆除的 transport 报告 ready。调用方应等待 `stop()` settle,再调用 `start()` 后重新 await `ready()`。
70
+
71
+ 若后续 `stop()` 取消了该排队重启,排队 `start()` Promise 仍按既定语义 resolve 且不会打开 transport,但 `ready()` 会以生命周期错误 reject,而不会把已停止的 bus 报告为 ready。
72
+
73
+ Tab 处于 BFCache 挂起态时(`pagehide` 之后、`pageshow` 之前),`ready()` 会以挂起态错误 reject。挂起路径会把 `startPromise` 复用为异步 `transport.stop()` 的 gate,若直接返回它,就会针对一个被有意停止的 transport 报告 ready。`pageshow` 或显式 `start()` 会清除挂起标记并安装真正的重开 Promise,此后 `ready()` 会在 transport 就绪后正常 resolve。
74
+
75
+ 若该排队重启在 transport 启动阶段失败,即使未传入 `initialConfig`,`ready()` 也会以底层启动错误 reject。该失败会保留给显式恢复,而不会被通用的「缺少配置」错误掩盖。
76
+
69
77
  未传入 `initialConfig` 且未显式调用 `start(config)` 时,`ready()` 返回 rejected Promise 而不是同步抛出,调用方可以统一通过 `.catch` 处理并决定是否显式启动。
70
78
 
71
- `ready()` 不等价于服务端已连接,协议连接状态通过 `onStatus` 获取。
79
+ `ready()` 会在当前 transport 满足其 `start()` 契约时 resolve;它不保证远端服务端已能处理应用流量。内置 WebSocket 后端的 `start()` 会等待 socket 握手:握手前发生 `error`、`close`,或超过 `connectTimeoutMs` 时都会 reject,因此 `ready()` 不会在 socket 仍处于 `CONNECTING` 时报告就绪。协议连接状态仍通过 `onStatus` 获取。
72
80
 
73
81
  ### `subscribe(topic, handler)`
74
82
 
@@ -84,9 +92,10 @@ subscribe(
84
92
  - 同一 Topic 的多个 handler 使用引用计数。
85
93
  - 当前 Tab 第一个 handler 会登记集群订阅。
86
94
  - 最后一个 handler 释放后,当前 Tab 才退出该 Topic。
87
- - transport 尚未 ready 时订阅自动排队。
95
+ - transport 尚未 ready 时订阅自动排队;transport 恢复待定时同样如此:订阅会挂在恢复门之后,等重开成功才下发,而不会写入刚刚上报 `error` 的连接。
96
+ - 显式 `stop()` 尚未 settle 时发起的订阅不会登记:`subscribe()` 通过 `onError` 上报并返回 no-op 释放函数。调用方应等待 `stop()` settle,再调用 `start()` 后重新订阅。
88
97
  - 通配符订阅:以 `.*` 结尾的 Topic(如 `chat.*`)匹配任意后缀,`*` 匹配全部。pattern 以字面量参与路由、归属与传输订阅;携带匹配的具体 topic(或 pattern 本身)的发布都会投递给通配 handler。匹配规则见下方 `topicMatchesPattern`。
89
- - 重放(可选):构造 bus 时传 `replay: { maxPerTopic }` 开启缓冲,`maxPerTopic` 必须是正安全整数;`subscribe()` 第三个参数传 `{ replay: true | n }` 后,新 handler 会立即收到缓冲历史(最多 `n` 条,受 `maxPerTopic` 上限约束,默认 100),消息带 `message.replayed: true` 标记——晚加入的 handler 不会错过更早的发布。只有被分发过的消息才入缓冲(无本地订阅者的 topic 会被 owner 丢弃);缓冲仅存内存,该 topic 最后一个 handler 退订时清空。通配订阅会对所有匹配 pattern 的已缓冲 topic 做回放。需要跨 reload/BFCache 持久化时,可传入 `createIndexedDbReplayPersistence({ maxPerTopic })` 创建的 `persistence`;持久化为异步操作,失败会通过 `onError` 报告,不影响实时投递。设置 `retentionMs` 后,如果 adapter 支持 `clearBefore`,会在 hydrate 和追加后自动清理过期历史。设置 `persistenceRetry: { maxAttempts, backoffMs }` 可重试瞬时持久化失败;默认仍保持单次尝试。设置 `pruneStrategy` 为 `'count'`(默认)、`'age'` 或 `'both'`,分别表示按 `maxPerTopic` 截断、按 `retentionMs` 清理,或两者都应用。
98
+ - 重放(可选):构造 bus 时传 `replay: { maxPerTopic }` 开启缓冲,`maxPerTopic` 必须是正安全整数;`subscribe()` 第三个参数传 `{ replay: true | n }` 后,新 handler 会立即收到缓冲历史(最多 `n` 条,受 `maxPerTopic` 上限约束,默认 100),消息带 `message.replayed: true` 标记——晚加入的 handler 不会错过更早的发布。只有被分发过的消息才入缓冲(无本地订阅者的 topic 会被 owner 丢弃);缓冲仅存内存,该 topic 最后一个 handler 退订时清空。通配订阅会对所有匹配 pattern 的已缓冲 topic 做回放。需要跨 reload/BFCache 持久化时,可传入 `createIndexedDbReplayPersistence({ maxPerTopic })` 创建的 `persistence`;持久化为异步操作,失败会通过 `onError` 报告,不影响实时投递。设置 `retentionMs` 后会清理内存中过期的 producer-timestamped 历史,并通过实现 `clearBefore` 的 adapter 在 hydrate 和追加后清理 durable 历史。设置 `persistenceRetry: { maxAttempts, backoffMs }` 可重试瞬时持久化失败;默认仍保持单次尝试。设置 `pruneStrategy` 为 `'count'`(默认)、`'age'` 或 `'both'`,分别表示按 `maxPerTopic` 截断、按 `retentionMs` 清理带时间戳历史,或两者都应用。`age` 下无时间戳的 legacy 条目会保留,但受 `maxPerTopic` 限制;带时间戳条目由 retention 窗口约束。
90
99
  启用 trace 后,重试会发出 `reliability` 事件,包含 `operation: 'persistence_retry'`、有界的 `persistenceOperation` 和 `attempt`。
91
100
 
92
101
  WebSocket transport 支持以 `ArrayBuffer` 或浏览器 `Blob` 帧接收二进制 publication。
@@ -118,6 +127,10 @@ publish(
118
127
 
119
128
  当 owner 是远端 Tab、且发布控制消息无法投递时(例如 BroadcastChannel 无法克隆 payload),`publish()` 会通过 `onError` 上报失败,而不是静默丢弃。
120
129
 
130
+ 在 `stop()` 尚未 settle 时调用 `publish()` 会通过 `onError` 上报且不路由任何消息;消息不会延迟到之后的 start。更早发出、仍排队等待 transport open 的发布会被 stop 取消。
131
+
132
+ 运行期 transport 上报 `error` 后发起的发布同样会挂在恢复门之后,等 transport 重新 ready 再发送,因此不会被写进刚刚失败的连接。若恢复预算耗尽,或等待被 `stop()` / 页面隐藏取代,该发布会按文档丢弃而不是无限期延迟(页面挂起仍保持「不延迟、直接丢弃」语义)。干净的 `disconnected` 不会触发后台 DataBus 自动重开,但也不会再吞掉后续操作:干净关闭后发起的 `subscribe()` / `publish()` 会触发一次按需重开,先挂起等待替代连接就绪,随后再 flush。可显式调用 `start()`(或直接发起操作)来重开。
133
+
121
134
  传入 `options.messageId` 和 `options.timestamp` 后,元数据会穿过跨 Tab 路由、Worker 边界和支持的 transport。服务端必须回显或以其他方式保留它们,入站去重和 replay retention 才能使用。
122
135
 
123
136
  `DataBusMessage` 与 `DataBusPublication` 暴露相同的可选元数据。
@@ -143,6 +156,8 @@ publishBatch(
143
156
 
144
157
  每条 item 的 `messageId` 与 `timestamp` 在传输后保留,dedup、replay 与顺序都按 item 维度、以源顺序生效。空 batch 为 no-op;单 item batch 直接委托给 `publish()`。直接操作协调层的调用方可用 `WorkerClusterRuntime` 上的同名方法。
145
158
 
159
+ 在 `stop()` 尚未 settle 时提交非空 batch 会通过 `onError` 上报且不发送任何内容;空 batch 仍为 no-op。
160
+
146
161
  ### `clearReplay()`
147
162
 
148
163
  ```ts
@@ -193,7 +208,7 @@ getHealthSummary(): DataBusHealthSummary
193
208
 
194
209
  ```ts
195
210
  interface DataBusHealthSummary {
196
- healthy: boolean; // 已启动、未挂起、transport 就绪
211
+ healthy: boolean; // 已启动、未挂起、transport 实时状态为 connected
197
212
  state: 'stopped' | 'starting' | 'healthy' | 'recovering' | 'suspended' | 'degraded';
198
213
  status: WorkerStatus;
199
214
  sdkVersion: string;
@@ -208,7 +223,7 @@ interface DataBusHealthSummary {
208
223
  }
209
224
  ```
210
225
 
211
- `state` 语义:`stopped`(未启动)、`starting`(首次连接进行中)、`recovering`(transport 自动恢复进行中)、`suspended`(Tab 隐藏,pageshow 后自动恢复)、`degraded`(自动恢复已耗尽,需要手动 `start()` 或重新 subscribe 触发恢复)、`healthy`。`lastFailure` 是覆盖全部失败来源的统一账本,每次显式 `start()` 后重置。
226
+ `state` 语义:`stopped`(未启动)、`starting`(首次连接进行中)、`recovering`(transport 自动恢复进行中)、`suspended`(Tab 隐藏,pageshow 后自动恢复)、`degraded`(自动恢复已耗尽,需要手动 `start()` 或重新 subscribe 触发恢复)、`healthy`。处于 degraded 时再次调用 `start()` 会保留 cluster、订阅和 replay 缓冲区,重置失败/恢复账本后重新打开 transport;subscribe 与 publish 也走同一恢复路径。`lastFailure` 是覆盖全部失败来源的统一账本,每次显式 `start()` 后重置。`healthy` 依据 transport 的实时状态判定;`transport.ready` 是诊断字段,在 transport 已报告 `connected`、但其 `start()` Promise 尚未 settle 的短暂窗口内可能仍为 `false`,此时操作会排队等待该在途 start,而不会丢失。
212
227
 
213
228
  ### `getMetrics()`
214
229
 
@@ -227,7 +242,7 @@ getRecoveryStats(): { attempt; exhausted; maxAttempts; hasError; errorMessage; e
227
242
  getPersistenceStats(): { failures; lastFailureAt; lastErrorMessage }
228
243
  ```
229
244
 
230
- `recovery.generation` 在每次 transport 成功打开时递增(首次启动与每次恢复);`lastSuccessAt` 是该次成功的时间戳(首次成功前为 `null`)。持久化计数仅覆盖可选的 replay 持久化后端。
245
+ `recovery.generation` 在每次 transport 成功打开时递增(首次启动与每次恢复);`lastSuccessAt` 是该次成功的时间戳(首次成功前为 `null`)。`recovery.hasError` / `errorMessage` / `errorAt` 描述最近一次被保留的 **transport** 失败——无论是 transport 打开失败还是运行期 `onError`——并与统一的 `lastFailure` 账本具有相同的生命周期:恢复成功后最后一次失败仍然可见,只有显式 `start()` 会清除它。非 transport 失败(`persistence`、`dispatch`)不会改动 recovery 账本,仍可通过 `lastFailure`(以及 replay 后端的 `getPersistenceStats()`)观察。同一次 transport 失败只取一次时间戳,因此 `recovery.errorAt` 与该次失败对应的 `lastFailure.at` 相等。持久化计数仅覆盖可选的 replay 持久化后端。
231
246
 
232
247
  ### `getDiagnostics()`
233
248
 
@@ -288,7 +303,7 @@ trace: {
288
303
  stop(): Promise<void>
289
304
  ```
290
305
 
291
- 永久销毁当前实例:清理 handler、集群注册、路由、Worker 和 transport。普通页面隐藏和恢复不需要调用。
306
+ 永久销毁当前实例:清理 handler、集群注册、路由、Worker 和 transport。若 transport open/reopen 仍在收敛,`stop()` 会等待它结束并使该结果失效,确保它不会在 stop 后变为 ready。普通页面隐藏和恢复不需要调用。teardown 对故障容错:即使 transport 自身的 `stop()` reject(或同步抛错),`stop()` 仍会在实例销毁完成后 resolve,并通过 `onError` 与统一的 `lastFailure` 记录上报该失败,而不是让 `stop()` 变成 rejected;因此 React / Vue adapter 中 fire-and-forget 的卸载路径不会产生 unhandled rejection。实例之后仍可重新 start。
292
307
 
293
308
  ## `DataBusTransport<TConfig, TData>`
294
309
 
@@ -384,13 +399,14 @@ const bus = createWebSocketDataBus({
384
399
  new WebSocketTransport<TData>(connection: WebSocketDataBusConfig)
385
400
  ```
386
401
 
387
- 实现 `DataBusTransport`。连接生命周期直接映射 DataBus 状态:socket `open` → `connected`,`close` → `disconnected`,`error` → `error`(触发 DataBus 自动恢复)。socket 原地重连时会自动重发订阅;socket 未打开期间被丢弃的帧通过 `handlers.onError` 上报,重开后自动补发订阅帧。
402
+ 实现 `DataBusTransport`。`start()` 只在 socket 握手完成后 settle:`open` 时 resolve;握手前发生 `error`、`close`,或超过 `connectTimeoutMs` 时 reject。连接生命周期直接映射 DataBus 状态:socket `open` → `connected`,`close` → `disconnected`,`error` → `error`(触发 DataBus 自动恢复)。socket 原地重连时会自动重发订阅;当 bus 在 socket 失败后重新打开时(`error` 触发自动恢复,或 `close` 后显式 `start()` / 页面恢复 / 后续操作),`start()` 会创建替代 socket,并忽略被取代 socket 的迟到生命周期与消息回调(包括超时尝试之后迟到的 `open`);socket 未打开期间被丢弃的帧通过 `handlers.onError` 上报,替代 socket 打开后自动补发订阅帧。
388
403
 
389
404
  `WebSocketDataBusConfig` 字段:
390
405
 
391
406
  - `url` — WebSocket 端点。
392
407
  - `protocols` — 可选的握手子协议。
393
408
  - `webSocketFactory` — 可选工厂 `(url, protocols) => WebSocketLike`,用于测试与非浏览器运行时(默认使用全局 `WebSocket`)。
409
+ - `connectTimeoutMs` — 可选握手预算(毫秒),默认 `30000`;传 `0` 或 `Infinity` 表示无限等待。超时会通过 `error` 上报并 reject `start()`(也就是 `ready()`),随后关闭半开 socket。
394
410
 
395
411
  ### 线协议
396
412
 
@@ -424,7 +440,7 @@ React(>= 18)是可选 peer 依赖;独立入口保证非 React 消费者不
424
440
 
425
441
  ### `useCrossTabHealth(bus, options?)`
426
442
 
427
- 将 `bus.getHealthSummary()` 镜像为 React 状态(`DataBusHealthSummary | null`)。由于健康摘要是快照而非事件流,该 hook 按间隔轮询(默认 1000 ms;传 `{ intervalMs: 0 }` 可仅依赖事件驱动刷新),并在状态变化与错误发生时立即刷新。bus 创建前返回 `null`。
443
+ 将 `bus.getHealthSummary()` 镜像为 React 状态(`DataBusHealthSummary | null`)。由于健康摘要是快照而非事件流,该 hook 按间隔轮询(默认 1000 ms;传 `{ intervalMs: 0 }` 可仅依赖事件驱动刷新),并在状态变化与错误发生时立即刷新。修改 `intervalMs` 会替换轮询定时器,但不会重建 bus。bus 创建前返回 `null`。
428
444
 
429
445
  ## Vue Composables(`cross-tab-worker-databus/vue`)
430
446
 
@@ -440,7 +456,7 @@ useVueCrossTabSubscription(bus, 'chat.*', message => console.log(message.data));
440
456
 
441
457
  ### `useVueCrossTabHealth(bus, options?)`
442
458
 
443
- `useCrossTabHealth` 的 Vue 绑定:将 `bus.getHealthSummary()` 镜像为 Vue `Ref<DataBusHealthSummary | null>`。健康摘要是快照而非事件流,因此该组合式函数按间隔轮询(默认 1000 ms;传 `{ intervalMs: 0 }` 可仅依赖事件驱动刷新),并在状态变化与错误发生时立即刷新。bus 创建前返回 `null`。
459
+ `useCrossTabHealth` 的 Vue 绑定:将 `bus.getHealthSummary()` 镜像为 Vue `Ref<DataBusHealthSummary | null>`。健康摘要是快照而非事件流,因此该组合式函数按间隔轮询(默认 1000 ms;传 `{ intervalMs: 0 }` 可仅依赖事件驱动刷新),并在状态变化与错误发生时立即刷新。响应式修改 `intervalMs` 会替换轮询定时器,但不会重建 bus。bus 创建前返回 `null`。
444
460
 
445
461
  ## `WorkerClusterRuntime`
446
462
 
@@ -508,8 +508,9 @@ Transport 消息 → isAssigned(topic)? → 是 → broadcastEvent(EVENT)
508
508
  - **Handoff ACK 有效性。** `ROUTE_RELEASED` 只有在 route 仍指向接收方、释放来自记录的 `handoffFromWorkerId`、且 ACK generation 不小于存储 route 的 generation 时才被接受。来自更早交接轮次的重复 ACK(如 a↔b 反复交接)携带更旧的 generation,会被丢弃。
509
509
  - **Replay 持久化清理顺序。** 排队在当前任务之后的批量持久化 flush 会与竞速的清理操作对账:`unsubscribe` 与 `clearReplayTopic` 丢弃该 topic 的待写条目,`clearReplayBefore` 丢弃早于截止时间的条目。已清理的历史不会被在途 flush 复活。
510
510
  - **存储写失败恢复。** 合并写入按指数退避重试(50 ms → 1.6 s 封顶)。结构性失败的关键在 5 次尝试后被丢弃(伴随 `console.warn`),且不会永久阻塞其他排队 key;队列完全清空或 `clear()` 取消重试后,退避延迟重置。
511
- - **Transport 恢复预算。** 自动恢复由冷却时间限速、由 `recovery.maxAttempts` 限量,预算耗尽后标记 `exhausted`。成功的重开会重置尝试计数与 exhausted 标记;transport 宕机时显式 `subscribe` 仍可手动恢复。
511
+ - **Transport 恢复预算。** 自动恢复由冷却时间限速、由 `recovery.maxAttempts` 限量,预算耗尽后标记 `exhausted`。成功的重开会重置尝试计数与 exhausted 标记;transport 宕机时显式 `subscribe` 仍可手动恢复。自动调度本身不会重开连接:后端必须先释放失效连接,重试才能创建或重开 socket。`WebSocketTransport` 只在 socket `open` 后 resolve `start()`;握手前的 `error`/`close` 或 `connectTimeoutMs` 超时都会 reject。它仅在 socket 有效期间将其标记为 active;`error`/`close` 会立即失效,下一次 `start()` 在调用工厂前清除旧引用,因此被取代 socket 的迟到回调会被忽略。
512
512
  - **BFCache 挂起。** Tab 隐藏时停止 transport、递增持久化重试 generation(取消在途重试且不对外报错)并门控分发;pageshow 时重开 transport,每轮循环只重建一次订阅。
513
+ - **挂起态就绪判定。** 挂起中的 bus 会把 `startPromise` 复用为 `pendingStop`(即 chained `transport.stop()` 的 gate),该 Promise 只能证明清理完成,不能证明可以承载数据。`ready()` 在 `stopping` 门之后检查 `suspended`,以挂起态错误 reject,而不是返回 stop gate;`pageshow`/`reopenTransport()` 与显式 `start()` 会清除标记并安装真正的重开 Promise,使 `ready()` 跟随最新生命周期意图。`getHealthSummary()` 原本就报告 `{ healthy: false, state: 'suspended' }`,reject 让 `ready()` 与该判定保持一致。
513
514
  - **交接通道关闭顺序。** `pause()` 将物理 `channel.close()` 推迟一个任务。同步关闭会丢弃仍在排队等待投递的消息(包括交接的 `ROUTE_RELEASED`),使交接目标持有未确认路由。
514
515
  - **悬挂交接恢复。** 若前任 owner 已消失而其 `ROUTE_RELEASED` 始终未到达(高负载下通道消息丢失,或 route 写入与 ACK 发送之间崩溃),reconcile 循环会在该未确认交接悬挂超过一个 worker TTL(默认 10 秒)后重新选举存活 owner:路由以全新 generation 重写并清除交接标记,使常规确认路径得以完成(已有回归固化)。年龄门限很关键——刚写入的未确认路由可能只是在等确认落盘,不能误判为悬挂;而只要前任 owner 仍然存活,新 owner 会继续等待,因此严格交接的无重叠保证不受影响。
515
516
  - **丢失与恢复矩阵。** 每类协调消息都有有界恢复路径:丢失的 `CONTROL/SUBSCRIBE` 由心跳 reconcile 对未确认路由重发;丢失的 `REGISTRY` 通知最多损失一个心跳间隔(默认 3 秒),因为每次 tick 都会 reconcile;丢失的 `ROUTE_RELEASED` 由 reconcile 在前任 owner 消失且交接悬挂超过一个 worker TTL 后重新选举恢复(见上文悬挂交接不变量,已有回归固化);transport 断连窗口内被丢弃的 publication 是唯一文档化的不可恢复丢失(transport 契约)。storage-event 降级通道通过信封内的单调序列号保证变值投递,丢失的派发由同一 reconcile 循环恢复。
@@ -521,6 +522,8 @@ DataBus 将"业务订阅意图"与"transport 当前订阅状态"分离。transpo
521
522
 
522
523
  内置 Centrifuge transport 也会保留自己的 Subscriptions 并做协议层重连。两层恢复都要求 `subscribe` / `unsubscribe` 幂等。
523
524
 
525
+ 运行期 `error` 会有意保留 `transportReady`:该标记记录「本次会话中已安装的 transport 曾成功打开」,使 `ready()` 跟随 transport 而不是随协议连接抖动。transport 的*操作*由独立的恢复门(recovery gate)控制:当自动或按需重开尚未完成时,`runTransport()` 会把新的 `subscribe` / `publish` 挂在该门之后,而不是写入刚刚上报 `error` 的连接;门只在重开成功(或 transport 自愈回到 `connected`)后释放,此时所有挂起的操作才在可用 transport 上执行。自动尝试失败后门保持关闭,但下一次显式操作可以立即触发按需重开,而不必再等一个冷却周期;当 `recovery.maxAttempts` 耗尽,或被 `stop()` / 页面隐藏取代时,门会被释放,使文档化的显式重试路径与挂起丢弃语义继续成立。`disconnected` 是干净关闭而非可恢复失败:它不会调度后台 DataBus 重开,只有显式 `start()`、页面恢复、transport 自身的重连,或后续的 transport 操作才会回到 `connected`。最后一条路径很关键:transport 一旦真正到达过 `connected` 后再上报 `disconnected`,ready 快速路径就会被拒绝;这类干净关闭后到达的 `subscribe()` / `publish()` 会被挂在同一个恢复门之后并触发一次按需重开,随后在替换连接上 flush,而不再写入已关闭的连接。对于在首次 `connected` 之前就 resolve `start()` 的 transport(worker 型后端异步上报连接状态),操作仍会直接交给它,因为此时的 `disconnected` 表示「尚未连接」,而不是「已建立的连接断开」。
526
+
524
527
  ## 生命周期状态机
525
528
 
526
529
  `CrossTabDataBus` 使用多个布尔标志和 Promise gate 来串行化生命周期转换。它们之间的交互是 DataBus 层最复杂的部分。
@@ -532,9 +535,14 @@ DataBus 将"业务订阅意图"与"transport 当前订阅状态"分离。transpo
532
535
  | `started` | `boolean` | `start()` 已被调用,且之后没有 `stop()` 完成 |
533
536
  | `stopping` | `boolean` | `stop()` 正在执行中;阻止新操作 |
534
537
  | `suspended` | `boolean` | Tab 已隐藏;transport 被有意暂停 |
535
- | `transportReady` | `boolean` | transport 已上报 `connected`,可接受操作 |
538
+ | `transportReady` | `boolean` | 本次会话中 transport 已成功打开;运行期 `error` 后会保留,使 `ready()` 继续跟随已安装的 transport(待执行操作由恢复门而非该标记控制) |
536
539
  | `startPromise` | `Promise \| null` | 并发 `start()` 调用的 gate;操作完成后清除 |
540
+ | `stopPromise` | `Promise \| null` | 显式 `stop()` 及其后排队的 restart 共享的 gate |
541
+ | `queuedStart` | `Promise \| null` | 等待进行中的显式 stop 完成后执行的一次全新 start |
542
+ | `queuedStartToken` | `number` | 每次排队 restart 获得的单调令牌,避免取消被误认为更晚的 restart |
543
+ | `canceledQueuedStartToken` | `number` | 被 `stop()` 取消的最高 queued-restart 令牌;令牌不高于它的续体只 resolve,不打开 transport |
537
544
  | `pendingStop` | `Promise \| null` | 异步 `transport.stop()` 的 gate;由 suspend 和故障路径共享 |
545
+ | `lifecycleEpoch` | `number` | 单调所有权令牌;使被取代 open 的回调与清理失效 |
538
546
 
539
547
  ### 状态转换
540
548
 
@@ -564,9 +572,16 @@ DataBus 将"业务订阅意图"与"transport 当前订阅状态"分离。transpo
564
572
 
565
573
  **关键行为:**
566
574
 
567
- - **并发 start**:`startPromise` 非空时第二次调用 `start()` 返回同一个 promise。任何时候只有一个 transport open 在飞行中。
575
+ - **并发 start**:真实 transport open 在飞行中时,第二次调用 `start()` 返回同一个 promise,任何时候只有一个 transport open 在飞行中。pagehide 产生的 stop 也可能占用 `startPromise`;`start()` 会识别 `startPromise === pendingStop`,把 reopen 排在该 stop 之后,而不是把清理 promise 当作成功启动返回。
576
+ - **显式 stop 期间 start**:`stop()` 用共享的 `stopPromise` 服务并发调用者。若 `start()` 在该 stop settle 期间到达,只保存一个 `queuedStart`;stop 的 `finally` 清理生命周期状态后,排队的 start 使用新配置开启全新生命周期。此窗口内的重复调用共享 stop 和 queued-start promise。
577
+ - **stop 取消排队 restart**:排队续体已经挂在 stop promise 上、无法撤销调度,因此在它执行前再次 `stop()` 会改为使其失效。每个排队 restart 携带单调令牌;`stop()` 记录当前令牌并释放唯一的队列槽位,续体发现自己的令牌不再是最新时只 resolve、不打开 transport。排队 `start()` Promise 保留这一「取消即 resolve」契约,而单独的 readiness 视图会让 `ready()` 对被取消的意图 reject。由于后到的 `start()` 会签发更高令牌,`stop → start → stop → start` 仍以运行态结束,而 `stop → start → stop` 以停止态结束且不会多打开一次 transport。
578
+ - **停止期间发布拒绝**:`stop()` 设置 `stopping` 后,新发起的 `publish()` 与非空 `publishBatch()` 无法到达 transport;它们通过 `onError` 上报错误,而不是让 `runTransport()` 静默返回;空 batch 仍为 no-op。已排队在飞行中 open 之后的发布会被 stop 取消(最新生命周期意图优先),而页面隐藏挂起仍保持文档所述的「不延迟、直接丢弃」语义。
579
+ - **停止期间生命周期操作拒绝**:`stopping` gate 同样覆盖 `subscribe()` 与 `ready()`。迟到的 `subscribe()` 会通过 `onError` 上报并返回 no-op 释放函数,避免 handler 被 `topicHandlers.clear()` 清掉,或订阅漂移进下一次 restart 却没有对应 handler。`ready()` 会 reject,而不是对正在停止的 transport 报告 ready。若 `start()` 已在该 stop 之后排队重启,`ready()` 仍返回 queued-start promise,因为这是最新生命周期意图。
580
+ - **排队重启失败保留**:排队重启若在 transport 启动阶段失败,会清除 `started`,但为后续 `ready()` 调用保留真实错误。未提供 `initialConfig` 时,这些调用会以启动失败 reject,而不是返回通用的配置错误;显式 `start(config)` 仍以全新失败账本执行干净的手动重试。
568
581
  - **启动期间隐藏**:`pagehide` 在 `openTransport` 飞行中触发时,`suspendTransport()` 设置 `suspended = true`,并在飞行中的 start 之后链式执行 `transport.stop()`。`openTransport` 的 catch 路径检测到 `suspended` 后放弃本次 open,不视为失败。
582
+ - **被取代 open 失效**:每次全新 start、reopen、suspend 和 stop 都会推进 `lifecycleEpoch`。open 会捕获自己的 epoch;一旦更新的转换接管生命周期,旧 open 的 status/message/error 回调会被忽略,也不会再把 transport 标记为 ready 或执行失败清理。因此 `stop()` 会等待未完成的 open/reopen,并阻止被取代的 open 在 stop 完成后变为 ready。
569
583
  - **恢复冷却**:transport 上报 `error` 且 `started` 为 true、`stopping` 为 false 时,`updateStatus` 在 `RECOVERY_COOLDOWN_MS`(1000 ms)后调度自动 `reopenTransport()`。冷却窗口内的第二次错误被抑制,防止紧循环重试。
584
+ - **传输恢复门**:调度重开的同时会抬起恢复门,使冷却期间发起的 `subscribe` / `publish` 无法到达失效连接,等重开成功后才释放。自动尝试失败后门刻意保持关闭:下一次显式操作会立即触发按需重开,而不是等待下一个限速尝试,挂起的操作则在该次成功后一并 flush。`recovery.maxAttempts` 耗尽,或被 `stop()` / `suspendTransport()` 取代时释放门,从而保留显式重试路径与挂起丢弃语义。运行期 `error` 不会清除 `transportReady`——清除它会让调用方流量绕过冷却重开,并在连接尚未承载数据时报告 ready。
570
585
  - **暂停期间停止**:`stop()` 设置 `stopping = true`,阻止 `suspendTransport()` 执行。清理过程会 await `startPromise` 和 `pendingStop`,确保任何飞行中的 open 或 stop 完成后才执行最终的 `transport.stop()`。
571
586
 
572
587
  ## 降级