cross-tab-worker-databus 0.21.6 → 0.21.8

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/zh/api.md CHANGED
@@ -96,11 +96,11 @@ subscribe(
96
96
  - 显式 `stop()` 尚未 settle 时发起的订阅不会登记:`subscribe()` 通过 `onError` 上报并返回 no-op 释放函数。调用方应等待 `stop()` settle,再调用 `start()` 后重新订阅。
97
97
  - 通配符订阅:以 `.*` 结尾的 Topic(如 `chat.*`)匹配任意后缀,`*` 匹配全部。pattern 以字面量参与路由、归属与传输订阅;携带匹配的具体 topic(或 pattern 本身)的发布都会投递给通配 handler。匹配规则见下方 `topicMatchesPattern`。
98
98
  - Topic 必须非空:`subscribe()`、`publish()`、`publishBatch()` 传入 `''` 时会抛出 `TypeError`,且在任何其他副作用之前(不会请求 start,也不会登记 handler)。没有任何 transport 能寻址空 channel,因此对它的订阅永远收不到消息,向它的发布也会被无声丢弃。`''` 自 0.20.96 起每个实例告警一次,自 0.21.0 起直接拒绝——遵循 1.0 前的弃用策略;该守卫与选项校验断言放在一起,空 topic 与非法 `replay.maxPerTopic` 以同样方式失败。`publishBatch('', [])` 同样抛错:非法参数先于空数组 no-op 检查。
99
- - 重放(可选):构造 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 窗口约束。
99
+ - 重放(可选):构造 bus 时传 `replay: { maxPerTopic }` 开启缓冲,`maxPerTopic` 必须是正安全整数;`subscribe()` 第三个参数传 `{ replay: true | n }` 后,新 handler 会立即收到缓冲历史(最多 `n` 条,受 `maxPerTopic` 上限约束,默认 100),消息带 `message.replayed: true` 标记——晚加入的 handler 不会错过更早的发布。只有被分发过的消息才入缓冲(无本地订阅者的 topic 会被 owner 丢弃);缓冲仅存内存,该 topic 最后一个 handler 退订时清空。通配订阅会对所有匹配 pattern 的已缓冲 topic 做回放——但 pattern 本身不是一个 topic:它填上的缓冲环仍按具体发布 topic 归档,所以退订 `chat.*` 不会清空其中任何一条(durable 清理会以 `chat.*` 这个键调用,而存储里并没有对应行),重新订阅该 pattern 时仍会回放尚存的缓冲。`maxPerTopic` 限制每条环的深度,不限制宽 pattern 产生的环数量,因此 `*` 或 `prefix.*` 面对无界 topic 空间会让缓冲 map 持续增长,直到你主动约束——用 `clearReplay()` / `clearReplayTopic(topic)` 清理,或用 `retentionMs` 按时间收敛。需要跨 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 窗口约束。
100
100
  启用 trace 后,重试会发出 `reliability` 事件,包含 `operation: 'persistence_retry'`、有界的 `persistenceOperation` 和 `attempt`。
101
101
 
102
102
  WebSocket transport 支持以 `ArrayBuffer` 或浏览器 `Blob` 帧接收二进制 publication。
103
- - 持久化 replay store 还可实现 `clearTopic(topic)`;bus 会在最后一个 handler 退订时调用。应用可自行保留 `clear()` 做全量留存清理;`stop()` 会保留 durable history,以支持 reload/BFCache 恢复。
103
+ - 持久化 replay store 还可实现 `clearTopic(topic)`;bus 会在该 topic 最后一个 handler 退订时调用——对通配订阅传入的是 pattern 字符串,而它填上的具体 topic 另有归档键,因此不会被清掉(见上文说明)。应用可自行保留 `clear()` 做全量留存清理;`stop()` 会保留 durable history,以支持 reload/BFCache 恢复。
104
104
 
105
105
  ### `unsubscribe(topic, handler?)`
106
106
 
@@ -296,7 +296,7 @@ trace: {
296
296
  }
297
297
  ```
298
298
 
299
- 低频事件类型包括 `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 应尽量避免抛出异常——预期中的错误条件应通过事件数据表达,而不是通过异常上报。
299
+ 低频事件类型包括 `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=…`,反映已收敛的路由列表)。所有公开事件都使用固定结构,其中不会携带消息 payload、连接地址或错误正文。Topic 明文只出现在两种事件里:`subscription` 记录订阅变更,`reliability` 则为涉及具体路由的操作标注该路由(`route_ack`、`route_migration`、`route_migration_recovery`,其余 reliability 操作不带 Topic);其它事件不含 Topic,`coordination` 使用不可逆的 `topicKey` 而非名称来标识路由。既然这两种事件确实携带 Topic 名称,就应把 trace sink 视为诊断出口:在把事件转发给外部监控前,按业务 Topic 约定脱敏,详见 `configuration.md`。sink 抛错会被隔离,不会中断消息分发,但会向 `console.warn` 输出错误,便于定位诊断配置问题。sink 应尽量避免抛出异常——预期中的错误条件应通过事件数据表达,而不是通过异常上报。
300
300
 
