cross-tab-worker-databus 0.1.0
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 +28 -0
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/README.zh.md +98 -0
- package/dist/centrifuge-protocol.d.ts +77 -0
- package/dist/centrifuge-protocol.d.ts.map +1 -0
- package/dist/centrifuge-session.d.ts +39 -0
- package/dist/centrifuge-session.d.ts.map +1 -0
- package/dist/centrifuge.d.ts +135 -0
- package/dist/centrifuge.d.ts.map +1 -0
- package/dist/centrifuge.js +407 -0
- package/dist/centrifuge.js.map +7 -0
- package/dist/centrifuge.shared.worker.js +5220 -0
- package/dist/centrifuge.shared.worker.js.map +7 -0
- package/dist/centrifuge.worker.js +5109 -0
- package/dist/centrifuge.worker.js.map +7 -0
- package/dist/chunk-GABYBK7I.js +1527 -0
- package/dist/chunk-GABYBK7I.js.map +7 -0
- package/dist/core/cluster.d.ts +219 -0
- package/dist/core/cluster.d.ts.map +1 -0
- package/dist/core/data-bus.d.ts +133 -0
- package/dist/core/data-bus.d.ts.map +1 -0
- package/dist/core/environment.d.ts +67 -0
- package/dist/core/environment.d.ts.map +1 -0
- package/dist/core/hash.d.ts +11 -0
- package/dist/core/hash.d.ts.map +1 -0
- package/dist/core/routing.d.ts +42 -0
- package/dist/core/routing.d.ts.map +1 -0
- package/dist/core/storage-batch.d.ts +35 -0
- package/dist/core/storage-batch.d.ts.map +1 -0
- package/dist/core/trace.d.ts +126 -0
- package/dist/core/trace.d.ts.map +1 -0
- package/dist/core/types.d.ts +112 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +7 -0
- package/dist/worker-mode.d.ts +25 -0
- package/dist/worker-mode.d.ts.map +1 -0
- package/dist/workers/centrifuge.shared.worker.d.ts +2 -0
- package/dist/workers/centrifuge.shared.worker.d.ts.map +1 -0
- package/dist/workers/centrifuge.worker.d.ts +2 -0
- package/dist/workers/centrifuge.worker.d.ts.map +1 -0
- package/dist/workers/port-reaper.d.ts +52 -0
- package/dist/workers/port-reaper.d.ts.map +1 -0
- package/docs/README.md +21 -0
- package/docs/api.md +261 -0
- package/docs/architecture.md +514 -0
- package/docs/capabilities.md +41 -0
- package/docs/configuration.md +211 -0
- package/docs/getting-started.md +161 -0
- package/docs/zh/README.md +23 -0
- package/docs/zh/api.md +261 -0
- package/docs/zh/architecture.md +515 -0
- package/docs/zh/capabilities.md +41 -0
- package/docs/zh/configuration.md +211 -0
- package/docs/zh/getting-started.md +161 -0
- package/package.json +71 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
> [中文](./zh/architecture.md) | English
|
|
2
|
+
|
|
3
|
+
# Architecture
|
|
4
|
+
|
|
5
|
+
## Runtime Model
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
graph TB
|
|
9
|
+
subgraph Browser["Browser (same-origin)"]
|
|
10
|
+
subgraph TabA["Tab A"]
|
|
11
|
+
AppA["Business Module"] --> BusA["CrossTabDataBus"]
|
|
12
|
+
BusA --> RuntimeA["WorkerClusterRuntime"]
|
|
13
|
+
BusA --> WorkerA["Dedicated / Shared Worker A"]
|
|
14
|
+
end
|
|
15
|
+
subgraph TabB["Tab B"]
|
|
16
|
+
AppB["Business Module"] --> BusB["CrossTabDataBus"]
|
|
17
|
+
BusB --> RuntimeB["WorkerClusterRuntime"]
|
|
18
|
+
BusB --> WorkerB["Dedicated / Shared Worker B"]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
RuntimeA <--> Channel["BroadcastChannel Control Plane"]
|
|
23
|
+
RuntimeB <--> Channel
|
|
24
|
+
RuntimeA <--> Registry["localStorage Worker Registration"]
|
|
25
|
+
RuntimeB <--> Registry
|
|
26
|
+
RuntimeA <--> Routes["localStorage Topic Routes"]
|
|
27
|
+
RuntimeB <--> Routes
|
|
28
|
+
WorkerA --> Server["Centrifuge / realtime server"]
|
|
29
|
+
WorkerB --> Server
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
By default, when `workerMode: 'dedicated'`, each Tab has its own dedicated transport Worker. When configured as `shared` or `auto` and the browser supports SharedWorker, same-origin tabs share the same SharedWorker; each connection port within the SharedWorker creates its own independent `CentrifugeSession`, so one Tab refreshing or stopping does not affect other Tabs. The `auto` mode degrades in order of **SharedWorker → Dedicated Worker → Local mode**, while the `dedicated` mode degrades in order of **Dedicated Worker → SharedWorker → Local mode**. `BroadcastChannel` is only responsible for control messages and real-time publication forwarding; localStorage is only responsible for eventually-consistent coordination metadata.
|
|
33
|
+
|
|
34
|
+
Because `MessagePort` has no `close` event, a tab that crashes before sending `STOP` would otherwise leak its session and WebSocket. The main thread therefore sends a `PING` every 10 seconds, and the SharedWorker reaps any port that stays silent for more than 30 seconds, releasing the session and its subscriptions.
|
|
35
|
+
|
|
36
|
+
## Layers
|
|
37
|
+
|
|
38
|
+
| Layer | Entry | Responsibility |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| DataBus | `CrossTabDataBus` | Local handler reference counting, message dispatch, state and transport lifecycle |
|
|
41
|
+
| Cluster Coordination | `WorkerClusterRuntime` | Worker registration, roles, heartbeat, Topic owner, migration and broadcast protocol |
|
|
42
|
+
| Transport | `DataBusTransport` | Executes subscribe, unsubscribe, publish on the real Worker/connection |
|
|
43
|
+
| Centrifuge | `CentrifugeWorkerTransport` | Protocol adaptation between the main thread and the built-in Centrifuge Worker |
|
|
44
|
+
|
|
45
|
+
## Glossary
|
|
46
|
+
|
|
47
|
+
Terms are explained in plain language; the code and the rest of this document use the short names.
|
|
48
|
+
|
|
49
|
+
| Term | Short name in code | Plain-language meaning |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| **Topic** | `topic` | A named channel (e.g. `price.feed`) that applications subscribe to or publish on. |
|
|
52
|
+
| **Topic key** | `topicKey` | An opaque 128-bit hash of the Topic name. The Topic name itself is never persisted in coordination storage. |
|
|
53
|
+
| **Tab** | `tabId` | One browser page instance. `tabId` survives refresh so a tab keeps its identity across the page lifecycle. |
|
|
54
|
+
| **Worker** | `workerId` | One runtime instance inside a Tab. Each Worker publishes its own heartbeat and can own Topics. A Tab can briefly run two Workers during a restart/handoff. |
|
|
55
|
+
| **Topic owner** | — | The Worker responsible for the real transport subscription of a Topic. "Owner" is a hat a Worker wears, not a permanent role: it receives the Topic's publications from the server and fans them out to other Tabs. |
|
|
56
|
+
| **Assignment** | `assignedTopics` | The set of Topics a Worker currently owns. |
|
|
57
|
+
| **Active / standby** | `role` | `active` Workers are eligible to become new Topic owners; `standby` Workers are not. A hidden tab is still `active` if it already owns Topics. |
|
|
58
|
+
| **Subscriber** | `subscriber` | A Tab that holds a local subscription record for a Topic. |
|
|
59
|
+
| **Route** | `route` | The persisted record mapping a `topicKey` to its owner Worker. |
|
|
60
|
+
| **Sticky** | — | Existing routes keep their owner while that owner is alive; load and visibility only affect placement of brand-new routes. |
|
|
61
|
+
| **Heartbeat** | `heartbeatAt` | A Worker's periodic liveness write to storage. Workers past `workerTtlMs` without refreshing are considered dead. |
|
|
62
|
+
| **Handoff** | `handoffFromWorkerId` | The graceful passing of a Topic from an old owner to a new one (e.g. on `pagehide`), with a strict release-ACK protocol so no Topic is ever owned twice simultaneously. |
|
|
63
|
+
| **Generation** | `generation` | A monotonic counter on each route. Handoff ACKs must reference a generation at least as new as the route's, so stale ACKs are ignored. |
|
|
64
|
+
| **Local mode** | `coordinated: false` | Degraded operation when storage or BroadcastChannel is unavailable: no cross-Tab routing, the Tab only uses its own transport. |
|
|
65
|
+
|
|
66
|
+
## Storage Structure
|
|
67
|
+
|
|
68
|
+
All keys are isolated by `createOpaqueKey(clusterKey)`. Topics are also stored as 128-bit opaque keys.
|
|
69
|
+
|
|
70
|
+
BroadcastChannel messages carry topic names, event types, and publication payloads in plaintext. Only the channel name (derived from `clusterKey`) is hashed. If topic names are sensitive, avoid including them as part of the plaintext payload, or use an end-to-end encryption layer on top of the data bus.
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
cross-tab-worker-databus:{clusterHash}:worker:{workerId}
|
|
74
|
+
cross-tab-worker-databus:{clusterHash}:route:{topicKey}
|
|
75
|
+
cross-tab-worker-databus:{clusterHash}:subscriber:{topicKey}:{tabId}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Unlike the old single-JSON route table, subscribers use per-Tab independent keys. When Tab A and Tab B subscribe/unsubscribe concurrently, they do not perform a read-modify-write on the same `subscribers[]`, structurally reducing the probability of lost updates.
|
|
79
|
+
|
|
80
|
+
### Worker Record
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
interface WorkerRecord {
|
|
84
|
+
workerId: string;
|
|
85
|
+
tabId: string;
|
|
86
|
+
load: number;
|
|
87
|
+
role: 'active' | 'standby';
|
|
88
|
+
status: 'connecting' | 'connected' | 'disconnected' | 'error';
|
|
89
|
+
visibilityState: 'visible' | 'hidden';
|
|
90
|
+
heartbeatAt: number;
|
|
91
|
+
registeredAt: number;
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Each Worker writes its own record independently. `load` is the number of Topics it is responsible for, not CPU percentage.
|
|
96
|
+
|
|
97
|
+
### Topic Route
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
interface WorkerRoute {
|
|
101
|
+
topicKey: string;
|
|
102
|
+
workerId: string;
|
|
103
|
+
tabId: string;
|
|
104
|
+
updatedAt: number;
|
|
105
|
+
generation: number;
|
|
106
|
+
handoffFromWorkerId?: string;
|
|
107
|
+
confirmedAt?: number;
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`generation` increments on every re-assignment and must match across the handoff handshake; `handoffFromWorkerId` records the previous owner during a graceful handoff. The interface above matches the current protocol — see [Failover](#failover) for how these two fields drive takeover.
|
|
112
|
+
|
|
113
|
+
Routes do not store the original topic string or payload. When the actual owner receives `CONTROL/SUBSCRIBE`, the original topic string is only passed through the BroadcastChannel in-memory message. `confirmedAt` is written after the owner processes the control message; before the route is confirmed, the subscriber Runtime holding the original topic string will resend `SUBSCRIBE` to recover from BroadcastChannel message loss that results in "a route without a real subscription".
|
|
114
|
+
|
|
115
|
+
### How `topic`, `topicKey`, `tabId`, `workerId`, and BroadcastChannel relate
|
|
116
|
+
|
|
117
|
+
These identifiers represent different layers:
|
|
118
|
+
|
|
119
|
+
| Object | Meaning | Main use | Persisted in coordination storage |
|
|
120
|
+
|---|---|---|---|
|
|
121
|
+
| `topic` | Original application Topic string | Passed to transport `subscribe`, `unsubscribe`, and `publish` | No; kept in Runtime memory and control messages |
|
|
122
|
+
| `topicKey` | Stable opaque key from `createOpaqueKey(topic)` | Joins route and subscriber records | Yes |
|
|
123
|
+
| `tabId` | Stable identity of a browser Tab | Identifies which Tab subscribes to a `topicKey` | Yes, in subscriber keys |
|
|
124
|
+
| `workerId` | Identity of the current Runtime/Worker instance | Identifies the Worker that owns the transport subscription | Yes, in worker/route records |
|
|
125
|
+
| `BroadcastChannel` | Same-origin, in-memory real-time channel | Carries control actions, publication events, and reconciliation signals | No |
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
topic
|
|
129
|
+
└─ createOpaqueKey(topic) → topicKey
|
|
130
|
+
├─ route:{topicKey}
|
|
131
|
+
│ └─ workerId / tabId / generation / confirmedAt
|
|
132
|
+
└─ subscriber:{topicKey}:{tabId}
|
|
133
|
+
|
|
134
|
+
BroadcastChannel CONTROL
|
|
135
|
+
└─ topic + topicKey + sourceWorkerId + targetWorkerId + action
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`topicKey` links storage records to control messages, but it cannot be reversed to recover the original `topic`. Only a live Runtime retains the in-memory `topicKey → topic` mapping.
|
|
139
|
+
|
|
140
|
+
### In-memory topic key cache (`knownTopics`)
|
|
141
|
+
|
|
142
|
+
Each Runtime maintains a `Map<topicKey, topic>` called `knownTopics` that serves as the reverse-lookup cache from opaque key to plaintext topic. It is populated by `rememberTopic()`, which is called on every `subscribe`, `publish`, `unsubscribe`, and inbound `CONTROL` message.
|
|
143
|
+
|
|
144
|
+
The cache exists for two reasons:
|
|
145
|
+
|
|
146
|
+
1. **Storage-less fallback.** When localStorage is unavailable (degraded mode), `readRoute()` and `readSubscriberTabIds()` have no persisted records to query. They reconstruct the route from in-memory state — but that requires recovering the plaintext `topic` from a `topicKey`. Without `knownTopics`, a topic whose key was evicted would silently return `null` from `readRoute()` even though the worker still owns it.
|
|
147
|
+
|
|
148
|
+
2. **Avoid re-hashing on every reconcile.** Each reconcile cycle iterates `subscribedTopics` and calls `rememberTopic` for each topic. The cache is updated unconditionally (hash is cheap, so there is no hit/miss penalty), but the reverse mapping is essential for the storage-less path.
|
|
149
|
+
|
|
150
|
+
**Cap and eviction.** The cache is capped at `MAX_KNOWN_TOPICS = 500` entries. This limit prevents a misbehaving or malicious peer from exhausting memory by referencing arbitrary topics in control messages — every `CONTROL` message the handler processes calls `rememberTopic`, which would otherwise grow the map unboundedly.
|
|
151
|
+
|
|
152
|
+
Eviction is FIFO (insertion order, Map iteration order). When the cache exceeds the cap, the oldest entry (first key in Map iteration) is removed:
|
|
153
|
+
|
|
154
|
+
- An entry is **never evicted** if the current worker still owns it (`assignedTopics.has(oldest)` guard), because the storage-less `readRoute` path depends on it.
|
|
155
|
+
- The entry being inserted is never evicted in the same step (`oldest !== topicKey` guard).
|
|
156
|
+
- Reads do not promote recency, so this is not true LRU. Hashing is cheap enough that a missed reverse-lookup merely recomputes the key.
|
|
157
|
+
|
|
158
|
+
**`isAssigned` bypasses the cache.** `isAssigned(topic)` calls `createOpaqueKey(topic)` directly rather than `rememberTopic()`. This is deliberate: `isAssigned` is a read-only query, not a state change, so it must not populate `knownTopics` (which could evict an entry the storage-less path needs). It also prefers the synchronous `assignedTopics` Map over reading the route from storage, avoiding a race with the `BatchingStorageWriter` flush window.
|
|
159
|
+
|
|
160
|
+
**Opaque key collision.** `createOpaqueKey` is a non-cryptographic 128-bit hash. The birthday collision bound (~2⁶⁴ for 50% probability) is far beyond the number of topics a single cluster handles (thousands at most). Similarly, `clusterKey` is hashed via `createOpaqueKey` to derive the storage prefix and BroadcastChannel name. In practice, `clusterKey` is always a connection URL or a developer-controlled namespace — naturally unique, so cross-cluster collision is not a concern.
|
|
161
|
+
|
|
162
|
+
**`clusterKey` isolation.** The `clusterKey` defines the cluster boundary. Two DataBus instances with different `clusterKey` values — even in the same origin — operate on completely isolated storage namespaces and BroadcastChannel names, even if they happen to use the same transport connection. This is how different logical clusters (e.g. market data vs. notifications) coexist without cross-talk.
|
|
163
|
+
|
|
164
|
+
**`knownTopics` lifecycle.** The cache is populated, read, and cleaned at specific points:
|
|
165
|
+
|
|
166
|
+
| Event | `knownTopics` mutation | Why |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| `subscribe(topic)` | `rememberTopic(topic)` → `set(topicKey, topic)` | Populate the reverse mapping; needed for storage-less `readRoute` |
|
|
169
|
+
| `publish(topic, data)` | `rememberTopic(topic)` → `set(topicKey, topic)` | Populate; same reason |
|
|
170
|
+
| `unsubscribe(topic)` | `delete(topicKey)` if not in `assignedTopics` | No longer needed; only keep it if we still own the topic |
|
|
171
|
+
| `CONTROL` received (any action: SUBSCRIBE / UNSUBSCRIBE / PUBLISH) | `rememberTopic(message.topic)` → `set(topicKey, topic)` | Every inbound control message carries the plaintext topic and the handler caches it before acting |
|
|
172
|
+
| `CONTROL/UNSUBSCRIBE` received | no direct deletion | `rememberTopic` still caches the topic; the entry is later removed by `reconcileAssignedTopics` once the route no longer points to this worker |
|
|
173
|
+
| `reconcileAssignedTopics` | `delete(topicKey)` if not subscribed and not owned | Route no longer points to us — clean up unless we're still a subscriber |
|
|
174
|
+
| `stop()` | `clear()` | Full teardown |
|
|
175
|
+
| FIFO eviction (next `rememberTopic` call) | `delete(oldest)` if `!assignedTopics.has(oldest)` | Cache size exceeded `MAX_KNOWN_TOPICS`; never evict owned keys |
|
|
176
|
+
|
|
177
|
+
**Storage-less fallback dependency.** When `this.storage` is `null` (degraded mode), `readRoute()` and `readSubscriberTabIds()` cannot query persisted records. They reconstruct routes from in-memory state alone:
|
|
178
|
+
|
|
179
|
+
- `readRoute(topicKey)` → looks up `knownTopics.get(topicKey)` to recover the plaintext topic, then checks `subscribedTopics.has(topic)` or `assignedTopics.has(topicKey)` to determine if this worker is the owner.
|
|
180
|
+
- `readSubscriberTabIds(topicKey, workers)` → `knownTopics.get(topicKey)` recovers the plaintext topic, then checks `subscribedTopics.has(topic)` — if we are a subscriber, we are the only subscriber (no storage means no cross-tab coordination).
|
|
181
|
+
|
|
182
|
+
This is why `assignedTopics` guards the FIFO eviction: evicting a key we still own would silently break `readRoute()` in storage-less mode, causing `isAssigned()` to disagree with `readRoute()`.
|
|
183
|
+
|
|
184
|
+
### One subscription and publication flow
|
|
185
|
+
|
|
186
|
+
```mermaid
|
|
187
|
+
sequenceDiagram
|
|
188
|
+
participant App as App (Tab A)
|
|
189
|
+
participant RuntimeA as Runtime A
|
|
190
|
+
participant Storage as localStorage
|
|
191
|
+
participant Channel as BroadcastChannel
|
|
192
|
+
participant RuntimeB as Owner Runtime B
|
|
193
|
+
participant Transport as Transport/server
|
|
194
|
+
|
|
195
|
+
App->>RuntimeA: subscribe(topic, handler)
|
|
196
|
+
RuntimeA->>RuntimeA: derive topicKey
|
|
197
|
+
RuntimeA->>Storage: write subscriber:{topicKey}:{tabId}
|
|
198
|
+
RuntimeA->>Storage: read or create route:{topicKey}
|
|
199
|
+
RuntimeA->>Channel: CONTROL/SUBSCRIBE(topic, topicKey, targetWorkerId)
|
|
200
|
+
Channel->>RuntimeB: deliver control message
|
|
201
|
+
RuntimeB->>Transport: subscribe(topic)
|
|
202
|
+
RuntimeB->>Storage: write route.confirmedAt
|
|
203
|
+
Transport-->>RuntimeB: publication(topic, payload)
|
|
204
|
+
RuntimeB->>Channel: EVENT/DATABUS_PUBLICATION
|
|
205
|
+
Channel->>RuntimeA: deliver event
|
|
206
|
+
RuntimeA->>App: invoke handler(payload)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
A second Tab subscribing to the same Topic adds only its own subscriber record; it does not create another transport subscription while the existing owner is alive. Unsubscribe removes the current Tab's subscriber record. The owner unsubscribes the transport and removes the route only when no subscriber remains.
|
|
210
|
+
|
|
211
|
+
### Console diagnostics
|
|
212
|
+
|
|
213
|
+
If the application exposes the DataBus instance as `window.__bus`, the live Runtime can be inspected with:
|
|
214
|
+
|
|
215
|
+
```js
|
|
216
|
+
__bus.getClusterSnapshot().subscribedTopics
|
|
217
|
+
__bus.getClusterSnapshot().assignedTopics
|
|
218
|
+
__bus.getClusterSnapshot().knownTopics
|
|
219
|
+
console.table(__bus.getClusterSnapshot().routes)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`routes` now includes the plaintext `topic` (injected from the in-memory `knownTopics` cache), so each entry shows both the opaque key and the original topic name. `knownTopics` exposes the full `topicKey → topic` mapping for debugging. BroadcastChannel has no history API; inspect live messages by enabling trace or temporarily logging the `postMessage` and receive paths.
|
|
223
|
+
|
|
224
|
+
### Why Multiple localStorage Keys
|
|
225
|
+
|
|
226
|
+
This decentralized structure is a trade-off for concurrency correctness, not for reducing event listeners:
|
|
227
|
+
|
|
228
|
+
| Approach | Write Conflict | Cleanup Granularity | Main Issue |
|
|
229
|
+
|---|---|---|---|
|
|
230
|
+
| Single large JSON for Worker/route/subscriber | High | Only whole read/write | Multiple Tabs doing read-modify-write concurrently can easily overwrite each other, losing subscribers |
|
|
231
|
+
| Independent key per entity | Low | Can clean up per Worker, per route, per Topic+Tab precisely | More keys, requires TTL-based garbage collection |
|
|
232
|
+
|
|
233
|
+
The SDK does not rely on `storage` events to drive coordination; control notifications use BroadcastChannel. Although Worker heartbeats update their own independent key, this does not trigger repeated business callbacks or message dispatch within the SDK. The core benefit of separate keys is that different Tabs write different records, avoiding overwrite contention on a shared large object.
|
|
234
|
+
|
|
235
|
+
Under normal conditions, the number of keys is approximately: `Number of Workers + Number of Topic routes + Number of Topic/Tab subscription relationships`. The Runtime cleans up timed-out Workers, orphaned subscribers without active Tabs, and orphaned routes that have exceeded the Worker TTL and no longer have subscribers. Other naming structures left over from older versions do not belong to the current SDK protocol and do not participate in current route resolution.
|
|
236
|
+
|
|
237
|
+
### Storage Write Coalescing
|
|
238
|
+
|
|
239
|
+
Coordination metadata writes first enter an in-memory pending table, where they are merged by key within the same task (heartbeats, route confirmations, and subscriber updates share one flush), then batch-flushed to localStorage via a microtask. When a flush encounters a quota or write failure, it retries with exponential backoff from `50ms → 1600ms`. The current Tab's transport is not interrupted by coordination write failures. `clear()` resets the backoff counter to avoid starting retries from a delayed initial value after frequent cleaning.
|
|
240
|
+
|
|
241
|
+
Reads always see the not-yet-flushed pending values within the same task; cross-tab visibility is guaranteed by the microtask flush and the synchronous flush on `pagehide` / `stop()`. During `pagehide`, the owner writes and flushes the transferred route and its worker removal before broadcasting `REGISTRY`. If the unload-time `CONTROL / SUBSCRIBE` message is lost, receiving tabs therefore reconcile against the final persisted topology instead of waiting for their next heartbeat.
|
|
242
|
+
|
|
243
|
+
## Key State Inventory
|
|
244
|
+
|
|
245
|
+
Every keyed piece of state in the system — the full picture of what the previous sections described piece by piece. Each row has its own lifecycle; that is **why they are separate** and must not be merged:
|
|
246
|
+
|
|
247
|
+
| State | Owner class | Key | Value | Lifecycle | Why it is separate |
|
|
248
|
+
|---|---|---|---|---|---|
|
|
249
|
+
| `topicHandlers` | `CrossTabDataBus` | Plaintext `topic` | `Set<handler>` | Added/removed by app `subscribe`/`unsubscribe`; entry deleted when its last handler leaves | Reference-counts application-level handlers; belongs to the business layer |
|
|
250
|
+
| `transportSubscribedTopics` | `CrossTabDataBus` | Plaintext `topic` | marker | Cleared on disconnect; replayed from `assignedTopics` on reconnect | Tracks what the live transport connection actually holds; dies with the connection |
|
|
251
|
+
| `subscribedTopics` | `WorkerClusterRuntime` | Plaintext `topic` | marker | Grows as the first local handler subscribes; shrinks when the last one leaves | The Tab's durable subscription intent, survives transport failures |
|
|
252
|
+
| `assignedTopics` | `WorkerClusterRuntime` | `topicKey` | Plaintext `topic` | Set on receiving `CONTROL/SUBSCRIBE`; cleared on `CONTROL/UNSUBSCRIBE` or handoff | The authoritative "what I own" set; drives `isAssigned` and load |
|
|
253
|
+
| `knownTopics` | `WorkerClusterRuntime` | `topicKey` | Plaintext `topic` | FIFO-capped at 500; never evicts owned keys | The reverse-lookup cache; also the only source of plaintext in storage-less mode |
|
|
254
|
+
| Storage `worker:` | persisted | `clusterHash:…:worker:{workerId}` | JSON `WorkerRecord` | Heartbeat refresh; pruned after `workerTtlMs` | Cross-tab liveness discovery |
|
|
255
|
+
| Storage `route:` | persisted | `clusterHash:…:route:{topicKey}` | JSON `WorkerRoute` | Created/stamped by subscriber; pruned when no subscribers + TTL expired | Cross-tab owner mapping |
|
|
256
|
+
| Storage `subscriber:` | persisted | `clusterHash:…:subscriber:{topicKey}:{tabId}` | JSON `TopicSubscriberRecord` | Written per Tab subscription; pruned when the Tab dies | Cross-tab subscriber intent |
|
|
257
|
+
|
|
258
|
+
**How the three in-memory topic forms relate** (`knownTopics` ↔ `assignedTopics` ↔ the four plaintext sets):
|
|
259
|
+
|
|
260
|
+
```text
|
|
261
|
+
app subscribe/unsubscribe loop
|
|
262
|
+
│ (handler reference counting)
|
|
263
|
+
▼
|
|
264
|
+
topicHandlers ──────────────► subscribedTopics ──► storage subscriber + route
|
|
265
|
+
(plaintext topic) (plaintext topic) (topicKey)
|
|
266
|
+
│ CONTROL/SUBSCRIBE on the wire
|
|
267
|
+
▼
|
|
268
|
+
assignedTopics ──► transport subscription
|
|
269
|
+
(topicKey) (plaintext topic again)
|
|
270
|
+
│
|
|
271
|
+
└─► knownTopics: reverse cache used by
|
|
272
|
+
readRoute/readSubscriberTabIds (esp. storage-less)
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The two `topicKey → topic` maps (`assignedTopics`, `knownTopics`) deliberately hold **the same pairs with different lifecycles**: `assignedTopics` is authoritative and never evicts, `knownTopics` is a bounded cache to keep plaintext reachable when no storage is present. After a plaintext topic leaves `assignedTopics` and `knownTopics` (via `reconcileAssignedTopics` or eviction), the Runtime can still read routes by `topicKey` — it just can no longer reverse them to plaintext.
|
|
276
|
+
|
|
277
|
+
## BroadcastChannel Protocol
|
|
278
|
+
|
|
279
|
+
All real-time coordination flows through one BroadcastChannel per cluster, whose name is derived from `clusterKey`. Messages on it exist only in memory: they never touch localStorage and never pass through the transport server. Four message types are exchanged:
|
|
280
|
+
|
|
281
|
+
| Type | Direction | Purpose |
|
|
282
|
+
|---|---|---|
|
|
283
|
+
| `CONTROL` | point-to-point (A → B) | Ask the target Worker to `SUBSCRIBE`, `UNSUBSCRIBE`, or `PUBLISH` a topic. Carries `action`, `topic`, `topicKey`, `targetWorkerId`, and an optional `data` payload. |
|
|
284
|
+
| `EVENT` | broadcast (owner → all Tabs) | Fan out a publication that the transport delivered to the owning Worker. Carries `eventType` and `payload`. |
|
|
285
|
+
| `REGISTRY` | broadcast | Nudge every Tab to reconcile immediately after a registry or route write, instead of waiting for the next heartbeat. |
|
|
286
|
+
| `ROUTE_RELEASED` | point-to-point (old owner → new owner) | Acknowledge a graceful handoff; only the new owner whose route `generation` matches may `SUBSCRIBE` (see Failover). |
|
|
287
|
+
|
|
288
|
+
The owning Worker filters every publication it receives with `isAssigned(topic)`, and every Tab filters inbound `EVENT` messages through its local subscriber records — each message is therefore dispatched exactly once. BroadcastChannel never echoes a message back to its sender, which is also why the owner does not double-dispatch its own broadcast.
|
|
289
|
+
|
|
290
|
+
## Owner Selection
|
|
291
|
+
|
|
292
|
+
1. Workers with status `connecting` / `connected` are prioritized for the candidate set.
|
|
293
|
+
2. If a visible Tab exists, visible Workers are preferred when choosing an owner for a new Topic; when all Tabs are hidden, hidden Workers remain eligible.
|
|
294
|
+
3. Sorted by `registeredAt, workerId`, up to 3 active Workers are selected as new-route candidates.
|
|
295
|
+
4. An existing route remains sticky as long as its owner Worker record is alive, regardless of load, visibility, or whether the owner remains in the new-route candidate set.
|
|
296
|
+
5. A second Tab subscribing to an existing Topic only writes its subscriber record. It does not modify the route or call its own transport `subscribe`.
|
|
297
|
+
6. Only a Topic without a route, or a route whose owner has departed or expired by heartbeat TTL, is assigned to the least-loaded candidate Worker.
|
|
298
|
+
7. A new route is considered unconfirmed until the owner writes `confirmedAt`; the subscriber will automatically resend the control message.
|
|
299
|
+
|
|
300
|
+
## Subscription Flow
|
|
301
|
+
|
|
302
|
+
```mermaid
|
|
303
|
+
sequenceDiagram
|
|
304
|
+
participant App as Business Module
|
|
305
|
+
participant Bus as CrossTabDataBus
|
|
306
|
+
participant Route as Topic Route
|
|
307
|
+
participant Channel as BroadcastChannel
|
|
308
|
+
participant Owner as Owner Worker
|
|
309
|
+
|
|
310
|
+
App->>Bus: subscribe(topic, handler)
|
|
311
|
+
Bus->>Bus: First handler in this Tab?
|
|
312
|
+
Bus->>Route: Write subscriber:{topicKey}:{tabId}
|
|
313
|
+
Route-->>Bus: Current owner
|
|
314
|
+
alt Owner does not exist or is invalid
|
|
315
|
+
Bus->>Route: Write lowest-load owner
|
|
316
|
+
Bus->>Channel: CONTROL / SUBSCRIBE
|
|
317
|
+
Channel->>Owner: transport.subscribe(topic)
|
|
318
|
+
end
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Within the same DataBus instance, multiple handlers subscribing to the same topic are only registered once; the Tab's subscription is only canceled from the cluster after the last handler is released.
|
|
322
|
+
|
|
323
|
+
### Subscription state layers
|
|
324
|
+
|
|
325
|
+
The system maintains four independent subscription-tracking sets. Understanding their relationship is key to the architecture:
|
|
326
|
+
|
|
327
|
+
| Set | Location | Tracks | Lifecycle |
|
|
328
|
+
|---|---|---|---|
|
|
329
|
+
| `topicHandlers` | `CrossTabDataBus` | Application-level handler references per topic | Added/removed by `subscribe(topic, handler)` / `unsubscribe(topic, handler)` |
|
|
330
|
+
| `subscribedTopics` | `WorkerClusterRuntime` | Topics this tab has asked the cluster to coordinate | Added when `topicHandlers` goes 0→1; removed when it goes n→0 |
|
|
331
|
+
| `assignedTopics` | `WorkerClusterRuntime` | Topics this worker is the owner of (transport subscription responsibility) | Set on receiving `CONTROL/SUBSCRIBE`; cleared on `CONTROL/UNSUBSCRIBE` or handoff |
|
|
332
|
+
| `transportSubscribedTopics` | `CrossTabDataBus` | Topics the transport has been asked to subscribe to | Cleared on disconnect; replayed from `assignedTopics` on reconnect |
|
|
333
|
+
|
|
334
|
+
**Subscription propagation chain:**
|
|
335
|
+
|
|
336
|
+
```text
|
|
337
|
+
Application: subscribe(topic, handler)
|
|
338
|
+
→ topicHandlers 0→1
|
|
339
|
+
→ cluster.subscribe(topic) → subscribedTopics.add(topic)
|
|
340
|
+
→ write subscriber:{topicKey}:{tabId}
|
|
341
|
+
→ readRoute(topicKey)
|
|
342
|
+
→ if no route: selectLeastLoadedWorker, writeRoute, sendControl(SUBSCRIBE)
|
|
343
|
+
→ owner receives CONTROL/SUBSCRIBE
|
|
344
|
+
→ assignedTopics.set(topicKey, topic)
|
|
345
|
+
→ transport.subscribe(topic) → transportSubscribedTopics.add(topic)
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
**Unsubscribe propagation chain:**
|
|
349
|
+
|
|
350
|
+
```text
|
|
351
|
+
Application: unsubscribe(topic, handler) (last handler)
|
|
352
|
+
→ topicHandlers empty
|
|
353
|
+
→ cluster.unsubscribe(topic) → subscribedTopics.delete(topic)
|
|
354
|
+
→ releaseSubscription → delete subscriber record
|
|
355
|
+
→ if no subscribers left: delete route, sendControl(UNSUBSCRIBE)
|
|
356
|
+
→ owner receives CONTROL/UNSUBSCRIBE
|
|
357
|
+
→ assignedTopics.delete(topicKey)
|
|
358
|
+
→ transport.unsubscribe(topic) → transportSubscribedTopics.delete(topic)
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**Disconnect / reconnect behavior:**
|
|
362
|
+
|
|
363
|
+
- On transport disconnect: `transportSubscribedTopics` is **cleared** immediately. The other three sets (`topicHandlers`, `subscribedTopics`, `assignedTopics`) survive unchanged.
|
|
364
|
+
- On transport reconnect: `CrossTabDataBus` iterates `assignedTopics` and re-calls `transport.subscribe(topic)` for each one, repopulating `transportSubscribedTopics`.
|
|
365
|
+
- This is how business subscription intent survives transport failures: the application never needs to re-subscribe after a reconnect.
|
|
366
|
+
|
|
367
|
+
## Message Flow
|
|
368
|
+
|
|
369
|
+
A publication travels publisher → current Topic owner → transport/server → owner → all Tabs:
|
|
370
|
+
|
|
371
|
+
1. Any Tab calls `publish(topic, data)`. The Runtime looks up `route:{topicKey}` and sends `CONTROL/PUBLISH` to the owner Worker; when no route exists the message is submitted to the current Tab's own transport.
|
|
372
|
+
2. The owner runs `transport.publish(topic, data)`. Because only the owner holds a real transport subscription to the topic, the server delivers the resulting publication back to exactly one Worker.
|
|
373
|
+
3. The owner accepts a publication only while `isAssigned(topic)` still holds. Stale messages from an expired owner are discarded — keeping the fan-out single-sourced.
|
|
374
|
+
4. The owner broadcasts `EVENT/DATABUS_PUBLICATION` over the BroadcastChannel and, if its own Tab also has a local subscription, dispatches once directly. BroadcastChannel never echoes to the sender, so there is no duplicate dispatch.
|
|
375
|
+
5. Every other Tab receives the `EVENT` but invokes its local handlers only when it holds a `subscriber:{topicKey}:{tabId}` record for that topic; Tabs without a local subscription drop the message.
|
|
376
|
+
|
|
377
|
+
```mermaid
|
|
378
|
+
sequenceDiagram
|
|
379
|
+
participant Pub as Publisher Tab A
|
|
380
|
+
participant CH as BroadcastChannel
|
|
381
|
+
participant Owner as Owner Tab B
|
|
382
|
+
participant Server as Transport / server
|
|
383
|
+
participant Other as Other Tabs C / D / E
|
|
384
|
+
|
|
385
|
+
Pub->>CH: CONTROL/PUBLISH(topic, data, targetWorkerId=owner)
|
|
386
|
+
CH->>Owner: deliver CONTROL/PUBLISH
|
|
387
|
+
Owner->>Server: transport.publish(topic, data)
|
|
388
|
+
Server-->>Owner: publication(topic, payload)
|
|
389
|
+
Owner->>Owner: isAssigned(topic) holds?
|
|
390
|
+
Owner->>CH: EVENT/DATABUS_PUBLICATION
|
|
391
|
+
Owner->>Owner: local dispatch (if subscribed)
|
|
392
|
+
CH->>Other: deliver EVENT
|
|
393
|
+
Other->>Other: hasLocalSubscriber(topic) → invoke handlers
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
BroadcastChannel does not echo to its sender, so the owner receives no `EVENT` back for its own broadcast — its local dispatch is the only local delivery.
|
|
397
|
+
|
|
398
|
+
Publications are not written to localStorage. Message data only exists in the BroadcastChannel in-memory event and within the transport; batch writes only cover coordination metadata.
|
|
399
|
+
|
|
400
|
+
### Dispatch flow: three gates
|
|
401
|
+
|
|
402
|
+
Every publication from the transport goes through three checks before reaching the application handler:
|
|
403
|
+
|
|
404
|
+
1. **`isAssigned(topic)`** — called on the owning Worker when a transport message arrives (`handleTransportMessage`). If the topic is no longer assigned to this worker (e.g. a stale message from a previous ownership window), the message is dropped immediately. This is the outer gate: it prevents a non-owner from broadcasting.
|
|
405
|
+
|
|
406
|
+
2. **`broadcastEvent('DATABUS_PUBLICATION', message)`** — called only after `isAssigned` passes. The owning Worker fans the message out to all tabs via BroadcastChannel `EVENT`. Each tab receives the event but does not dispatch yet — it must pass the inner gate.
|
|
407
|
+
|
|
408
|
+
3. **`hasLocalSubscriber(topic)`** — called on each tab receiving the `EVENT`. Only tabs that have a local subscriber record for this topic invoke the registered handler. Tabs without a local subscription drop the message silently.
|
|
409
|
+
|
|
410
|
+
These three checks ensure **exactly-once dispatch per subscriber**:
|
|
411
|
+
- The outer gate (`isAssigned`) prevents duplicate broadcasts from a stale owner.
|
|
412
|
+
- The inner gate (`hasLocalSubscriber`) prevents a tab from dispatching a topic it never subscribed to.
|
|
413
|
+
- BroadcastChannel never echoes to its sender, so the owner does not receive its own `EVENT` — its local dispatch is the only local delivery.
|
|
414
|
+
|
|
415
|
+
```text
|
|
416
|
+
Transport message → isAssigned(topic)? → Yes → broadcastEvent(EVENT)
|
|
417
|
+
↓
|
|
418
|
+
Each tab receives EVENT
|
|
419
|
+
↓
|
|
420
|
+
hasLocalSubscriber(topic)? → Yes → dispatch(handler)
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
## Coordination & Reconciliation
|
|
424
|
+
|
|
425
|
+
Cluster convergence is driven on two timelines:
|
|
426
|
+
|
|
427
|
+
- **Heartbeat + reconcile loop** (default `3000 ms`, `heartbeatIntervalMs`). On every tick each Worker refreshes its own record and runs a reconcile pass: prunes Workers past `workerTtlMs`, orphaned subscribers whose Tab is no longer active, and orphaned routes that have no subscribers and exceed the TTL; recomputes its own active/standby role; re-writes its subscriber records; and re-sends `CONTROL/SUBSCRIBE` for any route that still lacks `confirmedAt` — which also recovers control messages lost on the channel.
|
|
428
|
+
- **`REGISTRY` nudge**. Writes to Worker records, routes, or subscribers broadcast a `REGISTRY` message so every peer reconciles immediately rather than waiting for the next heartbeat.
|
|
429
|
+
|
|
430
|
+
Heartbeat writes are not announced, so a stale record is only noticed within one heartbeat interval. The worst case for failing to detect a dead owner is `heartbeatIntervalMs + workerTtlMs` (default about 13 s); see [TTL Message-Loss Window](./configuration.md#ttl-message-loss-window) for the trade-offs.
|
|
431
|
+
|
|
432
|
+
## Failover
|
|
433
|
+
|
|
434
|
+
On normal close or entry into BFCache, `pagehide` pauses the Runtime: it deletes Worker and subscriber records, yields the actual owner, and closes the underlying transport, but retains the business subscription intent in memory. After the page is restored via `pageshow`, DataBus rebuilds the Worker/connection, and the Runtime automatically re-registers, restores subscriber records, and reconciles Topics, without requiring the business to re-call `subscribe`.
|
|
435
|
+
|
|
436
|
+
#### Tab identity and `window.open`
|
|
437
|
+
|
|
438
|
+
Each Runtime stores its `tabId` in `sessionStorage` so refreshes retain identity; every Runtime gets a random-suffixed `workerId`. Browsers may clone the opener's `sessionStorage` when `window.open()` creates a page, causing two physical tabs to share a `tabId` and collide on subscriber or diagnostic keys.
|
|
439
|
+
|
|
440
|
+
Applications should use `noopener` when opening a new tab. As a safety net, the SDK discards a copied sessionStorage id when an opener is detected and generates a fresh one. Inspection records are keyed by `tabId + workerId`, preventing Worker restart or handoff snapshots from overwriting one another.
|
|
441
|
+
|
|
442
|
+
`visibilitychange` does not remove business subscriptions or migrate established routes. A hidden Tab keeps the Topics it already owns and can still receive data broadcast by other owners. Visibility only affects candidate selection when a new Topic needs its first owner.
|
|
443
|
+
|
|
444
|
+
When an abnormal exit cannot execute the `pagehide` cleanup, other Runtimes scan Worker records and clean up by TTL.
|
|
445
|
+
|
|
446
|
+
If a Tab still subscribed to a Topic finds that the owner Worker has departed or expired, it selects a new owner and increments the route `generation`. Normal `pagehide` handoff is strict: the new route records `handoffFromWorkerId`, the old owner unsubscribes from transport first, then sends `ROUTE_RELEASED(generation)`, and only the matching new owner ACK handler sends `SUBSCRIBE`. If the old Worker has already disappeared, the new owner takes over immediately. A refreshed Tab that rejoins afterward records itself as a subscriber and reuses the replacement owner instead of taking the route back.
|
|
447
|
+
|
|
448
|
+
This process prevents overlap during graceful owner handoff while retaining availability during failure recovery; it does not guarantee exactly-once delivery.
|
|
449
|
+
|
|
450
|
+
## Transport Reconnection
|
|
451
|
+
|
|
452
|
+
DataBus separates "business subscription intent" from "transport current subscription state". When the transport reports `disconnected` / `error`, it only clears the underlying subscription flag, not the business handler; when it re-enters `connected`, DataBus automatically replays the Topics the current Worker is responsible for.
|
|
453
|
+
|
|
454
|
+
The built-in Centrifuge transport also retains its own Subscriptions and performs protocol-level reconnection. Both layers of recovery require `subscribe` / `unsubscribe` to be idempotent.
|
|
455
|
+
|
|
456
|
+
## Lifecycle State Machine
|
|
457
|
+
|
|
458
|
+
`CrossTabDataBus` uses several boolean flags and promise gates to serialize lifecycle transitions. The interaction between them is the most complex part of the DataBus layer.
|
|
459
|
+
|
|
460
|
+
### Flags
|
|
461
|
+
|
|
462
|
+
| Flag | Type | Meaning |
|
|
463
|
+
|---|---|---|
|
|
464
|
+
| `started` | `boolean` | `start()` has been called and no `stop()` has completed since |
|
|
465
|
+
| `stopping` | `boolean` | `stop()` is in progress; prevents new operations |
|
|
466
|
+
| `suspended` | `boolean` | Tab is hidden; transport is intentionally stopped |
|
|
467
|
+
| `transportReady` | `boolean` | Transport has reported `connected` and is accepting operations |
|
|
468
|
+
| `startPromise` | `Promise \| null` | Gate for concurrent `start()` calls; cleared after settle |
|
|
469
|
+
| `pendingStop` | `Promise \| null` | Gate for async `transport.stop()`; shared by suspend and failure paths |
|
|
470
|
+
|
|
471
|
+
### State transitions
|
|
472
|
+
|
|
473
|
+
```text
|
|
474
|
+
┌──────────────────────────────────────────────┐
|
|
475
|
+
│ ▼
|
|
476
|
+
┌───────┐ start(config) ┌──────────┐ openTransport ok ┌───────────┐
|
|
477
|
+
│ idle │ ──────────────────→ │ starting │ ──────────────────→ │ running │
|
|
478
|
+
└───────┘ └──────────┘ └───────────┘
|
|
479
|
+
▲ │ │
|
|
480
|
+
│ │ openTransport fails │ pagehide
|
|
481
|
+
│ ▼ │
|
|
482
|
+
│ ┌──────────┐ ▼
|
|
483
|
+
│ │ failed │ ┌───────────┐
|
|
484
|
+
│ └──────────┘ │ suspended │
|
|
485
|
+
│ │ └───────────┘
|
|
486
|
+
│ │ start(config) again │
|
|
487
|
+
│ ▼ │ pageshow
|
|
488
|
+
│ ┌──────────┐ │
|
|
489
|
+
│ │ starting │◄───────────────────────┘
|
|
490
|
+
│ └──────────┘
|
|
491
|
+
│
|
|
492
|
+
│ stop() ┌──────────┐
|
|
493
|
+
└────────────────────────── │ stopped │
|
|
494
|
+
└──────────┘
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
**Key behaviors:**
|
|
498
|
+
|
|
499
|
+
- **Concurrent start**: If `start()` is called while `startPromise` is non-null, the second call returns the same promise. Only one transport open is in flight at a time.
|
|
500
|
+
- **Suspend during start**: If `pagehide` fires while `openTransport` is in flight, `suspendTransport()` sets `suspended = true` and chains a `transport.stop()` after the in-flight start. The `openTransport` catch path detects `suspended` and abandons the open without treating it as a failure.
|
|
501
|
+
- **Recovery cooldown**: When the transport reports `error` while `started` is true and `stopping` is false, `updateStatus` schedules an automatic `reopenTransport()` after `RECOVERY_COOLDOWN_MS` (1000 ms). A second error within the cooldown window is suppressed to prevent a tight retry loop.
|
|
502
|
+
- **Stop during suspend**: `stop()` sets `stopping = true`, which prevents `suspendTransport()` from running. The cleanup awaits `startPromise` and `pendingStop` to ensure any in-flight open or stop completes before the final `transport.stop()`.
|
|
503
|
+
|
|
504
|
+
## Degradation
|
|
505
|
+
|
|
506
|
+
The Runtime degrades to local mode when any of the following conditions are met:
|
|
507
|
+
|
|
508
|
+
- localStorage is not writable
|
|
509
|
+
- BroadcastChannel does not exist or construction fails
|
|
510
|
+
- SSR / Node environment without browser APIs
|
|
511
|
+
|
|
512
|
+
Local mode still calls the current transport's subscribe and publish methods, but does not perform cross-Tab routing or forwarding.
|
|
513
|
+
|
|
514
|
+
The Centrifuge transport also has a backend degradation scheme based on `workerMode`: `auto` tries SharedWorker → Dedicated Worker → main-thread local session in order; `dedicated` tries Dedicated Worker → SharedWorker → main-thread local session in order. The Runtime's cross-Tab degradation and the transport's backend degradation are independent of each other: even if the transport runs in a Worker, when localStorage or BroadcastChannel is unavailable, it still only runs within the current Tab.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
> [中文](./zh/capabilities.md) | English
|
|
2
|
+
|
|
3
|
+
# Capabilities Matrix
|
|
4
|
+
|
|
5
|
+
Status Legend: `✅ Implemented` means the current version has code and test coverage; `Not Implemented` means no guarantee is currently provided; `Planned` means it has entered the scope of future design, but no version has been committed yet.
|
|
6
|
+
|
|
7
|
+
| Category | Capability | Status | Current Behavior / Boundary |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| Core API | Framework-agnostic subscribe, unsubscribe, publish, and status listening | ✅ Implemented | Provides a unified API via `CrossTabDataBus` |
|
|
10
|
+
| Startup Experience | Auto-start after creation, queued subscriptions before connecting, `ready()` | ✅ Implemented | Apps don't need to wait for a connection before registering Topics |
|
|
11
|
+
| In-Tab Reuse | Reference counting for multiple handlers on the same Topic | ✅ Implemented | The first handler registers; the last handler releases |
|
|
12
|
+
| Cross-Tab Coordination | Worker transport with BroadcastChannel control plane | ✅ Implemented | Each Tab gets a Dedicated Worker by default; in SharedWorker mode same-origin Tabs reuse the Worker, and Topic owners are shared across Tabs |
|
|
13
|
+
| Resource Limits | At most three active Workers | ✅ Implemented | Configurable via `maxActiveWorkers` |
|
|
14
|
+
| Topic Routing | Sticky existing owners with load-balanced first assignment | ✅ Implemented | Existing routes stay unchanged while the owner is alive; only new or orphaned Topics select the least-loaded candidate |
|
|
15
|
+
| Subscription Reliability | Per-Tab independent subscriber records | ✅ Implemented | Avoids multiple Tabs modifying the same subscriber array and overwriting each other |
|
|
16
|
+
| Subscription Reliability | Owner acknowledgment and automatic resend of unconfirmed routes | ✅ Implemented | Automatically retransmits when control messages are lost, so a stored route is not mistaken for a real successful subscription |
|
|
17
|
+
| Page Lifecycle | `pagehide` owner pre-transfer and transport shutdown | ✅ Implemented | Persists the replacement route and worker removal before notifying peers, so a dropped unload-time control message still converges immediately |
|
|
18
|
+
| Page Lifecycle | `pageshow`, BFCache restoration, and business subscription rebuild | ✅ Implemented | Apps don't need to call `subscribe` again |
|
|
19
|
+
| Visibility | Visible preference for new Topic placement | ✅ Implemented | Visibility changes do not migrate established routes; visible Workers are preferred only when a Topic needs a new owner |
|
|
20
|
+
| Exception Recovery | Worker TTL, stale owner migration, and coordination cache cleanup | ✅ Implemented | Reclaims dead Workers, orphaned subscribers, and expired routes with no subscribers |
|
|
21
|
+
| transport | Replays owner Topics after reconnect | ✅ Implemented | Business handlers and subscription intent are not cleared on disconnect |
|
|
22
|
+
| Degradation | Runs locally when localStorage or BroadcastChannel is unavailable | ✅ Implemented | Preserves the current Tab's connection and subscription capabilities |
|
|
23
|
+
| Centrifuge | Built-in Dedicated / Shared Worker transport | ✅ Implemented | Supports subscribe, unsubscribe, publish, connection status, and error reporting; `auto` degrades from SharedWorker → Dedicated Worker → main-thread WebSocket |
|
|
24
|
+
| Security Boundary | localStorage uses opaque keys derived from connection and Topic; BroadcastChannel coordination messages carry plaintext topic names | ✅ Implemented | Does not persist URLs, raw Topic names, credentials, or publication payloads. BroadcastChannel coordination messages are in-memory only and carry plaintext topic names — they are not persisted. |
|
|
25
|
+
| Diagnostics | Aggregates lifecycle events, throughput, and delivery latency | ✅ Implemented | Disabled by default; metrics are emitted every 5 seconds by default, with latency reporting sample count, average, P50, P95, and maximum |
|
|
26
|
+
| Performance | Batched writes of coordination metadata with backoff retry | ✅ Implemented | Heartbeat, route, and subscriber writes are merged and flushed in a microtask; failures use exponential backoff; `pagehide` / `stop()` flush synchronously |
|
|
27
|
+
| Performance | Optional ArrayBuffer Transferable transport | ✅ Implemented | With `transferable: true`, binary publish / receive bypasses structured clone copying; the object message API is unchanged |
|
|
28
|
+
| Message Semantics | exactly-once delivery | Not Implemented | Graceful handoff avoids overlap, but crash recovery and transport/server behavior still do not provide an exactly-once guarantee |
|
|
29
|
+
| Message Semantics | Pluggable publication deduplication | Planned | Plans to support caller-provided message IDs and a deduplication window |
|
|
30
|
+
| Authentication | Async credential refresh bridge inside the Worker | Planned | Current Worker config must be structured-cloneable and cannot pass functions |
|
|
31
|
+
| Load Policy | Adaptive weighting by message rate, byte count, or CPU | Planned | Load is currently computed only from the number of owner Topics |
|
|
32
|
+
| Observability | Metrics for owner acknowledgment latency, migration duration, and retry counts | Planned | Current trace provides status, lifecycle, message throughput, and delivery latency |
|
|
33
|
+
| Runtime Model | SharedWorker / Dedicated Worker transport | ✅ Implemented | `workerMode` supports `dedicated`, `shared`, and `auto`, defaulting to `dedicated` |
|
|
34
|
+
| Runtime Model | Service Worker transport | Not Implemented | Service Worker hosting of real-time connections is not currently provided |
|
|
35
|
+
| Durable Messages | Persisting publications or publish commands across page close | Not Implemented | The SDK does not persist business payloads, nor does it replay publish commands after restoration |
|
|
36
|
+
|
|
37
|
+
## Acceptance Criteria
|
|
38
|
+
|
|
39
|
+
- "Implemented" does not mean every browser environment provides cross-Tab capability; when localStorage or BroadcastChannel is missing, it falls back to local degradation by design.
|
|
40
|
+
- Writing an owner route to storage does not mean the subscription has been established. Only when the owner writes `confirmedAt` after processing `SUBSCRIBE` does it represent that the control message has arrived; the server-side final subscription state is still the responsibility of the transport.
|
|
41
|
+
- The SDK guarantees subscription-intent recovery and eventual migration, but does not guarantee exactly-once within the migration window.
|