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.
Files changed (35) hide show
  1. package/dist/app/NexaApp.d.ts +3 -0
  2. package/dist/app/NexaApp.js +38 -11
  3. package/dist/firestore/CollectionReference.d.ts +2 -2
  4. package/dist/firestore/CollectionReference.js +41 -2
  5. package/dist/firestore/DocumentReference.d.ts +1 -1
  6. package/dist/firestore/DocumentReference.js +41 -11
  7. package/dist/firestore/FieldPath.d.ts +2 -1
  8. package/dist/firestore/FieldPath.js +31 -4
  9. package/dist/firestore/FieldValue.d.ts +8 -4
  10. package/dist/firestore/FieldValue.js +59 -7
  11. package/dist/firestore/Firestore.js +16 -4
  12. package/dist/firestore/GeoPoint.d.ts +1 -0
  13. package/dist/firestore/GeoPoint.js +13 -6
  14. package/dist/firestore/Query.d.ts +5 -0
  15. package/dist/firestore/Query.js +190 -56
  16. package/dist/firestore/QueryUtils.d.ts +19 -2
  17. package/dist/firestore/QueryUtils.js +296 -58
  18. package/dist/firestore/Snapshot.js +62 -17
  19. package/dist/firestore/SnapshotManager.js +27 -12
  20. package/dist/firestore/Timestamp.d.ts +1 -0
  21. package/dist/firestore/Timestamp.js +28 -2
  22. package/dist/firestore/batch.js +89 -36
  23. package/dist/firestore/pathValidation.d.ts +17 -0
  24. package/dist/firestore/pathValidation.js +61 -0
  25. package/dist/firestore/queryConstraints.d.ts +4 -4
  26. package/dist/firestore/queryConstraints.js +69 -8
  27. package/dist/firestore/transaction.js +72 -18
  28. package/dist/firestore/writes.d.ts +1 -1
  29. package/dist/firestore/writes.js +32 -14
  30. package/dist/transport/WebSocketClient.d.ts +341 -9
  31. package/dist/transport/WebSocketClient.js +1895 -131
  32. package/dist/transport/WebSocketClient.test.d.ts +1 -0
  33. package/dist/transport/WebSocketClient.test.js +454 -0
  34. package/dist/types/index.d.ts +1 -0
  35. 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.socket = null;
8
- this.isConnected = false;
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.subscribedPaths = new Set();
11
- // Cross-tab sync properties
87
+ this.localSubscribedPaths = new Map(); // cleanPath -> refCount
88
+ this.activeLocalSendsCount = 0;
89
+ // Cross-Tab Synchronization
12
90
  this.broadcastChannel = null;
13
- this.isLeader = false;
14
- this.hasInitialized = false;
15
- this.projectId = projectId;
16
- this.serverUrl = serverUrl;
17
- if (typeof window !== 'undefined' && 'BroadcastChannel' in window) {
18
- this.broadcastChannel = new BroadcastChannel(`nexabase_ws_${projectId}`);
19
- this.broadcastChannel.onmessage = this.handleBroadcastMessage.bind(this);
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
- const { type, payload } = event.data;
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
- if (type === 'follower_subscribe') {
26
- this.socket?.emit('subscribe', this.projectId, payload.path);
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
- else if (type === 'follower_unsubscribe') {
29
- this.socket?.emit('unsubscribe', this.projectId, payload.path);
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
- else if (type === 'follower_sync_request') {
32
- this.broadcastChannel?.postMessage({ type: 'leader_state', payload: { isConnected: this.isConnected } });
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
- else {
36
- if (type === 'leader_event') {
37
- const { event: socketEvent, data } = payload;
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.isConnected = true;
40
- this.dispatch('open', data);
928
+ this._isLeaderSocketConnected = true;
929
+ this.updateFollowerConnectionState(true);
930
+ this.checkReadinessWaiters();
41
931
  }
42
932
  else if (socketEvent === 'disconnect') {
43
- this.isConnected = false;
44
- this.dispatch('close', data);
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 === 'leader_state') {
55
- this.isConnected = payload.isConnected;
56
- if (this.isConnected) {
57
- this.dispatch('open', {});
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 (!this.listeners.has(event)) {
64
- this.listeners.set(event, new Set());
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.listeners.get(event).add(callback);
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(event);
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(event);
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
- const cleanPath = (path || '').replace(/^\/+|\/+$/g, '');
81
- this.subscribedPaths.add(cleanPath);
82
- if (this.isLeader && this.socket && this.isConnected) {
83
- this.socket.emit('subscribe', this.projectId, cleanPath);
84
- }
85
- else if (!this.isLeader && this.broadcastChannel) {
86
- this.broadcastChannel.postMessage({ type: 'follower_subscribe', payload: { path: cleanPath } });
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
- const cleanPath = (path || '').replace(/^\/+|\/+$/g, '');
91
- this.subscribedPaths.delete(cleanPath);
92
- if (this.isLeader && this.socket && this.isConnected) {
93
- this.socket.emit('unsubscribe', this.projectId, cleanPath);
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 if (!this.isLeader && this.broadcastChannel) {
96
- this.broadcastChannel.postMessage({ type: 'follower_unsubscribe', payload: { path: cleanPath } });
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.broadcastChannel) {
101
- this.broadcastChannel.postMessage({ type: 'leader_event', payload: { event, data } });
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.hasInitialized || typeof window === 'undefined')
1266
+ if (this._state !== 'idle' || this._isSuspended || typeof window === 'undefined')
106
1267
  return;
107
- this.hasInitialized = true;
108
- if (navigator.locks) {
109
- navigator.locks.request(`nexabase_ws_leader_${this.projectId}`, { mode: 'exclusive', ifAvailable: false }, async (lock) => {
110
- // This promise won't resolve until we explicitly return from it (which we only do on close)
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.isLeader = true;
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
- }).catch(() => {
122
- // We are a follower
123
- this.isLeader = false;
124
- this.broadcastChannel?.postMessage({ type: 'follower_sync_request' });
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
- // Fallback if no Web Locks API
129
- this.isLeader = true;
130
- this.startSocketConnection();
1334
+ this.switchToIndependentMode('no_locks_or_bc');
131
1335
  }
132
1336
  }
133
- startSocketConnection() {
134
- this.socket = (0, socket_io_client_1.io)(this.serverUrl, {
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.socket.on('connect', () => {
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.socket.on('stats_update', (payload) => {
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.socket.on('connect_error', (err) => {
172
- this.dispatch('error', err);
173
- this.broadcastToFollowers('connect_error', err);
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.socket.on('disconnect', (reason) => {
176
- this.isConnected = false;
177
- this.dispatch('close', { reason });
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
- send(event, data) {
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
- this.ensureConnected();
184
- if (!this.socket && this.isLeader) {
185
- return reject(new Error('WebSocket client unavailable'));
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.isLeader) {
188
- // Followers can't send via websocket directly in this design without more plumbing.
189
- // We fallback to standard HTTP API for writes anyway.
190
- return reject(new Error('Cannot send WebSocket message from follower tab'));
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
- this.socket.emit(event, data, (response) => {
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(new Error(response.error));
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
- if (!this.hasListeners() && this.subscribedPaths.size === 0 && this.socket) {
204
- this.socket.disconnect();
205
- this.socket = null;
206
- this.isConnected = false;
207
- this.hasInitialized = false;
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
- callbacks.forEach((cb) => {
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.socket) {
232
- this.socket.disconnect();
233
- this.socket = null;
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.isConnected = false;
236
- this.hasInitialized = false;
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.subscribedPaths.clear();
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
- this.broadcastChannel.close();
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
+ ]);