cross-tab-worker-databus 0.21.12 → 0.21.14

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +2 -2
  3. package/README.zh.md +2 -2
  4. package/dist/centrifuge-protocol.d.ts.map +1 -1
  5. package/dist/centrifuge-session.d.ts +10 -3
  6. package/dist/centrifuge-session.d.ts.map +1 -1
  7. package/dist/centrifuge.d.ts +10 -4
  8. package/dist/centrifuge.d.ts.map +1 -1
  9. package/dist/centrifuge.js +25 -11
  10. package/dist/centrifuge.js.map +2 -2
  11. package/dist/centrifuge.shared.worker.js +28 -11
  12. package/dist/centrifuge.shared.worker.js.map +2 -2
  13. package/dist/centrifuge.worker.js +10 -3
  14. package/dist/centrifuge.worker.js.map +2 -2
  15. package/dist/{chunk-TSTV3SDC.js → chunk-3XBN7FFJ.js} +67 -33
  16. package/dist/chunk-3XBN7FFJ.js.map +7 -0
  17. package/dist/{chunk-2TTJ7LGZ.js → chunk-NPFTD7YE.js} +1 -1
  18. package/dist/chunk-NPFTD7YE.js.map +7 -0
  19. package/dist/cjs/centrifuge.cjs +88 -40
  20. package/dist/cjs/centrifuge.cjs.map +2 -2
  21. package/dist/cjs/hooks.cjs.map +2 -2
  22. package/dist/cjs/index.cjs +80 -36
  23. package/dist/cjs/index.cjs.map +2 -2
  24. package/dist/cjs/vue.cjs.map +2 -2
  25. package/dist/core/cluster.d.ts +33 -16
  26. package/dist/core/cluster.d.ts.map +1 -1
  27. package/dist/core/data-bus.d.ts +19 -5
  28. package/dist/core/data-bus.d.ts.map +1 -1
  29. package/dist/core/dedup-manager.d.ts +6 -3
  30. package/dist/core/dedup-manager.d.ts.map +1 -1
  31. package/dist/core/environment.d.ts +14 -5
  32. package/dist/core/environment.d.ts.map +1 -1
  33. package/dist/core/hash.d.ts +10 -4
  34. package/dist/core/hash.d.ts.map +1 -1
  35. package/dist/core/replay-manager.d.ts +2 -1
  36. package/dist/core/replay-manager.d.ts.map +1 -1
  37. package/dist/core/routing.d.ts +18 -9
  38. package/dist/core/routing.d.ts.map +1 -1
  39. package/dist/core/storage-batch.d.ts +2 -2
  40. package/dist/core/storage-batch.d.ts.map +1 -1
  41. package/dist/core/trace.d.ts +21 -9
  42. package/dist/core/trace.d.ts.map +1 -1
  43. package/dist/core/types.d.ts +13 -5
  44. package/dist/core/types.d.ts.map +1 -1
  45. package/dist/hooks.d.ts.map +1 -1
  46. package/dist/hooks.js +1 -1
  47. package/dist/hooks.js.map +2 -2
  48. package/dist/index.d.ts +2 -2
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +17 -7
  51. package/dist/index.js.map +2 -2
  52. package/dist/utils/constants.d.ts +7 -4
  53. package/dist/utils/constants.d.ts.map +1 -1
  54. package/dist/utils/validation.d.ts +12 -4
  55. package/dist/utils/validation.d.ts.map +1 -1
  56. package/dist/vue.js +1 -1
  57. package/dist/websocket.d.ts +24 -6
  58. package/dist/websocket.d.ts.map +1 -1
  59. package/dist/workers/port-reaper.d.ts +18 -8
  60. package/dist/workers/port-reaper.d.ts.map +1 -1
  61. package/docs/api.md +9 -8
  62. package/docs/architecture.md +12 -9
  63. package/docs/benchmarks.md +9 -9
  64. package/docs/configuration.md +14 -9
  65. package/docs/getting-started.md +1 -1
  66. package/docs/release-checklist.md +4 -4
  67. package/docs/roadmap.md +64 -1
  68. package/docs/transports.md +11 -8
  69. package/docs/zh/api.md +10 -9
  70. package/docs/zh/architecture.md +13 -10
  71. package/docs/zh/benchmarks.md +9 -9
  72. package/docs/zh/capabilities.md +1 -1
  73. package/docs/zh/configuration.md +14 -9
  74. package/docs/zh/getting-started.md +1 -1
  75. package/docs/zh/release-checklist.md +4 -4
  76. package/docs/zh/roadmap.md +50 -1
  77. package/docs/zh/transports.md +3 -2
  78. package/package.json +2 -1
  79. package/dist/chunk-2TTJ7LGZ.js.map +0 -7
  80. package/dist/chunk-TSTV3SDC.js.map +0 -7
@@ -6,11 +6,19 @@
6
6
  * same cross-tab clustering stack (owner dedup, sticky routes, EVENT fan-out)
