cross-tab-worker-databus 0.2.1 → 0.4.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 +31 -0
- package/README.md +3 -0
- package/README.zh.md +3 -0
- package/dist/centrifuge.js +20 -3
- package/dist/centrifuge.js.map +2 -2
- package/dist/chunk-5WRI5ZAA.js +31 -0
- package/dist/chunk-5WRI5ZAA.js.map +7 -0
- package/dist/{chunk-LBXREMZA.js → chunk-ZGQRELIV.js} +91 -5
- package/dist/chunk-ZGQRELIV.js.map +7 -0
- package/dist/cjs/centrifuge.cjs +2205 -0
- package/dist/cjs/centrifuge.cjs.map +7 -0
- package/dist/cjs/hooks.cjs +1975 -0
- package/dist/cjs/hooks.cjs.map +7 -0
- package/dist/cjs/index.cjs +1865 -0
- package/dist/cjs/index.cjs.map +7 -0
- package/dist/core/cluster.d.ts +2 -1
- package/dist/core/cluster.d.ts.map +1 -1
- package/dist/core/data-bus.d.ts +28 -2
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/core/routing.d.ts +9 -0
- package/dist/core/routing.d.ts.map +1 -1
- package/dist/core/types.d.ts +3 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/hooks.d.ts +31 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +1946 -0
- package/dist/hooks.js.map +7 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +113 -3
- package/dist/index.js.map +3 -3
- package/dist/websocket.d.ts +86 -0
- package/dist/websocket.d.ts.map +1 -0
- package/docs/api.md +75 -0
- package/docs/getting-started.md +6 -0
- package/docs/transports.md +27 -0
- package/docs/zh/api.md +75 -0
- package/docs/zh/getting-started.md +6 -0
- package/docs/zh/transports.md +24 -0
- package/package.json +24 -7
- package/dist/chunk-LBXREMZA.js.map +0 -7
|
@@ -0,0 +1,2205 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/centrifuge.ts
|
|
21
|
+
var centrifuge_exports = {};
|
|
22
|
+
__export(centrifuge_exports, {
|
|
23
|
+
CentrifugeWorkerTransport: () => CentrifugeWorkerTransport,
|
|
24
|
+
createCentrifugeDataBus: () => createCentrifugeDataBus
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(centrifuge_exports);
|
|
27
|
+
|
|
28
|
+
// src/core/environment.ts
|
|
29
|
+
function getStorage(name) {
|
|
30
|
+
try {
|
|
31
|
+
return typeof window === "undefined" ? null : window[name];
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function randomId() {
|
|
37
|
+
try {
|
|
38
|
+
return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
|
|
39
|
+
} catch {
|
|
40
|
+
return Math.random().toString(36).slice(2);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
var tabIdentityInitialized = false;
|
|
44
|
+
function createBrowserEnvironment() {
|
|
45
|
+
return {
|
|
46
|
+
storage: getStorage("localStorage"),
|
|
47
|
+
sessionStorage: getStorage("sessionStorage"),
|
|
48
|
+
now: Date.now,
|
|
49
|
+
randomId,
|
|
50
|
+
createChannel: (name) => {
|
|
51
|
+
try {
|
|
52
|
+
return typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(name);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
setInterval: (callback, intervalMs) => globalThis.setInterval(callback, intervalMs),
|
|
58
|
+
clearInterval: (handle) => globalThis.clearInterval(handle),
|
|
59
|
+
getVisibilityState: () => typeof document !== "undefined" && document.visibilityState === "hidden" ? "hidden" : "visible",
|
|
60
|
+
addVisibilityChangeListener: (listener) => {
|
|
61
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", listener);
|
|
62
|
+
},
|
|
63
|
+
removeVisibilityChangeListener: (listener) => {
|
|
64
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", listener);
|
|
65
|
+
},
|
|
66
|
+
addPageHideListener: (listener) => {
|
|
67
|
+
if (typeof window !== "undefined") window.addEventListener("pagehide", listener);
|
|
68
|
+
},
|
|
69
|
+
removePageHideListener: (listener) => {
|
|
70
|
+
if (typeof window !== "undefined") window.removeEventListener("pagehide", listener);
|
|
71
|
+
},
|
|
72
|
+
addPageShowListener: (listener) => {
|
|
73
|
+
if (typeof window !== "undefined") window.addEventListener("pageshow", listener);
|
|
74
|
+
},
|
|
75
|
+
removePageShowListener: (listener) => {
|
|
76
|
+
if (typeof window !== "undefined") window.removeEventListener("pageshow", listener);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function canUseStorage(storage, probeKey) {
|
|
81
|
+
if (!storage) return false;
|
|
82
|
+
try {
|
|
83
|
+
storage.setItem(probeKey, "1");
|
|
84
|
+
storage.removeItem(probeKey);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function getOrCreateTabId(environment, key = "cross-tab-worker-databus:tab-id") {
|
|
91
|
+
const storage = environment.sessionStorage;
|
|
92
|
+
try {
|
|
93
|
+
const existing = storage?.getItem(key);
|
|
94
|
+
const hasOpener = typeof window !== "undefined" && Boolean(window.opener);
|
|
95
|
+
if (existing && (!hasOpener || tabIdentityInitialized)) {
|
|
96
|
+
tabIdentityInitialized = true;
|
|
97
|
+
return existing;
|
|
98
|
+
}
|
|
99
|
+
const created = `tab-${environment.randomId()}`;
|
|
100
|
+
storage?.setItem(key, created);
|
|
101
|
+
tabIdentityInitialized = true;
|
|
102
|
+
return created;
|
|
103
|
+
} catch {
|
|
104
|
+
return `tab-${environment.randomId()}`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/core/hash.ts
|
|
109
|
+
function createOpaqueKey(value) {
|
|
110
|
+
let h1 = SEED_H1 ^ value.length;
|
|
111
|
+
let h2 = SEED_H2 ^ value.length;
|
|
112
|
+
let h3 = SEED_H3 ^ value.length;
|
|
113
|
+
let h4 = SEED_H4 ^ value.length;
|
|
114
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
115
|
+
const code = value.charCodeAt(index);
|
|
116
|
+
h1 = Math.imul(h1 ^ code, PRIME_H1);
|
|
117
|
+
h2 = Math.imul(h2 ^ code, PRIME_H2);
|
|
118
|
+
h3 = Math.imul(h3 ^ code, PRIME_H3);
|
|
119
|
+
h4 = Math.imul(h4 ^ code, PRIME_H4);
|
|
120
|
+
}
|
|
121
|
+
h1 = avalancheMix(h1, h2);
|
|
122
|
+
h2 = avalancheMix(h2, h3);
|
|
123
|
+
h3 = avalancheMix(h3, h4);
|
|
124
|
+
h4 = avalancheMix(h4, h1);
|
|
125
|
+
return [h1, h2, h3, h4].map((hash) => (hash >>> 0).toString(16).padStart(8, "0")).join("");
|
|
126
|
+
}
|
|
127
|
+
var SEED_H1 = 3735928559;
|
|
128
|
+
var SEED_H2 = 1103547991;
|
|
129
|
+
var SEED_H3 = 3235826430;
|
|
130
|
+
var SEED_H4 = 2654435769;
|
|
131
|
+
var PRIME_H1 = 2654435761;
|
|
132
|
+
var PRIME_H2 = 1597334677;
|
|
133
|
+
var PRIME_H3 = 2246822519;
|
|
134
|
+
var PRIME_H4 = 3266489917;
|
|
135
|
+
var AVALANCHE_PRIME = 2246822507;
|
|
136
|
+
var AVALANCHE_CROSS = 3266489909;
|
|
137
|
+
function avalancheMix(self, neighbor) {
|
|
138
|
+
return Math.imul(self ^ self >>> 16, AVALANCHE_PRIME) ^ Math.imul(neighbor ^ neighbor >>> 13, AVALANCHE_CROSS);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/core/routing.ts
|
|
142
|
+
var DEFAULT_MAX_ACTIVE_WORKERS = 3;
|
|
143
|
+
function selectLeastLoadedWorker(workers, preferredWorkerId) {
|
|
144
|
+
const preferred = workers.find((worker) => worker.workerId === preferredWorkerId);
|
|
145
|
+
if (preferred) return preferred;
|
|
146
|
+
return workers.reduce((least, worker) => {
|
|
147
|
+
if (!least) return worker;
|
|
148
|
+
const byLoad = worker.load - least.load;
|
|
149
|
+
if (byLoad !== 0) return byLoad < 0 ? worker : least;
|
|
150
|
+
if (worker.workerId < least.workerId) return worker;
|
|
151
|
+
return least;
|
|
152
|
+
}, void 0);
|
|
153
|
+
}
|
|
154
|
+
function selectActiveWorkers(workers, maxActiveWorkers = DEFAULT_MAX_ACTIVE_WORKERS) {
|
|
155
|
+
const healthyWorkers = workers.filter((worker) => worker.status === "connecting" || worker.status === "connected");
|
|
156
|
+
const availableWorkers = healthyWorkers.length > 0 ? healthyWorkers : [...workers];
|
|
157
|
+
const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === "visible");
|
|
158
|
+
const candidates = visibleWorkers.length > 0 ? visibleWorkers : availableWorkers;
|
|
159
|
+
return candidates.sort(
|
|
160
|
+
(left, right) => left.registeredAt - right.registeredAt || (left.workerId < right.workerId ? -1 : left.workerId > right.workerId ? 1 : 0)
|
|
161
|
+
).slice(0, maxActiveWorkers);
|
|
162
|
+
}
|
|
163
|
+
function isWildcardTopic(pattern) {
|
|
164
|
+
return pattern === "*" || pattern.endsWith(".*");
|
|
165
|
+
}
|
|
166
|
+
function topicMatchesPattern(pattern, topic) {
|
|
167
|
+
if (!pattern || !topic) return false;
|
|
168
|
+
if (pattern === topic) return true;
|
|
169
|
+
if (pattern === "*") return true;
|
|
170
|
+
if (!pattern.endsWith(".*")) return false;
|
|
171
|
+
return topic.startsWith(pattern.slice(0, -1));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/core/storage-batch.ts
|
|
175
|
+
var INITIAL_RETRY_DELAY_MS = 50;
|
|
176
|
+
var MAX_RETRY_DELAY_MS = 1600;
|
|
177
|
+
var MAX_RETRY_ATTEMPTS = 5;
|
|
178
|
+
var BatchingStorageWriter = class {
|
|
179
|
+
constructor(storage) {
|
|
180
|
+
this.storage = storage;
|
|
181
|
+
}
|
|
182
|
+
/** Coalesced write set. A `null` value represents a pending delete. */
|
|
183
|
+
pending = /* @__PURE__ */ new Map();
|
|
184
|
+
/** Per-key retry counter, reset on a successful write. */
|
|
185
|
+
retryCount = /* @__PURE__ */ new Map();
|
|
186
|
+
flushScheduled = false;
|
|
187
|
+
retryHandle = null;
|
|
188
|
+
retryDelayMs = INITIAL_RETRY_DELAY_MS;
|
|
189
|
+
/** Number of writes queued in memory but not yet flushed to storage.
|
|
190
|
+
* Used by tests to assert the coalescing window and by flush() to detect
|
|
191
|
+
* the all-drained state. */
|
|
192
|
+
get pendingSize() {
|
|
193
|
+
return this.pending.size;
|
|
194
|
+
}
|
|
195
|
+
get length() {
|
|
196
|
+
return this.keys().length;
|
|
197
|
+
}
|
|
198
|
+
clear() {
|
|
199
|
+
this.pending.clear();
|
|
200
|
+
this.flushScheduled = false;
|
|
201
|
+
this.storage.clear();
|
|
202
|
+
this.cancelRetry();
|
|
203
|
+
this.retryCount.clear();
|
|
204
|
+
this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
|
|
205
|
+
}
|
|
206
|
+
// Reads always see the pending value first (task-local consistency), then
|
|
207
|
+
// fall back to the underlying storage.
|
|
208
|
+
getItem(key) {
|
|
209
|
+
if (this.pending.has(key)) return this.pending.get(key) ?? null;
|
|
210
|
+
return this.storage.getItem(key);
|
|
211
|
+
}
|
|
212
|
+
key(index) {
|
|
213
|
+
return this.keys()[index] ?? null;
|
|
214
|
+
}
|
|
215
|
+
removeItem(key) {
|
|
216
|
+
this.pending.set(key, null);
|
|
217
|
+
this.scheduleFlush();
|
|
218
|
+
}
|
|
219
|
+
setItem(key, value) {
|
|
220
|
+
this.pending.set(key, value);
|
|
221
|
+
this.scheduleFlush();
|
|
222
|
+
}
|
|
223
|
+
flush() {
|
|
224
|
+
this.flushScheduled = false;
|
|
225
|
+
this.cancelRetry();
|
|
226
|
+
for (const [key, value] of Array.from(this.pending)) {
|
|
227
|
+
try {
|
|
228
|
+
if (value === null) this.storage.removeItem(key);
|
|
229
|
+
else this.storage.setItem(key, value);
|
|
230
|
+
this.pending.delete(key);
|
|
231
|
+
this.retryCount.delete(key);
|
|
232
|
+
} catch {
|
|
233
|
+
const attempts = (this.retryCount.get(key) ?? 0) + 1;
|
|
234
|
+
if (attempts >= MAX_RETRY_ATTEMPTS) {
|
|
235
|
+
this.pending.delete(key);
|
|
236
|
+
this.retryCount.delete(key);
|
|
237
|
+
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
238
|
+
console.warn("[cross-tab-worker-databus] storage write gave up after retries, dropping key:", key);
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
this.retryCount.set(key, attempts);
|
|
243
|
+
this.scheduleRetry();
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (this.pending.size === 0) {
|
|
248
|
+
this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
|
|
249
|
+
this.retryCount.clear();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** Union of persisted keys and pending writes, minus pending deletes. */
|
|
253
|
+
keys() {
|
|
254
|
+
const keys = /* @__PURE__ */ new Set();
|
|
255
|
+
for (let index = 0; index < this.storage.length; index += 1) {
|
|
256
|
+
const key = this.storage.key(index);
|
|
257
|
+
if (key !== null) keys.add(key);
|
|
258
|
+
}
|
|
259
|
+
for (const [key, value] of this.pending) {
|
|
260
|
+
if (value === null) keys.delete(key);
|
|
261
|
+
else keys.add(key);
|
|
262
|
+
}
|
|
263
|
+
return Array.from(keys);
|
|
264
|
+
}
|
|
265
|
+
// Coalesce all synchronous writes within one task into a single microtask
|
|
266
|
+
// flush, avoiding a localStorage write per heartbeat/route/subscriber update.
|
|
267
|
+
// The queueMicrotask fallback to setTimeout handles older runtimes and
|
|
268
|
+
// non-browser environments where queueMicrotask is absent.
|
|
269
|
+
scheduleFlush() {
|
|
270
|
+
if (this.flushScheduled) return;
|
|
271
|
+
this.flushScheduled = true;
|
|
272
|
+
const flush = () => {
|
|
273
|
+
this.flushScheduled = false;
|
|
274
|
+
this.flush();
|
|
275
|
+
};
|
|
276
|
+
if (typeof queueMicrotask === "function") queueMicrotask(flush);
|
|
277
|
+
else setTimeout(flush, 0);
|
|
278
|
+
}
|
|
279
|
+
// Schedule a single retry timer. The guard ensures only one retry is in
|
|
280
|
+
// flight at a time; subsequent scheduleRetry calls during the wait are
|
|
281
|
+
// no-ops because the first retry will re-flush all pending keys together.
|
|
282
|
+
scheduleRetry() {
|
|
283
|
+
if (this.retryHandle !== null) return;
|
|
284
|
+
this.retryHandle = setTimeout(() => {
|
|
285
|
+
this.retryHandle = null;
|
|
286
|
+
this.flush();
|
|
287
|
+
}, this.retryDelayMs);
|
|
288
|
+
this.retryDelayMs = Math.min(MAX_RETRY_DELAY_MS, this.retryDelayMs * 2);
|
|
289
|
+
}
|
|
290
|
+
cancelRetry() {
|
|
291
|
+
if (this.retryHandle !== null) {
|
|
292
|
+
clearTimeout(this.retryHandle);
|
|
293
|
+
this.retryHandle = null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// src/core/cluster.ts
|
|
299
|
+
var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
|
|
300
|
+
var DEFAULT_WORKER_TTL_MS = 1e4;
|
|
301
|
+
var DEFAULT_STORAGE_PREFIX = "cross-tab-worker-databus";
|
|
302
|
+
var MAX_KNOWN_TOPICS = 500;
|
|
303
|
+
function readJson(storage, key) {
|
|
304
|
+
try {
|
|
305
|
+
const value = storage.getItem(key);
|
|
306
|
+
return value ? JSON.parse(value) : null;
|
|
307
|
+
} catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function writeJson(storage, key, value) {
|
|
312
|
+
try {
|
|
313
|
+
storage.setItem(key, JSON.stringify(value));
|
|
314
|
+
} catch {
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function listKeys(storage, prefix) {
|
|
318
|
+
try {
|
|
319
|
+
return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter(
|
|
320
|
+
(key) => Boolean(key?.startsWith(prefix))
|
|
321
|
+
);
|
|
322
|
+
} catch {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function readAllByPrefix(storage, prefix) {
|
|
327
|
+
return listKeys(storage, prefix).map((key) => ({ key, value: readJson(storage, key) })).filter((entry) => entry.value !== null);
|
|
328
|
+
}
|
|
329
|
+
var WorkerClusterRuntime = class {
|
|
330
|
+
tabId;
|
|
331
|
+
workerId;
|
|
332
|
+
environment;
|
|
333
|
+
handlers;
|
|
334
|
+
storage;
|
|
335
|
+
maxActiveWorkers;
|
|
336
|
+
heartbeatIntervalMs;
|
|
337
|
+
workerTtlMs;
|
|
338
|
+
workerPrefix;
|
|
339
|
+
routePrefix;
|
|
340
|
+
subscriberPrefix;
|
|
341
|
+
channelName;
|
|
342
|
+
// Topics this tab has subscribed to (local interest, plaintext).
|
|
343
|
+
subscribedTopics = /* @__PURE__ */ new Set();
|
|
344
|
+
// Topics assigned to this Worker as owner (topicKey → topic). Authoritative:
|
|
345
|
+
// membership drives isAssigned() and load. Grows only via CONTROL/SUBSCRIBE
|
|
346
|
+
// (or local self-subscribe), never via the reverse cache.
|
|
347
|
+
assignedTopics = /* @__PURE__ */ new Map();
|
|
348
|
+
// Reverse mapping: opaque topicKey → plaintext topic. A bounded cache with
|
|
349
|
+
// FIFO eviction — NOT authoritative. It can hold a topicKey that is also in
|
|
350
|
+
// assignedTopics (the owned guard prevents evicting those), because it is
|
|
351
|
+
// the only source of plaintext when storage is unavailable. See the
|
|
352
|
+
// rememberTopic() doc for the eviction contract.
|
|
353
|
+
knownTopics = /* @__PURE__ */ new Map();
|
|
354
|
+
channel = null;
|
|
355
|
+
heartbeatHandle = null;
|
|
356
|
+
started = false;
|
|
357
|
+
suspended = false;
|
|
358
|
+
lifecycleListening = false;
|
|
359
|
+
currentRecord;
|
|
360
|
+
constructor(options) {
|
|
361
|
+
this.environment = options.environment ?? createBrowserEnvironment();
|
|
362
|
+
this.handlers = options.handlers;
|
|
363
|
+
this.maxActiveWorkers = options.maxActiveWorkers ?? DEFAULT_MAX_ACTIVE_WORKERS;
|
|
364
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
365
|
+
this.workerTtlMs = options.workerTtlMs ?? DEFAULT_WORKER_TTL_MS;
|
|
366
|
+
const clusterHash = createOpaqueKey(options.clusterKey || "__default__");
|
|
367
|
+
const prefix = options.storagePrefix ?? DEFAULT_STORAGE_PREFIX;
|
|
368
|
+
const baseKey = `${prefix}:${clusterHash}`;
|
|
369
|
+
this.workerPrefix = `${baseKey}:worker:`;
|
|
370
|
+
this.routePrefix = `${baseKey}:route:`;
|
|
371
|
+
this.subscriberPrefix = `${baseKey}:subscriber:`;
|
|
372
|
+
this.channelName = `${prefix}:bus:${clusterHash}`;
|
|
373
|
+
this.storage = canUseStorage(this.environment.storage, `${baseKey}:probe`) ? new BatchingStorageWriter(this.environment.storage) : null;
|
|
374
|
+
this.tabId = options.tabId ?? getOrCreateTabId(this.environment, `${prefix}:tab-id`);
|
|
375
|
+
this.workerId = options.workerId ?? `worker-${this.tabId}-${this.environment.randomId()}`;
|
|
376
|
+
const now = this.environment.now();
|
|
377
|
+
this.currentRecord = {
|
|
378
|
+
workerId: this.workerId,
|
|
379
|
+
tabId: this.tabId,
|
|
380
|
+
load: 0,
|
|
381
|
+
role: "standby",
|
|
382
|
+
status: "connecting",
|
|
383
|
+
visibilityState: this.environment.getVisibilityState(),
|
|
384
|
+
heartbeatAt: now,
|
|
385
|
+
registeredAt: now
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/** Start the cluster: register, listen for lifecycle events, and begin heartbeats. */
|
|
389
|
+
start() {
|
|
390
|
+
if (this.started) return;
|
|
391
|
+
this.suspended = false;
|
|
392
|
+
this.addLifecycleListeners();
|
|
393
|
+
this.activate();
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Stop the cluster: pause heartbeats, hand off assigned topics, remove
|
|
397
|
+
* the worker record, and clean up lifecycle listeners. Idempotent.
|
|
398
|
+
* The .clear() calls after pause() are safe no-ops when pause already
|
|
399
|
+
* cleared the maps (the handoff path), but ensure a full teardown in the
|
|
400
|
+
* stop() path where callers expect every Set/Map to be empty afterwards.
|
|
401
|
+
*/
|
|
402
|
+
stop() {
|
|
403
|
+
if (!this.started && !this.suspended) return;
|
|
404
|
+
this.pause();
|
|
405
|
+
this.flushStorage();
|
|
406
|
+
this.removeLifecycleListeners();
|
|
407
|
+
this.subscribedTopics.clear();
|
|
408
|
+
this.assignedTopics.clear();
|
|
409
|
+
this.knownTopics.clear();
|
|
410
|
+
this.suspended = false;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Activate the cluster: open the BroadcastChannel, register the worker record,
|
|
414
|
+
* subscribe to topics, and start the heartbeat interval.
|
|
415
|
+
*/
|
|
416
|
+
activate() {
|
|
417
|
+
if (this.started) return;
|
|
418
|
+
this.started = true;
|
|
419
|
+
this.channel = this.storage ? this.environment.createChannel(this.channelName) : null;
|
|
420
|
+
if (!this.channel) this.storage = null;
|
|
421
|
+
this.channel?.addEventListener("message", this.handleMessage);
|
|
422
|
+
const now = this.environment.now();
|
|
423
|
+
this.currentRecord = {
|
|
424
|
+
...this.currentRecord,
|
|
425
|
+
heartbeatAt: now,
|
|
426
|
+
registeredAt: now,
|
|
427
|
+
visibilityState: this.environment.getVisibilityState()
|
|
428
|
+
};
|
|
429
|
+
this.refreshRole(this.readWorkers());
|
|
430
|
+
this.writeRecord(true);
|
|
431
|
+
for (const topic of this.subscribedTopics) {
|
|
432
|
+
const topicKey = this.rememberTopic(topic);
|
|
433
|
+
if (!this.storage) this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
|
|
434
|
+
else this.writeSubscriber(topicKey);
|
|
435
|
+
}
|
|
436
|
+
this.reconcile();
|
|
437
|
+
this.heartbeatHandle = this.environment.setInterval(() => {
|
|
438
|
+
this.writeRecord(false);
|
|
439
|
+
this.reconcile();
|
|
440
|
+
}, this.heartbeatIntervalMs);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Pause the cluster on pagehide: stop heartbeats, hand off assigned topics
|
|
444
|
+
* to other workers, remove our worker record, and close the channel.
|
|
445
|
+
*/
|
|
446
|
+
pause() {
|
|
447
|
+
if (!this.started) return;
|
|
448
|
+
this.started = false;
|
|
449
|
+
this.suspended = true;
|
|
450
|
+
if (this.heartbeatHandle !== null) this.environment.clearInterval(this.heartbeatHandle);
|
|
451
|
+
this.heartbeatHandle = null;
|
|
452
|
+
this.channel?.removeEventListener("message", this.handleMessage);
|
|
453
|
+
for (const topic of this.subscribedTopics) this.releaseSubscription(topic, false);
|
|
454
|
+
this.handoffAssignedTopics();
|
|
455
|
+
this.assignedTopics.clear();
|
|
456
|
+
this.removeStorage(this.workerStorageKey(this.workerId));
|
|
457
|
+
this.flushStorage();
|
|
458
|
+
this.notifyRegistry();
|
|
459
|
+
this.channel?.close();
|
|
460
|
+
this.channel = null;
|
|
461
|
+
this.handlers.onSuspend?.();
|
|
462
|
+
}
|
|
463
|
+
/** Update the worker's connection status and persist the change. */
|
|
464
|
+
setStatus(status) {
|
|
465
|
+
if (this.currentRecord.status === status) return;
|
|
466
|
+
this.currentRecord = { ...this.currentRecord, status };
|
|
467
|
+
if (this.started) this.writeRecord(true);
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Subscribe to a topic. Returns true if this worker becomes the assigned owner.
|
|
471
|
+
* The topic is recorded locally and the cluster is notified via storage or
|
|
472
|
+
* direct control message.
|
|
473
|
+
*/
|
|
474
|
+
subscribe(topic) {
|
|
475
|
+
const topicKey = this.rememberTopic(topic);
|
|
476
|
+
this.subscribedTopics.add(topic);
|
|
477
|
+
if (!this.started) return false;
|
|
478
|
+
if (!this.storage) {
|
|
479
|
+
this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
this.writeSubscriber(topicKey);
|
|
483
|
+
const workers = this.readWorkers();
|
|
484
|
+
const existingRoute = this.readRoute(topicKey);
|
|
485
|
+
if (this.routeOwnerIsLive(existingRoute, workers)) {
|
|
486
|
+
return existingRoute?.workerId === this.workerId;
|
|
487
|
+
}
|
|
488
|
+
const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
|
|
489
|
+
const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
|
|
490
|
+
this.writeRoute(topicKey, owner, void 0, (existingRoute?.generation ?? 0) + 1);
|
|
491
|
+
this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
|
|
492
|
+
this.notifyRegistry();
|
|
493
|
+
return owner.workerId === this.workerId;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Remove the local subscription. Cleans up the subscriber record and, if no
|
|
497
|
+
* subscribers remain, deletes the route so the owning Worker can unsubscribe.
|
|
498
|
+
*/
|
|
499
|
+
unsubscribe(topic) {
|
|
500
|
+
this.subscribedTopics.delete(topic);
|
|
501
|
+
const topicKey = this.releaseSubscription(topic);
|
|
502
|
+
if (topicKey && !this.assignedTopics.has(topicKey)) this.knownTopics.delete(topicKey);
|
|
503
|
+
}
|
|
504
|
+
/** Remove this tab's subscriber record and, when it was the last one, delete
|
|
505
|
+
* the route. Returns the topicKey (so callers like `unsubscribe` can reuse
|
|
506
|
+
* it instead of re-hashing the topic to evict the reverse cache). */
|
|
507
|
+
releaseSubscription(topic, notifyOwner = true) {
|
|
508
|
+
const topicKey = this.rememberTopic(topic);
|
|
509
|
+
this.removeStorage(this.subscriberStorageKey(topicKey, this.tabId));
|
|
510
|
+
const route = this.readRoute(topicKey);
|
|
511
|
+
if (!route) return topicKey;
|
|
512
|
+
const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
|
|
513
|
+
if (subscribers.length === 0) {
|
|
514
|
+
this.removeStorage(this.routeStorageKey(topicKey));
|
|
515
|
+
if (notifyOwner) this.sendControl(route.workerId, "UNSUBSCRIBE", topic, topicKey);
|
|
516
|
+
}
|
|
517
|
+
return topicKey;
|
|
518
|
+
}
|
|
519
|
+
/** Transfer assigned topics to other active workers so subscribers are not orphaned during pause. */
|
|
520
|
+
handoffAssignedTopics() {
|
|
521
|
+
if (!this.storage || this.assignedTopics.size === 0) return;
|
|
522
|
+
const remainingWorkers = this.readWorkers().filter((worker) => worker.workerId !== this.workerId);
|
|
523
|
+
const activeWorkers = selectActiveWorkers(remainingWorkers, this.maxActiveWorkers);
|
|
524
|
+
const projectedLoads = new Map(activeWorkers.map((worker) => [worker.workerId, worker.load]));
|
|
525
|
+
for (const [topicKey, topic] of this.assignedTopics) {
|
|
526
|
+
const previous = this.readRoute(topicKey);
|
|
527
|
+
if (previous?.workerId !== this.workerId) continue;
|
|
528
|
+
const subscribers = this.readSubscriberTabIds(topicKey, remainingWorkers);
|
|
529
|
+
if (subscribers.length === 0) {
|
|
530
|
+
this.removeStorage(this.routeStorageKey(topicKey));
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const owner = selectLeastLoadedWorker(
|
|
534
|
+
activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load }))
|
|
535
|
+
);
|
|
536
|
+
if (!owner) continue;
|
|
537
|
+
projectedLoads.set(owner.workerId, (projectedLoads.get(owner.workerId) ?? owner.load) + 1);
|
|
538
|
+
const generation = (previous?.generation ?? 0) + 1;
|
|
539
|
+
this.writeRoute(topicKey, owner, previous?.workerId, generation);
|
|
540
|
+
this.flushStorage();
|
|
541
|
+
this.handlers.onControl("UNSUBSCRIBE", topic);
|
|
542
|
+
this.sendRouteReleased(owner.workerId, topic, topicKey, generation);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Publish a message to `topic`, routing through the owning Worker (or self if
|
|
547
|
+
* no owner is found). Returns false when the control message could not be
|
|
548
|
+
* posted to a remote owner, so the caller can surface the failure instead of
|
|
549
|
+
* silently dropping the publication.
|
|
550
|
+
*/
|
|
551
|
+
publish(topic, data) {
|
|
552
|
+
const topicKey = this.rememberTopic(topic);
|
|
553
|
+
const workers = this.readWorkers();
|
|
554
|
+
const route = this.readRoute(topicKey);
|
|
555
|
+
const target = this.routeOwnerIsLive(route, workers) ? route?.workerId ?? this.workerId : this.workerId;
|
|
556
|
+
return this.sendControl(target, "PUBLISH", topic, topicKey, data);
|
|
557
|
+
}
|
|
558
|
+
/** True when `route` exists and its owner worker is among `workers`.
|
|
559
|
+
* Shared by subscribe (skip re-assignment) and publish (route to owner).
|
|
560
|
+
* Intentionally returns a plain boolean (not a type guard) so the caller
|
|
561
|
+
* can still access `route?.generation` in the false branch. */
|
|
562
|
+
routeOwnerIsLive(route, workers) {
|
|
563
|
+
return Boolean(route && workers.some((worker) => worker.workerId === route.workerId));
|
|
564
|
+
}
|
|
565
|
+
/** Broadcast an event to every tab — used to fan out transport publications. */
|
|
566
|
+
broadcastEvent(eventType, payload) {
|
|
567
|
+
this.send({ type: "EVENT", sourceWorkerId: this.workerId, eventType, payload });
|
|
568
|
+
}
|
|
569
|
+
isAssigned(topic) {
|
|
570
|
+
const topicKey = createOpaqueKey(topic);
|
|
571
|
+
if (this.assignedTopics.has(topicKey)) return true;
|
|
572
|
+
for (const pattern of this.assignedTopics.values()) {
|
|
573
|
+
if (pattern !== topic && topicMatchesPattern(pattern, topic)) return true;
|
|
574
|
+
}
|
|
575
|
+
return this.readRoute(topicKey)?.workerId === this.workerId;
|
|
576
|
+
}
|
|
577
|
+
/** True if this worker is among the active set (eligible to own topics). */
|
|
578
|
+
isActiveWorker() {
|
|
579
|
+
return this.isActiveAmong(this.readWorkers());
|
|
580
|
+
}
|
|
581
|
+
/** True when this workerId is in the active subset of `workers`. Shared by
|
|
582
|
+
* isActiveWorker() and refreshRole() so both compute role identically. */
|
|
583
|
+
isActiveAmong(workers) {
|
|
584
|
+
return selectActiveWorkers(workers, this.maxActiveWorkers).some(
|
|
585
|
+
(worker) => worker.workerId === this.workerId
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
/** True when this tab has a local subscriber registered for `topic` —
|
|
589
|
+
* exactly, or via a wildcard subscription that matches it. */
|
|
590
|
+
hasLocalSubscriber(topic) {
|
|
591
|
+
if (this.subscribedTopics.has(topic)) return true;
|
|
592
|
+
for (const pattern of this.subscribedTopics) {
|
|
593
|
+
if (pattern !== topic && topicMatchesPattern(pattern, topic)) return true;
|
|
594
|
+
}
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
/** Read-only snapshot of the cluster state (workers, routes, assignments). */
|
|
598
|
+
getSnapshot() {
|
|
599
|
+
const routes = this.storage ? readAllByPrefix(this.storage, this.routePrefix).map(({ value }) => ({
|
|
600
|
+
...value,
|
|
601
|
+
topic: this.knownTopics.get(value.topicKey) ?? null
|
|
602
|
+
})) : [];
|
|
603
|
+
return {
|
|
604
|
+
coordinated: Boolean(this.storage && this.channel),
|
|
605
|
+
suspended: this.suspended,
|
|
606
|
+
currentWorker: { ...this.currentRecord },
|
|
607
|
+
workers: this.readWorkers().map((worker) => ({ ...worker })),
|
|
608
|
+
routes,
|
|
609
|
+
subscribedTopics: Array.from(this.subscribedTopics),
|
|
610
|
+
assignedTopics: Array.from(this.assignedTopics.values()),
|
|
611
|
+
knownTopics: Array.from(this.knownTopics.entries(), ([topicKey, topic]) => ({ topicKey, topic }))
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
handlePageHide = () => this.pause();
|
|
615
|
+
handlePageShow = () => {
|
|
616
|
+
if (!this.suspended) return;
|
|
617
|
+
this.suspended = false;
|
|
618
|
+
this.handlers.onResume?.();
|
|
619
|
+
this.activate();
|
|
620
|
+
};
|
|
621
|
+
handleVisibilityChange = () => {
|
|
622
|
+
const visibilityState = this.environment.getVisibilityState();
|
|
623
|
+
if (visibilityState === this.currentRecord.visibilityState) return;
|
|
624
|
+
this.currentRecord = { ...this.currentRecord, visibilityState };
|
|
625
|
+
if (this.started) {
|
|
626
|
+
this.writeRecord(true);
|
|
627
|
+
this.reconcile();
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
addLifecycleListeners() {
|
|
631
|
+
if (this.lifecycleListening) return;
|
|
632
|
+
this.lifecycleListening = true;
|
|
633
|
+
this.environment.addPageHideListener(this.handlePageHide);
|
|
634
|
+
this.environment.addPageShowListener(this.handlePageShow);
|
|
635
|
+
this.environment.addVisibilityChangeListener(this.handleVisibilityChange);
|
|
636
|
+
}
|
|
637
|
+
removeLifecycleListeners() {
|
|
638
|
+
if (!this.lifecycleListening) return;
|
|
639
|
+
this.lifecycleListening = false;
|
|
640
|
+
this.environment.removePageHideListener(this.handlePageHide);
|
|
641
|
+
this.environment.removePageShowListener(this.handlePageShow);
|
|
642
|
+
this.environment.removeVisibilityChangeListener(this.handleVisibilityChange);
|
|
643
|
+
}
|
|
644
|
+
/** Handle an incoming cluster message: dispatch by type to the per-type handlers. */
|
|
645
|
+
handleMessage = (event) => {
|
|
646
|
+
const message = event.data;
|
|
647
|
+
if (!message || message.sourceWorkerId === this.workerId) return;
|
|
648
|
+
switch (message.type) {
|
|
649
|
+
case "CONTROL":
|
|
650
|
+
return this.handleControlMessage(message);
|
|
651
|
+
case "ROUTE_RELEASED":
|
|
652
|
+
return this.handleRouteReleasedMessage(message);
|
|
653
|
+
case "EVENT":
|
|
654
|
+
this.handlers.onEvent(message.eventType, message.payload, message.sourceWorkerId);
|
|
655
|
+
return;
|
|
656
|
+
case "REGISTRY":
|
|
657
|
+
default:
|
|
658
|
+
this.reconcile();
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
/** Handle a point-to-point CONTROL message (SUBSCRIBE / UNSUBSCRIBE / PUBLISH). */
|
|
663
|
+
handleControlMessage(message) {
|
|
664
|
+
if (message.targetWorkerId !== this.workerId) return;
|
|
665
|
+
this.rememberTopic(message.topic);
|
|
666
|
+
switch (message.action) {
|
|
667
|
+
case "SUBSCRIBE":
|
|
668
|
+
this.assignedTopics.set(message.topicKey, message.topic);
|
|
669
|
+
this.confirmRoute(message.topicKey);
|
|
670
|
+
break;
|
|
671
|
+
case "UNSUBSCRIBE":
|
|
672
|
+
if (this.releaseHandoffOnUnsubscribe(message)) return;
|
|
673
|
+
break;
|
|
674
|
+
case "PUBLISH":
|
|
675
|
+
default:
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
this.handlers.onControl(message.action, message.topic, message.data);
|
|
679
|
+
if (message.action !== "PUBLISH") this.updateLoad();
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* When this worker is the previous owner in a graceful handoff and the new
|
|
683
|
+
* owner asks us to unsubscribe, release the old transport subscription and
|
|
684
|
+
* ACK the handoff with ROUTE_RELEASED. Returns true when the message was a
|
|
685
|
+
* handoff release (the generic CONTROL dispatch must not run as well).
|
|
686
|
+
*/
|
|
687
|
+
releaseHandoffOnUnsubscribe(message) {
|
|
688
|
+
this.assignedTopics.delete(message.topicKey);
|
|
689
|
+
const route = this.readRoute(message.topicKey);
|
|
690
|
+
if (route?.handoffFromWorkerId !== this.workerId) return false;
|
|
691
|
+
this.handlers.onControl("UNSUBSCRIBE", message.topic, void 0);
|
|
692
|
+
this.sendRouteReleased(route.workerId, message.topic, message.topicKey, route.generation);
|
|
693
|
+
this.updateLoad();
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
/** Post a ROUTE_RELEASED ACK to the new owner, carrying the current route
|
|
697
|
+
* generation so only the matching new owner may act on it. */
|
|
698
|
+
sendRouteReleased(targetWorkerId, topic, topicKey, generation) {
|
|
699
|
+
this.send({
|
|
700
|
+
type: "ROUTE_RELEASED",
|
|
701
|
+
sourceWorkerId: this.workerId,
|
|
702
|
+
targetWorkerId,
|
|
703
|
+
topic,
|
|
704
|
+
topicKey,
|
|
705
|
+
generation
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Accept a graceful handoff only when the route still points to this worker,
|
|
710
|
+
* the release comes from the recorded previous owner, and the generation is
|
|
711
|
+
* at least as new as ours. Any other ROUTE_RELEASED is stale and dropped.
|
|
712
|
+
*/
|
|
713
|
+
handleRouteReleasedMessage(message) {
|
|
714
|
+
if (message.targetWorkerId !== this.workerId) return;
|
|
715
|
+
const route = this.readRoute(message.topicKey);
|
|
716
|
+
if (!route || this.isStaleRouteRelease(route, message)) return;
|
|
717
|
+
this.assignedTopics.set(message.topicKey, message.topic);
|
|
718
|
+
this.confirmRoute(message.topicKey);
|
|
719
|
+
this.handlers.onControl("SUBSCRIBE", message.topic, void 0);
|
|
720
|
+
this.updateLoad();
|
|
721
|
+
}
|
|
722
|
+
/** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
|
|
723
|
+
* points to us, the release comes from the recorded previous owner, and
|
|
724
|
+
* the release generation is at least as new as ours. */
|
|
725
|
+
isStaleRouteRelease(route, message) {
|
|
726
|
+
return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || route.generation < message.generation;
|
|
727
|
+
}
|
|
728
|
+
/** Full reconciliation cycle: workers, subscriptions, and assigned topics. */
|
|
729
|
+
reconcile() {
|
|
730
|
+
if (!this.started) return;
|
|
731
|
+
const workers = this.reconcileWorkers();
|
|
732
|
+
const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
|
|
733
|
+
this.reconcileSubscriptions(workers, activeWorkers);
|
|
734
|
+
this.reconcileAssignedTopics();
|
|
735
|
+
this.updateLoad();
|
|
736
|
+
}
|
|
737
|
+
/** Prune stale workers/subscribers/routes and refresh role. Returns the live worker list.
|
|
738
|
+
* Subscribers are cleaned before routes so cleanupOrphanedRoutes sees the
|
|
739
|
+
* updated subscriber set when deciding whether a route is truly orphaned. */
|
|
740
|
+
reconcileWorkers() {
|
|
741
|
+
const workers = this.readWorkers();
|
|
742
|
+
this.cleanupOrphanedSubscribers(workers);
|
|
743
|
+
this.cleanupOrphanedRoutes(workers);
|
|
744
|
+
const roleChanged = this.refreshRole(workers);
|
|
745
|
+
if (roleChanged) this.writeRecord(false);
|
|
746
|
+
return workers;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Ensure every local subscription has a route and write subscriber records.
|
|
750
|
+
*
|
|
751
|
+
* Existing routes are deliberately sticky while their owner Worker is alive.
|
|
752
|
+
* Load and visibility only influence placement of a new route; they must not
|
|
753
|
+
* move an already-subscribed Topic merely because another Tab joins or becomes
|
|
754
|
+
* visible. Ownership changes only after the owner leaves or its heartbeat
|
|
755
|
+
* expires, which avoids unnecessary transport subscribe/unsubscribe churn.
|
|
756
|
+
*/
|
|
757
|
+
reconcileSubscriptions(workers, activeWorkers) {
|
|
758
|
+
const liveWorkerIds = new Set(workers.map((worker) => worker.workerId));
|
|
759
|
+
for (const topic of this.subscribedTopics) {
|
|
760
|
+
const topicKey = this.rememberTopic(topic);
|
|
761
|
+
this.writeSubscriber(topicKey);
|
|
762
|
+
const route = this.readRoute(topicKey);
|
|
763
|
+
if (!route || !liveWorkerIds.has(route.workerId)) {
|
|
764
|
+
const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
|
|
765
|
+
this.writeRoute(topicKey, owner, void 0, (route?.generation ?? 0) + 1);
|
|
766
|
+
this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
|
|
767
|
+
this.notifyRegistry();
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (route.confirmedAt === void 0) {
|
|
771
|
+
if (!route.handoffFromWorkerId) {
|
|
772
|
+
this.sendControl(route.workerId, "SUBSCRIBE", topic, topicKey);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
/** Drop assignments where the route no longer points to this worker. */
|
|
778
|
+
reconcileAssignedTopics() {
|
|
779
|
+
for (const [topicKey, topic] of [...this.assignedTopics]) {
|
|
780
|
+
const route = this.readRoute(topicKey);
|
|
781
|
+
if (route?.workerId === this.workerId) continue;
|
|
782
|
+
this.assignedTopics.delete(topicKey);
|
|
783
|
+
this.handlers.onControl("UNSUBSCRIBE", topic, void 0);
|
|
784
|
+
if (route?.handoffFromWorkerId === this.workerId) {
|
|
785
|
+
this.sendRouteReleased(route.workerId, topic, topicKey, route.generation);
|
|
786
|
+
}
|
|
787
|
+
if (!this.subscribedTopics.has(topic)) this.knownTopics.delete(topicKey);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Send a control message to `targetWorkerId`, or execute locally when targeting self.
|
|
792
|
+
* Local execution updates the assignment map and route synchronously, bypassing
|
|
793
|
+
* the BroadcastChannel latency.
|
|
794
|
+
*/
|
|
795
|
+
sendControl(targetWorkerId, action, topic, topicKey, data) {
|
|
796
|
+
if (targetWorkerId === this.workerId) {
|
|
797
|
+
switch (action) {
|
|
798
|
+
case "SUBSCRIBE":
|
|
799
|
+
this.assignedTopics.set(topicKey, topic);
|
|
800
|
+
this.confirmRoute(topicKey);
|
|
801
|
+
break;
|
|
802
|
+
case "UNSUBSCRIBE":
|
|
803
|
+
this.assignedTopics.delete(topicKey);
|
|
804
|
+
break;
|
|
805
|
+
case "PUBLISH":
|
|
806
|
+
default:
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
809
|
+
this.handlers.onControl(action, topic, data);
|
|
810
|
+
if (action !== "PUBLISH") this.updateLoad();
|
|
811
|
+
return true;
|
|
812
|
+
}
|
|
813
|
+
return this.send({
|
|
814
|
+
type: "CONTROL",
|
|
815
|
+
sourceWorkerId: this.workerId,
|
|
816
|
+
targetWorkerId,
|
|
817
|
+
action,
|
|
818
|
+
topic,
|
|
819
|
+
topicKey,
|
|
820
|
+
...data === void 0 ? {} : { data }
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
/** Post a message on the BroadcastChannel. Returns false on postMessage failure. */
|
|
824
|
+
send(message) {
|
|
825
|
+
if (!this.channel) return false;
|
|
826
|
+
try {
|
|
827
|
+
this.channel.postMessage(message);
|
|
828
|
+
return true;
|
|
829
|
+
} catch {
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
/** Read all live worker records from storage, pruning stale entries past the TTL. */
|
|
834
|
+
readWorkers() {
|
|
835
|
+
if (!this.storage) return [this.currentRecord];
|
|
836
|
+
const now = this.environment.now();
|
|
837
|
+
const workers = [];
|
|
838
|
+
for (const { key, value: worker } of readAllByPrefix(this.storage, this.workerPrefix)) {
|
|
839
|
+
if (worker.workerId !== this.workerId && now - worker.heartbeatAt > this.workerTtlMs) {
|
|
840
|
+
this.removeStorage(key);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
workers.push(worker);
|
|
844
|
+
}
|
|
845
|
+
if (this.started && !workers.some((worker) => worker.workerId === this.workerId)) workers.push(this.currentRecord);
|
|
846
|
+
return workers;
|
|
847
|
+
}
|
|
848
|
+
/** Enumerate all tab IDs that have a subscriber record for `topicKey`. */
|
|
849
|
+
readSubscriberTabIds(topicKey, workers) {
|
|
850
|
+
if (!this.storage) {
|
|
851
|
+
const topic = this.knownTopics.get(topicKey);
|
|
852
|
+
return topic && this.subscribedTopics.has(topic) ? [this.tabId] : [];
|
|
853
|
+
}
|
|
854
|
+
const activeTabIds = new Set(workers.map((worker) => worker.tabId));
|
|
855
|
+
const subscribers = /* @__PURE__ */ new Set();
|
|
856
|
+
for (const { key, value: record } of readAllByPrefix(
|
|
857
|
+
this.storage,
|
|
858
|
+
`${this.subscriberPrefix}${topicKey}:`
|
|
859
|
+
)) {
|
|
860
|
+
if (!activeTabIds.has(record.tabId)) {
|
|
861
|
+
this.removeStorage(key);
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
subscribers.add(record.tabId);
|
|
865
|
+
}
|
|
866
|
+
return Array.from(subscribers);
|
|
867
|
+
}
|
|
868
|
+
/** Read the current route for `topicKey`, returning null when no storage layer exists. */
|
|
869
|
+
readRoute(topicKey) {
|
|
870
|
+
if (!this.storage) return this.buildLocalRoute(topicKey);
|
|
871
|
+
return readJson(this.storage, this.routeStorageKey(topicKey));
|
|
872
|
+
}
|
|
873
|
+
/** Synthesize a self-owned route when storage is unavailable (degraded mode).
|
|
874
|
+
* The plaintext topic must be recoverable from the knownTopics cache; a
|
|
875
|
+
* missing entry means we never subscribed to or were assigned the topic,
|
|
876
|
+
* so there is no route to report. */
|
|
877
|
+
buildLocalRoute(topicKey) {
|
|
878
|
+
const topic = this.knownTopics.get(topicKey);
|
|
879
|
+
if (!topic) return null;
|
|
880
|
+
if (!this.subscribedTopics.has(topic) && !this.assignedTopics.has(topicKey)) return null;
|
|
881
|
+
return {
|
|
882
|
+
topicKey,
|
|
883
|
+
workerId: this.workerId,
|
|
884
|
+
tabId: this.tabId,
|
|
885
|
+
updatedAt: this.environment.now(),
|
|
886
|
+
generation: 1
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
/** Persist a route assignment, mapping `topicKey` to the owning Worker. */
|
|
890
|
+
writeRoute(topicKey, owner, handoffFromWorkerId, generation = 1) {
|
|
891
|
+
if (!this.storage) return;
|
|
892
|
+
writeJson(this.storage, this.routeStorageKey(topicKey), this.buildRouteRecord(topicKey, owner, handoffFromWorkerId, generation));
|
|
893
|
+
}
|
|
894
|
+
/** Construct a WorkerRoute record from the owner + handoff fields. Extracted
|
|
895
|
+
* so writeRoute and confirmRoute share the same shape; confirmedAt is added
|
|
896
|
+
* by confirmRoute via spread. */
|
|
897
|
+
buildRouteRecord(topicKey, owner, handoffFromWorkerId, generation) {
|
|
898
|
+
return {
|
|
899
|
+
topicKey,
|
|
900
|
+
workerId: owner.workerId,
|
|
901
|
+
tabId: owner.tabId,
|
|
902
|
+
updatedAt: this.environment.now(),
|
|
903
|
+
generation,
|
|
904
|
+
...handoffFromWorkerId ? { handoffFromWorkerId } : {}
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
/** Stamp a route as confirmed once the owning Worker has acknowledged the assignment. */
|
|
908
|
+
confirmRoute(topicKey) {
|
|
909
|
+
if (!this.storage) return;
|
|
910
|
+
const route = this.readRoute(topicKey);
|
|
911
|
+
if (!route || route.workerId !== this.workerId || route.confirmedAt !== void 0) return;
|
|
912
|
+
writeJson(this.storage, this.routeStorageKey(topicKey), {
|
|
913
|
+
...route,
|
|
914
|
+
confirmedAt: this.environment.now()
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
/** Remove routes whose topic has no subscribers and whose TTL has expired. */
|
|
918
|
+
cleanupOrphanedRoutes(workers) {
|
|
919
|
+
if (!this.storage) return;
|
|
920
|
+
const now = this.environment.now();
|
|
921
|
+
for (const { key, value: route } of readAllByPrefix(this.storage, this.routePrefix)) {
|
|
922
|
+
if (now - route.updatedAt <= this.workerTtlMs) continue;
|
|
923
|
+
if (this.readSubscriberTabIds(route.topicKey, workers).length > 0) continue;
|
|
924
|
+
this.removeStorage(key);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/** Remove subscriber records for tabs that are no longer active. */
|
|
928
|
+
cleanupOrphanedSubscribers(workers) {
|
|
929
|
+
if (!this.storage) return;
|
|
930
|
+
const activeTabIds = new Set(workers.map((worker) => worker.tabId));
|
|
931
|
+
for (const { key, value: record } of readAllByPrefix(this.storage, this.subscriberPrefix)) {
|
|
932
|
+
if (!activeTabIds.has(record.tabId)) this.removeStorage(key);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/** Persist a subscriber record for this tab on `topicKey`. */
|
|
936
|
+
writeSubscriber(topicKey) {
|
|
937
|
+
if (!this.storage) return;
|
|
938
|
+
writeJson(this.storage, this.subscriberStorageKey(topicKey, this.tabId), {
|
|
939
|
+
tabId: this.tabId,
|
|
940
|
+
updatedAt: this.environment.now()
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
/** Persist the current worker record with an updated heartbeat timestamp.
|
|
944
|
+
* @param notify — when true, broadcast a REGISTRY nudge so peers reconcile
|
|
945
|
+
* immediately instead of waiting for the next heartbeat. False on the
|
|
946
|
+
* periodic heartbeat tick (peers will notice on their own heartbeat) to
|
|
947
|
+
* avoid a REGISTRY storm every 3 s; true on status/role changes that
|
|
948
|
+
* peers should observe promptly. */
|
|
949
|
+
writeRecord(notify) {
|
|
950
|
+
this.currentRecord = { ...this.currentRecord, heartbeatAt: this.environment.now() };
|
|
951
|
+
if (this.storage) writeJson(this.storage, this.workerStorageKey(this.workerId), this.currentRecord);
|
|
952
|
+
if (notify) this.notifyRegistry();
|
|
953
|
+
}
|
|
954
|
+
/** Broadcast a REGISTRY message to trigger reconciliation on other tabs. */
|
|
955
|
+
notifyRegistry() {
|
|
956
|
+
this.send({ type: "REGISTRY", sourceWorkerId: this.workerId });
|
|
957
|
+
}
|
|
958
|
+
/** Recompute whether this worker is active (eligible to own topics) or standby. Returns true when changed. */
|
|
959
|
+
refreshRole(workers) {
|
|
960
|
+
const role = this.isActiveAmong(workers) ? "active" : "standby";
|
|
961
|
+
if (role === this.currentRecord.role) return false;
|
|
962
|
+
this.currentRecord = { ...this.currentRecord, role };
|
|
963
|
+
return true;
|
|
964
|
+
}
|
|
965
|
+
/** Persist the current topic load count (number of assigned topics) for load-balanced routing. */
|
|
966
|
+
updateLoad() {
|
|
967
|
+
const load = this.assignedTopics.size;
|
|
968
|
+
if (load === this.currentRecord.load) return;
|
|
969
|
+
this.currentRecord = { ...this.currentRecord, load };
|
|
970
|
+
if (this.started) this.writeRecord(true);
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Hash `topic` into its opaque key and populate the reverse-lookup cache.
|
|
974
|
+
*
|
|
975
|
+
* Despite the name, this is NOT a cache lookup — it unconditionally writes
|
|
976
|
+
* the `topicKey → topic` pair. Hashing is cheap enough that a caller needing
|
|
977
|
+
* the key should always call this rather than check `knownTopics` first;
|
|
978
|
+
* the cache's FIFO eviction below keeps it bounded. Only `isAssigned`
|
|
979
|
+
* deliberately bypasses this (it must not pollute the cache on a read-only
|
|
980
|
+
* query), so if you add a new call site, prefer `rememberTopic` unless you
|
|
981
|
+
* have the same "read-only query" reason.
|
|
982
|
+
*/
|
|
983
|
+
rememberTopic(topic) {
|
|
984
|
+
const topicKey = createOpaqueKey(topic);
|
|
985
|
+
this.knownTopics.set(topicKey, topic);
|
|
986
|
+
if (this.knownTopics.size > MAX_KNOWN_TOPICS) {
|
|
987
|
+
for (const candidate of this.knownTopics.keys()) {
|
|
988
|
+
if (candidate === topicKey || this.assignedTopics.has(candidate)) continue;
|
|
989
|
+
this.knownTopics.delete(candidate);
|
|
990
|
+
break;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return topicKey;
|
|
994
|
+
}
|
|
995
|
+
workerStorageKey(workerId) {
|
|
996
|
+
return `${this.workerPrefix}${workerId}`;
|
|
997
|
+
}
|
|
998
|
+
routeStorageKey(topicKey) {
|
|
999
|
+
return `${this.routePrefix}${topicKey}`;
|
|
1000
|
+
}
|
|
1001
|
+
subscriberStorageKey(topicKey, tabId) {
|
|
1002
|
+
return `${this.subscriberPrefix}${topicKey}:${tabId}`;
|
|
1003
|
+
}
|
|
1004
|
+
removeStorage(key) {
|
|
1005
|
+
try {
|
|
1006
|
+
this.storage?.removeItem(key);
|
|
1007
|
+
} catch {
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
/** Force-flush any pending batched writes (used during shutdown/teardown). */
|
|
1011
|
+
flushStorage() {
|
|
1012
|
+
if (this.storage instanceof BatchingStorageWriter) this.storage.flush();
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
// src/core/trace.ts
|
|
1017
|
+
var DEFAULT_METRICS_INTERVAL_MS = 5e3;
|
|
1018
|
+
var MAX_PENDING_TOPICS = 1e3;
|
|
1019
|
+
var MAX_PENDING_MESSAGES_PER_TOPIC = 256;
|
|
1020
|
+
var LATENCY_BUCKET_COUNT = 20;
|
|
1021
|
+
var LATENCY_BUCKET_SIZE_MS = 50;
|
|
1022
|
+
var DataBusTraceReporter = class {
|
|
1023
|
+
enabled;
|
|
1024
|
+
mode;
|
|
1025
|
+
metricsIntervalMs;
|
|
1026
|
+
sink;
|
|
1027
|
+
now;
|
|
1028
|
+
intervalHandle = null;
|
|
1029
|
+
intervalStartedAt = 0;
|
|
1030
|
+
received = 0;
|
|
1031
|
+
dispatched = 0;
|
|
1032
|
+
latencySamples = 0;
|
|
1033
|
+
topics = /* @__PURE__ */ new Set();
|
|
1034
|
+
// Per-topic FIFO of received timestamps, used to compute dispatch latency.
|
|
1035
|
+
receivedAt = /* @__PURE__ */ new Map();
|
|
1036
|
+
// Bucketed histogram: bucket index = floor(delayMs / 50), capped at 19.
|
|
1037
|
+
latencyBuckets = new Array(LATENCY_BUCKET_COUNT).fill(0);
|
|
1038
|
+
latencySumMs = 0;
|
|
1039
|
+
constructor(options, now = Date.now) {
|
|
1040
|
+
this.enabled = options?.enabled ?? false;
|
|
1041
|
+
this.mode = options?.mode ?? "all";
|
|
1042
|
+
this.metricsIntervalMs = normalizeInterval(options?.metricsIntervalMs);
|
|
1043
|
+
this.sink = options?.sink ?? (() => void 0);
|
|
1044
|
+
this.now = now;
|
|
1045
|
+
}
|
|
1046
|
+
/** Start the periodic metrics flush interval. No-op when mode is 'events'
|
|
1047
|
+
* (no metrics to emit), when disabled, or when already running. */
|
|
1048
|
+
start() {
|
|
1049
|
+
if (!this.enabled || this.intervalHandle || this.mode === "events") return;
|
|
1050
|
+
this.intervalStartedAt = this.now();
|
|
1051
|
+
this.intervalHandle = setInterval(() => this.flush(), this.metricsIntervalMs);
|
|
1052
|
+
}
|
|
1053
|
+
/** Pause the metrics interval and reset accumulated counters. */
|
|
1054
|
+
pause() {
|
|
1055
|
+
if (this.intervalHandle) clearInterval(this.intervalHandle);
|
|
1056
|
+
this.intervalHandle = null;
|
|
1057
|
+
this.intervalStartedAt = 0;
|
|
1058
|
+
this.resetMetrics();
|
|
1059
|
+
}
|
|
1060
|
+
stop() {
|
|
1061
|
+
this.pause();
|
|
1062
|
+
}
|
|
1063
|
+
/** Record an instantaneous trace event (lifecycle, status, error, etc.). */
|
|
1064
|
+
event(event) {
|
|
1065
|
+
if (!this.enabled || this.mode === "metrics") return;
|
|
1066
|
+
this.emit({ ...event, timestamp: this.now() });
|
|
1067
|
+
}
|
|
1068
|
+
/** Record that a message was received on `topic`; stores its timestamp for latency tracking. */
|
|
1069
|
+
recordReceived(topic) {
|
|
1070
|
+
if (!this.metricsActive) return;
|
|
1071
|
+
this.received += 1;
|
|
1072
|
+
this.topics.add(topic);
|
|
1073
|
+
const queue = this.receivedAt.get(topic);
|
|
1074
|
+
if (!queue) {
|
|
1075
|
+
if (this.receivedAt.size >= MAX_PENDING_TOPICS) return;
|
|
1076
|
+
this.receivedAt.set(topic, [this.now()]);
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (queue.length >= MAX_PENDING_MESSAGES_PER_TOPIC) return;
|
|
1080
|
+
queue.push(this.now());
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Record that a received message will never be dispatched locally. Pops the
|
|
1084
|
+
* matching FIFO slot so a later dispatch on the same topic does not pair
|
|
1085
|
+
* with a stale receive timestamp.
|
|
1086
|
+
*/
|
|
1087
|
+
recordDiscarded(topic) {
|
|
1088
|
+
if (!this.metricsActive) return;
|
|
1089
|
+
const queue = this.receivedAt.get(topic);
|
|
1090
|
+
if (!queue) return;
|
|
1091
|
+
queue.shift();
|
|
1092
|
+
if (queue.length === 0) this.receivedAt.delete(topic);
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Record that a message was dispatched on `topic`. Pops the oldest receive
|
|
1096
|
+
* timestamp (FIFO) and increments the latency histogram. Dispatches without
|
|
1097
|
+
* a matching receive (e.g. broadcast fan-out from another tab) still count
|
|
1098
|
+
* as dispatched but do not produce a latency sample.
|
|
1099
|
+
*/
|
|
1100
|
+
recordDispatched(topic) {
|
|
1101
|
+
if (!this.metricsActive) return;
|
|
1102
|
+
this.dispatched += 1;
|
|
1103
|
+
this.topics.add(topic);
|
|
1104
|
+
const queue = this.receivedAt.get(topic);
|
|
1105
|
+
const receivedTimestamp = queue?.shift();
|
|
1106
|
+
if (queue && queue.length === 0) this.receivedAt.delete(topic);
|
|
1107
|
+
if (receivedTimestamp === void 0) return;
|
|
1108
|
+
this.latencySamples += 1;
|
|
1109
|
+
const delayMs = Math.max(0, this.now() - receivedTimestamp);
|
|
1110
|
+
const bucketIndex = Math.min(LATENCY_BUCKET_COUNT - 1, Math.floor(delayMs / LATENCY_BUCKET_SIZE_MS));
|
|
1111
|
+
this.latencyBuckets[bucketIndex] = (this.latencyBuckets[bucketIndex] ?? 0) + 1;
|
|
1112
|
+
this.latencySumMs += delayMs;
|
|
1113
|
+
}
|
|
1114
|
+
/** True when metrics recording is active: enabled and mode includes metrics.
|
|
1115
|
+
* Extracted so the four record / flush methods share one guard expression
|
|
1116
|
+
* instead of repeating `!this.enabled || this.mode === 'events'` at each. */
|
|
1117
|
+
get metricsActive() {
|
|
1118
|
+
return this.enabled && this.mode !== "events";
|
|
1119
|
+
}
|
|
1120
|
+
/** Emit the accumulated metrics snapshot if the interval is active. */
|
|
1121
|
+
flush() {
|
|
1122
|
+
if (!this.metricsActive) return;
|
|
1123
|
+
this.flushNow();
|
|
1124
|
+
}
|
|
1125
|
+
flushNow() {
|
|
1126
|
+
const timestamp = this.now();
|
|
1127
|
+
if (this.received > 0 || this.dispatched > 0) {
|
|
1128
|
+
const samples = this.latencySamples;
|
|
1129
|
+
this.emit({
|
|
1130
|
+
type: "message_metrics",
|
|
1131
|
+
durationMs: Math.max(0, timestamp - this.intervalStartedAt),
|
|
1132
|
+
received: this.received,
|
|
1133
|
+
dispatched: this.dispatched,
|
|
1134
|
+
topics: this.topics.size,
|
|
1135
|
+
dispatchSamples: samples,
|
|
1136
|
+
dispatchAvgMs: roundMs(samples === 0 ? 0 : this.latencySumMs / samples),
|
|
1137
|
+
// Percentiles are derived from the histogram, not sorted samples.
|
|
1138
|
+
dispatchP50Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.5)),
|
|
1139
|
+
dispatchP95Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.95)),
|
|
1140
|
+
dispatchMaxMs: roundMs(percentileMs(this.latencyBuckets, samples, 1)),
|
|
1141
|
+
timestamp
|
|
1142
|
+
});
|
|
1143
|
+
this.resetMetrics();
|
|
1144
|
+
}
|
|
1145
|
+
this.intervalStartedAt = timestamp;
|
|
1146
|
+
}
|
|
1147
|
+
resetMetrics() {
|
|
1148
|
+
this.received = 0;
|
|
1149
|
+
this.dispatched = 0;
|
|
1150
|
+
this.latencySamples = 0;
|
|
1151
|
+
this.topics.clear();
|
|
1152
|
+
this.receivedAt.clear();
|
|
1153
|
+
this.latencyBuckets.fill(0);
|
|
1154
|
+
this.latencySumMs = 0;
|
|
1155
|
+
}
|
|
1156
|
+
emit(event) {
|
|
1157
|
+
try {
|
|
1158
|
+
this.sink(event);
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
1161
|
+
console.warn("[cross-tab-worker-databus] trace sink threw:", error);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
function normalizeInterval(value) {
|
|
1167
|
+
if (value === void 0) return DEFAULT_METRICS_INTERVAL_MS;
|
|
1168
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1169
|
+
throw new RangeError("trace.metricsIntervalMs must be a positive finite number.");
|
|
1170
|
+
}
|
|
1171
|
+
return value;
|
|
1172
|
+
}
|
|
1173
|
+
function percentileMs(buckets, sampleCount, percentile) {
|
|
1174
|
+
if (sampleCount <= 0) return 0;
|
|
1175
|
+
const rank = Math.max(1, Math.ceil(percentile * sampleCount));
|
|
1176
|
+
let seen = 0;
|
|
1177
|
+
for (let index = 0; index < buckets.length; index += 1) {
|
|
1178
|
+
seen += buckets[index] ?? 0;
|
|
1179
|
+
if (seen >= rank) return (index + 0.5) * LATENCY_BUCKET_SIZE_MS;
|
|
1180
|
+
}
|
|
1181
|
+
return buckets.length * LATENCY_BUCKET_SIZE_MS;
|
|
1182
|
+
}
|
|
1183
|
+
function roundMs(value) {
|
|
1184
|
+
return Math.round(value * 10) / 10;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// src/core/data-bus.ts
|
|
1188
|
+
var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
|
|
1189
|
+
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
1190
|
+
var CrossTabDataBus = class _CrossTabDataBus {
|
|
1191
|
+
transport;
|
|
1192
|
+
cluster;
|
|
1193
|
+
// Map of topic → set of local subscribers.
|
|
1194
|
+
topicHandlers = /* @__PURE__ */ new Map();
|
|
1195
|
+
// Topics for which the transport has been asked to subscribe (used to avoid
|
|
1196
|
+
// duplicate subscribe calls during reconnection).
|
|
1197
|
+
transportSubscribedTopics = /* @__PURE__ */ new Set();
|
|
1198
|
+
statusHandlers = /* @__PURE__ */ new Set();
|
|
1199
|
+
errorHandlers = /* @__PURE__ */ new Set();
|
|
1200
|
+
// Bounded per-topic ring of recent dispatched publications. Null unless
|
|
1201
|
+
// replay is enabled — buffering is opt-in and must cost nothing otherwise.
|
|
1202
|
+
replayBuffers;
|
|
1203
|
+
replayMaxPerTopic;
|
|
1204
|
+
initialConfig;
|
|
1205
|
+
hasInitialConfig;
|
|
1206
|
+
trace;
|
|
1207
|
+
activeConfig;
|
|
1208
|
+
status = "disconnected";
|
|
1209
|
+
started = false;
|
|
1210
|
+
stopping = false;
|
|
1211
|
+
transportReady = false;
|
|
1212
|
+
// Last transport failure, retained so ready() can surface it to callers who
|
|
1213
|
+
// never awaited start() directly. Cleared on the next successful start.
|
|
1214
|
+
lastError = null;
|
|
1215
|
+
// Gate that serialises start/stop/suspend/resume — only one lifecycle
|
|
1216
|
+
// transition at a time. Resets to null once the operation settles.
|
|
1217
|
+
startPromise = null;
|
|
1218
|
+
// Timestamp of the last automatic transport recovery attempt.
|
|
1219
|
+
// Used to avoid a tight retry loop when the transport fails repeatedly.
|
|
1220
|
+
lastRecoveryAt = 0;
|
|
1221
|
+
// True while the tab is hidden so an in-flight transport start does not mark
|
|
1222
|
+
// the transport ready after suspendTransport() has stopped it.
|
|
1223
|
+
suspended = false;
|
|
1224
|
+
// Single gate for async transport.stop() cleanup, shared by failed opens and
|
|
1225
|
+
// page-hide suspension. Kept separate from startPromise so ready() still
|
|
1226
|
+
// surfaces a failure while later opens and automatic recovery wait for the
|
|
1227
|
+
// stop to settle.
|
|
1228
|
+
pendingStop = null;
|
|
1229
|
+
// Minimum interval in ms between automatic recovery attempts.
|
|
1230
|
+
static RECOVERY_COOLDOWN_MS = 1e3;
|
|
1231
|
+
constructor(options) {
|
|
1232
|
+
const replay = options.replay;
|
|
1233
|
+
if (replay) {
|
|
1234
|
+
const maxPerTopic = replay.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
|
|
1235
|
+
if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
|
|
1236
|
+
throw new TypeError(
|
|
1237
|
+
`replay.maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
this.replayMaxPerTopic = replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
|
|
1242
|
+
this.replayBuffers = replay ? /* @__PURE__ */ new Map() : null;
|
|
1243
|
+
const { autoStart, initialConfig, trace, transport, ...clusterOptions } = options;
|
|
1244
|
+
this.transport = transport;
|
|
1245
|
+
this.initialConfig = initialConfig;
|
|
1246
|
+
this.hasInitialConfig = "initialConfig" in options;
|
|
1247
|
+
this.trace = new DataBusTraceReporter(trace);
|
|
1248
|
+
this.cluster = new WorkerClusterRuntime({
|
|
1249
|
+
...clusterOptions,
|
|
1250
|
+
handlers: {
|
|
1251
|
+
// The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
|
|
1252
|
+
// control message — meaning the owning Worker has delegated the action to us.
|
|
1253
|
+
onControl: (action, topic, data) => {
|
|
1254
|
+
switch (action) {
|
|
1255
|
+
case "SUBSCRIBE":
|
|
1256
|
+
if (this.subscribeTransport(topic)) this.traceSubscription("subscribe", topic);
|
|
1257
|
+
break;
|
|
1258
|
+
case "UNSUBSCRIBE":
|
|
1259
|
+
if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
|
|
1260
|
+
break;
|
|
1261
|
+
case "PUBLISH":
|
|
1262
|
+
this.runTransport(() => this.transport.publish(topic, data));
|
|
1263
|
+
break;
|
|
1264
|
+
default:
|
|
1265
|
+
break;
|
|
1266
|
+
}
|
|
1267
|
+
},
|
|
1268
|
+
// The cluster calls `onEvent` when a publication broadcast arrives from
|
|
1269
|
+
// another tab. Dispatch locally if we have subscribers. The payload is
|
|
1270
|
+
// typed `unknown` at the cluster boundary (the cluster is transport-
|
|
1271
|
+
// agnostic); here we narrow it to DataBusMessage — the sender is our
|
|
1272
|
+
// own broadcastEvent call, which always posts a DataBusMessage.
|
|
1273
|
+
onEvent: (eventType, payload) => {
|
|
1274
|
+
if (eventType !== PUBLICATION_EVENT) return;
|
|
1275
|
+
const message = payload;
|
|
1276
|
+
if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
|
|
1277
|
+
},
|
|
1278
|
+
onSuspend: () => {
|
|
1279
|
+
if (!this.stopping) this.trace.event({ type: "lifecycle", action: "suspend" });
|
|
1280
|
+
this.trace.pause();
|
|
1281
|
+
this.suspendTransport();
|
|
1282
|
+
},
|
|
1283
|
+
onResume: () => {
|
|
1284
|
+
this.trace.event({ type: "lifecycle", action: "resume" });
|
|
1285
|
+
this.trace.start();
|
|
1286
|
+
this.resumeTransport();
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
if (autoStart ?? this.hasInitialConfig) this.ensureStarted();
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Start the DataBus with the given transport config.
|
|
1294
|
+
*
|
|
1295
|
+
* The first call starts the cluster and opens the transport. Concurrent calls
|
|
1296
|
+
* during an in-flight start return the same promise. Once the operation
|
|
1297
|
+
* settles (success or failure) the promise gate is cleared so a subsequent
|
|
1298
|
+
* start() or resumeTransport() can open a fresh lifecycle.
|
|
1299
|
+
*/
|
|
1300
|
+
start(config) {
|
|
1301
|
+
if (this.startPromise) return this.startPromise;
|
|
1302
|
+
if (this.started) return Promise.resolve();
|
|
1303
|
+
this.started = true;
|
|
1304
|
+
this.stopping = false;
|
|
1305
|
+
this.suspended = false;
|
|
1306
|
+
this.activeConfig = config;
|
|
1307
|
+
this.lastError = null;
|
|
1308
|
+
this.trace.event({ type: "lifecycle", action: "start" });
|
|
1309
|
+
this.trace.start();
|
|
1310
|
+
this.updateStatus("connecting");
|
|
1311
|
+
this.cluster.start();
|
|
1312
|
+
const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
|
|
1313
|
+
this.startPromise = opening;
|
|
1314
|
+
for (const topic of this.topicHandlers.keys()) {
|
|
1315
|
+
this.cluster.subscribe(topic);
|
|
1316
|
+
}
|
|
1317
|
+
const snapshot = this.cluster.getSnapshot();
|
|
1318
|
+
this.trace.event({
|
|
1319
|
+
type: "coordination",
|
|
1320
|
+
coordinated: snapshot.coordinated,
|
|
1321
|
+
activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
|
|
1322
|
+
workers: snapshot.workers.map(formatWorkerTrace),
|
|
1323
|
+
routes: snapshot.routes.map(formatRouteTrace)
|
|
1324
|
+
});
|
|
1325
|
+
void opening.then(
|
|
1326
|
+
() => {
|
|
1327
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
1328
|
+
},
|
|
1329
|
+
() => {
|
|
1330
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
1331
|
+
}
|
|
1332
|
+
);
|
|
1333
|
+
return opening;
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Open the transport, chained after `before` to ensure lifecycle ordering.
|
|
1337
|
+
* When `stopClusterOnFailure` is true (initial start), a transport failure
|
|
1338
|
+
* tears down the cluster as well.
|
|
1339
|
+
*/
|
|
1340
|
+
openTransport(config, before, stopClusterOnFailure) {
|
|
1341
|
+
this.transportReady = false;
|
|
1342
|
+
const chainedPendingStop = this.pendingStop;
|
|
1343
|
+
return before.catch(() => void 0).then(() => {
|
|
1344
|
+
if (this.stopping || this.suspended) return;
|
|
1345
|
+
if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
|
|
1346
|
+
return Promise.resolve(
|
|
1347
|
+
this.transport.start(config, {
|
|
1348
|
+
onMessage: (message) => this.handleTransportMessage(message),
|
|
1349
|
+
onStatus: (status) => this.updateStatus(status),
|
|
1350
|
+
onError: (error) => this.reportError(error)
|
|
1351
|
+
})
|
|
1352
|
+
).then(() => {
|
|
1353
|
+
if (this.status === "error") {
|
|
1354
|
+
throw new Error("Transport failed during startup.");
|
|
1355
|
+
}
|
|
1356
|
+
if (!this.suspended && !this.stopping) this.transportReady = true;
|
|
1357
|
+
});
|
|
1358
|
+
}).catch((error) => {
|
|
1359
|
+
if (stopClusterOnFailure) this.started = false;
|
|
1360
|
+
if (!this.pendingStop) {
|
|
1361
|
+
this.pendingStop = this.createStopPromise();
|
|
1362
|
+
}
|
|
1363
|
+
this.updateStatus("error");
|
|
1364
|
+
this.reportError(error);
|
|
1365
|
+
this.lastError = error;
|
|
1366
|
+
this.transportReady = false;
|
|
1367
|
+
if (stopClusterOnFailure) {
|
|
1368
|
+
this.stopping = true;
|
|
1369
|
+
this.cluster.stop();
|
|
1370
|
+
this.stopping = false;
|
|
1371
|
+
}
|
|
1372
|
+
this.startPromise = null;
|
|
1373
|
+
throw error;
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Await the DataBus to be fully started (lazy init when using initialConfig).
|
|
1378
|
+
* Returns a rejected promise when the transport has failed and no start is in
|
|
1379
|
+
* flight — the caller can retry by calling start() or ready() again.
|
|
1380
|
+
*/
|
|
1381
|
+
ready() {
|
|
1382
|
+
try {
|
|
1383
|
+
this.ensureStarted();
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
return Promise.reject(error);
|
|
1386
|
+
}
|
|
1387
|
+
if (this.startPromise) return this.startPromise;
|
|
1388
|
+
if (this.transportReady) return Promise.resolve();
|
|
1389
|
+
if (this.lastError !== null) return Promise.reject(this.lastError);
|
|
1390
|
+
return Promise.reject(
|
|
1391
|
+
new Error("Transport is not ready and no start operation is in flight")
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Register a handler for `topic`. The handler fires on every publication
|
|
1396
|
+
* delivered to this tab, regardless of which tab published it. Returns an
|
|
1397
|
+
* unsubscribe function for convenience.
|
|
1398
|
+
*/
|
|
1399
|
+
subscribe(topic, handler, options) {
|
|
1400
|
+
this.ensureStarted();
|
|
1401
|
+
const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
|
|
1402
|
+
const wasUnused = handlers.size === 0;
|
|
1403
|
+
handlers.add(handler);
|
|
1404
|
+
this.topicHandlers.set(topic, handlers);
|
|
1405
|
+
if (wasUnused) this.cluster.subscribe(topic);
|
|
1406
|
+
if (options?.replay) {
|
|
1407
|
+
const limit = Math.min(
|
|
1408
|
+
typeof options.replay === "number" ? Math.floor(options.replay) : this.replayMaxPerTopic,
|
|
1409
|
+
this.replayMaxPerTopic
|
|
1410
|
+
);
|
|
1411
|
+
this.deliverReplay(topic, limit, handler);
|
|
1412
|
+
}
|
|
1413
|
+
return () => this.unsubscribe(topic, handler);
|
|
1414
|
+
}
|
|
1415
|
+
/** Remove a specific handler, or all handlers for `topic`.
|
|
1416
|
+
* When `handler` is omitted, clears every handler for the topic — the
|
|
1417
|
+
* caller used the `unsubscribe(topic)` form expecting a full teardown.
|
|
1418
|
+
* The cluster is only notified on the n→0 transition (handlers.size === 0). */
|
|
1419
|
+
unsubscribe(topic, handler) {
|
|
1420
|
+
const handlers = this.topicHandlers.get(topic);
|
|
1421
|
+
if (!handlers) return;
|
|
1422
|
+
if (handler) handlers.delete(handler);
|
|
1423
|
+
else handlers.clear();
|
|
1424
|
+
if (handlers.size > 0) return;
|
|
1425
|
+
this.topicHandlers.delete(topic);
|
|
1426
|
+
this.replayBuffers?.delete(topic);
|
|
1427
|
+
this.cluster.unsubscribe(topic);
|
|
1428
|
+
}
|
|
1429
|
+
/** Publish a message to `topic`. The owning Worker delivers it to the transport. */
|
|
1430
|
+
publish(topic, data) {
|
|
1431
|
+
this.ensureStarted();
|
|
1432
|
+
if (!this.cluster.publish(topic, data)) {
|
|
1433
|
+
this.reportError(
|
|
1434
|
+
new Error("Failed to send the publish control message to the owning worker.")
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
/** Register a handler that fires on every transport status change. Immediately invoked with the current status. */
|
|
1439
|
+
onStatus(handler) {
|
|
1440
|
+
this.statusHandlers.add(handler);
|
|
1441
|
+
try {
|
|
1442
|
+
handler(this.status);
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
this.reportError(error);
|
|
1445
|
+
}
|
|
1446
|
+
return () => this.statusHandlers.delete(handler);
|
|
1447
|
+
}
|
|
1448
|
+
/** Register a handler for transport errors. */
|
|
1449
|
+
onError(handler) {
|
|
1450
|
+
this.errorHandlers.add(handler);
|
|
1451
|
+
return () => this.errorHandlers.delete(handler);
|
|
1452
|
+
}
|
|
1453
|
+
/** Current transport connection status. */
|
|
1454
|
+
getStatus() {
|
|
1455
|
+
return this.status;
|
|
1456
|
+
}
|
|
1457
|
+
/** Snapshot of the cluster state (workers, routes, assignments).
|
|
1458
|
+
* For diagnostics only — the returned object is a shallow copy but
|
|
1459
|
+
* nested arrays are snapshots at call time. */
|
|
1460
|
+
getClusterSnapshot() {
|
|
1461
|
+
return this.cluster.getSnapshot();
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
|
|
1465
|
+
* and close the transport. Idempotent.
|
|
1466
|
+
*/
|
|
1467
|
+
async stop() {
|
|
1468
|
+
if (!this.started) return;
|
|
1469
|
+
this.stopping = true;
|
|
1470
|
+
this.trace.event({ type: "lifecycle", action: "stop" });
|
|
1471
|
+
this.trace.stop();
|
|
1472
|
+
this.topicHandlers.clear();
|
|
1473
|
+
this.replayBuffers?.clear();
|
|
1474
|
+
this.cluster.stop();
|
|
1475
|
+
try {
|
|
1476
|
+
await this.startPromise?.catch(() => void 0);
|
|
1477
|
+
const pendingStop = this.pendingStop;
|
|
1478
|
+
if (pendingStop) await pendingStop.catch(() => void 0);
|
|
1479
|
+
else await this.transport.stop();
|
|
1480
|
+
} finally {
|
|
1481
|
+
this.transportSubscribedTopics.clear();
|
|
1482
|
+
this.started = false;
|
|
1483
|
+
this.stopping = false;
|
|
1484
|
+
this.suspended = false;
|
|
1485
|
+
this.transportReady = false;
|
|
1486
|
+
this.startPromise = null;
|
|
1487
|
+
this.pendingStop = null;
|
|
1488
|
+
this.lastError = null;
|
|
1489
|
+
this.activeConfig = void 0;
|
|
1490
|
+
this.updateStatus("disconnected");
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Incoming message from the transport.
|
|
1495
|
+
* Records metrics, checks ownership via the cluster, broadcasts to other tabs,
|
|
1496
|
+
* and dispatches locally.
|
|
1497
|
+
*/
|
|
1498
|
+
handleTransportMessage(message) {
|
|
1499
|
+
this.trace.recordReceived(message.topic);
|
|
1500
|
+
if (!this.cluster.isAssigned(message.topic)) {
|
|
1501
|
+
this.trace.recordDiscarded(message.topic);
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
this.cluster.broadcastEvent(PUBLICATION_EVENT, message);
|
|
1505
|
+
if (this.cluster.hasLocalSubscriber(message.topic)) {
|
|
1506
|
+
this.dispatch(message);
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
this.trace.recordDiscarded(message.topic);
|
|
1510
|
+
}
|
|
1511
|
+
/** Deliver a message to every local handler registered for its topic,
|
|
1512
|
+
* plus every handler registered with a wildcard subscription that matches
|
|
1513
|
+
* (e.g. a handler subscribed to "chat.*" receives "chat.room.1"). */
|
|
1514
|
+
dispatch(message) {
|
|
1515
|
+
this.trace.recordDispatched(message.topic);
|
|
1516
|
+
this.invokeHandlers(this.topicHandlers.get(message.topic) ?? [], (handler) => handler(message));
|
|
1517
|
+
for (const [pattern, handlers] of this.topicHandlers) {
|
|
1518
|
+
if (pattern !== message.topic && topicMatchesPattern(pattern, message.topic)) {
|
|
1519
|
+
this.invokeHandlers(handlers, (handler) => handler(message));
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
this.recordReplay(message);
|
|
1523
|
+
}
|
|
1524
|
+
/** Append a dispatched publication to the topic's replay ring buffer.
|
|
1525
|
+
* No-op when replay is disabled. */
|
|
1526
|
+
recordReplay(message) {
|
|
1527
|
+
if (!this.replayBuffers) return;
|
|
1528
|
+
let buffer = this.replayBuffers.get(message.topic);
|
|
1529
|
+
if (!buffer) {
|
|
1530
|
+
buffer = [];
|
|
1531
|
+
this.replayBuffers.set(message.topic, buffer);
|
|
1532
|
+
}
|
|
1533
|
+
buffer.push(message);
|
|
1534
|
+
if (buffer.length > this.replayMaxPerTopic) buffer.shift();
|
|
1535
|
+
}
|
|
1536
|
+
/** Deliver buffered history to a newly-registered handler. For an exact
|
|
1537
|
+
* topic this is that topic's ring; for a wildcard subscription every
|
|
1538
|
+
* buffered topic matching the pattern contributes (in buffer insertion
|
|
1539
|
+
* order). Replay deliveries are marked `replayed: true` and are not
|
|
1540
|
+
* counted into trace metrics. */
|
|
1541
|
+
deliverReplay(topic, limit, handler) {
|
|
1542
|
+
if (!this.replayBuffers || limit <= 0) return;
|
|
1543
|
+
const deliver = (buffer2) => {
|
|
1544
|
+
for (const message of buffer2.slice(-limit)) {
|
|
1545
|
+
this.invokeHandlers([handler], (h) => h({ ...message, replayed: true }));
|
|
1546
|
+
}
|
|
1547
|
+
};
|
|
1548
|
+
if (isWildcardTopic(topic)) {
|
|
1549
|
+
for (const [bufferedTopic, buffer2] of this.replayBuffers) {
|
|
1550
|
+
if (topicMatchesPattern(topic, bufferedTopic)) deliver(buffer2);
|
|
1551
|
+
}
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
const buffer = this.replayBuffers.get(topic);
|
|
1555
|
+
if (buffer) deliver(buffer);
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Propagate a status change to the cluster, trace, and all registered
|
|
1559
|
+
* status handlers. On reconnect, re-subscribe any topics assigned to us.
|
|
1560
|
+
*/
|
|
1561
|
+
updateStatus(status) {
|
|
1562
|
+
const previousStatus = this.status;
|
|
1563
|
+
this.status = status;
|
|
1564
|
+
if (previousStatus !== status) this.trace.event({ type: "status", status });
|
|
1565
|
+
this.cluster.setStatus(status);
|
|
1566
|
+
if (status === "disconnected" || status === "error") this.transportSubscribedTopics.clear();
|
|
1567
|
+
if (status === "connected" && previousStatus !== "connected") {
|
|
1568
|
+
for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
|
|
1569
|
+
}
|
|
1570
|
+
if (status === "error" && this.started && !this.stopping) {
|
|
1571
|
+
const now = Date.now();
|
|
1572
|
+
if (now - this.lastRecoveryAt >= _CrossTabDataBus.RECOVERY_COOLDOWN_MS) {
|
|
1573
|
+
this.lastRecoveryAt = now;
|
|
1574
|
+
setTimeout(() => {
|
|
1575
|
+
if (this.stopping || !this.started || this.suspended) return;
|
|
1576
|
+
if (this.status !== "error") return;
|
|
1577
|
+
void this.reopenTransport();
|
|
1578
|
+
}, _CrossTabDataBus.RECOVERY_COOLDOWN_MS);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
|
|
1582
|
+
}
|
|
1583
|
+
reportError(error) {
|
|
1584
|
+
this.trace.event({ type: "error", source: "transport" });
|
|
1585
|
+
this.invokeHandlers(this.errorHandlers, (handler) => handler(error), "error handler");
|
|
1586
|
+
}
|
|
1587
|
+
traceSubscription(action, topic) {
|
|
1588
|
+
this.trace.event({
|
|
1589
|
+
type: "subscription",
|
|
1590
|
+
action,
|
|
1591
|
+
topic,
|
|
1592
|
+
activeTopics: this.transportSubscribedTopics.size
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
/** Ask the transport to subscribe to a topic (idempotent). */
|
|
1596
|
+
subscribeTransport(topic) {
|
|
1597
|
+
if (this.transportSubscribedTopics.has(topic)) return false;
|
|
1598
|
+
this.transportSubscribedTopics.add(topic);
|
|
1599
|
+
this.runTransport(() => this.transport.subscribe(topic));
|
|
1600
|
+
return true;
|
|
1601
|
+
}
|
|
1602
|
+
unsubscribeTransport(topic) {
|
|
1603
|
+
if (!this.transportSubscribedTopics.delete(topic)) return false;
|
|
1604
|
+
this.runTransport(() => this.transport.unsubscribe(topic));
|
|
1605
|
+
return true;
|
|
1606
|
+
}
|
|
1607
|
+
/** Invoke `callback` for each item in `handlers`, isolating a throwing
|
|
1608
|
+
* callback so the remaining ones still run. Dispatch/status handler failures
|
|
1609
|
+
* are routed to `reportError` (which surfaces them to error subscribers);
|
|
1610
|
+
* error-handler failures are logged to the console to avoid infinite
|
|
1611
|
+
* recursion through reportError itself. */
|
|
1612
|
+
invokeHandlers(handlers, callback, label = "dispatch") {
|
|
1613
|
+
for (const handler of handlers) {
|
|
1614
|
+
try {
|
|
1615
|
+
callback(handler);
|
|
1616
|
+
} catch (error) {
|
|
1617
|
+
if (label === "error handler") {
|
|
1618
|
+
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
1619
|
+
console.warn("[cross-tab-worker-databus] error handler threw:", error);
|
|
1620
|
+
}
|
|
1621
|
+
} else {
|
|
1622
|
+
this.reportError(error);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* Suspend the transport when the tab goes hidden. Stops the transport and
|
|
1629
|
+
* clears subscription state so it will be re-established on resume.
|
|
1630
|
+
*/
|
|
1631
|
+
suspendTransport() {
|
|
1632
|
+
if (this.stopping) return;
|
|
1633
|
+
this.suspended = true;
|
|
1634
|
+
this.transportReady = false;
|
|
1635
|
+
this.transportSubscribedTopics.clear();
|
|
1636
|
+
this.updateStatus("disconnected");
|
|
1637
|
+
if (this.pendingStop) return;
|
|
1638
|
+
const pending = this.startPromise ?? Promise.resolve();
|
|
1639
|
+
const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
|
|
1640
|
+
this.startPromise = stopping;
|
|
1641
|
+
this.pendingStop = stopping;
|
|
1642
|
+
}
|
|
1643
|
+
/** Create an immediate stop promise (no prior chain). Used by openTransport's
|
|
1644
|
+
* failure path where there is no in-flight start to wait for. */
|
|
1645
|
+
createStopPromise() {
|
|
1646
|
+
return Promise.resolve().then(() => this.transport.stop()).catch((stopError) => this.reportError(stopError));
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* Resume the transport when the tab becomes visible again, or recover from a
|
|
1650
|
+
* runtime transport failure. Re-opens the transport with the stored active
|
|
1651
|
+
* config, chained after any pending operation so an async transport stop
|
|
1652
|
+
* completes before the new start. Returns the opening promise.
|
|
1653
|
+
*/
|
|
1654
|
+
resumeTransport() {
|
|
1655
|
+
void this.reopenTransport();
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Re-open the transport with the previously stored active config. Chains
|
|
1659
|
+
* after any in-flight lifecycle operation (e.g. a suspend stop), swallowing
|
|
1660
|
+
* its rejection so the reopen is not blocked. Returns the opening promise so
|
|
1661
|
+
* callers can queue operations behind it.
|
|
1662
|
+
*/
|
|
1663
|
+
reopenTransport() {
|
|
1664
|
+
if (this.stopping || this.activeConfig === void 0) return Promise.resolve();
|
|
1665
|
+
if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
|
|
1666
|
+
const config = this.activeConfig;
|
|
1667
|
+
this.started = true;
|
|
1668
|
+
this.suspended = false;
|
|
1669
|
+
this.updateStatus("connecting");
|
|
1670
|
+
const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
|
|
1671
|
+
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
|
|
1672
|
+
this.startPromise = opening;
|
|
1673
|
+
void opening.then(
|
|
1674
|
+
() => {
|
|
1675
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
1676
|
+
},
|
|
1677
|
+
() => void 0
|
|
1678
|
+
);
|
|
1679
|
+
void opening.catch(() => void 0);
|
|
1680
|
+
return opening;
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1683
|
+
* Run a transport operation now if the transport is ready, otherwise queue
|
|
1684
|
+
* it behind the start promise. This ensures subscribe/unsubscribe calls made
|
|
1685
|
+
* during startup are not lost.
|
|
1686
|
+
*/
|
|
1687
|
+
runTransport(operation) {
|
|
1688
|
+
if (this.suspended) return;
|
|
1689
|
+
if (this.transportReady && !this.stopping) {
|
|
1690
|
+
try {
|
|
1691
|
+
void Promise.resolve(operation()).catch((error) => this.reportError(error));
|
|
1692
|
+
} catch (error) {
|
|
1693
|
+
this.reportError(error);
|
|
1694
|
+
}
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
let ready = this.startPromise;
|
|
1698
|
+
if (!ready && this.started && !this.stopping && this.activeConfig !== void 0) {
|
|
1699
|
+
ready = this.reopenTransport();
|
|
1700
|
+
}
|
|
1701
|
+
if (!ready || this.stopping) return;
|
|
1702
|
+
void ready.then(() => {
|
|
1703
|
+
if (!this.started || this.stopping || this.suspended) return;
|
|
1704
|
+
return operation();
|
|
1705
|
+
}).catch((error) => this.reportError(error));
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Ensure the DataBus is started, throwing if no initialConfig was provided.
|
|
1709
|
+
* Called automatically by subscribe/publish/ready when autoStart is true.
|
|
1710
|
+
*/
|
|
1711
|
+
ensureStarted() {
|
|
1712
|
+
if (this.started) return;
|
|
1713
|
+
if (!this.hasInitialConfig) {
|
|
1714
|
+
throw new Error(
|
|
1715
|
+
"CrossTabDataBus requires initialConfig for automatic startup, or an explicit start(config) call."
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
const starting = this.start(this.initialConfig);
|
|
1719
|
+
void starting.catch(() => void 0);
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
function formatWorkerTrace(worker) {
|
|
1723
|
+
return `${worker.workerId}|${worker.status}|load=${worker.load}|tab=${worker.tabId}`;
|
|
1724
|
+
}
|
|
1725
|
+
function formatRouteTrace(route) {
|
|
1726
|
+
return `${route.topicKey}@${route.workerId}|confirmed=${route.confirmedAt !== void 0}`;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// src/centrifuge-session.ts
|
|
1730
|
+
var import_centrifuge = require("centrifuge");
|
|
1731
|
+
var CentrifugeSession = class {
|
|
1732
|
+
constructor(sink) {
|
|
1733
|
+
this.sink = sink;
|
|
1734
|
+
}
|
|
1735
|
+
client = null;
|
|
1736
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
1737
|
+
transferable = false;
|
|
1738
|
+
/** Dispatch an incoming Worker message to the matching operation.
|
|
1739
|
+
* Unknown message types are ignored rather than thrown, so a future protocol
|
|
1740
|
+
* extension adding a new variant cannot crash an older session. */
|
|
1741
|
+
handle(message) {
|
|
1742
|
+
switch (message.type) {
|
|
1743
|
+
case "INIT":
|
|
1744
|
+
this.initialize(message.url, message.config, message.transferable === true);
|
|
1745
|
+
return;
|
|
1746
|
+
case "SUBSCRIBE":
|
|
1747
|
+
this.subscribe(message.topic);
|
|
1748
|
+
return;
|
|
1749
|
+
case "UNSUBSCRIBE":
|
|
1750
|
+
this.unsubscribe(message.topic);
|
|
1751
|
+
return;
|
|
1752
|
+
case "PUBLISH":
|
|
1753
|
+
case "PUBLISH_BIN":
|
|
1754
|
+
this.publish(message.topic, message.data);
|
|
1755
|
+
return;
|
|
1756
|
+
case "STOP":
|
|
1757
|
+
this.stop();
|
|
1758
|
+
return;
|
|
1759
|
+
default:
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
/** Create the Centrifuge client, wire up lifecycle listeners, and connect. */
|
|
1764
|
+
initialize(url, config, transferable) {
|
|
1765
|
+
if (this.client) return;
|
|
1766
|
+
this.transferable = transferable;
|
|
1767
|
+
const client = new import_centrifuge.Centrifuge(url, config);
|
|
1768
|
+
this.client = client;
|
|
1769
|
+
client.on("state", (context) => {
|
|
1770
|
+
this.post({ type: "STATUS", status: normalizeStatus(context.newState) });
|
|
1771
|
+
});
|
|
1772
|
+
client.on("connected", () => this.post({ type: "STATUS", status: "connected" }));
|
|
1773
|
+
client.on("disconnected", () => this.post({ type: "STATUS", status: "disconnected" }));
|
|
1774
|
+
client.on("error", (context) => this.postError(context));
|
|
1775
|
+
client.on("publication", (context) => {
|
|
1776
|
+
const topic = context.channel || getPayloadTopic(context.data);
|
|
1777
|
+
if (!topic || this.subscriptions.has(topic)) return;
|
|
1778
|
+
this.postPublication(topic, context.data);
|
|
1779
|
+
});
|
|
1780
|
+
client.connect();
|
|
1781
|
+
}
|
|
1782
|
+
/** Subscribe to a Centrifuge channel. Reuses an existing subscription if one exists.
|
|
1783
|
+
* Listeners are only registered once per subscription object — a repeated
|
|
1784
|
+
* SUBSCRIBE for an already-tracked topic skips the listener wiring entirely,
|
|
1785
|
+
* avoiding the removeAllListeners + re-on churn on every duplicate message. */
|
|
1786
|
+
subscribe(topic) {
|
|
1787
|
+
if (!this.client) return this.postError(new Error("Centrifuge client is not initialized."));
|
|
1788
|
+
const existing = this.subscriptions.get(topic);
|
|
1789
|
+
if (existing) {
|
|
1790
|
+
existing.subscribe();
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
let subscription = this.client.getSubscription(topic);
|
|
1794
|
+
if (!subscription) subscription = this.client.newSubscription(topic);
|
|
1795
|
+
subscription.removeAllListeners("publication");
|
|
1796
|
+
subscription.removeAllListeners("error");
|
|
1797
|
+
subscription.removeAllListeners("unsubscribed");
|
|
1798
|
+
this.subscriptions.set(topic, subscription);
|
|
1799
|
+
subscription.on("publication", (context) => {
|
|
1800
|
+
this.postPublication(topic, context.data);
|
|
1801
|
+
});
|
|
1802
|
+
subscription.on("error", (context) => this.postError(context));
|
|
1803
|
+
subscription.on("unsubscribed", () => this.subscriptions.delete(topic));
|
|
1804
|
+
subscription.subscribe();
|
|
1805
|
+
}
|
|
1806
|
+
/** Unsubscribe from a Centrifuge channel and clean up the local reference.
|
|
1807
|
+
* Listeners are removed before unsubscribing so a late `unsubscribed` event
|
|
1808
|
+
* cannot delete a subscription that a subsequent `subscribe()` re-added. */
|
|
1809
|
+
unsubscribe(topic) {
|
|
1810
|
+
const subscription = this.subscriptions.get(topic) ?? this.client?.getSubscription(topic);
|
|
1811
|
+
if (!subscription) return;
|
|
1812
|
+
subscription.removeAllListeners("publication");
|
|
1813
|
+
subscription.removeAllListeners("error");
|
|
1814
|
+
subscription.removeAllListeners("unsubscribed");
|
|
1815
|
+
this.subscriptions.delete(topic);
|
|
1816
|
+
subscription.unsubscribe();
|
|
1817
|
+
}
|
|
1818
|
+
/** Publish a message to the Centrifuge channel. */
|
|
1819
|
+
publish(topic, data) {
|
|
1820
|
+
if (!this.client) return this.postError(new Error("Centrifuge client is not initialized."));
|
|
1821
|
+
void this.client.publish(topic, data).catch((error) => this.postError(error));
|
|
1822
|
+
}
|
|
1823
|
+
/** Forward a publication to the transport. Binary payloads take the
|
|
1824
|
+
* zero-copy `MESSAGE_BIN` path when `transferable` is enabled; everything
|
|
1825
|
+
* else is structured-cloned via `MESSAGE`. An empty topic means the
|
|
1826
|
+
* publication carried no channel info and is silently dropped. */
|
|
1827
|
+
postPublication(topic, data) {
|
|
1828
|
+
if (!topic) return;
|
|
1829
|
+
if (this.transferable && data instanceof ArrayBuffer) {
|
|
1830
|
+
this.post({ type: "MESSAGE_BIN", topic, data }, [data]);
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
this.post({ type: "MESSAGE", topic, data });
|
|
1834
|
+
}
|
|
1835
|
+
/** Disconnect the client and clear all subscriptions. */
|
|
1836
|
+
stop() {
|
|
1837
|
+
this.client?.disconnect();
|
|
1838
|
+
this.subscriptions.clear();
|
|
1839
|
+
this.client = null;
|
|
1840
|
+
this.post({ type: "STATUS", status: "disconnected" });
|
|
1841
|
+
}
|
|
1842
|
+
/** Forward a message to the sink (the transport layer). */
|
|
1843
|
+
post(message, transfer) {
|
|
1844
|
+
this.sink.post(message, transfer);
|
|
1845
|
+
}
|
|
1846
|
+
/** Serialise and report an error. The Centrifuge client handles reconnection
|
|
1847
|
+
* internally, so a transient error should not trigger a `STATUS: error` that
|
|
1848
|
+
* would cause `selectActiveWorkers()` to exclude this worker from routing.
|
|
1849
|
+
* Fatal errors are distinguished by the client eventually emitting
|
|
1850
|
+
* `disconnected` without a subsequent `connected`. */
|
|
1851
|
+
postError(error) {
|
|
1852
|
+
this.post({ type: "ERROR", error: serializeError(error) });
|
|
1853
|
+
}
|
|
1854
|
+
};
|
|
1855
|
+
function getPayloadTopic(data) {
|
|
1856
|
+
if (!data || typeof data !== "object") return "";
|
|
1857
|
+
const payload = data;
|
|
1858
|
+
const push = payload.push;
|
|
1859
|
+
const nested = typeof push === "object" && push !== null ? push.channel : void 0;
|
|
1860
|
+
const topic = nested ?? payload.channel;
|
|
1861
|
+
return typeof topic === "string" ? topic : "";
|
|
1862
|
+
}
|
|
1863
|
+
var LIVE_STATES = /* @__PURE__ */ new Set(["connecting", "connected"]);
|
|
1864
|
+
function normalizeStatus(status) {
|
|
1865
|
+
return LIVE_STATES.has(status) ? status : "disconnected";
|
|
1866
|
+
}
|
|
1867
|
+
function serializeError(error) {
|
|
1868
|
+
if (error instanceof Error) {
|
|
1869
|
+
return {
|
|
1870
|
+
name: error.name,
|
|
1871
|
+
message: error.message,
|
|
1872
|
+
...error.stack ? { stack: error.stack } : {}
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
return {
|
|
1876
|
+
name: "CentrifugeError",
|
|
1877
|
+
message: typeof error === "string" ? error : "Centrifuge worker operation failed.",
|
|
1878
|
+
...error === void 0 ? {} : { context: error }
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// src/worker-mode.ts
|
|
1883
|
+
function selectWorkerBackend(mode, availability = {}) {
|
|
1884
|
+
const hasDedicated = availability.worker ?? typeof Worker !== "undefined";
|
|
1885
|
+
const hasShared = availability.sharedWorker ?? typeof SharedWorker !== "undefined";
|
|
1886
|
+
if (mode === "shared" || mode === "auto") {
|
|
1887
|
+
return hasShared ? "shared" : hasDedicated ? "dedicated" : "local";
|
|
1888
|
+
}
|
|
1889
|
+
return hasDedicated ? "dedicated" : hasShared ? "shared" : "local";
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// src/centrifuge-protocol.ts
|
|
1893
|
+
var DEFAULT_HEARTBEAT_INTERVAL_MS2 = 1e4;
|
|
1894
|
+
var DEFAULT_SESSION_TIMEOUT_MULTIPLIER = 3;
|
|
1895
|
+
var DEFAULT_SESSION_TIMEOUT_MS = DEFAULT_HEARTBEAT_INTERVAL_MS2 * DEFAULT_SESSION_TIMEOUT_MULTIPLIER;
|
|
1896
|
+
|
|
1897
|
+
// src/centrifuge.ts
|
|
1898
|
+
var import_meta = {};
|
|
1899
|
+
var CentrifugeWorkerTransport = class {
|
|
1900
|
+
workerMode;
|
|
1901
|
+
transferable;
|
|
1902
|
+
heartbeatIntervalMs;
|
|
1903
|
+
workerFactory;
|
|
1904
|
+
sharedWorkerFactory;
|
|
1905
|
+
backend = null;
|
|
1906
|
+
worker = null;
|
|
1907
|
+
sharedWorker = null;
|
|
1908
|
+
port = null;
|
|
1909
|
+
heartbeatHandle = null;
|
|
1910
|
+
localSession = null;
|
|
1911
|
+
handlers = null;
|
|
1912
|
+
// Monotonically increasing counter, bumped each time a backend is created.
|
|
1913
|
+
// Used to ignore late error events from a superseded Worker.
|
|
1914
|
+
generation = 0;
|
|
1915
|
+
// Generation captured when the current backend was created. Error handlers
|
|
1916
|
+
// only act when the backend that registered them is still current.
|
|
1917
|
+
backendGeneration = 0;
|
|
1918
|
+
constructor(options = {}) {
|
|
1919
|
+
this.workerMode = options.workerMode ?? "dedicated";
|
|
1920
|
+
this.transferable = options.transferable ?? false;
|
|
1921
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS2;
|
|
1922
|
+
assertHeartbeatInterval(this.heartbeatIntervalMs);
|
|
1923
|
+
this.workerFactory = options.workerFactory;
|
|
1924
|
+
this.sharedWorkerFactory = options.sharedWorkerFactory;
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Start the transport: select a backend, initialise the Worker (or local
|
|
1928
|
+
* session), and send the INIT message with connection parameters.
|
|
1929
|
+
*/
|
|
1930
|
+
start(config, handlers) {
|
|
1931
|
+
if (this.backend) return;
|
|
1932
|
+
assertStructuredCloneable(config.options ?? {});
|
|
1933
|
+
this.handlers = handlers;
|
|
1934
|
+
const backend = selectWorkerBackend(this.workerMode, {
|
|
1935
|
+
worker: this.workerFactory !== void 0,
|
|
1936
|
+
sharedWorker: this.sharedWorkerFactory !== void 0
|
|
1937
|
+
});
|
|
1938
|
+
const input = this.buildInitInput(config);
|
|
1939
|
+
if (backend === "shared") {
|
|
1940
|
+
this.startSharedWorker(input);
|
|
1941
|
+
this.backend = "shared";
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
if (backend === "dedicated") {
|
|
1945
|
+
this.startDedicatedWorker(input);
|
|
1946
|
+
this.backend = "dedicated";
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
this.localSession = new CentrifugeSession({ post: this.handleSessionOutput });
|
|
1950
|
+
this.localSession.handle(input);
|
|
1951
|
+
this.backend = "local";
|
|
1952
|
+
}
|
|
1953
|
+
subscribe(topic) {
|
|
1954
|
+
this.post({ type: "SUBSCRIBE", topic });
|
|
1955
|
+
}
|
|
1956
|
+
unsubscribe(topic) {
|
|
1957
|
+
this.post({ type: "UNSUBSCRIBE", topic });
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* Publish data to `topic`. Binary data (ArrayBuffer) is sent via Transferable
|
|
1961
|
+
* when `transferable` is enabled, avoiding a structured-clone cycle.
|
|
1962
|
+
*/
|
|
1963
|
+
publish(topic, data) {
|
|
1964
|
+
if (this.transferable && data instanceof ArrayBuffer) {
|
|
1965
|
+
this.post({ type: "PUBLISH_BIN", topic, data }, [data]);
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
this.post({ type: "PUBLISH", topic, data });
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* Gracefully stop the transport: send STOP, clean up event listeners, and
|
|
1972
|
+
* terminate the Worker (or close the SharedWorker port).
|
|
1973
|
+
*/
|
|
1974
|
+
stop() {
|
|
1975
|
+
if (!this.backend) return;
|
|
1976
|
+
this.generation++;
|
|
1977
|
+
this.post({ type: "STOP" });
|
|
1978
|
+
this.clearHeartbeat();
|
|
1979
|
+
if (this.worker) {
|
|
1980
|
+
this.worker.removeEventListener("message", this.handleMessage);
|
|
1981
|
+
this.worker.removeEventListener("error", this.handleWorkerError);
|
|
1982
|
+
this.worker.terminate();
|
|
1983
|
+
}
|
|
1984
|
+
if (this.sharedWorker) {
|
|
1985
|
+
this.detachSharedWorkerListeners();
|
|
1986
|
+
this.port?.close();
|
|
1987
|
+
}
|
|
1988
|
+
this.resetBackend();
|
|
1989
|
+
this.handlers = null;
|
|
1990
|
+
}
|
|
1991
|
+
/** Build the INIT payload sent to the Worker / local session. Optional fields
|
|
1992
|
+
* are only included when they deviate from the defaults, so the Worker's own
|
|
1993
|
+
* default-resolution logic kicks in for the common case. */
|
|
1994
|
+
buildInitInput(config) {
|
|
1995
|
+
return {
|
|
1996
|
+
type: "INIT",
|
|
1997
|
+
url: config.url,
|
|
1998
|
+
config: config.options ?? {},
|
|
1999
|
+
...this.transferable ? { transferable: true } : {},
|
|
2000
|
+
...this.heartbeatIntervalMs !== DEFAULT_HEARTBEAT_INTERVAL_MS2 ? { heartbeatIntervalMs: this.heartbeatIntervalMs } : {}
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
/** Create and initialise a dedicated Worker, then send the INIT message. */
|
|
2004
|
+
startDedicatedWorker(input) {
|
|
2005
|
+
this.backendGeneration = ++this.generation;
|
|
2006
|
+
const worker = (this.workerFactory ?? createDefaultWorker)();
|
|
2007
|
+
this.worker = worker;
|
|
2008
|
+
worker.addEventListener("message", this.handleMessage);
|
|
2009
|
+
worker.addEventListener("error", this.handleWorkerError);
|
|
2010
|
+
worker.postMessage(input);
|
|
2011
|
+
}
|
|
2012
|
+
/** Create and initialise a SharedWorker, open the MessagePort, and send the INIT message. */
|
|
2013
|
+
startSharedWorker(input) {
|
|
2014
|
+
this.backendGeneration = ++this.generation;
|
|
2015
|
+
const shared = (this.sharedWorkerFactory ?? createDefaultSharedWorker)();
|
|
2016
|
+
this.sharedWorker = shared;
|
|
2017
|
+
const port = shared.port;
|
|
2018
|
+
this.port = port;
|
|
2019
|
+
port.addEventListener("message", this.handleMessage);
|
|
2020
|
+
port.addEventListener("messageerror", this.handlePortError);
|
|
2021
|
+
shared.addEventListener("error", this.handleSharedWorkerError);
|
|
2022
|
+
port.start();
|
|
2023
|
+
port.postMessage(input);
|
|
2024
|
+
this.startHeartbeat();
|
|
2025
|
+
}
|
|
2026
|
+
/** Handle a message event from the Worker (dedicated or shared). */
|
|
2027
|
+
handleMessage = (event) => {
|
|
2028
|
+
this.handleOutput(event.data);
|
|
2029
|
+
};
|
|
2030
|
+
/** Handle a message from the in-process CentrifugeSession (local fallback). */
|
|
2031
|
+
handleSessionOutput = (message) => {
|
|
2032
|
+
this.handleOutput(message);
|
|
2033
|
+
};
|
|
2034
|
+
/** Route a Worker output message to the appropriate handler callback.
|
|
2035
|
+
* Shared by the Worker message listener, the SharedWorker port listener,
|
|
2036
|
+
* and the local-session sink — all three feed into this single dispatcher. */
|
|
2037
|
+
handleOutput(message) {
|
|
2038
|
+
if (message.type === "STATUS") this.handlers?.onStatus(message.status);
|
|
2039
|
+
if (message.type === "MESSAGE") this.handlers?.onMessage({ topic: message.topic, data: message.data });
|
|
2040
|
+
if (message.type === "MESSAGE_BIN") this.handlers?.onMessage({ topic: message.topic, data: message.data });
|
|
2041
|
+
if (message.type === "ERROR") this.handlers?.onError(deserializeWorkerError(message.error));
|
|
2042
|
+
}
|
|
2043
|
+
/** Handle a Worker-level failure (crash, message decode error). Discards the
|
|
2044
|
+
* dead backend so a later start()/reopen can rebuild from scratch, and
|
|
2045
|
+
* signals an error status so the DataBus can trigger recovery.
|
|
2046
|
+
* Only invoked when the generation guard confirms the failing backend is
|
|
2047
|
+
* still current — late errors from a superseded Worker are silently dropped. */
|
|
2048
|
+
onWorkerFailed(message) {
|
|
2049
|
+
this.worker?.removeEventListener("message", this.handleMessage);
|
|
2050
|
+
this.worker?.removeEventListener("error", this.handleWorkerError);
|
|
2051
|
+
this.worker?.terminate();
|
|
2052
|
+
this.detachSharedWorkerListeners();
|
|
2053
|
+
this.port?.close();
|
|
2054
|
+
this.clearHeartbeat();
|
|
2055
|
+
this.resetBackend();
|
|
2056
|
+
this.handlers?.onError(new Error(message));
|
|
2057
|
+
this.handlers?.onStatus("error");
|
|
2058
|
+
}
|
|
2059
|
+
/** Remove every listener attached to the current SharedWorker and its port. */
|
|
2060
|
+
detachSharedWorkerListeners() {
|
|
2061
|
+
this.port?.removeEventListener("message", this.handleMessage);
|
|
2062
|
+
this.port?.removeEventListener("messageerror", this.handlePortError);
|
|
2063
|
+
this.sharedWorker?.removeEventListener("error", this.handleSharedWorkerError);
|
|
2064
|
+
}
|
|
2065
|
+
/** Periodically ping the SharedWorker so its session reaper can detect a dead tab. */
|
|
2066
|
+
startHeartbeat() {
|
|
2067
|
+
if (this.heartbeatHandle !== null) return;
|
|
2068
|
+
if (this.heartbeatIntervalMs === Infinity) return;
|
|
2069
|
+
this.heartbeatHandle = setInterval(() => {
|
|
2070
|
+
this.post({ type: "PING" });
|
|
2071
|
+
}, this.heartbeatIntervalMs);
|
|
2072
|
+
}
|
|
2073
|
+
clearHeartbeat() {
|
|
2074
|
+
if (this.heartbeatHandle !== null) clearInterval(this.heartbeatHandle);
|
|
2075
|
+
this.heartbeatHandle = null;
|
|
2076
|
+
}
|
|
2077
|
+
handleWorkerError = () => {
|
|
2078
|
+
if (this.generation !== this.backendGeneration) return;
|
|
2079
|
+
this.onWorkerFailed("Centrifuge worker failed.");
|
|
2080
|
+
};
|
|
2081
|
+
handlePortError = () => {
|
|
2082
|
+
if (this.generation !== this.backendGeneration) return;
|
|
2083
|
+
this.onWorkerFailed("Centrifuge shared worker message decoding failed.");
|
|
2084
|
+
};
|
|
2085
|
+
handleSharedWorkerError = () => {
|
|
2086
|
+
if (this.generation !== this.backendGeneration) return;
|
|
2087
|
+
this.onWorkerFailed("Centrifuge shared worker failed.");
|
|
2088
|
+
};
|
|
2089
|
+
/** Clear the Worker/port/backend references after a failure or stop. */
|
|
2090
|
+
resetBackend() {
|
|
2091
|
+
this.worker = null;
|
|
2092
|
+
this.sharedWorker = null;
|
|
2093
|
+
this.port = null;
|
|
2094
|
+
this.backend = null;
|
|
2095
|
+
this.localSession = null;
|
|
2096
|
+
}
|
|
2097
|
+
/**
|
|
2098
|
+
* Post a message to the active backend. Accepts optional Transferable buffers
|
|
2099
|
+
* for zero-copy ArrayBuffer transfer.
|
|
2100
|
+
*/
|
|
2101
|
+
post(message, transfer) {
|
|
2102
|
+
if (this.worker) {
|
|
2103
|
+
postToPortLike(this.worker, message, transfer);
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
if (this.port) {
|
|
2107
|
+
postToPortLike(this.port, message, transfer);
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
if (this.localSession) {
|
|
2111
|
+
this.localSession.handle(message);
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
throw new Error("CentrifugeWorkerTransport.start() must be called first.");
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
function createCentrifugeDataBus(options) {
|
|
2118
|
+
const {
|
|
2119
|
+
clusterKey,
|
|
2120
|
+
connection,
|
|
2121
|
+
heartbeatIntervalMs,
|
|
2122
|
+
sharedWorkerFactory,
|
|
2123
|
+
transferable,
|
|
2124
|
+
workerFactory,
|
|
2125
|
+
workerMode,
|
|
2126
|
+
...dataBusOptions
|
|
2127
|
+
} = options;
|
|
2128
|
+
return new CrossTabDataBus({
|
|
2129
|
+
...dataBusOptions,
|
|
2130
|
+
autoStart: true,
|
|
2131
|
+
clusterKey: clusterKey ?? connection.url,
|
|
2132
|
+
initialConfig: connection,
|
|
2133
|
+
transport: new CentrifugeWorkerTransport({
|
|
2134
|
+
...workerFactory ? { workerFactory } : {},
|
|
2135
|
+
...sharedWorkerFactory ? { sharedWorkerFactory } : {},
|
|
2136
|
+
...transferable === void 0 ? {} : { transferable },
|
|
2137
|
+
...workerMode ? { workerMode } : {},
|
|
2138
|
+
...heartbeatIntervalMs === void 0 ? {} : { heartbeatIntervalMs }
|
|
2139
|
+
})
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
function createDefaultWorker() {
|
|
2143
|
+
if (typeof Worker === "undefined") {
|
|
2144
|
+
throw new Error("CentrifugeWorkerTransport requires a browser Worker implementation.");
|
|
2145
|
+
}
|
|
2146
|
+
let workerUrl;
|
|
2147
|
+
try {
|
|
2148
|
+
workerUrl = new URL("./centrifuge.worker.js", import_meta.url);
|
|
2149
|
+
} catch {
|
|
2150
|
+
throw new Error(
|
|
2151
|
+
"The default Centrifuge Worker URL is unavailable in this module format; provide workerFactory explicitly."
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
return new Worker(workerUrl, {
|
|
2155
|
+
name: "cross-tab-worker-databus",
|
|
2156
|
+
type: "module"
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
function createDefaultSharedWorker() {
|
|
2160
|
+
if (typeof SharedWorker === "undefined") {
|
|
2161
|
+
throw new Error("CentrifugeWorkerTransport requires a browser SharedWorker implementation.");
|
|
2162
|
+
}
|
|
2163
|
+
let workerUrl;
|
|
2164
|
+
try {
|
|
2165
|
+
workerUrl = new URL("./centrifuge.shared.worker.js", import_meta.url);
|
|
2166
|
+
} catch {
|
|
2167
|
+
throw new Error(
|
|
2168
|
+
"The default Centrifuge SharedWorker URL is unavailable in this module format; provide sharedWorkerFactory explicitly."
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
return new SharedWorker(workerUrl, {
|
|
2172
|
+
name: "cross-tab-worker-databus-shared",
|
|
2173
|
+
type: "module"
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
function assertHeartbeatInterval(value) {
|
|
2177
|
+
if (value === Infinity) return;
|
|
2178
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return;
|
|
2179
|
+
throw new TypeError(
|
|
2180
|
+
`Centrifuge heartbeatIntervalMs must be a positive number or Infinity, got ${String(value)}.`
|
|
2181
|
+
);
|
|
2182
|
+
}
|
|
2183
|
+
function assertStructuredCloneable(value) {
|
|
2184
|
+
if (typeof structuredClone !== "function") return;
|
|
2185
|
+
try {
|
|
2186
|
+
structuredClone(value);
|
|
2187
|
+
} catch (error) {
|
|
2188
|
+
throw new TypeError(
|
|
2189
|
+
"Centrifuge Worker configuration and published data must be structured-cloneable.",
|
|
2190
|
+
{ cause: error }
|
|
2191
|
+
);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
function postToPortLike(target, message, transfer) {
|
|
2195
|
+
if (transfer) target.postMessage(message, transfer);
|
|
2196
|
+
else target.postMessage(message);
|
|
2197
|
+
}
|
|
2198
|
+
function deserializeWorkerError(error) {
|
|
2199
|
+
const result = new Error(error.message);
|
|
2200
|
+
result.name = error.name;
|
|
2201
|
+
if (error.stack) result.stack = error.stack;
|
|
2202
|
+
if (error.context !== void 0) Object.assign(result, { context: error.context });
|
|
2203
|
+
return result;
|
|
2204
|
+
}
|
|
2205
|
+
//# sourceMappingURL=centrifuge.cjs.map
|