cross-tab-worker-databus 0.20.71 → 0.20.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +204 -2
  2. package/README.md +8 -0
  3. package/README.zh.md +4 -0
  4. package/dist/centrifuge-protocol.d.ts +59 -26
  5. package/dist/centrifuge-protocol.d.ts.map +1 -1
  6. package/dist/centrifuge-session.d.ts +9 -0
  7. package/dist/centrifuge-session.d.ts.map +1 -1
  8. package/dist/centrifuge.d.ts +12 -2
  9. package/dist/centrifuge.d.ts.map +1 -1
  10. package/dist/centrifuge.js +168 -88
  11. package/dist/centrifuge.js.map +3 -3
  12. package/dist/centrifuge.shared.worker.js +146 -38
  13. package/dist/centrifuge.shared.worker.js.map +3 -3
  14. package/dist/centrifuge.worker.js +140 -33
  15. package/dist/centrifuge.worker.js.map +3 -3
  16. package/dist/{chunk-D2SIT473.js → chunk-PW63EWIK.js} +1090 -494
  17. package/dist/chunk-PW63EWIK.js.map +7 -0
  18. package/dist/chunk-TZ7ZP7YD.js +175 -0
  19. package/dist/chunk-TZ7ZP7YD.js.map +7 -0
  20. package/dist/cjs/centrifuge.cjs +1403 -637
  21. package/dist/cjs/centrifuge.cjs.map +4 -4
  22. package/dist/cjs/hooks.cjs +37 -2
  23. package/dist/cjs/hooks.cjs.map +3 -3
  24. package/dist/cjs/index.cjs +1453 -730
  25. package/dist/cjs/index.cjs.map +4 -4
  26. package/dist/cjs/vue.cjs +45 -2
  27. package/dist/cjs/vue.cjs.map +3 -3
  28. package/dist/core/cluster.d.ts +40 -4
  29. package/dist/core/cluster.d.ts.map +1 -1
  30. package/dist/core/data-bus.d.ts +93 -69
  31. package/dist/core/data-bus.d.ts.map +1 -1
  32. package/dist/core/dedup-manager.d.ts +98 -0
  33. package/dist/core/dedup-manager.d.ts.map +1 -0
  34. package/dist/core/environment.d.ts +43 -3
  35. package/dist/core/environment.d.ts.map +1 -1
  36. package/dist/core/replay-manager.d.ts +128 -0
  37. package/dist/core/replay-manager.d.ts.map +1 -0
  38. package/dist/core/replay-persistence.d.ts +2 -1
  39. package/dist/core/replay-persistence.d.ts.map +1 -1
  40. package/dist/core/routing.d.ts +22 -3
  41. package/dist/core/routing.d.ts.map +1 -1
  42. package/dist/core/storage-batch.d.ts +0 -4
  43. package/dist/core/storage-batch.d.ts.map +1 -1
  44. package/dist/core/trace.d.ts +55 -15
  45. package/dist/core/trace.d.ts.map +1 -1
  46. package/dist/core/types.d.ts +58 -9
  47. package/dist/core/types.d.ts.map +1 -1
  48. package/dist/core/version.d.ts +2 -0
  49. package/dist/core/version.d.ts.map +1 -0
  50. package/dist/hooks.d.ts +12 -1
  51. package/dist/hooks.d.ts.map +1 -1
  52. package/dist/hooks.js +28 -2
  53. package/dist/hooks.js.map +2 -2
  54. package/dist/index.d.ts +6 -6
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +228 -162
  57. package/dist/index.js.map +3 -3
  58. package/dist/utils/constants.d.ts +170 -0
  59. package/dist/utils/constants.d.ts.map +1 -0
  60. package/dist/utils/error-utils.d.ts +28 -0
  61. package/dist/utils/error-utils.d.ts.map +1 -0
  62. package/dist/utils/metadata.d.ts +16 -0
  63. package/dist/utils/metadata.d.ts.map +1 -0
  64. package/dist/utils/storage-utils.d.ts +24 -0
  65. package/dist/utils/storage-utils.d.ts.map +1 -0
  66. package/dist/utils/validation.d.ts +76 -0
  67. package/dist/utils/validation.d.ts.map +1 -0
  68. package/dist/vue.d.ts +13 -1
  69. package/dist/vue.d.ts.map +1 -1
  70. package/dist/vue.js +36 -2
  71. package/dist/vue.js.map +2 -2
  72. package/dist/websocket.d.ts +6 -1
  73. package/dist/websocket.d.ts.map +1 -1
  74. package/dist/worker-mode.d.ts +6 -2
  75. package/dist/worker-mode.d.ts.map +1 -1
  76. package/docs/README.md +1 -0
  77. package/docs/api.md +122 -2
  78. package/docs/architecture.md +80 -0
  79. package/docs/benchmarks.md +24 -0
  80. package/docs/capabilities.md +24 -3
  81. package/docs/configuration.md +58 -0
  82. package/docs/getting-started.md +35 -0
  83. package/docs/release-checklist.md +28 -5
  84. package/docs/roadmap.md +74 -5
  85. package/docs/zh/README.md +2 -1
  86. package/docs/zh/api.md +121 -2
  87. package/docs/zh/architecture.md +49 -0
  88. package/docs/zh/benchmarks.md +24 -0
  89. package/docs/zh/capabilities.md +21 -3
  90. package/docs/zh/configuration.md +53 -0
  91. package/docs/zh/getting-started.md +35 -0
  92. package/docs/zh/release-checklist.md +29 -6
  93. package/docs/zh/roadmap.md +86 -5
  94. package/package.json +39 -16
  95. package/dist/chunk-D2SIT473.js.map +0 -7
@@ -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,215 +1855,605 @@ 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
- pendingReplayPersistence = [];
1446
- replayPersistenceFlushScheduled = false;
1447
- replayHydration;
1448
- // Retention cleanup is coalesced so a burst of publications does not issue
1449
- // one IndexedDB read/write transaction per message. The newest cutoff wins.
1450
- replayRetentionCleanup = null;
1451
- replayRetentionCutoff = null;
1452
- replayRetentionTimer = null;
1453
- initialConfig;
1454
- hasInitialConfig;
1455
- trace;
1456
- dedupMaxEntries;
1457
- dedupTtlMs;
1458
- dedupAdaptiveBounds;
1459
- dedupWindowStartedAt = 0;
1460
- dedupWindowAccepted = 0;
1461
- dedupAdaptiveWindowMs = 5e3;
1462
- dedupSweepMs;
1463
- dedupSweepTimer = null;
1464
- dedupEnabled;
1465
1892
  now;