301
301
  **`asyncSink: true` 的投递语义。** 默认(`false`)下,每条事件同步调用 sink。开启 `asyncSink: true` 后,事件先入内存队列,在**一个微任务批次**中统一投递:任务的第一条事件调度 `queueMicrotask`,在该微任务运行前产生的所有事件(含周期性 `message_metrics` 快照)按 FIFO 顺序一次性送往 sink。这样热路径永远不会因 sink 工作而阻塞。错误隔离与同步模式一致——sink 抛错被捕获并记入 `console.warn`,既不会中断分发,也不会中断批次内其余事件。顺序保证边界:**批次内**顺序是确定的,但投递被推迟到下一个微任务,因此事件不再保证在你的下一行语句执行前可见;批次 flush 之后新产生的事件会落入后续批次。当需要"每条事件在下一行代码前可见"时,请使用默认的同步 sink(或自行合并计数)。
302
302
 
@@ -138,6 +138,24 @@ interface WorkerRoute {
138
138
 
139
139
  路由不保存原始 Topic 字符串或 payload。真实 owner 收到 `CONTROL/SUBSCRIBE` 时,原始 Topic 字符串只通过 BroadcastChannel 内存消息传递。接收方只有在持久化 route 当前指向自己时才接受该控制帧;来自较早分配轮的迟到帧会被丢弃,等待 `ROUTE_RELEASED` 的交接也只能由精确匹配的 ACK 确认。`confirmedAt` 在 owner 处理控制消息后写入;在路由确认之前,持有原始 Topic 字符串的订阅方 Runtime 会重发 `SUBSCRIBE`,以从"有路由但无真实订阅"的 BroadcastChannel 消息丢失中恢复。
140
140
 
141
+ ### 接收侧的帧校验
142
+
143
+ 控制通道是 `BroadcastChannel`:同源、无鉴权,同源内的任意脚本都能写入,因此接收方会把帧字段当作攻击者可控数据处理,除非它做了检查。以下三项属于线协议契约的一部分;未通过的帧被**静默丢弃**而不是报错——没有可以对端的接收者。
144
+
145
+ | 检查 | 位置 | 未通过的后果 |
146
+ |---|---|---|
147
+ | `targetWorkerId` 指向本 worker | 所有点对点处理器 | 帧被忽略 |
148
+ | `topicKey === createOpaqueKey(topic)` | `CONTROL`(任意 action)与 `ROUTE_RELEASED` | 帧被忽略 |
149
+ | 携带 `items` 的 `CONTROL/PUBLISH` 帧必须给出非空**数组** | `CONTROL/PUBLISH` | 帧被忽略 |
150
+
151
+ 第二行是所有权的授权前提。`topicKey` 是 `topic` 的纯函数,所以本库构造的每个帧两者必然一致;不一致就是替换而非变体。由于所有权依据 `topicKey` 对应的持久路由判定,而传输层订阅的名字来自 `topic`,不校验就允许一帧借用某个频道的授权去命名另一个频道。持久路由记录只保存 `topicKey`、从不保存明文,因此 key → 明文的映射只存在于内存中,无法通过 localStorage 注入。
152
+
153
+ 第三行存在是因为批量路径会迭代负载:只有 `length` 而没有迭代器的值会从消息监听器里抛出异常;可迭代的非数组(字符串)会被当成多个单字符条目,其 `data` 为 `undefined`,随后 owner 会以自己的会话把它们发布出去。
154
+
155
+ 有两处是**故意不校验**的。`EVENT` 帧只做形状校验(`eventType` 加一个含字符串 `topic` 的负载)并转发其余字段,因为投递仍要求本地存在该频道的订阅者,而这种宽容正是新旧 SDK 版本能共处一个集群的前提。批量条目内部的*内容*也不校验:`{ data: 任意} ` 就是合法条目的样子,而 `messageId` / `timestamp` 只会被转发给 `transport.publish()`——接收侧在解析发布数据时会丢弃非字符串 id 与非有限数值时间戳。
156
+
157
+ **给非 JS 对端或自定义 environment 的约定:**用 `createOpaqueKey` 从 topic 推导 `topicKey`,不要发送零条目的批次,并预期不一致的帧会被忽略且没有任何响应。
158
+
141
159
  ### `topic`、`topicKey`、`tabId`、`workerId` 与 BroadcastChannel 的关联
142
160
 
143
161
  这几个标识分别表示不同层次的对象,不应混用:
@@ -2,19 +2,19 @@
2
2
 
3
3
  # 浏览器基准趋势
4
4
 
5
- > 数据截至 2026-09-22,基于 52 份归档的 `bench-results/browser-*.json` 报告(运行 `pnpm bench:browser` 追加一份;用 `node scripts/bench-trend.mjs` 重新生成本文档)。
5
+ > 数据截至 2026-09-22,基于 56 份归档的 `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 的噪声;此前只看"上一份报告"并不稳定,因为页内热路径指标在同一份代码上会在快/慢两档之间来回跳)。本文记录长期趋势:数值为逐指标延迟,越低越好;最后一列是最近 5 份报告内的最优值,而不是历史纪录——页内基准矩阵在 2026 年 9 月初变更过测量语义,更早的报告不可比。
8
8
 
9
9
  <!-- BENCH-TREND:BEGIN (machine-generated table) -->
10
10
  | 指标 | 上次 (ms) | 本次 (ms) | Δ | 近 5 次最优 (ms) |
11
11
  |---|---|---|---|---|
12
- | publish per-message (ms, lower is better) — dedicated | 54.4672 | 47.1625 | -7.30 | 47.1625 |
13
- | publish per-message (ms, lower is better) — shared | 33.9924 | 33.8136 | -0.18 | 33.8136 |
14
- | wildcard dispatch ×1000 (ms, lower is better) | 5.5 | 5.8 | +0.30 | 5.5 |
15
- | publishBatch ×1000 (ms, lower is better) | 3.9 | 3.9 | +0.00 | 3.9 |
16
- | dedup ×1000 (ms, lower is better) | 19.3 | 11 | -8.30 | 11 |
17
- | trace + publish ×1000 (ms, lower is better) | 6.6 | 4.8 | -1.80 | 4.8 |
12
+ | publish per-message (ms, lower is better) — dedicated | 51.9353 | 51.5536 | -0.38 | 40.0685 |
13
+ | publish per-message (ms, lower is better) — shared | 40.9535 | 35.4994 | -5.45 | 33.7695 |
14
+ | wildcard dispatch ×1000 (ms, lower is better) | 6.1 | 6.6 | +0.50 | 5.6 |
15
+ | publishBatch ×1000 (ms, lower is better) | 4.3 | 3.8 | -0.50 | 3.5 |
16
+ | dedup ×1000 (ms, lower is better) | 12.4 | 12.1 | -0.30 | 10.9 |
17
+ | trace + publish ×1000 (ms, lower is better) | 4.8 | 5.4 | +0.60 | 4.7 |
18
18
  | first-packet cold dispatch (ms, lower is better) | 0 | 0 | +0.00 | 0 |
19
19
  <!-- BENCH-TREND:END -->
20
20
 
@@ -1,6 +1,6 @@
1
1
  # 发布检查清单
2
2
 
3
- 每个 1.0.0 之前的版本都按此清单执行。仓库不由助手执行发布;完成打包验证并人工审阅后,再手动运行 npm 命令。
3
+ 每个 1.0.0 之前的版本都按此清单执行。推送版本标签即为发布:`Release` 工作流会在标签上运行,由于本仓库已配置 `NPM_TOKEN`,它会发布到 npm,随后运行阻塞式的 `verify:published` 校验。本文末尾的「手动 `npm publish`」只适用于不存在该 secret 的仓库状态。
4
4
 
5
5
  ## 公共 API 稳定性与弃用策略(1.0 之前)
6
6
 
@@ -11,7 +11,7 @@
11
11
 
12
12
  ## 自动化门禁(CI)
13
13
 
14
- `CI` 工作流的 `verify` job 在每次 push 与 pull request 上运行 `pnpm check`、`pnpm lint`、`pnpm test:coverage`(`vitest.config.ts` 中的下限:语句 96% / 分支 92% / 函数 96% / 行 97%)、`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 在每次 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 解析基线。
15
15
 
16
16
  只有 `pnpm bench:browser` / `pnpm bench:compare` 保持仅本地执行:共享 runner 的计时噪声会让数值型 CI 门禁不可靠。
17
17
 
@@ -1,6 +1,24 @@
1
1
  # 路线图
2
2
 
3
- 0.21.6 已于 2026 年 9 月 22 日发布。项目会先持续完成可靠性发布,再进入 1.0.0 稳定性冻结。
3
+ 0.21.8 已于 2026 年 9 月 23 日发布。项目会先持续完成可靠性发布,再进入 1.0.0 稳定性冻结。
4
+
5
+ ## 0.21.8 已完成范围
6
+
7
+ - 第二个随包分发的句子同样是错的,性质与 0.21.7 那条一致。`docs/api.md` 在中英文里都承诺任何公开 trace 事件都不包含原始 Topic,而 `docs/configuration.md` 在两种语言里写的恰恰相反,还进一步提醒接入方在把 sink 转发给遥测前对 Topic 名称脱敏。事实是:subscription 事件携带它所报告的 Topic,涉及具体路由的 `reliability` 操作也一样。修正后的文字只陈述测量到的东西:任何事件都不携带消息 payload、连接地址或错误正文;Topic 明文恰好只出现在这两种事件里;`coordination` 使用不可逆的 key 来标识路由。现在有一个测试把 payload 哨兵与错误正文哨兵穿过真实的 bus,并断言 sink 两者都看不到——这是这两条声明第一次拥有见证。
8
+ - 套件里那个长期被当作共享 runner 抖动的 E2E 失败并不是负载。shared 模式的下线用例断言的是 demo server 的全局连接数,而 specs 是并行跑的,其它存活 tab 都会被算进去;即便通过也是空过:一个 topic 只有 owner 的 transport 会订阅它的频道,所以「2」可能是一个持有频道的 socket 加上一个什么都没订阅的 socket。现在 socket 按各自持有的频道来寻址,用例也同时断言两半:关闭的 tab 的 socket 必须从服务器列表里消失,存活 tab 的那个必须仍在列表里。把该用例重复三次(旧断言下三次全败)现在能通过。
9
+ - 两个带种子的不变量 harness 分别跑到出厂深度的 80 倍与 133 倍(400,000 与 200,000 个种子,753.7 秒与 262.6 秒),没有发现任何破坏,因此剩下的零计数腿是分类工作,而不是隐藏的协同缺陷。它们的文件头现在记录了可用深度,以及能结束一次深跑的三道彼此独立的限制。
10
+ - 一个一直被读反的覆盖零值现在按它真正计量的东西标注:当 `??` 的兜底是一个箭头函数时,分支区间落在该闭包体内,所以这一臂统计的是被调用,而不是有多少调用方省略了参数。全仓库扫描下来,这是唯一一条这种形状的腿,规则已写进 `AGENTS.md`。
11
+ - 本次发布没有行为、线格式或存储布局的变更。
12
+
13
+ ## 0.21.7 已完成范围
14
+
15
+ - 一处随包发布的说明是错的。`docs/api.md` 承诺「该 topic 最后一个 handler 退订时清空重放缓冲」,同时又说通配订阅会回放所有匹配 pattern 的已缓冲 topic——但 pattern 不是一个 topic:历史是按具体发布 topic 归档的,所以退订 `chat.*` 一条都不会清空,而 durable 清理拿到的是一个没有任何记录对应的键。中英文文档现在都写清了每次清理作用的键、`maxPerTopic` 限制的是每条环的深度而非环的数量,以及真正能回收内存的那几个调用。行为没有改动(按 pattern 剪枝会连带丢掉其它仍在线的订阅者拥有的环),并新增一条测试把这个边界钉住,避免文档再次漂回去。
16
+ - 补上一个真实的测试缺口:`ReplayManager` catch 中的 hydration epoch 检查从未执行过。`resetBuffers()` 只抬 epoch 不抬 retry generation,于是重置之后到达的加载失败会替「替换它的那个会话」上报错误,并把完成闩锁置位——那正是会抑制新会话加载的状态:一次幽灵 persistence error,外加该实例整个生命周期内 durable 历史缺失。
17
+ - 接收侧的帧校验规则(0.21.5 / 0.21.6)从维护者笔记搬进了 `docs/architecture.md` 的两个语言版本,包含两条刻意不做的检查,以及非 JS 对端要怎样才能被接受——这些守卫是 fail-closed 的,坏帧被静默丢弃且没有任何回应,对接方不看文档就无从知道。
18
+ - 五个模块里约十条 zero-count 分支用实验而不是推断定了下来:一条被真正钉住,其余的在原地处标注了「谁覆盖了它」以及删掉它的代价。没有删除任何一条,本次发布也没有声称任何未经测量的覆盖率提升。
19
+ - 覆盖率门禁重新变成了门禁:96 / 92 / 96 / 97 的下限已经比实测分支覆盖率低了 4.7 个百分点,意味着丢掉一整个模块的测试仍能通过包括发布门禁在内的所有检查。现在是 98 / 96 / 98 / 99,留出的余量只为唯一一个真正依赖运行机器的因素(模糊测试按墙钟界定深度),并且用「把阈值抬高到超过实测值就会失败」验证过它有牙齿。
20
+ - 本次发布没有改变任何行为、线格式或存储结构。
21
+
4
22
 
5
23
  ## 0.21.6 已完成范围
6
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cross-tab-worker-databus",
3
- "version": "0.21.6",
3
+ "version": "0.21.8",
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",