cross-tab-worker-databus 0.20.70 → 0.20.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +209 -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-IKBJIREH.js → chunk-PW63EWIK.js} +1090 -476
  17. package/dist/chunk-PW63EWIK.js.map +7 -0
  18. package/dist/chunk-TZ7ZP7YD.js +175 -0
  19. package/dist/chunk-TZ7ZP7YD.js.map +7 -0
  20. package/dist/cjs/centrifuge.cjs +1405 -621
  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 +1469 -688
  25. package/dist/cjs/index.cjs.map +4 -4
  26. package/dist/cjs/vue.cjs +45 -2
  27. package/dist/cjs/vue.cjs.map +3 -3
  28. package/dist/core/cluster.d.ts +40 -4
  29. package/dist/core/cluster.d.ts.map +1 -1
  30. package/dist/core/data-bus.d.ts +93 -66
  31. package/dist/core/data-bus.d.ts.map +1 -1
  32. package/dist/core/dedup-manager.d.ts +98 -0
  33. package/dist/core/dedup-manager.d.ts.map +1 -0
  34. package/dist/core/environment.d.ts +43 -3
  35. package/dist/core/environment.d.ts.map +1 -1
  36. package/dist/core/replay-manager.d.ts +128 -0
  37. package/dist/core/replay-manager.d.ts.map +1 -0
  38. package/dist/core/replay-persistence.d.ts +4 -1
  39. package/dist/core/replay-persistence.d.ts.map +1 -1
  40. package/dist/core/routing.d.ts +22 -3
  41. package/dist/core/routing.d.ts.map +1 -1
  42. package/dist/core/storage-batch.d.ts +0 -4
  43. package/dist/core/storage-batch.d.ts.map +1 -1
  44. package/dist/core/trace.d.ts +55 -15
  45. package/dist/core/trace.d.ts.map +1 -1
  46. package/dist/core/types.d.ts +58 -9
  47. package/dist/core/types.d.ts.map +1 -1
  48. package/dist/core/version.d.ts +2 -0
  49. package/dist/core/version.d.ts.map +1 -0
  50. package/dist/hooks.d.ts +12 -1
  51. package/dist/hooks.d.ts.map +1 -1
  52. package/dist/hooks.js +28 -2
  53. package/dist/hooks.js.map +2 -2
  54. package/dist/index.d.ts +6 -6
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +231 -125
  57. package/dist/index.js.map +3 -3
  58. package/dist/utils/constants.d.ts +170 -0
  59. package/dist/utils/constants.d.ts.map +1 -0
  60. package/dist/utils/error-utils.d.ts +28 -0
  61. package/dist/utils/error-utils.d.ts.map +1 -0
  62. package/dist/utils/metadata.d.ts +16 -0
  63. package/dist/utils/metadata.d.ts.map +1 -0
  64. package/dist/utils/storage-utils.d.ts +24 -0
  65. package/dist/utils/storage-utils.d.ts.map +1 -0
  66. package/dist/utils/validation.d.ts +76 -0
  67. package/dist/utils/validation.d.ts.map +1 -0
  68. package/dist/vue.d.ts +13 -1
  69. package/dist/vue.d.ts.map +1 -1
  70. package/dist/vue.js +36 -2
  71. package/dist/vue.js.map +2 -2
  72. package/dist/websocket.d.ts +6 -1
  73. package/dist/websocket.d.ts.map +1 -1
  74. package/dist/worker-mode.d.ts +6 -2
  75. package/dist/worker-mode.d.ts.map +1 -1
  76. package/docs/README.md +1 -0
  77. package/docs/api.md +122 -2
  78. package/docs/architecture.md +80 -0
  79. package/docs/benchmarks.md +24 -0
  80. package/docs/capabilities.md +24 -3
  81. package/docs/configuration.md +58 -0
  82. package/docs/getting-started.md +35 -0
  83. package/docs/release-checklist.md +28 -5
  84. package/docs/roadmap.md +78 -5
  85. package/docs/zh/README.md +2 -1
  86. package/docs/zh/api.md +121 -2
  87. package/docs/zh/architecture.md +49 -0
  88. package/docs/zh/benchmarks.md +24 -0
  89. package/docs/zh/capabilities.md +21 -3
  90. package/docs/zh/configuration.md +53 -0
  91. package/docs/zh/getting-started.md +35 -0
  92. package/docs/zh/release-checklist.md +29 -6
  93. package/docs/zh/roadmap.md +90 -5
  94. package/package.json +39 -16
  95. package/dist/chunk-IKBJIREH.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,217 +1855,609 @@ 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-manager.ts