1466
- seenMessageIds = /* @__PURE__ */ new Map();
1467
- dedupSuppressed = 0;
1468
- dedupAccepted = 0;
1469
- activeConfig;
1470
- status = "disconnected";
1471
- started = false;
1472
- stopping = false;
1473
- transportReady = false;
1474
- // Last transport failure, retained so ready() can surface it to callers who
1475
- // never awaited start() directly. Cleared on the next successful start.
1476
- lastError = null;
1477
- lastErrorAt = null;
1478
- // Gate that serialises start/stop/suspend/resume only one lifecycle
1479
- // transition at a time. Resets to null once the operation settles.
1480
- startPromise = null;
1481
- // Timestamp of the last automatic transport recovery attempt.
1482
- // Used to avoid a tight retry loop when the transport fails repeatedly.
1483
- lastRecoveryAt = 0;
1484
- // Monotonic attempt number within one runtime recovery sequence; reset once
1485
- // a transport reopen succeeds so traces can correlate repeated failures.
1486
- recoveryAttempt = 0;
1487
- recoveryExhausted = false;
1488
- /** Monotonic generation incremented on every successful transport open.
1489
- * Stays in lockstep with `lastSuccessAt` so callers can detect that the
1490
- * transport has been reopened even if the timestamp window is short. */
1491
- recoveryGeneration = 0;
1492
- /** Timestamp of the most recent successful transport open. Null until the
1493
- * transport has reached the `ready` state at least once. */
1494
- lastSuccessAt = null;
1495
- // True while the tab is hidden so an in-flight transport start does not mark
1496
- // the transport ready after suspendTransport() has stopped it.
1497
- suspended = false;
1498
- // Single gate for async transport.stop() cleanup, shared by failed opens and
1499
- // page-hide suspension. Kept separate from startPromise so ready() still
1500
- // surfaces a failure while later opens and automatic recovery wait for the
1501
- // stop to settle.
1502
- pendingStop = null;
1503
- // Minimum interval in ms between automatic recovery attempts.
1504
- recoveryCooldownMs;
1505
- recoveryMaxAttempts;
1506
- constructor(options) {
1507
- const replay = options.replay;
1508
- if (replay) {
1509
- const maxPerTopic = replay.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1510
- if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
1511
- throw new TypeError(
1512
- `replay.maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`
1513
- );
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);
1917
+ }
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();
1922
+ }
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();
1514
1929
  }
1515
1930
  }
1516
- this.replayMaxPerTopic = replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1517
- this.replayBuffers = replay ? /* @__PURE__ */ new Map() : null;
1518
- this.replayPersistence = replay?.persistence ?? null;
1519
- this.replayRetentionMs = replay?.retentionMs;
1520
- this.replayPruneStrategy = replay?.pruneStrategy ?? "count";
1521
- if (!["count", "age", "both"].includes(this.replayPruneStrategy)) throw new TypeError("replay.pruneStrategy must be count, age, or both.");
1522
- if (this.replayRetentionMs !== void 0 && (!Number.isFinite(this.replayRetentionMs) || this.replayRetentionMs <= 0)) {
1523
- throw new TypeError("replay.retentionMs must be a positive finite number.");
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));
1524
1937
  }
1525
- this.replayRetentionSweepMs = replay?.retentionSweepMs;
1526
- if (this.replayRetentionSweepMs !== void 0 && (!Number.isFinite(this.replayRetentionSweepMs) || this.replayRetentionSweepMs <= 0)) {
1527
- throw new TypeError("replay.retentionSweepMs must be a positive finite number.");
1938
+ if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
1939
+ this.scheduleRetentionCleanup(this.now() - this.retentionMs);
1528
1940
  }
1529
- this.persistenceRetryMaxAttempts = replay?.persistenceRetry?.maxAttempts ?? 1;
1530
- this.persistenceRetryBackoffMs = replay?.persistenceRetry?.backoffMs ?? 50;
1531
- if (!Number.isSafeInteger(this.persistenceRetryMaxAttempts) || this.persistenceRetryMaxAttempts <= 0) {
1532
- throw new TypeError("replay.persistenceRetry.maxAttempts must be a positive safe integer.");
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;
1533
1960
  }
1534
- if (!Number.isFinite(this.persistenceRetryBackoffMs) || this.persistenceRetryBackoffMs < 0) {
1535
- throw new TypeError("replay.persistenceRetry.backoffMs must be a non-negative 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
+ }
1536
1988
  }
1537
- const { autoStart, initialConfig, trace, transport, dedup, recovery, ...clusterOptions } = options;
1538
- this.recoveryCooldownMs = recovery?.cooldownMs ?? 1e3;
1539
- this.recoveryMaxAttempts = recovery?.maxAttempts ?? Number.POSITIVE_INFINITY;
1540
- if (!Number.isFinite(this.recoveryCooldownMs) || this.recoveryCooldownMs <= 0) {
1541
- throw new TypeError("recovery.cooldownMs must be a positive finite number.");
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;
2001
+ }
1542
2002
  }
1543
- if (!(this.recoveryMaxAttempts === Number.POSITIVE_INFINITY || Number.isSafeInteger(this.recoveryMaxAttempts) && this.recoveryMaxAttempts > 0)) {
1544
- throw new TypeError("recovery.maxAttempts must be a positive safe integer.");
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
+ }
1545
2013
  }
1546
- this.now = dedup?.now ?? Date.now;
1547
- this.replayHydration = this.hydrateReplay();
1548
- this.transport = transport;
1549
- this.initialConfig = initialConfig;
1550
- this.hasInitialConfig = "initialConfig" in options;
1551
- this.trace = new DataBusTraceReporter(trace);
1552
- this.dedupMaxEntries = dedup?.maxEntries ?? 1e3;
1553
- this.dedupTtlMs = dedup?.ttlMs ?? 6e4;
1554
- this.dedupAdaptiveBounds = dedup?.adaptiveTtl;
1555
- if (this.dedupAdaptiveBounds && (this.dedupAdaptiveBounds.minMs <= 0 || this.dedupAdaptiveBounds.maxMs < this.dedupAdaptiveBounds.minMs)) throw new TypeError("dedup.adaptiveTtl bounds are invalid.");
1556
- this.dedupWindowStartedAt = this.now();
1557
- this.dedupSweepMs = dedup?.sweepMs;
1558
- this.dedupEnabled = dedup !== void 0;
1559
- if (!Number.isSafeInteger(this.dedupMaxEntries) || this.dedupMaxEntries <= 0) {
1560
- throw new TypeError("dedup.maxEntries must be a positive safe integer.");
1561
- }
1562
- if (!Number.isFinite(this.dedupTtlMs) || this.dedupTtlMs <= 0) {
1563
- throw new TypeError("dedup.ttlMs must be a positive finite number.");
1564
- }
1565
- if (this.dedupSweepMs !== void 0 && (!Number.isFinite(this.dedupSweepMs) || this.dedupSweepMs <= 0)) {
1566
- throw new TypeError("dedup.sweepMs must be a positive finite number.");
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
+ }
1567
2024
  }
