cross-tab-worker-databus 0.1.2 → 0.2.1

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 (47) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +32 -0
  3. package/README.zh.md +30 -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 +11 -3
  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 +69 -26
  11. package/dist/centrifuge.js.map +2 -2
  12. package/dist/centrifuge.shared.worker.js +73 -25
  13. package/dist/centrifuge.shared.worker.js.map +2 -2
  14. package/dist/centrifuge.worker.js +41 -12
  15. package/dist/centrifuge.worker.js.map +2 -2
  16. package/dist/{chunk-53INHVYO.js → chunk-LBXREMZA.js} +263 -158
  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 +12 -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 +21 -6
  37. package/dist/workers/port-reaper.d.ts.map +1 -1
  38. package/docs/README.md +1 -0
  39. package/docs/architecture.md +18 -8
  40. package/docs/getting-started.md +3 -0
  41. package/docs/transports.md +145 -0
  42. package/docs/zh/README.md +1 -0
  43. package/docs/zh/architecture.md +18 -8
  44. package/docs/zh/getting-started.md +3 -0
  45. package/docs/zh/transports.md +133 -0
  46. package/package.json +29 -6
  47. package/dist/chunk-53INHVYO.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) {
@@ -146,6 +160,9 @@ var BatchingStorageWriter = class {
146
160
  flushScheduled = false;
147
161
  retryHandle = null;
148
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. */
149
166
  get pendingSize() {
150
167
  return this.pending.size;
151
168
  }
@@ -180,7 +197,7 @@ var BatchingStorageWriter = class {
180
197
  flush() {
181
198
  this.flushScheduled = false;
182
199
  this.cancelRetry();
183
- for (const [key, value] of [...this.pending]) {
200
+ for (const [key, value] of Array.from(this.pending)) {
184
201
  try {
185
202
  if (value === null) this.storage.removeItem(key);
186
203
  else this.storage.setItem(key, value);
@@ -217,10 +234,12 @@ var BatchingStorageWriter = class {
217
234
  if (value === null) keys.delete(key);
218
235
  else keys.add(key);
219
236
  }
220
- return [...keys];
237
+ return Array.from(keys);
221
238
  }
222
239
  // Coalesce all synchronous writes within one task into a single microtask
223
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.
224
243
  scheduleFlush() {
225
244
  if (this.flushScheduled) return;
226
245
  this.flushScheduled = true;
@@ -231,6 +250,9 @@ var BatchingStorageWriter = class {
231
250
  if (typeof queueMicrotask === "function") queueMicrotask(flush);
232
251
  else setTimeout(flush, 0);
233
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.
234
256
  scheduleRetry() {
235
257
  if (this.retryHandle !== null) return;
236
258
  this.retryHandle = setTimeout(() => {
@@ -275,6 +297,9 @@ function listKeys(storage, prefix) {
275
297
  return [];
276
298
  }
277
299
  }
300
+ function readAllByPrefix(storage, prefix) {
301
+ return listKeys(storage, prefix).map((key) => ({ key, value: readJson(storage, key) })).filter((entry) => entry.value !== null);
302
+ }
278
303
  var WorkerClusterRuntime = class {
279
304
  tabId;
280
305
  workerId;
@@ -344,6 +369,9 @@ var WorkerClusterRuntime = class {
344
369
  /**
345
370
  * Stop the cluster: pause heartbeats, hand off assigned topics, remove
346
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.
347
375
  */
348
376
  stop() {
349
377
  if (!this.started && !this.suspended) return;
@@ -374,12 +402,10 @@ var WorkerClusterRuntime = class {
374
402
  };
375
403
  this.refreshRole(this.readWorkers());
376
404
  this.writeRecord(true);
377
- if (!this.storage) {
378
- for (const topic of this.subscribedTopics) {
379
- this.sendControl(this.workerId, "SUBSCRIBE", topic, this.rememberTopic(topic));
380
- }
381
- } else {
382
- 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);
383
409
  }
384
410
  this.reconcile();
385
411
  this.heartbeatHandle = this.environment.setInterval(() => {
@@ -430,8 +456,8 @@ var WorkerClusterRuntime = class {
430
456
  this.writeSubscriber(topicKey);
431
457
  const workers = this.readWorkers();
432
458
  const existingRoute = this.readRoute(topicKey);
433
- if (existingRoute && workers.some((worker) => worker.workerId === existingRoute.workerId)) {
434
- return existingRoute.workerId === this.workerId;
459
+ if (this.routeOwnerIsLive(existingRoute, workers)) {
460
+ return existingRoute?.workerId === this.workerId;
435
461
  }
436
462
  const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
437
463
  const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
@@ -445,22 +471,24 @@ var WorkerClusterRuntime = class {
445
471
  * subscribers remain, deletes the route so the owning Worker can unsubscribe.
446
472
  */
447
473
  unsubscribe(topic) {
448
- const topicKey = this.rememberTopic(topic);
449
474
  this.subscribedTopics.delete(topic);
450
- this.releaseSubscription(topic);
451
- 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);
452
477
  }
453
- /** 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). */
454
481
  releaseSubscription(topic, notifyOwner = true) {
455
482
  const topicKey = this.rememberTopic(topic);
456
483
  this.removeStorage(this.subscriberStorageKey(topicKey, this.tabId));
457
484
  const route = this.readRoute(topicKey);
458
- if (!route) return;
485
+ if (!route) return topicKey;
459
486
  const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
460
487
  if (subscribers.length === 0) {
461
488
  this.removeStorage(this.routeStorageKey(topicKey));
462
489
  if (notifyOwner) this.sendControl(route.workerId, "UNSUBSCRIBE", topic, topicKey);
463
490
  }
491
+ return topicKey;
464
492
  }
465
493
  /** Transfer assigned topics to other active workers so subscribers are not orphaned during pause. */
466
494
  handoffAssignedTopics() {
@@ -469,7 +497,8 @@ var WorkerClusterRuntime = class {
469
497
  const activeWorkers = selectActiveWorkers(remainingWorkers, this.maxActiveWorkers);
470
498
  const projectedLoads = new Map(activeWorkers.map((worker) => [worker.workerId, worker.load]));
471
499
  for (const [topicKey, topic] of this.assignedTopics) {
472
- if (this.readRoute(topicKey)?.workerId !== this.workerId) continue;
500
+ const previous = this.readRoute(topicKey);
501
+ if (previous?.workerId !== this.workerId) continue;
473
502
  const subscribers = this.readSubscriberTabIds(topicKey, remainingWorkers);
474
503
  if (subscribers.length === 0) {
475
504
  this.removeStorage(this.routeStorageKey(topicKey));
@@ -480,19 +509,11 @@ var WorkerClusterRuntime = class {
480
509
  );
481
510
  if (!owner) continue;
482
511
  projectedLoads.set(owner.workerId, (projectedLoads.get(owner.workerId) ?? owner.load) + 1);
483
- const previous = this.readRoute(topicKey);
484
- this.writeRoute(topicKey, owner, previous?.workerId, (previous?.generation ?? 0) + 1);
485
- this.flushStorage();
486
512
  const generation = (previous?.generation ?? 0) + 1;
513
+ this.writeRoute(topicKey, owner, previous?.workerId, generation);
514
+ this.flushStorage();
487
515
  this.handlers.onControl("UNSUBSCRIBE", topic);
488
- this.send({
489
- type: "ROUTE_RELEASED",
490
- sourceWorkerId: this.workerId,
491
- targetWorkerId: owner.workerId,
492
- topic,
493
- topicKey,
494
- generation
495
- });
516
+ this.sendRouteReleased(owner.workerId, topic, topicKey, generation);
496
517
  }
497
518
  }
498
519
  /**
@@ -505,8 +526,15 @@ var WorkerClusterRuntime = class {
505
526
  const topicKey = this.rememberTopic(topic);
506
527
  const workers = this.readWorkers();
507
528
  const route = this.readRoute(topicKey);
508
- const target = route && workers.some((worker) => worker.workerId === route.workerId) ? route.workerId : this.workerId;
509
- 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));
510
538
  }
511
539
  /** Broadcast an event to every tab — used to fan out transport publications. */
512
540
  broadcastEvent(eventType, payload) {
@@ -519,7 +547,12 @@ var WorkerClusterRuntime = class {
519
547
  }
520
548
  /** True if this worker is among the active set (eligible to own topics). */
521
549
  isActiveWorker() {
522
- 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(
523
556
  (worker) => worker.workerId === this.workerId
524
557
  );
525
558
  }
@@ -529,19 +562,19 @@ var WorkerClusterRuntime = class {
529
562
  }
530
563
  /** Read-only snapshot of the cluster state (workers, routes, assignments). */
531
564
  getSnapshot() {
532
- const routes = listKeysSafe(this.storage, this.routePrefix).map((key) => this.storage ? readJson(this.storage, key) : null).filter((route) => Boolean(route)).map((route) => ({
533
- ...route,
534
- topic: this.knownTopics.get(route.topicKey) ?? null
535
- }));
565
+ const routes = this.storage ? readAllByPrefix(this.storage, this.routePrefix).map(({ value }) => ({
566
+ ...value,
567
+ topic: this.knownTopics.get(value.topicKey) ?? null
568
+ })) : [];
536
569
  return {
537
570
  coordinated: Boolean(this.storage && this.channel),
538
571
  suspended: this.suspended,
539
572
  currentWorker: { ...this.currentRecord },
540
573
  workers: this.readWorkers().map((worker) => ({ ...worker })),
541
574
  routes,
542
- subscribedTopics: [...this.subscribedTopics],
543
- assignedTopics: [...this.assignedTopics.values()],
544
- 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 }))
545
578
  };
546
579
  }
547
580
  handlePageHide = () => this.pause();
@@ -578,24 +611,35 @@ var WorkerClusterRuntime = class {
578
611
  handleMessage = (event) => {
579
612
  const message = event.data;
580
613
  if (!message || message.sourceWorkerId === this.workerId) return;
581
- if (message.type === "CONTROL") return this.handleControlMessage(message);
582
- if (message.type === "ROUTE_RELEASED") return this.handleRouteReleasedMessage(message);
583
- if (message.type === "EVENT") {
584
- this.handlers.onEvent(message.eventType, message.payload, message.sourceWorkerId);
585
- 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;
586
626
  }
587
- this.reconcile();
588
627
  };
589
628
  /** Handle a point-to-point CONTROL message (SUBSCRIBE / UNSUBSCRIBE / PUBLISH). */
590
629
  handleControlMessage(message) {
591
630
  if (message.targetWorkerId !== this.workerId) return;
592
631
  this.rememberTopic(message.topic);
593
- if (message.action === "SUBSCRIBE") {
594
- this.assignedTopics.set(message.topicKey, message.topic);
595
- this.confirmRoute(message.topicKey);
596
- }
597
- if (message.action === "UNSUBSCRIBE") {
598
- 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;
599
643
  }
600
644
  this.handlers.onControl(message.action, message.topic, message.data);
601
645
  if (message.action !== "PUBLISH") this.updateLoad();
@@ -611,16 +655,21 @@ var WorkerClusterRuntime = class {
611
655
  const route = this.readRoute(message.topicKey);
612
656
  if (route?.handoffFromWorkerId !== this.workerId) return false;
613
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) {
614
665
  this.send({
615
666
  type: "ROUTE_RELEASED",
616
667
  sourceWorkerId: this.workerId,
617
- targetWorkerId: route.workerId,
618
- topic: message.topic,
619
- topicKey: message.topicKey,
620
- generation: route.generation
668
+ targetWorkerId,
669
+ topic,
670
+ topicKey,
671
+ generation
621
672
  });
622
- this.updateLoad();
623
- return true;
624
673
  }
625
674
  /**
626
675
  * Accept a graceful handoff only when the route still points to this worker,
@@ -630,12 +679,18 @@ var WorkerClusterRuntime = class {
630
679
  handleRouteReleasedMessage(message) {
631
680
  if (message.targetWorkerId !== this.workerId) return;
632
681
  const route = this.readRoute(message.topicKey);
633
- if (!route || route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || route.generation < message.generation) return;
682
+ if (!route || this.isStaleRouteRelease(route, message)) return;
634
683
  this.assignedTopics.set(message.topicKey, message.topic);
635
684
  this.confirmRoute(message.topicKey);
636
685
  this.handlers.onControl("SUBSCRIBE", message.topic, void 0);
637
686
  this.updateLoad();
638
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
+ }
639
694
  /** Full reconciliation cycle: workers, subscriptions, and assigned topics. */
640
695
  reconcile() {
641
696
  if (!this.started) return;
@@ -645,7 +700,9 @@ var WorkerClusterRuntime = class {
645
700
  this.reconcileAssignedTopics();
646
701
  this.updateLoad();
647
702
  }
648
- /** 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. */
649
706
  reconcileWorkers() {
650
707
  const workers = this.readWorkers();
651
708
  this.cleanupOrphanedSubscribers(workers);
@@ -686,19 +743,12 @@ var WorkerClusterRuntime = class {
686
743
  /** Drop assignments where the route no longer points to this worker. */
687
744
  reconcileAssignedTopics() {
688
745
  for (const [topicKey, topic] of [...this.assignedTopics]) {
689
- if (this.readRoute(topicKey)?.workerId === this.workerId) continue;
746
+ const route = this.readRoute(topicKey);
747
+ if (route?.workerId === this.workerId) continue;
690
748
  this.assignedTopics.delete(topicKey);
691
749
  this.handlers.onControl("UNSUBSCRIBE", topic, void 0);
692
- const route = this.readRoute(topicKey);
693
750
  if (route?.handoffFromWorkerId === this.workerId) {
694
- this.send({
695
- type: "ROUTE_RELEASED",
696
- sourceWorkerId: this.workerId,
697
- targetWorkerId: route.workerId,
698
- topic,
699
- topicKey,
700
- generation: route.generation
701
- });
751
+ this.sendRouteReleased(route.workerId, topic, topicKey, route.generation);
702
752
  }
703
753
  if (!this.subscribedTopics.has(topic)) this.knownTopics.delete(topicKey);
704
754
  }
@@ -710,11 +760,18 @@ var WorkerClusterRuntime = class {
710
760
  */
711
761
  sendControl(targetWorkerId, action, topic, topicKey, data) {
712
762
  if (targetWorkerId === this.workerId) {
713
- if (action === "SUBSCRIBE") {
714
- this.assignedTopics.set(topicKey, topic);
715
- 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;
716
774
  }
717
- if (action === "UNSUBSCRIBE") this.assignedTopics.delete(topicKey);
718
775
  this.handlers.onControl(action, topic, data);
719
776
  if (action !== "PUBLISH") this.updateLoad();
720
777
  return true;
@@ -744,9 +801,7 @@ var WorkerClusterRuntime = class {
744
801
  if (!this.storage) return [this.currentRecord];
745
802
  const now = this.environment.now();
746
803
  const workers = [];
747
- for (const key of listKeys(this.storage, this.workerPrefix)) {
748
- const worker = readJson(this.storage, key);
749
- if (!worker) continue;
804
+ for (const { key, value: worker } of readAllByPrefix(this.storage, this.workerPrefix)) {
750
805
  if (worker.workerId !== this.workerId && now - worker.heartbeatAt > this.workerTtlMs) {
751
806
  this.removeStorage(key);
752
807
  continue;
@@ -758,44 +813,62 @@ var WorkerClusterRuntime = class {
758
813
  }
759
814
  /** Enumerate all tab IDs that have a subscriber record for `topicKey`. */
760
815
  readSubscriberTabIds(topicKey, workers) {
761
- 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
+ }
762
820
  const activeTabIds = new Set(workers.map((worker) => worker.tabId));
763
821
  const subscribers = /* @__PURE__ */ new Set();
764
- for (const key of listKeys(this.storage, `${this.subscriberPrefix}${topicKey}:`)) {
765
- const record = readJson(this.storage, key);
766
- 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)) {
767
827
  this.removeStorage(key);
768
828
  continue;
769
829
  }
770
830
  subscribers.add(record.tabId);
771
831
  }
772
- return [...subscribers];
832
+ return Array.from(subscribers);
773
833
  }
774
834
  /** Read the current route for `topicKey`, returning null when no storage layer exists. */
775
835
  readRoute(topicKey) {
776
- if (!this.storage) {
777
- const topic = this.knownTopics.get(topicKey);
778
- return topic && (this.subscribedTopics.has(topic) || this.assignedTopics.has(topicKey)) ? {
779
- topicKey,
780
- workerId: this.workerId,
781
- tabId: this.tabId,
782
- updatedAt: this.environment.now(),
783
- generation: 1
784
- } : null;
785
- }
836
+ if (!this.storage) return this.buildLocalRoute(topicKey);
786
837
  return readJson(this.storage, this.routeStorageKey(topicKey));
787
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
+ }
788
855
  /** Persist a route assignment, mapping `topicKey` to the owning Worker. */
789
856
  writeRoute(topicKey, owner, handoffFromWorkerId, generation = 1) {
790
857
  if (!this.storage) return;
791
- 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 {
792
865
  topicKey,
793
866
  workerId: owner.workerId,
794
867
  tabId: owner.tabId,
795
868
  updatedAt: this.environment.now(),
796
869
  generation,
797
870
  ...handoffFromWorkerId ? { handoffFromWorkerId } : {}
798
- });
871
+ };
799
872
  }
800
873
  /** Stamp a route as confirmed once the owning Worker has acknowledged the assignment. */
801
874
  confirmRoute(topicKey) {
@@ -811,9 +884,8 @@ var WorkerClusterRuntime = class {
811
884
  cleanupOrphanedRoutes(workers) {
812
885
  if (!this.storage) return;
813
886
  const now = this.environment.now();
814
- for (const key of listKeys(this.storage, this.routePrefix)) {
815
- const route = readJson(this.storage, key);
816
- 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;
817
889
  if (this.readSubscriberTabIds(route.topicKey, workers).length > 0) continue;
818
890
  this.removeStorage(key);
819
891
  }
@@ -822,9 +894,8 @@ var WorkerClusterRuntime = class {
822
894
  cleanupOrphanedSubscribers(workers) {
823
895
  if (!this.storage) return;
824
896
  const activeTabIds = new Set(workers.map((worker) => worker.tabId));
825
- for (const key of listKeys(this.storage, this.subscriberPrefix)) {
826
- const record = readJson(this.storage, key);
827
- 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);
828
899
  }
829
900
  }
830
901
  /** Persist a subscriber record for this tab on `topicKey`. */
@@ -835,7 +906,12 @@ var WorkerClusterRuntime = class {
835
906
  updatedAt: this.environment.now()
836
907
  });
837
908
  }
838
- /** 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. */
839
915
  writeRecord(notify) {
840
916
  this.currentRecord = { ...this.currentRecord, heartbeatAt: this.environment.now() };
841
917
  if (this.storage) writeJson(this.storage, this.workerStorageKey(this.workerId), this.currentRecord);
@@ -847,7 +923,7 @@ var WorkerClusterRuntime = class {
847
923
  }
848
924
  /** Recompute whether this worker is active (eligible to own topics) or standby. Returns true when changed. */
849
925
  refreshRole(workers) {
850
- const role = selectActiveWorkers(workers, this.maxActiveWorkers).some((worker) => worker.workerId === this.workerId) ? "active" : "standby";
926
+ const role = this.isActiveAmong(workers) ? "active" : "standby";
851
927
  if (role === this.currentRecord.role) return false;
852
928
  this.currentRecord = { ...this.currentRecord, role };
853
929
  return true;
@@ -902,9 +978,6 @@ var WorkerClusterRuntime = class {
902
978
  if (this.storage instanceof BatchingStorageWriter) this.storage.flush();
903
979
  }
904
980
  };
905
- function listKeysSafe(storage, prefix) {
906
- return storage ? listKeys(storage, prefix) : [];
907
- }
908
981
 
909
982
  // src/core/trace.ts
910
983
  var DEFAULT_METRICS_INTERVAL_MS = 5e3;
@@ -936,7 +1009,8 @@ var DataBusTraceReporter = class {
936
1009
  this.sink = options?.sink ?? (() => void 0);
937
1010
  this.now = now;
938
1011
  }
939
- /** 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. */
940
1014
  start() {
941
1015
  if (!this.enabled || this.intervalHandle || this.mode === "events") return;
942
1016
  this.intervalStartedAt = this.now();
@@ -959,7 +1033,7 @@ var DataBusTraceReporter = class {
959
1033
  }
960
1034
  /** Record that a message was received on `topic`; stores its timestamp for latency tracking. */
961
1035
  recordReceived(topic) {
962
- if (!this.enabled || this.mode === "events") return;
1036
+ if (!this.metricsActive) return;
963
1037
  this.received += 1;
964
1038
  this.topics.add(topic);
965
1039
  const queue = this.receivedAt.get(topic);
@@ -977,7 +1051,7 @@ var DataBusTraceReporter = class {
977
1051
  * with a stale receive timestamp.
978
1052
  */
979
1053
  recordDiscarded(topic) {
980
- if (!this.enabled || this.mode === "events") return;
1054
+ if (!this.metricsActive) return;
981
1055
  const queue = this.receivedAt.get(topic);
982
1056
  if (!queue) return;
983
1057
  queue.shift();
@@ -990,7 +1064,7 @@ var DataBusTraceReporter = class {
990
1064
  * as dispatched but do not produce a latency sample.
991
1065
  */
992
1066
  recordDispatched(topic) {
993
- if (!this.enabled || this.mode === "events") return;
1067
+ if (!this.metricsActive) return;
994
1068
  this.dispatched += 1;
995
1069
  this.topics.add(topic);
996
1070
  const queue = this.receivedAt.get(topic);
@@ -1003,9 +1077,15 @@ var DataBusTraceReporter = class {
1003
1077
  this.latencyBuckets[bucketIndex] = (this.latencyBuckets[bucketIndex] ?? 0) + 1;
1004
1078
  this.latencySumMs += delayMs;
1005
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
+ }
1006
1086
  /** Emit the accumulated metrics snapshot if the interval is active. */
1007
1087
  flush() {
1008
- if (!this.enabled || this.mode === "events") return;
1088
+ if (!this.metricsActive) return;
1009
1089
  this.flushNow();
1010
1090
  }
1011
1091
  flushNow() {
@@ -1121,16 +1201,25 @@ var CrossTabDataBus = class _CrossTabDataBus {
1121
1201
  // The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
1122
1202
  // control message — meaning the owning Worker has delegated the action to us.
1123
1203
  onControl: (action, topic, data) => {
1124
- if (action === "SUBSCRIBE") {
1125
- 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;
1126
1216
  }
1127
- if (action === "UNSUBSCRIBE") {
1128
- if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
1129
- }
1130
- if (action === "PUBLISH") this.runTransport(() => this.transport.publish(topic, data));
1131
1217
  },
1132
1218
  // The cluster calls `onEvent` when a publication broadcast arrives from
1133
- // 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.
1134
1223
  onEvent: (eventType, payload) => {
1135
1224
  if (eventType !== PUBLICATION_EVENT) return;
1136
1225
  const message = payload;
@@ -1180,8 +1269,8 @@ var CrossTabDataBus = class _CrossTabDataBus {
1180
1269
  type: "coordination",
1181
1270
  coordinated: snapshot.coordinated,
1182
1271
  activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
1183
- workers: snapshot.workers.map((w) => `${w.workerId}|${w.status}|load=${w.load}|tab=${w.tabId}`),
1184
- 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)
1185
1274
  });
1186
1275
  void opening.then(
1187
1276
  () => {
@@ -1219,7 +1308,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1219
1308
  }).catch((error) => {
1220
1309
  if (stopClusterOnFailure) this.started = false;
1221
1310
  if (!this.pendingStop) {
1222
- this.pendingStop = Promise.resolve().then(() => this.transport.stop()).catch((stopError) => this.reportError(stopError));
1311
+ this.pendingStop = this.createStopPromise();
1223
1312
  }
1224
1313
  this.updateStatus("error");
1225
1314
  this.reportError(error);
@@ -1266,7 +1355,10 @@ var CrossTabDataBus = class _CrossTabDataBus {
1266
1355
  if (wasUnused) this.cluster.subscribe(topic);
1267
1356
  return () => this.unsubscribe(topic, handler);
1268
1357
  }
1269
- /** 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). */
1270
1362
  unsubscribe(topic, handler) {
1271
1363
  const handlers = this.topicHandlers.get(topic);
1272
1364
  if (!handlers) return;
@@ -1304,7 +1396,9 @@ var CrossTabDataBus = class _CrossTabDataBus {
1304
1396
  getStatus() {
1305
1397
  return this.status;
1306
1398
  }
1307
- /** 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. */
1308
1402
  getClusterSnapshot() {
1309
1403
  return this.cluster.getSnapshot();
1310
1404
  }
@@ -1358,13 +1452,7 @@ var CrossTabDataBus = class _CrossTabDataBus {
1358
1452
  /** Deliver a message to every local handler registered for its topic. */
1359
1453
  dispatch(message) {
1360
1454
  this.trace.recordDispatched(message.topic);
1361
- for (const handler of this.topicHandlers.get(message.topic) ?? []) {
1362
- try {
1363
- handler(message);
1364
- } catch (error) {
1365
- this.reportError(error);
1366
- }
1367
- }
1455
+ this.invokeHandlers(this.topicHandlers.get(message.topic) ?? [], (handler) => handler(message));
1368
1456
  }
1369
1457
  /**
1370
1458
  * Propagate a status change to the cluster, trace, and all registered
@@ -1390,25 +1478,11 @@ var CrossTabDataBus = class _CrossTabDataBus {
1390
1478
  }, _CrossTabDataBus.RECOVERY_COOLDOWN_MS);
1391
1479
  }
1392
1480
  }
1393
- for (const handler of this.statusHandlers) {
1394
- try {
1395
- handler(status);
1396
- } catch (error) {
1397
- this.reportError(error);
1398
- }
1399
- }
1481
+ this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
1400
1482
  }
1401
1483
  reportError(error) {
1402
1484
  this.trace.event({ type: "error", source: "transport" });
1403
- for (const handler of this.errorHandlers) {
1404
- try {
1405
- handler(error);
1406
- } catch (handlerError) {
1407
- if (typeof console !== "undefined" && typeof console.warn === "function") {
1408
- console.warn("[cross-tab-worker-databus] error handler threw:", handlerError);
1409
- }
1410
- }
1411
- }
1485
+ this.invokeHandlers(this.errorHandlers, (handler) => handler(error), "error handler");
1412
1486
  }
1413
1487
  traceSubscription(action, topic) {
1414
1488
  this.trace.event({
@@ -1430,6 +1504,26 @@ var CrossTabDataBus = class _CrossTabDataBus {
1430
1504
  this.runTransport(() => this.transport.unsubscribe(topic));
1431
1505
  return true;
1432
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
+ }
1433
1527
  /**
1434
1528
  * Suspend the transport when the tab goes hidden. Stops the transport and
1435
1529
  * clears subscription state so it will be re-established on resume.
@@ -1446,6 +1540,11 @@ var CrossTabDataBus = class _CrossTabDataBus {
1446
1540
  this.startPromise = stopping;
1447
1541
  this.pendingStop = stopping;
1448
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
+ }
1449
1548
  /**
1450
1549
  * Resume the transport when the tab becomes visible again, or recover from a
1451
1550
  * runtime transport failure. Re-opens the transport with the stored active
@@ -1520,6 +1619,12 @@ var CrossTabDataBus = class _CrossTabDataBus {
1520
1619
  void starting.catch(() => void 0);
1521
1620
  }
1522
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
+ }
1523
1628
 
1524
1629
  // src/worker-mode.ts
1525
1630
  function selectWorkerBackend(mode, availability = {}) {
@@ -1544,4 +1649,4 @@ export {
1544
1649
  CrossTabDataBus,
1545
1650
  selectWorkerBackend
1546
1651
  };
1547
- //# sourceMappingURL=chunk-53INHVYO.js.map
1652
+ //# sourceMappingURL=chunk-LBXREMZA.js.map