7
7
  * that the Centrifuge backend uses.
8
8
  *
9
- * Wire protocol (JSON text frames):
9
+ * Wire protocol — two frame formats, both over text/binary WebSocket messages:
10
10
  * - client → server: `{"op":"subscribe"|"unsubscribe"|"publish","topic":...,"data":...}`
11
11
  * - client → server (batched): `{"op":"publishBatch","topic":...,"items":[{data,...}]}`
12
12
  * - server → client: `{"topic":...,"data":...}` for publications; anything
13
13
  * without a string `topic` field is ignored (forward-compatible).
14
+ * - binary publications use a *separate* tagged frame, not JSON:
15
+ * `0xc7 | uint16 topicLength | topic UTF-8 | raw payload`, written by
16
+ * `sendBinaryFrame` when `publish()` receives an `ArrayBuffer` with no
17
+ * `messageId`/`timestamp` (with metadata it degrades to a JSON PUBLISH whose
18
+ * `data` is a number array, so an id is never lost to the compact shape), and
19
+ * parsed by the matching reader. A server that implements only the JSON arm
20
+ * above silently drops those — see `scripts/demo-ws-server.mjs`, which
21
+ * implements both directions.
14
22
  */
15
23
  import { CrossTabDataBus } from './core/data-bus';
16
24
  import type { CrossTabDataBusOptions } from './core/data-bus';
@@ -80,7 +88,8 @@ export declare class WebSocketTransport<TData = unknown> implements DataBusTrans
80
88
  /** Idempotent: re-subscribing an active topic re-sends the frame but does
81
89
  * not duplicate the local tracking entry. */
82
90
  subscribe(topic: string): MaybePromise<void>;
83
- /** Idempotent: unsubscribing an unknown topic is a no-op. */
91
+ /** Idempotent for local tracking: an unknown topic has nothing to delete, but
92
+ * the UNSUBSCRIBE frame is still sent, exactly as SUBSCRIBE is re-sent above. */
84
93
  unsubscribe(topic: string): MaybePromise<void>;
85
94
  /** Publish `data` to `topic` as a JSON frame. Requires an open socket. */
86
95
  publish(topic: string, data: unknown, options?: DataBusPublishOptions): MaybePromise<void>;
@@ -101,13 +110,22 @@ export declare class WebSocketTransport<TData = unknown> implements DataBusTrans
101
110
  private abortSocket;
102
111
  private clearConnectTimer;
103
112
  /** Send one JSON frame. Frames are dropped with an `onError` report when
104
- * the socket is not open — subscribe frames are re-sent on open, so the
105
- * only real loss is a publish during a disconnect window. */
113
+ * the socket is not open. A dropped SUBSCRIBE is recovered — `onopen`
114
+ * re-asserts every topic in `subscribedTopics` — but the other two are not:
115
+ * a dropped PUBLISH is the documented at-most-once loss, and a dropped
116
+ * UNSUBSCRIBE is the quieter one, because `unsubscribe()` has already deleted
117
+ * the topic from that set, so no later re-assert resends it and the server
118
+ * keeps streaming a channel the app left. Through the DataBus neither of the
119
+ * two is reachable while closed — `runTransport()` queues both operations
120
+ * behind recovery — so this reads as a direct-transport-contract note. */
106
121
  private sendFrame;
107
122
  private sendBinaryFrame;
108
123
  /** Parse a server frame. Only objects carrying a string `topic` are
109
- * publications; malformed JSON and unknown shapes are ignored so a chatty
110
- * server cannot crash the message path. */
124
+ * publications, and unknown shapes are dropped silently so a chatty server
125
+ * cannot crash the message path. Malformed JSON is *not* dropped silently: it
126
+ * goes to `handlers.onError` as "WebSocket server sent a non-JSON frame.",
127
+ * which also records it in the bus's last-failure diagnostics. Neither case
128
+ * throws out of the socket listener. */
111
129
  private handleMessage;
112
130
  }
