cross-tab-worker-databus 0.20.71 → 0.20.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +204 -2
  2. package/README.md +8 -0
  3. package/README.zh.md +4 -0
  4. package/dist/centrifuge-protocol.d.ts +59 -26
  5. package/dist/centrifuge-protocol.d.ts.map +1 -1
  6. package/dist/centrifuge-session.d.ts +9 -0
  7. package/dist/centrifuge-session.d.ts.map +1 -1
  8. package/dist/centrifuge.d.ts +12 -2
  9. package/dist/centrifuge.d.ts.map +1 -1
  10. package/dist/centrifuge.js +168 -88
  11. package/dist/centrifuge.js.map +3 -3
  12. package/dist/centrifuge.shared.worker.js +146 -38
  13. package/dist/centrifuge.shared.worker.js.map +3 -3
  14. package/dist/centrifuge.worker.js +140 -33
  15. package/dist/centrifuge.worker.js.map +3 -3
  16. package/dist/{chunk-D2SIT473.js → chunk-PW63EWIK.js} +1090 -494
  17. package/dist/chunk-PW63EWIK.js.map +7 -0
  18. package/dist/chunk-TZ7ZP7YD.js +175 -0
  19. package/dist/chunk-TZ7ZP7YD.js.map +7 -0
  20. package/dist/cjs/centrifuge.cjs +1403 -637
  21. package/dist/cjs/centrifuge.cjs.map +4 -4
  22. package/dist/cjs/hooks.cjs +37 -2
  23. package/dist/cjs/hooks.cjs.map +3 -3
  24. package/dist/cjs/index.cjs +1453 -730
  25. package/dist/cjs/index.cjs.map +4 -4
  26. package/dist/cjs/vue.cjs +45 -2
  27. package/dist/cjs/vue.cjs.map +3 -3
  28. package/dist/core/cluster.d.ts +40 -4
  29. package/dist/core/cluster.d.ts.map +1 -1
  30. package/dist/core/data-bus.d.ts +93 -69
  31. package/dist/core/data-bus.d.ts.map +1 -1
  32. package/dist/core/dedup-manager.d.ts +98 -0
  33. package/dist/core/dedup-manager.d.ts.map +1 -0
  34. package/dist/core/environment.d.ts +43 -3
  35. package/dist/core/environment.d.ts.map +1 -1
  36. package/dist/core/replay-manager.d.ts +128 -0
  37. package/dist/core/replay-manager.d.ts.map +1 -0
  38. package/dist/core/replay-persistence.d.ts +2 -1
  39. package/dist/core/replay-persistence.d.ts.map +1 -1
  40. package/dist/core/routing.d.ts +22 -3
  41. package/dist/core/routing.d.ts.map +1 -1
  42. package/dist/core/storage-batch.d.ts +0 -4
  43. package/dist/core/storage-batch.d.ts.map +1 -1
  44. package/dist/core/trace.d.ts +55 -15
  45. package/dist/core/trace.d.ts.map +1 -1
  46. package/dist/core/types.d.ts +58 -9
  47. package/dist/core/types.d.ts.map +1 -1
  48. package/dist/core/version.d.ts +2 -0
  49. package/dist/core/version.d.ts.map +1 -0
  50. package/dist/hooks.d.ts +12 -1
  51. package/dist/hooks.d.ts.map +1 -1
  52. package/dist/hooks.js +28 -2
  53. package/dist/hooks.js.map +2 -2
  54. package/dist/index.d.ts +6 -6
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +228 -162
  57. package/dist/index.js.map +3 -3
  58. package/dist/utils/constants.d.ts +170 -0
  59. package/dist/utils/constants.d.ts.map +1 -0
  60. package/dist/utils/error-utils.d.ts +28 -0
  61. package/dist/utils/error-utils.d.ts.map +1 -0
  62. package/dist/utils/metadata.d.ts +16 -0
  63. package/dist/utils/metadata.d.ts.map +1 -0
  64. package/dist/utils/storage-utils.d.ts +24 -0
  65. package/dist/utils/storage-utils.d.ts.map +1 -0
  66. package/dist/utils/validation.d.ts +76 -0
  67. package/dist/utils/validation.d.ts.map +1 -0
  68. package/dist/vue.d.ts +13 -1
  69. package/dist/vue.d.ts.map +1 -1
  70. package/dist/vue.js +36 -2
  71. package/dist/vue.js.map +2 -2
  72. package/dist/websocket.d.ts +6 -1
  73. package/dist/websocket.d.ts.map +1 -1
  74. package/dist/worker-mode.d.ts +6 -2
  75. package/dist/worker-mode.d.ts.map +1 -1
  76. package/docs/README.md +1 -0
  77. package/docs/api.md +122 -2
  78. package/docs/architecture.md +80 -0
  79. package/docs/benchmarks.md +24 -0
  80. package/docs/capabilities.md +24 -3
  81. package/docs/configuration.md +58 -0
  82. package/docs/getting-started.md +35 -0
  83. package/docs/release-checklist.md +28 -5
  84. package/docs/roadmap.md +74 -5
  85. package/docs/zh/README.md +2 -1
  86. package/docs/zh/api.md +121 -2
  87. package/docs/zh/architecture.md +49 -0
  88. package/docs/zh/benchmarks.md +24 -0
  89. package/docs/zh/capabilities.md +21 -3
  90. package/docs/zh/configuration.md +53 -0
  91. package/docs/zh/getting-started.md +35 -0
  92. package/docs/zh/release-checklist.md +29 -6
  93. package/docs/zh/roadmap.md +86 -5
  94. package/package.json +39 -16
  95. package/dist/chunk-D2SIT473.js.map +0 -7
@@ -25,6 +25,145 @@ __export(centrifuge_exports, {
25
25
  });
26
26
  module.exports = __toCommonJS(centrifuge_exports);
27
27
 
28
+ // src/utils/constants.ts
29
+ var WORKER_STATUS = {
30
+ CONNECTING: "connecting",
31
+ CONNECTED: "connected",
32
+ DISCONNECTED: "disconnected",
33
+ ERROR: "error"
34
+ };
35
+ var WORKER_ROLE = {
36
+ ACTIVE: "active",
37
+ STANDBY: "standby"
38
+ };
39
+ var TAB_VISIBILITY = {
40
+ VISIBLE: "visible",
41
+ HIDDEN: "hidden"
42
+ };
43
+ var WORKER_MODE = {
44
+ DEDICATED: "dedicated",
45
+ SHARED: "shared",
46
+ AUTO: "auto"
47
+ };
48
+ var WORKER_BACKEND = {
49
+ DEDICATED: "dedicated",
50
+ SHARED: "shared",
51
+ LOCAL: "local"
52
+ };
53
+ var CHANNEL_FALLBACK = {
54
+ NONE: "none",
55
+ STORAGE_EVENT: "storage-event"
56
+ };
57
+ var CONTROL_ACTION = {
58
+ SUBSCRIBE: "SUBSCRIBE",
59
+ UNSUBSCRIBE: "UNSUBSCRIBE",
60
+ PUBLISH: "PUBLISH"
61
+ };
62
+ var CLUSTER_MESSAGE_TYPE = {
63
+ CONTROL: "CONTROL",
64
+ EVENT: "EVENT",
65
+ REGISTRY: "REGISTRY",
66
+ ROUTE_RELEASED: "ROUTE_RELEASED"
67
+ };
68
+ var DEFAULT_STORAGE_PREFIX = "cross-tab-worker-databus";
69
+ var STORAGE_CHANNEL_PREFIX = `${DEFAULT_STORAGE_PREFIX}:channel:`;
70
+ var TAB_ID_STORAGE_KEY = `${DEFAULT_STORAGE_PREFIX}:tab-id`;
71
+ var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
72
+ var TRACE_EVENT_TYPE = {
73
+ LIFECYCLE: "lifecycle",
74
+ STATUS: "status",
75
+ SUBSCRIPTION: "subscription",
76
+ COORDINATION: "coordination",
77
+ ERROR: "error",
78
+ RELIABILITY: "reliability",
79
+ MESSAGE_METRICS: "message_metrics"
80
+ };
81
+ var TRACE_LIFECYCLE_ACTION = {
82
+ START: "start",
83
+ STOP: "stop",
84
+ SUSPEND: "suspend",
85
+ RESUME: "resume"
86
+ };
87
+ var INVOKE_LABEL = {
88
+ DISPATCH: "dispatch",
89
+ STATUS: "status",
90
+ ERROR_HANDLER: "error handler"
91
+ };
92
+ var TRACE_MODE = {
93
+ EVENTS: "events",
94
+ METRICS: "metrics",
95
+ ALL: "all"
96
+ };
97
+ var TRACE_ERROR_SOURCE = {
98
+ TRANSPORT: "transport",
99
+ OPERATION: "operation"
100
+ };
101
+ var RELIABILITY_OPERATION = {
102
+ TRANSPORT_RECOVERY: "transport_recovery",
103
+ ROUTE_ACK: "route_ack",
104
+ ROUTE_MIGRATION: "route_migration",
105
+ // A re-election that recovered a stranded unconfirmed handoff (previous
106
+ // owner gone, ACK never arrived). Distinct from ROUTE_MIGRATION so trace
107
+ // consumers can tell recoveries apart from routine graceful handoffs.
108
+ ROUTE_MIGRATION_RECOVERY: "route_migration_recovery",
109
+ PERSISTENCE_CLEANUP: "persistence_cleanup",
110
+ PERSISTENCE_RETRY: "persistence_retry",
111
+ DEDUP_SUPPRESSED: "dedup_suppressed"
112
+ };
113
+ var RECOVERY_OUTCOME = {
114
+ SCHEDULED: "scheduled",
115
+ SUCCEEDED: "succeeded",
116
+ FAILED: "failed",
117
+ EXHAUSTED: "exhausted"
118
+ };
119
+ var SUBSCRIPTION_ACTION = {
120
+ SUBSCRIBE: "subscribe",
121
+ UNSUBSCRIBE: "unsubscribe"
122
+ };
123
+ var PRUNE_STRATEGY = {
124
+ COUNT: "count",
125
+ AGE: "age",
126
+ BOTH: "both"
127
+ };
128
+ var PERSISTENCE_OPERATION = {
129
+ LOAD: "load",
130
+ APPEND: "append",
131
+ CLEAR: "clear",
132
+ CLEAR_TOPIC: "clearTopic",
133
+ CLEAR_BEFORE: "clearBefore"
134
+ };
135
+ var FAILURE_SOURCE = {
136
+ TRANSPORT: "transport",
137
+ PERSISTENCE: "persistence",
138
+ DISPATCH: "dispatch"
139
+ };
140
+ var HEALTH_STATE = {
141
+ STOPPED: "stopped",
142
+ STARTING: "starting",
143
+ HEALTHY: "healthy",
144
+ RECOVERING: "recovering",
145
+ SUSPENDED: "suspended",
146
+ DEGRADED: "degraded"
147
+ };
148
+ var CENTRIFUGE_INPUT_TYPE = {
149
+ INIT: "INIT",
150
+ SUBSCRIBE: "SUBSCRIBE",
151
+ UNSUBSCRIBE: "UNSUBSCRIBE",
152
+ PUBLISH: "PUBLISH",
153
+ PUBLISH_BIN: "PUBLISH_BIN",
154
+ PING: "PING",
155
+ STOP: "STOP",
156
+ TOKEN_RESPONSE: "TOKEN_RESPONSE",
157
+ TOKEN_ERROR: "TOKEN_ERROR"
158
+ };
159
+ var CENTRIFUGE_OUTPUT_TYPE = {
160
+ STATUS: "STATUS",
161
+ MESSAGE: "MESSAGE",
162
+ MESSAGE_BIN: "MESSAGE_BIN",
163
+ ERROR: "ERROR",
164
+ TOKEN_REQUEST: "TOKEN_REQUEST"
165
+ };
166
+
28
167
  // src/core/environment.ts
