cross-tab-worker-databus 0.20.71 → 0.20.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +204 -2
  2. package/README.md +8 -0
  3. package/README.zh.md +4 -0
  4. package/dist/centrifuge-protocol.d.ts +59 -26
  5. package/dist/centrifuge-protocol.d.ts.map +1 -1
  6. package/dist/centrifuge-session.d.ts +9 -0
  7. package/dist/centrifuge-session.d.ts.map +1 -1
  8. package/dist/centrifuge.d.ts +12 -2
  9. package/dist/centrifuge.d.ts.map +1 -1
  10. package/dist/centrifuge.js +168 -88
  11. package/dist/centrifuge.js.map +3 -3
  12. package/dist/centrifuge.shared.worker.js +146 -38
  13. package/dist/centrifuge.shared.worker.js.map +3 -3
  14. package/dist/centrifuge.worker.js +140 -33
  15. package/dist/centrifuge.worker.js.map +3 -3
  16. package/dist/{chunk-D2SIT473.js → chunk-PW63EWIK.js} +1090 -494
  17. package/dist/chunk-PW63EWIK.js.map +7 -0
  18. package/dist/chunk-TZ7ZP7YD.js +175 -0
  19. package/dist/chunk-TZ7ZP7YD.js.map +7 -0
  20. package/dist/cjs/centrifuge.cjs +1403 -637
  21. package/dist/cjs/centrifuge.cjs.map +4 -4
  22. package/dist/cjs/hooks.cjs +37 -2
  23. package/dist/cjs/hooks.cjs.map +3 -3
  24. package/dist/cjs/index.cjs +1453 -730
  25. package/dist/cjs/index.cjs.map +4 -4
  26. package/dist/cjs/vue.cjs +45 -2
  27. package/dist/cjs/vue.cjs.map +3 -3
  28. package/dist/core/cluster.d.ts +40 -4
  29. package/dist/core/cluster.d.ts.map +1 -1
  30. package/dist/core/data-bus.d.ts +93 -69
  31. package/dist/core/data-bus.d.ts.map +1 -1
  32. package/dist/core/dedup-manager.d.ts +98 -0
  33. package/dist/core/dedup-manager.d.ts.map +1 -0
  34. package/dist/core/environment.d.ts +43 -3
  35. package/dist/core/environment.d.ts.map +1 -1
  36. package/dist/core/replay-manager.d.ts +128 -0
  37. package/dist/core/replay-manager.d.ts.map +1 -0
  38. package/dist/core/replay-persistence.d.ts +2 -1
  39. package/dist/core/replay-persistence.d.ts.map +1 -1
  40. package/dist/core/routing.d.ts +22 -3
  41. package/dist/core/routing.d.ts.map +1 -1
  42. package/dist/core/storage-batch.d.ts +0 -4
  43. package/dist/core/storage-batch.d.ts.map +1 -1
  44. package/dist/core/trace.d.ts +55 -15
  45. package/dist/core/trace.d.ts.map +1 -1
  46. package/dist/core/types.d.ts +58 -9
  47. package/dist/core/types.d.ts.map +1 -1
  48. package/dist/core/version.d.ts +2 -0
  49. package/dist/core/version.d.ts.map +1 -0
  50. package/dist/hooks.d.ts +12 -1
  51. package/dist/hooks.d.ts.map +1 -1
  52. package/dist/hooks.js +28 -2
  53. package/dist/hooks.js.map +2 -2
  54. package/dist/index.d.ts +6 -6
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +228 -162
  57. package/dist/index.js.map +3 -3
  58. package/dist/utils/constants.d.ts +170 -0
  59. package/dist/utils/constants.d.ts.map +1 -0
  60. package/dist/utils/error-utils.d.ts +28 -0
  61. package/dist/utils/error-utils.d.ts.map +1 -0
  62. package/dist/utils/metadata.d.ts +16 -0
  63. package/dist/utils/metadata.d.ts.map +1 -0
  64. package/dist/utils/storage-utils.d.ts +24 -0
  65. package/dist/utils/storage-utils.d.ts.map +1 -0
  66. package/dist/utils/validation.d.ts +76 -0
  67. package/dist/utils/validation.d.ts.map +1 -0
  68. package/dist/vue.d.ts +13 -1
  69. package/dist/vue.d.ts.map +1 -1
  70. package/dist/vue.js +36 -2
  71. package/dist/vue.js.map +2 -2
  72. package/dist/websocket.d.ts +6 -1
  73. package/dist/websocket.d.ts.map +1 -1
  74. package/dist/worker-mode.d.ts +6 -2
  75. package/dist/worker-mode.d.ts.map +1 -1
  76. package/docs/README.md +1 -0
  77. package/docs/api.md +122 -2
  78. package/docs/architecture.md +80 -0
  79. package/docs/benchmarks.md +24 -0
  80. package/docs/capabilities.md +24 -3
  81. package/docs/configuration.md +58 -0
  82. package/docs/getting-started.md +35 -0
  83. package/docs/release-checklist.md +28 -5
  84. package/docs/roadmap.md +74 -5
  85. package/docs/zh/README.md +2 -1
  86. package/docs/zh/api.md +121 -2
  87. package/docs/zh/architecture.md +49 -0
  88. package/docs/zh/benchmarks.md +24 -0
  89. package/docs/zh/capabilities.md +21 -3
  90. package/docs/zh/configuration.md +53 -0
  91. package/docs/zh/getting-started.md +35 -0
  92. package/docs/zh/release-checklist.md +29 -6
  93. package/docs/zh/roadmap.md +86 -5
  94. package/package.json +39 -16
  95. 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(); });\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>('connecting');\n let cleanup: (() => void) | undefined;\n watch(bus, next => {\n cleanup?.(); cleanup = undefined;\n status.value = next?.getStatus() ?? 'connecting';\n if (next) cleanup = next.onStatus(value => { status.value = value; });\n }, { immediate: true });\n onBeforeUnmount(() => cleanup?.());\n return status;\n}\n"],
