cross-tab-worker-databus 0.1.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/README.zh.md +98 -0
- package/dist/centrifuge-protocol.d.ts +77 -0
- package/dist/centrifuge-protocol.d.ts.map +1 -0
- package/dist/centrifuge-session.d.ts +39 -0
- package/dist/centrifuge-session.d.ts.map +1 -0
- package/dist/centrifuge.d.ts +135 -0
- package/dist/centrifuge.d.ts.map +1 -0
- package/dist/centrifuge.js +407 -0
- package/dist/centrifuge.js.map +7 -0
- package/dist/centrifuge.shared.worker.js +5220 -0
- package/dist/centrifuge.shared.worker.js.map +7 -0
- package/dist/centrifuge.worker.js +5109 -0
- package/dist/centrifuge.worker.js.map +7 -0
- package/dist/chunk-GABYBK7I.js +1527 -0
- package/dist/chunk-GABYBK7I.js.map +7 -0
- package/dist/core/cluster.d.ts +219 -0
- package/dist/core/cluster.d.ts.map +1 -0
- package/dist/core/data-bus.d.ts +133 -0
- package/dist/core/data-bus.d.ts.map +1 -0
- package/dist/core/environment.d.ts +67 -0
- package/dist/core/environment.d.ts.map +1 -0
- package/dist/core/hash.d.ts +11 -0
- package/dist/core/hash.d.ts.map +1 -0
- package/dist/core/routing.d.ts +42 -0
- package/dist/core/routing.d.ts.map +1 -0
- package/dist/core/storage-batch.d.ts +35 -0
- package/dist/core/storage-batch.d.ts.map +1 -0
- package/dist/core/trace.d.ts +126 -0
- package/dist/core/trace.d.ts.map +1 -0
- package/dist/core/types.d.ts +112 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +7 -0
- package/dist/worker-mode.d.ts +25 -0
- package/dist/worker-mode.d.ts.map +1 -0
- package/dist/workers/centrifuge.shared.worker.d.ts +2 -0
- package/dist/workers/centrifuge.shared.worker.d.ts.map +1 -0
- package/dist/workers/centrifuge.worker.d.ts +2 -0
- package/dist/workers/centrifuge.worker.d.ts.map +1 -0
- package/dist/workers/port-reaper.d.ts +52 -0
- package/dist/workers/port-reaper.d.ts.map +1 -0
- package/docs/README.md +21 -0
- package/docs/api.md +261 -0
- package/docs/architecture.md +514 -0
- package/docs/capabilities.md +41 -0
- package/docs/configuration.md +211 -0
- package/docs/getting-started.md +161 -0
- package/docs/zh/README.md +23 -0
- package/docs/zh/api.md +261 -0
- package/docs/zh/architecture.md +515 -0
- package/docs/zh/capabilities.md +41 -0
- package/docs/zh/configuration.md +211 -0
- package/docs/zh/getting-started.md +161 -0
- package/package.json +71 -0
|
@@ -0,0 +1,1527 @@
|
|
|
1
|
+
// src/core/environment.ts
|
|
2
|
+
function getStorage(name) {
|
|
3
|
+
try {
|
|
4
|
+
return typeof window === "undefined" ? null : window[name];
|
|
5
|
+
} catch {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function randomId() {
|
|
10
|
+
try {
|
|
11
|
+
return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
|
|
12
|
+
} catch {
|
|
13
|
+
return Math.random().toString(36).slice(2);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
var tabIdentityInitialized = false;
|
|
17
|
+
function createBrowserEnvironment() {
|
|
18
|
+
return {
|
|
19
|
+
storage: getStorage("localStorage"),
|
|
20
|
+
sessionStorage: getStorage("sessionStorage"),
|
|
21
|
+
now: Date.now,
|
|
22
|
+
randomId,
|
|
23
|
+
createChannel: (name) => {
|
|
24
|
+
try {
|
|
25
|
+
return typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(name);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
setInterval: (callback, intervalMs) => globalThis.setInterval(callback, intervalMs),
|
|
31
|
+
clearInterval: (handle) => globalThis.clearInterval(handle),
|
|
32
|
+
getVisibilityState: () => typeof document !== "undefined" && document.visibilityState === "hidden" ? "hidden" : "visible",
|
|
33
|
+
addVisibilityChangeListener: (listener) => {
|
|
34
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", listener);
|
|
35
|
+
},
|
|
36
|
+
removeVisibilityChangeListener: (listener) => {
|
|
37
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", listener);
|
|
38
|
+
},
|
|
39
|
+
addPageHideListener: (listener) => {
|
|
40
|
+
if (typeof window !== "undefined") window.addEventListener("pagehide", listener);
|
|
41
|
+
},
|
|
42
|
+
removePageHideListener: (listener) => {
|
|
43
|
+
if (typeof window !== "undefined") window.removeEventListener("pagehide", listener);
|
|
44
|
+
},
|
|
45
|
+
addPageShowListener: (listener) => {
|
|
46
|
+
if (typeof window !== "undefined") window.addEventListener("pageshow", listener);
|
|
47
|
+
},
|
|
48
|
+
removePageShowListener: (listener) => {
|
|
49
|
+
if (typeof window !== "undefined") window.removeEventListener("pageshow", listener);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function canUseStorage(storage, probeKey) {
|
|
54
|
+
if (!storage) return false;
|
|
55
|
+
try {
|
|
56
|
+
storage.setItem(probeKey, "1");
|
|
57
|
+
storage.removeItem(probeKey);
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function getOrCreateTabId(environment, key = "cross-tab-worker-databus:tab-id") {
|
|
64
|
+
const storage = environment.sessionStorage;
|
|
65
|
+
try {
|
|
66
|
+
const existing = storage?.getItem(key);
|
|
67
|
+
const hasOpener = typeof window !== "undefined" && Boolean(window.opener);
|
|
68
|
+
if (existing && (!hasOpener || tabIdentityInitialized)) {
|
|
69
|
+
tabIdentityInitialized = true;
|
|
70
|
+
return existing;
|
|
71
|
+
}
|
|
72
|
+
const created = `tab-${environment.randomId()}`;
|
|
73
|
+
storage?.setItem(key, created);
|
|
74
|
+
tabIdentityInitialized = true;
|
|
75
|
+
return created;
|
|
76
|
+
} catch {
|
|
77
|
+
return `tab-${environment.randomId()}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/core/hash.ts
|
|
82
|
+
function createOpaqueKey(value) {
|
|
83
|
+
let h1 = 3735928559 ^ value.length;
|
|
84
|
+
let h2 = 1103547991 ^ value.length;
|
|
85
|
+
let h3 = 3235826430 ^ value.length;
|
|
86
|
+
let h4 = 2654435769 ^ value.length;
|
|
87
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
88
|
+
const code = value.charCodeAt(index);
|
|
89
|
+
h1 = Math.imul(h1 ^ code, 2654435761);
|
|
90
|
+
h2 = Math.imul(h2 ^ code, 1597334677);
|
|
91
|
+
h3 = Math.imul(h3 ^ code, 2246822519);
|
|
92
|
+
h4 = Math.imul(h4 ^ code, 3266489917);
|
|
93
|
+
}
|
|
94
|
+
h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
|
|
95
|
+
h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h3 ^ h3 >>> 13, 3266489909);
|
|
96
|
+
h3 = Math.imul(h3 ^ h3 >>> 16, 2246822507) ^ Math.imul(h4 ^ h4 >>> 13, 3266489909);
|
|
97
|
+
h4 = Math.imul(h4 ^ h4 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
|
|
98
|
+
return [h1, h2, h3, h4].map((hash) => (hash >>> 0).toString(16).padStart(8, "0")).join("");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/core/routing.ts
|
|
102
|
+
var DEFAULT_MAX_ACTIVE_WORKERS = 3;
|
|
103
|
+
function selectLeastLoadedWorker(workers, preferredWorkerId) {
|
|
104
|
+
const preferred = workers.find((worker) => worker.workerId === preferredWorkerId);
|
|
105
|
+
if (preferred) return preferred;
|
|
106
|
+
return workers.reduce((least, worker) => {
|
|
107
|
+
if (!least) return worker;
|
|
108
|
+
const byLoad = worker.load - least.load;
|
|
109
|
+
if (byLoad !== 0) return byLoad < 0 ? worker : least;
|
|
110
|
+
return worker.workerId.localeCompare(least.workerId) < 0 ? worker : least;
|
|
111
|
+
}, void 0);
|
|
112
|
+
}
|
|
113
|
+
function selectActiveWorkers(workers, maxActiveWorkers = DEFAULT_MAX_ACTIVE_WORKERS) {
|
|
114
|
+
const healthyWorkers = workers.filter((worker) => worker.status === "connecting" || worker.status === "connected");
|
|
115
|
+
const availableWorkers = healthyWorkers.length > 0 ? healthyWorkers : [...workers];
|
|
116
|
+
const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === "visible");
|
|
117
|
+
const candidates = visibleWorkers.length > 0 ? visibleWorkers : availableWorkers;
|
|
118
|
+
return candidates.sort(
|
|
119
|
+
(left, right) => left.registeredAt - right.registeredAt || left.workerId.localeCompare(right.workerId)
|
|
120
|
+
).slice(0, maxActiveWorkers);
|
|
121
|
+
}
|
|
122
|
+
function selectRebalanceTarget(workers, currentWorkerId) {
|
|
123
|
+
const currentWorker = workers.find((worker) => worker.workerId === currentWorkerId);
|
|
124
|
+
const leastLoadedWorker = selectLeastLoadedWorker(workers);
|
|
125
|
+
if (!currentWorker || !leastLoadedWorker || currentWorker.workerId === leastLoadedWorker.workerId || currentWorker.load <= leastLoadedWorker.load + 1) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return leastLoadedWorker;
|
|
129
|
+
}
|
|
130
|
+
function hasActiveOwner(route, workers) {
|
|
131
|
+
return Boolean(route && workers.some((worker) => worker.workerId === route.workerId));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/core/storage-batch.ts
|
|
135
|
+
var INITIAL_RETRY_DELAY_MS = 50;
|
|
136
|
+
var MAX_RETRY_DELAY_MS = 1600;
|
|
137
|
+
var BatchingStorageWriter = class {
|
|
138
|
+
constructor(storage) {
|
|
139
|
+
this.storage = storage;
|
|
140
|
+
}
|
|
141
|
+
/** Coalesced write set. A `null` value represents a pending delete. */
|
|
142
|
+
pending = /* @__PURE__ */ new Map();
|
|
143
|
+
flushScheduled = false;
|
|
144
|
+
retryHandle = null;
|
|
145
|
+
retryDelayMs = INITIAL_RETRY_DELAY_MS;
|
|
146
|
+
get pendingSize() {
|
|
147
|
+
return this.pending.size;
|
|
148
|
+
}
|
|
149
|
+
get length() {
|
|
150
|
+
return this.keys().length;
|
|
151
|
+
}
|
|
152
|
+
clear() {
|
|
153
|
+
this.pending.clear();
|
|
154
|
+
this.flushScheduled = false;
|
|
155
|
+
this.storage.clear();
|
|
156
|
+
this.cancelRetry();
|
|
157
|
+
this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
|
|
158
|
+
}
|
|
159
|
+
// Reads always see the pending value first (task-local consistency), then
|
|
160
|
+
// fall back to the underlying storage.
|
|
161
|
+
getItem(key) {
|
|
162
|
+
if (this.pending.has(key)) return this.pending.get(key) ?? null;
|
|
163
|
+
return this.storage.getItem(key);
|
|
164
|
+
}
|
|
165
|
+
key(index) {
|
|
166
|
+
return this.keys()[index] ?? null;
|
|
167
|
+
}
|
|
168
|
+
removeItem(key) {
|
|
169
|
+
this.pending.set(key, null);
|
|
170
|
+
this.scheduleFlush();
|
|
171
|
+
}
|
|
172
|
+
setItem(key, value) {
|
|
173
|
+
this.pending.set(key, value);
|
|
174
|
+
this.scheduleFlush();
|
|
175
|
+
}
|
|
176
|
+
flush() {
|
|
177
|
+
this.flushScheduled = false;
|
|
178
|
+
this.cancelRetry();
|
|
179
|
+
for (const [key, value] of [...this.pending]) {
|
|
180
|
+
try {
|
|
181
|
+
if (value === null) this.storage.removeItem(key);
|
|
182
|
+
else this.storage.setItem(key, value);
|
|
183
|
+
this.pending.delete(key);
|
|
184
|
+
} catch {
|
|
185
|
+
this.scheduleRetry();
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (this.pending.size === 0) this.retryDelayMs = INITIAL_RETRY_DELAY_MS;
|
|
190
|
+
}
|
|
191
|
+
/** Union of persisted keys and pending writes, minus pending deletes. */
|
|
192
|
+
keys() {
|
|
193
|
+
const keys = /* @__PURE__ */ new Set();
|
|
194
|
+
for (let index = 0; index < this.storage.length; index += 1) {
|
|
195
|
+
const key = this.storage.key(index);
|
|
196
|
+
if (key !== null) keys.add(key);
|
|
197
|
+
}
|
|
198
|
+
for (const [key, value] of this.pending) {
|
|
199
|
+
if (value === null) keys.delete(key);
|
|
200
|
+
else keys.add(key);
|
|
201
|
+
}
|
|
202
|
+
return [...keys];
|
|
203
|
+
}
|
|
204
|
+
// Coalesce all synchronous writes within one task into a single microtask
|
|
205
|
+
// flush, avoiding a localStorage write per heartbeat/route/subscriber update.
|
|
206
|
+
scheduleFlush() {
|
|
207
|
+
if (this.flushScheduled) return;
|
|
208
|
+
this.flushScheduled = true;
|
|
209
|
+
const flush = () => {
|
|
210
|
+
this.flushScheduled = false;
|
|
211
|
+
this.flush();
|
|
212
|
+
};
|
|
213
|
+
if (typeof queueMicrotask === "function") queueMicrotask(flush);
|
|
214
|
+
else setTimeout(flush, 0);
|
|
215
|
+
}
|
|
216
|
+
scheduleRetry() {
|
|
217
|
+
if (this.retryHandle !== null) return;
|
|
218
|
+
this.retryHandle = setTimeout(() => {
|
|
219
|
+
this.retryHandle = null;
|
|
220
|
+
this.flush();
|
|
221
|
+
}, this.retryDelayMs);
|
|
222
|
+
this.retryDelayMs = Math.min(MAX_RETRY_DELAY_MS, this.retryDelayMs * 2);
|
|
223
|
+
}
|
|
224
|
+
cancelRetry() {
|
|
225
|
+
if (this.retryHandle !== null) {
|
|
226
|
+
clearTimeout(this.retryHandle);
|
|
227
|
+
this.retryHandle = null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// src/core/cluster.ts
|
|
233
|
+
var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
|
|
234
|
+
var DEFAULT_WORKER_TTL_MS = 1e4;
|
|
235
|
+
var DEFAULT_STORAGE_PREFIX = "cross-tab-worker-databus";
|
|
236
|
+
var MAX_KNOWN_TOPICS = 500;
|
|
237
|
+
function readJson(storage, key) {
|
|
238
|
+
try {
|
|
239
|
+
const value = storage.getItem(key);
|
|
240
|
+
return value ? JSON.parse(value) : null;
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function writeJson(storage, key, value) {
|
|
246
|
+
try {
|
|
247
|
+
storage.setItem(key, JSON.stringify(value));
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function listKeys(storage, prefix) {
|
|
252
|
+
try {
|
|
253
|
+
return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter(
|
|
254
|
+
(key) => Boolean(key?.startsWith(prefix))
|
|
255
|
+
);
|
|
256
|
+
} catch {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
var WorkerClusterRuntime = class {
|
|
261
|
+
tabId;
|
|
262
|
+
workerId;
|
|
263
|
+
environment;
|
|
264
|
+
handlers;
|
|
265
|
+
storage;
|
|
266
|
+
maxActiveWorkers;
|
|
267
|
+
heartbeatIntervalMs;
|
|
268
|
+
workerTtlMs;
|
|
269
|
+
workerPrefix;
|
|
270
|
+
routePrefix;
|
|
271
|
+
subscriberPrefix;
|
|
272
|
+
channelName;
|
|
273
|
+
// Topics this tab has subscribed to (local interest, plaintext).
|
|
274
|
+
subscribedTopics = /* @__PURE__ */ new Set();
|
|
275
|
+
// Topics assigned to this Worker as owner (topicKey → topic). Authoritative:
|
|
276
|
+
// membership drives isAssigned() and load. Grows only via CONTROL/SUBSCRIBE
|
|
277
|
+
// (or local self-subscribe), never via the reverse cache.
|
|
278
|
+
assignedTopics = /* @__PURE__ */ new Map();
|
|
279
|
+
// Reverse mapping: opaque topicKey → plaintext topic. A bounded cache with
|
|
280
|
+
// FIFO eviction — NOT authoritative. It can hold a topicKey that is also in
|
|
281
|
+
// assignedTopics (the owned guard prevents evicting those), because it is
|
|
282
|
+
// the only source of plaintext when storage is unavailable. See the
|
|
283
|
+
// rememberTopic() doc for the eviction contract.
|
|
284
|
+
knownTopics = /* @__PURE__ */ new Map();
|
|
285
|
+
channel = null;
|
|
286
|
+
heartbeatHandle = null;
|
|
287
|
+
started = false;
|
|
288
|
+
suspended = false;
|
|
289
|
+
lifecycleListening = false;
|
|
290
|
+
currentRecord;
|
|
291
|
+
constructor(options) {
|
|
292
|
+
this.environment = options.environment ?? createBrowserEnvironment();
|
|
293
|
+
this.handlers = options.handlers;
|
|
294
|
+
this.maxActiveWorkers = options.maxActiveWorkers ?? DEFAULT_MAX_ACTIVE_WORKERS;
|
|
295
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
296
|
+
this.workerTtlMs = options.workerTtlMs ?? DEFAULT_WORKER_TTL_MS;
|
|
297
|
+
const clusterHash = createOpaqueKey(options.clusterKey || "__default__");
|
|
298
|
+
const prefix = options.storagePrefix ?? DEFAULT_STORAGE_PREFIX;
|
|
299
|
+
const baseKey = `${prefix}:${clusterHash}`;
|
|
300
|
+
this.workerPrefix = `${baseKey}:worker:`;
|
|
301
|
+
this.routePrefix = `${baseKey}:route:`;
|
|
302
|
+
this.subscriberPrefix = `${baseKey}:subscriber:`;
|
|
303
|
+
this.channelName = `${prefix}:bus:${clusterHash}`;
|
|
304
|
+
this.storage = canUseStorage(this.environment.storage, `${baseKey}:probe`) ? new BatchingStorageWriter(this.environment.storage) : null;
|
|
305
|
+
this.tabId = options.tabId ?? getOrCreateTabId(this.environment, `${prefix}:tab-id`);
|
|
306
|
+
this.workerId = options.workerId ?? `worker-${this.tabId}-${this.environment.randomId()}`;
|
|
307
|
+
const now = this.environment.now();
|
|
308
|
+
this.currentRecord = {
|
|
309
|
+
workerId: this.workerId,
|
|
310
|
+
tabId: this.tabId,
|
|
311
|
+
load: 0,
|
|
312
|
+
role: "standby",
|
|
313
|
+
status: "connecting",
|
|
314
|
+
visibilityState: this.environment.getVisibilityState(),
|
|
315
|
+
heartbeatAt: now,
|
|
316
|
+
registeredAt: now
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** Start the cluster: register, listen for lifecycle events, and begin heartbeats. */
|
|
320
|
+
start() {
|
|
321
|
+
if (this.started) return;
|
|
322
|
+
this.suspended = false;
|
|
323
|
+
this.addLifecycleListeners();
|
|
324
|
+
this.activate();
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Stop the cluster: pause heartbeats, hand off assigned topics, remove
|
|
328
|
+
* the worker record, and clean up lifecycle listeners. Idempotent.
|
|
329
|
+
*/
|
|
330
|
+
stop() {
|
|
331
|
+
if (!this.started && !this.suspended) return;
|
|
332
|
+
this.pause();
|
|
333
|
+
this.flushStorage();
|
|
334
|
+
this.removeLifecycleListeners();
|
|
335
|
+
this.subscribedTopics.clear();
|
|
336
|
+
this.assignedTopics.clear();
|
|
337
|
+
this.knownTopics.clear();
|
|
338
|
+
this.suspended = false;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Activate the cluster: open the BroadcastChannel, register the worker record,
|
|
342
|
+
* subscribe to topics, and start the heartbeat interval.
|
|
343
|
+
*/
|
|
344
|
+
activate() {
|
|
345
|
+
if (this.started) return;
|
|
346
|
+
this.started = true;
|
|
347
|
+
this.channel = this.storage ? this.environment.createChannel(this.channelName) : null;
|
|
348
|
+
if (!this.channel) this.storage = null;
|
|
349
|
+
this.channel?.addEventListener("message", this.handleMessage);
|
|
350
|
+
const now = this.environment.now();
|
|
351
|
+
this.currentRecord = {
|
|
352
|
+
...this.currentRecord,
|
|
353
|
+
heartbeatAt: now,
|
|
354
|
+
registeredAt: now,
|
|
355
|
+
visibilityState: this.environment.getVisibilityState()
|
|
356
|
+
};
|
|
357
|
+
this.refreshRole(this.readWorkers());
|
|
358
|
+
this.writeRecord(true);
|
|
359
|
+
if (!this.storage) {
|
|
360
|
+
for (const topic of this.subscribedTopics) {
|
|
361
|
+
this.sendControl(this.workerId, "SUBSCRIBE", topic, this.rememberTopic(topic));
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
for (const topic of this.subscribedTopics) this.writeSubscriber(this.rememberTopic(topic));
|
|
365
|
+
}
|
|
366
|
+
this.reconcile();
|
|
367
|
+
this.heartbeatHandle = this.environment.setInterval(() => {
|
|
368
|
+
this.writeRecord(false);
|
|
369
|
+
this.reconcile();
|
|
370
|
+
}, this.heartbeatIntervalMs);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Pause the cluster on pagehide: stop heartbeats, hand off assigned topics
|
|
374
|
+
* to other workers, remove our worker record, and close the channel.
|
|
375
|
+
*/
|
|
376
|
+
pause() {
|
|
377
|
+
if (!this.started) return;
|
|
378
|
+
this.started = false;
|
|
379
|
+
this.suspended = true;
|
|
380
|
+
if (this.heartbeatHandle !== null) this.environment.clearInterval(this.heartbeatHandle);
|
|
381
|
+
this.heartbeatHandle = null;
|
|
382
|
+
this.channel?.removeEventListener("message", this.handleMessage);
|
|
383
|
+
for (const topic of this.subscribedTopics) this.releaseSubscription(topic, false);
|
|
384
|
+
this.handoffAssignedTopics();
|
|
385
|
+
this.assignedTopics.clear();
|
|
386
|
+
this.removeStorage(this.workerStorageKey(this.workerId));
|
|
387
|
+
this.flushStorage();
|
|
388
|
+
this.notifyRegistry();
|
|
389
|
+
this.channel?.close();
|
|
390
|
+
this.channel = null;
|
|
391
|
+
this.handlers.onSuspend?.();
|
|
392
|
+
}
|
|
393
|
+
/** Update the worker's connection status and persist the change. */
|
|
394
|
+
setStatus(status) {
|
|
395
|
+
if (this.currentRecord.status === status) return;
|
|
396
|
+
this.currentRecord = { ...this.currentRecord, status };
|
|
397
|
+
if (this.started) this.writeRecord(true);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Subscribe to a topic. Returns true if this worker becomes the assigned owner.
|
|
401
|
+
* The topic is recorded locally and the cluster is notified via storage or
|
|
402
|
+
* direct control message.
|
|
403
|
+
*/
|
|
404
|
+
subscribe(topic) {
|
|
405
|
+
const topicKey = this.rememberTopic(topic);
|
|
406
|
+
this.subscribedTopics.add(topic);
|
|
407
|
+
if (!this.started) return false;
|
|
408
|
+
if (!this.storage) {
|
|
409
|
+
this.sendControl(this.workerId, "SUBSCRIBE", topic, topicKey);
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
this.writeSubscriber(topicKey);
|
|
413
|
+
const workers = this.readWorkers();
|
|
414
|
+
const existingRoute = this.readRoute(topicKey);
|
|
415
|
+
if (existingRoute && workers.some((worker) => worker.workerId === existingRoute.workerId)) {
|
|
416
|
+
return existingRoute.workerId === this.workerId;
|
|
417
|
+
}
|
|
418
|
+
const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
|
|
419
|
+
const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
|
|
420
|
+
this.writeRoute(topicKey, owner, void 0, (existingRoute?.generation ?? 0) + 1);
|
|
421
|
+
this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
|
|
422
|
+
this.notifyRegistry();
|
|
423
|
+
return owner.workerId === this.workerId;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Remove the local subscription. Cleans up the subscriber record and, if no
|
|
427
|
+
* subscribers remain, deletes the route so the owning Worker can unsubscribe.
|
|
428
|
+
*/
|
|
429
|
+
unsubscribe(topic) {
|
|
430
|
+
const topicKey = this.rememberTopic(topic);
|
|
431
|
+
this.subscribedTopics.delete(topic);
|
|
432
|
+
this.releaseSubscription(topic);
|
|
433
|
+
if (!this.assignedTopics.has(topicKey)) this.knownTopics.delete(topicKey);
|
|
434
|
+
}
|
|
435
|
+
/** Remove this tab's subscriber record and, when it was the last one, delete the route. */
|
|
436
|
+
releaseSubscription(topic, notifyOwner = true) {
|
|
437
|
+
const topicKey = this.rememberTopic(topic);
|
|
438
|
+
this.removeStorage(this.subscriberStorageKey(topicKey, this.tabId));
|
|
439
|
+
const route = this.readRoute(topicKey);
|
|
440
|
+
if (!route) return;
|
|
441
|
+
const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
|
|
442
|
+
if (subscribers.length === 0) {
|
|
443
|
+
this.removeStorage(this.routeStorageKey(topicKey));
|
|
444
|
+
if (notifyOwner) this.sendControl(route.workerId, "UNSUBSCRIBE", topic, topicKey);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/** Transfer assigned topics to other active workers so subscribers are not orphaned during pause. */
|
|
448
|
+
handoffAssignedTopics() {
|
|
449
|
+
if (!this.storage || this.assignedTopics.size === 0) return;
|
|
450
|
+
const remainingWorkers = this.readWorkers().filter((worker) => worker.workerId !== this.workerId);
|
|
451
|
+
const activeWorkers = selectActiveWorkers(remainingWorkers, this.maxActiveWorkers);
|
|
452
|
+
const projectedLoads = new Map(activeWorkers.map((worker) => [worker.workerId, worker.load]));
|
|
453
|
+
for (const [topicKey, topic] of this.assignedTopics) {
|
|
454
|
+
if (this.readRoute(topicKey)?.workerId !== this.workerId) continue;
|
|
455
|
+
const subscribers = this.readSubscriberTabIds(topicKey, remainingWorkers);
|
|
456
|
+
if (subscribers.length === 0) {
|
|
457
|
+
this.removeStorage(this.routeStorageKey(topicKey));
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const owner = selectLeastLoadedWorker(
|
|
461
|
+
activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load }))
|
|
462
|
+
);
|
|
463
|
+
if (!owner) continue;
|
|
464
|
+
projectedLoads.set(owner.workerId, (projectedLoads.get(owner.workerId) ?? owner.load) + 1);
|
|
465
|
+
const previous = this.readRoute(topicKey);
|
|
466
|
+
this.writeRoute(topicKey, owner, previous?.workerId, (previous?.generation ?? 0) + 1);
|
|
467
|
+
this.flushStorage();
|
|
468
|
+
const generation = (previous?.generation ?? 0) + 1;
|
|
469
|
+
this.handlers.onControl("UNSUBSCRIBE", topic);
|
|
470
|
+
this.send({
|
|
471
|
+
type: "ROUTE_RELEASED",
|
|
472
|
+
sourceWorkerId: this.workerId,
|
|
473
|
+
targetWorkerId: owner.workerId,
|
|
474
|
+
topic,
|
|
475
|
+
topicKey,
|
|
476
|
+
generation
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Publish a message to `topic`, routing through the owning Worker (or self if
|
|
482
|
+
* no owner is found). Returns false when the control message could not be
|
|
483
|
+
* posted to a remote owner, so the caller can surface the failure instead of
|
|
484
|
+
* silently dropping the publication.
|
|
485
|
+
*/
|
|
486
|
+
publish(topic, data) {
|
|
487
|
+
const topicKey = this.rememberTopic(topic);
|
|
488
|
+
const workers = this.readWorkers();
|
|
489
|
+
const route = this.readRoute(topicKey);
|
|
490
|
+
const target = route && workers.some((worker) => worker.workerId === route.workerId) ? route.workerId : this.workerId;
|
|
491
|
+
return this.sendControl(target ?? this.workerId, "PUBLISH", topic, topicKey, data);
|
|
492
|
+
}
|
|
493
|
+
/** Broadcast an event to every tab — used to fan out transport publications. */
|
|
494
|
+
broadcastEvent(eventType, payload) {
|
|
495
|
+
this.send({ type: "EVENT", sourceWorkerId: this.workerId, eventType, payload });
|
|
496
|
+
}
|
|
497
|
+
isAssigned(topic) {
|
|
498
|
+
const topicKey = createOpaqueKey(topic);
|
|
499
|
+
if (this.assignedTopics.has(topicKey)) return true;
|
|
500
|
+
return this.readRoute(topicKey)?.workerId === this.workerId;
|
|
501
|
+
}
|
|
502
|
+
/** True if this worker is among the active set (eligible to own topics). */
|
|
503
|
+
isActiveWorker() {
|
|
504
|
+
return selectActiveWorkers(this.readWorkers(), this.maxActiveWorkers).some(
|
|
505
|
+
(worker) => worker.workerId === this.workerId
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
/** True when this tab has a local subscriber registered for `topic`. */
|
|
509
|
+
hasLocalSubscriber(topic) {
|
|
510
|
+
return this.subscribedTopics.has(topic);
|
|
511
|
+
}
|
|
512
|
+
/** Read-only snapshot of the cluster state (workers, routes, assignments). */
|
|
513
|
+
getSnapshot() {
|
|
514
|
+
const routes = listKeysSafe(this.storage, this.routePrefix).map((key) => this.storage ? readJson(this.storage, key) : null).filter((route) => Boolean(route)).map((route) => ({
|
|
515
|
+
...route,
|
|
516
|
+
topic: this.knownTopics.get(route.topicKey) ?? null
|
|
517
|
+
}));
|
|
518
|
+
return {
|
|
519
|
+
coordinated: Boolean(this.storage && this.channel),
|
|
520
|
+
suspended: this.suspended,
|
|
521
|
+
currentWorker: { ...this.currentRecord },
|
|
522
|
+
workers: this.readWorkers().map((worker) => ({ ...worker })),
|
|
523
|
+
routes,
|
|
524
|
+
subscribedTopics: [...this.subscribedTopics],
|
|
525
|
+
assignedTopics: [...this.assignedTopics.values()],
|
|
526
|
+
knownTopics: [...this.knownTopics.entries()].map(([topicKey, topic]) => ({ topicKey, topic }))
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
handlePageHide = () => this.pause();
|
|
530
|
+
handlePageShow = () => {
|
|
531
|
+
if (!this.suspended) return;
|
|
532
|
+
this.suspended = false;
|
|
533
|
+
this.handlers.onResume?.();
|
|
534
|
+
this.activate();
|
|
535
|
+
};
|
|
536
|
+
handleVisibilityChange = () => {
|
|
537
|
+
const visibilityState = this.environment.getVisibilityState();
|
|
538
|
+
if (visibilityState === this.currentRecord.visibilityState) return;
|
|
539
|
+
this.currentRecord = { ...this.currentRecord, visibilityState };
|
|
540
|
+
if (this.started) {
|
|
541
|
+
this.writeRecord(true);
|
|
542
|
+
this.reconcile();
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
addLifecycleListeners() {
|
|
546
|
+
if (this.lifecycleListening) return;
|
|
547
|
+
this.lifecycleListening = true;
|
|
548
|
+
this.environment.addPageHideListener(this.handlePageHide);
|
|
549
|
+
this.environment.addPageShowListener(this.handlePageShow);
|
|
550
|
+
this.environment.addVisibilityChangeListener(this.handleVisibilityChange);
|
|
551
|
+
}
|
|
552
|
+
removeLifecycleListeners() {
|
|
553
|
+
if (!this.lifecycleListening) return;
|
|
554
|
+
this.lifecycleListening = false;
|
|
555
|
+
this.environment.removePageHideListener(this.handlePageHide);
|
|
556
|
+
this.environment.removePageShowListener(this.handlePageShow);
|
|
557
|
+
this.environment.removeVisibilityChangeListener(this.handleVisibilityChange);
|
|
558
|
+
}
|
|
559
|
+
/** Handle an incoming cluster message: dispatch by type to the per-type handlers. */
|
|
560
|
+
handleMessage = (event) => {
|
|
561
|
+
const message = event.data;
|
|
562
|
+
if (!message || message.sourceWorkerId === this.workerId) return;
|
|
563
|
+
if (message.type === "CONTROL") return this.handleControlMessage(message);
|
|
564
|
+
if (message.type === "ROUTE_RELEASED") return this.handleRouteReleasedMessage(message);
|
|
565
|
+
if (message.type === "EVENT") {
|
|
566
|
+
this.handlers.onEvent(message.eventType, message.payload, message.sourceWorkerId);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
this.reconcile();
|
|
570
|
+
};
|
|
571
|
+
/** Handle a point-to-point CONTROL message (SUBSCRIBE / UNSUBSCRIBE / PUBLISH). */
|
|
572
|
+
handleControlMessage(message) {
|
|
573
|
+
if (message.targetWorkerId !== this.workerId) return;
|
|
574
|
+
this.rememberTopic(message.topic);
|
|
575
|
+
if (message.action === "SUBSCRIBE") {
|
|
576
|
+
this.assignedTopics.set(message.topicKey, message.topic);
|
|
577
|
+
this.confirmRoute(message.topicKey);
|
|
578
|
+
}
|
|
579
|
+
if (message.action === "UNSUBSCRIBE") {
|
|
580
|
+
if (this.releaseHandoffOnUnsubscribe(message)) return;
|
|
581
|
+
}
|
|
582
|
+
this.handlers.onControl(message.action, message.topic, message.data);
|
|
583
|
+
if (message.action !== "PUBLISH") this.updateLoad();
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* When this worker is the previous owner in a graceful handoff and the new
|
|
587
|
+
* owner asks us to unsubscribe, release the old transport subscription and
|
|
588
|
+
* ACK the handoff with ROUTE_RELEASED. Returns true when the message was a
|
|
589
|
+
* handoff release (the generic CONTROL dispatch must not run as well).
|
|
590
|
+
*/
|
|
591
|
+
releaseHandoffOnUnsubscribe(message) {
|
|
592
|
+
this.assignedTopics.delete(message.topicKey);
|
|
593
|
+
const route = this.readRoute(message.topicKey);
|
|
594
|
+
if (route?.handoffFromWorkerId !== this.workerId) return false;
|
|
595
|
+
this.handlers.onControl("UNSUBSCRIBE", message.topic, void 0);
|
|
596
|
+
this.send({
|
|
597
|
+
type: "ROUTE_RELEASED",
|
|
598
|
+
sourceWorkerId: this.workerId,
|
|
599
|
+
targetWorkerId: route.workerId,
|
|
600
|
+
topic: message.topic,
|
|
601
|
+
topicKey: message.topicKey,
|
|
602
|
+
generation: route.generation
|
|
603
|
+
});
|
|
604
|
+
this.updateLoad();
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Accept a graceful handoff only when the route still points to this worker,
|
|
609
|
+
* the release comes from the recorded previous owner, and the generation is
|
|
610
|
+
* at least as new as ours. Any other ROUTE_RELEASED is stale and dropped.
|
|
611
|
+
*/
|
|
612
|
+
handleRouteReleasedMessage(message) {
|
|
613
|
+
if (message.targetWorkerId !== this.workerId) return;
|
|
614
|
+
const route = this.readRoute(message.topicKey);
|
|
615
|
+
if (!route || route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || route.generation < message.generation) return;
|
|
616
|
+
this.assignedTopics.set(message.topicKey, message.topic);
|
|
617
|
+
this.confirmRoute(message.topicKey);
|
|
618
|
+
this.handlers.onControl("SUBSCRIBE", message.topic, void 0);
|
|
619
|
+
this.updateLoad();
|
|
620
|
+
}
|
|
621
|
+
/** Full reconciliation cycle: workers, subscriptions, and assigned topics. */
|
|
622
|
+
reconcile() {
|
|
623
|
+
if (!this.started) return;
|
|
624
|
+
const workers = this.reconcileWorkers();
|
|
625
|
+
const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
|
|
626
|
+
this.reconcileSubscriptions(workers, activeWorkers);
|
|
627
|
+
this.reconcileAssignedTopics();
|
|
628
|
+
this.updateLoad();
|
|
629
|
+
}
|
|
630
|
+
/** Prune stale workers/subscribers/routes and refresh role. Returns the live worker list. */
|
|
631
|
+
reconcileWorkers() {
|
|
632
|
+
const workers = this.readWorkers();
|
|
633
|
+
this.cleanupOrphanedSubscribers(workers);
|
|
634
|
+
this.cleanupOrphanedRoutes(workers);
|
|
635
|
+
const roleChanged = this.refreshRole(workers);
|
|
636
|
+
if (roleChanged) this.writeRecord(false);
|
|
637
|
+
return workers;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Ensure every local subscription has a route and write subscriber records.
|
|
641
|
+
*
|
|
642
|
+
* Existing routes are deliberately sticky while their owner Worker is alive.
|
|
643
|
+
* Load and visibility only influence placement of a new route; they must not
|
|
644
|
+
* move an already-subscribed Topic merely because another Tab joins or becomes
|
|
645
|
+
* visible. Ownership changes only after the owner leaves or its heartbeat
|
|
646
|
+
* expires, which avoids unnecessary transport subscribe/unsubscribe churn.
|
|
647
|
+
*/
|
|
648
|
+
reconcileSubscriptions(workers, activeWorkers) {
|
|
649
|
+
const liveWorkerIds = new Set(workers.map((worker) => worker.workerId));
|
|
650
|
+
for (const topic of this.subscribedTopics) {
|
|
651
|
+
const topicKey = this.rememberTopic(topic);
|
|
652
|
+
this.writeSubscriber(topicKey);
|
|
653
|
+
const route = this.readRoute(topicKey);
|
|
654
|
+
if (!route || !liveWorkerIds.has(route.workerId)) {
|
|
655
|
+
const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
|
|
656
|
+
this.writeRoute(topicKey, owner, void 0, (route?.generation ?? 0) + 1);
|
|
657
|
+
this.sendControl(owner.workerId, "SUBSCRIBE", topic, topicKey);
|
|
658
|
+
this.notifyRegistry();
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (route.confirmedAt === void 0) {
|
|
662
|
+
if (!route.handoffFromWorkerId) {
|
|
663
|
+
this.sendControl(route.workerId, "SUBSCRIBE", topic, topicKey);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/** Drop assignments where the route no longer points to this worker. */
|
|
669
|
+
reconcileAssignedTopics() {
|
|
670
|
+
for (const [topicKey, topic] of [...this.assignedTopics]) {
|
|
671
|
+
if (this.readRoute(topicKey)?.workerId === this.workerId) continue;
|
|
672
|
+
this.assignedTopics.delete(topicKey);
|
|
673
|
+
this.handlers.onControl("UNSUBSCRIBE", topic, void 0);
|
|
674
|
+
const route = this.readRoute(topicKey);
|
|
675
|
+
if (route?.handoffFromWorkerId === this.workerId) {
|
|
676
|
+
this.send({
|
|
677
|
+
type: "ROUTE_RELEASED",
|
|
678
|
+
sourceWorkerId: this.workerId,
|
|
679
|
+
targetWorkerId: route.workerId,
|
|
680
|
+
topic,
|
|
681
|
+
topicKey,
|
|
682
|
+
generation: route.generation
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
if (!this.subscribedTopics.has(topic)) this.knownTopics.delete(topicKey);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Send a control message to `targetWorkerId`, or execute locally when targeting self.
|
|
690
|
+
* Local execution updates the assignment map and route synchronously, bypassing
|
|
691
|
+
* the BroadcastChannel latency.
|
|
692
|
+
*/
|
|
693
|
+
sendControl(targetWorkerId, action, topic, topicKey, data) {
|
|
694
|
+
if (targetWorkerId === this.workerId) {
|
|
695
|
+
if (action === "SUBSCRIBE") {
|
|
696
|
+
this.assignedTopics.set(topicKey, topic);
|
|
697
|
+
this.confirmRoute(topicKey);
|
|
698
|
+
}
|
|
699
|
+
if (action === "UNSUBSCRIBE") this.assignedTopics.delete(topicKey);
|
|
700
|
+
this.handlers.onControl(action, topic, data);
|
|
701
|
+
if (action !== "PUBLISH") this.updateLoad();
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
return this.send({
|
|
705
|
+
type: "CONTROL",
|
|
706
|
+
sourceWorkerId: this.workerId,
|
|
707
|
+
targetWorkerId,
|
|
708
|
+
action,
|
|
709
|
+
topic,
|
|
710
|
+
topicKey,
|
|
711
|
+
...data === void 0 ? {} : { data }
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
/** Post a message on the BroadcastChannel. Returns false on postMessage failure. */
|
|
715
|
+
send(message) {
|
|
716
|
+
if (!this.channel) return false;
|
|
717
|
+
try {
|
|
718
|
+
this.channel.postMessage(message);
|
|
719
|
+
return true;
|
|
720
|
+
} catch {
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/** Read all live worker records from storage, pruning stale entries past the TTL. */
|
|
725
|
+
readWorkers() {
|
|
726
|
+
if (!this.storage) return [this.currentRecord];
|
|
727
|
+
const now = this.environment.now();
|
|
728
|
+
const workers = [];
|
|
729
|
+
for (const key of listKeys(this.storage, this.workerPrefix)) {
|
|
730
|
+
const worker = readJson(this.storage, key);
|
|
731
|
+
if (!worker) continue;
|
|
732
|
+
if (worker.workerId !== this.workerId && now - worker.heartbeatAt > this.workerTtlMs) {
|
|
733
|
+
this.removeStorage(key);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
workers.push(worker);
|
|
737
|
+
}
|
|
738
|
+
if (this.started && !workers.some((worker) => worker.workerId === this.workerId)) workers.push(this.currentRecord);
|
|
739
|
+
return workers;
|
|
740
|
+
}
|
|
741
|
+
/** Enumerate all tab IDs that have a subscriber record for `topicKey`. */
|
|
742
|
+
readSubscriberTabIds(topicKey, workers) {
|
|
743
|
+
if (!this.storage) return this.subscribedTopics.has(this.knownTopics.get(topicKey) ?? "") ? [this.tabId] : [];
|
|
744
|
+
const activeTabIds = new Set(workers.map((worker) => worker.tabId));
|
|
745
|
+
const subscribers = /* @__PURE__ */ new Set();
|
|
746
|
+
for (const key of listKeys(this.storage, `${this.subscriberPrefix}${topicKey}:`)) {
|
|
747
|
+
const record = readJson(this.storage, key);
|
|
748
|
+
if (!record || !activeTabIds.has(record.tabId)) {
|
|
749
|
+
this.removeStorage(key);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
subscribers.add(record.tabId);
|
|
753
|
+
}
|
|
754
|
+
return [...subscribers];
|
|
755
|
+
}
|
|
756
|
+
/** Read the current route for `topicKey`, returning null when no storage layer exists. */
|
|
757
|
+
readRoute(topicKey) {
|
|
758
|
+
if (!this.storage) {
|
|
759
|
+
const topic = this.knownTopics.get(topicKey);
|
|
760
|
+
return topic && (this.subscribedTopics.has(topic) || this.assignedTopics.has(topicKey)) ? {
|
|
761
|
+
topicKey,
|
|
762
|
+
workerId: this.workerId,
|
|
763
|
+
tabId: this.tabId,
|
|
764
|
+
updatedAt: this.environment.now(),
|
|
765
|
+
generation: 1
|
|
766
|
+
} : null;
|
|
767
|
+
}
|
|
768
|
+
return readJson(this.storage, this.routeStorageKey(topicKey));
|
|
769
|
+
}
|
|
770
|
+
/** Persist a route assignment, mapping `topicKey` to the owning Worker. */
|
|
771
|
+
writeRoute(topicKey, owner, handoffFromWorkerId, generation = 1) {
|
|
772
|
+
if (!this.storage) return;
|
|
773
|
+
writeJson(this.storage, this.routeStorageKey(topicKey), {
|
|
774
|
+
topicKey,
|
|
775
|
+
workerId: owner.workerId,
|
|
776
|
+
tabId: owner.tabId,
|
|
777
|
+
updatedAt: this.environment.now(),
|
|
778
|
+
generation,
|
|
779
|
+
...handoffFromWorkerId ? { handoffFromWorkerId } : {}
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
/** Stamp a route as confirmed once the owning Worker has acknowledged the assignment. */
|
|
783
|
+
confirmRoute(topicKey) {
|
|
784
|
+
if (!this.storage) return;
|
|
785
|
+
const route = this.readRoute(topicKey);
|
|
786
|
+
if (!route || route.workerId !== this.workerId || route.confirmedAt !== void 0) return;
|
|
787
|
+
writeJson(this.storage, this.routeStorageKey(topicKey), {
|
|
788
|
+
...route,
|
|
789
|
+
confirmedAt: this.environment.now()
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
/** Remove routes whose topic has no subscribers and whose TTL has expired. */
|
|
793
|
+
cleanupOrphanedRoutes(workers) {
|
|
794
|
+
if (!this.storage) return;
|
|
795
|
+
const now = this.environment.now();
|
|
796
|
+
for (const key of listKeys(this.storage, this.routePrefix)) {
|
|
797
|
+
const route = readJson(this.storage, key);
|
|
798
|
+
if (!route || now - route.updatedAt <= this.workerTtlMs) continue;
|
|
799
|
+
if (this.readSubscriberTabIds(route.topicKey, workers).length > 0) continue;
|
|
800
|
+
this.removeStorage(key);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/** Remove subscriber records for tabs that are no longer active. */
|
|
804
|
+
cleanupOrphanedSubscribers(workers) {
|
|
805
|
+
if (!this.storage) return;
|
|
806
|
+
const activeTabIds = new Set(workers.map((worker) => worker.tabId));
|
|
807
|
+
for (const key of listKeys(this.storage, this.subscriberPrefix)) {
|
|
808
|
+
const record = readJson(this.storage, key);
|
|
809
|
+
if (!record || !activeTabIds.has(record.tabId)) this.removeStorage(key);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/** Persist a subscriber record for this tab on `topicKey`. */
|
|
813
|
+
writeSubscriber(topicKey) {
|
|
814
|
+
if (!this.storage) return;
|
|
815
|
+
writeJson(this.storage, this.subscriberStorageKey(topicKey, this.tabId), {
|
|
816
|
+
tabId: this.tabId,
|
|
817
|
+
updatedAt: this.environment.now()
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
/** Persist the current worker record with an updated heartbeat timestamp. */
|
|
821
|
+
writeRecord(notify) {
|
|
822
|
+
this.currentRecord = { ...this.currentRecord, heartbeatAt: this.environment.now() };
|
|
823
|
+
if (this.storage) writeJson(this.storage, this.workerStorageKey(this.workerId), this.currentRecord);
|
|
824
|
+
if (notify) this.notifyRegistry();
|
|
825
|
+
}
|
|
826
|
+
/** Broadcast a REGISTRY message to trigger reconciliation on other tabs. */
|
|
827
|
+
notifyRegistry() {
|
|
828
|
+
this.send({ type: "REGISTRY", sourceWorkerId: this.workerId });
|
|
829
|
+
}
|
|
830
|
+
/** Recompute whether this worker is active (eligible to own topics) or standby. Returns true when changed. */
|
|
831
|
+
refreshRole(workers) {
|
|
832
|
+
const role = selectActiveWorkers(workers, this.maxActiveWorkers).some((worker) => worker.workerId === this.workerId) ? "active" : "standby";
|
|
833
|
+
if (role === this.currentRecord.role) return false;
|
|
834
|
+
this.currentRecord = { ...this.currentRecord, role };
|
|
835
|
+
return true;
|
|
836
|
+
}
|
|
837
|
+
/** Persist the current topic load count (number of assigned topics) for load-balanced routing. */
|
|
838
|
+
updateLoad() {
|
|
839
|
+
const load = this.assignedTopics.size;
|
|
840
|
+
if (load === this.currentRecord.load) return;
|
|
841
|
+
this.currentRecord = { ...this.currentRecord, load };
|
|
842
|
+
if (this.started) this.writeRecord(true);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Hash `topic` into its opaque key and populate the reverse-lookup cache.
|
|
846
|
+
*
|
|
847
|
+
* Despite the name, this is NOT a cache lookup — it unconditionally writes
|
|
848
|
+
* the `topicKey → topic` pair. Hashing is cheap enough that a caller needing
|
|
849
|
+
* the key should always call this rather than check `knownTopics` first;
|
|
850
|
+
* the cache's FIFO eviction below keeps it bounded. Only `isAssigned`
|
|
851
|
+
* deliberately bypasses this (it must not pollute the cache on a read-only
|
|
852
|
+
* query), so if you add a new call site, prefer `rememberTopic` unless you
|
|
853
|
+
* have the same "read-only query" reason.
|
|
854
|
+
*/
|
|
855
|
+
rememberTopic(topic) {
|
|
856
|
+
const topicKey = createOpaqueKey(topic);
|
|
857
|
+
this.knownTopics.set(topicKey, topic);
|
|
858
|
+
if (this.knownTopics.size > MAX_KNOWN_TOPICS) {
|
|
859
|
+
const oldest = this.knownTopics.keys().next().value;
|
|
860
|
+
if (oldest !== void 0 && oldest !== topicKey && !this.assignedTopics.has(oldest)) {
|
|
861
|
+
this.knownTopics.delete(oldest);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return topicKey;
|
|
865
|
+
}
|
|
866
|
+
workerStorageKey(workerId) {
|
|
867
|
+
return `${this.workerPrefix}${workerId}`;
|
|
868
|
+
}
|
|
869
|
+
routeStorageKey(topicKey) {
|
|
870
|
+
return `${this.routePrefix}${topicKey}`;
|
|
871
|
+
}
|
|
872
|
+
subscriberStorageKey(topicKey, tabId) {
|
|
873
|
+
return `${this.subscriberPrefix}${topicKey}:${tabId}`;
|
|
874
|
+
}
|
|
875
|
+
removeStorage(key) {
|
|
876
|
+
try {
|
|
877
|
+
this.storage?.removeItem(key);
|
|
878
|
+
} catch {
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/** Force-flush any pending batched writes (used during shutdown/teardown). */
|
|
882
|
+
flushStorage() {
|
|
883
|
+
if (this.storage instanceof BatchingStorageWriter) this.storage.flush();
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
function listKeysSafe(storage, prefix) {
|
|
887
|
+
return storage ? listKeys(storage, prefix) : [];
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// src/core/trace.ts
|
|
891
|
+
var DEFAULT_METRICS_INTERVAL_MS = 5e3;
|
|
892
|
+
var MAX_PENDING_TOPICS = 1e3;
|
|
893
|
+
var MAX_PENDING_MESSAGES_PER_TOPIC = 256;
|
|
894
|
+
var LATENCY_BUCKET_COUNT = 20;
|
|
895
|
+
var LATENCY_BUCKET_SIZE_MS = 50;
|
|
896
|
+
var DataBusTraceReporter = class {
|
|
897
|
+
enabled;
|
|
898
|
+
mode;
|
|
899
|
+
metricsIntervalMs;
|
|
900
|
+
sink;
|
|
901
|
+
now;
|
|
902
|
+
intervalHandle = null;
|
|
903
|
+
intervalStartedAt = 0;
|
|
904
|
+
received = 0;
|
|
905
|
+
dispatched = 0;
|
|
906
|
+
latencySamples = 0;
|
|
907
|
+
topics = /* @__PURE__ */ new Set();
|
|
908
|
+
// Per-topic FIFO of received timestamps, used to compute dispatch latency.
|
|
909
|
+
receivedAt = /* @__PURE__ */ new Map();
|
|
910
|
+
// Bucketed histogram: bucket index = floor(delayMs / 50), capped at 19.
|
|
911
|
+
latencyBuckets = new Array(LATENCY_BUCKET_COUNT).fill(0);
|
|
912
|
+
latencySumMs = 0;
|
|
913
|
+
constructor(options, now = Date.now) {
|
|
914
|
+
this.enabled = options?.enabled ?? false;
|
|
915
|
+
this.mode = options?.mode ?? "all";
|
|
916
|
+
this.metricsIntervalMs = normalizeInterval(options?.metricsIntervalMs);
|
|
917
|
+
this.sink = options?.sink ?? (() => void 0);
|
|
918
|
+
this.now = now;
|
|
919
|
+
}
|
|
920
|
+
/** Start the periodic metrics flush interval. No-op when mode is 'events'. */
|
|
921
|
+
start() {
|
|
922
|
+
if (!this.enabled || this.intervalHandle || this.mode === "events") return;
|
|
923
|
+
this.intervalStartedAt = this.now();
|
|
924
|
+
this.intervalHandle = setInterval(() => this.flush(), this.metricsIntervalMs);
|
|
925
|
+
}
|
|
926
|
+
/** Pause the metrics interval and reset accumulated counters. */
|
|
927
|
+
pause() {
|
|
928
|
+
if (this.intervalHandle) clearInterval(this.intervalHandle);
|
|
929
|
+
this.intervalHandle = null;
|
|
930
|
+
this.resetMetrics();
|
|
931
|
+
}
|
|
932
|
+
stop() {
|
|
933
|
+
this.pause();
|
|
934
|
+
}
|
|
935
|
+
/** Record an instantaneous trace event (lifecycle, status, error, etc.). */
|
|
936
|
+
event(event) {
|
|
937
|
+
if (!this.enabled || this.mode === "metrics") return;
|
|
938
|
+
this.emit({ ...event, timestamp: this.now() });
|
|
939
|
+
}
|
|
940
|
+
/** Record that a message was received on `topic`; stores its timestamp for latency tracking. */
|
|
941
|
+
recordReceived(topic) {
|
|
942
|
+
if (!this.enabled || this.mode === "events") return;
|
|
943
|
+
this.received += 1;
|
|
944
|
+
this.topics.add(topic);
|
|
945
|
+
const queue = this.receivedAt.get(topic);
|
|
946
|
+
if (!queue) {
|
|
947
|
+
if (this.receivedAt.size >= MAX_PENDING_TOPICS) return;
|
|
948
|
+
this.receivedAt.set(topic, [this.now()]);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
if (queue.length >= MAX_PENDING_MESSAGES_PER_TOPIC) return;
|
|
952
|
+
queue.push(this.now());
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Record that a received message will never be dispatched locally. Pops the
|
|
956
|
+
* matching FIFO slot so a later dispatch on the same topic does not pair
|
|
957
|
+
* with a stale receive timestamp.
|
|
958
|
+
*/
|
|
959
|
+
recordDiscarded(topic) {
|
|
960
|
+
if (!this.enabled || this.mode === "events") return;
|
|
961
|
+
const queue = this.receivedAt.get(topic);
|
|
962
|
+
if (!queue) return;
|
|
963
|
+
queue.shift();
|
|
964
|
+
if (queue.length === 0) this.receivedAt.delete(topic);
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Record that a message was dispatched on `topic`. Pops the oldest receive
|
|
968
|
+
* timestamp (FIFO) and increments the latency histogram. Dispatches without
|
|
969
|
+
* a matching receive (e.g. broadcast fan-out from another tab) still count
|
|
970
|
+
* as dispatched but do not produce a latency sample.
|
|
971
|
+
*/
|
|
972
|
+
recordDispatched(topic) {
|
|
973
|
+
if (!this.enabled || this.mode === "events") return;
|
|
974
|
+
this.dispatched += 1;
|
|
975
|
+
this.topics.add(topic);
|
|
976
|
+
const queue = this.receivedAt.get(topic);
|
|
977
|
+
const receivedTimestamp = queue?.shift();
|
|
978
|
+
if (queue && queue.length === 0) this.receivedAt.delete(topic);
|
|
979
|
+
if (receivedTimestamp === void 0) return;
|
|
980
|
+
this.latencySamples += 1;
|
|
981
|
+
const delayMs = Math.max(0, this.now() - receivedTimestamp);
|
|
982
|
+
const bucketIndex = Math.min(LATENCY_BUCKET_COUNT - 1, Math.floor(delayMs / LATENCY_BUCKET_SIZE_MS));
|
|
983
|
+
this.latencyBuckets[bucketIndex] = (this.latencyBuckets[bucketIndex] ?? 0) + 1;
|
|
984
|
+
this.latencySumMs += delayMs;
|
|
985
|
+
}
|
|
986
|
+
/** Emit the accumulated metrics snapshot if the interval is active. */
|
|
987
|
+
flush() {
|
|
988
|
+
if (!this.enabled || this.mode === "events") return;
|
|
989
|
+
this.flushNow();
|
|
990
|
+
}
|
|
991
|
+
flushNow() {
|
|
992
|
+
const timestamp = this.now();
|
|
993
|
+
if (this.received > 0 || this.dispatched > 0) {
|
|
994
|
+
const samples = this.latencySamples;
|
|
995
|
+
this.emit({
|
|
996
|
+
type: "message_metrics",
|
|
997
|
+
durationMs: Math.max(0, timestamp - this.intervalStartedAt),
|
|
998
|
+
received: this.received,
|
|
999
|
+
dispatched: this.dispatched,
|
|
1000
|
+
topics: this.topics.size,
|
|
1001
|
+
dispatchSamples: samples,
|
|
1002
|
+
dispatchAvgMs: roundMs(samples === 0 ? 0 : this.latencySumMs / samples),
|
|
1003
|
+
// Percentiles are derived from the histogram, not sorted samples.
|
|
1004
|
+
dispatchP50Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.5)),
|
|
1005
|
+
dispatchP95Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.95)),
|
|
1006
|
+
dispatchMaxMs: roundMs(percentileMs(this.latencyBuckets, samples, 1)),
|
|
1007
|
+
timestamp
|
|
1008
|
+
});
|
|
1009
|
+
this.resetMetrics();
|
|
1010
|
+
}
|
|
1011
|
+
this.intervalStartedAt = timestamp;
|
|
1012
|
+
}
|
|
1013
|
+
resetMetrics() {
|
|
1014
|
+
this.received = 0;
|
|
1015
|
+
this.dispatched = 0;
|
|
1016
|
+
this.latencySamples = 0;
|
|
1017
|
+
this.topics.clear();
|
|
1018
|
+
this.receivedAt.clear();
|
|
1019
|
+
this.latencyBuckets.fill(0);
|
|
1020
|
+
this.latencySumMs = 0;
|
|
1021
|
+
}
|
|
1022
|
+
emit(event) {
|
|
1023
|
+
try {
|
|
1024
|
+
this.sink(event);
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
1027
|
+
console.warn("[cross-tab-worker-databus] trace sink threw:", error);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
function normalizeInterval(value) {
|
|
1033
|
+
if (value === void 0) return DEFAULT_METRICS_INTERVAL_MS;
|
|
1034
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1035
|
+
throw new RangeError("trace.metricsIntervalMs must be a positive finite number.");
|
|
1036
|
+
}
|
|
1037
|
+
return value;
|
|
1038
|
+
}
|
|
1039
|
+
function percentileMs(buckets, sampleCount, percentile) {
|
|
1040
|
+
if (sampleCount <= 0) return 0;
|
|
1041
|
+
const rank = Math.max(1, Math.ceil(percentile * sampleCount));
|
|
1042
|
+
let seen = 0;
|
|
1043
|
+
for (let index = 0; index < buckets.length; index += 1) {
|
|
1044
|
+
seen += buckets[index] ?? 0;
|
|
1045
|
+
if (seen >= rank) return (index + 0.5) * LATENCY_BUCKET_SIZE_MS;
|
|
1046
|
+
}
|
|
1047
|
+
return buckets.length * LATENCY_BUCKET_SIZE_MS;
|
|
1048
|
+
}
|
|
1049
|
+
function roundMs(value) {
|
|
1050
|
+
return Math.round(value * 10) / 10;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// src/core/data-bus.ts
|
|
1054
|
+
var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
|
|
1055
|
+
var CrossTabDataBus = class _CrossTabDataBus {
|
|
1056
|
+
transport;
|
|
1057
|
+
cluster;
|
|
1058
|
+
// Map of topic → set of local subscribers.
|
|
1059
|
+
topicHandlers = /* @__PURE__ */ new Map();
|
|
1060
|
+
// Topics for which the transport has been asked to subscribe (used to avoid
|
|
1061
|
+
// duplicate subscribe calls during reconnection).
|
|
1062
|
+
transportSubscribedTopics = /* @__PURE__ */ new Set();
|
|
1063
|
+
statusHandlers = /* @__PURE__ */ new Set();
|
|
1064
|
+
errorHandlers = /* @__PURE__ */ new Set();
|
|
1065
|
+
initialConfig;
|
|
1066
|
+
hasInitialConfig;
|
|
1067
|
+
trace;
|
|
1068
|
+
activeConfig;
|
|
1069
|
+
status = "disconnected";
|
|
1070
|
+
started = false;
|
|
1071
|
+
stopping = false;
|
|
1072
|
+
transportReady = false;
|
|
1073
|
+
// Last transport failure, retained so ready() can surface it to callers who
|
|
1074
|
+
// never awaited start() directly. Cleared on the next successful start.
|
|
1075
|
+
lastError = null;
|
|
1076
|
+
// Gate that serialises start/stop/suspend/resume — only one lifecycle
|
|
1077
|
+
// transition at a time. Resets to null once the operation settles.
|
|
1078
|
+
startPromise = null;
|
|
1079
|
+
// Timestamp of the last automatic transport recovery attempt.
|
|
1080
|
+
// Used to avoid a tight retry loop when the transport fails repeatedly.
|
|
1081
|
+
lastRecoveryAt = 0;
|
|
1082
|
+
// True while the tab is hidden so an in-flight transport start does not mark
|
|
1083
|
+
// the transport ready after suspendTransport() has stopped it.
|
|
1084
|
+
suspended = false;
|
|
1085
|
+
// Single gate for async transport.stop() cleanup, shared by failed opens and
|
|
1086
|
+
// page-hide suspension. Kept separate from startPromise so ready() still
|
|
1087
|
+
// surfaces a failure while later opens and automatic recovery wait for the
|
|
1088
|
+
// stop to settle.
|
|
1089
|
+
pendingStop = null;
|
|
1090
|
+
// Minimum interval in ms between automatic recovery attempts.
|
|
1091
|
+
static RECOVERY_COOLDOWN_MS = 1e3;
|
|
1092
|
+
constructor(options) {
|
|
1093
|
+
const { autoStart, initialConfig, trace, transport, ...clusterOptions } = options;
|
|
1094
|
+
this.transport = transport;
|
|
1095
|
+
this.initialConfig = initialConfig;
|
|
1096
|
+
this.hasInitialConfig = "initialConfig" in options;
|
|
1097
|
+
this.trace = new DataBusTraceReporter(trace);
|
|
1098
|
+
this.cluster = new WorkerClusterRuntime({
|
|
1099
|
+
...clusterOptions,
|
|
1100
|
+
handlers: {
|
|
1101
|
+
// The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
|
|
1102
|
+
// control message — meaning the owning Worker has delegated the action to us.
|
|
1103
|
+
onControl: (action, topic, data) => {
|
|
1104
|
+
if (action === "SUBSCRIBE") {
|
|
1105
|
+
if (this.subscribeTransport(topic)) this.traceSubscription("subscribe", topic);
|
|
1106
|
+
}
|
|
1107
|
+
if (action === "UNSUBSCRIBE") {
|
|
1108
|
+
if (this.unsubscribeTransport(topic)) this.traceSubscription("unsubscribe", topic);
|
|
1109
|
+
}
|
|
1110
|
+
if (action === "PUBLISH") this.runTransport(() => this.transport.publish(topic, data));
|
|
1111
|
+
},
|
|
1112
|
+
// The cluster calls `onEvent` when a publication broadcast arrives from
|
|
1113
|
+
// another tab. Dispatch locally if we have subscribers.
|
|
1114
|
+
onEvent: (eventType, payload) => {
|
|
1115
|
+
if (eventType !== PUBLICATION_EVENT) return;
|
|
1116
|
+
const message = payload;
|
|
1117
|
+
if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
|
|
1118
|
+
},
|
|
1119
|
+
onSuspend: () => {
|
|
1120
|
+
if (!this.stopping) this.trace.event({ type: "lifecycle", action: "suspend" });
|
|
1121
|
+
this.trace.pause();
|
|
1122
|
+
this.suspendTransport();
|
|
1123
|
+
},
|
|
1124
|
+
onResume: () => {
|
|
1125
|
+
this.trace.event({ type: "lifecycle", action: "resume" });
|
|
1126
|
+
this.trace.start();
|
|
1127
|
+
this.resumeTransport();
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
if (autoStart ?? this.hasInitialConfig) this.ensureStarted();
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Start the DataBus with the given transport config.
|
|
1135
|
+
*
|
|
1136
|
+
* The first call starts the cluster and opens the transport. Concurrent calls
|
|
1137
|
+
* during an in-flight start return the same promise. Once the operation
|
|
1138
|
+
* settles (success or failure) the promise gate is cleared so a subsequent
|
|
1139
|
+
* start() or resumeTransport() can open a fresh lifecycle.
|
|
1140
|
+
*/
|
|
1141
|
+
start(config) {
|
|
1142
|
+
if (this.startPromise) return this.startPromise;
|
|
1143
|
+
if (this.started) return Promise.resolve();
|
|
1144
|
+
this.started = true;
|
|
1145
|
+
this.stopping = false;
|
|
1146
|
+
this.suspended = false;
|
|
1147
|
+
this.activeConfig = config;
|
|
1148
|
+
this.lastError = null;
|
|
1149
|
+
this.trace.event({ type: "lifecycle", action: "start" });
|
|
1150
|
+
this.trace.start();
|
|
1151
|
+
this.updateStatus("connecting");
|
|
1152
|
+
this.cluster.start();
|
|
1153
|
+
const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
|
|
1154
|
+
this.startPromise = opening;
|
|
1155
|
+
for (const topic of this.topicHandlers.keys()) {
|
|
1156
|
+
this.cluster.subscribe(topic);
|
|
1157
|
+
}
|
|
1158
|
+
const snapshot = this.cluster.getSnapshot();
|
|
1159
|
+
this.trace.event({
|
|
1160
|
+
type: "coordination",
|
|
1161
|
+
coordinated: snapshot.coordinated,
|
|
1162
|
+
activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
|
|
1163
|
+
workers: snapshot.workers.map((w) => `${w.workerId}|${w.status}|load=${w.load}|tab=${w.tabId}`),
|
|
1164
|
+
routes: snapshot.routes.map((r) => `${r.topicKey}@${r.workerId}|confirmed=${r.confirmedAt !== void 0}`)
|
|
1165
|
+
});
|
|
1166
|
+
void opening.then(
|
|
1167
|
+
() => {
|
|
1168
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
1169
|
+
},
|
|
1170
|
+
() => {
|
|
1171
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
1172
|
+
}
|
|
1173
|
+
);
|
|
1174
|
+
return opening;
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Open the transport, chained after `before` to ensure lifecycle ordering.
|
|
1178
|
+
* When `stopClusterOnFailure` is true (initial start), a transport failure
|
|
1179
|
+
* tears down the cluster as well.
|
|
1180
|
+
*/
|
|
1181
|
+
openTransport(config, before, stopClusterOnFailure) {
|
|
1182
|
+
this.transportReady = false;
|
|
1183
|
+
const chainedPendingStop = this.pendingStop;
|
|
1184
|
+
return before.catch(() => void 0).then(() => {
|
|
1185
|
+
if (this.stopping || this.suspended) return;
|
|
1186
|
+
if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
|
|
1187
|
+
return Promise.resolve(
|
|
1188
|
+
this.transport.start(config, {
|
|
1189
|
+
onMessage: (message) => this.handleTransportMessage(message),
|
|
1190
|
+
onStatus: (status) => this.updateStatus(status),
|
|
1191
|
+
onError: (error) => this.reportError(error)
|
|
1192
|
+
})
|
|
1193
|
+
).then(() => {
|
|
1194
|
+
if (this.status === "error") {
|
|
1195
|
+
throw new Error("Transport failed during startup.");
|
|
1196
|
+
}
|
|
1197
|
+
if (!this.suspended && !this.stopping) this.transportReady = true;
|
|
1198
|
+
});
|
|
1199
|
+
}).catch((error) => {
|
|
1200
|
+
if (stopClusterOnFailure) this.started = false;
|
|
1201
|
+
if (!this.pendingStop) {
|
|
1202
|
+
this.pendingStop = Promise.resolve().then(() => this.transport.stop()).catch((stopError) => this.reportError(stopError));
|
|
1203
|
+
}
|
|
1204
|
+
this.updateStatus("error");
|
|
1205
|
+
this.reportError(error);
|
|
1206
|
+
this.lastError = error;
|
|
1207
|
+
this.transportReady = false;
|
|
1208
|
+
if (stopClusterOnFailure) {
|
|
1209
|
+
this.stopping = true;
|
|
1210
|
+
this.cluster.stop();
|
|
1211
|
+
this.stopping = false;
|
|
1212
|
+
}
|
|
1213
|
+
this.startPromise = null;
|
|
1214
|
+
throw error;
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Await the DataBus to be fully started (lazy init when using initialConfig).
|
|
1219
|
+
* Returns a rejected promise when the transport has failed and no start is in
|
|
1220
|
+
* flight — the caller can retry by calling start() or ready() again.
|
|
1221
|
+
*/
|
|
1222
|
+
ready() {
|
|
1223
|
+
try {
|
|
1224
|
+
this.ensureStarted();
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
return Promise.reject(error);
|
|
1227
|
+
}
|
|
1228
|
+
if (this.startPromise) return this.startPromise;
|
|
1229
|
+
if (this.transportReady) return Promise.resolve();
|
|
1230
|
+
if (this.lastError !== null) return Promise.reject(this.lastError);
|
|
1231
|
+
return Promise.reject(
|
|
1232
|
+
new Error("Transport is not ready and no start operation is in flight")
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Register a handler for `topic`. The handler fires on every publication
|
|
1237
|
+
* delivered to this tab, regardless of which tab published it. Returns an
|
|
1238
|
+
* unsubscribe function for convenience.
|
|
1239
|
+
*/
|
|
1240
|
+
subscribe(topic, handler) {
|
|
1241
|
+
this.ensureStarted();
|
|
1242
|
+
const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
|
|
1243
|
+
const wasUnused = handlers.size === 0;
|
|
1244
|
+
handlers.add(handler);
|
|
1245
|
+
this.topicHandlers.set(topic, handlers);
|
|
1246
|
+
if (wasUnused) this.cluster.subscribe(topic);
|
|
1247
|
+
return () => this.unsubscribe(topic, handler);
|
|
1248
|
+
}
|
|
1249
|
+
/** Remove a specific handler, or all handlers for `topic`. */
|
|
1250
|
+
unsubscribe(topic, handler) {
|
|
1251
|
+
const handlers = this.topicHandlers.get(topic);
|
|
1252
|
+
if (!handlers) return;
|
|
1253
|
+
if (handler) handlers.delete(handler);
|
|
1254
|
+
else handlers.clear();
|
|
1255
|
+
if (handlers.size > 0) return;
|
|
1256
|
+
this.topicHandlers.delete(topic);
|
|
1257
|
+
this.cluster.unsubscribe(topic);
|
|
1258
|
+
}
|
|
1259
|
+
/** Publish a message to `topic`. The owning Worker delivers it to the transport. */
|
|
1260
|
+
publish(topic, data) {
|
|
1261
|
+
this.ensureStarted();
|
|
1262
|
+
if (!this.cluster.publish(topic, data)) {
|
|
1263
|
+
this.reportError(
|
|
1264
|
+
new Error("Failed to send the publish control message to the owning worker.")
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
/** Register a handler that fires on every transport status change. Immediately invoked with the current status. */
|
|
1269
|
+
onStatus(handler) {
|
|
1270
|
+
this.statusHandlers.add(handler);
|
|
1271
|
+
try {
|
|
1272
|
+
handler(this.status);
|
|
1273
|
+
} catch (error) {
|
|
1274
|
+
this.reportError(error);
|
|
1275
|
+
}
|
|
1276
|
+
return () => this.statusHandlers.delete(handler);
|
|
1277
|
+
}
|
|
1278
|
+
/** Register a handler for transport errors. */
|
|
1279
|
+
onError(handler) {
|
|
1280
|
+
this.errorHandlers.add(handler);
|
|
1281
|
+
return () => this.errorHandlers.delete(handler);
|
|
1282
|
+
}
|
|
1283
|
+
/** Current transport connection status. */
|
|
1284
|
+
getStatus() {
|
|
1285
|
+
return this.status;
|
|
1286
|
+
}
|
|
1287
|
+
/** Snapshot of the cluster state (workers, routes, assignments). */
|
|
1288
|
+
getClusterSnapshot() {
|
|
1289
|
+
return this.cluster.getSnapshot();
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
|
|
1293
|
+
* and close the transport. Idempotent.
|
|
1294
|
+
*/
|
|
1295
|
+
async stop() {
|
|
1296
|
+
if (!this.started) return;
|
|
1297
|
+
this.stopping = true;
|
|
1298
|
+
this.trace.event({ type: "lifecycle", action: "stop" });
|
|
1299
|
+
this.trace.stop();
|
|
1300
|
+
this.topicHandlers.clear();
|
|
1301
|
+
this.cluster.stop();
|
|
1302
|
+
try {
|
|
1303
|
+
await this.startPromise?.catch(() => void 0);
|
|
1304
|
+
const pendingStop = this.pendingStop;
|
|
1305
|
+
if (pendingStop) await pendingStop.catch(() => void 0);
|
|
1306
|
+
else await this.transport.stop();
|
|
1307
|
+
} finally {
|
|
1308
|
+
this.transportSubscribedTopics.clear();
|
|
1309
|
+
this.started = false;
|
|
1310
|
+
this.stopping = false;
|
|
1311
|
+
this.suspended = false;
|
|
1312
|
+
this.transportReady = false;
|
|
1313
|
+
this.startPromise = null;
|
|
1314
|
+
this.pendingStop = null;
|
|
1315
|
+
this.lastError = null;
|
|
1316
|
+
this.activeConfig = void 0;
|
|
1317
|
+
this.updateStatus("disconnected");
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
/**
|
|
1321
|
+
* Incoming message from the transport.
|
|
1322
|
+
* Records metrics, checks ownership via the cluster, broadcasts to other tabs,
|
|
1323
|
+
* and dispatches locally.
|
|
1324
|
+
*/
|
|
1325
|
+
handleTransportMessage(message) {
|
|
1326
|
+
this.trace.recordReceived(message.topic);
|
|
1327
|
+
if (!this.cluster.isAssigned(message.topic)) {
|
|
1328
|
+
this.trace.recordDiscarded(message.topic);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
this.cluster.broadcastEvent(PUBLICATION_EVENT, message);
|
|
1332
|
+
if (this.cluster.hasLocalSubscriber(message.topic)) {
|
|
1333
|
+
this.dispatch(message);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
this.trace.recordDiscarded(message.topic);
|
|
1337
|
+
}
|
|
1338
|
+
/** Deliver a message to every local handler registered for its topic. */
|
|
1339
|
+
dispatch(message) {
|
|
1340
|
+
this.trace.recordDispatched(message.topic);
|
|
1341
|
+
for (const handler of this.topicHandlers.get(message.topic) ?? []) {
|
|
1342
|
+
try {
|
|
1343
|
+
handler(message);
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
this.reportError(error);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Propagate a status change to the cluster, trace, and all registered
|
|
1351
|
+
* status handlers. On reconnect, re-subscribe any topics assigned to us.
|
|
1352
|
+
*/
|
|
1353
|
+
updateStatus(status) {
|
|
1354
|
+
const previousStatus = this.status;
|
|
1355
|
+
this.status = status;
|
|
1356
|
+
if (previousStatus !== status) this.trace.event({ type: "status", status });
|
|
1357
|
+
this.cluster.setStatus(status);
|
|
1358
|
+
if (status === "disconnected" || status === "error") this.transportSubscribedTopics.clear();
|
|
1359
|
+
if (status === "connected" && previousStatus !== "connected") {
|
|
1360
|
+
for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
|
|
1361
|
+
}
|
|
1362
|
+
if (status === "error" && this.started && !this.stopping) {
|
|
1363
|
+
const now = Date.now();
|
|
1364
|
+
if (now - this.lastRecoveryAt >= _CrossTabDataBus.RECOVERY_COOLDOWN_MS) {
|
|
1365
|
+
this.lastRecoveryAt = now;
|
|
1366
|
+
setTimeout(() => {
|
|
1367
|
+
if (this.stopping || !this.started || this.suspended) return;
|
|
1368
|
+
if (this.status !== "error") return;
|
|
1369
|
+
void this.reopenTransport();
|
|
1370
|
+
}, _CrossTabDataBus.RECOVERY_COOLDOWN_MS);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
for (const handler of this.statusHandlers) {
|
|
1374
|
+
try {
|
|
1375
|
+
handler(status);
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
this.reportError(error);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
reportError(error) {
|
|
1382
|
+
this.trace.event({ type: "error", source: "transport" });
|
|
1383
|
+
for (const handler of this.errorHandlers) {
|
|
1384
|
+
try {
|
|
1385
|
+
handler(error);
|
|
1386
|
+
} catch (handlerError) {
|
|
1387
|
+
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
1388
|
+
console.warn("[cross-tab-worker-databus] error handler threw:", handlerError);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
traceSubscription(action, topic) {
|
|
1394
|
+
this.trace.event({
|
|
1395
|
+
type: "subscription",
|
|
1396
|
+
action,
|
|
1397
|
+
topic,
|
|
1398
|
+
activeTopics: this.transportSubscribedTopics.size
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
/** Ask the transport to subscribe to a topic (idempotent). */
|
|
1402
|
+
subscribeTransport(topic) {
|
|
1403
|
+
if (this.transportSubscribedTopics.has(topic)) return false;
|
|
1404
|
+
this.transportSubscribedTopics.add(topic);
|
|
1405
|
+
this.runTransport(() => this.transport.subscribe(topic));
|
|
1406
|
+
return true;
|
|
1407
|
+
}
|
|
1408
|
+
unsubscribeTransport(topic) {
|
|
1409
|
+
if (!this.transportSubscribedTopics.delete(topic)) return false;
|
|
1410
|
+
this.runTransport(() => this.transport.unsubscribe(topic));
|
|
1411
|
+
return true;
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Suspend the transport when the tab goes hidden. Stops the transport and
|
|
1415
|
+
* clears subscription state so it will be re-established on resume.
|
|
1416
|
+
*/
|
|
1417
|
+
suspendTransport() {
|
|
1418
|
+
if (this.stopping) return;
|
|
1419
|
+
this.suspended = true;
|
|
1420
|
+
this.transportReady = false;
|
|
1421
|
+
this.transportSubscribedTopics.clear();
|
|
1422
|
+
this.updateStatus("disconnected");
|
|
1423
|
+
if (this.pendingStop) return;
|
|
1424
|
+
const pending = this.startPromise ?? Promise.resolve();
|
|
1425
|
+
const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
|
|
1426
|
+
this.startPromise = stopping;
|
|
1427
|
+
this.pendingStop = stopping;
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Resume the transport when the tab becomes visible again, or recover from a
|
|
1431
|
+
* runtime transport failure. Re-opens the transport with the stored active
|
|
1432
|
+
* config, chained after any pending operation so an async transport stop
|
|
1433
|
+
* completes before the new start. Returns the opening promise.
|
|
1434
|
+
*/
|
|
1435
|
+
resumeTransport() {
|
|
1436
|
+
void this.reopenTransport();
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Re-open the transport with the previously stored active config. Chains
|
|
1440
|
+
* after any in-flight lifecycle operation (e.g. a suspend stop), swallowing
|
|
1441
|
+
* its rejection so the reopen is not blocked. Returns the opening promise so
|
|
1442
|
+
* callers can queue operations behind it.
|
|
1443
|
+
*/
|
|
1444
|
+
reopenTransport() {
|
|
1445
|
+
if (this.stopping || this.activeConfig === void 0) return Promise.resolve();
|
|
1446
|
+
if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
|
|
1447
|
+
const config = this.activeConfig;
|
|
1448
|
+
this.started = true;
|
|
1449
|
+
this.suspended = false;
|
|
1450
|
+
this.updateStatus("connecting");
|
|
1451
|
+
const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
|
|
1452
|
+
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
|
|
1453
|
+
this.startPromise = opening;
|
|
1454
|
+
void opening.then(
|
|
1455
|
+
() => {
|
|
1456
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
1457
|
+
},
|
|
1458
|
+
() => void 0
|
|
1459
|
+
);
|
|
1460
|
+
void opening.catch(() => void 0);
|
|
1461
|
+
return opening;
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Run a transport operation now if the transport is ready, otherwise queue
|
|
1465
|
+
* it behind the start promise. This ensures subscribe/unsubscribe calls made
|
|
1466
|
+
* during startup are not lost.
|
|
1467
|
+
*/
|
|
1468
|
+
runTransport(operation) {
|
|
1469
|
+
if (this.suspended) return;
|
|
1470
|
+
if (this.transportReady && !this.stopping) {
|
|
1471
|
+
try {
|
|
1472
|
+
void Promise.resolve(operation()).catch((error) => this.reportError(error));
|
|
1473
|
+
} catch (error) {
|
|
1474
|
+
this.reportError(error);
|
|
1475
|
+
}
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
let ready = this.startPromise;
|
|
1479
|
+
if (!ready && this.started && !this.stopping && this.activeConfig !== void 0) {
|
|
1480
|
+
ready = this.reopenTransport();
|
|
1481
|
+
}
|
|
1482
|
+
if (!ready || this.stopping) return;
|
|
1483
|
+
void ready.then(() => {
|
|
1484
|
+
if (!this.started || this.stopping || this.suspended) return;
|
|
1485
|
+
return operation();
|
|
1486
|
+
}).catch((error) => this.reportError(error));
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Ensure the DataBus is started, throwing if no initialConfig was provided.
|
|
1490
|
+
* Called automatically by subscribe/publish/ready when autoStart is true.
|
|
1491
|
+
*/
|
|
1492
|
+
ensureStarted() {
|
|
1493
|
+
if (this.started) return;
|
|
1494
|
+
if (!this.hasInitialConfig) {
|
|
1495
|
+
throw new Error(
|
|
1496
|
+
"CrossTabDataBus requires initialConfig for automatic startup, or an explicit start(config) call."
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
const starting = this.start(this.initialConfig);
|
|
1500
|
+
void starting.catch(() => void 0);
|
|
1501
|
+
}
|
|
1502
|
+
};
|
|
1503
|
+
|
|
1504
|
+
// src/worker-mode.ts
|
|
1505
|
+
function selectWorkerBackend(mode, availability = {}) {
|
|
1506
|
+
const hasDedicated = availability.worker ?? typeof Worker !== "undefined";
|
|
1507
|
+
const hasShared = availability.sharedWorker ?? typeof SharedWorker !== "undefined";
|
|
1508
|
+
if (mode === "shared" || mode === "auto") {
|
|
1509
|
+
return hasShared ? "shared" : hasDedicated ? "dedicated" : "local";
|
|
1510
|
+
}
|
|
1511
|
+
return hasDedicated ? "dedicated" : hasShared ? "shared" : "local";
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
export {
|
|
1515
|
+
createBrowserEnvironment,
|
|
1516
|
+
getOrCreateTabId,
|
|
1517
|
+
createOpaqueKey,
|
|
1518
|
+
DEFAULT_MAX_ACTIVE_WORKERS,
|
|
1519
|
+
selectLeastLoadedWorker,
|
|
1520
|
+
selectActiveWorkers,
|
|
1521
|
+
selectRebalanceTarget,
|
|
1522
|
+
hasActiveOwner,
|
|
1523
|
+
WorkerClusterRuntime,
|
|
1524
|
+
CrossTabDataBus,
|
|
1525
|
+
selectWorkerBackend
|
|
1526
|
+
};
|
|
1527
|
+
//# sourceMappingURL=chunk-GABYBK7I.js.map
|