cross-tab-worker-databus 0.20.71 → 0.20.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +216 -2
  2. package/README.md +8 -0
  3. package/README.zh.md +4 -0
  4. package/dist/centrifuge-protocol.d.ts +59 -26
  5. package/dist/centrifuge-protocol.d.ts.map +1 -1
  6. package/dist/centrifuge-session.d.ts +9 -0
  7. package/dist/centrifuge-session.d.ts.map +1 -1
  8. package/dist/centrifuge.d.ts +12 -2
  9. package/dist/centrifuge.d.ts.map +1 -1
  10. package/dist/centrifuge.js +168 -88
  11. package/dist/centrifuge.js.map +3 -3
  12. package/dist/centrifuge.shared.worker.js +146 -38
  13. package/dist/centrifuge.shared.worker.js.map +3 -3
  14. package/dist/centrifuge.worker.js +140 -33
  15. package/dist/centrifuge.worker.js.map +3 -3
  16. package/dist/{chunk-D2SIT473.js → chunk-SDOV3UHG.js} +1304 -515
  17. package/dist/chunk-SDOV3UHG.js.map +7 -0
  18. package/dist/chunk-TZ7ZP7YD.js +175 -0
  19. package/dist/chunk-TZ7ZP7YD.js.map +7 -0
  20. package/dist/cjs/centrifuge.cjs +1608 -650
  21. package/dist/cjs/centrifuge.cjs.map +4 -4
  22. package/dist/cjs/hooks.cjs +37 -2
  23. package/dist/cjs/hooks.cjs.map +3 -3
  24. package/dist/cjs/index.cjs +1692 -747
  25. package/dist/cjs/index.cjs.map +4 -4
  26. package/dist/cjs/vue.cjs +45 -2
  27. package/dist/cjs/vue.cjs.map +3 -3
  28. package/dist/core/cluster.d.ts +40 -4
  29. package/dist/core/cluster.d.ts.map +1 -1
  30. package/dist/core/data-bus.d.ts +134 -77
  31. package/dist/core/data-bus.d.ts.map +1 -1
  32. package/dist/core/dedup-manager.d.ts +98 -0
  33. package/dist/core/dedup-manager.d.ts.map +1 -0
  34. package/dist/core/environment.d.ts +43 -3
  35. package/dist/core/environment.d.ts.map +1 -1
  36. package/dist/core/replay-manager.d.ts +129 -0
  37. package/dist/core/replay-manager.d.ts.map +1 -0
  38. package/dist/core/replay-persistence.d.ts +2 -1
  39. package/dist/core/replay-persistence.d.ts.map +1 -1
  40. package/dist/core/replay-pruning.d.ts +21 -0
  41. package/dist/core/replay-pruning.d.ts.map +1 -0
  42. package/dist/core/routing.d.ts +22 -3
  43. package/dist/core/routing.d.ts.map +1 -1
  44. package/dist/core/storage-batch.d.ts +0 -4
  45. package/dist/core/storage-batch.d.ts.map +1 -1
  46. package/dist/core/trace.d.ts +55 -15
  47. package/dist/core/trace.d.ts.map +1 -1
  48. package/dist/core/types.d.ts +58 -9
  49. package/dist/core/types.d.ts.map +1 -1
  50. package/dist/core/version.d.ts +2 -0
  51. package/dist/core/version.d.ts.map +1 -0
  52. package/dist/hooks.d.ts +12 -1
  53. package/dist/hooks.d.ts.map +1 -1
  54. package/dist/hooks.js +28 -2
  55. package/dist/hooks.js.map +2 -2
  56. package/dist/index.d.ts +6 -6
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +263 -166
  59. package/dist/index.js.map +3 -3
  60. package/dist/utils/constants.d.ts +170 -0
  61. package/dist/utils/constants.d.ts.map +1 -0
  62. package/dist/utils/error-utils.d.ts +28 -0
  63. package/dist/utils/error-utils.d.ts.map +1 -0
  64. package/dist/utils/metadata.d.ts +16 -0
  65. package/dist/utils/metadata.d.ts.map +1 -0
  66. package/dist/utils/storage-utils.d.ts +24 -0
  67. package/dist/utils/storage-utils.d.ts.map +1 -0
  68. package/dist/utils/validation.d.ts +76 -0
  69. package/dist/utils/validation.d.ts.map +1 -0
  70. package/dist/vue.d.ts +13 -1
  71. package/dist/vue.d.ts.map +1 -1
  72. package/dist/vue.js +36 -2
  73. package/dist/vue.js.map +2 -2
  74. package/dist/websocket.d.ts +6 -1
  75. package/dist/websocket.d.ts.map +1 -1
  76. package/dist/worker-mode.d.ts +6 -2
  77. package/dist/worker-mode.d.ts.map +1 -1
  78. package/docs/README.md +1 -0
  79. package/docs/api.md +135 -4
  80. package/docs/architecture.md +92 -1
  81. package/docs/benchmarks.md +24 -0
  82. package/docs/capabilities.md +24 -3
  83. package/docs/configuration.md +58 -0
  84. package/docs/getting-started.md +35 -0
  85. package/docs/release-checklist.md +28 -5
  86. package/docs/roadmap.md +81 -5
  87. package/docs/zh/README.md +2 -1
  88. package/docs/zh/api.md +134 -4
  89. package/docs/zh/architecture.md +61 -1
  90. package/docs/zh/benchmarks.md +24 -0
  91. package/docs/zh/capabilities.md +21 -3
  92. package/docs/zh/configuration.md +53 -0
  93. package/docs/zh/getting-started.md +35 -0
  94. package/docs/zh/release-checklist.md +29 -6
  95. package/docs/zh/roadmap.md +93 -5
  96. package/package.json +39 -16
  97. package/dist/chunk-D2SIT473.js.map +0 -7
@@ -1,3 +1,30 @@
1
+ import {
2
+ CHANNEL_FALLBACK,
3
+ CLUSTER_MESSAGE_TYPE,
4
+ CONTROL_ACTION,
5
+ DEFAULT_STORAGE_PREFIX,
6
+ FAILURE_SOURCE,
7
+ HEALTH_STATE,
8
+ INVOKE_LABEL,
9
+ PERSISTENCE_OPERATION,
10
+ PRUNE_STRATEGY,
11
+ PUBLICATION_EVENT,
12
+ RECOVERY_OUTCOME,
13
+ RELIABILITY_OPERATION,
14
+ STORAGE_CHANNEL_PREFIX,
15
+ SUBSCRIPTION_ACTION,
16
+ TAB_ID_STORAGE_KEY,
17
+ TAB_VISIBILITY,
18
+ TRACE_ERROR_SOURCE,
19
+ TRACE_EVENT_TYPE,
20
+ TRACE_LIFECYCLE_ACTION,
21
+ TRACE_MODE,
22
+ WORKER_BACKEND,
23
+ WORKER_MODE,
24
+ WORKER_ROLE,
25
+ WORKER_STATUS
26
+ } from "./chunk-TZ7ZP7YD.js";
27
+
1
28
  // src/core/environment.ts