5
- "mappings": ";AAGA,SAAS,iBAAiB,WAAW,KAAK,YAAY,aAAuB;AAItE,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;AAAE,SAAK,KAAK;AAAA,EAAG,CAAC;AACtC,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,YAAY;AAC7C,MAAI;AACJ,QAAM,KAAK,UAAQ;AACjB,cAAU;AAAG,cAAU;AACvB,WAAO,QAAQ,MAAM,UAAU,KAAK;AACpC,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;",
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
  }
@@ -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
@@ -1 +1 @@
1
- {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"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"}
@@ -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 = 'dedicated' | 'shared' | 'auto';
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 = 'dedicated' | 'shared' | 'local';
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;;;;;;GAMG;AAEH;;;oEAGoE;AACpE,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEzD;yEACyE;AACzE,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE7D;;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"}
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
@@ -86,7 +86,7 @@ Registers a local subscription and returns a cleanup function.
86
86
  - The current tab only leaves the topic after the last handler is released.
87
87
  - Subscriptions are automatically queued when the transport is not yet ready.
88
88
  - 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 automatically prune durable history via `clearBefore` during hydration and after appends. Set `persistenceRetry: { maxAttempts, backoffMs }` to retry transient persistence failures; defaults preserve one-attempt behavior.
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 automatically prune durable history via `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 by `retentionMs`, or apply both.
90
90
  When tracing is enabled, retries emit `reliability` events with `operation: 'persistence_retry'`, a bounded `persistenceOperation`, and `attempt`.
91
91
 
92
92
  The WebSocket transport accepts binary publications delivered as either `ArrayBuffer` or browser `Blob` frames.
@@ -132,6 +132,19 @@ When supplied, `options.messageId` and `options.timestamp` are propagated throug
132
132
  }
133
133
  ```
134
134
 
135
+ ### `publishBatch(topic, items)`
136
+
137
+ ```ts
138
+ publishBatch(
139
+ topic: string,
140
+ items: Array<{ data: unknown; messageId?: string; timestamp?: number }>
141
+ ): void
142
+ ```
143
+
144
+ 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.
145
+
146
+ 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.
147
+
135
148
  ### `clearReplay()`
