cross-tab-worker-databus 0.1.1 → 0.2.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 (45) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +11 -0
  3. package/README.zh.md +9 -0
  4. package/dist/centrifuge-protocol.d.ts +51 -15
  5. package/dist/centrifuge-protocol.d.ts.map +1 -1
  6. package/dist/centrifuge-session.d.ts +14 -4
  7. package/dist/centrifuge-session.d.ts.map +1 -1
  8. package/dist/centrifuge.d.ts +10 -2
  9. package/dist/centrifuge.d.ts.map +1 -1
  10. package/dist/centrifuge.js +76 -28
  11. package/dist/centrifuge.js.map +2 -2
  12. package/dist/centrifuge.shared.worker.js +84 -28
  13. package/dist/centrifuge.shared.worker.js.map +2 -2
  14. package/dist/centrifuge.worker.js +48 -14
  15. package/dist/centrifuge.worker.js.map +2 -2
  16. package/dist/{chunk-GABYBK7I.js → chunk-LBXREMZA.js} +287 -162
  17. package/dist/chunk-LBXREMZA.js.map +7 -0
  18. package/dist/core/cluster.d.ts +49 -4
  19. package/dist/core/cluster.d.ts.map +1 -1
  20. package/dist/core/data-bus.d.ts +19 -2
  21. package/dist/core/data-bus.d.ts.map +1 -1
  22. package/dist/core/environment.d.ts +16 -2
  23. package/dist/core/environment.d.ts.map +1 -1
  24. package/dist/core/hash.d.ts.map +1 -1
  25. package/dist/core/routing.d.ts +5 -2
  26. package/dist/core/routing.d.ts.map +1 -1
  27. package/dist/core/storage-batch.d.ts +14 -0
  28. package/dist/core/storage-batch.d.ts.map +1 -1
  29. package/dist/core/trace.d.ts +16 -1
  30. package/dist/core/trace.d.ts.map +1 -1
  31. package/dist/core/types.d.ts +76 -21
  32. package/dist/core/types.d.ts.map +1 -1
  33. package/dist/index.js +1 -1
  34. package/dist/worker-mode.d.ts +12 -3
  35. package/dist/worker-mode.d.ts.map +1 -1
  36. package/dist/workers/port-reaper.d.ts +23 -6
  37. package/dist/workers/port-reaper.d.ts.map +1 -1
  38. package/docs/architecture.md +20 -8
  39. package/docs/configuration.md +2 -0
  40. package/docs/transports.md +144 -0
  41. package/docs/zh/architecture.md +20 -8
  42. package/docs/zh/configuration.md +2 -0
  43. package/docs/zh/transports.md +132 -0
  44. package/package.json +7 -2
  45. package/dist/chunk-GABYBK7I.js.map +0 -7
@@ -80,23 +80,36 @@ function getOrCreateTabId(environment, key = "cross-tab-worker-databus:tab-id")
80
80
 
81
81
  // src/core/hash.ts
82
82
  function createOpaqueKey(value) {
83
- let h1 = 3735928559 ^ value.length;
84
- let h2 = 1103547991 ^ value.length;
85
- let h3 = 3235826430 ^ value.length;
86
- let h4 = 2654435769 ^ value.length;
83
+ let h1 = SEED_H1 ^ value.length;
84
+ let h2 = SEED_H2 ^ value.length;
85
+ let h3 = SEED_H3 ^ value.length;
86
+ let h4 = SEED_H4 ^ value.length;
87
87
  for (let index = 0; index < value.length; index += 1) {
88
88
  const code = value.charCodeAt(index);
89
- h1 = Math.imul(h1 ^ code, 2654435761);
90
- h2 = Math.imul(h2 ^ code, 1597334677);
91
- h3 = Math.imul(h3 ^ code, 2246822519);
92
- h4 = Math.imul(h4 ^ code, 3266489917);
93
- }
94
- h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
95
- h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h3 ^ h3 >>> 13, 3266489909);
96
- h3 = Math.imul(h3 ^ h3 >>> 16, 2246822507) ^ Math.imul(h4 ^ h4 >>> 13, 3266489909);
97
- h4 = Math.imul(h4 ^ h4 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
89
+ h1 = Math.imul(h1 ^ code, PRIME_H1);
90
+ h2 = Math.imul(h2 ^ code, PRIME_H2);
91
+ h3 = Math.imul(h3 ^ code, PRIME_H3);
92
+ h4 = Math.imul(h4 ^ code, PRIME_H4);
93
+ }
94
+ h1 = avalancheMix(h1, h2);
95
+ h2 = avalancheMix(h2, h3);
96
+ h3 = avalancheMix(h3, h4);
97
+ h4 = avalancheMix(h4, h1);
98
98
  return [h1, h2, h3, h4].map((hash) => (hash >>> 0).toString(16).padStart(8, "0")).join("");
99
99
  }
100
+ var SEED_H1 = 3735928559;
101
+ var SEED_H2 = 1103547991;
102
+ var SEED_H3 = 3235826430;
103
+ var SEED_H4 = 2654435769;
104
+ var PRIME_H1 = 2654435761;
105
+ var PRIME_H2 = 1597334677;
106
+ var PRIME_H3 = 2246822519;
107
+ var PRIME_H4 = 3266489917;
108
+ var AVALANCHE_PRIME = 2246822507;
109
+ var AVALANCHE_CROSS = 3266489909;
110
+ function avalancheMix(self, neighbor) {
111
+ return Math.imul(self ^ self >>> 16, AVALANCHE_PRIME) ^ Math.imul(neighbor ^ neighbor >>> 13, AVALANCHE_CROSS);
112
+ }
100
113
 
101
114
  // src/core/routing.ts
102
115
  var DEFAULT_MAX_ACTIVE_WORKERS = 3;
@@ -107,7 +120,8 @@ function selectLeastLoadedWorker(workers, preferredWorkerId) {
107
120
  if (!least) return worker;
108
121
  const byLoad = worker.load - least.load;
109
122
  if (byLoad !== 0) return byLoad < 0 ? worker : least;
110
- return worker.workerId.localeCompare(least.workerId) < 0 ? worker : least;
123
+ if (worker.workerId < least.workerId) return worker;
124
+ return least;
111
125
  }, void 0);