1568
- this.cluster = new WorkerClusterRuntime({
1569
- ...clusterOptions,
1570
- handlers: {
1571
- // The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
1572
- // control message meaning the owning Worker has delegated the action to us.
1573
- onControl: (action, topic, data, messageId, timestamp) => {
1574
- switch (action) {
1575
- case "SUBSCRIBE":
1576
- if (this.subscribeTransport(topic)) this.traceSubscription("subscribe", topic);
1577
- break;
1578
- case "UNSUBSCRIBE":
1579
- if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
1580
- break;
1581
- case "PUBLISH":
1582
- this.runTransport(() => this.transport.publish(
1583
- topic,
1584
- data,
1585
- messageId === void 0 && timestamp === void 0 ? void 0 : {
1586
- ...messageId === void 0 ? {} : { messageId },
1587
- ...timestamp === void 0 ? {} : { timestamp }
1588
- }
1589
- ));
1590
- break;
1591
- default:
1592
- break;
1593
- }
1594
- },
1595
- // The cluster calls `onEvent` when a publication broadcast arrives from
1596
- // another tab. Dispatch locally if we have subscribers. The payload is
1597
- // typed `unknown` at the cluster boundary (the cluster is transport-
1598
- // agnostic); here we narrow it to DataBusMessage — the sender is our
1599
- // own broadcastEvent call, which always posts a DataBusMessage.
1600
- onEvent: (eventType, payload, _sourceWorkerId, originTabId) => {
1601
- if (eventType !== PUBLICATION_EVENT) return;
1602
- const incoming = payload;
1603
- const message = incoming.originTabId !== void 0 ? incoming : originTabId !== void 0 ? { ...incoming, originTabId } : incoming;
1604
- if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
1605
- },
1606
- onSuspend: () => {
1607
- if (!this.stopping) this.trace.event({ type: "lifecycle", action: "suspend" });
1608
- this.trace.pause();
1609
- this.persistenceRetryGeneration += 1;
1610
- this.stopDedupSweep();
1611
- this.stopReplayRetentionSweep();
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();
1612
2446
  this.suspendTransport();
1613
2447
  },
1614
2448
  onResume: () => {
1615
- this.trace.event({ type: "lifecycle", action: "resume" });
2449
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
1616
2450
  this.trace.start();
1617
2451
  this.startDedupSweep();
1618
- this.startReplayRetentionSweep();
2452
+ this.replayManager.start();
1619
2453
  this.resumeTransport();
1620
2454
  },
1621
2455
  onDiagnostic: (event) => {
1622
- this.trace.event({ type: "reliability", ...event });
2456
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, ...event });
1623
2457
  }
1624
2458
  }
1625
2459
  });
@@ -1641,27 +2475,24 @@ var CrossTabDataBus = class {
1641
2475
  this.suspended = false;
1642
2476
  this.activeConfig = config;
1643
2477
  this.lastError = null;
1644
- 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 });
1645
2483
  this.trace.start();
1646
2484
  this.startDedupSweep();
1647
- this.startReplayRetentionSweep();
1648
- this.updateStatus("connecting");
2485
+ this.replayManager.start();
2486
+ this.updateStatus(WORKER_STATUS.CONNECTING);
1649
2487
  this.cluster.start();
1650
2488
  const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
1651
2489
  this.startPromise = opening;
1652
2490
  for (const topic of this.topicHandlers.keys()) {
1653
2491
  this.cluster.subscribe(topic);
1654
2492
  }