136
149
 
137
150
  ```ts
@@ -172,6 +185,65 @@ getStatus(): WorkerStatus
172
185
 
173
186
  Returns the current status: `connecting`, `connected`, `disconnected`, or `error`.
174
187
 
188
+ ### `getHealthSummary()`
189
+
190
+ ```ts
191
+ getHealthSummary(): DataBusHealthSummary
192
+ ```
193
+
194
+ 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:
195
+
196
+ ```ts
197
+ interface DataBusHealthSummary {
198
+ healthy: boolean; // started, not suspended, transport ready
199
+ state: 'stopped' | 'starting' | 'healthy' | 'recovering' | 'suspended' | 'degraded';
200
+ status: WorkerStatus;
201
+ sdkVersion: string;
202
+ started: boolean;
203
+ suspended: boolean; // true while the tab is hidden (BFCache)
204
+ transport: { name; backend; ready; status };
205
+ recovery: { attempt; exhausted; maxAttempts; generation; lastSuccessAt; hasError; errorMessage; errorAt };
206
+ lastFailure: { source: 'transport' | 'persistence' | 'dispatch'; message: string; at: number } | null;
207
+ persistence: { failures: number; lastFailureAt: number | null; lastErrorMessage: string | null };
208
+ metrics: DataBusMetricsSnapshot | null; // live trace window, or null when trace metrics are inactive
209
+ trace: { asyncSink: boolean; pendingEvents: number }; // sink back-pressure visibility
210
+ }
211
+ ```
212
+
213
+ `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`. `lastFailure` is a unified ledger across all failure sources and resets on every explicit `start()`.
214
+
215
+ ### `getRecoveryStats()` / `getPersistenceStats()`
216
+
217
+ ```ts
218
+ getRecoveryStats(): { attempt; exhausted; maxAttempts; hasError; errorMessage; errorAt; generation; lastSuccessAt }
219
+ getPersistenceStats(): { failures; lastFailureAt; lastErrorMessage }
220
+ ```
221
+
222
+ `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.
223
+
224
+ ### `getDiagnostics()`
225
+
226
+ ```ts
227
+ getDiagnostics(): DataBusDiagnostics
228
+ ```
229
+
230
+ 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:
231
+
232
+ - `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).
233
+ - `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.
234
+
235
+ `sdkVersion` is injected from `package.json` at build time. Prefer `getHealthSummary()` when a consumer only needs the readiness verdict.
236
+
237
+ ### `getMetrics()`
238
+
239
+ ```ts
240
+ getMetrics(): DataBusMetricsSnapshot | null
241
+ ```
242
+
243
+ 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.
244
+
245
+ `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.
246
+
175
247
  ### `getClusterSnapshot()`
176
248
 
177
249
  Returns a diagnostic snapshot:
@@ -208,7 +280,9 @@ trace: {
208
280
  }
209
281
  ```
210
282
 
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.
283
+ 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.
284
+
285
+ **`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
286
 
213
287
  ### `stop()`
214
288
 
@@ -226,6 +300,9 @@ interface DataBusTransport<TConfig, TData> {
226
300
  subscribe(topic): void | Promise<void>;
227
301
  unsubscribe(topic): void | Promise<void>;
228
302
  publish(topic, data): void | Promise<void>;
303
+ /** Optional: one wire frame for many items. The DataBus falls back to
304
+ * per-item `publish` calls when this is absent. */
305
+ publishBatch?(topic, items): void | Promise<void>;
229
306
  stop(): void | Promise<void>;
230
307
  }
231
308
  ```
@@ -273,6 +350,18 @@ Available options:
273
350
  - `workerFactory`: custom Dedicated Worker loading method
274
351
  - `sharedWorkerFactory`: custom SharedWorker loading method
275
352
 
353
+ ## `createStorageEventChannel(options)`
354
+
355
+ ```ts
356
+ createStorageEventChannel(options: {
357
+ name: string;
358
+ storage: StorageLike | null;
359
+ win: StorageEventWindow | null;
360
+ }): ClusterChannel | null
361
+ ```
362
+
363
+ 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).
364
+
276
365
  ## WebSocket Transport Backend
