cross-tab-worker-databus 0.20.70 → 0.20.85

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 (95) hide show
  1. package/CHANGELOG.md +209 -2
  2. package/README.md +8 -0
  3. package/README.zh.md +4 -0
  4. package/dist/centrifuge-protocol.d.ts +59 -26
  5. package/dist/centrifuge-protocol.d.ts.map +1 -1
  6. package/dist/centrifuge-session.d.ts +9 -0
  7. package/dist/centrifuge-session.d.ts.map +1 -1
  8. package/dist/centrifuge.d.ts +12 -2
  9. package/dist/centrifuge.d.ts.map +1 -1
  10. package/dist/centrifuge.js +168 -88
  11. package/dist/centrifuge.js.map +3 -3
  12. package/dist/centrifuge.shared.worker.js +146 -38
  13. package/dist/centrifuge.shared.worker.js.map +3 -3
  14. package/dist/centrifuge.worker.js +140 -33
  15. package/dist/centrifuge.worker.js.map +3 -3
  16. package/dist/{chunk-IKBJIREH.js → chunk-PW63EWIK.js} +1090 -476
  17. package/dist/chunk-PW63EWIK.js.map +7 -0
  18. package/dist/chunk-TZ7ZP7YD.js +175 -0
  19. package/dist/chunk-TZ7ZP7YD.js.map +7 -0
  20. package/dist/cjs/centrifuge.cjs +1405 -621
  21. package/dist/cjs/centrifuge.cjs.map +4 -4
  22. package/dist/cjs/hooks.cjs +37 -2
  23. package/dist/cjs/hooks.cjs.map +3 -3
  24. package/dist/cjs/index.cjs +1469 -688
  25. package/dist/cjs/index.cjs.map +4 -4
  26. package/dist/cjs/vue.cjs +45 -2
  27. package/dist/cjs/vue.cjs.map +3 -3
  28. package/dist/core/cluster.d.ts +40 -4
  29. package/dist/core/cluster.d.ts.map +1 -1
  30. package/dist/core/data-bus.d.ts +93 -66
  31. package/dist/core/data-bus.d.ts.map +1 -1
  32. package/dist/core/dedup-manager.d.ts +98 -0
  33. package/dist/core/dedup-manager.d.ts.map +1 -0
  34. package/dist/core/environment.d.ts +43 -3
  35. package/dist/core/environment.d.ts.map +1 -1
  36. package/dist/core/replay-manager.d.ts +128 -0
  37. package/dist/core/replay-manager.d.ts.map +1 -0
  38. package/dist/core/replay-persistence.d.ts +4 -1
  39. package/dist/core/replay-persistence.d.ts.map +1 -1
  40. package/dist/core/routing.d.ts +22 -3
  41. package/dist/core/routing.d.ts.map +1 -1
  42. package/dist/core/storage-batch.d.ts +0 -4
  43. package/dist/core/storage-batch.d.ts.map +1 -1
  44. package/dist/core/trace.d.ts +55 -15
  45. package/dist/core/trace.d.ts.map +1 -1
  46. package/dist/core/types.d.ts +58 -9
  47. package/dist/core/types.d.ts.map +1 -1
  48. package/dist/core/version.d.ts +2 -0
  49. package/dist/core/version.d.ts.map +1 -0
  50. package/dist/hooks.d.ts +12 -1
  51. package/dist/hooks.d.ts.map +1 -1
  52. package/dist/hooks.js +28 -2
  53. package/dist/hooks.js.map +2 -2
  54. package/dist/index.d.ts +6 -6
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +231 -125
  57. package/dist/index.js.map +3 -3
  58. package/dist/utils/constants.d.ts +170 -0
  59. package/dist/utils/constants.d.ts.map +1 -0
  60. package/dist/utils/error-utils.d.ts +28 -0
  61. package/dist/utils/error-utils.d.ts.map +1 -0
  62. package/dist/utils/metadata.d.ts +16 -0
  63. package/dist/utils/metadata.d.ts.map +1 -0
  64. package/dist/utils/storage-utils.d.ts +24 -0
  65. package/dist/utils/storage-utils.d.ts.map +1 -0
  66. package/dist/utils/validation.d.ts +76 -0
  67. package/dist/utils/validation.d.ts.map +1 -0
  68. package/dist/vue.d.ts +13 -1
  69. package/dist/vue.d.ts.map +1 -1
  70. package/dist/vue.js +36 -2
  71. package/dist/vue.js.map +2 -2
  72. package/dist/websocket.d.ts +6 -1
  73. package/dist/websocket.d.ts.map +1 -1
  74. package/dist/worker-mode.d.ts +6 -2
  75. package/dist/worker-mode.d.ts.map +1 -1
  76. package/docs/README.md +1 -0
  77. package/docs/api.md +122 -2
  78. package/docs/architecture.md +80 -0
  79. package/docs/benchmarks.md +24 -0
  80. package/docs/capabilities.md +24 -3
  81. package/docs/configuration.md +58 -0
  82. package/docs/getting-started.md +35 -0
  83. package/docs/release-checklist.md +28 -5
  84. package/docs/roadmap.md +78 -5
  85. package/docs/zh/README.md +2 -1
  86. package/docs/zh/api.md +121 -2
  87. package/docs/zh/architecture.md +49 -0
  88. package/docs/zh/benchmarks.md +24 -0
  89. package/docs/zh/capabilities.md +21 -3
  90. package/docs/zh/configuration.md +53 -0
  91. package/docs/zh/getting-started.md +35 -0
  92. package/docs/zh/release-checklist.md +29 -6
  93. package/docs/zh/roadmap.md +90 -5
  94. package/package.json +39 -16
  95. package/dist/chunk-IKBJIREH.js.map +0 -7
package/docs/roadmap.md CHANGED
@@ -1,6 +1,79 @@
1
1
  # Roadmap
2
2
 
