cross-tab-worker-databus 0.2.1 → 0.4.0

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 (41) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +3 -0
  3. package/README.zh.md +3 -0
  4. package/dist/centrifuge.js +20 -3
  5. package/dist/centrifuge.js.map +2 -2
  6. package/dist/chunk-5WRI5ZAA.js +31 -0
  7. package/dist/chunk-5WRI5ZAA.js.map +7 -0
  8. package/dist/{chunk-LBXREMZA.js → chunk-ZGQRELIV.js} +91 -5
  9. package/dist/chunk-ZGQRELIV.js.map +7 -0
  10. package/dist/cjs/centrifuge.cjs +2205 -0
  11. package/dist/cjs/centrifuge.cjs.map +7 -0
  12. package/dist/cjs/hooks.cjs +1975 -0
  13. package/dist/cjs/hooks.cjs.map +7 -0
  14. package/dist/cjs/index.cjs +1865 -0
  15. package/dist/cjs/index.cjs.map +7 -0
  16. package/dist/core/cluster.d.ts +2 -1
  17. package/dist/core/cluster.d.ts.map +1 -1
  18. package/dist/core/data-bus.d.ts +28 -2
  19. package/dist/core/data-bus.d.ts.map +1 -1
  20. package/dist/core/routing.d.ts +9 -0
  21. package/dist/core/routing.d.ts.map +1 -1
  22. package/dist/core/types.d.ts +3 -0
  23. package/dist/core/types.d.ts.map +1 -1
  24. package/dist/hooks.d.ts +31 -0
  25. package/dist/hooks.d.ts.map +1 -0
  26. package/dist/hooks.js +1946 -0
  27. package/dist/hooks.js.map +7 -0
  28. package/dist/index.d.ts +4 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +113 -3
  31. package/dist/index.js.map +3 -3
  32. package/dist/websocket.d.ts +86 -0
  33. package/dist/websocket.d.ts.map +1 -0
  34. package/docs/api.md +75 -0
  35. package/docs/getting-started.md +6 -0
  36. package/docs/transports.md +27 -0
  37. package/docs/zh/api.md +75 -0
  38. package/docs/zh/getting-started.md +6 -0
  39. package/docs/zh/transports.md +24 -0
  40. package/package.json +24 -7
  41. package/dist/chunk-LBXREMZA.js.map +0 -7