1418
1859
  var PersistenceRetryCancelledError = class extends Error {
1419
1860
  constructor() {
1420
1861
  super("Persistence retry cancelled by lifecycle transition.");
1421
1862
  this.name = "PersistenceRetryCancelledError";
1422
1863
  }
1423
1864
  };
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;
1865
+ var MAX_RETRY_DELAY_MS2 = 1600;
1866
+ var ReplayManager = class {
1867
+ constructor(deps) {
1868
+ this.deps = deps;
1869
+ this.buffers = deps.enabled ? /* @__PURE__ */ new Map() : null;
1870
+ this.maxPerTopic = deps.maxPerTopic;
1871
+ this.persistence = deps.persistence ?? null;
1872
+ this.retentionMs = deps.retentionMs;
1873
+ this.pruneStrategy = deps.pruneStrategy;
1874
+ this.retentionSweepMs = deps.retentionSweepMs;
1875
+ this.persistenceRetryMaxAttempts = deps.persistenceRetryMaxAttempts;
1876
+ this.persistenceRetryBackoffMs = deps.persistenceRetryBackoffMs;
1877
+ this.now = deps.now;
1878
+ this.trace = deps.trace;
1879
+ this.onPersistenceError = deps.onPersistenceError;
1880
+ this.onDispatchError = deps.onDispatchError;
1881
+ this.hydration = this.hydrate();
1882
+ }
1883
+ deps;
1884
+ buffers;
1885
+ maxPerTopic;
1886
+ persistence;
1887
+ retentionMs;
1888
+ pruneStrategy;
1889
+ retentionSweepMs;
1442
1890
  persistenceRetryMaxAttempts;
1443
1891
  persistenceRetryBackoffMs;
1444
- persistenceRetryGeneration = 0;
1445
- replayHydration;
1446
- // Retention cleanup is coalesced so a burst of publications does not issue
1447
- // one IndexedDB read/write transaction per message. The newest cutoff wins.
1448
- replayRetentionCleanup = null;
1449
- replayRetentionCutoff = null;
1450
- replayRetentionTimer = null;
1451
- initialConfig;
1452
- hasInitialConfig;
1453
- trace;
1454
- dedupMaxEntries;
1455
- dedupTtlMs;
1456
- dedupAdaptiveBounds;
1457
- dedupWindowStartedAt = 0;
1458
- dedupWindowAccepted = 0;
1459
- dedupAdaptiveWindowMs = 5e3;
1460
- dedupSweepMs;
1461
- dedupSweepTimer = null;
1462
- dedupEnabled;
1463
1892
  now;
1464
- seenMessageIds = /* @__PURE__ */ new Map();
1465
- dedupSuppressed = 0;
1466
- dedupAccepted = 0;
1467
- activeConfig;
1468
- status = "disconnected";
1469
- started = false;
1470
- stopping = false;
1471
- transportReady = false;
1472
- // Last transport failure, retained so ready() can surface it to callers who
1473
- // never awaited start() directly. Cleared on the next successful start.
1474
- lastError = null;
1475
- lastErrorAt = null;
1476
- // Gate that serialises start/stop/suspend/resume only one lifecycle
1477
- // transition at a time. Resets to null once the operation settles.
1478
- startPromise = null;
1479
- // Timestamp of the last automatic transport recovery attempt.
1480
- // Used to avoid a tight retry loop when the transport fails repeatedly.
1481
- lastRecoveryAt = 0;
1482
- // Monotonic attempt number within one runtime recovery sequence; reset once
1483
- // a transport reopen succeeds so traces can correlate repeated failures.
1484
- recoveryAttempt = 0;
1485
- recoveryExhausted = false;
1486
- /** Monotonic generation incremented on every successful transport open.
1487
- * Stays in lockstep with `lastSuccessAt` so callers can detect that the
1488
- * transport has been reopened even if the timestamp window is short. */
1489
- recoveryGeneration = 0;
1490
- /** Timestamp of the most recent successful transport open. Null until the
1491
- * transport has reached the `ready` state at least once. */
1492
- lastSuccessAt = null;
1493
- // True while the tab is hidden so an in-flight transport start does not mark
1494
- // the transport ready after suspendTransport() has stopped it.
1495
- suspended = false;
1496
- // Single gate for async transport.stop() cleanup, shared by failed opens and
1497
- // page-hide suspension. Kept separate from startPromise so ready() still
1498
- // surfaces a failure while later opens and automatic recovery wait for the
1499
- // stop to settle.
1500
- pendingStop = null;
1501
- // Minimum interval in ms between automatic recovery attempts.
1502
- recoveryCooldownMs;
1503
- recoveryMaxAttempts;
1504
- constructor(options) {
1505
- const replay = options.replay;
1506
- if (replay) {
1507
- const maxPerTopic = replay.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1508
- if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
1509
- throw new TypeError(
1510
- `replay.maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`
1511
- );
1512
- }
1513
- }
1514
- this.replayMaxPerTopic = replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1515
- this.replayBuffers = replay ? /* @__PURE__ */ new Map() : null;
1516
- this.replayPersistence = replay?.persistence ?? null;
1517
- this.replayRetentionMs = replay?.retentionMs;
1518
- this.replayPruneStrategy = replay?.pruneStrategy ?? "count";
1519
- if (!["count", "age", "both"].includes(this.replayPruneStrategy)) throw new TypeError("replay.pruneStrategy must be count, age, or both.");
1520
- if (this.replayRetentionMs !== void 0 && (!Number.isFinite(this.replayRetentionMs) || this.replayRetentionMs <= 0)) {
1521
- throw new TypeError("replay.retentionMs must be a positive finite number.");
1893
+ trace;
1894
+ onPersistenceError;
1895
+ onDispatchError;
1896
+ /** Bumped on suspend/stop so in-flight persistence retries are cancelled. */
1897
+ retryGeneration = 0;
1898
+ pendingReplayPersistence = [];
1899
+ persistenceFlushScheduled = false;
1900
+ hydration;
1901
+ /** Coalesced retention cleanup: the newest cutoff wins while one is running. */
1902
+ retentionCleanup = null;
1903
+ retentionCutoff = null;
1904
+ retentionTimer = null;
1905
+ /** True when replay buffering is enabled. */
1906
+ get enabled() {
1907
+ return this.buffers !== null;
1908
+ }
1909
+ /** Append a dispatched publication to the topic's replay ring buffer.
1910
+ * No-op when replay is disabled. */
1911
+ record(message) {
1912
+ if (!this.buffers) return;
1913
+ let buffer = this.buffers.get(message.topic);
1914
+ if (!buffer) {
1915
+ buffer = [];
1916
+ this.buffers.set(message.topic, buffer);
1522
1917
  }
1523
- this.replayRetentionSweepMs = replay?.retentionSweepMs;
1524
- if (this.replayRetentionSweepMs !== void 0 && (!Number.isFinite(this.replayRetentionSweepMs) || this.replayRetentionSweepMs <= 0)) {
1525
- throw new TypeError("replay.retentionSweepMs must be a positive finite number.");
1918
+ buffer.push(message);
1919
+ const ageBounded = this.pruneStrategy !== PRUNE_STRATEGY.COUNT && this.retentionMs !== void 0;
1920
+ if (this.pruneStrategy !== PRUNE_STRATEGY.AGE || !ageBounded) {
1921
+ while (buffer.length > this.maxPerTopic) buffer.shift();
1526
1922
  }
1527
- this.persistenceRetryMaxAttempts = replay?.persistenceRetry?.maxAttempts ?? 1;
1528
- this.persistenceRetryBackoffMs = replay?.persistenceRetry?.backoffMs ?? 50;
1529
- if (!Number.isSafeInteger(this.persistenceRetryMaxAttempts) || this.persistenceRetryMaxAttempts <= 0) {
1530
- throw new TypeError("replay.persistenceRetry.maxAttempts must be a positive safe integer.");
1923
+ if (ageBounded) {
1924
+ const cutoff = this.now() - this.retentionMs;
1925
+ while (buffer.length > 0) {
1926
+ const first = buffer[0];
1927
+ if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
1928
+ buffer.shift();
1929
+ }
1531
1930
  }
1532
- if (!Number.isFinite(this.persistenceRetryBackoffMs) || this.persistenceRetryBackoffMs < 0) {
1533
- throw new TypeError("replay.persistenceRetry.backoffMs must be a non-negative finite number.");
1931
+ if (!this.persistence) return;
1932
+ if (this.persistence.appendBatch) {
1933
+ this.pendingReplayPersistence.push(message);
1934
+ this.schedulePersistenceFlush();
1935
+ } else {
1936
+ void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.append(message)).catch((error) => this.onPersistenceError(error));
1534
1937
  }
1535
- const { autoStart, initialConfig, trace, transport, dedup, recovery, ...clusterOptions } = options;
1536
- this.recoveryCooldownMs = recovery?.cooldownMs ?? 1e3;
1537
- this.recoveryMaxAttempts = recovery?.maxAttempts ?? Number.POSITIVE_INFINITY;
1538
- if (!Number.isFinite(this.recoveryCooldownMs) || this.recoveryCooldownMs <= 0) {
1539
- throw new TypeError("recovery.cooldownMs must be a positive finite number.");
1938
+ if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
1939
+ this.scheduleRetentionCleanup(this.now() - this.retentionMs);
1540
1940
  }
1541
- if (!(this.recoveryMaxAttempts === Number.POSITIVE_INFINITY || Number.isSafeInteger(this.recoveryMaxAttempts) && this.recoveryMaxAttempts > 0)) {
1542
- throw new TypeError("recovery.maxAttempts must be a positive safe integer.");
1941
+ }
1942
+ /** Deliver buffered history to a newly-registered handler. For an exact
1943
+ * topic this is that topic's ring; for a wildcard subscription every
1944
+ * buffered topic matching the pattern contributes (in buffer insertion
1945
+ * order). Replay deliveries are marked `replayed: true` and are not counted
1946
+ * into trace metrics.
1947
+ *
1948
+ * When a durable persistence backend is present, delivery waits for the
1949
+ * hydration load to settle first; `isHandlerActive` is then consulted so a
1950
+ * handler that unsubscribed during the async load does not receive history.
1951
+ */
1952
+ deliverReplay(topic, replayOption, handler, isHandlerActive) {
1953
+ if (!this.buffers) return;
1954
+ const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
1955
+ if (this.persistence) {
1956
+ void this.hydration.then(() => {
1957
+ if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
1958
+ });
1959
+ return;
1543
1960
  }
1544
- this.now = dedup?.now ?? Date.now;
1545
- this.replayHydration = this.hydrateReplay();
1546
- this.transport = transport;
1547
- this.initialConfig = initialConfig;
1548
- this.hasInitialConfig = "initialConfig" in options;
1549
- this.trace = new DataBusTraceReporter(trace);
1550
- this.dedupMaxEntries = dedup?.maxEntries ?? 1e3;
1551
- this.dedupTtlMs = dedup?.ttlMs ?? 6e4;
1552
- this.dedupAdaptiveBounds = dedup?.adaptiveTtl;
1553
- if (this.dedupAdaptiveBounds && (this.dedupAdaptiveBounds.minMs <= 0 || this.dedupAdaptiveBounds.maxMs < this.dedupAdaptiveBounds.minMs)) throw new TypeError("dedup.adaptiveTtl bounds are invalid.");
1554
- this.dedupWindowStartedAt = this.now();
1555
- this.dedupSweepMs = dedup?.sweepMs;
1556
- this.dedupEnabled = dedup !== void 0;
1557
- if (!Number.isSafeInteger(this.dedupMaxEntries) || this.dedupMaxEntries <= 0) {
1558
- throw new TypeError("dedup.maxEntries must be a positive safe integer.");
1559
- }
1560
- if (!Number.isFinite(this.dedupTtlMs) || this.dedupTtlMs <= 0) {
1561
- throw new TypeError("dedup.ttlMs must be a positive finite number.");
1562
- }
1563
- if (this.dedupSweepMs !== void 0 && (!Number.isFinite(this.dedupSweepMs) || this.dedupSweepMs <= 0)) {
1564
- throw new TypeError("dedup.sweepMs must be a positive finite number.");
1961
+ this.deliver(topic, limit, handler);
1962
+ }
1963
+ /** Clean up a topic that lost its last local handler: drop the ring buffer,
1964
+ * filter queued batch flushes (so an in-flight append cannot undo the
1965
+ * clearTopic), and prune durable history. */
1966
+ onTopicUnsubscribed(topic) {
1967
+ if (!this.buffers) return;
1968
+ this.buffers.delete(topic);
1969
+ this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
1970
+ if (this.persistence?.clearTopic) {
1971
+ void this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)).catch((error) => this.onPersistenceError(error));
1972
+ }
1973
+ }
1974
+ /** Clear all in-memory replay buffers and, when supported, durable history.
1975
+ * Reports persistence failures and rethrows, mirroring the public API
1976
+ * contract that callers can observe a failed clear. */
1977
+ async clearAll() {
1978
+ if (!this.buffers) return;
1979
+ this.buffers.clear();
1980
+ this.pendingReplayPersistence = [];
1981
+ if (this.persistence?.clear) {
1982
+ try {
1983
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear());
1984
+ } catch (error) {
1985
+ this.onPersistenceError(error);
1986
+ throw error;
1987
+ }
1565
1988
  }
1566
- this.cluster = new WorkerClusterRuntime({
1567
- ...clusterOptions,
1568
- handlers: {
1569
- // The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
1570
- // control message — meaning the owning Worker has delegated the action to us.
1571
- onControl: (action, topic, data, messageId, timestamp) => {
1572
- switch (action) {
1573
- case "SUBSCRIBE":
1574
- if (this.subscribeTransport(topic)) this.traceSubscription("subscribe", topic);
1575
- break;
1576
- case "UNSUBSCRIBE":
1577
- if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
1578
- break;
1579
- case "PUBLISH":
1580
- this.runTransport(() => this.transport.publish(
1581
- topic,
1582
- data,
1583
- messageId === void 0 && timestamp === void 0 ? void 0 : {
1584
- ...messageId === void 0 ? {} : { messageId },
1585
- ...timestamp === void 0 ? {} : { timestamp }
1586
- }
1587
- ));
1588
- break;
1589
- default:
1590
- break;
1591
- }
1592
- },
1593
- // The cluster calls `onEvent` when a publication broadcast arrives from
1594
- // another tab. Dispatch locally if we have subscribers. The payload is
1595
- // typed `unknown` at the cluster boundary (the cluster is transport-
1596
- // agnostic); here we narrow it to DataBusMessage — the sender is our
1597
- // own broadcastEvent call, which always posts a DataBusMessage.
1598
- onEvent: (eventType, payload, _sourceWorkerId, originTabId) => {
1599
- if (eventType !== PUBLICATION_EVENT) return;
1600
- const incoming = payload;
1601
- const message = incoming.originTabId !== void 0 ? incoming : originTabId !== void 0 ? { ...incoming, originTabId } : incoming;
1602
- if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
1603
- },
1604
- onSuspend: () => {
1605
- if (!this.stopping) this.trace.event({ type: "lifecycle", action: "suspend" });
1606
- this.trace.pause();
1607
- this.persistenceRetryGeneration += 1;
1608
- this.stopDedupSweep();
1609
- this.stopReplayRetentionSweep();
1610
- this.suspendTransport();
1611
- },
1612
- onResume: () => {
1613
- this.trace.event({ type: "lifecycle", action: "resume" });
1614
- this.trace.start();
1615
- this.startDedupSweep();
1616
- this.startReplayRetentionSweep();
1617
- this.resumeTransport();
1618
- },
1619
- onDiagnostic: (event) => {
1620
- this.trace.event({ type: "reliability", ...event });
1621
- }
1989
+ }
1990
+ /** Clear replay history for one exact topic, including durable storage. */
1991
+ async clearTopic(topic) {
1992
+ if (!this.buffers) return;
1993
+ this.buffers.delete(topic);
1994
+ this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
1995
+ if (this.persistence?.clearTopic) {
1996
+ try {
1997
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic));
1998
+ } catch (error) {
1999
+ this.onPersistenceError(error);
2000
+ throw error;
1622
2001
  }
1623
- });
1624
- if (autoStart ?? this.hasInitialConfig) this.ensureStarted();
2002
+ }
2003
+ }
2004
+ /** Remove replay entries older than an epoch-millisecond cutoff. */
2005
+ async clearBefore(timestamp) {
2006
+ if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
2007
+ if (this.buffers) {
2008
+ for (const [topic, messages] of this.buffers) {
2009
+ const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
2010
+ if (kept.length) this.buffers.set(topic, kept);
2011
+ else this.buffers.delete(topic);
2012
+ }
2013
+ }
2014
+ this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
2015
+ (message) => message.timestamp === void 0 || message.timestamp >= timestamp
2016
+ );
2017
+ if (this.persistence?.clearBefore) {
2018
+ try {
2019
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp));
2020
+ } catch (error) {
2021
+ this.onPersistenceError(error);
2022
+ throw error;
2023
+ }
2024
+ }
2025
+ }
2026
+ /** Start the periodic retention sweep. No-op when no durable retention
2027
+ * config makes it necessary. */
2028
+ start() {
2029
+ if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
2030
+ this.retentionTimer = setInterval(() => {
2031
+ this.scheduleRetentionCleanup(this.now() - this.retentionMs);
2032
+ }, this.retentionSweepMs);
2033
+ }
2034
+ /** Stop the periodic retention sweep. */
2035
+ stop() {
2036
+ if (this.retentionTimer) clearInterval(this.retentionTimer);
2037
+ this.retentionTimer = null;
2038
+ }
2039
+ /** Suspend the manager: cancel in-flight persistence retries (so a hidden tab
2040
+ * or stopped bus does not keep hammering the store) and stop the sweep. */
2041
+ suspend() {
2042
+ this.retryGeneration += 1;
2043
+ this.stop();
2044
+ }
2045
+ /** Drop all in-memory buffers (used on full teardown). */
2046
+ resetBuffers() {
2047
+ this.buffers?.clear();
2048
+ }
2049
+ /** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
2050
+ * payload footprint (same heuristic as adaptive load weighting), computed on
2051
+ * demand so the hot append path never pays for it. */
2052
+ getStats() {
2053
+ let messages = 0;
2054
+ let bytes = 0;
2055
+ if (this.buffers) {
2056
+ for (const buffer of this.buffers.values()) {
2057
+ messages += buffer.length;
2058
+ for (const message of buffer) bytes += approximatePayloadBytes(message.data);
2059
+ }
2060
+ }
2061
+ return { enabled: this.enabled, topics: this.buffers?.size ?? 0, messages, bytes };
2062
+ }
2063
+ /** Deliver history from one topic's ring to a handler, isolating a throwing
2064
+ * handler so the remaining buffers are still delivered. */
2065
+ deliver(topic, limit, handler) {
2066
+ if (!this.buffers || limit <= 0) return;
2067
+ const deliverBuffer = (buffer2) => {
2068
+ for (const message of buffer2.slice(-limit)) {
2069
+ try {
2070
+ handler({ ...message, replayed: true });
2071
+ } catch (error) {
2072
+ this.onDispatchError(error);
2073
+ }
2074
+ }
2075
+ };
2076
+ if (isWildcardTopic(topic)) {
2077
+ for (const [bufferedTopic, buffer2] of this.buffers) {
2078
+ if (topicMatchesPattern(topic, bufferedTopic)) deliverBuffer(buffer2);
2079
+ }
2080
+ return;
2081
+ }
2082
+ const buffer = this.buffers.get(topic);
2083
+ if (buffer) deliverBuffer(buffer);
2084
+ }
2085
+ /** Coalesce queued persistence appends into a single microtask batch so a
2086
+ * burst of publications does not issue one IndexedDB transaction each.
2087
+ * Only reachable when the backend advertises `appendBatch` (the sole queuer,
2088
+ * `record()`, guards on it), so the batched path is unconditional here. */
2089
+ schedulePersistenceFlush() {
2090
+ if (this.persistenceFlushScheduled) return;
2091
+ this.persistenceFlushScheduled = true;
2092
+ queueMicrotask(() => {
2093
+ this.persistenceFlushScheduled = false;
2094
+ const batch = this.pendingReplayPersistence.splice(0);
2095
+ if (batch.length === 0 || !this.persistence) return;
2096
+ void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
2097
+ });
2098
+ }
2099
+ /** Load durable history into the in-memory rings once at startup, pruning
2100
+ * entries past the retention window first. Failures are reported but do not
2101
+ * block startup — the bus runs with whatever survived. */
2102
+ async hydrate() {
2103
+ if (!this.buffers || !this.persistence) {
2104
+ return;
2105
+ }
2106
+ try {
2107
+ if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
2108
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
2109
+ }
2110
+ for (const message of await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load())) {
2111
+ let buffer = this.buffers.get(message.topic);
2112
+ if (!buffer) {
2113
+ buffer = [];
2114
+ this.buffers.set(message.topic, buffer);
2115
+ }
2116
+ buffer.push(message);
2117
+ if (buffer.length > this.maxPerTopic) buffer.shift();
2118
+ }
2119
+ } catch (error) {
2120
+ this.onPersistenceError(error);
2121
+ }
2122
+ }
2123
+ /** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
2124
+ * so a burst of publications issues at most one clearBefore transaction. */
2125
+ scheduleRetentionCleanup(cutoff) {
2126
+ if (!this.persistence?.clearBefore) return;
2127
+ if (this.retentionCutoff === null || cutoff > this.retentionCutoff) {
2128
+ this.retentionCutoff = cutoff;
2129
+ }
2130
+ if (this.retentionCleanup) return;
2131
+ this.retentionCleanup = (async () => {
2132
+ while (this.retentionCutoff !== null) {
2133
+ const nextCutoff = this.retentionCutoff;
2134
+ this.retentionCutoff = null;
2135
+ try {
2136
+ await this.persistence.clearBefore(nextCutoff);
2137
+ } catch (error) {
2138
+ this.onPersistenceError(error);
2139
+ }
2140
+ }
2141
+ })().finally(() => {
2142
+ this.retentionCleanup = null;
2143
+ if (this.retentionCutoff !== null) {
2144
+ this.scheduleRetentionCleanup(this.retentionCutoff);
2145
+ }
2146
+ });
2147
+ }
2148
+ /** Run a persistence operation with exponential backoff on transient failure.
2149
+ * Bumped `retryGeneration` (suspend/stop) cancels the loop early; a
2150
+ * structurally failing operation throws after `persistenceRetryMaxAttempts`,
2151
+ * leaving the ring buffer intact so the bus keeps working. */
2152
+ async withPersistenceRetry(persistenceOperation, operation) {
2153
+ const generation = this.retryGeneration;
2154
+ let attempt = 0;
2155
+ let delay = this.persistenceRetryBackoffMs;
2156
+ while (true) {
2157
+ attempt += 1;
2158
+ try {
2159
+ if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
2160
+ return await operation();
2161
+ } catch (error) {
2162
+ if (error instanceof PersistenceRetryCancelledError || generation !== this.retryGeneration) {
2163
+ throw new PersistenceRetryCancelledError();
2164
+ }
2165
+ if (attempt >= this.persistenceRetryMaxAttempts) throw error;
2166
+ this.trace.event({
2167
+ type: TRACE_EVENT_TYPE.RELIABILITY,
2168
+ operation: RELIABILITY_OPERATION.PERSISTENCE_RETRY,
2169
+ persistenceOperation,
2170
+ attempt
2171
+ });
2172
+ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
2173
+ if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
2174
+ delay = Math.min(delay * 2, MAX_RETRY_DELAY_MS2);
2175
+ }
2176
+ }
2177
+ }
2178
+ };
2179
+
2180
+ // src/core/dedup-manager.ts
2181
+ var ADAPTIVE_WINDOW_MS = 5e3;
2182
+ var QUIET_RATE_PER_MS = 0.01;
2183
+ var DedupManager = class {
2184
+ enabled;
2185
+ maxEntries;
2186
+ ttlMs;
2187
+ adaptiveBounds;
2188
+ sweepMs;
2189
+ now;
2190
+ trace;
2191
+ seenMessageIds = /* @__PURE__ */ new Map();
2192
+ sweepTimer = null;
2193
+ windowStartedAt = 0;
2194
+ windowAccepted = 0;
2195
+ suppressed = 0;
2196
+ accepted = 0;
2197
+ constructor(options) {
2198
+ this.enabled = options.enabled;
2199
+ this.maxEntries = options.maxEntries;
2200
+ this.ttlMs = options.ttlMs;
2201
+ this.adaptiveBounds = options.adaptiveBounds;
2202
+ this.sweepMs = options.sweepMs;
2203
+ this.now = options.now;
2204
+ this.trace = options.trace;
2205
+ this.windowStartedAt = this.now();
2206
+ }
2207
+ /** True when a publication carrying `messageId` was already seen. Records the
2208
+ * ID and updates counters/trace on acceptance. Disabled or ID-less messages
2209
+ * always pass through. */
2210
+ isDuplicate(messageId, topic) {
2211
+ if (!this.enabled || !messageId) return false;
2212
+ const now = this.now();
2213
+ const ttlMs = this.currentTtl();
2214
+ for (const [id, timestamp] of this.seenMessageIds) {
2215
+ if (now - timestamp > ttlMs) this.seenMessageIds.delete(id);
2216
+ }
2217
+ if (this.seenMessageIds.has(messageId)) {
2218
+ this.suppressed += 1;
2219
+ this.trace.event({
2220
+ type: TRACE_EVENT_TYPE.RELIABILITY,
2221
+ operation: RELIABILITY_OPERATION.DEDUP_SUPPRESSED,
2222
+ topic
2223
+ });
2224
+ this.trace.recordDedupSuppressed();
2225
+ return true;
2226
+ }
2227
+ this.seenMessageIds.set(messageId, now);
2228
+ this.accepted += 1;
2229
+ this.windowAccepted += 1;
2230
+ this.trace.recordDedupAccepted();
2231
+ while (this.seenMessageIds.size > this.maxEntries) {
2232
+ const oldest = this.seenMessageIds.keys().next().value;
2233
+ if (oldest === void 0) break;
2234
+ this.seenMessageIds.delete(oldest);
2235
+ }
2236
+ return false;
2237
+ }
2238
+ /** Start the periodic expiry sweep. No-op when disabled or no sweepMs was
2239
+ * configured (the hot-path opportunistic expiry still bounds the map). */
2240
+ start() {
2241
+ if (this.sweepTimer || !this.enabled || !this.sweepMs) return;
2242
+ this.sweepTimer = setInterval(() => this.pruneExpired(), this.sweepMs);
2243
+ }
2244
+ /** Stop the periodic expiry sweep. */
2245
+ stop() {
2246
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
2247
+ this.sweepTimer = null;
2248
+ }
2249
+ /** Return bounded deduplication counters for diagnostics and health checks. */
2250
+ getStats() {
2251
+ return {
2252
+ enabled: this.enabled,
2253
+ tracked: this.seenMessageIds.size,
2254
+ suppressed: this.suppressed,
2255
+ accepted: this.accepted,
2256
+ ...this.adaptiveBounds ? { ttlMs: this.currentTtl() } : {}
2257
+ };
2258
+ }
2259
+ /** Drop all remembered IDs and reset dedup counters. */
2260
+ reset() {
2261
+ this.seenMessageIds.clear();
2262
+ this.suppressed = 0;
2263
+ this.accepted = 0;
2264
+ this.windowStartedAt = this.now();
2265
+ this.windowAccepted = 0;
2266
+ }
2267
+ /** Remove IDs whose timestamp predates the effective TTL cutoff. */
2268
+ pruneExpired() {
2269
+ const cutoff = this.now() - this.currentTtl();
2270
+ for (const [id, timestamp] of this.seenMessageIds) {
2271
+ if (timestamp < cutoff) this.seenMessageIds.delete(id);
2272
+ }
2273
+ }
2274
+ /** Effective TTL: the fixed `ttlMs` unless adaptive bounds are configured, in
2275
+ * which case a higher recent message rate shortens the window (dedup only
2276
+ * needs to live long enough to bridge duplicate bursts). The window resets
2277
+ * every ADAPTIVE_WINDOW_MS. */
2278
+ currentTtl() {
2279
+ if (!this.adaptiveBounds) return this.ttlMs;
2280
+ const now = this.now();
2281
+ const elapsed = now - this.windowStartedAt;
2282
+ if (elapsed >= ADAPTIVE_WINDOW_MS) {
2283
+ this.windowStartedAt = now;
2284
+ this.windowAccepted = 0;
2285
+ return this.adaptiveBounds.maxMs;
2286
+ }
2287
+ const rate = this.windowAccepted / Math.max(1, elapsed);
2288
+ const factor = Math.min(1, rate / QUIET_RATE_PER_MS);
2289
+ return this.adaptiveBounds.maxMs - (this.adaptiveBounds.maxMs - this.adaptiveBounds.minMs) * factor;
2290
+ }
2291
+ };
2292
+
2293
+ // src/core/version.ts
2294
+ var SDK_VERSION = true ? "0.20.85" : "";
2295
+
2296
+ // src/core/data-bus.ts
2297
+ var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
2298
+ var CrossTabDataBus = class {
2299
+ transport;
2300
+ cluster;
2301
+ // Map of topic → set of local subscribers.
2302
+ topicHandlers = /* @__PURE__ */ new Map();
2303
+ // Topics for which the transport has been asked to subscribe (used to avoid
2304
+ // duplicate subscribe calls during reconnection).
2305
+ transportSubscribedTopics = /* @__PURE__ */ new Set();
2306
+ statusHandlers = /* @__PURE__ */ new Set();
2307
+ errorHandlers = /* @__PURE__ */ new Set();
2308
+ replayManager;
2309
+ initialConfig;
2310
+ hasInitialConfig;
2311
+ trace;
2312
+ dedupManager;
2313
+ now;
2314
+ activeConfig;
2315
+ status = WORKER_STATUS.DISCONNECTED;
2316
+ started = false;
2317
+ stopping = false;
2318
+ transportReady = false;
2319
+ // Last transport failure, retained so ready() can surface it to callers who
2320
+ // never awaited start() directly. Cleared on the next successful start.
2321
+ lastError = null;
2322
+ lastErrorAt = null;
2323
+ // Unified failure ledger for the health summary: the most recent failure of
2324
+ // any source (transport, persistence, dispatch) since the last explicit start.
2325
+ lastFailure = null;
2326
+ persistenceFailureCount = 0;
2327
+ persistenceLastFailureAt = null;
2328
+ persistenceLastErrorMessage = null;
2329
+ // Gate that serialises start/stop/suspend/resume — only one lifecycle
2330
+ // transition at a time. Resets to null once the operation settles.
2331
+ startPromise = null;
2332
+ // Timestamp of the last automatic transport recovery attempt.
2333
+ // Used to avoid a tight retry loop when the transport fails repeatedly.
2334
+ lastRecoveryAt = 0;
2335
+ // Monotonic attempt number within one runtime recovery sequence; reset once
2336
+ // a transport reopen succeeds so traces can correlate repeated failures.
2337
+ recoveryAttempt = 0;
2338
+ recoveryExhausted = false;
2339
+ /** Monotonic generation incremented on every successful transport open.
2340
+ * Stays in lockstep with `lastSuccessAt` so callers can detect that the
2341
+ * transport has been reopened even if the timestamp window is short. */
2342
+ recoveryGeneration = 0;
2343
+ /** Timestamp of the most recent successful transport open. Null until the
2344
+ * transport has reached the `ready` state at least once. */
2345
+ lastSuccessAt = null;
2346
+ // True while the tab is hidden so an in-flight transport start does not mark
2347
+ // the transport ready after suspendTransport() has stopped it.
2348
+ suspended = false;
2349
+ // Single gate for async transport.stop() cleanup, shared by failed opens and
2350
+ // page-hide suspension. Kept separate from startPromise so ready() still
2351
+ // surfaces a failure while later opens and automatic recovery wait for the
2352
+ // stop to settle.
2353
+ pendingStop = null;
2354
+ // Minimum interval in ms between automatic recovery attempts.
2355
+ recoveryCooldownMs;
2356
+ recoveryMaxAttempts;
2357
+ constructor(options) {
2358
+ const replay = options.replay;
2359
+ assertReplayOptions(replay);
2360
+ const { autoStart, initialConfig, trace, transport, dedup, recovery, ...clusterOptions } = options;
2361
+ assertRecoveryOptions(recovery);
2362
+ this.recoveryCooldownMs = recovery?.cooldownMs ?? 1e3;
2363
+ this.recoveryMaxAttempts = recovery?.maxAttempts ?? Number.POSITIVE_INFINITY;
2364
+ this.now = dedup?.now ?? Date.now;
2365
+ this.transport = transport;
2366
+ this.initialConfig = initialConfig;
2367
+ this.hasInitialConfig = "initialConfig" in options;
2368
+ this.trace = new DataBusTraceReporter(trace);
2369
+ this.replayManager = new ReplayManager({
2370
+ enabled: replay !== void 0,
2371
+ maxPerTopic: replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC,
2372
+ persistence: replay?.persistence ?? null,
2373
+ retentionMs: replay?.retentionMs,
2374
+ pruneStrategy: replay?.pruneStrategy ?? PRUNE_STRATEGY.COUNT,
2375
+ retentionSweepMs: replay?.retentionSweepMs,
2376
+ persistenceRetryMaxAttempts: replay?.persistenceRetry?.maxAttempts ?? 1,
2377
+ persistenceRetryBackoffMs: replay?.persistenceRetry?.backoffMs ?? 50,
2378
+ now: this.now,
2379
+ trace: this.trace,
2380
+ onPersistenceError: (error) => this.reportPersistenceError(error),
2381
+ onDispatchError: (error) => this.reportError(error, FAILURE_SOURCE.DISPATCH)
2382
+ });
2383
+ assertDedupOptions(dedup);
2384
+ this.dedupManager = new DedupManager({
2385
+ enabled: dedup !== void 0,
2386
+ maxEntries: dedup?.maxEntries ?? 1e3,
2387
+ ttlMs: dedup?.ttlMs ?? 6e4,
2388
+ adaptiveBounds: dedup?.adaptiveTtl,
2389
+ sweepMs: dedup?.sweepMs,
2390
+ now: this.now,
2391
+ trace: this.trace
2392
+ });
2393
+ this.cluster = new WorkerClusterRuntime({
2394
+ ...clusterOptions,
2395
+ handlers: {
2396
+ // The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
2397
+ // control message — meaning the owning Worker has delegated the action to us.
2398
+ onControl: (action, topic, data, messageId, timestamp) => {
2399
+ switch (action) {
2400
+ case CONTROL_ACTION.SUBSCRIBE:
2401
+ if (this.subscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.SUBSCRIBE, topic);
2402
+ break;
2403
+ case CONTROL_ACTION.UNSUBSCRIBE:
2404
+ if (this.unsubscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.UNSUBSCRIBE, topic);
2405
+ break;
2406
+ case CONTROL_ACTION.PUBLISH:
2407
+ this.runTransport(() => this.transport.publish(topic, data, publicationMetadata(messageId, timestamp)));
2408
+ break;
2409
+ default:
2410
+ break;
2411
+ }
2412
+ },
2413
+ // Batched variant of the PUBLISH action (CONTROL frames carrying
2414
+ // multiple items, and the local publishBatch fast path). Uses the
2415
+ // transport's one-frame publishBatch when available, preserving
2416
+ // per-item metadata; otherwise falls back to per-item publishes.
2417
+ onPublishBatch: (topic, items) => {
2418
+ if (typeof this.transport.publishBatch === "function") {
2419
+ this.runTransport(() => this.transport.publishBatch(topic, items));
2420
+ return;
2421
+ }
2422
+ for (const item of items) {
2423
+ this.runTransport(() => this.transport.publish(
2424
+ topic,
2425
+ item.data,
2426
+ publicationMetadata(item.messageId, item.timestamp)
2427
+ ));
2428
+ }
2429
+ },
2430
+ // The cluster calls `onEvent` when a publication broadcast arrives from
2431
+ // another tab. Dispatch locally if we have subscribers. The payload is
2432
+ // typed `unknown` at the cluster boundary (the cluster is transport-
2433
+ // agnostic); here we narrow it to DataBusMessage — the sender is our
2434
+ // own broadcastEvent call, which always posts a DataBusMessage.
2435
+ onEvent: (eventType, payload, _sourceWorkerId, originTabId) => {
2436
+ if (eventType !== PUBLICATION_EVENT) return;
2437
+ const incoming = payload;
2438
+ const message = incoming.originTabId !== void 0 ? incoming : originTabId !== void 0 ? { ...incoming, originTabId } : incoming;
2439
+ if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
2440
+ },
2441
+ onSuspend: () => {
2442
+ if (!this.stopping) this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.SUSPEND });
2443
+ this.trace.pause();
2444
+ this.replayManager.suspend();
2445
+ this.stopDedupSweep();
2446
+ this.suspendTransport();
2447
+ },
2448
+ onResume: () => {
2449
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
2450
+ this.trace.start();
2451
+ this.startDedupSweep();
2452
+ this.replayManager.start();
2453
+ this.resumeTransport();
2454
+ },
2455
+ onDiagnostic: (event) => {
2456
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, ...event });
2457
+ }
2458
+ }
2459
+ });
2460
+ if (autoStart ?? this.hasInitialConfig) this.ensureStarted();
1625
2461
  }
