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.
- package/CHANGELOG.md +59 -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 +14 -4
- 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 +76 -28
- package/dist/centrifuge.js.map +2 -2
- package/dist/centrifuge.shared.worker.js +84 -28
- package/dist/centrifuge.shared.worker.js.map +2 -2
- package/dist/centrifuge.worker.js +48 -14
- package/dist/centrifuge.worker.js.map +2 -2
- package/dist/{chunk-GABYBK7I.js → chunk-LBXREMZA.js} +287 -162
- 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 +14 -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 +23 -6
- package/dist/workers/port-reaper.d.ts.map +1 -1
- package/docs/architecture.md +20 -8
- package/docs/configuration.md +2 -0
- package/docs/transports.md +144 -0
- package/docs/zh/architecture.md +20 -8
- package/docs/zh/configuration.md +2 -0
- package/docs/zh/transports.md +132 -0
- package/package.json +7 -2
- 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 =
|
|
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) {
|
|
@@ -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
|
|
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)
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
416
|
-
return existingRoute
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
491
|
-
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));
|
|
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
|
|
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 =
|
|
515
|
-
...
|
|
516
|
-
topic: this.knownTopics.get(
|
|
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:
|
|
525
|
-
assignedTopics:
|
|
526
|
-
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 }))
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
|
600
|
-
topic
|
|
601
|
-
topicKey
|
|
602
|
-
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 ||
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
|
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)
|
|
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
|
|
747
|
-
|
|
748
|
-
|
|
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
|
|
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
|
|
797
|
-
|
|
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
|
|
808
|
-
|
|
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 =
|
|
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
|
|
860
|
-
|
|
861
|
-
this.knownTopics.delete(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1105
|
-
|
|
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(
|
|
1164
|
-
routes: snapshot.routes.map(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
1652
|
+
//# sourceMappingURL=chunk-LBXREMZA.js.map
|