cross-tab-worker-databus 0.21.10 → 0.21.12

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.
@@ -75,7 +75,7 @@ graph TB
75
75
  | 术语 | 代码中的简称 | 通俗含义 |
76
76
  |---|---|---|
77
77
  | **Topic**(主题) | `topic` | 一个有名字的频道(如 `price.feed`),应用订阅它或向它发布消息。 |
78
- | **Topic key**(主题键) | `topicKey` | Topic 名称的 128-bit 不透明哈希。Topic 名称本身从不写入协调存储。 |
78
+ | **Topic key**(主题键) | `topicKey` | Topic 名称的 128-bit 不透明哈希。worker / route / subscriber 三类协调记录里都不出现 Topic 名称;会写明文的只有两个需显式开启的选项:`replay.persistence`(IndexedDB)与 `channelFallback: 'storage-event'`。 |
79
79
  | **Tab**(标签页) | `tabId` | 一个浏览器页面实例。`tabId` 在刷新后保持稳定,让标签页在页面生命周期内保留身份。 |
80
80
  | **Worker**(工作器) | `workerId` | Tab 内的一个运行时实例。每个 Worker 发布自己的心跳,也可以拥有 Topic。重启/交接时一个 Tab 可能短暂存在两个 Worker。 |
81
81
  | **Topic owner**(主题持有者) | — | 负责某个 Topic 真实 transport 订阅的 Worker。"owner"是 Worker 戴的一顶帽子,不是永久角色:它从服务器接收该 Topic 的 publication 并扇出给其他 Tab。 |
@@ -107,9 +107,13 @@ cross-tab-worker-databus:{clusterHash}:subscriber:{topicKey}:{tabId}
107
107
 
