nexabase-console 2.1.2 → 2.1.3
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/dist/app/NexaApp.d.ts +3 -0
- package/dist/app/NexaApp.js +38 -11
- package/dist/firestore/CollectionReference.d.ts +2 -2
- package/dist/firestore/CollectionReference.js +41 -2
- package/dist/firestore/DocumentReference.d.ts +1 -1
- package/dist/firestore/DocumentReference.js +41 -11
- package/dist/firestore/FieldPath.d.ts +2 -1
- package/dist/firestore/FieldPath.js +31 -4
- package/dist/firestore/FieldValue.d.ts +8 -4
- package/dist/firestore/FieldValue.js +59 -7
- package/dist/firestore/Firestore.js +16 -4
- package/dist/firestore/GeoPoint.d.ts +1 -0
- package/dist/firestore/GeoPoint.js +13 -6
- package/dist/firestore/Query.d.ts +5 -0
- package/dist/firestore/Query.js +190 -56
- package/dist/firestore/QueryUtils.d.ts +19 -2
- package/dist/firestore/QueryUtils.js +296 -58
- package/dist/firestore/Snapshot.js +62 -17
- package/dist/firestore/SnapshotManager.js +27 -12
- package/dist/firestore/Timestamp.d.ts +1 -0
- package/dist/firestore/Timestamp.js +28 -2
- package/dist/firestore/batch.js +89 -36
- package/dist/firestore/pathValidation.d.ts +17 -0
- package/dist/firestore/pathValidation.js +61 -0
- package/dist/firestore/queryConstraints.d.ts +4 -4
- package/dist/firestore/queryConstraints.js +69 -8
- package/dist/firestore/transaction.js +72 -18
- package/dist/firestore/writes.d.ts +1 -1
- package/dist/firestore/writes.js +32 -14
- package/dist/transport/WebSocketClient.d.ts +341 -9
- package/dist/transport/WebSocketClient.js +1895 -131
- package/dist/transport/WebSocketClient.test.d.ts +1 -0
- package/dist/transport/WebSocketClient.test.js +454 -0
- package/dist/types/index.d.ts +1 -0
- package/package.json +1 -1
|
@@ -1,47 +1,940 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.WebSocketClient = void 0;
|
|
3
|
+
exports.WebSocketClient = exports.NexaWebSocketError = void 0;
|
|
4
4
|
const socket_io_client_1 = require("socket.io-client");
|
|
5
|
+
/**
|
|
6
|
+
* Production error class preserving structured metadata across tab boundaries.
|
|
7
|
+
*/
|
|
8
|
+
class NexaWebSocketError extends Error {
|
|
9
|
+
constructor(options) {
|
|
10
|
+
super(options.message);
|
|
11
|
+
this.name = 'NexaWebSocketError';
|
|
12
|
+
this.code = options.code || 'unavailable';
|
|
13
|
+
this.retryable = options.retryable ?? false;
|
|
14
|
+
this.source = options.source || 'client';
|
|
15
|
+
this.isAmbiguous = options.isAmbiguous ?? false;
|
|
16
|
+
this.details = options.details;
|
|
17
|
+
if (options.cause && typeof this.cause === 'undefined') {
|
|
18
|
+
this.cause = options.cause;
|
|
19
|
+
}
|
|
20
|
+
if (typeof Error.captureStackTrace === 'function') {
|
|
21
|
+
Error.captureStackTrace(this, NexaWebSocketError);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
toJSON() {
|
|
25
|
+
return {
|
|
26
|
+
name: this.name,
|
|
27
|
+
message: this.message,
|
|
28
|
+
code: this.code,
|
|
29
|
+
retryable: this.retryable,
|
|
30
|
+
source: this.source,
|
|
31
|
+
isAmbiguous: this.isAmbiguous,
|
|
32
|
+
details: this.details,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
static from(err, defaultSource = 'client') {
|
|
36
|
+
if (err instanceof NexaWebSocketError) {
|
|
37
|
+
return err;
|
|
38
|
+
}
|
|
39
|
+
if (err && typeof err === 'object') {
|
|
40
|
+
const e = err;
|
|
41
|
+
if (typeof e.message === 'string' && typeof e.code === 'string') {
|
|
42
|
+
return new NexaWebSocketError({
|
|
43
|
+
message: e.message,
|
|
44
|
+
code: e.code,
|
|
45
|
+
retryable: Boolean(e.retryable),
|
|
46
|
+
source: e.source || defaultSource,
|
|
47
|
+
isAmbiguous: Boolean(e.isAmbiguous),
|
|
48
|
+
details: e.details,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (e instanceof Error) {
|
|
52
|
+
return new NexaWebSocketError({
|
|
53
|
+
message: e.message,
|
|
54
|
+
code: 'unavailable',
|
|
55
|
+
source: defaultSource,
|
|
56
|
+
cause: e,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return new NexaWebSocketError({
|
|
61
|
+
message: typeof err === 'string' ? err : 'Unknown error',
|
|
62
|
+
code: 'unavailable',
|
|
63
|
+
source: defaultSource,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
exports.NexaWebSocketError = NexaWebSocketError;
|
|
68
|
+
/**
|
|
69
|
+
* Production-hardened WebSocketClient with distributed coordination,
|
|
70
|
+
* term-fenced follower forwarding, BFCache/background resilience, and strict invariants.
|
|
71
|
+
*/
|
|
5
72
|
class WebSocketClient {
|
|
6
|
-
constructor(projectId, serverUrl) {
|
|
7
|
-
this.
|
|
8
|
-
this.
|
|
73
|
+
constructor(projectId, serverUrl, options = {}) {
|
|
74
|
+
this._socket = null;
|
|
75
|
+
this._isConnected = false;
|
|
76
|
+
this._lastReportedConnected = false;
|
|
77
|
+
this._state = 'idle';
|
|
78
|
+
this._isSuspended = false;
|
|
79
|
+
// Leadership Term Fencing
|
|
80
|
+
this._electionId = null;
|
|
81
|
+
this._currentLeaderId = null;
|
|
82
|
+
this._currentElectionId = null;
|
|
83
|
+
this._currentTermSeq = 0;
|
|
84
|
+
this._isLeaderSocketConnected = false;
|
|
85
|
+
// Local Tab State
|
|
9
86
|
this.listeners = new Map();
|
|
10
|
-
this.
|
|
11
|
-
|
|
87
|
+
this.localSubscribedPaths = new Map(); // cleanPath -> refCount
|
|
88
|
+
this.activeLocalSendsCount = 0;
|
|
89
|
+
// Cross-Tab Synchronization
|
|
12
90
|
this.broadcastChannel = null;
|
|
13
|
-
this.
|
|
14
|
-
this.
|
|
15
|
-
|
|
16
|
-
this.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
91
|
+
this.lockAbortController = null;
|
|
92
|
+
this.resolveLeaderLock = null;
|
|
93
|
+
// Leader State
|
|
94
|
+
this.followerSubscriptions = new Map(); // cleanPath -> Set<tabId>
|
|
95
|
+
this.followerSubscriptionsByTab = new Map(); // tabId -> Set<cleanPath>
|
|
96
|
+
this.followerGlobalNeeds = new Map();
|
|
97
|
+
// Bounded deduplication cache for follower requests executed by the leader
|
|
98
|
+
this.processedFollowerRequests = new Map();
|
|
99
|
+
// Active Executions & Waiters
|
|
100
|
+
this.pendingSendRequests = new Map();
|
|
101
|
+
this.activeLeaderSends = new Map();
|
|
102
|
+
this.readinessWaiters = new Set();
|
|
103
|
+
// Timers
|
|
104
|
+
this.heartbeatTimer = null;
|
|
105
|
+
this.pruneTimer = null;
|
|
106
|
+
this.leaseTimer = null;
|
|
107
|
+
this.reconnectTimer = null;
|
|
108
|
+
this.pageLifecycleCleanup = null;
|
|
109
|
+
if (typeof projectId !== 'string' || !projectId.trim()) {
|
|
110
|
+
throw new NexaWebSocketError({
|
|
111
|
+
message: 'Invalid projectId: must be a non-empty string',
|
|
112
|
+
code: 'invalid-argument',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (typeof serverUrl !== 'string' || !serverUrl.trim()) {
|
|
116
|
+
throw new NexaWebSocketError({
|
|
117
|
+
message: 'Invalid serverUrl: must be a non-empty string',
|
|
118
|
+
code: 'invalid-argument',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
this._projectId = projectId.trim();
|
|
122
|
+
this._serverUrl = serverUrl.trim();
|
|
123
|
+
this._options = options;
|
|
124
|
+
this._authPayload = options.authPayload;
|
|
125
|
+
this._tabId = WebSocketClient.generateId();
|
|
126
|
+
this.followerTtlMs = options.followerTtlMs || WebSocketClient.DEFAULT_FOLLOWER_TTL;
|
|
127
|
+
this.heartbeatIntervalMs =
|
|
128
|
+
options.heartbeatIntervalMs || WebSocketClient.DEFAULT_HEARTBEAT_INTERVAL;
|
|
129
|
+
this.pruneIntervalMs = options.pruneIntervalMs || WebSocketClient.DEFAULT_PRUNE_INTERVAL;
|
|
130
|
+
this.leaderLeaseIntervalMs =
|
|
131
|
+
options.leaderLeaseIntervalMs || WebSocketClient.DEFAULT_LEADER_LEASE_INTERVAL;
|
|
132
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs || WebSocketClient.DEFAULT_TIMEOUT_MS;
|
|
133
|
+
const rawScope = options.sessionScope;
|
|
134
|
+
if (typeof rawScope === 'string' && rawScope.trim().length > 0) {
|
|
135
|
+
this._sessionScope = WebSocketClient.hashScope(rawScope.trim());
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
this._sessionScope = null;
|
|
139
|
+
}
|
|
140
|
+
this._coordinationKey = WebSocketClient.deriveCoordinationKey(this._projectId, this._serverUrl, this._sessionScope);
|
|
141
|
+
// Determine initial coordination mode
|
|
142
|
+
const hasWindow = typeof window !== 'undefined';
|
|
143
|
+
const hasBC = hasWindow &&
|
|
144
|
+
(typeof options.broadcastChannelFactory === 'function' || 'BroadcastChannel' in window);
|
|
145
|
+
const hasLocks = hasWindow &&
|
|
146
|
+
(options.locks !== undefined ||
|
|
147
|
+
(typeof navigator !== 'undefined' &&
|
|
148
|
+
navigator.locks &&
|
|
149
|
+
typeof navigator.locks.request === 'function'));
|
|
150
|
+
if (options.coordinationMode === 'independent' || !hasBC || !hasLocks) {
|
|
151
|
+
this._coordinationMode = 'independent';
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
this._coordinationMode = options.coordinationMode || 'shared';
|
|
155
|
+
}
|
|
156
|
+
if (this._coordinationMode === 'shared' && hasWindow) {
|
|
157
|
+
const channelName = `nexabase_bc_${this._coordinationKey}`;
|
|
158
|
+
try {
|
|
159
|
+
if (typeof options.broadcastChannelFactory === 'function') {
|
|
160
|
+
this.broadcastChannel = options.broadcastChannelFactory(channelName);
|
|
161
|
+
}
|
|
162
|
+
else if ('BroadcastChannel' in window) {
|
|
163
|
+
this.broadcastChannel = new BroadcastChannel(channelName);
|
|
164
|
+
}
|
|
165
|
+
if (this.broadcastChannel) {
|
|
166
|
+
this.broadcastChannel.onmessage = this.handleBroadcastMessage.bind(this);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
this.broadcastChannel = null;
|
|
171
|
+
this._coordinationMode = 'independent';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
this.setupPageLifecycleListeners();
|
|
175
|
+
}
|
|
176
|
+
// Public Getters
|
|
177
|
+
get projectId() {
|
|
178
|
+
return this._projectId;
|
|
179
|
+
}
|
|
180
|
+
get serverUrl() {
|
|
181
|
+
return this._serverUrl;
|
|
182
|
+
}
|
|
183
|
+
get tabId() {
|
|
184
|
+
return this._tabId;
|
|
185
|
+
}
|
|
186
|
+
get isConnected() {
|
|
187
|
+
return this._isConnected;
|
|
188
|
+
}
|
|
189
|
+
get isLeader() {
|
|
190
|
+
return this._state === 'leader';
|
|
191
|
+
}
|
|
192
|
+
get state() {
|
|
193
|
+
return this._state;
|
|
194
|
+
}
|
|
195
|
+
get electionId() {
|
|
196
|
+
return this._electionId;
|
|
197
|
+
}
|
|
198
|
+
get currentLeaderId() {
|
|
199
|
+
return this._currentLeaderId;
|
|
200
|
+
}
|
|
201
|
+
get currentElectionId() {
|
|
202
|
+
return this._currentElectionId;
|
|
203
|
+
}
|
|
204
|
+
get isIndependent() {
|
|
205
|
+
return this._coordinationMode === 'independent';
|
|
206
|
+
}
|
|
207
|
+
get coordinationMode() {
|
|
208
|
+
return this._coordinationMode;
|
|
209
|
+
}
|
|
210
|
+
get isSuspended() {
|
|
211
|
+
return this._isSuspended;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Unique ID generation with Web Crypto fallback.
|
|
215
|
+
*/
|
|
216
|
+
static generateId() {
|
|
217
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
218
|
+
try {
|
|
219
|
+
return crypto.randomUUID();
|
|
220
|
+
}
|
|
221
|
+
catch { }
|
|
222
|
+
}
|
|
223
|
+
return `id_${Math.random().toString(36).substring(2, 9)}_${Date.now().toString(36)}`;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Stable deterministic hashing function.
|
|
227
|
+
*/
|
|
228
|
+
static hashScope(scope) {
|
|
229
|
+
let h1 = 0xdeadbeef;
|
|
230
|
+
let h2 = 0x41c6ce57;
|
|
231
|
+
for (let i = 0; i < scope.length; i++) {
|
|
232
|
+
const ch = scope.charCodeAt(i);
|
|
233
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
234
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
235
|
+
}
|
|
236
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
237
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
238
|
+
const p1 = (h1 >>> 0).toString(16).padStart(8, '0');
|
|
239
|
+
const p2 = (h2 >>> 0).toString(16).padStart(8, '0');
|
|
240
|
+
return `${p1}${p2}`;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Normalizes server URL and creates a unique coordination key.
|
|
244
|
+
*/
|
|
245
|
+
static deriveCoordinationKey(projectId, serverUrl, sessionScope) {
|
|
246
|
+
let normalizedUrl = serverUrl.trim().toLowerCase();
|
|
247
|
+
try {
|
|
248
|
+
if (typeof URL !== 'undefined') {
|
|
249
|
+
const parsed = new URL(normalizedUrl);
|
|
250
|
+
normalizedUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, '')}`;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch { }
|
|
254
|
+
const raw = `v2_${projectId.trim()}_${normalizedUrl}_${sessionScope || 'anon'}`;
|
|
255
|
+
return WebSocketClient.hashScope(raw);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Sanitizes, segments, and bounds path strings.
|
|
259
|
+
*/
|
|
260
|
+
static cleanAndValidatePath(path) {
|
|
261
|
+
if (typeof path !== 'string')
|
|
262
|
+
return null;
|
|
263
|
+
const trimmed = path.trim();
|
|
264
|
+
if (!trimmed)
|
|
265
|
+
return null;
|
|
266
|
+
// Reject control characters
|
|
267
|
+
// eslint-disable-next-line no-control-regex
|
|
268
|
+
if (/[\x00-\x1F\x7F]/.test(trimmed))
|
|
269
|
+
return null;
|
|
270
|
+
const stripped = trimmed.replace(/^\/+|\/+$/g, '');
|
|
271
|
+
if (!stripped)
|
|
272
|
+
return null;
|
|
273
|
+
// Reject empty path segments
|
|
274
|
+
if (stripped.includes('//'))
|
|
275
|
+
return null;
|
|
276
|
+
// Check segment and total length limits
|
|
277
|
+
if (stripped.length > 2048)
|
|
278
|
+
return null;
|
|
279
|
+
const segments = stripped.split('/');
|
|
280
|
+
for (const segment of segments) {
|
|
281
|
+
if (!segment || segment.length > 500)
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
return stripped;
|
|
285
|
+
}
|
|
286
|
+
static validatePathOrThrow(path) {
|
|
287
|
+
const clean = WebSocketClient.cleanAndValidatePath(path);
|
|
288
|
+
if (!clean) {
|
|
289
|
+
throw new NexaWebSocketError({
|
|
290
|
+
message: `Invalid path: "${String(path)}"`,
|
|
291
|
+
code: 'invalid-argument',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return clean;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Segment-aware path matcher: checks exact match or segment hierarchy.
|
|
298
|
+
*/
|
|
299
|
+
static isPathMatch(eventPath, subPath) {
|
|
300
|
+
if (eventPath === subPath)
|
|
301
|
+
return true;
|
|
302
|
+
if (eventPath.startsWith(subPath + '/'))
|
|
303
|
+
return true;
|
|
304
|
+
if (subPath.startsWith(eventPath + '/'))
|
|
305
|
+
return true;
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Tri-state path extraction distinguishing: none vs valid vs invalid.
|
|
310
|
+
*/
|
|
311
|
+
static extractPayloadPathTriState(payload) {
|
|
312
|
+
if (!payload || typeof payload !== 'object') {
|
|
313
|
+
return { status: 'none' };
|
|
314
|
+
}
|
|
315
|
+
const p = payload;
|
|
316
|
+
const hasPathField = 'path' in p || 'docPath' in p || 'collectionPath' in p;
|
|
317
|
+
if (!hasPathField) {
|
|
318
|
+
return { status: 'none' };
|
|
319
|
+
}
|
|
320
|
+
const raw = p.path !== undefined ? p.path : p.docPath !== undefined ? p.docPath : p.collectionPath;
|
|
321
|
+
const clean = WebSocketClient.cleanAndValidatePath(raw);
|
|
322
|
+
if (clean) {
|
|
323
|
+
return { status: 'valid', path: clean };
|
|
324
|
+
}
|
|
325
|
+
return { status: 'invalid', raw };
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Backward-compatible extractor returning valid path or null.
|
|
329
|
+
*/
|
|
330
|
+
static extractPayloadPath(payload) {
|
|
331
|
+
const res = WebSocketClient.extractPayloadPathTriState(payload);
|
|
332
|
+
return res.status === 'valid' ? res.path : null;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Dynamic authentication updater.
|
|
336
|
+
*/
|
|
337
|
+
updateAuth(authPayload) {
|
|
338
|
+
this._authPayload = authPayload;
|
|
339
|
+
if (this._socket) {
|
|
340
|
+
this._socket.auth = authPayload;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async getFreshAuthPayload() {
|
|
344
|
+
if (typeof this._options.getAuthPayload === 'function') {
|
|
345
|
+
try {
|
|
346
|
+
const payload = await this._options.getAuthPayload();
|
|
347
|
+
this._authPayload = payload;
|
|
348
|
+
return payload;
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
console.warn('[WebSocketClient] Error resolving fresh auth payload:', err);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return this._authPayload;
|
|
355
|
+
}
|
|
356
|
+
transitionTo(newState) {
|
|
357
|
+
const current = this._state;
|
|
358
|
+
if (current === 'disposed')
|
|
359
|
+
return false;
|
|
360
|
+
if (current === newState)
|
|
361
|
+
return true;
|
|
362
|
+
if (current === 'disconnecting' && newState !== 'idle' && newState !== 'disposed') {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
this._state = newState;
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Atomically downgrades from shared coordination to independent socket mode.
|
|
370
|
+
*/
|
|
371
|
+
switchToIndependentMode(reason) {
|
|
372
|
+
if (this._coordinationMode === 'independent')
|
|
373
|
+
return;
|
|
374
|
+
this._coordinationMode = 'independent';
|
|
375
|
+
this.stopFollowerHeartbeat();
|
|
376
|
+
this.stopLeaderPruneTimer();
|
|
377
|
+
this.stopLeaderLeaseTimer();
|
|
378
|
+
if (this.broadcastChannel) {
|
|
379
|
+
try {
|
|
380
|
+
this.broadcastChannel.close();
|
|
381
|
+
}
|
|
382
|
+
catch { }
|
|
383
|
+
this.broadcastChannel = null;
|
|
384
|
+
}
|
|
385
|
+
this.releaseLeaderLock();
|
|
386
|
+
this._currentLeaderId = this._tabId;
|
|
387
|
+
this._currentElectionId = this._electionId || WebSocketClient.generateId();
|
|
388
|
+
this._electionId = this._currentElectionId;
|
|
389
|
+
this.transitionTo('leader');
|
|
390
|
+
this.startSocketConnection();
|
|
391
|
+
this.checkReadinessWaiters();
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Safe non-throwing broadcast poster returning a delivery indicator boolean.
|
|
395
|
+
*/
|
|
396
|
+
postBroadcast(type, payload = {}) {
|
|
397
|
+
if (!this.broadcastChannel ||
|
|
398
|
+
this._state === 'disposed' ||
|
|
399
|
+
this._isSuspended ||
|
|
400
|
+
this._coordinationMode === 'independent') {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
let msg;
|
|
405
|
+
const now = Date.now();
|
|
406
|
+
if (type === 'leader_elected' ||
|
|
407
|
+
type === 'leader_state' ||
|
|
408
|
+
type === 'leader_event' ||
|
|
409
|
+
type === 'leader_send_response') {
|
|
410
|
+
if (!this.isLeader || !this._electionId)
|
|
411
|
+
return false;
|
|
412
|
+
if (type === 'leader_elected' || type === 'leader_state') {
|
|
413
|
+
msg = {
|
|
414
|
+
type,
|
|
415
|
+
senderId: this._tabId,
|
|
416
|
+
leaderId: this._tabId,
|
|
417
|
+
electionId: this._electionId,
|
|
418
|
+
termSeq: this._currentTermSeq,
|
|
419
|
+
timestamp: now,
|
|
420
|
+
payload: {
|
|
421
|
+
leaderId: this._tabId,
|
|
422
|
+
electionId: this._electionId,
|
|
423
|
+
isConnected: this._isConnected,
|
|
424
|
+
termSeq: this._currentTermSeq,
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
else if (type === 'leader_event') {
|
|
429
|
+
msg = {
|
|
430
|
+
type: 'leader_event',
|
|
431
|
+
senderId: this._tabId,
|
|
432
|
+
leaderId: this._tabId,
|
|
433
|
+
electionId: this._electionId,
|
|
434
|
+
timestamp: now,
|
|
435
|
+
payload: {
|
|
436
|
+
event: payload.event,
|
|
437
|
+
data: payload.data,
|
|
438
|
+
targetTabIds: payload.targetTabIds,
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
msg = {
|
|
444
|
+
type: 'leader_send_response',
|
|
445
|
+
senderId: this._tabId,
|
|
446
|
+
leaderId: this._tabId,
|
|
447
|
+
electionId: this._electionId,
|
|
448
|
+
timestamp: now,
|
|
449
|
+
payload: {
|
|
450
|
+
requestId: payload.requestId,
|
|
451
|
+
targetTabId: payload.targetTabId,
|
|
452
|
+
response: payload.response,
|
|
453
|
+
error: payload.error,
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
msg = {
|
|
460
|
+
type,
|
|
461
|
+
senderId: this._tabId,
|
|
462
|
+
timestamp: now,
|
|
463
|
+
payload,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
this.broadcastChannel.postMessage(msg);
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
console.warn('[WebSocketClient] Failed to post broadcast message:', err);
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Strictly validates incoming broadcast payload schema, types, bounds, and string sizes.
|
|
476
|
+
*/
|
|
477
|
+
validateBroadcastMessage(raw) {
|
|
478
|
+
if (!raw || typeof raw !== 'object')
|
|
479
|
+
return null;
|
|
480
|
+
const msg = raw;
|
|
481
|
+
if (typeof msg.type !== 'string' ||
|
|
482
|
+
typeof msg.senderId !== 'string' ||
|
|
483
|
+
!msg.senderId ||
|
|
484
|
+
msg.senderId.length > 128) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
if (msg.senderId === this._tabId)
|
|
488
|
+
return null; // Ignore messages from self
|
|
489
|
+
const type = msg.type;
|
|
490
|
+
const payload = msg.payload;
|
|
491
|
+
if (!payload || typeof payload !== 'object') {
|
|
492
|
+
if (type !== 'follower_sync_request' && type !== 'follower_disconnect') {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const timestamp = typeof msg.timestamp === 'number' ? msg.timestamp : Date.now();
|
|
497
|
+
switch (type) {
|
|
498
|
+
case 'follower_subscribe':
|
|
499
|
+
case 'follower_unsubscribe': {
|
|
500
|
+
const clean = WebSocketClient.cleanAndValidatePath(payload.path);
|
|
501
|
+
if (!clean)
|
|
502
|
+
return null;
|
|
503
|
+
return {
|
|
504
|
+
type,
|
|
505
|
+
senderId: msg.senderId,
|
|
506
|
+
timestamp,
|
|
507
|
+
payload: { path: clean },
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
case 'follower_sync_request': {
|
|
511
|
+
return {
|
|
512
|
+
type: 'follower_sync_request',
|
|
513
|
+
senderId: msg.senderId,
|
|
514
|
+
timestamp,
|
|
515
|
+
payload: {},
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
case 'follower_announce': {
|
|
519
|
+
if (!Array.isArray(payload.paths))
|
|
520
|
+
return null;
|
|
521
|
+
if (payload.paths.length > 500)
|
|
522
|
+
return null;
|
|
523
|
+
const validPaths = [];
|
|
524
|
+
for (const p of payload.paths) {
|
|
525
|
+
const clean = WebSocketClient.cleanAndValidatePath(p);
|
|
526
|
+
if (clean)
|
|
527
|
+
validPaths.push(clean);
|
|
528
|
+
}
|
|
529
|
+
const count = payload.globalListenersCount;
|
|
530
|
+
if (typeof count !== 'number' || !Number.isInteger(count) || count < 0 || count > 100000) {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
type: 'follower_announce',
|
|
535
|
+
senderId: msg.senderId,
|
|
536
|
+
timestamp,
|
|
537
|
+
payload: {
|
|
538
|
+
paths: validPaths,
|
|
539
|
+
globalListenersCount: count,
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
case 'follower_heartbeat': {
|
|
544
|
+
const count = payload.globalListenersCount;
|
|
545
|
+
if (typeof count !== 'number' || !Number.isInteger(count) || count < 0 || count > 100000) {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
let paths;
|
|
549
|
+
if (Array.isArray(payload.paths)) {
|
|
550
|
+
if (payload.paths.length > 500)
|
|
551
|
+
return null;
|
|
552
|
+
paths = [];
|
|
553
|
+
for (const p of payload.paths) {
|
|
554
|
+
const clean = WebSocketClient.cleanAndValidatePath(p);
|
|
555
|
+
if (clean)
|
|
556
|
+
paths.push(clean);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
type: 'follower_heartbeat',
|
|
561
|
+
senderId: msg.senderId,
|
|
562
|
+
timestamp,
|
|
563
|
+
payload: {
|
|
564
|
+
globalListenersCount: count,
|
|
565
|
+
paths,
|
|
566
|
+
},
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
case 'follower_disconnect': {
|
|
570
|
+
return {
|
|
571
|
+
type: 'follower_disconnect',
|
|
572
|
+
senderId: msg.senderId,
|
|
573
|
+
timestamp,
|
|
574
|
+
payload: { tabId: msg.senderId },
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
case 'follower_send': {
|
|
578
|
+
const { requestId, expectedLeaderId, expectedElectionId, event, data, targetTabId, deadline, idempotencyKey, } = payload;
|
|
579
|
+
if (typeof requestId !== 'string' || !requestId || requestId.length > 128)
|
|
580
|
+
return null;
|
|
581
|
+
if (typeof expectedLeaderId !== 'string' ||
|
|
582
|
+
!expectedLeaderId ||
|
|
583
|
+
expectedLeaderId.length > 128)
|
|
584
|
+
return null;
|
|
585
|
+
if (typeof expectedElectionId !== 'string' ||
|
|
586
|
+
!expectedElectionId ||
|
|
587
|
+
expectedElectionId.length > 128)
|
|
588
|
+
return null;
|
|
589
|
+
if (typeof event !== 'string' || !event || event.length > 128)
|
|
590
|
+
return null;
|
|
591
|
+
if (typeof targetTabId !== 'string' || targetTabId !== msg.senderId)
|
|
592
|
+
return null;
|
|
593
|
+
if (typeof deadline !== 'number' || !Number.isFinite(deadline) || deadline <= 0)
|
|
594
|
+
return null;
|
|
595
|
+
if (deadline > Date.now() + 300000)
|
|
596
|
+
return null; // Cap deadline at 5m
|
|
597
|
+
return {
|
|
598
|
+
type: 'follower_send',
|
|
599
|
+
senderId: msg.senderId,
|
|
600
|
+
timestamp,
|
|
601
|
+
payload: {
|
|
602
|
+
requestId,
|
|
603
|
+
expectedLeaderId,
|
|
604
|
+
expectedElectionId,
|
|
605
|
+
event,
|
|
606
|
+
data,
|
|
607
|
+
targetTabId,
|
|
608
|
+
deadline,
|
|
609
|
+
idempotencyKey: typeof idempotencyKey === 'string' && idempotencyKey.length <= 128
|
|
610
|
+
? idempotencyKey
|
|
611
|
+
: undefined,
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
case 'leader_elected':
|
|
616
|
+
case 'leader_state': {
|
|
617
|
+
const leaderId = msg.leaderId || payload.leaderId;
|
|
618
|
+
const electionId = msg.electionId || payload.electionId;
|
|
619
|
+
const termSeq = typeof msg.termSeq === 'number' ? msg.termSeq : payload.termSeq || 0;
|
|
620
|
+
if (typeof leaderId !== 'string' || !leaderId || leaderId !== msg.senderId)
|
|
621
|
+
return null;
|
|
622
|
+
if (typeof electionId !== 'string' || !electionId)
|
|
623
|
+
return null;
|
|
624
|
+
return {
|
|
625
|
+
type,
|
|
626
|
+
senderId: msg.senderId,
|
|
627
|
+
leaderId,
|
|
628
|
+
electionId,
|
|
629
|
+
termSeq,
|
|
630
|
+
timestamp,
|
|
631
|
+
payload: {
|
|
632
|
+
leaderId,
|
|
633
|
+
electionId,
|
|
634
|
+
isConnected: Boolean(payload.isConnected),
|
|
635
|
+
termSeq,
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
case 'leader_event': {
|
|
640
|
+
const leaderId = msg.leaderId || payload.leaderId;
|
|
641
|
+
const electionId = msg.electionId || payload.electionId;
|
|
642
|
+
if (typeof leaderId !== 'string' || !leaderId || leaderId !== msg.senderId)
|
|
643
|
+
return null;
|
|
644
|
+
if (typeof electionId !== 'string' || !electionId)
|
|
645
|
+
return null;
|
|
646
|
+
if (typeof payload.event !== 'string' || !payload.event)
|
|
647
|
+
return null;
|
|
648
|
+
let targetTabIds;
|
|
649
|
+
if (Array.isArray(payload.targetTabIds)) {
|
|
650
|
+
targetTabIds = payload.targetTabIds.filter((id) => typeof id === 'string' && id.length > 0 && id.length <= 128);
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
type: 'leader_event',
|
|
654
|
+
senderId: msg.senderId,
|
|
655
|
+
leaderId,
|
|
656
|
+
electionId,
|
|
657
|
+
timestamp,
|
|
658
|
+
payload: {
|
|
659
|
+
event: payload.event,
|
|
660
|
+
data: payload.data,
|
|
661
|
+
targetTabIds,
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
case 'leader_send_response': {
|
|
666
|
+
const leaderId = msg.leaderId || payload.leaderId;
|
|
667
|
+
const electionId = msg.electionId || payload.electionId;
|
|
668
|
+
if (typeof leaderId !== 'string' || !leaderId || leaderId !== msg.senderId)
|
|
669
|
+
return null;
|
|
670
|
+
if (typeof electionId !== 'string' || !electionId)
|
|
671
|
+
return null;
|
|
672
|
+
if (typeof payload.requestId !== 'string' || !payload.requestId)
|
|
673
|
+
return null;
|
|
674
|
+
if (typeof payload.targetTabId !== 'string' || !payload.targetTabId)
|
|
675
|
+
return null;
|
|
676
|
+
let error;
|
|
677
|
+
if (payload.error) {
|
|
678
|
+
if (typeof payload.error === 'object') {
|
|
679
|
+
error = {
|
|
680
|
+
name: String(payload.error.name || 'Error'),
|
|
681
|
+
message: String(payload.error.message || 'Operation failed'),
|
|
682
|
+
code: payload.error.code || 'unavailable',
|
|
683
|
+
retryable: Boolean(payload.error.retryable),
|
|
684
|
+
source: payload.error.source || 'server',
|
|
685
|
+
isAmbiguous: Boolean(payload.error.isAmbiguous),
|
|
686
|
+
details: payload.error.details,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
else if (typeof payload.error === 'string') {
|
|
690
|
+
error = {
|
|
691
|
+
name: 'Error',
|
|
692
|
+
message: payload.error,
|
|
693
|
+
code: 'unavailable',
|
|
694
|
+
retryable: false,
|
|
695
|
+
source: 'leader',
|
|
696
|
+
isAmbiguous: false,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
type: 'leader_send_response',
|
|
702
|
+
senderId: msg.senderId,
|
|
703
|
+
leaderId,
|
|
704
|
+
electionId,
|
|
705
|
+
timestamp,
|
|
706
|
+
payload: {
|
|
707
|
+
requestId: payload.requestId,
|
|
708
|
+
targetTabId: payload.targetTabId,
|
|
709
|
+
response: payload.response,
|
|
710
|
+
error,
|
|
711
|
+
},
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
default:
|
|
715
|
+
return null;
|
|
20
716
|
}
|
|
21
717
|
}
|
|
22
718
|
handleBroadcastMessage(event) {
|
|
23
|
-
|
|
719
|
+
if (this._state === 'disposed' ||
|
|
720
|
+
this._isSuspended ||
|
|
721
|
+
this._coordinationMode === 'independent') {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const msg = this.validateBroadcastMessage(event.data);
|
|
725
|
+
if (!msg)
|
|
726
|
+
return;
|
|
24
727
|
if (this.isLeader) {
|
|
25
|
-
|
|
26
|
-
|
|
728
|
+
this.handleMessageAsLeader(msg);
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
this.handleMessageAsFollower(msg);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
handleMessageAsLeader(msg) {
|
|
735
|
+
const senderId = msg.senderId;
|
|
736
|
+
if (msg.type === 'follower_subscribe') {
|
|
737
|
+
this.addFollowerPathSubscription(senderId, msg.payload.path);
|
|
738
|
+
}
|
|
739
|
+
else if (msg.type === 'follower_unsubscribe') {
|
|
740
|
+
this.removeFollowerPathSubscription(senderId, msg.payload.path);
|
|
741
|
+
}
|
|
742
|
+
else if (msg.type === 'follower_sync_request') {
|
|
743
|
+
this.postBroadcast('leader_state', {
|
|
744
|
+
leaderId: this._tabId,
|
|
745
|
+
electionId: this._electionId,
|
|
746
|
+
isConnected: this._isConnected,
|
|
747
|
+
termSeq: this._currentTermSeq,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
else if (msg.type === 'follower_announce') {
|
|
751
|
+
this.handleFollowerAnnounce(senderId, msg.payload.paths, msg.payload.globalListenersCount);
|
|
752
|
+
}
|
|
753
|
+
else if (msg.type === 'follower_heartbeat') {
|
|
754
|
+
if (Array.isArray(msg.payload.paths)) {
|
|
755
|
+
this.handleFollowerAnnounce(senderId, msg.payload.paths, msg.payload.globalListenersCount);
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
const existing = this.followerGlobalNeeds.get(senderId);
|
|
759
|
+
this.followerGlobalNeeds.set(senderId, {
|
|
760
|
+
lastSeen: Date.now(),
|
|
761
|
+
globalListenersCount: msg.payload.globalListenersCount,
|
|
762
|
+
paths: existing?.paths,
|
|
763
|
+
});
|
|
27
764
|
}
|
|
28
|
-
|
|
29
|
-
|
|
765
|
+
}
|
|
766
|
+
else if (msg.type === 'follower_disconnect') {
|
|
767
|
+
this.pruneFollower(senderId);
|
|
768
|
+
}
|
|
769
|
+
else if (msg.type === 'follower_send') {
|
|
770
|
+
this.handleFollowerSendRequest(msg);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
pruneDedupCache() {
|
|
774
|
+
const now = Date.now();
|
|
775
|
+
this.processedFollowerRequests.forEach((entry, key) => {
|
|
776
|
+
if (now - entry.timestamp > WebSocketClient.DEDUP_CACHE_TTL_MS) {
|
|
777
|
+
this.processedFollowerRequests.delete(key);
|
|
30
778
|
}
|
|
31
|
-
|
|
32
|
-
|
|
779
|
+
});
|
|
780
|
+
if (this.processedFollowerRequests.size > WebSocketClient.MAX_DEDUP_CACHE_SIZE) {
|
|
781
|
+
const keysToDelete = Array.from(this.processedFollowerRequests.keys()).slice(0, this.processedFollowerRequests.size - WebSocketClient.MAX_DEDUP_CACHE_SIZE);
|
|
782
|
+
for (const k of keysToDelete) {
|
|
783
|
+
this.processedFollowerRequests.delete(k);
|
|
33
784
|
}
|
|
34
785
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
786
|
+
}
|
|
787
|
+
handleFollowerSendRequest(msg) {
|
|
788
|
+
const { requestId, expectedLeaderId, expectedElectionId, event, data, targetTabId, deadline, idempotencyKey, } = msg.payload;
|
|
789
|
+
const senderId = msg.senderId;
|
|
790
|
+
// Strict Term Fencing: Ignore if not addressed to this leader's active term
|
|
791
|
+
if (!this.isLeader ||
|
|
792
|
+
!this._electionId ||
|
|
793
|
+
expectedLeaderId !== this._tabId ||
|
|
794
|
+
expectedElectionId !== this._electionId) {
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (Date.now() >= deadline)
|
|
798
|
+
return; // Expired
|
|
799
|
+
this.pruneDedupCache();
|
|
800
|
+
// Bounded Deduplication: if already processed, return cached response directly
|
|
801
|
+
const cached = this.processedFollowerRequests.get(requestId);
|
|
802
|
+
if (cached) {
|
|
803
|
+
this.postBroadcast('leader_send_response', {
|
|
804
|
+
requestId,
|
|
805
|
+
targetTabId: senderId,
|
|
806
|
+
response: cached.response,
|
|
807
|
+
error: cached.error,
|
|
808
|
+
});
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
// If currently executing, do not duplicate
|
|
812
|
+
if (this.activeLeaderSends.has(requestId)) {
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
const currentElectionId = this._electionId;
|
|
816
|
+
const isMutating = WebSocketClient.isMutatingEvent(event);
|
|
817
|
+
const execution = {
|
|
818
|
+
requestId,
|
|
819
|
+
cancelled: false,
|
|
820
|
+
timer: null,
|
|
821
|
+
cleanup: () => {
|
|
822
|
+
if (execution.timer) {
|
|
823
|
+
clearTimeout(execution.timer);
|
|
824
|
+
execution.timer = null;
|
|
825
|
+
}
|
|
826
|
+
this.activeLeaderSends.delete(requestId);
|
|
827
|
+
},
|
|
828
|
+
};
|
|
829
|
+
this.activeLeaderSends.set(requestId, execution);
|
|
830
|
+
const payloadData = idempotencyKey && data && typeof data === 'object'
|
|
831
|
+
? { ...data, idempotencyKey }
|
|
832
|
+
: data;
|
|
833
|
+
this.executeSendAsLeader(event, payloadData, deadline)
|
|
834
|
+
.then((response) => {
|
|
835
|
+
execution.cleanup();
|
|
836
|
+
if (execution.cancelled || this._state === 'disposed' || !this.isLeader)
|
|
837
|
+
return;
|
|
838
|
+
this.processedFollowerRequests.set(requestId, {
|
|
839
|
+
timestamp: Date.now(),
|
|
840
|
+
response,
|
|
841
|
+
});
|
|
842
|
+
this.postBroadcast('leader_send_response', {
|
|
843
|
+
requestId,
|
|
844
|
+
targetTabId: senderId,
|
|
845
|
+
response,
|
|
846
|
+
});
|
|
847
|
+
})
|
|
848
|
+
.catch((error) => {
|
|
849
|
+
execution.cleanup();
|
|
850
|
+
if (execution.cancelled || this._state === 'disposed' || !this.isLeader)
|
|
851
|
+
return;
|
|
852
|
+
const isAmbiguous = isMutating &&
|
|
853
|
+
(this._electionId !== currentElectionId ||
|
|
854
|
+
!this._isConnected ||
|
|
855
|
+
(error instanceof NexaWebSocketError && error.isAmbiguous));
|
|
856
|
+
const structuredErr = error instanceof NexaWebSocketError
|
|
857
|
+
? { ...error.toJSON(), isAmbiguous: isAmbiguous || error.isAmbiguous }
|
|
858
|
+
: {
|
|
859
|
+
name: error?.name || 'Error',
|
|
860
|
+
message: error?.message || 'Send error',
|
|
861
|
+
code: isAmbiguous ? 'ambiguous-outcome' : 'unavailable',
|
|
862
|
+
retryable: !isAmbiguous,
|
|
863
|
+
source: 'leader',
|
|
864
|
+
isAmbiguous,
|
|
865
|
+
};
|
|
866
|
+
this.processedFollowerRequests.set(requestId, {
|
|
867
|
+
timestamp: Date.now(),
|
|
868
|
+
error: structuredErr,
|
|
869
|
+
});
|
|
870
|
+
this.postBroadcast('leader_send_response', {
|
|
871
|
+
requestId,
|
|
872
|
+
targetTabId: senderId,
|
|
873
|
+
error: structuredErr,
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
handleMessageAsFollower(msg) {
|
|
878
|
+
if (msg.type === 'leader_elected' ||
|
|
879
|
+
msg.type === 'leader_state' ||
|
|
880
|
+
msg.type === 'leader_event' ||
|
|
881
|
+
msg.type === 'leader_send_response') {
|
|
882
|
+
if (msg.type === 'leader_elected' || msg.type === 'leader_state') {
|
|
883
|
+
const { leaderId, electionId, isConnected, termSeq } = msg.payload;
|
|
884
|
+
// Fencing: Reject messages from older term sequences
|
|
885
|
+
if (termSeq < this._currentTermSeq) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const isNewTerm = this._currentLeaderId !== leaderId || this._currentElectionId !== electionId;
|
|
889
|
+
if (isNewTerm) {
|
|
890
|
+
this._currentLeaderId = leaderId;
|
|
891
|
+
this._currentElectionId = electionId;
|
|
892
|
+
this._currentTermSeq = termSeq;
|
|
893
|
+
// Invalidate and reject all pending follower sends tied to the older term
|
|
894
|
+
this.pendingSendRequests.forEach((req, reqId) => {
|
|
895
|
+
if (req.expectedElectionId !== electionId || req.expectedLeaderId !== leaderId) {
|
|
896
|
+
clearTimeout(req.timer);
|
|
897
|
+
this.pendingSendRequests.delete(reqId);
|
|
898
|
+
req.reject(new NexaWebSocketError({
|
|
899
|
+
message: 'Leadership term changed while request was pending',
|
|
900
|
+
code: req.isMutating ? 'ambiguous-outcome' : 'leader-changed',
|
|
901
|
+
isAmbiguous: req.isMutating,
|
|
902
|
+
source: 'leader',
|
|
903
|
+
}));
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
this._isLeaderSocketConnected = isConnected;
|
|
908
|
+
this.updateFollowerConnectionState(isConnected);
|
|
909
|
+
if (isNewTerm) {
|
|
910
|
+
this.announceNeedsToLeader();
|
|
911
|
+
}
|
|
912
|
+
this.checkReadinessWaiters();
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
// Strict Term Fencing for leader events and responses
|
|
916
|
+
if (!this._currentLeaderId ||
|
|
917
|
+
!this._currentElectionId ||
|
|
918
|
+
msg.leaderId !== this._currentLeaderId ||
|
|
919
|
+
msg.electionId !== this._currentElectionId) {
|
|
920
|
+
return; // Reject messages from non-active or stale leadership term
|
|
921
|
+
}
|
|
922
|
+
if (msg.type === 'leader_event') {
|
|
923
|
+
const { event: socketEvent, data, targetTabIds } = msg.payload;
|
|
924
|
+
if (Array.isArray(targetTabIds) && !targetTabIds.includes(this._tabId)) {
|
|
925
|
+
return; // Event was targeted to other tabs
|
|
926
|
+
}
|
|
38
927
|
if (socketEvent === 'connect') {
|
|
39
|
-
this.
|
|
40
|
-
this.
|
|
928
|
+
this._isLeaderSocketConnected = true;
|
|
929
|
+
this.updateFollowerConnectionState(true);
|
|
930
|
+
this.checkReadinessWaiters();
|
|
41
931
|
}
|
|
42
932
|
else if (socketEvent === 'disconnect') {
|
|
43
|
-
this.
|
|
44
|
-
this.
|
|
933
|
+
this._isLeaderSocketConnected = false;
|
|
934
|
+
this.updateFollowerConnectionState(false);
|
|
935
|
+
}
|
|
936
|
+
else if (socketEvent === 'error') {
|
|
937
|
+
this.dispatch('error', data);
|
|
45
938
|
}
|
|
46
939
|
else {
|
|
47
940
|
this.dispatch(socketEvent, data);
|
|
@@ -51,147 +944,598 @@ class WebSocketClient {
|
|
|
51
944
|
}
|
|
52
945
|
}
|
|
53
946
|
}
|
|
54
|
-
else if (type === '
|
|
55
|
-
|
|
56
|
-
if (this.
|
|
57
|
-
this.
|
|
947
|
+
else if (msg.type === 'leader_send_response') {
|
|
948
|
+
const { requestId, targetTabId, response, error } = msg.payload;
|
|
949
|
+
if (targetTabId === this._tabId && requestId && this.pendingSendRequests.has(requestId)) {
|
|
950
|
+
const req = this.pendingSendRequests.get(requestId);
|
|
951
|
+
clearTimeout(req.timer);
|
|
952
|
+
this.pendingSendRequests.delete(requestId);
|
|
953
|
+
if (error) {
|
|
954
|
+
req.reject(NexaWebSocketError.from(error, 'server'));
|
|
955
|
+
}
|
|
956
|
+
else {
|
|
957
|
+
req.resolve(response);
|
|
958
|
+
}
|
|
58
959
|
}
|
|
59
960
|
}
|
|
60
961
|
}
|
|
61
962
|
}
|
|
963
|
+
updateFollowerConnectionState(connected) {
|
|
964
|
+
this._isConnected = connected;
|
|
965
|
+
if (connected && !this._lastReportedConnected) {
|
|
966
|
+
this._lastReportedConnected = true;
|
|
967
|
+
this.dispatch('open', { socketId: 'follower' });
|
|
968
|
+
}
|
|
969
|
+
else if (!connected && this._lastReportedConnected) {
|
|
970
|
+
this._lastReportedConnected = false;
|
|
971
|
+
this.dispatch('close', { reason: 'leader disconnected' });
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
handleFollowerAnnounce(senderId, paths, globalListenersCount) {
|
|
975
|
+
const validPaths = new Set();
|
|
976
|
+
for (const p of paths) {
|
|
977
|
+
const clean = WebSocketClient.cleanAndValidatePath(p);
|
|
978
|
+
if (clean)
|
|
979
|
+
validPaths.add(clean);
|
|
980
|
+
}
|
|
981
|
+
const prevPaths = this.followerSubscriptionsByTab.get(senderId) || new Set();
|
|
982
|
+
this.followerSubscriptionsByTab.set(senderId, validPaths);
|
|
983
|
+
// Remove obsolete subscriptions
|
|
984
|
+
for (const p of prevPaths) {
|
|
985
|
+
if (!validPaths.has(p)) {
|
|
986
|
+
this.removeFollowerPathSubscription(senderId, p);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
// Add newly declared subscriptions
|
|
990
|
+
for (const p of validPaths) {
|
|
991
|
+
if (!prevPaths.has(p)) {
|
|
992
|
+
this.addFollowerPathSubscription(senderId, p);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
this.followerGlobalNeeds.set(senderId, {
|
|
996
|
+
lastSeen: Date.now(),
|
|
997
|
+
globalListenersCount: Math.max(0, Math.floor(globalListenersCount || 0)),
|
|
998
|
+
paths: validPaths,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
announceNeedsToLeader() {
|
|
1002
|
+
if (this._state === 'disposed' ||
|
|
1003
|
+
this._isSuspended ||
|
|
1004
|
+
this.isLeader ||
|
|
1005
|
+
this._coordinationMode === 'independent') {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const paths = Array.from(this.localSubscribedPaths.keys());
|
|
1009
|
+
const globalCount = this.getGlobalListenersCount();
|
|
1010
|
+
this.postBroadcast('follower_announce', {
|
|
1011
|
+
paths,
|
|
1012
|
+
globalListenersCount: globalCount,
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
announceGlobalListenersChange() {
|
|
1016
|
+
if (this._state === 'disposed' ||
|
|
1017
|
+
this._isSuspended ||
|
|
1018
|
+
this.isLeader ||
|
|
1019
|
+
this._coordinationMode === 'independent') {
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
this.postBroadcast('follower_heartbeat', {
|
|
1023
|
+
globalListenersCount: this.getGlobalListenersCount(),
|
|
1024
|
+
paths: Array.from(this.localSubscribedPaths.keys()),
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
getGlobalListenersCount() {
|
|
1028
|
+
let count = 0;
|
|
1029
|
+
this.listeners.forEach((set, evt) => {
|
|
1030
|
+
if (evt !== 'data_changed' &&
|
|
1031
|
+
evt !== 'firestore_changed' &&
|
|
1032
|
+
evt !== 'message' &&
|
|
1033
|
+
evt !== 'all' &&
|
|
1034
|
+
evt !== 'open' &&
|
|
1035
|
+
evt !== 'close' &&
|
|
1036
|
+
evt !== 'error' &&
|
|
1037
|
+
evt !== 'resync') {
|
|
1038
|
+
count += set.size;
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
return count;
|
|
1042
|
+
}
|
|
1043
|
+
addFollowerPathSubscription(tabId, cleanPath) {
|
|
1044
|
+
let tabSubs = this.followerSubscriptionsByTab.get(tabId);
|
|
1045
|
+
if (!tabSubs) {
|
|
1046
|
+
tabSubs = new Set();
|
|
1047
|
+
this.followerSubscriptionsByTab.set(tabId, tabSubs);
|
|
1048
|
+
}
|
|
1049
|
+
tabSubs.add(cleanPath);
|
|
1050
|
+
let pathSubs = this.followerSubscriptions.get(cleanPath);
|
|
1051
|
+
if (!pathSubs) {
|
|
1052
|
+
pathSubs = new Set();
|
|
1053
|
+
this.followerSubscriptions.set(cleanPath, pathSubs);
|
|
1054
|
+
}
|
|
1055
|
+
const prevTotal = pathSubs.size + (this.localSubscribedPaths.get(cleanPath) || 0);
|
|
1056
|
+
pathSubs.add(tabId);
|
|
1057
|
+
const newTotal = pathSubs.size + (this.localSubscribedPaths.get(cleanPath) || 0);
|
|
1058
|
+
if (prevTotal === 0 && newTotal > 0 && this._socket && this._isConnected) {
|
|
1059
|
+
this._socket.emit('subscribe', this._projectId, cleanPath);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
removeFollowerPathSubscription(tabId, cleanPath) {
|
|
1063
|
+
const tabSubs = this.followerSubscriptionsByTab.get(tabId);
|
|
1064
|
+
if (tabSubs) {
|
|
1065
|
+
tabSubs.delete(cleanPath);
|
|
1066
|
+
if (tabSubs.size === 0) {
|
|
1067
|
+
this.followerSubscriptionsByTab.delete(tabId);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const pathSubs = this.followerSubscriptions.get(cleanPath);
|
|
1071
|
+
if (!pathSubs)
|
|
1072
|
+
return;
|
|
1073
|
+
const prevTotal = pathSubs.size + (this.localSubscribedPaths.get(cleanPath) || 0);
|
|
1074
|
+
pathSubs.delete(tabId);
|
|
1075
|
+
if (pathSubs.size === 0) {
|
|
1076
|
+
this.followerSubscriptions.delete(cleanPath);
|
|
1077
|
+
}
|
|
1078
|
+
const newTotal = (this.followerSubscriptions.get(cleanPath)?.size || 0) +
|
|
1079
|
+
(this.localSubscribedPaths.get(cleanPath) || 0);
|
|
1080
|
+
if (prevTotal > 0 && newTotal === 0 && this._socket && this._isConnected) {
|
|
1081
|
+
this._socket.emit('unsubscribe', this._projectId, cleanPath);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
pruneFollower(tabId) {
|
|
1085
|
+
const tabSubs = this.followerSubscriptionsByTab.get(tabId);
|
|
1086
|
+
if (tabSubs) {
|
|
1087
|
+
tabSubs.forEach((path) => {
|
|
1088
|
+
this.removeFollowerPathSubscription(tabId, path);
|
|
1089
|
+
});
|
|
1090
|
+
this.followerSubscriptionsByTab.delete(tabId);
|
|
1091
|
+
}
|
|
1092
|
+
this.followerGlobalNeeds.delete(tabId);
|
|
1093
|
+
this.checkCloseConnection();
|
|
1094
|
+
}
|
|
1095
|
+
startFollowerHeartbeat() {
|
|
1096
|
+
if (this.heartbeatTimer ||
|
|
1097
|
+
typeof window === 'undefined' ||
|
|
1098
|
+
this._coordinationMode === 'independent') {
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
this.heartbeatTimer = setInterval(() => {
|
|
1102
|
+
if (this._state === 'follower' && !this._isSuspended) {
|
|
1103
|
+
this.postBroadcast('follower_heartbeat', {
|
|
1104
|
+
globalListenersCount: this.getGlobalListenersCount(),
|
|
1105
|
+
paths: Array.from(this.localSubscribedPaths.keys()),
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
}, this.heartbeatIntervalMs);
|
|
1109
|
+
if (typeof this.heartbeatTimer?.unref === 'function') {
|
|
1110
|
+
this.heartbeatTimer.unref();
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
stopFollowerHeartbeat() {
|
|
1114
|
+
if (this.heartbeatTimer) {
|
|
1115
|
+
clearInterval(this.heartbeatTimer);
|
|
1116
|
+
this.heartbeatTimer = null;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
startLeaderPruneTimer() {
|
|
1120
|
+
if (this.pruneTimer ||
|
|
1121
|
+
typeof window === 'undefined' ||
|
|
1122
|
+
this._coordinationMode === 'independent') {
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
this.pruneTimer = setInterval(() => {
|
|
1126
|
+
if (this.isLeader && !this._isSuspended) {
|
|
1127
|
+
const now = Date.now();
|
|
1128
|
+
this.followerGlobalNeeds.forEach((data, tabId) => {
|
|
1129
|
+
if (now - data.lastSeen > this.followerTtlMs) {
|
|
1130
|
+
this.pruneFollower(tabId);
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
}, this.pruneIntervalMs);
|
|
1135
|
+
if (typeof this.pruneTimer?.unref === 'function') {
|
|
1136
|
+
this.pruneTimer.unref();
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
stopLeaderPruneTimer() {
|
|
1140
|
+
if (this.pruneTimer) {
|
|
1141
|
+
clearInterval(this.pruneTimer);
|
|
1142
|
+
this.pruneTimer = null;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
startLeaderLeaseTimer() {
|
|
1146
|
+
if (this.leaseTimer ||
|
|
1147
|
+
typeof window === 'undefined' ||
|
|
1148
|
+
this._coordinationMode === 'independent') {
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
this.leaseTimer = setInterval(() => {
|
|
1152
|
+
if (this.isLeader && !this._isSuspended) {
|
|
1153
|
+
this.postBroadcast('leader_state', {
|
|
1154
|
+
leaderId: this._tabId,
|
|
1155
|
+
electionId: this._electionId,
|
|
1156
|
+
isConnected: this._isConnected,
|
|
1157
|
+
termSeq: this._currentTermSeq,
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
}, this.leaderLeaseIntervalMs);
|
|
1161
|
+
if (typeof this.leaseTimer?.unref === 'function') {
|
|
1162
|
+
this.leaseTimer.unref();
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
stopLeaderLeaseTimer() {
|
|
1166
|
+
if (this.leaseTimer) {
|
|
1167
|
+
clearInterval(this.leaseTimer);
|
|
1168
|
+
this.leaseTimer = null;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Subscribes an event listener callback with reference counting and connection management.
|
|
1173
|
+
*/
|
|
62
1174
|
addListener(event, callback) {
|
|
63
|
-
if (!
|
|
64
|
-
|
|
1175
|
+
if (typeof event !== 'string' || !event.trim()) {
|
|
1176
|
+
throw new NexaWebSocketError({
|
|
1177
|
+
message: 'Invalid event: must be a non-empty string',
|
|
1178
|
+
code: 'invalid-argument',
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
if (typeof callback !== 'function') {
|
|
1182
|
+
throw new NexaWebSocketError({
|
|
1183
|
+
message: 'Invalid callback: must be a function',
|
|
1184
|
+
code: 'invalid-argument',
|
|
1185
|
+
});
|
|
65
1186
|
}
|
|
66
|
-
this.
|
|
1187
|
+
if (this._state === 'disposed')
|
|
1188
|
+
return () => { };
|
|
1189
|
+
const cleanEvent = event.trim();
|
|
1190
|
+
if (!this.listeners.has(cleanEvent)) {
|
|
1191
|
+
this.listeners.set(cleanEvent, new Set());
|
|
1192
|
+
}
|
|
1193
|
+
this.listeners.get(cleanEvent).add(callback);
|
|
67
1194
|
this.ensureConnected();
|
|
1195
|
+
this.announceGlobalListenersChange();
|
|
68
1196
|
return () => {
|
|
69
|
-
const callbacks = this.listeners.get(
|
|
1197
|
+
const callbacks = this.listeners.get(cleanEvent);
|
|
70
1198
|
if (callbacks) {
|
|
71
1199
|
callbacks.delete(callback);
|
|
72
1200
|
if (callbacks.size === 0) {
|
|
73
|
-
this.listeners.delete(
|
|
1201
|
+
this.listeners.delete(cleanEvent);
|
|
74
1202
|
}
|
|
75
1203
|
}
|
|
1204
|
+
this.announceGlobalListenersChange();
|
|
76
1205
|
this.checkCloseConnection();
|
|
77
1206
|
};
|
|
78
1207
|
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Reference-counted path subscription.
|
|
1210
|
+
*/
|
|
79
1211
|
subscribe(path) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
1212
|
+
if (this._state === 'disposed')
|
|
1213
|
+
return;
|
|
1214
|
+
const cleanPath = WebSocketClient.validatePathOrThrow(path);
|
|
1215
|
+
const currentCount = this.localSubscribedPaths.get(cleanPath) || 0;
|
|
1216
|
+
this.localSubscribedPaths.set(cleanPath, currentCount + 1);
|
|
1217
|
+
this.ensureConnected();
|
|
1218
|
+
if (currentCount === 0) {
|
|
1219
|
+
if (this.isLeader || this._coordinationMode === 'independent') {
|
|
1220
|
+
const followerCount = this.followerSubscriptions.get(cleanPath)?.size || 0;
|
|
1221
|
+
if (followerCount === 0 && this._socket && this._isConnected) {
|
|
1222
|
+
this._socket.emit('subscribe', this._projectId, cleanPath);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
else {
|
|
1226
|
+
this.postBroadcast('follower_subscribe', { path: cleanPath });
|
|
1227
|
+
}
|
|
87
1228
|
}
|
|
88
1229
|
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Decrements reference-counted path subscription.
|
|
1232
|
+
*/
|
|
89
1233
|
unsubscribe(path) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
1234
|
+
if (this._state === 'disposed')
|
|
1235
|
+
return;
|
|
1236
|
+
const cleanPath = WebSocketClient.validatePathOrThrow(path);
|
|
1237
|
+
const currentCount = this.localSubscribedPaths.get(cleanPath) || 0;
|
|
1238
|
+
if (currentCount <= 0)
|
|
1239
|
+
return;
|
|
1240
|
+
if (currentCount === 1) {
|
|
1241
|
+
this.localSubscribedPaths.delete(cleanPath);
|
|
1242
|
+
if (this.isLeader || this._coordinationMode === 'independent') {
|
|
1243
|
+
const followerCount = this.followerSubscriptions.get(cleanPath)?.size || 0;
|
|
1244
|
+
if (followerCount === 0 && this._socket && this._isConnected) {
|
|
1245
|
+
this._socket.emit('unsubscribe', this._projectId, cleanPath);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
else {
|
|
1249
|
+
this.postBroadcast('follower_unsubscribe', { path: cleanPath });
|
|
1250
|
+
}
|
|
94
1251
|
}
|
|
95
|
-
else
|
|
96
|
-
this.
|
|
1252
|
+
else {
|
|
1253
|
+
this.localSubscribedPaths.set(cleanPath, currentCount - 1);
|
|
97
1254
|
}
|
|
1255
|
+
this.checkCloseConnection();
|
|
98
1256
|
}
|
|
99
|
-
broadcastToFollowers(event, data) {
|
|
100
|
-
if (this.isLeader && this.
|
|
101
|
-
this.
|
|
1257
|
+
broadcastToFollowers(event, data, targetTabIds) {
|
|
1258
|
+
if (this.isLeader && this._coordinationMode === 'shared') {
|
|
1259
|
+
this.postBroadcast('leader_event', { event, data, targetTabIds });
|
|
102
1260
|
}
|
|
103
1261
|
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Connects socket directly or coordinates election via Web Locks.
|
|
1264
|
+
*/
|
|
104
1265
|
ensureConnected() {
|
|
105
|
-
if (this.
|
|
1266
|
+
if (this._state !== 'idle' || this._isSuspended || typeof window === 'undefined')
|
|
106
1267
|
return;
|
|
107
|
-
this.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
1268
|
+
if (this._coordinationMode === 'independent') {
|
|
1269
|
+
this.transitionTo('leader');
|
|
1270
|
+
this._electionId = WebSocketClient.generateId();
|
|
1271
|
+
this._currentLeaderId = this._tabId;
|
|
1272
|
+
this._currentElectionId = this._electionId;
|
|
1273
|
+
this._currentTermSeq++;
|
|
1274
|
+
this.startSocketConnection();
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
this.transitionTo('electing');
|
|
1278
|
+
const locksManager = this._options.locks || (typeof navigator !== 'undefined' ? navigator.locks : null);
|
|
1279
|
+
if (locksManager && this.broadcastChannel) {
|
|
1280
|
+
this.lockAbortController = new AbortController();
|
|
1281
|
+
const lockName = `nexabase_lock_${this._coordinationKey}`;
|
|
1282
|
+
this.transitionTo('follower');
|
|
1283
|
+
this.startFollowerHeartbeat();
|
|
1284
|
+
this.postBroadcast('follower_sync_request');
|
|
1285
|
+
locksManager
|
|
1286
|
+
.request(lockName, { signal: this.lockAbortController.signal }, async () => {
|
|
1287
|
+
const current = this._state;
|
|
1288
|
+
if (current === 'disposed' ||
|
|
1289
|
+
current === 'disconnecting' ||
|
|
1290
|
+
this._isSuspended) {
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
this.stopFollowerHeartbeat();
|
|
1294
|
+
this.transitionTo('leader');
|
|
1295
|
+
this._electionId = WebSocketClient.generateId();
|
|
1296
|
+
this._currentLeaderId = this._tabId;
|
|
1297
|
+
this._currentElectionId = this._electionId;
|
|
1298
|
+
this._currentTermSeq++;
|
|
1299
|
+
// Invalidate pending follower requests tied to older term
|
|
1300
|
+
this.pendingSendRequests.forEach((req, reqId) => {
|
|
1301
|
+
clearTimeout(req.timer);
|
|
1302
|
+
this.pendingSendRequests.delete(reqId);
|
|
1303
|
+
req.reject(new NexaWebSocketError({
|
|
1304
|
+
message: 'Leadership term changed while request was pending',
|
|
1305
|
+
code: req.isMutating ? 'ambiguous-outcome' : 'leader-changed',
|
|
1306
|
+
isAmbiguous: req.isMutating,
|
|
1307
|
+
source: 'leader',
|
|
1308
|
+
}));
|
|
1309
|
+
});
|
|
1310
|
+
this.startLeaderPruneTimer();
|
|
1311
|
+
this.startLeaderLeaseTimer();
|
|
1312
|
+
this.startSocketConnection();
|
|
1313
|
+
this.postBroadcast('leader_elected', {
|
|
1314
|
+
leaderId: this._tabId,
|
|
1315
|
+
electionId: this._electionId,
|
|
1316
|
+
isConnected: this._isConnected,
|
|
1317
|
+
termSeq: this._currentTermSeq,
|
|
1318
|
+
});
|
|
1319
|
+
this.checkReadinessWaiters();
|
|
111
1320
|
return new Promise((resolve) => {
|
|
112
|
-
this.
|
|
113
|
-
this.startSocketConnection();
|
|
114
|
-
// If we are closed intentionally, resolve the lock
|
|
115
|
-
const originalClose = this.close.bind(this);
|
|
116
|
-
this.close = () => {
|
|
117
|
-
originalClose();
|
|
118
|
-
resolve();
|
|
119
|
-
};
|
|
1321
|
+
this.resolveLeaderLock = resolve;
|
|
120
1322
|
});
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
1323
|
+
})
|
|
1324
|
+
.catch((err) => {
|
|
1325
|
+
if (err?.name === 'AbortError')
|
|
1326
|
+
return; // Intentional abort on close/suspend
|
|
1327
|
+
console.warn('[WebSocketClient] Web Lock failed, switching to independent mode:', err);
|
|
1328
|
+
if (this._state !== 'disposed' && !this._isSuspended) {
|
|
1329
|
+
this.switchToIndependentMode('lock_error');
|
|
1330
|
+
}
|
|
125
1331
|
});
|
|
126
1332
|
}
|
|
127
1333
|
else {
|
|
128
|
-
|
|
129
|
-
this.isLeader = true;
|
|
130
|
-
this.startSocketConnection();
|
|
1334
|
+
this.switchToIndependentMode('no_locks_or_bc');
|
|
131
1335
|
}
|
|
132
1336
|
}
|
|
133
|
-
startSocketConnection() {
|
|
134
|
-
this.
|
|
1337
|
+
async startSocketConnection() {
|
|
1338
|
+
if (this._socket || this._state === 'disposed' || this._isSuspended)
|
|
1339
|
+
return;
|
|
1340
|
+
const authPayload = await this.getFreshAuthPayload();
|
|
1341
|
+
const socketOptions = {
|
|
135
1342
|
transports: ['websocket', 'polling'],
|
|
136
1343
|
autoConnect: true,
|
|
137
1344
|
reconnection: true,
|
|
138
1345
|
reconnectionDelay: 1000,
|
|
1346
|
+
};
|
|
1347
|
+
if (authPayload) {
|
|
1348
|
+
socketOptions.auth = authPayload;
|
|
1349
|
+
}
|
|
1350
|
+
if (typeof this._options.socketFactory === 'function') {
|
|
1351
|
+
this._socket = this._options.socketFactory(this._serverUrl, socketOptions);
|
|
1352
|
+
}
|
|
1353
|
+
else {
|
|
1354
|
+
this._socket = (0, socket_io_client_1.io)(this._serverUrl, socketOptions);
|
|
1355
|
+
}
|
|
1356
|
+
const onConnectHandler = () => {
|
|
1357
|
+
this._isConnected = true;
|
|
1358
|
+
this._isLeaderSocketConnected = true;
|
|
1359
|
+
if (!this._lastReportedConnected) {
|
|
1360
|
+
this._lastReportedConnected = true;
|
|
1361
|
+
this.dispatch('open', { socketId: this._socket?.id });
|
|
1362
|
+
}
|
|
1363
|
+
this.broadcastToFollowers('connect', { socketId: this._socket?.id });
|
|
1364
|
+
// Resubscribe all local + follower active paths
|
|
1365
|
+
const allPaths = new Set(this.localSubscribedPaths.keys());
|
|
1366
|
+
this.followerSubscriptions.forEach((set, path) => {
|
|
1367
|
+
if (set.size > 0)
|
|
1368
|
+
allPaths.add(path);
|
|
1369
|
+
});
|
|
1370
|
+
allPaths.forEach((path) => {
|
|
1371
|
+
if (path && this._socket) {
|
|
1372
|
+
this._socket.emit('subscribe', this._projectId, path);
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
this.checkReadinessWaiters();
|
|
1376
|
+
};
|
|
1377
|
+
this._socket.on('connect', onConnectHandler);
|
|
1378
|
+
if (this._socket.connected) {
|
|
1379
|
+
onConnectHandler();
|
|
1380
|
+
}
|
|
1381
|
+
this._socket.on('data_changed', (payload) => {
|
|
1382
|
+
this.handleSocketDataEvent('data_changed', payload);
|
|
1383
|
+
});
|
|
1384
|
+
this._socket.on('firestore_changed', (payload) => {
|
|
1385
|
+
this.handleSocketDataEvent('firestore_changed', payload);
|
|
139
1386
|
});
|
|
140
|
-
this.
|
|
141
|
-
this.isConnected = true;
|
|
142
|
-
this.dispatch('open', { socketId: this.socket?.id });
|
|
143
|
-
this.broadcastToFollowers('connect', { socketId: this.socket?.id });
|
|
144
|
-
// Always join project room
|
|
145
|
-
this.socket?.emit('subscribe', this.projectId, '');
|
|
146
|
-
// Resubscribe to all paths
|
|
147
|
-
this.subscribedPaths.forEach((path) => {
|
|
148
|
-
this.socket?.emit('subscribe', this.projectId, path);
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
this.socket.on('data_changed', (payload) => {
|
|
152
|
-
this.dispatch('data_changed', payload);
|
|
153
|
-
this.dispatch('message', payload);
|
|
154
|
-
this.dispatch('all', payload);
|
|
155
|
-
this.broadcastToFollowers('data_changed', payload);
|
|
156
|
-
});
|
|
157
|
-
this.socket.on('firestore_changed', (payload) => {
|
|
158
|
-
this.dispatch('firestore_changed', payload);
|
|
159
|
-
this.dispatch('message', payload);
|
|
160
|
-
this.dispatch('all', payload);
|
|
161
|
-
this.broadcastToFollowers('firestore_changed', payload);
|
|
162
|
-
});
|
|
163
|
-
this.socket.on('activity_logged', (payload) => {
|
|
1387
|
+
this._socket.on('activity_logged', (payload) => {
|
|
164
1388
|
this.dispatch('activity_logged', payload);
|
|
165
1389
|
this.broadcastToFollowers('activity_logged', payload);
|
|
166
1390
|
});
|
|
167
|
-
this.
|
|
1391
|
+
this._socket.on('stats_update', (payload) => {
|
|
168
1392
|
this.dispatch('stats_update', payload);
|
|
169
1393
|
this.broadcastToFollowers('stats_update', payload);
|
|
170
1394
|
});
|
|
171
|
-
this.
|
|
172
|
-
|
|
173
|
-
|
|
1395
|
+
this._socket.on('connect_error', (err) => {
|
|
1396
|
+
const errObj = {
|
|
1397
|
+
name: err?.name || 'ConnectError',
|
|
1398
|
+
message: err?.message || String(err),
|
|
1399
|
+
code: 'unavailable',
|
|
1400
|
+
retryable: true,
|
|
1401
|
+
source: 'server',
|
|
1402
|
+
isAmbiguous: false,
|
|
1403
|
+
};
|
|
1404
|
+
this.dispatch('error', errObj);
|
|
1405
|
+
this.broadcastToFollowers('error', errObj);
|
|
174
1406
|
});
|
|
175
|
-
this.
|
|
176
|
-
this.
|
|
177
|
-
this.
|
|
1407
|
+
this._socket.on('disconnect', (reason) => {
|
|
1408
|
+
this._isConnected = false;
|
|
1409
|
+
this._isLeaderSocketConnected = false;
|
|
1410
|
+
if (this._lastReportedConnected) {
|
|
1411
|
+
this._lastReportedConnected = false;
|
|
1412
|
+
this.dispatch('close', { reason });
|
|
1413
|
+
}
|
|
178
1414
|
this.broadcastToFollowers('disconnect', { reason });
|
|
1415
|
+
// Handle non-reconnecting disconnects (e.g. io server disconnect)
|
|
1416
|
+
if (reason === 'io server disconnect' &&
|
|
1417
|
+
this._state !== 'disposed' &&
|
|
1418
|
+
!this._isSuspended &&
|
|
1419
|
+
this.isLeader &&
|
|
1420
|
+
this.hasNetworkNeeds()) {
|
|
1421
|
+
if (this.reconnectTimer)
|
|
1422
|
+
clearTimeout(this.reconnectTimer);
|
|
1423
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
1424
|
+
if (this._state !== 'disposed' &&
|
|
1425
|
+
!this._isSuspended &&
|
|
1426
|
+
this.isLeader &&
|
|
1427
|
+
this.hasNetworkNeeds()) {
|
|
1428
|
+
const freshAuth = await this.getFreshAuthPayload();
|
|
1429
|
+
if (this._socket) {
|
|
1430
|
+
if (freshAuth)
|
|
1431
|
+
this._socket.auth = freshAuth;
|
|
1432
|
+
this._socket.connect();
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}, 1000);
|
|
1436
|
+
}
|
|
179
1437
|
});
|
|
180
1438
|
}
|
|
181
|
-
|
|
1439
|
+
isLocalSubscribedToPath(eventPath) {
|
|
1440
|
+
for (const subPath of this.localSubscribedPaths.keys()) {
|
|
1441
|
+
if (WebSocketClient.isPathMatch(eventPath, subPath)) {
|
|
1442
|
+
return true;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return false;
|
|
1446
|
+
}
|
|
1447
|
+
handleSocketDataEvent(eventName, payload) {
|
|
1448
|
+
const pathResult = WebSocketClient.extractPayloadPathTriState(payload);
|
|
1449
|
+
// If an invalid path was supplied, reject/quarantine to prevent malicious or malformed global broadcast
|
|
1450
|
+
if (pathResult.status === 'invalid') {
|
|
1451
|
+
console.warn(`[WebSocketClient] Quarantined event "${eventName}" with malformed path:`, pathResult.raw);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
const eventPath = pathResult.status === 'valid' ? pathResult.path : null;
|
|
1455
|
+
// For data_changed / firestore_changed: if no path was provided and no explicit global marker is present, reject
|
|
1456
|
+
if (!eventPath && (eventName === 'data_changed' || eventName === 'firestore_changed')) {
|
|
1457
|
+
const isExplicitGlobal = Boolean(payload && typeof payload === 'object' && payload.isGlobal);
|
|
1458
|
+
if (!isExplicitGlobal) {
|
|
1459
|
+
console.warn(`[WebSocketClient] Ignored un-targeted change event "${eventName}" without path`);
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
const shouldDispatchLocally = eventPath ? this.isLocalSubscribedToPath(eventPath) : true;
|
|
1464
|
+
if (shouldDispatchLocally) {
|
|
1465
|
+
this.dispatch(eventName, payload);
|
|
1466
|
+
if (eventName === 'data_changed' || eventName === 'firestore_changed') {
|
|
1467
|
+
this.dispatch('message', payload);
|
|
1468
|
+
this.dispatch('all', payload);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
// Route targeted event to matching follower tabs
|
|
1472
|
+
let targetTabIds;
|
|
1473
|
+
if (eventPath) {
|
|
1474
|
+
const targets = new Set();
|
|
1475
|
+
this.followerSubscriptions.forEach((subTabs, subPath) => {
|
|
1476
|
+
if (WebSocketClient.isPathMatch(eventPath, subPath)) {
|
|
1477
|
+
subTabs.forEach((id) => targets.add(id));
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
targetTabIds = Array.from(targets);
|
|
1481
|
+
}
|
|
1482
|
+
else {
|
|
1483
|
+
targetTabIds = undefined;
|
|
1484
|
+
}
|
|
1485
|
+
this.broadcastToFollowers(eventName, payload, targetTabIds);
|
|
1486
|
+
}
|
|
1487
|
+
executeSendAsLeader(event, data, deadline) {
|
|
182
1488
|
return new Promise((resolve, reject) => {
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
return reject(new
|
|
1489
|
+
const remainingMs = deadline - Date.now();
|
|
1490
|
+
if (remainingMs <= 0) {
|
|
1491
|
+
return reject(new NexaWebSocketError({
|
|
1492
|
+
message: `Send operation timed out before execution for event "${event}"`,
|
|
1493
|
+
code: 'timeout',
|
|
1494
|
+
source: 'leader',
|
|
1495
|
+
}));
|
|
186
1496
|
}
|
|
187
|
-
if (!this.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
1497
|
+
if (!this._socket || !this._isConnected) {
|
|
1498
|
+
return reject(new NexaWebSocketError({
|
|
1499
|
+
message: 'WebSocket client unavailable or disconnected',
|
|
1500
|
+
code: 'disconnected',
|
|
1501
|
+
source: 'leader',
|
|
1502
|
+
}));
|
|
191
1503
|
}
|
|
192
|
-
|
|
1504
|
+
const requestId = `leader_send_${WebSocketClient.generateId()}`;
|
|
1505
|
+
let completed = false;
|
|
1506
|
+
const cleanup = () => {
|
|
1507
|
+
completed = true;
|
|
1508
|
+
if (execution.timer) {
|
|
1509
|
+
clearTimeout(execution.timer);
|
|
1510
|
+
execution.timer = null;
|
|
1511
|
+
}
|
|
1512
|
+
this.activeLeaderSends.delete(requestId);
|
|
1513
|
+
};
|
|
1514
|
+
const timer = setTimeout(() => {
|
|
1515
|
+
if (completed)
|
|
1516
|
+
return;
|
|
1517
|
+
cleanup();
|
|
1518
|
+
const isMutating = WebSocketClient.isMutatingEvent(event);
|
|
1519
|
+
reject(new NexaWebSocketError({
|
|
1520
|
+
message: `WebSocket send timeout for event "${event}"`,
|
|
1521
|
+
code: isMutating ? 'ambiguous-outcome' : 'timeout',
|
|
1522
|
+
isAmbiguous: isMutating,
|
|
1523
|
+
source: 'leader',
|
|
1524
|
+
}));
|
|
1525
|
+
}, remainingMs);
|
|
1526
|
+
const execution = {
|
|
1527
|
+
requestId,
|
|
1528
|
+
cancelled: false,
|
|
1529
|
+
timer,
|
|
1530
|
+
cleanup,
|
|
1531
|
+
};
|
|
1532
|
+
this.activeLeaderSends.set(requestId, execution);
|
|
1533
|
+
this._socket.emit(event, data, (response) => {
|
|
1534
|
+
if (completed || execution.cancelled || this._state === 'disposed')
|
|
1535
|
+
return;
|
|
1536
|
+
cleanup();
|
|
193
1537
|
if (response && response.error) {
|
|
194
|
-
reject(
|
|
1538
|
+
reject(NexaWebSocketError.from(response.error, 'server'));
|
|
195
1539
|
}
|
|
196
1540
|
else {
|
|
197
1541
|
resolve(response);
|
|
@@ -199,13 +1543,226 @@ class WebSocketClient {
|
|
|
199
1543
|
});
|
|
200
1544
|
});
|
|
201
1545
|
}
|
|
1546
|
+
checkReadinessWaiters() {
|
|
1547
|
+
if (this.readinessWaiters.size === 0)
|
|
1548
|
+
return;
|
|
1549
|
+
const isReady = this._state === 'disposed' ||
|
|
1550
|
+
(this.isLeader && this._isConnected) ||
|
|
1551
|
+
(this._coordinationMode === 'independent' && this._isConnected) ||
|
|
1552
|
+
(this._state === 'follower' &&
|
|
1553
|
+
this._currentLeaderId !== null &&
|
|
1554
|
+
this._currentElectionId !== null &&
|
|
1555
|
+
this._isLeaderSocketConnected);
|
|
1556
|
+
if (!isReady)
|
|
1557
|
+
return;
|
|
1558
|
+
const waiters = Array.from(this.readinessWaiters);
|
|
1559
|
+
this.readinessWaiters.clear();
|
|
1560
|
+
waiters.forEach((waiter) => {
|
|
1561
|
+
clearTimeout(waiter.timer);
|
|
1562
|
+
if (this._state === 'disposed') {
|
|
1563
|
+
waiter.reject(new NexaWebSocketError({
|
|
1564
|
+
message: 'WebSocketClient is disposed',
|
|
1565
|
+
code: 'disposed',
|
|
1566
|
+
}));
|
|
1567
|
+
}
|
|
1568
|
+
else {
|
|
1569
|
+
waiter.resolve();
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
waitForReady(deadline) {
|
|
1574
|
+
if (this._state === 'disposed') {
|
|
1575
|
+
return Promise.reject(new NexaWebSocketError({
|
|
1576
|
+
message: 'WebSocketClient is disposed',
|
|
1577
|
+
code: 'disposed',
|
|
1578
|
+
}));
|
|
1579
|
+
}
|
|
1580
|
+
const isReady = (this.isLeader && this._isConnected) ||
|
|
1581
|
+
(this._coordinationMode === 'independent' && this._isConnected) ||
|
|
1582
|
+
(this._state === 'follower' &&
|
|
1583
|
+
this._currentLeaderId !== null &&
|
|
1584
|
+
this._currentElectionId !== null &&
|
|
1585
|
+
this._isLeaderSocketConnected);
|
|
1586
|
+
if (isReady) {
|
|
1587
|
+
return Promise.resolve();
|
|
1588
|
+
}
|
|
1589
|
+
const remainingMs = deadline - Date.now();
|
|
1590
|
+
if (remainingMs <= 0) {
|
|
1591
|
+
return Promise.reject(new NexaWebSocketError({
|
|
1592
|
+
message: 'Send operation timed out waiting for connection/leader',
|
|
1593
|
+
code: 'connection-timeout',
|
|
1594
|
+
}));
|
|
1595
|
+
}
|
|
1596
|
+
return new Promise((resolve, reject) => {
|
|
1597
|
+
const timer = setTimeout(() => {
|
|
1598
|
+
this.readinessWaiters.delete(waiter);
|
|
1599
|
+
reject(new NexaWebSocketError({
|
|
1600
|
+
message: 'Send operation timed out waiting for connection/leader',
|
|
1601
|
+
code: 'connection-timeout',
|
|
1602
|
+
}));
|
|
1603
|
+
}, remainingMs);
|
|
1604
|
+
const waiter = { deadline, resolve, reject, timer };
|
|
1605
|
+
this.readinessWaiters.add(waiter);
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
static isMutatingEvent(event) {
|
|
1609
|
+
const lower = event.toLowerCase();
|
|
1610
|
+
return (lower.includes('write') ||
|
|
1611
|
+
lower.includes('set') ||
|
|
1612
|
+
lower.includes('update') ||
|
|
1613
|
+
lower.includes('delete') ||
|
|
1614
|
+
lower.includes('patch') ||
|
|
1615
|
+
lower.includes('create') ||
|
|
1616
|
+
lower.includes('batch') ||
|
|
1617
|
+
lower.includes('transaction'));
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Production-hardened request execution with invariant counter handling in a single finally block.
|
|
1621
|
+
*/
|
|
1622
|
+
async send(event, data, timeoutMs) {
|
|
1623
|
+
if (typeof event !== 'string' || !event.trim()) {
|
|
1624
|
+
throw new NexaWebSocketError({
|
|
1625
|
+
message: 'Invalid event: must be a non-empty string',
|
|
1626
|
+
code: 'invalid-argument',
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
if (WebSocketClient.RESERVED_SEND_EVENTS.has(event.trim().toLowerCase())) {
|
|
1630
|
+
throw new NexaWebSocketError({
|
|
1631
|
+
message: `Event "${event}" is reserved by the WebSocket transport protocol`,
|
|
1632
|
+
code: 'invalid-argument',
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
if (this._state === 'disposed') {
|
|
1636
|
+
throw new NexaWebSocketError({
|
|
1637
|
+
message: 'WebSocketClient is disposed',
|
|
1638
|
+
code: 'disposed',
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
const effectiveTimeout = typeof timeoutMs === 'number'
|
|
1642
|
+
? timeoutMs
|
|
1643
|
+
: typeof this.defaultTimeoutMs === 'number'
|
|
1644
|
+
? this.defaultTimeoutMs
|
|
1645
|
+
: WebSocketClient.DEFAULT_TIMEOUT_MS;
|
|
1646
|
+
if (!Number.isFinite(effectiveTimeout) || effectiveTimeout <= 0) {
|
|
1647
|
+
throw new NexaWebSocketError({
|
|
1648
|
+
message: `Invalid timeout value: ${effectiveTimeout}. Must be a positive finite number.`,
|
|
1649
|
+
code: 'invalid-argument',
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
const deadline = Date.now() + Math.min(effectiveTimeout, 300000);
|
|
1653
|
+
const isMutating = WebSocketClient.isMutatingEvent(event);
|
|
1654
|
+
// Invariant: Counter is incremented exactly once here
|
|
1655
|
+
this.activeLocalSendsCount++;
|
|
1656
|
+
try {
|
|
1657
|
+
this.ensureConnected();
|
|
1658
|
+
await this.waitForReady(deadline);
|
|
1659
|
+
if (this._state === 'disposed') {
|
|
1660
|
+
throw new NexaWebSocketError({
|
|
1661
|
+
message: 'WebSocketClient is disposed',
|
|
1662
|
+
code: 'disposed',
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
const remainingMs = deadline - Date.now();
|
|
1666
|
+
if (remainingMs <= 0) {
|
|
1667
|
+
throw new NexaWebSocketError({
|
|
1668
|
+
message: `Send operation timed out for event "${event}"`,
|
|
1669
|
+
code: 'timeout',
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
if (this.isLeader || this._coordinationMode === 'independent') {
|
|
1673
|
+
return await this.executeSendAsLeader(event, data, deadline);
|
|
1674
|
+
}
|
|
1675
|
+
// Follower tab: forward to active leader via BroadcastChannel
|
|
1676
|
+
const expectedLeaderId = this._currentLeaderId;
|
|
1677
|
+
const expectedElectionId = this._currentElectionId;
|
|
1678
|
+
if (!expectedLeaderId || !expectedElectionId) {
|
|
1679
|
+
throw new NexaWebSocketError({
|
|
1680
|
+
message: 'No active leader elected to forward request',
|
|
1681
|
+
code: 'unavailable',
|
|
1682
|
+
source: 'follower',
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
const requestId = `req_${WebSocketClient.generateId()}`;
|
|
1686
|
+
const idempotencyKey = isMutating ? `idem_${WebSocketClient.generateId()}` : undefined;
|
|
1687
|
+
return await new Promise((resolve, reject) => {
|
|
1688
|
+
const timer = setTimeout(() => {
|
|
1689
|
+
this.pendingSendRequests.delete(requestId);
|
|
1690
|
+
reject(new NexaWebSocketError({
|
|
1691
|
+
message: `Follower send timeout for event "${event}"`,
|
|
1692
|
+
code: isMutating ? 'ambiguous-outcome' : 'timeout',
|
|
1693
|
+
isAmbiguous: isMutating,
|
|
1694
|
+
source: 'follower',
|
|
1695
|
+
}));
|
|
1696
|
+
}, remainingMs);
|
|
1697
|
+
this.pendingSendRequests.set(requestId, {
|
|
1698
|
+
requestId,
|
|
1699
|
+
resolve,
|
|
1700
|
+
reject,
|
|
1701
|
+
timer,
|
|
1702
|
+
deadline,
|
|
1703
|
+
expectedLeaderId,
|
|
1704
|
+
expectedElectionId,
|
|
1705
|
+
isMutating,
|
|
1706
|
+
});
|
|
1707
|
+
const posted = this.postBroadcast('follower_send', {
|
|
1708
|
+
requestId,
|
|
1709
|
+
expectedLeaderId,
|
|
1710
|
+
expectedElectionId,
|
|
1711
|
+
event,
|
|
1712
|
+
data,
|
|
1713
|
+
targetTabId: this._tabId,
|
|
1714
|
+
deadline,
|
|
1715
|
+
idempotencyKey,
|
|
1716
|
+
});
|
|
1717
|
+
if (!posted) {
|
|
1718
|
+
clearTimeout(timer);
|
|
1719
|
+
this.pendingSendRequests.delete(requestId);
|
|
1720
|
+
reject(new NexaWebSocketError({
|
|
1721
|
+
message: 'Failed to post message to BroadcastChannel',
|
|
1722
|
+
code: 'broadcast-failure',
|
|
1723
|
+
source: 'follower',
|
|
1724
|
+
}));
|
|
1725
|
+
}
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
finally {
|
|
1729
|
+
// Invariant: Counter is decremented exactly once in this single finally block
|
|
1730
|
+
this.activeLocalSendsCount = Math.max(0, this.activeLocalSendsCount - 1);
|
|
1731
|
+
this.checkCloseConnection();
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
202
1734
|
checkCloseConnection() {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
this.
|
|
207
|
-
|
|
1735
|
+
const current = this._state;
|
|
1736
|
+
if (current === 'disposed' ||
|
|
1737
|
+
current === 'disconnecting' ||
|
|
1738
|
+
this._isSuspended) {
|
|
1739
|
+
return;
|
|
208
1740
|
}
|
|
1741
|
+
if (this.hasNetworkNeeds())
|
|
1742
|
+
return;
|
|
1743
|
+
this.disconnectSocket();
|
|
1744
|
+
}
|
|
1745
|
+
hasNetworkNeeds() {
|
|
1746
|
+
if (this.hasLocalNeeds())
|
|
1747
|
+
return true;
|
|
1748
|
+
if (this.isLeader && this._coordinationMode === 'shared') {
|
|
1749
|
+
let totalFollowerSubs = 0;
|
|
1750
|
+
this.followerSubscriptions.forEach((set) => {
|
|
1751
|
+
totalFollowerSubs += set.size;
|
|
1752
|
+
});
|
|
1753
|
+
let totalFollowerGlobalListeners = 0;
|
|
1754
|
+
this.followerGlobalNeeds.forEach((need) => {
|
|
1755
|
+
totalFollowerGlobalListeners += need.globalListenersCount;
|
|
1756
|
+
});
|
|
1757
|
+
if (totalFollowerSubs > 0 || totalFollowerGlobalListeners > 0)
|
|
1758
|
+
return true;
|
|
1759
|
+
}
|
|
1760
|
+
return false;
|
|
1761
|
+
}
|
|
1762
|
+
hasLocalNeeds() {
|
|
1763
|
+
return (this.hasListeners() ||
|
|
1764
|
+
this.localSubscribedPaths.size > 0 ||
|
|
1765
|
+
this.activeLocalSendsCount > 0);
|
|
209
1766
|
}
|
|
210
1767
|
hasListeners() {
|
|
211
1768
|
let count = 0;
|
|
@@ -214,32 +1771,239 @@ class WebSocketClient {
|
|
|
214
1771
|
});
|
|
215
1772
|
return count > 0;
|
|
216
1773
|
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Dispatches events using a defensive snapshot of listeners to avoid mutation hazards.
|
|
1776
|
+
*/
|
|
217
1777
|
dispatch(event, data) {
|
|
218
1778
|
const callbacks = this.listeners.get(event);
|
|
219
|
-
if (callbacks) {
|
|
220
|
-
|
|
1779
|
+
if (callbacks && callbacks.size > 0) {
|
|
1780
|
+
const snapshot = Array.from(callbacks);
|
|
1781
|
+
for (const cb of snapshot) {
|
|
221
1782
|
try {
|
|
222
1783
|
cb(data);
|
|
223
1784
|
}
|
|
224
1785
|
catch (e) {
|
|
225
|
-
console.error(`[WebSocketClient] Error in listener for event ${event}:`, e);
|
|
1786
|
+
console.error(`[WebSocketClient] Error in listener for event "${event}":`, e);
|
|
226
1787
|
}
|
|
227
|
-
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
releaseLeaderLock() {
|
|
1792
|
+
if (this.lockAbortController) {
|
|
1793
|
+
this.lockAbortController.abort();
|
|
1794
|
+
this.lockAbortController = null;
|
|
1795
|
+
}
|
|
1796
|
+
if (this.resolveLeaderLock) {
|
|
1797
|
+
this.resolveLeaderLock();
|
|
1798
|
+
this.resolveLeaderLock = null;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
disconnectSocket() {
|
|
1802
|
+
if (this._state === 'disposed')
|
|
1803
|
+
return;
|
|
1804
|
+
this.stopFollowerHeartbeat();
|
|
1805
|
+
this.stopLeaderPruneTimer();
|
|
1806
|
+
this.stopLeaderLeaseTimer();
|
|
1807
|
+
if (this.reconnectTimer) {
|
|
1808
|
+
clearTimeout(this.reconnectTimer);
|
|
1809
|
+
this.reconnectTimer = null;
|
|
1810
|
+
}
|
|
1811
|
+
if (this._socket) {
|
|
1812
|
+
this._socket.removeAllListeners();
|
|
1813
|
+
this._socket.disconnect();
|
|
1814
|
+
this._socket = null;
|
|
1815
|
+
}
|
|
1816
|
+
this._isConnected = false;
|
|
1817
|
+
this._isLeaderSocketConnected = false;
|
|
1818
|
+
if (this._lastReportedConnected) {
|
|
1819
|
+
this._lastReportedConnected = false;
|
|
1820
|
+
this.dispatch('close', { reason: 'client disconnected' });
|
|
1821
|
+
}
|
|
1822
|
+
this.releaseLeaderLock();
|
|
1823
|
+
this.transitionTo('idle');
|
|
1824
|
+
}
|
|
1825
|
+
setupPageLifecycleListeners() {
|
|
1826
|
+
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function')
|
|
1827
|
+
return;
|
|
1828
|
+
const onBeforeUnload = () => {
|
|
1829
|
+
if (this._state === 'disposed')
|
|
1830
|
+
return;
|
|
1831
|
+
if (!this.isLeader && this._coordinationMode === 'shared') {
|
|
1832
|
+
this.postBroadcast('follower_disconnect', { tabId: this._tabId });
|
|
1833
|
+
}
|
|
1834
|
+
this.releaseLeaderLock();
|
|
1835
|
+
if (this._socket) {
|
|
1836
|
+
this._socket.disconnect();
|
|
1837
|
+
}
|
|
1838
|
+
};
|
|
1839
|
+
const onPageHide = () => {
|
|
1840
|
+
if (this._state === 'disposed')
|
|
1841
|
+
return;
|
|
1842
|
+
this._isSuspended = true;
|
|
1843
|
+
this.stopFollowerHeartbeat();
|
|
1844
|
+
this.stopLeaderPruneTimer();
|
|
1845
|
+
this.stopLeaderLeaseTimer();
|
|
1846
|
+
if (this.reconnectTimer) {
|
|
1847
|
+
clearTimeout(this.reconnectTimer);
|
|
1848
|
+
this.reconnectTimer = null;
|
|
1849
|
+
}
|
|
1850
|
+
this.releaseLeaderLock();
|
|
1851
|
+
if (this._socket) {
|
|
1852
|
+
this._socket.removeAllListeners();
|
|
1853
|
+
this._socket.disconnect();
|
|
1854
|
+
this._socket = null;
|
|
1855
|
+
}
|
|
1856
|
+
this._isConnected = false;
|
|
1857
|
+
this._isLeaderSocketConnected = false;
|
|
1858
|
+
this._currentLeaderId = null;
|
|
1859
|
+
this._currentElectionId = null;
|
|
1860
|
+
this._electionId = null;
|
|
1861
|
+
if (this._lastReportedConnected) {
|
|
1862
|
+
this._lastReportedConnected = false;
|
|
1863
|
+
this.dispatch('close', { reason: 'page suspended' });
|
|
1864
|
+
}
|
|
1865
|
+
this.transitionTo('idle');
|
|
1866
|
+
};
|
|
1867
|
+
const onPageShow = (event) => {
|
|
1868
|
+
if (this._state === 'disposed')
|
|
1869
|
+
return;
|
|
1870
|
+
this._isSuspended = false;
|
|
1871
|
+
if (event?.persisted) {
|
|
1872
|
+
this.dispatch('resync', { reason: 'bfcache_restore' });
|
|
1873
|
+
if (this.hasLocalNeeds()) {
|
|
1874
|
+
this.ensureConnected();
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1878
|
+
const onVisibilityChange = () => {
|
|
1879
|
+
if (this._state === 'disposed' ||
|
|
1880
|
+
this._isSuspended ||
|
|
1881
|
+
typeof document === 'undefined') {
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
if (document.visibilityState === 'visible') {
|
|
1885
|
+
if (!this.isLeader && this._coordinationMode === 'shared') {
|
|
1886
|
+
this.postBroadcast('follower_heartbeat', {
|
|
1887
|
+
globalListenersCount: this.getGlobalListenersCount(),
|
|
1888
|
+
paths: Array.from(this.localSubscribedPaths.keys()),
|
|
1889
|
+
});
|
|
1890
|
+
this.postBroadcast('follower_announce', {
|
|
1891
|
+
paths: Array.from(this.localSubscribedPaths.keys()),
|
|
1892
|
+
globalListenersCount: this.getGlobalListenersCount(),
|
|
1893
|
+
});
|
|
1894
|
+
this.postBroadcast('follower_sync_request');
|
|
1895
|
+
}
|
|
1896
|
+
if (this.hasLocalNeeds() && this._state === 'idle') {
|
|
1897
|
+
this.ensureConnected();
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
};
|
|
1901
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
1902
|
+
window.addEventListener('pagehide', onPageHide);
|
|
1903
|
+
window.addEventListener('pageshow', onPageShow);
|
|
1904
|
+
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
|
1905
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
228
1906
|
}
|
|
1907
|
+
this.pageLifecycleCleanup = () => {
|
|
1908
|
+
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
1909
|
+
window.removeEventListener('pagehide', onPageHide);
|
|
1910
|
+
window.removeEventListener('pageshow', onPageShow);
|
|
1911
|
+
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
|
|
1912
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
1913
|
+
}
|
|
1914
|
+
};
|
|
229
1915
|
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Idempotent client teardown releasing locks, clearing queues, and sending best-effort disconnect notice.
|
|
1918
|
+
*/
|
|
230
1919
|
close() {
|
|
231
|
-
if (this.
|
|
232
|
-
|
|
233
|
-
|
|
1920
|
+
if (this._state === 'disposed')
|
|
1921
|
+
return;
|
|
1922
|
+
// Send best-effort disconnect broadcast before channel teardown and state change
|
|
1923
|
+
if (!this.isLeader && this._coordinationMode === 'shared') {
|
|
1924
|
+
this.postBroadcast('follower_disconnect', { tabId: this._tabId });
|
|
234
1925
|
}
|
|
235
|
-
this.
|
|
236
|
-
this.
|
|
1926
|
+
this._state = 'disposed';
|
|
1927
|
+
if (this.pageLifecycleCleanup) {
|
|
1928
|
+
this.pageLifecycleCleanup();
|
|
1929
|
+
this.pageLifecycleCleanup = null;
|
|
1930
|
+
}
|
|
1931
|
+
// Reject all readiness waiters
|
|
1932
|
+
this.readinessWaiters.forEach((waiter) => {
|
|
1933
|
+
clearTimeout(waiter.timer);
|
|
1934
|
+
waiter.reject(new NexaWebSocketError({
|
|
1935
|
+
message: 'WebSocketClient is disposed',
|
|
1936
|
+
code: 'disposed',
|
|
1937
|
+
}));
|
|
1938
|
+
});
|
|
1939
|
+
this.readinessWaiters.clear();
|
|
1940
|
+
// Reject all pending follower sends
|
|
1941
|
+
this.pendingSendRequests.forEach((req) => {
|
|
1942
|
+
clearTimeout(req.timer);
|
|
1943
|
+
req.reject(new NexaWebSocketError({
|
|
1944
|
+
message: 'WebSocketClient is disposed',
|
|
1945
|
+
code: req.isMutating ? 'ambiguous-outcome' : 'disposed',
|
|
1946
|
+
isAmbiguous: req.isMutating,
|
|
1947
|
+
}));
|
|
1948
|
+
});
|
|
1949
|
+
this.pendingSendRequests.clear();
|
|
1950
|
+
// Cancel all active leader executions
|
|
1951
|
+
this.activeLeaderSends.forEach((exec) => {
|
|
1952
|
+
exec.cancelled = true;
|
|
1953
|
+
exec.cleanup();
|
|
1954
|
+
});
|
|
1955
|
+
this.activeLeaderSends.clear();
|
|
1956
|
+
this.stopFollowerHeartbeat();
|
|
1957
|
+
this.stopLeaderPruneTimer();
|
|
1958
|
+
this.stopLeaderLeaseTimer();
|
|
1959
|
+
if (this.reconnectTimer) {
|
|
1960
|
+
clearTimeout(this.reconnectTimer);
|
|
1961
|
+
this.reconnectTimer = null;
|
|
1962
|
+
}
|
|
1963
|
+
if (this._socket) {
|
|
1964
|
+
this._socket.removeAllListeners();
|
|
1965
|
+
this._socket.disconnect();
|
|
1966
|
+
this._socket = null;
|
|
1967
|
+
}
|
|
1968
|
+
this._isConnected = false;
|
|
1969
|
+
this._isLeaderSocketConnected = false;
|
|
1970
|
+
this._electionId = null;
|
|
1971
|
+
this._currentLeaderId = null;
|
|
1972
|
+
this._currentElectionId = null;
|
|
1973
|
+
if (this._lastReportedConnected) {
|
|
1974
|
+
this._lastReportedConnected = false;
|
|
1975
|
+
this.dispatch('close', { reason: 'client disposed' });
|
|
1976
|
+
}
|
|
1977
|
+
this.releaseLeaderLock();
|
|
237
1978
|
this.listeners.clear();
|
|
238
|
-
this.
|
|
1979
|
+
this.localSubscribedPaths.clear();
|
|
1980
|
+
this.followerSubscriptions.clear();
|
|
1981
|
+
this.followerSubscriptionsByTab.clear();
|
|
1982
|
+
this.followerGlobalNeeds.clear();
|
|
1983
|
+
this.processedFollowerRequests.clear();
|
|
239
1984
|
if (this.broadcastChannel) {
|
|
240
|
-
|
|
1985
|
+
try {
|
|
1986
|
+
this.broadcastChannel.close();
|
|
1987
|
+
}
|
|
1988
|
+
catch { }
|
|
241
1989
|
this.broadcastChannel = null;
|
|
242
1990
|
}
|
|
243
1991
|
}
|
|
244
1992
|
}
|
|
245
1993
|
exports.WebSocketClient = WebSocketClient;
|
|
1994
|
+
WebSocketClient.DEFAULT_FOLLOWER_TTL = 30000;
|
|
1995
|
+
WebSocketClient.DEFAULT_HEARTBEAT_INTERVAL = 5000;
|
|
1996
|
+
WebSocketClient.DEFAULT_PRUNE_INTERVAL = 10000;
|
|
1997
|
+
WebSocketClient.DEFAULT_LEADER_LEASE_INTERVAL = 10000;
|
|
1998
|
+
WebSocketClient.DEFAULT_TIMEOUT_MS = 10000;
|
|
1999
|
+
WebSocketClient.MAX_DEDUP_CACHE_SIZE = 500;
|
|
2000
|
+
WebSocketClient.DEDUP_CACHE_TTL_MS = 60000;
|
|
2001
|
+
WebSocketClient.RESERVED_SEND_EVENTS = new Set([
|
|
2002
|
+
'connect',
|
|
2003
|
+
'connect_error',
|
|
2004
|
+
'disconnect',
|
|
2005
|
+
'disconnecting',
|
|
2006
|
+
'ping',
|
|
2007
|
+
'pong',
|
|
2008
|
+
'error',
|
|
2009
|
+
]);
|