cross-tab-worker-databus 0.20.71 → 0.20.86
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 +216 -2
- package/README.md +8 -0
- package/README.zh.md +4 -0
- package/dist/centrifuge-protocol.d.ts +59 -26
- package/dist/centrifuge-protocol.d.ts.map +1 -1
- package/dist/centrifuge-session.d.ts +9 -0
- package/dist/centrifuge-session.d.ts.map +1 -1
- package/dist/centrifuge.d.ts +12 -2
- package/dist/centrifuge.d.ts.map +1 -1
- package/dist/centrifuge.js +168 -88
- package/dist/centrifuge.js.map +3 -3
- package/dist/centrifuge.shared.worker.js +146 -38
- package/dist/centrifuge.shared.worker.js.map +3 -3
- package/dist/centrifuge.worker.js +140 -33
- package/dist/centrifuge.worker.js.map +3 -3
- package/dist/{chunk-D2SIT473.js → chunk-SDOV3UHG.js} +1304 -515
- package/dist/chunk-SDOV3UHG.js.map +7 -0
- package/dist/chunk-TZ7ZP7YD.js +175 -0
- package/dist/chunk-TZ7ZP7YD.js.map +7 -0
- package/dist/cjs/centrifuge.cjs +1608 -650
- package/dist/cjs/centrifuge.cjs.map +4 -4
- package/dist/cjs/hooks.cjs +37 -2
- package/dist/cjs/hooks.cjs.map +3 -3
- package/dist/cjs/index.cjs +1692 -747
- package/dist/cjs/index.cjs.map +4 -4
- package/dist/cjs/vue.cjs +45 -2
- package/dist/cjs/vue.cjs.map +3 -3
- package/dist/core/cluster.d.ts +40 -4
- package/dist/core/cluster.d.ts.map +1 -1
- package/dist/core/data-bus.d.ts +134 -77
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/core/dedup-manager.d.ts +98 -0
- package/dist/core/dedup-manager.d.ts.map +1 -0
- package/dist/core/environment.d.ts +43 -3
- package/dist/core/environment.d.ts.map +1 -1
- package/dist/core/replay-manager.d.ts +129 -0
- package/dist/core/replay-manager.d.ts.map +1 -0
- package/dist/core/replay-persistence.d.ts +2 -1
- package/dist/core/replay-persistence.d.ts.map +1 -1
- package/dist/core/replay-pruning.d.ts +21 -0
- package/dist/core/replay-pruning.d.ts.map +1 -0
- package/dist/core/routing.d.ts +22 -3
- package/dist/core/routing.d.ts.map +1 -1
- package/dist/core/storage-batch.d.ts +0 -4
- package/dist/core/storage-batch.d.ts.map +1 -1
- package/dist/core/trace.d.ts +55 -15
- package/dist/core/trace.d.ts.map +1 -1
- package/dist/core/types.d.ts +58 -9
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.d.ts.map +1 -0
- package/dist/hooks.d.ts +12 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +28 -2
- package/dist/hooks.js.map +2 -2
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +263 -166
- package/dist/index.js.map +3 -3
- package/dist/utils/constants.d.ts +170 -0
- package/dist/utils/constants.d.ts.map +1 -0
- package/dist/utils/error-utils.d.ts +28 -0
- package/dist/utils/error-utils.d.ts.map +1 -0
- package/dist/utils/metadata.d.ts +16 -0
- package/dist/utils/metadata.d.ts.map +1 -0
- package/dist/utils/storage-utils.d.ts +24 -0
- package/dist/utils/storage-utils.d.ts.map +1 -0
- package/dist/utils/validation.d.ts +76 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/vue.d.ts +13 -1
- package/dist/vue.d.ts.map +1 -1
- package/dist/vue.js +36 -2
- package/dist/vue.js.map +2 -2
- package/dist/websocket.d.ts +6 -1
- package/dist/websocket.d.ts.map +1 -1
- package/dist/worker-mode.d.ts +6 -2
- package/dist/worker-mode.d.ts.map +1 -1
- package/docs/README.md +1 -0
- package/docs/api.md +135 -4
- package/docs/architecture.md +92 -1
- package/docs/benchmarks.md +24 -0
- package/docs/capabilities.md +24 -3
- package/docs/configuration.md +58 -0
- package/docs/getting-started.md +35 -0
- package/docs/release-checklist.md +28 -5
- package/docs/roadmap.md +81 -5
- package/docs/zh/README.md +2 -1
- package/docs/zh/api.md +134 -4
- package/docs/zh/architecture.md +61 -1
- package/docs/zh/benchmarks.md +24 -0
- package/docs/zh/capabilities.md +21 -3
- package/docs/zh/configuration.md +53 -0
- package/docs/zh/getting-started.md +35 -0
- package/docs/zh/release-checklist.md +29 -6
- package/docs/zh/roadmap.md +93 -5
- package/package.json +39 -16
- package/dist/chunk-D2SIT473.js.map +0 -7
package/dist/vue.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/vue.ts"],
|
|
4
|
-
"sourcesContent": ["/** Vue 3 composables adapter for cross-tab-worker-databus.\n * Vue is an optional peer dependency; this module is a separate entry point.\n */\nimport { onBeforeUnmount, onMounted, ref, shallowRef, watch, type Ref } from 'vue';\nimport type { CrossTabDataBus } from './core/data-bus';\nimport type { DataBusMessage, WorkerStatus } from './core/types';\n\nexport function useCrossTabDataBus<TConfig, TData>(\n create: () => CrossTabDataBus<TConfig, TData>,\n deps: ReadonlyArray<Ref<unknown> | (() => unknown)> = []\n): Ref<CrossTabDataBus<TConfig, TData> | null> {\n const bus = shallowRef<CrossTabDataBus<TConfig, TData> | null>(null);\n let instance: CrossTabDataBus<TConfig, TData> | null = null;\n let lifecycleGeneration = 0;\n const stop = async () => { const current = instance; instance = null; bus.value = null; if (current) await current.stop(); };\n const start = () => {\n const generation = ++lifecycleGeneration;\n void stop().then(() => {\n if (generation !== lifecycleGeneration) return;\n const next = create();\n instance = next;\n bus.value = next;\n void next.ready().catch(() => {});\n });\n };\n onMounted(start);\n onBeforeUnmount(() => { void stop()
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["/** Vue 3 composables adapter for cross-tab-worker-databus.\n * Vue is an optional peer dependency; this module is a separate entry point.\n */\nimport { onBeforeUnmount, onMounted, ref, shallowRef, watch, type Ref } from 'vue';\nimport type { CrossTabDataBus, DataBusHealthSummary } from './core/data-bus';\nimport type { DataBusMessage, WorkerStatus } from './core/types';\nimport { WORKER_STATUS } from './utils/constants';\n\nexport function useCrossTabDataBus<TConfig, TData>(\n create: () => CrossTabDataBus<TConfig, TData>,\n deps: ReadonlyArray<Ref<unknown> | (() => unknown)> = []\n): Ref<CrossTabDataBus<TConfig, TData> | null> {\n const bus = shallowRef<CrossTabDataBus<TConfig, TData> | null>(null);\n let instance: CrossTabDataBus<TConfig, TData> | null = null;\n let lifecycleGeneration = 0;\n const stop = async () => { const current = instance; instance = null; bus.value = null; if (current) await current.stop(); };\n const start = () => {\n const generation = ++lifecycleGeneration;\n void stop().then(() => {\n if (generation !== lifecycleGeneration) return;\n const next = create();\n instance = next;\n bus.value = next;\n void next.ready().catch(() => {});\n });\n };\n onMounted(start);\n onBeforeUnmount(() => {\n // Bump the generation so a start() still awaiting its stop() sees itself\n // superseded. Without this the pending continuation would run create()\n // after the component is gone, leaving a live bus with no owner to stop\n // it (the React adapter has no such window: its create() is synchronous\n // inside useEffect).\n lifecycleGeneration += 1;\n void stop();\n });\n if (deps.length > 0) watch(deps, start);\n return bus as Ref<CrossTabDataBus<TConfig, TData> | null>;\n}\n\nexport function useCrossTabSubscription<TConfig, TData>(\n bus: Ref<CrossTabDataBus<TConfig, TData> | null>, topic: Ref<string> | string,\n handler: (message: DataBusMessage<TData>) => void\n): void {\n let currentBus: CrossTabDataBus<TConfig, TData> | null = null;\n let currentTopic: string | null = null;\n let cleanup: (() => void) | undefined;\n let latestHandler = handler;\n const stop = () => { cleanup?.(); cleanup = undefined; currentBus = null; currentTopic = null; };\n const sync = () => {\n const nextBus = bus.value;\n const nextTopic = typeof topic === 'string' ? topic : topic.value;\n if (nextBus === currentBus && currentTopic === nextTopic && cleanup) return;\n stop();\n if (!nextBus) return;\n currentBus = nextBus;\n currentTopic = nextTopic;\n cleanup = nextBus.subscribe(nextTopic, message => latestHandler(message));\n };\n watch(bus, sync, { immediate: true });\n if (typeof topic !== 'string') watch(topic, sync);\n watch(() => handler, value => { latestHandler = value; });\n onBeforeUnmount(stop);\n}\n\nexport function useCrossTabStatus<TConfig, TData>(\n bus: Ref<CrossTabDataBus<TConfig, TData> | null>\n): Ref<WorkerStatus> {\n const status = ref<WorkerStatus>(WORKER_STATUS.CONNECTING);\n let cleanup: (() => void) | undefined;\n watch(bus, next => {\n cleanup?.(); cleanup = undefined;\n status.value = next?.getStatus() ?? WORKER_STATUS.CONNECTING;\n if (next) cleanup = next.onStatus(value => { status.value = value; });\n }, { immediate: true });\n onBeforeUnmount(() => cleanup?.());\n return status;\n}\n\n/** Options for {@link useCrossTabHealth}. */\nexport interface UseCrossTabHealthOptions {\n /** Polling cadence in ms for the health snapshot. Default 1000; `0` disables\n * polling and relies on status/error events only. */\n intervalMs?: number;\n}\n\n/**\n * Mirror the bus health summary into a Vue ref. `getHealthSummary()` is a\n * snapshot, so the composable polls it on an interval (default 1000 ms) and\n * refreshes on status changes and errors. Returns `null` until the bus exists.\n */\nexport function useCrossTabHealth<TConfig, TData>(\n bus: Ref<CrossTabDataBus<TConfig, TData> | null>,\n options?: UseCrossTabHealthOptions\n): Ref<DataBusHealthSummary | null> {\n const health = ref<DataBusHealthSummary | null>(null);\n let timer: ReturnType<typeof setInterval> | null = null;\n let cleanups: Array<() => void> = [];\n const teardown = () => {\n if (timer) clearInterval(timer);\n timer = null;\n for (const cleanup of cleanups) cleanup();\n cleanups = [];\n };\n watch(bus, next => {\n teardown();\n if (!next) {\n health.value = null;\n return;\n }\n const refresh = () => { health.value = next.getHealthSummary(); };\n refresh();\n cleanups.push(next.onStatus(refresh));\n cleanups.push(next.onError(refresh));\n const intervalMs = options?.intervalMs ?? 1_000;\n if (intervalMs > 0) timer = setInterval(refresh, intervalMs);\n }, { immediate: true });\n onBeforeUnmount(teardown);\n return health as Ref<DataBusHealthSummary | null>;\n}\n"],
|
|
5
|
+
"mappings": ";;;;;AAGA,SAAS,iBAAiB,WAAW,KAAK,YAAY,aAAuB;AAKtE,SAAS,mBACd,QACA,OAAsD,CAAC,GACV;AAC7C,QAAM,MAAM,WAAmD,IAAI;AACnE,MAAI,WAAmD;AACvD,MAAI,sBAAsB;AAC1B,QAAM,OAAO,YAAY;AAAE,UAAM,UAAU;AAAU,eAAW;AAAM,QAAI,QAAQ;AAAM,QAAI,QAAS,OAAM,QAAQ,KAAK;AAAA,EAAG;AAC3H,QAAM,QAAQ,MAAM;AAClB,UAAM,aAAa,EAAE;AACrB,SAAK,KAAK,EAAE,KAAK,MAAM;AACrB,UAAI,eAAe,oBAAqB;AACxC,YAAM,OAAO,OAAO;AACpB,iBAAW;AACX,UAAI,QAAQ;AACZ,WAAK,KAAK,MAAM,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IAClC,CAAC;AAAA,EACH;AACA,YAAU,KAAK;AACf,kBAAgB,MAAM;AAMpB,2BAAuB;AACvB,SAAK,KAAK;AAAA,EACZ,CAAC;AACD,MAAI,KAAK,SAAS,EAAG,OAAM,MAAM,KAAK;AACtC,SAAO;AACT;AAEO,SAAS,wBACd,KAAkD,OAClD,SACM;AACN,MAAI,aAAqD;AACzD,MAAI,eAA8B;AAClC,MAAI;AACJ,MAAI,gBAAgB;AACpB,QAAM,OAAO,MAAM;AAAE,cAAU;AAAG,cAAU;AAAW,iBAAa;AAAM,mBAAe;AAAA,EAAM;AAC/F,QAAM,OAAO,MAAM;AACjB,UAAM,UAAU,IAAI;AACpB,UAAM,YAAY,OAAO,UAAU,WAAW,QAAQ,MAAM;AAC5D,QAAI,YAAY,cAAc,iBAAiB,aAAa,QAAS;AACrE,SAAK;AACL,QAAI,CAAC,QAAS;AACd,iBAAa;AACb,mBAAe;AACf,cAAU,QAAQ,UAAU,WAAW,aAAW,cAAc,OAAO,CAAC;AAAA,EAC1E;AACA,QAAM,KAAK,MAAM,EAAE,WAAW,KAAK,CAAC;AACpC,MAAI,OAAO,UAAU,SAAU,OAAM,OAAO,IAAI;AAChD,QAAM,MAAM,SAAS,WAAS;AAAE,oBAAgB;AAAA,EAAO,CAAC;AACxD,kBAAgB,IAAI;AACtB;AAEO,SAAS,kBACd,KACmB;AACnB,QAAM,SAAS,IAAkB,cAAc,UAAU;AACzD,MAAI;AACJ,QAAM,KAAK,UAAQ;AACjB,cAAU;AAAG,cAAU;AACvB,WAAO,QAAQ,MAAM,UAAU,KAAK,cAAc;AAClD,QAAI,KAAM,WAAU,KAAK,SAAS,WAAS;AAAE,aAAO,QAAQ;AAAA,IAAO,CAAC;AAAA,EACtE,GAAG,EAAE,WAAW,KAAK,CAAC;AACtB,kBAAgB,MAAM,UAAU,CAAC;AACjC,SAAO;AACT;AAcO,SAAS,kBACd,KACA,SACkC;AAClC,QAAM,SAAS,IAAiC,IAAI;AACpD,MAAI,QAA+C;AACnD,MAAI,WAA8B,CAAC;AACnC,QAAM,WAAW,MAAM;AACrB,QAAI,MAAO,eAAc,KAAK;AAC9B,YAAQ;AACR,eAAW,WAAW,SAAU,SAAQ;AACxC,eAAW,CAAC;AAAA,EACd;AACA,QAAM,KAAK,UAAQ;AACjB,aAAS;AACT,QAAI,CAAC,MAAM;AACT,aAAO,QAAQ;AACf;AAAA,IACF;AACA,UAAM,UAAU,MAAM;AAAE,aAAO,QAAQ,KAAK,iBAAiB;AAAA,IAAG;AAChE,YAAQ;AACR,aAAS,KAAK,KAAK,SAAS,OAAO,CAAC;AACpC,aAAS,KAAK,KAAK,QAAQ,OAAO,CAAC;AACnC,UAAM,aAAa,SAAS,cAAc;AAC1C,QAAI,aAAa,EAAG,SAAQ,YAAY,SAAS,UAAU;AAAA,EAC7D,GAAG,EAAE,WAAW,KAAK,CAAC;AACtB,kBAAgB,QAAQ;AACxB,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/websocket.d.ts
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Wire protocol (JSON text frames):
|
|
10
10
|
* - client → server: `{"op":"subscribe"|"unsubscribe"|"publish","topic":...,"data":...}`
|
|
11
|
+
* - client → server (batched): `{"op":"publishBatch","topic":...,"items":[{data,...}]}`
|
|
11
12
|
* - server → client: `{"topic":...,"data":...}` for publications; anything
|
|
12
13
|
* without a string `topic` field is ignored (forward-compatible).
|
|
13
14
|
*/
|
|
14
15
|
import { CrossTabDataBus } from './core/data-bus';
|
|
15
16
|
import type { CrossTabDataBusOptions } from './core/data-bus';
|
|
16
|
-
import type { DataBusTransport, DataBusTransportHandlers, DataBusPublishOptions, MaybePromise, WorkerStatus } from './core/types';
|
|
17
|
+
import type { DataBusTransport, DataBusTransportHandlers, DataBusPublishOptions, DataBusPublicationItem, MaybePromise, WorkerStatus } from './core/types';
|
|
17
18
|
/** Minimal WebSocket surface used by the transport. Matches the browser
|
|
18
19
|
* `WebSocket` subset the transport touches; injectable for tests and runtimes. */
|
|
19
20
|
export interface WebSocketLike {
|
|
@@ -68,6 +69,10 @@ export declare class WebSocketTransport<TData = unknown> implements DataBusTrans
|
|
|
68
69
|
unsubscribe(topic: string): MaybePromise<void>;
|
|
69
70
|
/** Publish `data` to `topic` as a JSON frame. Requires an open socket. */
|
|
70
71
|
publish(topic: string, data: unknown, options?: DataBusPublishOptions): MaybePromise<void>;
|
|
72
|
+
/** Publish many items for one topic as a single wire frame. One-item
|
|
73
|
+
* batches delegate to `publish` so the legacy single-publication frame
|
|
74
|
+
* shape (including binary framing) is preserved. */
|
|
75
|
+
publishBatch(topic: string, items: ReadonlyArray<DataBusPublicationItem>): MaybePromise<void>;
|
|
71
76
|
/** Close the socket and drop all state. Safe to call multiple times. */
|
|
72
77
|
stop(): MaybePromise<void>;
|
|
73
78
|
/** Send one JSON frame. Frames are dropped with an `onError` report when
|
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;;;;;;;;;;;;;GAaG;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;CAClF;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;AAID;;;;qEAIqE;AACrE,qBAAa,kBAAkB,CAAC,KAAK,GAAG,OAAO,CAC7C,YAAW,gBAAgB,CAAC,sBAAsB,EAAE,KAAK,CAAC;IAQ9C,OAAO,CAAC,QAAQ,CAAC,UAAU;IANvC,QAAQ,CAAC,eAAe,eAAe;IACvC,QAAQ,CAAC,kBAAkB,sBAAsB;IACjD,OAAO,CAAC,MAAM,CAA8B;IAC5C,OAAO,CAAC,QAAQ,CAAgD;IAChE,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqB;gBAEzB,UAAU,EAAE,sBAAsB;IAE/D;0EACsE;IACtE,KAAK,CAAC,MAAM,EAAE,sBAAsB,EAAE,QAAQ,EAAE,wBAAwB,CAAC,KAAK,CAAC,GAAG,YAAY,CAAC,IAAI,CAAC;IAoCpG;iDAC6C;IAC7C,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC;IAK5C,6DAA6D;IAC7D,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;IAQ1B;;iEAE6D;IAC7D,OAAO,CAAC,SAAS;IAQjB,OAAO,CAAC,eAAe;IA8BvB;;+CAE2C;YAC7B,aAAa;CAkC5B;AAUD;;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"}
|
package/dist/worker-mode.d.ts
CHANGED
|
@@ -4,15 +4,19 @@
|
|
|
4
4
|
* Defines the WorkerMode preference flags and the selectWorkerBackend function
|
|
5
5
|
* that resolves the preference against browser capability to pick the actual
|
|
6
6
|
* backend (dedicated, shared, or local fallback).
|
|
7
|
+
*
|
|
8
|
+
* The literal values are derived from `utils/constants.ts` so the preference and
|
|
9
|
+
* resolved-backend strings referenced across the transport stay in one place.
|
|
7
10
|
*/
|
|
11
|
+
import { WORKER_BACKEND, WORKER_MODE } from './utils/constants';
|
|
8
12
|
/** Preferred Worker mode.
|
|
9
13
|
* - `dedicated` → try Dedicated Worker first (one WebSocket per tab).
|
|
10
14
|
* - `shared` → try SharedWorker first (one process, per-port connections).
|
|
11
15
|
* - `auto` → same as `shared` (alias for forward compatibility). */
|
|
12
|
-
export type WorkerMode =
|
|
16
|
+
export type WorkerMode = (typeof WORKER_MODE)[keyof typeof WORKER_MODE];
|
|
13
17
|
/** Resolved backend that was actually created. `local` means the session
|
|
14
18
|
* runs on the main thread (fallback when no Worker API is available). */
|
|
15
|
-
export type WorkerBackend =
|
|
19
|
+
export type WorkerBackend = (typeof WORKER_BACKEND)[keyof typeof WORKER_BACKEND];
|
|
16
20
|
/** Override Worker availability for testing or environments where feature
|
|
17
21
|
* detection is unreliable (e.g. sandboxed iframes). When a field is omitted,
|
|
18
22
|
* the global `typeof Worker` / `typeof SharedWorker` check is used. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worker-mode.d.ts","sourceRoot":"","sources":["../src/worker-mode.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"worker-mode.d.ts","sourceRoot":"","sources":["../src/worker-mode.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhE;;;oEAGoE;AACpE,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,OAAO,WAAW,CAAC,CAAC;AAExE;yEACyE;AACzE,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,OAAO,cAAc,CAAC,CAAC;AAEjF;;uEAEuE;AACvE,MAAM,WAAW,kBAAkB;IACjC,gEAAgE;IAChE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,sEAAsE;IACtE,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,UAAU,EAChB,YAAY,GAAE,kBAAuB,GACpC,aAAa,CAOf"}
|
package/docs/README.md
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
| [Architecture](./architecture.md) | Worker cluster, routing, storage, migration, and degradation design |
|
|
12
12
|
| [Capabilities Matrix](./capabilities.md) | Implemented, not implemented, and planned capabilities matrix |
|
|
13
13
|
| [Roadmap](./roadmap.md) | Release-oriented priorities and verification checklist |
|
|
14
|
+
| [Benchmark trend](./benchmarks.md) | Auto-generated browser benchmark history from `bench-results/` |
|
|
14
15
|
| [Release checklist](./release-checklist.md) | Local gates, tagging, publishing, and post-release verification |
|
|
15
16
|
| [../examples/demo](../examples/demo) | Runnable multi-tab browser demo |
|
|
16
17
|
| [../CHANGELOG.md](../CHANGELOG.md) | Version changelog |
|
package/docs/api.md
CHANGED
|
@@ -56,7 +56,7 @@ Creates a DataBus. When `initialConfig` is provided, it starts automatically by
|
|
|
56
56
|
start(config: TConfig): Promise<void>
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
Starts cluster coordination and transport. The first call actually starts the transport; concurrent calls during
|
|
59
|
+
Starts cluster coordination and transport. The first call actually starts the transport; concurrent calls during an in-flight open share the same start Promise without creating a duplicate transport. A call made on a healthy started instance is an immediate no-op. If the transport is down, `start()` acts as an explicit manual recovery: it preserves the cluster, subscriptions, and replay buffers, resets the failure/recovery ledger, and reopens the transport. If an explicit `stop()` is still settling, `start()` queues one fresh start behind that cleanup and returns a Promise that settles with the restart. That queued restart belongs to the latest lifecycle intent: a `stop()` arriving before it can run cancels it (resolving the queued start Promise without opening a transport), and a `start()` issued after that cancellation queues a fresh restart. After `stop()` has completed, `start()` can be called normally to restart.
|
|
60
60
|
|
|
61
61
|
### `ready()`
|
|
62
62
|
|
|
@@ -66,6 +66,12 @@ ready(): Promise<void>
|
|
|
66
66
|
|
|
67
67
|
Waits for the current transport's `start` to complete. The Promise rejects when auto-start fails; calling again can trigger a retry based on `initialConfig`.
|
|
68
68
|
|
|
69
|
+
While an explicit `stop()` is settling, `ready()` rejects unless a `start()` has queued a restart behind that stop. It never resolves against a transport that is already being torn down. Wait for `stop()` to settle, then call `start()` before awaiting `ready()` again.
|
|
70
|
+
|
|
71
|
+
If a later `stop()` cancels that queued restart, the queued `start()` Promise still resolves without opening a transport, but `ready()` rejects with a lifecycle error rather than reporting a stopped bus as ready.
|
|
72
|
+
|
|
73
|
+
If that queued restart fails during transport startup, `ready()` rejects with the underlying startup error even when no `initialConfig` was supplied. The failure is retained for explicit recovery rather than being replaced by the generic missing-configuration error.
|
|
74
|
+
|
|
69
75
|
When no `initialConfig` is provided and `start(config)` has not been called, `ready()` returns a rejected Promise instead of throwing synchronously, so callers can attach `.catch` and decide whether to start explicitly.
|
|
70
76
|
|
|
71
77
|
`ready()` is not equivalent to the server being connected; protocol connection status is obtained via `onStatus`.
|
|
@@ -85,8 +91,9 @@ Registers a local subscription and returns a cleanup function.
|
|
|
85
91
|
- The first handler in the current tab registers a cluster subscription.
|
|
86
92
|
- The current tab only leaves the topic after the last handler is released.
|
|
87
93
|
- Subscriptions are automatically queued when the transport is not yet ready.
|
|
94
|
+
- A subscription requested while an explicit `stop()` is settling is not registered: `subscribe()` reports the rejection through `onError` and returns a no-op cleanup function. Wait for `stop()` to settle, then call `start()` before subscribing again.
|
|
88
95
|
- Wildcard subscriptions: a topic ending in `.*` (`chat.*`) matches any remainder, and `*` matches everything. The pattern is routed, owned, and transport-subscribed as a literal channel; publications tagged with a matching concrete topic (or with the pattern itself) are delivered to wildcard handlers. See `topicMatchesPattern` below.
|
|
89
|
-
- Replay (opt-in): construct the bus with `replay: { maxPerTopic }` and pass `{ replay: true | n }` as the third `subscribe()` argument. `maxPerTopic` must be a positive safe integer. The new handler immediately receives the buffered history (up to `n`, capped by `maxPerTopic`, default 100) with `message.replayed: true`, so late joiners do not miss earlier publications. Only dispatched publications are buffered (a topic with no local subscriber drops them as unowned); buffers are in-memory and cleared when the last handler for the topic unsubscribes. Wildcard subscriptions replay across every buffered topic matching the pattern. For reload/BFCache persistence, pass an optional `persistence` created by `createIndexedDbReplayPersistence({ maxPerTopic })`; persistence is asynchronous and failures are reported through `onError` without breaking live delivery. Set `retentionMs` to
|
|
96
|
+
- Replay (opt-in): construct the bus with `replay: { maxPerTopic }` and pass `{ replay: true | n }` as the third `subscribe()` argument. `maxPerTopic` must be a positive safe integer. The new handler immediately receives the buffered history (up to `n`, capped by `maxPerTopic`, default 100) with `message.replayed: true`, so late joiners do not miss earlier publications. Only dispatched publications are buffered (a topic with no local subscriber drops them as unowned); buffers are in-memory and cleared when the last handler for the topic unsubscribes. Wildcard subscriptions replay across every buffered topic matching the pattern. For reload/BFCache persistence, pass an optional `persistence` created by `createIndexedDbReplayPersistence({ maxPerTopic })`; persistence is asynchronous and failures are reported through `onError` without breaking live delivery. Set `retentionMs` to prune expired producer-timestamped history in memory and to sweep adapters that implement `clearBefore` during hydration and after appends. Set `persistenceRetry: { maxAttempts, backoffMs }` to retry transient persistence failures; defaults preserve one-attempt behavior. Set `pruneStrategy` to `'count'` (default), `'age'`, or `'both'` to cap by `maxPerTopic`, prune timestamped history by `retentionMs`, or apply both. Under `age`, timestamp-less legacy entries are retained but capped by `maxPerTopic`; timestamped entries are bounded by the retention window.
|
|
90
97
|
When tracing is enabled, retries emit `reliability` events with `operation: 'persistence_retry'`, a bounded `persistenceOperation`, and `attempt`.
|
|
91
98
|
|
|
92
99
|
The WebSocket transport accepts binary publications delivered as either `ArrayBuffer` or browser `Blob` frames.
|
|
@@ -118,6 +125,8 @@ Published data must satisfy the serialization constraints of the underlying tran
|
|
|
118
125
|
|
|
119
126
|
When the owning Worker is a remote Tab and the publish control message cannot be posted (for example the BroadcastChannel fails to clone the payload), `publish()` reports the failure through `onError` instead of silently dropping it.
|
|
120
127
|
|
|
128
|
+
Calling `publish()` while `stop()` is still settling reports through `onError` and routes nothing; the message is not deferred until a later start. Publications issued earlier and still queued behind an in-flight transport open are canceled by the stop.
|
|
129
|
+
|
|
121
130
|
Incoming messages may include a caller/server supplied `messageId`. Enable bounded duplicate suppression with `dedup: { maxEntries, ttlMs }`; repeated IDs within the window are ignored. This is disabled by default and does not provide an exactly-once server guarantee. Tests and hosts with a custom time source may provide `dedup.now`. A full `stop()` clears the remembered ID window; a later `start()` begins a fresh dedup session.
|
|
122
131
|
|
|
123
132
|
When supplied, `options.messageId` and `options.timestamp` are propagated through cross-tab routing, Worker boundaries, and supported transports. The server must echo or otherwise preserve them for inbound deduplication and replay retention.
|
|
@@ -132,6 +141,21 @@ When supplied, `options.messageId` and `options.timestamp` are propagated throug
|
|
|
132
141
|
}
|
|
133
142
|
```
|
|
134
143
|
|
|
144
|
+
### `publishBatch(topic, items)`
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
publishBatch(
|
|
148
|
+
topic: string,
|
|
149
|
+
items: Array<{ data: unknown; messageId?: string; timestamp?: number }>
|
|
150
|
+
): void
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Publishes many items to one topic as a single unit of work. The bundled WebSocket transport packs the whole batch into one wire frame (the `publishBatch` op) rather than one frame per item; a transport that does not implement the optional `DataBusTransport.publishBatch` hook falls back to per-item `publish()`, so the call is safe either way.
|
|
154
|
+
|
|
155
|
+
Per-item `messageId` and `timestamp` survive the wire frame, and dedup, replay, and ordering apply per item in source order. An empty batch is a no-op; a single-item batch delegates to `publish()`. `WorkerClusterRuntime` exposes the same method for callers that coordinate directly.
|
|
156
|
+
|
|
157
|
+
A non-empty batch issued while `stop()` is still settling reports through `onError` and sends nothing; an empty batch remains a no-op.
|
|
158
|
+
|
|
135
159
|
### `clearReplay()`
|
|
136
160
|
|
|
137
161
|
```ts
|
|
@@ -172,6 +196,65 @@ getStatus(): WorkerStatus
|
|
|
172
196
|
|
|
173
197
|
Returns the current status: `connecting`, `connected`, `disconnected`, or `error`.
|
|
174
198
|
|
|
199
|
+
### `getHealthSummary()`
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
getHealthSummary(): DataBusHealthSummary
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Compact readiness verdict for dashboards, readiness probes, and support bundles. Answers "is the bus usable right now" first, then attaches the failure and recovery context that explains the verdict:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
interface DataBusHealthSummary {
|
|
209
|
+
healthy: boolean; // started, not suspended, transport ready
|
|
210
|
+
state: 'stopped' | 'starting' | 'healthy' | 'recovering' | 'suspended' | 'degraded';
|
|
211
|
+
status: WorkerStatus;
|
|
212
|
+
sdkVersion: string;
|
|
213
|
+
started: boolean;
|
|
214
|
+
suspended: boolean; // true while the tab is hidden (BFCache)
|
|
215
|
+
transport: { name; backend; ready; status };
|
|
216
|
+
recovery: { attempt; exhausted; maxAttempts; generation; lastSuccessAt; hasError; errorMessage; errorAt };
|
|
217
|
+
lastFailure: { source: 'transport' | 'persistence' | 'dispatch'; message: string; at: number } | null;
|
|
218
|
+
persistence: { failures: number; lastFailureAt: number | null; lastErrorMessage: string | null };
|
|
219
|
+
metrics: DataBusMetricsSnapshot | null; // live trace window, or null when trace metrics are inactive
|
|
220
|
+
trace: { asyncSink: boolean; pendingEvents: number }; // sink back-pressure visibility
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`state` semantics: `stopped` (not started), `starting` (initial open in flight), `recovering` (automatic transport recovery in progress), `suspended` (tab hidden, resumes on pageshow), `degraded` (automatic recovery exhausted — call `start()` or subscribe again to recover manually), `healthy`. Calling `start()` again while degraded keeps the cluster, subscriptions, and replay buffers intact, resets the failure/recovery ledger, and reopens the transport; subscribe and publish also trigger the same reopen path. `lastFailure` is a unified ledger across all failure sources and resets on every explicit `start()`.
|
|
225
|
+
|
|
226
|
+
### `getRecoveryStats()` / `getPersistenceStats()`
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
getRecoveryStats(): { attempt; exhausted; maxAttempts; hasError; errorMessage; errorAt; generation; lastSuccessAt }
|
|
230
|
+
getPersistenceStats(): { failures; lastFailureAt; lastErrorMessage }
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`recovery.generation` increments on every successful transport open (initial start and each recovery); `lastSuccessAt` is the timestamp of that open (`null` before the first one). Persistence counters cover the optional replay persistence backend only.
|
|
234
|
+
|
|
235
|
+
### `getDiagnostics()`
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
getDiagnostics(): DataBusDiagnostics
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Full diagnostics snapshot combining lifecycle, transport identity (`name`, `backend`, live `status`, `suspended`), recovery, dedup, replay, persistence, protocol (`version`, `unknownMessages`, `peers`), the cluster snapshot, plus two diagnostics-only additions:
|
|
242
|
+
|
|
243
|
+
- `metrics: DataBusMetricsSnapshot | null` — the current trace metrics window (throughput, dispatch latency percentiles, dedup outcomes), or `null` when trace metrics are inactive (disabled or events-only mode).
|
|
244
|
+
- `trace: { asyncSink: boolean; pendingEvents: number }` — sink delivery mode and queued-event depth; a growing `pendingEvents` under `asyncSink: true` is the first sign of sink back-pressure.
|
|
245
|
+
|
|
246
|
+
`sdkVersion` is injected from `package.json` at build time. Prefer `getHealthSummary()` when a consumer only needs the readiness verdict.
|
|
247
|
+
|
|
248
|
+
### `getMetrics()`
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
getMetrics(): DataBusMetricsSnapshot | null
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Synchronous, non-destructive snapshot of the current trace metrics window — the same derived counters a periodic `message_metrics` event carries (received, dispatched, topics, dispatch latency avg/p50/p95/max, dedup accepted/suppressed), readable on demand without a sink or an interval flush. Returns `null` when trace metrics are inactive.
|
|
255
|
+
|
|
256
|
+
`getDiagnostics().replay` ships `{ enabled, topics, messages, bytes }` — `bytes` is the approximate in-memory payload footprint of the buffered replay rings (same string/binary/number sizing heuristic as adaptive load weighting), computed on demand so the hot append path never pays for it.
|
|
257
|
+
|
|
175
258
|
### `getClusterSnapshot()`
|
|
176
259
|
|
|
177
260
|
Returns a diagnostic snapshot:
|
|
@@ -208,7 +291,9 @@ trace: {
|
|
|
208
291
|
}
|
|
209
292
|
```
|
|
210
293
|
|
|
211
|
-
Low-frequency event types include `lifecycle`, `status`, `subscription`, `coordination`, 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`). All public events use a fixed structure and do not contain raw topics, message payloads, connection addresses, or error bodies. 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.
|
|
294
|
+
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 do not contain raw topics, message payloads, connection addresses, or error bodies. 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.
|
|
295
|
+
|
|
296
|
+
**`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.
|
|
212
297
|
|
|
213
298
|
### `stop()`
|
|
214
299
|
|
|
@@ -216,7 +301,7 @@ Low-frequency event types include `lifecycle`, `status`, `subscription`, `coordi
|
|
|
216
301
|
stop(): Promise<void>
|
|
217
302
|
```
|
|
218
303
|
|
|
219
|
-
Permanently destroys the current instance: cleans up handlers, cluster registration, routes, Workers, and transport. Normal page hide and restore do not require calling this method.
|
|
304
|
+
Permanently destroys the current instance: cleans up handlers, cluster registration, routes, Workers, and transport. If a transport open or reopen is still settling, `stop()` waits for it and invalidates its result so it cannot become ready after the stop. Normal page hide and restore do not require calling this method.
|
|
220
305
|
|
|
221
306
|
## `DataBusTransport<TConfig, TData>`
|
|
222
307
|
|
|
@@ -226,6 +311,9 @@ interface DataBusTransport<TConfig, TData> {
|
|
|
226
311
|
subscribe(topic): void | Promise<void>;
|
|
227
312
|
unsubscribe(topic): void | Promise<void>;
|
|
228
313
|
publish(topic, data): void | Promise<void>;
|
|
314
|
+
/** Optional: one wire frame for many items. The DataBus falls back to
|
|
315
|
+
* per-item `publish` calls when this is absent. */
|
|
316
|
+
publishBatch?(topic, items): void | Promise<void>;
|
|
229
317
|
stop(): void | Promise<void>;
|
|
230
318
|
}
|
|
231
319
|
```
|
|
@@ -273,6 +361,18 @@ Available options:
|
|
|
273
361
|
- `workerFactory`: custom Dedicated Worker loading method
|
|
274
362
|
- `sharedWorkerFactory`: custom SharedWorker loading method
|
|
275
363
|
|
|
364
|
+
## `createStorageEventChannel(options)`
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
createStorageEventChannel(options: {
|
|
368
|
+
name: string;
|
|
369
|
+
storage: StorageLike | null;
|
|
370
|
+
win: StorageEventWindow | null;
|
|
371
|
+
}): ClusterChannel | null
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
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); a monotonic sequence in the payload envelope keeps consecutive identical messages deliverable. Opt in via `createBrowserEnvironment({ channelFallback: 'storage-event' })`; see the security note in [configuration.md](./configuration.md#coordination-channel-fallback-broadcastchannel-unavailable).
|
|
375
|
+
|
|
276
376
|
## WebSocket Transport Backend
|
|
277
377
|
|
|
278
378
|
A dependency-free transport over a plain WebSocket. Any server speaking the JSON frame protocol below can back the same cross-tab clustering stack (owner dedup, sticky routes, failover) as the Centrifuge backend.
|
|
@@ -336,6 +436,10 @@ Attaches a message handler with automatic cleanup. The handler is read through a
|
|
|
336
436
|
|
|
337
437
|
Mirrors `bus.onStatus()` into React state and reads the current value synchronously whenever the bus identity changes. Returns `'connecting' | 'connected' | 'disconnected' | 'error'`.
|
|
338
438
|
|
|
439
|
+
### `useCrossTabHealth(bus, options?)`
|
|
440
|
+
|
|
441
|
+
Mirrors `bus.getHealthSummary()` into React state (`DataBusHealthSummary | null`). Because the summary is a snapshot, the hook polls it on an interval (default 1000 ms; pass `{ intervalMs: 0 }` for event-driven refreshes only) and refreshes immediately on status changes and errors. Returns `null` while the bus has not been created yet.
|
|
442
|
+
|
|
339
443
|
## Vue Composables (`cross-tab-worker-databus/vue`)
|
|
340
444
|
|
|
341
445
|
Vue 3.3+ is an optional peer dependency; this entry is separate from the core package.
|
|
@@ -348,6 +452,10 @@ useVueCrossTabSubscription(bus, 'chat.*', message => console.log(message.data));
|
|
|
348
452
|
|
|
349
453
|
`useCrossTabDataBus` returns a Vue `Ref` that is populated on mount and stopped on unmount. `useCrossTabSubscription` accepts a string or `Ref<string>` topic and rebinds when the bus or topic changes. `useCrossTabStatus` returns a `Ref<WorkerStatus>` synchronized with `bus.onStatus()`.
|
|
350
454
|
|
|
455
|
+
### `useVueCrossTabHealth(bus, options?)`
|
|
456
|
+
|
|
457
|
+
The Vue binding of `useCrossTabHealth`: mirrors `bus.getHealthSummary()` into a `Ref<DataBusHealthSummary | null>`. Because the summary is a snapshot rather than an event stream, the composable polls it on an interval (default 1000 ms; pass `{ intervalMs: 0 }` for event-driven refreshes only) and refreshes immediately on status changes and errors. Returns `null` while the bus has not been created yet.
|
|
458
|
+
|
|
351
459
|
## `WorkerClusterRuntime`
|
|
352
460
|
|
|
353
461
|
Advanced API responsible for Worker registration, heartbeat, visibility, routing, BroadcastChannel protocol, and migration. Business modules should not operate on it directly.
|
|
@@ -358,6 +466,7 @@ Main methods:
|
|
|
358
466
|
- `setStatus(status)`
|
|
359
467
|
- `subscribe(topic)` / `unsubscribe(topic)`
|
|
360
468
|
- `publish(topic, data)`
|
|
469
|
+
- `publishBatch(topic, items)`
|
|
361
470
|
- `broadcastEvent(eventType, payload)`
|
|
362
471
|
- `isAssigned(topic)`
|
|
363
472
|
- `isActiveWorker()`
|
|
@@ -374,6 +483,12 @@ Generates a stable 128-bit hexadecimal opaque key. Used to avoid writing raw con
|
|
|
374
483
|
|
|
375
484
|
Creates a default browser environment adapter, including storage, BroadcastChannel, timers, and page lifecycle events.
|
|
376
485
|
|
|
486
|
+
### `getOrCreateTabId(environment, key?)`
|
|
487
|
+
|
|
488
|
+
Returns the stable identity of the current page/tab instance, reading it from session storage and creating one (`tab-<random>`) on first call, so a reloaded tab reclaims its routes instead of looking like a brand new one.
|
|
489
|
+
|
|
490
|
+
The stored value is deliberately *not* reused when `window.opener` is present: `window.open()` clones the opener's `sessionStorage` into the child, so a blind reuse would make two live tabs share one identity. When storage is unavailable or throws, a fresh ID is generated instead.
|
|
491
|
+
|
|
377
492
|
### `selectWorkerBackend(mode, availability?)`
|
|
378
493
|
|
|
379
494
|
Selects the actual backend based on `WorkerMode` and capability detection, returning `'shared' | 'dedicated' | 'local'`:
|
|
@@ -383,6 +498,22 @@ Selects the actual backend based on `WorkerMode` and capability detection, retur
|
|
|
383
498
|
|
|
384
499
|
`availability` can explicitly pass `worker` / `sharedWorker` capability flags, for use in SSR, testing, or embedded environments, avoiding access to non-existent global objects.
|
|
385
500
|
|
|
501
|
+
### `effectiveWorkerLoad(worker, options?)`
|
|
502
|
+
|
|
503
|
+
The pure scoring function behind least-loaded owner selection: `worker.load` (the owned-Topic count) plus the weighted traffic and scheduling-lag terms when `options` carries the `loadWeighting` weights.
|
|
504
|
+
|
|
505
|
+
The score is always finite. A Worker with no throughput sample, an unset (all-zero) weight set, a non-positive or non-finite sample window, or a non-finite weighted sum scores as its raw Topic count — a non-finite score would never compare correctly and would make owner selection depend on the order of the Worker array rather than on load. See [configuration.md](./configuration.md#adaptive-owner-weighting) for the weights.
|
|
506
|
+
|
|
507
|
+
### `approximatePayloadBytes(payload)`
|
|
508
|
+
|
|
509
|
+
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.
|
|
510
|
+
|
|
511
|
+
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.
|
|
512
|
+
|
|
513
|
+
### `DEFAULT_MAX_ACTIVE_WORKERS`
|
|
514
|
+
|
|
515
|
+
The default cap on how many Workers may own Topics concurrently (`3`). It bounds fan-out breadth: only that many Workers are eligible to become new-route owners, so a cluster of twenty tabs still concentrates ownership on a few rather than spreading it thin. Override it with the `maxActiveWorkers` cluster option.
|
|
516
|
+
|
|
386
517
|
### Routing Functions
|
|
387
518
|
|
|
388
519
|
- `selectActiveWorkers`
|
package/docs/architecture.md
CHANGED
|
@@ -48,10 +48,36 @@ Because `MessagePort` has no `close` event, a tab that crashes before sending `S
|
|
|
48
48
|
| Layer | Entry | Responsibility |
|
|
49
49
|
|---|---|---|
|
|
50
50
|
| DataBus | `CrossTabDataBus` | Local handler reference counting, message dispatch, state and transport lifecycle |
|
|
51
|
+
| Replay | `ReplayManager` | Bounded per-topic history ring, durable IndexedDB persistence, retention cleanup, retry policy |
|
|
52
|
+
| Dedup | `DedupManager` | Opt-in bounded duplicate suppression by `messageId`, adaptive TTL, expiry sweep |
|
|
51
53
|
| Cluster Coordination | `WorkerClusterRuntime` | Worker registration, roles, heartbeat, Topic owner, migration and broadcast protocol |
|
|
52
54
|
| Transport | `DataBusTransport` | Executes subscribe, unsubscribe, publish on the real Worker/connection |
|
|
53
55
|
| Centrifuge | `CentrifugeWorkerTransport` | Protocol adaptation between the main thread and the built-in Centrifuge Worker |
|
|
54
56
|
|
|
57
|
+
## Source Layout & Shared Utils
|
|
58
|
+
|
|
59
|
+
The `src/` tree keeps platform adapters and the coordination core beside a small
|
|
60
|
+
`src/utils/` toolbox:
|
|
61
|
+
|
|
62
|
+
| File | Contents | Consumers |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `utils/constants.ts` | Every runtime string literal in one place — statuses, roles, actions, cluster/worker/protocol message types, trace event discriminants, enums, namespace prefixes. All literal-derived types (`WorkerStatus`, message `type` fields, trace `action`/`operation`, …) are derived from these constants with `(typeof X)[keyof typeof X]`, so a value and its type cannot drift apart. | all modules |
|
|
65
|
+
| `utils/metadata.ts` | `publicationMetadata(messageId, timestamp)` — spreads only defined metadata, previously duplicated across four modules. | data-bus, cluster, centrifuge, centrifuge-session |
|
|
66
|
+
| `utils/storage-utils.ts` | `readJson` / `writeJson` / `listKeys` / `readAllByPrefix` — fault-tolerant storage primitives (corrupt JSON → absent; failed write → swallowed). | cluster |
|
|
67
|
+
| `utils/validation.ts` | Constructor/option validation asserts (`assertReplayOptions`, `assertDedupOptions`, `assertRecoveryOptions`, `assertHeartbeatInterval`, …). Optional fields are validated only when explicitly provided; defaults are always valid. | data-bus, replay-persistence, centrifuge |
|
|
68
|
+
| `utils/error-utils.ts` | `serializeError` / `deserializeWorkerError` + `SerializedWorkerError` — Error round-tripping across the Worker boundary. | centrifuge, centrifuge-session |
|
|
69
|
+
|
|
70
|
+
Extracting these was deliberate: they are side-effect-free, dependency-light
|
|
71
|
+
helpers whose duplicated copies had already started to drift (e.g. four
|
|
72
|
+
near-identical `publicationMetadata` implementations). Stateful cross-cutting
|
|
73
|
+
concerns with their own lifecycle — replay buffering and dedup — live as
|
|
74
|
+
self-contained `DedupManager` / `ReplayManager` classes that the DataBus
|
|
75
|
+
delegates to; they own their maps/timers/stats and expose thin start/stop/
|
|
76
|
+
record/clear surfaces. The DataBus lifecycle state machine (start/stop/
|
|
77
|
+
suspend/resume, promise gates, recovery pacing) was kept inside
|
|
78
|
+
`CrossTabDataBus` on purpose: those flags interlock tightly and extracting
|
|
79
|
+
them would re-introduce the race conditions the gates exist to prevent.
|
|
80
|
+
|
|
55
81
|
## Glossary
|
|
56
82
|
|
|
57
83
|
Terms are explained in plain language; the code and the rest of this document use the short names.
|
|
@@ -307,6 +333,46 @@ The owning Worker filters every publication it receives with `isAssigned(topic)`
|
|
|
307
333
|
6. Only a Topic without a route, or a route whose owner has departed or expired by heartbeat TTL, is assigned to the least-loaded candidate Worker.
|
|
308
334
|
7. A new route is considered unconfirmed until the owner writes `confirmedAt`; the subscriber will automatically resend the control message.
|
|
309
335
|
|
|
336
|
+
### Adaptive owner weighting (`loadWeighting`)
|
|
337
|
+
|
|
338
|
+
By default "least-loaded" means the fewest owned Topics. The opt-in `loadWeighting`
|
|
339
|
+
option adds traffic and scheduling signals, still only for NEW or orphaned routes —
|
|
340
|
+
existing routes stay sticky and are never migrated:
|
|
341
|
+
|
|
342
|
+
- Each Worker samples its own fan-out activity between heartbeats and publishes a
|
|
343
|
+
`WorkerThroughputSample` (`windowMs`, `messageCount`, `byteCount`, `overrunMs`,
|
|
344
|
+
`sampledAt`) with the worker record.
|
|
345
|
+
- `overrunMs` is the positive excess of the sample window over the nominal heartbeat
|
|
346
|
+
interval. A starved event loop (the browser-observable proxy for CPU saturation)
|
|
347
|
+
lands heartbeats late and stretches the window, so this is a cheap native signal.
|
|
348
|
+
- `effectiveWorkerLoad` (a pure function in `routing.ts`) scores a Worker as
|
|
349
|
+
`load + messageRateWeight × msg/s + byteRateWeight × B/s + scheduleLagWeight × (overrunMs ÷ windowMs)`.
|
|
350
|
+
All weights default to `0`, keeping the legacy pure topic-count score byte-identical.
|
|
351
|
+
- The scorer is deterministic and reads the same persisted records on every tab, so
|
|
352
|
+
routing agrees cluster-wide; a scheduling-laggy Worker becomes less attractive for
|
|
353
|
+
new routes even when it carries fewer topics.
|
|
354
|
+
|
|
355
|
+
### Async credential refresh bridge (`credentialProvider`)
|
|
356
|
+
|
|
357
|
+
Centrifuge client options are structured-cloned into the Worker, so function-valued
|
|
358
|
+
`getToken` / `getChannelToken` cannot travel with the config. The opt-in
|
|
359
|
+
`credentialProvider` (`{ getToken, getChannelToken }`) on `createCentrifugeDataBus`
|
|
360
|
+
runs on the main thread instead:
|
|
361
|
+
|
|
362
|
+
1. When a provider is configured, INIT carries `tokenBridge: true` and the Worker's
|
|
363
|
+
`CentrifugeSession` wires `getToken` / `getChannelToken` to issue a
|
|
364
|
+
`TOKEN_REQUEST` output (`requestId`, `kind`, optional `channel`).
|
|
365
|
+
2. The transport resolves it on the main thread: `resolveTokenRequest` calls the
|
|
366
|
+
provider and posts `TOKEN_RESPONSE` (or `TOKEN_ERROR` on rejection / empty token)
|
|
367
|
+
back with the matching `requestId`.
|
|
368
|
+
3. The session settles the pending promise by `requestId`; a `STOP` rejects all
|
|
369
|
+
in-flight requests so a stopped worker never awaits a response forever.
|
|
370
|
+
4. Without a provider, INIT omits `tokenBridge` and the config stays byte-identical
|
|
371
|
+
to legacy — a server that never asks for a token triggers no requests.
|
|
372
|
+
5. Dedicated, SharedWorker, and local backends share `CentrifugeSession`, so the
|
|
373
|
+
bridge works across all three; the token never crosses the Worker boundary as a
|
|
374
|
+
function, only as a resolved string.
|
|
375
|
+
|
|
310
376
|
## Subscription Flow
|
|
311
377
|
|
|
312
378
|
```mermaid
|
|
@@ -465,6 +531,20 @@ If a Tab still subscribed to a Topic finds that the owner Worker has departed or
|
|
|
465
531
|
|
|
466
532
|
This process prevents overlap during graceful owner handoff while retaining availability during failure recovery; it does not guarantee exactly-once delivery.
|
|
467
533
|
|
|
534
|
+
## Stability Invariants
|
|
535
|
+
|
|
536
|
+
These invariants are pinned by regression tests (see `tests/stability.test.ts` and `tests/replay-persistence.test.ts`) and must hold through future refactors:
|
|
537
|
+
|
|
538
|
+
- **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 is at least as new as the stored route generation. Replayed ACKs from an earlier handoff round (e.g. an a↔b ping-pong) carry an older generation and are dropped.
|
|
539
|
+
- **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. Cleared history is never re-appended by an in-flight flush.
|
|
540
|
+
- **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.
|
|
541
|
+
- **Transport recovery budget.** Automatic recovery is paced by a cooldown, bounded by `recovery.maxAttempts`, and reports `exhausted` when the budget is spent. A successful reopen resets the attempt counter and the exhausted flag; explicit `subscribe` on a down transport can still recover manually.
|
|
542
|
+
- **BFCache suspension.** Hiding the tab stops the transport, bumps the persistence-retry generation (cancelling in-flight persistence retries without surfacing errors), and gates dispatch; pageshow reopens the transport and re-establishes subscriptions exactly once per cycle.
|
|
543
|
+
- **Handoff channel close ordering.** `pause()` defers the physical `channel.close()` by one task. Closing synchronously would discard messages still queued for delivery — including the handoff's `ROUTE_RELEASED` — stranding the handoff target with an unconfirmed route.
|
|
544
|
+
- **Stranded-handoff recovery.** If the previous owner is gone and its `ROUTE_RELEASED` never arrives (dropped channel message under load, or a crash between the route write and the ACK), the reconcile loop re-elects a live owner once the unconfirmed handoff has been stuck longer than a worker TTL (10 s default): the route is rewritten with a fresh generation and the handoff marker cleared, so the normal confirmation path completes (pinned by regression). The age gate matters — a fresh unconfirmed route may simply be waiting out its confirmation flush — and while the previous owner is still alive the new owner keeps waiting, so the strict handoff keeps its no-overlap guarantee.
|
|
545
|
+
- **Loss and recovery matrix.** Each coordination message has a bounded recovery path: a lost `CONTROL/SUBSCRIBE` is re-sent by the heartbeat reconcile for any route still lacking `confirmedAt`; a lost `REGISTRY` nudge costs at most one heartbeat interval (3 s default) because every tick reconciles anyway; a lost `ROUTE_RELEASED` is recovered by reconcile re-election once the previous owner is gone and the handoff has been stuck longer than a worker TTL (see the stranded-handoff invariant above, pinned by regression); publications dropped during a transport disconnect window are the one documented unrecoverable loss (transport contract). The storage-event fallback channel guarantees value-change delivery via a monotonic sequence in the envelope, and a dropped dispatch recovers through the same reconcile loop.
|
|
546
|
+
- **Recovery diagnostics.** `getHealthSummary()` derives a single readiness verdict from the lifecycle flags (`stopped` / `starting` / `healthy` / `recovering` / `suspended` / `degraded`); the unified `lastFailure` ledger and persistence counters reset on every explicit `start()`.
|
|
547
|
+
|
|
468
548
|
## Transport Reconnection
|
|
469
549
|
|
|
470
550
|
DataBus separates "business subscription intent" from "transport current subscription state". When the transport reports `disconnected` / `error`, it only clears the underlying subscription flag, not the business handler; when it re-enters `connected`, DataBus automatically replays the Topics the current Worker is responsible for.
|
|
@@ -484,7 +564,12 @@ The built-in Centrifuge transport also retains its own Subscriptions and perform
|
|
|
484
564
|
| `suspended` | `boolean` | Tab is hidden; transport is intentionally stopped |
|
|
485
565
|
| `transportReady` | `boolean` | Transport has reported `connected` and is accepting operations |
|
|
486
566
|
| `startPromise` | `Promise \| null` | Gate for concurrent `start()` calls; cleared after settle |
|
|
567
|
+
| `stopPromise` | `Promise \| null` | Shared gate for an explicit `stop()` and any restart queued behind it |
|
|
568
|
+
| `queuedStart` | `Promise \| null` | One fresh start waiting for an in-flight explicit stop to settle |
|
|
569
|
+
| `queuedStartToken` | `number` | Monotonic token issued to each queued restart so a cancellation cannot be mistaken for a later one |
|
|
570
|
+
| `canceledQueuedStartToken` | `number` | Highest queued-restart token invalidated by `stop()`; a continuation at or below it resolves without opening |
|
|
487
571
|
| `pendingStop` | `Promise \| null` | Gate for async `transport.stop()`; shared by suspend and failure paths |
|
|
572
|
+
| `lifecycleEpoch` | `number` | Monotonic ownership token; invalidates callbacks and cleanup from superseded opens |
|
|
488
573
|
|
|
489
574
|
### State transitions
|
|
490
575
|
|
|
@@ -514,8 +599,14 @@ The built-in Centrifuge transport also retains its own Subscriptions and perform
|
|
|
514
599
|
|
|
515
600
|
**Key behaviors:**
|
|
516
601
|
|
|
517
|
-
- **Concurrent start**: If `start()` is called while
|
|
602
|
+
- **Concurrent start**: If `start()` is called while a real transport opening is in flight, the second call returns the same promise. Only one transport open is in flight at a time. A page-hide stop can also occupy `startPromise`; `start()` recognizes that `startPromise === pendingStop` and queues a reopen behind the stop rather than returning the cleanup promise as if it were a successful start.
|
|
603
|
+
- **Start during explicit stop**: `stop()` publishes a shared `stopPromise` for concurrent callers. A `start()` received while it is settling stores one `queuedStart`; after the stop's `finally` clears the lifecycle state, the queued start performs a fresh lifecycle with the new config. Repeated calls during that window share both the stop and queued-start promises.
|
|
604
|
+
- **Stop cancels a queued restart**: The queued continuation is chained to the stop promise and cannot be un-scheduled, so a second `stop()` before it runs invalidates it instead. Each queued restart carries a monotonic token; `stop()` records the current token and releases the single queue slot, and the continuation resolves without opening a transport when its own token is no longer newer. The queued `start()` promise retains that resolve-on-cancellation contract, while a separate readiness view makes `ready()` reject for the canceled intent. Because a later `start()` issues a higher token, `stop → start → stop → start` still ends running while `stop → start → stop` ends stopped with no extra transport open.
|
|
605
|
+
- **Stop-time publication rejection**: Once `stop()` sets `stopping`, new `publish()` and non-empty `publishBatch()` calls cannot reach a transport. They surface an error through `onError` rather than letting `runTransport()` return silently; empty batches stay no-ops. A publication already queued behind an in-flight open is canceled by the stop (latest intent wins), while page-hide suspension keeps its documented drop-without-defer semantics.
|
|
606
|
+
- **Stop-time lifecycle-operation rejection**: The `stopping` gate also covers `subscribe()` and `ready()`. A late `subscribe()` is reported through `onError` and returns a no-op cleanup, preventing a handler from being erased by `topicHandlers.clear()` or leaking into a later restart without its handler. `ready()` rejects instead of resolving against the stopping transport. If `start()` has already queued a restart behind the stop, `ready()` returns that queued-start promise because it is the newest lifecycle intent.
|
|
607
|
+
- **Queued-restart failure retention**: A queued restart that fails during transport startup clears `started` but retains its actual error for later `ready()` calls. Without `initialConfig`, those calls reject with the startup failure instead of the generic configuration error, while an explicit `start(config)` remains a clean manual retry with a fresh failure ledger.
|
|
518
608
|
- **Suspend during start**: If `pagehide` fires while `openTransport` is in flight, `suspendTransport()` sets `suspended = true` and chains a `transport.stop()` after the in-flight start. The `openTransport` catch path detects `suspended` and abandons the open without treating it as a failure.
|
|
609
|
+
- **Superseded open invalidation**: Every fresh start, reopen, suspend, and stop advances `lifecycleEpoch`. An open captures its epoch, ignores stale status/message/error callbacks, and neither marks the transport ready nor performs failure cleanup after a newer transition owns the lifecycle. `stop()` therefore waits for pending opens/reopens and prevents a superseded open from becoming ready after the stop completes.
|
|
519
610
|
- **Recovery cooldown**: When the transport reports `error` while `started` is true and `stopping` is false, `updateStatus` schedules an automatic `reopenTransport()` after `RECOVERY_COOLDOWN_MS` (1000 ms). A second error within the cooldown window is suppressed to prevent a tight retry loop.
|
|
520
611
|
- **Stop during suspend**: `stop()` sets `stopping = true`, which prevents `suspendTransport()` from running. The cleanup awaits `startPromise` and `pendingStop` to ensure any in-flight open or stop completes before the final `transport.stop()`.
|
|
521
612
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!-- Generated by scripts/bench-trend.mjs — the whole file is machine-generated; edit the script, not this doc. -->
|
|
2
|
+
|
|
3
|
+
# Browser Benchmark Trend
|
|
4
|
+
|
|
5
|
+
> Data through 2026-09-12, from the 14 archived `bench-results/browser-*.json` reports (run `pnpm bench:browser` to add one; regenerate this doc with `node scripts/bench-trend.mjs`).
|
|
6
|
+
|
|
7
|
+
The comparison baseline for release gating is `pnpm bench:compare --fail-above-pct 50` between the two most recent reports (50% ceiling absorbs shared-runner noise). This doc records the long-run picture: values are per-metric latencies where lower is better, and the all-time best marks the healthiest observed run on this machine.
|
|
8
|
+
|
|
9
|
+
<!-- BENCH-TREND:BEGIN (machine-generated table) -->
|
|
10
|
+
| Metric | Previous (ms) | Latest (ms) | Δ | All-time best (ms) |
|
|
11
|
+
|---|---|---|---|---|
|
|
12
|
+
| publish per-message (ms, lower is better) — dedicated | 61.1276 | 58.888 | -2.24 | 40.8227 |
|
|
13
|
+
| publish per-message (ms, lower is better) — shared | 38.5784 | 45.0451 | +6.47 | 33.8206 |
|
|
14
|
+
| wildcard dispatch ×1000 (ms, lower is better) | 6.5 | 7.5 | +1.00 | 0.1 |
|
|
15
|
+
| publishBatch ×1000 (ms, lower is better) | 4.5 | 5.1 | +0.60 | 0.4 |
|
|
16
|
+
| dedup ×1000 (ms, lower is better) | 13.4 | 14.5 | +1.10 | 0 |
|
|
17
|
+
| trace + publish ×1000 (ms, lower is better) | 5.3 | 5.2 | -0.10 | 4.8 |
|
|
18
|
+
| first-packet cold dispatch (ms, lower is better) | 0.1 | 0 | -0.10 | 0 |
|
|
19
|
+
<!-- BENCH-TREND:END -->
|
|
20
|
+
|
|
21
|
+
Notes:
|
|
22
|
+
|
|
23
|
+
- publish rows measure the full demo round-trip (publish click → transport → demo server → EVENT fan-out → receiver metric), so they include real browser and server latency; the databus rows are in-page hot-path microbenchmarks.
|
|
24
|
+
- A sustained upward drift across several runs — not a single outlier — is the regression signal. Investigate the hot-path changes in that window before tagging a release.
|
package/docs/capabilities.md
CHANGED
|
@@ -20,20 +20,41 @@ Status Legend: `✅ Implemented` means the current version has code and test cov
|
|
|
20
20
|
| Exception Recovery | Worker TTL, stale owner migration, and coordination cache cleanup | ✅ Implemented | Reclaims dead Workers, orphaned subscribers, and expired routes with no subscribers |
|
|
21
21
|
| transport | Replays owner Topics after reconnect | ✅ Implemented | Business handlers and subscription intent are not cleared on disconnect |
|
|
22
22
|
| Degradation | Runs locally when localStorage or BroadcastChannel is unavailable | ✅ Implemented | Preserves the current Tab's connection and subscription capabilities |
|
|
23
|
+
| Degradation | Opt-in localStorage storage-event coordination channel when BroadcastChannel is unavailable | ✅ Implemented | `createBrowserEnvironment({ channelFallback: 'storage-event' })`; coordination payloads (plaintext topic names) persist to localStorage — documented trade-off |
|
|
23
24
|
| Centrifuge | Built-in Dedicated / Shared Worker transport | ✅ Implemented | Supports subscribe, unsubscribe, publish, connection status, and error reporting; `auto` degrades from SharedWorker → Dedicated Worker → main-thread WebSocket |
|
|
24
25
|
| Security Boundary | localStorage uses opaque keys derived from connection and Topic; BroadcastChannel coordination messages carry plaintext topic names | ✅ Implemented | Does not persist URLs, raw Topic names, credentials, or publication payloads. BroadcastChannel coordination messages are in-memory only and carry plaintext topic names — they are not persisted. |
|
|
25
|
-
| Diagnostics | Aggregates lifecycle events, throughput, delivery latency, dedup outcomes, recovery retries, route acknowledgments, and migrations | ✅ Implemented | Disabled by default; metrics are emitted every 5 seconds by default, with bounded reliability events for recovery and route coordination |
|
|
26
|
+
| Diagnostics | Aggregates lifecycle events, throughput, delivery latency, dedup outcomes, recovery retries, route acknowledgments, and migrations | ✅ Implemented | Disabled by default; metrics are emitted every 5 seconds by default, with bounded reliability events for recovery and route coordination. On-demand `getMetrics()` / `getDiagnostics().metrics` expose the current window synchronously without a sink |
|
|
27
|
+
| Diagnostics | `getHealthSummary()` single-object readiness verdict and unified failure ledger | ✅ Implemented | `healthy`/`state` covers stopped, starting, recovering, BFCache-suspended, and recovery-exhausted degraded; `lastFailure` unifies transport/persistence/dispatch sources and resets on `start()` |
|
|
26
28
|
| Performance | Batched writes of coordination metadata with backoff retry | ✅ Implemented | Heartbeat, route, and subscriber writes are merged and flushed in a microtask; failures use exponential backoff; `pagehide` / `stop()` flush synchronously |
|
|
27
29
|
| Performance | Optional ArrayBuffer Transferable transport | ✅ Implemented | With `transferable: true`, binary publish / receive bypasses structured clone copying; the object message API is unchanged |
|
|
30
|
+
| Performance | Optional `DataBusTransport.publishBatch` one-frame burst publishing | ✅ Implemented | The bundled WebSocket transport sends multi-item batches in one wire frame (`publishBatch` op, demo server supported); transports without batch support fall back to per-item `publish` |
|
|
28
31
|
| Message Semantics | exactly-once delivery | Not Implemented | Graceful handoff avoids overlap, but crash recovery and transport/server behavior still do not provide an exactly-once guarantee |
|
|
29
32
|
| Message Semantics | Pluggable publication deduplication | ✅ Implemented | Opt-in bounded inbound suppression by `DataBusMessage.messageId`; default is disabled and transport/server IDs remain caller-controlled |
|
|
30
|
-
| Authentication | Async credential refresh bridge inside the Worker |
|
|
31
|
-
| Load Policy | Adaptive weighting by message rate, byte count, or
|
|
33
|
+
| Authentication | Async credential refresh bridge inside the Worker | ✅ Implemented | Opt-in `credentialProvider` ({ getToken, getChannelToken }) on `createCentrifugeDataBus`. Worker config stays structured-cloneable; the Worker asks the main thread for each fresh token over a TOKEN_REQUEST / TOKEN_RESPONSE exchange, and the provider supplies it from application context |
|
|
34
|
+
| Load Policy | Adaptive weighting by message rate, byte count, or scheduling lag | ✅ Implemented | Opt-in `loadWeighting` ({ messageRateWeight, byteRateWeight, scheduleLagWeight }): workers sample their own fan-out traffic and heartbeat scheduling overrun per window and publish it with the worker record; new-route owner selection adds the normalized per-second rates and lag ratio to the topic count. Default (unset) keeps pure topic-count routing; existing routes stay sticky. |
|
|
32
35
|
| Observability | Metrics/events for owner acknowledgments, migrations, and recovery attempts | ✅ Implemented | `DataBusReliabilityTraceEvent` reports bounded route ack/migration and transport recovery events; exact server-side ack remains transport-specific |
|
|
33
36
|
| Runtime Model | SharedWorker / Dedicated Worker transport | ✅ Implemented | `workerMode` supports `dedicated`, `shared`, and `auto`, defaulting to `dedicated` |
|
|
34
37
|
| Runtime Model | Service Worker transport | Not Implemented | Deliberately deferred; lifetime and long-lived connection constraints are documented in `docs/architecture.md` |
|
|
35
38
|
| Durable Messages | Persisting publications or publish commands across page close | Not Implemented | The SDK does not persist business payloads, nor does it replay publish commands after restoration |
|
|
36
39
|
|
|
40
|
+
## Verified Guarantees (pinned by the regression suite)
|
|
41
|
+
|
|
42
|
+
These invariants are consolidated from the "Stability Invariants" section of
|
|
43
|
+
`docs/architecture.md` (pinned by `tests/stability.test.ts` and
|
|
44
|
+
`tests/replay-persistence.test.ts`) so each guarantee below is both documented
|
|
45
|
+
and regression-locked:
|
|
46
|
+
|
|
47
|
+
| Invariant | Area | Regressed guarantee |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| Handoff ACK validity | coordination | `ROUTE_RELEASED` is honored only when the route still points to the receiver, the release matches `handoffFromWorkerId`, and the ACK generation is at least as new as the stored route — stale ACKs from an earlier a↔b ping-pong are dropped |
|
|
50
|
+
| Replay persistence cleanup ordering | durability | Queued batch flushes are filtered against the winning cleanup (`unsubscribe`/`clearReplayTopic` drop the topic's pending entries, `clearReplayBefore` drops entries older than the cutoff); cleared history is never re-appended by an in-flight flush |
|
|
51
|
+
| Storage write recovery | coordination | Coalesced writes retry with exponential backoff (50 ms → 1.6 s cap); a structurally failing key is dropped after 5 attempts (with `console.warn`) without blocking other queued keys; backoff resets once the queue drains or `clear()` cancels the retries |
|
|
52
|
+
| Transport recovery budget | lifecycle | Auto-recovery is paced by a cooldown, bounded by `recovery.maxAttempts`, and reports `exhausted` when the budget is spent; a successful reopen resets attempt + exhausted, and an explicit `subscribe` on a down transport can still recover manually |
|
|
53
|
+
| BFCache suspension | lifecycle | Hiding the tab stops the transport and silently cancels in-flight persistence retries; pageshow reopens the transport and re-establishes subscriptions exactly once per cycle |
|
|
54
|
+
| Handoff channel close ordering | coordination | `pause()` defers the physical `channel.close()` by one task so queued handoff frames (including `ROUTE_RELEASED`) flush before the channel dies |
|
|
55
|
+
| Loss & recovery matrix | coordination | Every coordination message has a bounded recovery path (re-send of unconfirmed SUBSCRIBE, REGISTRY nudge, TTL + re-election for lost ACKs); publications lost during a transport disconnect window are the one documented unrecoverable loss |
|
|
56
|
+
| Recovery diagnostics | observability | `getHealthSummary()` derives a single readiness verdict, and the unified `lastFailure` ledger persists across transport failures, resetting on every explicit `start()` |
|
|
57
|
+
|
|
37
58
|
## Acceptance Criteria
|
|
38
59
|
|
|
39
60
|
- "Implemented" does not mean every browser environment provides cross-Tab capability; when localStorage or BroadcastChannel is missing, it falls back to local degradation by design.
|