108
108
  ```ts
109
109
  interface WorkerRecord {
110
+ /** 该 Worker 声明的集群协议版本;legacy 对端不写这个字段。 */
111
+ protocolVersion?: number;
110
112
  workerId: string;
111
113
  tabId: string;
112
114
  load: number;
115
+ /** 滚动流量采样,仅在开启 `loadWeighting` 时出现。 */
116
+ throughput?: WorkerThroughputSample;
113
117
  role: 'active' | 'standby';
114
118
  status: 'connecting' | 'connected' | 'disconnected' | 'error';
115
119
  visibilityState: 'visible' | 'hidden';
@@ -136,7 +140,7 @@ interface WorkerRoute {
136
140
 
137
141
  `generation` 在每次重新分配时递增,交接握手必须匹配该值;`handoffFromWorkerId` 记录优雅迁移时的前任 owner。上面的接口与当前协议一致——这两个字段如何驱动接管见 [故障转移](#故障转移)。
138
142
 
139
- 路由不保存原始 Topic 字符串或 payload。真实 owner 收到 `CONTROL/SUBSCRIBE` 时,原始 Topic 字符串只通过 BroadcastChannel 内存消息传递。接收方只有在持久化 route 当前指向自己时才接受该控制帧;来自较早分配轮的迟到帧会被丢弃,等待 `ROUTE_RELEASED` 的交接也只能由精确匹配的 ACK 确认。`confirmedAt` 在 owner 处理控制消息后写入;在路由确认之前,持有原始 Topic 字符串的订阅方 Runtime 会重发 `SUBSCRIBE`,以从"有路由但无真实订阅"的 BroadcastChannel 消息丢失中恢复。
143
+ 路由不保存原始 Topic 字符串或 payload。真实 owner 收到 `CONTROL/SUBSCRIBE` 时,Topic 明文只经由协调通道到达它(BroadcastChannel 上存在于内存;`channelFallback: 'storage-event'` 下它本身就是被写入 localStorage 的那帧的一部分)。当持久路由指向**另一个** Worker 时接收方才丢弃该控制帧,因此较早分配轮的迟到帧不能让非 owner 完成订阅;等待 `ROUTE_RELEASED` 的交接也只能由精确匹配的 ACK 确认。读不到路由的 topic 是被接受而非丢弃——没有路由可盖章时 `confirmRoute` 什么都不写,所以这一帧凭空造不出持久所有权,下一次 reconcile 会收回这条 assignment;保留这个容忍是为了在存储不可用、或记录已过期时协调仍能工作。`confirmedAt` 是在接受该帧时就写入,**早于**把控制动作交给 transport,所以它记录的是"这个 Worker 认领了任务",而不是"服务端订阅已建立"——这也正是未确认路由要被重发的原因:它是从"有路由但无真实订阅"的 BroadcastChannel 消息丢失中恢复的手段。
140
144
 
141
145
  ### 接收侧的帧校验
142
146
 
@@ -148,7 +152,7 @@ interface WorkerRoute {
148
152
  | `topicKey === createOpaqueKey(topic)` | `CONTROL`(任意 action)与 `ROUTE_RELEASED` | 帧被忽略 |
149
153
  | 携带 `items` 的 `CONTROL/PUBLISH` 帧必须给出非空**数组** | `CONTROL/PUBLISH` | 帧被忽略 |
150
154
 
151
- 第二行是所有权的授权前提。`topicKey` 是 `topic` 的纯函数,所以本库构造的每个帧两者必然一致;不一致就是替换而非变体。由于所有权依据 `topicKey` 对应的持久路由判定,而传输层订阅的名字来自 `topic`,不校验就允许一帧借用某个频道的授权去命名另一个频道。持久路由记录只保存 `topicKey`、从不保存明文,因此 key → 明文的映射只存在于内存中,无法通过 localStorage 注入。
155
+ 第二行是所有权的授权前提。`topicKey` 是 `topic` 的纯函数,所以本库构造的每个帧两者必然一致;不一致就是替换而非变体。由于所有权依据 `topicKey` 对应的持久路由判定,而传输层订阅的名字来自 `topic`,不校验就允许一帧借用某个频道的授权去命名另一个频道。持久路由记录只保存 `topicKey`、从不保存明文,因此 key → 明文的映射只存在于内存中,无法通过协调记录注入。storage-event 降级通道是唯一一帧"经由 localStorage 到达"的路径;配对规则的作用不变——其他租户写进这块存储的帧,和直接 post 进通道一样无效。
152
156
 
153
157
  第三行存在是因为批量路径会迭代负载:只有 `length` 而没有迭代器的值会从消息监听器里抛出异常;可迭代的非数组(字符串)会被当成多个单字符条目,其 `data` 为 `undefined`,随后 owner 会以自己的会话把它们发布出去。
154
158
 
@@ -167,6 +171,7 @@ interface WorkerRoute {
167
171
  | `tabId` | 一个浏览器 Tab 的稳定身份 | 标识哪个 Tab 订阅了某个 `topicKey` | 是,体现在 subscriber key 中 |
168
172
  | `workerId` | 当前 Runtime/Worker 实例身份 | 标识哪个 Worker 负责实际 transport 订阅 | 是,体现在 worker/route 记录中 |
169
173
  | `BroadcastChannel` | 同源 Tab 间的实时内存通道 | 传递控制动作、publication 事件和重协调通知 | 否,不持久化消息 |
174
+ | storage-event 通道 | 建立在 localStorage `storage` 事件上的同一套 channel 接口,仅在设置了 `channelFallback: 'storage-event'` 且 BroadcastChannel 不可用时启用 | 传递与上面完全相同的帧 | **是**——每帧整体写到 `cross-tab-worker-databus:channel:*` 之下,因此明文 Topic 与 `PUBLISH` payload 会保留到该 channel 关闭(tab 先退出则无限期保留) |
170
175
 
171
176
  它们通过以下 key 和消息字段关联:
172
177
 
@@ -185,7 +190,7 @@ BroadcastChannel CONTROL
185
190
 
186
191
  ### 内存 Topic key 缓存 (`knownTopics`)
187
192
 
188
- 每个 Runtime 维护一个 `Map<topicKey, topic>` 称为 `knownTopics`,作为不透明 key 到原始 topic 的反向查找缓存。它由 `rememberTopic()` 填充,`subscribe`、`publish`、`unsubscribe` 和入站 `CONTROL` 消息都会调用它。
193
+ 每个 Runtime 维护一个 `Map<topicKey, topic>` 称为 `knownTopics`,作为不透明 key 到原始 topic 的反向查找缓存。它由 `rememberTopic()` 填充:`subscribe`、`publish`、`unsubscribe` 都会调用它,入站 `CONTROL` 消息则只在通过 `topicKey` 配对校验后才走到这一步——一对不相符的帧在读到明文之前就被丢弃,因此无效配对无法撑大这张表。合法调用仍然可以(同源脚本能通过页面上自己的 bus 触达这些 API),约束它们的正是下面的上限。
189
194
 
190
195
  该缓存存在两个原因:
191
196
 
@@ -277,7 +282,7 @@ console.table(__bus.getClusterSnapshot().routes)
277
282
  | 单个大 JSON 存放 Worker/路由/subscriber | 高 | 只能整体读写 | 多个 Tab 并发读-改-写容易互相覆盖,丢失 subscriber |
278
283
  | 每个实体独立 key | 低 | 可精确按 Worker、路由、Topic+Tab 清理 | key 更多,需要基于 TTL 的垃圾回收 |
279
284
 
280
- SDK 不依赖 `storage` 事件驱动协调,控制通知使用 BroadcastChannel。虽然 Worker 心跳会更新自己的独立 key,但这不会在 SDK 内部触发重复的业务回调或消息分发。独立 key 的核心好处是不同 Tab 写不同的记录,避免对共享大对象产生覆盖竞争。
285
+ 默认情况下 SDK 不依赖 `storage` 事件驱动协调,控制通知使用 BroadcastChannel。这是默认值而非保证:`channelFallback: 'storage-event'` 选的正是一条完全建立在 `storage` 事件上的通道,那也是协调通知唯一经由这种事件到达的配置。虽然 Worker 心跳会更新自己的独立 key,但这不会在 SDK 内部触发重复的业务回调或消息分发。独立 key 的核心好处是不同 Tab 写不同的记录,避免对共享大对象产生覆盖竞争。
281
286
 
282
287
  正常条件下 key 数量约为:`Worker 数量 + Topic 路由数量 + Topic/Tab 订阅关系数量`。Runtime 会清理超时的 Worker、无活跃 Tab 的孤儿 subscriber,以及超过 Worker TTL 且不再有 subscriber 的孤儿路由。旧版本遗留的其他命名结构不属于当前 SDK 协议,不参与当前路由解析。
283
288
 
@@ -323,7 +328,7 @@ SDK 不依赖 `storage` 事件驱动协调,控制通知使用 BroadcastChannel
323
328
 
324
329
  ## BroadcastChannel 通信协议
325
330
 
326
- 所有实时协调都经由每个集群唯一的一条 BroadcastChannel(名称由 `clusterKey` 派生)传递。通道上的消息只存在于内存:不写入 localStorage,也不经过 transport 服务器。共四类消息:
331
+ 所有实时协调都经由每个集群唯一的一条通道传递(名称由 `clusterKey` 派生)。BroadcastChannel——未设置 `channelFallback` 时的实现——其消息只存在于内存:不写入 localStorage。storage-event 降级通道是构造上的例外:它本身就是 localStorage,所以帧在 key 存续期间一直留在盘上。两种实现都不经过 transport 服务器。共四类消息:
327
332
 
328
333
  | 类型 | 方向 | 用途 |
329
334
  |---|---|---|
@@ -467,7 +472,7 @@ sequenceDiagram
467
472
 
468
473
  BroadcastChannel 不会把消息回传给发送者,因此 owner 收不到自己广播的 `EVENT`——本地分发是唯一一次本地投递。
469
474
 
470
- publication 不写入 localStorage。消息数据与 publication 元数据只存在于 BroadcastChannel 内存事件和 transport 内;批量写入只覆盖协调元数据。
475
+ 协调层与 transport 都不会把 publication 写入 localStorage;消息数据与 publication 元数据只存在于 BroadcastChannel 内存事件和 transport 内,批量写入只覆盖协调元数据。两个需要显式开启的选项确实会把 payload 放进浏览器存储,并且各自在文档里点名:`channelFallback: 'storage-event'` 通道把它承载的每一帧(含 `PUBLISH` payload)写到自己的 channel key 下;`replay.persistence` 则把 publication 本体存进 IndexedDB,并以明文 Topic 作为 key。
471
476
 
472
477
  ### Service Worker 边界
473
478
 
@@ -541,7 +546,7 @@ Transport 消息 → isAssigned(topic)? → 是 → broadcastEvent(EVENT)
541
546
  - **最后一个订阅者的释放。** owner Worker 通过最后一个订阅者发来的 `CONTROL/UNSUBSCRIBE` 得知某 Topic 已无人订阅:它会同时丢弃本地 ownership 并把释放动作派发给自己的 transport。若只在交接短路里消费该消息,server 订阅(以及它的入站扇出)会在所有 Tab 离开后依然存活。
542
547
  - **归属漂移的修复。** 每一轮 reconcile 都会丢弃那些 `assignedTopics` 里仍持有、但持久路由已不再指向本 Worker 的 Topic,并顺带释放对应的 transport 订阅。于是 ownership 跟随存储记录,而不是最后到达的那条消息:陈旧的 `CONTROL/SUBSCRIBE`、丢失的路由写入、或某个抢到了不属于自己的路由的 Tab,都会在一个 heartbeat 内收敛回“恰好一个 owner”。`tests/coordination-invariants.test.ts` 会针对三个 Tab 随机制造这些破坏并断言静默后的结果(由回归测试固定:把这一轮清扫掏空,模糊测试的终态就会失败)。
543
548
  - **悬挂交接恢复。** 若前任 owner 已消失而其 `ROUTE_RELEASED` 始终未到达(高负载下通道消息丢失,或 route 写入与 ACK 发送之间崩溃),reconcile 循环会在该未确认交接悬挂超过一个 worker TTL(默认 10 秒)后重新选举存活 owner:路由以全新 generation 重写并清除交接标记,使常规确认路径得以完成(已有回归固化)。年龄门限很关键——刚写入的未确认路由可能只是在等确认落盘,不能误判为悬挂;而只要前任 owner 仍然存活,新 owner 会继续等待,因此严格交接的无重叠保证不受影响。
544
- - **丢失与恢复矩阵。** 每类协调消息都有有界恢复路径:丢失的 `CONTROL/SUBSCRIBE` 由心跳 reconcile 对未确认路由重发;丢失的 `REGISTRY` 通知最多损失一个心跳间隔(默认 3 秒),因为每次 tick 都会 reconcile;丢失的 `ROUTE_RELEASED` 由 reconcile 在前任 owner 消失且交接悬挂超过一个 worker TTL 后重新选举恢复(见上文悬挂交接不变量,已有回归固化);transport 断连窗口内被丢弃的 publication 是唯一文档化的不可恢复丢失(transport 契约)。storage-event 降级通道通过信封内的单调序列号保证变值投递,丢失的派发由同一 reconcile 循环恢复。
549
+ - **丢失与恢复矩阵。** 每类协调消息都有有界恢复路径:丢失的 `CONTROL/SUBSCRIBE` 由心跳 reconcile 对未确认路由重发;丢失的 `REGISTRY` 通知最多损失一个心跳间隔(默认 3 秒),因为每次 tick 都会 reconcile;丢失的 `ROUTE_RELEASED` 由 reconcile 在前任 owner 消失且交接悬挂超过一个 worker TTL 后重新选举恢复(见上文悬挂交接不变量,已有回归固化);transport 断连窗口内被丢弃的 publication 是唯一文档化的不可恢复丢失(transport 契约)。storage-event 降级通道让每次写入都是一个不同的值——信封里同时带一个**每通道 sender nonce** 和一个单调递增的序列号——因为浏览器在 `setItem` 没有改变存储值时会抑制 `storage` 事件,而两个 Tab 各自的第一帧同样都是 `seq=1`,把它们区分开靠的是那个 nonce;丢失的派发仍由同一 reconcile 循环恢复。
545
550
  - **恢复诊断。** `getHealthSummary()` 从生命周期标志推导单一就绪判定(`stopped` / `starting` / `healthy` / `recovering` / `suspended` / `degraded`);统一的 `lastFailure` 账本与持久化计数在每次显式 `start()` 后重置。
546
551
 
547
552
  ## Transport 重连
@@ -22,7 +22,7 @@
22
22
  | 降级 | localStorage 或 BroadcastChannel 不可用时本地运行 | ✅ 已实现 | 保留当前 Tab 的连接和订阅能力 |
23
23
  | 降级 | BroadcastChannel 不可用时 opt-in 启用 localStorage storage-event 协调通道 | ✅ 已实现 | `createBrowserEnvironment({ channelFallback: 'storage-event' })`;协调载荷(明文 Topic 名称)会写入 localStorage,已在文档中标注权衡 |
24
24
  | Centrifuge | 内置 Dedicated / Shared Worker transport | ✅ 已实现 | 支持 subscribe、unsubscribe、publish、连接状态和错误上报;`auto` 从 SharedWorker → Dedicated Worker → 主线程 WebSocket 降级 |
25
- | 安全边界 | localStorage 使用连接和 Topic 派生不透明 key;BroadcastChannel 协调消息以明文传输 Topic 名称 | ✅ 已实现 | 不持久化 URL、原始 Topic 名称、凭证或 publication payload。BroadcastChannel 协调消息仅存在于内存中,以明文传输 Topic 名称——不会被持久化。 |
25
+ | 安全边界 | localStorage 使用连接和 Topic 派生不透明 key;BroadcastChannel 协调消息以明文传输 Topic 名称 | ✅ 已实现 | 协调记录本身不持久化 URL、凭证、原始 Topic 名称或 publication payload;BroadcastChannel 协调消息仅存在于内存中(带明文 Topic 名称,但从不落盘)。两个需要显式开启的选项会写入默认路径不写的数据,且都是文档化的取舍:`replay.persistence`(IndexedDB)以明文 Topic 作为对象仓的 key,并把 publication payload 存进去;`channelFallback: 'storage-event'` 会把每一帧协调消息——明文 Topic 名称,以及 `PUBLISH` 帧的 payload——写到 localStorage 的 channel key 下,直到该 channel 关闭。 |
26
26
  | 诊断 | 聚合生命周期、吞吐量、分发延迟、去重结果、恢复重试、路由确认和迁移 | ✅ 已实现 | 默认关闭;默认每 5 秒输出指标,并以有界 reliability 事件记录恢复和路由协调。按需 `getMetrics()` / `getDiagnostics().metrics` 无需 sink 即可同步暴露当前窗口 |
27
27
  | 诊断 | `getHealthSummary()` 单对象健康判定与统一失败账本 | ✅ 已实现 | `healthy`/`state` 覆盖未启动、启动中、恢复中、BFCache 挂起与恢复耗尽降级;`lastFailure` 统一 transport/persistence/dispatch 来源,`start()` 时重置 |
28
28
  | 性能 | 协调元数据批量写入 + 退避重试 | ✅ 已实现 | 心跳、路由和 subscriber 写入合并后在微任务中 flush;失败时指数退避;`pagehide` / `stop()` 同步 flush |
@@ -35,7 +35,8 @@
35
35
  | 可观测性 | owner 确认、迁移和恢复尝试的指标/事件 | ✅ 已实现 | `DataBusReliabilityTraceEvent` 记录有界的 route ack/migration 与 transport recovery;服务端最终确认仍由 transport 决定 |
36
36
  | 运行时模型 | SharedWorker / Dedicated Worker transport | ✅ 已实现 | `workerMode` 支持 `dedicated`、`shared` 和 `auto`,默认 `dedicated` |
37
37
  | 运行时模型 | Service Worker transport | 未实现 | 刻意延后;生命周期与长连接约束见 `docs/zh/architecture.md` |
38
- | 持久消息 | 跨页面关闭持久化 publication 或发布命令 | 未实现 | SDK 不持久化业务 payload,也不在恢复后重放发布命令 |
38
+ | 持久消息 | 向稍后加载的 Tab 回放已发布的 payload | ✅ 已实现 | 需要显式开启:`createIndexedDbReplayPersistence` 保存 publication 自身——包含业务 payload,并以明文 Topic 作为对象仓的 key——下次加载时重新水合,因此历史可以跨越 reload 或页面关闭。这是给订阅方的入站回放,不是投递保证 |
39
+ | 持久消息 | 页面关闭后重发外发发布命令 | 未实现 | 发布命令不会被持久化;已经被丢弃的 publication(transport 断连窗口,或 local-mode 降级)是唯一文档化的不可恢复丢失 |
39
40
 
40
41
  ## 已验证保证(由回归套件钉住)
41
42
 
@@ -43,7 +44,7 @@
43
44
 
44
45
  | 不变量 | 领域 | 被回归固化的保证 |
45
46
  |---|---|---|
46
- | SUBSCRIBE 路由绑定 | 协调 | 仅当持久化 route 当前指向接收方、且不在等待 `ROUTE_RELEASED` 时才执行入站 `CONTROL/SUBSCRIBE`;较早分配轮的迟到帧不能创建 ownership、订阅 transport 或确认悬挂交接 |
47
+ | SUBSCRIBE 路由绑定 | 协调 | 当持久化 route 指向另一个 Worker 时,入站 `CONTROL/SUBSCRIBE` 会被丢弃;等待 `ROUTE_RELEASED` 的 route 只被匹配的交接 ACK 确认——因此较早分配轮的迟到帧不能创建持久 ownership、订阅 transport 或确认悬挂交接。topic 读不到 route 时该帧是被接受而非丢弃,而这个容忍是有界的:没有 route 可盖章时 `confirmRoute` 什么都不写,下一次 reconcile 会收回这条 assignment。它换来的是存储不可用、或记录已过期时协调仍能工作 |
47
48
  | 交接 ACK 有效性 | 协调 | 仅当 route 仍指向接收方、释放方匹配 `handoffFromWorkerId`、且 ACK 代数与存储 route 代数精确相等时才接受 `ROUTE_RELEASED`——其他交接轮次的过期 ACK(如 a↔b 乒乓)会被丢弃 |
48
49
  | 回放持久化清理顺序 | 持久性 | 排队中的批量 flush 会按先到清理过滤(`unsubscribe`/`clearReplayTopic` 丢弃该 topic 的待写条目,`clearReplayBefore` 丢弃早于截止时间的条目,`suspend()`/`stop()` 丢弃整个待写批次);已清或已停止会话的历史不会被进行中的 flush 重新追加 |
49
50
  | 存储写入恢复 | 协调 | 合并写以指数退避重试(50 ms → 1.6 s 上限);结构性失败键在 5 次后丢弃(并 `console.warn`)而不阻塞其他排队键;队列清空或 `clear()` 取消后退避重置 |
@@ -54,7 +54,7 @@ const bus = new CrossTabDataBus({
54
54
 
55
55
  `replay.retentionSweepMs` 可选地按周期触发同一清理逻辑。它适合安静 topic 的 durable 旧记录也需要过期的场景;需要同时配置 `retentionMs` 和实现 `clearBefore()` 的持久化适配器。定时器遵循页面可见性和生命周期切换,默认关闭。
56
56
 
57
- `replay.persistenceRetry` 可选地控制瞬时持久化失败的恢复。`maxAttempts` 是总尝试次数(默认 `1`),`backoffMs` 是首次重试前的延迟(默认 `50`);延迟会指数增长并封顶。最终失败仍沿用现有 `onError` 和 reliability 行为。bus 挂起或停止时仍排队在微任务中的批量 flush 会随其生命周期代际一起丢弃,不会在 teardown 后启动 durable append。
57
+ `replay.persistenceRetry` 可选地控制瞬时持久化失败的恢复。`maxAttempts` 是总尝试次数(默认 `1`),`backoffMs` 是首次重试前的延迟(默认 `50`);封顶只作用在增长上:每次尝试失败后延迟翻倍,翻倍后的值封顶在 1600 ms;`backoffMs` 作为首次延迟按配置值原样等待,所以 `backoffMs: 5000` 会先等 5000 ms、之后才回落到 1600 ms,而不是被压到封顶值。最终失败仍沿用现有 `onError` 和 reliability 行为。bus 挂起或停止时仍排队在微任务中的批量 flush 会随其生命周期代际一起丢弃,不会在 teardown 后启动 durable append。
58
58
 
59
59
  ### Replay 选项
60
60
 
@@ -65,7 +65,7 @@ const bus = new CrossTabDataBus({
65
65
  | `retentionMs` | `number` | — | 生产者时间戳保留窗口;早于 cutoff 的历史通过适配器的 `clearBefore` 清理 |
66
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
- | `persistenceRetry` | `{ maxAttempts, backoffMs }` | `1` / `50` | 瞬时持久化失败的有界重试;延迟指数增长并封顶 |
68
+ | `persistenceRetry` | `{ maxAttempts, backoffMs }` | `1` / `50` | 瞬时持久化失败的有界重试;每次失败后延迟翻倍,翻倍值封顶 1600 ms,`backoffMs` 首次按原值等待 |
69
69
 
70
70
  ### 去重选项
71
71
 
@@ -14,7 +14,8 @@ pnpm add cross-tab-worker-databus
14
14
  - `cross-tab-worker-databus/centrifuge`:内置 Centrifuge Worker transport
15
15
  - `cross-tab-worker-databus/centrifuge.worker`:Dedicated Worker 构建产物,默认由内置 factory 加载,通常无需直接引用
16
16
  - `cross-tab-worker-databus/centrifuge.shared.worker`:SharedWorker 构建产物,默认由内置 factory 加载,通常无需直接引用
17
- - `cross-tab-worker-databus/hooks`:可选的 React hooks 适配层(`useCrossTabDataBus`、`useCrossTabSubscription`、`useCrossTabStatus`);React(>= 18)为可选 peer 依赖
17
+ - `cross-tab-worker-databus/hooks`:可选的 React hooks 适配层(`useCrossTabDataBus`、`useCrossTabSubscription`、`useCrossTabStatus`、`useCrossTabHealth`);React(>= 18)为可选 peer 依赖
18
+ - `cross-tab-worker-databus/vue`:可选的 Vue 组合式 API,四个名字相同(`useCrossTabDataBus`、`useCrossTabSubscription`、`useCrossTabStatus`、`useCrossTabHealth`);Vue(>= 3.3)为可选 peer 依赖
18
19
  - `cross-tab-worker-databus` 同时导出零依赖的原生 `WebSocketTransport` / `createWebSocketDataBus`,适用于本身使用 WebSocket 的服务器
19
20
 
20
21
  `cross-tab-worker-databus/centrifuge` 入口依赖可选 peer dependency `centrifuge`(^5.5.3)。使用内置 Centrifuge transport 时请一并安装:`pnpm add centrifuge`。
@@ -46,7 +47,7 @@ export const dataBus = createCentrifugeDataBus<ResourceEvent>({
46
47
 
47
48
  `clusterKey` 默认由连接地址派生。它只用于集群隔离,进入 localStorage 和 BroadcastChannel 通道名称前会转换为不透明 key。请注意,通过 BroadcastChannel 协调通道发送的 Topic 名称和事件类型以明文传输;仅 localStorage 元数据通过哈希进行混淆。
48
49
 
49
- 默认使用 Dedicated Worker,每个 Tab 一个 Worker。希望同源 Tab 复用同一个连接时,设置 `workerMode: 'shared'` 或 `'auto'`:
50
+ 默认使用 Dedicated Worker,每个 Tab 一个 Worker。`workerMode: 'shared'`(或先尝试 shared 的 `'auto'`)会把同源 Tab 放进**同一个** SharedWorker 进程——但不会合并它们的连接:每个连接的 port 都有自己的 `CentrifugeSession`,因此也有自己的 WebSocket,这正是关闭或刷新一个 Tab 不影响其余 Tab 的原因——N 个 Tab 仍是 N 条服务端连接、N 个 channel 订阅。shared 模式省下的是 Worker 进程,不是 socket:
50
51
 
51
52
  ```ts