1626
2462
  /**
1627
2463
  * Start the DataBus with the given transport config.
@@ -1639,27 +2475,24 @@ var CrossTabDataBus = class {
1639
2475
  this.suspended = false;
1640
2476
  this.activeConfig = config;
1641
2477
  this.lastError = null;
1642
- this.trace.event({ type: "lifecycle", action: "start" });
2478
+ this.lastFailure = null;
2479
+ this.persistenceFailureCount = 0;
2480
+ this.persistenceLastFailureAt = null;
2481
+ this.persistenceLastErrorMessage = null;
2482
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
1643
2483
  this.trace.start();
1644
2484
  this.startDedupSweep();
1645
- this.startReplayRetentionSweep();
1646
- this.updateStatus("connecting");
2485
+ this.replayManager.start();
2486
+ this.updateStatus(WORKER_STATUS.CONNECTING);
1647
2487
  this.cluster.start();
1648
2488
  const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
1649
2489
  this.startPromise = opening;
1650
2490
  for (const topic of this.topicHandlers.keys()) {
1651
2491
  this.cluster.subscribe(topic);
1652
2492
  }
1653
- const snapshot = this.cluster.getSnapshot();
1654
- this.trace.event({
1655
- type: "coordination",
1656
- coordinated: snapshot.coordinated,
1657
- activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
1658
- workers: snapshot.workers.map(formatWorkerTrace),
1659
- routes: snapshot.routes.map(formatRouteTrace)
1660
- });
1661
2493
  void opening.then(
1662
2494
  () => {
2495
+ this.emitCoordinationTrace();
1663
2496
  if (this.startPromise === opening) this.startPromise = null;
1664
2497
  },
1665
2498
  () => {
@@ -1686,7 +2519,7 @@ var CrossTabDataBus = class {
1686
2519
  onError: (error) => this.reportError(error)
1687
2520
  })
1688
2521
  ).then(() => {
1689
- if (this.status === "error") {
2522
+ if (this.status === WORKER_STATUS.ERROR) {
1690
2523
  throw new Error("Transport failed during startup.");
1691
2524
  }
1692
2525
  if (!this.suspended && !this.stopping) {
@@ -1700,7 +2533,7 @@ var CrossTabDataBus = class {
1700
2533
  if (!this.pendingStop) {
1701
2534
  this.pendingStop = this.createStopPromise();
1702
2535
  }
1703
- this.updateStatus("error");
2536
+ this.updateStatus(WORKER_STATUS.ERROR);
1704
2537
  this.reportError(error);
1705
2538
  this.lastError = error;
1706
2539
  this.lastErrorAt = this.now();
@@ -1745,17 +2578,12 @@ var CrossTabDataBus = class {
1745
2578
  this.topicHandlers.set(topic, handlers);
1746
2579
  if (wasUnused) this.cluster.subscribe(topic);
1747
2580
  if (options?.replay) {
1748
- const limit = Math.min(
1749
- typeof options.replay === "number" ? Math.floor(options.replay) : this.replayMaxPerTopic,
1750
- this.replayMaxPerTopic
2581
+ this.replayManager.deliverReplay(
2582
+ topic,
2583
+ options.replay,
2584
+ handler,
2585
+ () => Boolean(this.topicHandlers.get(topic)?.has(handler))
1751
2586
  );
1752
- if (this.replayPersistence) {
1753
- void this.replayHydration.then(() => {
1754
- if (this.topicHandlers.get(topic)?.has(handler)) this.deliverReplay(topic, limit, handler);
1755
- });
1756
- } else {
1757
- this.deliverReplay(topic, limit, handler);
1758
- }
1759
2587
  }
1760
2588
  return () => this.unsubscribe(topic, handler);
1761
2589
  }
@@ -1770,70 +2598,28 @@ var CrossTabDataBus = class {
1770
2598
  else handlers.clear();
1771
2599
  if (handlers.size > 0) return;
1772
2600
  this.topicHandlers.delete(topic);
1773
- this.replayBuffers?.delete(topic);
1774
- if (this.replayPersistence?.clearTopic) {
1775
- void this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic)).catch((error) => this.reportPersistenceError(error));
1776
- }
2601
+ this.replayManager.onTopicUnsubscribed(topic);
1777
2602
  this.cluster.unsubscribe(topic);
1778
2603
  }
1779
2604
  /** Clear all in-memory replay buffers and, when supported, durable history. */