1655
- const snapshot = this.cluster.getSnapshot();
1656
- this.trace.event({
1657
- type: "coordination",
1658
- coordinated: snapshot.coordinated,
1659
- activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
1660
- workers: snapshot.workers.map(formatWorkerTrace),
1661
- routes: snapshot.routes.map(formatRouteTrace)
1662
- });
1663
2493
  void opening.then(
1664
2494
  () => {
2495
+ this.emitCoordinationTrace();
1665
2496
  if (this.startPromise === opening) this.startPromise = null;
1666
2497
  },
1667
2498
  () => {
@@ -1688,7 +2519,7 @@ var CrossTabDataBus = class {
1688
2519
  onError: (error) => this.reportError(error)
1689
2520
  })
1690
2521
  ).then(() => {
1691
- if (this.status === "error") {
2522
+ if (this.status === WORKER_STATUS.ERROR) {
1692
2523
  throw new Error("Transport failed during startup.");
1693
2524
  }
1694
2525
  if (!this.suspended && !this.stopping) {
@@ -1702,7 +2533,7 @@ var CrossTabDataBus = class {
1702
2533
  if (!this.pendingStop) {
1703
2534
  this.pendingStop = this.createStopPromise();
1704
2535
  }
1705
- this.updateStatus("error");
2536
+ this.updateStatus(WORKER_STATUS.ERROR);
1706
2537
  this.reportError(error);
1707
2538
  this.lastError = error;
1708
2539
  this.lastErrorAt = this.now();
@@ -1747,17 +2578,12 @@ var CrossTabDataBus = class {
1747
2578
  this.topicHandlers.set(topic, handlers);
1748
2579
  if (wasUnused) this.cluster.subscribe(topic);
1749
2580
  if (options?.replay) {
1750
- const limit = Math.min(
1751
- typeof options.replay === "number" ? Math.floor(options.replay) : this.replayMaxPerTopic,
1752
- this.replayMaxPerTopic
2581
+ this.replayManager.deliverReplay(
2582
+ topic,
2583
+ options.replay,
2584
+ handler,
2585
+ () => Boolean(this.topicHandlers.get(topic)?.has(handler))
1753
2586
  );
1754
- if (this.replayPersistence) {
1755
- void this.replayHydration.then(() => {
1756
- if (this.topicHandlers.get(topic)?.has(handler)) this.deliverReplay(topic, limit, handler);
1757
- });
1758
- } else {
1759
- this.deliverReplay(topic, limit, handler);
1760
- }
1761
2587
  }
1762
2588
  return () => this.unsubscribe(topic, handler);
1763
2589
  }
@@ -1772,70 +2598,28 @@ var CrossTabDataBus = class {
1772
2598
  else handlers.clear();
1773
2599
  if (handlers.size > 0) return;
1774
2600
  this.topicHandlers.delete(topic);
1775
- this.replayBuffers?.delete(topic);
1776
- if (this.replayPersistence?.clearTopic) {
1777
- void this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic)).catch((error) => this.reportPersistenceError(error));
1778
- }
2601
+ this.replayManager.onTopicUnsubscribed(topic);
1779
2602
  this.cluster.unsubscribe(topic);
1780
2603
  }
1781
2604
  /** Clear all in-memory replay buffers and, when supported, durable history. */
1782
2605
  async clearReplay() {
1783
- this.replayBuffers?.clear();
1784
- if (this.replayPersistence?.clear) {
1785
- try {
1786
- await this.withPersistenceRetry("clear", () => this.replayPersistence.clear());
1787
- } catch (error) {
1788
- this.reportPersistenceError(error);
1789
- throw error;
1790
- }
1791
- }
2606
+ await this.replayManager.clearAll();
1792
2607
  }
1793
2608
  /** Clear replay history for one exact topic, including durable storage. */
1794
2609
  async clearReplayTopic(topic) {
1795
- this.replayBuffers?.delete(topic);
1796
- if (this.replayPersistence?.clearTopic) {
1797
- try {
1798
- await this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic));
1799
- } catch (error) {
1800
- this.reportPersistenceError(error);
1801
- throw error;
1802
- }
1803
- }
2610
+ await this.replayManager.clearTopic(topic);
1804
2611
  }
1805
2612
  /** Remove replay entries older than an epoch-millisecond cutoff. */
1806
2613
  async clearReplayBefore(timestamp) {
1807
- if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
1808
- if (this.replayBuffers) {
1809
- for (const [topic, messages] of this.replayBuffers) {
1810
- const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
1811
- if (kept.length) this.replayBuffers.set(topic, kept);
1812
- else this.replayBuffers.delete(topic);
1813
- }
1814
- }
1815
- if (this.replayPersistence?.clearBefore) {
1816
- try {
1817
- await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(timestamp));
1818
- } catch (error) {
1819
- this.reportPersistenceError(error);
1820
- throw error;
1821
- }
1822
- }
2614
+ await this.replayManager.clearBefore(timestamp);
1823
2615
  }
1824
2616
  /** Return bounded deduplication counters for diagnostics and health checks. */
1825
2617
  getDedupStats() {
1826
- return {
1827
- enabled: this.dedupEnabled,
1828
- tracked: this.seenMessageIds.size,
1829
- suppressed: this.dedupSuppressed,
1830
- accepted: this.dedupAccepted,
1831
- ...this.dedupAdaptiveBounds ? { ttlMs: this.currentDedupTtl() } : {}
1832
- };
2618
+ return this.dedupManager.getStats();
1833
2619
  }
1834
2620
  /** Drop all remembered IDs and reset dedup counters. */
1835
2621
  resetDedup() {
1836
- this.seenMessageIds.clear();
1837
- this.dedupSuppressed = 0;
1838
- this.dedupAccepted = 0;
2622
+ this.dedupManager.reset();
1839
2623
  }
1840
2624
  /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
1841
2625
  publish(topic, data, options) {
@@ -1863,8 +2647,7 @@ var CrossTabDataBus = class {
1863
2647
  }
1864
2648
  const mapped = items.map((item) => ({
1865
2649
  data: item.data,
1866
- ...item.options?.messageId !== void 0 ? { messageId: item.options.messageId } : {},
1867
- ...item.options?.timestamp !== void 0 ? { timestamp: item.options.timestamp } : {}
2650
+ ...publicationMetadata(item.options?.messageId, item.options?.timestamp)
1868
2651
  }));
1869
2652
  if (!this.cluster.publishBatch(topic, mapped)) {
1870
2653
  this.reportError(
@@ -1909,6 +2692,41 @@ var CrossTabDataBus = class {
1909
2692
  lastSuccessAt: this.lastSuccessAt
1910
2693
  };
1911
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
+ }
1912
2730
  /** Snapshot of the cluster state (workers, routes, assignments).
1913
2731
  * For diagnostics only — the returned object is a shallow copy but
1914
2732
  * nested arrays are snapshots at call time. */
@@ -1917,8 +2735,7 @@ var CrossTabDataBus = class {
1917
2735
  }
1918
2736
  /** Return a single health snapshot combining lifecycle, recovery, dedup, replay, and cluster state. */
1919
2737
  getDiagnostics() {
1920
- let messages = 0;
1921
- if (this.replayBuffers) for (const buffer of this.replayBuffers.values()) messages += buffer.length;
2738
+ const replay = this.replayManager.getStats();
1922
2739
  const cluster = this.cluster.getSnapshot();
1923
2740
  const unknownMessages = this.cluster.getUnknownMessageStats();
1924
2741
  const transport = this.transport;
@@ -1929,12 +2746,26 @@ var CrossTabDataBus = class {
1929
2746
  transportReady: this.transportReady,
1930
2747
  recovery: this.getRecoveryStats(),
1931
2748
  dedup: this.getDedupStats(),
1932
- 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(),
1933
2751
  protocol: { version: cluster.protocolVersion, unknownMessages: unknownMessages.count, lastUnknownMessageType: unknownMessages.lastType, peers: cluster.peerProtocolVersions },
1934
- transport: { name: transport.diagnosticsName ?? transport.constructor.name, backend: transport.diagnosticsBackend ?? null },
1935
- cluster
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()
1936
2761
  };
1937
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
+ }
1938
2769
  /**
1939
2770
  * Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
1940
2771
  * and close the transport. Idempotent.
@@ -1942,13 +2773,12 @@ var CrossTabDataBus = class {
1942
2773
  async stop() {
1943
2774
  if (!this.started) return;
1944
2775
  this.stopping = true;
1945
- this.persistenceRetryGeneration += 1;
1946
- 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 });
1947
2778
  this.trace.stop();
1948
2779
  this.stopDedupSweep();
1949
- this.stopReplayRetentionSweep();
1950
2780
  this.topicHandlers.clear();
1951
- this.replayBuffers?.clear();
2781
+ this.replayManager.resetBuffers();
1952
2782
  this.cluster.stop();
1953
2783
  try {
1954
2784
  await this.startPromise?.catch(() => void 0);
@@ -1969,7 +2799,7 @@ var CrossTabDataBus = class {
1969
2799
  this.activeConfig = void 0;
1970
2800
  this.recoveryAttempt = 0;
1971
2801
  this.recoveryExhausted = false;
1972
- this.updateStatus("disconnected");
2802
+ this.updateStatus(WORKER_STATUS.DISCONNECTED);
1973
2803
  }
1974
2804
  }
1975
2805
  /**
@@ -1978,7 +2808,7 @@ var CrossTabDataBus = class {
1978
2808
  * and dispatches locally.
1979
2809
  */
1980
2810
  handleTransportMessage(message) {
1981
- if (this.isDuplicate(message)) return;
2811
+ if (this.dedupManager.isDuplicate(message.messageId ?? "", message.topic)) return;
1982
2812
  this.trace.recordReceived(message.topic);
1983
2813
  if (!this.cluster.isAssigned(message.topic)) {
1984
2814
  this.trace.recordDiscarded(message.topic);
@@ -1992,55 +2822,13 @@ var CrossTabDataBus = class {
1992
2822
  }
1993
2823
  this.trace.recordDiscarded(message.topic);
1994
2824
  }
1995
- isDuplicate(message) {
1996
- if (!this.dedupEnabled || !message.messageId) return false;
1997
- const now = this.now();
1998
- for (const [id, timestamp] of this.seenMessageIds) {
1999
- if (now - timestamp > this.dedupTtlMs) this.seenMessageIds.delete(id);
2000
- }
2001
- if (this.seenMessageIds.has(message.messageId)) {
2002
- this.trace.event({ type: "reliability", operation: "dedup_suppressed", topic: message.topic });
2003
- this.dedupSuppressed += 1;
2004
- this.trace.recordDedupSuppressed();
2005
- return true;
2006
- }
2007
- this.seenMessageIds.set(message.messageId, now);
2008
- this.dedupAccepted += 1;
2009
- this.dedupWindowAccepted += 1;
2010
- this.trace.recordDedupAccepted();
2011
- while (this.seenMessageIds.size > this.dedupMaxEntries) {
2012
- const oldest = this.seenMessageIds.keys().next().value;
2013
- if (oldest === void 0) break;
2014
- this.seenMessageIds.delete(oldest);
2015
- }
2016
- return false;
2017
- }
2825
+ /** Start enqueuing the dedup expiry sweep (delegated to {@link DedupManager}). */
2018
2826
  startDedupSweep() {
2019
- if (this.dedupSweepTimer || !this.dedupEnabled || !this.dedupSweepMs) return;
2020
- this.dedupSweepTimer = setInterval(() => this.pruneExpiredDedup(), this.dedupSweepMs);
2827
+ this.dedupManager.start();
2021
2828
  }
2829
+ /** Stop enqueuing the dedup expiry sweep. */
2022
2830
  stopDedupSweep() {
2023
- if (this.dedupSweepTimer) clearInterval(this.dedupSweepTimer);
2024
- this.dedupSweepTimer = null;
2025
- }
2026
- pruneExpiredDedup() {
2027
- const cutoff = this.now() - this.currentDedupTtl();
2028
- for (const [id, timestamp] of this.seenMessageIds) {
2029
- if (timestamp < cutoff) this.seenMessageIds.delete(id);
2030
- }
2031
- }
2032
- currentDedupTtl() {
2033
- if (!this.dedupAdaptiveBounds) return this.dedupTtlMs;
2034
- const now = this.now();
2035
- const elapsed = now - this.dedupWindowStartedAt;
2036
- if (elapsed >= this.dedupAdaptiveWindowMs) {
2037
- this.dedupWindowStartedAt = now;
2038
- this.dedupWindowAccepted = 0;
2039
- return this.dedupAdaptiveBounds.maxMs;
2040
- }
2041
- const rate = this.dedupWindowAccepted / Math.max(1, elapsed);
2042
- const factor = Math.min(1, rate / 0.01);
2043
- return this.dedupAdaptiveBounds.maxMs - (this.dedupAdaptiveBounds.maxMs - this.dedupAdaptiveBounds.minMs) * factor;
2831
+ this.dedupManager.stop();
2044
2832
  }
2045
2833
  /** Deliver a message to every local handler registered for its topic,
2046
2834
  * plus every handler registered with a wildcard subscription that matches
@@ -2053,153 +2841,7 @@ var CrossTabDataBus = class {
2053
2841
  this.invokeHandlers(handlers, (handler) => handler(message));
2054
2842
  }
2055
2843
  }
2056
- this.recordReplay(message);
2057
- }
2058
- /** Append a dispatched publication to the topic's replay ring buffer.
2059
- * No-op when replay is disabled. */
2060
- recordReplay(message) {
2061
- if (!this.replayBuffers) return;
2062
- let buffer = this.replayBuffers.get(message.topic);
2063
- if (!buffer) {
2064
- buffer = [];
2065
- this.replayBuffers.set(message.topic, buffer);
2066
- }
2067
- const storedMessage = message;
2068
- buffer.push(storedMessage);
2069
- if (this.replayPruneStrategy !== "age") {
2070
- while (buffer.length > this.replayMaxPerTopic) buffer.shift();
2071
- }
2072
- if (this.replayPruneStrategy !== "count" && this.replayRetentionMs !== void 0) {
2073
- const cutoff = this.now() - this.replayRetentionMs;
2074
- while (buffer.length > 0) {
2075
- const first = buffer[0];
2076
- if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
2077
- buffer.shift();
2078
- }
2079
- }
2080
- if (this.replayPersistence) {
2081
- if (this.replayPersistence.appendBatch) {
2082
- this.pendingReplayPersistence.push(storedMessage);
2083
- this.scheduleReplayPersistenceFlush();
2084
- } else {
2085
- void this.withPersistenceRetry("append", () => this.replayPersistence.append(storedMessage)).catch((error) => this.reportPersistenceError(error));
2086
- }
2087
- if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
2088
- this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
2089
- }
2090
- }
2091
- }
2092
- scheduleReplayPersistenceFlush() {
2093
- if (this.replayPersistenceFlushScheduled) return;
2094
- this.replayPersistenceFlushScheduled = true;
2095
- queueMicrotask(() => {
2096
- this.replayPersistenceFlushScheduled = false;
2097
- const batch = this.pendingReplayPersistence.splice(0);
2098
- if (batch.length === 0 || !this.replayPersistence) return;
2099
- const operation = this.replayPersistence.appendBatch ? () => this.replayPersistence.appendBatch(batch) : () => Promise.all(batch.map((message) => this.replayPersistence.append(message))).then(() => void 0);
2100
- void this.withPersistenceRetry("append", operation).catch((error) => this.reportPersistenceError(error));
2101
- });
2102
- }
2103
- async hydrateReplay() {
2104
- if (!this.replayBuffers || !this.replayPersistence) {
2105
- return;
2106
- }
2107
- try {
2108
- if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
2109
- await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(this.now() - this.replayRetentionMs));
2110
- }
2111
- for (const message of await this.withPersistenceRetry("load", () => this.replayPersistence.load())) {
2112
- let buffer = this.replayBuffers.get(message.topic);
2113
- if (!buffer) {
2114
- buffer = [];
2115
- this.replayBuffers.set(message.topic, buffer);
2116
- }
2117
- buffer.push(message);
2118
- if (buffer.length > this.replayMaxPerTopic) buffer.shift();
2119
- }
2120
- } catch (error) {
2121
- this.reportPersistenceError(error);
2122
- }
2123
- }
2124
- scheduleReplayRetentionCleanup(cutoff) {
2125
- if (!this.replayPersistence?.clearBefore) return;
2126
- if (this.replayRetentionCutoff === null || cutoff > this.replayRetentionCutoff) {
2127
- this.replayRetentionCutoff = cutoff;
2128
- }
2129
- if (this.replayRetentionCleanup) return;
2130
- this.replayRetentionCleanup = (async () => {
2131
- while (this.replayRetentionCutoff !== null) {
2132
- const nextCutoff = this.replayRetentionCutoff;
2133
- this.replayRetentionCutoff = null;
2134
- try {
2135
- await this.replayPersistence.clearBefore(nextCutoff);
2136
- } catch (error) {
2137
- this.reportPersistenceError(error);
2138
- }
2139
- }
2140
- })().finally(() => {
2141
- this.replayRetentionCleanup = null;
2142
- if (this.replayRetentionCutoff !== null) {
2143
- this.scheduleReplayRetentionCleanup(this.replayRetentionCutoff);
2144
- }
2145
- });
2146
- }
2147
- startReplayRetentionSweep() {
2148
- if (this.replayRetentionTimer || !this.replayRetentionMs || !this.replayRetentionSweepMs || !this.replayPersistence?.clearBefore) return;
2149
- this.replayRetentionTimer = setInterval(() => {
2150
- this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
2151
- }, this.replayRetentionSweepMs);
2152
- }
2153
- stopReplayRetentionSweep() {
2154
- if (this.replayRetentionTimer) clearInterval(this.replayRetentionTimer);
2155
- this.replayRetentionTimer = null;
2156
- }
2157
- async withPersistenceRetry(persistenceOperation, operation) {
2158
- const generation = this.persistenceRetryGeneration;
2159
- let attempt = 0;
2160
- let delay = this.persistenceRetryBackoffMs;
2161
- while (true) {
2162
- attempt += 1;
2163
- try {
2164
- if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
2165
- return await operation();
2166
- } catch (error) {
2167
- if (error instanceof PersistenceRetryCancelledError || generation !== this.persistenceRetryGeneration) {
2168
- throw new PersistenceRetryCancelledError();
2169
- }
2170
- if (attempt >= this.persistenceRetryMaxAttempts) throw error;
2171
- this.trace.event({
2172
- type: "reliability",
2173
- operation: "persistence_retry",
2174
- persistenceOperation,
2175
- attempt
2176
- });
2177
- if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
2178
- if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
2179
- delay = Math.min(delay * 2, 1600);
2180
- }
2181
- }
2182
- }
2183
- /** Deliver buffered history to a newly-registered handler. For an exact
2184
- * topic this is that topic's ring; for a wildcard subscription every
2185
- * buffered topic matching the pattern contributes (in buffer insertion
2186
- * order). Replay deliveries are marked `replayed: true` and are not
2187
- * counted into trace metrics. */
2188
- deliverReplay(topic, limit, handler) {
2189
- if (!this.replayBuffers || limit <= 0) return;
2190
- const deliver = (buffer2) => {
2191
- for (const message of buffer2.slice(-limit)) {
2192
- this.invokeHandlers([handler], (h) => h({ ...message, replayed: true }));
2193
- }
2194
- };
2195
- if (isWildcardTopic(topic)) {
2196
- for (const [bufferedTopic, buffer2] of this.replayBuffers) {
2197
- if (topicMatchesPattern(topic, bufferedTopic)) deliver(buffer2);
2198
- }
2199
- return;
2200
- }
2201
- const buffer = this.replayBuffers.get(topic);
2202
- if (buffer) deliver(buffer);
2844
+ this.replayManager.record(message);
2203
2845
  }
2204
2846
  /**
2205
2847
  * Propagate a status change to the cluster, trace, and all registered
@@ -2208,13 +2850,13 @@ var CrossTabDataBus = class {
2208
2850
  updateStatus(status) {
2209
2851
  const previousStatus = this.status;
2210
2852
  this.status = status;
2211
- if (previousStatus !== status) this.trace.event({ type: "status", status });
2853
+ if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
2212
2854
  this.cluster.setStatus(status);
2213
- if (status === "disconnected" || status === "error") this.transportSubscribedTopics.clear();
2214
- 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) {
2215
2857
  for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
2216
2858
  }
2217
- if (status === "error" && this.started && !this.stopping) {
2859
+ if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
2218
2860
  const now = this.now();
2219
2861
  if (now - this.lastRecoveryAt >= this.recoveryCooldownMs) {
2220
2862
  this.lastRecoveryAt = now;
@@ -2222,37 +2864,67 @@ var CrossTabDataBus = class {
2222
2864
  if (attempt > this.recoveryMaxAttempts) {
2223
2865
  if (!this.recoveryExhausted) {
2224
2866
  this.recoveryExhausted = true;
2225
- 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 });
2226
2868
  }
2227
2869
  return;
2228
2870
  }
2229
- 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 });
2230
2872
  setTimeout(() => {
2231
2873
  if (this.stopping || !this.started || this.suspended) return;
2232
- if (this.status !== "error") return;
2874
+ if (this.status !== WORKER_STATUS.ERROR) return;
2233
2875
  void this.reopenTransport(attempt);
2234
2876
  }, this.recoveryCooldownMs);
2235
2877
  }
2236
2878
  }
2237
2879
  this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
2238
2880
  }
2239
- reportError(error) {
2240
- this.trace.event({ type: "error", source: "transport" });
2241
- 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);
2242
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). */
2243
2901
  reportPersistenceError(error) {
2244
2902
  if (error instanceof PersistenceRetryCancelledError) return;
2245
- this.trace.event({ type: "reliability", operation: "persistence_cleanup" });
2246
- this.reportError(error);
2903
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.PERSISTENCE_CLEANUP });
2904
+ this.reportError(error, FAILURE_SOURCE.PERSISTENCE);
2247
2905
  }
