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.
- package/CHANGELOG.md +44 -0
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/dist/centrifuge-protocol.d.ts.map +1 -1
- package/dist/centrifuge-session.d.ts +10 -3
- package/dist/centrifuge-session.d.ts.map +1 -1
- package/dist/centrifuge.d.ts +10 -4
- package/dist/centrifuge.d.ts.map +1 -1
- package/dist/centrifuge.js +25 -11
- package/dist/centrifuge.js.map +2 -2
- package/dist/centrifuge.shared.worker.js +28 -11
- package/dist/centrifuge.shared.worker.js.map +2 -2
- package/dist/centrifuge.worker.js +10 -3
- package/dist/centrifuge.worker.js.map +2 -2
- package/dist/{chunk-TSTV3SDC.js → chunk-3XBN7FFJ.js} +67 -33
- package/dist/chunk-3XBN7FFJ.js.map +7 -0
- package/dist/{chunk-2TTJ7LGZ.js → chunk-NPFTD7YE.js} +1 -1
- package/dist/chunk-NPFTD7YE.js.map +7 -0
- package/dist/cjs/centrifuge.cjs +88 -40
- package/dist/cjs/centrifuge.cjs.map +2 -2
- package/dist/cjs/hooks.cjs.map +2 -2
- package/dist/cjs/index.cjs +80 -36
- package/dist/cjs/index.cjs.map +2 -2
- package/dist/cjs/vue.cjs.map +2 -2
- package/dist/core/cluster.d.ts +33 -16
- package/dist/core/cluster.d.ts.map +1 -1
- package/dist/core/data-bus.d.ts +19 -5
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/core/dedup-manager.d.ts +6 -3
- package/dist/core/dedup-manager.d.ts.map +1 -1
- package/dist/core/environment.d.ts +14 -5
- package/dist/core/environment.d.ts.map +1 -1
- package/dist/core/hash.d.ts +10 -4
- package/dist/core/hash.d.ts.map +1 -1
- package/dist/core/replay-manager.d.ts +2 -1
- package/dist/core/replay-manager.d.ts.map +1 -1
- package/dist/core/routing.d.ts +18 -9
- package/dist/core/routing.d.ts.map +1 -1
- package/dist/core/storage-batch.d.ts +2 -2
- package/dist/core/storage-batch.d.ts.map +1 -1
- package/dist/core/trace.d.ts +21 -9
- package/dist/core/trace.d.ts.map +1 -1
- package/dist/core/types.d.ts +13 -5
- package/dist/core/types.d.ts.map +1 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +1 -1
- package/dist/hooks.js.map +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -7
- package/dist/index.js.map +2 -2
- package/dist/utils/constants.d.ts +7 -4
- package/dist/utils/constants.d.ts.map +1 -1
- package/dist/utils/validation.d.ts +12 -4
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/vue.js +1 -1
- package/dist/websocket.d.ts +24 -6
- package/dist/websocket.d.ts.map +1 -1
- package/dist/workers/port-reaper.d.ts +18 -8
- package/dist/workers/port-reaper.d.ts.map +1 -1
- package/docs/api.md +9 -8
- package/docs/architecture.md +12 -9
- package/docs/benchmarks.md +9 -9
- package/docs/configuration.md +14 -9
- package/docs/getting-started.md +1 -1
- package/docs/release-checklist.md +4 -4
- package/docs/roadmap.md +64 -1
- package/docs/transports.md +11 -8
- package/docs/zh/api.md +10 -9
- package/docs/zh/architecture.md +13 -10
- package/docs/zh/benchmarks.md +9 -9
- package/docs/zh/capabilities.md +1 -1
- package/docs/zh/configuration.md +14 -9
- package/docs/zh/getting-started.md +1 -1
- package/docs/zh/release-checklist.md +4 -4
- package/docs/zh/roadmap.md +50 -1
- package/docs/zh/transports.md +3 -2
- package/package.json +2 -1
- package/dist/chunk-2TTJ7LGZ.js.map +0 -7
- package/dist/chunk-TSTV3SDC.js.map +0 -7
package/dist/websocket.d.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
105
|
-
*
|
|
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
|
|
110
|
-
*
|
|
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.
|
package/dist/websocket.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAElD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAE9D,OAAO,KAAK,EACV,gBAAgB,EAChB,wBAAwB,EACxB,qBAAqB,EACrB,sBAAsB,EAEtB,YAAY,EACZ,YAAY,EACb,MAAM,cAAc,CAAC;AAEtB;kFACkF;AAClF,MAAM,WAAW,aAAa;IAC5B,mEAAmE;IACnE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAAC;IACvC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,MAAM,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAC;IAC5B,OAAO,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAC;IAC7B,OAAO,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAC;IAC7B,SAAS,EAAE,CAAC,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;CACxD;AAED,+DAA+D;AAC/D,MAAM,WAAW,sBAAsB;IACrC,wDAAwD;IACxD,GAAG,EAAE,MAAM,CAAC;IACZ,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC9B;6CACyC;IACzC,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,KAAK,aAAa,CAAC;IACjF;;;;;4CAKwC;IACxC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,0FAA0F;AAC1F,MAAM,WAAW,6BAA6B,CAAC,KAAK,GAAG,OAAO,CAC5D,SAAQ,IAAI,CACV,sBAAsB,CAAC,sBAAsB,EAAE,KAAK,CAAC,EACrD,WAAW,GAAG,YAAY,GAAG,eAAe,GAAG,WAAW,CAC3D;IACD,0CAA0C;IAC1C,UAAU,EAAE,sBAAsB,CAAC;IACnC,8EAA8E;IAC9E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AASD;;;;qEAIqE;AACrE,qBAAa,kBAAkB,CAAC,KAAK,GAAG,OAAO,CAC7C,YAAW,gBAAgB,CAAC,sBAAsB,EAAE,KAAK,CAAC;IAgB9C,OAAO,CAAC,QAAQ,CAAC,UAAU;IAdvC,QAAQ,CAAC,eAAe,eAAe;IACvC,QAAQ,CAAC,kBAAkB,sBAAsB;IACjD,OAAO,CAAC,MAAM,CAA8B;IAC5C,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAgD;IAChE,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqB;IAItD,OAAO,CAAC,cAAc,CAA8B;IACpD,OAAO,CAAC,cAAc,CAA6B;IACnD,OAAO,CAAC,aAAa,CAA2C;IAChE,OAAO,CAAC,YAAY,CAA8C;gBAErC,UAAU,EAAE,sBAAsB;IAE/D;;;;kCAI8B;IAC9B,KAAK,CAAC,MAAM,EAAE,sBAAsB,EAAE,QAAQ,EAAE,wBAAwB,CAAC,KAAK,CAAC,GAAG,YAAY,CAAC,IAAI,CAAC;IA8GpG;iDAC6C;IAC7C,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC;IAK5C;qFACiF;IACjF,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC;IAK9C,0EAA0E;IAC1E,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,qBAAqB,GAAG,YAAY,CAAC,IAAI,CAAC;IAc1F;;wDAEoD;IACpD,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,CAAC,sBAAsB,CAAC,GAAG,YAAY,CAAC,IAAI,CAAC;IA0B7F,wEAAwE;IACxE,IAAI,IAAI,YAAY,CAAC,IAAI,CAAC;IAe1B;yEACqE;IACrE,OAAO,CAAC,aAAa;IAQrB;gCAC4B;IAC5B,OAAO,CAAC,WAAW;IAQnB;gFAC4E;IAC5E,OAAO,CAAC,WAAW;IASnB,OAAO,CAAC,iBAAiB;IAOzB;;;;;;;;8EAQ0E;IAC1E,OAAO,CAAC,SAAS;IAQjB,OAAO,CAAC,eAAe;IA8BvB;;;;;4CAKwC;YAC1B,aAAa;CA2C5B;AAYD;;iEAEiE;AACjE,wBAAgB,sBAAsB,CAAC,KAAK,GAAG,OAAO,EACpD,OAAO,EAAE,6BAA6B,CAAC,KAAK,CAAC,GAC5C,eAAe,CAAC,sBAAsB,EAAE,KAAK,CAAC,CAShD;AAED,wEAAwE;AACxE,YAAY,EAAE,YAAY,EAAE,CAAC"}
|
|
@@ -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
|
|
55
|
-
* default
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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.
|
|
75
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/docs/architecture.md
CHANGED
|
@@ -39,7 +39,7 @@ graph TB
|
|
|
39
39
|
end
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
By default, when `workerMode: 'dedicated'`, each Tab has its own dedicated transport Worker. When configured as `shared` or `auto` and the browser supports SharedWorker, same-origin tabs share the same SharedWorker; each connection port within the SharedWorker creates its own independent `CentrifugeSession`, so one Tab refreshing or stopping does not affect other Tabs. The `auto` mode degrades in order of **SharedWorker → Dedicated Worker → Local mode**, while the `dedicated` mode degrades in order of **Dedicated Worker → SharedWorker → Local mode**. `BroadcastChannel` is only responsible for control messages and real-time publication forwarding
|
|
42
|
+
By default, when `workerMode: 'dedicated'`, each Tab has its own dedicated transport Worker. When configured as `shared` or `auto` and the browser supports SharedWorker, same-origin tabs share the same SharedWorker; each connection port within the SharedWorker creates its own independent `CentrifugeSession`, so one Tab refreshing or stopping does not affect other Tabs. The `auto` mode degrades in order of **SharedWorker → Dedicated Worker → Local mode**, while the `dedicated` mode degrades in order of **Dedicated Worker → SharedWorker → Local mode**. `BroadcastChannel` is only responsible for control messages and real-time publication forwarding, and what localStorage holds for the cluster is eventually-consistent coordination metadata — with the one exception that under `channelFallback: 'storage-event'` the channel itself is localStorage, so the frames travel through it; see [Storage Data Boundaries](./configuration.md#storage-data-boundaries).
|
|
43
43
|
|
|
44
44
|
Because `MessagePort` has no `close` event, a tab that crashes before sending `STOP` would otherwise leak its session and WebSocket. The main thread therefore sends a `PING` every 10 seconds, and the SharedWorker reaps any port that stays silent for more than 30 seconds, releasing the session and its subscriptions. Silence is not the same as absence, though, so a reclaimed port is announced before it is closed: a tab whose heartbeat was merely starved (a long synchronous task, the throttling a backgrounded tab gets) learns its backend is gone, reports the failure, and rebuilds the session on a fresh port.
|
|
45
45
|
|
|
@@ -197,7 +197,7 @@ These identifiers represent different layers:
|
|
|
197
197
|
|
|
198
198
|
| Object | Meaning | Main use | Persisted in coordination storage |
|
|
199
199
|
|---|---|---|---|
|
|
200
|
-
| `topic` | Original application Topic string | Passed to transport `subscribe`, `unsubscribe`, and `
|
|
200
|
+
| `topic` | Original application Topic string | Passed to transport `subscribe`, `unsubscribe`, `publish`, and `publishBatch` | Not in any coordination record — it lives in Runtime memory and in control messages. The storage-event row below is the exception: when that fallback carries the frames, the plaintext each `CONTROL` names is written with them |
|
|
201
201
|
| `topicKey` | Stable opaque key from `createOpaqueKey(topic)` | Joins route and subscriber records | Yes |
|
|
202
202
|
| `tabId` | Stable identity of a browser Tab | Identifies which Tab subscribes to a `topicKey` | Yes, in subscriber keys |
|
|
203
203
|
| `workerId` | Identity of the current Runtime/Worker instance | Identifies the Worker that owns the transport subscription | Yes, in worker/route records |
|
|
@@ -219,7 +219,7 @@ BroadcastChannel CONTROL
|
|
|
219
219
|
|
|
220
220
|
### In-memory topic key cache (`knownTopics`)
|
|
221
221
|
|
|
222
|
-
Each Runtime maintains a `Map<topicKey, topic>` called `knownTopics` that serves as the reverse-lookup cache from opaque key to plaintext topic. It is populated
|
|
222
|
+
Each Runtime maintains a `Map<topicKey, topic>` called `knownTopics` that serves as the reverse-lookup cache from opaque key to plaintext topic. It is populated wherever `rememberTopic()` is called, and its call sites are: `subscribe`, `publish`, `publishBatch`, `unsubscribe` (inside `releaseSubscription`), `activate()` for topics that were queued before the cluster started, and `reconcileSubscriptions()` on each cycle. Plus inbound `CONTROL` messages — where the cache only grows for a frame that survives both guards at the top of `handleControlMessage`: one naming another worker, or one whose `topicKey`/`topic` pair disagrees, is dropped before its plaintext is ever read, so neither can reach `rememberTopic`. Legitimate calls still can, through any API a same-origin script may reach on the page's own bus, which is what the cap below bounds.
|
|
223
223
|
|
|
224
224
|
The cache exists for two reasons:
|
|
225
225
|
|
|
@@ -247,8 +247,11 @@ Eviction is FIFO (insertion order, Map iteration order). When the cache exceeds
|
|
|
247
247
|
|---|---|---|
|
|
248
248
|
| `subscribe(topic)` | `rememberTopic(topic)` → `set(topicKey, topic)` | Populate the reverse mapping; needed for storage-less `readRoute` |
|
|
249
249
|
| `publish(topic, data)` | `rememberTopic(topic)` → `set(topicKey, topic)` | Populate; same reason |
|
|
250
|
-
| `
|
|
251
|
-
| `
|
|
250
|
+
| `publishBatch(topic, items)` | `rememberTopic(topic)` → `set(topicKey, topic)` | Populate; same reason — a batch resolves one key, not one per item |
|
|
251
|
+
| `activate()` (start / resume) | `rememberTopic(topic)` → `set(topicKey, topic)`, once per topic already in `subscribedTopics` | The reverse mapping has to exist before either branch of that loop runs: the storage-less path sends `CONTROL/SUBSCRIBE`, the other writes the subscriber record |
|
|
252
|
+
| `reconcileSubscriptions()` (each cycle) | `rememberTopic(topic)` → `set(topicKey, topic)`, once per `subscribedTopics` entry | Re-affirms the mapping on every heartbeat; see reason 2 above |
|
|
253
|
+
| `unsubscribe(topic)` | `rememberTopic(topic)` (inside `releaseSubscription`), then `delete(topicKey)` if not in `assignedTopics` | The key is derived before the ownership check, and the entry is kept only if we still own the topic |
|
|
254
|
+
| `CONTROL` received (any action: SUBSCRIBE / UNSUBSCRIBE / PUBLISH) | `rememberTopic(message.topic)` → `set(topicKey, topic)` | Inbound frames carry the plaintext topic — but `rememberTopic` runs *after* the `targetWorkerId` and `topicKey` pairing guards, so a frame either guard drops is never cached |
|
|
252
255
|
| `CONTROL/UNSUBSCRIBE` received | no direct deletion | `rememberTopic` still caches the topic; the entry is later removed by `reconcileAssignedTopics` once the route no longer points to this worker |
|
|
253
256
|
| `reconcileAssignedTopics` | `delete(topicKey)` if not subscribed and not owned | Route no longer points to us — clean up unless we're still a subscriber |
|
|
254
257
|
| `stop()` | `clear()` | Full teardown |
|
|
@@ -286,7 +289,7 @@ sequenceDiagram
|
|
|
286
289
|
RuntimeA->>App: invoke handler(payload)
|
|
287
290
|
```
|
|
288
291
|
|
|
289
|
-
A second Tab subscribing to the same Topic adds only its own subscriber record; it does not create another transport subscription while the existing owner is alive. Unsubscribe removes the current Tab's subscriber record
|
|
292
|
+
A second Tab subscribing to the same Topic adds only its own subscriber record; it does not create another transport subscription while the existing owner is alive. Unsubscribe removes the current Tab's subscriber record and, when that was the last one, the same Tab deletes the route and sends `CONTROL/UNSUBSCRIBE` to the worker the route named, which then drops its assignment and releases the transport subscription.
|
|
290
293
|
|
|
291
294
|
### Console diagnostics
|
|
292
295
|
|
|
@@ -367,7 +370,7 @@ implementation passes through the transport server. Four message types are excha
|
|
|
367
370
|
|
|
368
371
|
| Type | Direction | Purpose |
|
|
369
372
|
|---|---|---|
|
|
370
|
-
| `CONTROL` | point-to-point (A → B) | Ask the target Worker to `SUBSCRIBE`, `UNSUBSCRIBE`, or `PUBLISH` a topic. Carries `action`, `topic`, `topicKey`, `targetWorkerId`, and an optional `data` payload. A `SUBSCRIBE` is
|
|
373
|
+
| `CONTROL` | point-to-point (A → B) | Ask the target Worker to `SUBSCRIBE`, `UNSUBSCRIBE`, or `PUBLISH` a topic. Carries `action`, `topic`, `topicKey`, `targetWorkerId`, and an optional `data` payload. A `SUBSCRIBE` is dropped when the durable route names a *different* worker, and a route still awaiting `ROUTE_RELEASED` accepts no frame but its matching ACK; a route that cannot be read at all passes, because such a frame mints no durable ownership and the next reconcile withdraws the assignment. |
|
|
371
374
|
| `EVENT` | broadcast (owner → all Tabs) | Fan out a publication that the transport delivered to the owning Worker. Carries `eventType` and `payload`. |
|
|
372
375
|
| `REGISTRY` | broadcast | Nudge every Tab to reconcile immediately after a registry or route write, instead of waiting for the next heartbeat. |
|
|
373
376
|
| `ROUTE_RELEASED` | point-to-point (old owner → new owner) | Acknowledge a graceful handoff; only the new owner whose route `generation` matches may `SUBSCRIBE` (see Failover). |
|
|
@@ -569,7 +572,7 @@ Transport message → isAssigned(topic)? → Yes → broadcastEvent(EVENT)
|
|
|
569
572
|
Cluster convergence is driven on two timelines:
|
|
570
573
|
|
|
571
574
|
- **Heartbeat + reconcile loop** (default `3000 ms`, `heartbeatIntervalMs`). On every tick each Worker refreshes its own record and runs a reconcile pass: prunes Workers past `workerTtlMs`, orphaned subscribers whose Tab is no longer active, and orphaned routes that have no subscribers and exceed the TTL; recomputes its own active/standby role; re-writes its subscriber records; and re-sends `CONTROL/SUBSCRIBE` for any route that still lacks `confirmedAt` — which also recovers control messages lost on the channel.
|
|
572
|
-
- **`REGISTRY` nudge**. Writes to Worker records
|
|
575
|
+
- **`REGISTRY` nudge**. Writes to Worker records and routes broadcast a `REGISTRY` message so every peer reconciles immediately rather than waiting for the next heartbeat. A subscriber-only write does not: `subscribe()` on a topic whose owner is still live writes this Tab's subscriber record and returns.
|
|
573
576
|
|
|
574
577
|
Heartbeat writes are not announced, so a stale record is only noticed within one heartbeat interval. The worst case for failing to detect a dead owner is `heartbeatIntervalMs + workerTtlMs` (default about 13 s); see [TTL Message-Loss Window](./configuration.md#ttl-message-loss-window) for the trade-offs.
|
|
575
578
|
|
|
@@ -595,7 +598,7 @@ This process prevents overlap during graceful owner handoff while retaining avai
|
|
|
595
598
|
|
|
596
599
|
These invariants are pinned by regression tests (see `tests/stability.test.ts` and `tests/replay-persistence.test.ts`) and must hold through future refactors:
|
|
597
600
|
|
|
598
|
-
- **SUBSCRIBE route binding.** An inbound `CONTROL/SUBSCRIBE` is
|
|
601
|
+
- **SUBSCRIBE route binding.** An inbound `CONTROL/SUBSCRIBE` is dropped when the durable route names a *different* worker, and a handoff still awaiting its ACK accepts no frame but the matching `ROUTE_RELEASED`. A route that cannot be read — expired, corrupt, or storage absent — is the accepted exception: `confirmRoute` writes nothing without a route to stamp, so the frame mints no durable ownership and `reconcileAssignedTopics` withdraws the assignment on the next tick. Both halves are pinned by `tests/cluster.test.ts`. Ownership follows the current route record, not the order in which control frames arrive.
|
|
599
602
|
- **Handoff ACK validity.** A `ROUTE_RELEASED` is accepted only when the route still points at the receiver, the release comes from the recorded `handoffFromWorkerId`, and the ACK generation exactly matches the stored route generation. Replayed ACKs from any other handoff round (e.g. an a↔b ping-pong) are dropped instead of confirming the current route.
|
|
600
603
|
- **Replay persistence cleanup ordering.** A batched persistence flush queued behind the current task is filtered against the cleanup that wins the race: `unsubscribe` and `clearReplayTopic` drop the topic's pending entries, `clearReplayBefore` drops entries older than the cutoff, and `suspend()`/`stop()` discard the whole queued batch before it can start under the next lifecycle generation. Cleared or stopped-session history is never re-appended by an in-flight flush.
|
|
601
604
|
- **Storage write recovery.** Coalesced writes retry with exponential backoff (50 ms → 1.6 s cap). A structurally failing key is dropped after 5 attempts (with a `console.warn`) without permanently blocking other queued keys, and the backoff delay resets once the queue fully drains or `clear()` cancels the retries.
|
package/docs/benchmarks.md
CHANGED
|
@@ -2,20 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
# Browser Benchmark Trend
|
|
4
4
|
|
|
5
|
-
> Data through 2026-09-23, from the
|
|
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 |
|
|
13
|
-
| publish per-message (ms, lower is better) — shared |
|
|
14
|
-
| wildcard dispatch ×1000 (ms, lower is better) | 5.
|
|
15
|
-
| publishBatch ×1000 (ms, lower is better) | 3.5 | 3.
|
|
16
|
-
| dedup ×1000 (ms, lower is better) |
|
|
17
|
-
| trace + publish ×1000 (ms, lower is better) |
|
|
18
|
-
| first-packet cold dispatch (ms, lower is better) | 0 | 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:
|
package/docs/configuration.md
CHANGED
|
@@ -165,7 +165,7 @@ Main configuration for `createCentrifugeDataBus<TData>(options)`:
|
|
|
165
165
|
| `heartbeatIntervalMs` | `number` | `10000` | SharedWorker PING heartbeat interval (see SharedWorker Session Reaper below); `Infinity` disables heartbeats entirely and exempts that port from reaping. Distinct from the Core cluster heartbeat (default 3000 ms) which tracks worker liveness via localStorage |
|
|
166
166
|
| `workerFactory` | `() => Worker` | Built-in Worker | For testing or custom Worker loading |
|
|
167
167
|
| `sharedWorkerFactory` | `() => SharedWorker` | Built-in SharedWorker | For testing or custom SharedWorker loading |
|
|
168
|
-
| `credentialProvider` | `{ getToken?, getChannelToken? }` | `undefined` | Async credential refresh bridge: the Worker asks the main thread for each fresh token
|
|
168
|
+
| `credentialProvider` | `{ getToken?, getChannelToken? }` | `undefined` | Async credential refresh bridge: the Worker asks the main thread for each fresh token and this provider supplies it from application context. In practice that ask is `getToken`, the only credential hook `centrifuge@5.7.4` exposes — the type carries `getChannelToken` and the Worker protocol carries that request kind so a later SDK needs no change on this side, but nothing asks it today. Required because function-valued Centrifuge options cannot be structured-cloned into the Worker |
|
|
169
169
|
| Other Core config | Corresponding type | Core defaults | `storagePrefix`, heartbeat, TTL, etc. |
|
|
170
170
|
|
|
171
171
|
```ts
|
|
@@ -256,21 +256,26 @@ When non-clonable data is passed, `CentrifugeWorkerTransport` will throw a clear
|
|
|
256
256
|
|
|
257
257
|
## Storage Data Boundaries
|
|
258
258
|
|
|
259
|
-
|
|
259
|
+
The coordination records this library writes itself — `{clusterHash}:worker:{workerId}`, `{clusterHash}:route:{topicKey}`, `{clusterHash}:subscriber:{topicKey}:{tabId}`, and the tab-id key — hold only:
|
|
260
260
|
|
|
261
|
-
- Worker
|
|
262
|
-
-
|
|
263
|
-
-
|
|
261
|
+
- Worker and Tab identity, role, status, visibility, the owned-topic `load` figure, an optional rolling throughput sample, the advertised protocol version, and the heartbeat / first-registration timestamps
|
|
262
|
+
- The opaque Topic key, its owner's Worker and Tab identity, the last-update and generation numbers, and — during a graceful handoff — the previous owner and the owner's confirmation timestamp
|
|
263
|
+
- A subscriber record's Tab ID and its last-update timestamp
|
|
264
|
+
- The tab's own persisted id, which is what lets a reload reclaim its routes — written to `sessionStorage`, not to the cluster's `localStorage` namespace
|
|
264
265
|
|
|
265
|
-
|
|
266
|
+
Those records never hold:
|
|
266
267
|
|
|
267
268
|
- Connection URL plaintext
|
|
268
269
|
- Topic names
|
|
269
270
|
- Connection credentials
|
|
270
271
|
- Publication data
|
|
271
|
-
- Publish data
|
|
272
272
|
|
|
273
|
-
|
|
273
|
+
That is a boundary around *those keys*, not around browser storage in general. Two opt-ins put Topic plaintext and payloads into it:
|
|
274
|
+
|
|
275
|
+
- `channelFallback: 'storage-event'` writes every coordination **frame** whole under `cross-tab-worker-databus:channel:*`, and a `CONTROL/PUBLISH` frame carries its Topic name and payload — so they sit in `localStorage` until the channel is closed, and indefinitely if the tab is killed first.
|
|
276
|
+
- `replay.persistence` (e.g. `createIndexedDbReplayPersistence`) keeps replay history in IndexedDB in an object store keyed by the plaintext Topic, with the payloads in its rows.
|
|
277
|
+
|
|
278
|
+
Note: BroadcastChannel coordination messages carry topic names, event types, and publication payloads in plaintext (in memory only, unless the fallback above is what is carrying them). Only localStorage metadata is hashed via `createOpaqueKey()`.
|
|
274
279
|
|
|
275
280
|
## Security & Trust Model
|
|
276
281
|
|
|
@@ -282,7 +287,7 @@ The coordination plane has **no authentication**. Only use this library on pages
|
|
|
282
287
|
- `clusterKey` is hashed via `createOpaqueKey` (a non-cryptographic 128-bit hash) to derive the storage prefix and BroadcastChannel name. In practice `clusterKey` is always a connection URL or a developer-controlled namespace, so a hash collision between two different clusterKeys (~2⁻⁶⁴ birthday bound) is not a practical concern.
|
|
283
288
|
- Mitigations: keep the page free of untrusted third-party scripts; load coordination on an isolated origin; treat the origin's `localStorage` and BroadcastChannel namespaces as public. CSP cannot restrict BroadcastChannel or localStorage access from same-origin scripts.
|
|
284
289
|
|
|
285
|
-
The transport plane (e.g. the Centrifuge WebSocket) has its own security model — tokens, TLS, and server-side permissions — and is unaffected by the above. The cluster only
|
|
290
|
+
The transport plane (e.g. the Centrifuge WebSocket) has its own security model — tokens, TLS, and server-side permissions — and is unaffected by the above. The cluster only decides which tab owns the transport subscription; a payload then reaches a tab from that owner over the BroadcastChannel, or from the server directly for the tab holding the subscription. The one exception is the `channelFallback: 'storage-event'` path above: there the channel *is* `localStorage`, so those same frames — payloads included — are written into it.
|
|
286
291
|
|
|
287
292
|
## Cluster Isolation Recommendations
|
|
288
293
|
|
package/docs/getting-started.md
CHANGED
|
@@ -47,7 +47,7 @@ export const dataBus = createCentrifugeDataBus<ResourceEvent>({
|
|
|
47
47
|
|
|
48
48
|
`clusterKey` is derived from the connection URL by default. It is only used for cluster isolation and is converted to an opaque key before entering localStorage and as the BroadcastChannel channel name. Note that topic names and event types sent over the BroadcastChannel coordination channel are transmitted in plaintext; only localStorage metadata is obfuscated via hashing.
|
|
49
49
|
|
|
50
|
-
By default, a Dedicated Worker is used, one Worker per Tab. `workerMode: 'shared'` (or `'auto'`, which tries shared first) puts same-origin Tabs into **one** SharedWorker process — but it does not merge their connections. Each connecting port gets its own `CentrifugeSession` and so its own WebSocket, which is why closing or refreshing one tab leaves the others subscribed: N tabs still mean N server-side connections
|
|
50
|
+
By default, a Dedicated Worker is used, one Worker per Tab. `workerMode: 'shared'` (or `'auto'`, which tries shared first) puts same-origin Tabs into **one** SharedWorker process — but it does not merge their connections. Each connecting port gets its own `CentrifugeSession` and so its own WebSocket, which is why closing or refreshing one tab leaves the others subscribed: N tabs still mean N server-side connections. The channels are not multiplied the same way, because a transport subscribes only the Topics its own worker owns, so a given Topic's channel appears on one tab's socket rather than on all N. Shared mode saves the Worker process, not the socket:
|
|
51
51
|
|
|
52
52
|
```ts
|
|
53
53
|
export const dataBus = createCentrifugeDataBus<ResourceEvent>({
|
|
@@ -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`
|
|
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`
|
|
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.
|
|
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
|
|
package/docs/transports.md
CHANGED
|
@@ -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.
|
|
30
|
-
* `ReadonlyArray` to whatever your
|
|
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
|
|
113
|
-
transport stops
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|