1780
2605
  async clearReplay() {
1781
- this.replayBuffers?.clear();
1782
- if (this.replayPersistence?.clear) {
1783
- try {
1784
- await this.withPersistenceRetry("clear", () => this.replayPersistence.clear());
1785
- } catch (error) {
1786
- this.reportPersistenceError(error);
1787
- throw error;
1788
- }
1789
- }
2606
+ await this.replayManager.clearAll();
1790
2607
  }
1791
2608
  /** Clear replay history for one exact topic, including durable storage. */
1792
2609
  async clearReplayTopic(topic) {
1793
- this.replayBuffers?.delete(topic);
1794
- if (this.replayPersistence?.clearTopic) {
1795
- try {
1796
- await this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic));
1797
- } catch (error) {
1798
- this.reportPersistenceError(error);
1799
- throw error;
1800
- }
1801
- }
2610
+ await this.replayManager.clearTopic(topic);
1802
2611
  }
1803
2612
  /** Remove replay entries older than an epoch-millisecond cutoff. */
1804
2613
  async clearReplayBefore(timestamp) {
1805
- if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
1806
- if (this.replayBuffers) {
1807
- for (const [topic, messages] of this.replayBuffers) {
1808
- const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
1809
- if (kept.length) this.replayBuffers.set(topic, kept);
1810
- else this.replayBuffers.delete(topic);
1811
- }
1812
- }
1813
- if (this.replayPersistence?.clearBefore) {
1814
- try {
1815
- await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(timestamp));
1816
- } catch (error) {
1817
- this.reportPersistenceError(error);
1818
- throw error;
1819
- }
1820
- }
2614
+ await this.replayManager.clearBefore(timestamp);
1821
2615
  }