113
131
  /** Create a CrossTabDataBus backed by a plain WebSocket transport.
@@ -1 +1 @@
1
- {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"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"}
@@ -51,12 +51,19 @@ export declare class PortReaper {
51
51
  * `Infinity` is the documented way to disable the PING heartbeat, and it means
52
52
  * this port can never be judged silent: `startHeartbeat()` sends nothing at all
53
53
  * once configured that way, so a timeout would be a death sentence with no
54
- * possible appeal. A non-finite-or-non-positive *other* value falls back to the
55
- * default so a bad payload cannot degenerate the reaper into a busy loop or
56
- * silence it — those three (NaN, 0, negative) are rejected by
57
- * `assertHeartbeatInterval` at the transport constructor and so can only arrive
58
- * from a main thread that is not this library. No-op for untracked ports (e.g.
59
- * setTimeout arrives after remove/STOP). */
54
+ * possible appeal. A non-finite-or-non-positive *other* value falls back to
55
+ * the default, which keeps the timeout comparison from degenerating into
56
+ * "always expired" — a NaN timeout reaps every port on the first tick. Read
57
+ * that narrowly, because it is not a busy-loop guard: the busy-loop vector is
58
+ * a tiny *positive* interval, which the ternary passes through untouched
59
+ * (0.0001 → a 0.0003 ms timeout → every tick reaps everything), and the
60
+ * reaper's own cadence is bounded elsewhere — `computeMinHeartbeat` starts
61
+ * from DEFAULT_HEARTBEAT_INTERVAL_MS and only takes a minimum, so a payload
62
+ * can shorten the sweep but never stretch it past 10 s. The three values
63
+ * rejected here (NaN, 0, negative) are also rejected by
64
+ * `assertHeartbeatInterval` at the transport constructor and so can only
65
+ * arrive from a main thread that is not this library. No-op for untracked
66
+ * ports (e.g. setTimeout arrives after remove/STOP). */
60
67
  setTimeout(port: MessagePort, heartbeatIntervalMs: number): void;
61
68
  /** Remove a port (STOP message). The caller is responsible for closing the
62
69
  * port and stopping the session. No-op for untracked ports so a duplicate
@@ -71,8 +78,11 @@ export declare class PortReaper {
71
78
  * each spawning redundant timers when the cadence hasn't changed. */
72
79
  private schedule;
73
80
  /** Smallest heartbeat interval among active ports, derived from each port's
74
- * configured session timeout. Exposed as a method so the cadence logic can
75
- * be unit-tested in isolation from the timer plumbing. */
81
+ * configured session timeout. Extracted because `schedule()` derives its delay
82
+ * from it on every re-arm. Not a test seam: nothing in `tests/` calls it
83
+ * (grep: zero hits), and the cadence is asserted through the injected
84
+ * `setTimer` — i.e. through the same plumbing this note used to claim it was
85
+ * isolated from. */
76
86
  private computeMinHeartbeat;
