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.
Files changed (97) hide show
  1. package/CHANGELOG.md +216 -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-SDOV3UHG.js} +1304 -515
  17. package/dist/chunk-SDOV3UHG.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 +1608 -650
  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 +1692 -747
  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 +134 -77
  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 +129 -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/replay-pruning.d.ts +21 -0
  41. package/dist/core/replay-pruning.d.ts.map +1 -0
  42. package/dist/core/routing.d.ts +22 -3
  43. package/dist/core/routing.d.ts.map +1 -1
  44. package/dist/core/storage-batch.d.ts +0 -4
  45. package/dist/core/storage-batch.d.ts.map +1 -1
  46. package/dist/core/trace.d.ts +55 -15
  47. package/dist/core/trace.d.ts.map +1 -1
  48. package/dist/core/types.d.ts +58 -9
  49. package/dist/core/types.d.ts.map +1 -1
  50. package/dist/core/version.d.ts +2 -0
  51. package/dist/core/version.d.ts.map +1 -0
  52. package/dist/hooks.d.ts +12 -1
  53. package/dist/hooks.d.ts.map +1 -1
  54. package/dist/hooks.js +28 -2
  55. package/dist/hooks.js.map +2 -2
  56. package/dist/index.d.ts +6 -6
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +263 -166
  59. package/dist/index.js.map +3 -3
  60. package/dist/utils/constants.d.ts +170 -0
  61. package/dist/utils/constants.d.ts.map +1 -0
  62. package/dist/utils/error-utils.d.ts +28 -0
  63. package/dist/utils/error-utils.d.ts.map +1 -0
  64. package/dist/utils/metadata.d.ts +16 -0
  65. package/dist/utils/metadata.d.ts.map +1 -0
  66. package/dist/utils/storage-utils.d.ts +24 -0
  67. package/dist/utils/storage-utils.d.ts.map +1 -0
  68. package/dist/utils/validation.d.ts +76 -0
  69. package/dist/utils/validation.d.ts.map +1 -0
  70. package/dist/vue.d.ts +13 -1
  71. package/dist/vue.d.ts.map +1 -1
  72. package/dist/vue.js +36 -2
  73. package/dist/vue.js.map +2 -2
  74. package/dist/websocket.d.ts +6 -1
  75. package/dist/websocket.d.ts.map +1 -1
  76. package/dist/worker-mode.d.ts +6 -2
  77. package/dist/worker-mode.d.ts.map +1 -1
  78. package/docs/README.md +1 -0
  79. package/docs/api.md +135 -4
  80. package/docs/architecture.md +92 -1
  81. package/docs/benchmarks.md +24 -0
  82. package/docs/capabilities.md +24 -3
  83. package/docs/configuration.md +58 -0
  84. package/docs/getting-started.md +35 -0
  85. package/docs/release-checklist.md +28 -5
  86. package/docs/roadmap.md +81 -5
  87. package/docs/zh/README.md +2 -1
  88. package/docs/zh/api.md +134 -4
  89. package/docs/zh/architecture.md +61 -1
  90. package/docs/zh/benchmarks.md +24 -0
  91. package/docs/zh/capabilities.md +21 -3
  92. package/docs/zh/configuration.md +53 -0
  93. package/docs/zh/getting-started.md +35 -0
  94. package/docs/zh/release-checklist.md +29 -6
  95. package/docs/zh/roadmap.md +93 -5
  96. package/package.json +39 -16
  97. package/dist/chunk-D2SIT473.js.map +0 -7
