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.
@@ -2505,7 +2505,7 @@ var DedupManager = class {
2505
2505
  };
2506
2506
 
2507
2507
  // src/core/version.ts
2508
- var SDK_VERSION = true ? "0.21.13" : "";
2508
+ var SDK_VERSION = true ? "0.21.15" : "";
2509
2509
 
2510
2510
  // src/core/data-bus.ts
2511
2511
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  selectRebalanceTarget,
21
21
  selectWorkerBackend,
22
22
  topicMatchesPattern
23
- } from "./chunk-4HW6PYIR.js";
23
+ } from "./chunk-VQJIMGJN.js";
24
24
  import {
25
25
  DEFAULT_STORAGE_PREFIX,
26
26
  PRUNE_STRATEGY,
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 same stop/recreate path as BFCache suspend/resume. Returns the active bus or `null` before the first effect (SSR / initial render).
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
 
@@ -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; localStorage is only responsible for eventually-consistent coordination metadata.
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 `publish` | No; kept in Runtime memory and control messages |
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 by `rememberTopic()`, which is called on every `subscribe`, `publish`, and `unsubscribe`, and on every inbound `CONTROL` message that survives the `topicKey` pairing check — a frame dropped for disagreeing about that pair is dropped before its plaintext is ever read, so invalid pairs cannot grow this map. 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.
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
- | `unsubscribe(topic)` | `delete(topicKey)` if not in `assignedTopics` | No longer needed; only keep it if we still own the topic |
251
- | `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 |
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. The owner unsubscribes the transport and removes the route only when no subscriber remains.
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 honored only when the durable route currently names the target and is not awaiting `ROUTE_RELEASED`. |
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, routes, or subscribers broadcast a `REGISTRY` message so every peer reconciles immediately rather than waiting for the next heartbeat.
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 accepted only when the durable route currently names the receiver and is not an unconfirmed graceful handoff. A delayed frame from an earlier assignment round cannot add ownership, subscribe the transport, or confirm the route; only the matching `ROUTE_RELEASED` can authorize a pending handoff. Ownership follows the current route record, not the order in which control frames arrive.
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.
@@ -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 (`getToken` / `getChannelToken`) and this provider supplies it from application context. Required because function-valued Centrifuge options cannot be structured-cloned into the Worker |
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
- Storage only holds:
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 ID, Tab ID, status, visibility, load, and heartbeat
262
- - Opaque Topic key, owner Worker, and last update time
263
- - Topic subscriber's Tab ID
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
- Storage does NOT hold:
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
- Note: BroadcastChannel coordination messages carry topic names, event types, and publication payloads in plaintext (in-memory only). Only localStorage metadata is hashed via `createOpaqueKey()`.
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 routes which tab owns the transport subscription; it never proxies the payload through `localStorage` (payloads travel via BroadcastChannel in memory or via the server directly).
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
 
@@ -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 and N channel subscriptions. Shared mode saves the Worker process, not the socket:
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.13 was released on September 24, 2026. The project is intentionally continuing through reliability-focused releases before a 1.0.0 stability freeze.
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 is recorded as an open
23
- question with the interleaving it would take to call it a defect — the test double cannot exhibit
24
- the guard, so no existing test can see it either way.
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
@@ -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 or the
114
- transport stops. Only the asynchronous credential bridge compares it, because a
115
- provider can settle after the Worker it was answering is gone. Worker error
116
- handlers read no such counter in the reference implementation — `stop()` and
117
- `onWorkerFailed()` remove those listeners before the generation moves on, so a
118
- superseded backend cannot reach the transport at all. If your backend keeps a
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` 四项有界统计;`resetDedup()` 清除已记忆 ID 和计数,不改变 dedup 配置。为测试或非墙上时钟宿主,可额外提供 `dedup.now`。完整 `stop()` 会清空已记忆的 ID 窗口,之后 `start()` 会开启新的 dedup 会话。
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()` 的持久化适配器会同步执行该清理。没有 producer timestamp 的 legacy 消息会为兼容性保留。transport 未提供时间戳时,系统仍会补充 bus timestamp,但该时间戳不会被当作 producer metadata 用于 retention 清理。
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 双调用走的是与 BFCache 挂起/恢复相同的停止/重建路径。返回当前 bus;首次 effect 之前(SSR / 初始渲染)为 `null`。
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
 
@@ -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 → 主线程 WebSocket** 降级,`dedicated` 模式按 **Dedicated Worker → SharedWorker → 主线程 WebSocket** 降级。`BroadcastChannel` 只负责控制消息和实时 publication 转发;localStorage 只负责最终一致的协调元数据。
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` 和 `publish` | 否;仅在 Runtime 内存和 BroadcastChannel 控制消息中出现 |
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` 能把路由记录和控制消息对应起来,但不能从 localStorage 反推出原始 `topic`;只有仍存活的 Runtime 才保留 `topicKey → topic` 的内存映射。
189
+ 因此,`topicKey` 能把路由记录和控制消息对应起来,但无法由 key 本身反推出原始 `topic`;只有仍存活的 Runtime 才保留 `topicKey → topic` 的内存映射。
190
190
 
191
191
  ### 内存 Topic key 缓存 (`knownTopics`)
192
192
 
193
- 每个 Runtime 维护一个 `Map<topicKey, topic>` 称为 `knownTopics`,作为不透明 key 到原始 topic 的反向查找缓存。它由 `rememberTopic()` 填充:`subscribe`、`publish`、`unsubscribe` 都会调用它,入站 `CONTROL` 消息则只在通过 `topicKey` 配对校验后才走到这一步——一对不相符的帧在读到明文之前就被丢弃,因此无效配对无法撑大这张表。合法调用仍然可以(同源脚本能通过页面上自己的 bus 触达这些 API),约束它们的正是下面的上限。
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
- | `unsubscribe(topic)` | 如不在 `assignedTopics` 中则 `delete(topicKey)` | 不再需要;仅当仍持有该 topic 时才保留 |
221
- | 收到 `CONTROL`(任意动作:SUBSCRIBE / UNSUBSCRIBE / PUBLISH) | `rememberTopic(message.topic)` → `set(topicKey, topic)` | 每条入站控制消息都携带明文 topic,handler 在动作分派前先缓存它 |
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 记录;当没有任何 subscriber 时,owner 才会退订 transport 并清理 route。
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`。只有持久化 route 当前指向目标且不在等待 `ROUTE_RELEASED` 时,`SUBSCRIBE` 才会被执行。 |
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 记录、路由或 subscriber 写入后广播 `REGISTRY`,让所有对端立即 reconcile,而不是等下一轮心跳。
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` 只有在持久化 route 当前指向接收方、且该 route 不是未确认的优雅交接时才会被接受。来自较早分配轮的迟到帧不能加入 ownership、不能订阅 transport,也不能确认 route;等待中的交接只能由精确匹配的 `ROUTE_RELEASED` 授权。ownership 以当前 route 记录为准,而不以控制帧到达顺序为准。
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()` 取消重试后,退避延迟重置。
@@ -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`)而不阻塞其他排队键;队列清空或 `clear()` 取消后退避重置 |
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 重开传输并每个周期恰好一次重建订阅 |
@@ -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(`getToken` / `getChannelToken`),由该 provider 从应用上下文提供。必要原因:函数型 Centrifuge 选项无法 structured-clone 进 Worker |
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
- storage 只保存:
252
+ 本库亲自写入的协调记录——`{clusterHash}:worker:{workerId}`、`{clusterHash}:route:{topicKey}`、`{clusterHash}:subscriber:{topicKey}:{tabId}` 以及 tab-id 键——只保存:
253
253
 
254
- - Worker ID、Tab ID、状态、可见性、负载和心跳
255
- - Topic 的不透明 key、owner Worker 和更新时间
256
- - Topic subscriber 的 Tab ID
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
- storage 不保存:
259
+ 这些记录里永远不会出现:
259
260
 
260
261
  - 连接地址原文
261
262
  - Topic 原文
262
263
  - 连接凭证
263
264
  - publication 数据
264
- - `publish` 数据
265
265
 
266
- 注意:BroadcastChannel 协调消息以明文传输 Topic 名称、事件类型和 publication payload(仅存在于内存中)。只有 localStorage 元数据通过 `createOpaqueKey()` 哈希处理。
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 订阅,从不把 payload 写入 `localStorage`(payload 经 BroadcastChannel 内存传输或由服务端直发)。
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 条服务端连接、N 个 channel 订阅。shared 模式省下的是 Worker 进程,不是 socket:
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>({