@@ -0,0 +1,2205 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/centrifuge.ts
21
+ var centrifuge_exports = {};
22
+ __export(centrifuge_exports, {
23
+ CentrifugeWorkerTransport: () => CentrifugeWorkerTransport,
24
+ createCentrifugeDataBus: () => createCentrifugeDataBus
25
+ });
26
+ module.exports = __toCommonJS(centrifuge_exports);
27
+
28
+ // src/core/environment.ts
29
+ function getStorage(name) {
30
+ try {
31
+ return typeof window === "undefined" ? null : window[name];
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+ function randomId() {
37
+ try {
38
+ return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
39
+ } catch {
40
+ return Math.random().toString(36).slice(2);
41
+ }
42
+ }
43
+ var tabIdentityInitialized = false;
44
+ function createBrowserEnvironment() {
45
+ return {
46
+ storage: getStorage("localStorage"),
47
+ sessionStorage: getStorage("sessionStorage"),
48
+ now: Date.now,
49
+ randomId,
50
+ createChannel: (name) => {
51
+ try {
52
+ return typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(name);
53
+ } catch {
54
+ return null;
55
+ }
56
+ },
57
+ setInterval: (callback, intervalMs) => globalThis.setInterval(callback, intervalMs),
58
+ clearInterval: (handle) => globalThis.clearInterval(handle),
59
+ getVisibilityState: () => typeof document !== "undefined" && document.visibilityState === "hidden" ? "hidden" : "visible",
60
+ addVisibilityChangeListener: (listener) => {
61
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", listener);
62
+ },
63
+ removeVisibilityChangeListener: (listener) => {
64
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", listener);
65
+ },
66
+ addPageHideListener: (listener) => {
67
+ if (typeof window !== "undefined") window.addEventListener("pagehide", listener);
68
+ },
69
+ removePageHideListener: (listener) => {
70
+ if (typeof window !== "undefined") window.removeEventListener("pagehide", listener);
71
+ },
72
+ addPageShowListener: (listener) => {
73
+ if (typeof window !== "undefined") window.addEventListener("pageshow", listener);
74
+ },
75
+ removePageShowListener: (listener) => {
76
+ if (typeof window !== "undefined") window.removeEventListener("pageshow", listener);
77
+ }
78
+ };
79
+ }
80
+ function canUseStorage(storage, probeKey) {
81
+ if (!storage) return false;
82
+ try {
83
+ storage.setItem(probeKey, "1");
84
+ storage.removeItem(probeKey);
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+ function getOrCreateTabId(environment, key = "cross-tab-worker-databus:tab-id") {
91
+ const storage = environment.sessionStorage;
92
+ try {
93
+ const existing = storage?.getItem(key);
94
+ const hasOpener = typeof window !== "undefined" && Boolean(window.opener);
95
+ if (existing && (!hasOpener || tabIdentityInitialized)) {
96
+ tabIdentityInitialized = true;
97
+ return existing;
98
+ }
99
+ const created = `tab-${environment.randomId()}`;
100
+ storage?.setItem(key, created);
101
+ tabIdentityInitialized = true;
102
+ return created;
103
+ } catch {
104
+ return `tab-${environment.randomId()}`;
105
+ }
106
+ }
107
+
108
+ // src/core/hash.ts
109
+ function createOpaqueKey(value) {
110
+ let h1 = SEED_H1 ^ value.length;
111
+ let h2 = SEED_H2 ^ value.length;
112
+ let h3 = SEED_H3 ^ value.length;
113
+ let h4 = SEED_H4 ^ value.length;
114
+ for (let index = 0; index < value.length; index += 1) {
115
+ const code = value.charCodeAt(index);
116
+ h1 = Math.imul(h1 ^ code, PRIME_H1);
117
+ h2 = Math.imul(h2 ^ code, PRIME_H2);
118
+ h3 = Math.imul(h3 ^ code, PRIME_H3);
119
+ h4 = Math.imul(h4 ^ code, PRIME_H4);
120
+ }
121
+ h1 = avalancheMix(h1, h2);
122
+ h2 = avalancheMix(h2, h3);
123
+ h3 = avalancheMix(h3, h4);
124
+ h4 = avalancheMix(h4, h1);
125
+ return [h1, h2, h3, h4].map((hash) => (hash >>> 0).toString(16).padStart(8, "0")).join("");
126
+ }
127
+ var SEED_H1 = 3735928559;
128
+ var SEED_H2 = 1103547991;
129
+ var SEED_H3 = 3235826430;
130
+ var SEED_H4 = 2654435769;
131
+ var PRIME_H1 = 2654435761;
132
+ var PRIME_H2 = 1597334677;
133
+ var PRIME_H3 = 2246822519;
134
+ var PRIME_H4 = 3266489917;
135
+ var AVALANCHE_PRIME = 2246822507;
136
+ var AVALANCHE_CROSS = 3266489909;
137
+ function avalancheMix(self, neighbor) {
138
+ return Math.imul(self ^ self >>> 16, AVALANCHE_PRIME) ^ Math.imul(neighbor ^ neighbor >>> 13, AVALANCHE_CROSS);
139
+ }
140
+
141
+ // src/core/routing.ts
142
+ var DEFAULT_MAX_ACTIVE_WORKERS = 3;
143
+ function selectLeastLoadedWorker(workers, preferredWorkerId) {
144
+ const preferred = workers.find((worker) => worker.workerId === preferredWorkerId);
145
+ if (preferred) return preferred;
146
+ return workers.reduce((least, worker) => {
147
+ if (!least) return worker;
148
+ const byLoad = worker.load - least.load;
149
+ if (byLoad !== 0) return byLoad < 0 ? worker : least;
150
+ if (worker.workerId < least.workerId) return worker;
151
+ return least;
152
+ }, void 0);
153
+ }
154
+ function selectActiveWorkers(workers, maxActiveWorkers = DEFAULT_MAX_ACTIVE_WORKERS) {
155
+ const healthyWorkers = workers.filter((worker) => worker.status === "connecting" || worker.status === "connected");
156
+ const availableWorkers = healthyWorkers.length > 0 ? healthyWorkers : [...workers];
157
+ const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === "visible");
158
+ const candidates = visibleWorkers.length > 0 ? visibleWorkers : availableWorkers;
159
+ return candidates.sort(
160
+ (left, right) => left.registeredAt - right.registeredAt || (left.workerId < right.workerId ? -1 : left.workerId > right.workerId ? 1 : 0)
161
+ ).slice(0, maxActiveWorkers);
162
+ }
163
+ function isWildcardTopic(pattern) {
164
+ return pattern === "*" || pattern.endsWith(".*");
165
+ }
166
+ function topicMatchesPattern(pattern, topic) {
167
+ if (!pattern || !topic) return false;
168
+ if (pattern === topic) return true;
169
+ if (pattern === "*") return true;
170
+ if (!pattern.endsWith(".*")) return false;
171
+ return topic.startsWith(pattern.slice(0, -1));
172
+ }
173
+
174
+ // src/core/storage-batch.ts
175
+ var INITIAL_RETRY_DELAY_MS = 50;
176
+ var MAX_RETRY_DELAY_MS = 1600;
177
+ var MAX_RETRY_ATTEMPTS = 5;
178
+ var BatchingStorageWriter = class {
179
+ constructor(storage) {
180
+ this.storage = storage;
181
+ }
182
+ /** Coalesced write set. A `null` value represents a pending delete. */
183
+ pending = /* @__PURE__ */ new Map();
184
+ /** Per-key retry counter, reset on a successful write. */
185
+ retryCount = /* @__PURE__ */ new Map();
186
+ flushScheduled = false;
187
+ retryHandle = null;
188
+ retryDelayMs = INITIAL_RETRY_DELAY_MS;
189
+ /** Number of writes queued in memory but not yet flushed to storage.
190
+ * Used by tests to assert the coalescing window and by flush() to detect
191
+ * the all-drained state. */
192
+ get pendingSize() {
193
+ return this.pending.size;
194
+ }
195
+ get length() {
196
+ return this.keys().length;
197
+ }
198
+ clear() {
199
+ this.pending.clear();
200
+ this.flushScheduled = false;
201
+ this.storage.clear();
202
+ this.cancelRetry();
203
+ this.retryCount.clear();
204
+ this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
205
+ }
206
+ // Reads always see the pending value first (task-local consistency), then
207
+ // fall back to the underlying storage.
208
+ getItem(key) {
209
+ if (this.pending.has(key)) return this.pending.get(key) ?? null;
210
+ return this.storage.getItem(key);
211
+ }
212
+ key(index) {
213
+ return this.keys()[index] ?? null;
214
+ }
215
+ removeItem(key) {
216
+ this.pending.set(key, null);
217
+ this.scheduleFlush();
218
+ }
219
+ setItem(key, value) {
220
+ this.pending.set(key, value);
221
+ this.scheduleFlush();
222
+ }
223
+ flush() {
224
+ this.flushScheduled = false;
225
+ this.cancelRetry();
226
+ for (const [key, value] of Array.from(this.pending)) {
227
+ try {
228
+ if (value === null) this.storage.removeItem(key);
229
+ else this.storage.setItem(key, value);
230
+ this.pending.delete(key);
231
+ this.retryCount.delete(key);
232
+ } catch {
233
+ const attempts = (this.retryCount.get(key) ?? 0) + 1;
234
+ if (attempts >= MAX_RETRY_ATTEMPTS) {
235
+ this.pending.delete(key);
236
+ this.retryCount.delete(key);
237
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
238
+ console.warn("[cross-tab-worker-databus] storage write gave up after retries, dropping key:", key);
239
+ }
240
+ continue;
241
+ }
242
+ this.retryCount.set(key, attempts);
243
+ this.scheduleRetry();
244
+ break;
245
+ }
246
+ }
247
+ if (this.pending.size === 0) {
248
+ this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
249
+ this.retryCount.clear();
250
+ }
251
+ }
252
+ /** Union of persisted keys and pending writes, minus pending deletes. */
253
+ keys() {
254
+ const keys = /* @__PURE__ */ new Set();
255
+ for (let index = 0; index < this.storage.length; index += 1) {
256
+ const key = this.storage.key(index);
257
+ if (key !== null) keys.add(key);
258
+ }
259
+ for (const [key, value] of this.pending) {
260
+ if (value === null) keys.delete(key);
261
+ else keys.add(key);
262
+ }
263
+ return Array.from(keys);
264
+ }
265
+ // Coalesce all synchronous writes within one task into a single microtask
266
+ // flush, avoiding a localStorage write per heartbeat/route/subscriber update.
267
+ // The queueMicrotask fallback to setTimeout handles older runtimes and
268
+ // non-browser environments where queueMicrotask is absent.
269
+ scheduleFlush() {
270
+ if (this.flushScheduled) return;
271
+ this.flushScheduled = true;
272
+ const flush = () => {
273
+ this.flushScheduled = false;
274
+ this.flush();
275
+ };
276
+ if (typeof queueMicrotask === "function") queueMicrotask(flush);
277
+ else setTimeout(flush, 0);
278
+ }
279
+ // Schedule a single retry timer. The guard ensures only one retry is in
280
+ // flight at a time; subsequent scheduleRetry calls during the wait are
281
+ // no-ops because the first retry will re-flush all pending keys together.
282
+ scheduleRetry() {
283
+ if (this.retryHandle !== null) return;
284
+ this.retryHandle = setTimeout(() => {
285
+ this.retryHandle = null;
286
+ this.flush();
287
+ }, this.retryDelayMs);
288
+ this.retryDelayMs = Math.min(MAX_RETRY_DELAY_MS, this.retryDelayMs * 2);
289
+ }
290
+ cancelRetry() {
291
+ if (this.retryHandle !== null) {
292
+ clearTimeout(this.retryHandle);
293
+ this.retryHandle = null;
294
+ }
295
+ }
296
+ };
297
+
298
+ // src/core/cluster.ts
299
+ var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
300
+ var DEFAULT_WORKER_TTL_MS = 1e4;
301
+ var DEFAULT_STORAGE_PREFIX = "cross-tab-worker-databus";
302
+ var MAX_KNOWN_TOPICS = 500;
303
+ function readJson(storage, key) {
304
+ try {
305
+ const value = storage.getItem(key);
306
+ return value ? JSON.parse(value) : null;
307
+ } catch {
308
+ return null;
309
+ }
310
+ }
311
+ function writeJson(storage, key, value) {
312
+ try {
313
+ storage.setItem(key, JSON.stringify(value));
314
+ } catch {
315
+ }
316
+ }
317
+ function listKeys(storage, prefix) {
318
+ try {
319
+ return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter(
320
+ (key) => Boolean(key?.startsWith(prefix))
321
+ );
322
+ } catch {
323
+ return [];
324
+ }
325
+ }
326
+ function readAllByPrefix(storage, prefix) {
327
+ return listKeys(storage, prefix).map((key) => ({ key, value: readJson(storage, key) })).filter((entry) => entry.value !== null);
328
+ }
329
+ var WorkerClusterRuntime = class {
330
+ tabId;
331
+ workerId;
332
+ environment;
333
+ handlers;
334
+ storage;
335
+ maxActiveWorkers;
336
+ heartbeatIntervalMs;
337
+ workerTtlMs;
338
+ workerPrefix;
339
+ routePrefix;
340
+ subscriberPrefix;
341
+ channelName;
342
+ // Topics this tab has subscribed to (local interest, plaintext).
343
+ subscribedTopics = /* @__PURE__ */ new Set();
344
+ // Topics assigned to this Worker as owner (topicKey → topic). Authoritative:
345
+ // membership drives isAssigned() and load. Grows only via CONTROL/SUBSCRIBE
346
+ // (or local self-subscribe), never via the reverse cache.
347
+ assignedTopics = /* @__PURE__ */ new Map();
348
+ // Reverse mapping: opaque topicKey → plaintext topic. A bounded cache with
349
+ // FIFO eviction — NOT authoritative. It can hold a topicKey that is also in
350
+ // assignedTopics (the owned guard prevents evicting those), because it is
351
+ // the only source of plaintext when storage is unavailable. See the
352
+ // rememberTopic() doc for the eviction contract.
353
+ knownTopics = /* @__PURE__ */ new Map();
354
+ channel = null;
355
+ heartbeatHandle = null;
356
+ started = false;
357
+ suspended = false;
358
+ lifecycleListening = false;
359
+ currentRecord;
360
+ constructor(options) {
361
+ this.environment = options.environment ?? createBrowserEnvironment();
362
+ this.handlers = options.handlers;
363
+ this.maxActiveWorkers = options.maxActiveWorkers ?? DEFAULT_MAX_ACTIVE_WORKERS;
364
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
365
+ this.workerTtlMs = options.workerTtlMs ?? DEFAULT_WORKER_TTL_MS;
366
+ const clusterHash = createOpaqueKey(options.clusterKey || "__default__");
367
+ const prefix = options.storagePrefix ?? DEFAULT_STORAGE_PREFIX;
368
+ const baseKey = `${prefix}:${clusterHash}`;
369
+ this.workerPrefix = `${baseKey}:worker:`;
370
+ this.routePrefix = `${baseKey}:route:`;
371
+ this.subscriberPrefix = `${baseKey}:subscriber:`;
372
+ this.channelName = `${prefix}:bus:${clusterHash}`;
373
+ this.storage = canUseStorage(this.environment.storage, `${baseKey}:probe`) ? new BatchingStorageWriter(this.environment.storage) : null;
374
+ this.tabId = options.tabId ?? getOrCreateTabId(this.environment, `${prefix}:tab-id`);
375
+ this.workerId = options.workerId ?? `worker-${this.tabId}-${this.environment.randomId()}`;
376
+ const now = this.environment.now();
377
+ this.currentRecord = {
378
+ workerId: this.workerId,
379
+ tabId: this.tabId,
380
+ load: 0,
381
+ role: "standby",
382
+ status: "connecting",
383
+ visibilityState: this.environment.getVisibilityState(),
384
+ heartbeatAt: now,
385
+ registeredAt: now
386
+ };
387
+ }
388
+ /** Start the cluster: register, listen for lifecycle events, and begin heartbeats. */
389
+ start() {
390
+ if (this.started) return;
391
+ this.suspended = false;
392
+ this.addLifecycleListeners();
393
+ this.activate();
394
+ }
395
+ /**
396
+ * Stop the cluster: pause heartbeats, hand off assigned topics, remove
397
+ * the worker record, and clean up lifecycle listeners. Idempotent.
398
+ * The .clear() calls after pause() are safe no-ops when pause already
399
+ * cleared the maps (the handoff path), but ensure a full teardown in the
400
+ * stop() path where callers expect every Set/Map to be empty afterwards.
401
+ */
402
+ stop() {
403
+ if (!this.started && !this.suspended) return;
404
+ this.pause();
405
+ this.flushStorage();
406
+ this.removeLifecycleListeners();
407
+ this.subscribedTopics.clear();
408
+ this.assignedTopics.clear();
409
+ this.knownTopics.clear();
410
+ this.suspended = false;
411
+ }
412
+ /**
413
+ * Activate the cluster: open the BroadcastChannel, register the worker record,
414
+ * subscribe to topics, and start the heartbeat interval.
415
+ */
416
+ activate() {
417
+ if (this.started) return;
418
+ this.started = true;
419
+ this.channel = this.storage ? this.environment.createChannel(this.channelName) : null;
420
+ if (!this.channel) this.storage = null;
421
+ this.channel?.addEventListener("message", this.handleMessage);
422
+ const now = this.environment.now();
423
+ this.currentRecord = {
424
+ ...this.currentRecord,
425
+ heartbeatAt: now,
426
+ registeredAt: now,
427
+ visibilityState: this.environment.getVisibilityState()
428
+ };
429
+ this.refreshRole(this.readWorkers());
430
+ this.writeRecord(true);
431
+ for (const topic of this.subscribedTopics) {
432
+ const topicKey = this.rememberTopic(topic);
433
+ if (!this.storage) this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
434
+ else this.writeSubscriber(topicKey);
435
+ }
436
+ this.reconcile();
437
+ this.heartbeatHandle = this.environment.setInterval(() => {
438
+ this.writeRecord(false);
439
+ this.reconcile();
440
+ }, this.heartbeatIntervalMs);
441
+ }
442
+ /**
443
+ * Pause the cluster on pagehide: stop heartbeats, hand off assigned topics
444
+ * to other workers, remove our worker record, and close the channel.
445
+ */
446
+ pause() {
447
+ if (!this.started) return;
448
+ this.started = false;
449
+ this.suspended = true;
450
+ if (this.heartbeatHandle !== null) this.environment.clearInterval(this.heartbeatHandle);
451
+ this.heartbeatHandle = null;
452
+ this.channel?.removeEventListener("message", this.handleMessage);
453
+ for (const topic of this.subscribedTopics) this.releaseSubscription(topic, false);
454
+ this.handoffAssignedTopics();
455
+ this.assignedTopics.clear();
456
+ this.removeStorage(this.workerStorageKey(this.workerId));
457
+ this.flushStorage();
458
+ this.notifyRegistry();
459
+ this.channel?.close();
460
+ this.channel = null;
461
+ this.handlers.onSuspend?.();
462
+ }
463
+ /** Update the worker's connection status and persist the change. */
464
+ setStatus(status) {
465
+ if (this.currentRecord.status === status) return;
466
+ this.currentRecord = { ...this.currentRecord, status };
467
+ if (this.started) this.writeRecord(true);
468
+ }
469
+ /**
470
+ * Subscribe to a topic. Returns true if this worker becomes the assigned owner.
471
+ * The topic is recorded locally and the cluster is notified via storage or
472
+ * direct control message.
473
+ */
474
+ subscribe(topic) {
475
+ const topicKey = this.rememberTopic(topic);
476
+ this.subscribedTopics.add(topic);
477
+ if (!this.started) return false;
478
+ if (!this.storage) {
479
+ this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
480
+ return true;
481
+ }
482
+ this.writeSubscriber(topicKey);
483
+ const workers = this.readWorkers();
484
+ const existingRoute = this.readRoute(topicKey);
485
+ if (this.routeOwnerIsLive(existingRoute, workers)) {
486
+ return existingRoute?.workerId === this.workerId;
487
+ }
488
+ const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
489
+ const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
490
+ this.writeRoute(topicKey, owner, void 0, (existingRoute?.generation ?? 0) + 1);
491
+ this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
492
+ this.notifyRegistry();
493
+ return owner.workerId === this.workerId;
494
+ }
495
+ /**
496
+ * Remove the local subscription. Cleans up the subscriber record and, if no
497
+ * subscribers remain, deletes the route so the owning Worker can unsubscribe.
498
+ */
499
+ unsubscribe(topic) {
500
+ this.subscribedTopics.delete(topic);
501
+ const topicKey = this.releaseSubscription(topic);
502
+ if (topicKey && !this.assignedTopics.has(topicKey)) this.knownTopics.delete(topicKey);
503
+ }
504
+ /** Remove this tab's subscriber record and, when it was the last one, delete
505
+ * the route. Returns the topicKey (so callers like `unsubscribe` can reuse
506
+ * it instead of re-hashing the topic to evict the reverse cache). */
507
+ releaseSubscription(topic, notifyOwner = true) {
508
+ const topicKey = this.rememberTopic(topic);
509
+ this.removeStorage(this.subscriberStorageKey(topicKey, this.tabId));
510
+ const route = this.readRoute(topicKey);
511
+ if (!route) return topicKey;
512
+ const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
513
+ if (subscribers.length === 0) {
514
+ this.removeStorage(this.routeStorageKey(topicKey));
515
+ if (notifyOwner) this.sendControl(route.workerId, "UNSUBSCRIBE", topic, topicKey);
516
+ }
517
+ return topicKey;
518
+ }
519
+ /** Transfer assigned topics to other active workers so subscribers are not orphaned during pause. */
520
+ handoffAssignedTopics() {
521
+ if (!this.storage || this.assignedTopics.size === 0) return;
522
+ const remainingWorkers = this.readWorkers().filter((worker) => worker.workerId !== this.workerId);
523
+ const activeWorkers = selectActiveWorkers(remainingWorkers, this.maxActiveWorkers);
524
+ const projectedLoads = new Map(activeWorkers.map((worker) => [worker.workerId, worker.load]));
525
+ for (const [topicKey, topic] of this.assignedTopics) {
526
+ const previous = this.readRoute(topicKey);
527
+ if (previous?.workerId !== this.workerId) continue;
528
+ const subscribers = this.readSubscriberTabIds(topicKey, remainingWorkers);
529
+ if (subscribers.length === 0) {
530
+ this.removeStorage(this.routeStorageKey(topicKey));
531
+ continue;
532
+ }
533
+ const owner = selectLeastLoadedWorker(
534
+ activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load }))
535
+ );
536
+ if (!owner) continue;
537
+ projectedLoads.set(owner.workerId, (projectedLoads.get(owner.workerId) ?? owner.load) + 1);
538
+ const generation = (previous?.generation ?? 0) + 1;
539
+ this.writeRoute(topicKey, owner, previous?.workerId, generation);
540
+ this.flushStorage();
541
+ this.handlers.onControl("UNSUBSCRIBE", topic);
542
+ this.sendRouteReleased(owner.workerId, topic, topicKey, generation);
543
+ }
544
+ }
545
+ /**
546
+ * Publish a message to `topic`, routing through the owning Worker (or self if
547
+ * no owner is found). Returns false when the control message could not be
548
+ * posted to a remote owner, so the caller can surface the failure instead of
549
+ * silently dropping the publication.
550
+ */
551
+ publish(topic, data) {
552
+ const topicKey = this.rememberTopic(topic);
553
+ const workers = this.readWorkers();
554
+ const route = this.readRoute(topicKey);
555
+ const target = this.routeOwnerIsLive(route, workers) ? route?.workerId ?? this.workerId : this.workerId;
556
+ return this.sendControl(target, "PUBLISH", topic, topicKey, data);
557
+ }
558
+ /** True when `route` exists and its owner worker is among `workers`.
559
+ * Shared by subscribe (skip re-assignment) and publish (route to owner).
560
+ * Intentionally returns a plain boolean (not a type guard) so the caller
561
+ * can still access `route?.generation` in the false branch. */
562
+ routeOwnerIsLive(route, workers) {
563
+ return Boolean(route && workers.some((worker) => worker.workerId === route.workerId));
564
+ }
565
+ /** Broadcast an event to every tab — used to fan out transport publications. */
566
+ broadcastEvent(eventType, payload) {
567
+ this.send({ type: "EVENT", sourceWorkerId: this.workerId, eventType, payload });
568
+ }
569
+ isAssigned(topic) {
570
+ const topicKey = createOpaqueKey(topic);
571
+ if (this.assignedTopics.has(topicKey)) return true;
572
+ for (const pattern of this.assignedTopics.values()) {
573
+ if (pattern !== topic && topicMatchesPattern(pattern, topic)) return true;
574
+ }
575
+ return this.readRoute(topicKey)?.workerId === this.workerId;
576
+ }
577
+ /** True if this worker is among the active set (eligible to own topics). */
578
+ isActiveWorker() {
579
+ return this.isActiveAmong(this.readWorkers());
580
+ }
581
+ /** True when this workerId is in the active subset of `workers`. Shared by
582
+ * isActiveWorker() and refreshRole() so both compute role identically. */
583
+ isActiveAmong(workers) {
584
+ return selectActiveWorkers(workers, this.maxActiveWorkers).some(
585
+ (worker) => worker.workerId === this.workerId
586
+ );
587
+ }
588
+ /** True when this tab has a local subscriber registered for `topic` —
589
+ * exactly, or via a wildcard subscription that matches it. */
590
+ hasLocalSubscriber(topic) {
591
+ if (this.subscribedTopics.has(topic)) return true;
592
+ for (const pattern of this.subscribedTopics) {
593
+ if (pattern !== topic && topicMatchesPattern(pattern, topic)) return true;
594
+ }
595
+ return false;
596
+ }
597
+ /** Read-only snapshot of the cluster state (workers, routes, assignments). */
598
+ getSnapshot() {
599
+ const routes = this.storage ? readAllByPrefix(this.storage, this.routePrefix).map(({ value }) => ({
600
+ ...value,
601
+ topic: this.knownTopics.get(value.topicKey) ?? null
602
+ })) : [];
603
+ return {
604
+ coordinated: Boolean(this.storage && this.channel),
605
+ suspended: this.suspended,
606
+ currentWorker: { ...this.currentRecord },
607
+ workers: this.readWorkers().map((worker) => ({ ...worker })),
608
+ routes,
609
+ subscribedTopics: Array.from(this.subscribedTopics),
610
+ assignedTopics: Array.from(this.assignedTopics.values()),
611
+ knownTopics: Array.from(this.knownTopics.entries(), ([topicKey, topic]) => ({ topicKey, topic }))
612
+ };
613
+ }
614
+ handlePageHide = () => this.pause();
615
+ handlePageShow = () => {
616
+ if (!this.suspended) return;
617
+ this.suspended = false;
618
+ this.handlers.onResume?.();
619
+ this.activate();
620
+ };
621
+ handleVisibilityChange = () => {
622
+ const visibilityState = this.environment.getVisibilityState();
623
+ if (visibilityState === this.currentRecord.visibilityState) return;
624
+ this.currentRecord = { ...this.currentRecord, visibilityState };
625
+ if (this.started) {
626
+ this.writeRecord(true);
627
+ this.reconcile();
628
+ }
629
+ };
630
+ addLifecycleListeners() {
631
+ if (this.lifecycleListening) return;
632
+ this.lifecycleListening = true;
633
+ this.environment.addPageHideListener(this.handlePageHide);
634
+ this.environment.addPageShowListener(this.handlePageShow);
635
+ this.environment.addVisibilityChangeListener(this.handleVisibilityChange);
636
+ }
637
+ removeLifecycleListeners() {
638
+ if (!this.lifecycleListening) return;
639
+ this.lifecycleListening = false;
640
+ this.environment.removePageHideListener(this.handlePageHide);
641
+ this.environment.removePageShowListener(this.handlePageShow);
642
+ this.environment.removeVisibilityChangeListener(this.handleVisibilityChange);
643
+ }
644
+ /** Handle an incoming cluster message: dispatch by type to the per-type handlers. */
645
+ handleMessage = (event) => {
646
+ const message = event.data;
647
+ if (!message || message.sourceWorkerId === this.workerId) return;
648
+ switch (message.type) {
649
+ case "CONTROL":
650
+ return this.handleControlMessage(message);
651
+ case "ROUTE_RELEASED":
652
+ return this.handleRouteReleasedMessage(message);
653
+ case "EVENT":
654
+ this.handlers.onEvent(message.eventType, message.payload, message.sourceWorkerId);
655
+ return;
656
+ case "REGISTRY":
657
+ default:
658
+ this.reconcile();
659
+ return;
660
+ }
661
+ };
662
+ /** Handle a point-to-point CONTROL message (SUBSCRIBE / UNSUBSCRIBE / PUBLISH). */
663
+ handleControlMessage(message) {
664
+ if (message.targetWorkerId !== this.workerId) return;
665
+ this.rememberTopic(message.topic);
666
+ switch (message.action) {
667
+ case "SUBSCRIBE":
668
+ this.assignedTopics.set(message.topicKey, message.topic);
669
+ this.confirmRoute(message.topicKey);
670
+ break;
671
+ case "UNSUBSCRIBE":
672
+ if (this.releaseHandoffOnUnsubscribe(message)) return;
673
+ break;
674
+ case "PUBLISH":
675
+ default:
676
+ break;
677
+ }
678
+ this.handlers.onControl(message.action, message.topic, message.data);
679
+ if (message.action !== "PUBLISH") this.updateLoad();
680
+ }
681
+ /**
682
+ * When this worker is the previous owner in a graceful handoff and the new
683
+ * owner asks us to unsubscribe, release the old transport subscription and
684
+ * ACK the handoff with ROUTE_RELEASED. Returns true when the message was a
685
+ * handoff release (the generic CONTROL dispatch must not run as well).
686
+ */
687
+ releaseHandoffOnUnsubscribe(message) {
688
+ this.assignedTopics.delete(message.topicKey);
689
+ const route = this.readRoute(message.topicKey);
690
+ if (route?.handoffFromWorkerId !== this.workerId) return false;
691
+ this.handlers.onControl("UNSUBSCRIBE", message.topic, void 0);
692
+ this.sendRouteReleased(route.workerId, message.topic, message.topicKey, route.generation);
693
+ this.updateLoad();
694
+ return true;
695
+ }
696
+ /** Post a ROUTE_RELEASED ACK to the new owner, carrying the current route
697
+ * generation so only the matching new owner may act on it. */
698
+ sendRouteReleased(targetWorkerId, topic, topicKey, generation) {
699
+ this.send({
700
+ type: "ROUTE_RELEASED",
701
+ sourceWorkerId: this.workerId,
702
+ targetWorkerId,
703
+ topic,
704
+ topicKey,
705
+ generation
706
+ });
707
+ }
708
+ /**
709
+ * Accept a graceful handoff only when the route still points to this worker,
710
+ * the release comes from the recorded previous owner, and the generation is
711
+ * at least as new as ours. Any other ROUTE_RELEASED is stale and dropped.
712
+ */
713
+ handleRouteReleasedMessage(message) {
714
+ if (message.targetWorkerId !== this.workerId) return;
715
+ const route = this.readRoute(message.topicKey);
716
+ if (!route || this.isStaleRouteRelease(route, message)) return;
717
+ this.assignedTopics.set(message.topicKey, message.topic);
718
+ this.confirmRoute(message.topicKey);
719
+ this.handlers.onControl("SUBSCRIBE", message.topic, void 0);
720
+ this.updateLoad();
721
+ }
722
+ /** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
723
+ * points to us, the release comes from the recorded previous owner, and
724
+ * the release generation is at least as new as ours. */
725
+ isStaleRouteRelease(route, message) {
726
+ return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || route.generation < message.generation;
727
+ }
728
+ /** Full reconciliation cycle: workers, subscriptions, and assigned topics. */
729
+ reconcile() {
730
+ if (!this.started) return;
731
+ const workers = this.reconcileWorkers();
732
+ const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
733
+ this.reconcileSubscriptions(workers, activeWorkers);
734
+ this.reconcileAssignedTopics();
735
+ this.updateLoad();
736
+ }
737
+ /** Prune stale workers/subscribers/routes and refresh role. Returns the live worker list.
738
+ * Subscribers are cleaned before routes so cleanupOrphanedRoutes sees the
739
+ * updated subscriber set when deciding whether a route is truly orphaned. */
740
+ reconcileWorkers() {
741
+ const workers = this.readWorkers();
742
+ this.cleanupOrphanedSubscribers(workers);
743
+ this.cleanupOrphanedRoutes(workers);
744
+ const roleChanged = this.refreshRole(workers);
745
+ if (roleChanged) this.writeRecord(false);
746
+ return workers;
747
+ }
748
+ /**
749
+ * Ensure every local subscription has a route and write subscriber records.
750
+ *
751
+ * Existing routes are deliberately sticky while their owner Worker is alive.
752
+ * Load and visibility only influence placement of a new route; they must not
753
+ * move an already-subscribed Topic merely because another Tab joins or becomes
754
+ * visible. Ownership changes only after the owner leaves or its heartbeat
755
+ * expires, which avoids unnecessary transport subscribe/unsubscribe churn.
756
+ */
757
+ reconcileSubscriptions(workers, activeWorkers) {
758
+ const liveWorkerIds = new Set(workers.map((worker) => worker.workerId));
759
+ for (const topic of this.subscribedTopics) {
760
+ const topicKey = this.rememberTopic(topic);
761
+ this.writeSubscriber(topicKey);
762
+ const route = this.readRoute(topicKey);
763
+ if (!route || !liveWorkerIds.has(route.workerId)) {
764
+ const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
765
+ this.writeRoute(topicKey, owner, void 0, (route?.generation ?? 0) + 1);
766
+ this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
767
+ this.notifyRegistry();
768
+ continue;
769
+ }
770
+ if (route.confirmedAt === void 0) {
771
+ if (!route.handoffFromWorkerId) {
772
+ this.sendControl(route.workerId, "SUBSCRIBE", topic, topicKey);
773
+ }
774
+ }
775
+ }
776
+ }
777
+ /** Drop assignments where the route no longer points to this worker. */
778
+ reconcileAssignedTopics() {
779
+ for (const [topicKey, topic] of [...this.assignedTopics]) {
780
+ const route = this.readRoute(topicKey);
781
+ if (route?.workerId === this.workerId) continue;
782
+ this.assignedTopics.delete(topicKey);
783
+ this.handlers.onControl("UNSUBSCRIBE", topic, void 0);
784
+ if (route?.handoffFromWorkerId === this.workerId) {
785
+ this.sendRouteReleased(route.workerId, topic, topicKey, route.generation);
786
+ }
787
+ if (!this.subscribedTopics.has(topic)) this.knownTopics.delete(topicKey);
788
+ }
789
+ }
790
+ /**
791
+ * Send a control message to `targetWorkerId`, or execute locally when targeting self.
792
+ * Local execution updates the assignment map and route synchronously, bypassing
793
+ * the BroadcastChannel latency.
794
+ */
795
+ sendControl(targetWorkerId, action, topic, topicKey, data) {
796
+ if (targetWorkerId === this.workerId) {
797
+ switch (action) {
798
+ case "SUBSCRIBE":
799
+ this.assignedTopics.set(topicKey, topic);
800
+ this.confirmRoute(topicKey);
801
+ break;
802
+ case "UNSUBSCRIBE":
803
+ this.assignedTopics.delete(topicKey);
804
+ break;
805
+ case "PUBLISH":
806
+ default:
807
+ break;
808
+ }
809
+ this.handlers.onControl(action, topic, data);
810
+ if (action !== "PUBLISH") this.updateLoad();
811
+ return true;
812
+ }
813
+ return this.send({
814
+ type: "CONTROL",
815
+ sourceWorkerId: this.workerId,
816
+ targetWorkerId,
817
+ action,
818
+ topic,
819
+ topicKey,
820
+ ...data === void 0 ? {} : { data }
821
+ });
822
+ }
823
+ /** Post a message on the BroadcastChannel. Returns false on postMessage failure. */
824
+ send(message) {
825
+ if (!this.channel) return false;
826
+ try {
827
+ this.channel.postMessage(message);
828
+ return true;
829
+ } catch {
830
+ return false;
831
+ }
832
+ }
833
+ /** Read all live worker records from storage, pruning stale entries past the TTL. */
834
+ readWorkers() {
835
+ if (!this.storage) return [this.currentRecord];
836
+ const now = this.environment.now();
837
+ const workers = [];
838
+ for (const { key, value: worker } of readAllByPrefix(this.storage, this.workerPrefix)) {
839
+ if (worker.workerId !== this.workerId && now - worker.heartbeatAt > this.workerTtlMs) {
840
+ this.removeStorage(key);
841
+ continue;
842
+ }
843
+ workers.push(worker);
844
+ }
845
+ if (this.started && !workers.some((worker) => worker.workerId === this.workerId)) workers.push(this.currentRecord);
846
+ return workers;
847
+ }
848
+ /** Enumerate all tab IDs that have a subscriber record for `topicKey`. */
849
+ readSubscriberTabIds(topicKey, workers) {
850
+ if (!this.storage) {
851
+ const topic = this.knownTopics.get(topicKey);
852
+ return topic && this.subscribedTopics.has(topic) ? [this.tabId] : [];
853
+ }
854
+ const activeTabIds = new Set(workers.map((worker) => worker.tabId));
855
+ const subscribers = /* @__PURE__ */ new Set();
856
+ for (const { key, value: record } of readAllByPrefix(
857
+ this.storage,
858
+ `${this.subscriberPrefix}${topicKey}:`
859
+ )) {
860
+ if (!activeTabIds.has(record.tabId)) {
861
+ this.removeStorage(key);
862
+ continue;
863
+ }
864
+ subscribers.add(record.tabId);
865
+ }
866
+ return Array.from(subscribers);
867
+ }
868
+ /** Read the current route for `topicKey`, returning null when no storage layer exists. */
869
+ readRoute(topicKey) {
870
+ if (!this.storage) return this.buildLocalRoute(topicKey);
871
+ return readJson(this.storage, this.routeStorageKey(topicKey));
872
+ }
873
+ /** Synthesize a self-owned route when storage is unavailable (degraded mode).
874
+ * The plaintext topic must be recoverable from the knownTopics cache; a
875
+ * missing entry means we never subscribed to or were assigned the topic,
876
+ * so there is no route to report. */
877
+ buildLocalRoute(topicKey) {
878
+ const topic = this.knownTopics.get(topicKey);
879
+ if (!topic) return null;
880
+ if (!this.subscribedTopics.has(topic) && !this.assignedTopics.has(topicKey)) return null;
881
+ return {
882
+ topicKey,
883
+ workerId: this.workerId,
884
+ tabId: this.tabId,
885
+ updatedAt: this.environment.now(),
886
+ generation: 1
887
+ };
888
+ }
889
+ /** Persist a route assignment, mapping `topicKey` to the owning Worker. */
890
+ writeRoute(topicKey, owner, handoffFromWorkerId, generation = 1) {
891
+ if (!this.storage) return;
892
+ writeJson(this.storage, this.routeStorageKey(topicKey), this.buildRouteRecord(topicKey, owner, handoffFromWorkerId, generation));
893
+ }
894
+ /** Construct a WorkerRoute record from the owner + handoff fields. Extracted
895
+ * so writeRoute and confirmRoute share the same shape; confirmedAt is added
896
+ * by confirmRoute via spread. */
897
+ buildRouteRecord(topicKey, owner, handoffFromWorkerId, generation) {
898
+ return {
899
+ topicKey,
900
+ workerId: owner.workerId,
901
+ tabId: owner.tabId,
902
+ updatedAt: this.environment.now(),
903
+ generation,
904
+ ...handoffFromWorkerId ? { handoffFromWorkerId } : {}
905
+ };
906
+ }
907
+ /** Stamp a route as confirmed once the owning Worker has acknowledged the assignment. */
908
+ confirmRoute(topicKey) {
909
+ if (!this.storage) return;
910
+ const route = this.readRoute(topicKey);
911
+ if (!route || route.workerId !== this.workerId || route.confirmedAt !== void 0) return;
912
+ writeJson(this.storage, this.routeStorageKey(topicKey), {
913
+ ...route,
914
+ confirmedAt: this.environment.now()
915
+ });
916
+ }
917
+ /** Remove routes whose topic has no subscribers and whose TTL has expired. */
918
+ cleanupOrphanedRoutes(workers) {
919
+ if (!this.storage) return;
920
+ const now = this.environment.now();
921
+ for (const { key, value: route } of readAllByPrefix(this.storage, this.routePrefix)) {
922
+ if (now - route.updatedAt <= this.workerTtlMs) continue;
923
+ if (this.readSubscriberTabIds(route.topicKey, workers).length > 0) continue;
924
+ this.removeStorage(key);
925
+ }
926
+ }
927
+ /** Remove subscriber records for tabs that are no longer active. */
928
+ cleanupOrphanedSubscribers(workers) {
929
+ if (!this.storage) return;
930
+ const activeTabIds = new Set(workers.map((worker) => worker.tabId));
931
+ for (const { key, value: record } of readAllByPrefix(this.storage, this.subscriberPrefix)) {
932
+ if (!activeTabIds.has(record.tabId)) this.removeStorage(key);
933
+ }
934
+ }
935
+ /** Persist a subscriber record for this tab on `topicKey`. */
936
+ writeSubscriber(topicKey) {
937
+ if (!this.storage) return;
938
+ writeJson(this.storage, this.subscriberStorageKey(topicKey, this.tabId), {
939
+ tabId: this.tabId,
940
+ updatedAt: this.environment.now()
941
+ });
942
+ }
943
+ /** Persist the current worker record with an updated heartbeat timestamp.
944
+ * @param notify — when true, broadcast a REGISTRY nudge so peers reconcile
945
+ * immediately instead of waiting for the next heartbeat. False on the
946
+ * periodic heartbeat tick (peers will notice on their own heartbeat) to
947
+ * avoid a REGISTRY storm every 3 s; true on status/role changes that
948
+ * peers should observe promptly. */
949
+ writeRecord(notify) {
950
+ this.currentRecord = { ...this.currentRecord, heartbeatAt: this.environment.now() };
951
+ if (this.storage) writeJson(this.storage, this.workerStorageKey(this.workerId), this.currentRecord);
952
+ if (notify) this.notifyRegistry();
953
+ }
954
+ /** Broadcast a REGISTRY message to trigger reconciliation on other tabs. */
955
+ notifyRegistry() {
956
+ this.send({ type: "REGISTRY", sourceWorkerId: this.workerId });
957
+ }
958
+ /** Recompute whether this worker is active (eligible to own topics) or standby. Returns true when changed. */
959
+ refreshRole(workers) {
960
+ const role = this.isActiveAmong(workers) ? "active" : "standby";
961
+ if (role === this.currentRecord.role) return false;
962
+ this.currentRecord = { ...this.currentRecord, role };
963
+ return true;
964
+ }
965
+ /** Persist the current topic load count (number of assigned topics) for load-balanced routing. */
966
+ updateLoad() {
967
+ const load = this.assignedTopics.size;
968
+ if (load === this.currentRecord.load) return;
969
+ this.currentRecord = { ...this.currentRecord, load };
970
+ if (this.started) this.writeRecord(true);
971
+ }
972
+ /**
973
+ * Hash `topic` into its opaque key and populate the reverse-lookup cache.
974
+ *
975
+ * Despite the name, this is NOT a cache lookup — it unconditionally writes
976
+ * the `topicKey → topic` pair. Hashing is cheap enough that a caller needing
977
+ * the key should always call this rather than check `knownTopics` first;
978
+ * the cache's FIFO eviction below keeps it bounded. Only `isAssigned`
979
+ * deliberately bypasses this (it must not pollute the cache on a read-only
980
+ * query), so if you add a new call site, prefer `rememberTopic` unless you
981
+ * have the same "read-only query" reason.
982
+ */
983
+ rememberTopic(topic) {
984
+ const topicKey = createOpaqueKey(topic);
985
+ this.knownTopics.set(topicKey, topic);
986
+ if (this.knownTopics.size > MAX_KNOWN_TOPICS) {
987
+ for (const candidate of this.knownTopics.keys()) {
988
+ if (candidate === topicKey || this.assignedTopics.has(candidate)) continue;
989
+ this.knownTopics.delete(candidate);
990
+ break;
991
+ }
992
+ }
993
+ return topicKey;
994
+ }
995
+ workerStorageKey(workerId) {
996
+ return `${this.workerPrefix}${workerId}`;
997
+ }
998
+ routeStorageKey(topicKey) {
999
+ return `${this.routePrefix}${topicKey}`;
1000
+ }
1001
+ subscriberStorageKey(topicKey, tabId) {
1002
+ return `${this.subscriberPrefix}${topicKey}:${tabId}`;
1003
+ }
1004
+ removeStorage(key) {
1005
+ try {
1006
+ this.storage?.removeItem(key);
1007
+ } catch {
1008
+ }
1009
+ }
1010
+ /** Force-flush any pending batched writes (used during shutdown/teardown). */
1011
+ flushStorage() {
1012
+ if (this.storage instanceof BatchingStorageWriter) this.storage.flush();
1013
+ }
1014
+ };
1015
+
1016
+ // src/core/trace.ts
1017
+ var DEFAULT_METRICS_INTERVAL_MS = 5e3;
1018
+ var MAX_PENDING_TOPICS = 1e3;
1019
+ var MAX_PENDING_MESSAGES_PER_TOPIC = 256;
1020
+ var LATENCY_BUCKET_COUNT = 20;
1021
+ var LATENCY_BUCKET_SIZE_MS = 50;
1022
+ var DataBusTraceReporter = class {
1023
+ enabled;
1024
+ mode;
1025
+ metricsIntervalMs;
1026
+ sink;
1027
+ now;
1028
+ intervalHandle = null;
1029
+ intervalStartedAt = 0;
1030
+ received = 0;
1031
+ dispatched = 0;
1032
+ latencySamples = 0;
1033
+ topics = /* @__PURE__ */ new Set();
1034
+ // Per-topic FIFO of received timestamps, used to compute dispatch latency.
1035
+ receivedAt = /* @__PURE__ */ new Map();
1036
+ // Bucketed histogram: bucket index = floor(delayMs / 50), capped at 19.
1037
+ latencyBuckets = new Array(LATENCY_BUCKET_COUNT).fill(0);
1038
+ latencySumMs = 0;
1039
+ constructor(options, now = Date.now) {
1040
+ this.enabled = options?.enabled ?? false;
1041
+ this.mode = options?.mode ?? "all";
1042
+ this.metricsIntervalMs = normalizeInterval(options?.metricsIntervalMs);
1043
+ this.sink = options?.sink ?? (() => void 0);
1044
+ this.now = now;
1045
+ }
1046
+ /** Start the periodic metrics flush interval. No-op when mode is 'events'
1047
+ * (no metrics to emit), when disabled, or when already running. */
1048
+ start() {
1049
+ if (!this.enabled || this.intervalHandle || this.mode === "events") return;
1050
+ this.intervalStartedAt = this.now();
1051
+ this.intervalHandle = setInterval(() => this.flush(), this.metricsIntervalMs);
1052
+ }
1053
+ /** Pause the metrics interval and reset accumulated counters. */
1054
+ pause() {
1055
+ if (this.intervalHandle) clearInterval(this.intervalHandle);
1056
+ this.intervalHandle = null;
1057
+ this.intervalStartedAt = 0;
1058
+ this.resetMetrics();
1059
+ }
1060
+ stop() {
1061
+ this.pause();
1062
+ }
1063
+ /** Record an instantaneous trace event (lifecycle, status, error, etc.). */
1064
+ event(event) {
1065
+ if (!this.enabled || this.mode === "metrics") return;
1066
+ this.emit({ ...event, timestamp: this.now() });
1067
+ }
1068
+ /** Record that a message was received on `topic`; stores its timestamp for latency tracking. */
1069
+ recordReceived(topic) {
1070
+ if (!this.metricsActive) return;
1071
+ this.received += 1;
1072
+ this.topics.add(topic);
1073
+ const queue = this.receivedAt.get(topic);
1074
+ if (!queue) {
1075
+ if (this.receivedAt.size >= MAX_PENDING_TOPICS) return;
1076
+ this.receivedAt.set(topic, [this.now()]);
1077
+ return;
1078
+ }
1079
+ if (queue.length >= MAX_PENDING_MESSAGES_PER_TOPIC) return;
1080
+ queue.push(this.now());
1081
+ }
1082
+ /**
1083
+ * Record that a received message will never be dispatched locally. Pops the
1084
+ * matching FIFO slot so a later dispatch on the same topic does not pair
1085
+ * with a stale receive timestamp.
1086
+ */
1087
+ recordDiscarded(topic) {
1088
+ if (!this.metricsActive) return;
1089
+ const queue = this.receivedAt.get(topic);
1090
+ if (!queue) return;
1091
+ queue.shift();
1092
+ if (queue.length === 0) this.receivedAt.delete(topic);
1093
+ }
1094
+ /**
1095
+ * Record that a message was dispatched on `topic`. Pops the oldest receive
1096
+ * timestamp (FIFO) and increments the latency histogram. Dispatches without
1097
+ * a matching receive (e.g. broadcast fan-out from another tab) still count
1098
+ * as dispatched but do not produce a latency sample.
1099
+ */
1100
+ recordDispatched(topic) {
1101
+ if (!this.metricsActive) return;
1102
+ this.dispatched += 1;
1103
+ this.topics.add(topic);
1104
+ const queue = this.receivedAt.get(topic);
1105
+ const receivedTimestamp = queue?.shift();
1106
+ if (queue && queue.length === 0) this.receivedAt.delete(topic);
1107
+ if (receivedTimestamp === void 0) return;
1108
+ this.latencySamples += 1;
1109
+ const delayMs = Math.max(0, this.now() - receivedTimestamp);
1110
+ const bucketIndex = Math.min(LATENCY_BUCKET_COUNT - 1, Math.floor(delayMs / LATENCY_BUCKET_SIZE_MS));
1111
+ this.latencyBuckets[bucketIndex] = (this.latencyBuckets[bucketIndex] ?? 0) + 1;
1112
+ this.latencySumMs += delayMs;
1113
+ }
1114
+ /** True when metrics recording is active: enabled and mode includes metrics.
1115
+ * Extracted so the four record / flush methods share one guard expression
1116
+ * instead of repeating `!this.enabled || this.mode === 'events'` at each. */
1117
+ get metricsActive() {
1118
+ return this.enabled && this.mode !== "events";
1119
+ }
1120
+ /** Emit the accumulated metrics snapshot if the interval is active. */
1121
+ flush() {
1122
+ if (!this.metricsActive) return;
1123
+ this.flushNow();
1124
+ }
1125
+ flushNow() {
1126
+ const timestamp = this.now();
1127
+ if (this.received > 0 || this.dispatched > 0) {
1128
+ const samples = this.latencySamples;
1129
+ this.emit({
1130
+ type: "message_metrics",
1131
+ durationMs: Math.max(0, timestamp - this.intervalStartedAt),
1132
+ received: this.received,
1133
+ dispatched: this.dispatched,
1134
+ topics: this.topics.size,
1135
+ dispatchSamples: samples,
1136
+ dispatchAvgMs: roundMs(samples === 0 ? 0 : this.latencySumMs / samples),
1137
+ // Percentiles are derived from the histogram, not sorted samples.
1138
+ dispatchP50Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.5)),
1139
+ dispatchP95Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.95)),
1140
+ dispatchMaxMs: roundMs(percentileMs(this.latencyBuckets, samples, 1)),
1141
+ timestamp
1142
+ });
1143
+ this.resetMetrics();
1144
+ }
1145
+ this.intervalStartedAt = timestamp;
1146
+ }
1147
+ resetMetrics() {
1148
+ this.received = 0;
1149
+ this.dispatched = 0;
1150
+ this.latencySamples = 0;
1151
+ this.topics.clear();
1152
+ this.receivedAt.clear();
1153
+ this.latencyBuckets.fill(0);
1154
+ this.latencySumMs = 0;
1155
+ }
1156
+ emit(event) {
1157
+ try {
1158
+ this.sink(event);
1159
+ } catch (error) {
1160
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
1161
+ console.warn("[cross-tab-worker-databus] trace sink threw:", error);
1162
+ }
1163
+ }
1164
+ }
1165
+ };
1166
+ function normalizeInterval(value) {
1167
+ if (value === void 0) return DEFAULT_METRICS_INTERVAL_MS;
1168
+ if (!Number.isFinite(value) || value <= 0) {
1169
+ throw new RangeError("trace.metricsIntervalMs must be a positive finite number.");
1170
+ }
1171
+ return value;
1172
+ }
1173
+ function percentileMs(buckets, sampleCount, percentile) {
1174
+ if (sampleCount <= 0) return 0;
1175
+ const rank = Math.max(1, Math.ceil(percentile * sampleCount));
1176
+ let seen = 0;
1177
+ for (let index = 0; index < buckets.length; index += 1) {
1178
+ seen += buckets[index] ?? 0;
1179
+ if (seen >= rank) return (index + 0.5) * LATENCY_BUCKET_SIZE_MS;
1180
+ }
1181
+ return buckets.length * LATENCY_BUCKET_SIZE_MS;
1182
+ }
1183
+ function roundMs(value) {
1184
+ return Math.round(value * 10) / 10;
1185
+ }
1186
+
1187
+ // src/core/data-bus.ts
1188
+ var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
1189
+ var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
1190
+ var CrossTabDataBus = class _CrossTabDataBus {
1191
+ transport;
1192
+ cluster;
1193
+ // Map of topic → set of local subscribers.
1194
+ topicHandlers = /* @__PURE__ */ new Map();
1195
+ // Topics for which the transport has been asked to subscribe (used to avoid
1196
+ // duplicate subscribe calls during reconnection).
1197
+ transportSubscribedTopics = /* @__PURE__ */ new Set();
1198
+ statusHandlers = /* @__PURE__ */ new Set();
1199
+ errorHandlers = /* @__PURE__ */ new Set();
1200
+ // Bounded per-topic ring of recent dispatched publications. Null unless
1201
+ // replay is enabled — buffering is opt-in and must cost nothing otherwise.
1202
+ replayBuffers;
1203
+ replayMaxPerTopic;
1204
+ initialConfig;
1205
+ hasInitialConfig;
1206
+ trace;
1207
+ activeConfig;
1208
+ status = "disconnected";
1209
+ started = false;
1210
+ stopping = false;
1211
+ transportReady = false;
1212
+ // Last transport failure, retained so ready() can surface it to callers who
1213
+ // never awaited start() directly. Cleared on the next successful start.
1214
+ lastError = null;
1215
+ // Gate that serialises start/stop/suspend/resume — only one lifecycle
1216
+ // transition at a time. Resets to null once the operation settles.
1217
+ startPromise = null;
1218
+ // Timestamp of the last automatic transport recovery attempt.
1219
+ // Used to avoid a tight retry loop when the transport fails repeatedly.
1220
+ lastRecoveryAt = 0;
1221
+ // True while the tab is hidden so an in-flight transport start does not mark
1222
+ // the transport ready after suspendTransport() has stopped it.
1223
+ suspended = false;
1224
+ // Single gate for async transport.stop() cleanup, shared by failed opens and
1225
+ // page-hide suspension. Kept separate from startPromise so ready() still
1226
+ // surfaces a failure while later opens and automatic recovery wait for the
1227
+ // stop to settle.
1228
+ pendingStop = null;
1229
+ // Minimum interval in ms between automatic recovery attempts.
1230
+ static RECOVERY_COOLDOWN_MS = 1e3;
1231
+ constructor(options) {
1232
+ const replay = options.replay;
1233
+ if (replay) {
1234
+ const maxPerTopic = replay.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1235
+ if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
1236
+ throw new TypeError(
1237
+ `replay.maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`
1238
+ );
1239
+ }
1240
+ }
1241
+ this.replayMaxPerTopic = replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
1242
+ this.replayBuffers = replay ? /* @__PURE__ */ new Map() : null;
1243
+ const { autoStart, initialConfig, trace, transport, ...clusterOptions } = options;
1244
+ this.transport = transport;
1245
+ this.initialConfig = initialConfig;
1246
+ this.hasInitialConfig = "initialConfig" in options;
1247
+ this.trace = new DataBusTraceReporter(trace);
1248
+ this.cluster = new WorkerClusterRuntime({
1249
+ ...clusterOptions,
1250
+ handlers: {
1251
+ // The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
1252
+ // control message — meaning the owning Worker has delegated the action to us.
1253
+ onControl: (action, topic, data) => {
1254
+ switch (action) {
1255
+ case "SUBSCRIBE":
1256
+ if (this.subscribeTransport(topic)) this.traceSubscription("subscribe", topic);
1257
+ break;
1258
+ case "UNSUBSCRIBE":
1259
+ if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
1260
+ break;
1261
+ case "PUBLISH":
1262
+ this.runTransport(() => this.transport.publish(topic, data));
1263
+ break;
1264
+ default:
1265
+ break;
1266
+ }
1267
+ },
1268
+ // The cluster calls `onEvent` when a publication broadcast arrives from
1269
+ // another tab. Dispatch locally if we have subscribers. The payload is
1270
+ // typed `unknown` at the cluster boundary (the cluster is transport-
1271
+ // agnostic); here we narrow it to DataBusMessage — the sender is our
1272
+ // own broadcastEvent call, which always posts a DataBusMessage.
1273
+ onEvent: (eventType, payload) => {
1274
+ if (eventType !== PUBLICATION_EVENT) return;
1275
+ const message = payload;
1276
+ if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
1277
+ },
1278
+ onSuspend: () => {
1279
+ if (!this.stopping) this.trace.event({ type: "lifecycle", action: "suspend" });
1280
+ this.trace.pause();
1281
+ this.suspendTransport();
1282
+ },
1283
+ onResume: () => {
1284
+ this.trace.event({ type: "lifecycle", action: "resume" });
1285
+ this.trace.start();
1286
+ this.resumeTransport();
1287
+ }
1288
+ }
1289
+ });
1290
+ if (autoStart ?? this.hasInitialConfig) this.ensureStarted();
1291
+ }
1292
+ /**
1293
+ * Start the DataBus with the given transport config.
1294
+ *
1295
+ * The first call starts the cluster and opens the transport. Concurrent calls
1296
+ * during an in-flight start return the same promise. Once the operation
1297
+ * settles (success or failure) the promise gate is cleared so a subsequent
1298
+ * start() or resumeTransport() can open a fresh lifecycle.
1299
+ */
1300
+ start(config) {
1301
+ if (this.startPromise) return this.startPromise;
1302
+ if (this.started) return Promise.resolve();
1303
+ this.started = true;
1304
+ this.stopping = false;
1305
+ this.suspended = false;
1306
+ this.activeConfig = config;
1307
+ this.lastError = null;
1308
+ this.trace.event({ type: "lifecycle", action: "start" });
1309
+ this.trace.start();
1310
+ this.updateStatus("connecting");
1311
+ this.cluster.start();
1312
+ const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
1313
+ this.startPromise = opening;
1314
+ for (const topic of this.topicHandlers.keys()) {
1315
+ this.cluster.subscribe(topic);
1316
+ }
1317
+ const snapshot = this.cluster.getSnapshot();
1318
+ this.trace.event({
1319
+ type: "coordination",
1320
+ coordinated: snapshot.coordinated,
1321
+ activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
1322
+ workers: snapshot.workers.map(formatWorkerTrace),
1323
+ routes: snapshot.routes.map(formatRouteTrace)
1324
+ });
1325
+ void opening.then(
1326
+ () => {
1327
+ if (this.startPromise === opening) this.startPromise = null;
1328
+ },
1329
+ () => {
1330
+ if (this.startPromise === opening) this.startPromise = null;
1331
+ }
1332
+ );
1333
+ return opening;
1334
+ }
1335
+ /**
1336
+ * Open the transport, chained after `before` to ensure lifecycle ordering.
1337
+ * When `stopClusterOnFailure` is true (initial start), a transport failure
1338
+ * tears down the cluster as well.
1339
+ */
1340
+ openTransport(config, before, stopClusterOnFailure) {
1341
+ this.transportReady = false;
1342
+ const chainedPendingStop = this.pendingStop;
1343
+ return before.catch(() => void 0).then(() => {
1344
+ if (this.stopping || this.suspended) return;
1345
+ if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
1346
+ return Promise.resolve(
1347
+ this.transport.start(config, {
1348
+ onMessage: (message) => this.handleTransportMessage(message),
1349
+ onStatus: (status) => this.updateStatus(status),
1350
+ onError: (error) => this.reportError(error)
1351
+ })
1352
+ ).then(() => {
1353
+ if (this.status === "error") {
1354
+ throw new Error("Transport failed during startup.");
1355
+ }
1356
+ if (!this.suspended && !this.stopping) this.transportReady = true;
1357
+ });
1358
+ }).catch((error) => {
1359
+ if (stopClusterOnFailure) this.started = false;
1360
+ if (!this.pendingStop) {
1361
+ this.pendingStop = this.createStopPromise();
1362
+ }
1363
+ this.updateStatus("error");
1364
+ this.reportError(error);
1365
+ this.lastError = error;
1366
+ this.transportReady = false;
1367
+ if (stopClusterOnFailure) {
1368
+ this.stopping = true;
1369
+ this.cluster.stop();
1370
+ this.stopping = false;
1371
+ }
1372
+ this.startPromise = null;
1373
+ throw error;
1374
+ });
1375
+ }
1376
+ /**
1377
+ * Await the DataBus to be fully started (lazy init when using initialConfig).
1378
+ * Returns a rejected promise when the transport has failed and no start is in
1379
+ * flight — the caller can retry by calling start() or ready() again.
1380
+ */
1381
+ ready() {
1382
+ try {
1383
+ this.ensureStarted();
1384
+ } catch (error) {
1385
+ return Promise.reject(error);
1386
+ }
1387
+ if (this.startPromise) return this.startPromise;
1388
+ if (this.transportReady) return Promise.resolve();
1389
+ if (this.lastError !== null) return Promise.reject(this.lastError);
1390
+ return Promise.reject(
1391
+ new Error("Transport is not ready and no start operation is in flight")
1392
+ );
1393
+ }
1394
+ /**
1395
+ * Register a handler for `topic`. The handler fires on every publication
1396
+ * delivered to this tab, regardless of which tab published it. Returns an
1397
+ * unsubscribe function for convenience.
1398
+ */
1399
+ subscribe(topic, handler, options) {
1400
+ this.ensureStarted();
1401
+ const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
1402
+ const wasUnused = handlers.size === 0;
1403
+ handlers.add(handler);
1404
+ this.topicHandlers.set(topic, handlers);
1405
+ if (wasUnused) this.cluster.subscribe(topic);
1406
+ if (options?.replay) {
1407
+ const limit = Math.min(
1408
+ typeof options.replay === "number" ? Math.floor(options.replay) : this.replayMaxPerTopic,
1409
+ this.replayMaxPerTopic
1410
+ );
1411
+ this.deliverReplay(topic, limit, handler);
1412
+ }
1413
+ return () => this.unsubscribe(topic, handler);
1414
+ }
1415
+ /** Remove a specific handler, or all handlers for `topic`.
1416
+ * When `handler` is omitted, clears every handler for the topic — the
1417
+ * caller used the `unsubscribe(topic)` form expecting a full teardown.
1418
+ * The cluster is only notified on the n→0 transition (handlers.size === 0). */
1419
+ unsubscribe(topic, handler) {
1420
+ const handlers = this.topicHandlers.get(topic);
1421
+ if (!handlers) return;
1422
+ if (handler) handlers.delete(handler);
1423
+ else handlers.clear();
1424
+ if (handlers.size > 0) return;
1425
+ this.topicHandlers.delete(topic);
1426
+ this.replayBuffers?.delete(topic);
1427
+ this.cluster.unsubscribe(topic);
1428
+ }
1429
+ /** Publish a message to `topic`. The owning Worker delivers it to the transport. */
1430
+ publish(topic, data) {
1431
+ this.ensureStarted();
1432
+ if (!this.cluster.publish(topic, data)) {
1433
+ this.reportError(
1434
+ new Error("Failed to send the publish control message to the owning worker.")
1435
+ );
1436
+ }
1437
+ }
1438
+ /** Register a handler that fires on every transport status change. Immediately invoked with the current status. */
1439
+ onStatus(handler) {
1440
+ this.statusHandlers.add(handler);
1441
+ try {
1442
+ handler(this.status);
1443
+ } catch (error) {
1444
+ this.reportError(error);
1445
+ }
1446
+ return () => this.statusHandlers.delete(handler);
1447
+ }
1448
+ /** Register a handler for transport errors. */
1449
+ onError(handler) {
1450
+ this.errorHandlers.add(handler);
1451
+ return () => this.errorHandlers.delete(handler);
1452
+ }
1453
+ /** Current transport connection status. */
1454
+ getStatus() {
1455
+ return this.status;
1456
+ }
1457
+ /** Snapshot of the cluster state (workers, routes, assignments).
1458
+ * For diagnostics only — the returned object is a shallow copy but
1459
+ * nested arrays are snapshots at call time. */
1460
+ getClusterSnapshot() {
1461
+ return this.cluster.getSnapshot();
1462
+ }
1463
+ /**
1464
+ * Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
1465
+ * and close the transport. Idempotent.
1466
+ */
1467
+ async stop() {
1468
+ if (!this.started) return;
1469
+ this.stopping = true;
1470
+ this.trace.event({ type: "lifecycle", action: "stop" });
1471
+ this.trace.stop();
1472
+ this.topicHandlers.clear();
1473
+ this.replayBuffers?.clear();
1474
+ this.cluster.stop();
1475
+ try {
1476
+ await this.startPromise?.catch(() => void 0);
1477
+ const pendingStop = this.pendingStop;
1478
+ if (pendingStop) await pendingStop.catch(() => void 0);
1479
+ else await this.transport.stop();
1480
+ } finally {
1481
+ this.transportSubscribedTopics.clear();
1482
+ this.started = false;
1483
+ this.stopping = false;
1484
+ this.suspended = false;
1485
+ this.transportReady = false;
1486
+ this.startPromise = null;
1487
+ this.pendingStop = null;
1488
+ this.lastError = null;
1489
+ this.activeConfig = void 0;
1490
+ this.updateStatus("disconnected");
1491
+ }
1492
+ }
1493
+ /**
1494
+ * Incoming message from the transport.
1495
+ * Records metrics, checks ownership via the cluster, broadcasts to other tabs,
1496
+ * and dispatches locally.
1497
+ */
1498
+ handleTransportMessage(message) {
1499
+ this.trace.recordReceived(message.topic);
1500
+ if (!this.cluster.isAssigned(message.topic)) {
1501
+ this.trace.recordDiscarded(message.topic);
1502
+ return;
1503
+ }
1504
+ this.cluster.broadcastEvent(PUBLICATION_EVENT, message);
1505
+ if (this.cluster.hasLocalSubscriber(message.topic)) {
1506
+ this.dispatch(message);
1507
+ return;
1508
+ }
1509
+ this.trace.recordDiscarded(message.topic);
1510
+ }
1511
+ /** Deliver a message to every local handler registered for its topic,
1512
+ * plus every handler registered with a wildcard subscription that matches
1513
+ * (e.g. a handler subscribed to "chat.*" receives "chat.room.1"). */
1514
+ dispatch(message) {
1515
+ this.trace.recordDispatched(message.topic);
1516
+ this.invokeHandlers(this.topicHandlers.get(message.topic) ?? [], (handler) => handler(message));
1517
+ for (const [pattern, handlers] of this.topicHandlers) {
1518
+ if (pattern !== message.topic && topicMatchesPattern(pattern, message.topic)) {
1519
+ this.invokeHandlers(handlers, (handler) => handler(message));
1520
+ }
1521
+ }
1522
+ this.recordReplay(message);
1523
+ }
1524
+ /** Append a dispatched publication to the topic's replay ring buffer.
1525
+ * No-op when replay is disabled. */
1526
+ recordReplay(message) {
1527
+ if (!this.replayBuffers) return;
1528
+ let buffer = this.replayBuffers.get(message.topic);
1529
+ if (!buffer) {
1530
+ buffer = [];
1531
+ this.replayBuffers.set(message.topic, buffer);
1532
+ }
1533
+ buffer.push(message);
1534
+ if (buffer.length > this.replayMaxPerTopic) buffer.shift();
1535
+ }
1536
+ /** Deliver buffered history to a newly-registered handler. For an exact
1537
+ * topic this is that topic's ring; for a wildcard subscription every
1538
+ * buffered topic matching the pattern contributes (in buffer insertion
1539
+ * order). Replay deliveries are marked `replayed: true` and are not
1540
+ * counted into trace metrics. */
1541
+ deliverReplay(topic, limit, handler) {
1542
+ if (!this.replayBuffers || limit <= 0) return;
1543
+ const deliver = (buffer2) => {
1544
+ for (const message of buffer2.slice(-limit)) {
1545
+ this.invokeHandlers([handler], (h) => h({ ...message, replayed: true }));
1546
+ }
1547
+ };
1548
+ if (isWildcardTopic(topic)) {
1549
+ for (const [bufferedTopic, buffer2] of this.replayBuffers) {
1550
+ if (topicMatchesPattern(topic, bufferedTopic)) deliver(buffer2);
1551
+ }
1552
+ return;
1553
+ }
1554
+ const buffer = this.replayBuffers.get(topic);
1555
+ if (buffer) deliver(buffer);
1556
+ }
1557
+ /**
1558
+ * Propagate a status change to the cluster, trace, and all registered
1559
+ * status handlers. On reconnect, re-subscribe any topics assigned to us.
1560
+ */
1561
+ updateStatus(status) {
1562
+ const previousStatus = this.status;
1563
+ this.status = status;
1564
+ if (previousStatus !== status) this.trace.event({ type: "status", status });
1565
+ this.cluster.setStatus(status);
1566
+ if (status === "disconnected" || status === "error") this.transportSubscribedTopics.clear();
1567
+ if (status === "connected" && previousStatus !== "connected") {
1568
+ for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
1569
+ }
1570
+ if (status === "error" && this.started && !this.stopping) {
1571
+ const now = Date.now();
1572
+ if (now - this.lastRecoveryAt >= _CrossTabDataBus.RECOVERY_COOLDOWN_MS) {
1573
+ this.lastRecoveryAt = now;
1574
+ setTimeout(() => {
1575
+ if (this.stopping || !this.started || this.suspended) return;
1576
+ if (this.status !== "error") return;
1577
+ void this.reopenTransport();
1578
+ }, _CrossTabDataBus.RECOVERY_COOLDOWN_MS);
1579
+ }
1580
+ }
1581
+ this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
1582
+ }
1583
+ reportError(error) {
1584
+ this.trace.event({ type: "error", source: "transport" });
1585
+ this.invokeHandlers(this.errorHandlers, (handler) => handler(error), "error handler");
1586
+ }
1587
+ traceSubscription(action, topic) {
1588
+ this.trace.event({
1589
+ type: "subscription",
1590
+ action,
1591
+ topic,
1592
+ activeTopics: this.transportSubscribedTopics.size
1593
+ });
1594
+ }
1595
+ /** Ask the transport to subscribe to a topic (idempotent). */
1596
+ subscribeTransport(topic) {
1597
+ if (this.transportSubscribedTopics.has(topic)) return false;
1598
+ this.transportSubscribedTopics.add(topic);
1599
+ this.runTransport(() => this.transport.subscribe(topic));
1600
+ return true;
1601
+ }
1602
+ unsubscribeTransport(topic) {
1603
+ if (!this.transportSubscribedTopics.delete(topic)) return false;
1604
+ this.runTransport(() => this.transport.unsubscribe(topic));
1605
+ return true;
1606
+ }
1607
+ /** Invoke `callback` for each item in `handlers`, isolating a throwing
1608
+ * callback so the remaining ones still run. Dispatch/status handler failures
1609
+ * are routed to `reportError` (which surfaces them to error subscribers);
1610
+ * error-handler failures are logged to the console to avoid infinite
1611
+ * recursion through reportError itself. */
1612
+ invokeHandlers(handlers, callback, label = "dispatch") {
1613
+ for (const handler of handlers) {
1614
+ try {
1615
+ callback(handler);
1616
+ } catch (error) {
1617
+ if (label === "error handler") {
1618
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
1619
+ console.warn("[cross-tab-worker-databus] error handler threw:", error);
1620
+ }
1621
+ } else {
1622
+ this.reportError(error);
1623
+ }
1624
+ }
1625
+ }
1626
+ }
1627
+ /**
1628
+ * Suspend the transport when the tab goes hidden. Stops the transport and
1629
+ * clears subscription state so it will be re-established on resume.
1630
+ */
1631
+ suspendTransport() {
1632
+ if (this.stopping) return;
1633
+ this.suspended = true;
1634
+ this.transportReady = false;
1635
+ this.transportSubscribedTopics.clear();
1636
+ this.updateStatus("disconnected");
1637
+ if (this.pendingStop) return;
1638
+ const pending = this.startPromise ?? Promise.resolve();
1639
+ const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
1640
+ this.startPromise = stopping;
1641
+ this.pendingStop = stopping;
1642
+ }
1643
+ /** Create an immediate stop promise (no prior chain). Used by openTransport's
1644
+ * failure path where there is no in-flight start to wait for. */
1645
+ createStopPromise() {
1646
+ return Promise.resolve().then(() => this.transport.stop()).catch((stopError) => this.reportError(stopError));
1647
+ }
1648
+ /**
1649
+ * Resume the transport when the tab becomes visible again, or recover from a
1650
+ * runtime transport failure. Re-opens the transport with the stored active
1651
+ * config, chained after any pending operation so an async transport stop
1652
+ * completes before the new start. Returns the opening promise.
1653
+ */
1654
+ resumeTransport() {
1655
+ void this.reopenTransport();
1656
+ }
1657
+ /**
1658
+ * Re-open the transport with the previously stored active config. Chains
1659
+ * after any in-flight lifecycle operation (e.g. a suspend stop), swallowing
1660
+ * its rejection so the reopen is not blocked. Returns the opening promise so
1661
+ * callers can queue operations behind it.
1662
+ */
1663
+ reopenTransport() {
1664
+ if (this.stopping || this.activeConfig === void 0) return Promise.resolve();
1665
+ if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
1666
+ const config = this.activeConfig;
1667
+ this.started = true;
1668
+ this.suspended = false;
1669
+ this.updateStatus("connecting");
1670
+ const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
1671
+ const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
1672
+ this.startPromise = opening;
1673
+ void opening.then(
1674
+ () => {
1675
+ if (this.startPromise === opening) this.startPromise = null;
1676
+ },
1677
+ () => void 0
1678
+ );
1679
+ void opening.catch(() => void 0);
1680
+ return opening;
1681
+ }
1682
+ /**
1683
+ * Run a transport operation now if the transport is ready, otherwise queue
1684
+ * it behind the start promise. This ensures subscribe/unsubscribe calls made
1685
+ * during startup are not lost.
1686
+ */
1687
+ runTransport(operation) {
1688
+ if (this.suspended) return;
1689
+ if (this.transportReady && !this.stopping) {
1690
+ try {
1691
+ void Promise.resolve(operation()).catch((error) => this.reportError(error));
1692
+ } catch (error) {
1693
+ this.reportError(error);
1694
+ }
1695
+ return;
1696
+ }
1697
+ let ready = this.startPromise;
1698
+ if (!ready && this.started && !this.stopping && this.activeConfig !== void 0) {
1699
+ ready = this.reopenTransport();
1700
+ }
1701
+ if (!ready || this.stopping) return;
1702
+ void ready.then(() => {
1703
+ if (!this.started || this.stopping || this.suspended) return;
1704
+ return operation();
1705
+ }).catch((error) => this.reportError(error));
1706
+ }
1707
+ /**
1708
+ * Ensure the DataBus is started, throwing if no initialConfig was provided.
1709
+ * Called automatically by subscribe/publish/ready when autoStart is true.
1710
+ */
1711
+ ensureStarted() {
1712
+ if (this.started) return;
1713
+ if (!this.hasInitialConfig) {
1714
+ throw new Error(
1715
+ "CrossTabDataBus requires initialConfig for automatic startup, or an explicit start(config) call."
1716
+ );
1717
+ }
1718
+ const starting = this.start(this.initialConfig);
1719
+ void starting.catch(() => void 0);
1720
+ }
1721
+ };
1722
+ function formatWorkerTrace(worker) {
1723
+ return `${worker.workerId}|${worker.status}|load=${worker.load}|tab=${worker.tabId}`;
1724
+ }
1725
+ function formatRouteTrace(route) {
1726
+ return `${route.topicKey}@${route.workerId}|confirmed=${route.confirmedAt !== void 0}`;
1727
+ }
1728
+
1729
+ // src/centrifuge-session.ts
1730
+ var import_centrifuge = require("centrifuge");
1731
+ var CentrifugeSession = class {
1732
+ constructor(sink) {
1733
+ this.sink = sink;
1734
+ }
1735
+ client = null;
1736
+ subscriptions = /* @__PURE__ */ new Map();
1737
+ transferable = false;
1738
+ /** Dispatch an incoming Worker message to the matching operation.
1739
+ * Unknown message types are ignored rather than thrown, so a future protocol
1740
+ * extension adding a new variant cannot crash an older session. */
1741
+ handle(message) {
1742
+ switch (message.type) {
1743
+ case "INIT":
1744
+ this.initialize(message.url, message.config, message.transferable === true);
1745
+ return;
1746
+ case "SUBSCRIBE":
1747
+ this.subscribe(message.topic);
1748
+ return;
1749
+ case "UNSUBSCRIBE":
1750
+ this.unsubscribe(message.topic);
1751
+ return;
1752
+ case "PUBLISH":
1753
+ case "PUBLISH_BIN":
1754
+ this.publish(message.topic, message.data);
1755
+ return;
1756
+ case "STOP":
1757
+ this.stop();
1758
+ return;
1759
+ default:
1760
+ return;
1761
+ }
1762
+ }
1763
+ /** Create the Centrifuge client, wire up lifecycle listeners, and connect. */
1764
+ initialize(url, config, transferable) {
1765
+ if (this.client) return;
1766
+ this.transferable = transferable;
1767
+ const client = new import_centrifuge.Centrifuge(url, config);
1768
+ this.client = client;
1769
+ client.on("state", (context) => {
1770
+ this.post({ type: "STATUS", status: normalizeStatus(context.newState) });
1771
+ });
1772
+ client.on("connected", () => this.post({ type: "STATUS", status: "connected" }));
1773
+ client.on("disconnected", () => this.post({ type: "STATUS", status: "disconnected" }));
1774
+ client.on("error", (context) => this.postError(context));
1775
+ client.on("publication", (context) => {
1776
+ const topic = context.channel || getPayloadTopic(context.data);
1777
+ if (!topic || this.subscriptions.has(topic)) return;
1778
+ this.postPublication(topic, context.data);
1779
+ });
1780
+ client.connect();
1781
+ }
1782
+ /** Subscribe to a Centrifuge channel. Reuses an existing subscription if one exists.
1783
+ * Listeners are only registered once per subscription object — a repeated
1784
+ * SUBSCRIBE for an already-tracked topic skips the listener wiring entirely,
1785
+ * avoiding the removeAllListeners + re-on churn on every duplicate message. */
1786
+ subscribe(topic) {
1787
+ if (!this.client) return this.postError(new Error("Centrifuge client is not initialized."));
1788
+ const existing = this.subscriptions.get(topic);
1789
+ if (existing) {
1790
+ existing.subscribe();
1791
+ return;
1792
+ }
1793
+ let subscription = this.client.getSubscription(topic);
1794
+ if (!subscription) subscription = this.client.newSubscription(topic);
1795
+ subscription.removeAllListeners("publication");
1796
+ subscription.removeAllListeners("error");
1797
+ subscription.removeAllListeners("unsubscribed");
1798
+ this.subscriptions.set(topic, subscription);
1799
+ subscription.on("publication", (context) => {
1800
+ this.postPublication(topic, context.data);
1801
+ });
1802
+ subscription.on("error", (context) => this.postError(context));
1803
+ subscription.on("unsubscribed", () => this.subscriptions.delete(topic));
1804
+ subscription.subscribe();
1805
+ }
1806
+ /** Unsubscribe from a Centrifuge channel and clean up the local reference.
1807
+ * Listeners are removed before unsubscribing so a late `unsubscribed` event
1808
+ * cannot delete a subscription that a subsequent `subscribe()` re-added. */
1809
+ unsubscribe(topic) {
1810
+ const subscription = this.subscriptions.get(topic) ?? this.client?.getSubscription(topic);
1811
+ if (!subscription) return;
1812
+ subscription.removeAllListeners("publication");
1813
+ subscription.removeAllListeners("error");
1814
+ subscription.removeAllListeners("unsubscribed");
1815
+ this.subscriptions.delete(topic);
1816
+ subscription.unsubscribe();
1817
+ }
1818
+ /** Publish a message to the Centrifuge channel. */
1819
+ publish(topic, data) {
1820
+ if (!this.client) return this.postError(new Error("Centrifuge client is not initialized."));
1821
+ void this.client.publish(topic, data).catch((error) => this.postError(error));
1822
+ }
1823
+ /** Forward a publication to the transport. Binary payloads take the
1824
+ * zero-copy `MESSAGE_BIN` path when `transferable` is enabled; everything
1825
+ * else is structured-cloned via `MESSAGE`. An empty topic means the
1826
+ * publication carried no channel info and is silently dropped. */
1827
+ postPublication(topic, data) {
1828
+ if (!topic) return;
1829
+ if (this.transferable && data instanceof ArrayBuffer) {
1830
+ this.post({ type: "MESSAGE_BIN", topic, data }, [data]);
1831
+ return;
1832
+ }
1833
+ this.post({ type: "MESSAGE", topic, data });
1834
+ }
1835
+ /** Disconnect the client and clear all subscriptions. */
1836
+ stop() {
1837
+ this.client?.disconnect();
1838
+ this.subscriptions.clear();
1839
+ this.client = null;
1840
+ this.post({ type: "STATUS", status: "disconnected" });
1841
+ }
1842
+ /** Forward a message to the sink (the transport layer). */
1843
+ post(message, transfer) {
1844
+ this.sink.post(message, transfer);
1845
+ }
1846
+ /** Serialise and report an error. The Centrifuge client handles reconnection
1847
+ * internally, so a transient error should not trigger a `STATUS: error` that
1848
+ * would cause `selectActiveWorkers()` to exclude this worker from routing.
1849
+ * Fatal errors are distinguished by the client eventually emitting
1850
+ * `disconnected` without a subsequent `connected`. */
1851
+ postError(error) {
1852
+ this.post({ type: "ERROR", error: serializeError(error) });
1853
+ }
1854
+ };
1855
+ function getPayloadTopic(data) {
1856
+ if (!data || typeof data !== "object") return "";
1857
+ const payload = data;
1858
+ const push = payload.push;
1859
+ const nested = typeof push === "object" && push !== null ? push.channel : void 0;
1860
+ const topic = nested ?? payload.channel;
1861
+ return typeof topic === "string" ? topic : "";
1862
+ }
1863
+ var LIVE_STATES = /* @__PURE__ */ new Set(["connecting", "connected"]);
1864
+ function normalizeStatus(status) {
1865
+ return LIVE_STATES.has(status) ? status : "disconnected";
1866
+ }
1867
+ function serializeError(error) {
1868
+ if (error instanceof Error) {
1869
+ return {
1870
+ name: error.name,
1871
+ message: error.message,
1872
+ ...error.stack ? { stack: error.stack } : {}
1873
+ };
1874
+ }
1875
+ return {
1876
+ name: "CentrifugeError",
1877
+ message: typeof error === "string" ? error : "Centrifuge worker operation failed.",
1878
+ ...error === void 0 ? {} : { context: error }
1879
+ };
1880
+ }
1881
+
1882
+ // src/worker-mode.ts
1883
+ function selectWorkerBackend(mode, availability = {}) {
1884
+ const hasDedicated = availability.worker ?? typeof Worker !== "undefined";
1885
+ const hasShared = availability.sharedWorker ?? typeof SharedWorker !== "undefined";
1886
+ if (mode === "shared" || mode === "auto") {
1887
+ return hasShared ? "shared" : hasDedicated ? "dedicated" : "local";
1888
+ }
1889
+ return hasDedicated ? "dedicated" : hasShared ? "shared" : "local";
1890
+ }
1891
+
1892
+ // src/centrifuge-protocol.ts
1893
+ var DEFAULT_HEARTBEAT_INTERVAL_MS2 = 1e4;
1894
+ var DEFAULT_SESSION_TIMEOUT_MULTIPLIER = 3;
1895
+ var DEFAULT_SESSION_TIMEOUT_MS = DEFAULT_HEARTBEAT_INTERVAL_MS2 * DEFAULT_SESSION_TIMEOUT_MULTIPLIER;
1896
+
1897
+ // src/centrifuge.ts
1898
+ var import_meta = {};
1899
+ var CentrifugeWorkerTransport = class {
1900
+ workerMode;
1901
+ transferable;
1902
+ heartbeatIntervalMs;
1903
+ workerFactory;
1904
+ sharedWorkerFactory;
1905
+ backend = null;
1906
+ worker = null;
1907
+ sharedWorker = null;
1908
+ port = null;
1909
+ heartbeatHandle = null;
1910
+ localSession = null;
1911
+ handlers = null;
1912
+ // Monotonically increasing counter, bumped each time a backend is created.
1913
+ // Used to ignore late error events from a superseded Worker.
1914
+ generation = 0;
1915
+ // Generation captured when the current backend was created. Error handlers
1916
+ // only act when the backend that registered them is still current.
1917
+ backendGeneration = 0;
1918
+ constructor(options = {}) {
1919
+ this.workerMode = options.workerMode ?? "dedicated";
1920
+ this.transferable = options.transferable ?? false;
1921
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS2;
1922
+ assertHeartbeatInterval(this.heartbeatIntervalMs);
1923
+ this.workerFactory = options.workerFactory;
1924
+ this.sharedWorkerFactory = options.sharedWorkerFactory;
1925
+ }
1926
+ /**
1927
+ * Start the transport: select a backend, initialise the Worker (or local
1928
+ * session), and send the INIT message with connection parameters.
1929
+ */
1930
+ start(config, handlers) {
1931
+ if (this.backend) return;
1932
+ assertStructuredCloneable(config.options ?? {});
1933
+ this.handlers = handlers;
1934
+ const backend = selectWorkerBackend(this.workerMode, {
1935
+ worker: this.workerFactory !== void 0,
1936
+ sharedWorker: this.sharedWorkerFactory !== void 0
1937
+ });
1938
+ const input = this.buildInitInput(config);
1939
+ if (backend === "shared") {
1940
+ this.startSharedWorker(input);
1941
+ this.backend = "shared";
1942
+ return;
1943
+ }
1944
+ if (backend === "dedicated") {
1945
+ this.startDedicatedWorker(input);
1946
+ this.backend = "dedicated";
1947
+ return;
1948
+ }
1949
+ this.localSession = new CentrifugeSession({ post: this.handleSessionOutput });
1950
+ this.localSession.handle(input);
1951
+ this.backend = "local";
1952
+ }
1953
+ subscribe(topic) {
1954
+ this.post({ type: "SUBSCRIBE", topic });
1955
+ }
1956
+ unsubscribe(topic) {
1957
+ this.post({ type: "UNSUBSCRIBE", topic });
1958
+ }
1959
+ /**
1960
+ * Publish data to `topic`. Binary data (ArrayBuffer) is sent via Transferable
1961
+ * when `transferable` is enabled, avoiding a structured-clone cycle.
1962
+ */
1963
+ publish(topic, data) {
1964
+ if (this.transferable && data instanceof ArrayBuffer) {
1965
+ this.post({ type: "PUBLISH_BIN", topic, data }, [data]);
1966
+ return;
1967
+ }
1968
+ this.post({ type: "PUBLISH", topic, data });
1969
+ }
1970
+ /**
1971
+ * Gracefully stop the transport: send STOP, clean up event listeners, and
1972
+ * terminate the Worker (or close the SharedWorker port).
1973
+ */
1974
+ stop() {
1975
+ if (!this.backend) return;
1976
+ this.generation++;
1977
+ this.post({ type: "STOP" });
1978
+ this.clearHeartbeat();
1979
+ if (this.worker) {
1980
+ this.worker.removeEventListener("message", this.handleMessage);
1981
+ this.worker.removeEventListener("error", this.handleWorkerError);
1982
+ this.worker.terminate();
1983
+ }
1984
+ if (this.sharedWorker) {
1985
+ this.detachSharedWorkerListeners();
1986
+ this.port?.close();
1987
+ }
1988
+ this.resetBackend();
1989
+ this.handlers = null;
1990
+ }
1991
+ /** Build the INIT payload sent to the Worker / local session. Optional fields
1992
+ * are only included when they deviate from the defaults, so the Worker's own
1993
+ * default-resolution logic kicks in for the common case. */
1994
+ buildInitInput(config) {
1995
+ return {
1996
+ type: "INIT",
1997
+ url: config.url,
1998
+ config: config.options ?? {},
1999
+ ...this.transferable ? { transferable: true } : {},
2000
+ ...this.heartbeatIntervalMs !== DEFAULT_HEARTBEAT_INTERVAL_MS2 ? { heartbeatIntervalMs: this.heartbeatIntervalMs } : {}
2001
+ };
2002
+ }
2003
+ /** Create and initialise a dedicated Worker, then send the INIT message. */
2004
+ startDedicatedWorker(input) {
2005
+ this.backendGeneration = ++this.generation;
2006
+ const worker = (this.workerFactory ?? createDefaultWorker)();
2007
+ this.worker = worker;
2008
+ worker.addEventListener("message", this.handleMessage);
2009
+ worker.addEventListener("error", this.handleWorkerError);
2010
+ worker.postMessage(input);
2011
+ }
2012
+ /** Create and initialise a SharedWorker, open the MessagePort, and send the INIT message. */
2013
+ startSharedWorker(input) {
2014
+ this.backendGeneration = ++this.generation;
2015
+ const shared = (this.sharedWorkerFactory ?? createDefaultSharedWorker)();
2016
+ this.sharedWorker = shared;
2017
+ const port = shared.port;
2018
+ this.port = port;
2019
+ port.addEventListener("message", this.handleMessage);
2020
+ port.addEventListener("messageerror", this.handlePortError);
2021
+ shared.addEventListener("error", this.handleSharedWorkerError);
2022
+ port.start();
2023
+ port.postMessage(input);
2024
+ this.startHeartbeat();
2025
+ }
2026
+ /** Handle a message event from the Worker (dedicated or shared). */
2027
+ handleMessage = (event) => {
2028
+ this.handleOutput(event.data);
2029
+ };
2030
+ /** Handle a message from the in-process CentrifugeSession (local fallback). */
2031
+ handleSessionOutput = (message) => {
2032
+ this.handleOutput(message);
2033
+ };
2034
+ /** Route a Worker output message to the appropriate handler callback.
2035
+ * Shared by the Worker message listener, the SharedWorker port listener,
2036
+ * and the local-session sink — all three feed into this single dispatcher. */
2037
+ handleOutput(message) {
2038
+ if (message.type === "STATUS") this.handlers?.onStatus(message.status);
2039
+ if (message.type === "MESSAGE") this.handlers?.onMessage({ topic: message.topic, data: message.data });
2040
+ if (message.type === "MESSAGE_BIN") this.handlers?.onMessage({ topic: message.topic, data: message.data });
2041
+ if (message.type === "ERROR") this.handlers?.onError(deserializeWorkerError(message.error));
2042
+ }
2043
+ /** Handle a Worker-level failure (crash, message decode error). Discards the
2044
+ * dead backend so a later start()/reopen can rebuild from scratch, and
2045
+ * signals an error status so the DataBus can trigger recovery.
2046
+ * Only invoked when the generation guard confirms the failing backend is
2047
+ * still current — late errors from a superseded Worker are silently dropped. */
2048
+ onWorkerFailed(message) {
2049
+ this.worker?.removeEventListener("message", this.handleMessage);
2050
+ this.worker?.removeEventListener("error", this.handleWorkerError);
2051
+ this.worker?.terminate();
2052
+ this.detachSharedWorkerListeners();
2053
+ this.port?.close();
2054
+ this.clearHeartbeat();
2055
+ this.resetBackend();
2056
+ this.handlers?.onError(new Error(message));
2057
+ this.handlers?.onStatus("error");
2058
+ }
2059
+ /** Remove every listener attached to the current SharedWorker and its port. */
2060
+ detachSharedWorkerListeners() {
2061
+ this.port?.removeEventListener("message", this.handleMessage);
2062
+ this.port?.removeEventListener("messageerror", this.handlePortError);
2063
+ this.sharedWorker?.removeEventListener("error", this.handleSharedWorkerError);
2064
+ }
2065
+ /** Periodically ping the SharedWorker so its session reaper can detect a dead tab. */
2066
+ startHeartbeat() {
2067
+ if (this.heartbeatHandle !== null) return;
2068
+ if (this.heartbeatIntervalMs === Infinity) return;
2069
+ this.heartbeatHandle = setInterval(() => {
2070
+ this.post({ type: "PING" });
2071
+ }, this.heartbeatIntervalMs);
2072
+ }
2073
+ clearHeartbeat() {
2074
+ if (this.heartbeatHandle !== null) clearInterval(this.heartbeatHandle);
2075
+ this.heartbeatHandle = null;
2076
+ }
2077
+ handleWorkerError = () => {
2078
+ if (this.generation !== this.backendGeneration) return;
2079
+ this.onWorkerFailed("Centrifuge worker failed.");
2080
+ };
2081
+ handlePortError = () => {
2082
+ if (this.generation !== this.backendGeneration) return;
2083
+ this.onWorkerFailed("Centrifuge shared worker message decoding failed.");
2084
+ };
2085
+ handleSharedWorkerError = () => {
2086
+ if (this.generation !== this.backendGeneration) return;
2087
+ this.onWorkerFailed("Centrifuge shared worker failed.");
2088
+ };
2089
+ /** Clear the Worker/port/backend references after a failure or stop. */
2090
+ resetBackend() {
2091
+ this.worker = null;
2092
+ this.sharedWorker = null;
2093
+ this.port = null;
2094
+ this.backend = null;
2095
+ this.localSession = null;
2096
+ }
2097
+ /**
2098
+ * Post a message to the active backend. Accepts optional Transferable buffers
2099
+ * for zero-copy ArrayBuffer transfer.
2100
+ */
2101
+ post(message, transfer) {
2102
+ if (this.worker) {
2103
+ postToPortLike(this.worker, message, transfer);
2104
+ return;
2105
+ }
2106
+ if (this.port) {
2107
+ postToPortLike(this.port, message, transfer);
2108
+ return;
2109
+ }
2110
+ if (this.localSession) {
2111
+ this.localSession.handle(message);
2112
+ return;
2113
+ }
2114
+ throw new Error("CentrifugeWorkerTransport.start() must be called first.");
2115
+ }
2116
+ };
2117
+ function createCentrifugeDataBus(options) {
2118
+ const {
2119
+ clusterKey,
2120
+ connection,
2121
+ heartbeatIntervalMs,
2122
+ sharedWorkerFactory,
2123
+ transferable,
2124
+ workerFactory,
2125
+ workerMode,
2126
+ ...dataBusOptions
2127
+ } = options;
2128
+ return new CrossTabDataBus({
2129
+ ...dataBusOptions,
2130
+ autoStart: true,
2131
+ clusterKey: clusterKey ?? connection.url,
2132
+ initialConfig: connection,
2133
+ transport: new CentrifugeWorkerTransport({
2134
+ ...workerFactory ? { workerFactory } : {},
2135
+ ...sharedWorkerFactory ? { sharedWorkerFactory } : {},
2136
+ ...transferable === void 0 ? {} : { transferable },
2137
+ ...workerMode ? { workerMode } : {},
2138
+ ...heartbeatIntervalMs === void 0 ? {} : { heartbeatIntervalMs }
2139
+ })
2140
+ });
2141
+ }
2142
+ function createDefaultWorker() {
2143
+ if (typeof Worker === "undefined") {
2144
+ throw new Error("CentrifugeWorkerTransport requires a browser Worker implementation.");
2145
+ }
2146
+ let workerUrl;
2147
+ try {
2148
+ workerUrl = new URL("./centrifuge.worker.js", import_meta.url);
2149
+ } catch {
2150
+ throw new Error(
2151
+ "The default Centrifuge Worker URL is unavailable in this module format; provide workerFactory explicitly."
2152
+ );
2153
+ }
2154
+ return new Worker(workerUrl, {
2155
+ name: "cross-tab-worker-databus",
2156
+ type: "module"
2157
+ });
2158
+ }
2159
+ function createDefaultSharedWorker() {
2160
+ if (typeof SharedWorker === "undefined") {
2161
+ throw new Error("CentrifugeWorkerTransport requires a browser SharedWorker implementation.");
2162
+ }
2163
+ let workerUrl;
2164
+ try {
2165
+ workerUrl = new URL("./centrifuge.shared.worker.js", import_meta.url);
2166
+ } catch {
2167
+ throw new Error(
2168
+ "The default Centrifuge SharedWorker URL is unavailable in this module format; provide sharedWorkerFactory explicitly."
2169
+ );
2170
+ }
2171
+ return new SharedWorker(workerUrl, {
2172
+ name: "cross-tab-worker-databus-shared",
2173
+ type: "module"
2174
+ });
2175
+ }
2176
+ function assertHeartbeatInterval(value) {
2177
+ if (value === Infinity) return;
2178
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return;
2179
+ throw new TypeError(
2180
+ `Centrifuge heartbeatIntervalMs must be a positive number or Infinity, got ${String(value)}.`
2181
+ );
2182
+ }
2183
+ function assertStructuredCloneable(value) {
2184
+ if (typeof structuredClone !== "function") return;
2185
+ try {
2186
+ structuredClone(value);
2187
+ } catch (error) {
2188
+ throw new TypeError(
2189
+ "Centrifuge Worker configuration and published data must be structured-cloneable.",
2190
+ { cause: error }
2191
+ );
2192
+ }
2193
+ }
2194
+ function postToPortLike(target, message, transfer) {
2195
+ if (transfer) target.postMessage(message, transfer);
2196
+ else target.postMessage(message);
2197
+ }
2198
+ function deserializeWorkerError(error) {
2199
+ const result = new Error(error.message);
2200
+ result.name = error.name;
2201
+ if (error.stack) result.stack = error.stack;
2202
+ if (error.context !== void 0) Object.assign(result, { context: error.context });
2203
+ return result;
2204
+ }
2205
+ //# sourceMappingURL=centrifuge.cjs.map