277
366
 
278
367
  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 +425,10 @@ Attaches a message handler with automatic cleanup. The handler is read through a
336
425
 
337
426
  Mirrors `bus.onStatus()` into React state and reads the current value synchronously whenever the bus identity changes. Returns `'connecting' | 'connected' | 'disconnected' | 'error'`.
338
427
 
428
+ ### `useCrossTabHealth(bus, options?)`
429
+
430
+ 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.
431
+
339
432
  ## Vue Composables (`cross-tab-worker-databus/vue`)
340
433
 
341
434
  Vue 3.3+ is an optional peer dependency; this entry is separate from the core package.
@@ -348,6 +441,10 @@ useVueCrossTabSubscription(bus, 'chat.*', message => console.log(message.data));
348
441
 
349
442
  `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
443
 
444
+ ### `useVueCrossTabHealth(bus, options?)`
445
+
446
+ 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.
447
+
351
448
  ## `WorkerClusterRuntime`
352
449
 
353
450
  Advanced API responsible for Worker registration, heartbeat, visibility, routing, BroadcastChannel protocol, and migration. Business modules should not operate on it directly.
@@ -358,6 +455,7 @@ Main methods:
358
455
  - `setStatus(status)`
359
456
  - `subscribe(topic)` / `unsubscribe(topic)`
360
457
  - `publish(topic, data)`
458
+ - `publishBatch(topic, items)`
361
459
  - `broadcastEvent(eventType, payload)`
362
460
  - `isAssigned(topic)`
363
461
  - `isActiveWorker()`
@@ -374,6 +472,12 @@ Generates a stable 128-bit hexadecimal opaque key. Used to avoid writing raw con
374
472
 
375
473
  Creates a default browser environment adapter, including storage, BroadcastChannel, timers, and page lifecycle events.
376
474
 
475
+ ### `getOrCreateTabId(environment, key?)`
476
+
477
+ 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.
478
+
479
+ 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.
480
+
377
481
  ### `selectWorkerBackend(mode, availability?)`
378
482
 
379
483
  Selects the actual backend based on `WorkerMode` and capability detection, returning `'shared' | 'dedicated' | 'local'`:
@@ -383,6 +487,22 @@ Selects the actual backend based on `WorkerMode` and capability detection, retur
383
487
 
384
488
  `availability` can explicitly pass `worker` / `sharedWorker` capability flags, for use in SSR, testing, or embedded environments, avoiding access to non-existent global objects.
385
489
 
490
+ ### `effectiveWorkerLoad(worker, options?)`
491
+
492
+ 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.
493
+
494
+ 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.
495
+
496
+ ### `approximatePayloadBytes(payload)`
497
+
498
+ 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.
499
+
500
+ 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.
501
+
502
+ ### `DEFAULT_MAX_ACTIVE_WORKERS`
503
+
504
+ 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.
505
+
386
506
  ### Routing Functions
387
507
 
388
508
  - `selectActiveWorkers`
@@ -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.
@@ -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-11, from the 12 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 | 43.8388 | 40.8227 | -3.02 | 40.8227 |
13
+ | publish per-message (ms, lower is better) — shared | 34.2105 | 35.4339 | +1.22 | 33.8206 |
14
+ | wildcard dispatch ×1000 (ms, lower is better) | 6.5 | 6.7 | +0.20 | 0.1 |
15
+ | publishBatch ×1000 (ms, lower is better) | 4.2 | 4 | -0.20 | 0.4 |
16
+ | dedup ×1000 (ms, lower is better) | 15.5 | 13.9 | -1.60 | 0 |
17
+ | trace + publish ×1000 (ms, lower is better) | 5.8 | 5.3 | -0.50 | 4.8 |
18
+ | first-packet cold dispatch (ms, lower is better) | 0 | 0 | +0.00 | 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.
@@ -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 | Planned | Current Worker config must be structured-cloneable and cannot pass functions |
31
- | Load Policy | Adaptive weighting by message rate, byte count, or CPU | Planned | Load is currently computed only from the number of owner Topics |
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.
@@ -56,6 +56,27 @@ When `replay.retentionMs` is enabled, automatic durable cleanup is coalesced dur
56
56
 
57
57
  `replay.persistenceRetry` optionally controls transient persistence recovery. `maxAttempts` is the total number of attempts (default `1`), and `backoffMs` is the initial delay before retry (default `50`). Delays grow exponentially and are capped; final failures retain the existing `onError` and reliability behavior.
58
58
 
59
+ ### Replay Options
60
+
61
+ | Config | Type | Default | Description |
62
+ |---|---|---|---|
63
+ | `maxPerTopic` | `number` | `100` | Maximum buffered publications per topic; oldest are evicted first (positive safe integer) |
64
+ | `persistence` | `DataBusReplayPersistence` | — | Optional durable backend (`createIndexedDbReplayPersistence`); omitted keeps history in memory only |
65
+ | `retentionMs` | `number` | — | Producer-timestamp retention window; history older than the cutoff is pruned through the adapter's `clearBefore` |
66
+ | `pruneStrategy` | `'count' \| 'age' \| 'both'` | `'count'` | `count` caps each topic at `maxPerTopic`; `age` prunes by `retentionMs`; `both` applies both. `age` without `retentionMs` has nothing to prune by and falls back to the count cap |
67
+ | `retentionSweepMs` | `number` | — | Periodic durable-retention sweep for quiet topics; requires `retentionMs` and a `clearBefore` adapter |
68
+ | `persistenceRetry` | `{ maxAttempts, backoffMs }` | `1` / `50` | Bounded retry for transient persistence failures; delays grow exponentially and are capped |
69
+
70
+ ### Deduplication Options
71
+
72
+ | Config | Type | Default | Description |
73
+ |---|---|---|---|
74
+ | `maxEntries` | `number` | `1000` | Remembered message IDs before the oldest (FIFO) entry is evicted (positive safe integer) |
75
+ | `ttlMs` | `number` | `60000` | How long a remembered ID suppresses a repeat (positive finite) |
76
+ | `sweepMs` | `number` | — | Optional periodic sweep that expires quiet IDs; disabled by default |
77
+ | `now` | `() => number` | `Date.now` | Injectable clock for deterministic TTL tests and non-wall-clock hosts |
78
+ | `adaptiveTtl` | `{ minMs, maxMs }` | — | Optional bounded adaptive TTL: a high recent message rate shortens the window toward `minMs`, a quiet period relaxes it to `maxMs` (both required, `minMs <= maxMs`) |
79
+
59
80
  When tracing is enabled, each retry before the final attempt emits a bounded `reliability` event with `operation: persistence_retry`, `persistenceOperation` (`load`, `append`, `clear`, `clearTopic`, or `clearBefore`), and the failed attempt number. No payload, URL, credential, or error body is included.
60
81
 
61
82
  WebSocket binary frames may arrive as either `ArrayBuffer` or browser `Blob`; both use the same compact binary publication format. Blob conversion is asynchronous and conversion failures are reported through the transport error handler.
@@ -100,6 +121,36 @@ The active set is used only when a Topic needs a new owner. An existing live own
100
121
  - `2-3`: Balances resource reuse and fault recovery.
101
122
  - Larger values: Suitable for scenarios with many Topics and where a single connection faces server-side limits.
102
123
 
124
+ ### Adaptive Owner Weighting
125
+
126
+ By default a new Topic is assigned to the owner with the fewest owned Topics. The
127
+ opt-in `loadWeighting` option adds traffic and scheduling signals so new routes
128
+ favor quieter, healthier Workers — existing routes stay sticky and never migrate.
129
+
130
+ ```ts
131
+ const bus = createCentrifugeDataBus({
132
+ connection: { url: getConnectionUrl() },
133
+ loadWeighting: {
134
+ messageRateWeight: 0.5, // weight per message-per-second
135
+ byteRateWeight: 0.001, // weight per byte-per-second
136
+ scheduleLagWeight: 2 // weight for heartbeat scheduling-lag ratio
137
+ }
138
+ });
139
+ ```
140
+
141
+ | Option | Default | Effect |
142
+ |---|---|---|
143
+ | `messageRateWeight` | `0` | Each Worker samples its own fan-out message rate per heartbeat window; the normalized rate is added to the effective load. |
144
+ | `byteRateWeight` | `0` | Same, for approximate payload bytes per second. |
145
+ | `scheduleLagWeight` | `0` | Weights the scheduling-lag ratio (`overrunMs ÷ windowMs`). A starved event loop lands heartbeats late, so this steers new routes away from a throttled Worker. |
146
+
147
+ All weights default to `0`, keeping the legacy pure topic-count behavior byte-identical.
148
+ Each weight must be a non-negative finite number; a negative or non-finite weight throws a
149
+ `TypeError` at construction, since a negative weight would bias new routes toward the
150
+ *busiest* Worker instead.
151
+ The per-Worker sample (`WorkerThroughputSample`) is published with the heartbeat and
152
+ carries `windowMs`, `messageCount`, `byteCount`, `overrunMs`, and `sampledAt`.
153
+
103
154
  ## Centrifuge Configuration
104
155
 
105
156
  Main configuration for `createCentrifugeDataBus<TData>(options)`:
@@ -114,6 +165,7 @@ Main configuration for `createCentrifugeDataBus<TData>(options)`:
114
165
  | `heartbeatIntervalMs` | `number` | `10000` | SharedWorker PING heartbeat interval (see SharedWorker Session Reaper below); `Infinity` disables heartbeats entirely. Distinct from the Core cluster heartbeat (default 3000 ms) which tracks worker liveness via localStorage |
115
166
  | `workerFactory` | `() => Worker` | Built-in Worker | For testing or custom Worker loading |
116
167
  | `sharedWorkerFactory` | `() => SharedWorker` | Built-in SharedWorker | For testing or custom SharedWorker loading |
168
+ | `credentialProvider` | `{ getToken?, getChannelToken? }` | `undefined` | Async credential refresh bridge: the Worker asks the main thread for each fresh token (`getToken` / `getChannelToken`) and this provider supplies it from application context. Required because function-valued Centrifuge options cannot be structured-cloned into the Worker |
117
169
  | Other Core config | Corresponding type | Core defaults | `storagePrefix`, heartbeat, TTL, etc. |
118
170
 
119
171
  ```ts