29
168
  function getStorage(name) {
30
169
  try {
@@ -40,8 +179,53 @@ function randomId() {
40
179
  return Math.random().toString(36).slice(2);
41
180
  }
42
181
  }
182
+ function createStorageEventChannel(options) {
183
+ const { name, storage, win } = options;
184
+ if (!storage || !win) return null;
185
+ const key = `${STORAGE_CHANNEL_PREFIX}${name}`;
186
+ const listeners = /* @__PURE__ */ new Set();
187
+ let sequence = 0;
188
+ const onStorage = (event) => {
189
+ if (event.key !== key || event.newValue === null) return;
190
+ let message;
191
+ try {
192
+ const parsed = JSON.parse(event.newValue);
193
+ if (!parsed || typeof parsed !== "object" || typeof parsed.seq !== "number" || !parsed.message) return;
194
+ message = parsed.message;
195
+ } catch {
196
+ return;
197
+ }
198
+ const event_ = { data: message };
199
+ for (const listener of [...listeners]) listener(event_);
200
+ };
201
+ win.addEventListener("storage", onStorage);
202
+ let closed = false;
203
+ return {
204
+ addEventListener(_type, listener) {
205
+ listeners.add(listener);
206
+ },
207
+ removeEventListener(_type, listener) {
208
+ listeners.delete(listener);
209
+ },
210
+ postMessage(message) {
211
+ if (closed) return;
212
+ sequence += 1;
213
+ storage.setItem(key, JSON.stringify({ seq: sequence, message }));
214
+ },
215
+ close() {
216
+ closed = true;
217
+ win.removeEventListener("storage", onStorage);
218
+ listeners.clear();
219
+ try {
220
+ storage.removeItem(key);
221
+ } catch {
222
+ }
223
+ }
224
+ };
225
+ }
43
226
  var tabIdentityInitialized = false;
44
- function createBrowserEnvironment() {
227
+ function createBrowserEnvironment(options) {
228
+ const channelFallback = options?.channelFallback ?? CHANNEL_FALLBACK.NONE;
45
229
  return {
46
230
  storage: getStorage("localStorage"),
47
231
  sessionStorage: getStorage("sessionStorage"),
@@ -49,14 +233,18 @@ function createBrowserEnvironment() {
49
233
  randomId,
50
234
  createChannel: (name) => {
51
235
  try {
52
- return typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(name);
236
+ if (typeof BroadcastChannel !== "undefined") return new BroadcastChannel(name);
53
237
  } catch {
54
- return null;
55
238
  }
239
+ return channelFallback === CHANNEL_FALLBACK.STORAGE_EVENT ? createStorageEventChannel({
240
+ name,
241
+ storage: getStorage("localStorage"),
242
+ win: typeof window !== "undefined" && typeof window.addEventListener === "function" ? window : null
243
+ }) : null;
56
244
  },
57
245
  setInterval: (callback, intervalMs) => globalThis.setInterval(callback, intervalMs),
58
246
  clearInterval: (handle) => globalThis.clearInterval(handle),
59
- getVisibilityState: () => typeof document !== "undefined" && document.visibilityState === "hidden" ? "hidden" : "visible",
247
+ getVisibilityState: () => typeof document !== "undefined" && document.visibilityState === TAB_VISIBILITY.HIDDEN ? TAB_VISIBILITY.HIDDEN : TAB_VISIBILITY.VISIBLE,
60
248
  addVisibilityChangeListener: (listener) => {
61
249
  if (typeof document !== "undefined") document.addEventListener("visibilitychange", listener);
62
250
  },
@@ -87,7 +275,7 @@ function canUseStorage(storage, probeKey) {
87
275
  return false;
88
276
  }
89
277
  }
90
- function getOrCreateTabId(environment, key = "cross-tab-worker-databus:tab-id") {
278
+ function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY) {
91
279
  const storage = environment.sessionStorage;
92
280
  try {
93
281
  const existing = storage?.getItem(key);
@@ -140,21 +328,73 @@ function avalancheMix(self, neighbor) {
140
328
 
141
329
  // src/core/routing.ts
142
330
  var DEFAULT_MAX_ACTIVE_WORKERS = 3;
143
- function selectLeastLoadedWorker(workers, preferredWorkerId) {
331
+ function effectiveWorkerLoad(worker, options) {
332
+ const baseLoad = Number.isFinite(worker.load) ? worker.load : 0;
333
+ const sample = worker.throughput;
334
+ const messageRateWeight = options?.messageRateWeight ?? 0;
335
+ const byteRateWeight = options?.byteRateWeight ?? 0;
336
+ const scheduleLagWeight = options?.scheduleLagWeight ?? 0;
337
+ if (!sample || !Number.isFinite(sample.windowMs) || sample.windowMs <= 0 || messageRateWeight === 0 && byteRateWeight === 0 && scheduleLagWeight === 0) {
338
+ return baseLoad;
339
+ }
340
+ const windowSeconds = sample.windowMs / 1e3;
341
+ const messageRate = sample.messageCount / windowSeconds;
342
+ const byteRate = sample.byteCount / windowSeconds;
343
+ const scheduleLagRatio = sample.overrunMs / sample.windowMs;
344
+ const weighted = baseLoad + messageRateWeight * messageRate + byteRateWeight * byteRate + scheduleLagWeight * scheduleLagRatio;
345
+ return Number.isFinite(weighted) ? weighted : baseLoad;
346
+ }
347
+ function approximatePayloadBytes(payload) {
348
+ return estimatePayloadBytes(payload, 0);
349
+ }
350
+ var MAX_PAYLOAD_DEPTH = 6;
351
+ function estimatePayloadBytes(payload, depth) {
352
+ if (payload === null || payload === void 0) return 0;
353
+ switch (typeof payload) {
354
+ case "boolean":
355
+ return 4;
356
+ case "number":
357
+ case "bigint":
358
+ return 8;
359
+ case "string":
360
+ return payload.length;
361
+ case "symbol":
362
+ case "function":
363
+ return 0;
364
+ case "object":
365
+ break;
366
+ }
367
+ if (depth >= MAX_PAYLOAD_DEPTH) return 0;
368
+ if (payload instanceof ArrayBuffer) return payload.byteLength;
369
+ if (ArrayBuffer.isView(payload)) return payload.byteLength;
370
+ if (Array.isArray(payload)) {
371
+ let sum2 = 8;
372
+ for (const item of payload) sum2 += estimatePayloadBytes(item, depth + 1);
373
+ return sum2;
374
+ }
375
+ let sum = 0;
376
+ for (const value of Object.values(payload)) {
377
+ sum += estimatePayloadBytes(value, depth + 1);
378
+ }
379
+ return sum;
380
+ }
381
+ function selectLeastLoadedWorker(workers, preferredWorkerId, options) {
144
382
  const preferred = workers.find((worker) => worker.workerId === preferredWorkerId);
145
383
  if (preferred) return preferred;
146
384
  return workers.reduce((least, worker) => {
147
385
  if (!least) return worker;
148
- const byLoad = worker.load - least.load;
386
+ const byLoad = effectiveWorkerLoad(worker, options) - effectiveWorkerLoad(least, options);
149
387
  if (byLoad !== 0) return byLoad < 0 ? worker : least;
150
388
  if (worker.workerId < least.workerId) return worker;
151
389
  return least;
152
390
  }, void 0);
153
391
  }
154
392
  function selectActiveWorkers(workers, maxActiveWorkers = DEFAULT_MAX_ACTIVE_WORKERS) {
155
- const healthyWorkers = workers.filter((worker) => worker.status === "connecting" || worker.status === "connected");
393
+ const healthyWorkers = workers.filter(
394
+ (worker) => worker.status === WORKER_STATUS.CONNECTING || worker.status === WORKER_STATUS.CONNECTED
395
+ );
156
396
  const availableWorkers = healthyWorkers.length > 0 ? healthyWorkers : [...workers];
157
- const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === "visible");
397
+ const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === TAB_VISIBILITY.VISIBLE);
158
398
  const candidates = visibleWorkers.length > 0 ? visibleWorkers : availableWorkers;
159
399
  return candidates.sort(
160
400
  (left, right) => left.registeredAt - right.registeredAt || (left.workerId < right.workerId ? -1 : left.workerId > right.workerId ? 1 : 0)
@@ -179,6 +419,7 @@ var BatchingStorageWriter = class {
179
419
  constructor(storage) {
180
420
  this.storage = storage;
181
421
  }
422
+ storage;
182
423
  /** Coalesced write set. A `null` value represents a pending delete. */
183
424
  pending = /* @__PURE__ */ new Map();
184
425
  /** Per-key retry counter, reset on a successful write. */
@@ -235,7 +476,7 @@ var BatchingStorageWriter = class {
235
476
  this.pending.delete(key);
236
477
  this.retryCount.delete(key);
237
478
  if (typeof console !== "undefined" && typeof console.warn === "function") {
238
- console.warn("[cross-tab-worker-databus] storage write gave up after retries, dropping key:", key);
479
+ console.warn(`[${DEFAULT_STORAGE_PREFIX}] storage write gave up after retries, dropping key:`, key);
239
480
  }
240
481
  continue;
241
482
  }
@@ -295,12 +536,16 @@ var BatchingStorageWriter = class {
295
536
  }
296
537
  };
297
538
 
298
- // src/core/cluster.ts
299
- var CLUSTER_PROTOCOL_VERSION = 1;
300
- var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
301
- var DEFAULT_WORKER_TTL_MS = 1e4;
302
- var DEFAULT_STORAGE_PREFIX = "cross-tab-worker-databus";
303
- var MAX_KNOWN_TOPICS = 500;
539
+ // src/utils/metadata.ts
540
+ function publicationMetadata(messageId, timestamp) {
541
+ if (messageId === void 0 && timestamp === void 0) return void 0;
542
+ return {
543
+ ...messageId === void 0 ? {} : { messageId },
544
+ ...timestamp === void 0 ? {} : { timestamp }
545
+ };
546
+ }
547
+
548
+ // src/utils/storage-utils.ts
304
549
  function readJson(storage, key) {
305
550
  try {
306
551
  const value = storage.getItem(key);
@@ -315,13 +560,6 @@ function writeJson(storage, key, value) {
315
560
  } catch {
316
561
  }
317
562
  }
318
- function publicationMetadata(messageId, timestamp) {
319
- if (messageId === void 0 && timestamp === void 0) return void 0;
320
- return {
321
- ...messageId === void 0 ? {} : { messageId },
322
- ...timestamp === void 0 ? {} : { timestamp }
323
- };
324
- }
325
563
  function listKeys(storage, prefix) {
326
564
  try {
327
565
  return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter(
@@ -334,6 +572,120 @@ function listKeys(storage, prefix) {
334
572
  function readAllByPrefix(storage, prefix) {
335
573
  return listKeys(storage, prefix).map((key) => ({ key, value: readJson(storage, key) })).filter((entry) => entry.value !== null);
336
574
  }
575
+
576
+ // src/utils/validation.ts
577
+ function assertPositiveSafeInteger(value, name) {
578
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
579
+ throw new TypeError(`${name} must be a positive safe integer, got ${String(value)}.`);
580
+ }
581
+ }
582
+ function assertPositiveFiniteNumber(value, name) {
583
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
584
+ throw new TypeError(`${name} must be a positive finite number.`);
585
+ }
586
+ }
587
+ function assertNonNegativeFiniteNumber(value, name) {
588
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
589
+ throw new TypeError(`${name} must be a non-negative finite number.`);
590
+ }
591
+ }
592
+ function assertPruneStrategy(value) {
593
+ const allowed = [PRUNE_STRATEGY.COUNT, PRUNE_STRATEGY.AGE, PRUNE_STRATEGY.BOTH];
594
+ if (!allowed.includes(String(value))) {
595
+ throw new TypeError("pruneStrategy must be count, age, or both.");
596
+ }
597
+ }
598
+ function assertReplayOptions(replay) {
599
+ if (!replay) return;
600
+ if (replay.maxPerTopic !== void 0) assertPositiveSafeInteger(replay.maxPerTopic, "replay.maxPerTopic");
601
+ if (replay.pruneStrategy !== void 0) assertPruneStrategy(replay.pruneStrategy);
602
+ if (replay.retentionMs !== void 0) assertPositiveFiniteNumber(replay.retentionMs, "replay.retentionMs");
603
+ if (replay.retentionSweepMs !== void 0) {
604
+ assertPositiveFiniteNumber(replay.retentionSweepMs, "replay.retentionSweepMs");
605
+ }
606
+ if (replay.persistenceRetry) assertPersistenceRetryOptions(replay.persistenceRetry);
607
+ }
608
+ function assertPersistenceRetryOptions(retry) {
609
+ if (retry.maxAttempts !== void 0) {
610
+ assertPositiveSafeInteger(retry.maxAttempts, "replay.persistenceRetry.maxAttempts");
611
+ }
612
+ if (retry.backoffMs !== void 0) {
613
+ assertNonNegativeFiniteNumber(retry.backoffMs, "replay.persistenceRetry.backoffMs");
614
+ }
615
+ }
616
+ function assertDedupOptions(dedup) {
617
+ if (!dedup) return;
618
+ if (dedup.maxEntries !== void 0) assertPositiveSafeInteger(dedup.maxEntries, "dedup.maxEntries");
619
+ if (dedup.ttlMs !== void 0) assertPositiveFiniteNumber(dedup.ttlMs, "dedup.ttlMs");
620
+ if (dedup.sweepMs !== void 0) assertPositiveFiniteNumber(dedup.sweepMs, "dedup.sweepMs");
621
+ const bounds = dedup.adaptiveTtl;
622
+ if (bounds !== void 0) {
623
+ const finite = (value) => typeof value === "number" && Number.isFinite(value);
624
+ if (!finite(bounds.minMs) || !finite(bounds.maxMs) || bounds.minMs <= 0 || bounds.maxMs < bounds.minMs) {
625
+ throw new TypeError("dedup.adaptiveTtl bounds are invalid.");
626
+ }
627
+ }
628
+ }
629
+ function assertRecoveryOptions(recovery) {
630
+ if (!recovery) return;
631
+ if (recovery.cooldownMs !== void 0) {
632
+ assertPositiveFiniteNumber(recovery.cooldownMs, "recovery.cooldownMs");
633
+ }
634
+ const maxAttempts = recovery.maxAttempts;
635
+ if (maxAttempts !== void 0 && !(maxAttempts === Number.POSITIVE_INFINITY || typeof maxAttempts === "number" && Number.isSafeInteger(maxAttempts) && maxAttempts > 0)) {
636
+ throw new TypeError("recovery.maxAttempts must be a positive safe integer.");
637
+ }
638
+ }
639
+ function assertLoadWeightingOptions(loadWeighting) {
640
+ if (!loadWeighting) return;
641
+ const weights = {
642
+ messageRateWeight: loadWeighting.messageRateWeight,
643
+ byteRateWeight: loadWeighting.byteRateWeight,
644
+ scheduleLagWeight: loadWeighting.scheduleLagWeight
645
+ };
646
+ for (const [name, value] of Object.entries(weights)) {
647
+ if (value !== void 0) assertNonNegativeFiniteNumber(value, `loadWeighting.${name}`);
648
+ }
649
+ }
650
+ function assertClusterOptions(options) {
651
+ if (options.maxActiveWorkers !== void 0) {
652
+ assertPositiveSafeInteger(options.maxActiveWorkers, "maxActiveWorkers");
653
+ }
654
+ if (options.heartbeatIntervalMs !== void 0) {
655
+ assertPositiveFiniteNumber(options.heartbeatIntervalMs, "heartbeatIntervalMs");
656
+ }
657
+ if (options.workerTtlMs !== void 0) {
658
+ assertPositiveFiniteNumber(options.workerTtlMs, "workerTtlMs");
659
+ }
660
+ if (options.routeOwnerCacheMax !== void 0) {
661
+ assertPositiveSafeInteger(options.routeOwnerCacheMax, "routeOwnerCacheMax");
662
+ }
663
+ assertLoadWeightingOptions(options.loadWeighting);
664
+ }
665
+ function assertHeartbeatInterval(value) {
666
+ if (value === Infinity) return;
667
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return;
668
+ throw new TypeError(
669
+ `Centrifuge heartbeatIntervalMs must be a positive number or Infinity, got ${String(value)}.`
670
+ );
671
+ }
672
+ function assertStructuredCloneable(value) {
673
+ if (typeof structuredClone !== "function") return;
674
+ try {
675
+ structuredClone(value);
676
+ } catch (error) {
677
+ throw new TypeError(
678
+ "Centrifuge Worker configuration and published data must be structured-cloneable.",
679
+ { cause: error }
680
+ );
681
+ }
682
+ }
683
+
684
+ // src/core/cluster.ts
685
+ var CLUSTER_PROTOCOL_VERSION = 1;
686
+ var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
687
+ var DEFAULT_WORKER_TTL_MS = 1e4;
688
+ var MAX_KNOWN_TOPICS = 500;
337
689
  var WorkerClusterRuntime = class {
338
690
  tabId;
339
691
  workerId;
@@ -347,6 +699,14 @@ var WorkerClusterRuntime = class {
347
699
  routePrefix;
348
700
  subscriberPrefix;
349
701
  channelName;
702
+ /** Adaptive load weighting options; undefined keeps legacy topic-count routing. */
703
+ loadWeighting;
704
+ /** Rolling traffic accumulator folded into the worker record on writeRecord. */
705
+ throughputWindow = {
706
+ startedAt: 0,
707
+ messageCount: 0,
708
+ byteCount: 0
709
+ };
350
710
  // Topics this tab has subscribed to (local interest, plaintext).
351
711
  subscribedTopics = /* @__PURE__ */ new Set();
352
712
  // Topics assigned to this Worker as owner (topicKey → topic). Authoritative:
@@ -382,12 +742,14 @@ var WorkerClusterRuntime = class {
382
742
  lifecycleListening = false;
383
743
  currentRecord;
384
744
  constructor(options) {
745
+ assertClusterOptions(options);
385
746
  this.environment = options.environment ?? createBrowserEnvironment();
386
747
  this.handlers = options.handlers;
387
748
  this.maxActiveWorkers = options.maxActiveWorkers ?? DEFAULT_MAX_ACTIVE_WORKERS;
388
749
  this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
389
750
  this.routeOwnerCacheMax = options.routeOwnerCacheMax ?? 256;
390
751
  this.workerTtlMs = options.workerTtlMs ?? DEFAULT_WORKER_TTL_MS;
752
+ this.loadWeighting = options.loadWeighting;
391
753
  const clusterHash = createOpaqueKey(options.clusterKey || "__default__");
392
754
  const prefix = options.storagePrefix ?? DEFAULT_STORAGE_PREFIX;
393
755
  const baseKey = `${prefix}:${clusterHash}`;
@@ -404,8 +766,8 @@ var WorkerClusterRuntime = class {
404
766
  workerId: this.workerId,
405
767
  tabId: this.tabId,
406
768
  load: 0,
407
- role: "standby",
408
- status: "connecting",
769
+ role: WORKER_ROLE.STANDBY,
770
+ status: WORKER_STATUS.CONNECTING,
409
771
  visibilityState: this.environment.getVisibilityState(),
410
772
  heartbeatAt: now,
411
773
  registeredAt: now
@@ -458,7 +820,7 @@ var WorkerClusterRuntime = class {
458
820
  this.writeRecord(true);
459
821
  for (const topic of this.subscribedTopics) {
460
822
  const topicKey = this.rememberTopic(topic);
461
- if (!this.storage) this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
823
+ if (!this.storage) this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
462
824
  else this.writeSubscriber(topicKey);
463
825
  }
464
826
  this.reconcile();
@@ -486,9 +848,14 @@ var WorkerClusterRuntime = class {
486
848
  this.removeStorage(this.workerStorageKey(this.workerId));
487
849
  this.flushStorage();
488
850
  this.notifyRegistry();
489
- this.channel?.close();
851
+ const channel = this.channel;
490
852
  this.channel = null;
491
853
  this.handlers.onSuspend?.();
854
+ if (typeof globalThis.setTimeout === "function") {
855
+ globalThis.setTimeout(() => channel?.close(), 0);
856
+ } else {
857
+ channel?.close();
858
+ }
492
859
  }
493
860
  /** Update the worker's connection status and persist the change. */
494
861
  setStatus(status) {
@@ -506,7 +873,7 @@ var WorkerClusterRuntime = class {
506
873
  this.subscribedTopics.add(topic);
507
874
  if (!this.started) return false;
508
875
  if (!this.storage) {
509
- this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
876
+ this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
510
877
  return true;
511
878
  }
512
879
  this.writeSubscriber(topicKey);
@@ -516,9 +883,9 @@ var WorkerClusterRuntime = class {
516
883
  return existingRoute?.workerId === this.workerId;
517
884
  }
518
885
  const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
519
- const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
886
+ const owner = selectLeastLoadedWorker(activeWorkers, void 0, this.loadWeighting) ?? this.currentRecord;
520
887
  this.writeRoute(topicKey, owner, void 0, (existingRoute?.generation ?? 0) + 1);
521
- this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
888
+ this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
522
889
  this.notifyRegistry();
523
890
  return owner.workerId === this.workerId;
524
891
  }
@@ -542,7 +909,7 @@ var WorkerClusterRuntime = class {
542
909
  const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
543
910
  if (subscribers.length === 0) {
544
911
  this.removeStorage(this.routeStorageKey(topicKey));
545
- if (notifyOwner) this.sendControl(route.workerId, "UNSUBSCRIBE", topic, topicKey);
912
+ if (notifyOwner) this.sendControl(route.workerId, CONTROL_ACTION.UNSUBSCRIBE, topic, topicKey);
546
913
  }
547
914
  return topicKey;
548
915
  }
@@ -561,15 +928,17 @@ var WorkerClusterRuntime = class {
561
928
  continue;
562
929
  }
563
930
  const owner = selectLeastLoadedWorker(
564
- activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load }))
931
+ activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load })),
932
+ void 0,
933
+ this.loadWeighting
565
934
  );
566
935
  if (!owner) continue;
567
936
  projectedLoads.set(owner.workerId, (projectedLoads.get(owner.workerId) ?? owner.load) + 1);
568
937
  const generation = (previous?.generation ?? 0) + 1;
569
938
  this.writeRoute(topicKey, owner, previous?.workerId, generation);
570
- this.handlers.onDiagnostic?.({ operation: "route_migration", topic });
939
+ this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_MIGRATION, topic });
571
940
  this.flushStorage();
572
- this.handlers.onControl("UNSUBSCRIBE", topic);
941
+ this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, topic);
573
942
  this.sendRouteReleased(owner.workerId, topic, topicKey, generation);
574
943
  }
575
944
  }
@@ -577,22 +946,19 @@ var WorkerClusterRuntime = class {
577
946
  const metadata = typeof metadataOrMessageId === "string" ? { messageId: metadataOrMessageId } : metadataOrMessageId;
578
947
  const topicKey = this.rememberTopic(topic);
579
948
  if (this.assignedTopics.has(topicKey)) {
580
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
949
+ return this.sendControl(this.workerId, CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
581
950
  }
582
951
  const cachedPattern = this.wildcardPublishCache.get(topic);
583
- if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
584
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
585
- }
586
952
  if (cachedPattern === void 0) {
587
953
  for (const pattern of this.assignedTopics.values()) {
588
954
  if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
589
955
  this.wildcardPublishCache.set(topic, pattern);
590
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
956
+ return this.sendControl(this.workerId, CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
591
957
  }
592
958
  }
593
959
  this.wildcardPublishCache.set(topic, null);
594
960
  }
595
- return this.sendControl(this.resolvePublishTarget(topic, topicKey), "PUBLISH", topic, topicKey, data, metadata);
961
+ return this.sendControl(this.resolvePublishTarget(topic, topicKey), CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
596
962
  }
597
963
  /**
598
964
  * Burst-friendly variant of `publish()`: packs up to N items into a single
@@ -613,19 +979,15 @@ var WorkerClusterRuntime = class {
613
979
  }
614
980
  const topicKey = this.rememberTopic(topic);
615
981
  if (this.assignedTopics.has(topicKey)) {
616
- for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
982
+ this.dispatchLocalPublishBatch(topic, topicKey, items);
617
983
  return true;
618
984
  }
619
985
  const cachedPattern = this.wildcardPublishCache.get(topic);
620
- if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
621
- for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
622
- return true;
623
- }
624
986
  if (cachedPattern === void 0) {
625
987
  for (const pattern of this.assignedTopics.values()) {
626
988
  if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
627
989
  this.wildcardPublishCache.set(topic, pattern);
628
- for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
990
+ this.dispatchLocalPublishBatch(topic, topicKey, items);
629
991
  return true;
630
992
  }
631
993
  }
@@ -633,14 +995,14 @@ var WorkerClusterRuntime = class {
633
995
  }
634
996
  const target = this.resolvePublishTarget(topic, topicKey);
635
997
  if (target === this.workerId) {
636
- for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
998
+ this.dispatchLocalPublishBatch(topic, topicKey, items);
637
999
  return true;
638
1000
  }
639
1001
  return this.send({
640
- type: "CONTROL",
1002
+ type: CLUSTER_MESSAGE_TYPE.CONTROL,
641
1003
  sourceWorkerId: this.workerId,
642
1004
  targetWorkerId: target,
643
- action: "PUBLISH",
1005
+ action: CONTROL_ACTION.PUBLISH,
644
1006
  topic,
645
1007
  topicKey,
646
1008
  items: items.map((item) => ({
@@ -671,8 +1033,17 @@ var WorkerClusterRuntime = class {
671
1033
  dispatchLocalPublish(topic, topicKey, data, messageId, timestamp) {
672
1034
  void topicKey;
673
1035
  const meta = publicationMetadata(messageId, timestamp);
674
- if (meta) this.handlers.onControl("PUBLISH", topic, data, meta.messageId, meta.timestamp);
675
- else this.handlers.onControl("PUBLISH", topic, data);
1036
+ if (meta) this.handlers.onControl(CONTROL_ACTION.PUBLISH, topic, data, meta.messageId, meta.timestamp);
1037
+ else this.handlers.onControl(CONTROL_ACTION.PUBLISH, topic, data);
1038
+ }
1039
+ /** Fan out a publication batch to the local onControl path: one
1040
+ * onPublishBatch call when the owner supports it, per-item otherwise. */
1041
+ dispatchLocalPublishBatch(topic, topicKey, items) {
1042
+ if (this.handlers.onPublishBatch) {
1043
+ this.handlers.onPublishBatch(topic, items);
1044
+ return;
1045
+ }
1046
+ for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
676
1047
  }
677
1048
  /** True when `route` exists and its owner worker is among `workers`.
678
1049
  * Shared by subscribe (skip re-assignment) and publish (route to owner).
@@ -686,7 +1057,39 @@ var WorkerClusterRuntime = class {
686
1057
  * listeners can attribute the event to its source tab even after fan-out. */
687
1058
  broadcastEvent(eventType, payload, originTabId) {
688
1059
  const effectiveOriginTabId = originTabId ?? this.tabId;
689
- this.send({ type: "EVENT", sourceWorkerId: this.workerId, eventType, payload, originTabId: effectiveOriginTabId });
1060
+ this.recordTraffic(payload);
1061
+ this.send({ type: CLUSTER_MESSAGE_TYPE.EVENT, sourceWorkerId: this.workerId, eventType, payload, originTabId: effectiveOriginTabId });
1062
+ }
1063
+ /** Count one fan-out unit toward the adaptive load sample. No-op unless
1064
+ * adaptive weighting is configured. */
1065
+ recordTraffic(payload) {
1066
+ if (this.loadWeighting === void 0) return;
1067
+ this.throughputWindow.messageCount += 1;
1068
+ this.throughputWindow.byteCount += approximatePayloadBytes(payload);
1069
+ }
1070
+ /** Convert the accumulated window into a publishable throughput sample and
1071
+ * reset the accumulator. Returns undefined until a full window has elapsed
1072
+ * so the first write does not emit a zero-width sample. */
1073
+ sampleThroughput(now) {
1074
+ if (this.throughputWindow.startedAt === 0) {
1075
+ this.throughputWindow.startedAt = now;
1076
+ return void 0;
1077
+ }
1078
+ const windowMs = now - this.throughputWindow.startedAt;
1079
+ if (windowMs <= 0) return void 0;
1080
+ const sample = {
1081
+ windowMs,
1082
+ messageCount: this.throughputWindow.messageCount,
1083
+ byteCount: this.throughputWindow.byteCount,
1084
+ // How much later the heartbeat landed than its nominal interval. This
1085
+ // window is anchored at the previous writeRecord (one heartbeat tick),
1086
+ // so a starved event loop stretches windowMs past the interval and the
1087
+ // positive excess is the scheduling-overrun signal.
1088
+ overrunMs: Math.max(0, windowMs - this.heartbeatIntervalMs),
1089
+ sampledAt: now
1090
+ };
1091
+ this.throughputWindow = { startedAt: now, messageCount: 0, byteCount: 0 };
1092
+ return sample;
690
1093
  }
691
1094
  isAssigned(topic) {
692
1095
  const topicKey = createOpaqueKey(topic);
@@ -776,14 +1179,14 @@ var WorkerClusterRuntime = class {
776
1179
  const message = event.data;
777
1180
  if (!message || message.sourceWorkerId === this.workerId) return;
778
1181
  switch (message.type) {
779
- case "CONTROL":
1182
+ case CLUSTER_MESSAGE_TYPE.CONTROL:
780
1183
  return this.handleControlMessage(message);
781
- case "ROUTE_RELEASED":
1184
+ case CLUSTER_MESSAGE_TYPE.ROUTE_RELEASED:
782
1185
  return this.handleRouteReleasedMessage(message);
783
- case "EVENT":
1186
+ case CLUSTER_MESSAGE_TYPE.EVENT:
784
1187
  this.handlers.onEvent(message.eventType, message.payload, message.sourceWorkerId, message.originTabId);
785
1188
  return;
786
- case "REGISTRY":
1189
+ case CLUSTER_MESSAGE_TYPE.REGISTRY:
787
1190
  this.reconcile();
788
1191
  return;
789
1192
  default: {
@@ -800,19 +1203,23 @@ var WorkerClusterRuntime = class {
800
1203
  if (message.targetWorkerId !== this.workerId) return;
801
1204
  this.rememberTopic(message.topic);
802
1205
  switch (message.action) {
803
- case "SUBSCRIBE":
1206
+ case CONTROL_ACTION.SUBSCRIBE:
804
1207
  this.assignedTopics.set(message.topicKey, message.topic);
805
1208
  this.confirmRoute(message.topicKey);
806
1209
  break;
807
- case "UNSUBSCRIBE":
1210
+ case CONTROL_ACTION.UNSUBSCRIBE:
808
1211
  if (this.releaseHandoffOnUnsubscribe(message)) return;
809
1212
  break;
810
- case "PUBLISH":
1213
+ case CONTROL_ACTION.PUBLISH:
811
1214
  if (message.items && message.items.length > 0) {
1215
+ if (this.handlers.onPublishBatch) {
1216
+ this.handlers.onPublishBatch(message.topic, message.items);
1217
+ return;
1218
+ }
812
1219
  for (const item of message.items) {
813
1220
  const itemMeta = publicationMetadata(item.messageId, item.timestamp);
814
- if (itemMeta) this.handlers.onControl("PUBLISH", message.topic, item.data, itemMeta.messageId, itemMeta.timestamp);
815
- else this.handlers.onControl("PUBLISH", message.topic, item.data);
1221
+ if (itemMeta) this.handlers.onControl(CONTROL_ACTION.PUBLISH, message.topic, item.data, itemMeta.messageId, itemMeta.timestamp);
1222
+ else this.handlers.onControl(CONTROL_ACTION.PUBLISH, message.topic, item.data);
816
1223
  }
817
1224
  return;
818
1225
  }
@@ -829,7 +1236,7 @@ var WorkerClusterRuntime = class {
829
1236
  metadata.timestamp
830
1237
  );
831
1238
  else this.handlers.onControl(message.action, message.topic, message.data);
832
- if (message.action !== "PUBLISH") this.updateLoad();
1239
+ if (message.action !== CONTROL_ACTION.PUBLISH) this.updateLoad();
833
1240
  }
834
1241
  /**
835
1242
  * When this worker is the previous owner in a graceful handoff and the new
@@ -841,7 +1248,7 @@ var WorkerClusterRuntime = class {
841
1248
  this.assignedTopics.delete(message.topicKey);
842
1249
  const route = this.readRoute(message.topicKey);
843
1250
  if (route?.handoffFromWorkerId !== this.workerId) return false;
844
- this.handlers.onControl("UNSUBSCRIBE", message.topic, void 0);
1251
+ this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, message.topic, void 0);
845
1252
  this.sendRouteReleased(route.workerId, message.topic, message.topicKey, route.generation);
846
1253
  this.updateLoad();
847
1254
  return true;
@@ -850,7 +1257,7 @@ var WorkerClusterRuntime = class {
850
1257
  * generation so only the matching new owner may act on it. */
851
1258
  sendRouteReleased(targetWorkerId, topic, topicKey, generation) {
852
1259
  this.send({
853
- type: "ROUTE_RELEASED",
1260
+ type: CLUSTER_MESSAGE_TYPE.ROUTE_RELEASED,
854
1261
  sourceWorkerId: this.workerId,
855
1262
  targetWorkerId,
856
1263
  topic,
@@ -869,14 +1276,24 @@ var WorkerClusterRuntime = class {
869
1276
  if (!route || this.isStaleRouteRelease(route, message)) return;
870
1277
  this.assignedTopics.set(message.topicKey, message.topic);
871
1278
  this.confirmRoute(message.topicKey);
872
- this.handlers.onControl("SUBSCRIBE", message.topic, void 0);
1279
+ this.handlers.onControl(CONTROL_ACTION.SUBSCRIBE, message.topic, void 0);
873
1280
  this.updateLoad();
874
1281
  }
875
1282
  /** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
876
1283
  * points to us, the release comes from the recorded previous owner, and
877
- * the release generation is at least as new as ours. */
1284
+ * the release generation is at least as new as ours. A replayed ACK from an
1285
+ * earlier handoff round (e.g. an a↔b ping-pong) carries an older generation
1286
+ * and must not confirm the current round. */
878
1287
  isStaleRouteRelease(route, message) {
879
- return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || route.generation < message.generation;
1288
+ return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation < route.generation;
1289
+ }
1290
+ /** True when an unconfirmed handoff route has been stuck longer than a
1291
+ * worker TTL. The ACK for a live handoff is posted synchronously with the
1292
+ * route write, so anything older than the TTL with a dead previous owner
1293
+ * will never complete — while a fresh unconfirmed route may simply be
1294
+ * waiting out its confirmation flush and must be left alone. */
1295
+ isStaleHandoff(route) {
1296
+ return this.environment.now() - route.updatedAt > this.workerTtlMs;
880
1297
  }
881
1298
  /** Full reconciliation cycle: workers, subscriptions, and assigned topics. */
882
1299
  reconcile() {
@@ -909,20 +1326,39 @@ var WorkerClusterRuntime = class {
909
1326
  */
910
1327
  reconcileSubscriptions(workers, activeWorkers) {
911
1328
  const liveWorkerIds = new Set(workers.map((worker) => worker.workerId));
1329
+ const recoveryProjectedLoads = /* @__PURE__ */ new Map();
912
1330
  for (const topic of this.subscribedTopics) {
913
1331
  const topicKey = this.rememberTopic(topic);
914
1332
  this.writeSubscriber(topicKey);
915
1333
  const route = this.readRoute(topicKey);
916
1334
  if (!route || !liveWorkerIds.has(route.workerId)) {
917
- const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
1335
+ const owner = selectLeastLoadedWorker(activeWorkers, void 0, this.loadWeighting) ?? this.currentRecord;
918
1336
  this.writeRoute(topicKey, owner, void 0, (route?.generation ?? 0) + 1);
919
- this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
1337
+ this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
920
1338
  this.notifyRegistry();
921
1339
  continue;
922
1340
  }
923
1341
  if (route.confirmedAt === void 0) {
924
1342
  if (!route.handoffFromWorkerId) {
925
- this.sendControl(route.workerId, "SUBSCRIBE", topic, topicKey);
1343
+ this.sendControl(route.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
1344
+ } else if (!liveWorkerIds.has(route.handoffFromWorkerId) && this.isStaleHandoff(route)) {
1345
+ const owner = selectLeastLoadedWorker(
1346
+ activeWorkers.map((worker) => ({ ...worker, load: recoveryProjectedLoads.get(worker.workerId) ?? worker.load })),
1347
+ void 0,
1348
+ this.loadWeighting
1349
+ ) ?? this.currentRecord;
1350
+ recoveryProjectedLoads.set(owner.workerId, (recoveryProjectedLoads.get(owner.workerId) ?? owner.load) + 1);
1351
+ if (owner.workerId !== this.workerId) {
1352
+ const subscriberTabIds = new Set(this.readSubscriberTabIds(topicKey, workers));
1353
+ const ownerSubscribed = workers.some(
1354
+ (worker) => worker.workerId === owner.workerId && subscriberTabIds.has(worker.tabId)
1355
+ );
1356
+ if (ownerSubscribed) continue;
1357
+ }
1358
+ this.writeRoute(topicKey, owner, void 0, route.generation + 1);
1359
+ this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
1360
+ this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_MIGRATION_RECOVERY, topic });
1361
+ this.notifyRegistry();
926
1362
  }
927
1363
  }
928
1364
  }
@@ -933,7 +1369,7 @@ var WorkerClusterRuntime = class {
933
1369
  const route = this.readRoute(topicKey);
934
1370
  if (route?.workerId === this.workerId) continue;
935
1371
  this.assignedTopics.delete(topicKey);
936
- this.handlers.onControl("UNSUBSCRIBE", topic, void 0);
1372
+ this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, topic, void 0);
937
1373
  if (route?.handoffFromWorkerId === this.workerId) {
938
1374
  this.sendRouteReleased(route.workerId, topic, topicKey, route.generation);
939
1375
  }
@@ -948,14 +1384,14 @@ var WorkerClusterRuntime = class {
948
1384
  sendControl(targetWorkerId, action, topic, topicKey, data, metadata) {
949
1385
  if (targetWorkerId === this.workerId) {
950
1386
  switch (action) {
951
- case "SUBSCRIBE":
1387
+ case CONTROL_ACTION.SUBSCRIBE:
952
1388
  this.assignedTopics.set(topicKey, topic);
953
1389
  this.confirmRoute(topicKey);
954
1390
  break;
955
- case "UNSUBSCRIBE":
1391
+ case CONTROL_ACTION.UNSUBSCRIBE:
956
1392
  this.assignedTopics.delete(topicKey);
957
1393
  break;
958
- case "PUBLISH":
1394
+ case CONTROL_ACTION.PUBLISH:
959
1395
  default:
960
1396
  break;
961
1397
  }
@@ -967,11 +1403,11 @@ var WorkerClusterRuntime = class {
967
1403
  metadata.timestamp
968
1404
  );
969
1405
  else this.handlers.onControl(action, topic, data);
970
- if (action !== "PUBLISH") this.updateLoad();
1406
+ if (action !== CONTROL_ACTION.PUBLISH) this.updateLoad();
971
1407
  return true;
972
1408
  }
973
1409
  return this.send({
974
- type: "CONTROL",
1410
+ type: CLUSTER_MESSAGE_TYPE.CONTROL,
975
1411
  sourceWorkerId: this.workerId,
976
1412
  targetWorkerId,
977
1413
  action,
@@ -1076,7 +1512,7 @@ var WorkerClusterRuntime = class {
1076
1512
  confirmedAt: this.environment.now()
1077
1513
  });
1078
1514
  const topic = this.knownTopics.get(topicKey);
1079
- if (topic) this.handlers.onDiagnostic?.({ operation: "route_ack", topic });
1515
+ if (topic) this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_ACK, topic });
1080
1516
  }
1081
1517
  /** Remove routes whose topic has no subscribers and whose TTL has expired. */
1082
1518
  cleanupOrphanedRoutes(workers) {
@@ -1111,17 +1547,23 @@ var WorkerClusterRuntime = class {
1111
1547
  * avoid a REGISTRY storm every 3 s; true on status/role changes that
1112
1548
  * peers should observe promptly. */
1113
1549
  writeRecord(notify) {
1114
- this.currentRecord = { ...this.currentRecord, heartbeatAt: this.environment.now() };
1550
+ const now = this.environment.now();
1551
+ const sample = this.loadWeighting !== void 0 ? this.sampleThroughput(now) : void 0;
1552
+ this.currentRecord = {
1553
+ ...this.currentRecord,
1554
+ heartbeatAt: now,
1555
+ ...sample ? { throughput: sample } : {}
1556
+ };
1115
1557
  if (this.storage) writeJson(this.storage, this.workerStorageKey(this.workerId), this.currentRecord);
1116
1558
  if (notify) this.notifyRegistry();
1117
1559
  }
1118
1560
  /** Broadcast a REGISTRY message to trigger reconciliation on other tabs. */
1119
1561
  notifyRegistry() {
1120
- this.send({ type: "REGISTRY", sourceWorkerId: this.workerId });
1562
+ this.send({ type: CLUSTER_MESSAGE_TYPE.REGISTRY, sourceWorkerId: this.workerId });
1121
1563
  }
1122
1564
  /** Recompute whether this worker is active (eligible to own topics) or standby. Returns true when changed. */
1123
1565
  refreshRole(workers) {
1124
- const role = this.isActiveAmong(workers) ? "active" : "standby";
1566
+ const role = this.isActiveAmong(workers) ? WORKER_ROLE.ACTIVE : WORKER_ROLE.STANDBY;
1125
1567
  if (role === this.currentRecord.role) return false;
1126
1568
  this.currentRecord = { ...this.currentRecord, role };
1127
1569
  return true;
@@ -1219,7 +1661,7 @@ var DataBusTraceReporter = class {
1219
1661
  /** Start the periodic metrics flush interval. No-op when mode is 'events'
1220
1662
  * (no metrics to emit), when disabled, or when already running. */
1221
1663
  start() {
1222
- if (!this.enabled || this.intervalHandle || this.mode === "events") return;
1664
+ if (!this.enabled || this.intervalHandle || this.mode === TRACE_MODE.EVENTS) return;
1223
1665
  this.stopped = false;
1224
1666
  this.intervalStartedAt = this.now();
1225
1667
  this.intervalHandle = setInterval(() => this.flush(), this.metricsIntervalMs);
@@ -1235,11 +1677,40 @@ var DataBusTraceReporter = class {
1235
1677
  this.stopped = true;
1236
1678
  this.pause();
1237
1679
  }
1680
+ /** Synchronous sink state for diagnostics: whether delivery is async and how
1681
+ * many events are queued behind the microtask flush. A growing queue under
1682
+ * `asyncSink: true` is the first sign of sink back-pressure. */
1683
+ getSinkState() {
1684
+ return { asyncSink: this.asyncSink, pendingEvents: this.pendingEvents.length };
1685
+ }
1238
1686
  /** Record an instantaneous trace event (lifecycle, status, error, etc.). */
1239
1687
  event(event) {
1240
- if (!this.enabled || this.mode === "metrics") return;
1688
+ if (!this.enabled || this.mode === TRACE_MODE.METRICS) return;
1241
1689
  this.emit({ ...event, timestamp: this.now() });
1242
1690
  }
1691
+ /** Synchronous snapshot of the current metrics window without resetting it.
1692
+ * Returns the same derived counters as a periodic `message_metrics` event,
1693
+ * or null when metrics recording is inactive (disabled or events-only mode).
1694
+ * The window keeps accumulating until the next interval flush. */
1695
+ getMetrics() {
1696
+ if (!this.metricsActive || this.stopped) return null;
1697
+ const timestamp = this.now();
1698
+ const samples = this.latencySamples;
1699
+ return {
1700
+ durationMs: Math.max(0, timestamp - this.intervalStartedAt),
1701
+ received: this.received,
1702
+ dispatched: this.dispatched,
1703
+ topics: this.topics.size,
1704
+ dispatchSamples: samples,
1705
+ dispatchAvgMs: roundMs(samples === 0 ? 0 : this.latencySumMs / samples),
1706
+ dispatchP50Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.5)),
1707
+ dispatchP95Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.95)),
1708
+ dispatchMaxMs: roundMs(percentileMs(this.latencyBuckets, samples, 1)),
1709
+ dedupAccepted: this.dedupAccepted,
1710
+ dedupSuppressed: this.dedupSuppressed,
1711
+ timestamp
1712
+ };
1713
+ }
1243
1714
  /** Record that a message was received on `topic`; stores its timestamp for latency tracking. */
1244
1715
  recordReceived(topic) {
1245
1716
  if (!this.metricsActive) return;
@@ -1297,7 +1768,7 @@ var DataBusTraceReporter = class {
1297
1768
  * Extracted so the four record / flush methods share one guard expression
1298
1769
  * instead of repeating `!this.enabled || this.mode === 'events'` at each. */
1299
1770
  get metricsActive() {
1300
- return this.enabled && this.mode !== "events";
1771
+ return this.enabled && this.mode !== TRACE_MODE.EVENTS;
1301
1772
  }
1302
1773
  /** Emit the accumulated metrics snapshot if the interval is active. */
1303
1774
  flush() {
@@ -1309,7 +1780,7 @@ var DataBusTraceReporter = class {
1309
1780
  if (this.received > 0 || this.dispatched > 0 || this.dedupAccepted > 0 || this.dedupSuppressed > 0) {
1310
1781
  const samples = this.latencySamples;
1311
1782
  this.emit({
1312
- type: "message_metrics",
1783
+ type: TRACE_EVENT_TYPE.MESSAGE_METRICS,
1313
1784
  durationMs: Math.max(0, timestamp - this.intervalStartedAt),
1314
1785
  received: this.received,
1315
1786
  dispatched: this.dispatched,
@@ -1360,7 +1831,7 @@ var DataBusTraceReporter = class {
1360
1831
  this.sink(event);
1361
1832
  } catch (error) {
1362
1833
  if (typeof console !== "undefined" && typeof console.warn === "function") {
1363
- console.warn("[cross-tab-worker-databus] trace sink threw:", error);
1834
+ console.warn(`[${DEFAULT_STORAGE_PREFIX}] trace sink threw:`, error);
1364
1835
  }
1365
1836
  }
1366
1837
  }
@@ -1386,187 +1857,578 @@ function roundMs(value) {
1386
1857
  return Math.round(value * 10) / 10;
1387
1858
  }
1388
1859
 
1389
- // src/core/data-bus.ts
1390
- var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
1391
- var SDK_VERSION = "0.20.69";
1392
- var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
1860
+ // src/core/replay-manager.ts
1393
1861
  var PersistenceRetryCancelledError = class extends Error {
1394
1862
  constructor() {
1395
1863
  super("Persistence retry cancelled by lifecycle transition.");
1396
1864
  this.name = "PersistenceRetryCancelledError";
1397
1865
  }
1398
1866
  };
1399
- var CrossTabDataBus = class {
1400
- transport;
1401
- cluster;
1402
- // Map of topic → set of local subscribers.
1403
- topicHandlers = /* @__PURE__ */ new Map();
1404
- // Topics for which the transport has been asked to subscribe (used to avoid
1405
- // duplicate subscribe calls during reconnection).
1406
- transportSubscribedTopics = /* @__PURE__ */ new Set();
1407
- statusHandlers = /* @__PURE__ */ new Set();
1408
- errorHandlers = /* @__PURE__ */ new Set();
1409
- // Bounded per-topic ring of recent dispatched publications. Null unless
1410
- // replay is enabled — buffering is opt-in and must cost nothing otherwise.
1411
- replayBuffers;
1412
- replayMaxPerTopic;
1413
- replayPersistence;
1414
- replayRetentionMs;
1415
- replayPruneStrategy;
1416
- replayRetentionSweepMs;
1867
+ var MAX_RETRY_DELAY_MS2 = 1600;
1868
+ var ReplayManager = class {
1869
+ constructor(deps) {
1870
+ this.deps = deps;
1871
+ this.buffers = deps.enabled ? /* @__PURE__ */ new Map() : null;
1872
+ this.maxPerTopic = deps.maxPerTopic;
1873
+ this.persistence = deps.persistence ?? null;
1874
+ this.retentionMs = deps.retentionMs;
1875
+ this.pruneStrategy = deps.pruneStrategy;
1876
+ this.retentionSweepMs = deps.retentionSweepMs;
1877
+ this.persistenceRetryMaxAttempts = deps.persistenceRetryMaxAttempts;
1878
+ this.persistenceRetryBackoffMs = deps.persistenceRetryBackoffMs;
1879
+ this.now = deps.now;
1880
+ this.trace = deps.trace;
1881
+ this.onPersistenceError = deps.onPersistenceError;
1882
+ this.onDispatchError = deps.onDispatchError;
1883
+ this.hydration = this.hydrate();
1884
+ }
1885
+ deps;
1886
+ buffers;
1887
+ maxPerTopic;
1888
+ persistence;
1889
+ retentionMs;
1890
+ pruneStrategy;
1891
+ retentionSweepMs;
1417
1892
  persistenceRetryMaxAttempts;
1418
1893
  persistenceRetryBackoffMs;
1419
- persistenceRetryGeneration = 0;
1420
- pendingReplayPersistence = [];
1421
- replayPersistenceFlushScheduled = false;
1422
- replayHydration;
1423
- // Retention cleanup is coalesced so a burst of publications does not issue
1424
- // one IndexedDB read/write transaction per message. The newest cutoff wins.
1425
- replayRetentionCleanup = null;
1426
- replayRetentionCutoff = null;
1427
- replayRetentionTimer = null;
1428
- initialConfig;
1429
- hasInitialConfig;
1430
- trace;
1431
- dedupMaxEntries;
1432
- dedupTtlMs;
1433
- dedupAdaptiveBounds;
1434
- dedupWindowStartedAt = 0;
1435
- dedupWindowAccepted = 0;
1436
- dedupAdaptiveWindowMs = 5e3;
1437
- dedupSweepMs;
1438
- dedupSweepTimer = null;
1439
- dedupEnabled;
1440
1894
  now;
1441
- seenMessageIds = /* @__PURE__ */ new Map();
1442
- dedupSuppressed = 0;
1443
- dedupAccepted = 0;
1444
- activeConfig;
1445
- status = "disconnected";
1446
- started = false;
1447
- stopping = false;
1448
- transportReady = false;
1449
- // Last transport failure, retained so ready() can surface it to callers who
1450
- // never awaited start() directly. Cleared on the next successful start.
1451
- lastError = null;
1452
- lastErrorAt = null;
1453
- // Gate that serialises start/stop/suspend/resume only one lifecycle
1454
- // transition at a time. Resets to null once the operation settles.
1455
- startPromise = null;
1456
- // Timestamp of the last automatic transport recovery attempt.
1457
- // Used to avoid a tight retry loop when the transport fails repeatedly.
1458
- lastRecoveryAt = 0;
1459
- // Monotonic attempt number within one runtime recovery sequence; reset once
1460
- // a transport reopen succeeds so traces can correlate repeated failures.
1461
- recoveryAttempt = 0;
1462
- recoveryExhausted = false;
1463
- /** Monotonic generation incremented on every successful transport open.
1464
- * Stays in lockstep with `lastSuccessAt` so callers can detect that the
1465
- * transport has been reopened even if the timestamp window is short. */
1466
- recoveryGeneration = 0;
1467
- /** Timestamp of the most recent successful transport open. Null until the
1468
- * transport has reached the `ready` state at least once. */
1469
- lastSuccessAt = null;
1470
- // True while the tab is hidden so an in-flight transport start does not mark
1471
- // the transport ready after suspendTransport() has stopped it.
1472
- suspended = false;
1473
- // Single gate for async transport.stop() cleanup, shared by failed opens and
1474
- // page-hide suspension. Kept separate from startPromise so ready() still
1475
- // surfaces a failure while later opens and automatic recovery wait for the
1476
- // stop to settle.
1477
- pendingStop = null;
1478
- // Minimum interval in ms between automatic recovery attempts.
1479
- recoveryCooldownMs;
1480
- recoveryMaxAttempts;
1481
- constructor(options) {
1482
- const replay = options.replay;
1483
- if (replay) {
1484
- const maxPerTopic = replay.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1485
- if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
1486
- throw new TypeError(
1487
- `replay.maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`
1488
- );
1895
+ trace;
1896
+ onPersistenceError;
1897
+ onDispatchError;
1898
+ /** Bumped on suspend/stop so in-flight persistence retries are cancelled. */
1899
+ retryGeneration = 0;
1900
+ pendingReplayPersistence = [];
1901
+ persistenceFlushScheduled = false;
1902
+ hydration;
1903
+ /** Coalesced retention cleanup: the newest cutoff wins while one is running. */
1904
+ retentionCleanup = null;
1905
+ retentionCutoff = null;
1906
+ retentionTimer = null;
1907
+ /** True when replay buffering is enabled. */
1908
+ get enabled() {
1909
+ return this.buffers !== null;
1910
+ }
1911
+ /** Append a dispatched publication to the topic's replay ring buffer.
1912
+ * No-op when replay is disabled. */
1913
+ record(message) {
1914
+ if (!this.buffers) return;
1915
+ let buffer = this.buffers.get(message.topic);
1916
+ if (!buffer) {
1917
+ buffer = [];
1918
+ this.buffers.set(message.topic, buffer);
1919
+ }
1920
+ buffer.push(message);
1921
+ const ageBounded = this.pruneStrategy !== PRUNE_STRATEGY.COUNT && this.retentionMs !== void 0;
1922
+ if (this.pruneStrategy !== PRUNE_STRATEGY.AGE || !ageBounded) {
1923
+ while (buffer.length > this.maxPerTopic) buffer.shift();
1924
+ }
1925
+ if (ageBounded) {
1926
+ const cutoff = this.now() - this.retentionMs;
1927
+ while (buffer.length > 0) {
1928
+ const first = buffer[0];
1929
+ if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
1930
+ buffer.shift();
1489
1931
  }
1490
1932
  }
1491
- this.replayMaxPerTopic = replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1492
- this.replayBuffers = replay ? /* @__PURE__ */ new Map() : null;
1493
- this.replayPersistence = replay?.persistence ?? null;
1494
- this.replayRetentionMs = replay?.retentionMs;
1495
- this.replayPruneStrategy = replay?.pruneStrategy ?? "count";
1496
- if (!["count", "age", "both"].includes(this.replayPruneStrategy)) throw new TypeError("replay.pruneStrategy must be count, age, or both.");
1497
- if (this.replayRetentionMs !== void 0 && (!Number.isFinite(this.replayRetentionMs) || this.replayRetentionMs <= 0)) {
1498
- throw new TypeError("replay.retentionMs must be a positive finite number.");
1933
+ if (!this.persistence) return;
1934
+ if (this.persistence.appendBatch) {
1935
+ this.pendingReplayPersistence.push(message);
1936
+ this.schedulePersistenceFlush();
1937
+ } else {
1938
+ void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.append(message)).catch((error) => this.onPersistenceError(error));
1499
1939
  }
1500
- this.replayRetentionSweepMs = replay?.retentionSweepMs;
1501
- if (this.replayRetentionSweepMs !== void 0 && (!Number.isFinite(this.replayRetentionSweepMs) || this.replayRetentionSweepMs <= 0)) {
1502
- throw new TypeError("replay.retentionSweepMs must be a positive finite number.");
1940
+ if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
1941
+ this.scheduleRetentionCleanup(this.now() - this.retentionMs);
1503
1942
  }
1504
- this.persistenceRetryMaxAttempts = replay?.persistenceRetry?.maxAttempts ?? 1;
1505
- this.persistenceRetryBackoffMs = replay?.persistenceRetry?.backoffMs ?? 50;
1506
- if (!Number.isSafeInteger(this.persistenceRetryMaxAttempts) || this.persistenceRetryMaxAttempts <= 0) {
1507
- throw new TypeError("replay.persistenceRetry.maxAttempts must be a positive safe integer.");
1943
+ }
1944
+ /** Deliver buffered history to a newly-registered handler. For an exact
1945
+ * topic this is that topic's ring; for a wildcard subscription every
1946
+ * buffered topic matching the pattern contributes (in buffer insertion
1947
+ * order). Replay deliveries are marked `replayed: true` and are not counted
1948
+ * into trace metrics.
1949
+ *
1950
+ * When a durable persistence backend is present, delivery waits for the
1951
+ * hydration load to settle first; `isHandlerActive` is then consulted so a
1952
+ * handler that unsubscribed during the async load does not receive history.
1953
+ */
1954
+ deliverReplay(topic, replayOption, handler, isHandlerActive) {
1955
+ if (!this.buffers) return;
1956
+ const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
1957
+ if (this.persistence) {
1958
+ void this.hydration.then(() => {
1959
+ if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
1960
+ });
1961
+ return;
1508
1962
  }
1509
- if (!Number.isFinite(this.persistenceRetryBackoffMs) || this.persistenceRetryBackoffMs < 0) {
1510
- throw new TypeError("replay.persistenceRetry.backoffMs must be a non-negative finite number.");
1963
+ this.deliver(topic, limit, handler);
1964
+ }
1965
+ /** Clean up a topic that lost its last local handler: drop the ring buffer,
1966
+ * filter queued batch flushes (so an in-flight append cannot undo the
1967
+ * clearTopic), and prune durable history. */
1968
+ onTopicUnsubscribed(topic) {
1969
+ if (!this.buffers) return;
1970
+ this.buffers.delete(topic);
1971
+ this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
1972
+ if (this.persistence?.clearTopic) {
1973
+ void this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)).catch((error) => this.onPersistenceError(error));
1974
+ }
1975
+ }
1976
+ /** Clear all in-memory replay buffers and, when supported, durable history.
1977
+ * Reports persistence failures and rethrows, mirroring the public API
1978
+ * contract that callers can observe a failed clear. */
1979
+ async clearAll() {
1980
+ if (!this.buffers) return;
1981
+ this.buffers.clear();
1982
+ this.pendingReplayPersistence = [];
1983
+ if (this.persistence?.clear) {
1984
+ try {
1985
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear());
1986
+ } catch (error) {
1987
+ this.onPersistenceError(error);
1988
+ throw error;
1989
+ }
1511
1990
  }
1512
- const { autoStart, initialConfig, trace, transport, dedup, recovery, ...clusterOptions } = options;
1513
- this.recoveryCooldownMs = recovery?.cooldownMs ?? 1e3;
1514
- this.recoveryMaxAttempts = recovery?.maxAttempts ?? Number.POSITIVE_INFINITY;
1515
- if (!Number.isFinite(this.recoveryCooldownMs) || this.recoveryCooldownMs <= 0) {
1516
- throw new TypeError("recovery.cooldownMs must be a positive finite number.");
1991
+ }
1992
+ /** Clear replay history for one exact topic, including durable storage. */
1993
+ async clearTopic(topic) {
1994
+ if (!this.buffers) return;
1995
+ this.buffers.delete(topic);
1996
+ this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
1997
+ if (this.persistence?.clearTopic) {
1998
+ try {
1999
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic));
2000
+ } catch (error) {
2001
+ this.onPersistenceError(error);
2002
+ throw error;
2003
+ }
1517
2004
  }
1518
- if (!(this.recoveryMaxAttempts === Number.POSITIVE_INFINITY || Number.isSafeInteger(this.recoveryMaxAttempts) && this.recoveryMaxAttempts > 0)) {
1519
- throw new TypeError("recovery.maxAttempts must be a positive safe integer.");
2005
+ }
2006
+ /** Remove replay entries older than an epoch-millisecond cutoff. */
2007
+ async clearBefore(timestamp) {
2008
+ if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
2009
+ if (this.buffers) {
2010
+ for (const [topic, messages] of this.buffers) {
2011
+ const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
2012
+ if (kept.length) this.buffers.set(topic, kept);
2013
+ else this.buffers.delete(topic);
2014
+ }
1520
2015
  }
1521
- this.now = dedup?.now ?? Date.now;
1522
- this.replayHydration = this.hydrateReplay();
1523
- this.transport = transport;
1524
- this.initialConfig = initialConfig;
1525
- this.hasInitialConfig = "initialConfig" in options;
1526
- this.trace = new DataBusTraceReporter(trace);
1527
- this.dedupMaxEntries = dedup?.maxEntries ?? 1e3;
1528
- this.dedupTtlMs = dedup?.ttlMs ?? 6e4;
1529
- this.dedupAdaptiveBounds = dedup?.adaptiveTtl;
1530
- if (this.dedupAdaptiveBounds && (this.dedupAdaptiveBounds.minMs <= 0 || this.dedupAdaptiveBounds.maxMs < this.dedupAdaptiveBounds.minMs)) throw new TypeError("dedup.adaptiveTtl bounds are invalid.");
1531
- this.dedupWindowStartedAt = this.now();
1532
- this.dedupSweepMs = dedup?.sweepMs;
1533
- this.dedupEnabled = dedup !== void 0;
1534
- if (!Number.isSafeInteger(this.dedupMaxEntries) || this.dedupMaxEntries <= 0) {
1535
- throw new TypeError("dedup.maxEntries must be a positive safe integer.");
1536
- }
1537
- if (!Number.isFinite(this.dedupTtlMs) || this.dedupTtlMs <= 0) {
1538
- throw new TypeError("dedup.ttlMs must be a positive finite number.");
1539
- }
1540
- if (this.dedupSweepMs !== void 0 && (!Number.isFinite(this.dedupSweepMs) || this.dedupSweepMs <= 0)) {
1541
- throw new TypeError("dedup.sweepMs must be a positive finite number.");
2016
+ this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
2017
+ (message) => message.timestamp === void 0 || message.timestamp >= timestamp
2018
+ );
2019
+ if (this.persistence?.clearBefore) {
2020
+ try {
2021
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp));
2022
+ } catch (error) {
2023
+ this.onPersistenceError(error);
2024
+ throw error;
2025
+ }
1542
2026
  }
1543
- this.cluster = new WorkerClusterRuntime({
1544
- ...clusterOptions,
1545
- handlers: {
1546
- // The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
1547
- // control message meaning the owning Worker has delegated the action to us.
1548
- onControl: (action, topic, data, messageId, timestamp) => {
1549
- switch (action) {
1550
- case "SUBSCRIBE":
1551
- if (this.subscribeTransport(topic)) this.traceSubscription("subscribe", topic);
1552
- break;
1553
- case "UNSUBSCRIBE":
1554
- if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
1555
- break;
1556
- case "PUBLISH":
1557
- this.runTransport(() => this.transport.publish(
1558
- topic,
1559
- data,
1560
- messageId === void 0 && timestamp === void 0 ? void 0 : {
1561
- ...messageId === void 0 ? {} : { messageId },
1562
- ...timestamp === void 0 ? {} : { timestamp }
1563
- }
1564
- ));
2027
+ }
2028
+ /** Start the periodic retention sweep. No-op when no durable retention
2029
+ * config makes it necessary. */
2030
+ start() {
2031
+ if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
2032
+ this.retentionTimer = setInterval(() => {
2033
+ this.scheduleRetentionCleanup(this.now() - this.retentionMs);
2034
+ }, this.retentionSweepMs);
2035
+ }
2036
+ /** Stop the periodic retention sweep. */
2037
+ stop() {
2038
+ if (this.retentionTimer) clearInterval(this.retentionTimer);
2039
+ this.retentionTimer = null;
2040
+ }
2041
+ /** Suspend the manager: cancel in-flight persistence retries (so a hidden tab
2042
+ * or stopped bus does not keep hammering the store) and stop the sweep. */
2043
+ suspend() {
2044
+ this.retryGeneration += 1;
2045
+ this.stop();
2046
+ }
2047
+ /** Drop all in-memory buffers (used on full teardown). */
2048
+ resetBuffers() {
2049
+ this.buffers?.clear();
2050
+ }
2051
+ /** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
2052
+ * payload footprint (same heuristic as adaptive load weighting), computed on
2053
+ * demand so the hot append path never pays for it. */
2054
+ getStats() {
2055
+ let messages = 0;
2056
+ let bytes = 0;
2057
+ if (this.buffers) {
2058
+ for (const buffer of this.buffers.values()) {
2059
+ messages += buffer.length;
2060
+ for (const message of buffer) bytes += approximatePayloadBytes(message.data);
2061
+ }
2062
+ }
2063
+ return { enabled: this.enabled, topics: this.buffers?.size ?? 0, messages, bytes };
2064
+ }
2065
+ /** Deliver history from one topic's ring to a handler, isolating a throwing
2066
+ * handler so the remaining buffers are still delivered. */
2067
+ deliver(topic, limit, handler) {
2068
+ if (!this.buffers || limit <= 0) return;
2069
+ const deliverBuffer = (buffer2) => {
2070
+ for (const message of buffer2.slice(-limit)) {
2071
+ try {
2072
+ handler({ ...message, replayed: true });
2073
+ } catch (error) {
2074
+ this.onDispatchError(error);
2075
+ }
2076
+ }
2077
+ };
2078
+ if (isWildcardTopic(topic)) {
2079
+ for (const [bufferedTopic, buffer2] of this.buffers) {
2080
+ if (topicMatchesPattern(topic, bufferedTopic)) deliverBuffer(buffer2);
2081
+ }
2082
+ return;
2083
+ }
2084
+ const buffer = this.buffers.get(topic);
2085
+ if (buffer) deliverBuffer(buffer);
2086
+ }
2087
+ /** Coalesce queued persistence appends into a single microtask batch so a
2088
+ * burst of publications does not issue one IndexedDB transaction each.
2089
+ * Only reachable when the backend advertises `appendBatch` (the sole queuer,
2090
+ * `record()`, guards on it), so the batched path is unconditional here. */
2091
+ schedulePersistenceFlush() {
2092
+ if (this.persistenceFlushScheduled) return;
2093
+ this.persistenceFlushScheduled = true;
2094
+ queueMicrotask(() => {
2095
+ this.persistenceFlushScheduled = false;
2096
+ const batch = this.pendingReplayPersistence.splice(0);
2097
+ if (batch.length === 0 || !this.persistence) return;
2098
+ void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
2099
+ });
2100
+ }
2101
+ /** Load durable history into the in-memory rings once at startup, pruning
2102
+ * entries past the retention window first. Failures are reported but do not
2103
+ * block startup — the bus runs with whatever survived. */
2104
+ async hydrate() {
2105
+ if (!this.buffers || !this.persistence) {
2106
+ return;
2107
+ }
2108
+ try {
2109
+ if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
2110
+ await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
2111
+ }
2112
+ for (const message of await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load())) {
2113
+ let buffer = this.buffers.get(message.topic);
2114
+ if (!buffer) {
2115
+ buffer = [];
2116
+ this.buffers.set(message.topic, buffer);
2117
+ }
2118
+ buffer.push(message);
2119
+ if (buffer.length > this.maxPerTopic) buffer.shift();
2120
+ }
2121
+ } catch (error) {
2122
+ this.onPersistenceError(error);
2123
+ }
2124
+ }
2125
+ /** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
2126
+ * so a burst of publications issues at most one clearBefore transaction. */
2127
+ scheduleRetentionCleanup(cutoff) {
2128
+ if (!this.persistence?.clearBefore) return;
2129
+ if (this.retentionCutoff === null || cutoff > this.retentionCutoff) {
2130
+ this.retentionCutoff = cutoff;
2131
+ }
2132
+ if (this.retentionCleanup) return;
2133
+ this.retentionCleanup = (async () => {
2134
+ while (this.retentionCutoff !== null) {
2135
+ const nextCutoff = this.retentionCutoff;
2136
+ this.retentionCutoff = null;
2137
+ try {
2138
+ await this.persistence.clearBefore(nextCutoff);
2139
+ } catch (error) {
2140
+ this.onPersistenceError(error);
2141
+ }
2142
+ }
2143
+ })().finally(() => {
2144
+ this.retentionCleanup = null;
2145
+ if (this.retentionCutoff !== null) {
2146
+ this.scheduleRetentionCleanup(this.retentionCutoff);
2147
+ }
2148
+ });
2149
+ }
2150
+ /** Run a persistence operation with exponential backoff on transient failure.
2151
+ * Bumped `retryGeneration` (suspend/stop) cancels the loop early; a
2152
+ * structurally failing operation throws after `persistenceRetryMaxAttempts`,
2153
+ * leaving the ring buffer intact so the bus keeps working. */
2154
+ async withPersistenceRetry(persistenceOperation, operation) {
2155
+ const generation = this.retryGeneration;
2156
+ let attempt = 0;
2157
+ let delay = this.persistenceRetryBackoffMs;
2158
+ while (true) {
2159
+ attempt += 1;
2160
+ try {
2161
+ if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
2162
+ return await operation();
2163
+ } catch (error) {
2164
+ if (error instanceof PersistenceRetryCancelledError || generation !== this.retryGeneration) {
2165
+ throw new PersistenceRetryCancelledError();
2166
+ }
2167
+ if (attempt >= this.persistenceRetryMaxAttempts) throw error;
2168
+ this.trace.event({
2169
+ type: TRACE_EVENT_TYPE.RELIABILITY,
2170
+ operation: RELIABILITY_OPERATION.PERSISTENCE_RETRY,
2171
+ persistenceOperation,
2172
+ attempt
2173
+ });
2174
+ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
2175
+ if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
2176
+ delay = Math.min(delay * 2, MAX_RETRY_DELAY_MS2);
2177
+ }
2178
+ }
2179
+ }
2180
+ };
2181
+
2182
+ // src/core/dedup-manager.ts
2183
+ var ADAPTIVE_WINDOW_MS = 5e3;
2184
+ var QUIET_RATE_PER_MS = 0.01;
2185
+ var DedupManager = class {
2186
+ enabled;
2187
+ maxEntries;
2188
+ ttlMs;
2189
+ adaptiveBounds;
2190
+ sweepMs;
2191
+ now;
2192
+ trace;
2193
+ seenMessageIds = /* @__PURE__ */ new Map();
2194
+ sweepTimer = null;
2195
+ windowStartedAt = 0;
2196
+ windowAccepted = 0;
2197
+ suppressed = 0;
2198
+ accepted = 0;
2199
+ constructor(options) {
2200
+ this.enabled = options.enabled;
2201
+ this.maxEntries = options.maxEntries;
2202
+ this.ttlMs = options.ttlMs;
2203
+ this.adaptiveBounds = options.adaptiveBounds;
2204
+ this.sweepMs = options.sweepMs;
2205
+ this.now = options.now;
2206
+ this.trace = options.trace;
2207
+ this.windowStartedAt = this.now();
2208
+ }
2209
+ /** True when a publication carrying `messageId` was already seen. Records the
2210
+ * ID and updates counters/trace on acceptance. Disabled or ID-less messages
2211
+ * always pass through. */
2212
+ isDuplicate(messageId, topic) {
2213
+ if (!this.enabled || !messageId) return false;
2214
+ const now = this.now();
2215
+ const ttlMs = this.currentTtl();
2216
+ for (const [id, timestamp] of this.seenMessageIds) {
2217
+ if (now - timestamp > ttlMs) this.seenMessageIds.delete(id);
2218
+ }
2219
+ if (this.seenMessageIds.has(messageId)) {
2220
+ this.suppressed += 1;
2221
+ this.trace.event({
2222
+ type: TRACE_EVENT_TYPE.RELIABILITY,
2223
+ operation: RELIABILITY_OPERATION.DEDUP_SUPPRESSED,
2224
+ topic
2225
+ });
2226
+ this.trace.recordDedupSuppressed();
2227
+ return true;
2228
+ }
2229
+ this.seenMessageIds.set(messageId, now);
2230
+ this.accepted += 1;
2231
+ this.windowAccepted += 1;
2232
+ this.trace.recordDedupAccepted();
2233
+ while (this.seenMessageIds.size > this.maxEntries) {
2234
+ const oldest = this.seenMessageIds.keys().next().value;
2235
+ if (oldest === void 0) break;
2236
+ this.seenMessageIds.delete(oldest);
2237
+ }
2238
+ return false;
2239
+ }
2240
+ /** Start the periodic expiry sweep. No-op when disabled or no sweepMs was
2241
+ * configured (the hot-path opportunistic expiry still bounds the map). */
2242
+ start() {
2243
+ if (this.sweepTimer || !this.enabled || !this.sweepMs) return;
2244
+ this.sweepTimer = setInterval(() => this.pruneExpired(), this.sweepMs);
2245
+ }
2246
+ /** Stop the periodic expiry sweep. */
2247
+ stop() {
2248
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
2249
+ this.sweepTimer = null;
2250
+ }
2251
+ /** Return bounded deduplication counters for diagnostics and health checks. */
2252
+ getStats() {
2253
+ return {
2254
+ enabled: this.enabled,
2255
+ tracked: this.seenMessageIds.size,
2256
+ suppressed: this.suppressed,
2257
+ accepted: this.accepted,
2258
+ ...this.adaptiveBounds ? { ttlMs: this.currentTtl() } : {}
2259
+ };
2260
+ }
2261
+ /** Drop all remembered IDs and reset dedup counters. */
2262
+ reset() {
2263
+ this.seenMessageIds.clear();
2264
+ this.suppressed = 0;
2265
+ this.accepted = 0;
2266
+ this.windowStartedAt = this.now();
2267
+ this.windowAccepted = 0;
2268
+ }
2269
+ /** Remove IDs whose timestamp predates the effective TTL cutoff. */
2270
+ pruneExpired() {
2271
+ const cutoff = this.now() - this.currentTtl();
2272
+ for (const [id, timestamp] of this.seenMessageIds) {
2273
+ if (timestamp < cutoff) this.seenMessageIds.delete(id);
2274
+ }
2275
+ }
2276
+ /** Effective TTL: the fixed `ttlMs` unless adaptive bounds are configured, in
2277
+ * which case a higher recent message rate shortens the window (dedup only
2278
+ * needs to live long enough to bridge duplicate bursts). The window resets
2279
+ * every ADAPTIVE_WINDOW_MS. */
2280
+ currentTtl() {
2281
+ if (!this.adaptiveBounds) return this.ttlMs;
2282
+ const now = this.now();
2283
+ const elapsed = now - this.windowStartedAt;
2284
+ if (elapsed >= ADAPTIVE_WINDOW_MS) {
2285
+ this.windowStartedAt = now;
2286
+ this.windowAccepted = 0;
2287
+ return this.adaptiveBounds.maxMs;
2288
+ }
2289
+ const rate = this.windowAccepted / Math.max(1, elapsed);
2290
+ const factor = Math.min(1, rate / QUIET_RATE_PER_MS);
2291
+ return this.adaptiveBounds.maxMs - (this.adaptiveBounds.maxMs - this.adaptiveBounds.minMs) * factor;
2292
+ }
2293
+ };
2294
+
2295
+ // src/core/version.ts
2296
+ var SDK_VERSION = true ? "0.20.85" : "";
2297
+
2298
+ // src/core/data-bus.ts
2299
+ var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
2300
+ var CrossTabDataBus = class {
2301
+ transport;
2302
+ cluster;
2303
+ // Map of topic → set of local subscribers.
2304
+ topicHandlers = /* @__PURE__ */ new Map();
2305
+ // Topics for which the transport has been asked to subscribe (used to avoid
2306
+ // duplicate subscribe calls during reconnection).
2307
+ transportSubscribedTopics = /* @__PURE__ */ new Set();
2308
+ statusHandlers = /* @__PURE__ */ new Set();
2309
+ errorHandlers = /* @__PURE__ */ new Set();
2310
+ replayManager;
2311
+ initialConfig;
2312
+ hasInitialConfig;
2313
+ trace;
2314
+ dedupManager;
2315
+ now;
2316
+ activeConfig;
2317
+ status = WORKER_STATUS.DISCONNECTED;
2318
+ started = false;
2319
+ stopping = false;
2320
+ transportReady = false;
2321
+ // Last transport failure, retained so ready() can surface it to callers who
2322
+ // never awaited start() directly. Cleared on the next successful start.
2323
+ lastError = null;
2324
+ lastErrorAt = null;
2325
+ // Unified failure ledger for the health summary: the most recent failure of
2326
+ // any source (transport, persistence, dispatch) since the last explicit start.
2327
+ lastFailure = null;
2328
+ persistenceFailureCount = 0;
2329
+ persistenceLastFailureAt = null;
2330
+ persistenceLastErrorMessage = null;
2331
+ // Gate that serialises start/stop/suspend/resume — only one lifecycle
2332
+ // transition at a time. Resets to null once the operation settles.
2333
+ startPromise = null;
2334
+ // Timestamp of the last automatic transport recovery attempt.
2335
+ // Used to avoid a tight retry loop when the transport fails repeatedly.
2336
+ lastRecoveryAt = 0;
2337
+ // Monotonic attempt number within one runtime recovery sequence; reset once
2338
+ // a transport reopen succeeds so traces can correlate repeated failures.
2339
+ recoveryAttempt = 0;
2340
+ recoveryExhausted = false;
2341
+ /** Monotonic generation incremented on every successful transport open.
2342
+ * Stays in lockstep with `lastSuccessAt` so callers can detect that the
2343
+ * transport has been reopened even if the timestamp window is short. */
2344
+ recoveryGeneration = 0;
2345
+ /** Timestamp of the most recent successful transport open. Null until the
2346
+ * transport has reached the `ready` state at least once. */
2347
+ lastSuccessAt = null;
2348
+ // True while the tab is hidden so an in-flight transport start does not mark
2349
+ // the transport ready after suspendTransport() has stopped it.
2350
+ suspended = false;
2351
+ // Single gate for async transport.stop() cleanup, shared by failed opens and
2352
+ // page-hide suspension. Kept separate from startPromise so ready() still
2353
+ // surfaces a failure while later opens and automatic recovery wait for the
2354
+ // stop to settle.
2355
+ pendingStop = null;
2356
+ // Minimum interval in ms between automatic recovery attempts.
2357
+ recoveryCooldownMs;
2358
+ recoveryMaxAttempts;
2359
+ constructor(options) {
2360
+ const replay = options.replay;
2361
+ assertReplayOptions(replay);
2362
+ const { autoStart, initialConfig, trace, transport, dedup, recovery, ...clusterOptions } = options;
2363
+ assertRecoveryOptions(recovery);
2364
+ this.recoveryCooldownMs = recovery?.cooldownMs ?? 1e3;
2365
+ this.recoveryMaxAttempts = recovery?.maxAttempts ?? Number.POSITIVE_INFINITY;
2366
+ this.now = dedup?.now ?? Date.now;
2367
+ this.transport = transport;
2368
+ this.initialConfig = initialConfig;
2369
+ this.hasInitialConfig = "initialConfig" in options;
2370
+ this.trace = new DataBusTraceReporter(trace);
2371
+ this.replayManager = new ReplayManager({
2372
+ enabled: replay !== void 0,
2373
+ maxPerTopic: replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC,
2374
+ persistence: replay?.persistence ?? null,
2375
+ retentionMs: replay?.retentionMs,
2376
+ pruneStrategy: replay?.pruneStrategy ?? PRUNE_STRATEGY.COUNT,
2377
+ retentionSweepMs: replay?.retentionSweepMs,
2378
+ persistenceRetryMaxAttempts: replay?.persistenceRetry?.maxAttempts ?? 1,
2379
+ persistenceRetryBackoffMs: replay?.persistenceRetry?.backoffMs ?? 50,
2380
+ now: this.now,
2381
+ trace: this.trace,
2382
+ onPersistenceError: (error) => this.reportPersistenceError(error),
2383
+ onDispatchError: (error) => this.reportError(error, FAILURE_SOURCE.DISPATCH)
2384
+ });
2385
+ assertDedupOptions(dedup);
2386
+ this.dedupManager = new DedupManager({
2387
+ enabled: dedup !== void 0,
2388
+ maxEntries: dedup?.maxEntries ?? 1e3,
2389
+ ttlMs: dedup?.ttlMs ?? 6e4,
2390
+ adaptiveBounds: dedup?.adaptiveTtl,
2391
+ sweepMs: dedup?.sweepMs,
2392
+ now: this.now,
2393
+ trace: this.trace
2394
+ });
2395
+ this.cluster = new WorkerClusterRuntime({
2396
+ ...clusterOptions,
2397
+ handlers: {
2398
+ // The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
2399
+ // control message — meaning the owning Worker has delegated the action to us.
2400
+ onControl: (action, topic, data, messageId, timestamp) => {
2401
+ switch (action) {
2402
+ case CONTROL_ACTION.SUBSCRIBE:
2403
+ if (this.subscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.SUBSCRIBE, topic);
2404
+ break;
2405
+ case CONTROL_ACTION.UNSUBSCRIBE:
2406
+ if (this.unsubscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.UNSUBSCRIBE, topic);
2407
+ break;
2408
+ case CONTROL_ACTION.PUBLISH:
2409
+ this.runTransport(() => this.transport.publish(topic, data, publicationMetadata(messageId, timestamp)));
1565
2410
  break;
1566
2411
  default:
1567
2412
  break;
1568
2413
  }
1569
2414
  },
2415
+ // Batched variant of the PUBLISH action (CONTROL frames carrying
2416
+ // multiple items, and the local publishBatch fast path). Uses the
2417
+ // transport's one-frame publishBatch when available, preserving
2418
+ // per-item metadata; otherwise falls back to per-item publishes.
2419
+ onPublishBatch: (topic, items) => {
2420
+ if (typeof this.transport.publishBatch === "function") {
2421
+ this.runTransport(() => this.transport.publishBatch(topic, items));
2422
+ return;
2423
+ }
2424
+ for (const item of items) {
2425
+ this.runTransport(() => this.transport.publish(
2426
+ topic,
2427
+ item.data,
2428
+ publicationMetadata(item.messageId, item.timestamp)
2429
+ ));
2430
+ }
2431
+ },
1570
2432
  // The cluster calls `onEvent` when a publication broadcast arrives from
1571
2433
  // another tab. Dispatch locally if we have subscribers. The payload is
1572
2434
  // typed `unknown` at the cluster boundary (the cluster is transport-
@@ -1579,22 +2441,21 @@ var CrossTabDataBus = class {
1579
2441
  if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
1580
2442
  },
1581
2443
  onSuspend: () => {
1582
- if (!this.stopping) this.trace.event({ type: "lifecycle", action: "suspend" });
2444
+ if (!this.stopping) this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.SUSPEND });
1583
2445
  this.trace.pause();
1584
- this.persistenceRetryGeneration += 1;
2446
+ this.replayManager.suspend();
1585
2447
  this.stopDedupSweep();
1586
- this.stopReplayRetentionSweep();
1587
2448
  this.suspendTransport();
1588
2449
  },
1589
2450
  onResume: () => {
1590
- this.trace.event({ type: "lifecycle", action: "resume" });
2451
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
1591
2452
  this.trace.start();
1592
2453
  this.startDedupSweep();
1593
- this.startReplayRetentionSweep();
2454
+ this.replayManager.start();
1594
2455
  this.resumeTransport();
1595
2456
  },
1596
2457
  onDiagnostic: (event) => {
1597
- this.trace.event({ type: "reliability", ...event });
2458
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, ...event });
1598
2459
  }
1599
2460
  }
1600
2461
  });
@@ -1616,27 +2477,24 @@ var CrossTabDataBus = class {
1616
2477
  this.suspended = false;
1617
2478
  this.activeConfig = config;
1618
2479
  this.lastError = null;
1619
- this.trace.event({ type: "lifecycle", action: "start" });
2480
+ this.lastFailure = null;
2481
+ this.persistenceFailureCount = 0;
2482
+ this.persistenceLastFailureAt = null;
2483
+ this.persistenceLastErrorMessage = null;
2484
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
1620
2485
  this.trace.start();
1621
2486
  this.startDedupSweep();
1622
- this.startReplayRetentionSweep();
1623
- this.updateStatus("connecting");
2487
+ this.replayManager.start();
2488
+ this.updateStatus(WORKER_STATUS.CONNECTING);
1624
2489
  this.cluster.start();
1625
2490
  const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
1626
2491
  this.startPromise = opening;
1627
2492
  for (const topic of this.topicHandlers.keys()) {
1628
2493
  this.cluster.subscribe(topic);
1629
2494
  }
1630
- const snapshot = this.cluster.getSnapshot();
1631
- this.trace.event({
1632
- type: "coordination",
1633
- coordinated: snapshot.coordinated,
1634
- activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
1635
- workers: snapshot.workers.map(formatWorkerTrace),
1636
- routes: snapshot.routes.map(formatRouteTrace)
1637
- });
1638
2495
  void opening.then(
1639
2496
  () => {
2497
+ this.emitCoordinationTrace();
1640
2498
  if (this.startPromise === opening) this.startPromise = null;
1641
2499
  },
1642
2500
  () => {
@@ -1663,7 +2521,7 @@ var CrossTabDataBus = class {
1663
2521
  onError: (error) => this.reportError(error)
1664
2522
  })
1665
2523
  ).then(() => {
1666
- if (this.status === "error") {
2524
+ if (this.status === WORKER_STATUS.ERROR) {
1667
2525
  throw new Error("Transport failed during startup.");
1668
2526
  }
1669
2527
  if (!this.suspended && !this.stopping) {
@@ -1677,7 +2535,7 @@ var CrossTabDataBus = class {
1677
2535
  if (!this.pendingStop) {
1678
2536
  this.pendingStop = this.createStopPromise();
1679
2537
  }
1680
- this.updateStatus("error");
2538
+ this.updateStatus(WORKER_STATUS.ERROR);
1681
2539
  this.reportError(error);
1682
2540
  this.lastError = error;
1683
2541
  this.lastErrorAt = this.now();
@@ -1722,17 +2580,12 @@ var CrossTabDataBus = class {
1722
2580
  this.topicHandlers.set(topic, handlers);
1723
2581
  if (wasUnused) this.cluster.subscribe(topic);
1724
2582
  if (options?.replay) {
1725
- const limit = Math.min(
1726
- typeof options.replay === "number" ? Math.floor(options.replay) : this.replayMaxPerTopic,
1727
- this.replayMaxPerTopic
2583
+ this.replayManager.deliverReplay(
2584
+ topic,
2585
+ options.replay,
2586
+ handler,
2587
+ () => Boolean(this.topicHandlers.get(topic)?.has(handler))
1728
2588
  );
1729
- if (this.replayPersistence) {
1730
- void this.replayHydration.then(() => {
1731
- if (this.topicHandlers.get(topic)?.has(handler)) this.deliverReplay(topic, limit, handler);
1732
- });
1733
- } else {
1734
- this.deliverReplay(topic, limit, handler);
1735
- }
1736
2589
  }
1737
2590
  return () => this.unsubscribe(topic, handler);
1738
2591
  }
@@ -1747,70 +2600,28 @@ var CrossTabDataBus = class {
1747
2600
  else handlers.clear();
1748
2601
  if (handlers.size > 0) return;
1749
2602
  this.topicHandlers.delete(topic);
1750
- this.replayBuffers?.delete(topic);
1751
- if (this.replayPersistence?.clearTopic) {
1752
- void this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic)).catch((error) => this.reportPersistenceError(error));
1753
- }
2603
+ this.replayManager.onTopicUnsubscribed(topic);
1754
2604
  this.cluster.unsubscribe(topic);
1755
2605
  }
1756
2606
  /** Clear all in-memory replay buffers and, when supported, durable history. */
1757
2607
  async clearReplay() {
1758
- this.replayBuffers?.clear();
1759
- if (this.replayPersistence?.clear) {
1760
- try {
1761
- await this.withPersistenceRetry("clear", () => this.replayPersistence.clear());
1762
- } catch (error) {
1763
- this.reportPersistenceError(error);
1764
- throw error;
1765
- }
1766
- }
2608
+ await this.replayManager.clearAll();
1767
2609
  }
1768
2610
  /** Clear replay history for one exact topic, including durable storage. */
1769
2611
  async clearReplayTopic(topic) {
1770
- this.replayBuffers?.delete(topic);
1771
- if (this.replayPersistence?.clearTopic) {
1772
- try {
1773
- await this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic));
1774
- } catch (error) {
1775
- this.reportPersistenceError(error);
1776
- throw error;
1777
- }
1778
- }
2612
+ await this.replayManager.clearTopic(topic);
1779
2613
  }
1780
2614
  /** Remove replay entries older than an epoch-millisecond cutoff. */
1781
2615
  async clearReplayBefore(timestamp) {
1782
- if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
1783
- if (this.replayBuffers) {
1784
- for (const [topic, messages] of this.replayBuffers) {
1785
- const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
1786
- if (kept.length) this.replayBuffers.set(topic, kept);
1787
- else this.replayBuffers.delete(topic);
1788
- }
1789
- }
1790
- if (this.replayPersistence?.clearBefore) {
1791
- try {
1792
- await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(timestamp));
1793
- } catch (error) {
1794
- this.reportPersistenceError(error);
1795
- throw error;
1796
- }
1797
- }
2616
+ await this.replayManager.clearBefore(timestamp);
1798
2617
  }
1799
2618
  /** Return bounded deduplication counters for diagnostics and health checks. */
1800
2619
  getDedupStats() {
1801
- return {
1802
- enabled: this.dedupEnabled,
1803
- tracked: this.seenMessageIds.size,
1804
- suppressed: this.dedupSuppressed,
1805
- accepted: this.dedupAccepted,
1806
- ...this.dedupAdaptiveBounds ? { ttlMs: this.currentDedupTtl() } : {}
1807
- };
2620
+ return this.dedupManager.getStats();
1808
2621
  }
1809
2622
  /** Drop all remembered IDs and reset dedup counters. */
1810
2623
  resetDedup() {
1811
- this.seenMessageIds.clear();
1812
- this.dedupSuppressed = 0;
1813
- this.dedupAccepted = 0;
2624
+ this.dedupManager.reset();
1814
2625
  }
1815
2626
  /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
1816
2627
  publish(topic, data, options) {
@@ -1838,8 +2649,7 @@ var CrossTabDataBus = class {
1838
2649
  }
1839
2650
  const mapped = items.map((item) => ({
1840
2651
  data: item.data,
1841
- ...item.options?.messageId !== void 0 ? { messageId: item.options.messageId } : {},
1842
- ...item.options?.timestamp !== void 0 ? { timestamp: item.options.timestamp } : {}
2652
+ ...publicationMetadata(item.options?.messageId, item.options?.timestamp)
1843
2653
  }));
1844
2654
  if (!this.cluster.publishBatch(topic, mapped)) {
1845
2655
  this.reportError(
@@ -1884,6 +2694,41 @@ var CrossTabDataBus = class {
1884
2694
  lastSuccessAt: this.lastSuccessAt
1885
2695
  };
1886
2696
  }
2697
+ /** Bounded failure counters for the replay persistence backend. */
2698
+ getPersistenceStats() {
2699
+ return {
2700
+ failures: this.persistenceFailureCount,
2701
+ lastFailureAt: this.persistenceLastFailureAt,
2702
+ lastErrorMessage: this.persistenceLastErrorMessage
2703
+ };
2704
+ }
2705
+ /** Compact health verdict for dashboards, readiness probes, and support
2706
+ * bundles. Answers "is the bus usable right now" first, then attaches the
2707
+ * unified failure ledger and recovery context that explains the verdict. */
2708
+ getHealthSummary() {
2709
+ const transport = this.transport;
2710
+ const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2711
+ 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;
2712
+ return {
2713
+ healthy: state === HEALTH_STATE.HEALTHY,
2714
+ state,
2715
+ status: this.status,
2716
+ sdkVersion: SDK_VERSION,
2717
+ started: this.started,
2718
+ suspended: this.suspended,
2719
+ transport: {
2720
+ name: transport.diagnosticsName ?? transport.constructor.name,
2721
+ backend: transport.diagnosticsBackend ?? null,
2722
+ ready: this.transportReady,
2723
+ status: this.status
2724
+ },
2725
+ recovery: this.getRecoveryStats(),
2726
+ lastFailure: this.lastFailure,
2727
+ persistence: this.getPersistenceStats(),
2728
+ metrics: this.trace.getMetrics(),
2729
+ trace: this.trace.getSinkState()
2730
+ };
2731
+ }
1887
2732
  /** Snapshot of the cluster state (workers, routes, assignments).
1888
2733
  * For diagnostics only — the returned object is a shallow copy but
1889
2734
  * nested arrays are snapshots at call time. */
@@ -1892,8 +2737,7 @@ var CrossTabDataBus = class {
1892
2737
  }
1893
2738
  /** Return a single health snapshot combining lifecycle, recovery, dedup, replay, and cluster state. */
1894
2739
  getDiagnostics() {
1895
- let messages = 0;
1896
- if (this.replayBuffers) for (const buffer of this.replayBuffers.values()) messages += buffer.length;
2740
+ const replay = this.replayManager.getStats();
1897
2741
  const cluster = this.cluster.getSnapshot();
1898
2742
  const unknownMessages = this.cluster.getUnknownMessageStats();
1899
2743
  const transport = this.transport;
@@ -1904,12 +2748,26 @@ var CrossTabDataBus = class {
1904
2748
  transportReady: this.transportReady,
1905
2749
  recovery: this.getRecoveryStats(),
1906
2750
  dedup: this.getDedupStats(),
1907
- replay: { enabled: Boolean(this.replayBuffers), topics: this.replayBuffers?.size ?? 0, messages },
2751
+ replay: { enabled: replay.enabled, topics: replay.topics, messages: replay.messages, bytes: replay.bytes },
2752
+ persistence: this.getPersistenceStats(),
1908
2753
  protocol: { version: cluster.protocolVersion, unknownMessages: unknownMessages.count, lastUnknownMessageType: unknownMessages.lastType, peers: cluster.peerProtocolVersions },
1909
- transport: { name: transport.diagnosticsName ?? transport.constructor.name, backend: transport.diagnosticsBackend ?? null },
1910
- cluster
2754
+ transport: {
2755
+ name: transport.diagnosticsName ?? transport.constructor.name,
2756
+ backend: transport.diagnosticsBackend ?? null,
2757
+ status: this.status,
2758
+ suspended: this.suspended
2759
+ },
2760
+ cluster,
2761
+ metrics: this.trace.getMetrics(),
2762
+ trace: this.trace.getSinkState()
1911
2763
  };
1912
2764
  }
2765
+ /** Synchronous snapshot of the current trace metrics window (throughput,
2766
+ * dispatch latency, dedup outcomes), without flushing or resetting it.
2767
+ * Returns null when trace metrics are inactive (disabled or events-only). */
2768
+ getMetrics() {
2769
+ return this.trace.getMetrics();
2770
+ }
1913
2771
  /**
1914
2772
  * Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
1915
2773
  * and close the transport. Idempotent.
@@ -1917,13 +2775,12 @@ var CrossTabDataBus = class {
1917
2775
  async stop() {
1918
2776
  if (!this.started) return;
1919
2777
  this.stopping = true;
1920
- this.persistenceRetryGeneration += 1;
1921
- this.trace.event({ type: "lifecycle", action: "stop" });
2778
+ this.replayManager.suspend();
2779
+ this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
1922
2780
  this.trace.stop();
1923
2781
  this.stopDedupSweep();
1924
- this.stopReplayRetentionSweep();
1925
2782
  this.topicHandlers.clear();
1926
- this.replayBuffers?.clear();
2783
+ this.replayManager.resetBuffers();
1927
2784
  this.cluster.stop();
1928
2785
  try {
1929
2786
  await this.startPromise?.catch(() => void 0);
@@ -1944,7 +2801,7 @@ var CrossTabDataBus = class {
1944
2801
  this.activeConfig = void 0;
1945
2802
  this.recoveryAttempt = 0;
1946
2803
  this.recoveryExhausted = false;
1947
- this.updateStatus("disconnected");
2804
+ this.updateStatus(WORKER_STATUS.DISCONNECTED);
1948
2805
  }
1949
2806
  }
1950
2807
  /**
@@ -1953,7 +2810,7 @@ var CrossTabDataBus = class {
1953
2810
  * and dispatches locally.
1954
2811
  */
1955
2812
  handleTransportMessage(message) {
1956
- if (this.isDuplicate(message)) return;
2813
+ if (this.dedupManager.isDuplicate(message.messageId ?? "", message.topic)) return;
1957
2814
  this.trace.recordReceived(message.topic);
1958
2815
  if (!this.cluster.isAssigned(message.topic)) {
1959
2816
  this.trace.recordDiscarded(message.topic);
@@ -1967,55 +2824,13 @@ var CrossTabDataBus = class {
1967
2824
  }
1968
2825
  this.trace.recordDiscarded(message.topic);
1969
2826
  }
1970
- isDuplicate(message) {
1971
- if (!this.dedupEnabled || !message.messageId) return false;
1972
- const now = this.now();
1973
- for (const [id, timestamp] of this.seenMessageIds) {
1974
- if (now - timestamp > this.dedupTtlMs) this.seenMessageIds.delete(id);
1975
- }
1976
- if (this.seenMessageIds.has(message.messageId)) {
1977
- this.trace.event({ type: "reliability", operation: "dedup_suppressed", topic: message.topic });
1978
- this.dedupSuppressed += 1;
1979
- this.trace.recordDedupSuppressed();
1980
- return true;
1981
- }
1982
- this.seenMessageIds.set(message.messageId, now);
1983
- this.dedupAccepted += 1;
1984
- this.dedupWindowAccepted += 1;
1985
- this.trace.recordDedupAccepted();
1986
- while (this.seenMessageIds.size > this.dedupMaxEntries) {
1987
- const oldest = this.seenMessageIds.keys().next().value;
1988
- if (oldest === void 0) break;
1989
- this.seenMessageIds.delete(oldest);
1990
- }
1991
- return false;
1992
- }
2827
+ /** Start enqueuing the dedup expiry sweep (delegated to {@link DedupManager}). */
1993
2828
  startDedupSweep() {
1994
- if (this.dedupSweepTimer || !this.dedupEnabled || !this.dedupSweepMs) return;
1995
- this.dedupSweepTimer = setInterval(() => this.pruneExpiredDedup(), this.dedupSweepMs);
2829
+ this.dedupManager.start();
1996
2830
  }
2831
+ /** Stop enqueuing the dedup expiry sweep. */
1997
2832
  stopDedupSweep() {
1998
- if (this.dedupSweepTimer) clearInterval(this.dedupSweepTimer);
1999
- this.dedupSweepTimer = null;
2000
- }
2001
- pruneExpiredDedup() {
2002
- const cutoff = this.now() - this.currentDedupTtl();
2003
- for (const [id, timestamp] of this.seenMessageIds) {
2004
- if (timestamp < cutoff) this.seenMessageIds.delete(id);
2005
- }
2006
- }
2007
- currentDedupTtl() {
2008
- if (!this.dedupAdaptiveBounds) return this.dedupTtlMs;
2009
- const now = this.now();
2010
- const elapsed = now - this.dedupWindowStartedAt;
2011
- if (elapsed >= this.dedupAdaptiveWindowMs) {
2012
- this.dedupWindowStartedAt = now;
2013
- this.dedupWindowAccepted = 0;
2014
- return this.dedupAdaptiveBounds.maxMs;
2015
- }
2016
- const rate = this.dedupWindowAccepted / Math.max(1, elapsed);
2017
- const factor = Math.min(1, rate / 0.01);
2018
- return this.dedupAdaptiveBounds.maxMs - (this.dedupAdaptiveBounds.maxMs - this.dedupAdaptiveBounds.minMs) * factor;
2833
+ this.dedupManager.stop();
2019
2834
  }
2020
2835
  /** Deliver a message to every local handler registered for its topic,
2021
2836
  * plus every handler registered with a wildcard subscription that matches
@@ -2028,153 +2843,7 @@ var CrossTabDataBus = class {
2028
2843
  this.invokeHandlers(handlers, (handler) => handler(message));
2029
2844
  }
2030
2845
  }
2031
- this.recordReplay(message);
2032
- }
2033
- /** Append a dispatched publication to the topic's replay ring buffer.
2034
- * No-op when replay is disabled. */
2035
- recordReplay(message) {
2036
- if (!this.replayBuffers) return;
2037
- let buffer = this.replayBuffers.get(message.topic);
2038
- if (!buffer) {
2039
- buffer = [];
2040
- this.replayBuffers.set(message.topic, buffer);
2041
- }
2042
- const storedMessage = message;
2043
- buffer.push(storedMessage);
2044
- if (this.replayPruneStrategy !== "age") {
2045
- while (buffer.length > this.replayMaxPerTopic) buffer.shift();
2046
- }
2047
- if (this.replayPruneStrategy !== "count" && this.replayRetentionMs !== void 0) {
2048
- const cutoff = this.now() - this.replayRetentionMs;
2049
- while (buffer.length > 0) {
2050
- const first = buffer[0];
2051
- if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
2052
- buffer.shift();
2053
- }
2054
- }
2055
- if (this.replayPersistence) {
2056
- if (this.replayPersistence.appendBatch) {
2057
- this.pendingReplayPersistence.push(storedMessage);
2058
- this.scheduleReplayPersistenceFlush();
2059
- } else {
2060
- void this.withPersistenceRetry("append", () => this.replayPersistence.append(storedMessage)).catch((error) => this.reportPersistenceError(error));
2061
- }
2062
- if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
2063
- this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
2064
- }
2065
- }
2066
- }
2067
- scheduleReplayPersistenceFlush() {
2068
- if (this.replayPersistenceFlushScheduled) return;
2069
- this.replayPersistenceFlushScheduled = true;
2070
- queueMicrotask(() => {
2071
- this.replayPersistenceFlushScheduled = false;
2072
- const batch = this.pendingReplayPersistence.splice(0);
2073
- if (batch.length === 0 || !this.replayPersistence) return;
2074
- const operation = this.replayPersistence.appendBatch ? () => this.replayPersistence.appendBatch(batch) : () => Promise.all(batch.map((message) => this.replayPersistence.append(message))).then(() => void 0);
2075
- void this.withPersistenceRetry("append", operation).catch((error) => this.reportPersistenceError(error));
2076
- });
2077
- }
2078
- async hydrateReplay() {
2079
- if (!this.replayBuffers || !this.replayPersistence) {
2080
- return;
2081
- }
2082
- try {
2083
- if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
2084
- await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(this.now() - this.replayRetentionMs));
2085
- }
2086
- for (const message of await this.withPersistenceRetry("load", () => this.replayPersistence.load())) {
2087
- let buffer = this.replayBuffers.get(message.topic);
2088
- if (!buffer) {
2089
- buffer = [];
2090
- this.replayBuffers.set(message.topic, buffer);
2091
- }
2092
- buffer.push(message);
2093
- if (buffer.length > this.replayMaxPerTopic) buffer.shift();
2094
- }
2095
- } catch (error) {
2096
- this.reportPersistenceError(error);
2097
- }
2098
- }
2099
- scheduleReplayRetentionCleanup(cutoff) {
2100
- if (!this.replayPersistence?.clearBefore) return;
2101
- if (this.replayRetentionCutoff === null || cutoff > this.replayRetentionCutoff) {
2102
- this.replayRetentionCutoff = cutoff;
2103
- }
2104
- if (this.replayRetentionCleanup) return;
2105
- this.replayRetentionCleanup = (async () => {
2106
- while (this.replayRetentionCutoff !== null) {
2107
- const nextCutoff = this.replayRetentionCutoff;
2108
- this.replayRetentionCutoff = null;
2109
- try {
2110
- await this.replayPersistence.clearBefore(nextCutoff);
2111
- } catch (error) {
2112
- this.reportPersistenceError(error);
2113
- }
2114
- }
2115
- })().finally(() => {
2116
- this.replayRetentionCleanup = null;
2117
- if (this.replayRetentionCutoff !== null) {
2118
- this.scheduleReplayRetentionCleanup(this.replayRetentionCutoff);
2119
- }
2120
- });
2121
- }
2122
- startReplayRetentionSweep() {
2123
- if (this.replayRetentionTimer || !this.replayRetentionMs || !this.replayRetentionSweepMs || !this.replayPersistence?.clearBefore) return;
2124
- this.replayRetentionTimer = setInterval(() => {
2125
- this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
2126
- }, this.replayRetentionSweepMs);
2127
- }
2128
- stopReplayRetentionSweep() {
2129
- if (this.replayRetentionTimer) clearInterval(this.replayRetentionTimer);
2130
- this.replayRetentionTimer = null;
2131
- }
2132
- async withPersistenceRetry(persistenceOperation, operation) {
2133
- const generation = this.persistenceRetryGeneration;
2134
- let attempt = 0;
2135
- let delay = this.persistenceRetryBackoffMs;
2136
- while (true) {
2137
- attempt += 1;
2138
- try {
2139
- if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
2140
- return await operation();
2141
- } catch (error) {
2142
- if (error instanceof PersistenceRetryCancelledError || generation !== this.persistenceRetryGeneration) {
2143
- throw new PersistenceRetryCancelledError();
2144
- }
2145
- if (attempt >= this.persistenceRetryMaxAttempts) throw error;
2146
- this.trace.event({
2147
- type: "reliability",
2148
- operation: "persistence_retry",
2149
- persistenceOperation,
2150
- attempt
2151
- });
2152
- if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
2153
- if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
2154
- delay = Math.min(delay * 2, 1600);
2155
- }
2156
- }
2157
- }
2158
- /** Deliver buffered history to a newly-registered handler. For an exact
2159
- * topic this is that topic's ring; for a wildcard subscription every
2160
- * buffered topic matching the pattern contributes (in buffer insertion
2161
- * order). Replay deliveries are marked `replayed: true` and are not
2162
- * counted into trace metrics. */
2163
- deliverReplay(topic, limit, handler) {
2164
- if (!this.replayBuffers || limit <= 0) return;
2165
- const deliver = (buffer2) => {
2166
- for (const message of buffer2.slice(-limit)) {
2167
- this.invokeHandlers([handler], (h) => h({ ...message, replayed: true }));
2168
- }
2169
- };
2170
- if (isWildcardTopic(topic)) {
2171
- for (const [bufferedTopic, buffer2] of this.replayBuffers) {
2172
- if (topicMatchesPattern(topic, bufferedTopic)) deliver(buffer2);
2173
- }
2174
- return;
2175
- }
2176
- const buffer = this.replayBuffers.get(topic);
2177
- if (buffer) deliver(buffer);
2846
+ this.replayManager.record(message);
2178
2847
  }
2179
2848
  /**
2180
2849
  * Propagate a status change to the cluster, trace, and all registered
@@ -2183,13 +2852,13 @@ var CrossTabDataBus = class {
2183
2852
  updateStatus(status) {
2184
2853
  const previousStatus = this.status;
2185
2854
  this.status = status;
2186
- if (previousStatus !== status) this.trace.event({ type: "status", status });
2855
+ if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
2187
2856
  this.cluster.setStatus(status);
2188
- if (status === "disconnected" || status === "error") this.transportSubscribedTopics.clear();
2189
- if (status === "connected" && previousStatus !== "connected") {
2857
+ if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
2858
+ if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
2190
2859
  for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
2191
2860
  }
2192
- if (status === "error" && this.started && !this.stopping) {
2861
+ if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
2193
2862
  const now = this.now();
2194
2863
  if (now - this.lastRecoveryAt >= this.recoveryCooldownMs) {
2195
2864
  this.lastRecoveryAt = now;
@@ -2197,37 +2866,67 @@ var CrossTabDataBus = class {
2197
2866
  if (attempt > this.recoveryMaxAttempts) {
2198
2867
  if (!this.recoveryExhausted) {
2199
2868
  this.recoveryExhausted = true;
2200
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: this.recoveryMaxAttempts, outcome: "exhausted" });
2869
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
2201
2870
  }
2202
2871
  return;
2203
2872
  }
2204
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt, outcome: "scheduled" });
2873
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
2205
2874
  setTimeout(() => {
2206
2875
  if (this.stopping || !this.started || this.suspended) return;
2207
- if (this.status !== "error") return;
2876
+ if (this.status !== WORKER_STATUS.ERROR) return;
2208
2877
  void this.reopenTransport(attempt);
2209
2878
  }, this.recoveryCooldownMs);
2210
2879
  }
2211
2880
  }
2212
2881
  this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
2213
2882
  }
2214
- reportError(error) {
2215
- this.trace.event({ type: "error", source: "transport" });
2216
- this.invokeHandlers(this.errorHandlers, (handler) => handler(error), "error handler");
2883
+ reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
2884
+ this.lastFailure = {
2885
+ source,
2886
+ message: error instanceof Error ? error.message : String(error),
2887
+ at: this.now()
2888
+ };
2889
+ if (source === FAILURE_SOURCE.PERSISTENCE) {
2890
+ this.persistenceFailureCount += 1;
2891
+ this.persistenceLastFailureAt = this.lastFailure.at;
2892
+ this.persistenceLastErrorMessage = this.lastFailure.message;
2893
+ }
2894
+ this.trace.event({
2895
+ type: TRACE_EVENT_TYPE.ERROR,
2896
+ source: source === FAILURE_SOURCE.TRANSPORT ? TRACE_ERROR_SOURCE.TRANSPORT : TRACE_ERROR_SOURCE.OPERATION
2897
+ });
2898
+ this.invokeHandlers(this.errorHandlers, (handler) => handler(error), INVOKE_LABEL.ERROR_HANDLER);
2217
2899
  }
2900
+ /** Report a persistence failure to the trace and the unified failure ledger,
2901
+ * unless it is a {@link PersistenceRetryCancelledError} cancellation from a
2902
+ * lifecycle transition (teardown should stay quiet). */
2218
2903
  reportPersistenceError(error) {
2219
2904
  if (error instanceof PersistenceRetryCancelledError) return;
2220
- this.trace.event({ type: "reliability", operation: "persistence_cleanup" });
2221
- this.reportError(error);
2905
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.PERSISTENCE_CLEANUP });
2906
+ this.reportError(error, FAILURE_SOURCE.PERSISTENCE);
2222
2907
  }
2223
2908
  traceSubscription(action, topic) {
2224
2909
  this.trace.event({
2225
- type: "subscription",
2910
+ type: TRACE_EVENT_TYPE.SUBSCRIPTION,
2226
2911
  action,
2227
2912
  topic,
2228
2913
  activeTopics: this.transportSubscribedTopics.size
2229
2914
  });
2230
2915
  }
2916
+ /** Emit the coordination trace snapshot from the current cluster state.
2917
+ * Called after a transport opens (start and recovery), when the role and
2918
+ * route picture has settled — the synchronous pre-open snapshot would see no
2919
+ * routes because their writes are still coalesced in the batch writer. */
2920
+ emitCoordinationTrace() {
2921
+ const snapshot = this.cluster.getSnapshot();
2922
+ this.trace.event({
2923
+ type: TRACE_EVENT_TYPE.COORDINATION,
2924
+ coordinated: snapshot.coordinated,
2925
+ activeWorkers: snapshot.workers.filter((worker) => worker.role === WORKER_ROLE.ACTIVE).length,
2926
+ workers: snapshot.workers.map(formatWorkerTrace),
2927
+ routes: snapshot.routes.map(formatRouteTrace)
2928
+ });
2929
+ }
2231
2930
  /** Ask the transport to subscribe to a topic (idempotent). */
2232
2931
  subscribeTransport(topic) {
2233
2932
  if (this.transportSubscribedTopics.has(topic)) return false;
@@ -2245,17 +2944,17 @@ var CrossTabDataBus = class {
2245
2944
  * are routed to `reportError` (which surfaces them to error subscribers);
2246
2945
  * error-handler failures are logged to the console to avoid infinite
2247
2946
  * recursion through reportError itself. */
2248
- invokeHandlers(handlers, callback, label = "dispatch") {
2947
+ invokeHandlers(handlers, callback, label = INVOKE_LABEL.DISPATCH) {
2249
2948
  for (const handler of handlers) {
2250
2949
  try {
2251
2950
  callback(handler);
2252
2951
  } catch (error) {
2253
- if (label === "error handler") {
2952
+ if (label === INVOKE_LABEL.ERROR_HANDLER) {
2254
2953
  if (typeof console !== "undefined" && typeof console.warn === "function") {
2255
- console.warn("[cross-tab-worker-databus] error handler threw:", error);
2954
+ console.warn(`[${DEFAULT_STORAGE_PREFIX}] error handler threw:`, error);
2256
2955
  }
2257
2956
  } else {
2258
- this.reportError(error);
2957
+ this.reportError(error, FAILURE_SOURCE.DISPATCH);
2259
2958
  }
2260
2959
  }
2261
2960
  }
@@ -2269,7 +2968,7 @@ var CrossTabDataBus = class {
2269
2968
  this.suspended = true;
2270
2969
  this.transportReady = false;
2271
2970
  this.transportSubscribedTopics.clear();
2272
- this.updateStatus("disconnected");
2971
+ this.updateStatus(WORKER_STATUS.DISCONNECTED);
2273
2972
  if (this.pendingStop) return;
2274
2973
  const pending = this.startPromise ?? Promise.resolve();
2275
2974
  const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
@@ -2303,14 +3002,14 @@ var CrossTabDataBus = class {
2303
3002
  const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
2304
3003
  this.started = true;
2305
3004
  this.suspended = false;
2306
- this.updateStatus("connecting");
3005
+ this.updateStatus(WORKER_STATUS.CONNECTING);
2307
3006
  const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
2308
3007
  const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
2309
3008
  this.startPromise = opening;
2310
3009
  void opening.then(
2311
3010
  () => {
2312
3011
  if (traceAttempt !== void 0) {
2313
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: traceAttempt, outcome: "succeeded" });
3012
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
2314
3013
  this.recoveryAttempt = 0;
2315
3014
  this.recoveryExhausted = false;
2316
3015
  }
@@ -2318,7 +3017,7 @@ var CrossTabDataBus = class {
2318
3017
  },
2319
3018
  () => {
2320
3019
  if (traceAttempt !== void 0) {
2321
- this.trace.event({ type: "reliability", operation: "transport_recovery", attempt: traceAttempt, outcome: "failed" });
3020
+ this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
2322
3021
  }
2323
3022
  }
2324
3023
  );
@@ -2397,50 +3096,102 @@ function parseDataBusPublication(value, fallbackTopic) {
2397
3096
  };
2398
3097
  }
2399
3098
 
3099
+ // src/utils/error-utils.ts
3100
+ function serializeError(error) {
3101
+ if (error instanceof Error) {
3102
+ return {
3103
+ name: error.name,
3104
+ message: error.message,
3105
+ ...error.stack ? { stack: error.stack } : {}
3106
+ };
3107
+ }
3108
+ const context = error === void 0 ? void 0 : error;
3109
+ return {
3110
+ name: "CentrifugeError",
3111
+ message: typeof error === "string" ? error : "Centrifuge worker operation failed.",
3112
+ ...context === void 0 || !isStructuredCloneable(context) ? {} : { context }
3113
+ };
3114
+ }
3115
+ function isStructuredCloneable(value) {
3116
+ if (typeof structuredClone !== "function") return true;
3117
+ try {
3118
+ structuredClone(value);
3119
+ return true;
3120
+ } catch {
3121
+ return false;
3122
+ }
3123
+ }
3124
+ function deserializeWorkerError(error) {
3125
+ const result = new Error(error.message);
3126
+ result.name = error.name;
3127
+ if (error.stack) result.stack = error.stack;
3128
+ if (error.context !== void 0) Object.assign(result, { context: error.context });
3129
+ return result;
3130
+ }
3131
+
2400
3132
  // src/centrifuge-session.ts
2401
3133
  var CentrifugeSession = class {
2402
3134
  constructor(sink) {
2403
3135
  this.sink = sink;
2404
3136
  }
3137
+ sink;
2405
3138
  client = null;
2406
3139
  subscriptions = /* @__PURE__ */ new Map();
2407
3140
  transferable = false;
3141
+ tokenBridge = false;
3142
+ nextRequestId = 1;
3143
+ pendingTokenRequests = /* @__PURE__ */ new Map();
2408
3144
  /** Dispatch an incoming Worker message to the matching operation.
2409
3145
  * Unknown message types are ignored rather than thrown, so a future protocol
2410
3146
  * extension adding a new variant cannot crash an older session. */
2411
3147
  handle(message) {
2412
3148
  switch (message.type) {
2413
- case "INIT":
2414
- this.initialize(message.url, message.config, message.transferable === true);
3149
+ case CENTRIFUGE_INPUT_TYPE.INIT:
3150
+ this.initialize(message.url, message.config, message.transferable === true, message.tokenBridge === true);
2415
3151
  return;
2416
- case "SUBSCRIBE":
3152
+ case CENTRIFUGE_INPUT_TYPE.SUBSCRIBE:
2417
3153
  this.subscribe(message.topic);
2418
3154
  return;
2419
- case "UNSUBSCRIBE":
3155
+ case CENTRIFUGE_INPUT_TYPE.UNSUBSCRIBE:
2420
3156
  this.unsubscribe(message.topic);
2421
3157
  return;
2422
- case "PUBLISH":
2423
- case "PUBLISH_BIN":
3158
+ case CENTRIFUGE_INPUT_TYPE.PUBLISH:
3159
+ case CENTRIFUGE_INPUT_TYPE.PUBLISH_BIN:
2424
3160
  this.publish(message.topic, message.data, message.messageId, message.timestamp);
2425
3161
  return;
2426
- case "STOP":
3162
+ case CENTRIFUGE_INPUT_TYPE.STOP:
2427
3163
  this.stop();
2428
3164
  return;
3165
+ case CENTRIFUGE_INPUT_TYPE.TOKEN_RESPONSE:
3166
+ this.resolveToken(message.requestId, message.token);
3167
+ return;
3168
+ case CENTRIFUGE_INPUT_TYPE.TOKEN_ERROR:
3169
+ this.rejectToken(message.requestId, deserializeWorkerError(message.error));
3170
+ return;
2429
3171
  default:
2430
3172
  return;
2431
3173
  }
2432
3174
  }
2433
3175
  /** Create the Centrifuge client, wire up lifecycle listeners, and connect. */
2434
- initialize(url, config, transferable) {
3176
+ initialize(url, config, transferable, tokenBridge) {
2435
3177
  if (this.client) return;
2436
3178
  this.transferable = transferable;
2437
- const client = new import_centrifuge.Centrifuge(url, config);
3179
+ this.tokenBridge = tokenBridge;
3180
+ const clientOptions = tokenBridge ? {
3181
+ ...config,
3182
+ // Route credential fetches back to the main thread, where the
3183
+ // application's credentialProvider lives (config must stay
3184
+ // structured-cloneable, so functions cannot cross the boundary).
3185
+ getToken: () => this.requestToken("token"),
3186
+ getChannelToken: (channel) => this.requestToken("channelToken", channel)
3187
+ } : config;
3188
+ const client = new import_centrifuge.Centrifuge(url, clientOptions);
2438
3189
  this.client = client;
2439
3190
  client.on("state", (context) => {
2440
- this.post({ type: "STATUS", status: normalizeStatus(context.newState) });
3191
+ this.post({ type: CENTRIFUGE_OUTPUT_TYPE.STATUS, status: normalizeStatus(context.newState) });
2441
3192
  });
2442
- client.on("connected", () => this.post({ type: "STATUS", status: "connected" }));
2443
- client.on("disconnected", () => this.post({ type: "STATUS", status: "disconnected" }));
3193
+ client.on("connected", () => this.post({ type: CENTRIFUGE_OUTPUT_TYPE.STATUS, status: WORKER_STATUS.CONNECTED }));
3194
+ client.on("disconnected", () => this.post({ type: CENTRIFUGE_OUTPUT_TYPE.STATUS, status: WORKER_STATUS.DISCONNECTED }));
2444
3195
  client.on("error", (context) => this.postError(context));
2445
3196
  client.on("publication", (context) => {
2446
3197
  const topic = context.channel || getPayloadTopic(context.data);
@@ -2503,25 +3254,56 @@ var CentrifugeSession = class {
2503
3254
  postPublication(topic, data) {
2504
3255
  if (!topic) return;
2505
3256
  if (this.transferable && data instanceof ArrayBuffer) {
2506
- this.post({ type: "MESSAGE_BIN", topic, data }, [data]);
3257
+ this.post({ type: CENTRIFUGE_OUTPUT_TYPE.MESSAGE_BIN, topic, data }, [data]);
2507
3258
  return;
2508
3259
  }
2509
3260
  const publication = parseDataBusPublication(data, topic);
2510
3261
  if (!publication) return;
2511
3262
  this.post({
2512
- type: "MESSAGE",
3263
+ type: CENTRIFUGE_OUTPUT_TYPE.MESSAGE,
2513
3264
  topic: publication.topic,
2514
3265
  data: publication.data,
2515
- ...publication.messageId === void 0 ? {} : { messageId: publication.messageId },
2516
- ...publication.timestamp === void 0 ? {} : { timestamp: publication.timestamp }
3266
+ ...publicationMetadata(publication.messageId, publication.timestamp)
2517
3267
  });
2518
3268
  }
2519
3269
  /** Disconnect the client and clear all subscriptions. */
2520
3270
  stop() {
3271
+ for (const [, pending] of this.pendingTokenRequests) {
3272
+ pending.reject(new Error("Centrifuge session stopped before the credential was resolved."));
3273
+ }
3274
+ this.pendingTokenRequests.clear();
2521
3275
  this.client?.disconnect();
2522
3276
  this.subscriptions.clear();
2523
3277
  this.client = null;
2524
- this.post({ type: "STATUS", status: "disconnected" });
3278
+ this.post({ type: CENTRIFUGE_OUTPUT_TYPE.STATUS, status: WORKER_STATUS.DISCONNECTED });
3279
+ }
3280
+ /** Issue a credential request to the main thread and await the response.
3281
+ * Used as Centrifuge's `getToken` / `getChannelToken` when token bridging is
3282
+ * enabled; resolved or rejected by a matching TOKEN_RESPONSE / TOKEN_ERROR. */
3283
+ requestToken(kind, channel) {
3284
+ const requestId = this.nextRequestId;
3285
+ this.nextRequestId += 1;
3286
+ return new Promise((resolve, reject) => {
3287
+ this.pendingTokenRequests.set(requestId, { resolve, reject });
3288
+ this.post({
3289
+ type: CENTRIFUGE_OUTPUT_TYPE.TOKEN_REQUEST,
3290
+ requestId,
3291
+ kind,
3292
+ ...channel === void 0 ? {} : { channel }
3293
+ });
3294
+ });
3295
+ }
3296
+ resolveToken(requestId, token) {
3297
+ const pending = this.pendingTokenRequests.get(requestId);
3298
+ if (!pending) return;
3299
+ this.pendingTokenRequests.delete(requestId);
3300
+ pending.resolve(token);
3301
+ }
3302
+ rejectToken(requestId, error) {
3303
+ const pending = this.pendingTokenRequests.get(requestId);
3304
+ if (!pending) return;
3305
+ this.pendingTokenRequests.delete(requestId);
3306
+ pending.reject(error);
2525
3307
  }
2526
3308
  /** Forward a message to the sink (the transport layer). */
2527
3309
  post(message, transfer) {
@@ -2533,7 +3315,7 @@ var CentrifugeSession = class {
2533
3315
  * Fatal errors are distinguished by the client eventually emitting
2534
3316
  * `disconnected` without a subsequent `connected`. */
2535
3317
  postError(error) {
2536
- this.post({ type: "ERROR", error: serializeError(error) });
3318
+ this.post({ type: CENTRIFUGE_OUTPUT_TYPE.ERROR, error: serializeError(error) });
2537
3319
  }
2538
3320
  };
2539
3321
  function getPayloadTopic(data) {
@@ -2544,33 +3326,19 @@ function getPayloadTopic(data) {
2544
3326
  const topic = nested ?? payload.channel;
2545
3327
  return typeof topic === "string" ? topic : "";
2546
3328
  }
2547
- var LIVE_STATES = /* @__PURE__ */ new Set(["connecting", "connected"]);
3329
+ var LIVE_STATES = /* @__PURE__ */ new Set([WORKER_STATUS.CONNECTING, WORKER_STATUS.CONNECTED]);
2548
3330
  function normalizeStatus(status) {
2549
3331
  return LIVE_STATES.has(status) ? status : "disconnected";
2550
3332
  }
2551
- function serializeError(error) {
2552
- if (error instanceof Error) {
2553
- return {
2554
- name: error.name,
2555
- message: error.message,
2556
- ...error.stack ? { stack: error.stack } : {}
2557
- };
2558
- }
2559
- return {
2560
- name: "CentrifugeError",
2561
- message: typeof error === "string" ? error : "Centrifuge worker operation failed.",
2562
- ...error === void 0 ? {} : { context: error }
2563
- };
2564
- }
2565
3333
 
2566
3334
  // src/worker-mode.ts
2567
3335
  function selectWorkerBackend(mode, availability = {}) {
2568
3336
  const hasDedicated = availability.worker ?? typeof Worker !== "undefined";
2569
3337
  const hasShared = availability.sharedWorker ?? typeof SharedWorker !== "undefined";
2570
- if (mode === "shared" || mode === "auto") {
2571
- return hasShared ? "shared" : hasDedicated ? "dedicated" : "local";
3338
+ if (mode === WORKER_MODE.SHARED || mode === WORKER_MODE.AUTO) {
3339
+ return hasShared ? WORKER_BACKEND.SHARED : hasDedicated ? WORKER_BACKEND.DEDICATED : WORKER_BACKEND.LOCAL;
2572
3340
  }
2573
- return hasDedicated ? "dedicated" : hasShared ? "shared" : "local";
3341
+ return hasDedicated ? WORKER_BACKEND.DEDICATED : hasShared ? WORKER_BACKEND.SHARED : WORKER_BACKEND.LOCAL;
2574
3342
  }
2575
3343
 
2576
3344
  // src/centrifuge-protocol.ts
@@ -2587,6 +3355,7 @@ var CentrifugeWorkerTransport = class {
2587
3355
  heartbeatIntervalMs;
2588
3356
  workerFactory;
2589
3357
  sharedWorkerFactory;
3358
+ credentialProvider;
2590
3359
  backend = null;
2591
3360
  worker = null;
2592
3361
  sharedWorker = null;
@@ -2604,12 +3373,13 @@ var CentrifugeWorkerTransport = class {
2604
3373
  return this.backend ?? "uninitialized";
2605
3374
  }
2606
3375
  constructor(options = {}) {
2607
- this.workerMode = options.workerMode ?? "dedicated";
3376
+ this.workerMode = options.workerMode ?? WORKER_MODE.DEDICATED;
2608
3377
  this.transferable = options.transferable ?? false;
2609
3378
  this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS2;
2610
3379
  assertHeartbeatInterval(this.heartbeatIntervalMs);
2611
3380
  this.workerFactory = options.workerFactory;
2612
3381
  this.sharedWorkerFactory = options.sharedWorkerFactory;
3382
+ this.credentialProvider = options.credentialProvider;
2613
3383
  }
2614
3384
  /**
2615
3385
  * Start the transport: select a backend, initialise the Worker (or local
@@ -2620,29 +3390,29 @@ var CentrifugeWorkerTransport = class {
2620
3390
  assertStructuredCloneable(config.options ?? {});
2621
3391
  this.handlers = handlers;
2622
3392
  const backend = selectWorkerBackend(this.workerMode, {
2623
- worker: this.workerFactory !== void 0,
2624
- sharedWorker: this.sharedWorkerFactory !== void 0
3393
+ worker: this.workerFactory !== void 0 || typeof Worker !== "undefined",
3394
+ sharedWorker: this.sharedWorkerFactory !== void 0 || typeof SharedWorker !== "undefined"
2625
3395
  });
2626
3396
  const input = this.buildInitInput(config);
2627
- if (backend === "shared") {
3397
+ if (backend === WORKER_BACKEND.SHARED) {
2628
3398
  this.startSharedWorker(input);
2629
- this.backend = "shared";
3399
+ this.backend = WORKER_BACKEND.SHARED;
2630
3400
  return;
2631
3401
  }
2632
- if (backend === "dedicated") {
3402
+ if (backend === WORKER_BACKEND.DEDICATED) {
2633
3403
  this.startDedicatedWorker(input);
2634
- this.backend = "dedicated";
3404
+ this.backend = WORKER_BACKEND.DEDICATED;
2635
3405
  return;
2636
3406
  }
2637
3407
  this.localSession = new CentrifugeSession({ post: this.handleSessionOutput });
2638
3408
  this.localSession.handle(input);
2639
- this.backend = "local";
3409
+ this.backend = WORKER_BACKEND.LOCAL;
2640
3410
  }
2641
3411
  subscribe(topic) {
2642
- this.post({ type: "SUBSCRIBE", topic });
3412
+ this.post({ type: CENTRIFUGE_INPUT_TYPE.SUBSCRIBE, topic });
2643
3413
  }
2644
3414
  unsubscribe(topic) {
2645
- this.post({ type: "UNSUBSCRIBE", topic });
3415
+ this.post({ type: CENTRIFUGE_INPUT_TYPE.UNSUBSCRIBE, topic });
2646
3416
  }
2647
3417
  /**
2648
3418
  * Publish data to `topic`. Binary data (ArrayBuffer) is sent via Transferable
@@ -2650,10 +3420,10 @@ var CentrifugeWorkerTransport = class {
2650
3420
  */
2651
3421
  publish(topic, data, options) {
2652
3422
  if (this.transferable && data instanceof ArrayBuffer) {
2653
- this.post({ type: "PUBLISH_BIN", topic, data, ...publicationMetadata2(options) }, [data]);
3423
+ this.post({ type: CENTRIFUGE_INPUT_TYPE.PUBLISH_BIN, topic, data, ...publicationMetadata(options?.messageId, options?.timestamp) }, [data]);
2654
3424
  return;
2655
3425
  }
2656
- this.post({ type: "PUBLISH", topic, data, ...publicationMetadata2(options) });
3426
+ this.post({ type: CENTRIFUGE_INPUT_TYPE.PUBLISH, topic, data, ...publicationMetadata(options?.messageId, options?.timestamp) });
2657
3427
  }
2658
3428
  /**
2659
3429
  * Gracefully stop the transport: send STOP, clean up event listeners, and
@@ -2662,7 +3432,7 @@ var CentrifugeWorkerTransport = class {
2662
3432
  stop() {
2663
3433
  if (!this.backend) return;
2664
3434
  this.generation++;
2665
- this.post({ type: "STOP" });
3435
+ this.post({ type: CENTRIFUGE_INPUT_TYPE.STOP });
2666
3436
  this.clearHeartbeat();
2667
3437
  if (this.worker) {
2668
3438
  this.worker.removeEventListener("message", this.handleMessage);
@@ -2681,10 +3451,11 @@ var CentrifugeWorkerTransport = class {
2681
3451
  * default-resolution logic kicks in for the common case. */
2682
3452
  buildInitInput(config) {
2683
3453
  return {
2684
- type: "INIT",
3454
+ type: CENTRIFUGE_INPUT_TYPE.INIT,
2685
3455
  url: config.url,
2686
3456
  config: config.options ?? {},
2687
3457
  ...this.transferable ? { transferable: true } : {},
3458
+ ...this.credentialProvider ? { tokenBridge: true } : {},
2688
3459
  ...this.heartbeatIntervalMs !== DEFAULT_HEARTBEAT_INTERVAL_MS2 ? { heartbeatIntervalMs: this.heartbeatIntervalMs } : {}
2689
3460
  };
2690
3461
  }
@@ -2723,10 +3494,34 @@ var CentrifugeWorkerTransport = class {
2723
3494
  * Shared by the Worker message listener, the SharedWorker port listener,
2724
3495
  * and the local-session sink — all three feed into this single dispatcher. */
2725
3496
  handleOutput(message) {
2726
- if (message.type === "STATUS") this.handlers?.onStatus(message.status);
2727
- if (message.type === "MESSAGE") this.handlers?.onMessage({ topic: message.topic, data: message.data, ...publicationMetadata2(message) });
2728
- if (message.type === "MESSAGE_BIN") this.handlers?.onMessage({ topic: message.topic, data: message.data, ...publicationMetadata2(message) });
2729
- if (message.type === "ERROR") this.handlers?.onError(deserializeWorkerError(message.error));
3497
+ if (message.type === CENTRIFUGE_OUTPUT_TYPE.STATUS) this.handlers?.onStatus(message.status);
3498
+ if (message.type === CENTRIFUGE_OUTPUT_TYPE.MESSAGE) this.handlers?.onMessage({ topic: message.topic, data: message.data, ...publicationMetadata(message.messageId, message.timestamp) });
3499
+ if (message.type === CENTRIFUGE_OUTPUT_TYPE.MESSAGE_BIN) this.handlers?.onMessage({ topic: message.topic, data: message.data, ...publicationMetadata(message.messageId, message.timestamp) });
3500
+ if (message.type === CENTRIFUGE_OUTPUT_TYPE.ERROR) this.handlers?.onError(deserializeWorkerError(message.error));
3501
+ if (message.type === CENTRIFUGE_OUTPUT_TYPE.TOKEN_REQUEST) this.resolveTokenRequest(message.requestId, message.kind, message.channel);
3502
+ }
3503
+ /** Resolve a Worker credential request from the main thread: call the
3504
+ * credentialProvider, then post the fresh token (or a serialized failure)
3505
+ * back to the session that issued the request. */
3506
+ resolveTokenRequest(requestId, kind, channel) {
3507
+ const provider = this.credentialProvider;
3508
+ const value = kind === "channelToken" && provider?.getChannelToken ? provider.getChannelToken(channel ?? "") : provider?.getToken?.();
3509
+ Promise.resolve(value).then(
3510
+ (token) => {
3511
+ if (typeof token !== "string" || token.length === 0) {
3512
+ this.post({
3513
+ type: CENTRIFUGE_INPUT_TYPE.TOKEN_ERROR,
3514
+ requestId,
3515
+ error: serializeError(new Error(`The credential provider returned no ${kind}.`))
3516
+ });
3517
+ return;
3518
+ }
3519
+ this.post({ type: CENTRIFUGE_INPUT_TYPE.TOKEN_RESPONSE, requestId, token });
3520
+ },
3521
+ (error) => {
3522
+ this.post({ type: CENTRIFUGE_INPUT_TYPE.TOKEN_ERROR, requestId, error: serializeError(error) });
3523
+ }
3524
+ );
2730
3525
  }
2731
3526
  /** Handle a Worker-level failure (crash, message decode error). Discards the
2732
3527
  * dead backend so a later start()/reopen can rebuild from scratch, and
@@ -2742,7 +3537,7 @@ var CentrifugeWorkerTransport = class {
2742
3537
  this.clearHeartbeat();
2743
3538
  this.resetBackend();
2744
3539
  this.handlers?.onError(new Error(message));
2745
- this.handlers?.onStatus("error");
3540
+ this.handlers?.onStatus(WORKER_STATUS.ERROR);
2746
3541
  }
2747
3542
  /** Remove every listener attached to the current SharedWorker and its port. */
2748
3543
  detachSharedWorkerListeners() {
@@ -2755,7 +3550,7 @@ var CentrifugeWorkerTransport = class {
2755
3550
  if (this.heartbeatHandle !== null) return;
2756
3551
  if (this.heartbeatIntervalMs === Infinity) return;
2757
3552
  this.heartbeatHandle = setInterval(() => {
2758
- this.post({ type: "PING" });
3553
+ this.post({ type: CENTRIFUGE_INPUT_TYPE.PING });
2759
3554
  }, this.heartbeatIntervalMs);
2760
3555
  }
2761
3556
  clearHeartbeat() {
@@ -2806,6 +3601,7 @@ function createCentrifugeDataBus(options) {
2806
3601
  const {
2807
3602
  clusterKey,
2808
3603
  connection,
3604
+ credentialProvider,
2809
3605
  heartbeatIntervalMs,
2810
3606
  sharedWorkerFactory,
2811
3607
  transferable,
@@ -2823,7 +3619,8 @@ function createCentrifugeDataBus(options) {
2823
3619
  ...sharedWorkerFactory ? { sharedWorkerFactory } : {},
2824
3620
  ...transferable === void 0 ? {} : { transferable },
2825
3621
  ...workerMode ? { workerMode } : {},
2826
- ...heartbeatIntervalMs === void 0 ? {} : { heartbeatIntervalMs }
3622
+ ...heartbeatIntervalMs === void 0 ? {} : { heartbeatIntervalMs },
3623
+ ...credentialProvider ? { credentialProvider } : {}
2827
3624
  })
2828
3625
  });
2829
3626
  }
@@ -2840,7 +3637,7 @@ function createDefaultWorker() {
2840
3637
  );
2841
3638
  }
2842
3639
  return new Worker(workerUrl, {
2843
- name: "cross-tab-worker-databus",
3640
+ name: DEFAULT_STORAGE_PREFIX,
2844
3641
  type: "module"
2845
3642
  });
2846
3643
  }
@@ -2857,43 +3654,12 @@ function createDefaultSharedWorker() {
2857
3654
  );
2858
3655
  }
2859
3656
  return new SharedWorker(workerUrl, {
2860
- name: "cross-tab-worker-databus-shared",
3657
+ name: `${DEFAULT_STORAGE_PREFIX}-shared`,
2861
3658
  type: "module"
2862
3659
  });
2863
3660
  }
2864
- function assertHeartbeatInterval(value) {
2865
- if (value === Infinity) return;
2866
- if (typeof value === "number" && Number.isFinite(value) && value > 0) return;
2867
- throw new TypeError(
2868
- `Centrifuge heartbeatIntervalMs must be a positive number or Infinity, got ${String(value)}.`
2869
- );
2870
- }
2871
- function assertStructuredCloneable(value) {
2872
- if (typeof structuredClone !== "function") return;
2873
- try {
2874
- structuredClone(value);
2875
- } catch (error) {
2876
- throw new TypeError(
2877
- "Centrifuge Worker configuration and published data must be structured-cloneable.",
2878
- { cause: error }
2879
- );
2880
- }
2881
- }
2882
3661
  function postToPortLike(target, message, transfer) {
2883
3662
  if (transfer) target.postMessage(message, transfer);
2884
3663
  else target.postMessage(message);
2885
3664
  }
2886
- function deserializeWorkerError(error) {
2887
- const result = new Error(error.message);
2888
- result.name = error.name;
2889
- if (error.stack) result.stack = error.stack;
2890
- if (error.context !== void 0) Object.assign(result, { context: error.context });
2891
- return result;
2892
- }
2893
- function publicationMetadata2(metadata) {
2894
- return {
2895
- ...metadata?.messageId === void 0 ? {} : { messageId: metadata.messageId },
2896
- ...metadata?.timestamp === void 0 ? {} : { timestamp: metadata.timestamp }
2897
- };
2898
- }
2899
3665
  //# sourceMappingURL=centrifuge.cjs.map