1822
2616
  /** Return bounded deduplication counters for diagnostics and health checks. */
1823
2617
  getDedupStats() {
1824
- return {
1825
- enabled: this.dedupEnabled,
1826
- tracked: this.seenMessageIds.size,
1827
- suppressed: this.dedupSuppressed,
1828
- accepted: this.dedupAccepted,
1829
- ...this.dedupAdaptiveBounds ? { ttlMs: this.currentDedupTtl() } : {}
1830
- };
2618
+ return this.dedupManager.getStats();
1831
2619
  }
1832
2620
  /** Drop all remembered IDs and reset dedup counters. */
1833
2621
  resetDedup() {
1834
- this.seenMessageIds.clear();
1835
- this.dedupSuppressed = 0;
1836
- this.dedupAccepted = 0;
2622
+ this.dedupManager.reset();
1837
2623
  }
1838
2624
  /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
1839
2625
  publish(topic, data, options) {
@@ -1861,8 +2647,7 @@ var CrossTabDataBus = class {
1861
2647
  }
1862
2648
  const mapped = items.map((item) => ({
1863
2649
  data: item.data,
1864
- ...item.options?.messageId !== void 0 ? { messageId: item.options.messageId } : {},
1865
- ...item.options?.timestamp !== void 0 ? { timestamp: item.options.timestamp } : {}
2650
+ ...publicationMetadata(item.options?.messageId, item.options?.timestamp)
1866
2651
  }));
1867
2652
  if (!this.cluster.publishBatch(topic, mapped)) {
1868
2653
  this.reportError(
@@ -1907,6 +2692,41 @@ var CrossTabDataBus = class {
1907
2692
  lastSuccessAt: this.lastSuccessAt
1908
2693
  };
1909
2694
  }
2695
+ /** Bounded failure counters for the replay persistence backend. */
2696
+ getPersistenceStats() {
2697
+ return {
2698
+ failures: this.persistenceFailureCount,
2699
+ lastFailureAt: this.persistenceLastFailureAt,
2700
+ lastErrorMessage: this.persistenceLastErrorMessage
2701
+ };
2702
+ }
2703
+ /** Compact health verdict for dashboards, readiness probes, and support
2704
+ * bundles. Answers "is the bus usable right now" first, then attaches the
2705
+ * unified failure ledger and recovery context that explains the verdict. */
2706
+ getHealthSummary() {
2707
+ const transport = this.transport;
2708
+ const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2709
+ 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;
2710
+ return {
2711
+ healthy: state === HEALTH_STATE.HEALTHY,
2712
+ state,
2713
+ status: this.status,
2714
+ sdkVersion: SDK_VERSION,
2715
+ started: this.started,
2716
+ suspended: this.suspended,
2717
+ transport: {
2718
+ name: transport.diagnosticsName ?? transport.constructor.name,
2719
+ backend: transport.diagnosticsBackend ?? null,
2720
+ ready: this.transportReady,
2721
+ status: this.status
2722
+ },
2723
+ recovery: this.getRecoveryStats(),
2724
+ lastFailure: this.lastFailure,
2725
+ persistence: this.getPersistenceStats(),
2726
+ metrics: this.trace.getMetrics(),
2727
+ trace: this.trace.getSinkState()
2728
+ };
2729
+ }
1910
2730
  /** Snapshot of the cluster state (workers, routes, assignments).
1911
2731
  * For diagnostics only — the returned object is a shallow copy but
1912
2732
  * nested arrays are snapshots at call time. */
@@ -1915,8 +2735,7 @@ var CrossTabDataBus = class {
1915
2735
  }
1916
2736
  /** Return a single health snapshot combining lifecycle, recovery, dedup, replay, and cluster state. */
1917
2737
  getDiagnostics() {
1918
- let messages = 0;
1919
- if (this.replayBuffers) for (const buffer of this.replayBuffers.values()) messages += buffer.length;
2738
+ const replay = this.replayManager.getStats();
1920
2739
  const cluster = this.cluster.getSnapshot();
1921
2740
  const unknownMessages = this.cluster.getUnknownMessageStats();
1922
2741
  const transport = this.transport;
@@ -1927,12 +2746,26 @@ var CrossTabDataBus = class {
1927
2746
  transportReady: this.transportReady,
1928
2747
  recovery: this.getRecoveryStats(),
1929
2748
  dedup: this.getDedupStats(),
1930
- replay: { enabled: Boolean(this.replayBuffers), topics: this.replayBuffers?.size ?? 0, messages },
2749
+ replay: { enabled: replay.enabled, topics: replay.topics, messages: replay.messages, bytes: replay.bytes },
2750
+ persistence: this.getPersistenceStats(),
1931
2751
  protocol: { version: cluster.protocolVersion, unknownMessages: unknownMessages.count, lastUnknownMessageType: unknownMessages.lastType, peers: cluster.peerProtocolVersions },
1932
- transport: { name: transport.diagnosticsName ?? transport.constructor.name, backend: transport.diagnosticsBackend ?? null },
1933
- cluster
2752
+ transport: {
2753
+ name: transport.diagnosticsName ?? transport.constructor.name,
2754
+ backend: transport.diagnosticsBackend ?? null,
2755
+ status: this.status,
2756
+ suspended: this.suspended
2757
+ },
2758
+ cluster,
2759
+ metrics: this.trace.getMetrics(),
2760
+ trace: this.trace.getSinkState()
1934
2761
  };
1935
2762
  }
2763
+ /** Synchronous snapshot of the current trace metrics window (throughput,
2764
+ * dispatch latency, dedup outcomes), without flushing or resetting it.
2765
+ * Returns null when trace metrics are inactive (disabled or events-only). */
2766
+ getMetrics() {
2767
+ return this.trace.getMetrics();
2768
+ }
1936
2769
  /**
1937
2770
  * Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
1938
2771
  * and close the transport. Idempotent.
@@ -1940,13 +2773,12 @@ var CrossTabDataBus = class {
1940
2773
  async stop() {
1941
2774
  if (!this.started) return;
1942
2775
  this.stopping = true;
1943
- this.persistenceRetryGeneration += 1;
1944
- this.trace.event({ type: "lifecycle", action: "stop" });
2776
+ this.replayManager.suspend();
2777
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
1945
2778
  this.trace.stop();
1946
2779
  this.stopDedupSweep();
1947
- this.stopReplayRetentionSweep();
1948
2780
  this.topicHandlers.clear();
1949
- this.replayBuffers?.clear();
2781
+ this.replayManager.resetBuffers();
1950
2782
  this.cluster.stop();
1951
2783
  try {
1952
2784
  await this.startPromise?.catch(() => void 0);
@@ -1967,7 +2799,7 @@ var CrossTabDataBus = class {
1967
2799
  this.activeConfig = void 0;
1968
2800
  this.recoveryAttempt = 0;
1969
2801
  this.recoveryExhausted = false;
1970
- this.updateStatus("disconnected");
2802
+ this.updateStatus(WORKER_STATUS.DISCONNECTED);
1971
2803
  }
1972
2804
  }
1973
2805
  /**
@@ -1976,7 +2808,7 @@ var CrossTabDataBus = class {
1976
2808
  * and dispatches locally.
1977
2809
  */
1978
2810
  handleTransportMessage(message) {
1979
- if (this.isDuplicate(message)) return;
2811
+ if (this.dedupManager.isDuplicate(message.messageId ?? "", message.topic)) return;
1980
2812
  this.trace.recordReceived(message.topic);
1981
2813
  if (!this.cluster.isAssigned(message.topic)) {
1982
2814
  this.trace.recordDiscarded(message.topic);
@@ -1990,55 +2822,13 @@ var CrossTabDataBus = class {
1990
2822
  }
1991
2823
  this.trace.recordDiscarded(message.topic);
1992
2824
  }
1993
- isDuplicate(message) {
1994
- if (!this.dedupEnabled || !message.messageId) return false;
1995
- const now = this.now();
1996
- for (const [id, timestamp] of this.seenMessageIds) {
1997
- if (now - timestamp > this.dedupTtlMs) this.seenMessageIds.delete(id);
1998
- }
1999
- if (this.seenMessageIds.has(message.messageId)) {
2000
- this.trace.event({ type: "reliability", operation: "dedup_suppressed", topic: message.topic });
2001
- this.dedupSuppressed += 1;
2002
- this.trace.recordDedupSuppressed();
2003
- return true;
2004
- }
2005
- this.seenMessageIds.set(message.messageId, now);
2006
- this.dedupAccepted += 1;
2007
- this.dedupWindowAccepted += 1;
2008
- this.trace.recordDedupAccepted();
2009
- while (this.seenMessageIds.size > this.dedupMaxEntries) {
2010
- const oldest = this.seenMessageIds.keys().next().value;
2011
- if (oldest === void 0) break;
2012
- this.seenMessageIds.delete(oldest);
2013
- }
2014
- return false;
2015
- }
2825
+ /** Start enqueuing the dedup expiry sweep (delegated to {@link DedupManager}). */
2016
2826
  startDedupSweep() {
2017
- if (this.dedupSweepTimer || !this.dedupEnabled || !this.dedupSweepMs) return;
2018
- this.dedupSweepTimer = setInterval(() => this.pruneExpiredDedup(), this.dedupSweepMs);
2827
+ this.dedupManager.start();
2019
2828
  }
2829
+ /** Stop enqueuing the dedup expiry sweep. */
2020
2830
  stopDedupSweep() {
2021
- if (this.dedupSweepTimer) clearInterval(this.dedupSweepTimer);
2022
- this.dedupSweepTimer = null;
2023
- }
2024
- pruneExpiredDedup() {
2025
- const cutoff = this.now() - this.currentDedupTtl();
2026
- for (const [id, timestamp] of this.seenMessageIds) {
2027
- if (timestamp < cutoff) this.seenMessageIds.delete(id);
2028
- }
2029
- }
2030
- currentDedupTtl() {
2031
- if (!this.dedupAdaptiveBounds) return this.dedupTtlMs;
2032
- const now = this.now();
2033
- const elapsed = now - this.dedupWindowStartedAt;
2034
- if (elapsed >= this.dedupAdaptiveWindowMs) {
2035
- this.dedupWindowStartedAt = now;
2036
- this.dedupWindowAccepted = 0;
2037
- return this.dedupAdaptiveBounds.maxMs;
2038
- }
2039
- const rate = this.dedupWindowAccepted / Math.max(1, elapsed);
2040
- const factor = Math.min(1, rate / 0.01);
2041
- return this.dedupAdaptiveBounds.maxMs - (this.dedupAdaptiveBounds.maxMs - this.dedupAdaptiveBounds.minMs) * factor;
2831
+ this.dedupManager.stop();
2042
2832
  }
2043
2833
  /** Deliver a message to every local handler registered for its topic,
2044
2834
  * plus every handler registered with a wildcard subscription that matches
@@ -2051,137 +2841,7 @@ var CrossTabDataBus = class {
2051
2841
  this.invokeHandlers(handlers, (handler) => handler(message));
2052
2842
  }
2053
2843
  }
2054
- this.recordReplay(message);
2055
- }
2056
- /** Append a dispatched publication to the topic's replay ring buffer.
2057
- * No-op when replay is disabled. */
2058
- recordReplay(message) {
2059
- if (!this.replayBuffers) return;
2060
- let buffer = this.replayBuffers.get(message.topic);
2061
- if (!buffer) {
2062
- buffer = [];
2063
- this.replayBuffers.set(message.topic, buffer);
2064
- }
2065
- const storedMessage = message;
2066
- buffer.push(storedMessage);
2067
- if (this.replayPruneStrategy !== "age") {
2068
- while (buffer.length > this.replayMaxPerTopic) buffer.shift();
2069
- }
2070
- if (this.replayPruneStrategy !== "count" && this.replayRetentionMs !== void 0) {
2071
- const cutoff = this.now() - this.replayRetentionMs;
2072
- while (buffer.length > 0) {
2073
- const first = buffer[0];
2074
- if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
2075
- buffer.shift();
2076
- }
2077
- }
2078
- if (this.replayPersistence) {
2079
- void this.withPersistenceRetry("append", () => this.replayPersistence.append(storedMessage)).catch((error) => this.reportPersistenceError(error));
2080
- if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
2081
- this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
2082
- }
2083
- }
2084
- }
2085
- async hydrateReplay() {
2086
- if (!this.replayBuffers || !this.replayPersistence) {
2087
- return;
2088
- }
2089
- try {
2090
- if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
2091
- await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(this.now() - this.replayRetentionMs));
2092
- }
2093
- for (const message of await this.withPersistenceRetry("load", () => this.replayPersistence.load())) {
2094
- let buffer = this.replayBuffers.get(message.topic);
2095
- if (!buffer) {
2096
- buffer = [];
2097
- this.replayBuffers.set(message.topic, buffer);
2098
- }
2099
- buffer.push(message);
2100
- if (buffer.length > this.replayMaxPerTopic) buffer.shift();
2101
- }
2102
- } catch (error) {
2103
- this.reportPersistenceError(error);
2104
- }
2105
- }
2106
- scheduleReplayRetentionCleanup(cutoff) {
2107
- if (!this.replayPersistence?.clearBefore) return;
2108
- if (this.replayRetentionCutoff === null || cutoff > this.replayRetentionCutoff) {
2109
- this.replayRetentionCutoff = cutoff;
2110
- }
2111
- if (this.replayRetentionCleanup) return;
2112
- this.replayRetentionCleanup = (async () => {
2113
- while (this.replayRetentionCutoff !== null) {
2114
- const nextCutoff = this.replayRetentionCutoff;
2115
- this.replayRetentionCutoff = null;
2116
- try {
2117
- await this.replayPersistence.clearBefore(nextCutoff);
2118
- } catch (error) {
2119
- this.reportPersistenceError(error);
2120
- }
2121
- }
2122
- })().finally(() => {
2123
- this.replayRetentionCleanup = null;
2124
- if (this.replayRetentionCutoff !== null) {
2125
- this.scheduleReplayRetentionCleanup(this.replayRetentionCutoff);
2126
- }
2127
- });
2128
- }
2129
- startReplayRetentionSweep() {
2130
- if (this.replayRetentionTimer || !this.replayRetentionMs || !this.replayRetentionSweepMs || !this.replayPersistence?.clearBefore) return;
2131
- this.replayRetentionTimer = setInterval(() => {
2132
- this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
2133
- }, this.replayRetentionSweepMs);
2134
- }
2135
- stopReplayRetentionSweep() {
2136
- if (this.replayRetentionTimer) clearInterval(this.replayRetentionTimer);
2137
- this.replayRetentionTimer = null;
2138
- }
2139
- async withPersistenceRetry(persistenceOperation, operation) {
2140
- const generation = this.persistenceRetryGeneration;
2141
- let attempt = 0;
2142
- let delay = this.persistenceRetryBackoffMs;
2143
- while (true) {
2144
- attempt += 1;
2145
- try {
2146
- if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
2147
- return await operation();
2148
- } catch (error) {
2149
- if (error instanceof PersistenceRetryCancelledError || generation !== this.persistenceRetryGeneration) {
2150
- throw new PersistenceRetryCancelledError();
2151
- }
2152
- if (attempt >= this.persistenceRetryMaxAttempts) throw error;
2153
- this.trace.event({
2154
- type: "reliability",
2155
- operation: "persistence_retry",
2156
- persistenceOperation,
2157
- attempt
2158
- });
2159
- if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
2160
- if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
2161
- delay = Math.min(delay * 2, 1600);
2162
- }
2163
- }
2164
- }
2165
- /** Deliver buffered history to a newly-registered handler. For an exact
2166
- * topic this is that topic's ring; for a wildcard subscription every
2167
- * buffered topic matching the pattern contributes (in buffer insertion
2168
- * order). Replay deliveries are marked `replayed: true` and are not
2169
- * counted into trace metrics. */
2170
- deliverReplay(topic, limit, handler) {
2171
- if (!this.replayBuffers || limit <= 0) return;
2172
- const deliver = (buffer2) => {
2173
- for (const message of buffer2.slice(-limit)) {
2174
- this.invokeHandlers([handler], (h) => h({ ...message, replayed: true }));
2175
- }
2176
- };
2177
- if (isWildcardTopic(topic)) {
2178
- for (const [bufferedTopic, buffer2] of this.replayBuffers) {
2179
- if (topicMatchesPattern(topic, bufferedTopic)) deliver(buffer2);
2180
- }
2181
- return;
2182
- }
2183
- const buffer = this.replayBuffers.get(topic);
2184
- if (buffer) deliver(buffer);
2844
+ this.replayManager.record(message);
2185
2845
  }
