cross-tab-worker-databus 0.1.2 → 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.
- package/CHANGELOG.md +44 -0
- package/README.md +11 -0
- package/README.zh.md +9 -0
- package/dist/centrifuge-protocol.d.ts +51 -15
- package/dist/centrifuge-protocol.d.ts.map +1 -1
- package/dist/centrifuge-session.d.ts +11 -3
- package/dist/centrifuge-session.d.ts.map +1 -1
- package/dist/centrifuge.d.ts +10 -2
- package/dist/centrifuge.d.ts.map +1 -1
- package/dist/centrifuge.js +69 -26
- package/dist/centrifuge.js.map +2 -2
- package/dist/centrifuge.shared.worker.js +73 -25
- package/dist/centrifuge.shared.worker.js.map +2 -2
- package/dist/centrifuge.worker.js +41 -12
- package/dist/centrifuge.worker.js.map +2 -2
- package/dist/{chunk-53INHVYO.js → chunk-LBXREMZA.js} +263 -158
- package/dist/chunk-LBXREMZA.js.map +7 -0
- package/dist/core/cluster.d.ts +49 -4
- package/dist/core/cluster.d.ts.map +1 -1
- package/dist/core/data-bus.d.ts +19 -2
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/core/environment.d.ts +16 -2
- package/dist/core/environment.d.ts.map +1 -1
- package/dist/core/hash.d.ts.map +1 -1
- package/dist/core/routing.d.ts +5 -2
- package/dist/core/routing.d.ts.map +1 -1
- package/dist/core/storage-batch.d.ts +12 -0
- package/dist/core/storage-batch.d.ts.map +1 -1
- package/dist/core/trace.d.ts +16 -1
- package/dist/core/trace.d.ts.map +1 -1
- package/dist/core/types.d.ts +76 -21
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/worker-mode.d.ts +12 -3
- package/dist/worker-mode.d.ts.map +1 -1
- package/dist/workers/port-reaper.d.ts +21 -6
- package/dist/workers/port-reaper.d.ts.map +1 -1
- package/docs/architecture.md +18 -8
- package/docs/transports.md +144 -0
- package/docs/zh/architecture.md +18 -8
- package/docs/zh/transports.md +132 -0
- package/package.json +7 -2
- 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 =
|
|
84
|
-
let h2 =
|
|
85
|
-
let h3 =
|
|
86
|
-
let h4 =
|
|
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,
|
|
90
|
-
h2 = Math.imul(h2 ^ code,
|
|
91
|
-
h3 = Math.imul(h3 ^ code,
|
|
92
|
-
h4 = Math.imul(h4 ^ code,
|
|
93
|
-
}
|
|
94
|
-
h1 =
|
|
95
|
-
h2 =
|
|
96
|
-
h3 =
|
|
97
|
-
h4 =
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
434
|
-
return existingRoute
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
509
|
-
return this.sendControl(target
|
|
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
|
|
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 =
|
|
533
|
-
...
|
|
534
|
-
topic: this.knownTopics.get(
|
|
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:
|
|
543
|
-
assignedTopics:
|
|
544
|
-
knownTopics:
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
|
618
|
-
topic
|
|
619
|
-
topicKey
|
|
620
|
-
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 ||
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
|
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)
|
|
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
|
|
765
|
-
|
|
766
|
-
|
|
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
|
|
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
|
|
815
|
-
|
|
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
|
|
826
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1125
|
-
|
|
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(
|
|
1184
|
-
routes: snapshot.routes.map(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
1652
|
+
//# sourceMappingURL=chunk-LBXREMZA.js.map
|