112
126
  }
113
127
  function selectActiveWorkers(workers, maxActiveWorkers = DEFAULT_MAX_ACTIVE_WORKERS) {
@@ -116,7 +130,7 @@ function selectActiveWorkers(workers, maxActiveWorkers = DEFAULT_MAX_ACTIVE_WORK
116
130
  const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === "visible");
117
131
  const candidates = visibleWorkers.length > 0 ? visibleWorkers : availableWorkers;
118
132
  return candidates.sort(
119
- (left, right) => left.registeredAt - right.registeredAt || left.workerId.localeCompare(right.workerId)
133
+ (left, right) => left.registeredAt - right.registeredAt || (left.workerId < right.workerId ? -1 : left.workerId > right.workerId ? 1 : 0)
120
134
  ).slice(0, maxActiveWorkers);
121
135
  }
122
136
  function selectRebalanceTarget(workers, currentWorkerId) {
@@ -134,15 +148,21 @@ function hasActiveOwner(route, workers) {
134
148
  // src/core/storage-batch.ts
135
149
  var INITIAL_RETRY_DELAY_MS = 50;
136
150
  var MAX_RETRY_DELAY_MS = 1600;
151
+ var MAX_RETRY_ATTEMPTS = 5;
137
152
  var BatchingStorageWriter = class {
138
153
  constructor(storage) {
139
154
  this.storage = storage;
140
155
  }
141
156
  /** Coalesced write set. A `null` value represents a pending delete. */
142
157
  pending = /* @__PURE__ */ new Map();
158
+ /** Per-key retry counter, reset on a successful write. */
159
+ retryCount = /* @__PURE__ */ new Map();
143
160
  flushScheduled = false;
144
161
  retryHandle = null;
145
162
  retryDelayMs = INITIAL_RETRY_DELAY_MS;
163
+ /** Number of writes queued in memory but not yet flushed to storage.
164
+ * Used by tests to assert the coalescing window and by flush() to detect
165
+ * the all-drained state. */
146
166
  get pendingSize() {
147
167
  return this.pending.size;
148
168
  }
@@ -154,6 +174,7 @@ var BatchingStorageWriter = class {
154
174
  this.flushScheduled = false;
155
175
  this.storage.clear();
156
176
  this.cancelRetry();
177
+ this.retryCount.clear();
157
178
  this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
158
179
  }
159
180
  // Reads always see the pending value first (task-local consistency), then
@@ -176,17 +197,31 @@ var BatchingStorageWriter = class {
176
197
  flush() {
177
198
  this.flushScheduled = false;
178
199
  this.cancelRetry();
179
- for (const [key, value] of [...this.pending]) {
200
+ for (const [key, value] of Array.from(this.pending)) {
180
201
  try {
181
202
  if (value === null) this.storage.removeItem(key);
182
203
  else this.storage.setItem(key, value);
183
204
  this.pending.delete(key);
205
+ this.retryCount.delete(key);
184
206
  } catch {
207
+ const attempts = (this.retryCount.get(key) ?? 0) + 1;
208
+ if (attempts >= MAX_RETRY_ATTEMPTS) {
209
+ this.pending.delete(key);
210
+ this.retryCount.delete(key);
211
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
212
+ console.warn("[cross-tab-worker-databus] storage write gave up after retries, dropping key:", key);
213
+ }
214
+ continue;
215
+ }
216
+ this.retryCount.set(key, attempts);
185
217
  this.scheduleRetry();
186
218
  break;
187
219
  }
188
220
  }
189
- if (this.pending.size === 0) this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
221
+ if (this.pending.size === 0) {
222
+ this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
223
+ this.retryCount.clear();
224
+ }
190
225
  }
191
226
  /** Union of persisted keys and pending writes, minus pending deletes. */
192
227
  keys() {
@@ -199,10 +234,12 @@ var BatchingStorageWriter = class {
199
234
  if (value === null) keys.delete(key);
200
235
  else keys.add(key);
201
236
  }
202
- return [...keys];
237
+ return Array.from(keys);
203
238
  }
204
239
  // Coalesce all synchronous writes within one task into a single microtask
205
240
  // flush, avoiding a localStorage write per heartbeat/route/subscriber update.
241
+ // The queueMicrotask fallback to setTimeout handles older runtimes and
242
+ // non-browser environments where queueMicrotask is absent.
206
243
  scheduleFlush() {
207
244
  if (this.flushScheduled) return;
208
245
  this.flushScheduled = true;
@@ -213,6 +250,9 @@ var BatchingStorageWriter = class {
213
250
  if (typeof queueMicrotask === "function") queueMicrotask(flush);
214
251
  else setTimeout(flush, 0);
215
252
  }
253
+ // Schedule a single retry timer. The guard ensures only one retry is in
254
+ // flight at a time; subsequent scheduleRetry calls during the wait are
255
+ // no-ops because the first retry will re-flush all pending keys together.
216
256
  scheduleRetry() {
217
257
  if (this.retryHandle !== null) return;
218
258
  this.retryHandle = setTimeout(() => {
@@ -257,6 +297,9 @@ function listKeys(storage, prefix) {
257
297
  return [];
258
298
  }
259
299
  }
300
+ function readAllByPrefix(storage, prefix) {
301
+ return listKeys(storage, prefix).map((key) => ({ key, value: readJson(storage, key) })).filter((entry) => entry.value !== null);
302
+ }
260
303
  var WorkerClusterRuntime = class {
261
304
  tabId;
262
305
  workerId;
@@ -326,6 +369,9 @@ var WorkerClusterRuntime = class {
326
369
  /**
327
370
  * Stop the cluster: pause heartbeats, hand off assigned topics, remove
328
371
  * the worker record, and clean up lifecycle listeners. Idempotent.
372
+ * The .clear() calls after pause() are safe no-ops when pause already
373
+ * cleared the maps (the handoff path), but ensure a full teardown in the
374
+ * stop() path where callers expect every Set/Map to be empty afterwards.
329
375
  */
330
376
  stop() {
331
377
  if (!this.started && !this.suspended) return;
@@ -356,12 +402,10 @@ var WorkerClusterRuntime = class {
356
402
  };
357
403
  this.refreshRole(this.readWorkers());
358
404
  this.writeRecord(true);
359
- if (!this.storage) {
360
- for (const topic of this.subscribedTopics) {
361
- this.sendControl(this.workerId, "SUBSCRIBE", topic, this.rememberTopic(topic));
362
- }
363
- } else {
364
- for (const topic of this.subscribedTopics) this.writeSubscriber(this.rememberTopic(topic));
405
+ for (const topic of this.subscribedTopics) {
406
+ const topicKey = this.rememberTopic(topic);
407
+ if (!this.storage) this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
408
+ else this.writeSubscriber(topicKey);
365
409
  }
366
410
  this.reconcile();
367
411
  this.heartbeatHandle = this.environment.setInterval(() => {
@@ -412,8 +456,8 @@ var WorkerClusterRuntime = class {
412
456
  this.writeSubscriber(topicKey);
413
457
  const workers = this.readWorkers();
414
458
  const existingRoute = this.readRoute(topicKey);
415
- if (existingRoute && workers.some((worker) => worker.workerId === existingRoute.workerId)) {
416
- return existingRoute.workerId === this.workerId;
459
+ if (this.routeOwnerIsLive(existingRoute, workers)) {
460
+ return existingRoute?.workerId === this.workerId;
417
461
  }
418
462
  const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
419
463
  const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
@@ -427,22 +471,24 @@ var WorkerClusterRuntime = class {
427
471
  * subscribers remain, deletes the route so the owning Worker can unsubscribe.
428
472
  */
429
473
  unsubscribe(topic) {
430
- const topicKey = this.rememberTopic(topic);
431
474
  this.subscribedTopics.delete(topic);
432
- this.releaseSubscription(topic);
433
- if (!this.assignedTopics.has(topicKey)) this.knownTopics.delete(topicKey);
475
+ const topicKey = this.releaseSubscription(topic);
476
+ if (topicKey && !this.assignedTopics.has(topicKey)) this.knownTopics.delete(topicKey);
434
477
  }
435
- /** Remove this tab's subscriber record and, when it was the last one, delete the route. */
478
+ /** Remove this tab's subscriber record and, when it was the last one, delete
479
+ * the route. Returns the topicKey (so callers like `unsubscribe` can reuse
480
+ * it instead of re-hashing the topic to evict the reverse cache). */
436
481
  releaseSubscription(topic, notifyOwner = true) {
437
482
  const topicKey = this.rememberTopic(topic);
438
483
  this.removeStorage(this.subscriberStorageKey(topicKey, this.tabId));
439
484
  const route = this.readRoute(topicKey);
440
- if (!route) return;
485
+ if (!route) return topicKey;
441
486
  const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
442
487
  if (subscribers.length === 0) {
443
488
  this.removeStorage(this.routeStorageKey(topicKey));
444
489
  if (notifyOwner) this.sendControl(route.workerId, "UNSUBSCRIBE", topic, topicKey);
445
490
  }
491
+ return topicKey;
446
492
  }
447
493
  /** Transfer assigned topics to other active workers so subscribers are not orphaned during pause. */
448
494
  handoffAssignedTopics() {
@@ -451,7 +497,8 @@ var WorkerClusterRuntime = class {
451
497
  const activeWorkers = selectActiveWorkers(remainingWorkers, this.maxActiveWorkers);
452
498
  const projectedLoads = new Map(activeWorkers.map((worker) => [worker.workerId, worker.load]));
453
499
  for (const [topicKey, topic] of this.assignedTopics) {
454
- if (this.readRoute(topicKey)?.workerId !== this.workerId) continue;
500
+ const previous = this.readRoute(topicKey);
501
+ if (previous?.workerId !== this.workerId) continue;
455
502
  const subscribers = this.readSubscriberTabIds(topicKey, remainingWorkers);
456
503
  if (subscribers.length === 0) {
457
504
  this.removeStorage(this.routeStorageKey(topicKey));
@@ -462,19 +509,11 @@ var WorkerClusterRuntime = class {
462
509
  );
463
510
  if (!owner) continue;
464
511
  projectedLoads.set(owner.workerId, (projectedLoads.get(owner.workerId) ?? owner.load) + 1);
465
- const previous = this.readRoute(topicKey);
466
- this.writeRoute(topicKey, owner, previous?.workerId, (previous?.generation ?? 0) + 1);
467
- this.flushStorage();
468
512
  const generation = (previous?.generation ?? 0) + 1;
513
+ this.writeRoute(topicKey, owner, previous?.workerId, generation);
514
+ this.flushStorage();
469
515
  this.handlers.onControl("UNSUBSCRIBE", topic);
470
- this.send({
471
- type: "ROUTE_RELEASED",
472
- sourceWorkerId: this.workerId,
473
- targetWorkerId: owner.workerId,
474
- topic,
475
- topicKey,
476
- generation
477
- });
516
+ this.sendRouteReleased(owner.workerId, topic, topicKey, generation);
478
517
  }
479
518
  }
480
519
  /**
@@ -487,8 +526,15 @@ var WorkerClusterRuntime = class {
487
526
  const topicKey = this.rememberTopic(topic);
488
527
  const workers = this.readWorkers();
489
528
  const route = this.readRoute(topicKey);
490
- const target = route && workers.some((worker) => worker.workerId === route.workerId) ? route.workerId : this.workerId;
491
- return this.sendControl(target ?? this.workerId, "PUBLISH", topic, topicKey, data);
529
+ const target = this.routeOwnerIsLive(route, workers) ? route?.workerId ?? this.workerId : this.workerId;
530
+ return this.sendControl(target, "PUBLISH", topic, topicKey, data);
531
+ }
532
+ /** True when `route` exists and its owner worker is among `workers`.
533
+ * Shared by subscribe (skip re-assignment) and publish (route to owner).
534
+ * Intentionally returns a plain boolean (not a type guard) so the caller
535
+ * can still access `route?.generation` in the false branch. */
536
+ routeOwnerIsLive(route, workers) {
537
+ return Boolean(route && workers.some((worker) => worker.workerId === route.workerId));
492
538
  }
493
539
  /** Broadcast an event to every tab — used to fan out transport publications. */
494
540
  broadcastEvent(eventType, payload) {
@@ -501,7 +547,12 @@ var WorkerClusterRuntime = class {
501
547
  }
502
548
  /** True if this worker is among the active set (eligible to own topics). */
503
549
  isActiveWorker() {
504
- return selectActiveWorkers(this.readWorkers(), this.maxActiveWorkers).some(
550
+ return this.isActiveAmong(this.readWorkers());
551
+ }
552
+ /** True when this workerId is in the active subset of `workers`. Shared by
553
+ * isActiveWorker() and refreshRole() so both compute role identically. */
554
+ isActiveAmong(workers) {
555
+ return selectActiveWorkers(workers, this.maxActiveWorkers).some(
505
556
  (worker) => worker.workerId === this.workerId
506
557
  );
507
558
  }
@@ -511,19 +562,19 @@ var WorkerClusterRuntime = class {
511
562
  }
512
563
  /** Read-only snapshot of the cluster state (workers, routes, assignments). */
513
564
  getSnapshot() {
514
- const routes = listKeysSafe(this.storage, this.routePrefix).map((key) => this.storage ? readJson(this.storage, key) : null).filter((route) => Boolean(route)).map((route) => ({
515
- ...route,
516
- topic: this.knownTopics.get(route.topicKey) ?? null
517
- }));
565
+ const routes = this.storage ? readAllByPrefix(this.storage, this.routePrefix).map(({ value }) => ({
566
+ ...value,
567
+ topic: this.knownTopics.get(value.topicKey) ?? null
568
+ })) : [];
518
569
  return {
519
570
  coordinated: Boolean(this.storage && this.channel),
520
571
  suspended: this.suspended,
521
572
  currentWorker: { ...this.currentRecord },
522
573
  workers: this.readWorkers().map((worker) => ({ ...worker })),
523
574
  routes,
524
- subscribedTopics: [...this.subscribedTopics],
525
- assignedTopics: [...this.assignedTopics.values()],
526
- knownTopics: [...this.knownTopics.entries()].map(([topicKey, topic]) => ({ topicKey, topic }))
575
+ subscribedTopics: Array.from(this.subscribedTopics),
576
+ assignedTopics: Array.from(this.assignedTopics.values()),
577
+ knownTopics: Array.from(this.knownTopics.entries(), ([topicKey, topic]) => ({ topicKey, topic }))
527
578
  };
528
579
  }
529
580
  handlePageHide = () => this.pause();
@@ -560,24 +611,35 @@ var WorkerClusterRuntime = class {
560
611
  handleMessage = (event) => {
561
612
  const message = event.data;
562
613
  if (!message || message.sourceWorkerId === this.workerId) return;
563
- if (message.type === "CONTROL") return this.handleControlMessage(message);
564
- if (message.type === "ROUTE_RELEASED") return this.handleRouteReleasedMessage(message);
565
- if (message.type === "EVENT") {
566
- this.handlers.onEvent(message.eventType, message.payload, message.sourceWorkerId);
567
- return;
614
+ switch (message.type) {
615
+ case "CONTROL":
616
+ return this.handleControlMessage(message);
617
+ case "ROUTE_RELEASED":
618
+ return this.handleRouteReleasedMessage(message);
619
+ case "EVENT":
620
+ this.handlers.onEvent(message.eventType, message.payload, message.sourceWorkerId);
621
+ return;
622
+ case "REGISTRY":
623
+ default:
624
+ this.reconcile();
625
+ return;
568
626
  }
569
- this.reconcile();
570
627
  };
571
628
  /** Handle a point-to-point CONTROL message (SUBSCRIBE / UNSUBSCRIBE / PUBLISH). */
572
629
  handleControlMessage(message) {
573
630
  if (message.targetWorkerId !== this.workerId) return;
574
631
  this.rememberTopic(message.topic);
575
- if (message.action === "SUBSCRIBE") {
576
- this.assignedTopics.set(message.topicKey, message.topic);
577
- this.confirmRoute(message.topicKey);
578
- }
579
- if (message.action === "UNSUBSCRIBE") {
580
- if (this.releaseHandoffOnUnsubscribe(message)) return;
632
+ switch (message.action) {
633
+ case "SUBSCRIBE":
634
+ this.assignedTopics.set(message.topicKey, message.topic);
635
+ this.confirmRoute(message.topicKey);
636
+ break;
637
+ case "UNSUBSCRIBE":
638
+ if (this.releaseHandoffOnUnsubscribe(message)) return;
639
+ break;
640
+ case "PUBLISH":
641
+ default:
642
+ break;
581
643
  }
582
644
  this.handlers.onControl(message.action, message.topic, message.data);
583
645
  if (message.action !== "PUBLISH") this.updateLoad();
@@ -593,16 +655,21 @@ var WorkerClusterRuntime = class {
593
655
  const route = this.readRoute(message.topicKey);
594
656
  if (route?.handoffFromWorkerId !== this.workerId) return false;
595
657
  this.handlers.onControl("UNSUBSCRIBE", message.topic, void 0);
658
+ this.sendRouteReleased(route.workerId, message.topic, message.topicKey, route.generation);
659
+ this.updateLoad();
660
+ return true;
661
+ }
662
+ /** Post a ROUTE_RELEASED ACK to the new owner, carrying the current route
663
+ * generation so only the matching new owner may act on it. */
664
+ sendRouteReleased(targetWorkerId, topic, topicKey, generation) {
596
665
  this.send({
597
666
  type: "ROUTE_RELEASED",
598
667
  sourceWorkerId: this.workerId,
599
- targetWorkerId: route.workerId,
600
- topic: message.topic,
601
- topicKey: message.topicKey,
602
- generation: route.generation
668
+ targetWorkerId,
669
+ topic,
670
+ topicKey,
671
+ generation
603
672
  });
604
- this.updateLoad();
605
- return true;
606
673
  }
607
674
  /**
608
675
  * Accept a graceful handoff only when the route still points to this worker,
@@ -612,12 +679,18 @@ var WorkerClusterRuntime = class {
612
679
  handleRouteReleasedMessage(message) {
613
680
  if (message.targetWorkerId !== this.workerId) return;
614
681
  const route = this.readRoute(message.topicKey);
615
- if (!route || route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || route.generation < message.generation) return;
682
+ if (!route || this.isStaleRouteRelease(route, message)) return;
616
683
  this.assignedTopics.set(message.topicKey, message.topic);
617
684
  this.confirmRoute(message.topicKey);
618
685
  this.handlers.onControl("SUBSCRIBE", message.topic, void 0);
619
686
  this.updateLoad();
620
687
  }
688
+ /** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
689
+ * points to us, the release comes from the recorded previous owner, and
690
+ * the release generation is at least as new as ours. */
691
+ isStaleRouteRelease(route, message) {
692
+ return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || route.generation < message.generation;
693
+ }
621
694
  /** Full reconciliation cycle: workers, subscriptions, and assigned topics. */
622
695
  reconcile() {
623
696
  if (!this.started) return;
@@ -627,7 +700,9 @@ var WorkerClusterRuntime = class {
627
700
  this.reconcileAssignedTopics();
628
701
  this.updateLoad();
629
702
  }
630
- /** Prune stale workers/subscribers/routes and refresh role. Returns the live worker list. */
703
+ /** Prune stale workers/subscribers/routes and refresh role. Returns the live worker list.
704
+ * Subscribers are cleaned before routes so cleanupOrphanedRoutes sees the
705
+ * updated subscriber set when deciding whether a route is truly orphaned. */
631
706
  reconcileWorkers() {
632
707
  const workers = this.readWorkers();
633
708
  this.cleanupOrphanedSubscribers(workers);
@@ -668,19 +743,12 @@ var WorkerClusterRuntime = class {
668
743
  /** Drop assignments where the route no longer points to this worker. */
669
744
  reconcileAssignedTopics() {
670
745
  for (const [topicKey, topic] of [...this.assignedTopics]) {
671
- if (this.readRoute(topicKey)?.workerId === this.workerId) continue;
746
+ const route = this.readRoute(topicKey);
747
+ if (route?.workerId === this.workerId) continue;
672
748
  this.assignedTopics.delete(topicKey);
673
749
  this.handlers.onControl("UNSUBSCRIBE", topic, void 0);
674
- const route = this.readRoute(topicKey);
675
750
  if (route?.handoffFromWorkerId === this.workerId) {
676
- this.send({
677
- type: "ROUTE_RELEASED",
678
- sourceWorkerId: this.workerId,
679
- targetWorkerId: route.workerId,
680
- topic,
681
- topicKey,
682
- generation: route.generation
683
- });
751
+ this.sendRouteReleased(route.workerId, topic, topicKey, route.generation);
684
752
  }
685
753
  if (!this.subscribedTopics.has(topic)) this.knownTopics.delete(topicKey);
686
754
  }
@@ -692,11 +760,18 @@ var WorkerClusterRuntime = class {
692
760
  */
693
761
  sendControl(targetWorkerId, action, topic, topicKey, data) {
694
762
  if (targetWorkerId === this.workerId) {
695
- if (action === "SUBSCRIBE") {
696
- this.assignedTopics.set(topicKey, topic);
697
- this.confirmRoute(topicKey);
763
+ switch (action) {
764
+ case "SUBSCRIBE":
765
+ this.assignedTopics.set(topicKey, topic);
766
+ this.confirmRoute(topicKey);
767
+ break;
768
+ case "UNSUBSCRIBE":
769
+ this.assignedTopics.delete(topicKey);
770
+ break;
771
+ case "PUBLISH":
772
+ default:
773
+ break;
698
774
  }
699
- if (action === "UNSUBSCRIBE") this.assignedTopics.delete(topicKey);
700
775
  this.handlers.onControl(action, topic, data);
701
776
  if (action !== "PUBLISH") this.updateLoad();
702
777
  return true;
@@ -726,9 +801,7 @@ var WorkerClusterRuntime = class {
726
801
  if (!this.storage) return [this.currentRecord];
727
802
  const now = this.environment.now();
728
803
  const workers = [];
729
- for (const key of listKeys(this.storage, this.workerPrefix)) {
730
- const worker = readJson(this.storage, key);
731
- if (!worker) continue;
804
+ for (const { key, value: worker } of readAllByPrefix(this.storage, this.workerPrefix)) {
732
805
  if (worker.workerId !== this.workerId && now - worker.heartbeatAt > this.workerTtlMs) {
733
806
  this.removeStorage(key);
734
807
  continue;
@@ -740,44 +813,62 @@ var WorkerClusterRuntime = class {
740
813
  }
741
814
  /** Enumerate all tab IDs that have a subscriber record for `topicKey`. */
742
815
  readSubscriberTabIds(topicKey, workers) {
743
- if (!this.storage) return this.subscribedTopics.has(this.knownTopics.get(topicKey) ?? "") ? [this.tabId] : [];
816
+ if (!this.storage) {
817
+ const topic = this.knownTopics.get(topicKey);
818
+ return topic && this.subscribedTopics.has(topic) ? [this.tabId] : [];
819
+ }
744
820
  const activeTabIds = new Set(workers.map((worker) => worker.tabId));
745
821
  const subscribers = /* @__PURE__ */ new Set();
746
- for (const key of listKeys(this.storage, `${this.subscriberPrefix}${topicKey}:`)) {
747
- const record = readJson(this.storage, key);
748
- if (!record || !activeTabIds.has(record.tabId)) {
822
+ for (const { key, value: record } of readAllByPrefix(
823
+ this.storage,
824
+ `${this.subscriberPrefix}${topicKey}:`
825
+ )) {
826
+ if (!activeTabIds.has(record.tabId)) {
749
827
  this.removeStorage(key);
750
828
  continue;
751
829
  }
752
830
  subscribers.add(record.tabId);
753
831
  }
754
- return [...subscribers];
832
+ return Array.from(subscribers);
755
833
  }
756
834
  /** Read the current route for `topicKey`, returning null when no storage layer exists. */
757
835
  readRoute(topicKey) {
758
- if (!this.storage) {
759
- const topic = this.knownTopics.get(topicKey);
760
- return topic && (this.subscribedTopics.has(topic) || this.assignedTopics.has(topicKey)) ? {
761
- topicKey,
762
- workerId: this.workerId,
763
- tabId: this.tabId,
764
- updatedAt: this.environment.now(),
765
- generation: 1
766
- } : null;
767
- }
836
+ if (!this.storage) return this.buildLocalRoute(topicKey);
768
837
  return readJson(this.storage, this.routeStorageKey(topicKey));
769
838
  }
839
+ /** Synthesize a self-owned route when storage is unavailable (degraded mode).
840
+ * The plaintext topic must be recoverable from the knownTopics cache; a
841
+ * missing entry means we never subscribed to or were assigned the topic,
842
+ * so there is no route to report. */
843
+ buildLocalRoute(topicKey) {
844
+ const topic = this.knownTopics.get(topicKey);
845
+ if (!topic) return null;
846
+ if (!this.subscribedTopics.has(topic) && !this.assignedTopics.has(topicKey)) return null;
847
+ return {
848
+ topicKey,
849
+ workerId: this.workerId,
850
+ tabId: this.tabId,
851
+ updatedAt: this.environment.now(),
852
+ generation: 1
853
+ };
854
+ }
770
855
  /** Persist a route assignment, mapping `topicKey` to the owning Worker. */
771
856
  writeRoute(topicKey, owner, handoffFromWorkerId, generation = 1) {
772
857
  if (!this.storage) return;
773
- writeJson(this.storage, this.routeStorageKey(topicKey), {
858
+ writeJson(this.storage, this.routeStorageKey(topicKey), this.buildRouteRecord(topicKey, owner, handoffFromWorkerId, generation));
859
+ }
860
+ /** Construct a WorkerRoute record from the owner + handoff fields. Extracted
861
+ * so writeRoute and confirmRoute share the same shape; confirmedAt is added
862
+ * by confirmRoute via spread. */
863
+ buildRouteRecord(topicKey, owner, handoffFromWorkerId, generation) {
864
+ return {
774
865
  topicKey,
775
866
  workerId: owner.workerId,
776
867
  tabId: owner.tabId,
777
868
  updatedAt: this.environment.now(),
778
869
  generation,
779
870
  ...handoffFromWorkerId ? { handoffFromWorkerId } : {}
780
- });
871
+ };
781
872
  }
782
873
  /** Stamp a route as confirmed once the owning Worker has acknowledged the assignment. */
783
874
  confirmRoute(topicKey) {
@@ -793,9 +884,8 @@ var WorkerClusterRuntime = class {
793
884
  cleanupOrphanedRoutes(workers) {
794
885
  if (!this.storage) return;
795
886
  const now = this.environment.now();
796
- for (const key of listKeys(this.storage, this.routePrefix)) {
797
- const route = readJson(this.storage, key);
798
- if (!route || now - route.updatedAt <= this.workerTtlMs) continue;
887
+ for (const { key, value: route } of readAllByPrefix(this.storage, this.routePrefix)) {
888
+ if (now - route.updatedAt <= this.workerTtlMs) continue;
799
889
  if (this.readSubscriberTabIds(route.topicKey, workers).length > 0) continue;
800
890
  this.removeStorage(key);
801
891
  }
@@ -804,9 +894,8 @@ var WorkerClusterRuntime = class {
804
894
  cleanupOrphanedSubscribers(workers) {
805
895
  if (!this.storage) return;
806
896
  const activeTabIds = new Set(workers.map((worker) => worker.tabId));
807
- for (const key of listKeys(this.storage, this.subscriberPrefix)) {
808
- const record = readJson(this.storage, key);
809
- if (!record || !activeTabIds.has(record.tabId)) this.removeStorage(key);
897
+ for (const { key, value: record } of readAllByPrefix(this.storage, this.subscriberPrefix)) {
898
+ if (!activeTabIds.has(record.tabId)) this.removeStorage(key);
810
899
  }
811
900
  }
812
901
  /** Persist a subscriber record for this tab on `topicKey`. */
@@ -817,7 +906,12 @@ var WorkerClusterRuntime = class {
817
906
  updatedAt: this.environment.now()
818
907
  });
819
908
  }
820
- /** Persist the current worker record with an updated heartbeat timestamp. */
909
+ /** Persist the current worker record with an updated heartbeat timestamp.
910
+ * @param notify — when true, broadcast a REGISTRY nudge so peers reconcile
911
+ * immediately instead of waiting for the next heartbeat. False on the
912
+ * periodic heartbeat tick (peers will notice on their own heartbeat) to
913
+ * avoid a REGISTRY storm every 3 s; true on status/role changes that
914
+ * peers should observe promptly. */
821
915
  writeRecord(notify) {
822
916
  this.currentRecord = { ...this.currentRecord, heartbeatAt: this.environment.now() };
823
917
  if (this.storage) writeJson(this.storage, this.workerStorageKey(this.workerId), this.currentRecord);
@@ -829,7 +923,7 @@ var WorkerClusterRuntime = class {
829
923
  }
830
924
  /** Recompute whether this worker is active (eligible to own topics) or standby. Returns true when changed. */
831
925
  refreshRole(workers) {
832
- const role = selectActiveWorkers(workers, this.maxActiveWorkers).some((worker) => worker.workerId === this.workerId) ? "active" : "standby";
926
+ const role = this.isActiveAmong(workers) ? "active" : "standby";
833
927
  if (role === this.currentRecord.role) return false;
834
928
  this.currentRecord = { ...this.currentRecord, role };
835
929
  return true;
@@ -856,9 +950,10 @@ var WorkerClusterRuntime = class {
856
950
  const topicKey = createOpaqueKey(topic);
857
951
  this.knownTopics.set(topicKey, topic);
858
952
  if (this.knownTopics.size > MAX_KNOWN_TOPICS) {
859
- const oldest = this.knownTopics.keys().next().value;
860
- if (oldest !== void 0 && oldest !== topicKey && !this.assignedTopics.has(oldest)) {
861
- this.knownTopics.delete(oldest);
953
+ for (const candidate of this.knownTopics.keys()) {
954
+ if (candidate === topicKey || this.assignedTopics.has(candidate)) continue;
955
+ this.knownTopics.delete(candidate);
956
+ break;
862
957
  }
863
958
  }
864
959
  return topicKey;
@@ -883,9 +978,6 @@ var WorkerClusterRuntime = class {
883
978
  if (this.storage instanceof BatchingStorageWriter) this.storage.flush();
884
979
  }
885
980
  };
886
- function listKeysSafe(storage, prefix) {
887
- return storage ? listKeys(storage, prefix) : [];
888
- }
889
981
 
890
982
  // src/core/trace.ts
891
983
  var DEFAULT_METRICS_INTERVAL_MS = 5e3;
@@ -917,7 +1009,8 @@ var DataBusTraceReporter = class {
917
1009
  this.sink = options?.sink ?? (() => void 0);
918
1010
  this.now = now;
919
1011
  }
920
- /** Start the periodic metrics flush interval. No-op when mode is 'events'. */
1012
+ /** Start the periodic metrics flush interval. No-op when mode is 'events'
1013
+ * (no metrics to emit), when disabled, or when already running. */
921
1014
  start() {
922
1015
  if (!this.enabled || this.intervalHandle || this.mode === "events") return;
923
1016
  this.intervalStartedAt = this.now();
@@ -927,6 +1020,7 @@ var DataBusTraceReporter = class {
927
1020
  pause() {
928
1021
  if (this.intervalHandle) clearInterval(this.intervalHandle);
929
1022
  this.intervalHandle = null;
1023
+ this.intervalStartedAt = 0;
930
1024
  this.resetMetrics();
931
1025
  }
932
1026
  stop() {
@@ -939,7 +1033,7 @@ var DataBusTraceReporter = class {
939
1033
  }
940
1034
  /** Record that a message was received on `topic`; stores its timestamp for latency tracking. */
941
1035
  recordReceived(topic) {
942
- if (!this.enabled || this.mode === "events") return;
1036
+ if (!this.metricsActive) return;
943
1037
  this.received += 1;
944
1038
  this.topics.add(topic);
945
1039
  const queue = this.receivedAt.get(topic);
@@ -957,7 +1051,7 @@ var DataBusTraceReporter = class {
957
1051
  * with a stale receive timestamp.
958
1052
  */
959
1053
  recordDiscarded(topic) {
960
- if (!this.enabled || this.mode === "events") return;
1054
+ if (!this.metricsActive) return;
961
1055
  const queue = this.receivedAt.get(topic);
962
1056
  if (!queue) return;
963
1057
  queue.shift();
@@ -970,7 +1064,7 @@ var DataBusTraceReporter = class {
970
1064
  * as dispatched but do not produce a latency sample.
971
1065
  */
972
1066
  recordDispatched(topic) {
973
- if (!this.enabled || this.mode === "events") return;
1067
+ if (!this.metricsActive) return;
974
1068
  this.dispatched += 1;
975
1069
  this.topics.add(topic);
976
1070
  const queue = this.receivedAt.get(topic);
@@ -983,9 +1077,15 @@ var DataBusTraceReporter = class {
983
1077
  this.latencyBuckets[bucketIndex] = (this.latencyBuckets[bucketIndex] ?? 0) + 1;
984
1078
  this.latencySumMs += delayMs;
985
1079
  }
1080
+ /** True when metrics recording is active: enabled and mode includes metrics.
1081
+ * Extracted so the four record / flush methods share one guard expression
1082
+ * instead of repeating `!this.enabled || this.mode === 'events'` at each. */
1083
+ get metricsActive() {
1084
+ return this.enabled && this.mode !== "events";
1085
+ }
986
1086
  /** Emit the accumulated metrics snapshot if the interval is active. */
987
1087
  flush() {
988
- if (!this.enabled || this.mode === "events") return;
1088
+ if (!this.metricsActive) return;
989
1089
  this.flushNow();
990
1090
  }
991
1091
  flushNow() {
@@ -1101,16 +1201,25 @@ var CrossTabDataBus = class _CrossTabDataBus {
1101
1201
  // The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
1102
1202
  // control message — meaning the owning Worker has delegated the action to us.
1103
1203
  onControl: (action, topic, data) => {
1104
- if (action === "SUBSCRIBE") {
1105
- if (this.subscribeTransport(topic)) this.traceSubscription("subscribe", topic);
1204
+ switch (action) {
1205
+ case "SUBSCRIBE":
1206
+ if (this.subscribeTransport(topic)) this.traceSubscription("subscribe", topic);
1207
+ break;
1208
+ case "UNSUBSCRIBE":
1209
+ if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
1210
+ break;
1211
+ case "PUBLISH":
1212
+ this.runTransport(() => this.transport.publish(topic, data));
1213
+ break;
1214
+ default:
1215
+ break;
1106
1216
  }
1107
- if (action === "UNSUBSCRIBE") {
1108
- if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
1109
- }
1110
- if (action === "PUBLISH") this.runTransport(() => this.transport.publish(topic, data));
1111
1217
  },
1112
1218
  // The cluster calls `onEvent` when a publication broadcast arrives from
1113
- // another tab. Dispatch locally if we have subscribers.
1219
+ // another tab. Dispatch locally if we have subscribers. The payload is
1220
+ // typed `unknown` at the cluster boundary (the cluster is transport-
1221
+ // agnostic); here we narrow it to DataBusMessage — the sender is our
1222
+ // own broadcastEvent call, which always posts a DataBusMessage.
1114
1223
  onEvent: (eventType, payload) => {
1115
1224
  if (eventType !== PUBLICATION_EVENT) return;
1116
1225
  const message = payload;
@@ -1160,8 +1269,8 @@ var CrossTabDataBus = class _CrossTabDataBus {
1160
1269
  type: "coordination",
1161
1270
  coordinated: snapshot.coordinated,
1162
1271
  activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
1163
- workers: snapshot.workers.map((w) => `${w.workerId}|${w.status}|load=${w.load}|tab=${w.tabId}`),
1164
- routes: snapshot.routes.map((r) => `${r.topicKey}@${r.workerId}|confirmed=${r.confirmedAt !== void 0}`)
1272
+ workers: snapshot.workers.map(formatWorkerTrace),
1273
+ routes: snapshot.routes.map(formatRouteTrace)
1165
1274
  });
1166
1275
  void opening.then(
1167
1276
  () => {
@@ -1199,7 +1308,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1199
1308
  }).catch((error) => {
1200
1309
  if (stopClusterOnFailure) this.started = false;
1201
1310
  if (!this.pendingStop) {
1202
- this.pendingStop = Promise.resolve().then(() => this.transport.stop()).catch((stopError) => this.reportError(stopError));
1311
+ this.pendingStop = this.createStopPromise();
1203
1312
  }
1204
1313
  this.updateStatus("error");
1205
1314
  this.reportError(error);
@@ -1246,7 +1355,10 @@ var CrossTabDataBus = class _CrossTabDataBus {
1246
1355
  if (wasUnused) this.cluster.subscribe(topic);
1247
1356
  return () => this.unsubscribe(topic, handler);
1248
1357
  }
1249
- /** Remove a specific handler, or all handlers for `topic`. */
1358
+ /** Remove a specific handler, or all handlers for `topic`.
1359
+ * When `handler` is omitted, clears every handler for the topic — the
1360
+ * caller used the `unsubscribe(topic)` form expecting a full teardown.
1361
+ * The cluster is only notified on the n→0 transition (handlers.size === 0). */
1250
1362
  unsubscribe(topic, handler) {
1251
1363
  const handlers = this.topicHandlers.get(topic);
1252
1364
  if (!handlers) return;
@@ -1284,7 +1396,9 @@ var CrossTabDataBus = class _CrossTabDataBus {
1284
1396
  getStatus() {
1285
1397
  return this.status;
1286
1398
  }
1287
- /** Snapshot of the cluster state (workers, routes, assignments). */
1399
+ /** Snapshot of the cluster state (workers, routes, assignments).
1400
+ * For diagnostics only — the returned object is a shallow copy but
1401
+ * nested arrays are snapshots at call time. */
1288
1402
  getClusterSnapshot() {
1289
1403
  return this.cluster.getSnapshot();
1290
1404
  }
@@ -1338,13 +1452,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1338
1452
  /** Deliver a message to every local handler registered for its topic. */
1339
1453
  dispatch(message) {
1340
1454
  this.trace.recordDispatched(message.topic);
1341
- for (const handler of this.topicHandlers.get(message.topic) ?? []) {
1342
- try {
1343
- handler(message);
1344
- } catch (error) {
1345
- this.reportError(error);
1346
- }
1347
- }
1455
+ this.invokeHandlers(this.topicHandlers.get(message.topic) ?? [], (handler) => handler(message));
1348
1456
  }
1349
1457
  /**
1350
1458
  * Propagate a status change to the cluster, trace, and all registered
@@ -1370,25 +1478,11 @@ var CrossTabDataBus = class _CrossTabDataBus {
1370
1478
  }, _CrossTabDataBus.RECOVERY_COOLDOWN_MS);
1371
1479
  }
1372
1480
  }
1373
- for (const handler of this.statusHandlers) {
1374
- try {
1375
- handler(status);
1376
- } catch (error) {
1377
- this.reportError(error);
1378
- }
1379
- }
1481
+ this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
1380
1482
  }
1381
1483
  reportError(error) {
1382
1484
  this.trace.event({ type: "error", source: "transport" });
1383
- for (const handler of this.errorHandlers) {
1384
- try {
1385
- handler(error);
1386
- } catch (handlerError) {
1387
- if (typeof console !== "undefined" && typeof console.warn === "function") {
1388
- console.warn("[cross-tab-worker-databus] error handler threw:", handlerError);
1389
- }
1390
- }
1391
- }
1485
+ this.invokeHandlers(this.errorHandlers, (handler) => handler(error), "error handler");
1392
1486
  }
1393
1487
  traceSubscription(action, topic) {
1394
1488
  this.trace.event({
@@ -1410,6 +1504,26 @@ var CrossTabDataBus = class _CrossTabDataBus {
1410
1504
  this.runTransport(() => this.transport.unsubscribe(topic));
1411
1505
  return true;
1412
1506
  }
1507
+ /** Invoke `callback` for each item in `handlers`, isolating a throwing
1508
+ * callback so the remaining ones still run. Dispatch/status handler failures
1509
+ * are routed to `reportError` (which surfaces them to error subscribers);
1510
+ * error-handler failures are logged to the console to avoid infinite
1511
+ * recursion through reportError itself. */
1512
+ invokeHandlers(handlers, callback, label = "dispatch") {
1513
+ for (const handler of handlers) {
1514
+ try {
1515
+ callback(handler);
1516
+ } catch (error) {
1517
+ if (label === "error handler") {
1518
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
1519
+ console.warn("[cross-tab-worker-databus] error handler threw:", error);
1520
+ }
1521
+ } else {
1522
+ this.reportError(error);
1523
+ }
1524
+ }
1525
+ }
1526
+ }
1413
1527
  /**
1414
1528
  * Suspend the transport when the tab goes hidden. Stops the transport and
1415
1529
  * clears subscription state so it will be re-established on resume.
@@ -1426,6 +1540,11 @@ var CrossTabDataBus = class _CrossTabDataBus {
1426
1540
  this.startPromise = stopping;
1427
1541
  this.pendingStop = stopping;
1428
1542
  }
1543
+ /** Create an immediate stop promise (no prior chain). Used by openTransport's
1544
+ * failure path where there is no in-flight start to wait for. */
1545
+ createStopPromise() {
1546
+ return Promise.resolve().then(() => this.transport.stop()).catch((stopError) => this.reportError(stopError));
1547
+ }
1429
1548
  /**
1430
1549
  * Resume the transport when the tab becomes visible again, or recover from a
1431
1550
  * runtime transport failure. Re-opens the transport with the stored active
@@ -1500,6 +1619,12 @@ var CrossTabDataBus = class _CrossTabDataBus {
1500
1619
  void starting.catch(() => void 0);
1501
1620
  }
1502
1621
  };
1622
+ function formatWorkerTrace(worker) {
1623
+ return `${worker.workerId}|${worker.status}|load=${worker.load}|tab=${worker.tabId}`;
1624
+ }
1625
+ function formatRouteTrace(route) {
1626
+ return `${route.topicKey}@${route.workerId}|confirmed=${route.confirmedAt !== void 0}`;
1627
+ }
1503
1628
 
1504
1629
  // src/worker-mode.ts
1505
1630
  function selectWorkerBackend(mode, availability = {}) {
@@ -1524,4 +1649,4 @@ export {
1524
1649
  CrossTabDataBus,
1525
1650
  selectWorkerBackend
1526
1651
  };
1527
- //# sourceMappingURL=chunk-GABYBK7I.js.map
1652
+ //# sourceMappingURL=chunk-LBXREMZA.js.map