cross-tab-worker-databus 0.21.13 → 0.21.15
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 +45 -0
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/dist/centrifuge-session.d.ts.map +1 -1
- package/dist/centrifuge.d.ts.map +1 -1
- package/dist/centrifuge.js +8 -6
- package/dist/centrifuge.js.map +2 -2
- package/dist/centrifuge.shared.worker.js.map +2 -2
- package/dist/centrifuge.worker.js.map +2 -2
- package/dist/{chunk-4HW6PYIR.js → chunk-VQJIMGJN.js} +2 -2
- package/dist/cjs/centrifuge.cjs +8 -6
- package/dist/cjs/centrifuge.cjs.map +2 -2
- package/dist/cjs/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/docs/api.md +3 -3
- package/docs/architecture.md +12 -9
- package/docs/configuration.md +14 -9
- package/docs/getting-started.md +1 -1
- package/docs/release-checklist.md +2 -2
- package/docs/roadmap.md +70 -4
- package/docs/transports.md +8 -6
- package/docs/zh/api.md +4 -4
- package/docs/zh/architecture.md +13 -10
- package/docs/zh/capabilities.md +1 -1
- package/docs/zh/configuration.md +14 -9
- package/docs/zh/getting-started.md +1 -1
- package/docs/zh/release-checklist.md +2 -2
- package/docs/zh/roadmap.md +59 -3
- package/docs/zh/transports.md +1 -1
- package/package.json +1 -1
- /package/dist/{chunk-4HW6PYIR.js.map → chunk-VQJIMGJN.js.map} +0 -0
package/dist/cjs/index.cjs
CHANGED
package/dist/index.js
CHANGED
package/docs/api.md
CHANGED
|
@@ -169,7 +169,7 @@ clearReplay(): Promise<void>
|
|
|
169
169
|
|
|
170
170
|
Clears in-memory replay buffers and invokes the persistence adapter's optional `clear()` hook. Useful for retention policies, logout, or tenant switching. Durable history is otherwise preserved across `stop()`.
|
|
171
171
|
|
|
172
|
-
`clearReplayTopic(topic)` applies the same cleanup to one exact topic. `getDedupStats()` returns bounded counters (`enabled`, `tracked`, `accepted`, `suppressed`), and `resetDedup()` clears remembered IDs and counters without changing configuration.
|
|
172
|
+
`clearReplayTopic(topic)` applies the same cleanup to one exact topic. `getDedupStats()` returns bounded counters (`enabled`, `tracked`, `accepted`, `suppressed`, plus the current `ttlMs` whenever `dedup.adaptiveTtl` is configured), and `resetDedup()` clears remembered IDs and counters without changing configuration.
|
|
173
173
|
|
|
174
174
|
`clearReplayBefore(timestamp)` removes entries with an explicit producer timestamp older than an epoch-millisecond cutoff from memory and from adapters that implement optional `clearBefore(timestamp)`. Legacy messages without a producer timestamp are preserved for compatibility. Incoming messages receive a bus timestamp when the transport does not provide one; that bus timestamp is not treated as producer metadata for retention cleanup.
|
|
175
175
|
|
|
@@ -345,7 +345,7 @@ Creates an auto-starting Centrifuge DataBus. Defaults:
|
|
|
345
345
|
- Uses the bundled `centrifuge.worker.js`
|
|
346
346
|
- Worker name is `cross-tab-worker-databus`
|
|
347
347
|
|
|
348
|
-
SharedWorker mode uses the bundled `centrifuge.shared.worker.js`. With `workerMode: 'auto'`, it degrades from SharedWorker to Dedicated Worker to local mode. See [configuration.md](./configuration.md) for full configuration.
|
|
348
|
+
SharedWorker mode uses the bundled `centrifuge.shared.worker.js`, and its Worker is named `cross-tab-worker-databus-shared`. With `workerMode: 'auto'`, it degrades from SharedWorker to Dedicated Worker to local mode. See [configuration.md](./configuration.md) for full configuration.
|
|
349
349
|
|
|
350
350
|
## `CentrifugeWorkerTransport<TData>`
|
|
351
351
|
|
|
@@ -431,7 +431,7 @@ React (>= 18) is an optional peer dependency; this entry is separate so non-Reac
|
|
|
431
431
|
|
|
432
432
|
### `useCrossTabDataBus(create, deps?)`
|
|
433
433
|
|
|
434
|
-
Creates a bus for the component's lifetime: created on mount, stopped on unmount. StrictMode-safe — the double-invoked effect exercises the
|
|
434
|
+
Creates a bus for the component's lifetime: created on mount, stopped on unmount. StrictMode-safe — the double-invoked effect runs create → `stop()` → create across **separate** instances, so it exercises the stop path but not the BFCache one: page-hide takes `onSuspend()`, which keeps this same bus, its handlers and its replay buffers, and resume reverses it. Coverage of suspend/resume therefore has to come from a real hide/show, not from this hook. Returns the active bus or `null` before the first effect (SSR / initial render).
|
|
435
435
|
|
|
436
436
|
Pass a fresh bus per effect run (an inline factory); key recreation through `deps`.
|
|
437
437
|
|
package/docs/architecture.md
CHANGED
|
@@ -39,7 +39,7 @@ graph TB
|
|
|
39
39
|
end
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
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
|
|
42
|
+
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, and what localStorage holds for the cluster is eventually-consistent coordination metadata — with the one exception that under `channelFallback: 'storage-event'` the channel itself is localStorage, so the frames travel through it; see [Storage Data Boundaries](./configuration.md#storage-data-boundaries).
|
|
43
43
|
|
|
44
44
|
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. Silence is not the same as absence, though, so a reclaimed port is announced before it is closed: a tab whose heartbeat was merely starved (a long synchronous task, the throttling a backgrounded tab gets) learns its backend is gone, reports the failure, and rebuilds the session on a fresh port.
|
|
45
45
|
|
|
@@ -197,7 +197,7 @@ These identifiers represent different layers:
|
|
|
197
197
|
|
|
198
198
|
| Object | Meaning | Main use | Persisted in coordination storage |
|
|
199
199
|
|---|---|---|---|
|
|
200
|
-
| `topic` | Original application Topic string | Passed to transport `subscribe`, `unsubscribe`, and `
|
|
200
|
+
| `topic` | Original application Topic string | Passed to transport `subscribe`, `unsubscribe`, `publish`, and `publishBatch` | Not in any coordination record — it lives in Runtime memory and in control messages. The storage-event row below is the exception: when that fallback carries the frames, the plaintext each `CONTROL` names is written with them |
|
|
201
201
|
| `topicKey` | Stable opaque key from `createOpaqueKey(topic)` | Joins route and subscriber records | Yes |
|
|
202
202
|
| `tabId` | Stable identity of a browser Tab | Identifies which Tab subscribes to a `topicKey` | Yes, in subscriber keys |
|
|
203
203
|
| `workerId` | Identity of the current Runtime/Worker instance | Identifies the Worker that owns the transport subscription | Yes, in worker/route records |
|
|
@@ -219,7 +219,7 @@ BroadcastChannel CONTROL
|
|
|
219
219
|
|
|
220
220
|
### In-memory topic key cache (`knownTopics`)
|
|
221
221
|
|
|
222
|
-
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
|
|
222
|
+
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 wherever `rememberTopic()` is called, and its call sites are: `subscribe`, `publish`, `publishBatch`, `unsubscribe` (inside `releaseSubscription`), `activate()` for topics that were queued before the cluster started, and `reconcileSubscriptions()` on each cycle. Plus inbound `CONTROL` messages — where the cache only grows for a frame that survives both guards at the top of `handleControlMessage`: one naming another worker, or one whose `topicKey`/`topic` pair disagrees, is dropped before its plaintext is ever read, so neither can reach `rememberTopic`. Legitimate calls still can, through any API a same-origin script may reach on the page's own bus, which is what the cap below bounds.
|
|
223
223
|
|
|
224
224
|
The cache exists for two reasons:
|
|
225
225
|
|
|
@@ -247,8 +247,11 @@ Eviction is FIFO (insertion order, Map iteration order). When the cache exceeds
|
|
|
247
247
|
|---|---|---|
|
|
248
248
|
| `subscribe(topic)` | `rememberTopic(topic)` → `set(topicKey, topic)` | Populate the reverse mapping; needed for storage-less `readRoute` |
|
|
249
249
|
| `publish(topic, data)` | `rememberTopic(topic)` → `set(topicKey, topic)` | Populate; same reason |
|
|
250
|
-
| `
|
|
251
|
-
| `
|
|
250
|
+
| `publishBatch(topic, items)` | `rememberTopic(topic)` → `set(topicKey, topic)` | Populate; same reason — a batch resolves one key, not one per item |
|
|
251
|
+
| `activate()` (start / resume) | `rememberTopic(topic)` → `set(topicKey, topic)`, once per topic already in `subscribedTopics` | The reverse mapping has to exist before either branch of that loop runs: the storage-less path sends `CONTROL/SUBSCRIBE`, the other writes the subscriber record |
|
|
252
|
+
| `reconcileSubscriptions()` (each cycle) | `rememberTopic(topic)` → `set(topicKey, topic)`, once per `subscribedTopics` entry | Re-affirms the mapping on every heartbeat; see reason 2 above |
|
|
253
|
+
| `unsubscribe(topic)` | `rememberTopic(topic)` (inside `releaseSubscription`), then `delete(topicKey)` if not in `assignedTopics` | The key is derived before the ownership check, and the entry is kept only if we still own the topic |
|
|
254
|
+
| `CONTROL` received (any action: SUBSCRIBE / UNSUBSCRIBE / PUBLISH) | `rememberTopic(message.topic)` → `set(topicKey, topic)` | Inbound frames carry the plaintext topic — but `rememberTopic` runs *after* the `targetWorkerId` and `topicKey` pairing guards, so a frame either guard drops is never cached |
|
|
252
255
|
| `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 |
|
|
253
256
|
| `reconcileAssignedTopics` | `delete(topicKey)` if not subscribed and not owned | Route no longer points to us — clean up unless we're still a subscriber |
|
|
254
257
|
| `stop()` | `clear()` | Full teardown |
|
|
@@ -286,7 +289,7 @@ sequenceDiagram
|
|
|
286
289
|
RuntimeA->>App: invoke handler(payload)
|
|
287
290
|
```
|
|
288
291
|
|
|
289
|
-
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
|
|
292
|
+
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 and, when that was the last one, the same Tab deletes the route and sends `CONTROL/UNSUBSCRIBE` to the worker the route named, which then drops its assignment and releases the transport subscription.
|
|
290
293
|
|
|
291
294
|
### Console diagnostics
|
|
292
295
|
|
|
@@ -367,7 +370,7 @@ implementation passes through the transport server. Four message types are excha
|
|
|
367
370
|
|
|
368
371
|
| Type | Direction | Purpose |
|
|
369
372
|
|---|---|---|
|
|
370
|
-
| `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. A `SUBSCRIBE` is
|
|
373
|
+
| `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. A `SUBSCRIBE` is dropped when the durable route names a *different* worker, and a route still awaiting `ROUTE_RELEASED` accepts no frame but its matching ACK; a route that cannot be read at all passes, because such a frame mints no durable ownership and the next reconcile withdraws the assignment. |
|
|
371
374
|
| `EVENT` | broadcast (owner → all Tabs) | Fan out a publication that the transport delivered to the owning Worker. Carries `eventType` and `payload`. |
|
|
372
375
|
| `REGISTRY` | broadcast | Nudge every Tab to reconcile immediately after a registry or route write, instead of waiting for the next heartbeat. |
|
|
373
376
|
| `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). |
|
|
@@ -569,7 +572,7 @@ Transport message → isAssigned(topic)? → Yes → broadcastEvent(EVENT)
|
|
|
569
572
|
Cluster convergence is driven on two timelines:
|
|
570
573
|
|
|
571
574
|
- **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.
|
|
572
|
-
- **`REGISTRY` nudge**. Writes to Worker records
|
|
575
|
+
- **`REGISTRY` nudge**. Writes to Worker records and routes broadcast a `REGISTRY` message so every peer reconciles immediately rather than waiting for the next heartbeat. A subscriber-only write does not: `subscribe()` on a topic whose owner is still live writes this Tab's subscriber record and returns.
|
|
573
576
|
|
|
574
577
|
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.
|
|
575
578
|
|
|
@@ -595,7 +598,7 @@ This process prevents overlap during graceful owner handoff while retaining avai
|
|
|
595
598
|
|
|
596
599
|
These invariants are pinned by regression tests (see `tests/stability.test.ts` and `tests/replay-persistence.test.ts`) and must hold through future refactors:
|
|
597
600
|
|
|
598
|
-
- **SUBSCRIBE route binding.** An inbound `CONTROL/SUBSCRIBE` is
|
|
601
|
+
- **SUBSCRIBE route binding.** An inbound `CONTROL/SUBSCRIBE` is dropped when the durable route names a *different* worker, and a handoff still awaiting its ACK accepts no frame but the matching `ROUTE_RELEASED`. A route that cannot be read — expired, corrupt, or storage absent — is the accepted exception: `confirmRoute` writes nothing without a route to stamp, so the frame mints no durable ownership and `reconcileAssignedTopics` withdraws the assignment on the next tick. Both halves are pinned by `tests/cluster.test.ts`. Ownership follows the current route record, not the order in which control frames arrive.
|
|
599
602
|
- **Handoff ACK validity.** A `ROUTE_RELEASED` is accepted only when the route still points at the receiver, the release comes from the recorded `handoffFromWorkerId`, and the ACK generation exactly matches the stored route generation. Replayed ACKs from any other handoff round (e.g. an a↔b ping-pong) are dropped instead of confirming the current route.
|
|
600
603
|
- **Replay persistence cleanup ordering.** A batched persistence flush queued behind the current task is filtered against the cleanup that wins the race: `unsubscribe` and `clearReplayTopic` drop the topic's pending entries, `clearReplayBefore` drops entries older than the cutoff, and `suspend()`/`stop()` discard the whole queued batch before it can start under the next lifecycle generation. Cleared or stopped-session history is never re-appended by an in-flight flush.
|
|
601
604
|
- **Storage write recovery.** Coalesced writes retry with exponential backoff (50 ms → 1.6 s cap). A structurally failing key is dropped after 5 attempts (with a `console.warn`) without permanently blocking other queued keys, and the backoff delay resets once the queue fully drains or `clear()` cancels the retries.
|
package/docs/configuration.md
CHANGED
|
@@ -165,7 +165,7 @@ Main configuration for `createCentrifugeDataBus<TData>(options)`:
|
|
|
165
165
|
| `heartbeatIntervalMs` | `number` | `10000` | SharedWorker PING heartbeat interval (see SharedWorker Session Reaper below); `Infinity` disables heartbeats entirely and exempts that port from reaping. Distinct from the Core cluster heartbeat (default 3000 ms) which tracks worker liveness via localStorage |
|
|
166
166
|
| `workerFactory` | `() => Worker` | Built-in Worker | For testing or custom Worker loading |
|
|
167
167
|
| `sharedWorkerFactory` | `() => SharedWorker` | Built-in SharedWorker | For testing or custom SharedWorker loading |
|
|
168
|
-
| `credentialProvider` | `{ getToken?, getChannelToken? }` | `undefined` | Async credential refresh bridge: the Worker asks the main thread for each fresh token
|
|
168
|
+
| `credentialProvider` | `{ getToken?, getChannelToken? }` | `undefined` | Async credential refresh bridge: the Worker asks the main thread for each fresh token and this provider supplies it from application context. In practice that ask is `getToken`, the only credential hook `centrifuge@5.7.4` exposes — the type carries `getChannelToken` and the Worker protocol carries that request kind so a later SDK needs no change on this side, but nothing asks it today. Required because function-valued Centrifuge options cannot be structured-cloned into the Worker |
|
|
169
169
|
| Other Core config | Corresponding type | Core defaults | `storagePrefix`, heartbeat, TTL, etc. |
|
|
170
170
|
|
|
171
171
|
```ts
|
|
@@ -256,21 +256,26 @@ When non-clonable data is passed, `CentrifugeWorkerTransport` will throw a clear
|
|
|
256
256
|
|
|
257
257
|
## Storage Data Boundaries
|
|
258
258
|
|
|
259
|
-
|
|
259
|
+
The coordination records this library writes itself — `{clusterHash}:worker:{workerId}`, `{clusterHash}:route:{topicKey}`, `{clusterHash}:subscriber:{topicKey}:{tabId}`, and the tab-id key — hold only:
|
|
260
260
|
|
|
261
|
-
- Worker
|
|
262
|
-
-
|
|
263
|
-
-
|
|
261
|
+
- Worker and Tab identity, role, status, visibility, the owned-topic `load` figure, an optional rolling throughput sample, the advertised protocol version, and the heartbeat / first-registration timestamps
|
|
262
|
+
- The opaque Topic key, its owner's Worker and Tab identity, the last-update and generation numbers, and — during a graceful handoff — the previous owner and the owner's confirmation timestamp
|
|
263
|
+
- A subscriber record's Tab ID and its last-update timestamp
|
|
264
|
+
- The tab's own persisted id, which is what lets a reload reclaim its routes — written to `sessionStorage`, not to the cluster's `localStorage` namespace
|
|
264
265
|
|
|
265
|
-
|
|
266
|
+
Those records never hold:
|
|
266
267
|
|
|
267
268
|
- Connection URL plaintext
|
|
268
269
|
- Topic names
|
|
269
270
|
- Connection credentials
|
|
270
271
|
- Publication data
|
|
271
|
-
- Publish data
|
|
272
272
|
|
|
273
|
-
|
|
273
|
+
That is a boundary around *those keys*, not around browser storage in general. Two opt-ins put Topic plaintext and payloads into it:
|
|
274
|
+
|
|
275
|
+
- `channelFallback: 'storage-event'` writes every coordination **frame** whole under `cross-tab-worker-databus:channel:*`, and a `CONTROL/PUBLISH` frame carries its Topic name and payload — so they sit in `localStorage` until the channel is closed, and indefinitely if the tab is killed first.
|
|
276
|
+
- `replay.persistence` (e.g. `createIndexedDbReplayPersistence`) keeps replay history in IndexedDB in an object store keyed by the plaintext Topic, with the payloads in its rows.
|
|
277
|
+
|
|
278
|
+
Note: BroadcastChannel coordination messages carry topic names, event types, and publication payloads in plaintext (in memory only, unless the fallback above is what is carrying them). Only localStorage metadata is hashed via `createOpaqueKey()`.
|
|
274
279
|
|
|
275
280
|
## Security & Trust Model
|
|
276
281
|
|
|
@@ -282,7 +287,7 @@ The coordination plane has **no authentication**. Only use this library on pages
|
|
|
282
287
|
- `clusterKey` is hashed via `createOpaqueKey` (a non-cryptographic 128-bit hash) to derive the storage prefix and BroadcastChannel name. In practice `clusterKey` is always a connection URL or a developer-controlled namespace, so a hash collision between two different clusterKeys (~2⁻⁶⁴ birthday bound) is not a practical concern.
|
|
283
288
|
- Mitigations: keep the page free of untrusted third-party scripts; load coordination on an isolated origin; treat the origin's `localStorage` and BroadcastChannel namespaces as public. CSP cannot restrict BroadcastChannel or localStorage access from same-origin scripts.
|
|
284
289
|
|
|
285
|
-
The transport plane (e.g. the Centrifuge WebSocket) has its own security model — tokens, TLS, and server-side permissions — and is unaffected by the above. The cluster only
|
|
290
|
+
The transport plane (e.g. the Centrifuge WebSocket) has its own security model — tokens, TLS, and server-side permissions — and is unaffected by the above. The cluster only decides which tab owns the transport subscription; a payload then reaches a tab from that owner over the BroadcastChannel, or from the server directly for the tab holding the subscription. The one exception is the `channelFallback: 'storage-event'` path above: there the channel *is* `localStorage`, so those same frames — payloads included — are written into it.
|
|
286
291
|
|
|
287
292
|
## Cluster Isolation Recommendations
|
|
288
293
|
|
package/docs/getting-started.md
CHANGED
|
@@ -47,7 +47,7 @@ export const dataBus = createCentrifugeDataBus<ResourceEvent>({
|
|
|
47
47
|
|
|
48
48
|
`clusterKey` is derived from the connection URL by default. It is only used for cluster isolation and is converted to an opaque key before entering localStorage and as the BroadcastChannel channel name. Note that topic names and event types sent over the BroadcastChannel coordination channel are transmitted in plaintext; only localStorage metadata is obfuscated via hashing.
|
|
49
49
|
|
|
50
|
-
By default, a Dedicated Worker is used, one Worker per Tab. `workerMode: 'shared'` (or `'auto'`, which tries shared first) puts same-origin Tabs into **one** SharedWorker process — but it does not merge their connections. Each connecting port gets its own `CentrifugeSession` and so its own WebSocket, which is why closing or refreshing one tab leaves the others subscribed: N tabs still mean N server-side connections
|
|
50
|
+
By default, a Dedicated Worker is used, one Worker per Tab. `workerMode: 'shared'` (or `'auto'`, which tries shared first) puts same-origin Tabs into **one** SharedWorker process — but it does not merge their connections. Each connecting port gets its own `CentrifugeSession` and so its own WebSocket, which is why closing or refreshing one tab leaves the others subscribed: N tabs still mean N server-side connections. The channels are not multiplied the same way, because a transport subscribes only the Topics its own worker owns, so a given Topic's channel appears on one tab's socket rather than on all N. Shared mode saves the Worker process, not the socket:
|
|
51
51
|
|
|
52
52
|
```ts
|
|
53
53
|
export const dataBus = createCentrifugeDataBus<ResourceEvent>({
|
|
@@ -20,7 +20,7 @@ Only `pnpm bench:browser` / `pnpm bench:compare` / `pnpm bench:trend` stay local
|
|
|
20
20
|
1. Update `package.json`, `CHANGELOG.md`, and both roadmap files.
|
|
21
21
|
2. Run `pnpm check`, `pnpm lint`, `pnpm test:coverage`, `pnpm bench`, `pnpm test:e2e`, `pnpm bench:browser`, `pnpm verify:pack`, `pnpm verify:compat`, `pnpm verify:types`, and `git diff --check`. (`verify:compat` asserts the package `exports` subpaths and type fields present in the `COMPAT_BASE_TAG` baseline still exist; `verify:pack` steam-imports the full root public surface plus every subpath in ESM and CJS from the packed tarball; `verify:types` freezes every public entry's declared export names against the same baseline and asserts no public signature names a type that no entry exports — a type-only export emits no runtime key, so neither the `Object.keys()` freeze in `tests/dual-format.test.ts` nor `verify:compat` can see one change. `verify:compat` and `verify:types` resolve their baseline from the most recent release tag, so a shallow or tag-less clone must first run `git fetch --tags` or they fail with "no version tag found".)
|
|
22
22
|
3. Gate dependency security: `pnpm audit --registry=https://registry.npmjs.org` (the configured mirror registry lacks the audit endpoint; CI runs it on the public registry in the verify job). Fail the release on any known-vulnerability advisory; `pnpm-workspace.yaml` overrides pin patched ranges.
|
|
23
|
-
4. Gate browser benchmark regressions: run `pnpm bench:browser` (twice at minimum; the gate compares the newest report against the median of the same metric in up to the five preceding archived reports, **and additionally requires the new value to exceed the highest of those samples**) and then `pnpm bench:compare --fail-above-pct 50`. The median baseline exists because the in-page hot-path metrics alternate between a fast and a slow mode on identical code — two consecutive runs of `dedup1000Ms` measured 12.7 ms then 25.6 ms, and a single noisy previous report could fail the documented ceiling with no code change at all. The second leg exists because a median over an alternating sample is only stable while the modes stay mixed: when several consecutive reports land in the same mode the median itself jumps, which is how one comment-only tree read +121% against one baseline and −56.7% against another minutes apart. A metric excused by that leg is printed under `within-baseline, not gated` rather than passing silently — measured on the current archive, `publish/dedicated/perMessageMs`, `publish/shared/perMessageMs` and `databus/publishBatch1000Ms` were all inside their own five-report range, so treat that list as the metric's real resolution, not as a clean bill. `publish/dedicated/perMessageMs` is noisy the same way: two consecutive runs of identical code measured 49.9 ms then 71.1 ms, failing the gate at +75.7% while `dedup1000Ms` and `traceAndPublish1000Ms` *improved* in the same report — that single-metric-up-everything-else-down shape is the noise signature, and the next run reported 49.4 ms and passed. Before accepting or acting on a failure, re-run and check the spread; if the change touches a hot path, size it directly (a micro-benchmark of the 0.20.95 bounded-map rewrites measured 80–86 ns/op across all three variants, i.e. inside run-to-run variance). A baseline shift (e.g. a metric becoming real instead of a no-op) is an expected one-time failure. Refresh the long-run trend doc with `pnpm bench:trend` and commit it when the tables change.
|
|
23
|
+
4. Gate browser benchmark regressions: run `pnpm bench:browser` (twice at minimum; the gate compares the newest report against the median of the same metric in up to the five preceding archived reports, **and additionally requires the new value to exceed the highest of those samples**) and then `pnpm bench:compare --fail-above-pct 50`. The median baseline exists because the in-page hot-path metrics alternate between a fast and a slow mode on identical code — two consecutive runs of `dedup1000Ms` measured 12.7 ms then 25.6 ms, and a single noisy previous report could fail the documented ceiling with no code change at all. The second leg exists because a median over an alternating sample is only stable while the modes stay mixed: when several consecutive reports land in the same mode the median itself jumps, which is how one comment-only tree read +121% against one baseline and −56.7% against another minutes apart. A metric excused by that leg is printed under `within-baseline, not gated` rather than passing silently — measured on the current archive, `publish/dedicated/perMessageMs`, `publish/shared/perMessageMs` and `databus/publishBatch1000Ms` were all inside their own five-report range, so treat that list as the metric's real resolution, not as a clean bill. `publish/dedicated/perMessageMs` is noisy the same way: two consecutive runs of identical code measured 49.9 ms then 71.1 ms, failing the gate at +75.7% while `dedup1000Ms` and `traceAndPublish1000Ms` *improved* in the same report — that single-metric-up-everything-else-down shape is the noise signature, and the next run reported 49.4 ms and passed. Before accepting or acting on a failure, re-run and check the spread; if the change touches a hot path, size it directly (a micro-benchmark of the 0.20.95 bounded-map rewrites measured 80–86 ns/op across all three variants, i.e. inside run-to-run variance). A baseline shift (e.g. a metric becoming real instead of a no-op) is an expected one-time failure. Refresh the long-run trend doc with `pnpm bench:trend` and commit it when the tables change. **Re-running a failure can manufacture its own excuse, and that was measured.** `bench:compare`'s baseline — both the median and the maximum leg — is drawn from the most recent archived reports, and every `bench:browser` run appends to that same archive, so the samples you add while investigating a failure become the baseline that judges the next attempt. On 2026-09-24, at host load averages 7.7–15.1 with a zero non-comment `src/` diff in the tree, the first two runs measured `publish/dedicated 77.3 ms` and `publish/shared 67.7 ms` against a ~38/~34 ms fast mode and failed the 50% ceiling; five further re-runs reported **OK**, because the baseline median had slid to `70.6`. One clean run after taking those samples out of the archive showed every metric rising together (`dedicated +92.5%`, `shared +99.2%`, `dedup +43.4%`, `trace +21.0%`), which is the contention signature and the inverse of the single-metric-up shape above. So read the load average *before* re-running, and when a suspect failure occurs on a loaded host, record it as deferred with its numbers and move the off-mode samples aside instead of letting them into the baseline — and do not regenerate `docs/benchmarks.md` from them.
|
|
24
24
|
5. Confirm the package contains only intended files with `npm pack --dry-run --json`.
|
|
25
25
|
6. Commit on a feature branch, push that branch, and merge its green PR using squash or fast-forward (no merge commit). Fetch the merged commit, tag that exact commit, and push only the specific version tag; never push `main`/`master` directly or force-push. The workflow runs `node scripts/verify-release-version.mjs` to require `RELEASE_TAG` to equal `v` + the package version and to require exactly one non-empty CHANGELOG section.
|
|
26
26
|
|
|
@@ -30,7 +30,7 @@ The repository runs CodeQL (`javascript-typescript`; on push, on pull request, a
|
|
|
30
30
|
|
|
31
31
|
## Tagged-release workflow
|
|
32
32
|
|
|
33
|
-
Pushing a version tag triggers the `Release` GitHub Action: it runs `pnpm check` and `pnpm lint` (a tag can point at a commit that never passed CI's lint step), runs `verify:compat` and `verify:pack`, opens the GitHub release from the `CHANGELOG` section, publishes to npm when the `NPM_TOKEN` secret is set, and then runs the **blocking** published-consumer verification at a budget **twelve times the manual default** — the workflow sets `PUBLISHED_VERIFY_ATTEMPTS=48` and `PUBLISHED_VERIFY_DELAY_MS=7500` (a ~6-minute ceiling) in its own env, while `scripts/verify-published-consumer.mjs` defaults to 6 × 5000 ms = 30 s when run by hand. A release whose published package cannot be imported by a clean consumer fails the workflow — treat every `verify:published` failure as a failed release. For registry propagation or infrastructure failures, rerun the workflow against the unchanged tag; for artifact defects, ship a new patch version. Never move or reuse a published tag. When no token is configured the publish step is skipped, but verification still passes against whatever version is already on npm (e.g. one published manually).
|
|
33
|
+
Pushing a version tag triggers the `Release` GitHub Action: it runs `pnpm check` and `pnpm lint` (a tag can point at a commit that never passed CI's lint step), runs `verify:compat`, `verify:types` and `verify:pack`, opens the GitHub release from the `CHANGELOG` section, publishes to npm when the `NPM_TOKEN` secret is set, and then runs the **blocking** published-consumer verification at a budget **twelve times the manual default** — the workflow sets `PUBLISHED_VERIFY_ATTEMPTS=48` and `PUBLISHED_VERIFY_DELAY_MS=7500` (a ~6-minute ceiling) in its own env, while `scripts/verify-published-consumer.mjs` defaults to 6 × 5000 ms = 30 s when run by hand. A release whose published package cannot be imported by a clean consumer fails the workflow — treat every `verify:published` failure as a failed release. For registry propagation or infrastructure failures, rerun the workflow against the unchanged tag; for artifact defects, ship a new patch version. Never move or reuse a published tag. When no token is configured the publish step is skipped, but verification still passes against whatever version is already on npm (e.g. one published manually).
|
|
34
34
|
|
|
35
35
|
## Publishing
|
|
36
36
|
|
package/docs/roadmap.md
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
# Roadmap
|
|
2
2
|
|
|
3
|
-
0.21.
|
|
3
|
+
0.21.15 was released on September 24, 2026. The project is intentionally continuing through reliability-focused releases before a 1.0.0 stability freeze.
|
|
4
|
+
|
|
5
|
+
## 0.21.15 delivered scope
|
|
6
|
+
|
|
7
|
+
- Both point-to-frame addressee guards are now pinned by name. `handleMessage` dispatches on `type` alone and
|
|
8
|
+
the cluster channel is broadcast, so `targetWorkerId !== this.workerId` is the only thing keeping a runtime
|
|
9
|
+
from acting on a frame meant for someone else; measured by deleting each line in turn, 369 and then 358
|
|
10
|
+
tests stayed green, and the coordination fuzz cannot assert either one because its invariants are end-state
|
|
11
|
+
checks and a slower convergence still passes.
|
|
12
|
+
- The `ROUTE_RELEASED` pin needed a construction its staleness test could not refuse: a durable route that
|
|
13
|
+
genuinely names this runtime, with a matching `generation` and `handoffFromWorkerId`, and the ACK addressed
|
|
14
|
+
to a third worker. That is also why the older forged-`topicKey` case never reached the line.
|
|
15
|
+
- The coordination fuzz gained a third limit, placed where neither half of a loop owns it. Its two existing
|
|
16
|
+
bounds were both made of time, and the failure that had been turning `verify` red spent its time in
|
|
17
|
+
microtasks, where no same-thread deadline can reach it; `ChannelHub` now stops delivering past 50,000
|
|
18
|
+
posts in one seed, with the threshold taken from a measured distribution (p50 = 28, p99 = 90, max = 16,763)
|
|
19
|
+
rather than from the size of the wedge, and a cut seed is printed by name and excluded from the depth floor.
|
|
20
|
+
- What that loop actually is was then measured instead of inferred, and it is the harness rather than the
|
|
21
|
+
library: 11,542 accepts against 11,538 `confirmRoute` skips and 6 writes reaching storage, because the
|
|
22
|
+
owner's confirmation sits in its own batching writer while a peer reads the flushed record — and the cycle
|
|
23
|
+
cannot yield to a microtask because the test hub delivers every frame on the caller's stack. Deferring
|
|
24
|
+
delivery by one microtask removes the storm outright. The earlier note in this file that called it an open
|
|
25
|
+
product defect is corrected here, and the six injected-frame seeds that correction leaves behind are named
|
|
26
|
+
as the next question rather than resolved.
|
|
27
|
+
- `docs/release-checklist.md`, in both languages, now records that `pnpm bench:compare` can be closed by the
|
|
28
|
+
act of investigating it: its baseline is drawn from the same rolling archive every `bench:browser` run
|
|
29
|
+
appends to, so the instruction is to read the host load first and record a suspect failure as deferred
|
|
30
|
+
instead of chasing a green.
|
|
31
|
+
- `docs/progress.md` has a structural gate. Two PRs for one phase had left two `## Phase 142` blocks, with one
|
|
32
|
+
of them pushed below the anchor every entry is appended at; `tests/documentation.test.ts` now requires each
|
|
33
|
+
phase number to be new, later than the previous entry, and above the standing sections.
|
|
34
|
+
|
|
35
|
+
No behavior, wire-format, storage-layout or cluster-protocol change: `src/` is untouched in this version, and
|
|
36
|
+
the release is owed to `docs/release-checklist.md` and `CHANGELOG.md`, both of which ship in the package.
|
|
37
|
+
|
|
38
|
+
## 0.21.14 delivered scope
|
|
39
|
+
|
|
40
|
+
- The published Chinese mirrors were read against `src/` for the first time. `package.json` `files`
|
|
41
|
+
ships ten `docs/*.md` plus their `docs/zh/` copies and both READMEs, and earlier passes had applied
|
|
42
|
+
their corrections sentence by sentence to whichever language they were reading — so the Chinese
|
|
43
|
+
paragraphs kept claims English had already retracted, and English kept claims only its own tables had
|
|
44
|
+
corrected. Twenty-two claim classes came out of that reading, in both directions.
|
|
45
|
+
- The coordination fuzz can now bound a single seed. A `verify` run reported
|
|
46
|
+
`stopped at 1046/5000 seeds after 158476ms (slowest seed 151850ms)` and then the 120s per-test
|
|
47
|
+
ceiling: 1,045 seeds summed to ~6.6 ms each while one interleaving ate 96% of the sweep, and the
|
|
48
|
+
between-seeds fuse could not reach inside it. Each awaiting step now races a 2s deadline and a seed
|
|
49
|
+
that trips it is counted, named by its operation list, and left unasserted rather than judged on a
|
|
50
|
+
state that never quiesced.
|
|
51
|
+
- What that guard cannot do was measured instead of assumed. Forcing the deadline to 12 ms over a full
|
|
52
|
+
5,000-seed sweep trips it zero times, because every locally reproducible slow seed is *synchronous*
|
|
53
|
+
cost inside the first tab's `stop()` — nothing a same-thread deadline can preempt. So the mechanism is
|
|
54
|
+
pinned directly in both directions, and three mutants die at three separate assertions: a cap that
|
|
55
|
+
never expires, one that expires eagerly, and one that reports settled work as expired.
|
|
56
|
+
- The `knownTopics` section of `docs/architecture.md` enumerated three of the six in-process call sites
|
|
57
|
+
that populate the cache; `publishBatch` and `activate()` appeared nowhere in it, in either language.
|
|
58
|
+
- The storage absolute this cycle retracted elsewhere turned out to be hiding in a **table cell**: the
|
|
59
|
+
identifier table's `topic` row said "No; kept in Runtime memory and control messages" while the row
|
|
60
|
+
later in the same table says each storage-event frame, topic plaintext included, is written to
|
|
61
|
+
`localStorage`. Four passes had been through that file, including one that corrected the contradicting
|
|
62
|
+
row. `configuration.md`'s boundary list had also filed the tab-id key with the `localStorage` records;
|
|
63
|
+
it is written to `sessionStorage`.
|
|
64
|
+
- No behavior, wire-format, storage-layout or cluster-protocol change. The only executable edit is in
|
|
65
|
+
`tests/`, which the package does not publish; the shipped diff in `src/` is comments only.
|
|
4
66
|
|
|
5
67
|
## 0.21.13 delivered scope
|
|
6
68
|
|
|
@@ -19,9 +81,13 @@
|
|
|
19
81
|
- Claims about a *dependency* now name it. Against `centrifuge@5.7.4` there is one client-level
|
|
20
82
|
credential hook and no `getChannelToken` in the package at all, so that request kind has never
|
|
21
83
|
fired outside a test fake; and `CentrifugeSession.unsubscribe()` removes the emitter's own no-op
|
|
22
|
-
`error` guard under a comment claiming internals were preserved. The second
|
|
23
|
-
question
|
|
24
|
-
|
|
84
|
+
`error` guard under a comment claiming internals were preserved. The second was first recorded as
|
|
85
|
+
an open question — the test double cannot exhibit the guard, so no existing test could see it
|
|
86
|
+
either way — and has since been closed against the same pinned version: all twelve `emit('error')`
|
|
87
|
+
sites in `BaseSubscription` but one sit behind a `_isSubscribing()`/`_isSubscribed()` test, and the
|
|
88
|
+
three ways back into that one after `unsubscribe()` are closed by a state re-test, by the client's
|
|
89
|
+
`Subscribing`-only reconnect pass, and by the refresh timer `_clearSubscribedState()` cancels. The
|
|
90
|
+
enumeration is in the comment, and no defensive re-attach was warranted.
|
|
25
91
|
- `pnpm bench:compare --fail-above-pct 50` gained the leg its median baseline could not supply: a
|
|
26
92
|
value must now also exceed the highest sample in its own baseline window, because a median over a
|
|
27
93
|
bimodal metric is stable only while the modes stay mixed. What that excuses is printed rather than
|
package/docs/transports.md
CHANGED
|
@@ -110,12 +110,14 @@ reference shape, including:
|
|
|
110
110
|
|
|
111
111
|
- **Backend selection**: reuse `selectWorkerBackend` from `worker-mode.ts` so
|
|
112
112
|
your backend degrades consistently with the rest of the SDK.
|
|
113
|
-
- **Generation guard**: bump a monotonic counter when a backend is created
|
|
114
|
-
transport stops
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
113
|
+
- **Generation guard**: bump a monotonic counter when a *Worker* backend is created
|
|
114
|
+
or the transport stops — the reference implementation's in-process local backend is
|
|
115
|
+
installed without a bump, which is why its `isCurrentBackend()` compares
|
|
116
|
+
Worker/port/`localSession` identity as well. Only the asynchronous credential bridge
|
|
117
|
+
compares it, because a provider can settle after the Worker it was answering is gone. Worker
|
|
118
|
+
error handlers read no such counter in the reference implementation — `stop()` moves the
|
|
119
|
+
generation on *before* it removes those listeners, and `onWorkerFailed()` never touches the
|
|
120
|
+
counter at all, so a superseded backend cannot reach the transport. If your backend keeps a
|
|
119
121
|
listener alive across a swap, that is when you need the check yourself.
|
|
120
122
|
- **SharedWorker heartbeat**: if you use a SharedWorker, send periodic PINGs
|
|
121
123
|
so a `PortReaper` can reclaim dead-tab sessions, and handle the
|
package/docs/zh/api.md
CHANGED
|
@@ -169,9 +169,9 @@ clearReplay(): Promise<void>
|
|
|
169
169
|
|
|
170
170
|
清空内存 replay 缓冲,并调用持久化适配器可选的 `clear()`。适合留存策略、退出登录或租户切换;普通 `stop()` 仍会保留 durable history。
|
|
171
171
|
|
|
172
|
-
`clearReplayTopic(topic)` 只清理一个精确 topic。`getDedupStats()` 返回 `enabled`、`tracked`、`accepted`、`suppressed`
|
|
172
|
+
`clearReplayTopic(topic)` 只清理一个精确 topic。`getDedupStats()` 返回 `enabled`、`tracked`、`accepted`、`suppressed` 四项有界统计,配置了 `dedup.adaptiveTtl` 时还会多一个当前 `ttlMs`;`resetDedup()` 清除已记忆 ID 和计数,不改变 dedup 配置。为测试或非墙上时钟宿主,可额外提供 `dedup.now`。完整 `stop()` 会清空已记忆的 ID 窗口,之后 `start()` 会开启新的 dedup 会话。
|
|
173
173
|
|
|
174
|
-
`clearReplayBefore(timestamp)` 按毫秒时间戳清理带显式 producer timestamp 且早于 cutoff 的记录;实现可选 `clearBefore()`
|
|
174
|
+
`clearReplayBefore(timestamp)` 按毫秒时间戳清理带显式 producer timestamp 且早于 cutoff 的记录;实现可选 `clearBefore()` 的持久化适配器由它 `await`,这一路还经过带退避的重试,因此清理不会在同一个任务内同步完成。没有 producer timestamp 的 legacy 消息会为兼容性保留。transport 未提供时间戳时,系统仍会补充 bus timestamp,但该时间戳不会被当作 producer metadata 用于 retention 清理。
|
|
175
175
|
|
|
176
176
|
启用自动 retention 时,如果持久化清理正在进行,后续清理请求会合并,完成后再应用最新 cutoff。
|
|
177
177
|
|
|
@@ -344,7 +344,7 @@ createCentrifugeDataBus<TData>(options): CrossTabDataBus<CentrifugeDataBusConfig
|
|
|
344
344
|
- 使用包内 `centrifuge.worker.js`
|
|
345
345
|
- Worker 名称为 `cross-tab-worker-databus`
|
|
346
346
|
|
|
347
|
-
SharedWorker 模式使用包内 `centrifuge.shared.worker.js`。`workerMode: 'auto'` 时按 SharedWorker → Dedicated Worker → 本地模式降级。完整配置见 [configuration.md](./configuration.md)。
|
|
347
|
+
SharedWorker 模式使用包内 `centrifuge.shared.worker.js`,其 Worker 名称为 `cross-tab-worker-databus-shared`。`workerMode: 'auto'` 时按 SharedWorker → Dedicated Worker → 本地模式降级。完整配置见 [configuration.md](./configuration.md)。
|
|
348
348
|
|
|
349
349
|
## `CentrifugeWorkerTransport<TData>`
|
|
350
350
|
|
|
@@ -430,7 +430,7 @@ React(>= 18)是可选 peer 依赖;独立入口保证非 React 消费者不
|
|
|
430
430
|
|
|
431
431
|
### `useCrossTabDataBus(create, deps?)`
|
|
432
432
|
|
|
433
|
-
创建随组件生命周期存活的 bus:挂载时创建,卸载时停止。StrictMode 安全——effect
|
|
433
|
+
创建随组件生命周期存活的 bus:挂载时创建,卸载时停止。StrictMode 安全——effect 双调用会跨**不同实例**走完 create → `stop()` → create,因此它覆盖的是停止路径,而不是 BFCache 路径:页面隐藏走的是 `onSuspend()`,它保留同一个 bus、它的 `topicHandlers` 与 replay 缓冲,恢复时再反转;挂起/恢复的覆盖只能来自真实的 hide/show。返回当前 bus;首次 effect 之前(SSR / 初始渲染)为 `null`。
|
|
434
434
|
|
|
435
435
|
每次 effect 返回一个全新 bus(内联工厂即可);需要重建时通过 `deps` 控制。
|
|
436
436
|
|
package/docs/zh/architecture.md
CHANGED
|
@@ -39,7 +39,7 @@ graph TB
|
|
|
39
39
|
end
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
默认 `workerMode: 'dedicated'` 时,每个 Tab 使用独立的 transport Worker。配置为 `shared` 或 `auto` 且浏览器支持 SharedWorker 时,同源 Tab 复用同一个 SharedWorker;SharedWorker 内每个连接 port 各自维护独立的 `CentrifugeSession`,一个 Tab 刷新或停止不会影响其他 Tab。`auto` 模式按 **SharedWorker → Dedicated Worker →
|
|
42
|
+
默认 `workerMode: 'dedicated'` 时,每个 Tab 使用独立的 transport Worker。配置为 `shared` 或 `auto` 且浏览器支持 SharedWorker 时,同源 Tab 复用同一个 SharedWorker;SharedWorker 内每个连接 port 各自维护独立的 `CentrifugeSession`,一个 Tab 刷新或停止不会影响其他 Tab。`auto` 模式按 **SharedWorker → Dedicated Worker → Local 模式**降级,`dedicated` 模式按 **Dedicated Worker → SharedWorker → Local 模式**降级。`BroadcastChannel` 只负责控制消息和实时 publication 转发,localStorage 里与集群相关的也只是最终一致的协调元数据——唯一的例外是 `channelFallback: 'storage-event'`:那时通道本身就是 localStorage,帧会经由它传递,详见 [storage 数据边界](./configuration.md#storage-数据边界)。
|
|
43
43
|
|
|
44
44
|
由于 `MessagePort` 没有 `close` 事件,Tab 崩溃且未发送 `STOP` 时会遗留 session 和 WebSocket。主线程因此每 10 秒发送一次 `PING`,SharedWorker 对超过 30 秒无消息的 port 执行回收,释放对应 session 及其订阅。但“沉默”并不等于“不存在”,因此被回收的端口会在关闭前先收到一条通知:心跳只是被饿死(长同步任务、后台 Tab 受到的定时器节流)的 Tab 由此得知 backend 已失效,会上报该失败并在新端口上重建会话。
|
|
45
45
|
|
|
@@ -166,7 +166,7 @@ interface WorkerRoute {
|
|
|
166
166
|
|
|
167
167
|
| 对象 | 含义 | 主要用途 | 是否写入协调存储 |
|
|
168
168
|
|---|---|---|---|
|
|
169
|
-
| `topic` | 业务使用的原始 Topic 字符串 | 调用 transport 的 `subscribe`、`unsubscribe` 和 `
|
|
169
|
+
| `topic` | 业务使用的原始 Topic 字符串 | 调用 transport 的 `subscribe`、`unsubscribe`、`publish` 和 `publishBatch` | 不进入任何协调记录——只出现在 Runtime 内存与控制消息中。同一张表里的 storage-event 通道那一行是唯一的例外:由它承载帧时,每条 `CONTROL` 点名的明文都会随帧一起落盘 |
|
|
170
170
|
| `topicKey` | `createOpaqueKey(topic)` 生成的稳定不透明 key | 关联 route 与 subscriber 记录 | 是 |
|
|
171
171
|
| `tabId` | 一个浏览器 Tab 的稳定身份 | 标识哪个 Tab 订阅了某个 `topicKey` | 是,体现在 subscriber key 中 |
|
|
172
172
|
| `workerId` | 当前 Runtime/Worker 实例身份 | 标识哪个 Worker 负责实际 transport 订阅 | 是,体现在 worker/route 记录中 |
|
|
@@ -186,11 +186,11 @@ BroadcastChannel CONTROL
|
|
|
186
186
|
└─ topic + topicKey + sourceWorkerId + targetWorkerId + action
|
|
187
187
|
```
|
|
188
188
|
|
|
189
|
-
因此,`topicKey`
|
|
189
|
+
因此,`topicKey` 能把路由记录和控制消息对应起来,但无法由 key 本身反推出原始 `topic`;只有仍存活的 Runtime 才保留 `topicKey → topic` 的内存映射。
|
|
190
190
|
|
|
191
191
|
### 内存 Topic key 缓存 (`knownTopics`)
|
|
192
192
|
|
|
193
|
-
每个 Runtime 维护一个 `Map<topicKey, topic>` 称为 `knownTopics`,作为不透明 key 到原始 topic
|
|
193
|
+
每个 Runtime 维护一个 `Map<topicKey, topic>` 称为 `knownTopics`,作为不透明 key 到原始 topic 的反向查找缓存。它在每一个 `rememberTopic()` 调用点被写入:`subscribe`、`publish`、`publishBatch`、`unsubscribe`(在 `releaseSubscription` 内部)、启动/恢复时为队列里每个 topic 各调用一次的 `activate()`,以及每轮的 `reconcileSubscriptions()`。入站 `CONTROL` 消息则只有在跨过 `handleControlMessage` 顶部两道守卫之后才走到这一步——点名其他 worker 的帧,与配对不相符的帧,都会在读到明文之前就被丢弃,因此两者都无法撑大这张表。合法调用仍然可以(同源脚本能通过页面上自己的 bus 触达这些 API),约束它们的正是下面的上限。
|
|
194
194
|
|
|
195
195
|
该缓存存在两个原因:
|
|
196
196
|
|
|
@@ -217,8 +217,11 @@ BroadcastChannel CONTROL
|
|
|
217
217
|
|---|---|---|
|
|
218
218
|
| `subscribe(topic)` | `rememberTopic(topic)` → `set(topicKey, topic)` | 填充反向映射,供无 storage 模式的 `readRoute` 使用 |
|
|
219
219
|
| `publish(topic, data)` | `rememberTopic(topic)` → `set(topicKey, topic)` | 同上 |
|
|
220
|
-
| `
|
|
221
|
-
|
|
|
220
|
+
| `publishBatch(topic, items)` | `rememberTopic(topic)` → `set(topicKey, topic)` | 同上;一个批次只解析一个 key,不是每条 item 一个(只含一条的批次直接转由 `publish` 处理) |
|
|
221
|
+
| `activate()`(启动 / 恢复) | 对 `subscribedTopics` 里每个 topic 调用 `rememberTopic(topic)` → `set(topicKey, topic)` | 反向映射必须在该循环的两个分支任一执行之前就已存在:无 storage 路径要发 `CONTROL/SUBSCRIBE`,另一支要写 subscriber 记录 |
|
|
222
|
+
| `reconcileSubscriptions()`(每轮) | 对 `subscribedTopics` 里每个 topic 调用 `rememberTopic(topic)` → `set(topicKey, topic)` | 每轮心跳重申一次映射;见上文原因 2 |
|
|
223
|
+
| `unsubscribe(topic)` | 先 `rememberTopic(topic)`(在 `releaseSubscription` 内),如不在 `assignedTopics` 中再 `delete(topicKey)` | key 在归属检查之前推导;仅当仍持有该 topic 时才保留 |
|
|
224
|
+
| 收到 `CONTROL`(任意动作:SUBSCRIBE / UNSUBSCRIBE / PUBLISH) | `rememberTopic(message.topic)` → `set(topicKey, topic)` | 入站帧携带明文 topic,但 `rememberTopic` 位于 `targetWorkerId` 与 `topicKey` 配对两道守卫**之后**,被任一守卫丢弃的帧永远不会被缓存 |
|
|
222
225
|
| 收到 `CONTROL/UNSUBSCRIBE` | 不做直接删除 | `rememberTopic` 仍会缓存该 topic;路由不再指向本 worker 后由 `reconcileAssignedTopics` 移除 |
|
|
223
226
|
| `reconcileAssignedTopics` | 如未订阅且未持有则 `delete(topicKey)` | 路由不再指向我们——除非仍是 subscriber 否则清理 |
|
|
224
227
|
| `stop()` | `clear()` | 完全销毁 |
|
|
@@ -258,7 +261,7 @@ sequenceDiagram
|
|
|
258
261
|
RuntimeA->>App: 调用 handler(payload)
|
|
259
262
|
```
|
|
260
263
|
|
|
261
|
-
第二个 Tab 订阅同一 Topic 时,只新增自己的 `subscriber:{topicKey}:{tabId}`;只要现有 route 的 owner 仍存活,就不会再次建立一条 transport 订阅。退订时删除当前 Tab 的 subscriber
|
|
264
|
+
第二个 Tab 订阅同一 Topic 时,只新增自己的 `subscriber:{topicKey}:{tabId}`;只要现有 route 的 owner 仍存活,就不会再次建立一条 transport 订阅。退订时删除当前 Tab 的 subscriber 记录;若它已是最后一条,同一个 Tab 顺带删除 route,并向 route 记录的 worker 发出 `CONTROL/UNSUBSCRIBE`——收到的一方就此放弃 ownership 并释放 transport 订阅。
|
|
262
265
|
|
|
263
266
|
### 控制台排查
|
|
264
267
|
|
|
@@ -332,7 +335,7 @@ console.table(__bus.getClusterSnapshot().routes)
|
|
|
332
335
|
|
|
333
336
|
| 类型 | 方向 | 用途 |
|
|
334
337
|
|---|---|---|
|
|
335
|
-
| `CONTROL` | 点对点(A → B) | 请求目标 Worker 对某 Topic 执行 `SUBSCRIBE`、`UNSUBSCRIBE` 或 `PUBLISH`。携带 `action`、`topic`、`topicKey`、`targetWorkerId` 和可选 `data
|
|
338
|
+
| `CONTROL` | 点对点(A → B) | 请求目标 Worker 对某 Topic 执行 `SUBSCRIBE`、`UNSUBSCRIBE` 或 `PUBLISH`。携带 `action`、`topic`、`topicKey`、`targetWorkerId` 和可选 `data`。持久化 route 指向*其他* worker 时 `SUBSCRIBE` 被丢弃;仍在等待 ACK 的交接只接受与它配对的那条 `ROUTE_RELEASED`。route 完全读不到时帧被放行——这样的帧换不到持久所有权(`confirmRoute` 无 route 可写),下一轮 reconcile 会撤回该 assignment。 |
|
|
336
339
|
| `EVENT` | 广播(owner → 所有 Tab) | 把 transport 投递给 owner Worker 的 publication 扇出到所有 Tab。携带 `eventType` 和 `payload`。 |
|
|
337
340
|
| `REGISTRY` | 广播 | 注册表或路由写入后通知所有 Tab 立即 reconcile,而不是等下一轮心跳。 |
|
|
338
341
|
| `ROUTE_RELEASED` | 点对点(旧 owner → 新 owner) | 确认一次优雅迁移;只有 route `generation` 匹配的新 owner 才允许发送 `SUBSCRIBE`(见故障转移)。 |
|
|
@@ -506,7 +509,7 @@ Transport 消息 → isAssigned(topic)? → 是 → broadcastEvent(EVENT)
|
|
|
506
509
|
集群收敛由两条时间线驱动:
|
|
507
510
|
|
|
508
511
|
- **心跳 + reconcile 循环**(默认 `3000 ms`,`heartbeatIntervalMs`)。每轮心跳每个 Worker 刷新自己的记录并跑一次 reconcile:清理超过 `workerTtlMs` 的 Worker、所属 Tab 已不活跃的孤儿 subscriber、以及没有 subscriber 且超过 TTL 的孤儿路由;重算自己的 active/standby 角色;重写自己的 subscriber 记录;并对任何还缺 `confirmedAt` 的 route 重发 `CONTROL/SUBSCRIBE`——顺带恢复通道上丢失的控制消息。
|
|
509
|
-
- **`REGISTRY` 通知**。Worker
|
|
512
|
+
- **`REGISTRY` 通知**。Worker 记录与路由写入后广播 `REGISTRY`,让所有对端立即 reconcile,而不是等下一轮心跳;只写 subscriber 记录不会广播:`subscribe()` 命中一个仍然存活的 owner 时,写完本 Tab 的 subscriber 记录就直接返回。
|
|
510
513
|
|
|
511
514
|
心跳写入不广播,因此在发现过期记录之前最多会经过一个完整心跳间隔。检测死 owner 的最坏窗口为 `heartbeatIntervalMs + workerTtlMs`(默认约 13 秒);相关权衡见 [TTL 消息丢失窗口](./configuration.md#ttl-消息丢失窗口)。
|
|
512
515
|
|
|
@@ -532,7 +535,7 @@ Transport 消息 → isAssigned(topic)? → 是 → broadcastEvent(EVENT)
|
|
|
532
535
|
|
|
533
536
|
以下不变量由回归测试固化(见 `tests/stability.test.ts` 与 `tests/replay-persistence.test.ts`),后续重构必须继续保持:
|
|
534
537
|
|
|
535
|
-
- **SUBSCRIBE 路由绑定。** 入站 `CONTROL/SUBSCRIBE`
|
|
538
|
+
- **SUBSCRIBE 路由绑定。** 入站 `CONTROL/SUBSCRIBE` 在持久化 route 指向*其他* worker 时被丢弃,未确认的优雅交接也只能由精确匹配的 `ROUTE_RELEASED` 完成。route 完全读不到(过期、损坏,或根本没有 storage)是唯一被放行的例外:`confirmRoute` 没有 route 可写,因此这帧换不到持久所有权,`reconcileAssignedTopics` 会在下一轮撤回该 assignment。两侧都由 `tests/cluster.test.ts` 固化。ownership 以当前 route 记录为准,而不以控制帧到达顺序为准。
|
|
536
539
|
- **Handoff ACK 有效性。** `ROUTE_RELEASED` 只有在 route 仍指向接收方、释放来自记录的 `handoffFromWorkerId`、且 ACK generation 与存储 route 的 generation 精确相等时才被接受。来自其他交接轮次的重复或迟到 ACK(如 a↔b 反复交接)会被丢弃,不能确认当前 route。
|
|
537
540
|
- **Replay 持久化清理顺序。** 排队在当前任务之后的批量持久化 flush 会与竞速的清理操作对账:`unsubscribe` 与 `clearReplayTopic` 丢弃该 topic 的待写条目,`clearReplayBefore` 丢弃早于截止时间的条目,`suspend()`/`stop()` 则丢弃整个待写批次,避免它在新生命周期代际下启动。已清理或属于已停止会话的历史不会被在途 flush 复活。
|
|
538
541
|
- **存储写失败恢复。** 合并写入按指数退避重试(50 ms → 1.6 s 封顶)。结构性失败的关键在 5 次尝试后被丢弃(伴随 `console.warn`),且不会永久阻塞其他排队 key;队列完全清空或 `clear()` 取消重试后,退避延迟重置。
|
package/docs/zh/capabilities.md
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
| SUBSCRIBE 路由绑定 | 协调 | 当持久化 route 指向另一个 Worker 时,入站 `CONTROL/SUBSCRIBE` 会被丢弃;等待 `ROUTE_RELEASED` 的 route 只被匹配的交接 ACK 确认——因此较早分配轮的迟到帧不能创建持久 ownership、订阅 transport 或确认悬挂交接。topic 读不到 route 时该帧是被接受而非丢弃,而这个容忍是有界的:没有 route 可盖章时 `confirmRoute` 什么都不写,下一次 reconcile 会收回这条 assignment。它换来的是存储不可用、或记录已过期时协调仍能工作 |
|
|
48
48
|
| 交接 ACK 有效性 | 协调 | 仅当 route 仍指向接收方、释放方匹配 `handoffFromWorkerId`、且 ACK 代数与存储 route 代数精确相等时才接受 `ROUTE_RELEASED`——其他交接轮次的过期 ACK(如 a↔b 乒乓)会被丢弃 |
|
|
49
49
|
| 回放持久化清理顺序 | 持久性 | 排队中的批量 flush 会按先到清理过滤(`unsubscribe`/`clearReplayTopic` 丢弃该 topic 的待写条目,`clearReplayBefore` 丢弃早于截止时间的条目,`suspend()`/`stop()` 丢弃整个待写批次);已清或已停止会话的历史不会被进行中的 flush 重新追加 |
|
|
50
|
-
| 存储写入恢复 | 协调 | 合并写以指数退避重试(50 ms → 1.6 s 上限);结构性失败键在 5 次后丢弃(并 `console.warn
|
|
50
|
+
| 存储写入恢复 | 协调 | 合并写以指数退避重试(50 ms → 1.6 s 上限);结构性失败键在 5 次后丢弃(并 `console.warn`);本轮 flush 在第一个失败处停止,因此排在它之后的键要等该键的那次重试(四轮退避 50+100+200+400 ms)一起补写——被延后,而非永久阻塞;队列清空或 `clear()` 取消后退避重置 |
|
|
51
51
|
| 传输恢复预算 | 生命周期 | 自动恢复由冷却间隔节流、以 `recovery.maxAttempts` 为界,预算耗尽时报 `exhausted`;成功重开后重置尝试计数与 exhausted,断线传输上的显式 `subscribe` 仍可手动恢复 |
|
|
52
52
|
| 恢复 waiter 生命周期 | 生命周期 | 停靠在 transport recovery gate 上的操作会捕获恢复取消 generation;`stop()` / 页面隐藏会使其失效,因此紧随其后的显式 `start()` 在 replacement transport 重建订阅后,不会再执行过期操作造成重复订阅。相对地,显式 `start()` 取代自动恢复 timer 时会保留门及其停靠操作;若手动重开也失败,后续自动或按需重开仍可成功回放这些操作 |
|
|
53
53
|
| BFCache 挂起 | 生命周期 | 隐藏页面停掉传输并静默取消进行中的持久化重试;pageshow 重开传输并每个周期恰好一次重建订阅 |
|
package/docs/zh/configuration.md
CHANGED
|
@@ -158,7 +158,7 @@ const bus = createCentrifugeDataBus({
|
|
|
158
158
|
| `heartbeatIntervalMs` | `number` | `10000` | SharedWorker PING 心跳间隔(见下方 SharedWorker 会话回收);传 `Infinity` 完全禁用心跳,该端口也因此豁免于回收。与 Core 集群心跳(默认 3000 ms,通过 localStorage 跟踪 worker 存活)相互独立 |
|
|
159
159
|
| `workerFactory` | `() => Worker` | 内置 Worker | 测试或自定义 Worker 加载方式 |
|
|
160
160
|
| `sharedWorkerFactory` | `() => SharedWorker` | 内置 SharedWorker | 测试或自定义 SharedWorker 加载方式 |
|
|
161
|
-
| `credentialProvider` | `{ getToken?, getChannelToken? }` | `undefined` | 异步凭证刷新桥:Worker 向主线程请求每个新 token
|
|
161
|
+
| `credentialProvider` | `{ getToken?, getChannelToken? }` | `undefined` | 异步凭证刷新桥:Worker 向主线程请求每个新 token,由该 provider 从应用上下文提供。实践中这一问只发生在 `getToken` 上——它是 `centrifuge@5.7.4` 暴露的唯一凭证钩子;类型里保留着 `getChannelToken`,Worker 协议也保留该请求种类,以便未来的 SDK 无需改动本侧,但今天没有任何代码会问它。必要原因:函数型 Centrifuge 选项无法 structured-clone 进 Worker |
|
|
162
162
|
| 其他 Core 配置 | 对应类型 | Core 默认值 | `storagePrefix`、心跳、TTL 等 |
|
|
163
163
|
|
|
164
164
|
```ts
|
|
@@ -249,21 +249,26 @@ Worker 模式下配置通过 `Worker` / `SharedWorker` 的 `postMessage` 发送
|
|
|
249
249
|
|
|
250
250
|
## storage 数据边界
|
|
251
251
|
|
|
252
|
-
|
|
252
|
+
本库亲自写入的协调记录——`{clusterHash}:worker:{workerId}`、`{clusterHash}:route:{topicKey}`、`{clusterHash}:subscriber:{topicKey}:{tabId}` 以及 tab-id 键——只保存:
|
|
253
253
|
|
|
254
|
-
- Worker
|
|
255
|
-
- Topic 的不透明 key
|
|
256
|
-
-
|
|
254
|
+
- Worker 与 Tab 身份、角色、状态、可见性、代表持有 Topic 数量的 `load`、可选的滚动吞吐样本、广播出去的协议版本,以及心跳与首次注册的时间戳
|
|
255
|
+
- Topic 的不透明 key、其 owner 的 Worker 与 Tab 身份、最后更新时间与 generation,以及优雅交接期间的上一任 owner 和 owner 的确认时间
|
|
256
|
+
- subscriber 记录的 Tab ID 与它的最后更新时间
|
|
257
|
+
- Tab 自己的持久化 id——正是它让刷新后的页面能认领回自己的 route;它写在 `sessionStorage`,不在本集群的 `localStorage` 命名空间里
|
|
257
258
|
|
|
258
|
-
|
|
259
|
+
这些记录里永远不会出现:
|
|
259
260
|
|
|
260
261
|
- 连接地址原文
|
|
261
262
|
- Topic 原文
|
|
262
263
|
- 连接凭证
|
|
263
264
|
- publication 数据
|
|
264
|
-
- `publish` 数据
|
|
265
265
|
|
|
266
|
-
|
|
266
|
+
这是对**这些键**的约束,不是对浏览器存储整体的约束。有两个开关会把 Topic 明文和 payload 写进去:
|
|
267
|
+
|
|
268
|
+
- `channelFallback: 'storage-event'` 把每一帧协调消息整体写到 `cross-tab-worker-databus:channel:*` 之下,而一条 `CONTROL/PUBLISH` 帧同时携带它的 Topic 名称与 payload——因此这些数据会留在 `localStorage` 直到该 channel 关闭,若 tab 先退出则无限期保留。
|
|
269
|
+
- `replay.persistence`(如 `createIndexedDbReplayPersistence`)把回放历史存在 IndexedDB 的一个 object store 里,该 store 以 Topic 明文为 key,payload 就在其中的行里。
|
|
270
|
+
|
|
271
|
+
注意:BroadcastChannel 协调消息以明文传输 Topic 名称、事件类型和 publication payload(仅存在于内存中,除非承载它们的是上面那条降级通道)。只有 localStorage 元数据通过 `createOpaqueKey()` 哈希处理。
|
|
267
272
|
|
|
268
273
|
## 安全与信任模型
|
|
269
274
|
|
|
@@ -275,7 +280,7 @@ storage 不保存:
|
|
|
275
280
|
- `clusterKey` 通过 `createOpaqueKey`(非密码学 128-bit 哈希)派生 storage 前缀与 BroadcastChannel 名称。实践中 `clusterKey` 总是连接 URL 或开发者控制的命名空间,两个不同 `clusterKey` 间的哈希碰撞(~2⁻⁶⁴ 生日界限)不是实际问题。
|
|
276
281
|
- 缓解措施:页面内不要加载不受信任的第三方脚本;在独立源上承载协调逻辑;将源的 `localStorage` 与 BroadcastChannel 命名空间视为公开区域。CSP 无法限制同源脚本对 BroadcastChannel 或 localStorage 的访问。
|
|
277
282
|
|
|
278
|
-
transport 平面(如 Centrifuge WebSocket)有自己的安全模型——token、TLS 和服务端权限——不受上述影响。集群只决定哪个 Tab 持有 transport
|
|
283
|
+
transport 平面(如 Centrifuge WebSocket)有自己的安全模型——token、TLS 和服务端权限——不受上述影响。集群只决定哪个 Tab 持有 transport 订阅;payload 随后由该 owner 经 BroadcastChannel 送达其他 Tab,或直接由服务端送达持有订阅的那个 Tab。唯一的例外就是上面那条 `channelFallback: 'storage-event'` 降级路径:那里的通道本身就是 `localStorage`,同样的帧——连 payload 一起——会被写进去。
|
|
279
284
|
|
|
280
285
|
## 集群隔离建议
|
|
281
286
|
|
|
@@ -47,7 +47,7 @@ export const dataBus = createCentrifugeDataBus<ResourceEvent>({
|
|
|
47
47
|
|
|
48
48
|
`clusterKey` 默认由连接地址派生。它只用于集群隔离,进入 localStorage 和 BroadcastChannel 通道名称前会转换为不透明 key。请注意,通过 BroadcastChannel 协调通道发送的 Topic 名称和事件类型以明文传输;仅 localStorage 元数据通过哈希进行混淆。
|
|
49
49
|
|
|
50
|
-
默认使用 Dedicated Worker,每个 Tab 一个 Worker。`workerMode: 'shared'`(或先尝试 shared 的 `'auto'`)会把同源 Tab 放进**同一个** SharedWorker 进程——但不会合并它们的连接:每个连接的 port 都有自己的 `CentrifugeSession`,因此也有自己的 WebSocket,这正是关闭或刷新一个 Tab 不影响其余 Tab 的原因——N 个 Tab 仍是 N
|
|
50
|
+
默认使用 Dedicated Worker,每个 Tab 一个 Worker。`workerMode: 'shared'`(或先尝试 shared 的 `'auto'`)会把同源 Tab 放进**同一个** SharedWorker 进程——但不会合并它们的连接:每个连接的 port 都有自己的 `CentrifugeSession`,因此也有自己的 WebSocket,这正是关闭或刷新一个 Tab 不影响其余 Tab 的原因——N 个 Tab 仍是 N 条服务端连接。channel 却不会按 N 倍增:一个 transport 只订阅自己那个 worker 拥有的 Topic,所以某个 Topic 的 channel 只会出现在其中一个 Tab 的 socket 上,而不是 N 条上都有。shared 模式省下的是 Worker 进程,不是 socket:
|
|
51
51
|
|
|
52
52
|
```ts
|
|
53
53
|
export const dataBus = createCentrifugeDataBus<ResourceEvent>({
|