3
- 0.20.70 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.85 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.85 delivered scope
6
+
7
+ - A seeded property suite (`tests/property.test.ts`) for the pure hot-path helpers and the stateful managers: finiteness/totality, order-independent selection, cycle-safe sizing, publication topic/metadata validity, `serializeError` cloneability, and dedup/replay bounds under long random operation sequences.
8
+ - `effectiveWorkerLoad` is total against a corrupt stored base load — a non-finite value (JSON `1e999` → `Infinity`) can no longer leak into owner selection and re-introduce array-order dependence.
9
+ - `approximatePayloadBytes` is depth-bounded, so a cyclic payload (structured clone preserves cycles) can no longer overflow the stack in the replay-buffer footprint or the adaptive-load sampler.
10
+ - `serializeError` always produces a structured-cloneable result; a non-cloneable context (function/symbol) is dropped instead of making the error report itself throw `DataCloneError`.
11
+ - Replay history is bounded when `pruneStrategy: 'age'` is set without a `retentionMs`: the count cap now applies in both the in-memory ring and the IndexedDB record.
12
+
13
+ ## 0.20.84 delivered scope
14
+
15
+ - Release/CI gates are enforced instead of advisory: `pnpm test:coverage`, `pnpm verify:compat`, and `pnpm verify:pack` run in the CI verify job, the Release workflow re-runs lint + compat + pack before publishing, and both checkouts fetch full history and tags so the compat baseline resolves.
16
+ - Coordination recovery hardening: a lost handoff ACK, a crashed owner, or the previous owner's departure now recovers through a worker-TTL-gated re-election with a single writer and projected-load spreading, and each route acknowledgment / migration / recovery is observable as a bounded `reliability` trace event.
17
+ - Three real correctness fixes: the Vue `useCrossTabDataBus` unmount leak (a pending start could create a bus with no owner), the adaptive dedup TTL not taking effect on the hot path, and `effectiveWorkerLoad` leaking a non-finite score from a corrupt stored load back into owner selection.
18
+ - Product-demo observability: the event feed renders reliability / subscription / coordination trace events, chaos toggles exercise the dropped-ACK and crash recovery paths in a real browser, and the config panel shows the active chaos mode.
19
+ - Coverage and toolchain: direct `ReplayManager` / `DedupManager` suites, transport error-isolation and partial-metadata coverage, vitest 5 (benchmark API migrated) and the eslint 10 lint config, with TypeScript 7 deferred until typescript-eslint supports it.
20
+
21
+ ## 0.20.83 delivered scope
22
+
23
+ - Adapter edge-case coverage for the health hook, archived browser benchmarks with a comparison script, and a README feature list aligned with current capabilities.
24
+ - A large internal cleanup: every runtime string literal centralized in `utils/constants.ts` with literal-derived types, and replay/dedup split out of `CrossTabDataBus` into self-contained `ReplayManager` / `DedupManager` classes.
25
+ - The demo's "批量 10" publishBatch button with `/debug/wsstats` frame counting and a single-frame E2E, `asyncSink: true` delivery-semantics documentation, and a release checklist aligned with the blocking published-consumer gate.
26
+
27
+ ## 0.20.82 delivered scope
28
+
29
+ - A 20 s default E2E assertion ceiling, structured-clone rejection coverage, and shared-mode session lifecycle verified end to end through the examples server's connection-count endpoint.
30
+
31
+ ## 0.20.81 delivered scope
32
+
33
+ - The browser benchmark gained the data-bus hot-path matrix, health summaries are asserted end to end in E2E, and the storage-event channel plus transport batching joined the API docs and capabilities matrix.
34
+
35
+ ## 0.20.80 delivered scope
36
+
37
+ - E2E reliability governance: failure traces/videos with longer retention, converge-before-publish patterns for reload tests, a staggered burst pattern within documented guarantees, and the loss-and-recovery matrix in the architecture docs.
38
+
39
+ ## 0.20.79 delivered scope
40
+
41
+ - Published-consumer verification became a blocking release gate, and the lost-handoff-ACK recovery chain (TTL cleanup + resume re-election) is pinned by a regression.
42
+
43
+ ## 0.20.78 delivered scope
44
+
45
+ - E2E now asserts the real transport backend per tab, the deferred-close handoff invariant is pinned by a regression test and documented, and the getting-started guide covers health summaries and the coordination fallback.
46
+
47
+ ## 0.20.77 delivered scope
48
+
49
+ - Fixed a silent local-session degradation for factory-less consumers (bundled Workers are now actually used), added default-backend and channel loss-recovery coverage, and surfaced coordination-channel diagnostics plus the fallback toggle in the demo.
50
+
51
+ ## 0.20.76 delivered scope
52
+
53
+ - Opt-in storage-event coordination fallback for BroadcastChannel-less environments, with an owner-election integration test over the fallback channel and updated degradation documentation and capabilities matrix.
54
+
55
+ ## 0.20.75 delivered scope
56
+
57
+ - Hot-path performance gates joined the unit suite, and IndexedDB replay persistence gained scripted fault-injection coverage for its invalidate-and-recover error paths. The Release workflow's published-consumer verification was audited and confirmed complete.
58
+
59
+ ## 0.20.74 delivered scope
60
+
61
+ - Optional `DataBusTransport.publishBatch` with a one-frame WebSocket implementation, demo-server support, and per-item fallback; `useCrossTabHealth` bindings for React and Vue; health verdict now honors the live transport status.
62
+
63
+ ## 0.20.73 delivered scope
64
+
65
+ - IndexedDB replay persistence is now covered by unit tests (via `fake-indexeddb`) across pruning strategies, batch grouping, mutation serialization, cleanup semantics, and transient open-failure recovery.
66
+ - Real-browser E2E now covers concurrent multi-publisher bursts and full connection re-apply; the architecture docs gained a stability-invariants reference (English and Chinese).
67
+
68
+ ## 0.20.72 delivered scope
69
+
70
+ - Expanded the benchmark matrix across publish batching, wildcard routing, deduplication, replay pruning, bulk persistence, and asynchronous trace sinks.
71
+ - Long-session stability hardening: regression coverage for handoff ACK generation validation, repeated BFCache round-trips, recovery exhaustion reset, storage write backoff recovery, and replay persistence cleanup races; fixed an inverted stale-ACK generation check and a batch-flush resurrection race in replay cleanup.
72
+ - Production capabilities: `getHealthSummary()` readiness verdict, `getPersistenceStats()`, transport status/suspended granularity in diagnostics, and a build-time injected SDK version.
73
+
74
+ ## 0.20.71 delivered scope
75
+
76
+ - Added optional `appendBatch` replay persistence and IndexedDB transaction coalescing for publication bursts.
4
77
 
5
78
  ## 0.20.70 delivered scope
6
79
 
@@ -394,10 +467,10 @@
394
467
 
395
468
  ## 0.20.69 candidates
396
469
 
397
- 1. Publish a peer capability matrix and expose SDK/backend/transport identity in diagnostics.
398
- 2. Unify replay, deduplication, trace, recovery, and cluster health counters.
399
- 3. Optimize IndexedDB concurrent append and cleanup paths.
400
- 4. Add performance baselines for adaptive dedup, async trace, pruning, and long-running multi-tab workloads.
470
+ 1. ~~Publish a peer capability matrix and expose SDK/backend/transport identity in diagnostics.~~ Delivered: `getDiagnostics()` carries protocol version, unknown-message stats, peer protocol versions, and transport identity; `getHealthSummary()`/`getMetrics()` now ship live trace metrics and sink state.
471
+ 2. ~~Unify replay, deduplication, trace, recovery, and cluster health counters.~~ Delivered: `getDiagnostics()` + `getMetrics()` + `getHealthSummary()` cover lifecycle, recovery, dedup, replay, persistence, protocol, transport, cluster, trace metrics, and sink back-pressure in single snapshots.
472
+ 3. ~~Optimize IndexedDB concurrent append and cleanup paths.~~ Delivered: adjacent `appendBatch` mutations coalesce into one transaction; clear/clearTopic/clearBefore ordering preserved.
473
+ 4. ~~Add performance baselines for adaptive dedup, async trace, pruning, and long-running multi-tab workloads.~~ Partially delivered: bench covers load-weighting scoring, `getMetrics`, publishBatch batch-size sensitivity, and the existing dedup/async-sink/persistence cases.
401
474
 
402
475
  ## 0.13.0 candidates
403
476
 
package/docs/zh/README.md CHANGED
@@ -13,8 +13,9 @@
13
13
  | [架构说明](./architecture.md) | Worker 集群、路由、存储、迁移和降级设计 |
14
14
  | [能力矩阵](./capabilities.md) | 已实现、未实现和计划待实现的能力矩阵 |
15
15
  | [路线图](./roadmap.md) | 面向版本的优先级与验证清单 |
16
+ | [基准趋势](./benchmarks.md) | 基于 `bench-results/` 自动生成的浏览器基准历史 |
16
17
  | [发布检查清单](./release-checklist.md) | 本地门禁、打 tag、发布和发布后验证 |
