cross-tab-worker-databus 0.20.85 → 0.20.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,6 +66,12 @@ 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
+ 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.
74
+
69
75
  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
76
 
71
77
  `ready()` is not equivalent to the server being connected; protocol connection status is obtained via `onStatus`.
@@ -85,8 +91,9 @@ Registers a local subscription and returns a cleanup function.
85
91
  - The first handler in the current tab registers a cluster subscription.
86
92
  - The current tab only leaves the topic after the last handler is released.
87
93
  - Subscriptions are automatically queued when the transport is not yet ready.
94
+ - 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
95
  - 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.
96
+ - 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
97
  When tracing is enabled, retries emit `reliability` events with `operation: 'persistence_retry'`, a bounded `persistenceOperation`, and `attempt`.
91
98
 
92
99
  The WebSocket transport accepts binary publications delivered as either `ArrayBuffer` or browser `Blob` frames.
@@ -118,6 +125,8 @@ Published data must satisfy the serialization constraints of the underlying tran
118
125
 
119
126
  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
127
 
128
+ 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.
129
+
121
130
  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
131
 
123
132
  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 +154,8 @@ Publishes many items to one topic as a single unit of work. The bundled WebSocke
145
154
 
146
155
  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
156
 
157
+ A non-empty batch issued while `stop()` is still settling reports through `onError` and sends nothing; an empty batch remains a no-op.
158
+
148
159
  ### `clearReplay()`
149
160
 
150
161
  ```ts
@@ -210,7 +221,7 @@ interface DataBusHealthSummary {
210
221
  }
211
222
  ```
212
223
 
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()`.
224
+ `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()`.
214
225
 
215
226
  ### `getRecoveryStats()` / `getPersistenceStats()`
216
227
 
@@ -290,7 +301,7 @@ Low-frequency event types include `lifecycle`, `status`, `subscription`, `coordi
290
301
  stop(): Promise<void>
291
302
  ```
292
303
 
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.
304
+ 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.
294
305
 
295
306
  ## `DataBusTransport<TConfig, TData>`
296
307
 
@@ -564,7 +564,12 @@ The built-in Centrifuge transport also retains its own Subscriptions and perform
564
564
  | `suspended` | `boolean` | Tab is hidden; transport is intentionally stopped |
565
565
  | `transportReady` | `boolean` | Transport has reported `connected` and is accepting operations |
566
566
  | `startPromise` | `Promise \| null` | Gate for concurrent `start()` calls; cleared after settle |
567
+ | `stopPromise` | `Promise \| null` | Shared gate for an explicit `stop()` and any restart queued behind it |
568
+ | `queuedStart` | `Promise \| null` | One fresh start waiting for an in-flight explicit stop to settle |
569
+ | `queuedStartToken` | `number` | Monotonic token issued to each queued restart so a cancellation cannot be mistaken for a later one |
570
+ | `canceledQueuedStartToken` | `number` | Highest queued-restart token invalidated by `stop()`; a continuation at or below it resolves without opening |
567
571
  | `pendingStop` | `Promise \| null` | Gate for async `transport.stop()`; shared by suspend and failure paths |
572
+ | `lifecycleEpoch` | `number` | Monotonic ownership token; invalidates callbacks and cleanup from superseded opens |
568
573
 
569
574
  ### State transitions
570
575
 
@@ -594,8 +599,14 @@ The built-in Centrifuge transport also retains its own Subscriptions and perform
594
599
 
595
600
  **Key behaviors:**
596
601
 
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.
602
+ - **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.
603
+ - **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.
604
+ - **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.
605
+ - **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.
606
+ - **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.
607
+ - **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
608
  - **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.
609
+ - **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
610
  - **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.
600
611
  - **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
612
 
@@ -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,13 @@
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.86 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.86 delivered scope
6
+
7
+ - 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.
8
+ - Explicit `start()` now performs the documented manual recovery after automatic recovery exhaustion, while preserving cluster state, subscriptions, and replay history.
9
+ - 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.
10
+ - 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
11
 
5
12
  ## 0.20.85 delivered scope
6
13
 
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,6 +66,12 @@ 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
+ 若该排队重启在 transport 启动阶段失败,即使未传入 `initialConfig`,`ready()` 也会以底层启动错误 reject。该失败会保留给显式恢复,而不会被通用的「缺少配置」错误掩盖。
74
+
69
75
  未传入 `initialConfig` 且未显式调用 `start(config)` 时,`ready()` 返回 rejected Promise 而不是同步抛出,调用方可以统一通过 `.catch` 处理并决定是否显式启动。
70
76
 
71
77
  `ready()` 不等价于服务端已连接,协议连接状态通过 `onStatus` 获取。
@@ -85,8 +91,9 @@ subscribe(
85
91
  - 当前 Tab 第一个 handler 会登记集群订阅。
86
92
  - 最后一个 handler 释放后,当前 Tab 才退出该 Topic。
87
93
  - transport 尚未 ready 时订阅自动排队。
94
+ - 显式 `stop()` 尚未 settle 时发起的订阅不会登记:`subscribe()` 通过 `onError` 上报并返回 no-op 释放函数。调用方应等待 `stop()` settle,再调用 `start()` 后重新订阅。
88
95
  - 通配符订阅:以 `.*` 结尾的 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` 清理,或两者都应用。