@@ -146,6 +198,12 @@ The full chain for `auto` is **SharedWorker -> Dedicated Worker -> main-thread l
146
198
 
147
199
  When neither `sharedWorkerFactory` nor `workerFactory` is provided, the transport detects global `SharedWorker` / `Worker` capability at startup. When a custom factory is provided, the corresponding backend is considered available, avoiding false negatives from global capability detection in Node, SSR, or embedded environments. All modes perform the same structured clone validation; config and `publish` data must be structured-clonable.
148
200
 
201
+ ## Coordination Channel Fallback (BroadcastChannel unavailable)
202
+
203
+ When `BroadcastChannel` is unavailable (some WebViews, older browsers), the cluster normally degrades to local-only operation: the transport works, but tabs do not coordinate ownership.
204
+
205
+ `createBrowserEnvironment({ channelFallback: 'storage-event' })` opts into a fallback `ClusterChannel` backed by localStorage `storage` events, preserving cross-tab coordination. It is opt-in because of a security trade-off: BroadcastChannel messages live in memory only, while the fallback writes coordination payloads (which carry plaintext topic names) to localStorage under the `cross-tab-worker-databus:channel:` key namespace — at least transiently, and indefinitely after a tab crash. The key is removed when the channel closes.
206
+
149
207
  ## SharedWorker Session Reaper
150
208
 
151
209
  A `MessagePort` has no `close` event, so the SharedWorker cannot know when a tab has crashed or been closed without sending a `STOP` message. To avoid leaking a `CentrifugeSession` (and its WebSocket) for a dead tab, the transport sends a periodic **PING heartbeat** to the SharedWorker, and the SharedWorker runs a **reaper** that closes any session whose port has been silent for longer than its timeout.