77
87
  /** Close sessions whose port has been silent longer than its timeout.
78
88
  * Iterates a snapshot. That is defensive rather than load-bearing: `Map`
@@ -1 +1 @@
1
- {"version":3,"file":"port-reaper.d.ts","sourceRoot":"","sources":["../../src/workers/port-reaper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAQH;;+DAE+D;AAC/D,MAAM,WAAW,UAAU;IACzB,MAAM,IAAI,IAAI,CAAC;IACf,KAAK,IAAI,IAAI,CAAC;IACd,IAAI,IAAI,IAAI,CAAC;CACd;AAED,KAAK,QAAQ,GAAG,CAAC,QAAQ,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,MAAM,CAAC;AAC7D,KAAK,UAAU,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;AAE3C,qBAAa,UAAU;IAQnB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAPtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsC;IAC9D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAkC;IAC7D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAkC;IACnE,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,UAAU,CAAiC;gBAGhC,GAAG,GAAE,MAAM,MAAiB,EAC7C,QAAQ,GAAE,QAA2E,EACrF,UAAU,GAAE,UAA4C;IAM1D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAW;IACpC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAa;IAExC;;;oEAGgE;IAChE,QAAQ,CAAC,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,GAAG,IAAI;IAOrD;;;;;;qDAMiD;IACjD,KAAK,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI;IAK9B;;;;;;;;;gDAS4C;IAC5C,UAAU,CAAC,IAAI,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,GAAG,IAAI;IAWhE;;4EAEwE;IACxE,MAAM,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI;IAQ/B;2EACuE;IACvE,OAAO,IAAI,IAAI;IAgBf;;;yEAGqE;IACrE,OAAO,CAAC,QAAQ;IAahB;;8DAE0D;IAC1D,OAAO,CAAC,mBAAmB;IAQ3B;;;;;;;mDAO+C;IAC/C,OAAO,CAAC,IAAI;CAoDb"}
1
+ {"version":3,"file":"port-reaper.d.ts","sourceRoot":"","sources":["../../src/workers/port-reaper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAQH;;+DAE+D;AAC/D,MAAM,WAAW,UAAU;IACzB,MAAM,IAAI,IAAI,CAAC;IACf,KAAK,IAAI,IAAI,CAAC;IACd,IAAI,IAAI,IAAI,CAAC;CACd;AAED,KAAK,QAAQ,GAAG,CAAC,QAAQ,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,MAAM,CAAC;AAC7D,KAAK,UAAU,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;AAE3C,qBAAa,UAAU;IAQnB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAPtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsC;IAC9D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAkC;IAC7D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAkC;IACnE,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,UAAU,CAAiC;gBAGhC,GAAG,GAAE,MAAM,MAAiB,EAC7C,QAAQ,GAAE,QAA2E,EACrF,UAAU,GAAE,UAA4C;IAM1D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAW;IACpC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAa;IAExC;;;oEAGgE;IAChE,QAAQ,CAAC,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,GAAG,IAAI;IAOrD;;;;;;qDAMiD;IACjD,KAAK,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI;IAK9B;;;;;;;;;;;;;;;;4DAgBwD;IACxD,UAAU,CAAC,IAAI,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,GAAG,IAAI;IAWhE;;4EAEwE;IACxE,MAAM,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI;IAQ/B;2EACuE;IACvE,OAAO,IAAI,IAAI;IAgBf;;;yEAGqE;IACrE,OAAO,CAAC,QAAQ;IAahB;;;;;wBAKoB;IACpB,OAAO,CAAC,mBAAmB;IAQ3B;;;;;;;mDAO+C;IAC/C,OAAO,CAAC,IAAI;CAoDb"}
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
 
@@ -296,7 +296,7 @@ trace: {
296
296
  }
297
297
  ```
298
298
 
299
- Low-frequency event types include `lifecycle`, `status`, `subscription`, `coordination`, `reliability`, and `error`; high-frequency data is output as `message_metrics` per window, containing receive/dispatch counts, active topic count, and dispatch latency aggregates (`dispatchSamples`, `dispatchAvgMs`, `dispatchP50Ms`, `dispatchP95Ms`, `dispatchMaxMs`), and deduplication outcomes (`dedupAccepted`, `dedupSuppressed`). Route ownership changes surface as `reliability` events: a graceful handoff reports `operation: 'route_migration'`, while a re-election that recovered a stranded unconfirmed handoff (previous owner gone, ACK never arrived) reports `operation: 'route_migration_recovery'`, so trace consumers can tell recoveries apart from routine handoffs. The `coordination` event is emitted after each transport open and carries `coordinated`, `activeWorkers`, `workers` (formatted worker records), and `routes` (`topicKey@workerId|confirmed=…`) reflecting the settled route list. All public events use a fixed structure, and none of them carries a message payload, a connection address, or an error body. Topic plaintext appears in exactly two event types: `subscription`, which reports the transition, and `reliability`, which names the route for the route-scoped operations (`route_ack`, `route_migration`, `route_migration_recovery`) and omits it elsewhere. Every other event is topic-free, and `coordination` identifies routes by their opaque `topicKey` rather than by name. Since those two event types do carry topic names, treat a trace sink as a diagnostic surface and redact sensitive topic conventions before forwarding events to external telemetry (see `configuration.md`). Errors thrown by the sink are isolated and do not interrupt message dispatch, but are output to `console.warn` to facilitate diagnosing configuration issues. The sink should ideally not throw — capture expected error conditions in the event data rather than raising exceptions.
299
+ Low-frequency event types include `lifecycle`, `status`, `subscription`, `coordination`, `reliability`, and `error`; high-frequency data is output as `message_metrics` per window, containing receive/dispatch counts, active topic count, and dispatch latency aggregates (`dispatchSamples`, `dispatchAvgMs`, `dispatchP50Ms`, `dispatchP95Ms`, `dispatchMaxMs`), and deduplication outcomes (`dedupAccepted`, `dedupSuppressed`). Route ownership changes surface as `reliability` events: a graceful handoff reports `operation: 'route_migration'`, while a re-election that recovered a stranded unconfirmed handoff (previous owner gone, ACK never arrived) reports `operation: 'route_migration_recovery'`, so trace consumers can tell recoveries apart from routine handoffs. The `coordination` event is emitted once per successful `start()` — a later automatic or on-demand reopen emits no second snapshot — and carries `coordinated`, `activeWorkers`, `workers` (formatted worker records), and `routes` (`topicKey@workerId|confirmed=…`) reflecting the settled route list. All public events use a fixed structure, and none of them carries a message payload, a connection address, or an error body. Topic plaintext appears in exactly two event types: `subscription`, which reports the transition, and `reliability`, which names the route for the route-scoped operations (`route_ack`, `route_migration`, `route_migration_recovery`) and omits it elsewhere. Every other event is topic-free, and `coordination` identifies routes by their opaque `topicKey` rather than by name. Since those two event types do carry topic names, treat a trace sink as a diagnostic surface and redact sensitive topic conventions before forwarding events to external telemetry (see `configuration.md`). Errors thrown by the sink are isolated and do not interrupt message dispatch, but are output to `console.warn` to facilitate diagnosing configuration issues. The sink should ideally not throw — capture expected error conditions in the event data rather than raising exceptions.
300
300
 
301
301
  **`asyncSink: true` delivery semantics.** By default (`false`) the sink runs synchronously for each emitted event. With `asyncSink: true`, events are queued in memory and delivered in one microtask batch: the first event of a task schedules `queueMicrotask`, and every event emitted before that microtask runs is sent to the sink in FIFO order in a single pass (including the periodic `message_metrics` snapshots). The hot message path therefore never blocks on sink work. Error isolation is unchanged from the synchronous mode — a throwing sink is caught and reported via `console.warn` and never interrupts dispatch or the remaining events in the batch. Ordering boundary: order **within** a batch is guaranteed, but delivery is deferred to the next microtask, so a trace event is no longer observed before your next statement runs, and events emitted after a batch flushed settle into a later batch. Use the default synchronous sink (or consolidate counters yourself) when each event must be visible before the following line executes.
302
302
 
@@ -317,7 +317,8 @@ interface DataBusTransport<TConfig, TData> {
317
317
  unsubscribe(topic): void | Promise<void>;
318
318
  publish(topic, data): void | Promise<void>;
319
319
  /** Optional: one wire frame for many items. The DataBus falls back to
320
- * per-item `publish` calls when this is absent. */
320
+ * per-item `publish` calls when this is absent. Each item is a
321
+ * `DataBusPublicationItem` — `{ data, messageId?, timestamp? }`, exported. */
321
322
  publishBatch?(topic, items): void | Promise<void>;
