cross-tab-worker-databus 0.2.0 → 0.3.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.
@@ -0,0 +1,1806 @@
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/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ CrossTabDataBus: () => CrossTabDataBus,
24
+ DEFAULT_MAX_ACTIVE_WORKERS: () => DEFAULT_MAX_ACTIVE_WORKERS,
25
+ WebSocketTransport: () => WebSocketTransport,
26
+ WorkerClusterRuntime: () => WorkerClusterRuntime,
27
+ createBrowserEnvironment: () => createBrowserEnvironment,
28
+ createOpaqueKey: () => createOpaqueKey,
29
+ createWebSocketDataBus: () => createWebSocketDataBus,
30
+ getOrCreateTabId: () => getOrCreateTabId,
31
+ hasActiveOwner: () => hasActiveOwner,
32
+ isWildcardTopic: () => isWildcardTopic,
33
+ selectActiveWorkers: () => selectActiveWorkers,
34
+ selectLeastLoadedWorker: () => selectLeastLoadedWorker,
35
+ selectRebalanceTarget: () => selectRebalanceTarget,
36
+ selectWorkerBackend: () => selectWorkerBackend,
37
+ topicMatchesPattern: () => topicMatchesPattern
38
+ });
39
+ module.exports = __toCommonJS(src_exports);
40
+
41
+ // src/core/environment.ts
42
+ function getStorage(name) {
43
+ try {
44
+ return typeof window === "undefined" ? null : window[name];
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+ function randomId() {
50
+ try {
51
+ return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
52
+ } catch {
53
+ return Math.random().toString(36).slice(2);
54
+ }
55
+ }
56
+ var tabIdentityInitialized = false;
57
+ function createBrowserEnvironment() {
58
+ return {
59
+ storage: getStorage("localStorage"),
60
+ sessionStorage: getStorage("sessionStorage"),
61
+ now: Date.now,
62
+ randomId,
63
+ createChannel: (name) => {
64
+ try {
65
+ return typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(name);
66
+ } catch {
67
+ return null;
68
+ }
69
+ },
70
+ setInterval: (callback, intervalMs) => globalThis.setInterval(callback, intervalMs),
71
+ clearInterval: (handle) => globalThis.clearInterval(handle),
72
+ getVisibilityState: () => typeof document !== "undefined" && document.visibilityState === "hidden" ? "hidden" : "visible",
73
+ addVisibilityChangeListener: (listener) => {
74
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", listener);
75
+ },
76
+ removeVisibilityChangeListener: (listener) => {
77
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", listener);
78
+ },
79
+ addPageHideListener: (listener) => {
80
+ if (typeof window !== "undefined") window.addEventListener("pagehide", listener);
81
+ },
82
+ removePageHideListener: (listener) => {
83
+ if (typeof window !== "undefined") window.removeEventListener("pagehide", listener);
84
+ },
85
+ addPageShowListener: (listener) => {
86
+ if (typeof window !== "undefined") window.addEventListener("pageshow", listener);
87
+ },
88
+ removePageShowListener: (listener) => {
89
+ if (typeof window !== "undefined") window.removeEventListener("pageshow", listener);
90
+ }
91
+ };
92
+ }
93
+ function canUseStorage(storage, probeKey) {
94
+ if (!storage) return false;
95
+ try {
96
+ storage.setItem(probeKey, "1");
97
+ storage.removeItem(probeKey);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+ function getOrCreateTabId(environment, key = "cross-tab-worker-databus:tab-id") {
104
+ const storage = environment.sessionStorage;
105
+ try {
106
+ const existing = storage?.getItem(key);
107
+ const hasOpener = typeof window !== "undefined" && Boolean(window.opener);
108
+ if (existing && (!hasOpener || tabIdentityInitialized)) {
109
+ tabIdentityInitialized = true;
110
+ return existing;
111
+ }
112
+ const created = `tab-${environment.randomId()}`;
113
+ storage?.setItem(key, created);
114
+ tabIdentityInitialized = true;
115
+ return created;
116
+ } catch {
117
+ return `tab-${environment.randomId()}`;
118
+ }
119
+ }
120
+
121
+ // src/core/hash.ts
122
+ function createOpaqueKey(value) {
123
+ let h1 = SEED_H1 ^ value.length;
124
+ let h2 = SEED_H2 ^ value.length;
125
+ let h3 = SEED_H3 ^ value.length;
126
+ let h4 = SEED_H4 ^ value.length;
127
+ for (let index = 0; index < value.length; index += 1) {
128
+ const code = value.charCodeAt(index);
129
+ h1 = Math.imul(h1 ^ code, PRIME_H1);
130
+ h2 = Math.imul(h2 ^ code, PRIME_H2);
131
+ h3 = Math.imul(h3 ^ code, PRIME_H3);
132
+ h4 = Math.imul(h4 ^ code, PRIME_H4);
133
+ }
134
+ h1 = avalancheMix(h1, h2);
135
+ h2 = avalancheMix(h2, h3);
136
+ h3 = avalancheMix(h3, h4);
137
+ h4 = avalancheMix(h4, h1);
138
+ return [h1, h2, h3, h4].map((hash) => (hash >>> 0).toString(16).padStart(8, "0")).join("");
139
+ }
140
+ var SEED_H1 = 3735928559;
141
+ var SEED_H2 = 1103547991;
142
+ var SEED_H3 = 3235826430;
143
+ var SEED_H4 = 2654435769;
144
+ var PRIME_H1 = 2654435761;
145
+ var PRIME_H2 = 1597334677;
146
+ var PRIME_H3 = 2246822519;
147
+ var PRIME_H4 = 3266489917;
148
+ var AVALANCHE_PRIME = 2246822507;
149
+ var AVALANCHE_CROSS = 3266489909;
150
+ function avalancheMix(self, neighbor) {
151
+ return Math.imul(self ^ self >>> 16, AVALANCHE_PRIME) ^ Math.imul(neighbor ^ neighbor >>> 13, AVALANCHE_CROSS);
152
+ }
153
+
154
+ // src/core/routing.ts
155
+ var DEFAULT_MAX_ACTIVE_WORKERS = 3;
156
+ function selectLeastLoadedWorker(workers, preferredWorkerId) {
157
+ const preferred = workers.find((worker) => worker.workerId === preferredWorkerId);
158
+ if (preferred) return preferred;
159
+ return workers.reduce((least, worker) => {
160
+ if (!least) return worker;
161
+ const byLoad = worker.load - least.load;
162
+ if (byLoad !== 0) return byLoad < 0 ? worker : least;
163
+ if (worker.workerId < least.workerId) return worker;
164
+ return least;
165
+ }, void 0);
166
+ }
167
+ function selectActiveWorkers(workers, maxActiveWorkers = DEFAULT_MAX_ACTIVE_WORKERS) {
168
+ const healthyWorkers = workers.filter((worker) => worker.status === "connecting" || worker.status === "connected");
169
+ const availableWorkers = healthyWorkers.length > 0 ? healthyWorkers : [...workers];
170
+ const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === "visible");
171
+ const candidates = visibleWorkers.length > 0 ? visibleWorkers : availableWorkers;
172
+ return candidates.sort(
173
+ (left, right) => left.registeredAt - right.registeredAt || (left.workerId < right.workerId ? -1 : left.workerId > right.workerId ? 1 : 0)
174
+ ).slice(0, maxActiveWorkers);
175
+ }
176
+ function selectRebalanceTarget(workers, currentWorkerId) {
177
+ const currentWorker = workers.find((worker) => worker.workerId === currentWorkerId);
178
+ const leastLoadedWorker = selectLeastLoadedWorker(workers);
179
+ if (!currentWorker || !leastLoadedWorker || currentWorker.workerId === leastLoadedWorker.workerId || currentWorker.load <= leastLoadedWorker.load + 1) {
180
+ return null;
181
+ }
182
+ return leastLoadedWorker;
183
+ }
184
+ function hasActiveOwner(route, workers) {
185
+ return Boolean(route && workers.some((worker) => worker.workerId === route.workerId));
186
+ }
187
+ function isWildcardTopic(pattern) {
188
+ return pattern === "*" || pattern.endsWith(".*");
189
+ }
190
+ function topicMatchesPattern(pattern, topic) {
191
+ if (!pattern || !topic) return false;
192
+ if (pattern === topic) return true;
193
+ if (pattern === "*") return true;
194
+ if (!pattern.endsWith(".*")) return false;
195
+ return topic.startsWith(pattern.slice(0, -1));
196
+ }
197
+
198
+ // src/core/storage-batch.ts
199
+ var INITIAL_RETRY_DELAY_MS = 50;
200
+ var MAX_RETRY_DELAY_MS = 1600;
201
+ var MAX_RETRY_ATTEMPTS = 5;
202
+ var BatchingStorageWriter = class {
203
+ constructor(storage) {
204
+ this.storage = storage;
205
+ }
206
+ /** Coalesced write set. A `null` value represents a pending delete. */
207
+ pending = /* @__PURE__ */ new Map();
208
+ /** Per-key retry counter, reset on a successful write. */
209
+ retryCount = /* @__PURE__ */ new Map();
210
+ flushScheduled = false;
211
+ retryHandle = null;
212
+ retryDelayMs = INITIAL_RETRY_DELAY_MS;
213
+ /** Number of writes queued in memory but not yet flushed to storage.
214
+ * Used by tests to assert the coalescing window and by flush() to detect
215
+ * the all-drained state. */
216
+ get pendingSize() {
217
+ return this.pending.size;
218
+ }
219
+ get length() {
220
+ return this.keys().length;
221
+ }
222
+ clear() {
223
+ this.pending.clear();
224
+ this.flushScheduled = false;
225
+ this.storage.clear();
226
+ this.cancelRetry();
227
+ this.retryCount.clear();
228
+ this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
229
+ }
230
+ // Reads always see the pending value first (task-local consistency), then
231
+ // fall back to the underlying storage.
232
+ getItem(key) {
233
+ if (this.pending.has(key)) return this.pending.get(key) ?? null;
234
+ return this.storage.getItem(key);
235
+ }
236
+ key(index) {
237
+ return this.keys()[index] ?? null;
238
+ }
239
+ removeItem(key) {
240
+ this.pending.set(key, null);
241
+ this.scheduleFlush();
242
+ }
243
+ setItem(key, value) {
244
+ this.pending.set(key, value);
245
+ this.scheduleFlush();
246
+ }
247
+ flush() {
248
+ this.flushScheduled = false;
249
+ this.cancelRetry();
250
+ for (const [key, value] of Array.from(this.pending)) {
251
+ try {
252
+ if (value === null) this.storage.removeItem(key);
253
+ else this.storage.setItem(key, value);
254
+ this.pending.delete(key);
255
+ this.retryCount.delete(key);
256
+ } catch {
257
+ const attempts = (this.retryCount.get(key) ?? 0) + 1;
258
+ if (attempts >= MAX_RETRY_ATTEMPTS) {
259
+ this.pending.delete(key);
260
+ this.retryCount.delete(key);
261
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
262
+ console.warn("[cross-tab-worker-databus] storage write gave up after retries, dropping key:", key);
263
+ }
264
+ continue;
265
+ }
266
+ this.retryCount.set(key, attempts);
267
+ this.scheduleRetry();
268
+ break;
269
+ }
270
+ }
271
+ if (this.pending.size === 0) {
272
+ this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
273
+ this.retryCount.clear();
274
+ }
275
+ }
276
+ /** Union of persisted keys and pending writes, minus pending deletes. */
277
+ keys() {
278
+ const keys = /* @__PURE__ */ new Set();
279
+ for (let index = 0; index < this.storage.length; index += 1) {
280
+ const key = this.storage.key(index);
281
+ if (key !== null) keys.add(key);
282
+ }
283
+ for (const [key, value] of this.pending) {
284
+ if (value === null) keys.delete(key);
285
+ else keys.add(key);
286
+ }
287
+ return Array.from(keys);
288
+ }
289
+ // Coalesce all synchronous writes within one task into a single microtask
290
+ // flush, avoiding a localStorage write per heartbeat/route/subscriber update.
291
+ // The queueMicrotask fallback to setTimeout handles older runtimes and
292
+ // non-browser environments where queueMicrotask is absent.
293
+ scheduleFlush() {
294
+ if (this.flushScheduled) return;
295
+ this.flushScheduled = true;
296
+ const flush = () => {
297
+ this.flushScheduled = false;
298
+ this.flush();
299
+ };
300
+ if (typeof queueMicrotask === "function") queueMicrotask(flush);
301
+ else setTimeout(flush, 0);
302
+ }
303
+ // Schedule a single retry timer. The guard ensures only one retry is in
304
+ // flight at a time; subsequent scheduleRetry calls during the wait are
305
+ // no-ops because the first retry will re-flush all pending keys together.
306
+ scheduleRetry() {
307
+ if (this.retryHandle !== null) return;
308
+ this.retryHandle = setTimeout(() => {
309
+ this.retryHandle = null;
310
+ this.flush();
311
+ }, this.retryDelayMs);
312
+ this.retryDelayMs = Math.min(MAX_RETRY_DELAY_MS, this.retryDelayMs * 2);
313
+ }
314
+ cancelRetry() {
315
+ if (this.retryHandle !== null) {
316
+ clearTimeout(this.retryHandle);
317
+ this.retryHandle = null;
318
+ }
319
+ }
320
+ };
321
+
322
+ // src/core/cluster.ts
323
+ var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
324
+ var DEFAULT_WORKER_TTL_MS = 1e4;
325
+ var DEFAULT_STORAGE_PREFIX = "cross-tab-worker-databus";
326
+ var MAX_KNOWN_TOPICS = 500;
327
+ function readJson(storage, key) {
328
+ try {
329
+ const value = storage.getItem(key);
330
+ return value ? JSON.parse(value) : null;
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+ function writeJson(storage, key, value) {
336
+ try {
337
+ storage.setItem(key, JSON.stringify(value));
338
+ } catch {
339
+ }
340
+ }
341
+ function listKeys(storage, prefix) {
342
+ try {
343
+ return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter(
344
+ (key) => Boolean(key?.startsWith(prefix))
345
+ );
346
+ } catch {
347
+ return [];
348
+ }
349
+ }
350
+ function readAllByPrefix(storage, prefix) {
351
+ return listKeys(storage, prefix).map((key) => ({ key, value: readJson(storage, key) })).filter((entry) => entry.value !== null);
352
+ }
353
+ var WorkerClusterRuntime = class {
354
+ tabId;
355
+ workerId;
356
+ environment;
357
+ handlers;
358
+ storage;
359
+ maxActiveWorkers;
360
+ heartbeatIntervalMs;
361
+ workerTtlMs;
362
+ workerPrefix;
363
+ routePrefix;
364
+ subscriberPrefix;
365
+ channelName;
366
+ // Topics this tab has subscribed to (local interest, plaintext).
367
+ subscribedTopics = /* @__PURE__ */ new Set();
368
+ // Topics assigned to this Worker as owner (topicKey → topic). Authoritative:
369
+ // membership drives isAssigned() and load. Grows only via CONTROL/SUBSCRIBE
370
+ // (or local self-subscribe), never via the reverse cache.
371
+ assignedTopics = /* @__PURE__ */ new Map();
372
+ // Reverse mapping: opaque topicKey → plaintext topic. A bounded cache with
373
+ // FIFO eviction — NOT authoritative. It can hold a topicKey that is also in
374
+ // assignedTopics (the owned guard prevents evicting those), because it is
375
+ // the only source of plaintext when storage is unavailable. See the
376
+ // rememberTopic() doc for the eviction contract.
377
+ knownTopics = /* @__PURE__ */ new Map();
378
+ channel = null;
379
+ heartbeatHandle = null;
380
+ started = false;
381
+ suspended = false;
382
+ lifecycleListening = false;
383
+ currentRecord;
384
+ constructor(options) {
385
+ this.environment = options.environment ?? createBrowserEnvironment();
386
+ this.handlers = options.handlers;
387
+ this.maxActiveWorkers = options.maxActiveWorkers ?? DEFAULT_MAX_ACTIVE_WORKERS;
388
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
389
+ this.workerTtlMs = options.workerTtlMs ?? DEFAULT_WORKER_TTL_MS;
390
+ const clusterHash = createOpaqueKey(options.clusterKey || "__default__");
391
+ const prefix = options.storagePrefix ?? DEFAULT_STORAGE_PREFIX;
392
+ const baseKey = `${prefix}:${clusterHash}`;
393
+ this.workerPrefix = `${baseKey}:worker:`;
394
+ this.routePrefix = `${baseKey}:route:`;
395
+ this.subscriberPrefix = `${baseKey}:subscriber:`;
396
+ this.channelName = `${prefix}:bus:${clusterHash}`;
397
+ this.storage = canUseStorage(this.environment.storage, `${baseKey}:probe`) ? new BatchingStorageWriter(this.environment.storage) : null;
398
+ this.tabId = options.tabId ?? getOrCreateTabId(this.environment, `${prefix}:tab-id`);
399
+ this.workerId = options.workerId ?? `worker-${this.tabId}-${this.environment.randomId()}`;
400
+ const now = this.environment.now();
401
+ this.currentRecord = {
402
+ workerId: this.workerId,
403
+ tabId: this.tabId,
404
+ load: 0,
405
+ role: "standby",
406
+ status: "connecting",
407
+ visibilityState: this.environment.getVisibilityState(),
408
+ heartbeatAt: now,
409
+ registeredAt: now
410
+ };
411
+ }
412
+ /** Start the cluster: register, listen for lifecycle events, and begin heartbeats. */
413
+ start() {
414
+ if (this.started) return;
415
+ this.suspended = false;
416
+ this.addLifecycleListeners();
417
+ this.activate();
418
+ }
419
+ /**
420
+ * Stop the cluster: pause heartbeats, hand off assigned topics, remove
421
+ * the worker record, and clean up lifecycle listeners. Idempotent.
422
+ * The .clear() calls after pause() are safe no-ops when pause already
423
+ * cleared the maps (the handoff path), but ensure a full teardown in the
424
+ * stop() path where callers expect every Set/Map to be empty afterwards.
425
+ */
426
+ stop() {
427
+ if (!this.started && !this.suspended) return;
428
+ this.pause();
429
+ this.flushStorage();
430
+ this.removeLifecycleListeners();
431
+ this.subscribedTopics.clear();
432
+ this.assignedTopics.clear();
433
+ this.knownTopics.clear();
434
+ this.suspended = false;
435
+ }
436
+ /**
437
+ * Activate the cluster: open the BroadcastChannel, register the worker record,
438
+ * subscribe to topics, and start the heartbeat interval.
439
+ */
440
+ activate() {
441
+ if (this.started) return;
442
+ this.started = true;
443
+ this.channel = this.storage ? this.environment.createChannel(this.channelName) : null;
444
+ if (!this.channel) this.storage = null;
445
+ this.channel?.addEventListener("message", this.handleMessage);
446
+ const now = this.environment.now();
447
+ this.currentRecord = {
448
+ ...this.currentRecord,
449
+ heartbeatAt: now,
450
+ registeredAt: now,
451
+ visibilityState: this.environment.getVisibilityState()
452
+ };
453
+ this.refreshRole(this.readWorkers());
454
+ this.writeRecord(true);
455
+ for (const topic of this.subscribedTopics) {
456
+ const topicKey = this.rememberTopic(topic);
457
+ if (!this.storage) this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
458
+ else this.writeSubscriber(topicKey);
459
+ }
460
+ this.reconcile();
461
+ this.heartbeatHandle = this.environment.setInterval(() => {
462
+ this.writeRecord(false);
463
+ this.reconcile();
464
+ }, this.heartbeatIntervalMs);
465
+ }
466
+ /**
467
+ * Pause the cluster on pagehide: stop heartbeats, hand off assigned topics
468
+ * to other workers, remove our worker record, and close the channel.
469
+ */
470
+ pause() {
471
+ if (!this.started) return;
472
+ this.started = false;
473
+ this.suspended = true;
474
+ if (this.heartbeatHandle !== null) this.environment.clearInterval(this.heartbeatHandle);
475
+ this.heartbeatHandle = null;
476
+ this.channel?.removeEventListener("message", this.handleMessage);
477
+ for (const topic of this.subscribedTopics) this.releaseSubscription(topic, false);
478
+ this.handoffAssignedTopics();
479
+ this.assignedTopics.clear();
480
+ this.removeStorage(this.workerStorageKey(this.workerId));
481
+ this.flushStorage();
482
+ this.notifyRegistry();
483
+ this.channel?.close();
484
+ this.channel = null;
485
+ this.handlers.onSuspend?.();
486
+ }
487
+ /** Update the worker's connection status and persist the change. */
488
+ setStatus(status) {
489
+ if (this.currentRecord.status === status) return;
490
+ this.currentRecord = { ...this.currentRecord, status };
491
+ if (this.started) this.writeRecord(true);
492
+ }
493
+ /**
494
+ * Subscribe to a topic. Returns true if this worker becomes the assigned owner.
495
+ * The topic is recorded locally and the cluster is notified via storage or
496
+ * direct control message.
497
+ */
498
+ subscribe(topic) {
499
+ const topicKey = this.rememberTopic(topic);
500
+ this.subscribedTopics.add(topic);
501
+ if (!this.started) return false;
502
+ if (!this.storage) {
503
+ this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
504
+ return true;
505
+ }
506
+ this.writeSubscriber(topicKey);
507
+ const workers = this.readWorkers();
508
+ const existingRoute = this.readRoute(topicKey);
509
+ if (this.routeOwnerIsLive(existingRoute, workers)) {
510
+ return existingRoute?.workerId === this.workerId;
511
+ }
512
+ const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
513
+ const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
514
+ this.writeRoute(topicKey, owner, void 0, (existingRoute?.generation ?? 0) + 1);
515
+ this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
516
+ this.notifyRegistry();
517
+ return owner.workerId === this.workerId;
518
+ }
519
+ /**
520
+ * Remove the local subscription. Cleans up the subscriber record and, if no
521
+ * subscribers remain, deletes the route so the owning Worker can unsubscribe.
522
+ */
523
+ unsubscribe(topic) {
524
+ this.subscribedTopics.delete(topic);
525
+ const topicKey = this.releaseSubscription(topic);
526
+ if (topicKey && !this.assignedTopics.has(topicKey)) this.knownTopics.delete(topicKey);
527
+ }
528
+ /** Remove this tab's subscriber record and, when it was the last one, delete
529
+ * the route. Returns the topicKey (so callers like `unsubscribe` can reuse
530
+ * it instead of re-hashing the topic to evict the reverse cache). */
531
+ releaseSubscription(topic, notifyOwner = true) {
532
+ const topicKey = this.rememberTopic(topic);
533
+ this.removeStorage(this.subscriberStorageKey(topicKey, this.tabId));
534
+ const route = this.readRoute(topicKey);
535
+ if (!route) return topicKey;
536
+ const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
537
+ if (subscribers.length === 0) {
538
+ this.removeStorage(this.routeStorageKey(topicKey));
539
+ if (notifyOwner) this.sendControl(route.workerId, "UNSUBSCRIBE", topic, topicKey);
540
+ }
541
+ return topicKey;
542
+ }
543
+ /** Transfer assigned topics to other active workers so subscribers are not orphaned during pause. */
544
+ handoffAssignedTopics() {
545
+ if (!this.storage || this.assignedTopics.size === 0) return;
546
+ const remainingWorkers = this.readWorkers().filter((worker) => worker.workerId !== this.workerId);
547
+ const activeWorkers = selectActiveWorkers(remainingWorkers, this.maxActiveWorkers);
548
+ const projectedLoads = new Map(activeWorkers.map((worker) => [worker.workerId, worker.load]));
549
+ for (const [topicKey, topic] of this.assignedTopics) {
550
+ const previous = this.readRoute(topicKey);
551
+ if (previous?.workerId !== this.workerId) continue;
552
+ const subscribers = this.readSubscriberTabIds(topicKey, remainingWorkers);
553
+ if (subscribers.length === 0) {
554
+ this.removeStorage(this.routeStorageKey(topicKey));
555
+ continue;
556
+ }
557
+ const owner = selectLeastLoadedWorker(
558
+ activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load }))
559
+ );
560
+ if (!owner) continue;
561
+ projectedLoads.set(owner.workerId, (projectedLoads.get(owner.workerId) ?? owner.load) + 1);
562
+ const generation = (previous?.generation ?? 0) + 1;
563
+ this.writeRoute(topicKey, owner, previous?.workerId, generation);
564
+ this.flushStorage();
565
+ this.handlers.onControl("UNSUBSCRIBE", topic);
566
+ this.sendRouteReleased(owner.workerId, topic, topicKey, generation);
567
+ }
568
+ }
569
+ /**
570
+ * Publish a message to `topic`, routing through the owning Worker (or self if
571
+ * no owner is found). Returns false when the control message could not be
572
+ * posted to a remote owner, so the caller can surface the failure instead of
573
+ * silently dropping the publication.
574
+ */
575
+ publish(topic, data) {
576
+ const topicKey = this.rememberTopic(topic);
577
+ const workers = this.readWorkers();
578
+ const route = this.readRoute(topicKey);
579
+ const target = this.routeOwnerIsLive(route, workers) ? route?.workerId ?? this.workerId : this.workerId;
580
+ return this.sendControl(target, "PUBLISH", topic, topicKey, data);
581
+ }
582
+ /** True when `route` exists and its owner worker is among `workers`.
583
+ * Shared by subscribe (skip re-assignment) and publish (route to owner).
584
+ * Intentionally returns a plain boolean (not a type guard) so the caller
585
+ * can still access `route?.generation` in the false branch. */
586
+ routeOwnerIsLive(route, workers) {
587
+ return Boolean(route && workers.some((worker) => worker.workerId === route.workerId));
588
+ }
589
+ /** Broadcast an event to every tab — used to fan out transport publications. */
590
+ broadcastEvent(eventType, payload) {
591
+ this.send({ type: "EVENT", sourceWorkerId: this.workerId, eventType, payload });
592
+ }
593
+ isAssigned(topic) {
594
+ const topicKey = createOpaqueKey(topic);
595
+ if (this.assignedTopics.has(topicKey)) return true;
596
+ for (const pattern of this.assignedTopics.values()) {
597
+ if (pattern !== topic && topicMatchesPattern(pattern, topic)) return true;
598
+ }
599
+ return this.readRoute(topicKey)?.workerId === this.workerId;
600
+ }
601
+ /** True if this worker is among the active set (eligible to own topics). */
602
+ isActiveWorker() {
603
+ return this.isActiveAmong(this.readWorkers());
604
+ }
605
+ /** True when this workerId is in the active subset of `workers`. Shared by
606
+ * isActiveWorker() and refreshRole() so both compute role identically. */
607
+ isActiveAmong(workers) {
608
+ return selectActiveWorkers(workers, this.maxActiveWorkers).some(
609
+ (worker) => worker.workerId === this.workerId
610
+ );
611
+ }
612
+ /** True when this tab has a local subscriber registered for `topic` —
613
+ * exactly, or via a wildcard subscription that matches it. */
614
+ hasLocalSubscriber(topic) {
615
+ if (this.subscribedTopics.has(topic)) return true;
616
+ for (const pattern of this.subscribedTopics) {
617
+ if (pattern !== topic && topicMatchesPattern(pattern, topic)) return true;
618
+ }
619
+ return false;
620
+ }
621
+ /** Read-only snapshot of the cluster state (workers, routes, assignments). */
622
+ getSnapshot() {
623
+ const routes = this.storage ? readAllByPrefix(this.storage, this.routePrefix).map(({ value }) => ({
624
+ ...value,
625
+ topic: this.knownTopics.get(value.topicKey) ?? null
626
+ })) : [];
627
+ return {
628
+ coordinated: Boolean(this.storage && this.channel),
629
+ suspended: this.suspended,
630
+ currentWorker: { ...this.currentRecord },
631
+ workers: this.readWorkers().map((worker) => ({ ...worker })),
632
+ routes,
633
+ subscribedTopics: Array.from(this.subscribedTopics),
634
+ assignedTopics: Array.from(this.assignedTopics.values()),
635
+ knownTopics: Array.from(this.knownTopics.entries(), ([topicKey, topic]) => ({ topicKey, topic }))
636
+ };
637
+ }
638
+ handlePageHide = () => this.pause();
639
+ handlePageShow = () => {
640
+ if (!this.suspended) return;
641
+ this.suspended = false;
642
+ this.handlers.onResume?.();
643
+ this.activate();
644
+ };
645
+ handleVisibilityChange = () => {
646
+ const visibilityState = this.environment.getVisibilityState();
647
+ if (visibilityState === this.currentRecord.visibilityState) return;
648
+ this.currentRecord = { ...this.currentRecord, visibilityState };
649
+ if (this.started) {
650
+ this.writeRecord(true);
651
+ this.reconcile();
652
+ }
653
+ };
654
+ addLifecycleListeners() {
655
+ if (this.lifecycleListening) return;
656
+ this.lifecycleListening = true;
657
+ this.environment.addPageHideListener(this.handlePageHide);
658
+ this.environment.addPageShowListener(this.handlePageShow);
659
+ this.environment.addVisibilityChangeListener(this.handleVisibilityChange);
660
+ }
661
+ removeLifecycleListeners() {
662
+ if (!this.lifecycleListening) return;
663
+ this.lifecycleListening = false;
664
+ this.environment.removePageHideListener(this.handlePageHide);
665
+ this.environment.removePageShowListener(this.handlePageShow);
666
+ this.environment.removeVisibilityChangeListener(this.handleVisibilityChange);
667
+ }
668
+ /** Handle an incoming cluster message: dispatch by type to the per-type handlers. */
669
+ handleMessage = (event) => {
670
+ const message = event.data;
671
+ if (!message || message.sourceWorkerId === this.workerId) return;
672
+ switch (message.type) {
673
+ case "CONTROL":
674
+ return this.handleControlMessage(message);
675
+ case "ROUTE_RELEASED":
676
+ return this.handleRouteReleasedMessage(message);
677
+ case "EVENT":
678
+ this.handlers.onEvent(message.eventType, message.payload, message.sourceWorkerId);
679
+ return;
680
+ case "REGISTRY":
681
+ default:
682
+ this.reconcile();
683
+ return;
684
+ }
685
+ };
686
+ /** Handle a point-to-point CONTROL message (SUBSCRIBE / UNSUBSCRIBE / PUBLISH). */
687
+ handleControlMessage(message) {
688
+ if (message.targetWorkerId !== this.workerId) return;
689
+ this.rememberTopic(message.topic);
690
+ switch (message.action) {
691
+ case "SUBSCRIBE":
692
+ this.assignedTopics.set(message.topicKey, message.topic);
693
+ this.confirmRoute(message.topicKey);
694
+ break;
695
+ case "UNSUBSCRIBE":
696
+ if (this.releaseHandoffOnUnsubscribe(message)) return;
697
+ break;
698
+ case "PUBLISH":
699
+ default:
700
+ break;
701
+ }
702
+ this.handlers.onControl(message.action, message.topic, message.data);
703
+ if (message.action !== "PUBLISH") this.updateLoad();
704
+ }
705
+ /**
706
+ * When this worker is the previous owner in a graceful handoff and the new
707
+ * owner asks us to unsubscribe, release the old transport subscription and
708
+ * ACK the handoff with ROUTE_RELEASED. Returns true when the message was a
709
+ * handoff release (the generic CONTROL dispatch must not run as well).
710
+ */
711
+ releaseHandoffOnUnsubscribe(message) {
712
+ this.assignedTopics.delete(message.topicKey);
713
+ const route = this.readRoute(message.topicKey);
714
+ if (route?.handoffFromWorkerId !== this.workerId) return false;
715
+ this.handlers.onControl("UNSUBSCRIBE", message.topic, void 0);
716
+ this.sendRouteReleased(route.workerId, message.topic, message.topicKey, route.generation);
717
+ this.updateLoad();
718
+ return true;
719
+ }
720
+ /** Post a ROUTE_RELEASED ACK to the new owner, carrying the current route
721
+ * generation so only the matching new owner may act on it. */
722
+ sendRouteReleased(targetWorkerId, topic, topicKey, generation) {
723
+ this.send({
724
+ type: "ROUTE_RELEASED",
725
+ sourceWorkerId: this.workerId,
726
+ targetWorkerId,
727
+ topic,
728
+ topicKey,
729
+ generation
730
+ });
731
+ }
732
+ /**
733
+ * Accept a graceful handoff only when the route still points to this worker,
734
+ * the release comes from the recorded previous owner, and the generation is
735
+ * at least as new as ours. Any other ROUTE_RELEASED is stale and dropped.
736
+ */
737
+ handleRouteReleasedMessage(message) {
738
+ if (message.targetWorkerId !== this.workerId) return;
739
+ const route = this.readRoute(message.topicKey);
740
+ if (!route || this.isStaleRouteRelease(route, message)) return;
741
+ this.assignedTopics.set(message.topicKey, message.topic);
742
+ this.confirmRoute(message.topicKey);
743
+ this.handlers.onControl("SUBSCRIBE", message.topic, void 0);
744
+ this.updateLoad();
745
+ }
746
+ /** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
747
+ * points to us, the release comes from the recorded previous owner, and
748
+ * the release generation is at least as new as ours. */
749
+ isStaleRouteRelease(route, message) {
750
+ return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || route.generation < message.generation;
751
+ }
752
+ /** Full reconciliation cycle: workers, subscriptions, and assigned topics. */
753
+ reconcile() {
754
+ if (!this.started) return;
755
+ const workers = this.reconcileWorkers();
756
+ const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
757
+ this.reconcileSubscriptions(workers, activeWorkers);
758
+ this.reconcileAssignedTopics();
759
+ this.updateLoad();
760
+ }
761
+ /** Prune stale workers/subscribers/routes and refresh role. Returns the live worker list.
762
+ * Subscribers are cleaned before routes so cleanupOrphanedRoutes sees the
763
+ * updated subscriber set when deciding whether a route is truly orphaned. */
764
+ reconcileWorkers() {
765
+ const workers = this.readWorkers();
766
+ this.cleanupOrphanedSubscribers(workers);
767
+ this.cleanupOrphanedRoutes(workers);
768
+ const roleChanged = this.refreshRole(workers);
769
+ if (roleChanged) this.writeRecord(false);
770
+ return workers;
771
+ }
772
+ /**
773
+ * Ensure every local subscription has a route and write subscriber records.
774
+ *
775
+ * Existing routes are deliberately sticky while their owner Worker is alive.
776
+ * Load and visibility only influence placement of a new route; they must not
777
+ * move an already-subscribed Topic merely because another Tab joins or becomes
778
+ * visible. Ownership changes only after the owner leaves or its heartbeat
779
+ * expires, which avoids unnecessary transport subscribe/unsubscribe churn.
780
+ */
781
+ reconcileSubscriptions(workers, activeWorkers) {
782
+ const liveWorkerIds = new Set(workers.map((worker) => worker.workerId));
783
+ for (const topic of this.subscribedTopics) {
784
+ const topicKey = this.rememberTopic(topic);
785
+ this.writeSubscriber(topicKey);
786
+ const route = this.readRoute(topicKey);
787
+ if (!route || !liveWorkerIds.has(route.workerId)) {
788
+ const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
789
+ this.writeRoute(topicKey, owner, void 0, (route?.generation ?? 0) + 1);
790
+ this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
791
+ this.notifyRegistry();
792
+ continue;
793
+ }
794
+ if (route.confirmedAt === void 0) {
795
+ if (!route.handoffFromWorkerId) {
796
+ this.sendControl(route.workerId, "SUBSCRIBE", topic, topicKey);
797
+ }
798
+ }
799
+ }
800
+ }
801
+ /** Drop assignments where the route no longer points to this worker. */
802
+ reconcileAssignedTopics() {
803
+ for (const [topicKey, topic] of [...this.assignedTopics]) {
804
+ const route = this.readRoute(topicKey);
805
+ if (route?.workerId === this.workerId) continue;
806
+ this.assignedTopics.delete(topicKey);
807
+ this.handlers.onControl("UNSUBSCRIBE", topic, void 0);
808
+ if (route?.handoffFromWorkerId === this.workerId) {
809
+ this.sendRouteReleased(route.workerId, topic, topicKey, route.generation);
810
+ }
811
+ if (!this.subscribedTopics.has(topic)) this.knownTopics.delete(topicKey);
812
+ }
813
+ }
814
+ /**
815
+ * Send a control message to `targetWorkerId`, or execute locally when targeting self.
816
+ * Local execution updates the assignment map and route synchronously, bypassing
817
+ * the BroadcastChannel latency.
818
+ */
819
+ sendControl(targetWorkerId, action, topic, topicKey, data) {
820
+ if (targetWorkerId === this.workerId) {
821
+ switch (action) {
822
+ case "SUBSCRIBE":
823
+ this.assignedTopics.set(topicKey, topic);
824
+ this.confirmRoute(topicKey);
825
+ break;
826
+ case "UNSUBSCRIBE":
827
+ this.assignedTopics.delete(topicKey);
828
+ break;
829
+ case "PUBLISH":
830
+ default:
831
+ break;
832
+ }
833
+ this.handlers.onControl(action, topic, data);
834
+ if (action !== "PUBLISH") this.updateLoad();
835
+ return true;
836
+ }
837
+ return this.send({
838
+ type: "CONTROL",
839
+ sourceWorkerId: this.workerId,
840
+ targetWorkerId,
841
+ action,
842
+ topic,
843
+ topicKey,
844
+ ...data === void 0 ? {} : { data }
845
+ });
846
+ }
847
+ /** Post a message on the BroadcastChannel. Returns false on postMessage failure. */
848
+ send(message) {
849
+ if (!this.channel) return false;
850
+ try {
851
+ this.channel.postMessage(message);
852
+ return true;
853
+ } catch {
854
+ return false;
855
+ }
856
+ }
857
+ /** Read all live worker records from storage, pruning stale entries past the TTL. */
858
+ readWorkers() {
859
+ if (!this.storage) return [this.currentRecord];
860
+ const now = this.environment.now();
861
+ const workers = [];
862
+ for (const { key, value: worker } of readAllByPrefix(this.storage, this.workerPrefix)) {
863
+ if (worker.workerId !== this.workerId && now - worker.heartbeatAt > this.workerTtlMs) {
864
+ this.removeStorage(key);
865
+ continue;
866
+ }
867
+ workers.push(worker);
868
+ }
869
+ if (this.started && !workers.some((worker) => worker.workerId === this.workerId)) workers.push(this.currentRecord);
870
+ return workers;
871
+ }
872
+ /** Enumerate all tab IDs that have a subscriber record for `topicKey`. */
873
+ readSubscriberTabIds(topicKey, workers) {
874
+ if (!this.storage) {
875
+ const topic = this.knownTopics.get(topicKey);
876
+ return topic && this.subscribedTopics.has(topic) ? [this.tabId] : [];
877
+ }
878
+ const activeTabIds = new Set(workers.map((worker) => worker.tabId));
879
+ const subscribers = /* @__PURE__ */ new Set();
880
+ for (const { key, value: record } of readAllByPrefix(
881
+ this.storage,
882
+ `${this.subscriberPrefix}${topicKey}:`
883
+ )) {
884
+ if (!activeTabIds.has(record.tabId)) {
885
+ this.removeStorage(key);
886
+ continue;
887
+ }
888
+ subscribers.add(record.tabId);
889
+ }
890
+ return Array.from(subscribers);
891
+ }
892
+ /** Read the current route for `topicKey`, returning null when no storage layer exists. */
893
+ readRoute(topicKey) {
894
+ if (!this.storage) return this.buildLocalRoute(topicKey);
895
+ return readJson(this.storage, this.routeStorageKey(topicKey));
896
+ }
897
+ /** Synthesize a self-owned route when storage is unavailable (degraded mode).
898
+ * The plaintext topic must be recoverable from the knownTopics cache; a
899
+ * missing entry means we never subscribed to or were assigned the topic,
900
+ * so there is no route to report. */
901
+ buildLocalRoute(topicKey) {
902
+ const topic = this.knownTopics.get(topicKey);
903
+ if (!topic) return null;
904
+ if (!this.subscribedTopics.has(topic) && !this.assignedTopics.has(topicKey)) return null;
905
+ return {
906
+ topicKey,
907
+ workerId: this.workerId,
908
+ tabId: this.tabId,
909
+ updatedAt: this.environment.now(),
910
+ generation: 1
911
+ };
912
+ }
913
+ /** Persist a route assignment, mapping `topicKey` to the owning Worker. */
914
+ writeRoute(topicKey, owner, handoffFromWorkerId, generation = 1) {
915
+ if (!this.storage) return;
916
+ writeJson(this.storage, this.routeStorageKey(topicKey), this.buildRouteRecord(topicKey, owner, handoffFromWorkerId, generation));
917
+ }
918
+ /** Construct a WorkerRoute record from the owner + handoff fields. Extracted
919
+ * so writeRoute and confirmRoute share the same shape; confirmedAt is added
920
+ * by confirmRoute via spread. */
921
+ buildRouteRecord(topicKey, owner, handoffFromWorkerId, generation) {
922
+ return {
923
+ topicKey,
924
+ workerId: owner.workerId,
925
+ tabId: owner.tabId,
926
+ updatedAt: this.environment.now(),
927
+ generation,
928
+ ...handoffFromWorkerId ? { handoffFromWorkerId } : {}
929
+ };
930
+ }
931
+ /** Stamp a route as confirmed once the owning Worker has acknowledged the assignment. */
932
+ confirmRoute(topicKey) {
933
+ if (!this.storage) return;
934
+ const route = this.readRoute(topicKey);
935
+ if (!route || route.workerId !== this.workerId || route.confirmedAt !== void 0) return;
936
+ writeJson(this.storage, this.routeStorageKey(topicKey), {
937
+ ...route,
938
+ confirmedAt: this.environment.now()
939
+ });
940
+ }
941
+ /** Remove routes whose topic has no subscribers and whose TTL has expired. */
942
+ cleanupOrphanedRoutes(workers) {
943
+ if (!this.storage) return;
944
+ const now = this.environment.now();
945
+ for (const { key, value: route } of readAllByPrefix(this.storage, this.routePrefix)) {
946
+ if (now - route.updatedAt <= this.workerTtlMs) continue;
947
+ if (this.readSubscriberTabIds(route.topicKey, workers).length > 0) continue;
948
+ this.removeStorage(key);
949
+ }
950
+ }
951
+ /** Remove subscriber records for tabs that are no longer active. */
952
+ cleanupOrphanedSubscribers(workers) {
953
+ if (!this.storage) return;
954
+ const activeTabIds = new Set(workers.map((worker) => worker.tabId));
955
+ for (const { key, value: record } of readAllByPrefix(this.storage, this.subscriberPrefix)) {
956
+ if (!activeTabIds.has(record.tabId)) this.removeStorage(key);
957
+ }
958
+ }
959
+ /** Persist a subscriber record for this tab on `topicKey`. */
960
+ writeSubscriber(topicKey) {
961
+ if (!this.storage) return;
962
+ writeJson(this.storage, this.subscriberStorageKey(topicKey, this.tabId), {
963
+ tabId: this.tabId,
964
+ updatedAt: this.environment.now()
965
+ });
966
+ }
967
+ /** Persist the current worker record with an updated heartbeat timestamp.
968
+ * @param notify — when true, broadcast a REGISTRY nudge so peers reconcile
969
+ * immediately instead of waiting for the next heartbeat. False on the
970
+ * periodic heartbeat tick (peers will notice on their own heartbeat) to
971
+ * avoid a REGISTRY storm every 3 s; true on status/role changes that
972
+ * peers should observe promptly. */
973
+ writeRecord(notify) {
974
+ this.currentRecord = { ...this.currentRecord, heartbeatAt: this.environment.now() };
975
+ if (this.storage) writeJson(this.storage, this.workerStorageKey(this.workerId), this.currentRecord);
976
+ if (notify) this.notifyRegistry();
977
+ }
978
+ /** Broadcast a REGISTRY message to trigger reconciliation on other tabs. */
979
+ notifyRegistry() {
980
+ this.send({ type: "REGISTRY", sourceWorkerId: this.workerId });
981
+ }
982
+ /** Recompute whether this worker is active (eligible to own topics) or standby. Returns true when changed. */
983
+ refreshRole(workers) {
984
+ const role = this.isActiveAmong(workers) ? "active" : "standby";
985
+ if (role === this.currentRecord.role) return false;
986
+ this.currentRecord = { ...this.currentRecord, role };
987
+ return true;
988
+ }
989
+ /** Persist the current topic load count (number of assigned topics) for load-balanced routing. */
990
+ updateLoad() {
991
+ const load = this.assignedTopics.size;
992
+ if (load === this.currentRecord.load) return;
993
+ this.currentRecord = { ...this.currentRecord, load };
994
+ if (this.started) this.writeRecord(true);
995
+ }
996
+ /**
997
+ * Hash `topic` into its opaque key and populate the reverse-lookup cache.
998
+ *
999
+ * Despite the name, this is NOT a cache lookup — it unconditionally writes
1000
+ * the `topicKey → topic` pair. Hashing is cheap enough that a caller needing
1001
+ * the key should always call this rather than check `knownTopics` first;
1002
+ * the cache's FIFO eviction below keeps it bounded. Only `isAssigned`
1003
+ * deliberately bypasses this (it must not pollute the cache on a read-only
1004
+ * query), so if you add a new call site, prefer `rememberTopic` unless you
1005
+ * have the same "read-only query" reason.
1006
+ */
1007
+ rememberTopic(topic) {
1008
+ const topicKey = createOpaqueKey(topic);
1009
+ this.knownTopics.set(topicKey, topic);
1010
+ if (this.knownTopics.size > MAX_KNOWN_TOPICS) {
1011
+ for (const candidate of this.knownTopics.keys()) {
1012
+ if (candidate === topicKey || this.assignedTopics.has(candidate)) continue;
1013
+ this.knownTopics.delete(candidate);
1014
+ break;
1015
+ }
1016
+ }
1017
+ return topicKey;
1018
+ }
1019
+ workerStorageKey(workerId) {
1020
+ return `${this.workerPrefix}${workerId}`;
1021
+ }
1022
+ routeStorageKey(topicKey) {
1023
+ return `${this.routePrefix}${topicKey}`;
1024
+ }
1025
+ subscriberStorageKey(topicKey, tabId) {
1026
+ return `${this.subscriberPrefix}${topicKey}:${tabId}`;
1027
+ }
1028
+ removeStorage(key) {
1029
+ try {
1030
+ this.storage?.removeItem(key);
1031
+ } catch {
1032
+ }
1033
+ }
1034
+ /** Force-flush any pending batched writes (used during shutdown/teardown). */
1035
+ flushStorage() {
1036
+ if (this.storage instanceof BatchingStorageWriter) this.storage.flush();
1037
+ }
1038
+ };
1039
+
1040
+ // src/core/trace.ts
1041
+ var DEFAULT_METRICS_INTERVAL_MS = 5e3;
1042
+ var MAX_PENDING_TOPICS = 1e3;
1043
+ var MAX_PENDING_MESSAGES_PER_TOPIC = 256;
1044
+ var LATENCY_BUCKET_COUNT = 20;
1045
+ var LATENCY_BUCKET_SIZE_MS = 50;
1046
+ var DataBusTraceReporter = class {
1047
+ enabled;
1048
+ mode;
1049
+ metricsIntervalMs;
1050
+ sink;
1051
+ now;
1052
+ intervalHandle = null;
1053
+ intervalStartedAt = 0;
1054
+ received = 0;
1055
+ dispatched = 0;
1056
+ latencySamples = 0;
1057
+ topics = /* @__PURE__ */ new Set();
1058
+ // Per-topic FIFO of received timestamps, used to compute dispatch latency.
1059
+ receivedAt = /* @__PURE__ */ new Map();
1060
+ // Bucketed histogram: bucket index = floor(delayMs / 50), capped at 19.
1061
+ latencyBuckets = new Array(LATENCY_BUCKET_COUNT).fill(0);
1062
+ latencySumMs = 0;
1063
+ constructor(options, now = Date.now) {
1064
+ this.enabled = options?.enabled ?? false;
1065
+ this.mode = options?.mode ?? "all";
1066
+ this.metricsIntervalMs = normalizeInterval(options?.metricsIntervalMs);
1067
+ this.sink = options?.sink ?? (() => void 0);
1068
+ this.now = now;
1069
+ }
1070
+ /** Start the periodic metrics flush interval. No-op when mode is 'events'
1071
+ * (no metrics to emit), when disabled, or when already running. */
1072
+ start() {
1073
+ if (!this.enabled || this.intervalHandle || this.mode === "events") return;
1074
+ this.intervalStartedAt = this.now();
1075
+ this.intervalHandle = setInterval(() => this.flush(), this.metricsIntervalMs);
1076
+ }
1077
+ /** Pause the metrics interval and reset accumulated counters. */
1078
+ pause() {
1079
+ if (this.intervalHandle) clearInterval(this.intervalHandle);
1080
+ this.intervalHandle = null;
1081
+ this.intervalStartedAt = 0;
1082
+ this.resetMetrics();
1083
+ }
1084
+ stop() {
1085
+ this.pause();
1086
+ }
1087
+ /** Record an instantaneous trace event (lifecycle, status, error, etc.). */
1088
+ event(event) {
1089
+ if (!this.enabled || this.mode === "metrics") return;
1090
+ this.emit({ ...event, timestamp: this.now() });
1091
+ }
1092
+ /** Record that a message was received on `topic`; stores its timestamp for latency tracking. */
1093
+ recordReceived(topic) {
1094
+ if (!this.metricsActive) return;
1095
+ this.received += 1;
1096
+ this.topics.add(topic);
1097
+ const queue = this.receivedAt.get(topic);
1098
+ if (!queue) {
1099
+ if (this.receivedAt.size >= MAX_PENDING_TOPICS) return;
1100
+ this.receivedAt.set(topic, [this.now()]);
1101
+ return;
1102
+ }
1103
+ if (queue.length >= MAX_PENDING_MESSAGES_PER_TOPIC) return;
1104
+ queue.push(this.now());
1105
+ }
1106
+ /**
1107
+ * Record that a received message will never be dispatched locally. Pops the
1108
+ * matching FIFO slot so a later dispatch on the same topic does not pair
1109
+ * with a stale receive timestamp.
1110
+ */
1111
+ recordDiscarded(topic) {
1112
+ if (!this.metricsActive) return;
1113
+ const queue = this.receivedAt.get(topic);
1114
+ if (!queue) return;
1115
+ queue.shift();
1116
+ if (queue.length === 0) this.receivedAt.delete(topic);
1117
+ }
1118
+ /**
1119
+ * Record that a message was dispatched on `topic`. Pops the oldest receive
1120
+ * timestamp (FIFO) and increments the latency histogram. Dispatches without
1121
+ * a matching receive (e.g. broadcast fan-out from another tab) still count
1122
+ * as dispatched but do not produce a latency sample.
1123
+ */
1124
+ recordDispatched(topic) {
1125
+ if (!this.metricsActive) return;
1126
+ this.dispatched += 1;
1127
+ this.topics.add(topic);
1128
+ const queue = this.receivedAt.get(topic);
1129
+ const receivedTimestamp = queue?.shift();
1130
+ if (queue && queue.length === 0) this.receivedAt.delete(topic);
1131
+ if (receivedTimestamp === void 0) return;
1132
+ this.latencySamples += 1;
1133
+ const delayMs = Math.max(0, this.now() - receivedTimestamp);
1134
+ const bucketIndex = Math.min(LATENCY_BUCKET_COUNT - 1, Math.floor(delayMs / LATENCY_BUCKET_SIZE_MS));
1135
+ this.latencyBuckets[bucketIndex] = (this.latencyBuckets[bucketIndex] ?? 0) + 1;
1136
+ this.latencySumMs += delayMs;
1137
+ }
1138
+ /** True when metrics recording is active: enabled and mode includes metrics.
1139
+ * Extracted so the four record / flush methods share one guard expression
1140
+ * instead of repeating `!this.enabled || this.mode === 'events'` at each. */
1141
+ get metricsActive() {
1142
+ return this.enabled && this.mode !== "events";
1143
+ }
1144
+ /** Emit the accumulated metrics snapshot if the interval is active. */
1145
+ flush() {
1146
+ if (!this.metricsActive) return;
1147
+ this.flushNow();
1148
+ }
1149
+ flushNow() {
1150
+ const timestamp = this.now();
1151
+ if (this.received > 0 || this.dispatched > 0) {
1152
+ const samples = this.latencySamples;
1153
+ this.emit({
1154
+ type: "message_metrics",
1155
+ durationMs: Math.max(0, timestamp - this.intervalStartedAt),
1156
+ received: this.received,
1157
+ dispatched: this.dispatched,
1158
+ topics: this.topics.size,
1159
+ dispatchSamples: samples,
1160
+ dispatchAvgMs: roundMs(samples === 0 ? 0 : this.latencySumMs / samples),
1161
+ // Percentiles are derived from the histogram, not sorted samples.
1162
+ dispatchP50Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.5)),
1163
+ dispatchP95Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.95)),
1164
+ dispatchMaxMs: roundMs(percentileMs(this.latencyBuckets, samples, 1)),
1165
+ timestamp
1166
+ });
1167
+ this.resetMetrics();
1168
+ }
1169
+ this.intervalStartedAt = timestamp;
1170
+ }
1171
+ resetMetrics() {
1172
+ this.received = 0;
1173
+ this.dispatched = 0;
1174
+ this.latencySamples = 0;
1175
+ this.topics.clear();
1176
+ this.receivedAt.clear();
1177
+ this.latencyBuckets.fill(0);
1178
+ this.latencySumMs = 0;
1179
+ }
1180
+ emit(event) {
1181
+ try {
1182
+ this.sink(event);
1183
+ } catch (error) {
1184
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
1185
+ console.warn("[cross-tab-worker-databus] trace sink threw:", error);
1186
+ }
1187
+ }
1188
+ }
1189
+ };
1190
+ function normalizeInterval(value) {
1191
+ if (value === void 0) return DEFAULT_METRICS_INTERVAL_MS;
1192
+ if (!Number.isFinite(value) || value <= 0) {
1193
+ throw new RangeError("trace.metricsIntervalMs must be a positive finite number.");
1194
+ }
1195
+ return value;
1196
+ }
1197
+ function percentileMs(buckets, sampleCount, percentile) {
1198
+ if (sampleCount <= 0) return 0;
1199
+ const rank = Math.max(1, Math.ceil(percentile * sampleCount));
1200
+ let seen = 0;
1201
+ for (let index = 0; index < buckets.length; index += 1) {
1202
+ seen += buckets[index] ?? 0;
1203
+ if (seen >= rank) return (index + 0.5) * LATENCY_BUCKET_SIZE_MS;
1204
+ }
1205
+ return buckets.length * LATENCY_BUCKET_SIZE_MS;
1206
+ }
1207
+ function roundMs(value) {
1208
+ return Math.round(value * 10) / 10;
1209
+ }
1210
+
1211
+ // src/core/data-bus.ts
1212
+ var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
1213
+ var CrossTabDataBus = class _CrossTabDataBus {
1214
+ transport;
1215
+ cluster;
1216
+ // Map of topic → set of local subscribers.
1217
+ topicHandlers = /* @__PURE__ */ new Map();
1218
+ // Topics for which the transport has been asked to subscribe (used to avoid
1219
+ // duplicate subscribe calls during reconnection).
1220
+ transportSubscribedTopics = /* @__PURE__ */ new Set();
1221
+ statusHandlers = /* @__PURE__ */ new Set();
1222
+ errorHandlers = /* @__PURE__ */ new Set();
1223
+ initialConfig;
1224
+ hasInitialConfig;
1225
+ trace;
1226
+ activeConfig;
1227
+ status = "disconnected";
1228
+ started = false;
1229
+ stopping = false;
1230
+ transportReady = false;
1231
+ // Last transport failure, retained so ready() can surface it to callers who
1232
+ // never awaited start() directly. Cleared on the next successful start.
1233
+ lastError = null;
1234
+ // Gate that serialises start/stop/suspend/resume — only one lifecycle
1235
+ // transition at a time. Resets to null once the operation settles.
1236
+ startPromise = null;
1237
+ // Timestamp of the last automatic transport recovery attempt.
1238
+ // Used to avoid a tight retry loop when the transport fails repeatedly.
1239
+ lastRecoveryAt = 0;
1240
+ // True while the tab is hidden so an in-flight transport start does not mark
1241
+ // the transport ready after suspendTransport() has stopped it.
1242
+ suspended = false;
1243
+ // Single gate for async transport.stop() cleanup, shared by failed opens and
1244
+ // page-hide suspension. Kept separate from startPromise so ready() still
1245
+ // surfaces a failure while later opens and automatic recovery wait for the
1246
+ // stop to settle.
1247
+ pendingStop = null;
1248
+ // Minimum interval in ms between automatic recovery attempts.
1249
+ static RECOVERY_COOLDOWN_MS = 1e3;
1250
+ constructor(options) {
1251
+ const { autoStart, initialConfig, trace, transport, ...clusterOptions } = options;
1252
+ this.transport = transport;
1253
+ this.initialConfig = initialConfig;
1254
+ this.hasInitialConfig = "initialConfig" in options;
1255
+ this.trace = new DataBusTraceReporter(trace);
1256
+ this.cluster = new WorkerClusterRuntime({
1257
+ ...clusterOptions,
1258
+ handlers: {
1259
+ // The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
1260
+ // control message — meaning the owning Worker has delegated the action to us.
1261
+ onControl: (action, topic, data) => {
1262
+ switch (action) {
1263
+ case "SUBSCRIBE":
1264
+ if (this.subscribeTransport(topic)) this.traceSubscription("subscribe", topic);
1265
+ break;
1266
+ case "UNSUBSCRIBE":
1267
+ if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
1268
+ break;
1269
+ case "PUBLISH":
1270
+ this.runTransport(() => this.transport.publish(topic, data));
1271
+ break;
1272
+ default:
1273
+ break;
1274
+ }
1275
+ },
1276
+ // The cluster calls `onEvent` when a publication broadcast arrives from
1277
+ // another tab. Dispatch locally if we have subscribers. The payload is
1278
+ // typed `unknown` at the cluster boundary (the cluster is transport-
1279
+ // agnostic); here we narrow it to DataBusMessage — the sender is our
1280
+ // own broadcastEvent call, which always posts a DataBusMessage.
1281
+ onEvent: (eventType, payload) => {
1282
+ if (eventType !== PUBLICATION_EVENT) return;
1283
+ const message = payload;
1284
+ if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
1285
+ },
1286
+ onSuspend: () => {
1287
+ if (!this.stopping) this.trace.event({ type: "lifecycle", action: "suspend" });
1288
+ this.trace.pause();
1289
+ this.suspendTransport();
1290
+ },
1291
+ onResume: () => {
1292
+ this.trace.event({ type: "lifecycle", action: "resume" });
1293
+ this.trace.start();
1294
+ this.resumeTransport();
1295
+ }
1296
+ }
1297
+ });
1298
+ if (autoStart ?? this.hasInitialConfig) this.ensureStarted();
1299
+ }
1300
+ /**
1301
+ * Start the DataBus with the given transport config.
1302
+ *
1303
+ * The first call starts the cluster and opens the transport. Concurrent calls
1304
+ * during an in-flight start return the same promise. Once the operation
1305
+ * settles (success or failure) the promise gate is cleared so a subsequent
1306
+ * start() or resumeTransport() can open a fresh lifecycle.
1307
+ */
1308
+ start(config) {
1309
+ if (this.startPromise) return this.startPromise;
1310
+ if (this.started) return Promise.resolve();
1311
+ this.started = true;
1312
+ this.stopping = false;
1313
+ this.suspended = false;
1314
+ this.activeConfig = config;
1315
+ this.lastError = null;
1316
+ this.trace.event({ type: "lifecycle", action: "start" });
1317
+ this.trace.start();
1318
+ this.updateStatus("connecting");
1319
+ this.cluster.start();
1320
+ const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
1321
+ this.startPromise = opening;
1322
+ for (const topic of this.topicHandlers.keys()) {
1323
+ this.cluster.subscribe(topic);
1324
+ }
1325
+ const snapshot = this.cluster.getSnapshot();
1326
+ this.trace.event({
1327
+ type: "coordination",
1328
+ coordinated: snapshot.coordinated,
1329
+ activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
1330
+ workers: snapshot.workers.map(formatWorkerTrace),
1331
+ routes: snapshot.routes.map(formatRouteTrace)
1332
+ });
1333
+ void opening.then(
1334
+ () => {
1335
+ if (this.startPromise === opening) this.startPromise = null;
1336
+ },
1337
+ () => {
1338
+ if (this.startPromise === opening) this.startPromise = null;
1339
+ }
1340
+ );
1341
+ return opening;
1342
+ }
1343
+ /**
1344
+ * Open the transport, chained after `before` to ensure lifecycle ordering.
1345
+ * When `stopClusterOnFailure` is true (initial start), a transport failure
1346
+ * tears down the cluster as well.
1347
+ */
1348
+ openTransport(config, before, stopClusterOnFailure) {
1349
+ this.transportReady = false;
1350
+ const chainedPendingStop = this.pendingStop;
1351
+ return before.catch(() => void 0).then(() => {
1352
+ if (this.stopping || this.suspended) return;
1353
+ if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
1354
+ return Promise.resolve(
1355
+ this.transport.start(config, {
1356
+ onMessage: (message) => this.handleTransportMessage(message),
1357
+ onStatus: (status) => this.updateStatus(status),
1358
+ onError: (error) => this.reportError(error)
1359
+ })
1360
+ ).then(() => {
1361
+ if (this.status === "error") {
1362
+ throw new Error("Transport failed during startup.");
1363
+ }
1364
+ if (!this.suspended && !this.stopping) this.transportReady = true;
1365
+ });
1366
+ }).catch((error) => {
1367
+ if (stopClusterOnFailure) this.started = false;
1368
+ if (!this.pendingStop) {
1369
+ this.pendingStop = this.createStopPromise();
1370
+ }
1371
+ this.updateStatus("error");
1372
+ this.reportError(error);
1373
+ this.lastError = error;
1374
+ this.transportReady = false;
1375
+ if (stopClusterOnFailure) {
1376
+ this.stopping = true;
1377
+ this.cluster.stop();
1378
+ this.stopping = false;
1379
+ }
1380
+ this.startPromise = null;
1381
+ throw error;
1382
+ });
1383
+ }
1384
+ /**
1385
+ * Await the DataBus to be fully started (lazy init when using initialConfig).
1386
+ * Returns a rejected promise when the transport has failed and no start is in
1387
+ * flight — the caller can retry by calling start() or ready() again.
1388
+ */
1389
+ ready() {
1390
+ try {
1391
+ this.ensureStarted();
1392
+ } catch (error) {
1393
+ return Promise.reject(error);
1394
+ }
1395
+ if (this.startPromise) return this.startPromise;
1396
+ if (this.transportReady) return Promise.resolve();
1397
+ if (this.lastError !== null) return Promise.reject(this.lastError);
1398
+ return Promise.reject(
1399
+ new Error("Transport is not ready and no start operation is in flight")
1400
+ );
1401
+ }
1402
+ /**
1403
+ * Register a handler for `topic`. The handler fires on every publication
1404
+ * delivered to this tab, regardless of which tab published it. Returns an
1405
+ * unsubscribe function for convenience.
1406
+ */
1407
+ subscribe(topic, handler) {
1408
+ this.ensureStarted();
1409
+ const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
1410
+ const wasUnused = handlers.size === 0;
1411
+ handlers.add(handler);
1412
+ this.topicHandlers.set(topic, handlers);
1413
+ if (wasUnused) this.cluster.subscribe(topic);
1414
+ return () => this.unsubscribe(topic, handler);
1415
+ }
1416
+ /** Remove a specific handler, or all handlers for `topic`.
1417
+ * When `handler` is omitted, clears every handler for the topic — the
1418
+ * caller used the `unsubscribe(topic)` form expecting a full teardown.
1419
+ * The cluster is only notified on the n→0 transition (handlers.size === 0). */
1420
+ unsubscribe(topic, handler) {
1421
+ const handlers = this.topicHandlers.get(topic);
1422
+ if (!handlers) return;
1423
+ if (handler) handlers.delete(handler);
1424
+ else handlers.clear();
1425
+ if (handlers.size > 0) return;
1426
+ this.topicHandlers.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.cluster.stop();
1474
+ try {
1475
+ await this.startPromise?.catch(() => void 0);
1476
+ const pendingStop = this.pendingStop;
1477
+ if (pendingStop) await pendingStop.catch(() => void 0);
1478
+ else await this.transport.stop();
1479
+ } finally {
1480
+ this.transportSubscribedTopics.clear();
1481
+ this.started = false;
1482
+ this.stopping = false;
1483
+ this.suspended = false;
1484
+ this.transportReady = false;
1485
+ this.startPromise = null;
1486
+ this.pendingStop = null;
1487
+ this.lastError = null;
1488
+ this.activeConfig = void 0;
1489
+ this.updateStatus("disconnected");
1490
+ }
1491
+ }
1492
+ /**
1493
+ * Incoming message from the transport.
1494
+ * Records metrics, checks ownership via the cluster, broadcasts to other tabs,
1495
+ * and dispatches locally.
1496
+ */
1497
+ handleTransportMessage(message) {
1498
+ this.trace.recordReceived(message.topic);
1499
+ if (!this.cluster.isAssigned(message.topic)) {
1500
+ this.trace.recordDiscarded(message.topic);
1501
+ return;
1502
+ }
1503
+ this.cluster.broadcastEvent(PUBLICATION_EVENT, message);
1504
+ if (this.cluster.hasLocalSubscriber(message.topic)) {
1505
+ this.dispatch(message);
1506
+ return;
1507
+ }
1508
+ this.trace.recordDiscarded(message.topic);
1509
+ }
1510
+ /** Deliver a message to every local handler registered for its topic,
1511
+ * plus every handler registered with a wildcard subscription that matches
1512
+ * (e.g. a handler subscribed to "chat.*" receives "chat.room.1"). */
1513
+ dispatch(message) {
1514
+ this.trace.recordDispatched(message.topic);
1515
+ this.invokeHandlers(this.topicHandlers.get(message.topic) ?? [], (handler) => handler(message));
1516
+ for (const [pattern, handlers] of this.topicHandlers) {
1517
+ if (pattern !== message.topic && topicMatchesPattern(pattern, message.topic)) {
1518
+ this.invokeHandlers(handlers, (handler) => handler(message));
1519
+ }
1520
+ }
1521
+ }
1522
+ /**
1523
+ * Propagate a status change to the cluster, trace, and all registered
1524
+ * status handlers. On reconnect, re-subscribe any topics assigned to us.
1525
+ */
1526
+ updateStatus(status) {
1527
+ const previousStatus = this.status;
1528
+ this.status = status;
1529
+ if (previousStatus !== status) this.trace.event({ type: "status", status });
1530
+ this.cluster.setStatus(status);
1531
+ if (status === "disconnected" || status === "error") this.transportSubscribedTopics.clear();
1532
+ if (status === "connected" && previousStatus !== "connected") {
1533
+ for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
1534
+ }
1535
+ if (status === "error" && this.started && !this.stopping) {
1536
+ const now = Date.now();
1537
+ if (now - this.lastRecoveryAt >= _CrossTabDataBus.RECOVERY_COOLDOWN_MS) {
1538
+ this.lastRecoveryAt = now;
1539
+ setTimeout(() => {
1540
+ if (this.stopping || !this.started || this.suspended) return;
1541
+ if (this.status !== "error") return;
1542
+ void this.reopenTransport();
1543
+ }, _CrossTabDataBus.RECOVERY_COOLDOWN_MS);
1544
+ }
1545
+ }
1546
+ this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
1547
+ }
1548
+ reportError(error) {
1549
+ this.trace.event({ type: "error", source: "transport" });
1550
+ this.invokeHandlers(this.errorHandlers, (handler) => handler(error), "error handler");
1551
+ }
1552
+ traceSubscription(action, topic) {
1553
+ this.trace.event({
1554
+ type: "subscription",
1555
+ action,
1556
+ topic,
1557
+ activeTopics: this.transportSubscribedTopics.size
1558
+ });
1559
+ }
1560
+ /** Ask the transport to subscribe to a topic (idempotent). */
1561
+ subscribeTransport(topic) {
1562
+ if (this.transportSubscribedTopics.has(topic)) return false;
1563
+ this.transportSubscribedTopics.add(topic);
1564
+ this.runTransport(() => this.transport.subscribe(topic));
1565
+ return true;
1566
+ }
1567
+ unsubscribeTransport(topic) {
1568
+ if (!this.transportSubscribedTopics.delete(topic)) return false;
1569
+ this.runTransport(() => this.transport.unsubscribe(topic));
1570
+ return true;
1571
+ }
1572
+ /** Invoke `callback` for each item in `handlers`, isolating a throwing
1573
+ * callback so the remaining ones still run. Dispatch/status handler failures
1574
+ * are routed to `reportError` (which surfaces them to error subscribers);
1575
+ * error-handler failures are logged to the console to avoid infinite
1576
+ * recursion through reportError itself. */
1577
+ invokeHandlers(handlers, callback, label = "dispatch") {
1578
+ for (const handler of handlers) {
1579
+ try {
1580
+ callback(handler);
1581
+ } catch (error) {
1582
+ if (label === "error handler") {
1583
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
1584
+ console.warn("[cross-tab-worker-databus] error handler threw:", error);
1585
+ }
1586
+ } else {
1587
+ this.reportError(error);
1588
+ }
1589
+ }
1590
+ }
1591
+ }
1592
+ /**
1593
+ * Suspend the transport when the tab goes hidden. Stops the transport and
1594
+ * clears subscription state so it will be re-established on resume.
1595
+ */
1596
+ suspendTransport() {
1597
+ if (this.stopping) return;
1598
+ this.suspended = true;
1599
+ this.transportReady = false;
1600
+ this.transportSubscribedTopics.clear();
1601
+ this.updateStatus("disconnected");
1602
+ if (this.pendingStop) return;
1603
+ const pending = this.startPromise ?? Promise.resolve();
1604
+ const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
1605
+ this.startPromise = stopping;
1606
+ this.pendingStop = stopping;
1607
+ }
1608
+ /** Create an immediate stop promise (no prior chain). Used by openTransport's
1609
+ * failure path where there is no in-flight start to wait for. */
1610
+ createStopPromise() {
1611
+ return Promise.resolve().then(() => this.transport.stop()).catch((stopError) => this.reportError(stopError));
1612
+ }
1613
+ /**
1614
+ * Resume the transport when the tab becomes visible again, or recover from a
1615
+ * runtime transport failure. Re-opens the transport with the stored active
1616
+ * config, chained after any pending operation so an async transport stop
1617
+ * completes before the new start. Returns the opening promise.
1618
+ */
1619
+ resumeTransport() {
1620
+ void this.reopenTransport();
1621
+ }
1622
+ /**
1623
+ * Re-open the transport with the previously stored active config. Chains
1624
+ * after any in-flight lifecycle operation (e.g. a suspend stop), swallowing
1625
+ * its rejection so the reopen is not blocked. Returns the opening promise so
1626
+ * callers can queue operations behind it.
1627
+ */
1628
+ reopenTransport() {
1629
+ if (this.stopping || this.activeConfig === void 0) return Promise.resolve();
1630
+ if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
1631
+ const config = this.activeConfig;
1632
+ this.started = true;
1633
+ this.suspended = false;
1634
+ this.updateStatus("connecting");
1635
+ const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
1636
+ const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
1637
+ this.startPromise = opening;
1638
+ void opening.then(
1639
+ () => {
1640
+ if (this.startPromise === opening) this.startPromise = null;
1641
+ },
1642
+ () => void 0
1643
+ );
1644
+ void opening.catch(() => void 0);
1645
+ return opening;
1646
+ }
1647
+ /**
1648
+ * Run a transport operation now if the transport is ready, otherwise queue
1649
+ * it behind the start promise. This ensures subscribe/unsubscribe calls made
1650
+ * during startup are not lost.
1651
+ */
1652
+ runTransport(operation) {
1653
+ if (this.suspended) return;
1654
+ if (this.transportReady && !this.stopping) {
1655
+ try {
1656
+ void Promise.resolve(operation()).catch((error) => this.reportError(error));
1657
+ } catch (error) {
1658
+ this.reportError(error);
1659
+ }
1660
+ return;
1661
+ }
1662
+ let ready = this.startPromise;
1663
+ if (!ready && this.started && !this.stopping && this.activeConfig !== void 0) {
1664
+ ready = this.reopenTransport();
1665
+ }
1666
+ if (!ready || this.stopping) return;
1667
+ void ready.then(() => {
1668
+ if (!this.started || this.stopping || this.suspended) return;
1669
+ return operation();
1670
+ }).catch((error) => this.reportError(error));
1671
+ }
1672
+ /**
1673
+ * Ensure the DataBus is started, throwing if no initialConfig was provided.
1674
+ * Called automatically by subscribe/publish/ready when autoStart is true.
1675
+ */
1676
+ ensureStarted() {
1677
+ if (this.started) return;
1678
+ if (!this.hasInitialConfig) {
1679
+ throw new Error(
1680
+ "CrossTabDataBus requires initialConfig for automatic startup, or an explicit start(config) call."
1681
+ );
1682
+ }
1683
+ const starting = this.start(this.initialConfig);
1684
+ void starting.catch(() => void 0);
1685
+ }
1686
+ };
1687
+ function formatWorkerTrace(worker) {
1688
+ return `${worker.workerId}|${worker.status}|load=${worker.load}|tab=${worker.tabId}`;
1689
+ }
1690
+ function formatRouteTrace(route) {
1691
+ return `${route.topicKey}@${route.workerId}|confirmed=${route.confirmedAt !== void 0}`;
1692
+ }
1693
+
1694
+ // src/websocket.ts
1695
+ var WS_OPEN = 1;
1696
+ var WebSocketTransport = class {
1697
+ constructor(connection) {
1698
+ this.connection = connection;
1699
+ }
1700
+ socket = null;
1701
+ handlers = null;
1702
+ subscribedTopics = /* @__PURE__ */ new Set();
1703
+ /** Open the WebSocket and wire lifecycle listeners. A factory failure is
1704
+ * reported through `onStatus('error')` so the DataBus can recover. */
1705
+ start(config, handlers) {
1706
+ if (this.socket) return;
1707
+ this.handlers = handlers;
1708
+ const factory = config.webSocketFactory ?? this.connection.webSocketFactory ?? defaultWebSocketFactory;
1709
+ const protocols = config.protocols ?? this.connection.protocols;
1710
+ let socket;
1711
+ try {
1712
+ socket = factory(config.url, protocols);
1713
+ } catch (error) {
1714
+ handlers.onStatus("error");
1715
+ handlers.onError(error);
1716
+ return;
1717
+ }
1718
+ socket.onopen = () => {
1719
+ for (const topic of this.subscribedTopics) {
1720
+ this.sendFrame({ op: "subscribe", topic });
1721
+ }
1722
+ handlers.onStatus("connected");
1723
+ };
1724
+ socket.onclose = () => handlers.onStatus("disconnected");
1725
+ socket.onerror = () => handlers.onStatus("error");
1726
+ socket.onmessage = (event) => this.handleMessage(event.data);
1727
+ this.socket = socket;
1728
+ }
1729
+ /** Idempotent: re-subscribing an active topic re-sends the frame but does
1730
+ * not duplicate the local tracking entry. */
1731
+ subscribe(topic) {
1732
+ this.subscribedTopics.add(topic);
1733
+ this.sendFrame({ op: "subscribe", topic });
1734
+ }
1735
+ /** Idempotent: unsubscribing an unknown topic is a no-op. */
1736
+ unsubscribe(topic) {
1737
+ this.subscribedTopics.delete(topic);
1738
+ this.sendFrame({ op: "unsubscribe", topic });
1739
+ }
1740
+ /** Publish `data` to `topic` as a JSON frame. Requires an open socket. */
1741
+ publish(topic, data) {
1742
+ this.sendFrame({ op: "publish", topic, data });
1743
+ }
1744
+ /** Close the socket and drop all state. Safe to call multiple times. */
1745
+ stop() {
1746
+ const socket = this.socket;
1747
+ this.socket = null;
1748
+ this.handlers = null;
1749
+ this.subscribedTopics.clear();
1750
+ socket?.close();
1751
+ }
1752
+ /** Send one JSON frame. Frames are dropped with an `onError` report when
1753
+ * the socket is not open — subscribe frames are re-sent on open, so the
1754
+ * only real loss is a publish during a disconnect window. */
1755
+ sendFrame(payload) {
1756
+ if (this.socket?.readyState !== WS_OPEN) {
1757
+ this.handlers?.onError(new Error(`WebSocket is not open; dropped "${payload.op}" frame.`));
1758
+ return;
1759
+ }
1760
+ this.socket.send(JSON.stringify(payload));
1761
+ }
1762
+ /** Parse a server frame. Only objects carrying a string `topic` are
1763
+ * publications; malformed JSON and unknown shapes are ignored so a chatty
1764
+ * server cannot crash the message path. */
1765
+ handleMessage(raw) {
1766
+ let parsed;
1767
+ if (typeof raw !== "string") return;
1768
+ try {
1769
+ parsed = JSON.parse(raw);
1770
+ } catch {
1771
+ this.handlers?.onError(new Error("WebSocket server sent a non-JSON frame."));
1772
+ return;
1773
+ }
1774
+ if (!parsed || typeof parsed !== "object") return;
1775
+ const frame = parsed;
1776
+ if (typeof frame.topic !== "string") return;
1777
+ this.handlers?.onMessage({ topic: frame.topic, data: frame.data });
1778
+ }
1779
+ };
1780
+ function defaultWebSocketFactory(url, protocols) {
1781
+ if (typeof WebSocket === "undefined") {
1782
+ throw new Error("WebSocketTransport requires a WebSocket implementation.");
1783
+ }
1784
+ return new WebSocket(url, protocols);
1785
+ }
1786
+ function createWebSocketDataBus(options) {
1787
+ const { clusterKey, connection, ...dataBusOptions } = options;
1788
+ return new CrossTabDataBus({
1789
+ ...dataBusOptions,
1790
+ autoStart: true,
1791
+ clusterKey: clusterKey ?? connection.url,
1792
+ initialConfig: connection,
1793
+ transport: new WebSocketTransport(connection)
1794
+ });
1795
+ }
1796
+
1797
+ // src/worker-mode.ts
1798
+ function selectWorkerBackend(mode, availability = {}) {
1799
+ const hasDedicated = availability.worker ?? typeof Worker !== "undefined";
1800
+ const hasShared = availability.sharedWorker ?? typeof SharedWorker !== "undefined";
1801
+ if (mode === "shared" || mode === "auto") {
1802
+ return hasShared ? "shared" : hasDedicated ? "dedicated" : "local";
1803
+ }
1804
+ return hasDedicated ? "dedicated" : hasShared ? "shared" : "local";
1805
+ }
1806
+ //# sourceMappingURL=index.cjs.map