cross-tab-worker-databus 0.20.71 → 0.20.86
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 +216 -2
- package/README.md +8 -0
- package/README.zh.md +4 -0
- package/dist/centrifuge-protocol.d.ts +59 -26
- package/dist/centrifuge-protocol.d.ts.map +1 -1
- package/dist/centrifuge-session.d.ts +9 -0
- package/dist/centrifuge-session.d.ts.map +1 -1
- package/dist/centrifuge.d.ts +12 -2
- package/dist/centrifuge.d.ts.map +1 -1
- package/dist/centrifuge.js +168 -88
- package/dist/centrifuge.js.map +3 -3
- package/dist/centrifuge.shared.worker.js +146 -38
- package/dist/centrifuge.shared.worker.js.map +3 -3
- package/dist/centrifuge.worker.js +140 -33
- package/dist/centrifuge.worker.js.map +3 -3
- package/dist/{chunk-D2SIT473.js → chunk-SDOV3UHG.js} +1304 -515
- package/dist/chunk-SDOV3UHG.js.map +7 -0
- package/dist/chunk-TZ7ZP7YD.js +175 -0
- package/dist/chunk-TZ7ZP7YD.js.map +7 -0
- package/dist/cjs/centrifuge.cjs +1608 -650
- package/dist/cjs/centrifuge.cjs.map +4 -4
- package/dist/cjs/hooks.cjs +37 -2
- package/dist/cjs/hooks.cjs.map +3 -3
- package/dist/cjs/index.cjs +1692 -747
- package/dist/cjs/index.cjs.map +4 -4
- package/dist/cjs/vue.cjs +45 -2
- package/dist/cjs/vue.cjs.map +3 -3
- package/dist/core/cluster.d.ts +40 -4
- package/dist/core/cluster.d.ts.map +1 -1
- package/dist/core/data-bus.d.ts +134 -77
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/core/dedup-manager.d.ts +98 -0
- package/dist/core/dedup-manager.d.ts.map +1 -0
- package/dist/core/environment.d.ts +43 -3
- package/dist/core/environment.d.ts.map +1 -1
- package/dist/core/replay-manager.d.ts +129 -0
- package/dist/core/replay-manager.d.ts.map +1 -0
- package/dist/core/replay-persistence.d.ts +2 -1
- package/dist/core/replay-persistence.d.ts.map +1 -1
- package/dist/core/replay-pruning.d.ts +21 -0
- package/dist/core/replay-pruning.d.ts.map +1 -0
- package/dist/core/routing.d.ts +22 -3
- package/dist/core/routing.d.ts.map +1 -1
- package/dist/core/storage-batch.d.ts +0 -4
- package/dist/core/storage-batch.d.ts.map +1 -1
- package/dist/core/trace.d.ts +55 -15
- package/dist/core/trace.d.ts.map +1 -1
- package/dist/core/types.d.ts +58 -9
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.d.ts.map +1 -0
- package/dist/hooks.d.ts +12 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +28 -2
- package/dist/hooks.js.map +2 -2
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +263 -166
- package/dist/index.js.map +3 -3
- package/dist/utils/constants.d.ts +170 -0
- package/dist/utils/constants.d.ts.map +1 -0
- package/dist/utils/error-utils.d.ts +28 -0
- package/dist/utils/error-utils.d.ts.map +1 -0
- package/dist/utils/metadata.d.ts +16 -0
- package/dist/utils/metadata.d.ts.map +1 -0
- package/dist/utils/storage-utils.d.ts +24 -0
- package/dist/utils/storage-utils.d.ts.map +1 -0
- package/dist/utils/validation.d.ts +76 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/vue.d.ts +13 -1
- package/dist/vue.d.ts.map +1 -1
- package/dist/vue.js +36 -2
- package/dist/vue.js.map +2 -2
- package/dist/websocket.d.ts +6 -1
- package/dist/websocket.d.ts.map +1 -1
- package/dist/worker-mode.d.ts +6 -2
- package/dist/worker-mode.d.ts.map +1 -1
- package/docs/README.md +1 -0
- package/docs/api.md +135 -4
- package/docs/architecture.md +92 -1
- package/docs/benchmarks.md +24 -0
- package/docs/capabilities.md +24 -3
- package/docs/configuration.md +58 -0
- package/docs/getting-started.md +35 -0
- package/docs/release-checklist.md +28 -5
- package/docs/roadmap.md +81 -5
- package/docs/zh/README.md +2 -1
- package/docs/zh/api.md +134 -4
- package/docs/zh/architecture.md +61 -1
- package/docs/zh/benchmarks.md +24 -0
- package/docs/zh/capabilities.md +21 -3
- package/docs/zh/configuration.md +53 -0
- package/docs/zh/getting-started.md +35 -0
- package/docs/zh/release-checklist.md +29 -6
- package/docs/zh/roadmap.md +93 -5
- package/package.json +39 -16
- package/dist/chunk-D2SIT473.js.map +0 -7
package/dist/cjs/centrifuge.cjs
CHANGED
|
@@ -25,6 +25,145 @@ __export(centrifuge_exports, {
|
|
|
25
25
|
});
|
|
26
26
|
module.exports = __toCommonJS(centrifuge_exports);
|
|
27
27
|
|
|
28
|
+
// src/utils/constants.ts
|
|
29
|
+
var WORKER_STATUS = {
|
|
30
|
+
CONNECTING: "connecting",
|
|
31
|
+
CONNECTED: "connected",
|
|
32
|
+
DISCONNECTED: "disconnected",
|
|
33
|
+
ERROR: "error"
|
|
34
|
+
};
|
|
35
|
+
var WORKER_ROLE = {
|
|
36
|
+
ACTIVE: "active",
|
|
37
|
+
STANDBY: "standby"
|
|
38
|
+
};
|
|
39
|
+
var TAB_VISIBILITY = {
|
|
40
|
+
VISIBLE: "visible",
|
|
41
|
+
HIDDEN: "hidden"
|
|
42
|
+
};
|
|
43
|
+
var WORKER_MODE = {
|
|
44
|
+
DEDICATED: "dedicated",
|
|
45
|
+
SHARED: "shared",
|
|
46
|
+
AUTO: "auto"
|
|
47
|
+
};
|
|
48
|
+
var WORKER_BACKEND = {
|
|
49
|
+
DEDICATED: "dedicated",
|
|
50
|
+
SHARED: "shared",
|
|
51
|
+
LOCAL: "local"
|
|
52
|
+
};
|
|
53
|
+
var CHANNEL_FALLBACK = {
|
|
54
|
+
NONE: "none",
|
|
55
|
+
STORAGE_EVENT: "storage-event"
|
|
56
|
+
};
|
|
57
|
+
var CONTROL_ACTION = {
|
|
58
|
+
SUBSCRIBE: "SUBSCRIBE",
|
|
59
|
+
UNSUBSCRIBE: "UNSUBSCRIBE",
|
|
60
|
+
PUBLISH: "PUBLISH"
|
|
61
|
+
};
|
|
62
|
+
var CLUSTER_MESSAGE_TYPE = {
|
|
63
|
+
CONTROL: "CONTROL",
|
|
64
|
+
EVENT: "EVENT",
|
|
65
|
+
REGISTRY: "REGISTRY",
|
|
66
|
+
ROUTE_RELEASED: "ROUTE_RELEASED"
|
|
67
|
+
};
|
|
68
|
+
var DEFAULT_STORAGE_PREFIX = "cross-tab-worker-databus";
|
|
69
|
+
var STORAGE_CHANNEL_PREFIX = `${DEFAULT_STORAGE_PREFIX}:channel:`;
|
|
70
|
+
var TAB_ID_STORAGE_KEY = `${DEFAULT_STORAGE_PREFIX}:tab-id`;
|
|
71
|
+
var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
|
|
72
|
+
var TRACE_EVENT_TYPE = {
|
|
73
|
+
LIFECYCLE: "lifecycle",
|
|
74
|
+
STATUS: "status",
|
|
75
|
+
SUBSCRIPTION: "subscription",
|
|
76
|
+
COORDINATION: "coordination",
|
|
77
|
+
ERROR: "error",
|
|
78
|
+
RELIABILITY: "reliability",
|
|
79
|
+
MESSAGE_METRICS: "message_metrics"
|
|
80
|
+
};
|
|
81
|
+
var TRACE_LIFECYCLE_ACTION = {
|
|
82
|
+
START: "start",
|
|
83
|
+
STOP: "stop",
|
|
84
|
+
SUSPEND: "suspend",
|
|
85
|
+
RESUME: "resume"
|
|
86
|
+
};
|
|
87
|
+
var INVOKE_LABEL = {
|
|
88
|
+
DISPATCH: "dispatch",
|
|
89
|
+
STATUS: "status",
|
|
90
|
+
ERROR_HANDLER: "error handler"
|
|
91
|
+
};
|
|
92
|
+
var TRACE_MODE = {
|
|
93
|
+
EVENTS: "events",
|
|
94
|
+
METRICS: "metrics",
|
|
95
|
+
ALL: "all"
|
|
96
|
+
};
|
|
97
|
+
var TRACE_ERROR_SOURCE = {
|
|
98
|
+
TRANSPORT: "transport",
|
|
99
|
+
OPERATION: "operation"
|
|
100
|
+
};
|
|
101
|
+
var RELIABILITY_OPERATION = {
|
|
102
|
+
TRANSPORT_RECOVERY: "transport_recovery",
|
|
103
|
+
ROUTE_ACK: "route_ack",
|
|
104
|
+
ROUTE_MIGRATION: "route_migration",
|
|
105
|
+
// A re-election that recovered a stranded unconfirmed handoff (previous
|
|
106
|
+
// owner gone, ACK never arrived). Distinct from ROUTE_MIGRATION so trace
|
|
107
|
+
// consumers can tell recoveries apart from routine graceful handoffs.
|
|
108
|
+
ROUTE_MIGRATION_RECOVERY: "route_migration_recovery",
|
|
109
|
+
PERSISTENCE_CLEANUP: "persistence_cleanup",
|
|
110
|
+
PERSISTENCE_RETRY: "persistence_retry",
|
|
111
|
+
DEDUP_SUPPRESSED: "dedup_suppressed"
|
|
112
|
+
};
|
|
113
|
+
var RECOVERY_OUTCOME = {
|
|
114
|
+
SCHEDULED: "scheduled",
|
|
115
|
+
SUCCEEDED: "succeeded",
|
|
116
|
+
FAILED: "failed",
|
|
117
|
+
EXHAUSTED: "exhausted"
|
|
118
|
+
};
|
|
119
|
+
var SUBSCRIPTION_ACTION = {
|
|
120
|
+
SUBSCRIBE: "subscribe",
|
|
121
|
+
UNSUBSCRIBE: "unsubscribe"
|
|
122
|
+
};
|
|
123
|
+
var PRUNE_STRATEGY = {
|
|
124
|
+
COUNT: "count",
|
|
125
|
+
AGE: "age",
|
|
126
|
+
BOTH: "both"
|
|
127
|
+
};
|
|
128
|
+
var PERSISTENCE_OPERATION = {
|
|
129
|
+
LOAD: "load",
|
|
130
|
+
APPEND: "append",
|
|
131
|
+
CLEAR: "clear",
|
|
132
|
+
CLEAR_TOPIC: "clearTopic",
|
|
133
|
+
CLEAR_BEFORE: "clearBefore"
|
|
134
|
+
};
|
|
135
|
+
var FAILURE_SOURCE = {
|
|
136
|
+
TRANSPORT: "transport",
|
|
137
|
+
PERSISTENCE: "persistence",
|
|
138
|
+
DISPATCH: "dispatch"
|
|
139
|
+
};
|
|
140
|
+
var HEALTH_STATE = {
|
|
141
|
+
STOPPED: "stopped",
|
|
142
|
+
STARTING: "starting",
|
|
143
|
+
HEALTHY: "healthy",
|
|
144
|
+
RECOVERING: "recovering",
|
|
145
|
+
SUSPENDED: "suspended",
|
|
146
|
+
DEGRADED: "degraded"
|
|
147
|
+
};
|
|
148
|
+
var CENTRIFUGE_INPUT_TYPE = {
|
|
149
|
+
INIT: "INIT",
|
|
150
|
+
SUBSCRIBE: "SUBSCRIBE",
|
|
151
|
+
UNSUBSCRIBE: "UNSUBSCRIBE",
|
|
152
|
+
PUBLISH: "PUBLISH",
|
|
153
|
+
PUBLISH_BIN: "PUBLISH_BIN",
|
|
154
|
+
PING: "PING",
|
|
155
|
+
STOP: "STOP",
|
|
156
|
+
TOKEN_RESPONSE: "TOKEN_RESPONSE",
|
|
157
|
+
TOKEN_ERROR: "TOKEN_ERROR"
|
|
158
|
+
};
|
|
159
|
+
var CENTRIFUGE_OUTPUT_TYPE = {
|
|
160
|
+
STATUS: "STATUS",
|
|
161
|
+
MESSAGE: "MESSAGE",
|
|
162
|
+
MESSAGE_BIN: "MESSAGE_BIN",
|
|
163
|
+
ERROR: "ERROR",
|
|
164
|
+
TOKEN_REQUEST: "TOKEN_REQUEST"
|
|
165
|
+
};
|
|
166
|
+
|
|
28
167
|
// src/core/environment.ts
|
|
29
168
|
function getStorage(name) {
|
|
30
169
|
try {
|
|
@@ -40,8 +179,53 @@ function randomId() {
|
|
|
40
179
|
return Math.random().toString(36).slice(2);
|
|
41
180
|
}
|
|
42
181
|
}
|
|
182
|
+
function createStorageEventChannel(options) {
|
|
183
|
+
const { name, storage, win } = options;
|
|
184
|
+
if (!storage || !win) return null;
|
|
185
|
+
const key = `${STORAGE_CHANNEL_PREFIX}${name}`;
|
|
186
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
187
|
+
let sequence = 0;
|
|
188
|
+
const onStorage = (event) => {
|
|
189
|
+
if (event.key !== key || event.newValue === null) return;
|
|
190
|
+
let message;
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(event.newValue);
|
|
193
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.seq !== "number" || !parsed.message) return;
|
|
194
|
+
message = parsed.message;
|
|
195
|
+
} catch {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const event_ = { data: message };
|
|
199
|
+
for (const listener of [...listeners]) listener(event_);
|
|
200
|
+
};
|
|
201
|
+
win.addEventListener("storage", onStorage);
|
|
202
|
+
let closed = false;
|
|
203
|
+
return {
|
|
204
|
+
addEventListener(_type, listener) {
|
|
205
|
+
listeners.add(listener);
|
|
206
|
+
},
|
|
207
|
+
removeEventListener(_type, listener) {
|
|
208
|
+
listeners.delete(listener);
|
|
209
|
+
},
|
|
210
|
+
postMessage(message) {
|
|
211
|
+
if (closed) return;
|
|
212
|
+
sequence += 1;
|
|
213
|
+
storage.setItem(key, JSON.stringify({ seq: sequence, message }));
|
|
214
|
+
},
|
|
215
|
+
close() {
|
|
216
|
+
closed = true;
|
|
217
|
+
win.removeEventListener("storage", onStorage);
|
|
218
|
+
listeners.clear();
|
|
219
|
+
try {
|
|
220
|
+
storage.removeItem(key);
|
|
221
|
+
} catch {
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
43
226
|
var tabIdentityInitialized = false;
|
|
44
|
-
function createBrowserEnvironment() {
|
|
227
|
+
function createBrowserEnvironment(options) {
|
|
228
|
+
const channelFallback = options?.channelFallback ?? CHANNEL_FALLBACK.NONE;
|
|
45
229
|
return {
|
|
46
230
|
storage: getStorage("localStorage"),
|
|
47
231
|
sessionStorage: getStorage("sessionStorage"),
|
|
@@ -49,14 +233,18 @@ function createBrowserEnvironment() {
|
|
|
49
233
|
randomId,
|
|
50
234
|
createChannel: (name) => {
|
|
51
235
|
try {
|
|
52
|
-
|
|
236
|
+
if (typeof BroadcastChannel !== "undefined") return new BroadcastChannel(name);
|
|
53
237
|
} catch {
|
|
54
|
-
return null;
|
|
55
238
|
}
|
|
239
|
+
return channelFallback === CHANNEL_FALLBACK.STORAGE_EVENT ? createStorageEventChannel({
|
|
240
|
+
name,
|
|
241
|
+
storage: getStorage("localStorage"),
|
|
242
|
+
win: typeof window !== "undefined" && typeof window.addEventListener === "function" ? window : null
|
|
243
|
+
}) : null;
|
|
56
244
|
},
|
|
57
245
|
setInterval: (callback, intervalMs) => globalThis.setInterval(callback, intervalMs),
|
|
58
246
|
clearInterval: (handle) => globalThis.clearInterval(handle),
|
|
59
|
-
getVisibilityState: () => typeof document !== "undefined" && document.visibilityState ===
|
|
247
|
+
getVisibilityState: () => typeof document !== "undefined" && document.visibilityState === TAB_VISIBILITY.HIDDEN ? TAB_VISIBILITY.HIDDEN : TAB_VISIBILITY.VISIBLE,
|
|
60
248
|
addVisibilityChangeListener: (listener) => {
|
|
61
249
|
if (typeof document !== "undefined") document.addEventListener("visibilitychange", listener);
|
|
62
250
|
},
|
|
@@ -87,7 +275,7 @@ function canUseStorage(storage, probeKey) {
|
|
|
87
275
|
return false;
|
|
88
276
|
}
|
|
89
277
|
}
|
|
90
|
-
function getOrCreateTabId(environment, key =
|
|
278
|
+
function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY) {
|
|
91
279
|
const storage = environment.sessionStorage;
|
|
92
280
|
try {
|
|
93
281
|
const existing = storage?.getItem(key);
|
|
@@ -140,21 +328,73 @@ function avalancheMix(self, neighbor) {
|
|
|
140
328
|
|
|
141
329
|
// src/core/routing.ts
|
|
142
330
|
var DEFAULT_MAX_ACTIVE_WORKERS = 3;
|
|
143
|
-
function
|
|
331
|
+
function effectiveWorkerLoad(worker, options) {
|
|
332
|
+
const baseLoad = Number.isFinite(worker.load) ? worker.load : 0;
|
|
333
|
+
const sample = worker.throughput;
|
|
334
|
+
const messageRateWeight = options?.messageRateWeight ?? 0;
|
|
335
|
+
const byteRateWeight = options?.byteRateWeight ?? 0;
|
|
336
|
+
const scheduleLagWeight = options?.scheduleLagWeight ?? 0;
|
|
337
|
+
if (!sample || !Number.isFinite(sample.windowMs) || sample.windowMs <= 0 || messageRateWeight === 0 && byteRateWeight === 0 && scheduleLagWeight === 0) {
|
|
338
|
+
return baseLoad;
|
|
339
|
+
}
|
|
340
|
+
const windowSeconds = sample.windowMs / 1e3;
|
|
341
|
+
const messageRate = sample.messageCount / windowSeconds;
|
|
342
|
+
const byteRate = sample.byteCount / windowSeconds;
|
|
343
|
+
const scheduleLagRatio = sample.overrunMs / sample.windowMs;
|
|
344
|
+
const weighted = baseLoad + messageRateWeight * messageRate + byteRateWeight * byteRate + scheduleLagWeight * scheduleLagRatio;
|
|
345
|
+
return Number.isFinite(weighted) ? weighted : baseLoad;
|
|
346
|
+
}
|
|
347
|
+
function approximatePayloadBytes(payload) {
|
|
348
|
+
return estimatePayloadBytes(payload, 0);
|
|
349
|
+
}
|
|
350
|
+
var MAX_PAYLOAD_DEPTH = 6;
|
|
351
|
+
function estimatePayloadBytes(payload, depth) {
|
|
352
|
+
if (payload === null || payload === void 0) return 0;
|
|
353
|
+
switch (typeof payload) {
|
|
354
|
+
case "boolean":
|
|
355
|
+
return 4;
|
|
356
|
+
case "number":
|
|
357
|
+
case "bigint":
|
|
358
|
+
return 8;
|
|
359
|
+
case "string":
|
|
360
|
+
return payload.length;
|
|
361
|
+
case "symbol":
|
|
362
|
+
case "function":
|
|
363
|
+
return 0;
|
|
364
|
+
case "object":
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
if (depth >= MAX_PAYLOAD_DEPTH) return 0;
|
|
368
|
+
if (payload instanceof ArrayBuffer) return payload.byteLength;
|
|
369
|
+
if (ArrayBuffer.isView(payload)) return payload.byteLength;
|
|
370
|
+
if (Array.isArray(payload)) {
|
|
371
|
+
let sum2 = 8;
|
|
372
|
+
for (const item of payload) sum2 += estimatePayloadBytes(item, depth + 1);
|
|
373
|
+
return sum2;
|
|
374
|
+
}
|
|
375
|
+
let sum = 0;
|
|
376
|
+
for (const value of Object.values(payload)) {
|
|
377
|
+
sum += estimatePayloadBytes(value, depth + 1);
|
|
378
|
+
}
|
|
379
|
+
return sum;
|
|
380
|
+
}
|
|
381
|
+
function selectLeastLoadedWorker(workers, preferredWorkerId, options) {
|
|
144
382
|
const preferred = workers.find((worker) => worker.workerId === preferredWorkerId);
|
|
145
383
|
if (preferred) return preferred;
|
|
146
384
|
return workers.reduce((least, worker) => {
|
|
147
385
|
if (!least) return worker;
|
|
148
|
-
const byLoad = worker
|
|
386
|
+
const byLoad = effectiveWorkerLoad(worker, options) - effectiveWorkerLoad(least, options);
|
|
149
387
|
if (byLoad !== 0) return byLoad < 0 ? worker : least;
|
|
150
388
|
if (worker.workerId < least.workerId) return worker;
|
|
151
389
|
return least;
|
|
152
390
|
}, void 0);
|
|
153
391
|
}
|
|
154
392
|
function selectActiveWorkers(workers, maxActiveWorkers = DEFAULT_MAX_ACTIVE_WORKERS) {
|
|
155
|
-
const healthyWorkers = workers.filter(
|
|
393
|
+
const healthyWorkers = workers.filter(
|
|
394
|
+
(worker) => worker.status === WORKER_STATUS.CONNECTING || worker.status === WORKER_STATUS.CONNECTED
|
|
395
|
+
);
|
|
156
396
|
const availableWorkers = healthyWorkers.length > 0 ? healthyWorkers : [...workers];
|
|
157
|
-
const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState ===
|
|
397
|
+
const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === TAB_VISIBILITY.VISIBLE);
|
|
158
398
|
const candidates = visibleWorkers.length > 0 ? visibleWorkers : availableWorkers;
|
|
159
399
|
return candidates.sort(
|
|
160
400
|
(left, right) => left.registeredAt - right.registeredAt || (left.workerId < right.workerId ? -1 : left.workerId > right.workerId ? 1 : 0)
|
|
@@ -179,6 +419,7 @@ var BatchingStorageWriter = class {
|
|
|
179
419
|
constructor(storage) {
|
|
180
420
|
this.storage = storage;
|
|
181
421
|
}
|
|
422
|
+
storage;
|
|
182
423
|
/** Coalesced write set. A `null` value represents a pending delete. */
|
|
183
424
|
pending = /* @__PURE__ */ new Map();
|
|
184
425
|
/** Per-key retry counter, reset on a successful write. */
|
|
@@ -235,7 +476,7 @@ var BatchingStorageWriter = class {
|
|
|
235
476
|
this.pending.delete(key);
|
|
236
477
|
this.retryCount.delete(key);
|
|
237
478
|
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
238
|
-
console.warn(
|
|
479
|
+
console.warn(`[${DEFAULT_STORAGE_PREFIX}] storage write gave up after retries, dropping key:`, key);
|
|
239
480
|
}
|
|
240
481
|
continue;
|
|
241
482
|
}
|
|
@@ -295,12 +536,16 @@ var BatchingStorageWriter = class {
|
|
|
295
536
|
}
|
|
296
537
|
};
|
|
297
538
|
|
|
298
|
-
// src/
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
539
|
+
// src/utils/metadata.ts
|
|
540
|
+
function publicationMetadata(messageId, timestamp) {
|
|
541
|
+
if (messageId === void 0 && timestamp === void 0) return void 0;
|
|
542
|
+
return {
|
|
543
|
+
...messageId === void 0 ? {} : { messageId },
|
|
544
|
+
...timestamp === void 0 ? {} : { timestamp }
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/utils/storage-utils.ts
|
|
304
549
|
function readJson(storage, key) {
|
|
305
550
|
try {
|
|
306
551
|
const value = storage.getItem(key);
|
|
@@ -315,13 +560,6 @@ function writeJson(storage, key, value) {
|
|
|
315
560
|
} catch {
|
|
316
561
|
}
|
|
317
562
|
}
|
|
318
|
-
function publicationMetadata(messageId, timestamp) {
|
|
319
|
-
if (messageId === void 0 && timestamp === void 0) return void 0;
|
|
320
|
-
return {
|
|
321
|
-
...messageId === void 0 ? {} : { messageId },
|
|
322
|
-
...timestamp === void 0 ? {} : { timestamp }
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
563
|
function listKeys(storage, prefix) {
|
|
326
564
|
try {
|
|
327
565
|
return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter(
|
|
@@ -334,6 +572,120 @@ function listKeys(storage, prefix) {
|
|
|
334
572
|
function readAllByPrefix(storage, prefix) {
|
|
335
573
|
return listKeys(storage, prefix).map((key) => ({ key, value: readJson(storage, key) })).filter((entry) => entry.value !== null);
|
|
336
574
|
}
|
|
575
|
+
|
|
576
|
+
// src/utils/validation.ts
|
|
577
|
+
function assertPositiveSafeInteger(value, name) {
|
|
578
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
579
|
+
throw new TypeError(`${name} must be a positive safe integer, got ${String(value)}.`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
function assertPositiveFiniteNumber(value, name) {
|
|
583
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
584
|
+
throw new TypeError(`${name} must be a positive finite number.`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function assertNonNegativeFiniteNumber(value, name) {
|
|
588
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
589
|
+
throw new TypeError(`${name} must be a non-negative finite number.`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function assertPruneStrategy(value) {
|
|
593
|
+
const allowed = [PRUNE_STRATEGY.COUNT, PRUNE_STRATEGY.AGE, PRUNE_STRATEGY.BOTH];
|
|
594
|
+
if (!allowed.includes(String(value))) {
|
|
595
|
+
throw new TypeError("pruneStrategy must be count, age, or both.");
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function assertReplayOptions(replay) {
|
|
599
|
+
if (!replay) return;
|
|
600
|
+
if (replay.maxPerTopic !== void 0) assertPositiveSafeInteger(replay.maxPerTopic, "replay.maxPerTopic");
|
|
601
|
+
if (replay.pruneStrategy !== void 0) assertPruneStrategy(replay.pruneStrategy);
|
|
602
|
+
if (replay.retentionMs !== void 0) assertPositiveFiniteNumber(replay.retentionMs, "replay.retentionMs");
|
|
603
|
+
if (replay.retentionSweepMs !== void 0) {
|
|
604
|
+
assertPositiveFiniteNumber(replay.retentionSweepMs, "replay.retentionSweepMs");
|
|
605
|
+
}
|
|
606
|
+
if (replay.persistenceRetry) assertPersistenceRetryOptions(replay.persistenceRetry);
|
|
607
|
+
}
|
|
608
|
+
function assertPersistenceRetryOptions(retry) {
|
|
609
|
+
if (retry.maxAttempts !== void 0) {
|
|
610
|
+
assertPositiveSafeInteger(retry.maxAttempts, "replay.persistenceRetry.maxAttempts");
|
|
611
|
+
}
|
|
612
|
+
if (retry.backoffMs !== void 0) {
|
|
613
|
+
assertNonNegativeFiniteNumber(retry.backoffMs, "replay.persistenceRetry.backoffMs");
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function assertDedupOptions(dedup) {
|
|
617
|
+
if (!dedup) return;
|
|
618
|
+
if (dedup.maxEntries !== void 0) assertPositiveSafeInteger(dedup.maxEntries, "dedup.maxEntries");
|
|
619
|
+
if (dedup.ttlMs !== void 0) assertPositiveFiniteNumber(dedup.ttlMs, "dedup.ttlMs");
|
|
620
|
+
if (dedup.sweepMs !== void 0) assertPositiveFiniteNumber(dedup.sweepMs, "dedup.sweepMs");
|
|
621
|
+
const bounds = dedup.adaptiveTtl;
|
|
622
|
+
if (bounds !== void 0) {
|
|
623
|
+
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
624
|
+
if (!finite(bounds.minMs) || !finite(bounds.maxMs) || bounds.minMs <= 0 || bounds.maxMs < bounds.minMs) {
|
|
625
|
+
throw new TypeError("dedup.adaptiveTtl bounds are invalid.");
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function assertRecoveryOptions(recovery) {
|
|
630
|
+
if (!recovery) return;
|
|
631
|
+
if (recovery.cooldownMs !== void 0) {
|
|
632
|
+
assertPositiveFiniteNumber(recovery.cooldownMs, "recovery.cooldownMs");
|
|
633
|
+
}
|
|
634
|
+
const maxAttempts = recovery.maxAttempts;
|
|
635
|
+
if (maxAttempts !== void 0 && !(maxAttempts === Number.POSITIVE_INFINITY || typeof maxAttempts === "number" && Number.isSafeInteger(maxAttempts) && maxAttempts > 0)) {
|
|
636
|
+
throw new TypeError("recovery.maxAttempts must be a positive safe integer.");
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function assertLoadWeightingOptions(loadWeighting) {
|
|
640
|
+
if (!loadWeighting) return;
|
|
641
|
+
const weights = {
|
|
642
|
+
messageRateWeight: loadWeighting.messageRateWeight,
|
|
643
|
+
byteRateWeight: loadWeighting.byteRateWeight,
|
|
644
|
+
scheduleLagWeight: loadWeighting.scheduleLagWeight
|
|
645
|
+
};
|
|
646
|
+
for (const [name, value] of Object.entries(weights)) {
|
|
647
|
+
if (value !== void 0) assertNonNegativeFiniteNumber(value, `loadWeighting.${name}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
function assertClusterOptions(options) {
|
|
651
|
+
if (options.maxActiveWorkers !== void 0) {
|
|
652
|
+
assertPositiveSafeInteger(options.maxActiveWorkers, "maxActiveWorkers");
|
|
653
|
+
}
|
|
654
|
+
if (options.heartbeatIntervalMs !== void 0) {
|
|
655
|
+
assertPositiveFiniteNumber(options.heartbeatIntervalMs, "heartbeatIntervalMs");
|
|
656
|
+
}
|
|
657
|
+
if (options.workerTtlMs !== void 0) {
|
|
658
|
+
assertPositiveFiniteNumber(options.workerTtlMs, "workerTtlMs");
|
|
659
|
+
}
|
|
660
|
+
if (options.routeOwnerCacheMax !== void 0) {
|
|
661
|
+
assertPositiveSafeInteger(options.routeOwnerCacheMax, "routeOwnerCacheMax");
|
|
662
|
+
}
|
|
663
|
+
assertLoadWeightingOptions(options.loadWeighting);
|
|
664
|
+
}
|
|
665
|
+
function assertHeartbeatInterval(value) {
|
|
666
|
+
if (value === Infinity) return;
|
|
667
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return;
|
|
668
|
+
throw new TypeError(
|
|
669
|
+
`Centrifuge heartbeatIntervalMs must be a positive number or Infinity, got ${String(value)}.`
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
function assertStructuredCloneable(value) {
|
|
673
|
+
if (typeof structuredClone !== "function") return;
|
|
674
|
+
try {
|
|
675
|
+
structuredClone(value);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
throw new TypeError(
|
|
678
|
+
"Centrifuge Worker configuration and published data must be structured-cloneable.",
|
|
679
|
+
{ cause: error }
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// src/core/cluster.ts
|
|
685
|
+
var CLUSTER_PROTOCOL_VERSION = 1;
|
|
686
|
+
var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
|
|
687
|
+
var DEFAULT_WORKER_TTL_MS = 1e4;
|
|
688
|
+
var MAX_KNOWN_TOPICS = 500;
|
|
337
689
|
var WorkerClusterRuntime = class {
|
|
338
690
|
tabId;
|
|
339
691
|
workerId;
|
|
@@ -347,6 +699,14 @@ var WorkerClusterRuntime = class {
|
|
|
347
699
|
routePrefix;
|
|
348
700
|
subscriberPrefix;
|
|
349
701
|
channelName;
|
|
702
|
+
/** Adaptive load weighting options; undefined keeps legacy topic-count routing. */
|
|
703
|
+
loadWeighting;
|
|
704
|
+
/** Rolling traffic accumulator folded into the worker record on writeRecord. */
|
|
705
|
+
throughputWindow = {
|
|
706
|
+
startedAt: 0,
|
|
707
|
+
messageCount: 0,
|
|
708
|
+
byteCount: 0
|
|
709
|
+
};
|
|
350
710
|
// Topics this tab has subscribed to (local interest, plaintext).
|
|
351
711
|
subscribedTopics = /* @__PURE__ */ new Set();
|
|
352
712
|
// Topics assigned to this Worker as owner (topicKey → topic). Authoritative:
|
|
@@ -382,12 +742,14 @@ var WorkerClusterRuntime = class {
|
|
|
382
742
|
lifecycleListening = false;
|
|
383
743
|
currentRecord;
|
|
384
744
|
constructor(options) {
|
|
745
|
+
assertClusterOptions(options);
|
|
385
746
|
this.environment = options.environment ?? createBrowserEnvironment();
|
|
386
747
|
this.handlers = options.handlers;
|
|
387
748
|
this.maxActiveWorkers = options.maxActiveWorkers ?? DEFAULT_MAX_ACTIVE_WORKERS;
|
|
388
749
|
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
389
750
|
this.routeOwnerCacheMax = options.routeOwnerCacheMax ?? 256;
|
|
390
751
|
this.workerTtlMs = options.workerTtlMs ?? DEFAULT_WORKER_TTL_MS;
|
|
752
|
+
this.loadWeighting = options.loadWeighting;
|
|
391
753
|
const clusterHash = createOpaqueKey(options.clusterKey || "__default__");
|
|
392
754
|
const prefix = options.storagePrefix ?? DEFAULT_STORAGE_PREFIX;
|
|
393
755
|
const baseKey = `${prefix}:${clusterHash}`;
|
|
@@ -404,8 +766,8 @@ var WorkerClusterRuntime = class {
|
|
|
404
766
|
workerId: this.workerId,
|
|
405
767
|
tabId: this.tabId,
|
|
406
768
|
load: 0,
|
|
407
|
-
role:
|
|
408
|
-
status:
|
|
769
|
+
role: WORKER_ROLE.STANDBY,
|
|
770
|
+
status: WORKER_STATUS.CONNECTING,
|
|
409
771
|
visibilityState: this.environment.getVisibilityState(),
|
|
410
772
|
heartbeatAt: now,
|
|
411
773
|
registeredAt: now
|
|
@@ -458,7 +820,7 @@ var WorkerClusterRuntime = class {
|
|
|
458
820
|
this.writeRecord(true);
|
|
459
821
|
for (const topic of this.subscribedTopics) {
|
|
460
822
|
const topicKey = this.rememberTopic(topic);
|
|
461
|
-
if (!this.storage) this.sendControl(this.workerId,
|
|
823
|
+
if (!this.storage) this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
462
824
|
else this.writeSubscriber(topicKey);
|
|
463
825
|
}
|
|
464
826
|
this.reconcile();
|
|
@@ -486,9 +848,14 @@ var WorkerClusterRuntime = class {
|
|
|
486
848
|
this.removeStorage(this.workerStorageKey(this.workerId));
|
|
487
849
|
this.flushStorage();
|
|
488
850
|
this.notifyRegistry();
|
|
489
|
-
this.channel
|
|
851
|
+
const channel = this.channel;
|
|
490
852
|
this.channel = null;
|
|
491
853
|
this.handlers.onSuspend?.();
|
|
854
|
+
if (typeof globalThis.setTimeout === "function") {
|
|
855
|
+
globalThis.setTimeout(() => channel?.close(), 0);
|
|
856
|
+
} else {
|
|
857
|
+
channel?.close();
|
|
858
|
+
}
|
|
492
859
|
}
|
|
493
860
|
/** Update the worker's connection status and persist the change. */
|
|
494
861
|
setStatus(status) {
|
|
@@ -506,7 +873,7 @@ var WorkerClusterRuntime = class {
|
|
|
506
873
|
this.subscribedTopics.add(topic);
|
|
507
874
|
if (!this.started) return false;
|
|
508
875
|
if (!this.storage) {
|
|
509
|
-
this.sendControl(this.workerId,
|
|
876
|
+
this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
510
877
|
return true;
|
|
511
878
|
}
|
|
512
879
|
this.writeSubscriber(topicKey);
|
|
@@ -516,9 +883,9 @@ var WorkerClusterRuntime = class {
|
|
|
516
883
|
return existingRoute?.workerId === this.workerId;
|
|
517
884
|
}
|
|
518
885
|
const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
|
|
519
|
-
const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
|
|
886
|
+
const owner = selectLeastLoadedWorker(activeWorkers, void 0, this.loadWeighting) ?? this.currentRecord;
|
|
520
887
|
this.writeRoute(topicKey, owner, void 0, (existingRoute?.generation ?? 0) + 1);
|
|
521
|
-
this.sendControl(owner.workerId,
|
|
888
|
+
this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
522
889
|
this.notifyRegistry();
|
|
523
890
|
return owner.workerId === this.workerId;
|
|
524
891
|
}
|
|
@@ -542,7 +909,7 @@ var WorkerClusterRuntime = class {
|
|
|
542
909
|
const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
|
|
543
910
|
if (subscribers.length === 0) {
|
|
544
911
|
this.removeStorage(this.routeStorageKey(topicKey));
|
|
545
|
-
if (notifyOwner) this.sendControl(route.workerId,
|
|
912
|
+
if (notifyOwner) this.sendControl(route.workerId, CONTROL_ACTION.UNSUBSCRIBE, topic, topicKey);
|
|
546
913
|
}
|
|
547
914
|
return topicKey;
|
|
548
915
|
}
|
|
@@ -561,15 +928,17 @@ var WorkerClusterRuntime = class {
|
|
|
561
928
|
continue;
|
|
562
929
|
}
|
|
563
930
|
const owner = selectLeastLoadedWorker(
|
|
564
|
-
activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load }))
|
|
931
|
+
activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load })),
|
|
932
|
+
void 0,
|
|
933
|
+
this.loadWeighting
|
|
565
934
|
);
|
|
566
935
|
if (!owner) continue;
|
|
567
936
|
projectedLoads.set(owner.workerId, (projectedLoads.get(owner.workerId) ?? owner.load) + 1);
|
|
568
937
|
const generation = (previous?.generation ?? 0) + 1;
|
|
569
938
|
this.writeRoute(topicKey, owner, previous?.workerId, generation);
|
|
570
|
-
this.handlers.onDiagnostic?.({ operation:
|
|
939
|
+
this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_MIGRATION, topic });
|
|
571
940
|
this.flushStorage();
|
|
572
|
-
this.handlers.onControl(
|
|
941
|
+
this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, topic);
|
|
573
942
|
this.sendRouteReleased(owner.workerId, topic, topicKey, generation);
|
|
574
943
|
}
|
|
575
944
|
}
|
|
@@ -577,22 +946,19 @@ var WorkerClusterRuntime = class {
|
|
|
577
946
|
const metadata = typeof metadataOrMessageId === "string" ? { messageId: metadataOrMessageId } : metadataOrMessageId;
|
|
578
947
|
const topicKey = this.rememberTopic(topic);
|
|
579
948
|
if (this.assignedTopics.has(topicKey)) {
|
|
580
|
-
return this.sendControl(this.workerId,
|
|
949
|
+
return this.sendControl(this.workerId, CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
|
|
581
950
|
}
|
|
582
951
|
const cachedPattern = this.wildcardPublishCache.get(topic);
|
|
583
|
-
if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
|
|
584
|
-
return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
|
|
585
|
-
}
|
|
586
952
|
if (cachedPattern === void 0) {
|
|
587
953
|
for (const pattern of this.assignedTopics.values()) {
|
|
588
954
|
if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
|
|
589
955
|
this.wildcardPublishCache.set(topic, pattern);
|
|
590
|
-
return this.sendControl(this.workerId,
|
|
956
|
+
return this.sendControl(this.workerId, CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
|
|
591
957
|
}
|
|
592
958
|
}
|
|
593
959
|
this.wildcardPublishCache.set(topic, null);
|
|
594
960
|
}
|
|
595
|
-
return this.sendControl(this.resolvePublishTarget(topic, topicKey),
|
|
961
|
+
return this.sendControl(this.resolvePublishTarget(topic, topicKey), CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
|
|
596
962
|
}
|
|
597
963
|
/**
|
|
598
964
|
* Burst-friendly variant of `publish()`: packs up to N items into a single
|
|
@@ -613,19 +979,15 @@ var WorkerClusterRuntime = class {
|
|
|
613
979
|
}
|
|
614
980
|
const topicKey = this.rememberTopic(topic);
|
|
615
981
|
if (this.assignedTopics.has(topicKey)) {
|
|
616
|
-
|
|
982
|
+
this.dispatchLocalPublishBatch(topic, topicKey, items);
|
|
617
983
|
return true;
|
|
618
984
|
}
|
|
619
985
|
const cachedPattern = this.wildcardPublishCache.get(topic);
|
|
620
|
-
if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
|
|
621
|
-
for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
|
|
622
|
-
return true;
|
|
623
|
-
}
|
|
624
986
|
if (cachedPattern === void 0) {
|
|
625
987
|
for (const pattern of this.assignedTopics.values()) {
|
|
626
988
|
if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
|
|
627
989
|
this.wildcardPublishCache.set(topic, pattern);
|
|
628
|
-
|
|
990
|
+
this.dispatchLocalPublishBatch(topic, topicKey, items);
|
|
629
991
|
return true;
|
|
630
992
|
}
|
|
631
993
|
}
|
|
@@ -633,14 +995,14 @@ var WorkerClusterRuntime = class {
|
|
|
633
995
|
}
|
|
634
996
|
const target = this.resolvePublishTarget(topic, topicKey);
|
|
635
997
|
if (target === this.workerId) {
|
|
636
|
-
|
|
998
|
+
this.dispatchLocalPublishBatch(topic, topicKey, items);
|
|
637
999
|
return true;
|
|
638
1000
|
}
|
|
639
1001
|
return this.send({
|
|
640
|
-
type:
|
|
1002
|
+
type: CLUSTER_MESSAGE_TYPE.CONTROL,
|
|
641
1003
|
sourceWorkerId: this.workerId,
|
|
642
1004
|
targetWorkerId: target,
|
|
643
|
-
action:
|
|
1005
|
+
action: CONTROL_ACTION.PUBLISH,
|
|
644
1006
|
topic,
|
|
645
1007
|
topicKey,
|
|
646
1008
|
items: items.map((item) => ({
|
|
@@ -671,8 +1033,17 @@ var WorkerClusterRuntime = class {
|
|
|
671
1033
|
dispatchLocalPublish(topic, topicKey, data, messageId, timestamp) {
|
|
672
1034
|
void topicKey;
|
|
673
1035
|
const meta = publicationMetadata(messageId, timestamp);
|
|
674
|
-
if (meta) this.handlers.onControl(
|
|
675
|
-
else this.handlers.onControl(
|
|
1036
|
+
if (meta) this.handlers.onControl(CONTROL_ACTION.PUBLISH, topic, data, meta.messageId, meta.timestamp);
|
|
1037
|
+
else this.handlers.onControl(CONTROL_ACTION.PUBLISH, topic, data);
|
|
1038
|
+
}
|
|
1039
|
+
/** Fan out a publication batch to the local onControl path: one
|
|
1040
|
+
* onPublishBatch call when the owner supports it, per-item otherwise. */
|
|
1041
|
+
dispatchLocalPublishBatch(topic, topicKey, items) {
|
|
1042
|
+
if (this.handlers.onPublishBatch) {
|
|
1043
|
+
this.handlers.onPublishBatch(topic, items);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
|
|
676
1047
|
}
|
|
677
1048
|
/** True when `route` exists and its owner worker is among `workers`.
|
|
678
1049
|
* Shared by subscribe (skip re-assignment) and publish (route to owner).
|
|
@@ -686,7 +1057,39 @@ var WorkerClusterRuntime = class {
|
|
|
686
1057
|
* listeners can attribute the event to its source tab even after fan-out. */
|
|
687
1058
|
broadcastEvent(eventType, payload, originTabId) {
|
|
688
1059
|
const effectiveOriginTabId = originTabId ?? this.tabId;
|
|
689
|
-
this.
|
|
1060
|
+
this.recordTraffic(payload);
|
|
1061
|
+
this.send({ type: CLUSTER_MESSAGE_TYPE.EVENT, sourceWorkerId: this.workerId, eventType, payload, originTabId: effectiveOriginTabId });
|
|
1062
|
+
}
|
|
1063
|
+
/** Count one fan-out unit toward the adaptive load sample. No-op unless
|
|
1064
|
+
* adaptive weighting is configured. */
|
|
1065
|
+
recordTraffic(payload) {
|
|
1066
|
+
if (this.loadWeighting === void 0) return;
|
|
1067
|
+
this.throughputWindow.messageCount += 1;
|
|
1068
|
+
this.throughputWindow.byteCount += approximatePayloadBytes(payload);
|
|
1069
|
+
}
|
|
1070
|
+
/** Convert the accumulated window into a publishable throughput sample and
|
|
1071
|
+
* reset the accumulator. Returns undefined until a full window has elapsed
|
|
1072
|
+
* so the first write does not emit a zero-width sample. */
|
|
1073
|
+
sampleThroughput(now) {
|
|
1074
|
+
if (this.throughputWindow.startedAt === 0) {
|
|
1075
|
+
this.throughputWindow.startedAt = now;
|
|
1076
|
+
return void 0;
|
|
1077
|
+
}
|
|
1078
|
+
const windowMs = now - this.throughputWindow.startedAt;
|
|
1079
|
+
if (windowMs <= 0) return void 0;
|
|
1080
|
+
const sample = {
|
|
1081
|
+
windowMs,
|
|
1082
|
+
messageCount: this.throughputWindow.messageCount,
|
|
1083
|
+
byteCount: this.throughputWindow.byteCount,
|
|
1084
|
+
// How much later the heartbeat landed than its nominal interval. This
|
|
1085
|
+
// window is anchored at the previous writeRecord (one heartbeat tick),
|
|
1086
|
+
// so a starved event loop stretches windowMs past the interval and the
|
|
1087
|
+
// positive excess is the scheduling-overrun signal.
|
|
1088
|
+
overrunMs: Math.max(0, windowMs - this.heartbeatIntervalMs),
|
|
1089
|
+
sampledAt: now
|
|
1090
|
+
};
|
|
1091
|
+
this.throughputWindow = { startedAt: now, messageCount: 0, byteCount: 0 };
|
|
1092
|
+
return sample;
|
|
690
1093
|
}
|
|
691
1094
|
isAssigned(topic) {
|
|
692
1095
|
const topicKey = createOpaqueKey(topic);
|
|
@@ -776,14 +1179,14 @@ var WorkerClusterRuntime = class {
|
|
|
776
1179
|
const message = event.data;
|
|
777
1180
|
if (!message || message.sourceWorkerId === this.workerId) return;
|
|
778
1181
|
switch (message.type) {
|
|
779
|
-
case
|
|
1182
|
+
case CLUSTER_MESSAGE_TYPE.CONTROL:
|
|
780
1183
|
return this.handleControlMessage(message);
|
|
781
|
-
case
|
|
1184
|
+
case CLUSTER_MESSAGE_TYPE.ROUTE_RELEASED:
|
|
782
1185
|
return this.handleRouteReleasedMessage(message);
|
|
783
|
-
case
|
|
1186
|
+
case CLUSTER_MESSAGE_TYPE.EVENT:
|
|
784
1187
|
this.handlers.onEvent(message.eventType, message.payload, message.sourceWorkerId, message.originTabId);
|
|
785
1188
|
return;
|
|
786
|
-
case
|
|
1189
|
+
case CLUSTER_MESSAGE_TYPE.REGISTRY:
|
|
787
1190
|
this.reconcile();
|
|
788
1191
|
return;
|
|
789
1192
|
default: {
|
|
@@ -800,19 +1203,23 @@ var WorkerClusterRuntime = class {
|
|
|
800
1203
|
if (message.targetWorkerId !== this.workerId) return;
|
|
801
1204
|
this.rememberTopic(message.topic);
|
|
802
1205
|
switch (message.action) {
|
|
803
|
-
case
|
|
1206
|
+
case CONTROL_ACTION.SUBSCRIBE:
|
|
804
1207
|
this.assignedTopics.set(message.topicKey, message.topic);
|
|
805
1208
|
this.confirmRoute(message.topicKey);
|
|
806
1209
|
break;
|
|
807
|
-
case
|
|
1210
|
+
case CONTROL_ACTION.UNSUBSCRIBE:
|
|
808
1211
|
if (this.releaseHandoffOnUnsubscribe(message)) return;
|
|
809
1212
|
break;
|
|
810
|
-
case
|
|
1213
|
+
case CONTROL_ACTION.PUBLISH:
|
|
811
1214
|
if (message.items && message.items.length > 0) {
|
|
1215
|
+
if (this.handlers.onPublishBatch) {
|
|
1216
|
+
this.handlers.onPublishBatch(message.topic, message.items);
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
812
1219
|
for (const item of message.items) {
|
|
813
1220
|
const itemMeta = publicationMetadata(item.messageId, item.timestamp);
|
|
814
|
-
if (itemMeta) this.handlers.onControl(
|
|
815
|
-
else this.handlers.onControl(
|
|
1221
|
+
if (itemMeta) this.handlers.onControl(CONTROL_ACTION.PUBLISH, message.topic, item.data, itemMeta.messageId, itemMeta.timestamp);
|
|
1222
|
+
else this.handlers.onControl(CONTROL_ACTION.PUBLISH, message.topic, item.data);
|
|
816
1223
|
}
|
|
817
1224
|
return;
|
|
818
1225
|
}
|
|
@@ -829,7 +1236,7 @@ var WorkerClusterRuntime = class {
|
|
|
829
1236
|
metadata.timestamp
|
|
830
1237
|
);
|
|
831
1238
|
else this.handlers.onControl(message.action, message.topic, message.data);
|
|
832
|
-
if (message.action !==
|
|
1239
|
+
if (message.action !== CONTROL_ACTION.PUBLISH) this.updateLoad();
|
|
833
1240
|
}
|
|
834
1241
|
/**
|
|
835
1242
|
* When this worker is the previous owner in a graceful handoff and the new
|
|
@@ -841,7 +1248,7 @@ var WorkerClusterRuntime = class {
|
|
|
841
1248
|
this.assignedTopics.delete(message.topicKey);
|
|
842
1249
|
const route = this.readRoute(message.topicKey);
|
|
843
1250
|
if (route?.handoffFromWorkerId !== this.workerId) return false;
|
|
844
|
-
this.handlers.onControl(
|
|
1251
|
+
this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, message.topic, void 0);
|
|
845
1252
|
this.sendRouteReleased(route.workerId, message.topic, message.topicKey, route.generation);
|
|
846
1253
|
this.updateLoad();
|
|
847
1254
|
return true;
|
|
@@ -850,7 +1257,7 @@ var WorkerClusterRuntime = class {
|
|
|
850
1257
|
* generation so only the matching new owner may act on it. */
|
|
851
1258
|
sendRouteReleased(targetWorkerId, topic, topicKey, generation) {
|
|
852
1259
|
this.send({
|
|
853
|
-
type:
|
|
1260
|
+
type: CLUSTER_MESSAGE_TYPE.ROUTE_RELEASED,
|
|
854
1261
|
sourceWorkerId: this.workerId,
|
|
855
1262
|
targetWorkerId,
|
|
856
1263
|
topic,
|
|
@@ -869,14 +1276,24 @@ var WorkerClusterRuntime = class {
|
|
|
869
1276
|
if (!route || this.isStaleRouteRelease(route, message)) return;
|
|
870
1277
|
this.assignedTopics.set(message.topicKey, message.topic);
|
|
871
1278
|
this.confirmRoute(message.topicKey);
|
|
872
|
-
this.handlers.onControl(
|
|
1279
|
+
this.handlers.onControl(CONTROL_ACTION.SUBSCRIBE, message.topic, void 0);
|
|
873
1280
|
this.updateLoad();
|
|
874
1281
|
}
|
|
875
1282
|
/** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
|
|
876
1283
|
* points to us, the release comes from the recorded previous owner, and
|
|
877
|
-
* the release generation is at least as new as ours.
|
|
1284
|
+
* the release generation is at least as new as ours. A replayed ACK from an
|
|
1285
|
+
* earlier handoff round (e.g. an a↔b ping-pong) carries an older generation
|
|
1286
|
+
* and must not confirm the current round. */
|
|
878
1287
|
isStaleRouteRelease(route, message) {
|
|
879
|
-
return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId ||
|
|
1288
|
+
return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation < route.generation;
|
|
1289
|
+
}
|
|
1290
|
+
/** True when an unconfirmed handoff route has been stuck longer than a
|
|
1291
|
+
* worker TTL. The ACK for a live handoff is posted synchronously with the
|
|
1292
|
+
* route write, so anything older than the TTL with a dead previous owner
|
|
1293
|
+
* will never complete — while a fresh unconfirmed route may simply be
|
|
1294
|
+
* waiting out its confirmation flush and must be left alone. */
|
|
1295
|
+
isStaleHandoff(route) {
|
|
1296
|
+
return this.environment.now() - route.updatedAt > this.workerTtlMs;
|
|
880
1297
|
}
|
|
881
1298
|
/** Full reconciliation cycle: workers, subscriptions, and assigned topics. */
|
|
882
1299
|
reconcile() {
|
|
@@ -909,20 +1326,39 @@ var WorkerClusterRuntime = class {
|
|
|
909
1326
|
*/
|
|
910
1327
|
reconcileSubscriptions(workers, activeWorkers) {
|
|
911
1328
|
const liveWorkerIds = new Set(workers.map((worker) => worker.workerId));
|
|
1329
|
+
const recoveryProjectedLoads = /* @__PURE__ */ new Map();
|
|
912
1330
|
for (const topic of this.subscribedTopics) {
|
|
913
1331
|
const topicKey = this.rememberTopic(topic);
|
|
914
1332
|
this.writeSubscriber(topicKey);
|
|
915
1333
|
const route = this.readRoute(topicKey);
|
|
916
1334
|
if (!route || !liveWorkerIds.has(route.workerId)) {
|
|
917
|
-
const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
|
|
1335
|
+
const owner = selectLeastLoadedWorker(activeWorkers, void 0, this.loadWeighting) ?? this.currentRecord;
|
|
918
1336
|
this.writeRoute(topicKey, owner, void 0, (route?.generation ?? 0) + 1);
|
|
919
|
-
this.sendControl(owner.workerId,
|
|
1337
|
+
this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
920
1338
|
this.notifyRegistry();
|
|
921
1339
|
continue;
|
|
922
1340
|
}
|
|
923
1341
|
if (route.confirmedAt === void 0) {
|
|
924
1342
|
if (!route.handoffFromWorkerId) {
|
|
925
|
-
this.sendControl(route.workerId,
|
|
1343
|
+
this.sendControl(route.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
1344
|
+
} else if (!liveWorkerIds.has(route.handoffFromWorkerId) && this.isStaleHandoff(route)) {
|
|
1345
|
+
const owner = selectLeastLoadedWorker(
|
|
1346
|
+
activeWorkers.map((worker) => ({ ...worker, load: recoveryProjectedLoads.get(worker.workerId) ?? worker.load })),
|
|
1347
|
+
void 0,
|
|
1348
|
+
this.loadWeighting
|
|
1349
|
+
) ?? this.currentRecord;
|
|
1350
|
+
recoveryProjectedLoads.set(owner.workerId, (recoveryProjectedLoads.get(owner.workerId) ?? owner.load) + 1);
|
|
1351
|
+
if (owner.workerId !== this.workerId) {
|
|
1352
|
+
const subscriberTabIds = new Set(this.readSubscriberTabIds(topicKey, workers));
|
|
1353
|
+
const ownerSubscribed = workers.some(
|
|
1354
|
+
(worker) => worker.workerId === owner.workerId && subscriberTabIds.has(worker.tabId)
|
|
1355
|
+
);
|
|
1356
|
+
if (ownerSubscribed) continue;
|
|
1357
|
+
}
|
|
1358
|
+
this.writeRoute(topicKey, owner, void 0, route.generation + 1);
|
|
1359
|
+
this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
1360
|
+
this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_MIGRATION_RECOVERY, topic });
|
|
1361
|
+
this.notifyRegistry();
|
|
926
1362
|
}
|
|
927
1363
|
}
|
|
928
1364
|
}
|
|
@@ -933,7 +1369,7 @@ var WorkerClusterRuntime = class {
|
|
|
933
1369
|
const route = this.readRoute(topicKey);
|
|
934
1370
|
if (route?.workerId === this.workerId) continue;
|
|
935
1371
|
this.assignedTopics.delete(topicKey);
|
|
936
|
-
this.handlers.onControl(
|
|
1372
|
+
this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, topic, void 0);
|
|
937
1373
|
if (route?.handoffFromWorkerId === this.workerId) {
|
|
938
1374
|
this.sendRouteReleased(route.workerId, topic, topicKey, route.generation);
|
|
939
1375
|
}
|
|
@@ -948,14 +1384,14 @@ var WorkerClusterRuntime = class {
|
|
|
948
1384
|
sendControl(targetWorkerId, action, topic, topicKey, data, metadata) {
|
|
949
1385
|
if (targetWorkerId === this.workerId) {
|
|
950
1386
|
switch (action) {
|
|
951
|
-
case
|
|
1387
|
+
case CONTROL_ACTION.SUBSCRIBE:
|
|
952
1388
|
this.assignedTopics.set(topicKey, topic);
|
|
953
1389
|
this.confirmRoute(topicKey);
|
|
954
1390
|
break;
|
|
955
|
-
case
|
|
1391
|
+
case CONTROL_ACTION.UNSUBSCRIBE:
|
|
956
1392
|
this.assignedTopics.delete(topicKey);
|
|
957
1393
|
break;
|
|
958
|
-
case
|
|
1394
|
+
case CONTROL_ACTION.PUBLISH:
|
|
959
1395
|
default:
|
|
960
1396
|
break;
|
|
961
1397
|
}
|
|
@@ -967,11 +1403,11 @@ var WorkerClusterRuntime = class {
|
|
|
967
1403
|
metadata.timestamp
|
|
968
1404
|
);
|
|
969
1405
|
else this.handlers.onControl(action, topic, data);
|
|
970
|
-
if (action !==
|
|
1406
|
+
if (action !== CONTROL_ACTION.PUBLISH) this.updateLoad();
|
|
971
1407
|
return true;
|
|
972
1408
|
}
|
|
973
1409
|
return this.send({
|
|
974
|
-
type:
|
|
1410
|
+
type: CLUSTER_MESSAGE_TYPE.CONTROL,
|
|
975
1411
|
sourceWorkerId: this.workerId,
|
|
976
1412
|
targetWorkerId,
|
|
977
1413
|
action,
|
|
@@ -1076,7 +1512,7 @@ var WorkerClusterRuntime = class {
|
|
|
1076
1512
|
confirmedAt: this.environment.now()
|
|
1077
1513
|
});
|
|
1078
1514
|
const topic = this.knownTopics.get(topicKey);
|
|
1079
|
-
if (topic) this.handlers.onDiagnostic?.({ operation:
|
|
1515
|
+
if (topic) this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_ACK, topic });
|
|
1080
1516
|
}
|
|
1081
1517
|
/** Remove routes whose topic has no subscribers and whose TTL has expired. */
|
|
1082
1518
|
cleanupOrphanedRoutes(workers) {
|
|
@@ -1111,17 +1547,23 @@ var WorkerClusterRuntime = class {
|
|
|
1111
1547
|
* avoid a REGISTRY storm every 3 s; true on status/role changes that
|
|
1112
1548
|
* peers should observe promptly. */
|
|
1113
1549
|
writeRecord(notify) {
|
|
1114
|
-
|
|
1550
|
+
const now = this.environment.now();
|
|
1551
|
+
const sample = this.loadWeighting !== void 0 ? this.sampleThroughput(now) : void 0;
|
|
1552
|
+
this.currentRecord = {
|
|
1553
|
+
...this.currentRecord,
|
|
1554
|
+
heartbeatAt: now,
|
|
1555
|
+
...sample ? { throughput: sample } : {}
|
|
1556
|
+
};
|
|
1115
1557
|
if (this.storage) writeJson(this.storage, this.workerStorageKey(this.workerId), this.currentRecord);
|
|
1116
1558
|
if (notify) this.notifyRegistry();
|
|
1117
1559
|
}
|
|
1118
1560
|
/** Broadcast a REGISTRY message to trigger reconciliation on other tabs. */
|
|
1119
1561
|
notifyRegistry() {
|
|
1120
|
-
this.send({ type:
|
|
1562
|
+
this.send({ type: CLUSTER_MESSAGE_TYPE.REGISTRY, sourceWorkerId: this.workerId });
|
|
1121
1563
|
}
|
|
1122
1564
|
/** Recompute whether this worker is active (eligible to own topics) or standby. Returns true when changed. */
|
|
1123
1565
|
refreshRole(workers) {
|
|
1124
|
-
const role = this.isActiveAmong(workers) ?
|
|
1566
|
+
const role = this.isActiveAmong(workers) ? WORKER_ROLE.ACTIVE : WORKER_ROLE.STANDBY;
|
|
1125
1567
|
if (role === this.currentRecord.role) return false;
|
|
1126
1568
|
this.currentRecord = { ...this.currentRecord, role };
|
|
1127
1569
|
return true;
|
|
@@ -1219,7 +1661,7 @@ var DataBusTraceReporter = class {
|
|
|
1219
1661
|
/** Start the periodic metrics flush interval. No-op when mode is 'events'
|
|
1220
1662
|
* (no metrics to emit), when disabled, or when already running. */
|
|
1221
1663
|
start() {
|
|
1222
|
-
if (!this.enabled || this.intervalHandle || this.mode ===
|
|
1664
|
+
if (!this.enabled || this.intervalHandle || this.mode === TRACE_MODE.EVENTS) return;
|
|
1223
1665
|
this.stopped = false;
|
|
1224
1666
|
this.intervalStartedAt = this.now();
|
|
1225
1667
|
this.intervalHandle = setInterval(() => this.flush(), this.metricsIntervalMs);
|
|
@@ -1235,11 +1677,40 @@ var DataBusTraceReporter = class {
|
|
|
1235
1677
|
this.stopped = true;
|
|
1236
1678
|
this.pause();
|
|
1237
1679
|
}
|
|
1680
|
+
/** Synchronous sink state for diagnostics: whether delivery is async and how
|
|
1681
|
+
* many events are queued behind the microtask flush. A growing queue under
|
|
1682
|
+
* `asyncSink: true` is the first sign of sink back-pressure. */
|
|
1683
|
+
getSinkState() {
|
|
1684
|
+
return { asyncSink: this.asyncSink, pendingEvents: this.pendingEvents.length };
|
|
1685
|
+
}
|
|
1238
1686
|
/** Record an instantaneous trace event (lifecycle, status, error, etc.). */
|
|
1239
1687
|
event(event) {
|
|
1240
|
-
if (!this.enabled || this.mode ===
|
|
1688
|
+
if (!this.enabled || this.mode === TRACE_MODE.METRICS) return;
|
|
1241
1689
|
this.emit({ ...event, timestamp: this.now() });
|
|
1242
1690
|
}
|
|
1691
|
+
/** Synchronous snapshot of the current metrics window without resetting it.
|
|
1692
|
+
* Returns the same derived counters as a periodic `message_metrics` event,
|
|
1693
|
+
* or null when metrics recording is inactive (disabled or events-only mode).
|
|
1694
|
+
* The window keeps accumulating until the next interval flush. */
|
|
1695
|
+
getMetrics() {
|
|
1696
|
+
if (!this.metricsActive || this.stopped) return null;
|
|
1697
|
+
const timestamp = this.now();
|
|
1698
|
+
const samples = this.latencySamples;
|
|
1699
|
+
return {
|
|
1700
|
+
durationMs: Math.max(0, timestamp - this.intervalStartedAt),
|
|
1701
|
+
received: this.received,
|
|
1702
|
+
dispatched: this.dispatched,
|
|
1703
|
+
topics: this.topics.size,
|
|
1704
|
+
dispatchSamples: samples,
|
|
1705
|
+
dispatchAvgMs: roundMs(samples === 0 ? 0 : this.latencySumMs / samples),
|
|
1706
|
+
dispatchP50Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.5)),
|
|
1707
|
+
dispatchP95Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.95)),
|
|
1708
|
+
dispatchMaxMs: roundMs(percentileMs(this.latencyBuckets, samples, 1)),
|
|
1709
|
+
dedupAccepted: this.dedupAccepted,
|
|
1710
|
+
dedupSuppressed: this.dedupSuppressed,
|
|
1711
|
+
timestamp
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1243
1714
|
/** Record that a message was received on `topic`; stores its timestamp for latency tracking. */
|
|
1244
1715
|
recordReceived(topic) {
|
|
1245
1716
|
if (!this.metricsActive) return;
|
|
@@ -1297,7 +1768,7 @@ var DataBusTraceReporter = class {
|
|
|
1297
1768
|
* Extracted so the four record / flush methods share one guard expression
|
|
1298
1769
|
* instead of repeating `!this.enabled || this.mode === 'events'` at each. */
|
|
1299
1770
|
get metricsActive() {
|
|
1300
|
-
return this.enabled && this.mode !==
|
|
1771
|
+
return this.enabled && this.mode !== TRACE_MODE.EVENTS;
|
|
1301
1772
|
}
|
|
1302
1773
|
/** Emit the accumulated metrics snapshot if the interval is active. */
|
|
1303
1774
|
flush() {
|
|
@@ -1309,7 +1780,7 @@ var DataBusTraceReporter = class {
|
|
|
1309
1780
|
if (this.received > 0 || this.dispatched > 0 || this.dedupAccepted > 0 || this.dedupSuppressed > 0) {
|
|
1310
1781
|
const samples = this.latencySamples;
|
|
1311
1782
|
this.emit({
|
|
1312
|
-
type:
|
|
1783
|
+
type: TRACE_EVENT_TYPE.MESSAGE_METRICS,
|
|
1313
1784
|
durationMs: Math.max(0, timestamp - this.intervalStartedAt),
|
|
1314
1785
|
received: this.received,
|
|
1315
1786
|
dispatched: this.dispatched,
|
|
@@ -1360,7 +1831,7 @@ var DataBusTraceReporter = class {
|
|
|
1360
1831
|
this.sink(event);
|
|
1361
1832
|
} catch (error) {
|
|
1362
1833
|
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
1363
|
-
console.warn(
|
|
1834
|
+
console.warn(`[${DEFAULT_STORAGE_PREFIX}] trace sink threw:`, error);
|
|
1364
1835
|
}
|
|
1365
1836
|
}
|
|
1366
1837
|
}
|
|
@@ -1386,160 +1857,600 @@ function roundMs(value) {
|
|
|
1386
1857
|
return Math.round(value * 10) / 10;
|
|
1387
1858
|
}
|
|
1388
1859
|
|
|
1389
|
-
// src/core/
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1860
|
+
// src/core/replay-pruning.ts
|
|
1861
|
+
function pruneReplayHistory(messages, options) {
|
|
1862
|
+
const { maxPerTopic, pruneStrategy, retentionMs, now } = options;
|
|
1863
|
+
const ageEnabled = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
|
|
1864
|
+
if (!ageEnabled) {
|
|
1865
|
+
return messages.length > maxPerTopic ? messages.slice(-maxPerTopic) : messages;
|
|
1866
|
+
}
|
|
1867
|
+
const cutoff = now - retentionMs;
|
|
1868
|
+
let hasExpired = false;
|
|
1869
|
+
let timestamplessCount = 0;
|
|
1870
|
+
for (const message of messages) {
|
|
1871
|
+
if (message.timestamp === void 0) timestamplessCount += 1;
|
|
1872
|
+
else if (message.timestamp < cutoff) hasExpired = true;
|
|
1873
|
+
}
|
|
1874
|
+
let pruned = hasExpired ? messages.filter((message) => message.timestamp === void 0 || message.timestamp >= cutoff) : messages;
|
|
1875
|
+
if (pruneStrategy === PRUNE_STRATEGY.BOTH) {
|
|
1876
|
+
return pruned.length > maxPerTopic ? pruned.slice(-maxPerTopic) : pruned;
|
|
1877
|
+
}
|
|
1878
|
+
if (timestamplessCount <= maxPerTopic) return pruned;
|
|
1879
|
+
let timestamplessToDrop = timestamplessCount - maxPerTopic;
|
|
1880
|
+
pruned = pruned.filter((message) => {
|
|
1881
|
+
if (message.timestamp === void 0 && timestamplessToDrop > 0) {
|
|
1882
|
+
timestamplessToDrop -= 1;
|
|
1883
|
+
return false;
|
|
1884
|
+
}
|
|
1885
|
+
return true;
|
|
1886
|
+
});
|
|
1887
|
+
return pruned;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// src/core/replay-manager.ts
|
|
1393
1891
|
var PersistenceRetryCancelledError = class extends Error {
|
|
1394
1892
|
constructor() {
|
|
1395
1893
|
super("Persistence retry cancelled by lifecycle transition.");
|
|
1396
1894
|
this.name = "PersistenceRetryCancelledError";
|
|
1397
1895
|
}
|
|
1398
1896
|
};
|
|
1399
|
-
var
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1897
|
+
var MAX_RETRY_DELAY_MS2 = 1600;
|
|
1898
|
+
var ReplayManager = class {
|
|
1899
|
+
constructor(deps) {
|
|
1900
|
+
this.deps = deps;
|
|
1901
|
+
this.buffers = deps.enabled ? /* @__PURE__ */ new Map() : null;
|
|
1902
|
+
this.maxPerTopic = deps.maxPerTopic;
|
|
1903
|
+
this.persistence = deps.persistence ?? null;
|
|
1904
|
+
this.retentionMs = deps.retentionMs;
|
|
1905
|
+
this.pruneStrategy = deps.pruneStrategy;
|
|
1906
|
+
this.retentionSweepMs = deps.retentionSweepMs;
|
|
1907
|
+
this.persistenceRetryMaxAttempts = deps.persistenceRetryMaxAttempts;
|
|
1908
|
+
this.persistenceRetryBackoffMs = deps.persistenceRetryBackoffMs;
|
|
1909
|
+
this.now = deps.now;
|
|
1910
|
+
this.trace = deps.trace;
|
|
1911
|
+
this.onPersistenceError = deps.onPersistenceError;
|
|
1912
|
+
this.onDispatchError = deps.onDispatchError;
|
|
1913
|
+
this.hydration = this.hydrate();
|
|
1914
|
+
}
|
|
1915
|
+
deps;
|
|
1916
|
+
buffers;
|
|
1917
|
+
maxPerTopic;
|
|
1918
|
+
persistence;
|
|
1919
|
+
retentionMs;
|
|
1920
|
+
pruneStrategy;
|
|
1921
|
+
retentionSweepMs;
|
|
1417
1922
|
persistenceRetryMaxAttempts;
|
|
1418
1923
|
persistenceRetryBackoffMs;
|
|
1419
|
-
persistenceRetryGeneration = 0;
|
|
1420
|
-
pendingReplayPersistence = [];
|
|
1421
|
-
replayPersistenceFlushScheduled = false;
|
|
1422
|
-
replayHydration;
|
|
1423
|
-
// Retention cleanup is coalesced so a burst of publications does not issue
|
|
1424
|
-
// one IndexedDB read/write transaction per message. The newest cutoff wins.
|
|
1425
|
-
replayRetentionCleanup = null;
|
|
1426
|
-
replayRetentionCutoff = null;
|
|
1427
|
-
replayRetentionTimer = null;
|
|
1428
|
-
initialConfig;
|
|
1429
|
-
hasInitialConfig;
|
|
1430
|
-
trace;
|
|
1431
|
-
dedupMaxEntries;
|
|
1432
|
-
dedupTtlMs;
|
|
1433
|
-
dedupAdaptiveBounds;
|
|
1434
|
-
dedupWindowStartedAt = 0;
|
|
1435
|
-
dedupWindowAccepted = 0;
|
|
1436
|
-
dedupAdaptiveWindowMs = 5e3;
|
|
1437
|
-
dedupSweepMs;
|
|
1438
|
-
dedupSweepTimer = null;
|
|
1439
|
-
dedupEnabled;
|
|
1440
1924
|
now;
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
if (replay) {
|
|
1484
|
-
const maxPerTopic = replay.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
|
|
1485
|
-
if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
|
|
1486
|
-
throw new TypeError(
|
|
1487
|
-
`replay.maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`
|
|
1488
|
-
);
|
|
1489
|
-
}
|
|
1925
|
+
trace;
|
|
1926
|
+
onPersistenceError;
|
|
1927
|
+
onDispatchError;
|
|
1928
|
+
/** Bumped on suspend/stop so in-flight persistence retries are cancelled. */
|
|
1929
|
+
retryGeneration = 0;
|
|
1930
|
+
pendingReplayPersistence = [];
|
|
1931
|
+
persistenceFlushScheduled = false;
|
|
1932
|
+
hydration;
|
|
1933
|
+
/** Coalesced retention cleanup: the newest cutoff wins while one is running. */
|
|
1934
|
+
retentionCleanup = null;
|
|
1935
|
+
retentionCutoff = null;
|
|
1936
|
+
retentionTimer = null;
|
|
1937
|
+
/** True when replay buffering is enabled. */
|
|
1938
|
+
get enabled() {
|
|
1939
|
+
return this.buffers !== null;
|
|
1940
|
+
}
|
|
1941
|
+
/** Append a dispatched publication to the topic's replay ring buffer.
|
|
1942
|
+
* No-op when replay is disabled. */
|
|
1943
|
+
record(message) {
|
|
1944
|
+
if (!this.buffers) return;
|
|
1945
|
+
let buffer = this.buffers.get(message.topic);
|
|
1946
|
+
if (!buffer) {
|
|
1947
|
+
buffer = [];
|
|
1948
|
+
this.buffers.set(message.topic, buffer);
|
|
1949
|
+
}
|
|
1950
|
+
buffer.push(message);
|
|
1951
|
+
const pruned = pruneReplayHistory(buffer, {
|
|
1952
|
+
maxPerTopic: this.maxPerTopic,
|
|
1953
|
+
pruneStrategy: this.pruneStrategy,
|
|
1954
|
+
retentionMs: this.retentionMs,
|
|
1955
|
+
now: this.now()
|
|
1956
|
+
});
|
|
1957
|
+
if (pruned !== buffer) {
|
|
1958
|
+
buffer = pruned;
|
|
1959
|
+
this.buffers.set(message.topic, buffer);
|
|
1960
|
+
}
|
|
1961
|
+
if (!this.persistence) return;
|
|
1962
|
+
if (this.persistence.appendBatch) {
|
|
1963
|
+
this.pendingReplayPersistence.push(message);
|
|
1964
|
+
this.schedulePersistenceFlush();
|
|
1965
|
+
} else {
|
|
1966
|
+
void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.append(message)).catch((error) => this.onPersistenceError(error));
|
|
1490
1967
|
}
|
|
1491
|
-
this.
|
|
1492
|
-
|
|
1493
|
-
this.replayPersistence = replay?.persistence ?? null;
|
|
1494
|
-
this.replayRetentionMs = replay?.retentionMs;
|
|
1495
|
-
this.replayPruneStrategy = replay?.pruneStrategy ?? "count";
|
|
1496
|
-
if (!["count", "age", "both"].includes(this.replayPruneStrategy)) throw new TypeError("replay.pruneStrategy must be count, age, or both.");
|
|
1497
|
-
if (this.replayRetentionMs !== void 0 && (!Number.isFinite(this.replayRetentionMs) || this.replayRetentionMs <= 0)) {
|
|
1498
|
-
throw new TypeError("replay.retentionMs must be a positive finite number.");
|
|
1968
|
+
if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
|
|
1969
|
+
this.scheduleRetentionCleanup(this.now() - this.retentionMs);
|
|
1499
1970
|
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1971
|
+
}
|
|
1972
|
+
/** Deliver buffered history to a newly-registered handler. For an exact
|
|
1973
|
+
* topic this is that topic's ring; for a wildcard subscription every
|
|
1974
|
+
* buffered topic matching the pattern contributes (in buffer insertion
|
|
1975
|
+
* order). Replay deliveries are marked `replayed: true` and are not counted
|
|
1976
|
+
* into trace metrics.
|
|
1977
|
+
*
|
|
1978
|
+
* When a durable persistence backend is present, delivery waits for the
|
|
1979
|
+
* hydration load to settle first; `isHandlerActive` is then consulted so a
|
|
1980
|
+
* handler that unsubscribed during the async load does not receive history.
|
|
1981
|
+
*/
|
|
1982
|
+
deliverReplay(topic, replayOption, handler, isHandlerActive) {
|
|
1983
|
+
if (!this.buffers) return;
|
|
1984
|
+
const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
|
|
1985
|
+
if (this.persistence) {
|
|
1986
|
+
void this.hydration.then(() => {
|
|
1987
|
+
if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
|
|
1988
|
+
});
|
|
1989
|
+
return;
|
|
1503
1990
|
}
|
|
1504
|
-
this.
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1991
|
+
this.deliver(topic, limit, handler);
|
|
1992
|
+
}
|
|
1993
|
+
/** Clean up a topic that lost its last local handler: drop the ring buffer,
|
|
1994
|
+
* filter queued batch flushes (so an in-flight append cannot undo the
|
|
1995
|
+
* clearTopic), and prune durable history. */
|
|
1996
|
+
onTopicUnsubscribed(topic) {
|
|
1997
|
+
if (!this.buffers) return;
|
|
1998
|
+
this.buffers.delete(topic);
|
|
1999
|
+
this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
|
|
2000
|
+
if (this.persistence?.clearTopic) {
|
|
2001
|
+
void this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)).catch((error) => this.onPersistenceError(error));
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
/** Clear all in-memory replay buffers and, when supported, durable history.
|
|
2005
|
+
* Reports persistence failures and rethrows, mirroring the public API
|
|
2006
|
+
* contract that callers can observe a failed clear. */
|
|
2007
|
+
async clearAll() {
|
|
2008
|
+
if (!this.buffers) return;
|
|
2009
|
+
this.buffers.clear();
|
|
2010
|
+
this.pendingReplayPersistence = [];
|
|
2011
|
+
if (this.persistence?.clear) {
|
|
2012
|
+
try {
|
|
2013
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear());
|
|
2014
|
+
} catch (error) {
|
|
2015
|
+
this.onPersistenceError(error);
|
|
2016
|
+
throw error;
|
|
2017
|
+
}
|
|
1508
2018
|
}
|
|
1509
|
-
|
|
1510
|
-
|
|
2019
|
+
}
|
|
2020
|
+
/** Clear replay history for one exact topic, including durable storage. */
|
|
2021
|
+
async clearTopic(topic) {
|
|
2022
|
+
if (!this.buffers) return;
|
|
2023
|
+
this.buffers.delete(topic);
|
|
2024
|
+
this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
|
|
2025
|
+
if (this.persistence?.clearTopic) {
|
|
2026
|
+
try {
|
|
2027
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic));
|
|
2028
|
+
} catch (error) {
|
|
2029
|
+
this.onPersistenceError(error);
|
|
2030
|
+
throw error;
|
|
2031
|
+
}
|
|
1511
2032
|
}
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
if (!Number.isFinite(
|
|
1516
|
-
|
|
2033
|
+
}
|
|
2034
|
+
/** Remove replay entries older than an epoch-millisecond cutoff. */
|
|
2035
|
+
async clearBefore(timestamp) {
|
|
2036
|
+
if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
|
|
2037
|
+
if (this.buffers) {
|
|
2038
|
+
for (const [topic, messages] of this.buffers) {
|
|
2039
|
+
const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
|
|
2040
|
+
if (kept.length) this.buffers.set(topic, kept);
|
|
2041
|
+
else this.buffers.delete(topic);
|
|
2042
|
+
}
|
|
1517
2043
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
2044
|
+
this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
|
|
2045
|
+
(message) => message.timestamp === void 0 || message.timestamp >= timestamp
|
|
2046
|
+
);
|
|
2047
|
+
if (this.persistence?.clearBefore) {
|
|
2048
|
+
try {
|
|
2049
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp));
|
|
2050
|
+
} catch (error) {
|
|
2051
|
+
this.onPersistenceError(error);
|
|
2052
|
+
throw error;
|
|
2053
|
+
}
|
|
1520
2054
|
}
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
this.
|
|
1526
|
-
this.
|
|
1527
|
-
|
|
1528
|
-
this.
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
this.
|
|
1533
|
-
this.
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
2055
|
+
}
|
|
2056
|
+
/** Start the periodic retention sweep. No-op when no durable retention
|
|
2057
|
+
* config makes it necessary. */
|
|
2058
|
+
start() {
|
|
2059
|
+
if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
|
|
2060
|
+
this.retentionTimer = setInterval(() => {
|
|
2061
|
+
this.scheduleRetentionCleanup(this.now() - this.retentionMs);
|
|
2062
|
+
}, this.retentionSweepMs);
|
|
2063
|
+
}
|
|
2064
|
+
/** Stop the periodic retention sweep. */
|
|
2065
|
+
stop() {
|
|
2066
|
+
if (this.retentionTimer) clearInterval(this.retentionTimer);
|
|
2067
|
+
this.retentionTimer = null;
|
|
2068
|
+
}
|
|
2069
|
+
/** Suspend the manager: cancel in-flight persistence retries (so a hidden tab
|
|
2070
|
+
* or stopped bus does not keep hammering the store) and stop the sweep. */
|
|
2071
|
+
suspend() {
|
|
2072
|
+
this.retryGeneration += 1;
|
|
2073
|
+
this.stop();
|
|
2074
|
+
}
|
|
2075
|
+
/** Drop all in-memory buffers (used on full teardown). */
|
|
2076
|
+
resetBuffers() {
|
|
2077
|
+
this.buffers?.clear();
|
|
2078
|
+
}
|
|
2079
|
+
/** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
|
|
2080
|
+
* payload footprint (same heuristic as adaptive load weighting), computed on
|
|
2081
|
+
* demand so the hot append path never pays for it. */
|
|
2082
|
+
getStats() {
|
|
2083
|
+
let messages = 0;
|
|
2084
|
+
let bytes = 0;
|
|
2085
|
+
if (this.buffers) {
|
|
2086
|
+
for (const buffer of this.buffers.values()) {
|
|
2087
|
+
messages += buffer.length;
|
|
2088
|
+
for (const message of buffer) bytes += approximatePayloadBytes(message.data);
|
|
2089
|
+
}
|
|
1542
2090
|
}
|
|
2091
|
+
return { enabled: this.enabled, topics: this.buffers?.size ?? 0, messages, bytes };
|
|
2092
|
+
}
|
|
2093
|
+
/** Deliver history from one topic's ring to a handler, isolating a throwing
|
|
2094
|
+
* handler so the remaining buffers are still delivered. */
|
|
2095
|
+
deliver(topic, limit, handler) {
|
|
2096
|
+
if (!this.buffers || limit <= 0) return;
|
|
2097
|
+
const deliverBuffer = (buffer2) => {
|
|
2098
|
+
for (const message of buffer2.slice(-limit)) {
|
|
2099
|
+
try {
|
|
2100
|
+
handler({ ...message, replayed: true });
|
|
2101
|
+
} catch (error) {
|
|
2102
|
+
this.onDispatchError(error);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
};
|
|
2106
|
+
if (isWildcardTopic(topic)) {
|
|
2107
|
+
for (const [bufferedTopic, buffer2] of this.buffers) {
|
|
2108
|
+
if (topicMatchesPattern(topic, bufferedTopic)) deliverBuffer(buffer2);
|
|
2109
|
+
}
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
const buffer = this.buffers.get(topic);
|
|
2113
|
+
if (buffer) deliverBuffer(buffer);
|
|
2114
|
+
}
|
|
2115
|
+
/** Coalesce queued persistence appends into a single microtask batch so a
|
|
2116
|
+
* burst of publications does not issue one IndexedDB transaction each.
|
|
2117
|
+
* Only reachable when the backend advertises `appendBatch` (the sole queuer,
|
|
2118
|
+
* `record()`, guards on it), so the batched path is unconditional here. */
|
|
2119
|
+
schedulePersistenceFlush() {
|
|
2120
|
+
if (this.persistenceFlushScheduled) return;
|
|
2121
|
+
this.persistenceFlushScheduled = true;
|
|
2122
|
+
queueMicrotask(() => {
|
|
2123
|
+
this.persistenceFlushScheduled = false;
|
|
2124
|
+
const batch = this.pendingReplayPersistence.splice(0);
|
|
2125
|
+
if (batch.length === 0 || !this.persistence) return;
|
|
2126
|
+
void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
/** Load durable history into the in-memory rings once at startup, pruning
|
|
2130
|
+
* entries past the retention window first. Failures are reported but do not
|
|
2131
|
+
* block startup — the bus runs with whatever survived. */
|
|
2132
|
+
async hydrate() {
|
|
2133
|
+
if (!this.buffers || !this.persistence) {
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
try {
|
|
2137
|
+
if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
|
|
2138
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
|
|
2139
|
+
}
|
|
2140
|
+
const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
|
|
2141
|
+
for (const message of loaded) {
|
|
2142
|
+
let buffer = this.buffers.get(message.topic);
|
|
2143
|
+
if (!buffer) {
|
|
2144
|
+
buffer = [];
|
|
2145
|
+
this.buffers.set(message.topic, buffer);
|
|
2146
|
+
}
|
|
2147
|
+
buffer.push(message);
|
|
2148
|
+
}
|
|
2149
|
+
const hydrationNow = this.now();
|
|
2150
|
+
for (const [topic, buffer] of this.buffers) {
|
|
2151
|
+
const pruned = pruneReplayHistory(buffer, {
|
|
2152
|
+
maxPerTopic: this.maxPerTopic,
|
|
2153
|
+
pruneStrategy: this.pruneStrategy,
|
|
2154
|
+
retentionMs: this.retentionMs,
|
|
2155
|
+
now: hydrationNow
|
|
2156
|
+
});
|
|
2157
|
+
if (pruned !== buffer) this.buffers.set(topic, pruned);
|
|
2158
|
+
}
|
|
2159
|
+
} catch (error) {
|
|
2160
|
+
this.onPersistenceError(error);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
/** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
|
|
2164
|
+
* so a burst of publications issues at most one clearBefore transaction. */
|
|
2165
|
+
scheduleRetentionCleanup(cutoff) {
|
|
2166
|
+
if (!this.persistence?.clearBefore) return;
|
|
2167
|
+
if (this.retentionCutoff === null || cutoff > this.retentionCutoff) {
|
|
2168
|
+
this.retentionCutoff = cutoff;
|
|
2169
|
+
}
|
|
2170
|
+
if (this.retentionCleanup) return;
|
|
2171
|
+
this.retentionCleanup = (async () => {
|
|
2172
|
+
while (this.retentionCutoff !== null) {
|
|
2173
|
+
const nextCutoff = this.retentionCutoff;
|
|
2174
|
+
this.retentionCutoff = null;
|
|
2175
|
+
try {
|
|
2176
|
+
await this.persistence.clearBefore(nextCutoff);
|
|
2177
|
+
} catch (error) {
|
|
2178
|
+
this.onPersistenceError(error);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
})().finally(() => {
|
|
2182
|
+
this.retentionCleanup = null;
|
|
2183
|
+
if (this.retentionCutoff !== null) {
|
|
2184
|
+
this.scheduleRetentionCleanup(this.retentionCutoff);
|
|
2185
|
+
}
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
/** Run a persistence operation with exponential backoff on transient failure.
|
|
2189
|
+
* Bumped `retryGeneration` (suspend/stop) cancels the loop early; a
|
|
2190
|
+
* structurally failing operation throws after `persistenceRetryMaxAttempts`,
|
|
2191
|
+
* leaving the ring buffer intact so the bus keeps working. */
|
|
2192
|
+
async withPersistenceRetry(persistenceOperation, operation) {
|
|
2193
|
+
const generation = this.retryGeneration;
|
|
2194
|
+
let attempt = 0;
|
|
2195
|
+
let delay = this.persistenceRetryBackoffMs;
|
|
2196
|
+
while (true) {
|
|
2197
|
+
attempt += 1;
|
|
2198
|
+
try {
|
|
2199
|
+
if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
|
|
2200
|
+
return await operation();
|
|
2201
|
+
} catch (error) {
|
|
2202
|
+
if (error instanceof PersistenceRetryCancelledError || generation !== this.retryGeneration) {
|
|
2203
|
+
throw new PersistenceRetryCancelledError();
|
|
2204
|
+
}
|
|
2205
|
+
if (attempt >= this.persistenceRetryMaxAttempts) throw error;
|
|
2206
|
+
this.trace.event({
|
|
2207
|
+
type: TRACE_EVENT_TYPE.RELIABILITY,
|
|
2208
|
+
operation: RELIABILITY_OPERATION.PERSISTENCE_RETRY,
|
|
2209
|
+
persistenceOperation,
|
|
2210
|
+
attempt
|
|
2211
|
+
});
|
|
2212
|
+
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2213
|
+
if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
|
|
2214
|
+
delay = Math.min(delay * 2, MAX_RETRY_DELAY_MS2);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
};
|
|
2219
|
+
|
|
2220
|
+
// src/core/dedup-manager.ts
|
|
2221
|
+
var ADAPTIVE_WINDOW_MS = 5e3;
|
|
2222
|
+
var QUIET_RATE_PER_MS = 0.01;
|
|
2223
|
+
var DedupManager = class {
|
|
2224
|
+
enabled;
|
|
2225
|
+
maxEntries;
|
|
2226
|
+
ttlMs;
|
|
2227
|
+
adaptiveBounds;
|
|
2228
|
+
sweepMs;
|
|
2229
|
+
now;
|
|
2230
|
+
trace;
|
|
2231
|
+
seenMessageIds = /* @__PURE__ */ new Map();
|
|
2232
|
+
sweepTimer = null;
|
|
2233
|
+
windowStartedAt = 0;
|
|
2234
|
+
windowAccepted = 0;
|
|
2235
|
+
suppressed = 0;
|
|
2236
|
+
accepted = 0;
|
|
2237
|
+
constructor(options) {
|
|
2238
|
+
this.enabled = options.enabled;
|
|
2239
|
+
this.maxEntries = options.maxEntries;
|
|
2240
|
+
this.ttlMs = options.ttlMs;
|
|
2241
|
+
this.adaptiveBounds = options.adaptiveBounds;
|
|
2242
|
+
this.sweepMs = options.sweepMs;
|
|
2243
|
+
this.now = options.now;
|
|
2244
|
+
this.trace = options.trace;
|
|
2245
|
+
this.windowStartedAt = this.now();
|
|
2246
|
+
}
|
|
2247
|
+
/** True when a publication carrying `messageId` was already seen. Records the
|
|
2248
|
+
* ID and updates counters/trace on acceptance. Disabled or ID-less messages
|
|
2249
|
+
* always pass through. */
|
|
2250
|
+
isDuplicate(messageId, topic) {
|
|
2251
|
+
if (!this.enabled || !messageId) return false;
|
|
2252
|
+
const now = this.now();
|
|
2253
|
+
const ttlMs = this.currentTtl();
|
|
2254
|
+
for (const [id, timestamp] of this.seenMessageIds) {
|
|
2255
|
+
if (now - timestamp > ttlMs) this.seenMessageIds.delete(id);
|
|
2256
|
+
}
|
|
2257
|
+
if (this.seenMessageIds.has(messageId)) {
|
|
2258
|
+
this.suppressed += 1;
|
|
2259
|
+
this.trace.event({
|
|
2260
|
+
type: TRACE_EVENT_TYPE.RELIABILITY,
|
|
2261
|
+
operation: RELIABILITY_OPERATION.DEDUP_SUPPRESSED,
|
|
2262
|
+
topic
|
|
2263
|
+
});
|
|
2264
|
+
this.trace.recordDedupSuppressed();
|
|
2265
|
+
return true;
|
|
2266
|
+
}
|
|
2267
|
+
this.seenMessageIds.set(messageId, now);
|
|
2268
|
+
this.accepted += 1;
|
|
2269
|
+
this.windowAccepted += 1;
|
|
2270
|
+
this.trace.recordDedupAccepted();
|
|
2271
|
+
while (this.seenMessageIds.size > this.maxEntries) {
|
|
2272
|
+
const oldest = this.seenMessageIds.keys().next().value;
|
|
2273
|
+
if (oldest === void 0) break;
|
|
2274
|
+
this.seenMessageIds.delete(oldest);
|
|
2275
|
+
}
|
|
2276
|
+
return false;
|
|
2277
|
+
}
|
|
2278
|
+
/** Start the periodic expiry sweep. No-op when disabled or no sweepMs was
|
|
2279
|
+
* configured (the hot-path opportunistic expiry still bounds the map). */
|
|
2280
|
+
start() {
|
|
2281
|
+
if (this.sweepTimer || !this.enabled || !this.sweepMs) return;
|
|
2282
|
+
this.sweepTimer = setInterval(() => this.pruneExpired(), this.sweepMs);
|
|
2283
|
+
}
|
|
2284
|
+
/** Stop the periodic expiry sweep. */
|
|
2285
|
+
stop() {
|
|
2286
|
+
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
|
2287
|
+
this.sweepTimer = null;
|
|
2288
|
+
}
|
|
2289
|
+
/** Return bounded deduplication counters for diagnostics and health checks. */
|
|
2290
|
+
getStats() {
|
|
2291
|
+
return {
|
|
2292
|
+
enabled: this.enabled,
|
|
2293
|
+
tracked: this.seenMessageIds.size,
|
|
2294
|
+
suppressed: this.suppressed,
|
|
2295
|
+
accepted: this.accepted,
|
|
2296
|
+
...this.adaptiveBounds ? { ttlMs: this.currentTtl() } : {}
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
/** Drop all remembered IDs and reset dedup counters. */
|
|
2300
|
+
reset() {
|
|
2301
|
+
this.seenMessageIds.clear();
|
|
2302
|
+
this.suppressed = 0;
|
|
2303
|
+
this.accepted = 0;
|
|
2304
|
+
this.windowStartedAt = this.now();
|
|
2305
|
+
this.windowAccepted = 0;
|
|
2306
|
+
}
|
|
2307
|
+
/** Remove IDs whose timestamp predates the effective TTL cutoff. */
|
|
2308
|
+
pruneExpired() {
|
|
2309
|
+
const cutoff = this.now() - this.currentTtl();
|
|
2310
|
+
for (const [id, timestamp] of this.seenMessageIds) {
|
|
2311
|
+
if (timestamp < cutoff) this.seenMessageIds.delete(id);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
/** Effective TTL: the fixed `ttlMs` unless adaptive bounds are configured, in
|
|
2315
|
+
* which case a higher recent message rate shortens the window (dedup only
|
|
2316
|
+
* needs to live long enough to bridge duplicate bursts). The window resets
|
|
2317
|
+
* every ADAPTIVE_WINDOW_MS. */
|
|
2318
|
+
currentTtl() {
|
|
2319
|
+
if (!this.adaptiveBounds) return this.ttlMs;
|
|
2320
|
+
const now = this.now();
|
|
2321
|
+
const elapsed = now - this.windowStartedAt;
|
|
2322
|
+
if (elapsed >= ADAPTIVE_WINDOW_MS) {
|
|
2323
|
+
this.windowStartedAt = now;
|
|
2324
|
+
this.windowAccepted = 0;
|
|
2325
|
+
return this.adaptiveBounds.maxMs;
|
|
2326
|
+
}
|
|
2327
|
+
const rate = this.windowAccepted / Math.max(1, elapsed);
|
|
2328
|
+
const factor = Math.min(1, rate / QUIET_RATE_PER_MS);
|
|
2329
|
+
return this.adaptiveBounds.maxMs - (this.adaptiveBounds.maxMs - this.adaptiveBounds.minMs) * factor;
|
|
2330
|
+
}
|
|
2331
|
+
};
|
|
2332
|
+
|
|
2333
|
+
// src/core/version.ts
|
|
2334
|
+
var SDK_VERSION = true ? "0.20.86" : "";
|
|
2335
|
+
|
|
2336
|
+
// src/core/data-bus.ts
|
|
2337
|
+
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
2338
|
+
var CrossTabDataBus = class {
|
|
2339
|
+
transport;
|
|
2340
|
+
cluster;
|
|
2341
|
+
// Map of topic → set of local subscribers.
|
|
2342
|
+
topicHandlers = /* @__PURE__ */ new Map();
|
|
2343
|
+
// Topics for which the transport has been asked to subscribe (used to avoid
|
|
2344
|
+
// duplicate subscribe calls during reconnection).
|
|
2345
|
+
transportSubscribedTopics = /* @__PURE__ */ new Set();
|
|
2346
|
+
statusHandlers = /* @__PURE__ */ new Set();
|
|
2347
|
+
errorHandlers = /* @__PURE__ */ new Set();
|
|
2348
|
+
replayManager;
|
|
2349
|
+
initialConfig;
|
|
2350
|
+
hasInitialConfig;
|
|
2351
|
+
trace;
|
|
2352
|
+
dedupManager;
|
|
2353
|
+
now;
|
|
2354
|
+
activeConfig;
|
|
2355
|
+
status = WORKER_STATUS.DISCONNECTED;
|
|
2356
|
+
started = false;
|
|
2357
|
+
stopping = false;
|
|
2358
|
+
transportReady = false;
|
|
2359
|
+
// Last transport failure, retained so ready() can surface it to callers who
|
|
2360
|
+
// never awaited start() directly. Cleared on the next successful start.
|
|
2361
|
+
lastError = null;
|
|
2362
|
+
lastErrorAt = null;
|
|
2363
|
+
// Unified failure ledger for the health summary: the most recent failure of
|
|
2364
|
+
// any source (transport, persistence, dispatch) since the last explicit start.
|
|
2365
|
+
lastFailure = null;
|
|
2366
|
+
persistenceFailureCount = 0;
|
|
2367
|
+
persistenceLastFailureAt = null;
|
|
2368
|
+
persistenceLastErrorMessage = null;
|
|
2369
|
+
// Gate that serialises start/stop/suspend/resume — only one lifecycle
|
|
2370
|
+
// transition at a time. Resets to null once the operation settles.
|
|
2371
|
+
startPromise = null;
|
|
2372
|
+
// Gate for an explicit stop(). Concurrent stop() calls share it, and a
|
|
2373
|
+
// start() received while stopping chains a fresh start after it.
|
|
2374
|
+
stopPromise = null;
|
|
2375
|
+
// A start() requested while an explicit stop() is still settling. Kept
|
|
2376
|
+
// separate from startPromise because stop()'s finally block clears the
|
|
2377
|
+
// ordinary lifecycle gate before the queued start is allowed to run.
|
|
2378
|
+
queuedStart = null;
|
|
2379
|
+
// Lazy readiness view of queuedStart. start() keeps its documented
|
|
2380
|
+
// resolve-on-cancellation contract, while ready() must reject when the
|
|
2381
|
+
// queued intent was superseded by a later stop().
|
|
2382
|
+
queuedStartReady = null;
|
|
2383
|
+
queuedStartReadyToken = 0;
|
|
2384
|
+
// The queued continuation is chained to the stop promise and cannot be
|
|
2385
|
+
// un-scheduled once scheduled. A later stop() therefore invalidates the
|
|
2386
|
+
// current intent by recording its token; a subsequent start() issues a
|
|
2387
|
+
// higher token so the latest lifecycle request still wins.
|
|
2388
|
+
queuedStartToken = 0;
|
|
2389
|
+
canceledQueuedStartToken = 0;
|
|
2390
|
+
// Timestamp of the last automatic transport recovery attempt.
|
|
2391
|
+
// Used to avoid a tight retry loop when the transport fails repeatedly.
|
|
2392
|
+
lastRecoveryAt = 0;
|
|
2393
|
+
// Monotonic attempt number within one runtime recovery sequence; reset once
|
|
2394
|
+
// a transport reopen succeeds so traces can correlate repeated failures.
|
|
2395
|
+
recoveryAttempt = 0;
|
|
2396
|
+
recoveryExhausted = false;
|
|
2397
|
+
/** Monotonic generation incremented on every successful transport open.
|
|
2398
|
+
* Stays in lockstep with `lastSuccessAt` so callers can detect that the
|
|
2399
|
+
* transport has been reopened even if the timestamp window is short. */
|
|
2400
|
+
recoveryGeneration = 0;
|
|
2401
|
+
/** Timestamp of the most recent successful transport open. Null until the
|
|
2402
|
+
* transport has reached the `ready` state at least once. */
|
|
2403
|
+
lastSuccessAt = null;
|
|
2404
|
+
// True while the tab is hidden so an in-flight transport start does not mark
|
|
2405
|
+
// the transport ready after suspendTransport() has stopped it.
|
|
2406
|
+
suspended = false;
|
|
2407
|
+
// Single gate for async transport.stop() cleanup, shared by failed opens and
|
|
2408
|
+
// page-hide suspension. Kept separate from startPromise so ready() still
|
|
2409
|
+
// surfaces a failure while later opens and automatic recovery wait for the
|
|
2410
|
+
// stop to settle.
|
|
2411
|
+
pendingStop = null;
|
|
2412
|
+
// Ownership token for asynchronous transport opens. Every lifecycle
|
|
2413
|
+
// transition invalidates callbacks and failure cleanup from older opens.
|
|
2414
|
+
lifecycleEpoch = 0;
|
|
2415
|
+
// Minimum interval in ms between automatic recovery attempts.
|
|
2416
|
+
recoveryCooldownMs;
|
|
2417
|
+
recoveryMaxAttempts;
|
|
2418
|
+
constructor(options) {
|
|
2419
|
+
const replay = options.replay;
|
|
2420
|
+
assertReplayOptions(replay);
|
|
2421
|
+
const { autoStart, initialConfig, trace, transport, dedup, recovery, ...clusterOptions } = options;
|
|
2422
|
+
assertRecoveryOptions(recovery);
|
|
2423
|
+
this.recoveryCooldownMs = recovery?.cooldownMs ?? 1e3;
|
|
2424
|
+
this.recoveryMaxAttempts = recovery?.maxAttempts ?? Number.POSITIVE_INFINITY;
|
|
2425
|
+
this.now = dedup?.now ?? Date.now;
|
|
2426
|
+
this.transport = transport;
|
|
2427
|
+
this.initialConfig = initialConfig;
|
|
2428
|
+
this.hasInitialConfig = "initialConfig" in options;
|
|
2429
|
+
this.trace = new DataBusTraceReporter(trace);
|
|
2430
|
+
this.replayManager = new ReplayManager({
|
|
2431
|
+
enabled: replay !== void 0,
|
|
2432
|
+
maxPerTopic: replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC,
|
|
2433
|
+
persistence: replay?.persistence ?? null,
|
|
2434
|
+
retentionMs: replay?.retentionMs,
|
|
2435
|
+
pruneStrategy: replay?.pruneStrategy ?? PRUNE_STRATEGY.COUNT,
|
|
2436
|
+
retentionSweepMs: replay?.retentionSweepMs,
|
|
2437
|
+
persistenceRetryMaxAttempts: replay?.persistenceRetry?.maxAttempts ?? 1,
|
|
2438
|
+
persistenceRetryBackoffMs: replay?.persistenceRetry?.backoffMs ?? 50,
|
|
2439
|
+
now: this.now,
|
|
2440
|
+
trace: this.trace,
|
|
2441
|
+
onPersistenceError: (error) => this.reportPersistenceError(error),
|
|
2442
|
+
onDispatchError: (error) => this.reportError(error, FAILURE_SOURCE.DISPATCH)
|
|
2443
|
+
});
|
|
2444
|
+
assertDedupOptions(dedup);
|
|
2445
|
+
this.dedupManager = new DedupManager({
|
|
2446
|
+
enabled: dedup !== void 0,
|
|
2447
|
+
maxEntries: dedup?.maxEntries ?? 1e3,
|
|
2448
|
+
ttlMs: dedup?.ttlMs ?? 6e4,
|
|
2449
|
+
adaptiveBounds: dedup?.adaptiveTtl,
|
|
2450
|
+
sweepMs: dedup?.sweepMs,
|
|
2451
|
+
now: this.now,
|
|
2452
|
+
trace: this.trace
|
|
2453
|
+
});
|
|
1543
2454
|
this.cluster = new WorkerClusterRuntime({
|
|
1544
2455
|
...clusterOptions,
|
|
1545
2456
|
handlers: {
|
|
@@ -1547,26 +2458,36 @@ var CrossTabDataBus = class {
|
|
|
1547
2458
|
// control message — meaning the owning Worker has delegated the action to us.
|
|
1548
2459
|
onControl: (action, topic, data, messageId, timestamp) => {
|
|
1549
2460
|
switch (action) {
|
|
1550
|
-
case
|
|
1551
|
-
if (this.subscribeTransport(topic)) this.traceSubscription(
|
|
2461
|
+
case CONTROL_ACTION.SUBSCRIBE:
|
|
2462
|
+
if (this.subscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.SUBSCRIBE, topic);
|
|
1552
2463
|
break;
|
|
1553
|
-
case
|
|
1554
|
-
if (this.unsubscribeTransport(topic)) this.traceSubscription(
|
|
2464
|
+
case CONTROL_ACTION.UNSUBSCRIBE:
|
|
2465
|
+
if (this.unsubscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.UNSUBSCRIBE, topic);
|
|
1555
2466
|
break;
|
|
1556
|
-
case
|
|
1557
|
-
this.runTransport(() => this.transport.publish(
|
|
1558
|
-
topic,
|
|
1559
|
-
data,
|
|
1560
|
-
messageId === void 0 && timestamp === void 0 ? void 0 : {
|
|
1561
|
-
...messageId === void 0 ? {} : { messageId },
|
|
1562
|
-
...timestamp === void 0 ? {} : { timestamp }
|
|
1563
|
-
}
|
|
1564
|
-
));
|
|
2467
|
+
case CONTROL_ACTION.PUBLISH:
|
|
2468
|
+
this.runTransport(() => this.transport.publish(topic, data, publicationMetadata(messageId, timestamp)));
|
|
1565
2469
|
break;
|
|
1566
2470
|
default:
|
|
1567
2471
|
break;
|
|
1568
2472
|
}
|
|
1569
2473
|
},
|
|
2474
|
+
// Batched variant of the PUBLISH action (CONTROL frames carrying
|
|
2475
|
+
// multiple items, and the local publishBatch fast path). Uses the
|
|
2476
|
+
// transport's one-frame publishBatch when available, preserving
|
|
2477
|
+
// per-item metadata; otherwise falls back to per-item publishes.
|
|
2478
|
+
onPublishBatch: (topic, items) => {
|
|
2479
|
+
if (typeof this.transport.publishBatch === "function") {
|
|
2480
|
+
this.runTransport(() => this.transport.publishBatch(topic, items));
|
|
2481
|
+
return;
|
|
2482
|
+
}
|
|
2483
|
+
for (const item of items) {
|
|
2484
|
+
this.runTransport(() => this.transport.publish(
|
|
2485
|
+
topic,
|
|
2486
|
+
item.data,
|
|
2487
|
+
publicationMetadata(item.messageId, item.timestamp)
|
|
2488
|
+
));
|
|
2489
|
+
}
|
|
2490
|
+
},
|
|
1570
2491
|
// The cluster calls `onEvent` when a publication broadcast arrives from
|
|
1571
2492
|
// another tab. Dispatch locally if we have subscribers. The payload is
|
|
1572
2493
|
// typed `unknown` at the cluster boundary (the cluster is transport-
|
|
@@ -1579,22 +2500,21 @@ var CrossTabDataBus = class {
|
|
|
1579
2500
|
if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
|
|
1580
2501
|
},
|
|
1581
2502
|
onSuspend: () => {
|
|
1582
|
-
if (!this.stopping) this.trace.event({ type:
|
|
2503
|
+
if (!this.stopping) this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.SUSPEND });
|
|
1583
2504
|
this.trace.pause();
|
|
1584
|
-
this.
|
|
2505
|
+
this.replayManager.suspend();
|
|
1585
2506
|
this.stopDedupSweep();
|
|
1586
|
-
this.stopReplayRetentionSweep();
|
|
1587
2507
|
this.suspendTransport();
|
|
1588
2508
|
},
|
|
1589
2509
|
onResume: () => {
|
|
1590
|
-
this.trace.event({ type:
|
|
2510
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
|
|
1591
2511
|
this.trace.start();
|
|
1592
2512
|
this.startDedupSweep();
|
|
1593
|
-
this.
|
|
2513
|
+
this.replayManager.start();
|
|
1594
2514
|
this.resumeTransport();
|
|
1595
2515
|
},
|
|
1596
2516
|
onDiagnostic: (event) => {
|
|
1597
|
-
this.trace.event({ type:
|
|
2517
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, ...event });
|
|
1598
2518
|
}
|
|
1599
2519
|
}
|
|
1600
2520
|
});
|
|
@@ -1604,40 +2524,51 @@ var CrossTabDataBus = class {
|
|
|
1604
2524
|
* Start the DataBus with the given transport config.
|
|
1605
2525
|
*
|
|
1606
2526
|
* The first call starts the cluster and opens the transport. Concurrent calls
|
|
1607
|
-
* during an in-flight
|
|
1608
|
-
*
|
|
1609
|
-
*
|
|
2527
|
+
* during an in-flight open return the same promise. A call received while an
|
|
2528
|
+
* explicit stop() is settling queues one fresh start after cleanup; a later
|
|
2529
|
+
* stop() before that queued start runs cancels it, so the latest lifecycle
|
|
2530
|
+
* intent wins. Once an operation settles (success or failure) its promise
|
|
2531
|
+
* gate is cleared so a subsequent start() or resumeTransport() can open a
|
|
2532
|
+
* fresh lifecycle.
|
|
1610
2533
|
*/
|
|
1611
2534
|
start(config) {
|
|
1612
|
-
if (this.
|
|
1613
|
-
if (this.
|
|
2535
|
+
if (this.queuedStart) return this.queuedStart;
|
|
2536
|
+
if (this.stopping) return this.queueStartAfterStop(config);
|
|
2537
|
+
if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
|
|
2538
|
+
if (this.started) {
|
|
2539
|
+
const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
|
|
2540
|
+
if (!transportDown) return Promise.resolve();
|
|
2541
|
+
this.activeConfig = config;
|
|
2542
|
+
this.resetFailureState();
|
|
2543
|
+
return this.reopenTransport();
|
|
2544
|
+
}
|
|
1614
2545
|
this.started = true;
|
|
1615
2546
|
this.stopping = false;
|
|
1616
2547
|
this.suspended = false;
|
|
1617
2548
|
this.activeConfig = config;
|
|
1618
|
-
this.
|
|
1619
|
-
this.trace.event({ type:
|
|
2549
|
+
this.resetFailureState();
|
|
2550
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
|
|
1620
2551
|
this.trace.start();
|
|
1621
2552
|
this.startDedupSweep();
|
|
1622
|
-
this.
|
|
1623
|
-
this.updateStatus(
|
|
2553
|
+
this.replayManager.start();
|
|
2554
|
+
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
1624
2555
|
this.cluster.start();
|
|
1625
|
-
const
|
|
2556
|
+
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
2557
|
+
const opening = this.openTransport(
|
|
2558
|
+
config,
|
|
2559
|
+
this.pendingStop ?? Promise.resolve(),
|
|
2560
|
+
true,
|
|
2561
|
+
lifecycleEpoch
|
|
2562
|
+
);
|
|
1626
2563
|
this.startPromise = opening;
|
|
1627
2564
|
for (const topic of this.topicHandlers.keys()) {
|
|
1628
2565
|
this.cluster.subscribe(topic);
|
|
1629
2566
|
}
|
|
1630
|
-
const snapshot = this.cluster.getSnapshot();
|
|
1631
|
-
this.trace.event({
|
|
1632
|
-
type: "coordination",
|
|
1633
|
-
coordinated: snapshot.coordinated,
|
|
1634
|
-
activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
|
|
1635
|
-
workers: snapshot.workers.map(formatWorkerTrace),
|
|
1636
|
-
routes: snapshot.routes.map(formatRouteTrace)
|
|
1637
|
-
});
|
|
1638
2567
|
void opening.then(
|
|
1639
2568
|
() => {
|
|
1640
|
-
if (this.startPromise
|
|
2569
|
+
if (this.startPromise !== opening) return;
|
|
2570
|
+
this.emitCoordinationTrace();
|
|
2571
|
+
this.startPromise = null;
|
|
1641
2572
|
},
|
|
1642
2573
|
() => {
|
|
1643
2574
|
if (this.startPromise === opening) this.startPromise = null;
|
|
@@ -1645,25 +2576,81 @@ var CrossTabDataBus = class {
|
|
|
1645
2576
|
);
|
|
1646
2577
|
return opening;
|
|
1647
2578
|
}
|
|
2579
|
+
/** Return a cancellation-aware readiness view of the current queued start. */
|
|
2580
|
+
getQueuedStartReady() {
|
|
2581
|
+
const queued = this.queuedStart;
|
|
2582
|
+
if (!queued) {
|
|
2583
|
+
return Promise.reject(new Error("No queued start is in flight."));
|
|
2584
|
+
}
|
|
2585
|
+
const token = this.queuedStartToken;
|
|
2586
|
+
if (this.queuedStartReady && this.queuedStartReadyToken === token) {
|
|
2587
|
+
return this.queuedStartReady;
|
|
2588
|
+
}
|
|
2589
|
+
this.queuedStartReadyToken = token;
|
|
2590
|
+
this.queuedStartReady = queued.then(() => {
|
|
2591
|
+
if (token <= this.canceledQueuedStartToken) {
|
|
2592
|
+
throw new Error(
|
|
2593
|
+
"CrossTabDataBus start was canceled by a later stop(); ready() cannot report readiness. Call start() again after stop() resolves."
|
|
2594
|
+
);
|
|
2595
|
+
}
|
|
2596
|
+
if (!this.started || !this.transportReady) {
|
|
2597
|
+
throw new Error("CrossTabDataBus restart completed without a ready transport.");
|
|
2598
|
+
}
|
|
2599
|
+
});
|
|
2600
|
+
return this.queuedStartReady;
|
|
2601
|
+
}
|
|
2602
|
+
/** Queue exactly one fresh start after an in-flight explicit stop settles. */
|
|
2603
|
+
queueStartAfterStop(config) {
|
|
2604
|
+
if (this.queuedStart) return this.queuedStart;
|
|
2605
|
+
const stop = this.stopPromise ?? Promise.resolve();
|
|
2606
|
+
const token = ++this.queuedStartToken;
|
|
2607
|
+
const queued = stop.catch(() => void 0).then(() => {
|
|
2608
|
+
if (this.queuedStart === queued) this.queuedStart = null;
|
|
2609
|
+
if (token <= this.canceledQueuedStartToken) return;
|
|
2610
|
+
return this.start(config);
|
|
2611
|
+
});
|
|
2612
|
+
this.queuedStart = queued;
|
|
2613
|
+
return queued;
|
|
2614
|
+
}
|
|
2615
|
+
/** Reset failure and recovery diagnostics for a new explicit start session. */
|
|
2616
|
+
resetFailureState() {
|
|
2617
|
+
this.lastError = null;
|
|
2618
|
+
this.lastErrorAt = null;
|
|
2619
|
+
this.lastFailure = null;
|
|
2620
|
+
this.persistenceFailureCount = 0;
|
|
2621
|
+
this.persistenceLastFailureAt = null;
|
|
2622
|
+
this.persistenceLastErrorMessage = null;
|
|
2623
|
+
this.recoveryAttempt = 0;
|
|
2624
|
+
this.recoveryExhausted = false;
|
|
2625
|
+
this.lastRecoveryAt = 0;
|
|
2626
|
+
}
|
|
1648
2627
|
/**
|
|
1649
2628
|
* Open the transport, chained after `before` to ensure lifecycle ordering.
|
|
1650
2629
|
* When `stopClusterOnFailure` is true (initial start), a transport failure
|
|
1651
2630
|
* tears down the cluster as well.
|
|
1652
2631
|
*/
|
|
1653
|
-
openTransport(config, before, stopClusterOnFailure) {
|
|
2632
|
+
openTransport(config, before, stopClusterOnFailure, lifecycleEpoch) {
|
|
1654
2633
|
this.transportReady = false;
|
|
1655
2634
|
const chainedPendingStop = this.pendingStop;
|
|
2635
|
+
const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
|
|
1656
2636
|
return before.catch(() => void 0).then(() => {
|
|
1657
|
-
if (this.stopping || this.suspended) return;
|
|
2637
|
+
if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
|
|
1658
2638
|
if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
|
|
1659
2639
|
return Promise.resolve(
|
|
1660
2640
|
this.transport.start(config, {
|
|
1661
|
-
onMessage: (message) =>
|
|
1662
|
-
|
|
1663
|
-
|
|
2641
|
+
onMessage: (message) => {
|
|
2642
|
+
if (isCurrentLifecycle()) this.handleTransportMessage(message);
|
|
2643
|
+
},
|
|
2644
|
+
onStatus: (status) => {
|
|
2645
|
+
if (isCurrentLifecycle()) this.updateStatus(status);
|
|
2646
|
+
},
|
|
2647
|
+
onError: (error) => {
|
|
2648
|
+
if (isCurrentLifecycle()) this.reportError(error);
|
|
2649
|
+
}
|
|
1664
2650
|
})
|
|
1665
2651
|
).then(() => {
|
|
1666
|
-
if (
|
|
2652
|
+
if (!isCurrentLifecycle()) return;
|
|
2653
|
+
if (this.status === WORKER_STATUS.ERROR) {
|
|
1667
2654
|
throw new Error("Transport failed during startup.");
|
|
1668
2655
|
}
|
|
1669
2656
|
if (!this.suspended && !this.stopping) {
|
|
@@ -1673,11 +2660,12 @@ var CrossTabDataBus = class {
|
|
|
1673
2660
|
}
|
|
1674
2661
|
});
|
|
1675
2662
|
}).catch((error) => {
|
|
2663
|
+
if (!isCurrentLifecycle()) throw error;
|
|
1676
2664
|
if (stopClusterOnFailure) this.started = false;
|
|
1677
2665
|
if (!this.pendingStop) {
|
|
1678
2666
|
this.pendingStop = this.createStopPromise();
|
|
1679
2667
|
}
|
|
1680
|
-
this.updateStatus(
|
|
2668
|
+
this.updateStatus(WORKER_STATUS.ERROR);
|
|
1681
2669
|
this.reportError(error);
|
|
1682
2670
|
this.lastError = error;
|
|
1683
2671
|
this.lastErrorAt = this.now();
|
|
@@ -1687,16 +2675,26 @@ var CrossTabDataBus = class {
|
|
|
1687
2675
|
this.cluster.stop();
|
|
1688
2676
|
this.stopping = false;
|
|
1689
2677
|
}
|
|
1690
|
-
this.startPromise = null;
|
|
1691
2678
|
throw error;
|
|
1692
2679
|
});
|
|
1693
2680
|
}
|
|
1694
2681
|
/**
|
|
1695
2682
|
* Await the DataBus to be fully started (lazy init when using initialConfig).
|
|
1696
2683
|
* Returns a rejected promise when the transport has failed and no start is in
|
|
1697
|
-
* flight — the caller can retry by calling start() or ready() again.
|
|
2684
|
+
* flight — the caller can retry by calling start() or ready() again. While an
|
|
2685
|
+
* explicit stop() is settling, this rejects unless a restart is queued behind
|
|
2686
|
+
* it; false readiness during teardown is never reported.
|
|
1698
2687
|
*/
|
|
1699
2688
|
ready() {
|
|
2689
|
+
if (this.queuedStart) return this.getQueuedStartReady();
|
|
2690
|
+
if (this.stopping) {
|
|
2691
|
+
return Promise.reject(new Error(
|
|
2692
|
+
"CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
|
|
2693
|
+
));
|
|
2694
|
+
}
|
|
2695
|
+
if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
|
|
2696
|
+
return Promise.reject(this.lastError);
|
|
2697
|
+
}
|
|
1700
2698
|
try {
|
|
1701
2699
|
this.ensureStarted();
|
|
1702
2700
|
} catch (error) {
|
|
@@ -1712,9 +2710,18 @@ var CrossTabDataBus = class {
|
|
|
1712
2710
|
/**
|
|
1713
2711
|
* Register a handler for `topic`. The handler fires on every publication
|
|
1714
2712
|
* delivered to this tab, regardless of which tab published it. Returns an
|
|
1715
|
-
* unsubscribe function for convenience.
|
|
2713
|
+
* unsubscribe function for convenience. During an explicit stop() the
|
|
2714
|
+
* registration is rejected through onError and a no-op cleanup is returned,
|
|
2715
|
+
* so a late subscriber cannot leak into a future restart.
|
|
1716
2716
|
*/
|
|
1717
2717
|
subscribe(topic, handler, options) {
|
|
2718
|
+
if (this.stopping) {
|
|
2719
|
+
this.reportError(new Error(
|
|
2720
|
+
"CrossTabDataBus is stopping; subscribe() was not registered. Wait for stop() to resolve, then call start() before subscribing again."
|
|
2721
|
+
));
|
|
2722
|
+
return () => {
|
|
2723
|
+
};
|
|
2724
|
+
}
|
|
1718
2725
|
this.ensureStarted();
|
|
1719
2726
|
const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
|
|
1720
2727
|
const wasUnused = handlers.size === 0;
|
|
@@ -1722,17 +2729,12 @@ var CrossTabDataBus = class {
|
|
|
1722
2729
|
this.topicHandlers.set(topic, handlers);
|
|
1723
2730
|
if (wasUnused) this.cluster.subscribe(topic);
|
|
1724
2731
|
if (options?.replay) {
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
2732
|
+
this.replayManager.deliverReplay(
|
|
2733
|
+
topic,
|
|
2734
|
+
options.replay,
|
|
2735
|
+
handler,
|
|
2736
|
+
() => Boolean(this.topicHandlers.get(topic)?.has(handler))
|
|
1728
2737
|
);
|
|
1729
|
-
if (this.replayPersistence) {
|
|
1730
|
-
void this.replayHydration.then(() => {
|
|
1731
|
-
if (this.topicHandlers.get(topic)?.has(handler)) this.deliverReplay(topic, limit, handler);
|
|
1732
|
-
});
|
|
1733
|
-
} else {
|
|
1734
|
-
this.deliverReplay(topic, limit, handler);
|
|
1735
|
-
}
|
|
1736
2738
|
}
|
|
1737
2739
|
return () => this.unsubscribe(topic, handler);
|
|
1738
2740
|
}
|
|
@@ -1747,74 +2749,33 @@ var CrossTabDataBus = class {
|
|
|
1747
2749
|
else handlers.clear();
|
|
1748
2750
|
if (handlers.size > 0) return;
|
|
1749
2751
|
this.topicHandlers.delete(topic);
|
|
1750
|
-
this.
|
|
1751
|
-
if (this.replayPersistence?.clearTopic) {
|
|
1752
|
-
void this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic)).catch((error) => this.reportPersistenceError(error));
|
|
1753
|
-
}
|
|
2752
|
+
this.replayManager.onTopicUnsubscribed(topic);
|
|
1754
2753
|
this.cluster.unsubscribe(topic);
|
|
1755
2754
|
}
|
|
1756
2755
|
/** Clear all in-memory replay buffers and, when supported, durable history. */
|
|
1757
2756
|
async clearReplay() {
|
|
1758
|
-
this.
|
|
1759
|
-
if (this.replayPersistence?.clear) {
|
|
1760
|
-
try {
|
|
1761
|
-
await this.withPersistenceRetry("clear", () => this.replayPersistence.clear());
|
|
1762
|
-
} catch (error) {
|
|
1763
|
-
this.reportPersistenceError(error);
|
|
1764
|
-
throw error;
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
2757
|
+
await this.replayManager.clearAll();
|
|
1767
2758
|
}
|
|
1768
2759
|
/** Clear replay history for one exact topic, including durable storage. */
|
|
1769
2760
|
async clearReplayTopic(topic) {
|
|
1770
|
-
this.
|
|
1771
|
-
if (this.replayPersistence?.clearTopic) {
|
|
1772
|
-
try {
|
|
1773
|
-
await this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic));
|
|
1774
|
-
} catch (error) {
|
|
1775
|
-
this.reportPersistenceError(error);
|
|
1776
|
-
throw error;
|
|
1777
|
-
}
|
|
1778
|
-
}
|
|
2761
|
+
await this.replayManager.clearTopic(topic);
|
|
1779
2762
|
}
|
|
1780
2763
|
/** Remove replay entries older than an epoch-millisecond cutoff. */
|
|
1781
2764
|
async clearReplayBefore(timestamp) {
|
|
1782
|
-
|
|
1783
|
-
if (this.replayBuffers) {
|
|
1784
|
-
for (const [topic, messages] of this.replayBuffers) {
|
|
1785
|
-
const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
|
|
1786
|
-
if (kept.length) this.replayBuffers.set(topic, kept);
|
|
1787
|
-
else this.replayBuffers.delete(topic);
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
if (this.replayPersistence?.clearBefore) {
|
|
1791
|
-
try {
|
|
1792
|
-
await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(timestamp));
|
|
1793
|
-
} catch (error) {
|
|
1794
|
-
this.reportPersistenceError(error);
|
|
1795
|
-
throw error;
|
|
1796
|
-
}
|
|
1797
|
-
}
|
|
2765
|
+
await this.replayManager.clearBefore(timestamp);
|
|
1798
2766
|
}
|
|
1799
2767
|
/** Return bounded deduplication counters for diagnostics and health checks. */
|
|
1800
2768
|
getDedupStats() {
|
|
1801
|
-
return
|
|
1802
|
-
enabled: this.dedupEnabled,
|
|
1803
|
-
tracked: this.seenMessageIds.size,
|
|
1804
|
-
suppressed: this.dedupSuppressed,
|
|
1805
|
-
accepted: this.dedupAccepted,
|
|
1806
|
-
...this.dedupAdaptiveBounds ? { ttlMs: this.currentDedupTtl() } : {}
|
|
1807
|
-
};
|
|
2769
|
+
return this.dedupManager.getStats();
|
|
1808
2770
|
}
|
|
1809
2771
|
/** Drop all remembered IDs and reset dedup counters. */
|
|
1810
2772
|
resetDedup() {
|
|
1811
|
-
this.
|
|
1812
|
-
this.dedupSuppressed = 0;
|
|
1813
|
-
this.dedupAccepted = 0;
|
|
2773
|
+
this.dedupManager.reset();
|
|
1814
2774
|
}
|
|
1815
2775
|
/** Publish a message to `topic`. The owning Worker delivers it to the transport. */
|
|
1816
2776
|
publish(topic, data, options) {
|
|
1817
2777
|
this.ensureStarted();
|
|
2778
|
+
if (this.rejectPublishDuringStop("publish")) return;
|
|
1818
2779
|
if (!this.cluster.publish(topic, data, options)) {
|
|
1819
2780
|
this.reportError(
|
|
1820
2781
|
new Error("Failed to send the publish control message to the owning worker.")
|
|
@@ -1831,6 +2792,7 @@ var CrossTabDataBus = class {
|
|
|
1831
2792
|
publishBatch(topic, items) {
|
|
1832
2793
|
this.ensureStarted();
|
|
1833
2794
|
if (items.length === 0) return;
|
|
2795
|
+
if (this.rejectPublishDuringStop("publishBatch")) return;
|
|
1834
2796
|
if (items.length === 1) {
|
|
1835
2797
|
const first = items[0];
|
|
1836
2798
|
this.publish(topic, first.data, first.options);
|
|
@@ -1838,8 +2800,7 @@ var CrossTabDataBus = class {
|
|
|
1838
2800
|
}
|
|
1839
2801
|
const mapped = items.map((item) => ({
|
|
1840
2802
|
data: item.data,
|
|
1841
|
-
...item.options?.messageId
|
|
1842
|
-
...item.options?.timestamp !== void 0 ? { timestamp: item.options.timestamp } : {}
|
|
2803
|
+
...publicationMetadata(item.options?.messageId, item.options?.timestamp)
|
|
1843
2804
|
}));
|
|
1844
2805
|
if (!this.cluster.publishBatch(topic, mapped)) {
|
|
1845
2806
|
this.reportError(
|
|
@@ -1884,6 +2845,41 @@ var CrossTabDataBus = class {
|
|
|
1884
2845
|
lastSuccessAt: this.lastSuccessAt
|
|
1885
2846
|
};
|
|
1886
2847
|
}
|
|
2848
|
+
/** Bounded failure counters for the replay persistence backend. */
|
|
2849
|
+
getPersistenceStats() {
|
|
2850
|
+
return {
|
|
2851
|
+
failures: this.persistenceFailureCount,
|
|
2852
|
+
lastFailureAt: this.persistenceLastFailureAt,
|
|
2853
|
+
lastErrorMessage: this.persistenceLastErrorMessage
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
/** Compact health verdict for dashboards, readiness probes, and support
|
|
2857
|
+
* bundles. Answers "is the bus usable right now" first, then attaches the
|
|
2858
|
+
* unified failure ledger and recovery context that explains the verdict. */
|
|
2859
|
+
getHealthSummary() {
|
|
2860
|
+
const transport = this.transport;
|
|
2861
|
+
const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
|
|
2862
|
+
const state = !this.started ? HEALTH_STATE.STOPPED : this.suspended ? HEALTH_STATE.SUSPENDED : transportDown ? this.recoveryExhausted ? HEALTH_STATE.DEGRADED : this.status === WORKER_STATUS.CONNECTING && this.recoveryAttempt === 0 ? HEALTH_STATE.STARTING : HEALTH_STATE.RECOVERING : HEALTH_STATE.HEALTHY;
|
|
2863
|
+
return {
|
|
2864
|
+
healthy: state === HEALTH_STATE.HEALTHY,
|
|
2865
|
+
state,
|
|
2866
|
+
status: this.status,
|
|
2867
|
+
sdkVersion: SDK_VERSION,
|
|
2868
|
+
started: this.started,
|
|
2869
|
+
suspended: this.suspended,
|
|
2870
|
+
transport: {
|
|
2871
|
+
name: transport.diagnosticsName ?? transport.constructor.name,
|
|
2872
|
+
backend: transport.diagnosticsBackend ?? null,
|
|
2873
|
+
ready: this.transportReady,
|
|
2874
|
+
status: this.status
|
|
2875
|
+
},
|
|
2876
|
+
recovery: this.getRecoveryStats(),
|
|
2877
|
+
lastFailure: this.lastFailure,
|
|
2878
|
+
persistence: this.getPersistenceStats(),
|
|
2879
|
+
metrics: this.trace.getMetrics(),
|
|
2880
|
+
trace: this.trace.getSinkState()
|
|
2881
|
+
};
|
|
2882
|
+
}
|
|
1887
2883
|
/** Snapshot of the cluster state (workers, routes, assignments).
|
|
1888
2884
|
* For diagnostics only — the returned object is a shallow copy but
|
|
1889
2885
|
* nested arrays are snapshots at call time. */
|
|
@@ -1892,8 +2888,7 @@ var CrossTabDataBus = class {
|
|
|
1892
2888
|
}
|
|
1893
2889
|
/** Return a single health snapshot combining lifecycle, recovery, dedup, replay, and cluster state. */
|
|
1894
2890
|
getDiagnostics() {
|
|
1895
|
-
|
|
1896
|
-
if (this.replayBuffers) for (const buffer of this.replayBuffers.values()) messages += buffer.length;
|
|
2891
|
+
const replay = this.replayManager.getStats();
|
|
1897
2892
|
const cluster = this.cluster.getSnapshot();
|
|
1898
2893
|
const unknownMessages = this.cluster.getUnknownMessageStats();
|
|
1899
2894
|
const transport = this.transport;
|
|
@@ -1904,26 +2899,62 @@ var CrossTabDataBus = class {
|
|
|
1904
2899
|
transportReady: this.transportReady,
|
|
1905
2900
|
recovery: this.getRecoveryStats(),
|
|
1906
2901
|
dedup: this.getDedupStats(),
|
|
1907
|
-
replay: { enabled:
|
|
2902
|
+
replay: { enabled: replay.enabled, topics: replay.topics, messages: replay.messages, bytes: replay.bytes },
|
|
2903
|
+
persistence: this.getPersistenceStats(),
|
|
1908
2904
|
protocol: { version: cluster.protocolVersion, unknownMessages: unknownMessages.count, lastUnknownMessageType: unknownMessages.lastType, peers: cluster.peerProtocolVersions },
|
|
1909
|
-
transport: {
|
|
1910
|
-
|
|
2905
|
+
transport: {
|
|
2906
|
+
name: transport.diagnosticsName ?? transport.constructor.name,
|
|
2907
|
+
backend: transport.diagnosticsBackend ?? null,
|
|
2908
|
+
status: this.status,
|
|
2909
|
+
suspended: this.suspended
|
|
2910
|
+
},
|
|
2911
|
+
cluster,
|
|
2912
|
+
metrics: this.trace.getMetrics(),
|
|
2913
|
+
trace: this.trace.getSinkState()
|
|
1911
2914
|
};
|
|
1912
2915
|
}
|
|
2916
|
+
/** Synchronous snapshot of the current trace metrics window (throughput,
|
|
2917
|
+
* dispatch latency, dedup outcomes), without flushing or resetting it.
|
|
2918
|
+
* Returns null when trace metrics are inactive (disabled or events-only). */
|
|
2919
|
+
getMetrics() {
|
|
2920
|
+
return this.trace.getMetrics();
|
|
2921
|
+
}
|
|
1913
2922
|
/**
|
|
1914
2923
|
* Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
|
|
1915
|
-
* and close the transport.
|
|
2924
|
+
* and close the transport. Concurrent and repeated calls share the in-flight
|
|
2925
|
+
* stop promise. A start() received while stopping runs after this completes,
|
|
2926
|
+
* unless another stop() arrives first and cancels that queued restart.
|
|
1916
2927
|
*/
|
|
1917
|
-
|
|
1918
|
-
if (
|
|
2928
|
+
stop() {
|
|
2929
|
+
if (this.queuedStart) {
|
|
2930
|
+
this.canceledQueuedStartToken = this.queuedStartToken;
|
|
2931
|
+
this.queuedStart = null;
|
|
2932
|
+
}
|
|
2933
|
+
if (this.stopPromise) return this.stopPromise;
|
|
2934
|
+
if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
|
|
2935
|
+
return Promise.resolve();
|
|
2936
|
+
}
|
|
2937
|
+
const stopPromise = this.performStop();
|
|
2938
|
+
this.stopPromise = stopPromise;
|
|
2939
|
+
void stopPromise.then(
|
|
2940
|
+
() => {
|
|
2941
|
+
if (this.stopPromise === stopPromise) this.stopPromise = null;
|
|
2942
|
+
},
|
|
2943
|
+
() => {
|
|
2944
|
+
if (this.stopPromise === stopPromise) this.stopPromise = null;
|
|
2945
|
+
}
|
|
2946
|
+
);
|
|
2947
|
+
return stopPromise;
|
|
2948
|
+
}
|
|
2949
|
+
async performStop() {
|
|
2950
|
+
this.lifecycleEpoch += 1;
|
|
1919
2951
|
this.stopping = true;
|
|
1920
|
-
this.
|
|
1921
|
-
this.trace.event({ type:
|
|
2952
|
+
this.replayManager.suspend();
|
|
2953
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
|
|
1922
2954
|
this.trace.stop();
|
|
1923
2955
|
this.stopDedupSweep();
|
|
1924
|
-
this.stopReplayRetentionSweep();
|
|
1925
2956
|
this.topicHandlers.clear();
|
|
1926
|
-
this.
|
|
2957
|
+
this.replayManager.resetBuffers();
|
|
1927
2958
|
this.cluster.stop();
|
|
1928
2959
|
try {
|
|
1929
2960
|
await this.startPromise?.catch(() => void 0);
|
|
@@ -1944,7 +2975,7 @@ var CrossTabDataBus = class {
|
|
|
1944
2975
|
this.activeConfig = void 0;
|
|
1945
2976
|
this.recoveryAttempt = 0;
|
|
1946
2977
|
this.recoveryExhausted = false;
|
|
1947
|
-
this.updateStatus(
|
|
2978
|
+
this.updateStatus(WORKER_STATUS.DISCONNECTED);
|
|
1948
2979
|
}
|
|
1949
2980
|
}
|
|
1950
2981
|
/**
|
|
@@ -1953,7 +2984,7 @@ var CrossTabDataBus = class {
|
|
|
1953
2984
|
* and dispatches locally.
|
|
1954
2985
|
*/
|
|
1955
2986
|
handleTransportMessage(message) {
|
|
1956
|
-
if (this.isDuplicate(message)) return;
|
|
2987
|
+
if (this.dedupManager.isDuplicate(message.messageId ?? "", message.topic)) return;
|
|
1957
2988
|
this.trace.recordReceived(message.topic);
|
|
1958
2989
|
if (!this.cluster.isAssigned(message.topic)) {
|
|
1959
2990
|
this.trace.recordDiscarded(message.topic);
|
|
@@ -1967,55 +2998,13 @@ var CrossTabDataBus = class {
|
|
|
1967
2998
|
}
|
|
1968
2999
|
this.trace.recordDiscarded(message.topic);
|
|
1969
3000
|
}
|
|
1970
|
-
|
|
1971
|
-
if (!this.dedupEnabled || !message.messageId) return false;
|
|
1972
|
-
const now = this.now();
|
|
1973
|
-
for (const [id, timestamp] of this.seenMessageIds) {
|
|
1974
|
-
if (now - timestamp > this.dedupTtlMs) this.seenMessageIds.delete(id);
|
|
1975
|
-
}
|
|
1976
|
-
if (this.seenMessageIds.has(message.messageId)) {
|
|
1977
|
-
this.trace.event({ type: "reliability", operation: "dedup_suppressed", topic: message.topic });
|
|
1978
|
-
this.dedupSuppressed += 1;
|
|
1979
|
-
this.trace.recordDedupSuppressed();
|
|
1980
|
-
return true;
|
|
1981
|
-
}
|
|
1982
|
-
this.seenMessageIds.set(message.messageId, now);
|
|
1983
|
-
this.dedupAccepted += 1;
|
|
1984
|
-
this.dedupWindowAccepted += 1;
|
|
1985
|
-
this.trace.recordDedupAccepted();
|
|
1986
|
-
while (this.seenMessageIds.size > this.dedupMaxEntries) {
|
|
1987
|
-
const oldest = this.seenMessageIds.keys().next().value;
|
|
1988
|
-
if (oldest === void 0) break;
|
|
1989
|
-
this.seenMessageIds.delete(oldest);
|
|
1990
|
-
}
|
|
1991
|
-
return false;
|
|
1992
|
-
}
|
|
3001
|
+
/** Start enqueuing the dedup expiry sweep (delegated to {@link DedupManager}). */
|
|
1993
3002
|
startDedupSweep() {
|
|
1994
|
-
|
|
1995
|
-
this.dedupSweepTimer = setInterval(() => this.pruneExpiredDedup(), this.dedupSweepMs);
|
|
3003
|
+
this.dedupManager.start();
|
|
1996
3004
|
}
|
|
3005
|
+
/** Stop enqueuing the dedup expiry sweep. */
|
|
1997
3006
|
stopDedupSweep() {
|
|
1998
|
-
|
|
1999
|
-
this.dedupSweepTimer = null;
|
|
2000
|
-
}
|
|
2001
|
-
pruneExpiredDedup() {
|
|
2002
|
-
const cutoff = this.now() - this.currentDedupTtl();
|
|
2003
|
-
for (const [id, timestamp] of this.seenMessageIds) {
|
|
2004
|
-
if (timestamp < cutoff) this.seenMessageIds.delete(id);
|
|
2005
|
-
}
|
|
2006
|
-
}
|
|
2007
|
-
currentDedupTtl() {
|
|
2008
|
-
if (!this.dedupAdaptiveBounds) return this.dedupTtlMs;
|
|
2009
|
-
const now = this.now();
|
|
2010
|
-
const elapsed = now - this.dedupWindowStartedAt;
|
|
2011
|
-
if (elapsed >= this.dedupAdaptiveWindowMs) {
|
|
2012
|
-
this.dedupWindowStartedAt = now;
|
|
2013
|
-
this.dedupWindowAccepted = 0;
|
|
2014
|
-
return this.dedupAdaptiveBounds.maxMs;
|
|
2015
|
-
}
|
|
2016
|
-
const rate = this.dedupWindowAccepted / Math.max(1, elapsed);
|
|
2017
|
-
const factor = Math.min(1, rate / 0.01);
|
|
2018
|
-
return this.dedupAdaptiveBounds.maxMs - (this.dedupAdaptiveBounds.maxMs - this.dedupAdaptiveBounds.minMs) * factor;
|
|
3007
|
+
this.dedupManager.stop();
|
|
2019
3008
|
}
|
|
2020
3009
|
/** Deliver a message to every local handler registered for its topic,
|
|
2021
3010
|
* plus every handler registered with a wildcard subscription that matches
|
|
@@ -2028,153 +3017,7 @@ var CrossTabDataBus = class {
|
|
|
2028
3017
|
this.invokeHandlers(handlers, (handler) => handler(message));
|
|
2029
3018
|
}
|
|
2030
3019
|
}
|
|
2031
|
-
this.
|
|
2032
|
-
}
|
|
2033
|
-
/** Append a dispatched publication to the topic's replay ring buffer.
|
|
2034
|
-
* No-op when replay is disabled. */
|
|
2035
|
-
recordReplay(message) {
|
|
2036
|
-
if (!this.replayBuffers) return;
|
|
2037
|
-
let buffer = this.replayBuffers.get(message.topic);
|
|
2038
|
-
if (!buffer) {
|
|
2039
|
-
buffer = [];
|
|
2040
|
-
this.replayBuffers.set(message.topic, buffer);
|
|
2041
|
-
}
|
|
2042
|
-
const storedMessage = message;
|
|
2043
|
-
buffer.push(storedMessage);
|
|
2044
|
-
if (this.replayPruneStrategy !== "age") {
|
|
2045
|
-
while (buffer.length > this.replayMaxPerTopic) buffer.shift();
|
|
2046
|
-
}
|
|
2047
|
-
if (this.replayPruneStrategy !== "count" && this.replayRetentionMs !== void 0) {
|
|
2048
|
-
const cutoff = this.now() - this.replayRetentionMs;
|
|
2049
|
-
while (buffer.length > 0) {
|
|
2050
|
-
const first = buffer[0];
|
|
2051
|
-
if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
|
|
2052
|
-
buffer.shift();
|
|
2053
|
-
}
|
|
2054
|
-
}
|
|
2055
|
-
if (this.replayPersistence) {
|
|
2056
|
-
if (this.replayPersistence.appendBatch) {
|
|
2057
|
-
this.pendingReplayPersistence.push(storedMessage);
|
|
2058
|
-
this.scheduleReplayPersistenceFlush();
|
|
2059
|
-
} else {
|
|
2060
|
-
void this.withPersistenceRetry("append", () => this.replayPersistence.append(storedMessage)).catch((error) => this.reportPersistenceError(error));
|
|
2061
|
-
}
|
|
2062
|
-
if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
|
|
2063
|
-
this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
|
|
2064
|
-
}
|
|
2065
|
-
}
|
|
2066
|
-
}
|
|
2067
|
-
scheduleReplayPersistenceFlush() {
|
|
2068
|
-
if (this.replayPersistenceFlushScheduled) return;
|
|
2069
|
-
this.replayPersistenceFlushScheduled = true;
|
|
2070
|
-
queueMicrotask(() => {
|
|
2071
|
-
this.replayPersistenceFlushScheduled = false;
|
|
2072
|
-
const batch = this.pendingReplayPersistence.splice(0);
|
|
2073
|
-
if (batch.length === 0 || !this.replayPersistence) return;
|
|
2074
|
-
const operation = this.replayPersistence.appendBatch ? () => this.replayPersistence.appendBatch(batch) : () => Promise.all(batch.map((message) => this.replayPersistence.append(message))).then(() => void 0);
|
|
2075
|
-
void this.withPersistenceRetry("append", operation).catch((error) => this.reportPersistenceError(error));
|
|
2076
|
-
});
|
|
2077
|
-
}
|
|
2078
|
-
async hydrateReplay() {
|
|
2079
|
-
if (!this.replayBuffers || !this.replayPersistence) {
|
|
2080
|
-
return;
|
|
2081
|
-
}
|
|
2082
|
-
try {
|
|
2083
|
-
if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
|
|
2084
|
-
await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(this.now() - this.replayRetentionMs));
|
|
2085
|
-
}
|
|
2086
|
-
for (const message of await this.withPersistenceRetry("load", () => this.replayPersistence.load())) {
|
|
2087
|
-
let buffer = this.replayBuffers.get(message.topic);
|
|
2088
|
-
if (!buffer) {
|
|
2089
|
-
buffer = [];
|
|
2090
|
-
this.replayBuffers.set(message.topic, buffer);
|
|
2091
|
-
}
|
|
2092
|
-
buffer.push(message);
|
|
2093
|
-
if (buffer.length > this.replayMaxPerTopic) buffer.shift();
|
|
2094
|
-
}
|
|
2095
|
-
} catch (error) {
|
|
2096
|
-
this.reportPersistenceError(error);
|
|
2097
|
-
}
|
|
2098
|
-
}
|
|
2099
|
-
scheduleReplayRetentionCleanup(cutoff) {
|
|
2100
|
-
if (!this.replayPersistence?.clearBefore) return;
|
|
2101
|
-
if (this.replayRetentionCutoff === null || cutoff > this.replayRetentionCutoff) {
|
|
2102
|
-
this.replayRetentionCutoff = cutoff;
|
|
2103
|
-
}
|
|
2104
|
-
if (this.replayRetentionCleanup) return;
|
|
2105
|
-
this.replayRetentionCleanup = (async () => {
|
|
2106
|
-
while (this.replayRetentionCutoff !== null) {
|
|
2107
|
-
const nextCutoff = this.replayRetentionCutoff;
|
|
2108
|
-
this.replayRetentionCutoff = null;
|
|
2109
|
-
try {
|
|
2110
|
-
await this.replayPersistence.clearBefore(nextCutoff);
|
|
2111
|
-
} catch (error) {
|
|
2112
|
-
this.reportPersistenceError(error);
|
|
2113
|
-
}
|
|
2114
|
-
}
|
|
2115
|
-
})().finally(() => {
|
|
2116
|
-
this.replayRetentionCleanup = null;
|
|
2117
|
-
if (this.replayRetentionCutoff !== null) {
|
|
2118
|
-
this.scheduleReplayRetentionCleanup(this.replayRetentionCutoff);
|
|
2119
|
-
}
|
|
2120
|
-
});
|
|
2121
|
-
}
|
|
2122
|
-
startReplayRetentionSweep() {
|
|
2123
|
-
if (this.replayRetentionTimer || !this.replayRetentionMs || !this.replayRetentionSweepMs || !this.replayPersistence?.clearBefore) return;
|
|
2124
|
-
this.replayRetentionTimer = setInterval(() => {
|
|
2125
|
-
this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
|
|
2126
|
-
}, this.replayRetentionSweepMs);
|
|
2127
|
-
}
|
|
2128
|
-
stopReplayRetentionSweep() {
|
|
2129
|
-
if (this.replayRetentionTimer) clearInterval(this.replayRetentionTimer);
|
|
2130
|
-
this.replayRetentionTimer = null;
|
|
2131
|
-
}
|
|
2132
|
-
async withPersistenceRetry(persistenceOperation, operation) {
|
|
2133
|
-
const generation = this.persistenceRetryGeneration;
|
|
2134
|
-
let attempt = 0;
|
|
2135
|
-
let delay = this.persistenceRetryBackoffMs;
|
|
2136
|
-
while (true) {
|
|
2137
|
-
attempt += 1;
|
|
2138
|
-
try {
|
|
2139
|
-
if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
|
|
2140
|
-
return await operation();
|
|
2141
|
-
} catch (error) {
|
|
2142
|
-
if (error instanceof PersistenceRetryCancelledError || generation !== this.persistenceRetryGeneration) {
|
|
2143
|
-
throw new PersistenceRetryCancelledError();
|
|
2144
|
-
}
|
|
2145
|
-
if (attempt >= this.persistenceRetryMaxAttempts) throw error;
|
|
2146
|
-
this.trace.event({
|
|
2147
|
-
type: "reliability",
|
|
2148
|
-
operation: "persistence_retry",
|
|
2149
|
-
persistenceOperation,
|
|
2150
|
-
attempt
|
|
2151
|
-
});
|
|
2152
|
-
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2153
|
-
if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
|
|
2154
|
-
delay = Math.min(delay * 2, 1600);
|
|
2155
|
-
}
|
|
2156
|
-
}
|
|
2157
|
-
}
|
|
2158
|
-
/** Deliver buffered history to a newly-registered handler. For an exact
|
|
2159
|
-
* topic this is that topic's ring; for a wildcard subscription every
|
|
2160
|
-
* buffered topic matching the pattern contributes (in buffer insertion
|
|
2161
|
-
* order). Replay deliveries are marked `replayed: true` and are not
|
|
2162
|
-
* counted into trace metrics. */
|
|
2163
|
-
deliverReplay(topic, limit, handler) {
|
|
2164
|
-
if (!this.replayBuffers || limit <= 0) return;
|
|
2165
|
-
const deliver = (buffer2) => {
|
|
2166
|
-
for (const message of buffer2.slice(-limit)) {
|
|
2167
|
-
this.invokeHandlers([handler], (h) => h({ ...message, replayed: true }));
|
|
2168
|
-
}
|
|
2169
|
-
};
|
|
2170
|
-
if (isWildcardTopic(topic)) {
|
|
2171
|
-
for (const [bufferedTopic, buffer2] of this.replayBuffers) {
|
|
2172
|
-
if (topicMatchesPattern(topic, bufferedTopic)) deliver(buffer2);
|
|
2173
|
-
}
|
|
2174
|
-
return;
|
|
2175
|
-
}
|
|
2176
|
-
const buffer = this.replayBuffers.get(topic);
|
|
2177
|
-
if (buffer) deliver(buffer);
|
|
3020
|
+
this.replayManager.record(message);
|
|
2178
3021
|
}
|
|
2179
3022
|
/**
|
|
2180
3023
|
* Propagate a status change to the cluster, trace, and all registered
|
|
@@ -2183,13 +3026,13 @@ var CrossTabDataBus = class {
|
|
|
2183
3026
|
updateStatus(status) {
|
|
2184
3027
|
const previousStatus = this.status;
|
|
2185
3028
|
this.status = status;
|
|
2186
|
-
if (previousStatus !== status) this.trace.event({ type:
|
|
3029
|
+
if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
|
|
2187
3030
|
this.cluster.setStatus(status);
|
|
2188
|
-
if (status ===
|
|
2189
|
-
if (status ===
|
|
3031
|
+
if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
|
|
3032
|
+
if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
|
|
2190
3033
|
for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
|
|
2191
3034
|
}
|
|
2192
|
-
if (status ===
|
|
3035
|
+
if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
|
|
2193
3036
|
const now = this.now();
|
|
2194
3037
|
if (now - this.lastRecoveryAt >= this.recoveryCooldownMs) {
|
|
2195
3038
|
this.lastRecoveryAt = now;
|
|
@@ -2197,37 +3040,67 @@ var CrossTabDataBus = class {
|
|
|
2197
3040
|
if (attempt > this.recoveryMaxAttempts) {
|
|
2198
3041
|
if (!this.recoveryExhausted) {
|
|
2199
3042
|
this.recoveryExhausted = true;
|
|
2200
|
-
this.trace.event({ type:
|
|
3043
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
|
|
2201
3044
|
}
|
|
2202
3045
|
return;
|
|
2203
3046
|
}
|
|
2204
|
-
this.trace.event({ type:
|
|
3047
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
|
|
2205
3048
|
setTimeout(() => {
|
|
2206
3049
|
if (this.stopping || !this.started || this.suspended) return;
|
|
2207
|
-
if (this.status !==
|
|
3050
|
+
if (this.status !== WORKER_STATUS.ERROR) return;
|
|
2208
3051
|
void this.reopenTransport(attempt);
|
|
2209
3052
|
}, this.recoveryCooldownMs);
|
|
2210
3053
|
}
|
|
2211
3054
|
}
|
|
2212
3055
|
this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
|
|
2213
3056
|
}
|
|
2214
|
-
reportError(error) {
|
|
2215
|
-
this.
|
|
2216
|
-
|
|
3057
|
+
reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
|
|
3058
|
+
this.lastFailure = {
|
|
3059
|
+
source,
|
|
3060
|
+
message: error instanceof Error ? error.message : String(error),
|
|
3061
|
+
at: this.now()
|
|
3062
|
+
};
|
|
3063
|
+
if (source === FAILURE_SOURCE.PERSISTENCE) {
|
|
3064
|
+
this.persistenceFailureCount += 1;
|
|
3065
|
+
this.persistenceLastFailureAt = this.lastFailure.at;
|
|
3066
|
+
this.persistenceLastErrorMessage = this.lastFailure.message;
|
|
3067
|
+
}
|
|
3068
|
+
this.trace.event({
|
|
3069
|
+
type: TRACE_EVENT_TYPE.ERROR,
|
|
3070
|
+
source: source === FAILURE_SOURCE.TRANSPORT ? TRACE_ERROR_SOURCE.TRANSPORT : TRACE_ERROR_SOURCE.OPERATION
|
|
3071
|
+
});
|
|
3072
|
+
this.invokeHandlers(this.errorHandlers, (handler) => handler(error), INVOKE_LABEL.ERROR_HANDLER);
|
|
2217
3073
|
}
|
|
3074
|
+
/** Report a persistence failure to the trace and the unified failure ledger,
|
|
3075
|
+
* unless it is a {@link PersistenceRetryCancelledError} cancellation from a
|
|
3076
|
+
* lifecycle transition (teardown should stay quiet). */
|
|
2218
3077
|
reportPersistenceError(error) {
|
|
2219
3078
|
if (error instanceof PersistenceRetryCancelledError) return;
|
|
2220
|
-
this.trace.event({ type:
|
|
2221
|
-
this.reportError(error);
|
|
3079
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.PERSISTENCE_CLEANUP });
|
|
3080
|
+
this.reportError(error, FAILURE_SOURCE.PERSISTENCE);
|
|
2222
3081
|
}
|
|
2223
3082
|
traceSubscription(action, topic) {
|
|
2224
3083
|
this.trace.event({
|
|
2225
|
-
type:
|
|
3084
|
+
type: TRACE_EVENT_TYPE.SUBSCRIPTION,
|
|
2226
3085
|
action,
|
|
2227
3086
|
topic,
|
|
2228
3087
|
activeTopics: this.transportSubscribedTopics.size
|
|
2229
3088
|
});
|
|
2230
3089
|
}
|
|
3090
|
+
/** Emit the coordination trace snapshot from the current cluster state.
|
|
3091
|
+
* Called after a transport opens (start and recovery), when the role and
|
|
3092
|
+
* route picture has settled — the synchronous pre-open snapshot would see no
|
|
3093
|
+
* routes because their writes are still coalesced in the batch writer. */
|
|
3094
|
+
emitCoordinationTrace() {
|
|
3095
|
+
const snapshot = this.cluster.getSnapshot();
|
|
3096
|
+
this.trace.event({
|
|
3097
|
+
type: TRACE_EVENT_TYPE.COORDINATION,
|
|
3098
|
+
coordinated: snapshot.coordinated,
|
|
3099
|
+
activeWorkers: snapshot.workers.filter((worker) => worker.role === WORKER_ROLE.ACTIVE).length,
|
|
3100
|
+
workers: snapshot.workers.map(formatWorkerTrace),
|
|
3101
|
+
routes: snapshot.routes.map(formatRouteTrace)
|
|
3102
|
+
});
|
|
3103
|
+
}
|
|
2231
3104
|
/** Ask the transport to subscribe to a topic (idempotent). */
|
|
2232
3105
|
subscribeTransport(topic) {
|
|
2233
3106
|
if (this.transportSubscribedTopics.has(topic)) return false;
|
|
@@ -2245,17 +3118,17 @@ var CrossTabDataBus = class {
|
|
|
2245
3118
|
* are routed to `reportError` (which surfaces them to error subscribers);
|
|
2246
3119
|
* error-handler failures are logged to the console to avoid infinite
|
|
2247
3120
|
* recursion through reportError itself. */
|
|
2248
|
-
invokeHandlers(handlers, callback, label =
|
|
3121
|
+
invokeHandlers(handlers, callback, label = INVOKE_LABEL.DISPATCH) {
|
|
2249
3122
|
for (const handler of handlers) {
|
|
2250
3123
|
try {
|
|
2251
3124
|
callback(handler);
|
|
2252
3125
|
} catch (error) {
|
|
2253
|
-
if (label ===
|
|
3126
|
+
if (label === INVOKE_LABEL.ERROR_HANDLER) {
|
|
2254
3127
|
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
2255
|
-
console.warn(
|
|
3128
|
+
console.warn(`[${DEFAULT_STORAGE_PREFIX}] error handler threw:`, error);
|
|
2256
3129
|
}
|
|
2257
3130
|
} else {
|
|
2258
|
-
this.reportError(error);
|
|
3131
|
+
this.reportError(error, FAILURE_SOURCE.DISPATCH);
|
|
2259
3132
|
}
|
|
2260
3133
|
}
|
|
2261
3134
|
}
|
|
@@ -2266,10 +3139,11 @@ var CrossTabDataBus = class {
|
|
|
2266
3139
|
*/
|
|
2267
3140
|
suspendTransport() {
|
|
2268
3141
|
if (this.stopping) return;
|
|
3142
|
+
this.lifecycleEpoch += 1;
|
|
2269
3143
|
this.suspended = true;
|
|
2270
3144
|
this.transportReady = false;
|
|
2271
3145
|
this.transportSubscribedTopics.clear();
|
|
2272
|
-
this.updateStatus(
|
|
3146
|
+
this.updateStatus(WORKER_STATUS.DISCONNECTED);
|
|
2273
3147
|
if (this.pendingStop) return;
|
|
2274
3148
|
const pending = this.startPromise ?? Promise.resolve();
|
|
2275
3149
|
const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
|
|
@@ -2303,22 +3177,26 @@ var CrossTabDataBus = class {
|
|
|
2303
3177
|
const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
|
|
2304
3178
|
this.started = true;
|
|
2305
3179
|
this.suspended = false;
|
|
2306
|
-
this.updateStatus(
|
|
3180
|
+
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
3181
|
+
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
2307
3182
|
const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
|
|
2308
|
-
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
|
|
3183
|
+
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
|
|
2309
3184
|
this.startPromise = opening;
|
|
2310
3185
|
void opening.then(
|
|
2311
3186
|
() => {
|
|
3187
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
3188
|
+
if (lifecycleEpoch !== this.lifecycleEpoch) return;
|
|
2312
3189
|
if (traceAttempt !== void 0) {
|
|
2313
|
-
this.trace.event({ type:
|
|
3190
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
|
|
2314
3191
|
this.recoveryAttempt = 0;
|
|
2315
3192
|
this.recoveryExhausted = false;
|
|
2316
3193
|
}
|
|
2317
|
-
if (this.startPromise === opening) this.startPromise = null;
|
|
2318
3194
|
},
|
|
2319
3195
|
() => {
|
|
3196
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
3197
|
+
if (lifecycleEpoch !== this.lifecycleEpoch) return;
|
|
2320
3198
|
if (traceAttempt !== void 0) {
|
|
2321
|
-
this.trace.event({ type:
|
|
3199
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
|
|
2322
3200
|
}
|
|
2323
3201
|
}
|
|
2324
3202
|
);
|
|
@@ -2350,6 +3228,19 @@ var CrossTabDataBus = class {
|
|
|
2350
3228
|
return operation();
|
|
2351
3229
|
}).catch((error) => this.reportError(error));
|
|
2352
3230
|
}
|
|
3231
|
+
/**
|
|
3232
|
+
* Publications started after teardown begins cannot reach any transport.
|
|
3233
|
+
* Surface that as a normal asynchronous API failure instead of letting
|
|
3234
|
+
* runTransport() return silently. Empty publishBatch() calls remain a no-op
|
|
3235
|
+
* and are filtered by the caller before this check.
|
|
3236
|
+
*/
|
|
3237
|
+
rejectPublishDuringStop(operation) {
|
|
3238
|
+
if (!this.stopping) return false;
|
|
3239
|
+
this.reportError(new Error(
|
|
3240
|
+
`CrossTabDataBus is stopping; ${operation}() was not sent. Wait for stop() to resolve, then call start() before publishing again.`
|
|
3241
|
+
));
|
|
3242
|
+
return true;
|
|
3243
|
+
}
|
|
2353
3244
|
/**
|
|
2354
3245
|
* Ensure the DataBus is started, throwing if no initialConfig was provided.
|
|
2355
3246
|
* Called automatically by subscribe/publish/ready when autoStart is true.
|
|
@@ -2397,50 +3288,102 @@ function parseDataBusPublication(value, fallbackTopic) {
|
|
|
2397
3288
|
};
|
|
2398
3289
|
}
|
|
2399
3290
|
|
|
3291
|
+
// src/utils/error-utils.ts
|
|
3292
|
+
function serializeError(error) {
|
|
3293
|
+
if (error instanceof Error) {
|
|
3294
|
+
return {
|
|
3295
|
+
name: error.name,
|
|
3296
|
+
message: error.message,
|
|
3297
|
+
...error.stack ? { stack: error.stack } : {}
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
const context = error === void 0 ? void 0 : error;
|
|
3301
|
+
return {
|
|
3302
|
+
name: "CentrifugeError",
|
|
3303
|
+
message: typeof error === "string" ? error : "Centrifuge worker operation failed.",
|
|
3304
|
+
...context === void 0 || !isStructuredCloneable(context) ? {} : { context }
|
|
3305
|
+
};
|
|
3306
|
+
}
|
|
3307
|
+
function isStructuredCloneable(value) {
|
|
3308
|
+
if (typeof structuredClone !== "function") return true;
|
|
3309
|
+
try {
|
|
3310
|
+
structuredClone(value);
|
|
3311
|
+
return true;
|
|
3312
|
+
} catch {
|
|
3313
|
+
return false;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
function deserializeWorkerError(error) {
|
|
3317
|
+
const result = new Error(error.message);
|
|
3318
|
+
result.name = error.name;
|
|
3319
|
+
if (error.stack) result.stack = error.stack;
|
|
3320
|
+
if (error.context !== void 0) Object.assign(result, { context: error.context });
|
|
3321
|
+
return result;
|
|
3322
|
+
}
|
|
3323
|
+
|
|
2400
3324
|
// src/centrifuge-session.ts
|
|
2401
3325
|
var CentrifugeSession = class {
|
|
2402
3326
|
constructor(sink) {
|
|
2403
3327
|
this.sink = sink;
|
|
2404
3328
|
}
|
|
3329
|
+
sink;
|
|
2405
3330
|
client = null;
|
|
2406
3331
|
subscriptions = /* @__PURE__ */ new Map();
|
|
2407
3332
|
transferable = false;
|
|
3333
|
+
tokenBridge = false;
|
|
3334
|
+
nextRequestId = 1;
|
|
3335
|
+
pendingTokenRequests = /* @__PURE__ */ new Map();
|
|
2408
3336
|
/** Dispatch an incoming Worker message to the matching operation.
|
|
2409
3337
|
* Unknown message types are ignored rather than thrown, so a future protocol
|
|
2410
3338
|
* extension adding a new variant cannot crash an older session. */
|
|
2411
3339
|
handle(message) {
|
|
2412
3340
|
switch (message.type) {
|
|
2413
|
-
case
|
|
2414
|
-
this.initialize(message.url, message.config, message.transferable === true);
|
|
3341
|
+
case CENTRIFUGE_INPUT_TYPE.INIT:
|
|
3342
|
+
this.initialize(message.url, message.config, message.transferable === true, message.tokenBridge === true);
|
|
2415
3343
|
return;
|
|
2416
|
-
case
|
|
3344
|
+
case CENTRIFUGE_INPUT_TYPE.SUBSCRIBE:
|
|
2417
3345
|
this.subscribe(message.topic);
|
|
2418
3346
|
return;
|
|
2419
|
-
case
|
|
3347
|
+
case CENTRIFUGE_INPUT_TYPE.UNSUBSCRIBE:
|
|
2420
3348
|
this.unsubscribe(message.topic);
|
|
2421
3349
|
return;
|
|
2422
|
-
case
|
|
2423
|
-
case
|
|
3350
|
+
case CENTRIFUGE_INPUT_TYPE.PUBLISH:
|
|
3351
|
+
case CENTRIFUGE_INPUT_TYPE.PUBLISH_BIN:
|
|
2424
3352
|
this.publish(message.topic, message.data, message.messageId, message.timestamp);
|
|
2425
3353
|
return;
|
|
2426
|
-
case
|
|
3354
|
+
case CENTRIFUGE_INPUT_TYPE.STOP:
|
|
2427
3355
|
this.stop();
|
|
2428
3356
|
return;
|
|
3357
|
+
case CENTRIFUGE_INPUT_TYPE.TOKEN_RESPONSE:
|
|
3358
|
+
this.resolveToken(message.requestId, message.token);
|
|
3359
|
+
return;
|
|
3360
|
+
case CENTRIFUGE_INPUT_TYPE.TOKEN_ERROR:
|
|
3361
|
+
this.rejectToken(message.requestId, deserializeWorkerError(message.error));
|
|
3362
|
+
return;
|
|
2429
3363
|
default:
|
|
2430
3364
|
return;
|
|
2431
3365
|
}
|
|
2432
3366
|
}
|
|
2433
3367
|
/** Create the Centrifuge client, wire up lifecycle listeners, and connect. */
|
|
2434
|
-
initialize(url, config, transferable) {
|
|
3368
|
+
initialize(url, config, transferable, tokenBridge) {
|
|
2435
3369
|
if (this.client) return;
|
|
2436
3370
|
this.transferable = transferable;
|
|
2437
|
-
|
|
3371
|
+
this.tokenBridge = tokenBridge;
|
|
3372
|
+
const clientOptions = tokenBridge ? {
|
|
3373
|
+
...config,
|
|
3374
|
+
// Route credential fetches back to the main thread, where the
|
|
3375
|
+
// application's credentialProvider lives (config must stay
|
|
3376
|
+
// structured-cloneable, so functions cannot cross the boundary).
|
|
3377
|
+
getToken: () => this.requestToken("token"),
|
|
3378
|
+
getChannelToken: (channel) => this.requestToken("channelToken", channel)
|
|
3379
|
+
} : config;
|
|
3380
|
+
const client = new import_centrifuge.Centrifuge(url, clientOptions);
|
|
2438
3381
|
this.client = client;
|
|
2439
3382
|
client.on("state", (context) => {
|
|
2440
|
-
this.post({ type:
|
|
3383
|
+
this.post({ type: CENTRIFUGE_OUTPUT_TYPE.STATUS, status: normalizeStatus(context.newState) });
|
|
2441
3384
|
});
|
|
2442
|
-
client.on("connected", () => this.post({ type:
|
|
2443
|
-
client.on("disconnected", () => this.post({ type:
|
|
3385
|
+
client.on("connected", () => this.post({ type: CENTRIFUGE_OUTPUT_TYPE.STATUS, status: WORKER_STATUS.CONNECTED }));
|
|
3386
|
+
client.on("disconnected", () => this.post({ type: CENTRIFUGE_OUTPUT_TYPE.STATUS, status: WORKER_STATUS.DISCONNECTED }));
|
|
2444
3387
|
client.on("error", (context) => this.postError(context));
|
|
2445
3388
|
client.on("publication", (context) => {
|
|
2446
3389
|
const topic = context.channel || getPayloadTopic(context.data);
|
|
@@ -2503,25 +3446,56 @@ var CentrifugeSession = class {
|
|
|
2503
3446
|
postPublication(topic, data) {
|
|
2504
3447
|
if (!topic) return;
|
|
2505
3448
|
if (this.transferable && data instanceof ArrayBuffer) {
|
|
2506
|
-
this.post({ type:
|
|
3449
|
+
this.post({ type: CENTRIFUGE_OUTPUT_TYPE.MESSAGE_BIN, topic, data }, [data]);
|
|
2507
3450
|
return;
|
|
2508
3451
|
}
|
|
2509
3452
|
const publication = parseDataBusPublication(data, topic);
|
|
2510
3453
|
if (!publication) return;
|
|
2511
3454
|
this.post({
|
|
2512
|
-
type:
|
|
3455
|
+
type: CENTRIFUGE_OUTPUT_TYPE.MESSAGE,
|
|
2513
3456
|
topic: publication.topic,
|
|
2514
3457
|
data: publication.data,
|
|
2515
|
-
...publication.messageId
|
|
2516
|
-
...publication.timestamp === void 0 ? {} : { timestamp: publication.timestamp }
|
|
3458
|
+
...publicationMetadata(publication.messageId, publication.timestamp)
|
|
2517
3459
|
});
|
|
2518
3460
|
}
|
|
2519
3461
|
/** Disconnect the client and clear all subscriptions. */
|
|
2520
3462
|
stop() {
|
|
3463
|
+
for (const [, pending] of this.pendingTokenRequests) {
|
|
3464
|
+
pending.reject(new Error("Centrifuge session stopped before the credential was resolved."));
|
|
3465
|
+
}
|
|
3466
|
+
this.pendingTokenRequests.clear();
|
|
2521
3467
|
this.client?.disconnect();
|
|
2522
3468
|
this.subscriptions.clear();
|
|
2523
3469
|
this.client = null;
|
|
2524
|
-
this.post({ type:
|
|
3470
|
+
this.post({ type: CENTRIFUGE_OUTPUT_TYPE.STATUS, status: WORKER_STATUS.DISCONNECTED });
|
|
3471
|
+
}
|
|
3472
|
+
/** Issue a credential request to the main thread and await the response.
|
|
3473
|
+
* Used as Centrifuge's `getToken` / `getChannelToken` when token bridging is
|
|
3474
|
+
* enabled; resolved or rejected by a matching TOKEN_RESPONSE / TOKEN_ERROR. */
|
|
3475
|
+
requestToken(kind, channel) {
|
|
3476
|
+
const requestId = this.nextRequestId;
|
|
3477
|
+
this.nextRequestId += 1;
|
|
3478
|
+
return new Promise((resolve, reject) => {
|
|
3479
|
+
this.pendingTokenRequests.set(requestId, { resolve, reject });
|
|
3480
|
+
this.post({
|
|
3481
|
+
type: CENTRIFUGE_OUTPUT_TYPE.TOKEN_REQUEST,
|
|
3482
|
+
requestId,
|
|
3483
|
+
kind,
|
|
3484
|
+
...channel === void 0 ? {} : { channel }
|
|
3485
|
+
});
|
|
3486
|
+
});
|
|
3487
|
+
}
|
|
3488
|
+
resolveToken(requestId, token) {
|
|
3489
|
+
const pending = this.pendingTokenRequests.get(requestId);
|
|
3490
|
+
if (!pending) return;
|
|
3491
|
+
this.pendingTokenRequests.delete(requestId);
|
|
3492
|
+
pending.resolve(token);
|
|
3493
|
+
}
|
|
3494
|
+
rejectToken(requestId, error) {
|
|
3495
|
+
const pending = this.pendingTokenRequests.get(requestId);
|
|
3496
|
+
if (!pending) return;
|
|
3497
|
+
this.pendingTokenRequests.delete(requestId);
|
|
3498
|
+
pending.reject(error);
|
|
2525
3499
|
}
|
|
2526
3500
|
/** Forward a message to the sink (the transport layer). */
|
|
2527
3501
|
post(message, transfer) {
|
|
@@ -2533,7 +3507,7 @@ var CentrifugeSession = class {
|
|
|
2533
3507
|
* Fatal errors are distinguished by the client eventually emitting
|
|
2534
3508
|
* `disconnected` without a subsequent `connected`. */
|
|
2535
3509
|
postError(error) {
|
|
2536
|
-
this.post({ type:
|
|
3510
|
+
this.post({ type: CENTRIFUGE_OUTPUT_TYPE.ERROR, error: serializeError(error) });
|
|
2537
3511
|
}
|
|
2538
3512
|
};
|
|
2539
3513
|
function getPayloadTopic(data) {
|
|
@@ -2544,33 +3518,19 @@ function getPayloadTopic(data) {
|
|
|
2544
3518
|
const topic = nested ?? payload.channel;
|
|
2545
3519
|
return typeof topic === "string" ? topic : "";
|
|
2546
3520
|
}
|
|
2547
|
-
var LIVE_STATES = /* @__PURE__ */ new Set([
|
|
3521
|
+
var LIVE_STATES = /* @__PURE__ */ new Set([WORKER_STATUS.CONNECTING, WORKER_STATUS.CONNECTED]);
|
|
2548
3522
|
function normalizeStatus(status) {
|
|
2549
3523
|
return LIVE_STATES.has(status) ? status : "disconnected";
|
|
2550
3524
|
}
|
|
2551
|
-
function serializeError(error) {
|
|
2552
|
-
if (error instanceof Error) {
|
|
2553
|
-
return {
|
|
2554
|
-
name: error.name,
|
|
2555
|
-
message: error.message,
|
|
2556
|
-
...error.stack ? { stack: error.stack } : {}
|
|
2557
|
-
};
|
|
2558
|
-
}
|
|
2559
|
-
return {
|
|
2560
|
-
name: "CentrifugeError",
|
|
2561
|
-
message: typeof error === "string" ? error : "Centrifuge worker operation failed.",
|
|
2562
|
-
...error === void 0 ? {} : { context: error }
|
|
2563
|
-
};
|
|
2564
|
-
}
|
|
2565
3525
|
|
|
2566
3526
|
// src/worker-mode.ts
|
|
2567
3527
|
function selectWorkerBackend(mode, availability = {}) {
|
|
2568
3528
|
const hasDedicated = availability.worker ?? typeof Worker !== "undefined";
|
|
2569
3529
|
const hasShared = availability.sharedWorker ?? typeof SharedWorker !== "undefined";
|
|
2570
|
-
if (mode ===
|
|
2571
|
-
return hasShared ?
|
|
3530
|
+
if (mode === WORKER_MODE.SHARED || mode === WORKER_MODE.AUTO) {
|
|
3531
|
+
return hasShared ? WORKER_BACKEND.SHARED : hasDedicated ? WORKER_BACKEND.DEDICATED : WORKER_BACKEND.LOCAL;
|
|
2572
3532
|
}
|
|
2573
|
-
return hasDedicated ?
|
|
3533
|
+
return hasDedicated ? WORKER_BACKEND.DEDICATED : hasShared ? WORKER_BACKEND.SHARED : WORKER_BACKEND.LOCAL;
|
|
2574
3534
|
}
|
|
2575
3535
|
|
|
2576
3536
|
// src/centrifuge-protocol.ts
|
|
@@ -2587,6 +3547,7 @@ var CentrifugeWorkerTransport = class {
|
|
|
2587
3547
|
heartbeatIntervalMs;
|
|
2588
3548
|
workerFactory;
|
|
2589
3549
|
sharedWorkerFactory;
|
|
3550
|
+
credentialProvider;
|
|
2590
3551
|
backend = null;
|
|
2591
3552
|
worker = null;
|
|
2592
3553
|
sharedWorker = null;
|
|
@@ -2604,12 +3565,13 @@ var CentrifugeWorkerTransport = class {
|
|
|
2604
3565
|
return this.backend ?? "uninitialized";
|
|
2605
3566
|
}
|
|
2606
3567
|
constructor(options = {}) {
|
|
2607
|
-
this.workerMode = options.workerMode ??
|
|
3568
|
+
this.workerMode = options.workerMode ?? WORKER_MODE.DEDICATED;
|
|
2608
3569
|
this.transferable = options.transferable ?? false;
|
|
2609
3570
|
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS2;
|
|
2610
3571
|
assertHeartbeatInterval(this.heartbeatIntervalMs);
|
|
2611
3572
|
this.workerFactory = options.workerFactory;
|
|
2612
3573
|
this.sharedWorkerFactory = options.sharedWorkerFactory;
|
|
3574
|
+
this.credentialProvider = options.credentialProvider;
|
|
2613
3575
|
}
|
|
2614
3576
|
/**
|
|
2615
3577
|
* Start the transport: select a backend, initialise the Worker (or local
|
|
@@ -2620,29 +3582,29 @@ var CentrifugeWorkerTransport = class {
|
|
|
2620
3582
|
assertStructuredCloneable(config.options ?? {});
|
|
2621
3583
|
this.handlers = handlers;
|
|
2622
3584
|
const backend = selectWorkerBackend(this.workerMode, {
|
|
2623
|
-
worker: this.workerFactory !== void 0,
|
|
2624
|
-
sharedWorker: this.sharedWorkerFactory !== void 0
|
|
3585
|
+
worker: this.workerFactory !== void 0 || typeof Worker !== "undefined",
|
|
3586
|
+
sharedWorker: this.sharedWorkerFactory !== void 0 || typeof SharedWorker !== "undefined"
|
|
2625
3587
|
});
|
|
2626
3588
|
const input = this.buildInitInput(config);
|
|
2627
|
-
if (backend ===
|
|
3589
|
+
if (backend === WORKER_BACKEND.SHARED) {
|
|
2628
3590
|
this.startSharedWorker(input);
|
|
2629
|
-
this.backend =
|
|
3591
|
+
this.backend = WORKER_BACKEND.SHARED;
|
|
2630
3592
|
return;
|
|
2631
3593
|
}
|
|
2632
|
-
if (backend ===
|
|
3594
|
+
if (backend === WORKER_BACKEND.DEDICATED) {
|
|
2633
3595
|
this.startDedicatedWorker(input);
|
|
2634
|
-
this.backend =
|
|
3596
|
+
this.backend = WORKER_BACKEND.DEDICATED;
|
|
2635
3597
|
return;
|
|
2636
3598
|
}
|
|
2637
3599
|
this.localSession = new CentrifugeSession({ post: this.handleSessionOutput });
|
|
2638
3600
|
this.localSession.handle(input);
|
|
2639
|
-
this.backend =
|
|
3601
|
+
this.backend = WORKER_BACKEND.LOCAL;
|
|
2640
3602
|
}
|
|
2641
3603
|
subscribe(topic) {
|
|
2642
|
-
this.post({ type:
|
|
3604
|
+
this.post({ type: CENTRIFUGE_INPUT_TYPE.SUBSCRIBE, topic });
|
|
2643
3605
|
}
|
|
2644
3606
|
unsubscribe(topic) {
|
|
2645
|
-
this.post({ type:
|
|
3607
|
+
this.post({ type: CENTRIFUGE_INPUT_TYPE.UNSUBSCRIBE, topic });
|
|
2646
3608
|
}
|
|
2647
3609
|
/**
|
|
2648
3610
|
* Publish data to `topic`. Binary data (ArrayBuffer) is sent via Transferable
|
|
@@ -2650,10 +3612,10 @@ var CentrifugeWorkerTransport = class {
|
|
|
2650
3612
|
*/
|
|
2651
3613
|
publish(topic, data, options) {
|
|
2652
3614
|
if (this.transferable && data instanceof ArrayBuffer) {
|
|
2653
|
-
this.post({ type:
|
|
3615
|
+
this.post({ type: CENTRIFUGE_INPUT_TYPE.PUBLISH_BIN, topic, data, ...publicationMetadata(options?.messageId, options?.timestamp) }, [data]);
|
|
2654
3616
|
return;
|
|
2655
3617
|
}
|
|
2656
|
-
this.post({ type:
|
|
3618
|
+
this.post({ type: CENTRIFUGE_INPUT_TYPE.PUBLISH, topic, data, ...publicationMetadata(options?.messageId, options?.timestamp) });
|
|
2657
3619
|
}
|
|
2658
3620
|
/**
|
|
2659
3621
|
* Gracefully stop the transport: send STOP, clean up event listeners, and
|
|
@@ -2662,7 +3624,7 @@ var CentrifugeWorkerTransport = class {
|
|
|
2662
3624
|
stop() {
|
|
2663
3625
|
if (!this.backend) return;
|
|
2664
3626
|
this.generation++;
|
|
2665
|
-
this.post({ type:
|
|
3627
|
+
this.post({ type: CENTRIFUGE_INPUT_TYPE.STOP });
|
|
2666
3628
|
this.clearHeartbeat();
|
|
2667
3629
|
if (this.worker) {
|
|
2668
3630
|
this.worker.removeEventListener("message", this.handleMessage);
|
|
@@ -2681,10 +3643,11 @@ var CentrifugeWorkerTransport = class {
|
|
|
2681
3643
|
* default-resolution logic kicks in for the common case. */
|
|
2682
3644
|
buildInitInput(config) {
|
|
2683
3645
|
return {
|
|
2684
|
-
type:
|
|
3646
|
+
type: CENTRIFUGE_INPUT_TYPE.INIT,
|
|
2685
3647
|
url: config.url,
|
|
2686
3648
|
config: config.options ?? {},
|
|
2687
3649
|
...this.transferable ? { transferable: true } : {},
|
|
3650
|
+
...this.credentialProvider ? { tokenBridge: true } : {},
|
|
2688
3651
|
...this.heartbeatIntervalMs !== DEFAULT_HEARTBEAT_INTERVAL_MS2 ? { heartbeatIntervalMs: this.heartbeatIntervalMs } : {}
|
|
2689
3652
|
};
|
|
2690
3653
|
}
|
|
@@ -2723,10 +3686,34 @@ var CentrifugeWorkerTransport = class {
|
|
|
2723
3686
|
* Shared by the Worker message listener, the SharedWorker port listener,
|
|
2724
3687
|
* and the local-session sink — all three feed into this single dispatcher. */
|
|
2725
3688
|
handleOutput(message) {
|
|
2726
|
-
if (message.type ===
|
|
2727
|
-
if (message.type ===
|
|
2728
|
-
if (message.type ===
|
|
2729
|
-
if (message.type ===
|
|
3689
|
+
if (message.type === CENTRIFUGE_OUTPUT_TYPE.STATUS) this.handlers?.onStatus(message.status);
|
|
3690
|
+
if (message.type === CENTRIFUGE_OUTPUT_TYPE.MESSAGE) this.handlers?.onMessage({ topic: message.topic, data: message.data, ...publicationMetadata(message.messageId, message.timestamp) });
|
|
3691
|
+
if (message.type === CENTRIFUGE_OUTPUT_TYPE.MESSAGE_BIN) this.handlers?.onMessage({ topic: message.topic, data: message.data, ...publicationMetadata(message.messageId, message.timestamp) });
|
|
3692
|
+
if (message.type === CENTRIFUGE_OUTPUT_TYPE.ERROR) this.handlers?.onError(deserializeWorkerError(message.error));
|
|
3693
|
+
if (message.type === CENTRIFUGE_OUTPUT_TYPE.TOKEN_REQUEST) this.resolveTokenRequest(message.requestId, message.kind, message.channel);
|
|
3694
|
+
}
|
|
3695
|
+
/** Resolve a Worker credential request from the main thread: call the
|
|
3696
|
+
* credentialProvider, then post the fresh token (or a serialized failure)
|
|
3697
|
+
* back to the session that issued the request. */
|
|
3698
|
+
resolveTokenRequest(requestId, kind, channel) {
|
|
3699
|
+
const provider = this.credentialProvider;
|
|
3700
|
+
const value = kind === "channelToken" && provider?.getChannelToken ? provider.getChannelToken(channel ?? "") : provider?.getToken?.();
|
|
3701
|
+
Promise.resolve(value).then(
|
|
3702
|
+
(token) => {
|
|
3703
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
3704
|
+
this.post({
|
|
3705
|
+
type: CENTRIFUGE_INPUT_TYPE.TOKEN_ERROR,
|
|
3706
|
+
requestId,
|
|
3707
|
+
error: serializeError(new Error(`The credential provider returned no ${kind}.`))
|
|
3708
|
+
});
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
this.post({ type: CENTRIFUGE_INPUT_TYPE.TOKEN_RESPONSE, requestId, token });
|
|
3712
|
+
},
|
|
3713
|
+
(error) => {
|
|
3714
|
+
this.post({ type: CENTRIFUGE_INPUT_TYPE.TOKEN_ERROR, requestId, error: serializeError(error) });
|
|
3715
|
+
}
|
|
3716
|
+
);
|
|
2730
3717
|
}
|
|
2731
3718
|
/** Handle a Worker-level failure (crash, message decode error). Discards the
|
|
2732
3719
|
* dead backend so a later start()/reopen can rebuild from scratch, and
|
|
@@ -2742,7 +3729,7 @@ var CentrifugeWorkerTransport = class {
|
|
|
2742
3729
|
this.clearHeartbeat();
|
|
2743
3730
|
this.resetBackend();
|
|
2744
3731
|
this.handlers?.onError(new Error(message));
|
|
2745
|
-
this.handlers?.onStatus(
|
|
3732
|
+
this.handlers?.onStatus(WORKER_STATUS.ERROR);
|
|
2746
3733
|
}
|
|
2747
3734
|
/** Remove every listener attached to the current SharedWorker and its port. */
|
|
2748
3735
|
detachSharedWorkerListeners() {
|
|
@@ -2755,7 +3742,7 @@ var CentrifugeWorkerTransport = class {
|
|
|
2755
3742
|
if (this.heartbeatHandle !== null) return;
|
|
2756
3743
|
if (this.heartbeatIntervalMs === Infinity) return;
|
|
2757
3744
|
this.heartbeatHandle = setInterval(() => {
|
|
2758
|
-
this.post({ type:
|
|
3745
|
+
this.post({ type: CENTRIFUGE_INPUT_TYPE.PING });
|
|
2759
3746
|
}, this.heartbeatIntervalMs);
|
|
2760
3747
|
}
|
|
2761
3748
|
clearHeartbeat() {
|
|
@@ -2806,6 +3793,7 @@ function createCentrifugeDataBus(options) {
|
|
|
2806
3793
|
const {
|
|
2807
3794
|
clusterKey,
|
|
2808
3795
|
connection,
|
|
3796
|
+
credentialProvider,
|
|
2809
3797
|
heartbeatIntervalMs,
|
|
2810
3798
|
sharedWorkerFactory,
|
|
2811
3799
|
transferable,
|
|
@@ -2823,7 +3811,8 @@ function createCentrifugeDataBus(options) {
|
|
|
2823
3811
|
...sharedWorkerFactory ? { sharedWorkerFactory } : {},
|
|
2824
3812
|
...transferable === void 0 ? {} : { transferable },
|
|
2825
3813
|
...workerMode ? { workerMode } : {},
|
|
2826
|
-
...heartbeatIntervalMs === void 0 ? {} : { heartbeatIntervalMs }
|
|
3814
|
+
...heartbeatIntervalMs === void 0 ? {} : { heartbeatIntervalMs },
|
|
3815
|
+
...credentialProvider ? { credentialProvider } : {}
|
|
2827
3816
|
})
|
|
2828
3817
|
});
|
|
2829
3818
|
}
|
|
@@ -2840,7 +3829,7 @@ function createDefaultWorker() {
|
|
|
2840
3829
|
);
|
|
2841
3830
|
}
|
|
2842
3831
|
return new Worker(workerUrl, {
|
|
2843
|
-
name:
|
|
3832
|
+
name: DEFAULT_STORAGE_PREFIX,
|
|
2844
3833
|
type: "module"
|
|
2845
3834
|
});
|
|
2846
3835
|
}
|
|
@@ -2857,43 +3846,12 @@ function createDefaultSharedWorker() {
|
|
|
2857
3846
|
);
|
|
2858
3847
|
}
|
|
2859
3848
|
return new SharedWorker(workerUrl, {
|
|
2860
|
-
name:
|
|
3849
|
+
name: `${DEFAULT_STORAGE_PREFIX}-shared`,
|
|
2861
3850
|
type: "module"
|
|
2862
3851
|
});
|
|
2863
3852
|
}
|
|
2864
|
-
function assertHeartbeatInterval(value) {
|
|
2865
|
-
if (value === Infinity) return;
|
|
2866
|
-
if (typeof value === "number" && Number.isFinite(value) && value > 0) return;
|
|
2867
|
-
throw new TypeError(
|
|
2868
|
-
`Centrifuge heartbeatIntervalMs must be a positive number or Infinity, got ${String(value)}.`
|
|
2869
|
-
);
|
|
2870
|
-
}
|
|
2871
|
-
function assertStructuredCloneable(value) {
|
|
2872
|
-
if (typeof structuredClone !== "function") return;
|
|
2873
|
-
try {
|
|
2874
|
-
structuredClone(value);
|
|
2875
|
-
} catch (error) {
|
|
2876
|
-
throw new TypeError(
|
|
2877
|
-
"Centrifuge Worker configuration and published data must be structured-cloneable.",
|
|
2878
|
-
{ cause: error }
|
|
2879
|
-
);
|
|
2880
|
-
}
|
|
2881
|
-
}
|
|
2882
3853
|
function postToPortLike(target, message, transfer) {
|
|
2883
3854
|
if (transfer) target.postMessage(message, transfer);
|
|
2884
3855
|
else target.postMessage(message);
|
|
2885
3856
|
}
|
|
2886
|
-
function deserializeWorkerError(error) {
|
|
2887
|
-
const result = new Error(error.message);
|
|
2888
|
-
result.name = error.name;
|
|
2889
|
-
if (error.stack) result.stack = error.stack;
|
|
2890
|
-
if (error.context !== void 0) Object.assign(result, { context: error.context });
|
|
2891
|
-
return result;
|
|
2892
|
-
}
|
|
2893
|
-
function publicationMetadata2(metadata) {
|
|
2894
|
-
return {
|
|
2895
|
-
...metadata?.messageId === void 0 ? {} : { messageId: metadata.messageId },
|
|
2896
|
-
...metadata?.timestamp === void 0 ? {} : { timestamp: metadata.timestamp }
|
|
2897
|
-
};
|
|
2898
|
-
}
|
|
2899
3857
|
//# sourceMappingURL=centrifuge.cjs.map
|