2248
2906
  traceSubscription(action, topic) {
2249
2907
  this.trace.event({
2250
- type: "subscription",
2908
+ type: TRACE_EVENT_TYPE.SUBSCRIPTION,
2251
2909
  action,
2252
2910
  topic,
2253
2911
  activeTopics: this.transportSubscribedTopics.size
2254
2912
  });
2255
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
+ }
2256
2928
  /** Ask the transport to subscribe to a topic (idempotent). */
2257
2929
  subscribeTransport(topic) {
2258
2930
  if (this.transportSubscribedTopics.has(topic)) return false;
@@ -2270,17 +2942,17 @@ var CrossTabDataBus = class {
2270
2942
  * are routed to `reportError` (which surfaces them to error subscribers);
2271
2943
  * error-handler failures are logged to the console to avoid infinite
2272
2944
  * recursion through reportError itself. */
2273
- invokeHandlers(handlers, callback, label = "dispatch") {
2945
+ invokeHandlers(handlers, callback, label = INVOKE_LABEL.DISPATCH) {
2274
2946
  for (const handler of handlers) {
2275
2947
  try {
2276
2948
  callback(handler);
2277
2949
  } catch (error) {
2278
- if (label === "error handler") {
2950
+ if (label === INVOKE_LABEL.ERROR_HANDLER) {
2279
2951
  if (typeof console !== "undefined" && typeof console.warn === "function") {
2280
- console.warn("[cross-tab-worker-databus] error handler threw:", error);
2952
+ console.warn(`[${DEFAULT_STORAGE_PREFIX}] error handler threw:`, error);
2281
2953
  }
2282
2954
  } else {
2283
- this.reportError(error);
2955
+ this.reportError(error, FAILURE_SOURCE.DISPATCH);
2284
2956
  }
2285
2957
  }
2286
2958
  }
@@ -2294,7 +2966,7 @@ var CrossTabDataBus = class {
2294
2966
  this.suspended = true;
2295
2967
  this.transportReady = false;
2296
2968
  this.transportSubscribedTopics.clear();
2297
- this.updateStatus("disconnected");
2969
+ this.updateStatus(WORKER_STATUS.DISCONNECTED);
2298
2970
  if (this.pendingStop) return;
2299
2971
  const pending = this.startPromise ?? Promise.resolve();
2300
2972
  const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
@@ -2328,14 +3000,14 @@ var CrossTabDataBus = class {
2328
3000
  const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
2329
3001
  this.started = true;
2330
3002
  this.suspended = false;
2331
- this.updateStatus("connecting");
3003
+ this.updateStatus(WORKER_STATUS.CONNECTING);
2332
3004
  const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
2333
3005
  const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
2334
3006
  this.startPromise = opening;
2335
3007
  void opening.then(
2336
3008
  () => {
2337
3009
  if (traceAttempt !== void 0) {
2338
- 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 });
2339
3011
  this.recoveryAttempt = 0;
2340
3012
  this.recoveryExhausted = false;
2341
3013
  }
@@ -2343,7 +3015,7 @@ var CrossTabDataBus = class {
2343
3015
  },
2344
3016
  () => {
2345
3017
  if (traceAttempt !== void 0) {
2346
- 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 });
2347
3019
  }
2348
3020
  }
2349
3021
  );
@@ -2401,16 +3073,14 @@ function formatRouteTrace(route) {
2401
3073
  function createIndexedDbReplayPersistence(options) {
2402
3074
  const indexedDb = globalThis.indexedDB;
2403
3075
  if (!indexedDb) throw new Error("IndexedDB is unavailable in this environment.");
2404
- const dbName = options.dbName ?? "cross-tab-worker-databus";
3076
+ const dbName = options.dbName ?? DEFAULT_STORAGE_PREFIX;
2405
3077
  const storeName = "replay";
2406
3078
  const maxPerTopic = options.maxPerTopic;
2407
- const pruneStrategy = options.pruneStrategy ?? "count";
3079
+ const pruneStrategy = options.pruneStrategy ?? PRUNE_STRATEGY.COUNT;
2408
3080
  const retentionMs = options.retentionMs;
2409
- if (!["count", "age", "both"].includes(pruneStrategy)) throw new TypeError("pruneStrategy must be count, age, or both.");
2410
- if (retentionMs !== void 0 && (!Number.isFinite(retentionMs) || retentionMs <= 0)) throw new TypeError("retentionMs must be a positive finite number.");
2411
- if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
2412
- throw new TypeError(`maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`);
2413
- }
3081
+ assertPruneStrategy(pruneStrategy);
3082
+ if (retentionMs !== void 0) assertPositiveFiniteNumber(retentionMs, "retentionMs");
3083
+ assertPositiveSafeInteger(maxPerTopic, "maxPerTopic");
2414
3084
  let dbPromise = null;
2415
3085
  const invalidate = (db) => {
2416
3086
  if (dbPromise) {
@@ -2422,15 +3092,100 @@ function createIndexedDbReplayPersistence(options) {
2422
3092
  }, () => void 0);
2423
3093
  }
2424
3094
  };
2425
- let mutationQueue = Promise.resolve();
2426
- const serializeMutation = (mutation) => {
2427
- const next = mutationQueue.then(mutation, mutation);
2428
- mutationQueue = next.catch(() => void 0);
2429
- return next;
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
+ }
2430
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
+ })();
2431
3186
  const open = () => {
2432
3187
  if (dbPromise) return dbPromise;
2433
- const pending = new Promise((resolve, reject) => {
3188
+ const pending2 = new Promise((resolve, reject) => {
2434
3189
  const request = indexedDb.open(dbName, 1);
2435
3190
  request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath: "topic" });
2436
3191
  request.onsuccess = () => {
@@ -2443,11 +3198,11 @@ function createIndexedDbReplayPersistence(options) {
2443
3198
  };
2444
3199
  request.onerror = () => reject(request.error ?? new Error("Failed to open replay database."));
2445
3200
  });
2446
- dbPromise = pending;
2447
- void pending.catch(() => {
2448
- if (dbPromise === pending) dbPromise = null;
3201
+ dbPromise = pending2;
3202
+ void pending2.catch(() => {
3203
+ if (dbPromise === pending2) dbPromise = null;
2449
3204
  });
2450
- return pending;
3205
+ return pending2;
2451
3206
  };
2452
3207
  return {
2453
3208
  async load() {
@@ -2469,153 +3224,94 @@ function createIndexedDbReplayPersistence(options) {
2469
3224
  });
2470
3225
  },
2471
3226
  append(message) {
2472
- return serializeMutation(async () => {
2473
- const db = await open();
2474
- await new Promise((resolve, reject) => {
2475
- let transaction;
2476
- try {
2477
- transaction = db.transaction(storeName, "readwrite");
2478
- } catch (error) {
2479
- invalidate(db);
2480
- reject(error);
2481
- return;
2482
- }
2483
- const store = transaction.objectStore(storeName);
2484
- const request = store.get(message.topic);
2485
- request.onsuccess = () => {
2486
- let messages = (request.result?.messages ?? []).concat(message);
2487
- if (pruneStrategy !== "count" && retentionMs !== void 0) {
2488
- const cutoff = Date.now() - retentionMs;
2489
- messages = messages.filter((item) => item.timestamp === void 0 || item.timestamp >= cutoff);
2490
- }
2491
- if (pruneStrategy !== "age") messages = messages.slice(-maxPerTopic);
2492
- store.put({ topic: message.topic, messages });
2493
- };
2494
- request.onerror = () => {
2495
- invalidate(db);
2496
- reject(request.error ?? new Error("Failed to read replay history."));
2497
- };
2498
- transaction.oncomplete = () => resolve();
2499
- transaction.onerror = () => {
2500
- invalidate(db);
2501
- reject(transaction.error ?? new Error("Failed to persist replay history."));
2502
- };
2503
- });
2504
- });
3227
+ return enqueue({ kind: "batch", messages: [message] });
2505
3228
  },
2506
3229
  appendBatch(messages) {
2507
3230
  if (messages.length === 0) return Promise.resolve();
2508
- return serializeMutation(async () => {
2509
- const db = await open();
2510
- await new Promise((resolve, reject) => {
2511
- let transaction;
2512
- try {
2513
- transaction = db.transaction(storeName, "readwrite");
2514
- } catch (error) {
2515
- invalidate(db);
2516
- reject(error);
2517
- return;
2518
- }
2519
- const store = transaction.objectStore(storeName);
2520
- const grouped = /* @__PURE__ */ new Map();
2521
- for (const message of messages) grouped.set(message.topic, [...grouped.get(message.topic) ?? [], message]);
2522
- for (const [topic, topicMessages] of grouped) {
2523
- const request = store.get(topic);
2524
- request.onsuccess = () => {
2525
- let history = (request.result?.messages ?? []).concat(topicMessages);
2526
- if (pruneStrategy !== "count" && retentionMs !== void 0) {
2527
- const cutoff = Date.now() - retentionMs;
2528
- history = history.filter((item) => item.timestamp === void 0 || item.timestamp >= cutoff);
2529
- }
2530
- if (pruneStrategy !== "age") history = history.slice(-maxPerTopic);
2531
- store.put({ topic, messages: history });
2532
- };
2533
- request.onerror = () => {
2534
- invalidate(db);
2535
- reject(request.error ?? new Error("Failed to read replay history."));
2536
- };
2537
- }
2538
- transaction.oncomplete = () => resolve();
2539
- transaction.onerror = () => {
2540
- invalidate(db);
2541
- reject(transaction.error ?? new Error("Failed to persist replay history batch."));
2542
- };
2543
- });
2544
- });
3231
+ return enqueue({ kind: "batch", messages });
2545
3232
  },
2546
3233
  clear() {
2547
- return serializeMutation(async () => {
2548
- const db = await open();
2549
- await new Promise((resolve, reject) => {
2550
- let transaction;
2551
- try {
2552
- transaction = db.transaction(storeName, "readwrite");
2553
- } catch (error) {
2554
- invalidate(db);
2555
- reject(error);
2556
- return;
2557
- }
2558
- transaction.objectStore(storeName).clear();
2559
- transaction.oncomplete = () => resolve();
2560
- transaction.onerror = () => {
2561
- invalidate(db);
2562
- reject(transaction.error ?? new Error("Failed to clear replay history."));
2563
- };
2564
- });
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
+ })()
2565
3255
  });
2566
3256
  },
2567
3257
  clearTopic(topic) {
2568
- return serializeMutation(async () => {
2569
- const db = await open();
2570
- await new Promise((resolve, reject) => {
2571
- let transaction;
2572
- try {
2573
- transaction = db.transaction(storeName, "readwrite");
2574
- } catch (error) {
2575
- invalidate(db);
2576
- reject(error);
2577
- return;
2578
- }
2579
- transaction.objectStore(storeName).delete(topic);
2580
- transaction.oncomplete = () => resolve();
2581
- transaction.onerror = () => {
2582
- invalidate(db);
2583
- reject(transaction.error ?? new Error("Failed to clear topic replay history."));
2584
- };
2585
- });
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
+ })()
2586
3279
  });
2587
3280
  },
2588
3281
  clearBefore(timestamp) {
2589
- return serializeMutation(async () => {
2590
- const db = await open();
2591
- await new Promise((resolve, reject) => {
2592
- let transaction;
2593
- try {
2594
- transaction = db.transaction(storeName, "readwrite");
2595
- } catch (error) {
2596
- invalidate(db);
2597
- reject(error);
2598
- return;
2599
- }
2600
- const store = transaction.objectStore(storeName);
2601
- const request = store.getAll();
2602
- request.onsuccess = () => {
2603
- for (const record of request.result) {
2604
- const messages = record.messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
2605
- if (messages.length === 0) store.delete(record.topic);
2606
- else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
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;
2607
3294
  }
2608
- };
2609
- request.onerror = () => {
2610
- invalidate(db);
2611
- reject(request.error ?? new Error("Failed to read replay history."));
2612
- };
2613
- transaction.oncomplete = () => resolve();
2614
- transaction.onerror = () => {
2615
- invalidate(db);
2616
- reject(transaction.error ?? new Error("Failed to prune replay history."));
2617
- };
2618
- });
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
+ })()
2619
3315
  });