2186
2846
  /**
2187
2847
  * Propagate a status change to the cluster, trace, and all registered
@@ -2190,13 +2850,13 @@ var CrossTabDataBus = class {
2190
2850
  updateStatus(status) {
2191
2851
  const previousStatus = this.status;
2192
2852
  this.status = status;
2193
- if (previousStatus !== status) this.trace.event({ type: "status", status });
2853
+ if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
2194
2854
  this.cluster.setStatus(status);
2195
- if (status === "disconnected" || status === "error") this.transportSubscribedTopics.clear();
2196
- if (status === "connected" && previousStatus !== "connected") {
2855
+ if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
2856
+ if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
2197
2857
  for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
2198
2858
  }
2199
- if (status === "error" && this.started && !this.stopping) {
2859
+ if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
2200
2860
  const now = this.now();
2201
2861
  if (now - this.lastRecoveryAt >= this.recoveryCooldownMs) {
2202
2862
  this.lastRecoveryAt = now;
@@ -2204,37 +2864,67 @@ var CrossTabDataBus = class {
2204
2864
  if (attempt > this.recoveryMaxAttempts) {
2205
2865
  if (!this.recoveryExhausted) {
2206
2866
  this.recoveryExhausted = true;
2207
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: this.recoveryMaxAttempts, outcome: "exhausted" });
2867
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
2208
2868
  }
2209
2869
  return;
2210
2870
  }
2211
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt, outcome: "scheduled" });
2871
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
2212
2872
  setTimeout(() => {
2213
2873
  if (this.stopping || !this.started || this.suspended) return;
2214
- if (this.status !== "error") return;
2874
+ if (this.status !== WORKER_STATUS.ERROR) return;
2215
2875
  void this.reopenTransport(attempt);
2216
2876
  }, this.recoveryCooldownMs);
2217
2877
  }
2218
2878
  }
2219
2879
  this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
2220
2880
  }
2221
- reportError(error) {
2222
- this.trace.event({ type: "error", source: "transport" });
2223
- this.invokeHandlers(this.errorHandlers, (handler) => handler(error), "error handler");
2881
+ reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
2882
+ this.lastFailure = {
2883
+ source,
2884
+ message: error instanceof Error ? error.message : String(error),
2885
+ at: this.now()
2886
+ };
2887
+ if (source === FAILURE_SOURCE.PERSISTENCE) {
2888
+ this.persistenceFailureCount += 1;
2889
+ this.persistenceLastFailureAt = this.lastFailure.at;
2890
+ this.persistenceLastErrorMessage = this.lastFailure.message;
2891
+ }
2892
+ this.trace.event({
2893
+ type: TRACE_EVENT_TYPE.ERROR,
2894
+ source: source === FAILURE_SOURCE.TRANSPORT ? TRACE_ERROR_SOURCE.TRANSPORT : TRACE_ERROR_SOURCE.OPERATION
2895
+ });
2896
+ this.invokeHandlers(this.errorHandlers, (handler) => handler(error), INVOKE_LABEL.ERROR_HANDLER);
2224
2897
  }
2898
+ /** Report a persistence failure to the trace and the unified failure ledger,
2899
+ * unless it is a {@link PersistenceRetryCancelledError} cancellation from a
2900
+ * lifecycle transition (teardown should stay quiet). */
2225
2901
  reportPersistenceError(error) {
2226
2902
  if (error instanceof PersistenceRetryCancelledError) return;
2227
- this.trace.event({ type: "reliability", operation: "persistence_cleanup" });
2228
- this.reportError(error);
2903
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.PERSISTENCE_CLEANUP });
2904
+ this.reportError(error, FAILURE_SOURCE.PERSISTENCE);
2229
2905
  }
2230
2906
  traceSubscription(action, topic) {
2231
2907
  this.trace.event({
2232
- type: "subscription",
2908
+ type: TRACE_EVENT_TYPE.SUBSCRIPTION,
2233
2909
  action,
2234
2910
  topic,
2235
2911
  activeTopics: this.transportSubscribedTopics.size
2236
2912
  });
2237
2913
  }