17
- | [../..//examples/demo](../../examples/demo) | 可运行的多标签浏览器演示 |
18
+ | [../../examples/demo](../../examples/demo) | 可运行的多标签浏览器演示 |
18
19
  | [../../CHANGELOG.md](../../CHANGELOG.md) | 版本变更记录 |
19
20
 
20
21
  ## 阅读顺序
package/docs/zh/api.md CHANGED
@@ -86,7 +86,7 @@ subscribe(
86
86
  - 最后一个 handler 释放后,当前 Tab 才退出该 Topic。
87
87
  - transport 尚未 ready 时订阅自动排队。
88
88
  - 通配符订阅:以 `.*` 结尾的 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 }` 可重试瞬时持久化失败;默认仍保持单次尝试。
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` 清理,或两者都应用。
90
90
  启用 trace 后,重试会发出 `reliability` 事件,包含 `operation: 'persistence_retry'`、有界的 `persistenceOperation` 和 `attempt`。
91
91
 
92
92
  WebSocket transport 支持以 `ArrayBuffer` 或浏览器 `Blob` 帧接收二进制 publication。
@@ -130,6 +130,19 @@ publish(
130
130
  }
131
131
  ```
132
132
 
133
+ ### `publishBatch(topic, items)`
134
+
135
+ ```ts
136
+ publishBatch(
137
+ topic: string,
138
+ items: Array<{ data: unknown; messageId?: string; timestamp?: number }>
139
+ ): void
140
+ ```
141
+
142
+ 把多条 item 作为一个工作单元发布到同一个 topic。内置 WebSocket transport 会把整个 batch 打包成**一帧**(`publishBatch` op),而不是逐条一帧;未实现可选钩子 `DataBusTransport.publishBatch` 的 transport 会自动回退逐条 `publish()`,因此两种情况下调用都安全。
143
+
144
+ 每条 item 的 `messageId` 与 `timestamp` 在传输后保留,dedup、replay 与顺序都按 item 维度、以源顺序生效。空 batch 为 no-op;单 item batch 直接委托给 `publish()`。直接操作协调层的调用方可用 `WorkerClusterRuntime` 上的同名方法。
145
+
133
146
  ### `clearReplay()`
134
147
 
135
148
  ```ts
@@ -170,6 +183,65 @@ getStatus(): WorkerStatus
170
183
 
171
184
  返回当前状态:`connecting`、`connected`、`disconnected` 或 `error`。
172
185
 
186
+ ### `getHealthSummary()`
187
+
188
+ ```ts
189
+ getHealthSummary(): DataBusHealthSummary
190
+ ```
191
+
192
+ 面向仪表盘、就绪探针与支持包的紧凑健康判定。先回答「总线当前是否可用」,再附带解释该结论的失败与恢复上下文:
193
+
194
+ ```ts
195
+ interface DataBusHealthSummary {
196
+ healthy: boolean; // 已启动、未挂起、transport 就绪
197
+ state: 'stopped' | 'starting' | 'healthy' | 'recovering' | 'suspended' | 'degraded';
198
+ status: WorkerStatus;
199
+ sdkVersion: string;
200
+ started: boolean;
201
+ suspended: boolean; // Tab 隐藏(BFCache)期间为 true
202
+ transport: { name; backend; ready; status };
203
+ recovery: { attempt; exhausted; maxAttempts; generation; lastSuccessAt; hasError; errorMessage; errorAt };
204
+ lastFailure: { source: 'transport' | 'persistence' | 'dispatch'; message: string; at: number } | null;
205
+ persistence: { failures: number; lastFailureAt: number | null; lastErrorMessage: string | null };
206
+ metrics: DataBusMetricsSnapshot | null; // 实时 trace 窗口;trace 指标未启用时为 null
207
+ trace: { asyncSink: boolean; pendingEvents: number }; // sink 背压可见性
208
+ }
209
+ ```
210
+
211
+ `state` 语义:`stopped`(未启动)、`starting`(首次连接进行中)、`recovering`(transport 自动恢复进行中)、`suspended`(Tab 隐藏,pageshow 后自动恢复)、`degraded`(自动恢复已耗尽,需要手动 `start()` 或重新 subscribe 触发恢复)、`healthy`。`lastFailure` 是覆盖全部失败来源的统一账本,每次显式 `start()` 后重置。
212
+
213
+ ### `getMetrics()`
214
+
215
+ ```ts
216
+ getMetrics(): DataBusMetricsSnapshot | null
217
+ ```
218
+
219
+ 对当前 trace 指标窗口的**同步、非破坏性**快照——与周期性 `message_metrics` 事件相同的派生计数(received、dispatched、topics,分发延迟 avg/p50/p95/max,dedup accepted/suppressed),无需 sink 或间隔 flush 即可按需读取。trace 指标未启用(禁用或 events-only 模式)时返回 `null`。
220
+
221
+ `getDiagnostics().replay` 输出 `{ enabled, topics, messages, bytes }` —— `bytes` 是缓冲 replay 环的近似内存 payload 占用(与自适应负载加权相同的 string/binary/number 尺寸启发式),按需计算,热路径 append 不为此付任何成本。
222
+
223
+ ### `getRecoveryStats()` / `getPersistenceStats()`
224
+
225
+ ```ts
226
+ getRecoveryStats(): { attempt; exhausted; maxAttempts; hasError; errorMessage; errorAt; generation; lastSuccessAt }
227
+ getPersistenceStats(): { failures; lastFailureAt; lastErrorMessage }
228
+ ```
229
+
230
+ `recovery.generation` 在每次 transport 成功打开时递增(首次启动与每次恢复);`lastSuccessAt` 是该次成功的时间戳(首次成功前为 `null`)。持久化计数仅覆盖可选的 replay 持久化后端。
231
+
232
+ ### `getDiagnostics()`
233
+
234
+ ```ts
235
+ getDiagnostics(): DataBusDiagnostics
236
+ ```
237
+
238
+ 完整诊断快照,合并生命周期、transport 身份(`name`、`backend`、实时 `status`、`suspended`)、恢复、dedup、replay、持久化、协议(`version`、`unknownMessages`、`peers`)与集群快照,外加两个仅用于诊断的字段:
239
+
240
+ - `metrics: DataBusMetricsSnapshot | null` — 当前 trace 指标窗口(吞吐、分发延迟百分位、dedup 结果);trace 指标未启用(禁用或 events-only 模式)时为 `null`。
241
+ - `trace: { asyncSink: boolean; pendingEvents: number }` — sink 投递模式与排队事件深度;`asyncSink: true` 下 `pendingEvents` 持续增长是 sink 背压的第一个信号。
242
+
243
+ `sdkVersion` 构建时从 `package.json` 注入。只需要就绪结论的调用方应优先使用 `getHealthSummary()`。
244
+
173
245
  ### `getClusterSnapshot()`
174
246
 
175
247
  返回诊断快照:
@@ -206,7 +278,9 @@ trace: {
206
278
  }
207
279
  ```
208
280
 
209
- 低频事件类型包括 `lifecycle`、`status`、`subscription`、`coordination` 和 `error`;高频数据按窗口输出 `message_metrics`,包含接收/分发计数、活跃 Topic 数量和分发延迟聚合(`dispatchSamples`、`dispatchAvgMs`、`dispatchP50Ms`、`dispatchP95Ms`、`dispatchMaxMs`),以及去重结果(`dedupAccepted`、`dedupSuppressed`)。所有公开事件都使用固定结构,不包含原始 Topic、消息 payload、连接地址或错误正文。sink 抛错会被隔离,不会中断消息分发,但会向 `console.warn` 输出错误,便于定位诊断配置问题。sink 应尽量避免抛出异常——预期中的错误条件应通过事件数据表达,而不是通过异常上报。
281
+ 低频事件类型包括 `lifecycle`、`status`、`subscription`、`coordination`、`reliability` 和 `error`;高频数据按窗口输出 `message_metrics`,包含接收/分发计数、活跃 Topic 数量和分发延迟聚合(`dispatchSamples`、`dispatchAvgMs`、`dispatchP50Ms`、`dispatchP95Ms`、`dispatchMaxMs`),以及去重结果(`dedupAccepted`、`dedupSuppressed`)。路由归属变化通过 `reliability` 事件呈现:优雅交接上报 `operation: 'route_migration'`,而恢复悬挂未确认交接的重选上报 `operation: 'route_migration_recovery'`(前任 owner 已消失且 ACK 始终未到达),便于 trace 消费者区分恢复与常规交接。`coordination` 事件在每次 transport 打开后发出,携带 `coordinated`、`activeWorkers`、`workers`(格式化后的 worker 记录)与 `routes`(`topicKey@workerId|confirmed=…`,反映已收敛的路由列表)。所有公开事件都使用固定结构,不包含原始 Topic、消息 payload、连接地址或错误正文。sink 抛错会被隔离,不会中断消息分发,但会向 `console.warn` 输出错误,便于定位诊断配置问题。sink 应尽量避免抛出异常——预期中的错误条件应通过事件数据表达,而不是通过异常上报。
282
+
283
+ **`asyncSink: true` 的投递语义。** 默认(`false`)下,每条事件同步调用 sink。开启 `asyncSink: true` 后,事件先入内存队列,在**一个微任务批次**中统一投递:任务的第一条事件调度 `queueMicrotask`,在该微任务运行前产生的所有事件(含周期性 `message_metrics` 快照)按 FIFO 顺序一次性送往 sink。这样热路径永远不会因 sink 工作而阻塞。错误隔离与同步模式一致——sink 抛错被捕获并记入 `console.warn`,既不会中断分发,也不会中断批次内其余事件。顺序保证边界:**批次内**顺序是确定的,但投递被推迟到下一个微任务,因此事件不再保证在你的下一行语句执行前可见;批次 flush 之后新产生的事件会落入后续批次。当需要"每条事件在下一行代码前可见"时,请使用默认的同步 sink(或自行合并计数)。
210
284
 
211
285
  ### `stop()`
212
286
 
@@ -224,6 +298,8 @@ interface DataBusTransport<TConfig, TData> {
224
298
  subscribe(topic): void | Promise<void>;
225
299
  unsubscribe(topic): void | Promise<void>;
226
300
  publish(topic, data): void | Promise<void>;
301
+ /** 可选:将多条消息合并为一帧发送。未提供时 DataBus 回退为逐条 `publish`。 */
302
+ publishBatch?(topic, items): void | Promise<void>;
227
303
  stop(): void | Promise<void>;
228
304
  }
229
305
  ```
@@ -271,6 +347,18 @@ const transport = new CentrifugeWorkerTransport({
271
347
  - `workerFactory`:自定义 Dedicated Worker 加载方式
272
348
  - `sharedWorkerFactory`:自定义 SharedWorker 加载方式
273
349
 
350
+ ## `createStorageEventChannel(options)`
351
+
352
+ ```ts
353
+ createStorageEventChannel(options: {
354
+ name: string;
355
+ storage: StorageLike | null;
356
+ win: StorageEventWindow | null;
357
+ }): ClusterChannel | null
358
+ ```
359
+
360
+ 创建以 localStorage `storage` 事件为载体的 `ClusterChannel`——面向无 BroadcastChannel 环境的协调降级通道。storage 或 storage-event 来源缺失时返回 `null`。投递语义与 BroadcastChannel 一致(不回显给发送方、消息可 JSON 序列化、关闭后拒绝再写入);载荷信封内的单调序列号保证连续相同消息仍可投递。通过 `createBrowserEnvironment({ channelFallback: 'storage-event' })` 启用;安全权衡见 [configuration.md](./configuration.md#协调通道降级broadcastchannel-不可用)。
361
+
274
362
  ## WebSocket 传输后端
275
363
 
276
364
  基于原生 WebSocket 的零依赖传输。任何实现下列 JSON 帧协议的服务器都能驱动与 Centrifuge 后端相同的跨 Tab 集群栈(owner 去重、粘性路由、故障转移)。
@@ -334,6 +422,10 @@ React(>= 18)是可选 peer 依赖;独立入口保证非 React 消费者不
334
422
 
335
423
  把 `bus.onStatus()` 镜像为 React 状态,bus 身份变化时同步读取当前值。返回 `'connecting' | 'connected' | 'disconnected' | 'error'`。
336
424
 
425
+ ### `useCrossTabHealth(bus, options?)`
426
+
427
+ 将 `bus.getHealthSummary()` 镜像为 React 状态(`DataBusHealthSummary | null`)。由于健康摘要是快照而非事件流,该 hook 按间隔轮询(默认 1000 ms;传 `{ intervalMs: 0 }` 可仅依赖事件驱动刷新),并在状态变化与错误发生时立即刷新。bus 创建前返回 `null`。
428
+
337
429
  ## Vue Composables(`cross-tab-worker-databus/vue`)
338
430
 
339
431
  Vue 3.3+ 是可选 peer 依赖;独立入口不会影响核心包。
@@ -346,6 +438,10 @@ useVueCrossTabSubscription(bus, 'chat.*', message => console.log(message.data));
346
438
 
347
439
  `useCrossTabDataBus` 返回 Vue `Ref`,在组件挂载时创建 bus、卸载时停止。`useCrossTabSubscription` 接受字符串或 `Ref<string>` topic,在 bus/topic 变化时自动重绑。`useCrossTabStatus` 返回与 `bus.onStatus()` 同步的 `Ref<WorkerStatus>`。
348
440
 
441
+ ### `useVueCrossTabHealth(bus, options?)`
442
+
443
+ `useCrossTabHealth` 的 Vue 绑定:将 `bus.getHealthSummary()` 镜像为 Vue `Ref<DataBusHealthSummary | null>`。健康摘要是快照而非事件流,因此该组合式函数按间隔轮询(默认 1000 ms;传 `{ intervalMs: 0 }` 可仅依赖事件驱动刷新),并在状态变化与错误发生时立即刷新。bus 创建前返回 `null`。
444
+
349
445
  ## `WorkerClusterRuntime`
350
446
 
351
447
  高级 API,负责 Worker 注册、心跳、可见性、路由、BroadcastChannel 协议和迁移。业务模块不应直接操作它。
@@ -356,6 +452,7 @@ useVueCrossTabSubscription(bus, 'chat.*', message => console.log(message.data));
356
452
  - `setStatus(status)`
357
453
  - `subscribe(topic)` / `unsubscribe(topic)`
358
454
  - `publish(topic, data)`
455
+ - `publishBatch(topic, items)`
359
456
  - `broadcastEvent(eventType, payload)`
360
457
  - `isAssigned(topic)`
361
458
  - `isActiveWorker()`
@@ -372,6 +469,12 @@ useVueCrossTabSubscription(bus, 'chat.*', message => console.log(message.data));
372
469
 
373
470
  创建默认浏览器环境适配器,包含 storage、BroadcastChannel、定时器和页面生命周期事件。
374
471
 
472
+ ### `getOrCreateTabId(environment, key?)`
473
+
474
+ 返回当前页面/标签页实例的稳定标识:优先从 sessionStorage 读取,首次调用时创建(`tab-<random>`)并写回,因此刷新后的标签页会认领原有 route,而不是被当成一个全新标签页。
475
+
476
+ 存在 `window.opener` 时**刻意不**复用已存储的值:`window.open()` 会把 opener 的 `sessionStorage` 克隆给子页面,盲目复用会让两个存活的标签页共用一个身份。storage 不可用或抛错时改为生成新 ID。
477
+
375
478
  ### `selectWorkerBackend(mode, availability?)`
376
479
 
377
480
  按 `WorkerMode` 和能力检测选择实际后端,返回 `'shared' | 'dedicated' | 'local'`:
@@ -381,6 +484,22 @@ useVueCrossTabSubscription(bus, 'chat.*', message => console.log(message.data));
381
484
 
382
485
  `availability` 可显式传入 `worker` / `sharedWorker` 能力标记,用于 SSR、测试或嵌入环境,避免访问不存在的全局对象。
383
486
 
487
+ ### `effectiveWorkerLoad(worker, options?)`
488
+
489
+ 最少负载 owner 选择背后的纯打分函数:`worker.load`(拥有的 Topic 数)加上 `options` 中 `loadWeighting` 权重对应的流量与调度滞后项。
490
+
491
+ 打分结果始终是有限值。没有吞吐采样、权重全为 0(未启用)、采样窗口非正或非有限、或加权和为非有限时,都退回原始 Topic 数——非有限分数永远无法正确比较,会让 owner 选择取决于 Worker 数组顺序而非负载。权重含义见 [configuration.md](./configuration.md#自适应-owner-加权)。
492
+
493
+ ### `approximatePayloadBytes(payload)`
494
+
495
+ 低成本、零分配的 payload 线长估算,用于自适应负载采样的字节侧。仅在启用自适应路由时运行,因此近似值足够——目标是跨 Worker 的稳定比较,而非精确字节数。
496
+
497
+ 尺寸规则:`null`/`undefined` 与 symbol/function 为 `0`,boolean 为 `4`,number 与 bigint 为 `8`,字符串为长度,`ArrayBuffer` 与 TypedArray 视图为 `byteLength`,数组为 8 字节头部加各元素之和,普通对象为各值之和。
498
+
499
+ ### `DEFAULT_MAX_ACTIVE_WORKERS`
500
+
501
+ 同时拥有 Topic 的 Worker 数上限默认值(`3`)。它限制 fan-out 广度:只有这么多 Worker 有资格成为新 route 的 owner,因此二十个标签页的集群仍会把所有权集中在少数几个上,而不是摊薄。可通过集群选项 `maxActiveWorkers` 覆盖。
502
+
384
503
  ### 路由选择函数
385
504
 
386
505
  - `selectActiveWorkers`
@@ -48,10 +48,26 @@ graph TB
48
48
  | 层 | 入口 | 职责 |
49
49
  |---|---|---|
50
50
  | DataBus | `CrossTabDataBus` | 本地 handler 引用计数、消息分发、状态与 transport 生命周期 |
51
+ | Replay | `ReplayManager` | 有界的每 Topic 历史环形缓冲、IndexedDB 持久化、保留期清理、重试策略 |
52
+ | Dedup | `DedupManager` | 可选的按 `messageId` 有界去重、自适应 TTL、过期清扫 |
51
53
  | 集群协调 | `WorkerClusterRuntime` | Worker 注册、角色、心跳、Topic owner、迁移和广播协议 |
52
54
  | Transport | `DataBusTransport` | 在真实 Worker/连接上执行 subscribe、unsubscribe、publish |
53
55
  | Centrifuge | `CentrifugeWorkerTransport` | 主线程与内置 Centrifuge Worker 之间的协议适配 |
54
56
 
57
+ ## 源码组织与共享工具
58
+
59
+ `src/` 目录在平台适配器和协调核心之外维护一个轻量工具库 `src/utils/`:
60
+
61
+ | 文件 | 内容 | 使用方 |
62
+ |---|---|---|
63
+ | `utils/constants.ts` | 全部运行时字符串字面量集中一处——状态、角色、动作、集群/Worker/协议消息类型、trace 事件判别字段、枚举、命名空间前缀。所有由字面量派生的类型(`WorkerStatus`、消息 `type` 判别、trace 的 `action`/`operation` 等)都用 `(typeof X)[keyof typeof X]` 从这些常量派生,值和类型永不脱节。 | 所有模块 |
64
+ | `utils/metadata.ts` | `publicationMetadata(messageId, timestamp)`——只展开已定义字段,此前散落在四个模块各有一份。 | data-bus、cluster、centrifuge、centrifuge-session |
65
+ | `utils/storage-utils.ts` | `readJson` / `writeJson` / `listKeys` / `readAllByPrefix`——容错的存储原语(损坏 JSON 视为不存在;写失败静默)。 | cluster |
66
+ | `utils/validation.ts` | 构造参数/选项校验断言(`assertReplayOptions`、`assertDedupOptions`、`assertRecoveryOptions`、`assertHeartbeatInterval` 等)。可选字段只在显式提供时校验;默认值恒合法。 | data-bus、replay-persistence、centrifuge |
67
+ | `utils/error-utils.ts` | `serializeError` / `deserializeWorkerError` + `SerializedWorkerError`——Error 在 Worker 边界的往返传输。 | centrifuge、centrifuge-session |
68
+
69
+ 提取是刻意的选择:这些是无副作用、依赖轻的助手,其重复副本已经开始漂移(例如四份几乎相同的 `publicationMetadata` 实现)。有自身生命周期的有状态横切关注点——回放缓冲与去重——则作为自包含的 `DedupManager` / `ReplayManager` 类,由 DataBus 委托调用;它们持有自己的 map/timer/统计并暴露薄的 start/stop/record/clear 接口。DataBus 生命周期状态机(start/stop/suspend/resume、promise 门、恢复节奏)则刻意保留在 `CrossTabDataBus` 内部:这些标志紧密互锁,拆出去会重新引入这些门原本要防的竞态。
70
+
55
71
  ## 术语表
56
72
 
57
73
  用通俗语言解释核心术语;代码与本文档其余部分使用简称。
@@ -310,6 +326,25 @@ owner Worker 对 transport 收到的每条 publication 都先用 `isAssigned(top
310
326
  6. 只有 Topic 尚无 route,或者原 owner 已退出、心跳 TTL 过期时,才把 Topic 分配给负载最低的候选 Worker。
311
327
  7. 新路由在 owner 写入 `confirmedAt` 前视为未确认;subscriber 会自动重发控制消息。
312
328
 
329
+ ### 自适应 owner 加权(`loadWeighting`)
330
+
331
+ 默认"负载最低"指拥有最少的 Topic。可选的 `loadWeighting` 增加流量与调度信号,同样只作用于新路由或孤儿路由——已有 route 保持 sticky,绝不迁移:
332
+
333
+ - 每个 Worker 在心跳之间采样自身的 fan-out 活动,并把 `WorkerThroughputSample`(`windowMs`、`messageCount`、`byteCount`、`overrunMs`、`sampledAt`)随 worker 记录发布。
334
+ - `overrunMs` 是采样窗口超出名义心跳间隔的正向余量。事件循环饥饿(浏览器可观测的 CPU 饱和代理)会让心跳延迟、窗口拉长,因此这是一个廉价的原生信号。
335
+ - `effectiveWorkerLoad`(`routing.ts` 中的纯函数)把 Worker 打分记为 `load + messageRateWeight × 条/秒 + byteRateWeight × 字节/秒 + scheduleLagWeight × (overrunMs ÷ windowMs)`。所有权重默认 `0`,保持 legacy 纯 Topic 数打分字节级不变。
336
+ - 评分确定性且读取所有 Tab 相同的持久化记录,因此集群各处路由一致;调度滞后的 Worker 即使携带更少的 Topic,对新路由的吸引力也会下降。
337
+
338
+ ### 异步凭证刷新桥(`credentialProvider`)
339
+
340
+ Centrifuge 客户端选项会 structured-clone 进 Worker,因此函数型 `getToken` / `getChannelToken` 无法随配置传输。`createCentrifugeDataBus` 的可选 `credentialProvider`(`{ getToken, getChannelToken }`)改为在主线程运行:
341
+
342
+ 1. 配置了 provider 时,INIT 携带 `tokenBridge: true`,Worker 内 `CentrifugeSession` 把 `getToken` / `getChannelToken` 接到一次 `TOKEN_REQUEST` 输出(`requestId`、`kind`、可选 `channel`)。
343
+ 2. transport 在主线程解析:`resolveTokenRequest` 调用 provider,并把 `TOKEN_RESPONSE`(拒绝或空 token 时为 `TOKEN_ERROR`)按 `requestId` 回发。
344
+ 3. session 按 `requestId` 结算挂起的 promise;`STOP` 会拒绝所有在途请求,已停止的 Worker 不会永远等待响应。
345
+ 4. 未配置 provider 时 INIT 不带 `tokenBridge`,配置保持与 legacy 字节级一致——服务端从不请求 token 就永远不会触发请求。
346
+ 5. Dedicated、SharedWorker 与本地后端共用 `CentrifugeSession`,因此桥对三者都生效;token 从不以函数形式跨越 Worker 边界,只作为已解析的字符串返回。
347
+
313
348
  ## 订阅流程
314
349
 
315
350
  ```mermaid
@@ -466,6 +501,20 @@ Transport 消息 → isAssigned(topic)? → 是 → broadcastEvent(EVENT)
466
501
 
467
502
  该过程在正常 owner 交接时避免重复订阅,同时在故障恢复时保持可用;不保证 exactly-once。
468
503
 
504
+ ## 稳定性不变量
505
+
506
+ 以下不变量由回归测试固化(见 `tests/stability.test.ts` 与 `tests/replay-persistence.test.ts`),后续重构必须继续保持:
507
+
508
+ - **Handoff ACK 有效性。** `ROUTE_RELEASED` 只有在 route 仍指向接收方、释放来自记录的 `handoffFromWorkerId`、且 ACK generation 不小于存储 route 的 generation 时才被接受。来自更早交接轮次的重复 ACK(如 a↔b 反复交接)携带更旧的 generation,会被丢弃。
509
+ - **Replay 持久化清理顺序。** 排队在当前任务之后的批量持久化 flush 会与竞速的清理操作对账:`unsubscribe` 与 `clearReplayTopic` 丢弃该 topic 的待写条目,`clearReplayBefore` 丢弃早于截止时间的条目。已清理的历史不会被在途 flush 复活。
510
+ - **存储写失败恢复。** 合并写入按指数退避重试(50 ms → 1.6 s 封顶)。结构性失败的关键在 5 次尝试后被丢弃(伴随 `console.warn`),且不会永久阻塞其他排队 key;队列完全清空或 `clear()` 取消重试后,退避延迟重置。
511
+ - **Transport 恢复预算。** 自动恢复由冷却时间限速、由 `recovery.maxAttempts` 限量,预算耗尽后标记 `exhausted`。成功的重开会重置尝试计数与 exhausted 标记;transport 宕机时显式 `subscribe` 仍可手动恢复。
512
+ - **BFCache 挂起。** Tab 隐藏时停止 transport、递增持久化重试 generation(取消在途重试且不对外报错)并门控分发;pageshow 时重开 transport,每轮循环只重建一次订阅。
513
+ - **交接通道关闭顺序。** `pause()` 将物理 `channel.close()` 推迟一个任务。同步关闭会丢弃仍在排队等待投递的消息(包括交接的 `ROUTE_RELEASED`),使交接目标持有未确认路由。
514
+ - **悬挂交接恢复。** 若前任 owner 已消失而其 `ROUTE_RELEASED` 始终未到达(高负载下通道消息丢失,或 route 写入与 ACK 发送之间崩溃),reconcile 循环会在该未确认交接悬挂超过一个 worker TTL(默认 10 秒)后重新选举存活 owner:路由以全新 generation 重写并清除交接标记,使常规确认路径得以完成(已有回归固化)。年龄门限很关键——刚写入的未确认路由可能只是在等确认落盘,不能误判为悬挂;而只要前任 owner 仍然存活,新 owner 会继续等待,因此严格交接的无重叠保证不受影响。
515
+ - **丢失与恢复矩阵。** 每类协调消息都有有界恢复路径:丢失的 `CONTROL/SUBSCRIBE` 由心跳 reconcile 对未确认路由重发;丢失的 `REGISTRY` 通知最多损失一个心跳间隔(默认 3 秒),因为每次 tick 都会 reconcile;丢失的 `ROUTE_RELEASED` 由 reconcile 在前任 owner 消失且交接悬挂超过一个 worker TTL 后重新选举恢复(见上文悬挂交接不变量,已有回归固化);transport 断连窗口内被丢弃的 publication 是唯一文档化的不可恢复丢失(transport 契约)。storage-event 降级通道通过信封内的单调序列号保证变值投递,丢失的派发由同一 reconcile 循环恢复。
516
+ - **恢复诊断。** `getHealthSummary()` 从生命周期标志推导单一就绪判定(`stopped` / `starting` / `healthy` / `recovering` / `suspended` / `degraded`);统一的 `lastFailure` 账本与持久化计数在每次显式 `start()` 后重置。
517
+
469
518
  ## Transport 重连
470
519
 
471
520
  DataBus 将"业务订阅意图"与"transport 当前订阅状态"分离。transport 上报 `disconnected` / `error` 时,只清除底层订阅标志,不清除业务 handler;重新进入 `connected` 时,DataBus 自动重放当前 Worker 负责的 Topic。
@@ -0,0 +1,24 @@
1
+ <!-- 由 scripts/bench-trend.mjs 生成 —— 整个文件均为机器生成,请修改脚本而非本文档。 -->
2
+
3
+ # 浏览器基准趋势
4
+
5
+ > 数据截至 2026-09-11,基于 12 份归档的 `bench-results/browser-*.json` 报告(运行 `pnpm bench:browser` 追加一份;用 `node scripts/bench-trend.mjs` 重新生成本文档)。
6
+
7
+ 发布门禁的对比基线是最近两份报告之间的 `pnpm bench:compare --fail-above-pct 50`(50% 上限用于吸收共享 runner 的噪声)。本文记录长期趋势:数值为逐指标延迟,越低越好;历史最优为本机观察到的最健康一次运行。
8
+
9
+ <!-- BENCH-TREND:BEGIN (machine-generated table) -->
10
+ | 指标 | 上次 (ms) | 本次 (ms) | Δ | 历史最优 (ms) |
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 |
19
+ <!-- BENCH-TREND:END -->
20
+
21
+ 说明:
22
+
23
+ - publish 行测量完整演示链路(发布点击 → transport → 演示服务器 → EVENT 扇出 → 接收方指标),包含真实浏览器与服务器延迟;databus 行是页内热路径微基准。
24
+ - 回归信号是多轮持续上移,而不是单次离群值。打 tag 发布前,应排查对应窗口内的热路径改动。
@@ -20,20 +20,38 @@
20
20
  | 异常恢复 | Worker TTL、过期 owner 迁移和协调缓存清理 | ✅ 已实现 | 回收死 Worker、孤儿 subscriber 和无 subscriber 的过期路由 |
21
21
  | Transport | 重连后重放 owner Topic | ✅ 已实现 | 断开连接不清除业务 handler 和订阅意图 |
22
22
  | 降级 | localStorage 或 BroadcastChannel 不可用时本地运行 | ✅ 已实现 | 保留当前 Tab 的连接和订阅能力 |
23
+ | 降级 | BroadcastChannel 不可用时 opt-in 启用 localStorage storage-event 协调通道 | ✅ 已实现 | `createBrowserEnvironment({ channelFallback: 'storage-event' })`;协调载荷(明文 Topic 名称)会写入 localStorage,已在文档中标注权衡 |
23
24
  | Centrifuge | 内置 Dedicated / Shared Worker transport | ✅ 已实现 | 支持 subscribe、unsubscribe、publish、连接状态和错误上报;`auto` 从 SharedWorker → Dedicated Worker → 主线程 WebSocket 降级 |
24
25
  | 安全边界 | localStorage 使用连接和 Topic 派生不透明 key;BroadcastChannel 协调消息以明文传输 Topic 名称 | ✅ 已实现 | 不持久化 URL、原始 Topic 名称、凭证或 publication payload。BroadcastChannel 协调消息仅存在于内存中,以明文传输 Topic 名称——不会被持久化。 |
25
- | 诊断 | 聚合生命周期、吞吐量、分发延迟、去重结果、恢复重试、路由确认和迁移 | ✅ 已实现 | 默认关闭;默认每 5 秒输出指标,并以有界 reliability 事件记录恢复和路由协调 |
26
+ | 诊断 | 聚合生命周期、吞吐量、分发延迟、去重结果、恢复重试、路由确认和迁移 | ✅ 已实现 | 默认关闭;默认每 5 秒输出指标,并以有界 reliability 事件记录恢复和路由协调。按需 `getMetrics()` / `getDiagnostics().metrics` 无需 sink 即可同步暴露当前窗口 |
27
+ | 诊断 | `getHealthSummary()` 单对象健康判定与统一失败账本 | ✅ 已实现 | `healthy`/`state` 覆盖未启动、启动中、恢复中、BFCache 挂起与恢复耗尽降级;`lastFailure` 统一 transport/persistence/dispatch 来源,`start()` 时重置 |
26
28
  | 性能 | 协调元数据批量写入 + 退避重试 | ✅ 已实现 | 心跳、路由和 subscriber 写入合并后在微任务中 flush;失败时指数退避;`pagehide` / `stop()` 同步 flush |
27
29
  | 性能 | 可选 ArrayBuffer Transferable 传输 | ✅ 已实现 | 开启 `transferable: true` 后,二进制 publish/receive 跳过 structured clone 复制;对象消息 API 不变 |
30
+ | 性能 | 可选 `DataBusTransport.publishBatch` 单帧突发发布 | ✅ 已实现 | 内置 WebSocket transport 将多条消息合并为一帧(`publishBatch` op,demo server 已支持);无批量能力的 transport 回退逐条 `publish` |
28
31
  | 消息语义 | exactly-once 投递 | 未实现 | 正常交接会避免重叠,但异常恢复和 transport/服务端行为仍不提供 exactly-once 保证 |
29
32
  | 消息语义 | 可插拔的 publication 去重 | ✅ 已实现 | 按 `DataBusMessage.messageId` 做可选有界入站抑制;默认关闭,ID 仍由调用方/服务端控制 |
30
- | 认证 | Worker 内异步凭证刷新桥接 | 规划中 | 当前 Worker 配置必须可结构化克隆,不能传递函数 |
31
- | 负载策略 | 按消息速率、字节数或 CPU 自适应加权 | 规划中 | 当前负载仅按 owner Topic 数量计算 |
33
+ | 认证 | Worker 内异步凭证刷新桥接 | 已实现 | `createCentrifugeDataBus` 的可选 `credentialProvider`(`getToken` / `getChannelToken`)。Worker 配置保持结构化可克隆;Worker 通过 TOKEN_REQUEST / TOKEN_RESPONSE 交换向主线程请求每个新凭证,由 provider 从应用上下文提供 |
34
+ | 负载策略 | 按消息速率、字节数或调度滞后自适应加权 | 已实现 | 可选的 `loadWeighting`(`messageRateWeight`、`byteRateWeight`、`scheduleLagWeight`):Workers 按窗口采样自身 fan-out 流量与心跳调度超时并随记录发布;新 route 的 owner 选择在 Topic 数之上加入归一化速率与滞后比率。默认(未设置)保持纯按 Topic 数;已有 route 保持 sticky |
32
35
  | 可观测性 | owner 确认、迁移和恢复尝试的指标/事件 | ✅ 已实现 | `DataBusReliabilityTraceEvent` 记录有界的 route ack/migration 与 transport recovery;服务端最终确认仍由 transport 决定 |
33
36
  | 运行时模型 | SharedWorker / Dedicated Worker transport | ✅ 已实现 | `workerMode` 支持 `dedicated`、`shared` 和 `auto`,默认 `dedicated` |
34
37
  | 运行时模型 | Service Worker transport | 未实现 | 刻意延后;生命周期与长连接约束见 `docs/zh/architecture.md` |
35
38
  | 持久消息 | 跨页面关闭持久化 publication 或发布命令 | 未实现 | SDK 不持久化业务 payload,也不在恢复后重放发布命令 |
36
39
 
40
+ ## 已验证保证(由回归套件钉住)
41
+
42
+ 以下不变量统一收自 `docs/zh/architecture.md` 的「稳定性不变量」一节(由 `tests/stability.test.ts` 与 `tests/replay-persistence.test.ts` 固化的回归),保证每条保证既成文又被回归锁定:
43
+
44
+ | 不变量 | 领域 | 被回归固化的保证 |
45
+ |---|---|---|
46
+ | 交接 ACK 有效性 | 协调 | 仅当 route 仍指向接收方、释放方匹配 `handoffFromWorkerId`、且 ACK 代数 ≥ 存储 route 代数时才接受 `ROUTE_RELEASED`——更早交接轮次的过期 ACK(如 a↔b 乒乓)会被丢弃 |
47
+ | 回放持久化清理顺序 | 持久性 | 排队中的批量 flush 会按先到清理过滤(`unsubscribe`/`clearReplayTopic` 丢弃该 topic 的待写条目,`clearReplayBefore` 丢弃早于截止时间的条目);已清历史不会被进行中的 flush 重新追加 |
48
+ | 存储写入恢复 | 协调 | 合并写以指数退避重试(50 ms → 1.6 s 上限);结构性失败键在 5 次后丢弃(并 `console.warn`)而不阻塞其他排队键;队列清空或 `clear()` 取消后退避重置 |
49
+ | 传输恢复预算 | 生命周期 | 自动恢复由冷却间隔节流、以 `recovery.maxAttempts` 为界,预算耗尽时报 `exhausted`;成功重开后重置尝试计数与 exhausted,断线传输上的显式 `subscribe` 仍可手动恢复 |
50
+ | BFCache 挂起 | 生命周期 | 隐藏页面停掉传输并静默取消进行中的持久化重试;pageshow 重开传输并每个周期恰好一次重建订阅 |
51
+ | 交接通道关闭顺序 | 协调 | `pause()` 将物理 `channel.close()` 推迟一个任务,确保排队中的交接帧(含 `ROUTE_RELEASED`)先冲刷再关闭 |
52
+ | 丢失与恢复矩阵 | 协调 | 每条协调消息都有有界的恢复路径(未确认 SUBSCRIBE 的重发、REGISTRY 催促、丢失 ACK 的 TTL 清理 + 重新选举);传输断线窗口内丢失的发布是唯一有文档佐证的不恢复损失 |
53
+ | 恢复诊断 | 可观测性 | `getHealthSummary()` 得出单一就绪判定,统一的 `lastFailure` 账本跨传输失败持续保留,并在每次显式 `start()` 时重置 |
54
+
37
55
  ## 验收标准
38
56
 
39
57
  - "已实现"不代表每个浏览器环境都能提供跨 Tab 能力;缺少 localStorage 或 BroadcastChannel 时,按设计降级为本地运行。
@@ -12,6 +12,8 @@
12
12
  | `transport` | `DataBusTransport<TConfig, TData>` | 必填 | 真实连接和订阅实现 |
13
13
  | `initialConfig` | `TConfig` | 无 | 自动启动时传给 transport |
14
14
  | `autoStart` | `boolean` | 传入 `initialConfig` 时为 `true` | 是否在创建实例后自动启动 |
15
+ | `recovery.cooldownMs` | `number` | `1000` | 出错后自动重开 transport 前的最小延迟 |
16
+ | `recovery.maxAttempts` | `number` | `Infinity` | 每轮恢复序列的最大自动重开次数;显式订阅需求仍可继续重试 |
15
17
  | `storagePrefix` | `string` | `cross-tab-worker-databus` | storage key 和 BroadcastChannel 的命名空间 |
16
18
  | `maxActiveWorkers` | `number` | `3` | 可作为 Topic owner 的最大 Worker 数 |
17
19
  | `heartbeatIntervalMs` | `number` | `3000` | Worker 心跳间隔 |
@@ -54,6 +56,27 @@ const bus = new CrossTabDataBus({
54
56
 
55
57
  `replay.persistenceRetry` 可选地控制瞬时持久化失败的恢复。`maxAttempts` 是总尝试次数(默认 `1`),`backoffMs` 是首次重试前的延迟(默认 `50`);延迟会指数增长并封顶。最终失败仍沿用现有 `onError` 和 reliability 行为。
56
58
 
59
+ ### Replay 选项
60
+
61
+ | 配置 | 类型 | 默认值 | 说明 |
62
+ |---|---|---|---|
63
+ | `maxPerTopic` | `number` | `100` | 每个 topic 最多缓冲的 publication 数;超出时先淘汰最旧条目(正安全整数) |
64
+ | `persistence` | `DataBusReplayPersistence` | — | 可选持久化后端(`createIndexedDbReplayPersistence`);省略则历史仅存内存 |
65
+ | `retentionMs` | `number` | — | 生产者时间戳保留窗口;早于 cutoff 的历史通过适配器的 `clearBefore` 清理 |
66
+ | `pruneStrategy` | `'count' \| 'age' \| 'both'` | `'count'` | `count` 按 `maxPerTopic` 截断;`age` 按 `retentionMs` 清理;`both` 两者都应用。`age` 未配置 `retentionMs` 时无 age 可依,回退为数量上限 |
67
+ | `retentionSweepMs` | `number` | — | 面向安静 topic 的周期性 durable retention sweep;需要 `retentionMs` 与实现 `clearBefore` 的适配器 |
68
+ | `persistenceRetry` | `{ maxAttempts, backoffMs }` | `1` / `50` | 瞬时持久化失败的有界重试;延迟指数增长并封顶 |
69
+
70
+ ### 去重选项
71
+
72
+ | 配置 | 类型 | 默认值 | 说明 |
73
+ |---|---|---|---|
74
+ | `maxEntries` | `number` | `1000` | 记忆的 message ID 上限,超出时先淘汰最旧(FIFO)条目(正安全整数) |
75
+ | `ttlMs` | `number` | `60000` | 记忆 ID 抑制重复的时长(正有限值) |
76
+ | `sweepMs` | `number` | — | 可选的周期性清理,使安静 ID 过期;默认关闭 |
77
+ | `now` | `() => number` | `Date.now` | 可注入时钟,便于确定性 TTL 测试与非墙钟宿主 |
78
+ | `adaptiveTtl` | `{ minMs, maxMs }` | — | 可选有界自适应 TTL:近期消息速率高时窗口向 `minMs` 收紧,安静时放宽到 `maxMs`(两者必须同时提供,且 `minMs <= maxMs`) |
79
+
57
80
  启用 trace 后,每次在最终尝试之前发生的重试都会发出有界 `reliability` 事件:`operation: persistence_retry`,并包含 `persistenceOperation`(`load`、`append`、`clear`、`clearTopic` 或 `clearBefore`)和失败的尝试次数。不包含 payload、URL、凭证或错误正文。
58
81
 
59
82
  WebSocket 二进制帧可能以 `ArrayBuffer` 或浏览器 `Blob` 到达;两者使用相同的紧凑二进制 publication 格式。Blob 转换是异步的,转换失败会通过 transport error handler 报告。
@@ -98,6 +121,29 @@ active 集合只在 Topic 需要选择新 owner 时使用。已有 owner Worker
98
121
  - `2-3`:在资源复用和故障恢复之间取得平衡。
99
122
  - 更大值:适合 Topic 很多且单连接存在服务端限制的场景。
100
123
 
124
+ ### 自适应 owner 加权
125
+
126
+ 默认情况下新 Topic 分配给拥有最少 Topic 的 owner。可选的 `loadWeighting` 加入流量与调度信号,使新 route 偏向更空闲、更健康的 Worker——已有 route 保持 sticky,永不被迁移。
127
+
128
+ ```ts
129
+ const bus = createCentrifugeDataBus({
130
+ connection: { url: getConnectionUrl() },
131
+ loadWeighting: {
132
+ messageRateWeight: 0.5, // 每条消息/秒的权重
133
+ byteRateWeight: 0.001, // 每字节/秒的权重
134
+ scheduleLagWeight: 2 // 心跳调度滞后比率的权重
135
+ }
136
+ });
137
+ ```
138
+
139
+ | 选项 | 默认值 | 作用 |
140
+ |---|---|---|
141
+ | `messageRateWeight` | `0` | 每个 Worker 按心跳窗口采样自身的 fan-out 消息速率,归一化速率计入有效负载。 |
142
+ | `byteRateWeight` | `0` | 同上,针对近似 payload 字节数/秒。 |
143
+ | `scheduleLagWeight` | `0` | 加权调度滞后比率(`overrunMs ÷ windowMs`)。事件循环饥饿会让心跳延迟,从而引导新 route 远离被节流的 Worker。 |
144
+
145
+ 所有权重默认 `0`,保持纯按 Topic 数的 legacy 行为字节级不变。每个权重必须是非负有限数;负数或非有限值会在构造时抛出 `TypeError`,因为负权重会把新 route 导向**最繁忙**的 Worker。每个 Worker 的采样(`WorkerThroughputSample`)随心跳发布,含 `windowMs`、`messageCount`、`byteCount`、`overrunMs`、`sampledAt`。
146
+
101
147
  ## Centrifuge 配置
102
148
 
103
149
  `createCentrifugeDataBus<TData>(options)` 的主要配置:
@@ -112,6 +158,7 @@ active 集合只在 Topic 需要选择新 owner 时使用。已有 owner Worker
112
158
  | `heartbeatIntervalMs` | `number` | `10000` | SharedWorker PING 心跳间隔(见下方 SharedWorker 会话回收);传 `Infinity` 完全禁用心跳。与 Core 集群心跳(默认 3000 ms,通过 localStorage 跟踪 worker 存活)相互独立 |
113
159
  | `workerFactory` | `() => Worker` | 内置 Worker | 测试或自定义 Worker 加载方式 |
114
160
  | `sharedWorkerFactory` | `() => SharedWorker` | 内置 SharedWorker | 测试或自定义 SharedWorker 加载方式 |
161
+ | `credentialProvider` | `{ getToken?, getChannelToken? }` | `undefined` | 异步凭证刷新桥:Worker 向主线程请求每个新 token(`getToken` / `getChannelToken`),由该 provider 从应用上下文提供。必要原因:函数型 Centrifuge 选项无法 structured-clone 进 Worker |
115
162
  | 其他 Core 配置 | 对应类型 | Core 默认值 | `storagePrefix`、心跳、TTL 等 |
116
163
 
117
164
  ```ts
@@ -144,6 +191,12 @@ const bus = createCentrifugeDataBus({
144
191
 
145
192
  `sharedWorkerFactory` 和 `workerFactory` 都未提供时,transport 在启动时检测全局 `SharedWorker` / `Worker` 能力。提供自定义 factory 时,对应后端视为可用,避免在 Node、SSR 或嵌入环境中被全局能力检测误判。各模式都会执行相同的结构化克隆校验,配置和 `publish` 数据必须可结构化克隆。
146
193
 
194
+ ## 协调通道降级(BroadcastChannel 不可用)
195
+
196
+ 当 `BroadcastChannel` 不可用(部分 WebView、旧浏览器)时,集群通常降级为本地模式:transport 可用,但 Tab 之间不协调 owner。
197
+
198
+ `createBrowserEnvironment({ channelFallback: 'storage-event' })` 可选择启用基于 localStorage `storage` 事件的降级 `ClusterChannel`,保留跨 Tab 协调能力。该能力为 opt-in,原因是安全权衡:BroadcastChannel 消息仅存在于内存,而降级通道会把协调载荷(含明文 Topic 名称)写入 localStorage 的 `cross-tab-worker-databus:channel:` 键空间——至少短暂落盘,Tab 崩溃后可能长期留存。通道关闭时会清除该键。
199
+
147
200
  ## SharedWorker 会话回收
148
201
 
149
202
  `MessagePort` 没有 `close` 事件,因此 SharedWorker 无法在 Tab 崩溃或关闭时获知(除非收到 `STOP` 消息)。为避免泄漏已死 Tab 的 `CentrifugeSession`(及其 WebSocket),transport 定期向 SharedWorker 发送 **PING 心跳**,SharedWorker 运行一个**回收器**来关闭超过静默超时的端口会话。
@@ -168,3 +168,38 @@ const config = await loadTransportConfig();
168
168
  await bus.start(config);
169
169
  bus.subscribe('resource.changed', handleResourceEvent);
170
170
  ```
171
+
172
+ ## 10. 健康摘要与协调降级
173
+
174
+ 就绪探针与仪表盘可直接消费单对象健康判定,无需自行拼装诊断:
175
+
176
+ ```ts
177
+ const health = bus.getHealthSummary();
178
+ // { healthy: true, state: 'healthy', recovery: { attempt, … }, lastFailure: null, … }
179
+ ```
180
+
181
+ React 与 Vue 适配器提供 `useCrossTabHealth`,在状态变化、错误与轮询间隔时刷新。
182
+
183
+ 当 `BroadcastChannel` 不可用时,各 Tab 通常退化为本地模式。可选择启用基于 localStorage storage 事件的降级通道:
184
+
185
+ ```ts
186
+ import { createBrowserEnvironment } from 'cross-tab-worker-databus';
187
+
188
+ const bus = new CrossTabDataBus({
189
+ /* … */
190
+ environment: createBrowserEnvironment({ channelFallback: 'storage-event' })
191
+ });
192
+ ```
193
+
194
+ 该能力为 opt-in:启用后协调载荷(明文 Topic 名称)会写入 localStorage,详见 [configuration.md](./configuration.md#协调通道降级broadcastchannel-不可用)。
195
+
196
+ ## 11. 升级与弃用
197
+
198
+ 在 `1.0.0` 之前,SDK 允许增量新增,并在明确的弃用周期后才移除 API。根导出面由回归套件与 tag 间兼容性门禁钉住,意外删除会令 CI 失败,而不是静默破坏既有消费者。
199
+
200
+ 升级时请注意:
201
+
202
+ - 逐版阅读当前版本到目标版本之间每个版本的 CHANGELOG。每次移除都会在其中明确指出,且弃用周期只在小版本间顺延。
203
+ - 若运行时 `console.warn` 提到某弃用别名,请在下一个小版本前迁移——该别名在告警首次出现后至少再存活一个小版本。
204
+ - 混版本 Tab 仍可协调:集群在诊断中携带协议版本(`getDiagnostics().protocol`),协议变更后 legacy 消息帧至少再解析一个小版本。
205
+ - 1.0 前,请优先使用 [api.md](./api.md) 中的文档化入口与 [capabilities.md](./capabilities.md) 的能力矩阵。每次发布都会对 ESM 与 CommonJS 消费者做打包验证。