96
+ - 重放(可选):构造 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
97
  启用 trace 后,重试会发出 `reliability` 事件,包含 `operation: 'persistence_retry'`、有界的 `persistenceOperation` 和 `attempt`。
91
98
 
92
99
  WebSocket transport 支持以 `ArrayBuffer` 或浏览器 `Blob` 帧接收二进制 publication。
@@ -118,6 +125,8 @@ publish(
118
125
 
119
126
  当 owner 是远端 Tab、且发布控制消息无法投递时(例如 BroadcastChannel 无法克隆 payload),`publish()` 会通过 `onError` 上报失败,而不是静默丢弃。
120
127
 
128
+ 在 `stop()` 尚未 settle 时调用 `publish()` 会通过 `onError` 上报且不路由任何消息;消息不会延迟到之后的 start。更早发出、仍排队等待 transport open 的发布会被 stop 取消。
129
+
121
130
  传入 `options.messageId` 和 `options.timestamp` 后,元数据会穿过跨 Tab 路由、Worker 边界和支持的 transport。服务端必须回显或以其他方式保留它们,入站去重和 replay retention 才能使用。
122
131
 
123
132
  `DataBusMessage` 与 `DataBusPublication` 暴露相同的可选元数据。
@@ -143,6 +152,8 @@ publishBatch(
143
152
 
144
153
  每条 item 的 `messageId` 与 `timestamp` 在传输后保留,dedup、replay 与顺序都按 item 维度、以源顺序生效。空 batch 为 no-op;单 item batch 直接委托给 `publish()`。直接操作协调层的调用方可用 `WorkerClusterRuntime` 上的同名方法。
145
154
 
155
+ 在 `stop()` 尚未 settle 时提交非空 batch 会通过 `onError` 上报且不发送任何内容;空 batch 仍为 no-op。
156
+
146
157
  ### `clearReplay()`
147
158
 
148
159
  ```ts
@@ -208,7 +219,7 @@ interface DataBusHealthSummary {
208
219
  }
209
220
  ```
210
221
 
211
- `state` 语义:`stopped`(未启动)、`starting`(首次连接进行中)、`recovering`(transport 自动恢复进行中)、`suspended`(Tab 隐藏,pageshow 后自动恢复)、`degraded`(自动恢复已耗尽,需要手动 `start()` 或重新 subscribe 触发恢复)、`healthy`。`lastFailure` 是覆盖全部失败来源的统一账本,每次显式 `start()` 后重置。
222
+ `state` 语义:`stopped`(未启动)、`starting`(首次连接进行中)、`recovering`(transport 自动恢复进行中)、`suspended`(Tab 隐藏,pageshow 后自动恢复)、`degraded`(自动恢复已耗尽,需要手动 `start()` 或重新 subscribe 触发恢复)、`healthy`。处于 degraded 时再次调用 `start()` 会保留 cluster、订阅和 replay 缓冲区,重置失败/恢复账本后重新打开 transport;subscribe 与 publish 也走同一恢复路径。`lastFailure` 是覆盖全部失败来源的统一账本,每次显式 `start()` 后重置。
212
223
 
213
224
  ### `getMetrics()`
214
225
 
@@ -288,7 +299,7 @@ trace: {
288
299
  stop(): Promise<void>
289
300
  ```
290
301
 
291
- 永久销毁当前实例:清理 handler、集群注册、路由、Worker 和 transport。普通页面隐藏和恢复不需要调用。
302
+ 永久销毁当前实例:清理 handler、集群注册、路由、Worker 和 transport。若 transport open/reopen 仍在收敛,`stop()` 会等待它结束并使该结果失效,确保它不会在 stop 后变为 ready。普通页面隐藏和恢复不需要调用。
292
303
 
293
304
  ## `DataBusTransport<TConfig, TData>`
294
305
 
@@ -534,7 +534,12 @@ DataBus 将"业务订阅意图"与"transport 当前订阅状态"分离。transpo
534
534
  | `suspended` | `boolean` | Tab 已隐藏;transport 被有意暂停 |
535
535
  | `transportReady` | `boolean` | transport 已上报 `connected`,可接受操作 |
536
536
  | `startPromise` | `Promise \| null` | 并发 `start()` 调用的 gate;操作完成后清除 |
537
+ | `stopPromise` | `Promise \| null` | 显式 `stop()` 及其后排队的 restart 共享的 gate |
538
+ | `queuedStart` | `Promise \| null` | 等待进行中的显式 stop 完成后执行的一次全新 start |
539
+ | `queuedStartToken` | `number` | 每次排队 restart 获得的单调令牌,避免取消被误认为更晚的 restart |
540
+ | `canceledQueuedStartToken` | `number` | 被 `stop()` 取消的最高 queued-restart 令牌;令牌不高于它的续体只 resolve,不打开 transport |
537
541
  | `pendingStop` | `Promise \| null` | 异步 `transport.stop()` 的 gate;由 suspend 和故障路径共享 |
542
+ | `lifecycleEpoch` | `number` | 单调所有权令牌;使被取代 open 的回调与清理失效 |
538
543
 
539
544
  ### 状态转换
540
545
 
@@ -564,8 +569,14 @@ DataBus 将"业务订阅意图"与"transport 当前订阅状态"分离。transpo
564
569
 
565
570
  **关键行为:**
566
571
 
567
- - **并发 start**:`startPromise` 非空时第二次调用 `start()` 返回同一个 promise。任何时候只有一个 transport open 在飞行中。
572
+ - **并发 start**:真实 transport open 在飞行中时,第二次调用 `start()` 返回同一个 promise,任何时候只有一个 transport open 在飞行中。pagehide 产生的 stop 也可能占用 `startPromise`;`start()` 会识别 `startPromise === pendingStop`,把 reopen 排在该 stop 之后,而不是把清理 promise 当作成功启动返回。
573
+ - **显式 stop 期间 start**:`stop()` 用共享的 `stopPromise` 服务并发调用者。若 `start()` 在该 stop settle 期间到达,只保存一个 `queuedStart`;stop 的 `finally` 清理生命周期状态后,排队的 start 使用新配置开启全新生命周期。此窗口内的重复调用共享 stop 和 queued-start promise。
574
+ - **stop 取消排队 restart**:排队续体已经挂在 stop promise 上、无法撤销调度,因此在它执行前再次 `stop()` 会改为使其失效。每个排队 restart 携带单调令牌;`stop()` 记录当前令牌并释放唯一的队列槽位,续体发现自己的令牌不再是最新时只 resolve、不打开 transport。排队 `start()` Promise 保留这一「取消即 resolve」契约,而单独的 readiness 视图会让 `ready()` 对被取消的意图 reject。由于后到的 `start()` 会签发更高令牌,`stop → start → stop → start` 仍以运行态结束,而 `stop → start → stop` 以停止态结束且不会多打开一次 transport。
575
+ - **停止期间发布拒绝**:`stop()` 设置 `stopping` 后,新发起的 `publish()` 与非空 `publishBatch()` 无法到达 transport;它们通过 `onError` 上报错误,而不是让 `runTransport()` 静默返回;空 batch 仍为 no-op。已排队在飞行中 open 之后的发布会被 stop 取消(最新生命周期意图优先),而页面隐藏挂起仍保持文档所述的「不延迟、直接丢弃」语义。
576
+ - **停止期间生命周期操作拒绝**:`stopping` gate 同样覆盖 `subscribe()` 与 `ready()`。迟到的 `subscribe()` 会通过 `onError` 上报并返回 no-op 释放函数,避免 handler 被 `topicHandlers.clear()` 清掉,或订阅漂移进下一次 restart 却没有对应 handler。`ready()` 会 reject,而不是对正在停止的 transport 报告 ready。若 `start()` 已在该 stop 之后排队重启,`ready()` 仍返回 queued-start promise,因为这是最新生命周期意图。
577
+ - **排队重启失败保留**:排队重启若在 transport 启动阶段失败,会清除 `started`,但为后续 `ready()` 调用保留真实错误。未提供 `initialConfig` 时,这些调用会以启动失败 reject,而不是返回通用的配置错误;显式 `start(config)` 仍以全新失败账本执行干净的手动重试。
568
578
  - **启动期间隐藏**:`pagehide` 在 `openTransport` 飞行中触发时,`suspendTransport()` 设置 `suspended = true`,并在飞行中的 start 之后链式执行 `transport.stop()`。`openTransport` 的 catch 路径检测到 `suspended` 后放弃本次 open,不视为失败。
579
+ - **被取代 open 失效**:每次全新 start、reopen、suspend 和 stop 都会推进 `lifecycleEpoch`。open 会捕获自己的 epoch;一旦更新的转换接管生命周期,旧 open 的 status/message/error 回调会被忽略,也不会再把 transport 标记为 ready 或执行失败清理。因此 `stop()` 会等待未完成的 open/reopen,并阻止被取代的 open 在 stop 完成后变为 ready。
569
580
  - **恢复冷却**:transport 上报 `error` 且 `started` 为 true、`stopping` 为 false 时,`updateStatus` 在 `RECOVERY_COOLDOWN_MS`(1000 ms)后调度自动 `reopenTransport()`。冷却窗口内的第二次错误被抑制,防止紧循环重试。
570
581
  - **暂停期间停止**:`stop()` 设置 `stopping = true`,阻止 `suspendTransport()` 执行。清理过程会 await `startPromise` 和 `pendingStop`,确保任何飞行中的 open 或 stop 完成后才执行最终的 `transport.stop()`。
571
582
 
@@ -2,20 +2,20 @@
2
2
 
3
3
  # 浏览器基准趋势
4
4
 
5
- > 数据截至 2026-09-11,基于 12 份归档的 `bench-results/browser-*.json` 报告(运行 `pnpm bench:browser` 追加一份;用 `node scripts/bench-trend.mjs` 重新生成本文档)。
5
+ > 数据截至 2026-09-12,基于 14 份归档的 `bench-results/browser-*.json` 报告(运行 `pnpm bench:browser` 追加一份;用 `node scripts/bench-trend.mjs` 重新生成本文档)。
6
6
 
7
7
  发布门禁的对比基线是最近两份报告之间的 `pnpm bench:compare --fail-above-pct 50`(50% 上限用于吸收共享 runner 的噪声)。本文记录长期趋势:数值为逐指标延迟,越低越好;历史最优为本机观察到的最健康一次运行。
8
8
 
9
9
  <!-- BENCH-TREND:BEGIN (machine-generated table) -->
10
10
  | 指标 | 上次 (ms) | 本次 (ms) | Δ | 历史最优 (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
  说明:
@@ -60,10 +60,10 @@ const bus = new CrossTabDataBus({
60
60
 
61
61
  | 配置 | 类型 | 默认值 | 说明 |
62
62
  |---|---|---|---|
63
- | `maxPerTopic` | `number` | `100` | 每个 topic 最多缓冲的 publication 数;超出时先淘汰最旧条目(正安全整数) |
63
+ | `maxPerTopic` | `number` | `100` | `count`/`both` 下每个 topic 最多缓冲的 publication 数;超出时先淘汰最旧条目。`age` 下带时间戳条目按 retention 窗口有界,无时间戳的 legacy 条目仍受该值限制(正安全整数) |
64
64
  | `persistence` | `DataBusReplayPersistence` | — | 可选持久化后端(`createIndexedDbReplayPersistence`);省略则历史仅存内存 |
65
65
  | `retentionMs` | `number` | — | 生产者时间戳保留窗口;早于 cutoff 的历史通过适配器的 `clearBefore` 清理 |
66
- | `pruneStrategy` | `'count' \| 'age' \| 'both'` | `'count'` | `count` 按 `maxPerTopic` 截断;`age` 按 `retentionMs` 清理;`both` 两者都应用。`age` 未配置 `retentionMs` 时无 age 可依,回退为数量上限 |
66
+ | `pruneStrategy` | `'count' \| 'age' \| 'both'` | `'count'` | `count` 按 `maxPerTopic` 截断;`age` 按 `retentionMs` 清理带时间戳历史,并以 `maxPerTopic` 限制无时间戳的 legacy 条目;`both` 两者都应用。`age` 未配置 `retentionMs` 时无 age 可依,回退为数量上限 |
67
67
  | `retentionSweepMs` | `number` | — | 面向安静 topic 的周期性 durable retention sweep;需要 `retentionMs` 与实现 `clearBefore` 的适配器 |
68
68
  | `persistenceRetry` | `{ maxAttempts, backoffMs }` | `1` / `50` | 瞬时持久化失败的有界重试;延迟指数增长并封顶 |
69
69
 
@@ -1,6 +1,13 @@
1
1
  # 路线图
2
2
 
3
- 0.20.85 正在推进。项目会先持续完成可靠性与协议兼容性的中版本迭代,再进入 1.0.0 稳定性冻结。
3
+ 0.20.86 正在推进。项目会先持续完成可靠性与协议兼容性的中版本迭代,再进入 1.0.0 稳定性冻结。
4
+
5
+ ## 0.20.86 已完成范围
6
+
7
+ - 加固显式 stop/start 边界的生命周期:排队重启与进行中的 stop 串行化,并会被更新的 stop 取消,可通过 `ready()` 观测;被取代的异步开启不再拆除更新的 suspend/resume 转换;stop 期间调用的 `subscribe()` 与非空 `publish()`/`publishBatch()` 改为通过 `onError` 上报,不再修改 teardown 状态或被静默丢弃。
8
+ - 显式 `start()` 在自动恢复预算耗尽后,现在会执行文档所述的手动恢复,同时保留 cluster 状态、订阅与回放历史。
9
+ - IndexedDB replay 持久化在事务 abort(包括连接丢失导致的 abort)时结算全部 mutation,串行队列不再永久阻塞;replay AGE 裁剪改为内存与持久化历史共用同一套位置无关策略。
10
+ - 配置参考在中英文中完整记录 replay/dedup 公共选项,并新增从声明派生的文档守卫;固定种子属性不变量覆盖 active-worker 选择与 rebalance target。
4
11
 
5
12
  ## 0.20.85 已完成范围
6
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cross-tab-worker-databus",
3
- "version": "0.20.85",
3
+ "version": "0.20.86",
4
4
  "description": "Framework-agnostic cross-tab data bus with Dedicated/Shared Worker clustering and Centrifuge support.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -126,7 +126,7 @@
126
126
  "@eslint/js": "^10.0.1",
127
127
  "@playwright/test": "^1.63.0",
128
128
  "@testing-library/react": "^16.3.3",
129
- "@types/node": "^26.5.0",
129
+ "@types/node": "^26.5.1",
130
130
  "@types/react": "^19.3.0",
131
131
  "@vitest/coverage-v8": "^5.0.0",
132
132
  "esbuild": "^0.28.2",