2914
+ /** Emit the coordination trace snapshot from the current cluster state.
2915
+ * Called after a transport opens (start and recovery), when the role and
2916
+ * route picture has settled — the synchronous pre-open snapshot would see no
2917
+ * routes because their writes are still coalesced in the batch writer. */
2918
+ emitCoordinationTrace() {
2919
+ const snapshot = this.cluster.getSnapshot();
2920
+ this.trace.event({
2921
+ type: TRACE_EVENT_TYPE.COORDINATION,
2922
+ coordinated: snapshot.coordinated,
2923
+ activeWorkers: snapshot.workers.filter((worker) => worker.role === WORKER_ROLE.ACTIVE).length,
2924
+ workers: snapshot.workers.map(formatWorkerTrace),
2925
+ routes: snapshot.routes.map(formatRouteTrace)
2926
+ });
2927
+ }
2238
2928
  /** Ask the transport to subscribe to a topic (idempotent). */
2239
2929
  subscribeTransport(topic) {
2240
2930
  if (this.transportSubscribedTopics.has(topic)) return false;
@@ -2252,17 +2942,17 @@ var CrossTabDataBus = class {
2252
2942
  * are routed to `reportError` (which surfaces them to error subscribers);
2253
2943
  * error-handler failures are logged to the console to avoid infinite
2254
2944
  * recursion through reportError itself. */
2255
- invokeHandlers(handlers, callback, label = "dispatch") {
2945
+ invokeHandlers(handlers, callback, label = INVOKE_LABEL.DISPATCH) {
2256
2946
  for (const handler of handlers) {
2257
2947
  try {
2258
2948
  callback(handler);
2259
2949
  } catch (error) {
2260
- if (label === "error handler") {
2950
+ if (label === INVOKE_LABEL.ERROR_HANDLER) {
2261
2951
  if (typeof console !== "undefined" && typeof console.warn === "function") {
2262
- console.warn("[cross-tab-worker-databus] error handler threw:", error);
2952
+ console.warn(`[${DEFAULT_STORAGE_PREFIX}] error handler threw:`, error);
2263
2953
  }
2264
2954
  } else {
2265
- this.reportError(error);
2955
+ this.reportError(error, FAILURE_SOURCE.DISPATCH);
2266
2956
  }
2267
2957
  }
2268
2958
  }
@@ -2276,7 +2966,7 @@ var CrossTabDataBus = class {
2276
2966
  this.suspended = true;
2277
2967
  this.transportReady = false;
2278
2968
  this.transportSubscribedTopics.clear();
2279
- this.updateStatus("disconnected");
2969
+ this.updateStatus(WORKER_STATUS.DISCONNECTED);
2280
2970
  if (this.pendingStop) return;
2281
2971
  const pending = this.startPromise ?? Promise.resolve();
2282
2972
  const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
@@ -2310,14 +3000,14 @@ var CrossTabDataBus = class {
2310
3000
  const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
2311
3001
  this.started = true;
2312
3002
  this.suspended = false;
2313
- this.updateStatus("connecting");
3003
+ this.updateStatus(WORKER_STATUS.CONNECTING);
2314
3004
  const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
2315
3005
  const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
2316
3006
  this.startPromise = opening;
2317
3007
  void opening.then(
2318
3008
  () => {
2319
3009
  if (traceAttempt !== void 0) {
2320
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: traceAttempt, outcome: "succeeded" });
3010
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
2321
3011
  this.recoveryAttempt = 0;
2322
3012
  this.recoveryExhausted = false;
2323
3013
  }
@@ -2325,7 +3015,7 @@ var CrossTabDataBus = class {
2325
3015
  },
2326
3016
  () => {
2327
3017
  if (traceAttempt !== void 0) {
2328
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: traceAttempt, outcome: "failed" });
3018
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
2329
3019
  }
2330
3020
  }
2331
3021
  );
@@ -2383,16 +3073,14 @@ function formatRouteTrace(route) {
2383
3073
  function createIndexedDbReplayPersistence(options) {
2384
3074
  const indexedDb = globalThis.indexedDB;
2385
3075
  if (!indexedDb) throw new Error("IndexedDB is unavailable in this environment.");
2386
- const dbName = options.dbName ?? "cross-tab-worker-databus";
3076
+ const dbName = options.dbName ?? DEFAULT_STORAGE_PREFIX;
2387
3077
  const storeName = "replay";
2388
3078
  const maxPerTopic = options.maxPerTopic;
2389
- const pruneStrategy = options.pruneStrategy ?? "count";
3079
+ const pruneStrategy = options.pruneStrategy ?? PRUNE_STRATEGY.COUNT;
2390
3080
  const retentionMs = options.retentionMs;
2391
- if (!["count", "age", "both"].includes(pruneStrategy)) throw new TypeError("pruneStrategy must be count, age, or both.");
2392
- if (retentionMs !== void 0 && (!Number.isFinite(retentionMs) || retentionMs <= 0)) throw new TypeError("retentionMs must be a positive finite number.");
2393
- if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
2394
- throw new TypeError(`maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`);
2395
- }
3081
+ assertPruneStrategy(pruneStrategy);
3082
+ if (retentionMs !== void 0) assertPositiveFiniteNumber(retentionMs, "retentionMs");
3083
+ assertPositiveSafeInteger(maxPerTopic, "maxPerTopic");
2396
3084
  let dbPromise = null;
2397
3085
  const invalidate = (db) => {
2398
3086
  if (dbPromise) {
@@ -2404,15 +3092,100 @@ function createIndexedDbReplayPersistence(options) {
2404
3092
  }, () => void 0);
2405
3093
  }
2406
3094
  };
2407
- let mutationQueue = Promise.resolve();
2408
- const serializeMutation = (mutation) => {
2409
- const next = mutationQueue.then(mutation, mutation);
2410
- mutationQueue = next.catch(() => void 0);
2411
- return next;
3095
+ const pending = [];
3096
+ let draining = false;
3097
+ const drain = async () => {
3098
+ if (draining) return;
3099
+ draining = true;
3100
+ try {
3101
+ await Promise.resolve();
3102
+ while (pending.length > 0) {
3103
+ const head = pending[0].mutation;
3104
+ if (head.kind === "batch") {
3105
+ const merged = [];
3106
+ const entries = [];
3107
+ while (pending.length > 0) {
3108
+ const next = pending[0].mutation;
3109
+ if (next.kind !== "batch") break;
3110
+ merged.push(...next.messages);
3111
+ entries.push({ resolve: pending[0].resolve, reject: pending[0].reject });
3112
+ pending.shift();
3113
+ }
3114
+ try {
3115
+ await appendTransaction(merged);
3116
+ for (const entry of entries) entry.resolve();
3117
+ } catch (error) {
3118
+ for (const entry of entries) entry.reject(error);
3119
+ }
3120
+ } else {
3121
+ const entry = pending.shift();
3122
+ try {
3123
+ await head.run();
3124
+ entry.resolve();
3125
+ } catch (error) {
3126
+ entry.reject(error);
3127
+ }
3128
+ }
3129
+ }
3130
+ } finally {
3131
+ draining = false;
3132
+ }
2412
3133
  };
3134
+ const enqueue = (mutation) => new Promise((resolve, reject) => {
3135
+ pending.push({ mutation, resolve, reject });
3136
+ void drain();
3137
+ });
3138
+ const appendTransaction = (messages) => (async () => {
3139
+ const db = await open();
3140
+ return new Promise((resolve, reject) => {
3141
+ let transaction;
3142
+ try {
3143
+ transaction = db.transaction(storeName, "readwrite");
3144
+ } catch (error) {
3145
+ invalidate(db);
3146
+ reject(error);
3147
+ return;
3148
+ }
3149
+ const store = transaction.objectStore(storeName);
3150
+ const grouped = /* @__PURE__ */ new Map();
3151
+ for (const message of messages) {
3152
+ grouped.set(message.topic, [...grouped.get(message.topic) ?? [], message]);
3153
+ }
3154
+ let hasError = false;
3155
+ for (const [topic, topicMessages] of grouped) {
3156
+ const request = store.get(topic);
3157
+ request.onsuccess = () => {
3158
+ if (hasError) return;
3159
+ let history = (request.result?.messages ?? []).concat(topicMessages);
3160
+ const ageBounded = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
3161
+ if (ageBounded) {
3162
+ const cutoff = Date.now() - retentionMs;
3163
+ history = history.filter((item) => item.timestamp === void 0 || item.timestamp >= cutoff);
3164
+ }
3165
+ if (pruneStrategy !== PRUNE_STRATEGY.AGE || !ageBounded) history = history.slice(-maxPerTopic);
3166
+ store.put({ topic, messages: history });
3167
+ };
3168
+ request.onerror = () => {
3169
+ if (hasError) return;
3170
+ hasError = true;
3171
+ invalidate(db);
3172
+ reject(request.error ?? new Error("Failed to read replay history."));
3173
+ };
3174
+ }
3175
+ transaction.oncomplete = () => {
3176
+ if (!hasError) resolve();
3177
+ };
3178
+ transaction.onerror = () => {
3179
+ if (hasError) return;
3180
+ hasError = true;
3181
+ invalidate(db);
3182
+ reject(transaction.error ?? new Error("Failed to persist replay history."));
3183
+ };
3184
+ });
3185
+ })();
2413
3186
  const open = () => {
2414
3187
  if (dbPromise) return dbPromise;
2415
- const pending = new Promise((resolve, reject) => {
3188
+ const pending2 = new Promise((resolve, reject) => {
2416
3189
  const request = indexedDb.open(dbName, 1);
2417
3190
  request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath: "topic" });
2418
3191
  request.onsuccess = () => {
@@ -2425,11 +3198,11 @@ function createIndexedDbReplayPersistence(options) {
2425
3198
  };
2426
3199
  request.onerror = () => reject(request.error ?? new Error("Failed to open replay database."));
2427
3200
  });
2428
- dbPromise = pending;
2429
- void pending.catch(() => {
2430
- if (dbPromise === pending) dbPromise = null;
3201
+ dbPromise = pending2;
3202
+ void pending2.catch(() => {
3203
+ if (dbPromise === pending2) dbPromise = null;
2431
3204
  });
2432
- return pending;
3205
+ return pending2;
2433
3206
  };
2434
3207
  return {
2435
3208
  async load() {
@@ -2451,113 +3224,94 @@ function createIndexedDbReplayPersistence(options) {
2451
3224
  });
2452
3225
  },
2453
3226
  append(message) {
2454
- return serializeMutation(async () => {
2455
- const db = await open();
2456
- await new Promise((resolve, reject) => {
2457
- let transaction;
2458
- try {
2459
- transaction = db.transaction(storeName, "readwrite");
2460
- } catch (error) {
2461
- invalidate(db);
2462
- reject(error);
2463
- return;
2464
- }
2465
- const store = transaction.objectStore(storeName);
2466
- const request = store.get(message.topic);
2467
- request.onsuccess = () => {
2468
- let messages = (request.result?.messages ?? []).concat(message);
2469
- if (pruneStrategy !== "count" && retentionMs !== void 0) {
2470
- const cutoff = Date.now() - retentionMs;
2471
- messages = messages.filter((item) => item.timestamp === void 0 || item.timestamp >= cutoff);
2472
- }
2473
- if (pruneStrategy !== "age") messages = messages.slice(-maxPerTopic);
2474
- store.put({ topic: message.topic, messages });
2475
- };
2476
- request.onerror = () => {
2477
- invalidate(db);
2478
- reject(request.error ?? new Error("Failed to read replay history."));
2479
- };
2480
- transaction.oncomplete = () => resolve();
2481
- transaction.onerror = () => {
2482
- invalidate(db);
2483
- reject(transaction.error ?? new Error("Failed to persist replay history."));
2484
- };
2485
- });
2486
- });
3227
+ return enqueue({ kind: "batch", messages: [message] });
3228
+ },
3229
+ appendBatch(messages) {
3230
+ if (messages.length === 0) return Promise.resolve();
3231
+ return enqueue({ kind: "batch", messages });
2487
3232
  },
2488
3233
  clear() {
2489
- return serializeMutation(async () => {
2490
- const db = await open();
2491
- await new Promise((resolve, reject) => {
2492
- let transaction;
2493
- try {
2494
- transaction = db.transaction(storeName, "readwrite");
2495
- } catch (error) {
2496
- invalidate(db);
2497
- reject(error);
2498
- return;
2499
- }
2500
- transaction.objectStore(storeName).clear();
2501
- transaction.oncomplete = () => resolve();
2502
- transaction.onerror = () => {
2503
- invalidate(db);
2504
- reject(transaction.error ?? new Error("Failed to clear replay history."));
2505
- };
2506
- });
3234
+ return enqueue({
3235
+ kind: "run",
3236
+ run: () => (async () => {
3237
+ const db = await open();
3238
+ return new Promise((resolve, reject) => {
3239
+ let transaction;
3240
+ try {
3241
+ transaction = db.transaction(storeName, "readwrite");
3242
+ } catch (error) {
3243
+ invalidate(db);
3244
+ reject(error);
3245
+ return;
3246
+ }
3247
+ transaction.objectStore(storeName).clear();
3248
+ transaction.oncomplete = () => resolve();
3249
+ transaction.onerror = () => {
3250
+ invalidate(db);
3251
+ reject(transaction.error ?? new Error("Failed to clear replay history."));
3252
+ };
3253
+ });
3254
+ })()
2507
3255
  });
2508
3256
  },
2509
3257
  clearTopic(topic) {
2510
- return serializeMutation(async () => {
2511
- const db = await open();
2512
- await new Promise((resolve, reject) => {
2513
- let transaction;
2514
- try {
2515
- transaction = db.transaction(storeName, "readwrite");
2516
- } catch (error) {
2517
- invalidate(db);
2518
- reject(error);
2519
- return;
2520
- }
2521
- transaction.objectStore(storeName).delete(topic);
2522
- transaction.oncomplete = () => resolve();
2523
- transaction.onerror = () => {
2524
- invalidate(db);
2525
- reject(transaction.error ?? new Error("Failed to clear topic replay history."));
2526
- };
2527
- });
3258
+ return enqueue({
3259
+ kind: "run",
3260
+ run: () => (async () => {
3261
+ const db = await open();
3262
+ return new Promise((resolve, reject) => {
3263
+ let transaction;
3264
+ try {
3265
+ transaction = db.transaction(storeName, "readwrite");
3266
+ } catch (error) {
3267
+ invalidate(db);
3268
+ reject(error);
3269
+ return;
3270
+ }
3271
+ transaction.objectStore(storeName).delete(topic);
3272
+ transaction.oncomplete = () => resolve();
3273
+ transaction.onerror = () => {
3274
+ invalidate(db);
3275
+ reject(transaction.error ?? new Error("Failed to clear topic replay history."));
3276
+ };
3277
+ });
3278
+ })()
2528
3279
  });
2529
3280
  },
2530
3281
  clearBefore(timestamp) {
2531
- return serializeMutation(async () => {
2532
- const db = await open();
2533
- await new Promise((resolve, reject) => {
2534
- let transaction;
2535
- try {
2536
- transaction = db.transaction(storeName, "readwrite");
2537
- } catch (error) {
2538
- invalidate(db);
2539
- reject(error);
2540
- return;
2541
- }
2542
- const store = transaction.objectStore(storeName);
2543
- const request = store.getAll();
2544
- request.onsuccess = () => {
2545
- for (const record of request.result) {
2546
- const messages = record.messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
2547
- if (messages.length === 0) store.delete(record.topic);
2548
- else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
3282
+ return enqueue({
3283
+ kind: "run",
3284
+ run: () => (async () => {
3285
+ const db = await open();
3286
+ return new Promise((resolve, reject) => {
3287
+ let transaction;
3288
+ try {
3289
+ transaction = db.transaction(storeName, "readwrite");
3290
+ } catch (error) {
3291
+ invalidate(db);
3292
+ reject(error);
3293
+ return;
2549
3294
  }
2550
- };
2551
- request.onerror = () => {
2552
- invalidate(db);
2553
- reject(request.error ?? new Error("Failed to read replay history."));
2554
- };
2555
- transaction.oncomplete = () => resolve();
2556
- transaction.onerror = () => {
2557
- invalidate(db);
2558
- reject(transaction.error ?? new Error("Failed to prune replay history."));
2559
- };
2560
- });
3295
+ const store = transaction.objectStore(storeName);
3296
+ const request = store.getAll();
3297
+ request.onsuccess = () => {
3298
+ for (const record of request.result) {
3299
+ const messages = record.messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
3300
+ if (messages.length === 0) store.delete(record.topic);
3301
+ else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
3302
+ }
3303
+ };
3304
+ request.onerror = () => {
3305
+ invalidate(db);
3306
+ reject(request.error ?? new Error("Failed to read replay history."));
3307
+ };
3308
+ transaction.oncomplete = () => resolve();
3309
+ transaction.onerror = () => {
3310
+ invalidate(db);
3311
+ reject(transaction.error ?? new Error("Failed to prune replay history."));
3312
+ };
3313
+ });
3314
+ })()
2561
3315
  });
2562
3316
  }
2563
3317
  };
@@ -2591,6 +3345,7 @@ var WebSocketTransport = class {
2591
3345
  constructor(connection) {
2592
3346
  this.connection = connection;
2593
3347
  }
3348
+ connection;
2594
3349
  diagnosticsName = "websocket";
2595
3350
  diagnosticsBackend = "native-websocket";
2596
3351
  socket = null;
@@ -2607,22 +3362,22 @@ var WebSocketTransport = class {
2607
3362
  try {
2608
3363
  socket = factory(config.url, protocols);
2609
3364
  } catch (error) {
2610
- handlers.onStatus("error");
3365
+ handlers.onStatus(WORKER_STATUS.ERROR);
2611
3366
  handlers.onError(error);
2612
3367
  return;
2613
3368
  }
2614
3369
  socket.onopen = () => {
2615
3370
  if (this.socket !== socket || this.handlers !== handlers) return;
2616
3371
  for (const topic of this.subscribedTopics) {
2617
- this.sendFrame({ op: "subscribe", topic });
3372
+ this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
2618
3373
  }
2619
- handlers.onStatus("connected");
3374
+ handlers.onStatus(WORKER_STATUS.CONNECTED);
2620
3375
  };
2621
3376
  socket.onclose = () => {
2622
- if (this.socket === socket && this.handlers === handlers) handlers.onStatus("disconnected");
3377
+ if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.DISCONNECTED);
2623
3378
  };
2624
3379
  socket.onerror = () => {
2625
- if (this.socket === socket && this.handlers === handlers) handlers.onStatus("error");
3380
+ if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.ERROR);
2626
3381
  };
2627
3382
  socket.onmessage = (event) => {
2628
3383
  if (this.socket === socket && this.handlers === handlers) void this.handleMessage(event.data);
@@ -2633,12 +3388,12 @@ var WebSocketTransport = class {
2633
3388
  * not duplicate the local tracking entry. */
2634
3389
  subscribe(topic) {
2635
3390
  this.subscribedTopics.add(topic);
2636
- this.sendFrame({ op: "subscribe", topic });
3391
+ this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
2637
3392
  }
2638
3393
  /** Idempotent: unsubscribing an unknown topic is a no-op. */
2639
3394
  unsubscribe(topic) {
2640
3395
  this.subscribedTopics.delete(topic);
2641
- this.sendFrame({ op: "unsubscribe", topic });
3396
+ this.sendFrame({ op: WS_OP.UNSUBSCRIBE, topic });
2642
3397
  }
2643
3398
  /** Publish `data` to `topic` as a JSON frame. Requires an open socket. */
2644
3399
  publish(topic, data, options) {
@@ -2647,13 +3402,39 @@ var WebSocketTransport = class {
2647
3402
  return;
2648
3403
  }
2649
3404
  this.sendFrame({
2650
- op: "publish",
3405
+ op: WS_OP.PUBLISH,
2651
3406
  topic,
2652
3407
  data,
2653
3408
  ...options?.messageId === void 0 ? {} : { messageId: options.messageId },
2654
3409
  ...options?.timestamp === void 0 ? {} : { timestamp: options.timestamp }
2655
3410
  });
2656
3411
  }
3412
+ /** Publish many items for one topic as a single wire frame. One-item
3413
+ * batches delegate to `publish` so the legacy single-publication frame
3414
+ * shape (including binary framing) is preserved. */
3415
+ publishBatch(topic, items) {
3416
+ if (items.length === 0) return;
3417
+ if (items.length === 1) {
3418
+ const single = items[0];
3419
+ return this.publish(topic, single.data, {
3420
+ ...single.messageId === void 0 ? {} : { messageId: single.messageId },
3421
+ ...single.timestamp === void 0 ? {} : { timestamp: single.timestamp }
3422
+ });
3423
+ }
3424
+ if (this.socket?.readyState !== WS_OPEN) {
3425
+ this.handlers?.onError(new Error('WebSocket is not open; dropped "publishBatch" frame.'));
3426
+ return;
3427
+ }
3428
+ this.socket.send(JSON.stringify({
3429
+ op: WS_OP.PUBLISH_BATCH,
3430
+ topic,
3431
+ items: items.map((item) => ({
3432
+ data: item.data instanceof ArrayBuffer ? Array.from(new Uint8Array(item.data)) : item.data,
3433
+ ...item.messageId === void 0 ? {} : { messageId: item.messageId },
3434
+ ...item.timestamp === void 0 ? {} : { timestamp: item.timestamp }
3435
+ }))
3436
+ }));
3437
+ }
2657
3438
  /** Close the socket and drop all state. Safe to call multiple times. */
2658
3439
  stop() {
2659
3440
  const socket = this.socket;
@@ -2675,7 +3456,7 @@ var WebSocketTransport = class {
2675
3456
  sendBinaryFrame(topic, data, messageId, timestamp) {
2676
3457
  if (messageId !== void 0 || timestamp !== void 0) {
2677
3458
  this.sendFrame({
2678
- op: "publish",
3459
+ op: WS_OP.PUBLISH,
2679
3460
  topic,
2680
3461
  data: Array.from(new Uint8Array(data)),
2681
3462
  ...messageId === void 0 ? {} : { messageId },
@@ -2755,9 +3536,9 @@ function createWebSocketDataBus(options) {
2755
3536
  function selectWorkerBackend(mode, availability = {}) {
2756
3537
  const hasDedicated = availability.worker ?? typeof Worker !== "undefined";
2757
3538
  const hasShared = availability.sharedWorker ?? typeof SharedWorker !== "undefined";
2758
- if (mode === "shared" || mode === "auto") {
2759
- return hasShared ? "shared" : hasDedicated ? "dedicated" : "local";
3539
+ if (mode === WORKER_MODE.SHARED || mode === WORKER_MODE.AUTO) {
3540
+ return hasShared ? WORKER_BACKEND.SHARED : hasDedicated ? WORKER_BACKEND.DEDICATED : WORKER_BACKEND.LOCAL;
2760
3541
  }
2761
- return hasDedicated ? "dedicated" : hasShared ? "shared" : "local";
3542
+ return hasDedicated ? WORKER_BACKEND.DEDICATED : hasShared ? WORKER_BACKEND.SHARED : WORKER_BACKEND.LOCAL;
2762
3543
  }
2763
3544
  //# sourceMappingURL=index.cjs.map