cross-tab-worker-databus 0.20.86 → 0.20.88
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/CHANGELOG.md +26 -0
- package/dist/centrifuge.js +1 -1
- package/dist/{chunk-SDOV3UHG.js → chunk-4UOLOWOD.js} +175 -33
- package/dist/{chunk-SDOV3UHG.js.map → chunk-4UOLOWOD.js.map} +3 -3
- package/dist/cjs/centrifuge.cjs +174 -32
- package/dist/cjs/centrifuge.cjs.map +3 -3
- package/dist/cjs/hooks.cjs +2 -2
- package/dist/cjs/hooks.cjs.map +2 -2
- package/dist/cjs/index.cjs +281 -52
- package/dist/cjs/index.cjs.map +3 -3
- package/dist/cjs/vue.cjs +1 -1
- package/dist/cjs/vue.cjs.map +2 -2
- package/dist/core/data-bus.d.ts +34 -5
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/hooks.d.ts +2 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +2 -2
- package/dist/hooks.js.map +2 -2
- package/dist/index.js +108 -21
- package/dist/index.js.map +2 -2
- package/dist/vue.d.ts +2 -1
- package/dist/vue.d.ts.map +1 -1
- package/dist/vue.js +1 -1
- package/dist/vue.js.map +2 -2
- package/dist/websocket.d.ts +24 -2
- package/dist/websocket.d.ts.map +1 -1
- package/docs/api.md +15 -10
- package/docs/architecture.md +9 -4
- package/docs/roadmap.md +14 -1
- package/docs/transports.md +19 -2
- package/docs/zh/api.md +15 -10
- package/docs/zh/architecture.md +9 -4
- package/docs/zh/roadmap.md +14 -1
- package/docs/zh/transports.md +14 -2
- package/package.json +3 -3
package/docs/transports.md
CHANGED
|
@@ -35,6 +35,12 @@ connection state changes; call `onMessage` for each inbound publication; call
|
|
|
35
35
|
`onError` for non-fatal errors (the DataBus applies a recovery cooldown so a
|
|
36
36
|
flapping connection does not retry-loop).
|
|
37
37
|
|
|
38
|
+
`start()` MUST settle its returned promise only once the backend is connected,
|
|
39
|
+
and reject it when the attempt fails. The DataBus uses that settlement as its
|
|
40
|
+
readiness and recovery boundary: a `CONNECTING` socket is not ready, and queued
|
|
41
|
+
operations must not be released until the handshake succeeds. A backend that
|
|
42
|
+
can stall should enforce its own handshake timeout and reject.
|
|
43
|
+
|
|
38
44
|
## Architectural layers
|
|
39
45
|
|
|
40
46
|
```
|
|
@@ -154,10 +160,21 @@ without metadata keep their original shape. Metadata-bearing publishes use
|
|
|
154
160
|
`{ data, messageId?, timestamp? }`, while inbound publications additionally
|
|
155
161
|
accept the canonical nested `DataBusPublicationEnvelope`.
|
|
156
162
|
|
|
163
|
+
`start()` resolves only after `open` and rejects when the handshake errors,
|
|
164
|
+
closes before opening, or exceeds `connectTimeoutMs` (default `30000` ms; `0`
|
|
165
|
+
or `Infinity` waits indefinitely). A timed-out socket is closed and a late
|
|
166
|
+
`open` from that attempt is ignored.
|
|
167
|
+
|
|
157
168
|
Lifecycle mapping: `open` → `connected`, `close` → `disconnected`,
|
|
158
169
|
`error` → `error` (DataBus auto-recovery). Subscribe frames are re-sent when
|
|
159
|
-
the socket reopens in place. A
|
|
160
|
-
|
|
170
|
+
the socket reopens in place. A successful reopen can either reuse the same
|
|
171
|
+
socket object or create a replacement through the factory; callbacks from the
|
|
172
|
+
superseded socket are ignored, so a late close or message from the failed
|
|
173
|
+
connection cannot pollute the recovered one. A clean `disconnected` schedules no
|
|
174
|
+
background recovery, but the next `subscribe()` / `publish()` demands one reopen
|
|
175
|
+
and flushes behind it, so a post-close operation is never sent to the closed
|
|
176
|
+
socket. A pattern-aware server may tag publications with the concrete topic —
|
|
177
|
+
see wildcard subscriptions in [api.md](./api.md).
|
|
161
178
|
|
|
162
179
|
## Factory entry point
|
|
163
180
|
|
package/docs/zh/api.md
CHANGED
|
@@ -56,7 +56,7 @@ new CrossTabDataBus<TConfig, TData>(options)
|
|
|
56
56
|
start(config: TConfig): Promise<void>
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
启动集群协调和 transport。首次调用真正启动 transport;打开过程尚未结束时,并发调用共享同一个启动 Promise,不重复创建 transport。对健康且已启动的实例调用是立即 resolve 的空操作。若 transport 已断开,`start()` 作为显式手动恢复:保留 cluster、订阅和 replay 缓冲区,重置失败/恢复账本并重新打开 transport。若显式 `stop()` 尚未完成,`start()` 会在清理之后排队一次全新启动,并返回随重启完成而 settle 的 Promise。该排队重启归属于最近一次生命周期意图:若它在真正执行前又收到 `stop()`,则会被取消(排队 start 的 Promise resolve,但不会打开 transport);取消之后再调用 `start()` 会以更高令牌重新排队。`stop()` 完成后也可正常再次调用 `start()` 重启。
|
|
59
|
+
启动集群协调和 transport。首次调用真正启动 transport;打开过程尚未结束时,并发调用共享同一个启动 Promise,不重复创建 transport。对健康且已启动的实例调用是立即 resolve 的空操作。若 transport 已断开,`start()` 作为显式手动恢复:保留 cluster、订阅和 replay 缓冲区,重置失败/恢复账本并重新打开 transport。若显式 `stop()` 尚未完成,`start()` 会在清理之后排队一次全新启动,并返回随重启完成而 settle 的 Promise。该排队重启归属于最近一次生命周期意图:若它在真正执行前又收到 `stop()`,则会被取消(排队 start 的 Promise resolve,但不会打开 transport);取消之后再调用 `start()` 会以更高令牌重新排队。`stop()` 完成后也可正常再次调用 `start()` 重启。open 失败时,清理和生命周期 gate 会先 settle,再调用启动失败的 `onError` handler;因此在该回调中同步调用 `start()` 重试会在失败 transport 清理完成后开启一次全新尝试,而不是返回同一个已拒绝的 Promise。transport 在 `start()` 仍在飞行时同步上报 `error` 时,两类通知遵循同一契约:内部状态会立即更新,但用户可见的 `onStatus('error')` 会延迟到 `openTransport()` 完成清理、安装失败 transport 的 stop gate、记录失败并清除 `startPromise` 之后。此时从该状态回调或随后启动失败的 `onError` 回调中同步调用 `start()`,都会在清理完成后排队一次全新生命周期;若重试成功,它会开启新的失败账本,原 opening 的 rejection 不会再次写回。
|
|
60
60
|
|
|
61
61
|
### `ready()`
|
|
62
62
|
|
|
@@ -70,11 +70,13 @@ ready(): Promise<void>
|
|
|
70
70
|
|
|
71
71
|
若后续 `stop()` 取消了该排队重启,排队 `start()` Promise 仍按既定语义 resolve 且不会打开 transport,但 `ready()` 会以生命周期错误 reject,而不会把已停止的 bus 报告为 ready。
|
|
72
72
|
|
|
73
|
+
Tab 处于 BFCache 挂起态时(`pagehide` 之后、`pageshow` 之前),`ready()` 会以挂起态错误 reject。挂起路径会把 `startPromise` 复用为异步 `transport.stop()` 的 gate,若直接返回它,就会针对一个被有意停止的 transport 报告 ready。`pageshow` 或显式 `start()` 会清除挂起标记并安装真正的重开 Promise,此后 `ready()` 会在 transport 就绪后正常 resolve。两条恢复路径都会同时重启协调面:`pagehide` 会独立于 transport 暂停 cluster(关闭 channel、停止 heartbeat 并释放 route 分配),因此显式 `start()` 也会一并恢复 cluster——否则 bus 会报告 transport 健康,而跨 Tab 投递仍被丢弃。
|
|
74
|
+
|
|
73
75
|
若该排队重启在 transport 启动阶段失败,即使未传入 `initialConfig`,`ready()` 也会以底层启动错误 reject。该失败会保留给显式恢复,而不会被通用的「缺少配置」错误掩盖。
|
|
74
76
|
|
|
75
77
|
未传入 `initialConfig` 且未显式调用 `start(config)` 时,`ready()` 返回 rejected Promise 而不是同步抛出,调用方可以统一通过 `.catch` 处理并决定是否显式启动。
|
|
76
78
|
|
|
77
|
-
`ready()`
|
|
79
|
+
`ready()` 会在当前 transport 满足其 `start()` 契约时 resolve;它不保证远端服务端已能处理应用流量。内置 WebSocket 后端的 `start()` 会等待 socket 握手:握手前发生 `error`、`close`,或超过 `connectTimeoutMs` 时都会 reject,因此 `ready()` 不会在 socket 仍处于 `CONNECTING` 时报告就绪。协议连接状态仍通过 `onStatus` 获取。
|
|
78
80
|
|
|
79
81
|
### `subscribe(topic, handler)`
|
|
80
82
|
|
|
@@ -90,7 +92,7 @@ subscribe(
|
|
|
90
92
|
- 同一 Topic 的多个 handler 使用引用计数。
|
|
91
93
|
- 当前 Tab 第一个 handler 会登记集群订阅。
|
|
92
94
|
- 最后一个 handler 释放后,当前 Tab 才退出该 Topic。
|
|
93
|
-
- transport 尚未 ready
|
|
95
|
+
- transport 尚未 ready 时订阅自动排队;transport 恢复待定时同样如此:订阅会挂在恢复门之后,等重开成功才下发,而不会写入刚刚上报 `error` 的连接。
|
|
94
96
|
- 显式 `stop()` 尚未 settle 时发起的订阅不会登记:`subscribe()` 通过 `onError` 上报并返回 no-op 释放函数。调用方应等待 `stop()` settle,再调用 `start()` 后重新订阅。
|
|
95
97
|
- 通配符订阅:以 `.*` 结尾的 Topic(如 `chat.*`)匹配任意后缀,`*` 匹配全部。pattern 以字面量参与路由、归属与传输订阅;携带匹配的具体 topic(或 pattern 本身)的发布都会投递给通配 handler。匹配规则见下方 `topicMatchesPattern`。
|
|
96
98
|
- 重放(可选):构造 bus 时传 `replay: { maxPerTopic }` 开启缓冲,`maxPerTopic` 必须是正安全整数;`subscribe()` 第三个参数传 `{ replay: true | n }` 后,新 handler 会立即收到缓冲历史(最多 `n` 条,受 `maxPerTopic` 上限约束,默认 100),消息带 `message.replayed: true` 标记——晚加入的 handler 不会错过更早的发布。只有被分发过的消息才入缓冲(无本地订阅者的 topic 会被 owner 丢弃);缓冲仅存内存,该 topic 最后一个 handler 退订时清空。通配订阅会对所有匹配 pattern 的已缓冲 topic 做回放。需要跨 reload/BFCache 持久化时,可传入 `createIndexedDbReplayPersistence({ maxPerTopic })` 创建的 `persistence`;持久化为异步操作,失败会通过 `onError` 报告,不影响实时投递。设置 `retentionMs` 后会清理内存中过期的 producer-timestamped 历史,并通过实现 `clearBefore` 的 adapter 在 hydrate 和追加后清理 durable 历史。设置 `persistenceRetry: { maxAttempts, backoffMs }` 可重试瞬时持久化失败;默认仍保持单次尝试。设置 `pruneStrategy` 为 `'count'`(默认)、`'age'` 或 `'both'`,分别表示按 `maxPerTopic` 截断、按 `retentionMs` 清理带时间戳历史,或两者都应用。`age` 下无时间戳的 legacy 条目会保留,但受 `maxPerTopic` 限制;带时间戳条目由 retention 窗口约束。
|
|
@@ -127,6 +129,8 @@ publish(
|
|
|
127
129
|
|
|
128
130
|
在 `stop()` 尚未 settle 时调用 `publish()` 会通过 `onError` 上报且不路由任何消息;消息不会延迟到之后的 start。更早发出、仍排队等待 transport open 的发布会被 stop 取消。
|
|
129
131
|
|
|
132
|
+
运行期 transport 上报 `error` 后发起的发布同样会挂在恢复门之后,等 transport 重新 ready 再发送,因此不会被写进刚刚失败的连接。若恢复预算耗尽,或等待被 `stop()` / 页面隐藏取代,该发布会按文档丢弃而不是无限期延迟(页面挂起仍保持「不延迟、直接丢弃」语义)。干净的 `disconnected` 不会触发后台 DataBus 自动重开,但也不会再吞掉后续操作:干净关闭后发起的 `subscribe()` / `publish()` 会触发一次按需重开,先挂起等待替代连接就绪,随后再 flush。可显式调用 `start()`(或直接发起操作)来重开。
|
|
133
|
+
|
|
130
134
|
传入 `options.messageId` 和 `options.timestamp` 后,元数据会穿过跨 Tab 路由、Worker 边界和支持的 transport。服务端必须回显或以其他方式保留它们,入站去重和 replay retention 才能使用。
|
|
131
135
|
|
|
132
136
|
`DataBusMessage` 与 `DataBusPublication` 暴露相同的可选元数据。
|
|
@@ -204,7 +208,7 @@ getHealthSummary(): DataBusHealthSummary
|
|
|
204
208
|
|
|
205
209
|
```ts
|
|
206
210
|
interface DataBusHealthSummary {
|
|
207
|
-
healthy: boolean; // 已启动、未挂起、transport
|
|
211
|
+
healthy: boolean; // 已启动、未挂起、transport 实时状态为 connected
|
|
208
212
|
state: 'stopped' | 'starting' | 'healthy' | 'recovering' | 'suspended' | 'degraded';
|
|
209
213
|
status: WorkerStatus;
|
|
210
214
|
sdkVersion: string;
|
|
@@ -219,7 +223,7 @@ interface DataBusHealthSummary {
|
|
|
219
223
|
}
|
|
220
224
|
```
|
|
221
225
|
|
|
222
|
-
`state` 语义:`stopped`(未启动)、`starting`(首次连接进行中)、`recovering`(transport 自动恢复进行中)、`suspended`(Tab 隐藏,pageshow 后自动恢复)、`degraded`(自动恢复已耗尽,需要手动 `start()` 或重新 subscribe 触发恢复)、`healthy`。处于 degraded 时再次调用 `start()` 会保留 cluster、订阅和 replay 缓冲区,重置失败/恢复账本后重新打开 transport;subscribe 与 publish 也走同一恢复路径。`lastFailure` 是覆盖全部失败来源的统一账本,每次显式 `start()`
|
|
226
|
+
`state` 语义:`stopped`(未启动)、`starting`(首次连接进行中)、`recovering`(transport 自动恢复进行中)、`suspended`(Tab 隐藏,pageshow 后自动恢复)、`degraded`(自动恢复已耗尽,需要手动 `start()` 或重新 subscribe 触发恢复)、`healthy`。处于 degraded 时再次调用 `start()` 会保留 cluster、订阅和 replay 缓冲区,重置失败/恢复账本后重新打开 transport;subscribe 与 publish 也走同一恢复路径。`lastFailure` 是覆盖全部失败来源的统一账本,每次显式 `start()` 后重置。`healthy` 依据 transport 的实时状态判定;`transport.ready` 是诊断字段,在 transport 已报告 `connected`、但其 `start()` Promise 尚未 settle 的短暂窗口内可能仍为 `false`,此时操作会排队等待该在途 start,而不会丢失。
|
|
223
227
|
|
|
224
228
|
### `getMetrics()`
|
|
225
229
|
|
|
@@ -238,7 +242,7 @@ getRecoveryStats(): { attempt; exhausted; maxAttempts; hasError; errorMessage; e
|
|
|
238
242
|
getPersistenceStats(): { failures; lastFailureAt; lastErrorMessage }
|
|
239
243
|
```
|
|
240
244
|
|
|
241
|
-
`recovery.generation` 在每次 transport 成功打开时递增(首次启动与每次恢复);`lastSuccessAt` 是该次成功的时间戳(首次成功前为 `null
|
|
245
|
+
`recovery.generation` 在每次 transport 成功打开时递增(首次启动与每次恢复);`lastSuccessAt` 是该次成功的时间戳(首次成功前为 `null`)。`recovery.hasError` / `errorMessage` / `errorAt` 描述最近一次被保留的 **transport** 失败——无论是 transport 打开失败还是运行期 `onError`——并与统一的 `lastFailure` 账本具有相同的生命周期:恢复成功后最后一次失败仍然可见,只有显式 `start()` 会清除它。非 transport 失败(`persistence`、`dispatch`)不会改动 recovery 账本,仍可通过 `lastFailure`(以及 replay 后端的 `getPersistenceStats()`)观察。同一次 transport 失败只取一次时间戳,因此 `recovery.errorAt` 与该次失败对应的 `lastFailure.at` 相等。持久化计数仅覆盖可选的 replay 持久化后端。
|
|
242
246
|
|
|
243
247
|
### `getDiagnostics()`
|
|
244
248
|
|
|
@@ -299,7 +303,7 @@ trace: {
|
|
|
299
303
|
stop(): Promise<void>
|
|
300
304
|
```
|
|
301
305
|
|
|
302
|
-
永久销毁当前实例:清理 handler、集群注册、路由、Worker 和 transport。若 transport open/reopen 仍在收敛,`stop()` 会等待它结束并使该结果失效,确保它不会在 stop 后变为 ready。普通页面隐藏和恢复不需要调用。
|
|
306
|
+
永久销毁当前实例:清理 handler、集群注册、路由、Worker 和 transport。若 transport open/reopen 仍在收敛,`stop()` 会等待它结束并使该结果失效,确保它不会在 stop 后变为 ready。普通页面隐藏和恢复不需要调用。teardown 对故障容错:即使 transport 自身的 `stop()` reject(或同步抛错),`stop()` 仍会在实例销毁完成后 resolve,并通过 `onError` 与统一的 `lastFailure` 记录上报该失败,而不是让 `stop()` 变成 rejected;因此 React / Vue adapter 中 fire-and-forget 的卸载路径不会产生 unhandled rejection。实例之后仍可重新 start。
|
|
303
307
|
|
|
304
308
|
## `DataBusTransport<TConfig, TData>`
|
|
305
309
|
|
|
@@ -395,13 +399,14 @@ const bus = createWebSocketDataBus({
|
|
|
395
399
|
new WebSocketTransport<TData>(connection: WebSocketDataBusConfig)
|
|
396
400
|
```
|
|
397
401
|
|
|
398
|
-
实现 `DataBusTransport
|
|
402
|
+
实现 `DataBusTransport`。`start()` 只在 socket 握手完成后 settle:`open` 时 resolve;握手前发生 `error`、`close`,或超过 `connectTimeoutMs` 时 reject。连接生命周期直接映射 DataBus 状态:socket `open` → `connected`,`close` → `disconnected`,`error` → `error`(触发 DataBus 自动恢复)。socket 原地重连时会自动重发订阅;当 bus 在 socket 失败后重新打开时(`error` 触发自动恢复,或 `close` 后显式 `start()` / 页面恢复 / 后续操作),`start()` 会创建替代 socket,并忽略被取代 socket 的迟到生命周期与消息回调(包括超时尝试之后迟到的 `open`);socket 未打开期间被丢弃的帧通过 `handlers.onError` 上报,替代 socket 打开后自动补发订阅帧。
|
|
399
403
|
|
|
400
404
|
`WebSocketDataBusConfig` 字段:
|
|
401
405
|
|
|
402
406
|
- `url` — WebSocket 端点。
|
|
403
407
|
- `protocols` — 可选的握手子协议。
|
|
404
408
|
- `webSocketFactory` — 可选工厂 `(url, protocols) => WebSocketLike`,用于测试与非浏览器运行时(默认使用全局 `WebSocket`)。
|
|
409
|
+
- `connectTimeoutMs` — 可选握手预算(毫秒),默认 `30000`;传 `0` 或 `Infinity` 表示无限等待。超时会通过 `error` 上报并 reject `start()`(也就是 `ready()`),随后关闭半开 socket。
|
|
405
410
|
|
|
406
411
|
### 线协议
|
|
407
412
|
|
|
@@ -435,7 +440,7 @@ React(>= 18)是可选 peer 依赖;独立入口保证非 React 消费者不
|
|
|
435
440
|
|
|
436
441
|
### `useCrossTabHealth(bus, options?)`
|
|
437
442
|
|
|
438
|
-
将 `bus.getHealthSummary()` 镜像为 React 状态(`DataBusHealthSummary | null`)。由于健康摘要是快照而非事件流,该 hook 按间隔轮询(默认 1000 ms;传 `{ intervalMs: 0 }`
|
|
443
|
+
将 `bus.getHealthSummary()` 镜像为 React 状态(`DataBusHealthSummary | null`)。由于健康摘要是快照而非事件流,该 hook 按间隔轮询(默认 1000 ms;传 `{ intervalMs: 0 }` 可仅依赖事件驱动刷新),并在状态变化与错误发生时立即刷新。修改 `intervalMs` 会替换轮询定时器,但不会重建 bus。bus 创建前返回 `null`。
|
|
439
444
|
|
|
440
445
|
## Vue Composables(`cross-tab-worker-databus/vue`)
|
|
441
446
|
|
|
@@ -451,7 +456,7 @@ useVueCrossTabSubscription(bus, 'chat.*', message => console.log(message.data));
|
|
|
451
456
|
|
|
452
457
|
### `useVueCrossTabHealth(bus, options?)`
|
|
453
458
|
|
|
454
|
-
`useCrossTabHealth` 的 Vue 绑定:将 `bus.getHealthSummary()` 镜像为 Vue `Ref<DataBusHealthSummary | null>`。健康摘要是快照而非事件流,因此该组合式函数按间隔轮询(默认 1000 ms;传 `{ intervalMs: 0 }`
|
|
459
|
+
`useCrossTabHealth` 的 Vue 绑定:将 `bus.getHealthSummary()` 镜像为 Vue `Ref<DataBusHealthSummary | null>`。健康摘要是快照而非事件流,因此该组合式函数按间隔轮询(默认 1000 ms;传 `{ intervalMs: 0 }` 可仅依赖事件驱动刷新),并在状态变化与错误发生时立即刷新。响应式修改 `intervalMs` 会替换轮询定时器,但不会重建 bus。bus 创建前返回 `null`。
|
|
455
460
|
|
|
456
461
|
## `WorkerClusterRuntime`
|
|
457
462
|
|
package/docs/zh/architecture.md
CHANGED
|
@@ -508,8 +508,9 @@ Transport 消息 → isAssigned(topic)? → 是 → broadcastEvent(EVENT)
|
|
|
508
508
|
- **Handoff ACK 有效性。** `ROUTE_RELEASED` 只有在 route 仍指向接收方、释放来自记录的 `handoffFromWorkerId`、且 ACK generation 不小于存储 route 的 generation 时才被接受。来自更早交接轮次的重复 ACK(如 a↔b 反复交接)携带更旧的 generation,会被丢弃。
|
|
509
509
|
- **Replay 持久化清理顺序。** 排队在当前任务之后的批量持久化 flush 会与竞速的清理操作对账:`unsubscribe` 与 `clearReplayTopic` 丢弃该 topic 的待写条目,`clearReplayBefore` 丢弃早于截止时间的条目。已清理的历史不会被在途 flush 复活。
|
|
510
510
|
- **存储写失败恢复。** 合并写入按指数退避重试(50 ms → 1.6 s 封顶)。结构性失败的关键在 5 次尝试后被丢弃(伴随 `console.warn`),且不会永久阻塞其他排队 key;队列完全清空或 `clear()` 取消重试后,退避延迟重置。
|
|
511
|
-
- **Transport 恢复预算。** 自动恢复由冷却时间限速、由 `recovery.maxAttempts` 限量,预算耗尽后标记 `exhausted`。成功的重开会重置尝试计数与 exhausted 标记;transport 宕机时显式 `subscribe`
|
|
512
|
-
- **BFCache 挂起。** Tab 隐藏时停止 transport、递增持久化重试 generation
|
|
511
|
+
- **Transport 恢复预算。** 自动恢复由冷却时间限速、由 `recovery.maxAttempts` 限量,预算耗尽后标记 `exhausted`。成功的重开会重置尝试计数与 exhausted 标记;transport 宕机时显式 `subscribe` 仍可手动恢复。自动调度本身不会重开连接:后端必须先释放失效连接,重试才能创建或重开 socket。`WebSocketTransport` 只在 socket `open` 后 resolve `start()`;握手前的 `error`/`close` 或 `connectTimeoutMs` 超时都会 reject。它仅在 socket 有效期间将其标记为 active;`error`/`close` 会立即失效,下一次 `start()` 在调用工厂前清除旧引用,因此被取代 socket 的迟到回调会被忽略。
|
|
512
|
+
- **BFCache 挂起。** Tab 隐藏时停止 transport、递增持久化重试 generation(取消在途重试且不对外报错),同时暂停 trace metrics 与 dedup/replay 周期清理并门控分发;`pageshow` 或显式 `start()` 会重开 transport、恢复这些周期资源,并且每轮循环只重建一次订阅。
|
|
513
|
+
- **挂起态就绪判定。** 挂起中的 bus 会把 `startPromise` 复用为 `pendingStop`(即 chained `transport.stop()` 的 gate),该 Promise 只能证明清理完成,不能证明可以承载数据。`ready()` 在 `stopping` 门之后检查 `suspended`,以挂起态错误 reject,而不是返回 stop gate;`pageshow`/`reopenTransport()` 与显式 `start()` 会清除标记并安装真正的重开 Promise,使 `ready()` 跟随最新生命周期意图。`getHealthSummary()` 原本就报告 `{ healthy: false, state: 'suspended' }`,reject 让 `ready()` 与该判定保持一致。由于 `pagehide` 会独立于 transport 暂停 cluster,显式 `start()` 在解除挂起时也必须一并恢复 cluster;否则 bus 会报告 transport 健康,而 channel listener、heartbeat 与 route 分配仍保持休眠直到下一次 `pageshow`,所有入站 publication 都会因 `isAssigned()` 对照已清空的分配表而被丢弃。
|
|
513
514
|
- **交接通道关闭顺序。** `pause()` 将物理 `channel.close()` 推迟一个任务。同步关闭会丢弃仍在排队等待投递的消息(包括交接的 `ROUTE_RELEASED`),使交接目标持有未确认路由。
|
|
514
515
|
- **悬挂交接恢复。** 若前任 owner 已消失而其 `ROUTE_RELEASED` 始终未到达(高负载下通道消息丢失,或 route 写入与 ACK 发送之间崩溃),reconcile 循环会在该未确认交接悬挂超过一个 worker TTL(默认 10 秒)后重新选举存活 owner:路由以全新 generation 重写并清除交接标记,使常规确认路径得以完成(已有回归固化)。年龄门限很关键——刚写入的未确认路由可能只是在等确认落盘,不能误判为悬挂;而只要前任 owner 仍然存活,新 owner 会继续等待,因此严格交接的无重叠保证不受影响。
|
|
515
516
|
- **丢失与恢复矩阵。** 每类协调消息都有有界恢复路径:丢失的 `CONTROL/SUBSCRIBE` 由心跳 reconcile 对未确认路由重发;丢失的 `REGISTRY` 通知最多损失一个心跳间隔(默认 3 秒),因为每次 tick 都会 reconcile;丢失的 `ROUTE_RELEASED` 由 reconcile 在前任 owner 消失且交接悬挂超过一个 worker TTL 后重新选举恢复(见上文悬挂交接不变量,已有回归固化);transport 断连窗口内被丢弃的 publication 是唯一文档化的不可恢复丢失(transport 契约)。storage-event 降级通道通过信封内的单调序列号保证变值投递,丢失的派发由同一 reconcile 循环恢复。
|
|
@@ -521,6 +522,8 @@ DataBus 将"业务订阅意图"与"transport 当前订阅状态"分离。transpo
|
|
|
521
522
|
|
|
522
523
|
内置 Centrifuge transport 也会保留自己的 Subscriptions 并做协议层重连。两层恢复都要求 `subscribe` / `unsubscribe` 幂等。
|
|
523
524
|
|
|
525
|
+
运行期 `error` 会有意保留 `transportReady`:该标记记录「本次会话中已安装的 transport 曾成功打开」,使 `ready()` 跟随 transport 而不是随协议连接抖动。transport 的*操作*由独立的恢复门(recovery gate)控制:当自动或按需重开尚未完成时,`runTransport()` 会把新的 `subscribe` / `publish` 挂在该门之后,而不是写入刚刚上报 `error` 的连接;门只在重开成功(或 transport 自愈回到 `connected`)后释放,此时所有挂起的操作才在可用 transport 上执行。自动尝试失败后门保持关闭,但下一次显式操作可以立即触发按需重开,而不必再等一个冷却周期;当 `recovery.maxAttempts` 耗尽,或被 `stop()` / 页面隐藏取代时,门会被释放,使文档化的显式重试路径与挂起丢弃语义继续成立。`disconnected` 是干净关闭而非可恢复失败:它不会调度后台 DataBus 重开,只有显式 `start()`、页面恢复、transport 自身的重连,或后续的 transport 操作才会回到 `connected`。最后一条路径很关键:transport 一旦真正到达过 `connected` 后再上报 `disconnected`,ready 快速路径就会被拒绝;这类干净关闭后到达的 `subscribe()` / `publish()` 会被挂在同一个恢复门之后并触发一次按需重开,随后在替换连接上 flush,而不再写入已关闭的连接。对于在首次 `connected` 之前就 resolve `start()` 的 transport(worker 型后端异步上报连接状态),操作仍会直接交给它,因为此时的 `disconnected` 表示「尚未连接」,而不是「已建立的连接断开」。
|
|
526
|
+
|
|
524
527
|
## 生命周期状态机
|
|
525
528
|
|
|
526
529
|
`CrossTabDataBus` 使用多个布尔标志和 Promise gate 来串行化生命周期转换。它们之间的交互是 DataBus 层最复杂的部分。
|
|
@@ -532,7 +535,7 @@ DataBus 将"业务订阅意图"与"transport 当前订阅状态"分离。transpo
|
|
|
532
535
|
| `started` | `boolean` | `start()` 已被调用,且之后没有 `stop()` 完成 |
|
|
533
536
|
| `stopping` | `boolean` | `stop()` 正在执行中;阻止新操作 |
|
|
534
537
|
| `suspended` | `boolean` | Tab 已隐藏;transport 被有意暂停 |
|
|
535
|
-
| `transportReady` | `boolean` | transport
|
|
538
|
+
| `transportReady` | `boolean` | 本次会话中 transport 已成功打开;运行期 `error` 后会保留,使 `ready()` 继续跟随已安装的 transport(待执行操作由恢复门而非该标记控制) |
|
|
536
539
|
| `startPromise` | `Promise \| null` | 并发 `start()` 调用的 gate;操作完成后清除 |
|
|
537
540
|
| `stopPromise` | `Promise \| null` | 显式 `stop()` 及其后排队的 restart 共享的 gate |
|
|
538
541
|
| `queuedStart` | `Promise \| null` | 等待进行中的显式 stop 完成后执行的一次全新 start |
|
|
@@ -570,14 +573,16 @@ DataBus 将"业务订阅意图"与"transport 当前订阅状态"分离。transpo
|
|
|
570
573
|
**关键行为:**
|
|
571
574
|
|
|
572
575
|
- **并发 start**:真实 transport open 在飞行中时,第二次调用 `start()` 返回同一个 promise,任何时候只有一个 transport open 在飞行中。pagehide 产生的 stop 也可能占用 `startPromise`;`start()` 会识别 `startPromise === pendingStop`,把 reopen 排在该 stop 之后,而不是把清理 promise 当作成功启动返回。
|
|
576
|
+
- **失败通知顺序**:transport 可能在 `start()` 仍在飞行时同步上报 `error`。`openTransport()` 会立即更新内部状态,但会延迟用户可见的 `onStatus('error')` 通知,直到它清除 `transportReady`、拆掉初始启动的 cluster、安装失败 transport 的 stop gate、记录失败并清除 `startPromise`;启动失败的 `onError` 通知也在这些清理之后发送。因此在任一回调中同步调用 `start()` 都会在 stop gate 之后开启全新尝试,而不是共享刚刚 reject 的 promise。重试会重置失败账本,被取代 opening 的 rejection 由其自身生命周期清理消费,不会在重试成功后再次写回。真实 open 仍在飞行时的普通并发 start 仍共享同一个 promise。
|
|
573
577
|
- **显式 stop 期间 start**:`stop()` 用共享的 `stopPromise` 服务并发调用者。若 `start()` 在该 stop settle 期间到达,只保存一个 `queuedStart`;stop 的 `finally` 清理生命周期状态后,排队的 start 使用新配置开启全新生命周期。此窗口内的重复调用共享 stop 和 queued-start promise。
|
|
574
578
|
- **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
579
|
- **停止期间发布拒绝**:`stop()` 设置 `stopping` 后,新发起的 `publish()` 与非空 `publishBatch()` 无法到达 transport;它们通过 `onError` 上报错误,而不是让 `runTransport()` 静默返回;空 batch 仍为 no-op。已排队在飞行中 open 之后的发布会被 stop 取消(最新生命周期意图优先),而页面隐藏挂起仍保持文档所述的「不延迟、直接丢弃」语义。
|
|
576
580
|
- **停止期间生命周期操作拒绝**:`stopping` gate 同样覆盖 `subscribe()` 与 `ready()`。迟到的 `subscribe()` 会通过 `onError` 上报并返回 no-op 释放函数,避免 handler 被 `topicHandlers.clear()` 清掉,或订阅漂移进下一次 restart 却没有对应 handler。`ready()` 会 reject,而不是对正在停止的 transport 报告 ready。若 `start()` 已在该 stop 之后排队重启,`ready()` 仍返回 queued-start promise,因为这是最新生命周期意图。
|
|
577
581
|
- **排队重启失败保留**:排队重启若在 transport 启动阶段失败,会清除 `started`,但为后续 `ready()` 调用保留真实错误。未提供 `initialConfig` 时,这些调用会以启动失败 reject,而不是返回通用的配置错误;显式 `start(config)` 仍以全新失败账本执行干净的手动重试。
|
|
578
|
-
- **启动期间隐藏**:`pagehide` 在 `openTransport` 飞行中触发时,`suspendTransport()` 设置 `suspended = true`,并在飞行中的 start 之后链式执行 `transport.stop()`。`openTransport` 的 catch 路径检测到 `suspended` 后放弃本次 open
|
|
582
|
+
- **启动期间隐藏**:`pagehide` 在 `openTransport` 飞行中触发时,`suspendTransport()` 设置 `suspended = true`,并在飞行中的 start 之后链式执行 `transport.stop()`。`openTransport` 的 catch 路径检测到 `suspended` 后放弃本次 open,不视为失败。当重复的 hide/show 让排队的 resume opening 与更早的 stop gate 交错时,挂起会安装新的串行 stop gate 并恢复 `startPromise === pendingStop` 不变量,使下一次 `pageshow` 真正重开,而不是复用已被淘汰的 opening 并永久停留在挂起状态。
|
|
579
583
|
- **被取代 open 失效**:每次全新 start、reopen、suspend 和 stop 都会推进 `lifecycleEpoch`。open 会捕获自己的 epoch;一旦更新的转换接管生命周期,旧 open 的 status/message/error 回调会被忽略,也不会再把 transport 标记为 ready 或执行失败清理。因此 `stop()` 会等待未完成的 open/reopen,并阻止被取代的 open 在 stop 完成后变为 ready。
|
|
580
584
|
- **恢复冷却**:transport 上报 `error` 且 `started` 为 true、`stopping` 为 false 时,`updateStatus` 在 `RECOVERY_COOLDOWN_MS`(1000 ms)后调度自动 `reopenTransport()`。冷却窗口内的第二次错误被抑制,防止紧循环重试。
|
|
585
|
+
- **传输恢复门**:调度重开的同时会抬起恢复门,使冷却期间发起的 `subscribe` / `publish` 无法到达失效连接,等重开成功后才释放。自动尝试失败后门刻意保持关闭:下一次显式操作会立即触发按需重开,而不是等待下一个限速尝试,挂起的操作则在该次成功后一并 flush。`recovery.maxAttempts` 耗尽,或被 `stop()` / `suspendTransport()` 取代时释放门,从而保留显式重试路径与挂起丢弃语义。运行期 `error` 不会清除 `transportReady`——清除它会让调用方流量绕过冷却重开,并在连接尚未承载数据时报告 ready。
|
|
581
586
|
- **暂停期间停止**:`stop()` 设置 `stopping = true`,阻止 `suspendTransport()` 执行。清理过程会 await `startPromise` 和 `pendingStop`,确保任何飞行中的 open 或 stop 完成后才执行最终的 `transport.stop()`。
|
|
582
587
|
|
|
583
588
|
## 降级
|
package/docs/zh/roadmap.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# 路线图
|
|
2
2
|
|
|
3
|
-
0.20.
|
|
3
|
+
0.20.88 正在推进。项目会先持续完成可靠性与协议兼容性迭代,再进入 1.0.0 稳定性冻结。
|
|
4
|
+
|
|
5
|
+
## 0.20.88 已完成范围
|
|
6
|
+
|
|
7
|
+
- 启动失败恢复现在可重入:transport 在初始 `openTransport()` 尚未结算时同步上报 `error`,调用方可以从 `onStatus('error')` 或 `onError` 回调立即重试。失败 open 会先完成清理,重试建立新的生命周期,旧 rejection 不会重新污染已重置的失败账本。
|
|
8
|
+
- 初始 transport open 尚在飞行时反复发生 BFCache `pagehide`/`pageshow`,不再让 bus 永久停留在挂起状态。suspend 只在旧 stop gate 仍代表当前生命周期时复用;否则安装新的串行 stop,使下一次 resume 真正重开 transport。
|
|
9
|
+
- 显式 `start()` 现在是完整的 BFCache 恢复路径:除 transport 外还会恢复跨 Tab 协调,并重新启动 `pagehide` 暂停的 trace metrics、dedup 过期清扫与 replay retention 定时工作。
|
|
10
|
+
|
|
11
|
+
## 0.20.87 已完成范围
|
|
12
|
+
|
|
13
|
+
- transport 恢复与就绪加固:原生 WebSocket 后端现在遵守 `DataBusTransport.start()` 契约(仅在 `open` 后 resolve,握手失败或超过 `connectTimeoutMs` 时 reject),自动恢复会真正创建替代 socket,`getHealthSummary()` 跟随 live transport 状态而不是 `transportReady` 诊断标记。
|
|
14
|
+
- 不再把操作写进已经消失的连接:恢复门会把 `subscribe()` / `publish()` 停放在自动与按需重开之后(包括自动尝试失败但仍保留预算的情况);在真实连接之后出现的干净 `disconnected` 现在会触发一次按需重开,而不是交给已关闭的 socket;而异步上报连接状态的 worker 型后端仍保有其「尚未连接」窗口,不会被动重开。
|
|
15
|
+
- 生命周期与 `ready()` 边界修复:BFCache 挂起期间 `ready()` 会 reject;transport 自身 `stop()` reject 或抛错时 `stop()` 仍能 resolve;一次打开失败在两个恢复账本中只打一次时间戳;运行期 transport 错误会进入恢复账本;被取代的异步打开不再拆除更新的 suspend/resume 转换。
|
|
16
|
+
- 适配器与工具链:React/Vue `useCrossTabHealth` 在 `intervalMs` 变化时无需重建 bus 即可生效;`vitest` 及其 coverage-v8 provider 升级到 5.0.1 补丁版。
|
|
4
17
|
|
|
5
18
|
## 0.20.86 已完成范围
|
|
6
19
|
|
package/docs/zh/transports.md
CHANGED
|
@@ -32,6 +32,10 @@ interface DataBusTransportHandlers<TData = unknown> {
|
|
|
32
32
|
三个回调。连接状态变化时调 `onStatus`;收到 publication 时调 `onMessage`;
|
|
33
33
|
非致命错误调 `onError`(DataBus 有恢复冷却窗口,避免抖动连接死循环重试)。
|
|
34
34
|
|
|
35
|
+
`start()` MUST 在后端真正连接后才 settle 返回的 Promise;尝试失败时必须 reject。
|
|
36
|
+
DataBus 把这个 settlement 当作就绪与恢复边界:处于 `CONNECTING` 的 socket 不算
|
|
37
|
+
就绪,握手成功前不能释放排队操作。可能长期卡住的后端应自行设置握手超时并 reject。
|
|
38
|
+
|
|
35
39
|
## 架构分层
|
|
36
40
|
|
|
37
41
|
```
|
|
@@ -141,9 +145,17 @@ Centrifuge session 遵循同一个传输无关契约:不带 metadata 的 paylo
|
|
|
141
145
|
形状;带 metadata 的 publish 使用 `{ data, messageId?, timestamp? }`,入站还接受
|
|
142
146
|
标准嵌套 `DataBusPublicationEnvelope`。
|
|
143
147
|
|
|
148
|
+
`start()` 只在 `open` 后 resolve;握手前发生 error、close,或超过
|
|
149
|
+
`connectTimeoutMs` 时 reject(默认 `30000` ms;`0` / `Infinity` 表示无限等待)。
|
|
150
|
+
超时的 socket 会被关闭,该尝试之后迟到的 `open` 会被忽略。
|
|
151
|
+
|
|
144
152
|
生命周期映射:`open` → `connected`,`close` → `disconnected`,`error` → `error`
|
|
145
|
-
(触发 DataBus 自动恢复)。socket
|
|
146
|
-
|
|
153
|
+
(触发 DataBus 自动恢复)。socket 原地重连时自动重发订阅帧。成功的重开既可以
|
|
154
|
+
复用同一个 socket 对象,也可以通过工厂创建替代 socket;被取代 socket 的迟到
|
|
155
|
+
回调会被忽略,因此失败连接的 close 或 message 不会污染恢复后的连接。干净的
|
|
156
|
+
`disconnected` 不会调度后台自动恢复,但下一次 `subscribe()` / `publish()` 会触发
|
|
157
|
+
一次按需重开并在其后 flush,因此关闭后的操作不会被写进已关闭的 socket。支持
|
|
158
|
+
pattern 的服务器可以以具体 topic 标注发布——见 [api.md](../api.md) 中的通配符订阅。
|
|
147
159
|
|
|
148
160
|
## 工厂入口
|
|
149
161
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cross-tab-worker-databus",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.88",
|
|
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",
|
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
"@testing-library/react": "^16.3.3",
|
|
129
129
|
"@types/node": "^26.5.1",
|
|
130
130
|
"@types/react": "^19.3.0",
|
|
131
|
-
"@vitest/coverage-v8": "^5.0.
|
|
131
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
132
132
|
"esbuild": "^0.28.2",
|
|
133
133
|
"eslint": "^10.10.0",
|
|
134
134
|
"fake-indexeddb": "^6.2.5",
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
"react-dom": "^19.3.0",
|
|
139
139
|
"typescript": "^6.0.3",
|
|
140
140
|
"typescript-eslint": "^8.70.0",
|
|
141
|
-
"vitest": "^5.0.
|
|
141
|
+
"vitest": "^5.0.1",
|
|
142
142
|
"vue": "^3.5.42"
|
|
143
143
|
},
|
|
144
144
|
"engines": {
|