2
29
  function getStorage(name) {
3
30
  try {
@@ -13,8 +40,53 @@ function randomId() {
13
40
  return Math.random().toString(36).slice(2);
14
41
  }
15
42
  }
43
+ function createStorageEventChannel(options) {
44
+ const { name, storage, win } = options;
45
+ if (!storage || !win) return null;
46
+ const key = `${STORAGE_CHANNEL_PREFIX}${name}`;
47
+ const listeners = /* @__PURE__ */ new Set();
48
+ let sequence = 0;
49
+ const onStorage = (event) => {
50
+ if (event.key !== key || event.newValue === null) return;
51
+ let message;
52
+ try {
53
+ const parsed = JSON.parse(event.newValue);
54
+ if (!parsed || typeof parsed !== "object" || typeof parsed.seq !== "number" || !parsed.message) return;
55
+ message = parsed.message;
56
+ } catch {
57
+ return;
58
+ }
59
+ const event_ = { data: message };
60
+ for (const listener of [...listeners]) listener(event_);
61
+ };
62
+ win.addEventListener("storage", onStorage);
63
+ let closed = false;
64
+ return {
65
+ addEventListener(_type, listener) {
66
+ listeners.add(listener);
67
+ },
68
+ removeEventListener(_type, listener) {
69
+ listeners.delete(listener);
70
+ },
71
+ postMessage(message) {
72
+ if (closed) return;
73
+ sequence += 1;
74
+ storage.setItem(key, JSON.stringify({ seq: sequence, message }));
75
+ },
76
+ close() {
77
+ closed = true;
78
+ win.removeEventListener("storage", onStorage);
79
+ listeners.clear();
80
+ try {
81
+ storage.removeItem(key);
82
+ } catch {
83
+ }
84
+ }
85
+ };
86
+ }
16
87
  var tabIdentityInitialized = false;
17
- function createBrowserEnvironment() {
88
+ function createBrowserEnvironment(options) {
89
+ const channelFallback = options?.channelFallback ?? CHANNEL_FALLBACK.NONE;
18
90
  return {
19
91
  storage: getStorage("localStorage"),
20
92
  sessionStorage: getStorage("sessionStorage"),
@@ -22,14 +94,18 @@ function createBrowserEnvironment() {
22
94
  randomId,
23
95
  createChannel: (name) => {
24
96
  try {
25
- return typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(name);
97
+ if (typeof BroadcastChannel !== "undefined") return new BroadcastChannel(name);
26
98
  } catch {
27
- return null;
28
99
  }
100
+ return channelFallback === CHANNEL_FALLBACK.STORAGE_EVENT ? createStorageEventChannel({
101
+ name,
102
+ storage: getStorage("localStorage"),
103
+ win: typeof window !== "undefined" && typeof window.addEventListener === "function" ? window : null
104
+ }) : null;
29
105
  },
30
106
  setInterval: (callback, intervalMs) => globalThis.setInterval(callback, intervalMs),
31
107
  clearInterval: (handle) => globalThis.clearInterval(handle),
32
- getVisibilityState: () => typeof document !== "undefined" && document.visibilityState === "hidden" ? "hidden" : "visible",
108
+ getVisibilityState: () => typeof document !== "undefined" && document.visibilityState === TAB_VISIBILITY.HIDDEN ? TAB_VISIBILITY.HIDDEN : TAB_VISIBILITY.VISIBLE,
33
109
  addVisibilityChangeListener: (listener) => {
34
110
  if (typeof document !== "undefined") document.addEventListener("visibilitychange", listener);
35
111
  },
@@ -60,7 +136,7 @@ function canUseStorage(storage, probeKey) {
60
136
  return false;
61
137
  }
62
138
  }
63
- function getOrCreateTabId(environment, key = "cross-tab-worker-databus:tab-id") {
139
+ function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY) {
64
140
  const storage = environment.sessionStorage;
65
141
  try {
66
142
  const existing = storage?.getItem(key);
@@ -113,21 +189,73 @@ function avalancheMix(self, neighbor) {
113
189
 
114
190
  // src/core/routing.ts
115
191
  var DEFAULT_MAX_ACTIVE_WORKERS = 3;
116
- function selectLeastLoadedWorker(workers, preferredWorkerId) {
192
+ function effectiveWorkerLoad(worker, options) {
193
+ const baseLoad = Number.isFinite(worker.load) ? worker.load : 0;
194
+ const sample = worker.throughput;
195
+ const messageRateWeight = options?.messageRateWeight ?? 0;
196
+ const byteRateWeight = options?.byteRateWeight ?? 0;
197
+ const scheduleLagWeight = options?.scheduleLagWeight ?? 0;
198
+ if (!sample || !Number.isFinite(sample.windowMs) || sample.windowMs <= 0 || messageRateWeight === 0 && byteRateWeight === 0 && scheduleLagWeight === 0) {
199
+ return baseLoad;
200
+ }
201
+ const windowSeconds = sample.windowMs / 1e3;
202
+ const messageRate = sample.messageCount / windowSeconds;
203
+ const byteRate = sample.byteCount / windowSeconds;
204
+ const scheduleLagRatio = sample.overrunMs / sample.windowMs;
205
+ const weighted = baseLoad + messageRateWeight * messageRate + byteRateWeight * byteRate + scheduleLagWeight * scheduleLagRatio;
206
+ return Number.isFinite(weighted) ? weighted : baseLoad;
207
+ }
208
+ function approximatePayloadBytes(payload) {
209
+ return estimatePayloadBytes(payload, 0);
210
+ }
211
+ var MAX_PAYLOAD_DEPTH = 6;
212
+ function estimatePayloadBytes(payload, depth) {
213
+ if (payload === null || payload === void 0) return 0;
214
+ switch (typeof payload) {
215
+ case "boolean":
216
+ return 4;
217
+ case "number":
218
+ case "bigint":
219
+ return 8;
220
+ case "string":
221
+ return payload.length;
222
+ case "symbol":
223
+ case "function":
224
+ return 0;
225
+ case "object":
226
+ break;
227
+ }
228
+ if (depth >= MAX_PAYLOAD_DEPTH) return 0;
229
+ if (payload instanceof ArrayBuffer) return payload.byteLength;
230
+ if (ArrayBuffer.isView(payload)) return payload.byteLength;
231
+ if (Array.isArray(payload)) {
232
+ let sum2 = 8;
233
+ for (const item of payload) sum2 += estimatePayloadBytes(item, depth + 1);
234
+ return sum2;
235
+ }
236
+ let sum = 0;
237
+ for (const value of Object.values(payload)) {
238
+ sum += estimatePayloadBytes(value, depth + 1);
239
+ }
240
+ return sum;
241
+ }
242
+ function selectLeastLoadedWorker(workers, preferredWorkerId, options) {
117
243
  const preferred = workers.find((worker) => worker.workerId === preferredWorkerId);
118
244
  if (preferred) return preferred;
119
245
  return workers.reduce((least, worker) => {
120
246
  if (!least) return worker;
121
- const byLoad = worker.load - least.load;
247
+ const byLoad = effectiveWorkerLoad(worker, options) - effectiveWorkerLoad(least, options);
122
248
  if (byLoad !== 0) return byLoad < 0 ? worker : least;
123
249
  if (worker.workerId < least.workerId) return worker;
124
250
  return least;
125
251
  }, void 0);
126
252
  }
127
253
  function selectActiveWorkers(workers, maxActiveWorkers = DEFAULT_MAX_ACTIVE_WORKERS) {
128
- const healthyWorkers = workers.filter((worker) => worker.status === "connecting" || worker.status === "connected");
254
+ const healthyWorkers = workers.filter(
255
+ (worker) => worker.status === WORKER_STATUS.CONNECTING || worker.status === WORKER_STATUS.CONNECTED
256
+ );
129
257
  const availableWorkers = healthyWorkers.length > 0 ? healthyWorkers : [...workers];
130
- const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === "visible");
258
+ const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === TAB_VISIBILITY.VISIBLE);
131
259
  const candidates = visibleWorkers.length > 0 ? visibleWorkers : availableWorkers;
132
260
  return candidates.sort(
133
261
  (left, right) => left.registeredAt - right.registeredAt || (left.workerId < right.workerId ? -1 : left.workerId > right.workerId ? 1 : 0)
@@ -155,6 +283,123 @@ function topicMatchesPattern(pattern, topic) {
155
283
  return topic.startsWith(pattern.slice(0, -1));
156
284
  }
157
285
 
286
+ // src/utils/metadata.ts
287
+ function publicationMetadata(messageId, timestamp) {
288
+ if (messageId === void 0 && timestamp === void 0) return void 0;
289
+ return {
290
+ ...messageId === void 0 ? {} : { messageId },
291
+ ...timestamp === void 0 ? {} : { timestamp }
292
+ };
293
+ }
294
+
295
+ // src/utils/validation.ts
296
+ function assertPositiveSafeInteger(value, name) {
297
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
298
+ throw new TypeError(`${name} must be a positive safe integer, got ${String(value)}.`);
299
+ }
300
+ }
301
+ function assertPositiveFiniteNumber(value, name) {
302
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
303
+ throw new TypeError(`${name} must be a positive finite number.`);
304
+ }
305
+ }
306
+ function assertNonNegativeFiniteNumber(value, name) {
307
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
308
+ throw new TypeError(`${name} must be a non-negative finite number.`);
309
+ }
310
+ }
311
+ function assertPruneStrategy(value) {
312
+ const allowed = [PRUNE_STRATEGY.COUNT, PRUNE_STRATEGY.AGE, PRUNE_STRATEGY.BOTH];
313
+ if (!allowed.includes(String(value))) {
314
+ throw new TypeError("pruneStrategy must be count, age, or both.");
315
+ }
316
+ }
317
+ function assertReplayOptions(replay) {
318
+ if (!replay) return;
319
+ if (replay.maxPerTopic !== void 0) assertPositiveSafeInteger(replay.maxPerTopic, "replay.maxPerTopic");
320
+ if (replay.pruneStrategy !== void 0) assertPruneStrategy(replay.pruneStrategy);
321
+ if (replay.retentionMs !== void 0) assertPositiveFiniteNumber(replay.retentionMs, "replay.retentionMs");
322
+ if (replay.retentionSweepMs !== void 0) {
323
+ assertPositiveFiniteNumber(replay.retentionSweepMs, "replay.retentionSweepMs");
324
+ }
325
+ if (replay.persistenceRetry) assertPersistenceRetryOptions(replay.persistenceRetry);
326
+ }
327
+ function assertPersistenceRetryOptions(retry) {
328
+ if (retry.maxAttempts !== void 0) {
329
+ assertPositiveSafeInteger(retry.maxAttempts, "replay.persistenceRetry.maxAttempts");
330
+ }
331
+ if (retry.backoffMs !== void 0) {
332
+ assertNonNegativeFiniteNumber(retry.backoffMs, "replay.persistenceRetry.backoffMs");
333
+ }
334
+ }
335
+ function assertDedupOptions(dedup) {
336
+ if (!dedup) return;
337
+ if (dedup.maxEntries !== void 0) assertPositiveSafeInteger(dedup.maxEntries, "dedup.maxEntries");
338
+ if (dedup.ttlMs !== void 0) assertPositiveFiniteNumber(dedup.ttlMs, "dedup.ttlMs");
339
+ if (dedup.sweepMs !== void 0) assertPositiveFiniteNumber(dedup.sweepMs, "dedup.sweepMs");
340
+ const bounds = dedup.adaptiveTtl;
341
+ if (bounds !== void 0) {
342
+ const finite = (value) => typeof value === "number" && Number.isFinite(value);
343
+ if (!finite(bounds.minMs) || !finite(bounds.maxMs) || bounds.minMs <= 0 || bounds.maxMs < bounds.minMs) {
344
+ throw new TypeError("dedup.adaptiveTtl bounds are invalid.");
345
+ }
346
+ }
347
+ }
348
+ function assertRecoveryOptions(recovery) {
349
+ if (!recovery) return;
350
+ if (recovery.cooldownMs !== void 0) {
351
+ assertPositiveFiniteNumber(recovery.cooldownMs, "recovery.cooldownMs");
352
+ }
353
+ const maxAttempts = recovery.maxAttempts;
354
+ if (maxAttempts !== void 0 && !(maxAttempts === Number.POSITIVE_INFINITY || typeof maxAttempts === "number" && Number.isSafeInteger(maxAttempts) && maxAttempts > 0)) {
355
+ throw new TypeError("recovery.maxAttempts must be a positive safe integer.");
356
+ }
357
+ }
358
+ function assertLoadWeightingOptions(loadWeighting) {
359
+ if (!loadWeighting) return;
360
+ const weights = {
361
+ messageRateWeight: loadWeighting.messageRateWeight,
362
+ byteRateWeight: loadWeighting.byteRateWeight,
363
+ scheduleLagWeight: loadWeighting.scheduleLagWeight
364
+ };
365
+ for (const [name, value] of Object.entries(weights)) {
366
+ if (value !== void 0) assertNonNegativeFiniteNumber(value, `loadWeighting.${name}`);
367
+ }
368
+ }
369
+ function assertClusterOptions(options) {
370
+ if (options.maxActiveWorkers !== void 0) {
371
+ assertPositiveSafeInteger(options.maxActiveWorkers, "maxActiveWorkers");
372
+ }
373
+ if (options.heartbeatIntervalMs !== void 0) {
374
+ assertPositiveFiniteNumber(options.heartbeatIntervalMs, "heartbeatIntervalMs");
375
+ }
376
+ if (options.workerTtlMs !== void 0) {
377
+ assertPositiveFiniteNumber(options.workerTtlMs, "workerTtlMs");
378
+ }
379
+ if (options.routeOwnerCacheMax !== void 0) {
380
+ assertPositiveSafeInteger(options.routeOwnerCacheMax, "routeOwnerCacheMax");
381
+ }
382
+ assertLoadWeightingOptions(options.loadWeighting);
383
+ }
384
+ function assertHeartbeatInterval(value) {
385
+ if (value === Infinity) return;
386
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return;
387
+ throw new TypeError(
388
+ `Centrifuge heartbeatIntervalMs must be a positive number or Infinity, got ${String(value)}.`
389
+ );
390
+ }
391
+ function assertStructuredCloneable(value) {
392
+ if (typeof structuredClone !== "function") return;
393
+ try {
394
+ structuredClone(value);
395
+ } catch (error) {
396
+ throw new TypeError(
397
+ "Centrifuge Worker configuration and published data must be structured-cloneable.",
398
+ { cause: error }
399
+ );
400
+ }
401
+ }
402
+
158
403
  // src/core/storage-batch.ts
159
404
  var INITIAL_RETRY_DELAY_MS = 50;
160
405
  var MAX_RETRY_DELAY_MS = 1600;
@@ -163,6 +408,7 @@ var BatchingStorageWriter = class {
163
408
  constructor(storage) {
164
409
  this.storage = storage;
165
410
  }
411
+ storage;
166
412
  /** Coalesced write set. A `null` value represents a pending delete. */
167
413
  pending = /* @__PURE__ */ new Map();
168
414
  /** Per-key retry counter, reset on a successful write. */
@@ -219,7 +465,7 @@ var BatchingStorageWriter = class {
219
465
  this.pending.delete(key);
220
466
  this.retryCount.delete(key);
221
467
  if (typeof console !== "undefined" && typeof console.warn === "function") {
222
- console.warn("[cross-tab-worker-databus] storage write gave up after retries, dropping key:", key);
468
+ console.warn(`[${DEFAULT_STORAGE_PREFIX}] storage write gave up after retries, dropping key:`, key);
223
469
  }
224
470
  continue;
225
471
  }
@@ -279,12 +525,7 @@ var BatchingStorageWriter = class {
279
525
  }
280
526
  };
281
527
 
282
- // src/core/cluster.ts
283
- var CLUSTER_PROTOCOL_VERSION = 1;
284
- var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
285
- var DEFAULT_WORKER_TTL_MS = 1e4;
286
- var DEFAULT_STORAGE_PREFIX = "cross-tab-worker-databus";
287
- var MAX_KNOWN_TOPICS = 500;
528
+ // src/utils/storage-utils.ts
288
529
  function readJson(storage, key) {
289
530
  try {
290
531
  const value = storage.getItem(key);
@@ -299,13 +540,6 @@ function writeJson(storage, key, value) {
299
540
  } catch {
300
541
  }
301
542
  }
302
- function publicationMetadata(messageId, timestamp) {
303
- if (messageId === void 0 && timestamp === void 0) return void 0;
304
- return {
305
- ...messageId === void 0 ? {} : { messageId },
306
- ...timestamp === void 0 ? {} : { timestamp }
307
- };
308
- }
309
543
  function listKeys(storage, prefix) {
310
544
  try {
311
545
  return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter(
@@ -318,6 +552,12 @@ function listKeys(storage, prefix) {
318
552
  function readAllByPrefix(storage, prefix) {
319
553
  return listKeys(storage, prefix).map((key) => ({ key, value: readJson(storage, key) })).filter((entry) => entry.value !== null);
320
554
  }
555
+
556
+ // src/core/cluster.ts
557
+ var CLUSTER_PROTOCOL_VERSION = 1;
558
+ var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
559
+ var DEFAULT_WORKER_TTL_MS = 1e4;
560
+ var MAX_KNOWN_TOPICS = 500;
321
561
  var WorkerClusterRuntime = class {
322
562
  tabId;
323
563
  workerId;
@@ -331,6 +571,14 @@ var WorkerClusterRuntime = class {
331
571
  routePrefix;
332
572
  subscriberPrefix;
333
573
  channelName;
574
+ /** Adaptive load weighting options; undefined keeps legacy topic-count routing. */
575
+ loadWeighting;
576
+ /** Rolling traffic accumulator folded into the worker record on writeRecord. */
577
+ throughputWindow = {
578
+ startedAt: 0,
579
+ messageCount: 0,
580
+ byteCount: 0
581
+ };
334
582
  // Topics this tab has subscribed to (local interest, plaintext).
335
583
  subscribedTopics = /* @__PURE__ */ new Set();
336
584
  // Topics assigned to this Worker as owner (topicKey → topic). Authoritative:
@@ -366,12 +614,14 @@ var WorkerClusterRuntime = class {
366
614
  lifecycleListening = false;
367
615
  currentRecord;
368
616
  constructor(options) {
617
+ assertClusterOptions(options);
369
618
  this.environment = options.environment ?? createBrowserEnvironment();
370
619
  this.handlers = options.handlers;
371
620
  this.maxActiveWorkers = options.maxActiveWorkers ?? DEFAULT_MAX_ACTIVE_WORKERS;
372
621
  this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
373
622
  this.routeOwnerCacheMax = options.routeOwnerCacheMax ?? 256;
374
623
  this.workerTtlMs = options.workerTtlMs ?? DEFAULT_WORKER_TTL_MS;
624
+ this.loadWeighting = options.loadWeighting;
375
625
  const clusterHash = createOpaqueKey(options.clusterKey || "__default__");
376
626
  const prefix = options.storagePrefix ?? DEFAULT_STORAGE_PREFIX;
377
627
  const baseKey = `${prefix}:${clusterHash}`;
@@ -388,8 +638,8 @@ var WorkerClusterRuntime = class {
388
638
  workerId: this.workerId,
389
639
  tabId: this.tabId,
390
640
  load: 0,
391
- role: "standby",
392
- status: "connecting",
641
+ role: WORKER_ROLE.STANDBY,
642
+ status: WORKER_STATUS.CONNECTING,
393
643
  visibilityState: this.environment.getVisibilityState(),
394
644
  heartbeatAt: now,
395
645
  registeredAt: now
@@ -442,7 +692,7 @@ var WorkerClusterRuntime = class {
442
692
  this.writeRecord(true);
443
693
  for (const topic of this.subscribedTopics) {
444
694
  const topicKey = this.rememberTopic(topic);
445
- if (!this.storage) this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
695
+ if (!this.storage) this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
446
696
  else this.writeSubscriber(topicKey);
447
697
  }
448
698
  this.reconcile();
@@ -470,9 +720,14 @@ var WorkerClusterRuntime = class {
470
720
  this.removeStorage(this.workerStorageKey(this.workerId));
471
721
  this.flushStorage();
472
722
  this.notifyRegistry();
473
- this.channel?.close();
723
+ const channel = this.channel;
474
724
  this.channel = null;
475
725
  this.handlers.onSuspend?.();
726
+ if (typeof globalThis.setTimeout === "function") {
727
+ globalThis.setTimeout(() => channel?.close(), 0);
728
+ } else {
729
+ channel?.close();
730
+ }
476
731
  }
477
732
  /** Update the worker's connection status and persist the change. */
478
733
  setStatus(status) {
@@ -490,7 +745,7 @@ var WorkerClusterRuntime = class {
490
745
  this.subscribedTopics.add(topic);
491
746
  if (!this.started) return false;
492
747
  if (!this.storage) {
493
- this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
748
+ this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
494
749
  return true;
495
750
  }
496
751
  this.writeSubscriber(topicKey);
@@ -500,9 +755,9 @@ var WorkerClusterRuntime = class {
500
755
  return existingRoute?.workerId === this.workerId;
501
756
  }
502
757
  const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
503
- const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
758
+ const owner = selectLeastLoadedWorker(activeWorkers, void 0, this.loadWeighting) ?? this.currentRecord;
504
759
  this.writeRoute(topicKey, owner, void 0, (existingRoute?.generation ?? 0) + 1);
505
- this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
760
+ this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
506
761
  this.notifyRegistry();
507
762
  return owner.workerId === this.workerId;
508
763
  }
@@ -526,7 +781,7 @@ var WorkerClusterRuntime = class {
526
781
  const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
527
782
  if (subscribers.length === 0) {
528
783
  this.removeStorage(this.routeStorageKey(topicKey));
529
- if (notifyOwner) this.sendControl(route.workerId, "UNSUBSCRIBE", topic, topicKey);
784
+ if (notifyOwner) this.sendControl(route.workerId, CONTROL_ACTION.UNSUBSCRIBE, topic, topicKey);
530
785
  }
531
786
  return topicKey;
532
787
  }
@@ -545,15 +800,17 @@ var WorkerClusterRuntime = class {
545
800
  continue;
546
801
  }
547
802
  const owner = selectLeastLoadedWorker(
548
- activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load }))
803
+ activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load })),
804
+ void 0,
805
+ this.loadWeighting
549
806
  );
550
807
  if (!owner) continue;
551
808
  projectedLoads.set(owner.workerId, (projectedLoads.get(owner.workerId) ?? owner.load) + 1);
552
809
  const generation = (previous?.generation ?? 0) + 1;
553
810
  this.writeRoute(topicKey, owner, previous?.workerId, generation);
554
- this.handlers.onDiagnostic?.({ operation: "route_migration", topic });
811
+ this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_MIGRATION, topic });
555
812
  this.flushStorage();
556
- this.handlers.onControl("UNSUBSCRIBE", topic);
813
+ this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, topic);
557
814
  this.sendRouteReleased(owner.workerId, topic, topicKey, generation);
558
815
  }
559
816
  }
@@ -561,22 +818,19 @@ var WorkerClusterRuntime = class {
561
818
  const metadata = typeof metadataOrMessageId === "string" ? { messageId: metadataOrMessageId } : metadataOrMessageId;
562
819
  const topicKey = this.rememberTopic(topic);
563
820
  if (this.assignedTopics.has(topicKey)) {
564
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
821
+ return this.sendControl(this.workerId, CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
565
822
  }
566
823
  const cachedPattern = this.wildcardPublishCache.get(topic);
567
- if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
568
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
569
- }
570
824
  if (cachedPattern === void 0) {
571
825
  for (const pattern of this.assignedTopics.values()) {
572
826
  if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
573
827
  this.wildcardPublishCache.set(topic, pattern);
574
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
828
+ return this.sendControl(this.workerId, CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
575
829
  }
576
830
  }
577
831
  this.wildcardPublishCache.set(topic, null);
578
832
  }
579
- return this.sendControl(this.resolvePublishTarget(topic, topicKey), "PUBLISH", topic, topicKey, data, metadata);
833
+ return this.sendControl(this.resolvePublishTarget(topic, topicKey), CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
580
834
  }
581
835
  /**
582
836
  * Burst-friendly variant of `publish()`: packs up to N items into a single
@@ -597,19 +851,15 @@ var WorkerClusterRuntime = class {
597
851
  }
598
852
  const topicKey = this.rememberTopic(topic);
599
853
  if (this.assignedTopics.has(topicKey)) {
600
- for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
854
+ this.dispatchLocalPublishBatch(topic, topicKey, items);
601
855
  return true;
602
856
  }
603
857
  const cachedPattern = this.wildcardPublishCache.get(topic);
604
- if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
605
- for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
606
- return true;
607
- }
608
858
  if (cachedPattern === void 0) {
609
859
  for (const pattern of this.assignedTopics.values()) {
610
860
  if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
611
861
  this.wildcardPublishCache.set(topic, pattern);
612
- for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
862
+ this.dispatchLocalPublishBatch(topic, topicKey, items);
613
863
  return true;
614
864
  }
615
865
  }
@@ -617,14 +867,14 @@ var WorkerClusterRuntime = class {
617
867
  }
618
868
  const target = this.resolvePublishTarget(topic, topicKey);
619
869
  if (target === this.workerId) {
620
- for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
870
+ this.dispatchLocalPublishBatch(topic, topicKey, items);
621
871
  return true;
622
872
  }
623
873
  return this.send({
624
- type: "CONTROL",
874
+ type: CLUSTER_MESSAGE_TYPE.CONTROL,
625
875
  sourceWorkerId: this.workerId,
626
876
  targetWorkerId: target,
627
- action: "PUBLISH",
877
+ action: CONTROL_ACTION.PUBLISH,
628
878
  topic,
629
879
  topicKey,
630
880
  items: items.map((item) => ({
@@ -655,8 +905,17 @@ var WorkerClusterRuntime = class {
655
905
  dispatchLocalPublish(topic, topicKey, data, messageId, timestamp) {
656
906
  void topicKey;
657
907
  const meta = publicationMetadata(messageId, timestamp);
658
- if (meta) this.handlers.onControl("PUBLISH", topic, data, meta.messageId, meta.timestamp);
659
- else this.handlers.onControl("PUBLISH", topic, data);
908
+ if (meta) this.handlers.onControl(CONTROL_ACTION.PUBLISH, topic, data, meta.messageId, meta.timestamp);
909
+ else this.handlers.onControl(CONTROL_ACTION.PUBLISH, topic, data);
910
+ }
911
+ /** Fan out a publication batch to the local onControl path: one
912
+ * onPublishBatch call when the owner supports it, per-item otherwise. */
913
+ dispatchLocalPublishBatch(topic, topicKey, items) {
914
+ if (this.handlers.onPublishBatch) {
915
+ this.handlers.onPublishBatch(topic, items);
916
+ return;
917
+ }
918
+ for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
660
919
  }
661
920
  /** True when `route` exists and its owner worker is among `workers`.
662
921
  * Shared by subscribe (skip re-assignment) and publish (route to owner).
@@ -670,7 +929,39 @@ var WorkerClusterRuntime = class {
670
929
  * listeners can attribute the event to its source tab even after fan-out. */
671
930
  broadcastEvent(eventType, payload, originTabId) {
672
931
  const effectiveOriginTabId = originTabId ?? this.tabId;
673
- this.send({ type: "EVENT", sourceWorkerId: this.workerId, eventType, payload, originTabId: effectiveOriginTabId });
932
+ this.recordTraffic(payload);
933
+ this.send({ type: CLUSTER_MESSAGE_TYPE.EVENT, sourceWorkerId: this.workerId, eventType, payload, originTabId: effectiveOriginTabId });
934
+ }
935
+ /** Count one fan-out unit toward the adaptive load sample. No-op unless
936
+ * adaptive weighting is configured. */
937
+ recordTraffic(payload) {
938
+ if (this.loadWeighting === void 0) return;
939
+ this.throughputWindow.messageCount += 1;
940
+ this.throughputWindow.byteCount += approximatePayloadBytes(payload);
941
+ }
942
+ /** Convert the accumulated window into a publishable throughput sample and
943
+ * reset the accumulator. Returns undefined until a full window has elapsed
944
+ * so the first write does not emit a zero-width sample. */
945
+ sampleThroughput(now) {
946
+ if (this.throughputWindow.startedAt === 0) {
947
+ this.throughputWindow.startedAt = now;
948
+ return void 0;
949
+ }
950
+ const windowMs = now - this.throughputWindow.startedAt;
951
+ if (windowMs <= 0) return void 0;
952
+ const sample = {
953
+ windowMs,
954
+ messageCount: this.throughputWindow.messageCount,
955
+ byteCount: this.throughputWindow.byteCount,
956
+ // How much later the heartbeat landed than its nominal interval. This
957
+ // window is anchored at the previous writeRecord (one heartbeat tick),
958
+ // so a starved event loop stretches windowMs past the interval and the
959
+ // positive excess is the scheduling-overrun signal.
960
+ overrunMs: Math.max(0, windowMs - this.heartbeatIntervalMs),
961
+ sampledAt: now
962
+ };
963
+ this.throughputWindow = { startedAt: now, messageCount: 0, byteCount: 0 };
964
+ return sample;
674
965
  }
675
966
  isAssigned(topic) {
676
967
  const topicKey = createOpaqueKey(topic);
@@ -760,14 +1051,14 @@ var WorkerClusterRuntime = class {
760
1051
  const message = event.data;
761
1052
  if (!message || message.sourceWorkerId === this.workerId) return;
762
1053
  switch (message.type) {
763
- case "CONTROL":
1054
+ case CLUSTER_MESSAGE_TYPE.CONTROL:
764
1055
  return this.handleControlMessage(message);
765
- case "ROUTE_RELEASED":
1056
+ case CLUSTER_MESSAGE_TYPE.ROUTE_RELEASED:
766
1057
  return this.handleRouteReleasedMessage(message);
767
- case "EVENT":
1058
+ case CLUSTER_MESSAGE_TYPE.EVENT:
768
1059
  this.handlers.onEvent(message.eventType, message.payload, message.sourceWorkerId, message.originTabId);
769
1060
  return;
770
- case "REGISTRY":
1061
+ case CLUSTER_MESSAGE_TYPE.REGISTRY:
771
1062
  this.reconcile();
772
1063
  return;
773
1064
  default: {
@@ -784,19 +1075,23 @@ var WorkerClusterRuntime = class {
784
1075
  if (message.targetWorkerId !== this.workerId) return;
785
1076
  this.rememberTopic(message.topic);
786
1077
  switch (message.action) {
787
- case "SUBSCRIBE":
1078
+ case CONTROL_ACTION.SUBSCRIBE:
788
1079
  this.assignedTopics.set(message.topicKey, message.topic);
789
1080
  this.confirmRoute(message.topicKey);
790
1081
  break;
791
- case "UNSUBSCRIBE":
1082
+ case CONTROL_ACTION.UNSUBSCRIBE:
792
1083
  if (this.releaseHandoffOnUnsubscribe(message)) return;
793
1084
  break;
794
- case "PUBLISH":
1085
+ case CONTROL_ACTION.PUBLISH:
795
1086
  if (message.items && message.items.length > 0) {
1087
+ if (this.handlers.onPublishBatch) {
1088
+ this.handlers.onPublishBatch(message.topic, message.items);
1089
+ return;
1090
+ }
796
1091
  for (const item of message.items) {
797
1092
  const itemMeta = publicationMetadata(item.messageId, item.timestamp);
798
- if (itemMeta) this.handlers.onControl("PUBLISH", message.topic, item.data, itemMeta.messageId, itemMeta.timestamp);
799
- else this.handlers.onControl("PUBLISH", message.topic, item.data);
1093
+ if (itemMeta) this.handlers.onControl(CONTROL_ACTION.PUBLISH, message.topic, item.data, itemMeta.messageId, itemMeta.timestamp);
1094
+ else this.handlers.onControl(CONTROL_ACTION.PUBLISH, message.topic, item.data);
800
1095
  }
801
1096
  return;
802
1097
  }
@@ -813,7 +1108,7 @@ var WorkerClusterRuntime = class {
813
1108
  metadata.timestamp
814
1109
  );
815
1110
  else this.handlers.onControl(message.action, message.topic, message.data);
816
- if (message.action !== "PUBLISH") this.updateLoad();
1111
+ if (message.action !== CONTROL_ACTION.PUBLISH) this.updateLoad();
817
1112
  }
818
1113
  /**
819
1114
  * When this worker is the previous owner in a graceful handoff and the new
@@ -825,7 +1120,7 @@ var WorkerClusterRuntime = class {
825
1120
  this.assignedTopics.delete(message.topicKey);
826
1121
  const route = this.readRoute(message.topicKey);
827
1122
  if (route?.handoffFromWorkerId !== this.workerId) return false;
828
- this.handlers.onControl("UNSUBSCRIBE", message.topic, void 0);
1123
+ this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, message.topic, void 0);
829
1124
  this.sendRouteReleased(route.workerId, message.topic, message.topicKey, route.generation);
830
1125
  this.updateLoad();
831
1126
  return true;
@@ -834,7 +1129,7 @@ var WorkerClusterRuntime = class {
834
1129
  * generation so only the matching new owner may act on it. */
835
1130
  sendRouteReleased(targetWorkerId, topic, topicKey, generation) {
836
1131
  this.send({
837
- type: "ROUTE_RELEASED",
1132
+ type: CLUSTER_MESSAGE_TYPE.ROUTE_RELEASED,
838
1133
  sourceWorkerId: this.workerId,
839
1134
  targetWorkerId,
840
1135
  topic,
@@ -853,14 +1148,24 @@ var WorkerClusterRuntime = class {
853
1148
  if (!route || this.isStaleRouteRelease(route, message)) return;
854
1149
  this.assignedTopics.set(message.topicKey, message.topic);
855
1150
  this.confirmRoute(message.topicKey);
856
- this.handlers.onControl("SUBSCRIBE", message.topic, void 0);
1151
+ this.handlers.onControl(CONTROL_ACTION.SUBSCRIBE, message.topic, void 0);
857
1152
  this.updateLoad();
858
1153
  }
859
1154
  /** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
860
1155
  * points to us, the release comes from the recorded previous owner, and
861
- * the release generation is at least as new as ours. */
1156
+ * the release generation is at least as new as ours. A replayed ACK from an
1157
+ * earlier handoff round (e.g. an a↔b ping-pong) carries an older generation
1158
+ * and must not confirm the current round. */
862
1159
  isStaleRouteRelease(route, message) {
863
- return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || route.generation < message.generation;
1160
+ return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation < route.generation;
1161
+ }
1162
+ /** True when an unconfirmed handoff route has been stuck longer than a
1163
+ * worker TTL. The ACK for a live handoff is posted synchronously with the
1164
+ * route write, so anything older than the TTL with a dead previous owner
1165
+ * will never complete — while a fresh unconfirmed route may simply be
1166
+ * waiting out its confirmation flush and must be left alone. */
1167
+ isStaleHandoff(route) {
1168
+ return this.environment.now() - route.updatedAt > this.workerTtlMs;
864
1169
  }
865
1170
  /** Full reconciliation cycle: workers, subscriptions, and assigned topics. */
866
1171
  reconcile() {
@@ -893,20 +1198,39 @@ var WorkerClusterRuntime = class {
893
1198
  */
894
1199
  reconcileSubscriptions(workers, activeWorkers) {
895
1200
  const liveWorkerIds = new Set(workers.map((worker) => worker.workerId));
1201
+ const recoveryProjectedLoads = /* @__PURE__ */ new Map();
896
1202
  for (const topic of this.subscribedTopics) {
897
1203
  const topicKey = this.rememberTopic(topic);
898
1204
  this.writeSubscriber(topicKey);
899
1205
  const route = this.readRoute(topicKey);
900
1206
  if (!route || !liveWorkerIds.has(route.workerId)) {
901
- const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
1207
+ const owner = selectLeastLoadedWorker(activeWorkers, void 0, this.loadWeighting) ?? this.currentRecord;
902
1208
  this.writeRoute(topicKey, owner, void 0, (route?.generation ?? 0) + 1);
903
- this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
1209
+ this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
904
1210
  this.notifyRegistry();
905
1211
  continue;
906
1212
  }
907
1213
  if (route.confirmedAt === void 0) {
908
1214
  if (!route.handoffFromWorkerId) {
909
- this.sendControl(route.workerId, "SUBSCRIBE", topic, topicKey);
1215
+ this.sendControl(route.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
1216
+ } else if (!liveWorkerIds.has(route.handoffFromWorkerId) && this.isStaleHandoff(route)) {
1217
+ const owner = selectLeastLoadedWorker(
1218
+ activeWorkers.map((worker) => ({ ...worker, load: recoveryProjectedLoads.get(worker.workerId) ?? worker.load })),
1219
+ void 0,
1220
+ this.loadWeighting
1221
+ ) ?? this.currentRecord;
1222
+ recoveryProjectedLoads.set(owner.workerId, (recoveryProjectedLoads.get(owner.workerId) ?? owner.load) + 1);
1223
+ if (owner.workerId !== this.workerId) {
1224
+ const subscriberTabIds = new Set(this.readSubscriberTabIds(topicKey, workers));
1225
+ const ownerSubscribed = workers.some(
1226
+ (worker) => worker.workerId === owner.workerId && subscriberTabIds.has(worker.tabId)
1227
+ );
1228
+ if (ownerSubscribed) continue;
1229
+ }
1230
+ this.writeRoute(topicKey, owner, void 0, route.generation + 1);
1231
+ this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
1232
+ this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_MIGRATION_RECOVERY, topic });
1233
+ this.notifyRegistry();
910
1234
  }
911
1235
  }
912
1236
  }
@@ -917,7 +1241,7 @@ var WorkerClusterRuntime = class {
917
1241
  const route = this.readRoute(topicKey);
918
1242
  if (route?.workerId === this.workerId) continue;
919
1243
  this.assignedTopics.delete(topicKey);
920
- this.handlers.onControl("UNSUBSCRIBE", topic, void 0);
1244
+ this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, topic, void 0);
921
1245
  if (route?.handoffFromWorkerId === this.workerId) {
922
1246
  this.sendRouteReleased(route.workerId, topic, topicKey, route.generation);
923
1247
  }
@@ -932,14 +1256,14 @@ var WorkerClusterRuntime = class {
932
1256
  sendControl(targetWorkerId, action, topic, topicKey, data, metadata) {
933
1257
  if (targetWorkerId === this.workerId) {
934
1258
  switch (action) {
935
- case "SUBSCRIBE":
1259
+ case CONTROL_ACTION.SUBSCRIBE:
936
1260
  this.assignedTopics.set(topicKey, topic);
937
1261
  this.confirmRoute(topicKey);
938
1262
  break;
939
- case "UNSUBSCRIBE":
1263
+ case CONTROL_ACTION.UNSUBSCRIBE:
940
1264
  this.assignedTopics.delete(topicKey);
941
1265
  break;
942
- case "PUBLISH":
1266
+ case CONTROL_ACTION.PUBLISH:
943
1267
  default:
944
1268
  break;
945
1269
  }
@@ -951,11 +1275,11 @@ var WorkerClusterRuntime = class {
951
1275
  metadata.timestamp
952
1276
  );
953
1277
  else this.handlers.onControl(action, topic, data);
954
- if (action !== "PUBLISH") this.updateLoad();
1278
+ if (action !== CONTROL_ACTION.PUBLISH) this.updateLoad();
955
1279
  return true;
956
1280
  }
957
1281
  return this.send({
958
- type: "CONTROL",
1282
+ type: CLUSTER_MESSAGE_TYPE.CONTROL,
959
1283
  sourceWorkerId: this.workerId,
960
1284
  targetWorkerId,
961
1285
  action,
@@ -1060,7 +1384,7 @@ var WorkerClusterRuntime = class {
1060
1384
  confirmedAt: this.environment.now()
1061
1385
  });
1062
1386
  const topic = this.knownTopics.get(topicKey);
1063
- if (topic) this.handlers.onDiagnostic?.({ operation: "route_ack", topic });
1387
+ if (topic) this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_ACK, topic });
1064
1388
  }
1065
1389
  /** Remove routes whose topic has no subscribers and whose TTL has expired. */
1066
1390
  cleanupOrphanedRoutes(workers) {
@@ -1095,17 +1419,23 @@ var WorkerClusterRuntime = class {
1095
1419
  * avoid a REGISTRY storm every 3 s; true on status/role changes that
1096
1420
  * peers should observe promptly. */
1097
1421
  writeRecord(notify) {
1098
- this.currentRecord = { ...this.currentRecord, heartbeatAt: this.environment.now() };
1422
+ const now = this.environment.now();
1423
+ const sample = this.loadWeighting !== void 0 ? this.sampleThroughput(now) : void 0;
1424
+ this.currentRecord = {
1425
+ ...this.currentRecord,
1426
+ heartbeatAt: now,
1427
+ ...sample ? { throughput: sample } : {}
1428
+ };
1099
1429
  if (this.storage) writeJson(this.storage, this.workerStorageKey(this.workerId), this.currentRecord);
1100
1430
  if (notify) this.notifyRegistry();
1101
1431
  }
1102
1432
  /** Broadcast a REGISTRY message to trigger reconciliation on other tabs. */
1103
1433
  notifyRegistry() {
1104
- this.send({ type: "REGISTRY", sourceWorkerId: this.workerId });
1434
+ this.send({ type: CLUSTER_MESSAGE_TYPE.REGISTRY, sourceWorkerId: this.workerId });
1105
1435
  }
1106
1436
  /** Recompute whether this worker is active (eligible to own topics) or standby. Returns true when changed. */
1107
1437
  refreshRole(workers) {
1108
- const role = this.isActiveAmong(workers) ? "active" : "standby";
1438
+ const role = this.isActiveAmong(workers) ? WORKER_ROLE.ACTIVE : WORKER_ROLE.STANDBY;
1109
1439
  if (role === this.currentRecord.role) return false;
1110
1440
  this.currentRecord = { ...this.currentRecord, role };
1111
1441
  return true;
@@ -1203,7 +1533,7 @@ var DataBusTraceReporter = class {
1203
1533
  /** Start the periodic metrics flush interval. No-op when mode is 'events'
1204
1534
  * (no metrics to emit), when disabled, or when already running. */
1205
1535
  start() {
1206
- if (!this.enabled || this.intervalHandle || this.mode === "events") return;
1536
+ if (!this.enabled || this.intervalHandle || this.mode === TRACE_MODE.EVENTS) return;
1207
1537
  this.stopped = false;
1208
1538
  this.intervalStartedAt = this.now();
1209
1539
  this.intervalHandle = setInterval(() => this.flush(), this.metricsIntervalMs);
@@ -1219,11 +1549,40 @@ var DataBusTraceReporter = class {
1219
1549
  this.stopped = true;
1220
1550
  this.pause();
1221
1551
  }
1552
+ /** Synchronous sink state for diagnostics: whether delivery is async and how
1553
+ * many events are queued behind the microtask flush. A growing queue under
1554
+ * `asyncSink: true` is the first sign of sink back-pressure. */
1555
+ getSinkState() {
1556
+ return { asyncSink: this.asyncSink, pendingEvents: this.pendingEvents.length };
1557
+ }
1222
1558
  /** Record an instantaneous trace event (lifecycle, status, error, etc.). */
1223
1559
  event(event) {
1224
- if (!this.enabled || this.mode === "metrics") return;
1560
+ if (!this.enabled || this.mode === TRACE_MODE.METRICS) return;
1225
1561
  this.emit({ ...event, timestamp: this.now() });
1226
1562
  }
1563
+ /** Synchronous snapshot of the current metrics window without resetting it.
1564
+ * Returns the same derived counters as a periodic `message_metrics` event,
1565
+ * or null when metrics recording is inactive (disabled or events-only mode).
1566
+ * The window keeps accumulating until the next interval flush. */
1567
+ getMetrics() {
1568
+ if (!this.metricsActive || this.stopped) return null;
1569
+ const timestamp = this.now();
1570
+ const samples = this.latencySamples;
1571
+ return {
1572
+ durationMs: Math.max(0, timestamp - this.intervalStartedAt),
1573
+ received: this.received,
1574
+ dispatched: this.dispatched,
1575
+ topics: this.topics.size,
1576
+ dispatchSamples: samples,
1577
+ dispatchAvgMs: roundMs(samples === 0 ? 0 : this.latencySumMs / samples),
1578
+ dispatchP50Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.5)),
1579
+ dispatchP95Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.95)),
1580
+ dispatchMaxMs: roundMs(percentileMs(this.latencyBuckets, samples, 1)),
1581
+ dedupAccepted: this.dedupAccepted,
1582
+ dedupSuppressed: this.dedupSuppressed,
1583
+ timestamp
1584
+ };
1585
+ }
1227
1586
  /** Record that a message was received on `topic`; stores its timestamp for latency tracking. */
1228
1587
  recordReceived(topic) {
1229
1588
  if (!this.metricsActive) return;
@@ -1281,7 +1640,7 @@ var DataBusTraceReporter = class {
1281
1640
  * Extracted so the four record / flush methods share one guard expression
1282
1641
  * instead of repeating `!this.enabled || this.mode === 'events'` at each. */
1283
1642
  get metricsActive() {
1284
- return this.enabled && this.mode !== "events";
1643
+ return this.enabled && this.mode !== TRACE_MODE.EVENTS;
1285
1644
  }
1286
1645
  /** Emit the accumulated metrics snapshot if the interval is active. */
1287
1646
  flush() {
@@ -1293,7 +1652,7 @@ var DataBusTraceReporter = class {
1293
1652
  if (this.received > 0 || this.dispatched > 0 || this.dedupAccepted > 0 || this.dedupSuppressed > 0) {
1294
1653
  const samples = this.latencySamples;
1295
1654
  this.emit({
1296
- type: "message_metrics",
1655
+ type: TRACE_EVENT_TYPE.MESSAGE_METRICS,
1297
1656
  durationMs: Math.max(0, timestamp - this.intervalStartedAt),
1298
1657
  received: this.received,
1299
1658
  dispatched: this.dispatched,
@@ -1344,7 +1703,7 @@ var DataBusTraceReporter = class {
1344
1703
  this.sink(event);
1345
1704
  } catch (error) {
1346
1705
  if (typeof console !== "undefined" && typeof console.warn === "function") {
1347
- console.warn("[cross-tab-worker-databus] trace sink threw:", error);
1706
+ console.warn(`[${DEFAULT_STORAGE_PREFIX}] trace sink threw:`, error);
1348
1707
  }
1349
1708
  }
1350
1709
  }
@@ -1370,16 +1729,484 @@ function roundMs(value) {
1370
1729
  return Math.round(value * 10) / 10;
1371
1730
  }
1372
1731
 
1373
- // src/core/data-bus.ts
1374
- var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
1375
- var SDK_VERSION = "0.20.69";
1376
- var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
1732
+ // src/core/replay-pruning.ts
1733
+ function pruneReplayHistory(messages, options) {
1734
+ const { maxPerTopic, pruneStrategy, retentionMs, now } = options;
1735
+ const ageEnabled = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
1736
+ if (!ageEnabled) {
1737
+ return messages.length > maxPerTopic ? messages.slice(-maxPerTopic) : messages;
1738
+ }
1739
+ const cutoff = now - retentionMs;
1740
+ let hasExpired = false;
1741
+ let timestamplessCount = 0;
1742
+ for (const message of messages) {
1743
+ if (message.timestamp === void 0) timestamplessCount += 1;
1744
+ else if (message.timestamp < cutoff) hasExpired = true;
1745
+ }
1746
+ let pruned = hasExpired ? messages.filter((message) => message.timestamp === void 0 || message.timestamp >= cutoff) : messages;
1747
+ if (pruneStrategy === PRUNE_STRATEGY.BOTH) {
1748
+ return pruned.length > maxPerTopic ? pruned.slice(-maxPerTopic) : pruned;
1749
+ }
1750
+ if (timestamplessCount <= maxPerTopic) return pruned;
1751
+ let timestamplessToDrop = timestamplessCount - maxPerTopic;
1752
+ pruned = pruned.filter((message) => {
1753
+ if (message.timestamp === void 0 && timestamplessToDrop > 0) {
1754
+ timestamplessToDrop -= 1;
1755
+ return false;
1756
+ }
1757
+ return true;
1758
+ });
1759
+ return pruned;
1760
+ }
1761
+
1762
+ // src/core/replay-manager.ts
1377
1763
  var PersistenceRetryCancelledError = class extends Error {
1378
1764
  constructor() {
1379
1765
  super("Persistence retry cancelled by lifecycle transition.");
1380
1766
  this.name = "PersistenceRetryCancelledError";
1381
1767
  }
1382
1768
  };
1769
+ var MAX_RETRY_DELAY_MS2 = 1600;
1770
+ var ReplayManager = class {
1771
+ constructor(deps) {
1772
+ this.deps = deps;
1773
+ this.buffers = deps.enabled ? /* @__PURE__ */ new Map() : null;
1774
+ this.maxPerTopic = deps.maxPerTopic;
1775
+ this.persistence = deps.persistence ?? null;
1776
+ this.retentionMs = deps.retentionMs;
1777
+ this.pruneStrategy = deps.pruneStrategy;
1778
+ this.retentionSweepMs = deps.retentionSweepMs;
1779
+ this.persistenceRetryMaxAttempts = deps.persistenceRetryMaxAttempts;
1780
+ this.persistenceRetryBackoffMs = deps.persistenceRetryBackoffMs;
1781
+ this.now = deps.now;
1782
+ this.trace = deps.trace;
1783
+ this.onPersistenceError = deps.onPersistenceError;
1784
+ this.onDispatchError = deps.onDispatchError;
1785
+ this.hydration = this.hydrate();
1786
+ }
1787
+ deps;
1788
+ buffers;
1789
+ maxPerTopic;
1790
+ persistence;
1791
+ retentionMs;
1792
+ pruneStrategy;
1793
+ retentionSweepMs;
1794
+ persistenceRetryMaxAttempts;
1795
+ persistenceRetryBackoffMs;
1796
+ now;
1797
+ trace;
1798
+ onPersistenceError;
1799
+ onDispatchError;
1800
+ /** Bumped on suspend/stop so in-flight persistence retries are cancelled. */
1801
+ retryGeneration = 0;
1802
+ pendingReplayPersistence = [];
1803
+ persistenceFlushScheduled = false;
1804
+ hydration;
1805
+ /** Coalesced retention cleanup: the newest cutoff wins while one is running. */
1806
+ retentionCleanup = null;
1807
+ retentionCutoff = null;
1808
+ retentionTimer = null;
1809
+ /** True when replay buffering is enabled. */
1810
+ get enabled() {
1811
+ return this.buffers !== null;
1812
+ }
1813
+ /** Append a dispatched publication to the topic's replay ring buffer.
1814
+ * No-op when replay is disabled. */
1815
+ record(message) {
1816
+ if (!this.buffers) return;
1817
+ let buffer = this.buffers.get(message.topic);
1818
+ if (!buffer) {
1819
+ buffer = [];
1820
+ this.buffers.set(message.topic, buffer);
1821
+ }
1822
+ buffer.push(message);
1823
+ const pruned = pruneReplayHistory(buffer, {
1824
+ maxPerTopic: this.maxPerTopic,
1825
+ pruneStrategy: this.pruneStrategy,
1826
+ retentionMs: this.retentionMs,
1827
+ now: this.now()
1828
+ });
1829
+ if (pruned !== buffer) {
1830
+ buffer = pruned;
1831
+ this.buffers.set(message.topic, buffer);
1832
+ }
1833
+ if (!this.persistence) return;
1834
+ if (this.persistence.appendBatch) {
1835
+ this.pendingReplayPersistence.push(message);
1836
+ this.schedulePersistenceFlush();
1837
+ } else {
1838
+ void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.append(message)).catch((error) => this.onPersistenceError(error));
1839
+ }
1840
+ if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
1841
+ this.scheduleRetentionCleanup(this.now() - this.retentionMs);
1842
+ }
1843
+ }
1844
+ /** Deliver buffered history to a newly-registered handler. For an exact
1845
+ * topic this is that topic's ring; for a wildcard subscription every
1846
+ * buffered topic matching the pattern contributes (in buffer insertion
1847
+ * order). Replay deliveries are marked `replayed: true` and are not counted
1848
+ * into trace metrics.
1849
+ *
1850
+ * When a durable persistence backend is present, delivery waits for the
1851
+ * hydration load to settle first; `isHandlerActive` is then consulted so a
1852
+ * handler that unsubscribed during the async load does not receive history.
1853
+ */
1854
+ deliverReplay(topic, replayOption, handler, isHandlerActive) {
1855
+ if (!this.buffers) return;
1856
+ const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
1857
+ if (this.persistence) {
1858
+ void this.hydration.then(() => {
1859
+ if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
1860
+ });
1861
+ return;
1862
+ }
1863
+ this.deliver(topic, limit, handler);
1864
+ }
1865
+ /** Clean up a topic that lost its last local handler: drop the ring buffer,
1866
+ * filter queued batch flushes (so an in-flight append cannot undo the
1867
+ * clearTopic), and prune durable history. */
1868
+ onTopicUnsubscribed(topic) {
1869
+ if (!this.buffers) return;
1870
+ this.buffers.delete(topic);
1871
+ this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
1872
+ if (this.persistence?.clearTopic) {
1873
+ void this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)).catch((error) => this.onPersistenceError(error));
1874
+ }
1875
+ }
1876
+ /** Clear all in-memory replay buffers and, when supported, durable history.
1877
+ * Reports persistence failures and rethrows, mirroring the public API
1878
+ * contract that callers can observe a failed clear. */
1879
+ async clearAll() {
1880
+ if (!this.buffers) return;
1881
+ this.buffers.clear();
1882
+ this.pendingReplayPersistence = [];
1883
+ if (this.persistence?.clear) {
1884
+ try {
1885
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear());
1886
+ } catch (error) {
1887
+ this.onPersistenceError(error);
1888
+ throw error;
1889
+ }
1890
+ }
1891
+ }
1892
+ /** Clear replay history for one exact topic, including durable storage. */
1893
+ async clearTopic(topic) {
1894
+ if (!this.buffers) return;
1895
+ this.buffers.delete(topic);
1896
+ this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
1897
+ if (this.persistence?.clearTopic) {
1898
+ try {
1899
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic));
1900
+ } catch (error) {
1901
+ this.onPersistenceError(error);
1902
+ throw error;
1903
+ }
1904
+ }
1905
+ }
1906
+ /** Remove replay entries older than an epoch-millisecond cutoff. */
1907
+ async clearBefore(timestamp) {
1908
+ if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
1909
+ if (this.buffers) {
1910
+ for (const [topic, messages] of this.buffers) {
1911
+ const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
1912
+ if (kept.length) this.buffers.set(topic, kept);
1913
+ else this.buffers.delete(topic);
1914
+ }
1915
+ }
1916
+ this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
1917
+ (message) => message.timestamp === void 0 || message.timestamp >= timestamp
1918
+ );
1919
+ if (this.persistence?.clearBefore) {
1920
+ try {
1921
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp));
1922
+ } catch (error) {
1923
+ this.onPersistenceError(error);
1924
+ throw error;
1925
+ }
1926
+ }
1927
+ }
1928
+ /** Start the periodic retention sweep. No-op when no durable retention
1929
+ * config makes it necessary. */
1930
+ start() {
1931
+ if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
1932
+ this.retentionTimer = setInterval(() => {
1933
+ this.scheduleRetentionCleanup(this.now() - this.retentionMs);
1934
+ }, this.retentionSweepMs);
1935
+ }
1936
+ /** Stop the periodic retention sweep. */
1937
+ stop() {
1938
+ if (this.retentionTimer) clearInterval(this.retentionTimer);
1939
+ this.retentionTimer = null;
1940
+ }
1941
+ /** Suspend the manager: cancel in-flight persistence retries (so a hidden tab
1942
+ * or stopped bus does not keep hammering the store) and stop the sweep. */
1943
+ suspend() {
1944
+ this.retryGeneration += 1;
1945
+ this.stop();
1946
+ }
1947
+ /** Drop all in-memory buffers (used on full teardown). */
1948
+ resetBuffers() {
1949
+ this.buffers?.clear();
1950
+ }
1951
+ /** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
1952
+ * payload footprint (same heuristic as adaptive load weighting), computed on
1953
+ * demand so the hot append path never pays for it. */
1954
+ getStats() {
1955
+ let messages = 0;
1956
+ let bytes = 0;
1957
+ if (this.buffers) {
1958
+ for (const buffer of this.buffers.values()) {
1959
+ messages += buffer.length;
1960
+ for (const message of buffer) bytes += approximatePayloadBytes(message.data);
1961
+ }
1962
+ }
1963
+ return { enabled: this.enabled, topics: this.buffers?.size ?? 0, messages, bytes };
1964
+ }
1965
+ /** Deliver history from one topic's ring to a handler, isolating a throwing
1966
+ * handler so the remaining buffers are still delivered. */
1967
+ deliver(topic, limit, handler) {
1968
+ if (!this.buffers || limit <= 0) return;
1969
+ const deliverBuffer = (buffer2) => {
1970
+ for (const message of buffer2.slice(-limit)) {
1971
+ try {
1972
+ handler({ ...message, replayed: true });
1973
+ } catch (error) {
1974
+ this.onDispatchError(error);
1975
+ }
1976
+ }
1977
+ };
1978
+ if (isWildcardTopic(topic)) {
1979
+ for (const [bufferedTopic, buffer2] of this.buffers) {
1980
+ if (topicMatchesPattern(topic, bufferedTopic)) deliverBuffer(buffer2);
1981
+ }
1982
+ return;
1983
+ }
1984
+ const buffer = this.buffers.get(topic);
1985
+ if (buffer) deliverBuffer(buffer);
1986
+ }
1987
+ /** Coalesce queued persistence appends into a single microtask batch so a
1988
+ * burst of publications does not issue one IndexedDB transaction each.
1989
+ * Only reachable when the backend advertises `appendBatch` (the sole queuer,
1990
+ * `record()`, guards on it), so the batched path is unconditional here. */
1991
+ schedulePersistenceFlush() {
1992
+ if (this.persistenceFlushScheduled) return;
1993
+ this.persistenceFlushScheduled = true;
1994
+ queueMicrotask(() => {
1995
+ this.persistenceFlushScheduled = false;
1996
+ const batch = this.pendingReplayPersistence.splice(0);
1997
+ if (batch.length === 0 || !this.persistence) return;
1998
+ void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
1999
+ });
2000
+ }
2001
+ /** Load durable history into the in-memory rings once at startup, pruning
2002
+ * entries past the retention window first. Failures are reported but do not
2003
+ * block startup — the bus runs with whatever survived. */
2004
+ async hydrate() {
2005
+ if (!this.buffers || !this.persistence) {
2006
+ return;
2007
+ }
2008
+ try {
2009
+ if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
2010
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
2011
+ }
2012
+ const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
2013
+ for (const message of loaded) {
2014
+ let buffer = this.buffers.get(message.topic);
2015
+ if (!buffer) {
2016
+ buffer = [];
2017
+ this.buffers.set(message.topic, buffer);
2018
+ }
2019
+ buffer.push(message);
2020
+ }
2021
+ const hydrationNow = this.now();
2022
+ for (const [topic, buffer] of this.buffers) {
2023
+ const pruned = pruneReplayHistory(buffer, {
2024
+ maxPerTopic: this.maxPerTopic,
2025
+ pruneStrategy: this.pruneStrategy,
2026
+ retentionMs: this.retentionMs,
2027
+ now: hydrationNow
2028
+ });
2029
+ if (pruned !== buffer) this.buffers.set(topic, pruned);
2030
+ }
2031
+ } catch (error) {
2032
+ this.onPersistenceError(error);
2033
+ }
2034
+ }
2035
+ /** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
2036
+ * so a burst of publications issues at most one clearBefore transaction. */
2037
+ scheduleRetentionCleanup(cutoff) {
2038
+ if (!this.persistence?.clearBefore) return;
2039
+ if (this.retentionCutoff === null || cutoff > this.retentionCutoff) {
2040
+ this.retentionCutoff = cutoff;
2041
+ }
2042
+ if (this.retentionCleanup) return;
2043
+ this.retentionCleanup = (async () => {
2044
+ while (this.retentionCutoff !== null) {
2045
+ const nextCutoff = this.retentionCutoff;
2046
+ this.retentionCutoff = null;
2047
+ try {
2048
+ await this.persistence.clearBefore(nextCutoff);
2049
+ } catch (error) {
2050
+ this.onPersistenceError(error);
2051
+ }
2052
+ }
2053
+ })().finally(() => {
2054
+ this.retentionCleanup = null;
2055
+ if (this.retentionCutoff !== null) {
2056
+ this.scheduleRetentionCleanup(this.retentionCutoff);
2057
+ }
2058
+ });
2059
+ }
2060
+ /** Run a persistence operation with exponential backoff on transient failure.
2061
+ * Bumped `retryGeneration` (suspend/stop) cancels the loop early; a
2062
+ * structurally failing operation throws after `persistenceRetryMaxAttempts`,
2063
+ * leaving the ring buffer intact so the bus keeps working. */
2064
+ async withPersistenceRetry(persistenceOperation, operation) {
2065
+ const generation = this.retryGeneration;
2066
+ let attempt = 0;
2067
+ let delay = this.persistenceRetryBackoffMs;
2068
+ while (true) {
2069
+ attempt += 1;
2070
+ try {
2071
+ if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
2072
+ return await operation();
2073
+ } catch (error) {
2074
+ if (error instanceof PersistenceRetryCancelledError || generation !== this.retryGeneration) {
2075
+ throw new PersistenceRetryCancelledError();
2076
+ }
2077
+ if (attempt >= this.persistenceRetryMaxAttempts) throw error;
2078
+ this.trace.event({
2079
+ type: TRACE_EVENT_TYPE.RELIABILITY,
2080
+ operation: RELIABILITY_OPERATION.PERSISTENCE_RETRY,
2081
+ persistenceOperation,
2082
+ attempt
2083
+ });
2084
+ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
2085
+ if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
2086
+ delay = Math.min(delay * 2, MAX_RETRY_DELAY_MS2);
2087
+ }
2088
+ }
2089
+ }
2090
+ };
2091
+
2092
+ // src/core/dedup-manager.ts
2093
+ var ADAPTIVE_WINDOW_MS = 5e3;
2094
+ var QUIET_RATE_PER_MS = 0.01;
2095
+ var DedupManager = class {
2096
+ enabled;
2097
+ maxEntries;
2098
+ ttlMs;
2099
+ adaptiveBounds;
2100
+ sweepMs;
2101
+ now;
2102
+ trace;
2103
+ seenMessageIds = /* @__PURE__ */ new Map();
2104
+ sweepTimer = null;
2105
+ windowStartedAt = 0;
2106
+ windowAccepted = 0;
2107
+ suppressed = 0;
2108
+ accepted = 0;
2109
+ constructor(options) {
2110
+ this.enabled = options.enabled;
2111
+ this.maxEntries = options.maxEntries;
2112
+ this.ttlMs = options.ttlMs;
2113
+ this.adaptiveBounds = options.adaptiveBounds;
2114
+ this.sweepMs = options.sweepMs;
2115
+ this.now = options.now;
2116
+ this.trace = options.trace;
2117
+ this.windowStartedAt = this.now();
2118
+ }
2119
+ /** True when a publication carrying `messageId` was already seen. Records the
2120
+ * ID and updates counters/trace on acceptance. Disabled or ID-less messages
2121
+ * always pass through. */
2122
+ isDuplicate(messageId, topic) {
2123
+ if (!this.enabled || !messageId) return false;
2124
+ const now = this.now();
2125
+ const ttlMs = this.currentTtl();
2126
+ for (const [id, timestamp] of this.seenMessageIds) {
2127
+ if (now - timestamp > ttlMs) this.seenMessageIds.delete(id);
2128
+ }
2129
+ if (this.seenMessageIds.has(messageId)) {
2130
+ this.suppressed += 1;
2131
+ this.trace.event({
2132
+ type: TRACE_EVENT_TYPE.RELIABILITY,
2133
+ operation: RELIABILITY_OPERATION.DEDUP_SUPPRESSED,
2134
+ topic
2135
+ });
2136
+ this.trace.recordDedupSuppressed();
2137
+ return true;
2138
+ }
2139
+ this.seenMessageIds.set(messageId, now);
2140
+ this.accepted += 1;
2141
+ this.windowAccepted += 1;
2142
+ this.trace.recordDedupAccepted();
2143
+ while (this.seenMessageIds.size > this.maxEntries) {
2144
+ const oldest = this.seenMessageIds.keys().next().value;
2145
+ if (oldest === void 0) break;
2146
+ this.seenMessageIds.delete(oldest);
2147
+ }
2148
+ return false;
2149
+ }
2150
+ /** Start the periodic expiry sweep. No-op when disabled or no sweepMs was
2151
+ * configured (the hot-path opportunistic expiry still bounds the map). */
2152
+ start() {
2153
+ if (this.sweepTimer || !this.enabled || !this.sweepMs) return;
2154
+ this.sweepTimer = setInterval(() => this.pruneExpired(), this.sweepMs);
2155
+ }
2156
+ /** Stop the periodic expiry sweep. */
2157
+ stop() {
2158
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
2159
+ this.sweepTimer = null;
2160
+ }
2161
+ /** Return bounded deduplication counters for diagnostics and health checks. */
2162
+ getStats() {
2163
+ return {
2164
+ enabled: this.enabled,
2165
+ tracked: this.seenMessageIds.size,
2166
+ suppressed: this.suppressed,
2167
+ accepted: this.accepted,
2168
+ ...this.adaptiveBounds ? { ttlMs: this.currentTtl() } : {}
2169
+ };
2170
+ }
2171
+ /** Drop all remembered IDs and reset dedup counters. */
2172
+ reset() {
2173
+ this.seenMessageIds.clear();
2174
+ this.suppressed = 0;
2175
+ this.accepted = 0;
2176
+ this.windowStartedAt = this.now();
2177
+ this.windowAccepted = 0;
2178
+ }
2179
+ /** Remove IDs whose timestamp predates the effective TTL cutoff. */
2180
+ pruneExpired() {
2181
+ const cutoff = this.now() - this.currentTtl();
2182
+ for (const [id, timestamp] of this.seenMessageIds) {
2183
+ if (timestamp < cutoff) this.seenMessageIds.delete(id);
2184
+ }
2185
+ }
2186
+ /** Effective TTL: the fixed `ttlMs` unless adaptive bounds are configured, in
2187
+ * which case a higher recent message rate shortens the window (dedup only
2188
+ * needs to live long enough to bridge duplicate bursts). The window resets
2189
+ * every ADAPTIVE_WINDOW_MS. */
2190
+ currentTtl() {
2191
+ if (!this.adaptiveBounds) return this.ttlMs;
2192
+ const now = this.now();
2193
+ const elapsed = now - this.windowStartedAt;
2194
+ if (elapsed >= ADAPTIVE_WINDOW_MS) {
2195
+ this.windowStartedAt = now;
2196
+ this.windowAccepted = 0;
2197
+ return this.adaptiveBounds.maxMs;
2198
+ }
2199
+ const rate = this.windowAccepted / Math.max(1, elapsed);
2200
+ const factor = Math.min(1, rate / QUIET_RATE_PER_MS);
2201
+ return this.adaptiveBounds.maxMs - (this.adaptiveBounds.maxMs - this.adaptiveBounds.minMs) * factor;
2202
+ }
2203
+ };
2204
+
2205
+ // src/core/version.ts
2206
+ var SDK_VERSION = true ? "0.20.86" : "";
2207
+
2208
+ // src/core/data-bus.ts
2209
+ var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
1383
2210
  var CrossTabDataBus = class {
1384
2211
  transport;
1385
2212
  cluster;
@@ -1390,43 +2217,14 @@ var CrossTabDataBus = class {
1390
2217
  transportSubscribedTopics = /* @__PURE__ */ new Set();
1391
2218
  statusHandlers = /* @__PURE__ */ new Set();
1392
2219
  errorHandlers = /* @__PURE__ */ new Set();
1393
- // Bounded per-topic ring of recent dispatched publications. Null unless
1394
- // replay is enabled — buffering is opt-in and must cost nothing otherwise.
1395
- replayBuffers;
1396
- replayMaxPerTopic;
1397
- replayPersistence;
1398
- replayRetentionMs;
1399
- replayPruneStrategy;
1400
- replayRetentionSweepMs;
1401
- persistenceRetryMaxAttempts;
1402
- persistenceRetryBackoffMs;
1403
- persistenceRetryGeneration = 0;
1404
- pendingReplayPersistence = [];
1405
- replayPersistenceFlushScheduled = false;
1406
- replayHydration;
1407
- // Retention cleanup is coalesced so a burst of publications does not issue
1408
- // one IndexedDB read/write transaction per message. The newest cutoff wins.
1409
- replayRetentionCleanup = null;
1410
- replayRetentionCutoff = null;
1411
- replayRetentionTimer = null;
2220
+ replayManager;
1412
2221
  initialConfig;
1413
2222
  hasInitialConfig;
1414
2223
  trace;
1415
- dedupMaxEntries;
1416
- dedupTtlMs;
1417
- dedupAdaptiveBounds;
1418
- dedupWindowStartedAt = 0;
1419
- dedupWindowAccepted = 0;
1420
- dedupAdaptiveWindowMs = 5e3;
1421
- dedupSweepMs;
1422
- dedupSweepTimer = null;
1423
- dedupEnabled;
2224
+ dedupManager;
1424
2225
  now;
1425
- seenMessageIds = /* @__PURE__ */ new Map();
1426
- dedupSuppressed = 0;
1427
- dedupAccepted = 0;
1428
2226
  activeConfig;
1429
- status = "disconnected";
2227
+ status = WORKER_STATUS.DISCONNECTED;
1430
2228
  started = false;
1431
2229
  stopping = false;
1432
2230
  transportReady = false;
@@ -1434,9 +2232,33 @@ var CrossTabDataBus = class {
1434
2232
  // never awaited start() directly. Cleared on the next successful start.
1435
2233
  lastError = null;
1436
2234
  lastErrorAt = null;
2235
+ // Unified failure ledger for the health summary: the most recent failure of
2236
+ // any source (transport, persistence, dispatch) since the last explicit start.
2237
+ lastFailure = null;
2238
+ persistenceFailureCount = 0;
2239
+ persistenceLastFailureAt = null;
2240
+ persistenceLastErrorMessage = null;
1437
2241
  // Gate that serialises start/stop/suspend/resume — only one lifecycle
1438
2242
  // transition at a time. Resets to null once the operation settles.
1439
2243
  startPromise = null;
2244
+ // Gate for an explicit stop(). Concurrent stop() calls share it, and a
2245
+ // start() received while stopping chains a fresh start after it.
2246
+ stopPromise = null;
2247
+ // A start() requested while an explicit stop() is still settling. Kept
2248
+ // separate from startPromise because stop()'s finally block clears the
2249
+ // ordinary lifecycle gate before the queued start is allowed to run.
2250
+ queuedStart = null;
2251
+ // Lazy readiness view of queuedStart. start() keeps its documented
2252
+ // resolve-on-cancellation contract, while ready() must reject when the
2253
+ // queued intent was superseded by a later stop().
2254
+ queuedStartReady = null;
2255
+ queuedStartReadyToken = 0;
2256
+ // The queued continuation is chained to the stop promise and cannot be
2257
+ // un-scheduled once scheduled. A later stop() therefore invalidates the
2258
+ // current intent by recording its token; a subsequent start() issues a
2259
+ // higher token so the latest lifecycle request still wins.
2260
+ queuedStartToken = 0;
2261
+ canceledQueuedStartToken = 0;
1440
2262
  // Timestamp of the last automatic transport recovery attempt.
1441
2263
  // Used to avoid a tight retry loop when the transport fails repeatedly.
1442
2264
  lastRecoveryAt = 0;
@@ -1459,71 +2281,48 @@ var CrossTabDataBus = class {
1459
2281
  // surfaces a failure while later opens and automatic recovery wait for the
1460
2282
  // stop to settle.
1461
2283
  pendingStop = null;
2284
+ // Ownership token for asynchronous transport opens. Every lifecycle
2285
+ // transition invalidates callbacks and failure cleanup from older opens.
2286
+ lifecycleEpoch = 0;
1462
2287
  // Minimum interval in ms between automatic recovery attempts.
1463
2288
  recoveryCooldownMs;
1464
2289
  recoveryMaxAttempts;
1465
2290
  constructor(options) {
1466
2291
  const replay = options.replay;
1467
- if (replay) {
1468
- const maxPerTopic = replay.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1469
- if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
1470
- throw new TypeError(
1471
- `replay.maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`
1472
- );
1473
- }
1474
- }
1475
- this.replayMaxPerTopic = replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1476
- this.replayBuffers = replay ? /* @__PURE__ */ new Map() : null;
1477
- this.replayPersistence = replay?.persistence ?? null;
1478
- this.replayRetentionMs = replay?.retentionMs;
1479
- this.replayPruneStrategy = replay?.pruneStrategy ?? "count";
1480
- if (!["count", "age", "both"].includes(this.replayPruneStrategy)) throw new TypeError("replay.pruneStrategy must be count, age, or both.");
1481
- if (this.replayRetentionMs !== void 0 && (!Number.isFinite(this.replayRetentionMs) || this.replayRetentionMs <= 0)) {
1482
- throw new TypeError("replay.retentionMs must be a positive finite number.");
1483
- }
1484
- this.replayRetentionSweepMs = replay?.retentionSweepMs;
1485
- if (this.replayRetentionSweepMs !== void 0 && (!Number.isFinite(this.replayRetentionSweepMs) || this.replayRetentionSweepMs <= 0)) {
1486
- throw new TypeError("replay.retentionSweepMs must be a positive finite number.");
1487
- }
1488
- this.persistenceRetryMaxAttempts = replay?.persistenceRetry?.maxAttempts ?? 1;
1489
- this.persistenceRetryBackoffMs = replay?.persistenceRetry?.backoffMs ?? 50;
1490
- if (!Number.isSafeInteger(this.persistenceRetryMaxAttempts) || this.persistenceRetryMaxAttempts <= 0) {
1491
- throw new TypeError("replay.persistenceRetry.maxAttempts must be a positive safe integer.");
1492
- }
1493
- if (!Number.isFinite(this.persistenceRetryBackoffMs) || this.persistenceRetryBackoffMs < 0) {
1494
- throw new TypeError("replay.persistenceRetry.backoffMs must be a non-negative finite number.");
1495
- }
2292
+ assertReplayOptions(replay);
1496
2293
  const { autoStart, initialConfig, trace, transport, dedup, recovery, ...clusterOptions } = options;
2294
+ assertRecoveryOptions(recovery);
1497
2295
  this.recoveryCooldownMs = recovery?.cooldownMs ?? 1e3;
1498
2296
  this.recoveryMaxAttempts = recovery?.maxAttempts ?? Number.POSITIVE_INFINITY;
1499
- if (!Number.isFinite(this.recoveryCooldownMs) || this.recoveryCooldownMs <= 0) {
1500
- throw new TypeError("recovery.cooldownMs must be a positive finite number.");
1501
- }
1502
- if (!(this.recoveryMaxAttempts === Number.POSITIVE_INFINITY || Number.isSafeInteger(this.recoveryMaxAttempts) && this.recoveryMaxAttempts > 0)) {
1503
- throw new TypeError("recovery.maxAttempts must be a positive safe integer.");
1504
- }
1505
2297
  this.now = dedup?.now ?? Date.now;
1506
- this.replayHydration = this.hydrateReplay();
1507
2298
  this.transport = transport;
1508
2299
  this.initialConfig = initialConfig;
1509
2300
  this.hasInitialConfig = "initialConfig" in options;
1510
2301
  this.trace = new DataBusTraceReporter(trace);
1511
- this.dedupMaxEntries = dedup?.maxEntries ?? 1e3;
1512
- this.dedupTtlMs = dedup?.ttlMs ?? 6e4;
1513
- this.dedupAdaptiveBounds = dedup?.adaptiveTtl;
1514
- if (this.dedupAdaptiveBounds && (this.dedupAdaptiveBounds.minMs <= 0 || this.dedupAdaptiveBounds.maxMs < this.dedupAdaptiveBounds.minMs)) throw new TypeError("dedup.adaptiveTtl bounds are invalid.");
1515
- this.dedupWindowStartedAt = this.now();
1516
- this.dedupSweepMs = dedup?.sweepMs;
1517
- this.dedupEnabled = dedup !== void 0;
1518
- if (!Number.isSafeInteger(this.dedupMaxEntries) || this.dedupMaxEntries <= 0) {
1519
- throw new TypeError("dedup.maxEntries must be a positive safe integer.");
1520
- }
1521
- if (!Number.isFinite(this.dedupTtlMs) || this.dedupTtlMs <= 0) {
1522
- throw new TypeError("dedup.ttlMs must be a positive finite number.");
1523
- }
1524
- if (this.dedupSweepMs !== void 0 && (!Number.isFinite(this.dedupSweepMs) || this.dedupSweepMs <= 0)) {
1525
- throw new TypeError("dedup.sweepMs must be a positive finite number.");
1526
- }
2302
+ this.replayManager = new ReplayManager({
2303
+ enabled: replay !== void 0,
2304
+ maxPerTopic: replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC,
2305
+ persistence: replay?.persistence ?? null,
2306
+ retentionMs: replay?.retentionMs,
2307
+ pruneStrategy: replay?.pruneStrategy ?? PRUNE_STRATEGY.COUNT,
2308
+ retentionSweepMs: replay?.retentionSweepMs,
2309
+ persistenceRetryMaxAttempts: replay?.persistenceRetry?.maxAttempts ?? 1,
2310
+ persistenceRetryBackoffMs: replay?.persistenceRetry?.backoffMs ?? 50,
2311
+ now: this.now,
2312
+ trace: this.trace,
2313
+ onPersistenceError: (error) => this.reportPersistenceError(error),
2314
+ onDispatchError: (error) => this.reportError(error, FAILURE_SOURCE.DISPATCH)
2315
+ });
2316
+ assertDedupOptions(dedup);
2317
+ this.dedupManager = new DedupManager({
2318
+ enabled: dedup !== void 0,
2319
+ maxEntries: dedup?.maxEntries ?? 1e3,
2320
+ ttlMs: dedup?.ttlMs ?? 6e4,
2321
+ adaptiveBounds: dedup?.adaptiveTtl,
2322
+ sweepMs: dedup?.sweepMs,
2323
+ now: this.now,
2324
+ trace: this.trace
2325
+ });
1527
2326
  this.cluster = new WorkerClusterRuntime({
1528
2327
  ...clusterOptions,
1529
2328
  handlers: {
@@ -1531,26 +2330,36 @@ var CrossTabDataBus = class {
1531
2330
  // control message — meaning the owning Worker has delegated the action to us.
1532
2331
  onControl: (action, topic, data, messageId, timestamp) => {
1533
2332
  switch (action) {
1534
- case "SUBSCRIBE":
1535
- if (this.subscribeTransport(topic)) this.traceSubscription("subscribe", topic);
2333
+ case CONTROL_ACTION.SUBSCRIBE:
2334
+ if (this.subscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.SUBSCRIBE, topic);
1536
2335
  break;
1537
- case "UNSUBSCRIBE":
1538
- if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
2336
+ case CONTROL_ACTION.UNSUBSCRIBE:
2337
+ if (this.unsubscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.UNSUBSCRIBE, topic);
1539
2338
  break;
1540
- case "PUBLISH":
1541
- this.runTransport(() => this.transport.publish(
1542
- topic,
1543
- data,
1544
- messageId === void 0 && timestamp === void 0 ? void 0 : {
1545
- ...messageId === void 0 ? {} : { messageId },
1546
- ...timestamp === void 0 ? {} : { timestamp }
1547
- }
1548
- ));
2339
+ case CONTROL_ACTION.PUBLISH:
2340
+ this.runTransport(() => this.transport.publish(topic, data, publicationMetadata(messageId, timestamp)));
1549
2341
  break;
1550
2342
  default:
1551
2343
  break;
1552
2344
  }
1553
2345
  },
2346
+ // Batched variant of the PUBLISH action (CONTROL frames carrying
2347
+ // multiple items, and the local publishBatch fast path). Uses the
2348
+ // transport's one-frame publishBatch when available, preserving
2349
+ // per-item metadata; otherwise falls back to per-item publishes.
2350
+ onPublishBatch: (topic, items) => {
2351
+ if (typeof this.transport.publishBatch === "function") {
2352
+ this.runTransport(() => this.transport.publishBatch(topic, items));
2353
+ return;
2354
+ }
2355
+ for (const item of items) {
2356
+ this.runTransport(() => this.transport.publish(
2357
+ topic,
2358
+ item.data,
2359
+ publicationMetadata(item.messageId, item.timestamp)
2360
+ ));
2361
+ }
2362
+ },
1554
2363
  // The cluster calls `onEvent` when a publication broadcast arrives from
1555
2364
  // another tab. Dispatch locally if we have subscribers. The payload is
1556
2365
  // typed `unknown` at the cluster boundary (the cluster is transport-
@@ -1563,22 +2372,21 @@ var CrossTabDataBus = class {
1563
2372
  if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
1564
2373
  },
1565
2374
  onSuspend: () => {
1566
- if (!this.stopping) this.trace.event({ type: "lifecycle", action: "suspend" });
2375
+ if (!this.stopping) this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.SUSPEND });
1567
2376
  this.trace.pause();
1568
- this.persistenceRetryGeneration += 1;
2377
+ this.replayManager.suspend();
1569
2378
  this.stopDedupSweep();
1570
- this.stopReplayRetentionSweep();
1571
2379
  this.suspendTransport();
1572
2380
  },
1573
2381
  onResume: () => {
1574
- this.trace.event({ type: "lifecycle", action: "resume" });
2382
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
1575
2383
  this.trace.start();
1576
2384
  this.startDedupSweep();
1577
- this.startReplayRetentionSweep();
2385
+ this.replayManager.start();
1578
2386
  this.resumeTransport();
1579
2387
  },
1580
2388
  onDiagnostic: (event) => {
1581
- this.trace.event({ type: "reliability", ...event });
2389
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, ...event });
1582
2390
  }
1583
2391
  }
1584
2392
  });
@@ -1588,40 +2396,51 @@ var CrossTabDataBus = class {
1588
2396
  * Start the DataBus with the given transport config.
1589
2397
  *
1590
2398
  * The first call starts the cluster and opens the transport. Concurrent calls
1591
- * during an in-flight start return the same promise. Once the operation
1592
- * settles (success or failure) the promise gate is cleared so a subsequent
1593
- * start() or resumeTransport() can open a fresh lifecycle.
2399
+ * during an in-flight open return the same promise. A call received while an
2400
+ * explicit stop() is settling queues one fresh start after cleanup; a later
2401
+ * stop() before that queued start runs cancels it, so the latest lifecycle
2402
+ * intent wins. Once an operation settles (success or failure) its promise
2403
+ * gate is cleared so a subsequent start() or resumeTransport() can open a
2404
+ * fresh lifecycle.
1594
2405
  */
1595
2406
  start(config) {
1596
- if (this.startPromise) return this.startPromise;
1597
- if (this.started) return Promise.resolve();
2407
+ if (this.queuedStart) return this.queuedStart;
2408
+ if (this.stopping) return this.queueStartAfterStop(config);
2409
+ if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
2410
+ if (this.started) {
2411
+ const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2412
+ if (!transportDown) return Promise.resolve();
2413
+ this.activeConfig = config;
2414
+ this.resetFailureState();
2415
+ return this.reopenTransport();
2416
+ }
1598
2417
  this.started = true;
1599
2418
  this.stopping = false;
1600
2419
  this.suspended = false;
1601
2420
  this.activeConfig = config;
1602
- this.lastError = null;
1603
- this.trace.event({ type: "lifecycle", action: "start" });
2421
+ this.resetFailureState();
2422
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
1604
2423
  this.trace.start();
1605
2424
  this.startDedupSweep();
1606
- this.startReplayRetentionSweep();
1607
- this.updateStatus("connecting");
2425
+ this.replayManager.start();
2426
+ this.updateStatus(WORKER_STATUS.CONNECTING);
1608
2427
  this.cluster.start();
1609
- const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
2428
+ const lifecycleEpoch = ++this.lifecycleEpoch;
2429
+ const opening = this.openTransport(
2430
+ config,
2431
+ this.pendingStop ?? Promise.resolve(),
2432
+ true,
2433
+ lifecycleEpoch
2434
+ );
1610
2435
  this.startPromise = opening;
1611
2436
  for (const topic of this.topicHandlers.keys()) {
1612
2437
  this.cluster.subscribe(topic);
1613
2438
  }
1614
- const snapshot = this.cluster.getSnapshot();
1615
- this.trace.event({
1616
- type: "coordination",
1617
- coordinated: snapshot.coordinated,
1618
- activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
1619
- workers: snapshot.workers.map(formatWorkerTrace),
1620
- routes: snapshot.routes.map(formatRouteTrace)
1621
- });
1622
2439
  void opening.then(
1623
2440
  () => {
1624
- if (this.startPromise === opening) this.startPromise = null;
2441
+ if (this.startPromise !== opening) return;
2442
+ this.emitCoordinationTrace();
2443
+ this.startPromise = null;
1625
2444
  },
1626
2445
  () => {
1627
2446
  if (this.startPromise === opening) this.startPromise = null;
@@ -1629,25 +2448,81 @@ var CrossTabDataBus = class {
1629
2448
  );
1630
2449
  return opening;
1631
2450
  }
2451
+ /** Return a cancellation-aware readiness view of the current queued start. */
2452
+ getQueuedStartReady() {
2453
+ const queued = this.queuedStart;
2454
+ if (!queued) {
2455
+ return Promise.reject(new Error("No queued start is in flight."));
2456
+ }
2457
+ const token = this.queuedStartToken;
2458
+ if (this.queuedStartReady && this.queuedStartReadyToken === token) {
2459
+ return this.queuedStartReady;
2460
+ }
2461
+ this.queuedStartReadyToken = token;
2462
+ this.queuedStartReady = queued.then(() => {
2463
+ if (token <= this.canceledQueuedStartToken) {
2464
+ throw new Error(
2465
+ "CrossTabDataBus start was canceled by a later stop(); ready() cannot report readiness. Call start() again after stop() resolves."
2466
+ );
2467
+ }
2468
+ if (!this.started || !this.transportReady) {
2469
+ throw new Error("CrossTabDataBus restart completed without a ready transport.");
2470
+ }
2471
+ });
2472
+ return this.queuedStartReady;
2473
+ }
2474
+ /** Queue exactly one fresh start after an in-flight explicit stop settles. */
2475
+ queueStartAfterStop(config) {
2476
+ if (this.queuedStart) return this.queuedStart;
2477
+ const stop = this.stopPromise ?? Promise.resolve();
2478
+ const token = ++this.queuedStartToken;
2479
+ const queued = stop.catch(() => void 0).then(() => {
2480
+ if (this.queuedStart === queued) this.queuedStart = null;
2481
+ if (token <= this.canceledQueuedStartToken) return;
2482
+ return this.start(config);
2483
+ });
2484
+ this.queuedStart = queued;
2485
+ return queued;
2486
+ }
2487
+ /** Reset failure and recovery diagnostics for a new explicit start session. */
2488
+ resetFailureState() {
2489
+ this.lastError = null;
2490
+ this.lastErrorAt = null;
2491
+ this.lastFailure = null;
2492
+ this.persistenceFailureCount = 0;
2493
+ this.persistenceLastFailureAt = null;
2494
+ this.persistenceLastErrorMessage = null;
2495
+ this.recoveryAttempt = 0;
2496
+ this.recoveryExhausted = false;
2497
+ this.lastRecoveryAt = 0;
2498
+ }
1632
2499
  /**
1633
2500
  * Open the transport, chained after `before` to ensure lifecycle ordering.
1634
2501
  * When `stopClusterOnFailure` is true (initial start), a transport failure
1635
2502
  * tears down the cluster as well.
1636
2503
  */
1637
- openTransport(config, before, stopClusterOnFailure) {
2504
+ openTransport(config, before, stopClusterOnFailure, lifecycleEpoch) {
1638
2505
  this.transportReady = false;
1639
2506
  const chainedPendingStop = this.pendingStop;
2507
+ const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
1640
2508
  return before.catch(() => void 0).then(() => {
1641
- if (this.stopping || this.suspended) return;
2509
+ if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
1642
2510
  if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
1643
2511
  return Promise.resolve(
1644
2512
  this.transport.start(config, {
1645
- onMessage: (message) => this.handleTransportMessage(message),
1646
- onStatus: (status) => this.updateStatus(status),
1647
- onError: (error) => this.reportError(error)
2513
+ onMessage: (message) => {
2514
+ if (isCurrentLifecycle()) this.handleTransportMessage(message);
2515
+ },
2516
+ onStatus: (status) => {
2517
+ if (isCurrentLifecycle()) this.updateStatus(status);
2518
+ },
2519
+ onError: (error) => {
2520
+ if (isCurrentLifecycle()) this.reportError(error);
2521
+ }
1648
2522
  })
1649
2523
  ).then(() => {
1650
- if (this.status === "error") {
2524
+ if (!isCurrentLifecycle()) return;
2525
+ if (this.status === WORKER_STATUS.ERROR) {
1651
2526
  throw new Error("Transport failed during startup.");
1652
2527
  }
1653
2528
  if (!this.suspended && !this.stopping) {
@@ -1657,11 +2532,12 @@ var CrossTabDataBus = class {
1657
2532
  }
1658
2533
  });
1659
2534
  }).catch((error) => {
2535
+ if (!isCurrentLifecycle()) throw error;
1660
2536
  if (stopClusterOnFailure) this.started = false;
1661
2537
  if (!this.pendingStop) {
1662
2538
  this.pendingStop = this.createStopPromise();
1663
2539
  }
1664
- this.updateStatus("error");
2540
+ this.updateStatus(WORKER_STATUS.ERROR);
1665
2541
  this.reportError(error);
1666
2542
  this.lastError = error;
1667
2543
  this.lastErrorAt = this.now();
@@ -1671,16 +2547,26 @@ var CrossTabDataBus = class {
1671
2547
  this.cluster.stop();
1672
2548
  this.stopping = false;
1673
2549
  }
1674
- this.startPromise = null;
1675
2550
  throw error;
1676
2551
  });
1677
2552
  }
1678
2553
  /**
1679
2554
  * Await the DataBus to be fully started (lazy init when using initialConfig).
1680
2555
  * Returns a rejected promise when the transport has failed and no start is in
1681
- * flight — the caller can retry by calling start() or ready() again.
2556
+ * flight — the caller can retry by calling start() or ready() again. While an
2557
+ * explicit stop() is settling, this rejects unless a restart is queued behind
2558
+ * it; false readiness during teardown is never reported.
1682
2559
  */
1683
2560
  ready() {
2561
+ if (this.queuedStart) return this.getQueuedStartReady();
2562
+ if (this.stopping) {
2563
+ return Promise.reject(new Error(
2564
+ "CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
2565
+ ));
2566
+ }
2567
+ if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
2568
+ return Promise.reject(this.lastError);
2569
+ }
1684
2570
  try {
1685
2571
  this.ensureStarted();
1686
2572
  } catch (error) {
@@ -1696,9 +2582,18 @@ var CrossTabDataBus = class {
1696
2582
  /**
1697
2583
  * Register a handler for `topic`. The handler fires on every publication
1698
2584
  * delivered to this tab, regardless of which tab published it. Returns an
1699
- * unsubscribe function for convenience.
2585
+ * unsubscribe function for convenience. During an explicit stop() the
2586
+ * registration is rejected through onError and a no-op cleanup is returned,
2587
+ * so a late subscriber cannot leak into a future restart.
1700
2588
  */
1701
2589
  subscribe(topic, handler, options) {
2590
+ if (this.stopping) {
2591
+ this.reportError(new Error(
2592
+ "CrossTabDataBus is stopping; subscribe() was not registered. Wait for stop() to resolve, then call start() before subscribing again."
2593
+ ));
2594
+ return () => {
2595
+ };
2596
+ }
1702
2597
  this.ensureStarted();
1703
2598
  const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
1704
2599
  const wasUnused = handlers.size === 0;
@@ -1706,17 +2601,12 @@ var CrossTabDataBus = class {
1706
2601
  this.topicHandlers.set(topic, handlers);
1707
2602
  if (wasUnused) this.cluster.subscribe(topic);
1708
2603
  if (options?.replay) {
1709
- const limit = Math.min(
1710
- typeof options.replay === "number" ? Math.floor(options.replay) : this.replayMaxPerTopic,
1711
- this.replayMaxPerTopic
2604
+ this.replayManager.deliverReplay(
2605
+ topic,
2606
+ options.replay,
2607
+ handler,
2608
+ () => Boolean(this.topicHandlers.get(topic)?.has(handler))
1712
2609
  );
1713
- if (this.replayPersistence) {
1714
- void this.replayHydration.then(() => {
1715
- if (this.topicHandlers.get(topic)?.has(handler)) this.deliverReplay(topic, limit, handler);
1716
- });
1717
- } else {
1718
- this.deliverReplay(topic, limit, handler);
1719
- }
1720
2610
  }
1721
2611
  return () => this.unsubscribe(topic, handler);
1722
2612
  }
@@ -1731,74 +2621,33 @@ var CrossTabDataBus = class {
1731
2621
  else handlers.clear();
1732
2622
  if (handlers.size > 0) return;
1733
2623
  this.topicHandlers.delete(topic);
1734
- this.replayBuffers?.delete(topic);
1735
- if (this.replayPersistence?.clearTopic) {
1736
- void this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic)).catch((error) => this.reportPersistenceError(error));
1737
- }
2624
+ this.replayManager.onTopicUnsubscribed(topic);
1738
2625
  this.cluster.unsubscribe(topic);
1739
2626
  }
1740
2627
  /** Clear all in-memory replay buffers and, when supported, durable history. */
1741
2628
  async clearReplay() {
1742
- this.replayBuffers?.clear();
1743
- if (this.replayPersistence?.clear) {
1744
- try {
1745
- await this.withPersistenceRetry("clear", () => this.replayPersistence.clear());
1746
- } catch (error) {
1747
- this.reportPersistenceError(error);
1748
- throw error;
1749
- }
1750
- }
2629
+ await this.replayManager.clearAll();
1751
2630
  }
1752
2631
  /** Clear replay history for one exact topic, including durable storage. */
1753
2632
  async clearReplayTopic(topic) {
1754
- this.replayBuffers?.delete(topic);
1755
- if (this.replayPersistence?.clearTopic) {
1756
- try {
1757
- await this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic));
1758
- } catch (error) {
1759
- this.reportPersistenceError(error);
1760
- throw error;
1761
- }
1762
- }
2633
+ await this.replayManager.clearTopic(topic);
1763
2634
  }
1764
2635
  /** Remove replay entries older than an epoch-millisecond cutoff. */
1765
2636
  async clearReplayBefore(timestamp) {
1766
- if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
1767
- if (this.replayBuffers) {
1768
- for (const [topic, messages] of this.replayBuffers) {
1769
- const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
1770
- if (kept.length) this.replayBuffers.set(topic, kept);
1771
- else this.replayBuffers.delete(topic);
1772
- }
1773
- }
1774
- if (this.replayPersistence?.clearBefore) {
1775
- try {
1776
- await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(timestamp));
1777
- } catch (error) {
1778
- this.reportPersistenceError(error);
1779
- throw error;
1780
- }
1781
- }
2637
+ await this.replayManager.clearBefore(timestamp);
1782
2638
  }
1783
2639
  /** Return bounded deduplication counters for diagnostics and health checks. */
1784
2640
  getDedupStats() {
1785
- return {
1786
- enabled: this.dedupEnabled,
1787
- tracked: this.seenMessageIds.size,
1788
- suppressed: this.dedupSuppressed,
1789
- accepted: this.dedupAccepted,
1790
- ...this.dedupAdaptiveBounds ? { ttlMs: this.currentDedupTtl() } : {}
1791
- };
2641
+ return this.dedupManager.getStats();
1792
2642
  }
1793
2643
  /** Drop all remembered IDs and reset dedup counters. */
1794
2644
  resetDedup() {
1795
- this.seenMessageIds.clear();
1796
- this.dedupSuppressed = 0;
1797
- this.dedupAccepted = 0;
2645
+ this.dedupManager.reset();
1798
2646
  }
1799
2647
  /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
1800
2648
  publish(topic, data, options) {
1801
2649
  this.ensureStarted();
2650
+ if (this.rejectPublishDuringStop("publish")) return;
1802
2651
  if (!this.cluster.publish(topic, data, options)) {
1803
2652
  this.reportError(
1804
2653
  new Error("Failed to send the publish control message to the owning worker.")
@@ -1815,6 +2664,7 @@ var CrossTabDataBus = class {
1815
2664
  publishBatch(topic, items) {
1816
2665
  this.ensureStarted();
1817
2666
  if (items.length === 0) return;
2667
+ if (this.rejectPublishDuringStop("publishBatch")) return;
1818
2668
  if (items.length === 1) {
1819
2669
  const first = items[0];
1820
2670
  this.publish(topic, first.data, first.options);
@@ -1822,8 +2672,7 @@ var CrossTabDataBus = class {
1822
2672
  }
1823
2673
  const mapped = items.map((item) => ({
1824
2674
  data: item.data,
1825
- ...item.options?.messageId !== void 0 ? { messageId: item.options.messageId } : {},
1826
- ...item.options?.timestamp !== void 0 ? { timestamp: item.options.timestamp } : {}
2675
+ ...publicationMetadata(item.options?.messageId, item.options?.timestamp)
1827
2676
  }));
1828
2677
  if (!this.cluster.publishBatch(topic, mapped)) {
1829
2678
  this.reportError(
@@ -1868,6 +2717,41 @@ var CrossTabDataBus = class {
1868
2717
  lastSuccessAt: this.lastSuccessAt
1869
2718
  };
1870
2719
  }
2720
+ /** Bounded failure counters for the replay persistence backend. */
2721
+ getPersistenceStats() {
2722
+ return {
2723
+ failures: this.persistenceFailureCount,
2724
+ lastFailureAt: this.persistenceLastFailureAt,
2725
+ lastErrorMessage: this.persistenceLastErrorMessage
2726
+ };
2727
+ }
2728
+ /** Compact health verdict for dashboards, readiness probes, and support
2729
+ * bundles. Answers "is the bus usable right now" first, then attaches the
2730
+ * unified failure ledger and recovery context that explains the verdict. */
2731
+ getHealthSummary() {
2732
+ const transport = this.transport;
2733
+ const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2734
+ 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;
2735
+ return {
2736
+ healthy: state === HEALTH_STATE.HEALTHY,
2737
+ state,
2738
+ status: this.status,
2739
+ sdkVersion: SDK_VERSION,
2740
+ started: this.started,
2741
+ suspended: this.suspended,
2742
+ transport: {
2743
+ name: transport.diagnosticsName ?? transport.constructor.name,
2744
+ backend: transport.diagnosticsBackend ?? null,
2745
+ ready: this.transportReady,
2746
+ status: this.status
2747
+ },
2748
+ recovery: this.getRecoveryStats(),
2749
+ lastFailure: this.lastFailure,
2750
+ persistence: this.getPersistenceStats(),
2751
+ metrics: this.trace.getMetrics(),
2752
+ trace: this.trace.getSinkState()
2753
+ };
2754
+ }
1871
2755
  /** Snapshot of the cluster state (workers, routes, assignments).
1872
2756
  * For diagnostics only — the returned object is a shallow copy but
1873
2757
  * nested arrays are snapshots at call time. */
@@ -1876,8 +2760,7 @@ var CrossTabDataBus = class {
1876
2760
  }
1877
2761
  /** Return a single health snapshot combining lifecycle, recovery, dedup, replay, and cluster state. */
1878
2762
  getDiagnostics() {
1879
- let messages = 0;
1880
- if (this.replayBuffers) for (const buffer of this.replayBuffers.values()) messages += buffer.length;
2763
+ const replay = this.replayManager.getStats();
1881
2764
  const cluster = this.cluster.getSnapshot();
1882
2765
  const unknownMessages = this.cluster.getUnknownMessageStats();
1883
2766
  const transport = this.transport;
@@ -1888,26 +2771,62 @@ var CrossTabDataBus = class {
1888
2771
  transportReady: this.transportReady,
1889
2772
  recovery: this.getRecoveryStats(),
1890
2773
  dedup: this.getDedupStats(),
1891
- replay: { enabled: Boolean(this.replayBuffers), topics: this.replayBuffers?.size ?? 0, messages },
2774
+ replay: { enabled: replay.enabled, topics: replay.topics, messages: replay.messages, bytes: replay.bytes },
2775
+ persistence: this.getPersistenceStats(),
1892
2776
  protocol: { version: cluster.protocolVersion, unknownMessages: unknownMessages.count, lastUnknownMessageType: unknownMessages.lastType, peers: cluster.peerProtocolVersions },
1893
- transport: { name: transport.diagnosticsName ?? transport.constructor.name, backend: transport.diagnosticsBackend ?? null },
1894
- cluster
2777
+ transport: {
2778
+ name: transport.diagnosticsName ?? transport.constructor.name,
2779
+ backend: transport.diagnosticsBackend ?? null,
2780
+ status: this.status,
2781
+ suspended: this.suspended
2782
+ },
2783
+ cluster,
2784
+ metrics: this.trace.getMetrics(),
2785
+ trace: this.trace.getSinkState()
1895
2786
  };
1896
2787
  }
2788
+ /** Synchronous snapshot of the current trace metrics window (throughput,
2789
+ * dispatch latency, dedup outcomes), without flushing or resetting it.
2790
+ * Returns null when trace metrics are inactive (disabled or events-only). */
2791
+ getMetrics() {
2792
+ return this.trace.getMetrics();
2793
+ }
1897
2794
  /**
1898
2795
  * Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
1899
- * and close the transport. Idempotent.
2796
+ * and close the transport. Concurrent and repeated calls share the in-flight
2797
+ * stop promise. A start() received while stopping runs after this completes,
2798
+ * unless another stop() arrives first and cancels that queued restart.
1900
2799
  */
1901
- async stop() {
1902
- if (!this.started) return;
2800
+ stop() {
2801
+ if (this.queuedStart) {
2802
+ this.canceledQueuedStartToken = this.queuedStartToken;
2803
+ this.queuedStart = null;
2804
+ }
2805
+ if (this.stopPromise) return this.stopPromise;
2806
+ if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
2807
+ return Promise.resolve();
2808
+ }
2809
+ const stopPromise = this.performStop();
2810
+ this.stopPromise = stopPromise;
2811
+ void stopPromise.then(
2812
+ () => {
2813
+ if (this.stopPromise === stopPromise) this.stopPromise = null;
2814
+ },
2815
+ () => {
2816
+ if (this.stopPromise === stopPromise) this.stopPromise = null;
2817
+ }
2818
+ );
2819
+ return stopPromise;
2820
+ }
2821
+ async performStop() {
2822
+ this.lifecycleEpoch += 1;
1903
2823
  this.stopping = true;
1904
- this.persistenceRetryGeneration += 1;
1905
- this.trace.event({ type: "lifecycle", action: "stop" });
2824
+ this.replayManager.suspend();
2825
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
1906
2826
  this.trace.stop();
1907
2827
  this.stopDedupSweep();
1908
- this.stopReplayRetentionSweep();
1909
2828
  this.topicHandlers.clear();
1910
- this.replayBuffers?.clear();
2829
+ this.replayManager.resetBuffers();
1911
2830
  this.cluster.stop();
1912
2831
  try {
1913
2832
  await this.startPromise?.catch(() => void 0);
@@ -1928,7 +2847,7 @@ var CrossTabDataBus = class {
1928
2847
  this.activeConfig = void 0;
1929
2848
  this.recoveryAttempt = 0;
1930
2849
  this.recoveryExhausted = false;
1931
- this.updateStatus("disconnected");
2850
+ this.updateStatus(WORKER_STATUS.DISCONNECTED);
1932
2851
  }
1933
2852
  }
1934
2853
  /**
@@ -1937,7 +2856,7 @@ var CrossTabDataBus = class {
1937
2856
  * and dispatches locally.
1938
2857
  */
1939
2858
  handleTransportMessage(message) {
1940
- if (this.isDuplicate(message)) return;
2859
+ if (this.dedupManager.isDuplicate(message.messageId ?? "", message.topic)) return;
1941
2860
  this.trace.recordReceived(message.topic);
1942
2861
  if (!this.cluster.isAssigned(message.topic)) {
1943
2862
  this.trace.recordDiscarded(message.topic);
@@ -1951,55 +2870,13 @@ var CrossTabDataBus = class {
1951
2870
  }
1952
2871
  this.trace.recordDiscarded(message.topic);
1953
2872
  }
1954
- isDuplicate(message) {
1955
- if (!this.dedupEnabled || !message.messageId) return false;
1956
- const now = this.now();
1957
- for (const [id, timestamp] of this.seenMessageIds) {
1958
- if (now - timestamp > this.dedupTtlMs) this.seenMessageIds.delete(id);
1959
- }
1960
- if (this.seenMessageIds.has(message.messageId)) {
1961
- this.trace.event({ type: "reliability", operation: "dedup_suppressed", topic: message.topic });
1962
- this.dedupSuppressed += 1;
1963
- this.trace.recordDedupSuppressed();
1964
- return true;
1965
- }
1966
- this.seenMessageIds.set(message.messageId, now);
1967
- this.dedupAccepted += 1;
1968
- this.dedupWindowAccepted += 1;
1969
- this.trace.recordDedupAccepted();
1970
- while (this.seenMessageIds.size > this.dedupMaxEntries) {
1971
- const oldest = this.seenMessageIds.keys().next().value;
1972
- if (oldest === void 0) break;
1973
- this.seenMessageIds.delete(oldest);
1974
- }
1975
- return false;
1976
- }
2873
+ /** Start enqueuing the dedup expiry sweep (delegated to {@link DedupManager}). */
1977
2874
  startDedupSweep() {
1978
- if (this.dedupSweepTimer || !this.dedupEnabled || !this.dedupSweepMs) return;
1979
- this.dedupSweepTimer = setInterval(() => this.pruneExpiredDedup(), this.dedupSweepMs);
2875
+ this.dedupManager.start();
1980
2876
  }
2877
+ /** Stop enqueuing the dedup expiry sweep. */
1981
2878
  stopDedupSweep() {
1982
- if (this.dedupSweepTimer) clearInterval(this.dedupSweepTimer);
1983
- this.dedupSweepTimer = null;
1984
- }
1985
- pruneExpiredDedup() {
1986
- const cutoff = this.now() - this.currentDedupTtl();
1987
- for (const [id, timestamp] of this.seenMessageIds) {
1988
- if (timestamp < cutoff) this.seenMessageIds.delete(id);
1989
- }
1990
- }
1991
- currentDedupTtl() {
1992
- if (!this.dedupAdaptiveBounds) return this.dedupTtlMs;
1993
- const now = this.now();
1994
- const elapsed = now - this.dedupWindowStartedAt;
1995
- if (elapsed >= this.dedupAdaptiveWindowMs) {
1996
- this.dedupWindowStartedAt = now;
1997
- this.dedupWindowAccepted = 0;
1998
- return this.dedupAdaptiveBounds.maxMs;
1999
- }
2000
- const rate = this.dedupWindowAccepted / Math.max(1, elapsed);
2001
- const factor = Math.min(1, rate / 0.01);
2002
- return this.dedupAdaptiveBounds.maxMs - (this.dedupAdaptiveBounds.maxMs - this.dedupAdaptiveBounds.minMs) * factor;
2879
+ this.dedupManager.stop();
2003
2880
  }
2004
2881
  /** Deliver a message to every local handler registered for its topic,
2005
2882
  * plus every handler registered with a wildcard subscription that matches
@@ -2012,153 +2889,7 @@ var CrossTabDataBus = class {
2012
2889
  this.invokeHandlers(handlers, (handler) => handler(message));
2013
2890
  }
2014
2891
  }
2015
- this.recordReplay(message);
2016
- }
2017
- /** Append a dispatched publication to the topic's replay ring buffer.
2018
- * No-op when replay is disabled. */
2019
- recordReplay(message) {
2020
- if (!this.replayBuffers) return;
2021
- let buffer = this.replayBuffers.get(message.topic);
2022
- if (!buffer) {
2023
- buffer = [];
2024
- this.replayBuffers.set(message.topic, buffer);
2025
- }
2026
- const storedMessage = message;
2027
- buffer.push(storedMessage);
2028
- if (this.replayPruneStrategy !== "age") {
2029
- while (buffer.length > this.replayMaxPerTopic) buffer.shift();
2030
- }
2031
- if (this.replayPruneStrategy !== "count" && this.replayRetentionMs !== void 0) {
2032
- const cutoff = this.now() - this.replayRetentionMs;
2033
- while (buffer.length > 0) {
2034
- const first = buffer[0];
2035
- if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
2036
- buffer.shift();
2037
- }
2038
- }
2039
- if (this.replayPersistence) {
2040
- if (this.replayPersistence.appendBatch) {
2041
- this.pendingReplayPersistence.push(storedMessage);
2042
- this.scheduleReplayPersistenceFlush();
2043
- } else {
2044
- void this.withPersistenceRetry("append", () => this.replayPersistence.append(storedMessage)).catch((error) => this.reportPersistenceError(error));
2045
- }
2046
- if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
2047
- this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
2048
- }
2049
- }
2050
- }
2051
- scheduleReplayPersistenceFlush() {
2052
- if (this.replayPersistenceFlushScheduled) return;
2053
- this.replayPersistenceFlushScheduled = true;
2054
- queueMicrotask(() => {
2055
- this.replayPersistenceFlushScheduled = false;
2056
- const batch = this.pendingReplayPersistence.splice(0);
2057
- if (batch.length === 0 || !this.replayPersistence) return;
2058
- const operation = this.replayPersistence.appendBatch ? () => this.replayPersistence.appendBatch(batch) : () => Promise.all(batch.map((message) => this.replayPersistence.append(message))).then(() => void 0);
2059
- void this.withPersistenceRetry("append", operation).catch((error) => this.reportPersistenceError(error));
2060
- });
2061
- }
2062
- async hydrateReplay() {
2063
- if (!this.replayBuffers || !this.replayPersistence) {
2064
- return;
2065
- }
2066
- try {
2067
- if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
2068
- await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(this.now() - this.replayRetentionMs));
2069
- }
2070
- for (const message of await this.withPersistenceRetry("load", () => this.replayPersistence.load())) {
2071
- let buffer = this.replayBuffers.get(message.topic);
2072
- if (!buffer) {
2073
- buffer = [];
2074
- this.replayBuffers.set(message.topic, buffer);
2075
- }
2076
- buffer.push(message);
2077
- if (buffer.length > this.replayMaxPerTopic) buffer.shift();
2078
- }
2079
- } catch (error) {
2080
- this.reportPersistenceError(error);
2081
- }
2082
- }
2083
- scheduleReplayRetentionCleanup(cutoff) {
2084
- if (!this.replayPersistence?.clearBefore) return;
2085
- if (this.replayRetentionCutoff === null || cutoff > this.replayRetentionCutoff) {
2086
- this.replayRetentionCutoff = cutoff;
2087
- }
2088
- if (this.replayRetentionCleanup) return;
2089
- this.replayRetentionCleanup = (async () => {
2090
- while (this.replayRetentionCutoff !== null) {
2091
- const nextCutoff = this.replayRetentionCutoff;
2092
- this.replayRetentionCutoff = null;
2093
- try {
2094
- await this.replayPersistence.clearBefore(nextCutoff);
2095
- } catch (error) {
2096
- this.reportPersistenceError(error);
2097
- }
2098
- }
2099
- })().finally(() => {
2100
- this.replayRetentionCleanup = null;
2101
- if (this.replayRetentionCutoff !== null) {
2102
- this.scheduleReplayRetentionCleanup(this.replayRetentionCutoff);
2103
- }
2104
- });
2105
- }
2106
- startReplayRetentionSweep() {
2107
- if (this.replayRetentionTimer || !this.replayRetentionMs || !this.replayRetentionSweepMs || !this.replayPersistence?.clearBefore) return;
2108
- this.replayRetentionTimer = setInterval(() => {
2109
- this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
2110
- }, this.replayRetentionSweepMs);
2111
- }
2112
- stopReplayRetentionSweep() {
2113
- if (this.replayRetentionTimer) clearInterval(this.replayRetentionTimer);
2114
- this.replayRetentionTimer = null;
2115
- }
2116
- async withPersistenceRetry(persistenceOperation, operation) {
2117
- const generation = this.persistenceRetryGeneration;
2118
- let attempt = 0;
2119
- let delay = this.persistenceRetryBackoffMs;
2120
- while (true) {
2121
- attempt += 1;
2122
- try {
2123
- if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
2124
- return await operation();
2125
- } catch (error) {
2126
- if (error instanceof PersistenceRetryCancelledError || generation !== this.persistenceRetryGeneration) {
2127
- throw new PersistenceRetryCancelledError();
2128
- }
2129
- if (attempt >= this.persistenceRetryMaxAttempts) throw error;
2130
- this.trace.event({
2131
- type: "reliability",
2132
- operation: "persistence_retry",
2133
- persistenceOperation,
2134
- attempt
2135
- });
2136
- if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
2137
- if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
2138
- delay = Math.min(delay * 2, 1600);
2139
- }
2140
- }
2141
- }
2142
- /** Deliver buffered history to a newly-registered handler. For an exact
2143
- * topic this is that topic's ring; for a wildcard subscription every
2144
- * buffered topic matching the pattern contributes (in buffer insertion
2145
- * order). Replay deliveries are marked `replayed: true` and are not
2146
- * counted into trace metrics. */
2147
- deliverReplay(topic, limit, handler) {
2148
- if (!this.replayBuffers || limit <= 0) return;
2149
- const deliver = (buffer2) => {
2150
- for (const message of buffer2.slice(-limit)) {
2151
- this.invokeHandlers([handler], (h) => h({ ...message, replayed: true }));
2152
- }
2153
- };
2154
- if (isWildcardTopic(topic)) {
2155
- for (const [bufferedTopic, buffer2] of this.replayBuffers) {
2156
- if (topicMatchesPattern(topic, bufferedTopic)) deliver(buffer2);
2157
- }
2158
- return;
2159
- }
2160
- const buffer = this.replayBuffers.get(topic);
2161
- if (buffer) deliver(buffer);
2892
+ this.replayManager.record(message);
2162
2893
  }
2163
2894
  /**
2164
2895
  * Propagate a status change to the cluster, trace, and all registered
@@ -2167,13 +2898,13 @@ var CrossTabDataBus = class {
2167
2898
  updateStatus(status) {
2168
2899
  const previousStatus = this.status;
2169
2900
  this.status = status;
2170
- if (previousStatus !== status) this.trace.event({ type: "status", status });
2901
+ if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
2171
2902
  this.cluster.setStatus(status);
2172
- if (status === "disconnected" || status === "error") this.transportSubscribedTopics.clear();
2173
- if (status === "connected" && previousStatus !== "connected") {
2903
+ if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
2904
+ if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
2174
2905
  for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
2175
2906
  }
2176
- if (status === "error" && this.started && !this.stopping) {
2907
+ if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
2177
2908
  const now = this.now();
2178
2909
  if (now - this.lastRecoveryAt >= this.recoveryCooldownMs) {
2179
2910
  this.lastRecoveryAt = now;
@@ -2181,37 +2912,67 @@ var CrossTabDataBus = class {
2181
2912
  if (attempt > this.recoveryMaxAttempts) {
2182
2913
  if (!this.recoveryExhausted) {
2183
2914
  this.recoveryExhausted = true;
2184
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: this.recoveryMaxAttempts, outcome: "exhausted" });
2915
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
2185
2916
  }
2186
2917
  return;
2187
2918
  }
2188
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt, outcome: "scheduled" });
2919
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
2189
2920
  setTimeout(() => {
2190
2921
  if (this.stopping || !this.started || this.suspended) return;
2191
- if (this.status !== "error") return;
2922
+ if (this.status !== WORKER_STATUS.ERROR) return;
2192
2923
  void this.reopenTransport(attempt);
2193
2924
  }, this.recoveryCooldownMs);
2194
2925
  }
2195
2926
  }
2196
2927
  this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
2197
2928
  }
2198
- reportError(error) {
2199
- this.trace.event({ type: "error", source: "transport" });
2200
- this.invokeHandlers(this.errorHandlers, (handler) => handler(error), "error handler");
2929
+ reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
2930
+ this.lastFailure = {
2931
+ source,
2932
+ message: error instanceof Error ? error.message : String(error),
2933
+ at: this.now()
2934
+ };
2935
+ if (source === FAILURE_SOURCE.PERSISTENCE) {
2936
+ this.persistenceFailureCount += 1;
2937
+ this.persistenceLastFailureAt = this.lastFailure.at;
2938
+ this.persistenceLastErrorMessage = this.lastFailure.message;
2939
+ }
2940
+ this.trace.event({
2941
+ type: TRACE_EVENT_TYPE.ERROR,
2942
+ source: source === FAILURE_SOURCE.TRANSPORT ? TRACE_ERROR_SOURCE.TRANSPORT : TRACE_ERROR_SOURCE.OPERATION
2943
+ });
2944
+ this.invokeHandlers(this.errorHandlers, (handler) => handler(error), INVOKE_LABEL.ERROR_HANDLER);
2201
2945
  }
2946
+ /** Report a persistence failure to the trace and the unified failure ledger,
2947
+ * unless it is a {@link PersistenceRetryCancelledError} cancellation from a
2948
+ * lifecycle transition (teardown should stay quiet). */
2202
2949
  reportPersistenceError(error) {
2203
2950
  if (error instanceof PersistenceRetryCancelledError) return;
2204
- this.trace.event({ type: "reliability", operation: "persistence_cleanup" });
2205
- this.reportError(error);
2951
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.PERSISTENCE_CLEANUP });
2952
+ this.reportError(error, FAILURE_SOURCE.PERSISTENCE);
2206
2953
  }
2207
2954
  traceSubscription(action, topic) {
2208
2955
  this.trace.event({
2209
- type: "subscription",
2956
+ type: TRACE_EVENT_TYPE.SUBSCRIPTION,
2210
2957
  action,
2211
2958
  topic,
2212
2959
  activeTopics: this.transportSubscribedTopics.size
2213
2960
  });
2214
2961
  }
2962
+ /** Emit the coordination trace snapshot from the current cluster state.
2963
+ * Called after a transport opens (start and recovery), when the role and
2964
+ * route picture has settled — the synchronous pre-open snapshot would see no
2965
+ * routes because their writes are still coalesced in the batch writer. */
2966
+ emitCoordinationTrace() {
2967
+ const snapshot = this.cluster.getSnapshot();
2968
+ this.trace.event({
2969
+ type: TRACE_EVENT_TYPE.COORDINATION,
2970
+ coordinated: snapshot.coordinated,
2971
+ activeWorkers: snapshot.workers.filter((worker) => worker.role === WORKER_ROLE.ACTIVE).length,
2972
+ workers: snapshot.workers.map(formatWorkerTrace),
2973
+ routes: snapshot.routes.map(formatRouteTrace)
2974
+ });
2975
+ }
2215
2976
  /** Ask the transport to subscribe to a topic (idempotent). */
2216
2977
  subscribeTransport(topic) {
2217
2978
  if (this.transportSubscribedTopics.has(topic)) return false;
@@ -2229,17 +2990,17 @@ var CrossTabDataBus = class {
2229
2990
  * are routed to `reportError` (which surfaces them to error subscribers);
2230
2991
  * error-handler failures are logged to the console to avoid infinite
2231
2992
  * recursion through reportError itself. */
2232
- invokeHandlers(handlers, callback, label = "dispatch") {
2993
+ invokeHandlers(handlers, callback, label = INVOKE_LABEL.DISPATCH) {
2233
2994
  for (const handler of handlers) {
2234
2995
  try {
2235
2996
  callback(handler);
2236
2997
  } catch (error) {
2237
- if (label === "error handler") {
2998
+ if (label === INVOKE_LABEL.ERROR_HANDLER) {
2238
2999
  if (typeof console !== "undefined" && typeof console.warn === "function") {
2239
- console.warn("[cross-tab-worker-databus] error handler threw:", error);
3000
+ console.warn(`[${DEFAULT_STORAGE_PREFIX}] error handler threw:`, error);
2240
3001
  }
2241
3002
  } else {
2242
- this.reportError(error);
3003
+ this.reportError(error, FAILURE_SOURCE.DISPATCH);
2243
3004
  }
2244
3005
  }
2245
3006
  }
@@ -2250,10 +3011,11 @@ var CrossTabDataBus = class {
2250
3011
  */
2251
3012
  suspendTransport() {
2252
3013
  if (this.stopping) return;
3014
+ this.lifecycleEpoch += 1;
2253
3015
  this.suspended = true;
2254
3016
  this.transportReady = false;
2255
3017
  this.transportSubscribedTopics.clear();
2256
- this.updateStatus("disconnected");
3018
+ this.updateStatus(WORKER_STATUS.DISCONNECTED);
2257
3019
  if (this.pendingStop) return;
2258
3020
  const pending = this.startPromise ?? Promise.resolve();
2259
3021
  const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
@@ -2287,22 +3049,26 @@ var CrossTabDataBus = class {
2287
3049
  const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
2288
3050
  this.started = true;
2289
3051
  this.suspended = false;
2290
- this.updateStatus("connecting");
3052
+ this.updateStatus(WORKER_STATUS.CONNECTING);
3053
+ const lifecycleEpoch = ++this.lifecycleEpoch;
2291
3054
  const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
2292
- const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
3055
+ const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
2293
3056
  this.startPromise = opening;
2294
3057
  void opening.then(
2295
3058
  () => {
3059
+ if (this.startPromise === opening) this.startPromise = null;
3060
+ if (lifecycleEpoch !== this.lifecycleEpoch) return;
2296
3061
  if (traceAttempt !== void 0) {
2297
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: traceAttempt, outcome: "succeeded" });
3062
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
2298
3063
  this.recoveryAttempt = 0;
2299
3064
  this.recoveryExhausted = false;
2300
3065
  }
2301
- if (this.startPromise === opening) this.startPromise = null;
2302
3066
  },
2303
3067
  () => {
3068
+ if (this.startPromise === opening) this.startPromise = null;
3069
+ if (lifecycleEpoch !== this.lifecycleEpoch) return;
2304
3070
  if (traceAttempt !== void 0) {
2305
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: traceAttempt, outcome: "failed" });
3071
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
2306
3072
  }
2307
3073
  }
2308
3074
  );
@@ -2334,6 +3100,19 @@ var CrossTabDataBus = class {
2334
3100
  return operation();
2335
3101
  }).catch((error) => this.reportError(error));
2336
3102
  }
3103
+ /**
3104
+ * Publications started after teardown begins cannot reach any transport.
3105
+ * Surface that as a normal asynchronous API failure instead of letting
3106
+ * runTransport() return silently. Empty publishBatch() calls remain a no-op
3107
+ * and are filtered by the caller before this check.
3108
+ */
3109
+ rejectPublishDuringStop(operation) {
3110
+ if (!this.stopping) return false;
3111
+ this.reportError(new Error(
3112
+ `CrossTabDataBus is stopping; ${operation}() was not sent. Wait for stop() to resolve, then call start() before publishing again.`
3113
+ ));
3114
+ return true;
3115
+ }
2337
3116
  /**
2338
3117
  * Ensure the DataBus is started, throwing if no initialConfig was provided.
2339
3118
  * Called automatically by subscribe/publish/ready when autoStart is true.
@@ -2360,10 +3139,10 @@ function formatRouteTrace(route) {
2360
3139
  function selectWorkerBackend(mode, availability = {}) {
2361
3140
  const hasDedicated = availability.worker ?? typeof Worker !== "undefined";
2362
3141
  const hasShared = availability.sharedWorker ?? typeof SharedWorker !== "undefined";
2363
- if (mode === "shared" || mode === "auto") {
2364
- return hasShared ? "shared" : hasDedicated ? "dedicated" : "local";
3142
+ if (mode === WORKER_MODE.SHARED || mode === WORKER_MODE.AUTO) {
3143
+ return hasShared ? WORKER_BACKEND.SHARED : hasDedicated ? WORKER_BACKEND.DEDICATED : WORKER_BACKEND.LOCAL;
2365
3144
  }
2366
- return hasDedicated ? "dedicated" : hasShared ? "shared" : "local";
3145
+ return hasDedicated ? WORKER_BACKEND.DEDICATED : hasShared ? WORKER_BACKEND.SHARED : WORKER_BACKEND.LOCAL;
2367
3146
  }
2368
3147
 
2369
3148
  // src/core/publication.ts
@@ -2389,19 +3168,29 @@ function parseDataBusPublication(value, fallbackTopic) {
2389
3168
  }
2390
3169
 
2391
3170
  export {
3171
+ createStorageEventChannel,
2392
3172
  createBrowserEnvironment,
2393
3173
  getOrCreateTabId,
2394
3174
  createOpaqueKey,
2395
3175
  DEFAULT_MAX_ACTIVE_WORKERS,
3176
+ effectiveWorkerLoad,
3177
+ approximatePayloadBytes,
2396
3178
  selectLeastLoadedWorker,
2397
3179
  selectActiveWorkers,
2398
3180
  selectRebalanceTarget,
2399
3181
  hasActiveOwner,
2400
3182
  isWildcardTopic,
2401
3183
  topicMatchesPattern,
3184
+ publicationMetadata,
3185
+ assertPositiveSafeInteger,
3186
+ assertPositiveFiniteNumber,
3187
+ assertPruneStrategy,
3188
+ assertHeartbeatInterval,
3189
+ assertStructuredCloneable,
2402
3190
  WorkerClusterRuntime,
3191
+ pruneReplayHistory,
2403
3192
  CrossTabDataBus,
2404
3193
  parseDataBusPublication,
2405
3194
  selectWorkerBackend
2406
3195
  };
2407
- //# sourceMappingURL=chunk-D2SIT473.js.map
3196
+ //# sourceMappingURL=chunk-SDOV3UHG.js.map