52
53
  export const dataBus = createCentrifugeDataBus<ResourceEvent>({
@@ -144,7 +145,7 @@ pnpm examples
144
145
 
145
146
  打开 `http://localhost:4173/examples/demo/`,在多个浏览器标签页中同时打开即可观察跨 Tab 数据流转。页面支持:
146
147
 
147
- - 默认连接公共 Centrifugo 演示地址 `wss://faye.centrifugal.dev/connection/websocket`
148
+ - 默认连接演示页自带的本地 Centrifugo 端点(`<scheme>://<host>/centrifuge/demo/connection/websocket`,页面加载时即写入地址框);公共地址 `wss://faye.centrifugal.dev/connection/websocket` 只是可选预设之一,不是默认值
148
149
  - 在页面内修改 WSS 地址、`workerMode`、Topic 和 `transferable` 配置
149
150
  - 切换到"本地广播"模式,不依赖外部服务器,仅用 BroadcastChannel 演示多标签协同
150
151
  - 数据流动画、事件流、分发延迟指标和集群 Worker 路由状态
@@ -11,9 +11,9 @@
11
11
 
12
12
  ## 自动化门禁(CI)
13
13
 
14
- `CI` 工作流的 `verify` job 在每次 push 与 pull request 上运行 `pnpm check`、`pnpm lint`、`pnpm test:coverage`(`vitest.config.ts` 中的下限:语句 98% / 分支 96% / 函数 98% / 行 99%)、`pnpm verify:compat`、`pnpm verify:pack`、`pnpm bench` 与 `pnpm audit`;`browser` job 运行 Playwright E2E 套件。`Release` 工作流在发布前重跑 `verify:compat` 与 `verify:pack`,随后执行阻塞式 `verify:published` 门禁。两个工作流的 checkout 均使用 `fetch-depth: 0` + `fetch-tags: true`,因为 `verify:compat` 需要从最近的发布 tag 解析基线。
14
+ `CI` 工作流的 `verify` job 在**以 `main` 为目标分支**的 push 与 pull request 上运行 `pnpm check`、`pnpm lint`、`pnpm test:coverage`(`vitest.config.ts` 中的下限:语句 98% / 分支 96% / 函数 98% / 行 99%)、`pnpm verify:compat`、`pnpm verify:pack`、`pnpm bench` 与 `pnpm audit`——`ci.yml` 里两个触发器都被过滤为 `branches: [main]`,因此推送 topic 分支什么都不会跑,这项工作要到开出 PR 时才第一次进入 CI(base 不是 main 的 PR 同样不在覆盖范围内);`browser` job 运行 Playwright E2E 套件。`Release` 工作流在发布前重跑 `verify:compat` 与 `verify:pack`,随后执行阻塞式 `verify:published` 门禁。两个工作流的 checkout 均使用 `fetch-depth: 0` + `fetch-tags: true`,因为 `verify:compat` 需要从最近的发布 tag 解析基线。
15
15
 
16
- 只有 `pnpm bench:browser` / `pnpm bench:compare` 保持仅本地执行:共享 runner 的计时噪声会让数值型 CI 门禁不可靠。
16
+ 只有 `pnpm bench:browser` / `pnpm bench:compare` / `pnpm bench:trend` 保持仅本地执行:共享 runner 的计时噪声会让数值型 CI 门禁不可靠,而 `bench:trend` 会依据本地归档重写 `docs/benchmarks.md`,这本就不该由 CI 来做。
17
17
 
18
18
  ## 打 tag 前
19
19
 
@@ -30,7 +30,7 @@
30
30
 
31
31
  ## 打 tag 的发布工作流
32
32
 
33
- 推送版本 tag 会触发 `Release` GitHub Action:先跑 `pnpm check` 与 `pnpm lint`(tag 可能指向从未通过 CI lint 步骤的提交),再跑 `verify:compat` 与 `verify:pack`,从 `CHANGELOG` 对应章节生成 GitHub release,配置了 `NPM_TOKEN` 时自动发布到 npm,然后运行与手动执行相同预算的**阻塞式**消费者验证(`PUBLISHED_VERIFY_ATTEMPTS=48`、`PUBLISHED_VERIFY_DELAY_MS=7500`,即 6 分钟上限)。已发布包若无法被干净消费者导入,工作流即失败——任何 `verify:published` 失败都应视为发布失败。若为 registry 传播延迟或基础设施故障,针对不变的 tag 重跑工作流;若为产物缺陷,发布新的 patch 版本。禁止移动或重用已发布 tag。未配置 token 时跳过发布步骤,但验证仍会针对 npm 上已有的版本(例如手动发布的)通过。
33
+ 推送版本 tag 会触发 `Release` GitHub Action:先跑 `pnpm check` 与 `pnpm lint`(tag 可能指向从未通过 CI lint 步骤的提交),再跑 `verify:compat` 与 `verify:pack`,从 `CHANGELOG` 对应章节生成 GitHub release,配置了 `NPM_TOKEN` 时自动发布到 npm,然后运行**阻塞式**消费者验证,其预算是手动默认值的十二倍——工作流在自己的 env 里设置 `PUBLISHED_VERIFY_ATTEMPTS=48`、`PUBLISHED_VERIFY_DELAY_MS=7500`(约 6 分钟上限),而手动运行 `scripts/verify-published-consumer.mjs` 时的默认值是 6 × 5000 ms = 30 秒。已发布包若无法被干净消费者导入,工作流即失败——任何 `verify:published` 失败都应视为发布失败。若为 registry 传播延迟或基础设施故障,针对不变的 tag 重跑工作流;若为产物缺陷,发布新的 patch 版本。禁止移动或重用已发布 tag。未配置 token 时跳过发布步骤,但验证仍会针对 npm 上已有的版本(例如手动发布的)通过。
34
34
 
35
35
  ## 发布(手动场景)
36
36
 
@@ -42,4 +42,4 @@
42
42
  2. 在干净消费者中安装已发布版本或 tarball,并导入主入口及所有公开子路径。
43
43
  3. 将结果记录到发布说明。在公开 API 和协议弃用策略明确冻结前,不进入 `1.0.0`。
44
44
 
45
- 打 tag 的发布工作流已自动执行上述消费者验证;仅在需要离线复验时才手动运行 `PUBLISHED_VERSION=<version> pnpm verify:published`。工作流默认的 6 分钟上限足以吸收 npm CDN 的正常传播延迟(0.20.89 tag 首次运行在发布成功后排空了旧的 2 分钟预算);只有遇到异常慢的镜像才需要继续调大 `PUBLISHED_VERIFY_ATTEMPTS` 和 `PUBLISHED_VERIFY_DELAY_MS`。
45
+ 打 tag 的发布工作流已自动执行上述消费者验证;仅在需要离线复验时才手动运行 `PUBLISHED_VERSION=<version> pnpm verify:published`。工作流默认的 6 分钟上限足以吸收 npm CDN 的正常传播延迟(0.20.89 tag 首次运行在发布成功后排空了旧的 2 分钟预算);只有遇到异常慢的镜像才需要继续调大 `PUBLISHED_VERIFY_ATTEMPTS` 和 `PUBLISHED_VERIFY_DELAY_MS`。手动复验时要注意这个不对称:`verify-published-consumer.mjs` 的默认值是 `6` 次 × `5_000` ms——30 秒而不是 6 分钟——因为更大的那一对是由 tag 工作流以 env 传入、而非脚本自带。于是手动复跑可能在一个 CI 全绿的版本上报出 `ETARGET`,这看起来像发布失败而实际不是;要对齐 CI 预算就显式执行 `PUBLISHED_VERSION=<version> PUBLISHED_VERIFY_ATTEMPTS=48 PUBLISHED_VERIFY_DELAY_MS=7500 pnpm verify:published`,否则请按你实际跑到的 30 秒预算来判断手动失败。*绿色*的 tag 运行里出现约 39/48 次重试是预期内的:那是 registry 传播,不是症状。
@@ -1,6 +1,48 @@
1
1
  # 路线图
2
2
 
3
- 0.21.10 已于 2026 年 9 月 23 日发布。项目会先持续完成可靠性发布,再进入 1.0.0 稳定性冻结。
3
+ 0.21.12 已于 2026 年 9 月 23 日发布。项目会先持续完成可靠性发布,再进入 1.0.0 稳定性冻结。
4
+
5
+ ## 0.21.12 已完成范围
6
+
7
+ - 文案审计回到了它的起点:README。`README.md` 与 `README.zh.md` 承诺"持久层不存储连接地址、原始
8
+ Topic 文本和消息内容",而这句话的两条之上正是在宣传那份 durable 回放持久化——它的 IndexedDB 对象仓以
9
+ `keyPath: 'topic'` 创建,行里保存着 publication payload,而工厂函数是公开导出。该说法现在被收敛到它确实
10
+ 成立的范围(协调记录),并把两个会写入真实内容的可选开关点名列出。中文特性列表还补上了它此前完全缺失的两条
11
+ feature 条目,于是不再出现"只有隐私承诺、没有对应披露"的读法。
12
+ - 随包分发的 `docs/release-checklist.md` 里有三处会在关键时刻误导维护者。发布后消费者验证门禁被描述为"与手动
13
+ 执行相同预算"——实际上工作流以自身 env 传入 48 × 7500 ms,而脚本默认是 6 × 5000 ms,于是手动复跑会在一个 CI
14
+ 全绿的 tag 上 30 秒后报 `ETARGET`。以 `v0.21.11` 实测:成功的那次运行重试了 48 次中的 39 次,而离线复验首次即
15
+ 通过。现在两个预算都已写明,并给出复现 CI 预算的命令。CI 被写成"在每次 push 与 pull request 上运行",而两个
16
+ 触发器都被过滤为 `branches: [main]`;`bench:trend` 也从"仅本地"清单里漏掉了,尽管它会依据本地归档重写
17
+ `docs/benchmarks.md`。
18
+ - `ci.yml` 中记录覆盖率下限的注释在 0.21.7 把下限提升到 98/96/98/99 之后仍写着 96/92/96/97;配置、随包的分发
19
+ 清单与 `AGENTS.md` 早已一致,只有注释发生了漂移,现已修正。`AGENTS.md` 同时记录了这轮扫描的结论:审计"选项
20
+ 面"覆盖的是表格,而同一标题下的段落与 README 的摘要条目是彼此独立的说法——最值得优先搜寻的句式是关于存储的绝对
21
+ 表述,因为一个可选开关就足以把它击穿。
22
+ - 无行为、wire 格式、存储布局或集群协议变更,且本发布未触碰任何 `src/` 或 `tests/` 文件。覆盖率保持
23
+ 99.01 / 96.90 / 99.26 / 99.69,共 **895** 个测试。
24
+ ## 0.21.11 已完成范围
25
+
26
+ - 一轮针对此前从未被扫过的四个文档文件(`architecture.md`、`capabilities.md`、`getting-started.md`、
27
+ `transports.md`,以及它们共同依赖的 `api.md`)的文案审计,找出约十五句代码并不支持的表述,中英文一并修正。
28
+ 最要紧的都是"从不持久化"这一类。`architecture.md` 用六句话承诺协调流量绝不写入 localStorage,而
29
+ `channelFallback: 'storage-event'` 在构造上就与之相反:信封本身就是那一帧,因此明文 topic 与 publication
30
+ payload 会留在 channel key 下直到通道关闭,页面崩溃后更是无限期。能力矩阵声称 SDK 不持久化业务 payload,
31
+ 而它自己导出的 `createIndexedDbReplayPersistence` 就是反例(对象仓以明文 topic 为 key);矩阵与入门文档还把
32
+ `workerMode: 'shared'` 说成"复用同一条连接",实际上每个 port 都有自己的 `CentrifugeSession` 和自己的
33
+ WebSocket——shared 模式省的是进程,从来不是 socket。
34
+ - 两条写给 transport 实现者的指引指错了回调、也指错了守卫。自动恢复由 `onStatus('error')` 启动、按恢复冷却窗口
35
+ 节流;`onError` 只做记录与通知,因此只通过它上报死连接的 backend 永远不会被重开。generation 计数器只有异步凭证桥
36
+ 会比较它——被取代的 Worker 根本到不了 transport,因为监听器早已移除。同一文件的接口块、Worker 联合与 wire 草图
37
+ 各自漏掉了确实发布的成员(`publish` 的元数据参数、可选的 `publishBatch`、两个 diagnostics 标签、`TOKEN_*` 与
38
+ `SESSION_REAPED` 类型、批量帧)。
39
+ - 有一处过度绝对的说法同时出现在三个地方,其中包括 `cluster.ts` 的一条注释:路由校验丢弃的是指向*另一个* Worker 的
40
+ `CONTROL/SUBSCRIBE`,读不到路由的 topic 会被接受。这个容忍是有界的——没有路由可盖章时 `confirmRoute` 什么都不写,
41
+ 下一次 reconcile 会收回 assignment——并由新增测试固化。实测:按旧文案描述采用更严格的规则,会让这条新测试和两条本就依赖
42
+ 该容忍的负载加权测试一起失败。
43
+ - 同一次"把说法拿去测量、而不是重读一遍"的改动也在此发布:`configuration.md` 的重试封顶语句现在说明了 1600 ms
44
+ 上限究竟约束哪一半,两条新测试固化完整的翻倍序列与原样生效的首次等待,两条 `PortReaper` 注释改为描述其真实的保护作用。
45
+ - 无行为、wire 格式、存储布局或集群协议变更。全树覆盖率保持 99.01 / 96.90 / 99.26 / 99.69,共 **895** 个测试。
4
46
 
5
47
  ## 0.21.10 已完成范围
6
48
 
@@ -14,10 +14,19 @@ WebSocket、socket.io、SSE 等)。
14
14
 
15
15
  ```ts
16
16
  interface DataBusTransport<TConfig = unknown, TData = unknown> {
17
+ /** 可选的身份/后端标签,经 diagnostics 暴露。 */
18
+ readonly diagnosticsName?: string;
19
+ readonly diagnosticsBackend?: string;
17
20
  start(config: TConfig, handlers: DataBusTransportHandlers<TData>): MaybePromise<void>;
18
21
  subscribe(topic: string): MaybePromise<void>;
19
22
  unsubscribe(topic: string): MaybePromise<void>;
20
- publish(topic: string, data: unknown): MaybePromise<void>;
23
+ /** `options` 带上调用方的 `{ messageId, timestamp }`;后端若悄悄丢掉它,
24
+ * 对端就同时失去了去重身份与 producer 时间戳。 */
25
+ publish(topic: string, data: unknown, options?: DataBusPublishOptions): MaybePromise<void>;
26
+ /** 可选:突发时用一帧发多条。没有它,DataBus 会退化成逐条 `publish`。
27
+ * 每条是 `{ data, messageId?, timestamp? }`,即 `publish` 那份元数据的逐条版本。
28
+ * (这个条目类型是内部的:要么按内联结构写,要么按你的后端放宽 `ReadonlyArray`。) */
29
+ publishBatch?(topic: string, items: ReadonlyArray<{ data: unknown; messageId?: string; timestamp?: number }>): MaybePromise<void>;
21
30
  stop(): MaybePromise<void>;
22
31
  }
23
32
 
@@ -30,7 +39,7 @@ interface DataBusTransportHandlers<TData = unknown> {
30
39
 
31
40
  `start()` 接收用户提供的连接配置(无类型 `TConfig`——后端自行定义其形状)和
32
41
  三个回调。连接状态变化时调 `onStatus`;收到 publication 时调 `onMessage`;
33
- 非致命错误调 `onError`(DataBus 有恢复冷却窗口,避免抖动连接死循环重试)。
42
+ 非致命错误调 `onError`。只有 `onStatus('error')` 会启动自动恢复——DataBus 用恢复冷却窗口给重开节流,并用 `recovery.maxAttempts` 限定次数,因此抖动连接不会死循环重试。`onError` 只是把失败记入 `getHealthSummary().lastFailure` 并通知订阅者,除此之外不做任何事:只用 `onError` 上报死掉的 socket 的后端,永远不会被重开。
34
43
 
35
44
  `start()` MUST 在后端真正连接后才 settle 返回的 Promise;尝试失败时必须 reject。
36
45
  DataBus 把这个 settlement 当作就绪与恢复边界:处于 `CONNECTING` 的 socket 不算
@@ -59,8 +68,9 @@ DataBus 层负责跨 Tab 协调(BroadcastChannel 控制面、localStorage 路
59
68
 
60
69
  参照 Centrifuge 后端的 `centrifuge-protocol.ts`:一个主线程发给 Worker 的
61
70
  判别联合(`INIT` / `SUBSCRIBE` / `UNSUBSCRIBE` / `PUBLISH` / `PUBLISH_BIN` /
62
- `PING` / `STOP`)和一个 Worker 回传的联合(`STATUS` / `MESSAGE` / `MESSAGE_BIN` /
63
- `ERROR`)。保持结构化克隆安全
71
+ `PING` / `STOP`,外加回应凭证请求的 `TOKEN_RESPONSE` / `TOKEN_ERROR`)和一个
72
+ Worker 回传的联合(`STATUS` / `MESSAGE` / `MESSAGE_BIN` / `ERROR`,外加凭证桥用的
73
+ `TOKEN_REQUEST`,以及 SharedWorker 端口被回收时的 `SESSION_REAPED`)。保持结构化克隆安全
64
74
  (无函数、无类实例——`Error` 必须序列化)。
65
75
 
66
76
  ### 2. 实现 session
@@ -82,8 +92,7 @@ transport 选择后端(SharedWorker / Dedicated Worker / 本地),向它发
82
92
 
83
93
  - **后端选举**:复用 `worker-mode.ts` 的 `selectWorkerBackend`,使你的后端与
84
94
  SDK 其余部分降级行为一致。
85
- - **generation 守卫**:创建后端时递增单调计数器;错误处理检查它,使被取代的
86
- Worker 的迟到错误不会污染新 session。
95
+ - **generation 守卫**:创建后端或停止 transport 时递增单调计数器。参考实现里只有异步凭证桥会比较它,因为 provider 可能在它所应答的 Worker 已经消失之后才 settle。Worker 的 error 处理并不读这个计数器——`stop()` 与 `onWorkerFailed()` 在 generation 前移之前就移除了这些监听器,被取代的后端根本到不了这个对象。只有当你的后端会让某个监听器跨过后端替换继续存活时,才需要自己加这层检查。
87
96
  - **SharedWorker 心跳**:若用 SharedWorker,定期发 PING,让 `PortReaper` 能
88
97
  回收死 tab 的 session,并处理回收器在关闭端口前发出的 `SESSION_REAPED`
89
98
  消息。对被饿死但仍存活的 tab 来说,那是唯一能收到的信号(`MessagePort` 没有
@@ -137,6 +146,7 @@ const bus = createWebSocketDataBus({
137
146
  线协议(JSON 文本帧):
138
147
 
139
148
  - client → server:`{"op":"subscribe"|"unsubscribe"|"publish","topic":"...","data":...,"messageId"?:"...","timestamp"?:123}`
149
+ - client → server(批量):`{"op":"publishBatch","topic":"...","items":[{data,...,"messageId"?,"timestamp"?}]}`——一帧携带多条,由服务端拆成一条条 publication 重新扇出。二进制 payload 以字节数组内嵌其中。只有一条的 batch 会改走普通 `publish` 帧,因此只实现 `publish` 的服务端仍能看到旧的单条形状;空批次在发出前就被丢弃。socket 未打开时收到的批次会通过 `handlers.onError` 上报并丢弃。
140
150
  - server → client:标准 publication 为 `{"op":"publication","publication":{"topic":"...","data":...,"messageId"?:"...","timestamp"?:123}}`;旧的扁平 `{"topic":"...","data":...}` 帧仍然兼容。没有字符串 topic 的帧会被忽略;非法 JSON 通过 `handlers.onError` 上报而不会抛出。
141
151
  - 自带字符串 `topic` 的 publication 会按**该值**寻址,而不是按它到达的 channel——这正是 server 通过通配 channel(`chat.*`)投递时指明具体 topic 的方式。因此顶层恰好含 `topic` 字段的负载会被重新寻址,若集群中没有任何 Tab 拥有重定向后的 topic,就会被丢弃。Centrifuge 的 channel 通常由客户端库在带外给出,这也是必须在此说明该规则的原因:只有在这条链路上,"topic 本来就写在帧里"这件事才可见。
142
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cross-tab-worker-databus",
3
- "version": "0.21.10",
3
+ "version": "0.21.12",
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",