2620
3316
  }
2621
3317
  };
@@ -2649,6 +3345,7 @@ var WebSocketTransport = class {
2649
3345
  constructor(connection) {
2650
3346
  this.connection = connection;
2651
3347
  }
3348
+ connection;
2652
3349
  diagnosticsName = "websocket";
2653
3350
  diagnosticsBackend = "native-websocket";
2654
3351
  socket = null;
@@ -2665,22 +3362,22 @@ var WebSocketTransport = class {
2665
3362
  try {
2666
3363
  socket = factory(config.url, protocols);
2667
3364
  } catch (error) {
2668
- handlers.onStatus("error");
3365
+ handlers.onStatus(WORKER_STATUS.ERROR);
2669
3366
  handlers.onError(error);
2670
3367
  return;
2671
3368
  }
2672
3369
  socket.onopen = () => {
2673
3370
  if (this.socket !== socket || this.handlers !== handlers) return;
2674
3371
  for (const topic of this.subscribedTopics) {
2675
- this.sendFrame({ op: "subscribe", topic });
3372
+ this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
2676
3373
  }
2677
- handlers.onStatus("connected");
3374
+ handlers.onStatus(WORKER_STATUS.CONNECTED);
2678
3375
  };
2679
3376
  socket.onclose = () => {
2680
- if (this.socket === socket && this.handlers === handlers) handlers.onStatus("disconnected");
3377
+ if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.DISCONNECTED);
2681
3378
  };
2682
3379
  socket.onerror = () => {
2683
- if (this.socket === socket && this.handlers === handlers) handlers.onStatus("error");
3380
+ if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.ERROR);
2684
3381
  };