@@ -24,10 +24,13 @@ __export(src_exports, {
24
24
  DEFAULT_MAX_ACTIVE_WORKERS: () => DEFAULT_MAX_ACTIVE_WORKERS,
25
25
  WebSocketTransport: () => WebSocketTransport,
26
26
  WorkerClusterRuntime: () => WorkerClusterRuntime,
27
+ approximatePayloadBytes: () => approximatePayloadBytes,
27
28
  createBrowserEnvironment: () => createBrowserEnvironment,
28
29
  createIndexedDbReplayPersistence: () => createIndexedDbReplayPersistence,
29
30
  createOpaqueKey: () => createOpaqueKey,
31
+ createStorageEventChannel: () => createStorageEventChannel,
30
32
  createWebSocketDataBus: () => createWebSocketDataBus,
33
+ effectiveWorkerLoad: () => effectiveWorkerLoad,
31
34
  getOrCreateTabId: () => getOrCreateTabId,
32
35
  hasActiveOwner: () => hasActiveOwner,
33
36
  isWildcardTopic: () => isWildcardTopic,
@@ -39,6 +42,133 @@ __export(src_exports, {
39
42
  });
40
43
  module.exports = __toCommonJS(src_exports);
41
44
 
45
+ // src/utils/constants.ts
46
+ var WORKER_STATUS = {
47
+ CONNECTING: "connecting",
48
+ CONNECTED: "connected",
49
+ DISCONNECTED: "disconnected",
50
+ ERROR: "error"
51
+ };
52
+ var WORKER_ROLE = {
53
+ ACTIVE: "active",
54
+ STANDBY: "standby"
55
+ };
56
+ var TAB_VISIBILITY = {
57
+ VISIBLE: "visible",
58
+ HIDDEN: "hidden"
59
+ };
60
+ var WORKER_MODE = {
61
+ DEDICATED: "dedicated",
62
+ SHARED: "shared",
63
+ AUTO: "auto"
64
+ };
65
+ var WORKER_BACKEND = {
66
+ DEDICATED: "dedicated",
67
+ SHARED: "shared",
68
+ LOCAL: "local"
69
+ };
70
+ var CHANNEL_FALLBACK = {
71
+ NONE: "none",
72
+ STORAGE_EVENT: "storage-event"
73
+ };
74
+ var CONTROL_ACTION = {
75
+ SUBSCRIBE: "SUBSCRIBE",
76
+ UNSUBSCRIBE: "UNSUBSCRIBE",
77
+ PUBLISH: "PUBLISH"
78
+ };
79
+ var CLUSTER_MESSAGE_TYPE = {
80
+ CONTROL: "CONTROL",
81
+ EVENT: "EVENT",
82
+ REGISTRY: "REGISTRY",
83
+ ROUTE_RELEASED: "ROUTE_RELEASED"
84
+ };
85
+ var DEFAULT_STORAGE_PREFIX = "cross-tab-worker-databus";
86
+ var STORAGE_CHANNEL_PREFIX = `${DEFAULT_STORAGE_PREFIX}:channel:`;
87
+ var TAB_ID_STORAGE_KEY = `${DEFAULT_STORAGE_PREFIX}:tab-id`;
88
+ var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
89
+ var TRACE_EVENT_TYPE = {
90
+ LIFECYCLE: "lifecycle",
91
+ STATUS: "status",
92
+ SUBSCRIPTION: "subscription",
93
+ COORDINATION: "coordination",
94
+ ERROR: "error",
95
+ RELIABILITY: "reliability",
96
+ MESSAGE_METRICS: "message_metrics"
97
+ };
98
+ var TRACE_LIFECYCLE_ACTION = {
99
+ START: "start",
100
+ STOP: "stop",
101
+ SUSPEND: "suspend",
102
+ RESUME: "resume"
103
+ };
104
+ var INVOKE_LABEL = {
105
+ DISPATCH: "dispatch",
106
+ STATUS: "status",
107
+ ERROR_HANDLER: "error handler"
108
+ };
109
+ var TRACE_MODE = {
110
+ EVENTS: "events",
111
+ METRICS: "metrics",
112
+ ALL: "all"
113
+ };
114
+ var TRACE_ERROR_SOURCE = {
115
+ TRANSPORT: "transport",
116
+ OPERATION: "operation"
117
+ };
118
+ var RELIABILITY_OPERATION = {
119
+ TRANSPORT_RECOVERY: "transport_recovery",
120
+ ROUTE_ACK: "route_ack",
121
+ ROUTE_MIGRATION: "route_migration",
122
+ // A re-election that recovered a stranded unconfirmed handoff (previous
123
+ // owner gone, ACK never arrived). Distinct from ROUTE_MIGRATION so trace
124
+ // consumers can tell recoveries apart from routine graceful handoffs.
125
+ ROUTE_MIGRATION_RECOVERY: "route_migration_recovery",
126
+ PERSISTENCE_CLEANUP: "persistence_cleanup",
127
+ PERSISTENCE_RETRY: "persistence_retry",
128
+ DEDUP_SUPPRESSED: "dedup_suppressed"
129
+ };
130
+ var RECOVERY_OUTCOME = {
131
+ SCHEDULED: "scheduled",
132
+ SUCCEEDED: "succeeded",
133
+ FAILED: "failed",
134
+ EXHAUSTED: "exhausted"
135
+ };
136
+ var SUBSCRIPTION_ACTION = {
137
+ SUBSCRIBE: "subscribe",
138
+ UNSUBSCRIBE: "unsubscribe"
139
+ };
140
+ var PRUNE_STRATEGY = {
141
+ COUNT: "count",
142
+ AGE: "age",
143
+ BOTH: "both"
144
+ };
145
+ var PERSISTENCE_OPERATION = {
146
+ LOAD: "load",
147
+ APPEND: "append",
148
+ CLEAR: "clear",
149
+ CLEAR_TOPIC: "clearTopic",
150
+ CLEAR_BEFORE: "clearBefore"
151
+ };
152
+ var FAILURE_SOURCE = {
153
+ TRANSPORT: "transport",
154
+ PERSISTENCE: "persistence",
155
+ DISPATCH: "dispatch"
156
+ };
157
+ var HEALTH_STATE = {
158
+ STOPPED: "stopped",
159
+ STARTING: "starting",
160
+ HEALTHY: "healthy",
161
+ RECOVERING: "recovering",
162
+ SUSPENDED: "suspended",
163
+ DEGRADED: "degraded"
164
+ };
165
+ var WS_OP = {
166
+ SUBSCRIBE: "subscribe",
167
+ UNSUBSCRIBE: "unsubscribe",
168
+ PUBLISH: "publish",
169
+ PUBLISH_BATCH: "publishBatch"
170
+ };
171
+
42
172
  // src/core/environment.ts
43
173
  function getStorage(name) {
44
174
  try {
@@ -54,8 +184,53 @@ function randomId() {
54
184
  return Math.random().toString(36).slice(2);
55
185
  }
56
186
  }
187
+ function createStorageEventChannel(options) {
188
+ const { name, storage, win } = options;
189
+ if (!storage || !win) return null;
190
+ const key = `${STORAGE_CHANNEL_PREFIX}${name}`;
191
+ const listeners = /* @__PURE__ */ new Set();
192
+ let sequence = 0;
193
+ const onStorage = (event) => {
194
+ if (event.key !== key || event.newValue === null) return;
195
+ let message;
196
+ try {
197
+ const parsed = JSON.parse(event.newValue);
198
+ if (!parsed || typeof parsed !== "object" || typeof parsed.seq !== "number" || !parsed.message) return;
199
+ message = parsed.message;
200
+ } catch {
201
+ return;
202
+ }
203
+ const event_ = { data: message };
204
+ for (const listener of [...listeners]) listener(event_);
205
+ };
206
+ win.addEventListener("storage", onStorage);
207
+ let closed = false;
208
+ return {
209
+ addEventListener(_type, listener) {
210
+ listeners.add(listener);
211
+ },
212
+ removeEventListener(_type, listener) {
213
+ listeners.delete(listener);
214
+ },
215
+ postMessage(message) {
216
+ if (closed) return;
217
+ sequence += 1;
218
+ storage.setItem(key, JSON.stringify({ seq: sequence, message }));
219
+ },
220
+ close() {
221
+ closed = true;
222
+ win.removeEventListener("storage", onStorage);
223
+ listeners.clear();
224
+ try {
225
+ storage.removeItem(key);
226
+ } catch {
227
+ }
228
+ }
229
+ };
230
+ }
57
231
  var tabIdentityInitialized = false;
58
- function createBrowserEnvironment() {
232
+ function createBrowserEnvironment(options) {
233
+ const channelFallback = options?.channelFallback ?? CHANNEL_FALLBACK.NONE;
59
234
  return {
60
235
  storage: getStorage("localStorage"),
61
236
  sessionStorage: getStorage("sessionStorage"),
@@ -63,14 +238,18 @@ function createBrowserEnvironment() {
63
238
  randomId,
64
239
  createChannel: (name) => {
65
240
  try {
66
- return typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(name);
241
+ if (typeof BroadcastChannel !== "undefined") return new BroadcastChannel(name);
67
242
  } catch {
68
- return null;
69
243
  }
244
+ return channelFallback === CHANNEL_FALLBACK.STORAGE_EVENT ? createStorageEventChannel({
245
+ name,
246
+ storage: getStorage("localStorage"),
247
+ win: typeof window !== "undefined" && typeof window.addEventListener === "function" ? window : null
248
+ }) : null;
70
249
  },
71
250
  setInterval: (callback, intervalMs) => globalThis.setInterval(callback, intervalMs),
72
251
  clearInterval: (handle) => globalThis.clearInterval(handle),
73
- getVisibilityState: () => typeof document !== "undefined" && document.visibilityState === "hidden" ? "hidden" : "visible",
252
+ getVisibilityState: () => typeof document !== "undefined" && document.visibilityState === TAB_VISIBILITY.HIDDEN ? TAB_VISIBILITY.HIDDEN : TAB_VISIBILITY.VISIBLE,
74
253
  addVisibilityChangeListener: (listener) => {
75
254
  if (typeof document !== "undefined") document.addEventListener("visibilitychange", listener);
76
255
  },
@@ -101,7 +280,7 @@ function canUseStorage(storage, probeKey) {
101
280
  return false;
102
281
  }
103
282
  }
104
- function getOrCreateTabId(environment, key = "cross-tab-worker-databus:tab-id") {
283
+ function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY) {
105
284
  const storage = environment.sessionStorage;
106
285
  try {
107
286
  const existing = storage?.getItem(key);
@@ -154,21 +333,73 @@ function avalancheMix(self, neighbor) {
154
333
 
155
334
  // src/core/routing.ts
156
335
  var DEFAULT_MAX_ACTIVE_WORKERS = 3;
157
- function selectLeastLoadedWorker(workers, preferredWorkerId) {
336
+ function effectiveWorkerLoad(worker, options) {
337
+ const baseLoad = Number.isFinite(worker.load) ? worker.load : 0;
338
+ const sample = worker.throughput;
339
+ const messageRateWeight = options?.messageRateWeight ?? 0;
340
+ const byteRateWeight = options?.byteRateWeight ?? 0;
341
+ const scheduleLagWeight = options?.scheduleLagWeight ?? 0;
342
+ if (!sample || !Number.isFinite(sample.windowMs) || sample.windowMs <= 0 || messageRateWeight === 0 && byteRateWeight === 0 && scheduleLagWeight === 0) {
343
+ return baseLoad;
344
+ }
345
+ const windowSeconds = sample.windowMs / 1e3;
346
+ const messageRate = sample.messageCount / windowSeconds;
347
+ const byteRate = sample.byteCount / windowSeconds;
348
+ const scheduleLagRatio = sample.overrunMs / sample.windowMs;
349
+ const weighted = baseLoad + messageRateWeight * messageRate + byteRateWeight * byteRate + scheduleLagWeight * scheduleLagRatio;
350
+ return Number.isFinite(weighted) ? weighted : baseLoad;
351
+ }
352
+ function approximatePayloadBytes(payload) {
353
+ return estimatePayloadBytes(payload, 0);
354
+ }
355
+ var MAX_PAYLOAD_DEPTH = 6;
356
+ function estimatePayloadBytes(payload, depth) {
357
+ if (payload === null || payload === void 0) return 0;
358
+ switch (typeof payload) {
359
+ case "boolean":
360
+ return 4;
361
+ case "number":
362
+ case "bigint":
363
+ return 8;
364
+ case "string":
365
+ return payload.length;
366
+ case "symbol":
367
+ case "function":
368
+ return 0;
369
+ case "object":
370
+ break;
371
+ }
372
+ if (depth >= MAX_PAYLOAD_DEPTH) return 0;
373
+ if (payload instanceof ArrayBuffer) return payload.byteLength;
374
+ if (ArrayBuffer.isView(payload)) return payload.byteLength;
375
+ if (Array.isArray(payload)) {
376
+ let sum2 = 8;
377
+ for (const item of payload) sum2 += estimatePayloadBytes(item, depth + 1);
378
+ return sum2;
379
+ }
380
+ let sum = 0;
381
+ for (const value of Object.values(payload)) {
382
+ sum += estimatePayloadBytes(value, depth + 1);
383
+ }
384
+ return sum;
385
+ }
386
+ function selectLeastLoadedWorker(workers, preferredWorkerId, options) {
158
387
  const preferred = workers.find((worker) => worker.workerId === preferredWorkerId);
159
388
  if (preferred) return preferred;
160
389
  return workers.reduce((least, worker) => {
161
390
  if (!least) return worker;
162
- const byLoad = worker.load - least.load;
391
+ const byLoad = effectiveWorkerLoad(worker, options) - effectiveWorkerLoad(least, options);
163
392
  if (byLoad !== 0) return byLoad < 0 ? worker : least;
164
393
  if (worker.workerId < least.workerId) return worker;
165
394
  return least;
166
395
  }, void 0);
167
396
  }
168
397
  function selectActiveWorkers(workers, maxActiveWorkers = DEFAULT_MAX_ACTIVE_WORKERS) {
169
- const healthyWorkers = workers.filter((worker) => worker.status === "connecting" || worker.status === "connected");
398
+ const healthyWorkers = workers.filter(
399
+ (worker) => worker.status === WORKER_STATUS.CONNECTING || worker.status === WORKER_STATUS.CONNECTED
400
+ );
170
401
  const availableWorkers = healthyWorkers.length > 0 ? healthyWorkers : [...workers];
171
- const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === "visible");
402
+ const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === TAB_VISIBILITY.VISIBLE);
172
403
  const candidates = visibleWorkers.length > 0 ? visibleWorkers : availableWorkers;
173
404
  return candidates.sort(
174
405
  (left, right) => left.registeredAt - right.registeredAt || (left.workerId < right.workerId ? -1 : left.workerId > right.workerId ? 1 : 0)
@@ -204,6 +435,7 @@ var BatchingStorageWriter = class {
204
435
  constructor(storage) {
205
436
  this.storage = storage;
206
437
  }
438
+ storage;
207
439
  /** Coalesced write set. A `null` value represents a pending delete. */
208
440
  pending = /* @__PURE__ */ new Map();
209
441
  /** Per-key retry counter, reset on a successful write. */
@@ -260,7 +492,7 @@ var BatchingStorageWriter = class {
260
492
  this.pending.delete(key);
261
493
  this.retryCount.delete(key);
262
494
  if (typeof console !== "undefined" && typeof console.warn === "function") {
263
- console.warn("[cross-tab-worker-databus] storage write gave up after retries, dropping key:", key);
495
+ console.warn(`[${DEFAULT_STORAGE_PREFIX}] storage write gave up after retries, dropping key:`, key);
264
496
  }
265
497
  continue;
266
498
  }
@@ -320,12 +552,16 @@ var BatchingStorageWriter = class {
320
552
  }
321
553
  };
322
554
 
323
- // src/core/cluster.ts
324
- var CLUSTER_PROTOCOL_VERSION = 1;
325
- var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
326
- var DEFAULT_WORKER_TTL_MS = 1e4;
327
- var DEFAULT_STORAGE_PREFIX = "cross-tab-worker-databus";
328
- var MAX_KNOWN_TOPICS = 500;
555
+ // src/utils/metadata.ts
556
+ function publicationMetadata(messageId, timestamp) {
557
+ if (messageId === void 0 && timestamp === void 0) return void 0;
558
+ return {
559
+ ...messageId === void 0 ? {} : { messageId },
560
+ ...timestamp === void 0 ? {} : { timestamp }
561
+ };
562
+ }
563
+
564
+ // src/utils/storage-utils.ts
329
565
  function readJson(storage, key) {
330
566
  try {
331
567
  const value = storage.getItem(key);
@@ -340,13 +576,6 @@ function writeJson(storage, key, value) {
340
576
  } catch {
341
577
  }
342
578
  }
343
- function publicationMetadata(messageId, timestamp) {
344
- if (messageId === void 0 && timestamp === void 0) return void 0;
345
- return {
346
- ...messageId === void 0 ? {} : { messageId },
347
- ...timestamp === void 0 ? {} : { timestamp }
348
- };
349
- }
350
579
  function listKeys(storage, prefix) {
351
580
  try {
352
581
  return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter(
@@ -359,6 +588,102 @@ function listKeys(storage, prefix) {
359
588
  function readAllByPrefix(storage, prefix) {
360
589
  return listKeys(storage, prefix).map((key) => ({ key, value: readJson(storage, key) })).filter((entry) => entry.value !== null);
361
590
  }
591
+
592
+ // src/utils/validation.ts
593
+ function assertPositiveSafeInteger(value, name) {
594
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
595
+ throw new TypeError(`${name} must be a positive safe integer, got ${String(value)}.`);
596
+ }
597
+ }
598
+ function assertPositiveFiniteNumber(value, name) {
599
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
600
+ throw new TypeError(`${name} must be a positive finite number.`);
601
+ }
602
+ }
603
+ function assertNonNegativeFiniteNumber(value, name) {
604
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
605
+ throw new TypeError(`${name} must be a non-negative finite number.`);
606
+ }
607
+ }
608
+ function assertPruneStrategy(value) {
609
+ const allowed = [PRUNE_STRATEGY.COUNT, PRUNE_STRATEGY.AGE, PRUNE_STRATEGY.BOTH];
610
+ if (!allowed.includes(String(value))) {
611
+ throw new TypeError("pruneStrategy must be count, age, or both.");
612
+ }
613
+ }
614
+ function assertReplayOptions(replay) {
615
+ if (!replay) return;
616
+ if (replay.maxPerTopic !== void 0) assertPositiveSafeInteger(replay.maxPerTopic, "replay.maxPerTopic");
617
+ if (replay.pruneStrategy !== void 0) assertPruneStrategy(replay.pruneStrategy);
618
+ if (replay.retentionMs !== void 0) assertPositiveFiniteNumber(replay.retentionMs, "replay.retentionMs");
619
+ if (replay.retentionSweepMs !== void 0) {
620
+ assertPositiveFiniteNumber(replay.retentionSweepMs, "replay.retentionSweepMs");
621
+ }
622
+ if (replay.persistenceRetry) assertPersistenceRetryOptions(replay.persistenceRetry);
623
+ }
624
+ function assertPersistenceRetryOptions(retry) {
625
+ if (retry.maxAttempts !== void 0) {
626
+ assertPositiveSafeInteger(retry.maxAttempts, "replay.persistenceRetry.maxAttempts");
627
+ }
628
+ if (retry.backoffMs !== void 0) {
629
+ assertNonNegativeFiniteNumber(retry.backoffMs, "replay.persistenceRetry.backoffMs");
630
+ }
631
+ }
632
+ function assertDedupOptions(dedup) {
633
+ if (!dedup) return;
634
+ if (dedup.maxEntries !== void 0) assertPositiveSafeInteger(dedup.maxEntries, "dedup.maxEntries");
635
+ if (dedup.ttlMs !== void 0) assertPositiveFiniteNumber(dedup.ttlMs, "dedup.ttlMs");
636
+ if (dedup.sweepMs !== void 0) assertPositiveFiniteNumber(dedup.sweepMs, "dedup.sweepMs");
637
+ const bounds = dedup.adaptiveTtl;
638
+ if (bounds !== void 0) {
639
+ const finite = (value) => typeof value === "number" && Number.isFinite(value);
640
+ if (!finite(bounds.minMs) || !finite(bounds.maxMs) || bounds.minMs <= 0 || bounds.maxMs < bounds.minMs) {
641
+ throw new TypeError("dedup.adaptiveTtl bounds are invalid.");
642
+ }
643
+ }
644
+ }
645
+ function assertRecoveryOptions(recovery) {
646
+ if (!recovery) return;
647
+ if (recovery.cooldownMs !== void 0) {
648
+ assertPositiveFiniteNumber(recovery.cooldownMs, "recovery.cooldownMs");
649
+ }
650
+ const maxAttempts = recovery.maxAttempts;
651
+ if (maxAttempts !== void 0 && !(maxAttempts === Number.POSITIVE_INFINITY || typeof maxAttempts === "number" && Number.isSafeInteger(maxAttempts) && maxAttempts > 0)) {
652
+ throw new TypeError("recovery.maxAttempts must be a positive safe integer.");
653
+ }
654
+ }
655
+ function assertLoadWeightingOptions(loadWeighting) {
656
+ if (!loadWeighting) return;
657
+ const weights = {
658
+ messageRateWeight: loadWeighting.messageRateWeight,
659
+ byteRateWeight: loadWeighting.byteRateWeight,
660
+ scheduleLagWeight: loadWeighting.scheduleLagWeight
661
+ };
662
+ for (const [name, value] of Object.entries(weights)) {
663
+ if (value !== void 0) assertNonNegativeFiniteNumber(value, `loadWeighting.${name}`);
664
+ }
665
+ }
666
+ function assertClusterOptions(options) {
667
+ if (options.maxActiveWorkers !== void 0) {
668
+ assertPositiveSafeInteger(options.maxActiveWorkers, "maxActiveWorkers");
669
+ }
670
+ if (options.heartbeatIntervalMs !== void 0) {
671
+ assertPositiveFiniteNumber(options.heartbeatIntervalMs, "heartbeatIntervalMs");
672
+ }
673
+ if (options.workerTtlMs !== void 0) {
674
+ assertPositiveFiniteNumber(options.workerTtlMs, "workerTtlMs");
675
+ }
676
+ if (options.routeOwnerCacheMax !== void 0) {
677
+ assertPositiveSafeInteger(options.routeOwnerCacheMax, "routeOwnerCacheMax");
678
+ }
679
+ assertLoadWeightingOptions(options.loadWeighting);
680
+ }
681
+
682
+ // src/core/cluster.ts
683
+ var CLUSTER_PROTOCOL_VERSION = 1;
684
+ var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
685
+ var DEFAULT_WORKER_TTL_MS = 1e4;
686
+ var MAX_KNOWN_TOPICS = 500;
362
687
  var WorkerClusterRuntime = class {
363
688
  tabId;
364
689
  workerId;
@@ -372,6 +697,14 @@ var WorkerClusterRuntime = class {
372
697
  routePrefix;
373
698
  subscriberPrefix;
374
699
  channelName;
700
+ /** Adaptive load weighting options; undefined keeps legacy topic-count routing. */
701
+ loadWeighting;
702
+ /** Rolling traffic accumulator folded into the worker record on writeRecord. */
703
+ throughputWindow = {
704
+ startedAt: 0,
705
+ messageCount: 0,
706
+ byteCount: 0
707
+ };
375
708
  // Topics this tab has subscribed to (local interest, plaintext).
376
709
  subscribedTopics = /* @__PURE__ */ new Set();
377
710
  // Topics assigned to this Worker as owner (topicKey → topic). Authoritative:
@@ -407,12 +740,14 @@ var WorkerClusterRuntime = class {
407
740
  lifecycleListening = false;
408
741
  currentRecord;
409
742
  constructor(options) {
743
+ assertClusterOptions(options);
410
744
  this.environment = options.environment ?? createBrowserEnvironment();
411
745
  this.handlers = options.handlers;
412
746
  this.maxActiveWorkers = options.maxActiveWorkers ?? DEFAULT_MAX_ACTIVE_WORKERS;
413
747
  this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
414
748
  this.routeOwnerCacheMax = options.routeOwnerCacheMax ?? 256;
415
749
  this.workerTtlMs = options.workerTtlMs ?? DEFAULT_WORKER_TTL_MS;
750
+ this.loadWeighting = options.loadWeighting;
416
751
  const clusterHash = createOpaqueKey(options.clusterKey || "__default__");
417
752
  const prefix = options.storagePrefix ?? DEFAULT_STORAGE_PREFIX;
418
753
  const baseKey = `${prefix}:${clusterHash}`;
@@ -429,8 +764,8 @@ var WorkerClusterRuntime = class {
429
764
  workerId: this.workerId,
430
765
  tabId: this.tabId,
431
766
  load: 0,
432
- role: "standby",
433
- status: "connecting",
767
+ role: WORKER_ROLE.STANDBY,
768
+ status: WORKER_STATUS.CONNECTING,
434
769
  visibilityState: this.environment.getVisibilityState(),
435
770
  heartbeatAt: now,
436
771
  registeredAt: now
@@ -483,7 +818,7 @@ var WorkerClusterRuntime = class {
483
818
  this.writeRecord(true);
484
819
  for (const topic of this.subscribedTopics) {
485
820
  const topicKey = this.rememberTopic(topic);
486
- if (!this.storage) this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
821
+ if (!this.storage) this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
487
822
  else this.writeSubscriber(topicKey);
488
823
  }
489
824
  this.reconcile();
@@ -511,9 +846,14 @@ var WorkerClusterRuntime = class {
511
846
  this.removeStorage(this.workerStorageKey(this.workerId));
512
847
  this.flushStorage();
513
848
  this.notifyRegistry();
514
- this.channel?.close();
849
+ const channel = this.channel;
515
850
  this.channel = null;
516
851
  this.handlers.onSuspend?.();
852
+ if (typeof globalThis.setTimeout === "function") {
853
+ globalThis.setTimeout(() => channel?.close(), 0);
854
+ } else {
855
+ channel?.close();
856
+ }
517
857
  }
518
858
  /** Update the worker's connection status and persist the change. */
519
859
  setStatus(status) {
@@ -531,7 +871,7 @@ var WorkerClusterRuntime = class {
531
871
  this.subscribedTopics.add(topic);
532
872
  if (!this.started) return false;
533
873
  if (!this.storage) {
534
- this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
874
+ this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
535
875
  return true;
536
876
  }
537
877
  this.writeSubscriber(topicKey);
@@ -541,9 +881,9 @@ var WorkerClusterRuntime = class {
541
881
  return existingRoute?.workerId === this.workerId;
542
882
  }
543
883
  const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
544
- const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
884
+ const owner = selectLeastLoadedWorker(activeWorkers, void 0, this.loadWeighting) ?? this.currentRecord;
545
885
  this.writeRoute(topicKey, owner, void 0, (existingRoute?.generation ?? 0) + 1);
546
- this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
886
+ this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
547
887
  this.notifyRegistry();
548
888
  return owner.workerId === this.workerId;
549
889
  }
@@ -567,7 +907,7 @@ var WorkerClusterRuntime = class {
567
907
  const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
568
908
  if (subscribers.length === 0) {
569
909
  this.removeStorage(this.routeStorageKey(topicKey));
570
- if (notifyOwner) this.sendControl(route.workerId, "UNSUBSCRIBE", topic, topicKey);
910
+ if (notifyOwner) this.sendControl(route.workerId, CONTROL_ACTION.UNSUBSCRIBE, topic, topicKey);
571
911
  }
572
912
  return topicKey;
573
913
  }
@@ -586,15 +926,17 @@ var WorkerClusterRuntime = class {
586
926
  continue;
587
927
  }
588
928
  const owner = selectLeastLoadedWorker(
589
- activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load }))
929
+ activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load })),
930
+ void 0,
931
+ this.loadWeighting
590
932
  );
591
933
  if (!owner) continue;
592
934
  projectedLoads.set(owner.workerId, (projectedLoads.get(owner.workerId) ?? owner.load) + 1);
593
935
  const generation = (previous?.generation ?? 0) + 1;
594
936
  this.writeRoute(topicKey, owner, previous?.workerId, generation);
595
- this.handlers.onDiagnostic?.({ operation: "route_migration", topic });
937
+ this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_MIGRATION, topic });
596
938
  this.flushStorage();
597
- this.handlers.onControl("UNSUBSCRIBE", topic);
939
+ this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, topic);
598
940
  this.sendRouteReleased(owner.workerId, topic, topicKey, generation);
599
941
  }
600
942
  }
@@ -602,22 +944,19 @@ var WorkerClusterRuntime = class {
602
944
  const metadata = typeof metadataOrMessageId === "string" ? { messageId: metadataOrMessageId } : metadataOrMessageId;
603
945
  const topicKey = this.rememberTopic(topic);
604
946
  if (this.assignedTopics.has(topicKey)) {
605
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
947
+ return this.sendControl(this.workerId, CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
606
948
  }
607
949
  const cachedPattern = this.wildcardPublishCache.get(topic);
608
- if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
609
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
610
- }
611
950
  if (cachedPattern === void 0) {
612
951
  for (const pattern of this.assignedTopics.values()) {
613
952
  if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
614
953
  this.wildcardPublishCache.set(topic, pattern);
615
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
954
+ return this.sendControl(this.workerId, CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
616
955
  }
617
956
  }
618
957
  this.wildcardPublishCache.set(topic, null);
619
958
  }
620
- return this.sendControl(this.resolvePublishTarget(topic, topicKey), "PUBLISH", topic, topicKey, data, metadata);
959
+ return this.sendControl(this.resolvePublishTarget(topic, topicKey), CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
621
960
  }
622
961
  /**
623
962
  * Burst-friendly variant of `publish()`: packs up to N items into a single
@@ -638,19 +977,15 @@ var WorkerClusterRuntime = class {
638
977
  }
639
978
  const topicKey = this.rememberTopic(topic);
640
979
  if (this.assignedTopics.has(topicKey)) {
641
- for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
980
+ this.dispatchLocalPublishBatch(topic, topicKey, items);
642
981
  return true;
643
982
  }
644
983
  const cachedPattern = this.wildcardPublishCache.get(topic);
645
- if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
646
- for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
647
- return true;
648
- }
649
984
  if (cachedPattern === void 0) {
650
985
  for (const pattern of this.assignedTopics.values()) {
651
986
  if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
652
987
  this.wildcardPublishCache.set(topic, pattern);
653
- for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
988
+ this.dispatchLocalPublishBatch(topic, topicKey, items);
654
989
  return true;
655
990
  }
656
991
  }
@@ -658,14 +993,14 @@ var WorkerClusterRuntime = class {
658
993
  }
659
994
  const target = this.resolvePublishTarget(topic, topicKey);
660
995
  if (target === this.workerId) {
661
- for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
996
+ this.dispatchLocalPublishBatch(topic, topicKey, items);
662
997
  return true;
663
998
  }
664
999
  return this.send({
665
- type: "CONTROL",
1000
+ type: CLUSTER_MESSAGE_TYPE.CONTROL,
666
1001
  sourceWorkerId: this.workerId,
667
1002
  targetWorkerId: target,
668
- action: "PUBLISH",
1003
+ action: CONTROL_ACTION.PUBLISH,
669
1004
  topic,
670
1005
  topicKey,
671
1006
  items: items.map((item) => ({
@@ -696,8 +1031,17 @@ var WorkerClusterRuntime = class {
696
1031
  dispatchLocalPublish(topic, topicKey, data, messageId, timestamp) {
697
1032
  void topicKey;
698
1033
  const meta = publicationMetadata(messageId, timestamp);
699
- if (meta) this.handlers.onControl("PUBLISH", topic, data, meta.messageId, meta.timestamp);
700
- else this.handlers.onControl("PUBLISH", topic, data);
1034
+ if (meta) this.handlers.onControl(CONTROL_ACTION.PUBLISH, topic, data, meta.messageId, meta.timestamp);
1035
+ else this.handlers.onControl(CONTROL_ACTION.PUBLISH, topic, data);
1036
+ }
1037
+ /** Fan out a publication batch to the local onControl path: one
1038
+ * onPublishBatch call when the owner supports it, per-item otherwise. */
1039
+ dispatchLocalPublishBatch(topic, topicKey, items) {
1040
+ if (this.handlers.onPublishBatch) {
1041
+ this.handlers.onPublishBatch(topic, items);
1042
+ return;
1043
+ }
1044
+ for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
701
1045
  }
702
1046
  /** True when `route` exists and its owner worker is among `workers`.
703
1047
  * Shared by subscribe (skip re-assignment) and publish (route to owner).
@@ -711,7 +1055,39 @@ var WorkerClusterRuntime = class {
711
1055
  * listeners can attribute the event to its source tab even after fan-out. */
712
1056
  broadcastEvent(eventType, payload, originTabId) {
713
1057
  const effectiveOriginTabId = originTabId ?? this.tabId;
714
- this.send({ type: "EVENT", sourceWorkerId: this.workerId, eventType, payload, originTabId: effectiveOriginTabId });
1058
+ this.recordTraffic(payload);
1059
+ this.send({ type: CLUSTER_MESSAGE_TYPE.EVENT, sourceWorkerId: this.workerId, eventType, payload, originTabId: effectiveOriginTabId });
1060
+ }
1061
+ /** Count one fan-out unit toward the adaptive load sample. No-op unless
1062
+ * adaptive weighting is configured. */
1063
+ recordTraffic(payload) {
1064
+ if (this.loadWeighting === void 0) return;
1065
+ this.throughputWindow.messageCount += 1;
1066
+ this.throughputWindow.byteCount += approximatePayloadBytes(payload);
1067
+ }
1068
+ /** Convert the accumulated window into a publishable throughput sample and
1069
+ * reset the accumulator. Returns undefined until a full window has elapsed
1070
+ * so the first write does not emit a zero-width sample. */
1071
+ sampleThroughput(now) {
1072
+ if (this.throughputWindow.startedAt === 0) {
1073
+ this.throughputWindow.startedAt = now;
1074
+ return void 0;
1075
+ }
1076
+ const windowMs = now - this.throughputWindow.startedAt;
1077
+ if (windowMs <= 0) return void 0;
1078
+ const sample = {
1079
+ windowMs,
1080
+ messageCount: this.throughputWindow.messageCount,
1081
+ byteCount: this.throughputWindow.byteCount,
1082
+ // How much later the heartbeat landed than its nominal interval. This
1083
+ // window is anchored at the previous writeRecord (one heartbeat tick),
1084
+ // so a starved event loop stretches windowMs past the interval and the
1085
+ // positive excess is the scheduling-overrun signal.
1086
+ overrunMs: Math.max(0, windowMs - this.heartbeatIntervalMs),
1087
+ sampledAt: now
1088
+ };
1089
+ this.throughputWindow = { startedAt: now, messageCount: 0, byteCount: 0 };
1090
+ return sample;
715
1091
  }
716
1092
  isAssigned(topic) {
717
1093
  const topicKey = createOpaqueKey(topic);
@@ -801,14 +1177,14 @@ var WorkerClusterRuntime = class {
801
1177
  const message = event.data;
802
1178
  if (!message || message.sourceWorkerId === this.workerId) return;
803
1179
  switch (message.type) {
804
- case "CONTROL":
1180
+ case CLUSTER_MESSAGE_TYPE.CONTROL:
805
1181
  return this.handleControlMessage(message);
806
- case "ROUTE_RELEASED":
1182
+ case CLUSTER_MESSAGE_TYPE.ROUTE_RELEASED:
807
1183
  return this.handleRouteReleasedMessage(message);
808
- case "EVENT":
1184
+ case CLUSTER_MESSAGE_TYPE.EVENT:
809
1185
  this.handlers.onEvent(message.eventType, message.payload, message.sourceWorkerId, message.originTabId);
810
1186
  return;
811
- case "REGISTRY":
1187
+ case CLUSTER_MESSAGE_TYPE.REGISTRY:
812
1188
  this.reconcile();
813
1189
  return;
814
1190
  default: {
@@ -825,19 +1201,23 @@ var WorkerClusterRuntime = class {
825
1201
  if (message.targetWorkerId !== this.workerId) return;
826
1202
  this.rememberTopic(message.topic);
827
1203
  switch (message.action) {
828
- case "SUBSCRIBE":
1204
+ case CONTROL_ACTION.SUBSCRIBE:
829
1205
  this.assignedTopics.set(message.topicKey, message.topic);
830
1206
  this.confirmRoute(message.topicKey);
831
1207
  break;
832
- case "UNSUBSCRIBE":
1208
+ case CONTROL_ACTION.UNSUBSCRIBE:
833
1209
  if (this.releaseHandoffOnUnsubscribe(message)) return;
834
1210
  break;
835
- case "PUBLISH":
1211
+ case CONTROL_ACTION.PUBLISH:
836
1212
  if (message.items && message.items.length > 0) {
1213
+ if (this.handlers.onPublishBatch) {
1214
+ this.handlers.onPublishBatch(message.topic, message.items);
1215
+ return;
1216
+ }
837
1217
  for (const item of message.items) {
838
1218
  const itemMeta = publicationMetadata(item.messageId, item.timestamp);
839
- if (itemMeta) this.handlers.onControl("PUBLISH", message.topic, item.data, itemMeta.messageId, itemMeta.timestamp);
840
- else this.handlers.onControl("PUBLISH", message.topic, item.data);
1219
+ if (itemMeta) this.handlers.onControl(CONTROL_ACTION.PUBLISH, message.topic, item.data, itemMeta.messageId, itemMeta.timestamp);
1220
+ else this.handlers.onControl(CONTROL_ACTION.PUBLISH, message.topic, item.data);
841
1221
  }
842
1222
  return;
843
1223
  }
@@ -854,7 +1234,7 @@ var WorkerClusterRuntime = class {
854
1234
  metadata.timestamp
855
1235
  );
856
1236
  else this.handlers.onControl(message.action, message.topic, message.data);
857
- if (message.action !== "PUBLISH") this.updateLoad();
1237
+ if (message.action !== CONTROL_ACTION.PUBLISH) this.updateLoad();
858
1238
  }
859
1239
  /**
860
1240
  * When this worker is the previous owner in a graceful handoff and the new
@@ -866,7 +1246,7 @@ var WorkerClusterRuntime = class {
866
1246
  this.assignedTopics.delete(message.topicKey);
867
1247
  const route = this.readRoute(message.topicKey);
868
1248
  if (route?.handoffFromWorkerId !== this.workerId) return false;
869
- this.handlers.onControl("UNSUBSCRIBE", message.topic, void 0);
1249
+ this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, message.topic, void 0);
870
1250
  this.sendRouteReleased(route.workerId, message.topic, message.topicKey, route.generation);
871
1251
  this.updateLoad();
872
1252
  return true;
@@ -875,7 +1255,7 @@ var WorkerClusterRuntime = class {
875
1255
  * generation so only the matching new owner may act on it. */
876
1256
  sendRouteReleased(targetWorkerId, topic, topicKey, generation) {
877
1257
  this.send({
878
- type: "ROUTE_RELEASED",
1258
+ type: CLUSTER_MESSAGE_TYPE.ROUTE_RELEASED,
879
1259
  sourceWorkerId: this.workerId,
880
1260
  targetWorkerId,
881
1261
  topic,
@@ -894,14 +1274,24 @@ var WorkerClusterRuntime = class {
894
1274
  if (!route || this.isStaleRouteRelease(route, message)) return;
895
1275
  this.assignedTopics.set(message.topicKey, message.topic);
896
1276
  this.confirmRoute(message.topicKey);
897
- this.handlers.onControl("SUBSCRIBE", message.topic, void 0);
1277
+ this.handlers.onControl(CONTROL_ACTION.SUBSCRIBE, message.topic, void 0);
898
1278
  this.updateLoad();
899
1279
  }
900
1280
  /** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
901
1281
  * points to us, the release comes from the recorded previous owner, and
902
- * the release generation is at least as new as ours. */
1282
+ * the release generation is at least as new as ours. A replayed ACK from an
1283
+ * earlier handoff round (e.g. an a↔b ping-pong) carries an older generation
1284
+ * and must not confirm the current round. */
903
1285
  isStaleRouteRelease(route, message) {
904
- return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || route.generation < message.generation;
1286
+ return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation < route.generation;
1287
+ }
1288
+ /** True when an unconfirmed handoff route has been stuck longer than a
1289
+ * worker TTL. The ACK for a live handoff is posted synchronously with the
1290
+ * route write, so anything older than the TTL with a dead previous owner
1291
+ * will never complete — while a fresh unconfirmed route may simply be
1292
+ * waiting out its confirmation flush and must be left alone. */
1293
+ isStaleHandoff(route) {
1294
+ return this.environment.now() - route.updatedAt > this.workerTtlMs;
905
1295
  }
906
1296
  /** Full reconciliation cycle: workers, subscriptions, and assigned topics. */
907
1297
  reconcile() {
@@ -934,20 +1324,39 @@ var WorkerClusterRuntime = class {
934
1324
  */
935
1325
  reconcileSubscriptions(workers, activeWorkers) {
936
1326
  const liveWorkerIds = new Set(workers.map((worker) => worker.workerId));
1327
+ const recoveryProjectedLoads = /* @__PURE__ */ new Map();
937
1328
  for (const topic of this.subscribedTopics) {
938
1329
  const topicKey = this.rememberTopic(topic);
939
1330
  this.writeSubscriber(topicKey);
940
1331
  const route = this.readRoute(topicKey);
941
1332
  if (!route || !liveWorkerIds.has(route.workerId)) {
942
- const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
1333
+ const owner = selectLeastLoadedWorker(activeWorkers, void 0, this.loadWeighting) ?? this.currentRecord;
943
1334
  this.writeRoute(topicKey, owner, void 0, (route?.generation ?? 0) + 1);
944
- this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
1335
+ this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
945
1336
  this.notifyRegistry();
946
1337
  continue;
947
1338
  }
948
1339
  if (route.confirmedAt === void 0) {
949
1340
  if (!route.handoffFromWorkerId) {
950
- this.sendControl(route.workerId, "SUBSCRIBE", topic, topicKey);
1341
+ this.sendControl(route.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
1342
+ } else if (!liveWorkerIds.has(route.handoffFromWorkerId) && this.isStaleHandoff(route)) {
1343
+ const owner = selectLeastLoadedWorker(
1344
+ activeWorkers.map((worker) => ({ ...worker, load: recoveryProjectedLoads.get(worker.workerId) ?? worker.load })),
1345
+ void 0,
1346
+ this.loadWeighting
1347
+ ) ?? this.currentRecord;
1348
+ recoveryProjectedLoads.set(owner.workerId, (recoveryProjectedLoads.get(owner.workerId) ?? owner.load) + 1);
1349
+ if (owner.workerId !== this.workerId) {
1350
+ const subscriberTabIds = new Set(this.readSubscriberTabIds(topicKey, workers));
1351
+ const ownerSubscribed = workers.some(
1352
+ (worker) => worker.workerId === owner.workerId && subscriberTabIds.has(worker.tabId)
1353
+ );
1354
+ if (ownerSubscribed) continue;
1355
+ }
1356
+ this.writeRoute(topicKey, owner, void 0, route.generation + 1);
1357
+ this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
1358
+ this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_MIGRATION_RECOVERY, topic });
1359
+ this.notifyRegistry();
951
1360
  }
952
1361
  }
953
1362
  }
@@ -958,7 +1367,7 @@ var WorkerClusterRuntime = class {
958
1367
  const route = this.readRoute(topicKey);
959
1368
  if (route?.workerId === this.workerId) continue;
960
1369
  this.assignedTopics.delete(topicKey);
961
- this.handlers.onControl("UNSUBSCRIBE", topic, void 0);
1370
+ this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, topic, void 0);
962
1371
  if (route?.handoffFromWorkerId === this.workerId) {
963
1372
  this.sendRouteReleased(route.workerId, topic, topicKey, route.generation);
964
1373
  }
@@ -973,14 +1382,14 @@ var WorkerClusterRuntime = class {
973
1382
  sendControl(targetWorkerId, action, topic, topicKey, data, metadata) {
974
1383
  if (targetWorkerId === this.workerId) {
975
1384
  switch (action) {
976
- case "SUBSCRIBE":
1385
+ case CONTROL_ACTION.SUBSCRIBE:
977
1386
  this.assignedTopics.set(topicKey, topic);
978
1387
  this.confirmRoute(topicKey);
979
1388
  break;
980
- case "UNSUBSCRIBE":
1389
+ case CONTROL_ACTION.UNSUBSCRIBE:
981
1390
  this.assignedTopics.delete(topicKey);
982
1391
  break;
983
- case "PUBLISH":
1392
+ case CONTROL_ACTION.PUBLISH:
984
1393
  default:
985
1394
  break;
986
1395
  }
@@ -992,11 +1401,11 @@ var WorkerClusterRuntime = class {
992
1401
  metadata.timestamp
993
1402
  );
994
1403
  else this.handlers.onControl(action, topic, data);
995
- if (action !== "PUBLISH") this.updateLoad();
1404
+ if (action !== CONTROL_ACTION.PUBLISH) this.updateLoad();
996
1405
  return true;
997
1406
  }
998
1407
  return this.send({
999
- type: "CONTROL",
1408
+ type: CLUSTER_MESSAGE_TYPE.CONTROL,
1000
1409
  sourceWorkerId: this.workerId,
1001
1410
  targetWorkerId,
1002
1411
  action,
@@ -1101,7 +1510,7 @@ var WorkerClusterRuntime = class {
1101
1510
  confirmedAt: this.environment.now()
1102
1511
  });
1103
1512
  const topic = this.knownTopics.get(topicKey);
1104
- if (topic) this.handlers.onDiagnostic?.({ operation: "route_ack", topic });
1513
+ if (topic) this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_ACK, topic });
1105
1514
  }
1106
1515
  /** Remove routes whose topic has no subscribers and whose TTL has expired. */
1107
1516
  cleanupOrphanedRoutes(workers) {
@@ -1136,17 +1545,23 @@ var WorkerClusterRuntime = class {
1136
1545
  * avoid a REGISTRY storm every 3 s; true on status/role changes that
1137
1546
  * peers should observe promptly. */
1138
1547
  writeRecord(notify) {
1139
- this.currentRecord = { ...this.currentRecord, heartbeatAt: this.environment.now() };
1548
+ const now = this.environment.now();
1549
+ const sample = this.loadWeighting !== void 0 ? this.sampleThroughput(now) : void 0;
1550
+ this.currentRecord = {
1551
+ ...this.currentRecord,
1552
+ heartbeatAt: now,
1553
+ ...sample ? { throughput: sample } : {}
1554
+ };
1140
1555
  if (this.storage) writeJson(this.storage, this.workerStorageKey(this.workerId), this.currentRecord);
1141
1556
  if (notify) this.notifyRegistry();
1142
1557
  }
1143
1558
  /** Broadcast a REGISTRY message to trigger reconciliation on other tabs. */
1144
1559
  notifyRegistry() {
1145
- this.send({ type: "REGISTRY", sourceWorkerId: this.workerId });
1560
+ this.send({ type: CLUSTER_MESSAGE_TYPE.REGISTRY, sourceWorkerId: this.workerId });
1146
1561
  }
1147
1562
  /** Recompute whether this worker is active (eligible to own topics) or standby. Returns true when changed. */
1148
1563
  refreshRole(workers) {
1149
- const role = this.isActiveAmong(workers) ? "active" : "standby";
1564
+ const role = this.isActiveAmong(workers) ? WORKER_ROLE.ACTIVE : WORKER_ROLE.STANDBY;
1150
1565
  if (role === this.currentRecord.role) return false;
1151
1566
  this.currentRecord = { ...this.currentRecord, role };
1152
1567
  return true;
@@ -1244,7 +1659,7 @@ var DataBusTraceReporter = class {
1244
1659
  /** Start the periodic metrics flush interval. No-op when mode is 'events'
1245
1660
  * (no metrics to emit), when disabled, or when already running. */
1246
1661
  start() {
1247
- if (!this.enabled || this.intervalHandle || this.mode === "events") return;
1662
+ if (!this.enabled || this.intervalHandle || this.mode === TRACE_MODE.EVENTS) return;
1248
1663
  this.stopped = false;
1249
1664
  this.intervalStartedAt = this.now();
1250
1665
  this.intervalHandle = setInterval(() => this.flush(), this.metricsIntervalMs);
@@ -1260,11 +1675,40 @@ var DataBusTraceReporter = class {
1260
1675
  this.stopped = true;
1261
1676
  this.pause();
1262
1677
  }
1678
+ /** Synchronous sink state for diagnostics: whether delivery is async and how
1679
+ * many events are queued behind the microtask flush. A growing queue under
1680
+ * `asyncSink: true` is the first sign of sink back-pressure. */
1681
+ getSinkState() {
1682
+ return { asyncSink: this.asyncSink, pendingEvents: this.pendingEvents.length };
1683
+ }
1263
1684
  /** Record an instantaneous trace event (lifecycle, status, error, etc.). */
1264
1685
  event(event) {
1265
- if (!this.enabled || this.mode === "metrics") return;
1686
+ if (!this.enabled || this.mode === TRACE_MODE.METRICS) return;
1266
1687
  this.emit({ ...event, timestamp: this.now() });
1267
1688
  }
1689
+ /** Synchronous snapshot of the current metrics window without resetting it.
1690
+ * Returns the same derived counters as a periodic `message_metrics` event,
1691
+ * or null when metrics recording is inactive (disabled or events-only mode).
1692
+ * The window keeps accumulating until the next interval flush. */
1693
+ getMetrics() {
1694
+ if (!this.metricsActive || this.stopped) return null;
1695
+ const timestamp = this.now();
1696
+ const samples = this.latencySamples;
1697
+ return {
1698
+ durationMs: Math.max(0, timestamp - this.intervalStartedAt),
1699
+ received: this.received,
1700
+ dispatched: this.dispatched,
1701
+ topics: this.topics.size,
1702
+ dispatchSamples: samples,
1703
+ dispatchAvgMs: roundMs(samples === 0 ? 0 : this.latencySumMs / samples),
1704
+ dispatchP50Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.5)),
1705
+ dispatchP95Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.95)),
1706
+ dispatchMaxMs: roundMs(percentileMs(this.latencyBuckets, samples, 1)),
1707
+ dedupAccepted: this.dedupAccepted,
1708
+ dedupSuppressed: this.dedupSuppressed,
1709
+ timestamp
1710
+ };
1711
+ }
1268
1712
  /** Record that a message was received on `topic`; stores its timestamp for latency tracking. */
1269
1713
  recordReceived(topic) {
1270
1714
  if (!this.metricsActive) return;
@@ -1322,7 +1766,7 @@ var DataBusTraceReporter = class {
1322
1766
  * Extracted so the four record / flush methods share one guard expression
1323
1767
  * instead of repeating `!this.enabled || this.mode === 'events'` at each. */
1324
1768
  get metricsActive() {
1325
- return this.enabled && this.mode !== "events";
1769
+ return this.enabled && this.mode !== TRACE_MODE.EVENTS;
1326
1770
  }
1327
1771
  /** Emit the accumulated metrics snapshot if the interval is active. */
1328
1772
  flush() {
@@ -1334,7 +1778,7 @@ var DataBusTraceReporter = class {
1334
1778
  if (this.received > 0 || this.dispatched > 0 || this.dedupAccepted > 0 || this.dedupSuppressed > 0) {
1335
1779
  const samples = this.latencySamples;
1336
1780
  this.emit({
1337
- type: "message_metrics",
1781
+ type: TRACE_EVENT_TYPE.MESSAGE_METRICS,
1338
1782
  durationMs: Math.max(0, timestamp - this.intervalStartedAt),
1339
1783
  received: this.received,
1340
1784
  dispatched: this.dispatched,
@@ -1385,7 +1829,7 @@ var DataBusTraceReporter = class {
1385
1829
  this.sink(event);
1386
1830
  } catch (error) {
1387
1831
  if (typeof console !== "undefined" && typeof console.warn === "function") {
1388
- console.warn("[cross-tab-worker-databus] trace sink threw:", error);
1832
+ console.warn(`[${DEFAULT_STORAGE_PREFIX}] trace sink threw:`, error);
1389
1833
  }
1390
1834
  }
1391
1835
  }
@@ -1411,192 +1855,642 @@ function roundMs(value) {
1411
1855
  return Math.round(value * 10) / 10;
1412
1856
  }
1413
1857
 
1414
- // src/core/data-bus.ts
1415
- var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
1416
- var SDK_VERSION = "0.20.69";
1417
- var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
1858
+ // src/core/replay-pruning.ts
1859
+ function pruneReplayHistory(messages, options) {
1860
+ const { maxPerTopic, pruneStrategy, retentionMs, now } = options;
1861
+ const ageEnabled = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
1862
+ if (!ageEnabled) {
1863
+ return messages.length > maxPerTopic ? messages.slice(-maxPerTopic) : messages;
1864
+ }
1865
+ const cutoff = now - retentionMs;
1866
+ let hasExpired = false;
1867
+ let timestamplessCount = 0;
1868
+ for (const message of messages) {
1869
+ if (message.timestamp === void 0) timestamplessCount += 1;
1870
+ else if (message.timestamp < cutoff) hasExpired = true;
1871
+ }
1872
+ let pruned = hasExpired ? messages.filter((message) => message.timestamp === void 0 || message.timestamp >= cutoff) : messages;
1873
+ if (pruneStrategy === PRUNE_STRATEGY.BOTH) {
1874
+ return pruned.length > maxPerTopic ? pruned.slice(-maxPerTopic) : pruned;
1875
+ }
1876
+ if (timestamplessCount <= maxPerTopic) return pruned;
1877
+ let timestamplessToDrop = timestamplessCount - maxPerTopic;
1878
+ pruned = pruned.filter((message) => {
1879
+ if (message.timestamp === void 0 && timestamplessToDrop > 0) {
1880
+ timestamplessToDrop -= 1;
1881
+ return false;
1882
+ }
1883
+ return true;
1884
+ });
1885
+ return pruned;
1886
+ }
1887
+
1888
+ // src/core/replay-manager.ts
1418
1889
  var PersistenceRetryCancelledError = class extends Error {
1419
1890
  constructor() {
1420
1891
  super("Persistence retry cancelled by lifecycle transition.");
1421
1892
  this.name = "PersistenceRetryCancelledError";
1422
1893
  }
1423
1894
  };
1424
- var CrossTabDataBus = class {
1425
- transport;
1426
- cluster;
1427
- // Map of topic → set of local subscribers.
1428
- topicHandlers = /* @__PURE__ */ new Map();
1429
- // Topics for which the transport has been asked to subscribe (used to avoid
1430
- // duplicate subscribe calls during reconnection).
1431
- transportSubscribedTopics = /* @__PURE__ */ new Set();
1432
- statusHandlers = /* @__PURE__ */ new Set();
1433
- errorHandlers = /* @__PURE__ */ new Set();
1434
- // Bounded per-topic ring of recent dispatched publications. Null unless
1435
- // replay is enabled — buffering is opt-in and must cost nothing otherwise.
1436
- replayBuffers;
1437
- replayMaxPerTopic;
1438
- replayPersistence;
1439
- replayRetentionMs;
1440
- replayPruneStrategy;
1441
- replayRetentionSweepMs;
1895
+ var MAX_RETRY_DELAY_MS2 = 1600;
1896
+ var ReplayManager = class {
1897
+ constructor(deps) {
1898
+ this.deps = deps;
1899
+ this.buffers = deps.enabled ? /* @__PURE__ */ new Map() : null;
1900
+ this.maxPerTopic = deps.maxPerTopic;
1901
+ this.persistence = deps.persistence ?? null;
1902
+ this.retentionMs = deps.retentionMs;
1903
+ this.pruneStrategy = deps.pruneStrategy;
1904
+ this.retentionSweepMs = deps.retentionSweepMs;
1905
+ this.persistenceRetryMaxAttempts = deps.persistenceRetryMaxAttempts;
1906
+ this.persistenceRetryBackoffMs = deps.persistenceRetryBackoffMs;
1907
+ this.now = deps.now;
1908
+ this.trace = deps.trace;
1909
+ this.onPersistenceError = deps.onPersistenceError;
1910
+ this.onDispatchError = deps.onDispatchError;
1911
+ this.hydration = this.hydrate();
1912
+ }
1913
+ deps;
1914
+ buffers;
1915
+ maxPerTopic;
1916
+ persistence;
1917
+ retentionMs;
1918
+ pruneStrategy;
1919
+ retentionSweepMs;
1442
1920
  persistenceRetryMaxAttempts;
1443
1921
  persistenceRetryBackoffMs;
1444
- persistenceRetryGeneration = 0;
1445
- pendingReplayPersistence = [];
1446
- replayPersistenceFlushScheduled = false;
1447
- replayHydration;
1448
- // Retention cleanup is coalesced so a burst of publications does not issue
1449
- // one IndexedDB read/write transaction per message. The newest cutoff wins.
1450
- replayRetentionCleanup = null;
1451
- replayRetentionCutoff = null;
1452
- replayRetentionTimer = null;
1453
- initialConfig;
1454
- hasInitialConfig;
1455
- trace;
1456
- dedupMaxEntries;
1457
- dedupTtlMs;
1458
- dedupAdaptiveBounds;
1459
- dedupWindowStartedAt = 0;
1460
- dedupWindowAccepted = 0;
1461
- dedupAdaptiveWindowMs = 5e3;
1462
- dedupSweepMs;
1463
- dedupSweepTimer = null;
1464
- dedupEnabled;
1465
1922
  now;
1466
- seenMessageIds = /* @__PURE__ */ new Map();
1467
- dedupSuppressed = 0;
1468
- dedupAccepted = 0;
1469
- activeConfig;
1470
- status = "disconnected";
1471
- started = false;
1472
- stopping = false;
1473
- transportReady = false;
1474
- // Last transport failure, retained so ready() can surface it to callers who
1475
- // never awaited start() directly. Cleared on the next successful start.
1476
- lastError = null;
1477
- lastErrorAt = null;
1478
- // Gate that serialises start/stop/suspend/resume only one lifecycle
1479
- // transition at a time. Resets to null once the operation settles.
1480
- startPromise = null;
1481
- // Timestamp of the last automatic transport recovery attempt.
1482
- // Used to avoid a tight retry loop when the transport fails repeatedly.
1483
- lastRecoveryAt = 0;
1484
- // Monotonic attempt number within one runtime recovery sequence; reset once
1485
- // a transport reopen succeeds so traces can correlate repeated failures.
1486
- recoveryAttempt = 0;
1487
- recoveryExhausted = false;
1488
- /** Monotonic generation incremented on every successful transport open.
1489
- * Stays in lockstep with `lastSuccessAt` so callers can detect that the
1490
- * transport has been reopened even if the timestamp window is short. */
1491
- recoveryGeneration = 0;
1492
- /** Timestamp of the most recent successful transport open. Null until the
1493
- * transport has reached the `ready` state at least once. */
1494
- lastSuccessAt = null;
1495
- // True while the tab is hidden so an in-flight transport start does not mark
1496
- // the transport ready after suspendTransport() has stopped it.
1497
- suspended = false;
1498
- // Single gate for async transport.stop() cleanup, shared by failed opens and
1499
- // page-hide suspension. Kept separate from startPromise so ready() still
1500
- // surfaces a failure while later opens and automatic recovery wait for the
1501
- // stop to settle.
1502
- pendingStop = null;
1503
- // Minimum interval in ms between automatic recovery attempts.
1504
- recoveryCooldownMs;
1505
- recoveryMaxAttempts;
1506
- constructor(options) {
1507
- const replay = options.replay;
1508
- if (replay) {
1509
- const maxPerTopic = replay.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1510
- if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
1511
- throw new TypeError(
1512
- `replay.maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`
1513
- );
1514
- }
1923
+ trace;
1924
+ onPersistenceError;
1925
+ onDispatchError;
1926
+ /** Bumped on suspend/stop so in-flight persistence retries are cancelled. */
1927
+ retryGeneration = 0;
1928
+ pendingReplayPersistence = [];
1929
+ persistenceFlushScheduled = false;
1930
+ hydration;
1931
+ /** Coalesced retention cleanup: the newest cutoff wins while one is running. */
1932
+ retentionCleanup = null;
1933
+ retentionCutoff = null;
1934
+ retentionTimer = null;
1935
+ /** True when replay buffering is enabled. */
1936
+ get enabled() {
1937
+ return this.buffers !== null;
1938
+ }
1939
+ /** Append a dispatched publication to the topic's replay ring buffer.
1940
+ * No-op when replay is disabled. */
1941
+ record(message) {
1942
+ if (!this.buffers) return;
1943
+ let buffer = this.buffers.get(message.topic);
1944
+ if (!buffer) {
1945
+ buffer = [];
1946
+ this.buffers.set(message.topic, buffer);
1947
+ }
1948
+ buffer.push(message);
1949
+ const pruned = pruneReplayHistory(buffer, {
1950
+ maxPerTopic: this.maxPerTopic,
1951
+ pruneStrategy: this.pruneStrategy,
1952
+ retentionMs: this.retentionMs,
1953
+ now: this.now()
1954
+ });
1955
+ if (pruned !== buffer) {
1956
+ buffer = pruned;
1957
+ this.buffers.set(message.topic, buffer);
1958
+ }
1959
+ if (!this.persistence) return;
1960
+ if (this.persistence.appendBatch) {
1961
+ this.pendingReplayPersistence.push(message);
1962
+ this.schedulePersistenceFlush();
1963
+ } else {
1964
+ void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.append(message)).catch((error) => this.onPersistenceError(error));
1515
1965
  }
1516
- this.replayMaxPerTopic = replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1517
- this.replayBuffers = replay ? /* @__PURE__ */ new Map() : null;
1518
- this.replayPersistence = replay?.persistence ?? null;
1519
- this.replayRetentionMs = replay?.retentionMs;
1520
- this.replayPruneStrategy = replay?.pruneStrategy ?? "count";
1521
- if (!["count", "age", "both"].includes(this.replayPruneStrategy)) throw new TypeError("replay.pruneStrategy must be count, age, or both.");
1522
- if (this.replayRetentionMs !== void 0 && (!Number.isFinite(this.replayRetentionMs) || this.replayRetentionMs <= 0)) {
1523
- throw new TypeError("replay.retentionMs must be a positive finite number.");
1966
+ if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
1967
+ this.scheduleRetentionCleanup(this.now() - this.retentionMs);
1524
1968
  }
1525
- this.replayRetentionSweepMs = replay?.retentionSweepMs;
1526
- if (this.replayRetentionSweepMs !== void 0 && (!Number.isFinite(this.replayRetentionSweepMs) || this.replayRetentionSweepMs <= 0)) {
1527
- throw new TypeError("replay.retentionSweepMs must be a positive finite number.");
1969
+ }
1970
+ /** Deliver buffered history to a newly-registered handler. For an exact
1971
+ * topic this is that topic's ring; for a wildcard subscription every
1972
+ * buffered topic matching the pattern contributes (in buffer insertion
1973
+ * order). Replay deliveries are marked `replayed: true` and are not counted
1974
+ * into trace metrics.
1975
+ *
1976
+ * When a durable persistence backend is present, delivery waits for the
1977
+ * hydration load to settle first; `isHandlerActive` is then consulted so a
1978
+ * handler that unsubscribed during the async load does not receive history.
1979
+ */
1980
+ deliverReplay(topic, replayOption, handler, isHandlerActive) {
1981
+ if (!this.buffers) return;
1982
+ const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
1983
+ if (this.persistence) {
1984
+ void this.hydration.then(() => {
1985
+ if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
1986
+ });
1987
+ return;
1528
1988
  }
1529
- this.persistenceRetryMaxAttempts = replay?.persistenceRetry?.maxAttempts ?? 1;
1530
- this.persistenceRetryBackoffMs = replay?.persistenceRetry?.backoffMs ?? 50;
1531
- if (!Number.isSafeInteger(this.persistenceRetryMaxAttempts) || this.persistenceRetryMaxAttempts <= 0) {
1532
- throw new TypeError("replay.persistenceRetry.maxAttempts must be a positive safe integer.");
1989
+ this.deliver(topic, limit, handler);
1990
+ }
1991
+ /** Clean up a topic that lost its last local handler: drop the ring buffer,
1992
+ * filter queued batch flushes (so an in-flight append cannot undo the
1993
+ * clearTopic), and prune durable history. */
1994
+ onTopicUnsubscribed(topic) {
1995
+ if (!this.buffers) return;
1996
+ this.buffers.delete(topic);
1997
+ this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
1998
+ if (this.persistence?.clearTopic) {
1999
+ void this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)).catch((error) => this.onPersistenceError(error));
2000
+ }
2001
+ }
2002
+ /** Clear all in-memory replay buffers and, when supported, durable history.
2003
+ * Reports persistence failures and rethrows, mirroring the public API
2004
+ * contract that callers can observe a failed clear. */
2005
+ async clearAll() {
2006
+ if (!this.buffers) return;
2007
+ this.buffers.clear();
2008
+ this.pendingReplayPersistence = [];
2009
+ if (this.persistence?.clear) {
2010
+ try {
2011
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear());
2012
+ } catch (error) {
2013
+ this.onPersistenceError(error);
2014
+ throw error;
2015
+ }
1533
2016
  }
1534
- if (!Number.isFinite(this.persistenceRetryBackoffMs) || this.persistenceRetryBackoffMs < 0) {
1535
- throw new TypeError("replay.persistenceRetry.backoffMs must be a non-negative finite number.");
2017
+ }
2018
+ /** Clear replay history for one exact topic, including durable storage. */
2019
+ async clearTopic(topic) {
2020
+ if (!this.buffers) return;
2021
+ this.buffers.delete(topic);
2022
+ this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
2023
+ if (this.persistence?.clearTopic) {
2024
+ try {
2025
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic));
2026
+ } catch (error) {
2027
+ this.onPersistenceError(error);
2028
+ throw error;
2029
+ }
1536
2030
  }
1537
- const { autoStart, initialConfig, trace, transport, dedup, recovery, ...clusterOptions } = options;
1538
- this.recoveryCooldownMs = recovery?.cooldownMs ?? 1e3;
1539
- this.recoveryMaxAttempts = recovery?.maxAttempts ?? Number.POSITIVE_INFINITY;
1540
- if (!Number.isFinite(this.recoveryCooldownMs) || this.recoveryCooldownMs <= 0) {
1541
- throw new TypeError("recovery.cooldownMs must be a positive finite number.");
2031
+ }
2032
+ /** Remove replay entries older than an epoch-millisecond cutoff. */
2033
+ async clearBefore(timestamp) {
2034
+ if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
2035
+ if (this.buffers) {
2036
+ for (const [topic, messages] of this.buffers) {
2037
+ const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
2038
+ if (kept.length) this.buffers.set(topic, kept);
2039
+ else this.buffers.delete(topic);
2040
+ }
1542
2041
  }
1543
- if (!(this.recoveryMaxAttempts === Number.POSITIVE_INFINITY || Number.isSafeInteger(this.recoveryMaxAttempts) && this.recoveryMaxAttempts > 0)) {
1544
- throw new TypeError("recovery.maxAttempts must be a positive safe integer.");
2042
+ this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
2043
+ (message) => message.timestamp === void 0 || message.timestamp >= timestamp
2044
+ );
2045
+ if (this.persistence?.clearBefore) {
2046
+ try {
2047
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp));
2048
+ } catch (error) {
2049
+ this.onPersistenceError(error);
2050
+ throw error;
2051
+ }
1545
2052
  }
1546
- this.now = dedup?.now ?? Date.now;
1547
- this.replayHydration = this.hydrateReplay();
1548
- this.transport = transport;
1549
- this.initialConfig = initialConfig;
1550
- this.hasInitialConfig = "initialConfig" in options;
1551
- this.trace = new DataBusTraceReporter(trace);
1552
- this.dedupMaxEntries = dedup?.maxEntries ?? 1e3;
1553
- this.dedupTtlMs = dedup?.ttlMs ?? 6e4;
1554
- this.dedupAdaptiveBounds = dedup?.adaptiveTtl;
1555
- if (this.dedupAdaptiveBounds && (this.dedupAdaptiveBounds.minMs <= 0 || this.dedupAdaptiveBounds.maxMs < this.dedupAdaptiveBounds.minMs)) throw new TypeError("dedup.adaptiveTtl bounds are invalid.");
1556
- this.dedupWindowStartedAt = this.now();
1557
- this.dedupSweepMs = dedup?.sweepMs;
1558
- this.dedupEnabled = dedup !== void 0;
1559
- if (!Number.isSafeInteger(this.dedupMaxEntries) || this.dedupMaxEntries <= 0) {
1560
- throw new TypeError("dedup.maxEntries must be a positive safe integer.");
1561
- }
1562
- if (!Number.isFinite(this.dedupTtlMs) || this.dedupTtlMs <= 0) {
1563
- throw new TypeError("dedup.ttlMs must be a positive finite number.");
1564
- }
1565
- if (this.dedupSweepMs !== void 0 && (!Number.isFinite(this.dedupSweepMs) || this.dedupSweepMs <= 0)) {
1566
- throw new TypeError("dedup.sweepMs must be a positive finite number.");
2053
+ }
2054
+ /** Start the periodic retention sweep. No-op when no durable retention
2055
+ * config makes it necessary. */
2056
+ start() {
2057
+ if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
2058
+ this.retentionTimer = setInterval(() => {
2059
+ this.scheduleRetentionCleanup(this.now() - this.retentionMs);
2060
+ }, this.retentionSweepMs);
2061
+ }
2062
+ /** Stop the periodic retention sweep. */
2063
+ stop() {
2064
+ if (this.retentionTimer) clearInterval(this.retentionTimer);
2065
+ this.retentionTimer = null;
2066
+ }
2067
+ /** Suspend the manager: cancel in-flight persistence retries (so a hidden tab
2068
+ * or stopped bus does not keep hammering the store) and stop the sweep. */
2069
+ suspend() {
2070
+ this.retryGeneration += 1;
2071
+ this.stop();
2072
+ }
2073
+ /** Drop all in-memory buffers (used on full teardown). */
2074
+ resetBuffers() {
2075
+ this.buffers?.clear();
2076
+ }
2077
+ /** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
2078
+ * payload footprint (same heuristic as adaptive load weighting), computed on
2079
+ * demand so the hot append path never pays for it. */
2080
+ getStats() {
2081
+ let messages = 0;
2082
+ let bytes = 0;
2083
+ if (this.buffers) {
2084
+ for (const buffer of this.buffers.values()) {
2085
+ messages += buffer.length;
2086
+ for (const message of buffer) bytes += approximatePayloadBytes(message.data);
2087
+ }
1567
2088
  }
1568
- this.cluster = new WorkerClusterRuntime({
1569
- ...clusterOptions,
1570
- handlers: {
1571
- // The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
1572
- // control message — meaning the owning Worker has delegated the action to us.
1573
- onControl: (action, topic, data, messageId, timestamp) => {
1574
- switch (action) {
1575
- case "SUBSCRIBE":
1576
- if (this.subscribeTransport(topic)) this.traceSubscription("subscribe", topic);
1577
- break;
1578
- case "UNSUBSCRIBE":
1579
- if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
1580
- break;
1581
- case "PUBLISH":
1582
- this.runTransport(() => this.transport.publish(
1583
- topic,
1584
- data,
1585
- messageId === void 0 && timestamp === void 0 ? void 0 : {
1586
- ...messageId === void 0 ? {} : { messageId },
1587
- ...timestamp === void 0 ? {} : { timestamp }
1588
- }
1589
- ));
1590
- break;
1591
- default:
1592
- break;
1593
- }
1594
- },
1595
- // The cluster calls `onEvent` when a publication broadcast arrives from
1596
- // another tab. Dispatch locally if we have subscribers. The payload is
1597
- // typed `unknown` at the cluster boundary (the cluster is transport-
1598
- // agnostic); here we narrow it to DataBusMessage — the sender is our
1599
- // own broadcastEvent call, which always posts a DataBusMessage.
2089
+ return { enabled: this.enabled, topics: this.buffers?.size ?? 0, messages, bytes };
2090
+ }
2091
+ /** Deliver history from one topic's ring to a handler, isolating a throwing
2092
+ * handler so the remaining buffers are still delivered. */
2093
+ deliver(topic, limit, handler) {
2094
+ if (!this.buffers || limit <= 0) return;
2095
+ const deliverBuffer = (buffer2) => {
2096
+ for (const message of buffer2.slice(-limit)) {
2097
+ try {
2098
+ handler({ ...message, replayed: true });
2099
+ } catch (error) {
2100
+ this.onDispatchError(error);
2101
+ }
2102
+ }
2103
+ };
2104
+ if (isWildcardTopic(topic)) {
2105
+ for (const [bufferedTopic, buffer2] of this.buffers) {
2106
+ if (topicMatchesPattern(topic, bufferedTopic)) deliverBuffer(buffer2);
2107
+ }
2108
+ return;
2109
+ }
2110
+ const buffer = this.buffers.get(topic);
2111
+ if (buffer) deliverBuffer(buffer);
2112
+ }
2113
+ /** Coalesce queued persistence appends into a single microtask batch so a
2114
+ * burst of publications does not issue one IndexedDB transaction each.
2115
+ * Only reachable when the backend advertises `appendBatch` (the sole queuer,
2116
+ * `record()`, guards on it), so the batched path is unconditional here. */
2117
+ schedulePersistenceFlush() {
2118
+ if (this.persistenceFlushScheduled) return;
2119
+ this.persistenceFlushScheduled = true;
2120
+ queueMicrotask(() => {
2121
+ this.persistenceFlushScheduled = false;
2122
+ const batch = this.pendingReplayPersistence.splice(0);
2123
+ if (batch.length === 0 || !this.persistence) return;
2124
+ void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
2125
+ });
2126
+ }
2127
+ /** Load durable history into the in-memory rings once at startup, pruning
2128
+ * entries past the retention window first. Failures are reported but do not
2129
+ * block startup — the bus runs with whatever survived. */
2130
+ async hydrate() {
2131
+ if (!this.buffers || !this.persistence) {
2132
+ return;
2133
+ }
2134
+ try {
2135
+ if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
2136
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
2137
+ }
2138
+ const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
2139
+ for (const message of loaded) {
2140
+ let buffer = this.buffers.get(message.topic);
2141
+ if (!buffer) {
2142
+ buffer = [];
2143
+ this.buffers.set(message.topic, buffer);
2144
+ }
2145
+ buffer.push(message);
2146
+ }
2147
+ const hydrationNow = this.now();
2148
+ for (const [topic, buffer] of this.buffers) {
2149
+ const pruned = pruneReplayHistory(buffer, {
2150
+ maxPerTopic: this.maxPerTopic,
2151
+ pruneStrategy: this.pruneStrategy,
2152
+ retentionMs: this.retentionMs,
2153
+ now: hydrationNow
2154
+ });
2155
+ if (pruned !== buffer) this.buffers.set(topic, pruned);
2156
+ }
2157
+ } catch (error) {
2158
+ this.onPersistenceError(error);
2159
+ }
2160
+ }
2161
+ /** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
2162
+ * so a burst of publications issues at most one clearBefore transaction. */
2163
+ scheduleRetentionCleanup(cutoff) {
2164
+ if (!this.persistence?.clearBefore) return;
2165
+ if (this.retentionCutoff === null || cutoff > this.retentionCutoff) {
2166
+ this.retentionCutoff = cutoff;
2167
+ }
2168
+ if (this.retentionCleanup) return;
2169
+ this.retentionCleanup = (async () => {
2170
+ while (this.retentionCutoff !== null) {
2171
+ const nextCutoff = this.retentionCutoff;
2172
+ this.retentionCutoff = null;
2173
+ try {
2174
+ await this.persistence.clearBefore(nextCutoff);
2175
+ } catch (error) {
2176
+ this.onPersistenceError(error);
2177
+ }
2178
+ }
2179
+ })().finally(() => {
2180
+ this.retentionCleanup = null;
2181
+ if (this.retentionCutoff !== null) {
2182
+ this.scheduleRetentionCleanup(this.retentionCutoff);
2183
+ }
2184
+ });
2185
+ }
2186
+ /** Run a persistence operation with exponential backoff on transient failure.
2187
+ * Bumped `retryGeneration` (suspend/stop) cancels the loop early; a
2188
+ * structurally failing operation throws after `persistenceRetryMaxAttempts`,
2189
+ * leaving the ring buffer intact so the bus keeps working. */
2190
+ async withPersistenceRetry(persistenceOperation, operation) {
2191
+ const generation = this.retryGeneration;
2192
+ let attempt = 0;
2193
+ let delay = this.persistenceRetryBackoffMs;
2194
+ while (true) {
2195
+ attempt += 1;
2196
+ try {
2197
+ if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
2198
+ return await operation();
2199
+ } catch (error) {
2200
+ if (error instanceof PersistenceRetryCancelledError || generation !== this.retryGeneration) {
2201
+ throw new PersistenceRetryCancelledError();
2202
+ }
2203
+ if (attempt >= this.persistenceRetryMaxAttempts) throw error;
2204
+ this.trace.event({
2205
+ type: TRACE_EVENT_TYPE.RELIABILITY,
2206
+ operation: RELIABILITY_OPERATION.PERSISTENCE_RETRY,
2207
+ persistenceOperation,
2208
+ attempt
2209
+ });
2210
+ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
2211
+ if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
2212
+ delay = Math.min(delay * 2, MAX_RETRY_DELAY_MS2);
2213
+ }
2214
+ }
2215
+ }
2216
+ };
2217
+
2218
+ // src/core/dedup-manager.ts
2219
+ var ADAPTIVE_WINDOW_MS = 5e3;
2220
+ var QUIET_RATE_PER_MS = 0.01;
2221
+ var DedupManager = class {
2222
+ enabled;
2223
+ maxEntries;
2224
+ ttlMs;
2225
+ adaptiveBounds;
2226
+ sweepMs;
2227
+ now;
2228
+ trace;
2229
+ seenMessageIds = /* @__PURE__ */ new Map();
2230
+ sweepTimer = null;
2231
+ windowStartedAt = 0;
2232
+ windowAccepted = 0;
2233
+ suppressed = 0;
2234
+ accepted = 0;
2235
+ constructor(options) {
2236
+ this.enabled = options.enabled;
2237
+ this.maxEntries = options.maxEntries;
2238
+ this.ttlMs = options.ttlMs;
2239
+ this.adaptiveBounds = options.adaptiveBounds;
2240
+ this.sweepMs = options.sweepMs;
2241
+ this.now = options.now;
2242
+ this.trace = options.trace;
2243
+ this.windowStartedAt = this.now();
2244
+ }
2245
+ /** True when a publication carrying `messageId` was already seen. Records the
2246
+ * ID and updates counters/trace on acceptance. Disabled or ID-less messages
2247
+ * always pass through. */
2248
+ isDuplicate(messageId, topic) {
2249
+ if (!this.enabled || !messageId) return false;
2250
+ const now = this.now();
2251
+ const ttlMs = this.currentTtl();
2252
+ for (const [id, timestamp] of this.seenMessageIds) {
2253
+ if (now - timestamp > ttlMs) this.seenMessageIds.delete(id);
2254
+ }
2255
+ if (this.seenMessageIds.has(messageId)) {
2256
+ this.suppressed += 1;
2257
+ this.trace.event({
2258
+ type: TRACE_EVENT_TYPE.RELIABILITY,
2259
+ operation: RELIABILITY_OPERATION.DEDUP_SUPPRESSED,
2260
+ topic
2261
+ });
2262
+ this.trace.recordDedupSuppressed();
2263
+ return true;
2264
+ }
2265
+ this.seenMessageIds.set(messageId, now);
2266
+ this.accepted += 1;
2267
+ this.windowAccepted += 1;
2268
+ this.trace.recordDedupAccepted();
2269
+ while (this.seenMessageIds.size > this.maxEntries) {
2270
+ const oldest = this.seenMessageIds.keys().next().value;
2271
+ if (oldest === void 0) break;
2272
+ this.seenMessageIds.delete(oldest);
2273
+ }
2274
+ return false;
2275
+ }
2276
+ /** Start the periodic expiry sweep. No-op when disabled or no sweepMs was
2277
+ * configured (the hot-path opportunistic expiry still bounds the map). */
2278
+ start() {
2279
+ if (this.sweepTimer || !this.enabled || !this.sweepMs) return;
2280
+ this.sweepTimer = setInterval(() => this.pruneExpired(), this.sweepMs);
2281
+ }
2282
+ /** Stop the periodic expiry sweep. */
2283
+ stop() {
2284
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
2285
+ this.sweepTimer = null;
2286
+ }
2287
+ /** Return bounded deduplication counters for diagnostics and health checks. */
2288
+ getStats() {
2289
+ return {
2290
+ enabled: this.enabled,
2291
+ tracked: this.seenMessageIds.size,
2292
+ suppressed: this.suppressed,
2293
+ accepted: this.accepted,
2294
+ ...this.adaptiveBounds ? { ttlMs: this.currentTtl() } : {}
2295
+ };
2296
+ }
2297
+ /** Drop all remembered IDs and reset dedup counters. */
2298
+ reset() {
2299
+ this.seenMessageIds.clear();
2300
+ this.suppressed = 0;
2301
+ this.accepted = 0;
2302
+ this.windowStartedAt = this.now();
2303
+ this.windowAccepted = 0;
2304
+ }
2305
+ /** Remove IDs whose timestamp predates the effective TTL cutoff. */
2306
+ pruneExpired() {
2307
+ const cutoff = this.now() - this.currentTtl();
2308
+ for (const [id, timestamp] of this.seenMessageIds) {
2309
+ if (timestamp < cutoff) this.seenMessageIds.delete(id);
2310
+ }
2311
+ }
2312
+ /** Effective TTL: the fixed `ttlMs` unless adaptive bounds are configured, in
2313
+ * which case a higher recent message rate shortens the window (dedup only
2314
+ * needs to live long enough to bridge duplicate bursts). The window resets
2315
+ * every ADAPTIVE_WINDOW_MS. */
2316
+ currentTtl() {
2317
+ if (!this.adaptiveBounds) return this.ttlMs;
2318
+ const now = this.now();
2319
+ const elapsed = now - this.windowStartedAt;
2320
+ if (elapsed >= ADAPTIVE_WINDOW_MS) {
2321
+ this.windowStartedAt = now;
2322
+ this.windowAccepted = 0;
2323
+ return this.adaptiveBounds.maxMs;
2324
+ }
2325
+ const rate = this.windowAccepted / Math.max(1, elapsed);
2326
+ const factor = Math.min(1, rate / QUIET_RATE_PER_MS);
2327
+ return this.adaptiveBounds.maxMs - (this.adaptiveBounds.maxMs - this.adaptiveBounds.minMs) * factor;
2328
+ }
2329
+ };
2330
+
2331
+ // src/core/version.ts
2332
+ var SDK_VERSION = true ? "0.20.86" : "";
2333
+
2334
+ // src/core/data-bus.ts
2335
+ var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
2336
+ var CrossTabDataBus = class {
2337
+ transport;
2338
+ cluster;
2339
+ // Map of topic → set of local subscribers.
2340
+ topicHandlers = /* @__PURE__ */ new Map();
2341
+ // Topics for which the transport has been asked to subscribe (used to avoid
2342
+ // duplicate subscribe calls during reconnection).
2343
+ transportSubscribedTopics = /* @__PURE__ */ new Set();
2344
+ statusHandlers = /* @__PURE__ */ new Set();
2345
+ errorHandlers = /* @__PURE__ */ new Set();
2346
+ replayManager;
2347
+ initialConfig;
2348
+ hasInitialConfig;
2349
+ trace;
2350
+ dedupManager;
2351
+ now;
2352
+ activeConfig;
2353
+ status = WORKER_STATUS.DISCONNECTED;
2354
+ started = false;
2355
+ stopping = false;
2356
+ transportReady = false;
2357
+ // Last transport failure, retained so ready() can surface it to callers who
2358
+ // never awaited start() directly. Cleared on the next successful start.
2359
+ lastError = null;
2360
+ lastErrorAt = null;
2361
+ // Unified failure ledger for the health summary: the most recent failure of
2362
+ // any source (transport, persistence, dispatch) since the last explicit start.
2363
+ lastFailure = null;
2364
+ persistenceFailureCount = 0;
2365
+ persistenceLastFailureAt = null;
2366
+ persistenceLastErrorMessage = null;
2367
+ // Gate that serialises start/stop/suspend/resume — only one lifecycle
2368
+ // transition at a time. Resets to null once the operation settles.
2369
+ startPromise = null;
2370
+ // Gate for an explicit stop(). Concurrent stop() calls share it, and a
2371
+ // start() received while stopping chains a fresh start after it.
2372
+ stopPromise = null;
2373
+ // A start() requested while an explicit stop() is still settling. Kept
2374
+ // separate from startPromise because stop()'s finally block clears the
2375
+ // ordinary lifecycle gate before the queued start is allowed to run.
2376
+ queuedStart = null;
2377
+ // Lazy readiness view of queuedStart. start() keeps its documented
2378
+ // resolve-on-cancellation contract, while ready() must reject when the
2379
+ // queued intent was superseded by a later stop().
2380
+ queuedStartReady = null;
2381
+ queuedStartReadyToken = 0;
2382
+ // The queued continuation is chained to the stop promise and cannot be
2383
+ // un-scheduled once scheduled. A later stop() therefore invalidates the
2384
+ // current intent by recording its token; a subsequent start() issues a
2385
+ // higher token so the latest lifecycle request still wins.
2386
+ queuedStartToken = 0;
2387
+ canceledQueuedStartToken = 0;
2388
+ // Timestamp of the last automatic transport recovery attempt.
2389
+ // Used to avoid a tight retry loop when the transport fails repeatedly.
2390
+ lastRecoveryAt = 0;
2391
+ // Monotonic attempt number within one runtime recovery sequence; reset once
2392
+ // a transport reopen succeeds so traces can correlate repeated failures.
2393
+ recoveryAttempt = 0;
2394
+ recoveryExhausted = false;
2395
+ /** Monotonic generation incremented on every successful transport open.
2396
+ * Stays in lockstep with `lastSuccessAt` so callers can detect that the
2397
+ * transport has been reopened even if the timestamp window is short. */
2398
+ recoveryGeneration = 0;
2399
+ /** Timestamp of the most recent successful transport open. Null until the
2400
+ * transport has reached the `ready` state at least once. */
2401
+ lastSuccessAt = null;
2402
+ // True while the tab is hidden so an in-flight transport start does not mark
2403
+ // the transport ready after suspendTransport() has stopped it.
2404
+ suspended = false;
2405
+ // Single gate for async transport.stop() cleanup, shared by failed opens and
2406
+ // page-hide suspension. Kept separate from startPromise so ready() still
2407
+ // surfaces a failure while later opens and automatic recovery wait for the
2408
+ // stop to settle.
2409
+ pendingStop = null;
2410
+ // Ownership token for asynchronous transport opens. Every lifecycle
2411
+ // transition invalidates callbacks and failure cleanup from older opens.
2412
+ lifecycleEpoch = 0;
2413
+ // Minimum interval in ms between automatic recovery attempts.
2414
+ recoveryCooldownMs;
2415
+ recoveryMaxAttempts;
2416
+ constructor(options) {
2417
+ const replay = options.replay;
2418
+ assertReplayOptions(replay);
2419
+ const { autoStart, initialConfig, trace, transport, dedup, recovery, ...clusterOptions } = options;
2420
+ assertRecoveryOptions(recovery);
2421
+ this.recoveryCooldownMs = recovery?.cooldownMs ?? 1e3;
2422
+ this.recoveryMaxAttempts = recovery?.maxAttempts ?? Number.POSITIVE_INFINITY;
2423
+ this.now = dedup?.now ?? Date.now;
2424
+ this.transport = transport;
2425
+ this.initialConfig = initialConfig;
2426
+ this.hasInitialConfig = "initialConfig" in options;
2427
+ this.trace = new DataBusTraceReporter(trace);
2428
+ this.replayManager = new ReplayManager({
2429
+ enabled: replay !== void 0,
2430
+ maxPerTopic: replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC,
2431
+ persistence: replay?.persistence ?? null,
2432
+ retentionMs: replay?.retentionMs,
2433
+ pruneStrategy: replay?.pruneStrategy ?? PRUNE_STRATEGY.COUNT,
2434
+ retentionSweepMs: replay?.retentionSweepMs,
2435
+ persistenceRetryMaxAttempts: replay?.persistenceRetry?.maxAttempts ?? 1,
2436
+ persistenceRetryBackoffMs: replay?.persistenceRetry?.backoffMs ?? 50,
2437
+ now: this.now,
2438
+ trace: this.trace,
2439
+ onPersistenceError: (error) => this.reportPersistenceError(error),
2440
+ onDispatchError: (error) => this.reportError(error, FAILURE_SOURCE.DISPATCH)
2441
+ });
2442
+ assertDedupOptions(dedup);
2443
+ this.dedupManager = new DedupManager({
2444
+ enabled: dedup !== void 0,
2445
+ maxEntries: dedup?.maxEntries ?? 1e3,
2446
+ ttlMs: dedup?.ttlMs ?? 6e4,
2447
+ adaptiveBounds: dedup?.adaptiveTtl,
2448
+ sweepMs: dedup?.sweepMs,
2449
+ now: this.now,
2450
+ trace: this.trace
2451
+ });
2452
+ this.cluster = new WorkerClusterRuntime({
2453
+ ...clusterOptions,
2454
+ handlers: {
2455
+ // The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
2456
+ // control message — meaning the owning Worker has delegated the action to us.
2457
+ onControl: (action, topic, data, messageId, timestamp) => {
2458
+ switch (action) {
2459
+ case CONTROL_ACTION.SUBSCRIBE:
2460
+ if (this.subscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.SUBSCRIBE, topic);
2461
+ break;
2462
+ case CONTROL_ACTION.UNSUBSCRIBE:
2463
+ if (this.unsubscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.UNSUBSCRIBE, topic);
2464
+ break;
2465
+ case CONTROL_ACTION.PUBLISH:
2466
+ this.runTransport(() => this.transport.publish(topic, data, publicationMetadata(messageId, timestamp)));
2467
+ break;
2468
+ default:
2469
+ break;
2470
+ }
2471
+ },
2472
+ // Batched variant of the PUBLISH action (CONTROL frames carrying
2473
+ // multiple items, and the local publishBatch fast path). Uses the
2474
+ // transport's one-frame publishBatch when available, preserving
2475
+ // per-item metadata; otherwise falls back to per-item publishes.
2476
+ onPublishBatch: (topic, items) => {
2477
+ if (typeof this.transport.publishBatch === "function") {
2478
+ this.runTransport(() => this.transport.publishBatch(topic, items));
2479
+ return;
2480
+ }
2481
+ for (const item of items) {
2482
+ this.runTransport(() => this.transport.publish(
2483
+ topic,
2484
+ item.data,
2485
+ publicationMetadata(item.messageId, item.timestamp)
2486
+ ));
2487
+ }
2488
+ },
2489
+ // The cluster calls `onEvent` when a publication broadcast arrives from
2490
+ // another tab. Dispatch locally if we have subscribers. The payload is
2491
+ // typed `unknown` at the cluster boundary (the cluster is transport-
2492
+ // agnostic); here we narrow it to DataBusMessage — the sender is our
2493
+ // own broadcastEvent call, which always posts a DataBusMessage.
1600
2494
  onEvent: (eventType, payload, _sourceWorkerId, originTabId) => {
1601
2495
  if (eventType !== PUBLICATION_EVENT) return;
1602
2496
  const incoming = payload;
@@ -1604,22 +2498,21 @@ var CrossTabDataBus = class {
1604
2498
  if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
1605
2499
  },
1606
2500
  onSuspend: () => {
1607
- if (!this.stopping) this.trace.event({ type: "lifecycle", action: "suspend" });
2501
+ if (!this.stopping) this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.SUSPEND });
1608
2502
  this.trace.pause();
1609
- this.persistenceRetryGeneration += 1;
2503
+ this.replayManager.suspend();
1610
2504
  this.stopDedupSweep();
1611
- this.stopReplayRetentionSweep();
1612
2505
  this.suspendTransport();
1613
2506
  },
1614
2507
  onResume: () => {
1615
- this.trace.event({ type: "lifecycle", action: "resume" });
2508
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
1616
2509
  this.trace.start();
1617
2510
  this.startDedupSweep();
1618
- this.startReplayRetentionSweep();
2511
+ this.replayManager.start();
1619
2512
  this.resumeTransport();
1620
2513
  },
1621
2514
  onDiagnostic: (event) => {
1622
- this.trace.event({ type: "reliability", ...event });
2515
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, ...event });
1623
2516
  }
1624
2517
  }
1625
2518
  });
@@ -1629,40 +2522,51 @@ var CrossTabDataBus = class {
1629
2522
  * Start the DataBus with the given transport config.
1630
2523
  *
1631
2524
  * The first call starts the cluster and opens the transport. Concurrent calls
1632
- * during an in-flight start return the same promise. Once the operation
1633
- * settles (success or failure) the promise gate is cleared so a subsequent
1634
- * start() or resumeTransport() can open a fresh lifecycle.
2525
+ * during an in-flight open return the same promise. A call received while an
2526
+ * explicit stop() is settling queues one fresh start after cleanup; a later
2527
+ * stop() before that queued start runs cancels it, so the latest lifecycle
2528
+ * intent wins. Once an operation settles (success or failure) its promise
2529
+ * gate is cleared so a subsequent start() or resumeTransport() can open a
2530
+ * fresh lifecycle.
1635
2531
  */
1636
2532
  start(config) {
1637
- if (this.startPromise) return this.startPromise;
1638
- if (this.started) return Promise.resolve();
2533
+ if (this.queuedStart) return this.queuedStart;
2534
+ if (this.stopping) return this.queueStartAfterStop(config);
2535
+ if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
2536
+ if (this.started) {
2537
+ const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2538
+ if (!transportDown) return Promise.resolve();
2539
+ this.activeConfig = config;
2540
+ this.resetFailureState();
2541
+ return this.reopenTransport();
2542
+ }
1639
2543
  this.started = true;
1640
2544
  this.stopping = false;
1641
2545
  this.suspended = false;
1642
2546
  this.activeConfig = config;
1643
- this.lastError = null;
1644
- this.trace.event({ type: "lifecycle", action: "start" });
2547
+ this.resetFailureState();
2548
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
1645
2549
  this.trace.start();
1646
2550
  this.startDedupSweep();
1647
- this.startReplayRetentionSweep();
1648
- this.updateStatus("connecting");
2551
+ this.replayManager.start();
2552
+ this.updateStatus(WORKER_STATUS.CONNECTING);
1649
2553
  this.cluster.start();
1650
- const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
2554
+ const lifecycleEpoch = ++this.lifecycleEpoch;
2555
+ const opening = this.openTransport(
2556
+ config,
2557
+ this.pendingStop ?? Promise.resolve(),
2558
+ true,
2559
+ lifecycleEpoch
2560
+ );
1651
2561
  this.startPromise = opening;
1652
2562
  for (const topic of this.topicHandlers.keys()) {
1653
2563
  this.cluster.subscribe(topic);
1654
2564
  }
1655
- const snapshot = this.cluster.getSnapshot();
1656
- this.trace.event({
1657
- type: "coordination",
1658
- coordinated: snapshot.coordinated,
1659
- activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
1660
- workers: snapshot.workers.map(formatWorkerTrace),
1661
- routes: snapshot.routes.map(formatRouteTrace)
1662
- });
1663
2565
  void opening.then(
1664
2566
  () => {
1665
- if (this.startPromise === opening) this.startPromise = null;
2567
+ if (this.startPromise !== opening) return;
2568
+ this.emitCoordinationTrace();
2569
+ this.startPromise = null;
1666
2570
  },
1667
2571
  () => {
1668
2572
  if (this.startPromise === opening) this.startPromise = null;
@@ -1670,25 +2574,81 @@ var CrossTabDataBus = class {
1670
2574
  );
1671
2575
  return opening;
1672
2576
  }
2577
+ /** Return a cancellation-aware readiness view of the current queued start. */
2578
+ getQueuedStartReady() {
2579
+ const queued = this.queuedStart;
2580
+ if (!queued) {
2581
+ return Promise.reject(new Error("No queued start is in flight."));
2582
+ }
2583
+ const token = this.queuedStartToken;
2584
+ if (this.queuedStartReady && this.queuedStartReadyToken === token) {
2585
+ return this.queuedStartReady;
2586
+ }
2587
+ this.queuedStartReadyToken = token;
2588
+ this.queuedStartReady = queued.then(() => {
2589
+ if (token <= this.canceledQueuedStartToken) {
2590
+ throw new Error(
2591
+ "CrossTabDataBus start was canceled by a later stop(); ready() cannot report readiness. Call start() again after stop() resolves."
2592
+ );
2593
+ }
2594
+ if (!this.started || !this.transportReady) {
2595
+ throw new Error("CrossTabDataBus restart completed without a ready transport.");
2596
+ }
2597
+ });
2598
+ return this.queuedStartReady;
2599
+ }
2600
+ /** Queue exactly one fresh start after an in-flight explicit stop settles. */
2601
+ queueStartAfterStop(config) {
2602
+ if (this.queuedStart) return this.queuedStart;
2603
+ const stop = this.stopPromise ?? Promise.resolve();
2604
+ const token = ++this.queuedStartToken;
2605
+ const queued = stop.catch(() => void 0).then(() => {
2606
+ if (this.queuedStart === queued) this.queuedStart = null;
2607
+ if (token <= this.canceledQueuedStartToken) return;
2608
+ return this.start(config);
2609
+ });
2610
+ this.queuedStart = queued;
2611
+ return queued;
2612
+ }
2613
+ /** Reset failure and recovery diagnostics for a new explicit start session. */
2614
+ resetFailureState() {
2615
+ this.lastError = null;
2616
+ this.lastErrorAt = null;
2617
+ this.lastFailure = null;
2618
+ this.persistenceFailureCount = 0;
2619
+ this.persistenceLastFailureAt = null;
2620
+ this.persistenceLastErrorMessage = null;
2621
+ this.recoveryAttempt = 0;
2622
+ this.recoveryExhausted = false;
2623
+ this.lastRecoveryAt = 0;
2624
+ }
1673
2625
  /**
1674
2626
  * Open the transport, chained after `before` to ensure lifecycle ordering.
1675
2627
  * When `stopClusterOnFailure` is true (initial start), a transport failure
1676
2628
  * tears down the cluster as well.
1677
2629
  */
1678
- openTransport(config, before, stopClusterOnFailure) {
2630
+ openTransport(config, before, stopClusterOnFailure, lifecycleEpoch) {
1679
2631
  this.transportReady = false;
1680
2632
  const chainedPendingStop = this.pendingStop;
2633
+ const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
1681
2634
  return before.catch(() => void 0).then(() => {
1682
- if (this.stopping || this.suspended) return;
2635
+ if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
1683
2636
  if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
1684
2637
  return Promise.resolve(
1685
2638
  this.transport.start(config, {
1686
- onMessage: (message) => this.handleTransportMessage(message),
1687
- onStatus: (status) => this.updateStatus(status),
1688
- onError: (error) => this.reportError(error)
2639
+ onMessage: (message) => {
2640
+ if (isCurrentLifecycle()) this.handleTransportMessage(message);
2641
+ },
2642
+ onStatus: (status) => {
2643
+ if (isCurrentLifecycle()) this.updateStatus(status);
2644
+ },
2645
+ onError: (error) => {
2646
+ if (isCurrentLifecycle()) this.reportError(error);
2647
+ }
1689
2648
  })
1690
2649
  ).then(() => {
1691
- if (this.status === "error") {
2650
+ if (!isCurrentLifecycle()) return;
2651
+ if (this.status === WORKER_STATUS.ERROR) {
1692
2652
  throw new Error("Transport failed during startup.");
1693
2653
  }
1694
2654
  if (!this.suspended && !this.stopping) {
@@ -1698,11 +2658,12 @@ var CrossTabDataBus = class {
1698
2658
  }
1699
2659
  });
1700
2660
  }).catch((error) => {
2661
+ if (!isCurrentLifecycle()) throw error;
1701
2662
  if (stopClusterOnFailure) this.started = false;
1702
2663
  if (!this.pendingStop) {
1703
2664
  this.pendingStop = this.createStopPromise();
1704
2665
  }
1705
- this.updateStatus("error");
2666
+ this.updateStatus(WORKER_STATUS.ERROR);
1706
2667
  this.reportError(error);
1707
2668
  this.lastError = error;
1708
2669
  this.lastErrorAt = this.now();
@@ -1712,16 +2673,26 @@ var CrossTabDataBus = class {
1712
2673
  this.cluster.stop();
1713
2674
  this.stopping = false;
1714
2675
  }
1715
- this.startPromise = null;
1716
2676
  throw error;
1717
2677
  });
1718
2678
  }
1719
2679
  /**
1720
2680
  * Await the DataBus to be fully started (lazy init when using initialConfig).
1721
2681
  * Returns a rejected promise when the transport has failed and no start is in
1722
- * flight — the caller can retry by calling start() or ready() again.
2682
+ * flight — the caller can retry by calling start() or ready() again. While an
2683
+ * explicit stop() is settling, this rejects unless a restart is queued behind
2684
+ * it; false readiness during teardown is never reported.
1723
2685
  */
1724
2686
  ready() {
2687
+ if (this.queuedStart) return this.getQueuedStartReady();
2688
+ if (this.stopping) {
2689
+ return Promise.reject(new Error(
2690
+ "CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
2691
+ ));
2692
+ }
2693
+ if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
2694
+ return Promise.reject(this.lastError);
2695
+ }
1725
2696
  try {
1726
2697
  this.ensureStarted();
1727
2698
  } catch (error) {
@@ -1737,9 +2708,18 @@ var CrossTabDataBus = class {
1737
2708
  /**
1738
2709
  * Register a handler for `topic`. The handler fires on every publication
1739
2710
  * delivered to this tab, regardless of which tab published it. Returns an
1740
- * unsubscribe function for convenience.
2711
+ * unsubscribe function for convenience. During an explicit stop() the
2712
+ * registration is rejected through onError and a no-op cleanup is returned,
2713
+ * so a late subscriber cannot leak into a future restart.
1741
2714
  */
1742
2715
  subscribe(topic, handler, options) {
2716
+ if (this.stopping) {
2717
+ this.reportError(new Error(
2718
+ "CrossTabDataBus is stopping; subscribe() was not registered. Wait for stop() to resolve, then call start() before subscribing again."
2719
+ ));
2720
+ return () => {
2721
+ };
2722
+ }
1743
2723
  this.ensureStarted();
1744
2724
  const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
1745
2725
  const wasUnused = handlers.size === 0;
@@ -1747,17 +2727,12 @@ var CrossTabDataBus = class {
1747
2727
  this.topicHandlers.set(topic, handlers);
1748
2728
  if (wasUnused) this.cluster.subscribe(topic);
1749
2729
  if (options?.replay) {
1750
- const limit = Math.min(
1751
- typeof options.replay === "number" ? Math.floor(options.replay) : this.replayMaxPerTopic,
1752
- this.replayMaxPerTopic
2730
+ this.replayManager.deliverReplay(
2731
+ topic,
2732
+ options.replay,
2733
+ handler,
2734
+ () => Boolean(this.topicHandlers.get(topic)?.has(handler))
1753
2735
  );
1754
- if (this.replayPersistence) {
1755
- void this.replayHydration.then(() => {
1756
- if (this.topicHandlers.get(topic)?.has(handler)) this.deliverReplay(topic, limit, handler);
1757
- });
1758
- } else {
1759
- this.deliverReplay(topic, limit, handler);
1760
- }
1761
2736
  }
1762
2737
  return () => this.unsubscribe(topic, handler);
1763
2738
  }
@@ -1772,74 +2747,33 @@ var CrossTabDataBus = class {
1772
2747
  else handlers.clear();
1773
2748
  if (handlers.size > 0) return;
1774
2749
  this.topicHandlers.delete(topic);
1775
- this.replayBuffers?.delete(topic);
1776
- if (this.replayPersistence?.clearTopic) {
1777
- void this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic)).catch((error) => this.reportPersistenceError(error));
1778
- }
2750
+ this.replayManager.onTopicUnsubscribed(topic);
1779
2751
  this.cluster.unsubscribe(topic);
1780
2752
  }
1781
2753
  /** Clear all in-memory replay buffers and, when supported, durable history. */
1782
2754
  async clearReplay() {
1783
- this.replayBuffers?.clear();
1784
- if (this.replayPersistence?.clear) {
1785
- try {
1786
- await this.withPersistenceRetry("clear", () => this.replayPersistence.clear());
1787
- } catch (error) {
1788
- this.reportPersistenceError(error);
1789
- throw error;
1790
- }
1791
- }
2755
+ await this.replayManager.clearAll();
1792
2756
  }
1793
2757
  /** Clear replay history for one exact topic, including durable storage. */
1794
2758
  async clearReplayTopic(topic) {
1795
- this.replayBuffers?.delete(topic);
1796
- if (this.replayPersistence?.clearTopic) {
1797
- try {
1798
- await this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic));
1799
- } catch (error) {
1800
- this.reportPersistenceError(error);
1801
- throw error;
1802
- }
1803
- }
2759
+ await this.replayManager.clearTopic(topic);
1804
2760
  }
1805
2761
  /** Remove replay entries older than an epoch-millisecond cutoff. */
1806
2762
  async clearReplayBefore(timestamp) {
1807
- if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
1808
- if (this.replayBuffers) {
1809
- for (const [topic, messages] of this.replayBuffers) {
1810
- const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
1811
- if (kept.length) this.replayBuffers.set(topic, kept);
1812
- else this.replayBuffers.delete(topic);
1813
- }
1814
- }
1815
- if (this.replayPersistence?.clearBefore) {
1816
- try {
1817
- await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(timestamp));
1818
- } catch (error) {
1819
- this.reportPersistenceError(error);
1820
- throw error;
1821
- }
1822
- }
2763
+ await this.replayManager.clearBefore(timestamp);
1823
2764
  }
1824
2765
  /** Return bounded deduplication counters for diagnostics and health checks. */
1825
2766
  getDedupStats() {
1826
- return {
1827
- enabled: this.dedupEnabled,
1828
- tracked: this.seenMessageIds.size,
1829
- suppressed: this.dedupSuppressed,
1830
- accepted: this.dedupAccepted,
1831
- ...this.dedupAdaptiveBounds ? { ttlMs: this.currentDedupTtl() } : {}
1832
- };
2767
+ return this.dedupManager.getStats();
1833
2768
  }
1834
2769
  /** Drop all remembered IDs and reset dedup counters. */
1835
2770
  resetDedup() {
1836
- this.seenMessageIds.clear();
1837
- this.dedupSuppressed = 0;
1838
- this.dedupAccepted = 0;
2771
+ this.dedupManager.reset();
1839
2772
  }
1840
2773
  /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
1841
2774
  publish(topic, data, options) {
1842
2775
  this.ensureStarted();
2776
+ if (this.rejectPublishDuringStop("publish")) return;
1843
2777
  if (!this.cluster.publish(topic, data, options)) {
1844
2778
  this.reportError(
1845
2779
  new Error("Failed to send the publish control message to the owning worker.")
@@ -1856,6 +2790,7 @@ var CrossTabDataBus = class {
1856
2790
  publishBatch(topic, items) {
1857
2791
  this.ensureStarted();
1858
2792
  if (items.length === 0) return;
2793
+ if (this.rejectPublishDuringStop("publishBatch")) return;
1859
2794
  if (items.length === 1) {
1860
2795
  const first = items[0];
1861
2796
  this.publish(topic, first.data, first.options);
@@ -1863,8 +2798,7 @@ var CrossTabDataBus = class {
1863
2798
  }
1864
2799
  const mapped = items.map((item) => ({
1865
2800
  data: item.data,
1866
- ...item.options?.messageId !== void 0 ? { messageId: item.options.messageId } : {},
1867
- ...item.options?.timestamp !== void 0 ? { timestamp: item.options.timestamp } : {}
2801
+ ...publicationMetadata(item.options?.messageId, item.options?.timestamp)
1868
2802
  }));
1869
2803
  if (!this.cluster.publishBatch(topic, mapped)) {
1870
2804
  this.reportError(
@@ -1909,6 +2843,41 @@ var CrossTabDataBus = class {
1909
2843
  lastSuccessAt: this.lastSuccessAt
1910
2844
  };
1911
2845
  }
2846
+ /** Bounded failure counters for the replay persistence backend. */
2847
+ getPersistenceStats() {
2848
+ return {
2849
+ failures: this.persistenceFailureCount,
2850
+ lastFailureAt: this.persistenceLastFailureAt,
2851
+ lastErrorMessage: this.persistenceLastErrorMessage
2852
+ };
2853
+ }
2854
+ /** Compact health verdict for dashboards, readiness probes, and support
2855
+ * bundles. Answers "is the bus usable right now" first, then attaches the
2856
+ * unified failure ledger and recovery context that explains the verdict. */
2857
+ getHealthSummary() {
2858
+ const transport = this.transport;
2859
+ const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2860
+ const state = !this.started ? HEALTH_STATE.STOPPED : this.suspended ? HEALTH_STATE.SUSPENDED : transportDown ? this.recoveryExhausted ? HEALTH_STATE.DEGRADED : this.status === WORKER_STATUS.CONNECTING && this.recoveryAttempt === 0 ? HEALTH_STATE.STARTING : HEALTH_STATE.RECOVERING : HEALTH_STATE.HEALTHY;
2861
+ return {
2862
+ healthy: state === HEALTH_STATE.HEALTHY,
2863
+ state,
2864
+ status: this.status,
2865
+ sdkVersion: SDK_VERSION,
2866
+ started: this.started,
2867
+ suspended: this.suspended,
2868
+ transport: {
2869
+ name: transport.diagnosticsName ?? transport.constructor.name,
2870
+ backend: transport.diagnosticsBackend ?? null,
2871
+ ready: this.transportReady,
2872
+ status: this.status
2873
+ },
2874
+ recovery: this.getRecoveryStats(),
2875
+ lastFailure: this.lastFailure,
2876
+ persistence: this.getPersistenceStats(),
2877
+ metrics: this.trace.getMetrics(),
2878
+ trace: this.trace.getSinkState()
2879
+ };
2880
+ }
1912
2881
  /** Snapshot of the cluster state (workers, routes, assignments).
1913
2882
  * For diagnostics only — the returned object is a shallow copy but
1914
2883
  * nested arrays are snapshots at call time. */
@@ -1917,8 +2886,7 @@ var CrossTabDataBus = class {
1917
2886
  }
1918
2887
  /** Return a single health snapshot combining lifecycle, recovery, dedup, replay, and cluster state. */
1919
2888
  getDiagnostics() {
1920
- let messages = 0;
1921
- if (this.replayBuffers) for (const buffer of this.replayBuffers.values()) messages += buffer.length;
2889
+ const replay = this.replayManager.getStats();
1922
2890
  const cluster = this.cluster.getSnapshot();
1923
2891
  const unknownMessages = this.cluster.getUnknownMessageStats();
1924
2892
  const transport = this.transport;
@@ -1929,26 +2897,62 @@ var CrossTabDataBus = class {
1929
2897
  transportReady: this.transportReady,
1930
2898
  recovery: this.getRecoveryStats(),
1931
2899
  dedup: this.getDedupStats(),
1932
- replay: { enabled: Boolean(this.replayBuffers), topics: this.replayBuffers?.size ?? 0, messages },
2900
+ replay: { enabled: replay.enabled, topics: replay.topics, messages: replay.messages, bytes: replay.bytes },
2901
+ persistence: this.getPersistenceStats(),
1933
2902
  protocol: { version: cluster.protocolVersion, unknownMessages: unknownMessages.count, lastUnknownMessageType: unknownMessages.lastType, peers: cluster.peerProtocolVersions },
1934
- transport: { name: transport.diagnosticsName ?? transport.constructor.name, backend: transport.diagnosticsBackend ?? null },
1935
- cluster
2903
+ transport: {
2904
+ name: transport.diagnosticsName ?? transport.constructor.name,
2905
+ backend: transport.diagnosticsBackend ?? null,
2906
+ status: this.status,
2907
+ suspended: this.suspended
2908
+ },
2909
+ cluster,
2910
+ metrics: this.trace.getMetrics(),
2911
+ trace: this.trace.getSinkState()
1936
2912
  };
1937
2913
  }
2914
+ /** Synchronous snapshot of the current trace metrics window (throughput,
2915
+ * dispatch latency, dedup outcomes), without flushing or resetting it.
2916
+ * Returns null when trace metrics are inactive (disabled or events-only). */
2917
+ getMetrics() {
2918
+ return this.trace.getMetrics();
2919
+ }
1938
2920
  /**
1939
2921
  * Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
1940
- * and close the transport. Idempotent.
2922
+ * and close the transport. Concurrent and repeated calls share the in-flight
2923
+ * stop promise. A start() received while stopping runs after this completes,
2924
+ * unless another stop() arrives first and cancels that queued restart.
1941
2925
  */
1942
- async stop() {
1943
- if (!this.started) return;
2926
+ stop() {
2927
+ if (this.queuedStart) {
2928
+ this.canceledQueuedStartToken = this.queuedStartToken;
2929
+ this.queuedStart = null;
2930
+ }
2931
+ if (this.stopPromise) return this.stopPromise;
2932
+ if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
2933
+ return Promise.resolve();
2934
+ }
2935
+ const stopPromise = this.performStop();
2936
+ this.stopPromise = stopPromise;
2937
+ void stopPromise.then(
2938
+ () => {
2939
+ if (this.stopPromise === stopPromise) this.stopPromise = null;
2940
+ },
2941
+ () => {
2942
+ if (this.stopPromise === stopPromise) this.stopPromise = null;
2943
+ }
2944
+ );
2945
+ return stopPromise;
2946
+ }
2947
+ async performStop() {
2948
+ this.lifecycleEpoch += 1;
1944
2949
  this.stopping = true;
1945
- this.persistenceRetryGeneration += 1;
1946
- this.trace.event({ type: "lifecycle", action: "stop" });
2950
+ this.replayManager.suspend();
2951
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
1947
2952
  this.trace.stop();
1948
2953
  this.stopDedupSweep();
1949
- this.stopReplayRetentionSweep();
1950
2954
  this.topicHandlers.clear();
1951
- this.replayBuffers?.clear();
2955
+ this.replayManager.resetBuffers();
1952
2956
  this.cluster.stop();
1953
2957
  try {
1954
2958
  await this.startPromise?.catch(() => void 0);
@@ -1969,7 +2973,7 @@ var CrossTabDataBus = class {
1969
2973
  this.activeConfig = void 0;
1970
2974
  this.recoveryAttempt = 0;
1971
2975
  this.recoveryExhausted = false;
1972
- this.updateStatus("disconnected");
2976
+ this.updateStatus(WORKER_STATUS.DISCONNECTED);
1973
2977
  }
1974
2978
  }
1975
2979
  /**
@@ -1978,7 +2982,7 @@ var CrossTabDataBus = class {
1978
2982
  * and dispatches locally.
1979
2983
  */
1980
2984
  handleTransportMessage(message) {
1981
- if (this.isDuplicate(message)) return;
2985
+ if (this.dedupManager.isDuplicate(message.messageId ?? "", message.topic)) return;
1982
2986
  this.trace.recordReceived(message.topic);
1983
2987
  if (!this.cluster.isAssigned(message.topic)) {
1984
2988
  this.trace.recordDiscarded(message.topic);
@@ -1992,55 +2996,13 @@ var CrossTabDataBus = class {
1992
2996
  }
1993
2997
  this.trace.recordDiscarded(message.topic);
1994
2998
  }
1995
- isDuplicate(message) {
1996
- if (!this.dedupEnabled || !message.messageId) return false;
1997
- const now = this.now();
1998
- for (const [id, timestamp] of this.seenMessageIds) {
1999
- if (now - timestamp > this.dedupTtlMs) this.seenMessageIds.delete(id);
2000
- }
2001
- if (this.seenMessageIds.has(message.messageId)) {
2002
- this.trace.event({ type: "reliability", operation: "dedup_suppressed", topic: message.topic });
2003
- this.dedupSuppressed += 1;
2004
- this.trace.recordDedupSuppressed();
2005
- return true;
2006
- }
2007
- this.seenMessageIds.set(message.messageId, now);
2008
- this.dedupAccepted += 1;
2009
- this.dedupWindowAccepted += 1;
2010
- this.trace.recordDedupAccepted();
2011
- while (this.seenMessageIds.size > this.dedupMaxEntries) {
2012
- const oldest = this.seenMessageIds.keys().next().value;
2013
- if (oldest === void 0) break;
2014
- this.seenMessageIds.delete(oldest);
2015
- }
2016
- return false;
2017
- }
2999
+ /** Start enqueuing the dedup expiry sweep (delegated to {@link DedupManager}). */
2018
3000
  startDedupSweep() {
2019
- if (this.dedupSweepTimer || !this.dedupEnabled || !this.dedupSweepMs) return;
2020
- this.dedupSweepTimer = setInterval(() => this.pruneExpiredDedup(), this.dedupSweepMs);
3001
+ this.dedupManager.start();
2021
3002
  }
3003
+ /** Stop enqueuing the dedup expiry sweep. */
2022
3004
  stopDedupSweep() {
2023
- if (this.dedupSweepTimer) clearInterval(this.dedupSweepTimer);
2024
- this.dedupSweepTimer = null;
2025
- }
2026
- pruneExpiredDedup() {
2027
- const cutoff = this.now() - this.currentDedupTtl();
2028
- for (const [id, timestamp] of this.seenMessageIds) {
2029
- if (timestamp < cutoff) this.seenMessageIds.delete(id);
2030
- }
2031
- }
2032
- currentDedupTtl() {
2033
- if (!this.dedupAdaptiveBounds) return this.dedupTtlMs;
2034
- const now = this.now();
2035
- const elapsed = now - this.dedupWindowStartedAt;
2036
- if (elapsed >= this.dedupAdaptiveWindowMs) {
2037
- this.dedupWindowStartedAt = now;
2038
- this.dedupWindowAccepted = 0;
2039
- return this.dedupAdaptiveBounds.maxMs;
2040
- }
2041
- const rate = this.dedupWindowAccepted / Math.max(1, elapsed);
2042
- const factor = Math.min(1, rate / 0.01);
2043
- return this.dedupAdaptiveBounds.maxMs - (this.dedupAdaptiveBounds.maxMs - this.dedupAdaptiveBounds.minMs) * factor;
3005
+ this.dedupManager.stop();
2044
3006
  }
2045
3007
  /** Deliver a message to every local handler registered for its topic,
2046
3008
  * plus every handler registered with a wildcard subscription that matches
@@ -2053,153 +3015,7 @@ var CrossTabDataBus = class {
2053
3015
  this.invokeHandlers(handlers, (handler) => handler(message));
2054
3016
  }
2055
3017
  }
2056
- this.recordReplay(message);
2057
- }
2058
- /** Append a dispatched publication to the topic's replay ring buffer.
2059
- * No-op when replay is disabled. */
2060
- recordReplay(message) {
2061
- if (!this.replayBuffers) return;
2062
- let buffer = this.replayBuffers.get(message.topic);
2063
- if (!buffer) {
2064
- buffer = [];
2065
- this.replayBuffers.set(message.topic, buffer);
2066
- }
2067
- const storedMessage = message;
2068
- buffer.push(storedMessage);
2069
- if (this.replayPruneStrategy !== "age") {
2070
- while (buffer.length > this.replayMaxPerTopic) buffer.shift();
2071
- }
2072
- if (this.replayPruneStrategy !== "count" && this.replayRetentionMs !== void 0) {
2073
- const cutoff = this.now() - this.replayRetentionMs;
2074
- while (buffer.length > 0) {
2075
- const first = buffer[0];
2076
- if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
2077
- buffer.shift();
2078
- }
2079
- }
2080
- if (this.replayPersistence) {
2081
- if (this.replayPersistence.appendBatch) {
2082
- this.pendingReplayPersistence.push(storedMessage);
2083
- this.scheduleReplayPersistenceFlush();
2084
- } else {
2085
- void this.withPersistenceRetry("append", () => this.replayPersistence.append(storedMessage)).catch((error) => this.reportPersistenceError(error));
2086
- }
2087
- if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
2088
- this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
2089
- }
2090
- }
2091
- }
2092
- scheduleReplayPersistenceFlush() {
2093
- if (this.replayPersistenceFlushScheduled) return;
2094
- this.replayPersistenceFlushScheduled = true;
2095
- queueMicrotask(() => {
2096
- this.replayPersistenceFlushScheduled = false;
2097
- const batch = this.pendingReplayPersistence.splice(0);
2098
- if (batch.length === 0 || !this.replayPersistence) return;
2099
- const operation = this.replayPersistence.appendBatch ? () => this.replayPersistence.appendBatch(batch) : () => Promise.all(batch.map((message) => this.replayPersistence.append(message))).then(() => void 0);
2100
- void this.withPersistenceRetry("append", operation).catch((error) => this.reportPersistenceError(error));
2101
- });
2102
- }
2103
- async hydrateReplay() {
2104
- if (!this.replayBuffers || !this.replayPersistence) {
2105
- return;
2106
- }
2107
- try {
2108
- if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
2109
- await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(this.now() - this.replayRetentionMs));
2110
- }
2111
- for (const message of await this.withPersistenceRetry("load", () => this.replayPersistence.load())) {
2112
- let buffer = this.replayBuffers.get(message.topic);
2113
- if (!buffer) {
2114
- buffer = [];
2115
- this.replayBuffers.set(message.topic, buffer);
2116
- }
2117
- buffer.push(message);
2118
- if (buffer.length > this.replayMaxPerTopic) buffer.shift();
2119
- }
2120
- } catch (error) {
2121
- this.reportPersistenceError(error);
2122
- }
2123
- }
2124
- scheduleReplayRetentionCleanup(cutoff) {
2125
- if (!this.replayPersistence?.clearBefore) return;
2126
- if (this.replayRetentionCutoff === null || cutoff > this.replayRetentionCutoff) {
2127
- this.replayRetentionCutoff = cutoff;
2128
- }
2129
- if (this.replayRetentionCleanup) return;
2130
- this.replayRetentionCleanup = (async () => {
2131
- while (this.replayRetentionCutoff !== null) {
2132
- const nextCutoff = this.replayRetentionCutoff;
2133
- this.replayRetentionCutoff = null;
2134
- try {
2135
- await this.replayPersistence.clearBefore(nextCutoff);
2136
- } catch (error) {
2137
- this.reportPersistenceError(error);
2138
- }
2139
- }
2140
- })().finally(() => {
2141
- this.replayRetentionCleanup = null;
2142
- if (this.replayRetentionCutoff !== null) {
2143
- this.scheduleReplayRetentionCleanup(this.replayRetentionCutoff);
2144
- }
2145
- });
2146
- }
2147
- startReplayRetentionSweep() {
2148
- if (this.replayRetentionTimer || !this.replayRetentionMs || !this.replayRetentionSweepMs || !this.replayPersistence?.clearBefore) return;
2149
- this.replayRetentionTimer = setInterval(() => {
2150
- this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
2151
- }, this.replayRetentionSweepMs);
2152
- }
2153
- stopReplayRetentionSweep() {
2154
- if (this.replayRetentionTimer) clearInterval(this.replayRetentionTimer);
2155
- this.replayRetentionTimer = null;
2156
- }
2157
- async withPersistenceRetry(persistenceOperation, operation) {
2158
- const generation = this.persistenceRetryGeneration;
2159
- let attempt = 0;
2160
- let delay = this.persistenceRetryBackoffMs;
2161
- while (true) {
2162
- attempt += 1;
2163
- try {
2164
- if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
2165
- return await operation();
2166
- } catch (error) {
2167
- if (error instanceof PersistenceRetryCancelledError || generation !== this.persistenceRetryGeneration) {
2168
- throw new PersistenceRetryCancelledError();
2169
- }
2170
- if (attempt >= this.persistenceRetryMaxAttempts) throw error;
2171
- this.trace.event({
2172
- type: "reliability",
2173
- operation: "persistence_retry",
2174
- persistenceOperation,
2175
- attempt
2176
- });
2177
- if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
2178
- if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
2179
- delay = Math.min(delay * 2, 1600);
2180
- }
2181
- }
2182
- }
2183
- /** Deliver buffered history to a newly-registered handler. For an exact
2184
- * topic this is that topic's ring; for a wildcard subscription every
2185
- * buffered topic matching the pattern contributes (in buffer insertion
2186
- * order). Replay deliveries are marked `replayed: true` and are not
2187
- * counted into trace metrics. */
2188
- deliverReplay(topic, limit, handler) {
2189
- if (!this.replayBuffers || limit <= 0) return;
2190
- const deliver = (buffer2) => {
2191
- for (const message of buffer2.slice(-limit)) {
2192
- this.invokeHandlers([handler], (h) => h({ ...message, replayed: true }));
2193
- }
2194
- };
2195
- if (isWildcardTopic(topic)) {
2196
- for (const [bufferedTopic, buffer2] of this.replayBuffers) {
2197
- if (topicMatchesPattern(topic, bufferedTopic)) deliver(buffer2);
2198
- }
2199
- return;
2200
- }
2201
- const buffer = this.replayBuffers.get(topic);
2202
- if (buffer) deliver(buffer);
3018
+ this.replayManager.record(message);
2203
3019
  }
2204
3020
  /**
2205
3021
  * Propagate a status change to the cluster, trace, and all registered
@@ -2208,13 +3024,13 @@ var CrossTabDataBus = class {
2208
3024
  updateStatus(status) {
2209
3025
  const previousStatus = this.status;
2210
3026
  this.status = status;
2211
- if (previousStatus !== status) this.trace.event({ type: "status", status });
3027
+ if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
2212
3028
  this.cluster.setStatus(status);
2213
- if (status === "disconnected" || status === "error") this.transportSubscribedTopics.clear();
2214
- if (status === "connected" && previousStatus !== "connected") {
3029
+ if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
3030
+ if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
2215
3031
  for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
2216
3032
  }
2217
- if (status === "error" && this.started && !this.stopping) {
3033
+ if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
2218
3034
  const now = this.now();
2219
3035
  if (now - this.lastRecoveryAt >= this.recoveryCooldownMs) {
2220
3036
  this.lastRecoveryAt = now;
@@ -2222,37 +3038,67 @@ var CrossTabDataBus = class {
2222
3038
  if (attempt > this.recoveryMaxAttempts) {
2223
3039
  if (!this.recoveryExhausted) {
2224
3040
  this.recoveryExhausted = true;
2225
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: this.recoveryMaxAttempts, outcome: "exhausted" });
3041
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
2226
3042
  }
2227
3043
  return;
2228
3044
  }
2229
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt, outcome: "scheduled" });
3045
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
2230
3046
  setTimeout(() => {
2231
3047
  if (this.stopping || !this.started || this.suspended) return;
2232
- if (this.status !== "error") return;
3048
+ if (this.status !== WORKER_STATUS.ERROR) return;
2233
3049
  void this.reopenTransport(attempt);
2234
3050
  }, this.recoveryCooldownMs);
2235
3051
  }
2236
3052
  }
2237
3053
  this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
2238
3054
  }
2239
- reportError(error) {
2240
- this.trace.event({ type: "error", source: "transport" });
2241
- this.invokeHandlers(this.errorHandlers, (handler) => handler(error), "error handler");
3055
+ reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
3056
+ this.lastFailure = {
3057
+ source,
3058
+ message: error instanceof Error ? error.message : String(error),
3059
+ at: this.now()
3060
+ };
3061
+ if (source === FAILURE_SOURCE.PERSISTENCE) {
3062
+ this.persistenceFailureCount += 1;
3063
+ this.persistenceLastFailureAt = this.lastFailure.at;
3064
+ this.persistenceLastErrorMessage = this.lastFailure.message;
3065
+ }
3066
+ this.trace.event({
3067
+ type: TRACE_EVENT_TYPE.ERROR,
3068
+ source: source === FAILURE_SOURCE.TRANSPORT ? TRACE_ERROR_SOURCE.TRANSPORT : TRACE_ERROR_SOURCE.OPERATION
3069
+ });
3070
+ this.invokeHandlers(this.errorHandlers, (handler) => handler(error), INVOKE_LABEL.ERROR_HANDLER);
2242
3071
  }
3072
+ /** Report a persistence failure to the trace and the unified failure ledger,
3073
+ * unless it is a {@link PersistenceRetryCancelledError} cancellation from a
3074
+ * lifecycle transition (teardown should stay quiet). */
2243
3075
  reportPersistenceError(error) {
2244
3076
  if (error instanceof PersistenceRetryCancelledError) return;
2245
- this.trace.event({ type: "reliability", operation: "persistence_cleanup" });
2246
- this.reportError(error);
3077
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.PERSISTENCE_CLEANUP });
3078
+ this.reportError(error, FAILURE_SOURCE.PERSISTENCE);
2247
3079
  }
2248
3080
  traceSubscription(action, topic) {
2249
3081
  this.trace.event({
2250
- type: "subscription",
3082
+ type: TRACE_EVENT_TYPE.SUBSCRIPTION,
2251
3083
  action,
2252
3084
  topic,
2253
3085
  activeTopics: this.transportSubscribedTopics.size
2254
3086
  });
2255
3087
  }
3088
+ /** Emit the coordination trace snapshot from the current cluster state.
3089
+ * Called after a transport opens (start and recovery), when the role and
3090
+ * route picture has settled — the synchronous pre-open snapshot would see no
3091
+ * routes because their writes are still coalesced in the batch writer. */
3092
+ emitCoordinationTrace() {
3093
+ const snapshot = this.cluster.getSnapshot();
3094
+ this.trace.event({
3095
+ type: TRACE_EVENT_TYPE.COORDINATION,
3096
+ coordinated: snapshot.coordinated,
3097
+ activeWorkers: snapshot.workers.filter((worker) => worker.role === WORKER_ROLE.ACTIVE).length,
3098
+ workers: snapshot.workers.map(formatWorkerTrace),
3099
+ routes: snapshot.routes.map(formatRouteTrace)
3100
+ });
3101
+ }
2256
3102
  /** Ask the transport to subscribe to a topic (idempotent). */
2257
3103
  subscribeTransport(topic) {
2258
3104
  if (this.transportSubscribedTopics.has(topic)) return false;
@@ -2270,17 +3116,17 @@ var CrossTabDataBus = class {
2270
3116
  * are routed to `reportError` (which surfaces them to error subscribers);
2271
3117
  * error-handler failures are logged to the console to avoid infinite
2272
3118
  * recursion through reportError itself. */
2273
- invokeHandlers(handlers, callback, label = "dispatch") {
3119
+ invokeHandlers(handlers, callback, label = INVOKE_LABEL.DISPATCH) {
2274
3120
  for (const handler of handlers) {
2275
3121
  try {
2276
3122
  callback(handler);
2277
3123
  } catch (error) {
2278
- if (label === "error handler") {
3124
+ if (label === INVOKE_LABEL.ERROR_HANDLER) {
2279
3125
  if (typeof console !== "undefined" && typeof console.warn === "function") {
2280
- console.warn("[cross-tab-worker-databus] error handler threw:", error);
3126
+ console.warn(`[${DEFAULT_STORAGE_PREFIX}] error handler threw:`, error);
2281
3127
  }
2282
3128
  } else {
2283
- this.reportError(error);
3129
+ this.reportError(error, FAILURE_SOURCE.DISPATCH);
2284
3130
  }
2285
3131
  }
2286
3132
  }
@@ -2291,10 +3137,11 @@ var CrossTabDataBus = class {
2291
3137
  */
2292
3138
  suspendTransport() {
2293
3139
  if (this.stopping) return;
3140
+ this.lifecycleEpoch += 1;
2294
3141
  this.suspended = true;
2295
3142
  this.transportReady = false;
2296
3143
  this.transportSubscribedTopics.clear();
2297
- this.updateStatus("disconnected");
3144
+ this.updateStatus(WORKER_STATUS.DISCONNECTED);
2298
3145
  if (this.pendingStop) return;
2299
3146
  const pending = this.startPromise ?? Promise.resolve();
2300
3147
  const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
@@ -2328,22 +3175,26 @@ var CrossTabDataBus = class {
2328
3175
  const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
2329
3176
  this.started = true;
2330
3177
  this.suspended = false;
2331
- this.updateStatus("connecting");
3178
+ this.updateStatus(WORKER_STATUS.CONNECTING);
3179
+ const lifecycleEpoch = ++this.lifecycleEpoch;
2332
3180
  const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
2333
- const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
3181
+ const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
2334
3182
  this.startPromise = opening;
2335
3183
  void opening.then(
2336
3184
  () => {
3185
+ if (this.startPromise === opening) this.startPromise = null;
3186
+ if (lifecycleEpoch !== this.lifecycleEpoch) return;
2337
3187
  if (traceAttempt !== void 0) {
2338
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: traceAttempt, outcome: "succeeded" });
3188
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
2339
3189
  this.recoveryAttempt = 0;
2340
3190
  this.recoveryExhausted = false;
2341
3191
  }
2342
- if (this.startPromise === opening) this.startPromise = null;
2343
3192
  },
2344
3193
  () => {
3194
+ if (this.startPromise === opening) this.startPromise = null;
3195
+ if (lifecycleEpoch !== this.lifecycleEpoch) return;
2345
3196
  if (traceAttempt !== void 0) {
2346
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: traceAttempt, outcome: "failed" });
3197
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
2347
3198
  }
2348
3199
  }
2349
3200
  );
@@ -2375,6 +3226,19 @@ var CrossTabDataBus = class {
2375
3226
  return operation();
2376
3227
  }).catch((error) => this.reportError(error));
2377
3228
  }
3229
+ /**
3230
+ * Publications started after teardown begins cannot reach any transport.
3231
+ * Surface that as a normal asynchronous API failure instead of letting
3232
+ * runTransport() return silently. Empty publishBatch() calls remain a no-op
3233
+ * and are filtered by the caller before this check.
3234
+ */
3235
+ rejectPublishDuringStop(operation) {
3236
+ if (!this.stopping) return false;
3237
+ this.reportError(new Error(
3238
+ `CrossTabDataBus is stopping; ${operation}() was not sent. Wait for stop() to resolve, then call start() before publishing again.`
3239
+ ));
3240
+ return true;
3241
+ }
2378
3242
  /**
2379
3243
  * Ensure the DataBus is started, throwing if no initialConfig was provided.
2380
3244
  * Called automatically by subscribe/publish/ready when autoStart is true.
@@ -2401,16 +3265,14 @@ function formatRouteTrace(route) {
2401
3265
  function createIndexedDbReplayPersistence(options) {
2402
3266
  const indexedDb = globalThis.indexedDB;
2403
3267
  if (!indexedDb) throw new Error("IndexedDB is unavailable in this environment.");
2404
- const dbName = options.dbName ?? "cross-tab-worker-databus";
3268
+ const dbName = options.dbName ?? DEFAULT_STORAGE_PREFIX;
2405
3269
  const storeName = "replay";
2406
3270
  const maxPerTopic = options.maxPerTopic;
2407
- const pruneStrategy = options.pruneStrategy ?? "count";
3271
+ const pruneStrategy = options.pruneStrategy ?? PRUNE_STRATEGY.COUNT;
2408
3272
  const retentionMs = options.retentionMs;
2409
- if (!["count", "age", "both"].includes(pruneStrategy)) throw new TypeError("pruneStrategy must be count, age, or both.");
2410
- if (retentionMs !== void 0 && (!Number.isFinite(retentionMs) || retentionMs <= 0)) throw new TypeError("retentionMs must be a positive finite number.");
2411
- if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
2412
- throw new TypeError(`maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`);
2413
- }
3273
+ assertPruneStrategy(pruneStrategy);
3274
+ if (retentionMs !== void 0) assertPositiveFiniteNumber(retentionMs, "retentionMs");
3275
+ assertPositiveSafeInteger(maxPerTopic, "maxPerTopic");
2414
3276
  let dbPromise = null;
2415
3277
  const invalidate = (db) => {
2416
3278
  if (dbPromise) {
@@ -2422,15 +3284,94 @@ function createIndexedDbReplayPersistence(options) {
2422
3284
  }, () => void 0);
2423
3285
  }
2424
3286
  };
2425
- let mutationQueue = Promise.resolve();
2426
- const serializeMutation = (mutation) => {
2427
- const next = mutationQueue.then(mutation, mutation);
2428
- mutationQueue = next.catch(() => void 0);
2429
- return next;
3287
+ const pending = [];
3288
+ let draining = false;
3289
+ const drain = async () => {
3290
+ if (draining) return;
3291
+ draining = true;
3292
+ try {
3293
+ await Promise.resolve();
3294
+ while (pending.length > 0) {
3295
+ const head = pending[0].mutation;
3296
+ if (head.kind === "batch") {
3297
+ const merged = [];
3298
+ const entries = [];
3299
+ while (pending.length > 0) {
3300
+ const next = pending[0].mutation;
3301
+ if (next.kind !== "batch") break;
3302
+ merged.push(...next.messages);
3303
+ entries.push({ resolve: pending[0].resolve, reject: pending[0].reject });
3304
+ pending.shift();
3305
+ }
3306
+ try {
3307
+ await appendTransaction(merged);
3308
+ for (const entry of entries) entry.resolve();
3309
+ } catch (error) {
3310
+ for (const entry of entries) entry.reject(error);
3311
+ }
3312
+ } else {
3313
+ const entry = pending.shift();
3314
+ try {
3315
+ await head.run();
3316
+ entry.resolve();
3317
+ } catch (error) {
3318
+ entry.reject(error);
3319
+ }
3320
+ }
3321
+ }
3322
+ } finally {
3323
+ draining = false;
3324
+ }
2430
3325
  };
3326
+ const enqueue = (mutation) => new Promise((resolve, reject) => {
3327
+ pending.push({ mutation, resolve, reject });
3328
+ void drain();
3329
+ });
3330
+ const appendTransaction = (messages) => (async () => {
3331
+ const db = await open();
3332
+ return new Promise((resolve, reject) => {
3333
+ let transaction;
3334
+ try {
3335
+ transaction = db.transaction(storeName, "readwrite");
3336
+ } catch (error) {
3337
+ invalidate(db);
3338
+ reject(error);
3339
+ return;
3340
+ }
3341
+ const store = transaction.objectStore(storeName);
3342
+ const grouped = /* @__PURE__ */ new Map();
3343
+ for (const message of messages) {
3344
+ grouped.set(message.topic, [...grouped.get(message.topic) ?? [], message]);
3345
+ }
3346
+ let hasError = false;
3347
+ const fail = (error) => {
3348
+ if (hasError) return;
3349
+ hasError = true;
3350
+ invalidate(db);
3351
+ reject(error);
3352
+ };
3353
+ for (const [topic, topicMessages] of grouped) {
3354
+ const request = store.get(topic);
3355
+ request.onsuccess = () => {
3356
+ if (hasError) return;
3357
+ const history = pruneReplayHistory(
3358
+ (request.result?.messages ?? []).concat(topicMessages),
3359
+ { maxPerTopic, pruneStrategy, retentionMs, now: Date.now() }
3360
+ );
3361
+ store.put({ topic, messages: history });
3362
+ };
3363
+ request.onerror = () => fail(request.error ?? new Error("Failed to read replay history."));
3364
+ }
3365
+ transaction.oncomplete = () => {
3366
+ if (!hasError) resolve();
3367
+ };
3368
+ transaction.onerror = () => fail(transaction.error ?? new Error("Failed to persist replay history."));
3369
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to persist replay history."));
3370
+ });
3371
+ })();
2431
3372
  const open = () => {
2432
3373
  if (dbPromise) return dbPromise;
2433
- const pending = new Promise((resolve, reject) => {
3374
+ const pending2 = new Promise((resolve, reject) => {
2434
3375
  const request = indexedDb.open(dbName, 1);
2435
3376
  request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath: "topic" });
2436
3377
  request.onsuccess = () => {
@@ -2443,179 +3384,156 @@ function createIndexedDbReplayPersistence(options) {
2443
3384
  };
2444
3385
  request.onerror = () => reject(request.error ?? new Error("Failed to open replay database."));
2445
3386
  });
2446
- dbPromise = pending;
2447
- void pending.catch(() => {
2448
- if (dbPromise === pending) dbPromise = null;
3387
+ dbPromise = pending2;
3388
+ void pending2.catch(() => {
3389
+ if (dbPromise === pending2) dbPromise = null;
2449
3390
  });
2450
- return pending;
3391
+ return pending2;
2451
3392
  };
2452
3393
  return {
2453
3394
  async load() {
2454
3395
  const db = await open();
2455
3396
  return new Promise((resolve, reject) => {
3397
+ let transaction;
2456
3398
  let request;
2457
3399
  try {
2458
- request = db.transaction(storeName, "readonly").objectStore(storeName).getAll();
3400
+ transaction = db.transaction(storeName, "readonly");
3401
+ request = transaction.objectStore(storeName).getAll();
2459
3402
  } catch (error) {
2460
3403
  invalidate(db);
2461
3404
  reject(error);
2462
3405
  return;
2463
3406
  }
2464
- request.onsuccess = () => resolve(request.result.flatMap((record) => record.messages));
2465
- request.onerror = () => {
3407
+ let settled = false;
3408
+ const fail = (error) => {
3409
+ if (settled) return;
3410
+ settled = true;
2466
3411
  invalidate(db);
2467
- reject(request.error ?? new Error("Failed to load replay history."));
3412
+ reject(error);
3413
+ };
3414
+ let records = [];
3415
+ request.onsuccess = () => {
3416
+ records = request.result;
3417
+ };
3418
+ request.onerror = () => fail(request.error ?? new Error("Failed to load replay history."));
3419
+ transaction.oncomplete = () => {
3420
+ if (settled) return;
3421
+ settled = true;
3422
+ resolve(records.flatMap((record) => record.messages));
2468
3423
  };
3424
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to load replay history."));
2469
3425
  });
2470
3426
  },
2471
3427
  append(message) {
2472
- return serializeMutation(async () => {
2473
- const db = await open();
2474
- await new Promise((resolve, reject) => {
2475
- let transaction;
2476
- try {
2477
- transaction = db.transaction(storeName, "readwrite");
2478
- } catch (error) {
2479
- invalidate(db);
2480
- reject(error);
2481
- return;
2482
- }
2483
- const store = transaction.objectStore(storeName);
2484
- const request = store.get(message.topic);
2485
- request.onsuccess = () => {
2486
- let messages = (request.result?.messages ?? []).concat(message);
2487
- if (pruneStrategy !== "count" && retentionMs !== void 0) {
2488
- const cutoff = Date.now() - retentionMs;
2489
- messages = messages.filter((item) => item.timestamp === void 0 || item.timestamp >= cutoff);
2490
- }
2491
- if (pruneStrategy !== "age") messages = messages.slice(-maxPerTopic);
2492
- store.put({ topic: message.topic, messages });
2493
- };
2494
- request.onerror = () => {
2495
- invalidate(db);
2496
- reject(request.error ?? new Error("Failed to read replay history."));
2497
- };
2498
- transaction.oncomplete = () => resolve();
2499
- transaction.onerror = () => {
2500
- invalidate(db);
2501
- reject(transaction.error ?? new Error("Failed to persist replay history."));
2502
- };
2503
- });
2504
- });
3428
+ return enqueue({ kind: "batch", messages: [message] });
2505
3429
  },
2506
3430
  appendBatch(messages) {
2507
3431
  if (messages.length === 0) return Promise.resolve();
2508
- return serializeMutation(async () => {
2509
- const db = await open();
2510
- await new Promise((resolve, reject) => {
2511
- let transaction;
2512
- try {
2513
- transaction = db.transaction(storeName, "readwrite");
2514
- } catch (error) {
2515
- invalidate(db);
2516
- reject(error);
2517
- return;
2518
- }
2519
- const store = transaction.objectStore(storeName);
2520
- const grouped = /* @__PURE__ */ new Map();
2521
- for (const message of messages) grouped.set(message.topic, [...grouped.get(message.topic) ?? [], message]);
2522
- for (const [topic, topicMessages] of grouped) {
2523
- const request = store.get(topic);
2524
- request.onsuccess = () => {
2525
- let history = (request.result?.messages ?? []).concat(topicMessages);
2526
- if (pruneStrategy !== "count" && retentionMs !== void 0) {
2527
- const cutoff = Date.now() - retentionMs;
2528
- history = history.filter((item) => item.timestamp === void 0 || item.timestamp >= cutoff);
2529
- }
2530
- if (pruneStrategy !== "age") history = history.slice(-maxPerTopic);
2531
- store.put({ topic, messages: history });
2532
- };
2533
- request.onerror = () => {
2534
- invalidate(db);
2535
- reject(request.error ?? new Error("Failed to read replay history."));
2536
- };
2537
- }
2538
- transaction.oncomplete = () => resolve();
2539
- transaction.onerror = () => {
2540
- invalidate(db);
2541
- reject(transaction.error ?? new Error("Failed to persist replay history batch."));
2542
- };
2543
- });
2544
- });
3432
+ return enqueue({ kind: "batch", messages });
2545
3433
  },
2546
3434
  clear() {
2547
- return serializeMutation(async () => {
2548
- const db = await open();
2549
- await new Promise((resolve, reject) => {
2550
- let transaction;
2551
- try {
2552
- transaction = db.transaction(storeName, "readwrite");
2553
- } catch (error) {
2554
- invalidate(db);
2555
- reject(error);
2556
- return;
2557
- }
2558
- transaction.objectStore(storeName).clear();
2559
- transaction.oncomplete = () => resolve();
2560
- transaction.onerror = () => {
2561
- invalidate(db);
2562
- reject(transaction.error ?? new Error("Failed to clear replay history."));
2563
- };
2564
- });
3435
+ return enqueue({
3436
+ kind: "run",
3437
+ run: () => (async () => {
3438
+ const db = await open();
3439
+ return new Promise((resolve, reject) => {
3440
+ let transaction;
3441
+ try {
3442
+ transaction = db.transaction(storeName, "readwrite");
3443
+ } catch (error) {
3444
+ invalidate(db);
3445
+ reject(error);
3446
+ return;
3447
+ }
3448
+ let settled = false;
3449
+ const fail = (error) => {
3450
+ if (settled) return;
3451
+ settled = true;
3452
+ invalidate(db);
3453
+ reject(error);
3454
+ };
3455
+ transaction.objectStore(storeName).clear();
3456
+ transaction.oncomplete = () => {
3457
+ settled = true;
3458
+ resolve();
3459
+ };
3460
+ transaction.onerror = () => fail(transaction.error ?? new Error("Failed to clear replay history."));
3461
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to clear replay history."));
3462
+ });
3463
+ })()
2565
3464
  });
2566
3465
  },
2567
3466
  clearTopic(topic) {
2568
- return serializeMutation(async () => {
2569
- const db = await open();
2570
- await new Promise((resolve, reject) => {
2571
- let transaction;
2572
- try {
2573
- transaction = db.transaction(storeName, "readwrite");
2574
- } catch (error) {
2575
- invalidate(db);
2576
- reject(error);
2577
- return;
2578
- }
2579
- transaction.objectStore(storeName).delete(topic);
2580
- transaction.oncomplete = () => resolve();
2581
- transaction.onerror = () => {
2582
- invalidate(db);
2583
- reject(transaction.error ?? new Error("Failed to clear topic replay history."));
2584
- };
2585
- });
3467
+ return enqueue({
3468
+ kind: "run",
3469
+ run: () => (async () => {
3470
+ const db = await open();
3471
+ return new Promise((resolve, reject) => {
3472
+ let transaction;
3473
+ try {
3474
+ transaction = db.transaction(storeName, "readwrite");
3475
+ } catch (error) {
3476
+ invalidate(db);
3477
+ reject(error);
3478
+ return;
3479
+ }
3480
+ let settled = false;
3481
+ const fail = (error) => {
3482
+ if (settled) return;
3483
+ settled = true;
3484
+ invalidate(db);
3485
+ reject(error);
3486
+ };
3487
+ transaction.objectStore(storeName).delete(topic);
3488
+ transaction.oncomplete = () => {
3489
+ settled = true;
3490
+ resolve();
3491
+ };
3492
+ transaction.onerror = () => fail(transaction.error ?? new Error("Failed to clear topic replay history."));
3493
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to clear topic replay history."));
3494
+ });
3495
+ })()
2586
3496
  });
2587
3497
  },
2588
3498
  clearBefore(timestamp) {
2589
- return serializeMutation(async () => {
2590
- const db = await open();
2591
- await new Promise((resolve, reject) => {
2592
- let transaction;
2593
- try {
2594
- transaction = db.transaction(storeName, "readwrite");
2595
- } catch (error) {
2596
- invalidate(db);
2597
- reject(error);
2598
- return;
2599
- }
2600
- const store = transaction.objectStore(storeName);
2601
- const request = store.getAll();
2602
- request.onsuccess = () => {
2603
- for (const record of request.result) {
2604
- const messages = record.messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
2605
- if (messages.length === 0) store.delete(record.topic);
2606
- else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
3499
+ return enqueue({
3500
+ kind: "run",
3501
+ run: () => (async () => {
3502
+ const db = await open();
3503
+ return new Promise((resolve, reject) => {
3504
+ let transaction;
3505
+ try {
3506
+ transaction = db.transaction(storeName, "readwrite");
3507
+ } catch (error) {
3508
+ invalidate(db);
3509
+ reject(error);
3510
+ return;
2607
3511
  }
2608
- };
2609
- request.onerror = () => {
2610
- invalidate(db);
2611
- reject(request.error ?? new Error("Failed to read replay history."));
2612
- };
2613
- transaction.oncomplete = () => resolve();
2614
- transaction.onerror = () => {
2615
- invalidate(db);
2616
- reject(transaction.error ?? new Error("Failed to prune replay history."));
2617
- };
2618
- });
3512
+ let settled = false;
3513
+ const fail = (error) => {
3514
+ if (settled) return;
3515
+ settled = true;
3516
+ invalidate(db);
3517
+ reject(error);
3518
+ };
3519
+ const store = transaction.objectStore(storeName);
3520
+ const request = store.getAll();
3521
+ request.onsuccess = () => {
3522
+ for (const record of request.result) {
3523
+ const messages = record.messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
3524
+ if (messages.length === 0) store.delete(record.topic);
3525
+ else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
3526
+ }
3527
+ };
3528
+ request.onerror = () => fail(request.error ?? new Error("Failed to read replay history."));
3529
+ transaction.oncomplete = () => {
3530
+ settled = true;
3531
+ resolve();
3532
+ };
3533
+ transaction.onerror = () => fail(transaction.error ?? new Error("Failed to prune replay history."));
3534
+ transaction.onabort = () => fail(transaction.error ?? new Error("Failed to prune replay history."));
3535
+ });
3536
+ })()
2619
3537
  });
2620
3538
  }
2621
3539
  };
@@ -2649,6 +3567,7 @@ var WebSocketTransport = class {
2649
3567
  constructor(connection) {
2650
3568
  this.connection = connection;
2651
3569
  }
3570
+ connection;
2652
3571
  diagnosticsName = "websocket";
2653
3572
  diagnosticsBackend = "native-websocket";
2654
3573
  socket = null;
@@ -2665,22 +3584,22 @@ var WebSocketTransport = class {
2665
3584
  try {
2666
3585
  socket = factory(config.url, protocols);
2667
3586
  } catch (error) {
2668
- handlers.onStatus("error");
3587
+ handlers.onStatus(WORKER_STATUS.ERROR);
2669
3588
  handlers.onError(error);
2670
3589
  return;
2671
3590
  }
2672
3591
  socket.onopen = () => {
2673
3592
  if (this.socket !== socket || this.handlers !== handlers) return;
2674
3593
  for (const topic of this.subscribedTopics) {
2675
- this.sendFrame({ op: "subscribe", topic });
3594
+ this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
2676
3595
  }
2677
- handlers.onStatus("connected");
3596
+ handlers.onStatus(WORKER_STATUS.CONNECTED);
2678
3597
  };
2679
3598
  socket.onclose = () => {
2680
- if (this.socket === socket && this.handlers === handlers) handlers.onStatus("disconnected");
3599
+ if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.DISCONNECTED);
2681
3600
  };
2682
3601
  socket.onerror = () => {
2683
- if (this.socket === socket && this.handlers === handlers) handlers.onStatus("error");
3602
+ if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.ERROR);
2684
3603
  };
2685
3604
  socket.onmessage = (event) => {
2686
3605
  if (this.socket === socket && this.handlers === handlers) void this.handleMessage(event.data);
@@ -2691,12 +3610,12 @@ var WebSocketTransport = class {
2691
3610
  * not duplicate the local tracking entry. */
2692
3611
  subscribe(topic) {
2693
3612
  this.subscribedTopics.add(topic);
2694
- this.sendFrame({ op: "subscribe", topic });
3613
+ this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
2695
3614
  }
2696
3615
  /** Idempotent: unsubscribing an unknown topic is a no-op. */
2697
3616
  unsubscribe(topic) {
2698
3617
  this.subscribedTopics.delete(topic);
2699
- this.sendFrame({ op: "unsubscribe", topic });
3618
+ this.sendFrame({ op: WS_OP.UNSUBSCRIBE, topic });
2700
3619
  }
2701
3620
  /** Publish `data` to `topic` as a JSON frame. Requires an open socket. */
2702
3621
  publish(topic, data, options) {
@@ -2705,13 +3624,39 @@ var WebSocketTransport = class {
2705
3624
  return;
2706
3625
  }
2707
3626
  this.sendFrame({
2708
- op: "publish",
3627
+ op: WS_OP.PUBLISH,
2709
3628
  topic,
2710
3629
  data,
2711
3630
  ...options?.messageId === void 0 ? {} : { messageId: options.messageId },
2712
3631
  ...options?.timestamp === void 0 ? {} : { timestamp: options.timestamp }
2713
3632
  });
2714
3633
  }
3634
+ /** Publish many items for one topic as a single wire frame. One-item
3635
+ * batches delegate to `publish` so the legacy single-publication frame
3636
+ * shape (including binary framing) is preserved. */
3637
+ publishBatch(topic, items) {
3638
+ if (items.length === 0) return;
3639
+ if (items.length === 1) {
3640
+ const single = items[0];
3641
+ return this.publish(topic, single.data, {
3642
+ ...single.messageId === void 0 ? {} : { messageId: single.messageId },
3643
+ ...single.timestamp === void 0 ? {} : { timestamp: single.timestamp }
3644
+ });
3645
+ }
3646
+ if (this.socket?.readyState !== WS_OPEN) {
3647
+ this.handlers?.onError(new Error('WebSocket is not open; dropped "publishBatch" frame.'));
3648
+ return;
3649
+ }
3650
+ this.socket.send(JSON.stringify({
3651
+ op: WS_OP.PUBLISH_BATCH,
3652
+ topic,
3653
+ items: items.map((item) => ({
3654
+ data: item.data instanceof ArrayBuffer ? Array.from(new Uint8Array(item.data)) : item.data,
3655
+ ...item.messageId === void 0 ? {} : { messageId: item.messageId },
3656
+ ...item.timestamp === void 0 ? {} : { timestamp: item.timestamp }
3657
+ }))
3658
+ }));
3659
+ }
2715
3660
  /** Close the socket and drop all state. Safe to call multiple times. */
2716
3661
  stop() {
2717
3662
  const socket = this.socket;
@@ -2733,7 +3678,7 @@ var WebSocketTransport = class {
2733
3678
  sendBinaryFrame(topic, data, messageId, timestamp) {
2734
3679
  if (messageId !== void 0 || timestamp !== void 0) {
2735
3680
  this.sendFrame({
2736
- op: "publish",
3681
+ op: WS_OP.PUBLISH,
2737
3682
  topic,
2738
3683
  data: Array.from(new Uint8Array(data)),
2739
3684
  ...messageId === void 0 ? {} : { messageId },
@@ -2813,9 +3758,9 @@ function createWebSocketDataBus(options) {
2813
3758
  function selectWorkerBackend(mode, availability = {}) {
2814
3759
  const hasDedicated = availability.worker ?? typeof Worker !== "undefined";
2815
3760
  const hasShared = availability.sharedWorker ?? typeof SharedWorker !== "undefined";
2816
- if (mode === "shared" || mode === "auto") {
2817
- return hasShared ? "shared" : hasDedicated ? "dedicated" : "local";
3761
+ if (mode === WORKER_MODE.SHARED || mode === WORKER_MODE.AUTO) {
3762
+ return hasShared ? WORKER_BACKEND.SHARED : hasDedicated ? WORKER_BACKEND.DEDICATED : WORKER_BACKEND.LOCAL;
2818
3763
  }
2819
- return hasDedicated ? "dedicated" : hasShared ? "shared" : "local";
3764
+ return hasDedicated ? WORKER_BACKEND.DEDICATED : hasShared ? WORKER_BACKEND.SHARED : WORKER_BACKEND.LOCAL;
2820
3765
  }
2821
3766
  //# sourceMappingURL=index.cjs.map