322
323
  stop(): void | Promise<void>;
323
324
  }
@@ -344,7 +345,7 @@ Creates an auto-starting Centrifuge DataBus. Defaults:
344
345
  - Uses the bundled `centrifuge.worker.js`
345
346
  - Worker name is `cross-tab-worker-databus`
346
347
 
347
- 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.
348
349
 
349
350
  ## `CentrifugeWorkerTransport<TData>`
350
351
 
@@ -376,7 +377,7 @@ createStorageEventChannel(options: {
376
377
  }): ClusterChannel | null
377
378
  ```
378
379
 
379
- Creates a `ClusterChannel` backed by localStorage `storage` events — the coordination fallback for environments without BroadcastChannel. Returns `null` when storage or a storage-event source is unavailable. Delivery semantics mirror BroadcastChannel (no echo to the sender, JSON-serializable messages, closed channels refuse further posts); the payload envelope carries a per-channel sender nonce **and** a monotonic sequence, which is what keeps consecutive identical messages deliverable — including two tabs whose first frame would otherwise store the same `seq=1` value and be silently suppressed. Opt in via `createBrowserEnvironment({ channelFallback: 'storage-event' })`; see the security note in [configuration.md](./configuration.md#coordination-channel-fallback-broadcastchannel-unavailable).
380
+ Creates a `ClusterChannel` backed by localStorage `storage` events — the coordination fallback for environments without BroadcastChannel. Returns `null` when storage or a storage-event source is unavailable. Delivery semantics follow BroadcastChannel where cross-tab coordination needs them — no echo to the sending tab, JSON-serializable messages, closed channels refuse further posts — with one deliberate divergence: `postMessage` also never dispatches to the channel's own listeners, so two channels created in the **same** document do not see each other, which a real BroadcastChannel does. Only cross-tab delivery matters to this fallback, and the divergence is pinned by the sibling-channel case in `tests/storage-channel.test.ts`; the payload envelope carries a per-channel sender nonce **and** a monotonic sequence, which is what keeps consecutive identical messages deliverable — including two tabs whose first frame would otherwise store the same `seq=1` value and be silently suppressed. Opt in via `createBrowserEnvironment({ channelFallback: 'storage-event' })`; see the security note in [configuration.md](./configuration.md#coordination-channel-fallback-broadcastchannel-unavailable).
380
381
 
381
382
  ## WebSocket Transport Backend
382
383
 
@@ -430,7 +431,7 @@ React (>= 18) is an optional peer dependency; this entry is separate so non-Reac
430
431
 
431
432
  ### `useCrossTabDataBus(create, deps?)`
432
433
 
433
- 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).
434
435
 
435
436
  Pass a fresh bus per effect run (an inline factory); key recreation through `deps`.
436
437
 
@@ -502,7 +503,7 @@ Selects the actual backend based on `WorkerMode` and capability detection, retur
502
503
  - `shared` / `auto`: SharedWorker -> Dedicated Worker -> local mode
503
504
  - `dedicated` (default): Dedicated Worker -> SharedWorker -> local mode
504
505
 
505
- `availability` can explicitly pass `worker` / `sharedWorker` capability flags, for use in SSR, testing, or embedded environments, avoiding access to non-existent global objects.
506
+ `availability` (`WorkerAvailability`, exported) can explicitly pass `worker` / `sharedWorker` capability flags, for use in SSR, testing, or embedded environments, avoiding access to non-existent global objects.
506
507
 
507
508
  ### `effectiveWorkerLoad(worker, options?)`
508
509
 
@@ -512,7 +513,7 @@ The score is always finite. A Worker with no throughput sample, an unset (all-ze
512
513
 
513
514
  ### `approximatePayloadBytes(payload)`
514
515
 
515
- Cheap, allocation-free estimate of a payload's wire size, used for the byte side of an adaptive load sample. It only runs when adaptive routing is enabled, so approximate sizes are fine — the goal is a stable cross-Worker comparison, not an exact byte count.
516
+ Cheap estimate of a payload's wire size, used for the byte side of an adaptive load sample and — because `getDiagnostics().replay` reports a `bytes` figure computed on demand — to size the retained replay buffers, so it is not gated on adaptive routing. It is not allocation-free either: an object node materialises `Object.values`. Approximate sizes are fine either way; the goal is a stable cross-Worker comparison, not an exact byte count.
516
517
 
517
518
  Sizes: `null`/`undefined` and symbols/functions `0`, booleans `4`, numbers and bigints `8`, strings their length, `ArrayBuffer`s and typed-array views their `byteLength`, arrays an 8-byte header plus their elements, and plain objects the sum of their values.
518
519
 
@@ -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.
@@ -2,20 +2,20 @@
2
2
 
3
3
  # Browser Benchmark Trend
4
4
 
5
- > Data through 2026-09-23, from the 71 archived `bench-results/browser-*.json` reports (run `pnpm bench:browser` to add one; regenerate this doc with `node scripts/bench-trend.mjs`).
5
+ > Data through 2026-09-23, from the 87 archived `bench-results/browser-*.json` reports (run `pnpm bench:browser` to add one; regenerate this doc with `node scripts/bench-trend.mjs`).
6
6
 
7
- The comparison baseline for release gating is `pnpm bench:compare --fail-above-pct 50`, which compares the newest report against the median of the same metric in the preceding reports (50% ceiling absorbs shared-runner noise; a single previous report was a fragile baseline because the in-page hot-path metrics alternate between a fast and a slow mode on identical code). This doc records the long-run picture: values are per-metric latencies where lower is better, and the last column is the best run inside the most recent 5 reports — not an all-time record, because the in-page matrix changed measurement semantics in early September 2026 and older reports are not comparable.
7
+ The comparison baseline for release gating is `pnpm bench:compare --fail-above-pct 50`, which compares the newest report against the median of the same metric in the preceding reports *and* requires the new value to exceed the highest of those samples (50% ceiling absorbs shared-runner noise; a single previous report was a fragile baseline because the in-page hot-path metrics alternate between a fast and a slow mode on identical code — and a median over an alternating sample is only stable while the modes are mixed, so a percentage alone can still fail on nothing but a mode shift. Metrics excused by that second leg are listed as "within-baseline, not gated" rather than passing silently). This doc records the long-run picture: values are per-metric latencies where lower is better, and the last column is the best run inside the most recent 5 reports — not an all-time record, because the in-page matrix changed measurement semantics in early September 2026 and older reports are not comparable.
8
8
 
9
9
  <!-- BENCH-TREND:BEGIN (machine-generated table) -->
10
10
  | Metric | Previous (ms) | Latest (ms) | Δ | Best of last 5 runs (ms) |
11
11
  |---|---|---|---|---|
12
- | publish per-message (ms, lower is better) — dedicated | 55.4416 | 50.6133 | -4.83 | 50.6133 |
13
- | publish per-message (ms, lower is better) — shared | 58.2441 | 33.8955 | -24.35 | 33.8955 |
14
- | wildcard dispatch ×1000 (ms, lower is better) | 5.2 | 5.7 | +0.50 | 5.2 |
15
- | publishBatch ×1000 (ms, lower is better) | 3.5 | 3.7 | +0.20 | 3.5 |
16
- | dedup ×1000 (ms, lower is better) | 13 | 11 | -2.00 | 11 |
17
- | trace + publish ×1000 (ms, lower is better) | 4.6 | 4.7 | +0.10 | 4.6 |
18
- | first-packet cold dispatch (ms, lower is better) | 0 | 0 | +0.00 | 0 |
12
+ | publish per-message (ms, lower is better) — dedicated | 38.4709 | 36.5875 | -1.88 | 36.5875 |
13
+ | publish per-message (ms, lower is better) — shared | 33.9845 | 33.7984 | -0.19 | 33.7984 |
14
+ | wildcard dispatch ×1000 (ms, lower is better) | 5.5 | 5.7 | +0.20 | 5.5 |
15
+ | publishBatch ×1000 (ms, lower is better) | 3.5 | 3.6 | +0.10 | 3.5 |
16
+ | dedup ×1000 (ms, lower is better) | 29.2 | 11 | -18.20 | 11 |
17
+ | trace + publish ×1000 (ms, lower is better) | 7 | 4.6 | -2.40 | 4.6 |
18
+ | first-packet cold dispatch (ms, lower is better) | 0.1 | 0 | -0.10 | 0 |
19
19
  <!-- BENCH-TREND:END -->
20
20
 
21
21
  Notes:
@@ -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>({
@@ -11,16 +11,16 @@ Use this checklist for each pre-1.0 release. Pushing the version tag *is* the pu
11
11
 
12
12
  ## Automated gates (CI)
13
13
 
14
- The `CI` workflow's `verify` job runs `pnpm check`, `pnpm lint`, `pnpm test:coverage` (the `vitest.config.ts` floors: 98% statements / 96% branches / 98% functions / 99% lines), `pnpm verify:compat`, `pnpm verify:pack`, `pnpm bench`, and `pnpm audit` on pushes and pull requests **that target `main`** — both triggers are filtered to `branches: [main]` in `ci.yml`, so pushing a topic branch runs nothing and CI first covers the work when its PR opens (a PR based on another branch is not covered either); the `browser` job runs the Playwright E2E suite. The `Release` workflow repeats `verify:compat` and `verify:pack` before publishing, then runs the blocking `verify:published` gate. The checkout in both workflows uses `fetch-depth: 0` + `fetch-tags: true` because `verify:compat` resolves its baseline from the most recent release tag.
14
+ The `CI` workflow's `verify` job runs `pnpm check`, `pnpm lint`, `pnpm test:coverage` (the `vitest.config.ts` floors: 98% statements / 96% branches / 98% functions / 99% lines), `pnpm verify:compat`, `pnpm verify:types`, `pnpm verify:pack`, `pnpm bench`, and `pnpm audit` on pushes and pull requests **that target `main`** — both triggers are filtered to `branches: [main]` in `ci.yml`, so pushing a topic branch runs nothing and CI first covers the work when its PR opens (a PR based on another branch is not covered either); the `browser` job runs the Playwright E2E suite. The `Release` workflow repeats `verify:compat`, `verify:types` and `verify:pack` before publishing, then runs the blocking `verify:published` gate. The checkout in both workflows uses `fetch-depth: 0` + `fetch-tags: true` because `verify:compat` and `verify:types` resolve their baseline from the most recent release tag.
15
15
 
16
16
  Only `pnpm bench:browser` / `pnpm bench:compare` / `pnpm bench:trend` stay local-only: shared-runner timing noise makes numeric CI gates unreliable, and `bench:trend` rewrites `docs/benchmarks.md` from the local archive, which CI must not do.
17
17
 
18
18
  ## Before tagging
19
19
 
20
20
  1. Update `package.json`, `CHANGELOG.md`, and both roadmap files.
21
- 2. Run `pnpm check`, `pnpm lint`, `pnpm test:coverage`, `pnpm bench`, `pnpm test:e2e`, `pnpm bench:browser`, `pnpm verify:pack`, `pnpm verify:compat`, 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:compat` resolves its baseline from the most recent release tag, so a shallow or tag-less clone must first run `git fetch --tags` or it fails with "no version tag found".)
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 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. `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.
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,69 @@
1
1
  # Roadmap
2
2
 
3
- 0.21.12 was released on September 23, 2026. The project is intentionally continuing through reliability-focused releases before a 1.0.0 stability freeze.
3
+ 0.21.14 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.14 delivered scope
6
+
7
+ - The published Chinese mirrors were read against `src/` for the first time. `package.json` `files`
8
+ ships ten `docs/*.md` plus their `docs/zh/` copies and both READMEs, and earlier passes had applied
9
+ their corrections sentence by sentence to whichever language they were reading — so the Chinese
10
+ paragraphs kept claims English had already retracted, and English kept claims only its own tables had
11
+ corrected. Twenty-two claim classes came out of that reading, in both directions.
12
+ - The coordination fuzz can now bound a single seed. A `verify` run reported
13
+ `stopped at 1046/5000 seeds after 158476ms (slowest seed 151850ms)` and then the 120s per-test
14
+ ceiling: 1,045 seeds summed to ~6.6 ms each while one interleaving ate 96% of the sweep, and the
15
+ between-seeds fuse could not reach inside it. Each awaiting step now races a 2s deadline and a seed
16
+ that trips it is counted, named by its operation list, and left unasserted rather than judged on a
17
+ state that never quiesced.
18
+ - What that guard cannot do was measured instead of assumed. Forcing the deadline to 12 ms over a full
19
+ 5,000-seed sweep trips it zero times, because every locally reproducible slow seed is *synchronous*
20
+ cost inside the first tab's `stop()` — nothing a same-thread deadline can preempt. So the mechanism is
21
+ pinned directly in both directions, and three mutants die at three separate assertions: a cap that
22
+ never expires, one that expires eagerly, and one that reports settled work as expired.
23
+ - The `knownTopics` section of `docs/architecture.md` enumerated three of the six in-process call sites
24
+ that populate the cache; `publishBatch` and `activate()` appeared nowhere in it, in either language.
25
+ - The storage absolute this cycle retracted elsewhere turned out to be hiding in a **table cell**: the
26
+ identifier table's `topic` row said "No; kept in Runtime memory and control messages" while the row
27
+ later in the same table says each storage-event frame, topic plaintext included, is written to
28
+ `localStorage`. Four passes had been through that file, including one that corrected the contradicting
29
+ row. `configuration.md`'s boundary list had also filed the tab-id key with the `localStorage` records;
30
+ it is written to `sessionStorage`.
31
+ - No behavior, wire-format, storage-layout or cluster-protocol change. The only executable edit is in
32
+ `tests/`, which the package does not publish; the shipped diff in `src/` is comments only.
33
+
34
+ ## 0.21.13 delivered scope
35
+
36
+ - The prose audit reached the last published surface it had not read: comments *inside function
37
+ bodies* in `src/**`. They ship because esbuild preserves them into `dist/**/*.js` and
38
+ `dist/cjs/*.cjs`, both listed in `package.json` `files`, so the 0.21.11/0.21.12 sweep (docs +
39
+ READMEs) and the declaration-JSDoc sweep had each left this half of the published text unread.
40
+ Thirty-three claims were corrected after each was re-read against the code it describes, and a
41
+ dozen more candidates were cleared on inspection and recorded with the reason.
42
+ - Two of those corrections change how a maintainer reads the code, not just its prose. A proof that
43
+ a chained `.catch` resolves rested on "`reportError` cannot throw", which the same file contradicts
44
+ 500 lines lower and a shipped test pins; and three comments credited themselves with preventing
45
+ something the adjacent code already handles — most consequentially `isAssigned`, whose real reason
46
+ for preferring the in-memory map is a `CONTROL/SUBSCRIBE` accepted with no durable route, the state
47
+ `tests/cluster.test.ts` already pins.
48
+ - Claims about a *dependency* now name it. Against `centrifuge@5.7.4` there is one client-level
49
+ credential hook and no `getChannelToken` in the package at all, so that request kind has never
50
+ fired outside a test fake; and `CentrifugeSession.unsubscribe()` removes the emitter's own no-op
51
+ `error` guard under a comment claiming internals were preserved. The second was first recorded as
52
+ an open question — the test double cannot exhibit the guard, so no existing test could see it
53
+ either way — and has since been closed against the same pinned version: all twelve `emit('error')`
54
+ sites in `BaseSubscription` but one sit behind a `_isSubscribing()`/`_isSubscribed()` test, and the
55
+ three ways back into that one after `unsubscribe()` are closed by a state re-test, by the client's
56
+ `Subscribing`-only reconnect pass, and by the refresh timer `_clearSubscribedState()` cancels. The
57
+ enumeration is in the comment, and no defensive re-attach was warranted.
58
+ - `pnpm bench:compare --fail-above-pct 50` gained the leg its median baseline could not supply: a
59
+ value must now also exceed the highest sample in its own baseline window, because a median over a
60
+ bimodal metric is stable only while the modes stay mixed. What that excuses is printed rather than
61
+ passed silently, and the cost is written down beside it — a sustained shift inside the observed
62
+ range is now invisible to the gate. Both legs were attributed by mutation: removing the new one
63
+ fails exactly one test, suppressing everything fails four including the pre-existing pair path.
64
+ - No behavior, wire-format, storage-layout or cluster-protocol change. The only executable edit in
65
+ the release is inside `scripts/`, which the package does not publish; coverage stays at
66
+ 99.01 / 96.90 / 99.26 / 99.69 over **897** tests.
4
67
 
5
68
  ## 0.21.12 delivered scope
6
69
 
@@ -26,8 +26,9 @@ interface DataBusTransport<TConfig = unknown, TData = unknown> {
26
26
  publish(topic: string, data: unknown, options?: DataBusPublishOptions): MaybePromise<void>;
27
27
  /** Optional: one frame for a burst. Without it the DataBus loops `publish`.
28
28
  * Each item is `{ data, messageId?, timestamp? }` — the same metadata `publish`
29
- * takes, per entry. (That entry type is internal: name it inline, or widen the
30
- * `ReadonlyArray` to whatever your backend accepts.) */
29
+ * takes, per entry. That entry type is exported as `DataBusPublicationItem`;
30
+ * name it, write it inline, or widen the `ReadonlyArray` to whatever your
31
+ * backend accepts. */
31
32
  publishBatch?(topic: string, items: ReadonlyArray<{ data: unknown; messageId?: string; timestamp?: number }>): MaybePromise<void>;
32
33
  stop(): MaybePromise<void>;
33
34
  }
@@ -109,12 +110,14 @@ reference shape, including:
109
110
 
110
111
  - **Backend selection**: reuse `selectWorkerBackend` from `worker-mode.ts` so
111
112
  your backend degrades consistently with the rest of the SDK.
112
- - **Generation guard**: bump a monotonic counter when a backend is created or the
113
- transport stops. Only the asynchronous credential bridge compares it, because a
114
- provider can settle after the Worker it was answering is gone. Worker error
115
- handlers read no such counter in the reference implementation — `stop()` and
116
- `onWorkerFailed()` remove those listeners before the generation moves on, so a
117
- 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
118
121
  listener alive across a swap, that is when you need the check yourself.
119
122
  - **SharedWorker heartbeat**: if you use a SharedWorker, send periodic PINGs
120
123
  so a `PortReaper` can reclaim dead-tab sessions, and handle the