2685
3382
  socket.onmessage = (event) => {
2686
3383
  if (this.socket === socket && this.handlers === handlers) void this.handleMessage(event.data);
@@ -2691,12 +3388,12 @@ var WebSocketTransport = class {
2691
3388
  * not duplicate the local tracking entry. */
2692
3389
  subscribe(topic) {
2693
3390
  this.subscribedTopics.add(topic);
2694
- this.sendFrame({ op: "subscribe", topic });
3391
+ this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
2695
3392
  }
2696
3393
  /** Idempotent: unsubscribing an unknown topic is a no-op. */
2697
3394
  unsubscribe(topic) {
2698
3395
  this.subscribedTopics.delete(topic);
2699
- this.sendFrame({ op: "unsubscribe", topic });
3396
+ this.sendFrame({ op: WS_OP.UNSUBSCRIBE, topic });
2700
3397
  }
2701
3398
  /** Publish `data` to `topic` as a JSON frame. Requires an open socket. */
2702
3399
  publish(topic, data, options) {
@@ -2705,13 +3402,39 @@ var WebSocketTransport = class {
2705
3402
  return;
2706
3403
  }
2707
3404
  this.sendFrame({
2708
- op: "publish",
3405
+ op: WS_OP.PUBLISH,
2709
3406
  topic,
2710
3407
  data,
2711
3408
  ...options?.messageId === void 0 ? {} : { messageId: options.messageId },
2712
3409
  ...options?.timestamp === void 0 ? {} : { timestamp: options.timestamp }
2713
3410
  });
2714
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
+ }
2715
3438
  /** Close the socket and drop all state. Safe to call multiple times. */
2716
3439
  stop() {
2717
3440
  const socket = this.socket;
@@ -2733,7 +3456,7 @@ var WebSocketTransport = class {
2733
3456
  sendBinaryFrame(topic, data, messageId, timestamp) {
2734
3457
  if (messageId !== void 0 || timestamp !== void 0) {
2735
3458
  this.sendFrame({
2736
- op: "publish",
3459
+ op: WS_OP.PUBLISH,
2737
3460
  topic,
2738
3461
  data: Array.from(new Uint8Array(data)),
2739
3462
  ...messageId === void 0 ? {} : { messageId },
@@ -2813,9 +3536,9 @@ function createWebSocketDataBus(options) {
2813
3536
  function selectWorkerBackend(mode, availability = {}) {
2814
3537
  const hasDedicated = availability.worker ?? typeof Worker !== "undefined";
2815
3538
  const hasShared = availability.sharedWorker ?? typeof SharedWorker !== "undefined";
2816
- if (mode === "shared" || mode === "auto") {
2817
- 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;
2818
3541
  }
2819
- return hasDedicated ? "dedicated" : hasShared ? "shared" : "local";
3542
+ return hasDedicated ? WORKER_BACKEND.DEDICATED : hasShared ? WORKER_BACKEND.SHARED : WORKER_BACKEND.LOCAL;
2820
3543
  }
2821
3544
  //# sourceMappingURL=index.cjs.map