@webex/internal-plugin-mercury 3.11.0 → 3.12.0-auth-prejoin-fetch.1

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/src/mercury.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import url from 'url';
7
7
 
8
8
  import {WebexPlugin} from '@webex/webex-core';
9
- import {deprecated, oneFlight} from '@webex/common';
9
+ import {deprecated} from '@webex/common';
10
10
  import {camelCase, get, set} from 'lodash';
11
11
  import backoff from 'backoff';
12
12
 
@@ -21,10 +21,12 @@ import {
21
21
  } from './errors';
22
22
 
23
23
  const normalReconnectReasons = ['idle', 'done (forced)', 'pong not received', 'pong mismatch'];
24
+ const MERCURY_CLOSE_4000_METRIC = 'JS_SDK_MERCURY_CLOSE_4000';
24
25
 
25
26
  const Mercury = WebexPlugin.extend({
26
27
  namespace: 'Mercury',
27
28
  lastError: undefined,
29
+ defaultSessionId: 'mercury-default-session',
28
30
 
29
31
  session: {
30
32
  connected: {
@@ -39,7 +41,22 @@ const Mercury = WebexPlugin.extend({
39
41
  default: false,
40
42
  type: 'boolean',
41
43
  },
42
- socket: 'object',
44
+ sockets: {
45
+ default: () => new Map(),
46
+ type: 'object',
47
+ },
48
+ sessionWebSocketUrls: {
49
+ default: () => new Map(),
50
+ type: 'object',
51
+ },
52
+ backoffCalls: {
53
+ default: () => new Map(),
54
+ type: 'object',
55
+ },
56
+ _shutdownSwitchoverBackoffCalls: {
57
+ default: () => new Map(),
58
+ type: 'object',
59
+ },
43
60
  localClusterServiceUrls: 'object',
44
61
  mercuryTimeOffset: {
45
62
  default: undefined,
@@ -99,50 +116,63 @@ const Mercury = WebexPlugin.extend({
99
116
  /**
100
117
  * Attach event listeners to a socket.
101
118
  * @param {Socket} socket - The socket to attach listeners to
119
+ * @param {sessionId} sessionId - The socket related session ID
102
120
  * @returns {void}
103
121
  */
104
- _attachSocketEventListeners(socket) {
105
- socket.on('close', (event) => this._onclose(event, socket));
106
- socket.on('message', (...args) => this._onmessage(...args));
107
- socket.on('pong', (...args) => this._setTimeOffset(...args));
108
- socket.on('sequence-mismatch', (...args) => this._emit('sequence-mismatch', ...args));
109
- socket.on('ping-pong-latency', (...args) => this._emit('ping-pong-latency', ...args));
122
+ _attachSocketEventListeners(socket, sessionId) {
123
+ socket.on('close', (event) => this._onclose(sessionId, event, socket));
124
+ socket.on('message', (...args) => this._onmessage(sessionId, ...args));
125
+ socket.on('pong', (...args) => this._setTimeOffset(sessionId, ...args));
126
+ socket.on('sequence-mismatch', (...args) =>
127
+ this._emit(sessionId, 'sequence-mismatch', ...args)
128
+ );
129
+ socket.on('ping-pong-latency', (...args) =>
130
+ this._emit(sessionId, 'ping-pong-latency', ...args)
131
+ );
110
132
  },
111
133
 
112
134
  /**
113
135
  * Handle imminent shutdown by establishing a new connection while keeping
114
136
  * the current one alive (make-before-break).
115
137
  * Idempotent: will no-op if already in progress.
138
+ * @param {string} sessionId - The session ID for which the shutdown is imminent
116
139
  * @returns {void}
117
140
  */
118
- _handleImminentShutdown() {
141
+ _handleImminentShutdown(sessionId) {
142
+ const oldSocket = this.sockets.get(sessionId);
143
+
119
144
  try {
120
- if (this._shutdownSwitchoverInProgress) {
121
- this.logger.info(`${this.namespace}: [shutdown] switchover already in progress`);
145
+ // Idempotent: if we already have a switchover backoff call for this session,
146
+ // a switchover is in progress – do nothing.
147
+ if (this._shutdownSwitchoverBackoffCalls.get(sessionId)) {
148
+ this.logger.info(
149
+ `${this.namespace}: [shutdown] switchover already in progress for ${sessionId}`
150
+ );
122
151
 
123
152
  return;
124
153
  }
125
- this._shutdownSwitchoverInProgress = true;
154
+
126
155
  this._shutdownSwitchoverId = `${Date.now()}`;
127
156
  this.logger.info(
128
- `${this.namespace}: [shutdown] switchover start, id=${this._shutdownSwitchoverId}`
157
+ `${this.namespace}: [shutdown] switchover start, id=${this._shutdownSwitchoverId} for ${sessionId}`
129
158
  );
130
159
 
131
- this._connectWithBackoff(undefined, {
160
+ this._connectWithBackoff(undefined, sessionId, {
132
161
  isShutdownSwitchover: true,
133
162
  attemptOptions: {
134
163
  isShutdownSwitchover: true,
135
164
  onSuccess: (newSocket, webSocketUrl) => {
136
165
  this.logger.info(
137
- `${this.namespace}: [shutdown] switchover connected, url: ${webSocketUrl}`
166
+ `${this.namespace}: [shutdown] switchover connected, url: ${webSocketUrl} for ${sessionId}`
138
167
  );
139
168
 
140
- const oldSocket = this.socket;
141
169
  // Atomically switch active socket reference
142
- this.socket = newSocket;
143
- this.connected = true; // remain connected throughout
170
+ this.socket = this.sockets.get(this.defaultSessionId);
171
+ this.connected = this.hasConnectedSockets(); // remain connected throughout
144
172
 
145
- this._emit('event:mercury_shutdown_switchover_complete', {url: webSocketUrl});
173
+ this._emit(sessionId, 'event:mercury_shutdown_switchover_complete', {
174
+ url: webSocketUrl,
175
+ });
146
176
 
147
177
  if (oldSocket) {
148
178
  this.logger.info(
@@ -153,20 +183,25 @@ const Mercury = WebexPlugin.extend({
153
183
  },
154
184
  })
155
185
  .then(() => {
156
- this.logger.info(`${this.namespace}: [shutdown] switchover completed successfully`);
186
+ this.logger.info(
187
+ `${this.namespace}: [shutdown] switchover completed successfully for ${sessionId}`
188
+ );
157
189
  })
158
190
  .catch((err) => {
159
191
  this.logger.info(
160
- `${this.namespace}: [shutdown] switchover exhausted retries; will fall back to normal reconnection`,
192
+ `${this.namespace}: [shutdown] switchover exhausted retries; will fall back to normal reconnection for ${sessionId}: `,
161
193
  err
162
194
  );
163
- this._emit('event:mercury_shutdown_switchover_failed', {reason: err});
195
+ this._emit(sessionId, 'event:mercury_shutdown_switchover_failed', {reason: err});
164
196
  // Old socket will eventually close with 4001, triggering normal reconnection
165
197
  });
166
198
  } catch (e) {
167
- this.logger.error(`${this.namespace}: [shutdown] error during switchover`, e);
168
- this._shutdownSwitchoverInProgress = false;
169
- this._emit('event:mercury_shutdown_switchover_failed', {reason: e});
199
+ this.logger.error(
200
+ `${this.namespace}: [shutdown] error during switchover for ${sessionId}`,
201
+ e
202
+ );
203
+ this._shutdownSwitchoverBackoffCalls.delete(sessionId);
204
+ this._emit(sessionId, 'event:mercury_shutdown_switchover_failed', {reason: e});
170
205
  }
171
206
  },
172
207
 
@@ -178,29 +213,95 @@ const Mercury = WebexPlugin.extend({
178
213
  return this.lastError;
179
214
  },
180
215
 
181
- @oneFlight
182
- connect(webSocketUrl) {
183
- if (this.connected) {
184
- this.logger.info(`${this.namespace}: already connected, will not connect again`);
216
+ /**
217
+ * Get all active socket connections
218
+ * @returns {Map} Map of sessionId to socket instances
219
+ */
220
+ getSockets() {
221
+ return this.sockets;
222
+ },
223
+
224
+ /**
225
+ * Get a specific socket by connection ID
226
+ * @param {string} sessionId - The connection identifier
227
+ * @returns {Socket|undefined} The socket instance or undefined if not found
228
+ */
229
+ getSocket(sessionId = this.defaultSessionId) {
230
+ return this.sockets.get(sessionId);
231
+ },
232
+
233
+ /**
234
+ * Check if a socket is connected
235
+ * @param {string} [sessionId=this.defaultSessionId] - The session identifier
236
+ * @returns {boolean|undefined} True if the socket is connected
237
+ */
238
+ hasConnectedSockets(sessionId = this.defaultSessionId) {
239
+ const socket = this.sockets.get(sessionId || this.defaultSessionId);
240
+
241
+ return socket?.connected;
242
+ },
243
+
244
+ /**
245
+ * Check if any sockets are connecting
246
+ * @param {string} [sessionId=this.defaultSessionId] - The session identifier
247
+ * @returns {boolean|undefined} True if the socket is connecting
248
+ */
249
+ hasConnectingSockets(sessionId = this.defaultSessionId) {
250
+ const socket = this.sockets.get(sessionId || this.defaultSessionId);
251
+
252
+ return socket?.connecting;
253
+ },
254
+
255
+ /**
256
+ * Connect to Mercury for a specific session.
257
+ * @param {string} [webSocketUrl] - Optional websocket URL override. Falls back to the device websocket URL.
258
+ * @param {string} [sessionId=this.defaultSessionId] - The session identifier for this connection.
259
+ * @returns {Promise<void>} Resolves when connection flow completes for the session.
260
+ */
261
+ connect(webSocketUrl, sessionId = this.defaultSessionId) {
262
+ if (!this._connectPromises) this._connectPromises = new Map();
263
+
264
+ // First check if there's already a connection promise for this session
265
+ if (this._connectPromises.has(sessionId)) {
266
+ this.logger.info(
267
+ `${this.namespace}: connection ${sessionId} already in progress, returning existing promise`
268
+ );
269
+
270
+ return this._connectPromises.get(sessionId);
271
+ }
272
+
273
+ const sessionSocket = this.sockets.get(sessionId);
274
+ if (sessionSocket?.connected || sessionSocket?.connecting) {
275
+ this.logger.info(
276
+ `${this.namespace}: connection ${sessionId} already connected, will not connect again`
277
+ );
185
278
 
186
279
  return Promise.resolve();
187
280
  }
188
281
 
189
282
  this.connecting = true;
190
283
 
191
- this.logger.info(`${this.namespace}: starting connection attempt`);
284
+ this.logger.info(`${this.namespace}: starting connection attempt for ${sessionId}`);
192
285
  this.logger.info(
193
286
  `${this.namespace}: debug_mercury_logging stack: `,
194
287
  new Error('debug_mercury_logging').stack
195
288
  );
196
289
 
197
- return Promise.resolve(
290
+ const connectPromise = Promise.resolve(
198
291
  this.webex.internal.device.registered || this.webex.internal.device.register()
199
- ).then(() => {
200
- this.logger.info(`${this.namespace}: connecting`);
292
+ )
293
+ .then(() => {
294
+ this.logger.info(`${this.namespace}: connecting ${sessionId}`);
201
295
 
202
- return this._connectWithBackoff(webSocketUrl);
203
- });
296
+ return this._connectWithBackoff(webSocketUrl, sessionId);
297
+ })
298
+ .finally(() => {
299
+ this._connectPromises.delete(sessionId);
300
+ });
301
+
302
+ this._connectPromises.set(sessionId, connectPromise);
303
+
304
+ return connectPromise;
204
305
  },
205
306
 
206
307
  logout() {
@@ -210,7 +311,7 @@ const Mercury = WebexPlugin.extend({
210
311
  new Error('debug_mercury_logging').stack
211
312
  );
212
313
 
213
- return this.disconnect(
314
+ return this.disconnectAll(
214
315
  this.config.beforeLogoutOptionsCloseReason &&
215
316
  !normalReconnectReasons.includes(this.config.beforeLogoutOptionsCloseReason)
216
317
  ? {code: 3050, reason: this.config.beforeLogoutOptionsCloseReason}
@@ -218,26 +319,75 @@ const Mercury = WebexPlugin.extend({
218
319
  );
219
320
  },
220
321
 
221
- @oneFlight
222
- disconnect(options) {
322
+ /**
323
+ * Disconnect a Mercury socket for a specific session.
324
+ * @param {object} [options] - Optional websocket close options (for example: `{code, reason}`).
325
+ * @param {string} [sessionId=this.defaultSessionId] - The session identifier to disconnect.
326
+ * @returns {Promise<void>} Resolves after disconnect cleanup and close handling are initiated/completed.
327
+ */
328
+ disconnect(options, sessionId = this.defaultSessionId) {
329
+ this.logger.info(
330
+ `${this.namespace}#disconnect: connecting state: ${this.connecting}, connected state: ${
331
+ this.connected
332
+ }, socket exists: ${!!this.socket}, options: ${JSON.stringify(options)}`
333
+ );
334
+
223
335
  return new Promise((resolve) => {
224
- if (this.backoffCall) {
225
- this.logger.info(`${this.namespace}: aborting connection`);
226
- this.backoffCall.abort();
336
+ const backoffCall = this.backoffCalls.get(sessionId);
337
+ if (backoffCall) {
338
+ this.logger.info(`${this.namespace}: aborting connection ${sessionId}`);
339
+ backoffCall.abort();
340
+ this.backoffCalls.delete(sessionId);
227
341
  }
228
-
229
- if (this._shutdownSwitchoverBackoffCall) {
230
- this.logger.info(`${this.namespace}: aborting shutdown switchover`);
231
- this._shutdownSwitchoverBackoffCall.abort();
342
+ const shutdownSwitchoverBackoffCall = this._shutdownSwitchoverBackoffCalls.get(sessionId);
343
+ if (shutdownSwitchoverBackoffCall) {
344
+ this.logger.info(`${this.namespace}: aborting shutdown switchover connection ${sessionId}`);
345
+ shutdownSwitchoverBackoffCall.abort();
346
+ this._shutdownSwitchoverBackoffCalls.delete(sessionId);
232
347
  }
233
-
234
- if (this.socket) {
235
- this.socket.removeAllListeners('message');
236
- this.once('offline', resolve);
237
- resolve(this.socket.close(options || undefined));
348
+ // Clean up any pending connection promises
349
+ if (this._connectPromises) {
350
+ this._connectPromises.delete(sessionId);
238
351
  }
239
352
 
353
+ const sessionSocket = this.sockets.get(sessionId);
354
+ const suffix = sessionId === this.defaultSessionId ? '' : `:${sessionId}`;
355
+
356
+ if (sessionSocket) {
357
+ sessionSocket.removeAllListeners('message');
358
+ sessionSocket.connecting = false;
359
+ sessionSocket.connected = false;
360
+ this.once(sessionId === this.defaultSessionId ? 'offline' : `offline${suffix}`, resolve);
361
+ resolve(sessionSocket.close(options || undefined));
362
+ }
240
363
  resolve();
364
+
365
+ // Update overall connected status
366
+ this.connected = this.hasConnectedSockets();
367
+ });
368
+ },
369
+
370
+ /**
371
+ * Disconnect all socket connections
372
+ * @param {object} options - Close options
373
+ * @returns {Promise} Promise that resolves when all connections are closed
374
+ */
375
+ disconnectAll(options) {
376
+ const disconnectPromises = [];
377
+
378
+ for (const sessionId of this.sockets.keys()) {
379
+ disconnectPromises.push(this.disconnect(options, sessionId));
380
+ }
381
+
382
+ return Promise.all(disconnectPromises).then(() => {
383
+ this.connected = false;
384
+ this.sockets.clear();
385
+ this.sessionWebSocketUrls.clear();
386
+ this.backoffCalls.clear();
387
+ // Clear connection promises to prevent stale promises
388
+ if (this._connectPromises) {
389
+ this._connectPromises.clear();
390
+ }
241
391
  });
242
392
  },
243
393
 
@@ -329,34 +479,29 @@ const Mercury = WebexPlugin.extend({
329
479
  }
330
480
 
331
481
  webSocketUrl.query.clientTimestamp = Date.now();
482
+ delete webSocketUrl.search;
332
483
 
333
484
  return url.format(webSocketUrl);
334
485
  });
335
486
  },
336
487
 
337
- _attemptConnection(socketUrl, callback, options = {}) {
488
+ _attemptConnection(socketUrl, sessionId, callback, options = {}) {
338
489
  const {isShutdownSwitchover = false, onSuccess = null} = options;
339
490
 
340
491
  const socket = new Socket();
492
+ socket.connecting = true;
341
493
  let newWSUrl;
342
494
 
343
- this._attachSocketEventListeners(socket);
344
-
345
- // Check appropriate backoff call based on connection type
346
- if (isShutdownSwitchover && !this._shutdownSwitchoverBackoffCall) {
347
- const msg = `${this.namespace}: prevent socket open when switchover backoff call no longer defined`;
348
- const err = new Error(msg);
349
-
350
- this.logger.info(msg);
351
-
352
- // Call the callback with the error before rejecting
353
- callback(err);
495
+ this._attachSocketEventListeners(socket, sessionId);
354
496
 
355
- return Promise.reject(err);
356
- }
497
+ const backoffCall = isShutdownSwitchover
498
+ ? this._shutdownSwitchoverBackoffCalls.get(sessionId)
499
+ : this.backoffCalls.get(sessionId);
357
500
 
358
- if (!isShutdownSwitchover && !this.backoffCall) {
359
- const msg = `${this.namespace}: prevent socket open when backoffCall no longer defined`;
501
+ // Check appropriate backoff call based on connection type
502
+ if (!backoffCall) {
503
+ const mode = isShutdownSwitchover ? 'switchover backoff call' : 'backoffCall';
504
+ const msg = `${this.namespace}: prevent socket open when ${mode} no longer defined for ${sessionId}`;
360
505
  const err = new Error(msg);
361
506
 
362
507
  this.logger.info(msg);
@@ -370,16 +515,17 @@ const Mercury = WebexPlugin.extend({
370
515
  // For shutdown switchover, don't set socket yet (make-before-break)
371
516
  // For normal connection, set socket before opening to allow disconnect() to close it
372
517
  if (!isShutdownSwitchover) {
373
- this.socket = socket;
518
+ this.sockets.set(sessionId, socket);
374
519
  }
375
520
 
376
- return this._prepareAndOpenSocket(socket, socketUrl, isShutdownSwitchover)
521
+ return this._prepareAndOpenSocket(socket, socketUrl, sessionId, isShutdownSwitchover)
377
522
  .then((webSocketUrl) => {
378
523
  newWSUrl = webSocketUrl;
524
+
379
525
  this.logger.info(
380
526
  `${this.namespace}: ${
381
527
  isShutdownSwitchover ? '[shutdown] switchover' : ''
382
- } connected to mercury, success, action: connected, url: ${newWSUrl}`
528
+ } connected to mercury, success, action: connected for ${sessionId}, url: ${newWSUrl}`
383
529
  );
384
530
 
385
531
  // Custom success handler for shutdown switchover
@@ -406,7 +552,10 @@ const Mercury = WebexPlugin.extend({
406
552
  .catch((reason) => {
407
553
  // For shutdown, simpler error handling - just callback for retry
408
554
  if (isShutdownSwitchover) {
409
- this.logger.info(`${this.namespace}: [shutdown] switchover attempt failed`, reason);
555
+ this.logger.info(
556
+ `${this.namespace}: [shutdown] switchover attempt failed for ${sessionId}`,
557
+ reason
558
+ );
410
559
 
411
560
  return callback(reason);
412
561
  }
@@ -414,30 +563,36 @@ const Mercury = WebexPlugin.extend({
414
563
  // Normal connection error handling (existing complex logic)
415
564
  this.lastError = reason; // remember the last error
416
565
 
566
+ const backoffCallNormal = this.backoffCalls.get(sessionId);
417
567
  // Suppress connection errors that appear to be network related. This
418
568
  // may end up suppressing metrics during outages, but we might not care
419
569
  // (especially since many of our outages happen in a way that client
420
570
  // metrics can't be trusted).
421
- if (reason.code !== 1006 && this.backoffCall && this.backoffCall?.getNumRetries() > 0) {
422
- this._emit('connection_failed', reason, {retries: this.backoffCall?.getNumRetries()});
571
+ if (reason.code !== 1006 && backoffCallNormal && backoffCallNormal?.getNumRetries() > 0) {
572
+ this._emit(sessionId, 'connection_failed', reason, {
573
+ sessionId,
574
+ retries: backoffCallNormal?.getNumRetries(),
575
+ });
423
576
  }
424
577
  this.logger.info(
425
- `${this.namespace}: connection attempt failed`,
578
+ `${this.namespace}: connection attempt failed for ${sessionId}`,
426
579
  reason,
427
- this.backoffCall?.getNumRetries() === 0 ? reason.stack : ''
580
+ backoffCallNormal?.getNumRetries() === 0 ? reason.stack : ''
428
581
  );
429
582
  // UnknownResponse is produced by IE for any 4XXX; treated it like a bad
430
583
  // web socket url and let WDM handle the token checking
431
584
  if (reason instanceof UnknownResponse) {
432
585
  this.logger.info(
433
- `${this.namespace}: received unknown response code, refreshing device registration`
586
+ `${this.namespace}: received unknown response code for ${sessionId}, refreshing device registration`
434
587
  );
435
588
 
436
589
  return this.webex.internal.device.refresh().then(() => callback(reason));
437
590
  }
438
591
  // NotAuthorized implies expired token
439
592
  if (reason instanceof NotAuthorized) {
440
- this.logger.info(`${this.namespace}: received authorization error, reauthorizing`);
593
+ this.logger.info(
594
+ `${this.namespace}: received authorization error for ${sessionId}, reauthorizing`
595
+ );
441
596
 
442
597
  return this.webex.credentials.refresh({force: true}).then(() => callback(reason));
443
598
  }
@@ -450,8 +605,10 @@ const Mercury = WebexPlugin.extend({
450
605
  // BadRequest implies current credentials are for a Service Account
451
606
  // Forbidden implies current user is not entitle for Webex
452
607
  if (reason instanceof BadRequest || reason instanceof Forbidden) {
453
- this.logger.warn(`${this.namespace}: received unrecoverable response from mercury`);
454
- this.backoffCall.abort();
608
+ this.logger.warn(
609
+ `${this.namespace}: received unrecoverable response from mercury for ${sessionId}`
610
+ );
611
+ backoffCallNormal?.abort();
455
612
 
456
613
  return callback(reason);
457
614
  }
@@ -459,12 +616,24 @@ const Mercury = WebexPlugin.extend({
459
616
  return this.webex.internal.feature
460
617
  .getFeature('developer', 'web-high-availability')
461
618
  .then((haMessagingEnabled) => {
619
+ const wsUrl = newWSUrl || reason.webSocketUrl;
620
+
462
621
  if (haMessagingEnabled) {
463
622
  this.logger.info(
464
- `${this.namespace}: received a generic connection error, will try to connect to another datacenter. failed, action: 'failed', url: ${newWSUrl} error: ${reason.message}`
623
+ `${this.namespace}: received a generic connection error for ${sessionId}, will try to connect to another datacenter. failed, action: 'failed', url: ${wsUrl} error: ${reason.message}`
465
624
  );
466
625
 
467
- return this.webex.internal.services.markFailedUrl(newWSUrl);
626
+ if (wsUrl) {
627
+ this.logger.info(
628
+ `${this.namespace}: marking ${wsUrl} as failed for ${sessionId} due to connection error`
629
+ );
630
+
631
+ return this.webex.internal.services.markFailedUrl(wsUrl);
632
+ }
633
+
634
+ this.logger.info(
635
+ `${this.namespace}: no socket url available to mark as failed for ${sessionId} due to connection error`
636
+ );
468
637
  }
469
638
 
470
639
  return null;
@@ -475,12 +644,15 @@ const Mercury = WebexPlugin.extend({
475
644
  return callback(reason);
476
645
  })
477
646
  .catch((reason) => {
478
- this.logger.error(`${this.namespace}: failed to handle connection failure`, reason);
647
+ this.logger.error(
648
+ `${this.namespace}: failed to handle connection failure for ${sessionId}`,
649
+ reason
650
+ );
479
651
  callback(reason);
480
652
  });
481
653
  },
482
654
 
483
- _prepareAndOpenSocket(socket, socketUrl, isShutdownSwitchover = false) {
655
+ _prepareAndOpenSocket(socket, socketUrl, sessionId, isShutdownSwitchover = false) {
484
656
  const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
485
657
 
486
658
  return Promise.all([this._prepareUrl(socketUrl), this.webex.credentials.getUserToken()]).then(
@@ -503,30 +675,47 @@ const Mercury = WebexPlugin.extend({
503
675
  options = {...options, ...this.webex.config.defaultMercuryOptions};
504
676
  }
505
677
 
506
- this.logger.info(`${this.namespace}: ${logPrefix} url: ${webSocketUrl}`);
678
+ // Set the socket before opening it. This allows a disconnect() to close
679
+ // the socket if it is in the process of being opened.
680
+ this.sockets.set(sessionId, socket);
681
+ this.socket = this.sockets.get(this.defaultSessionId);
682
+
683
+ this.logger.info(`${this.namespace} ${logPrefix} url for ${sessionId}: ${webSocketUrl}`);
684
+
685
+ // Remember the resolved URL used to open this socket so that
686
+ // reconnection in _onclose re-derives from a catalog-valid host. This is
687
+ // captured before socket.open(), which a lower layer (e.g. the same-site
688
+ // websocket proxy done with interceptors) may rewrite into a non-catalog host.
689
+ // Reusing the post-proxy native socket URL would fail
690
+ // host-catalog validation in _prepareUrl and block reconnection.
691
+ this.sessionWebSocketUrls.set(sessionId, webSocketUrl);
507
692
 
508
- return socket.open(webSocketUrl, options).then(() => webSocketUrl);
693
+ return socket
694
+ .open(webSocketUrl, options)
695
+ .then(() => webSocketUrl)
696
+ .catch((err) => {
697
+ err.webSocketUrl = webSocketUrl;
698
+
699
+ return Promise.reject(err);
700
+ });
509
701
  }
510
702
  );
511
703
  },
512
704
 
513
- _connectWithBackoff(webSocketUrl, context = {}) {
705
+ _connectWithBackoff(webSocketUrl, sessionId, context = {}) {
514
706
  const {isShutdownSwitchover = false, attemptOptions = {}} = context;
515
707
 
516
708
  return new Promise((resolve, reject) => {
517
- // eslint gets confused about whether or not call is actually used
709
+ // eslint gets confused about whether call is actually used
518
710
  // eslint-disable-next-line prefer-const
519
711
  let call;
520
- const onComplete = (err) => {
521
- // Clear state flags based on connection type
712
+ const onComplete = (err, sid = sessionId) => {
522
713
  if (isShutdownSwitchover) {
523
- this._shutdownSwitchoverInProgress = false;
524
- this._shutdownSwitchoverBackoffCall = undefined;
714
+ this._shutdownSwitchoverBackoffCalls.delete(sid);
525
715
  } else {
526
- this.connecting = false;
527
- this.backoffCall = undefined;
716
+ this.backoffCalls.delete(sid);
528
717
  }
529
-
718
+ const sessionSocket = this.sockets.get(sid);
530
719
  if (err) {
531
720
  const msg = isShutdownSwitchover
532
721
  ? `[shutdown] switchover failed after ${call.getNumRetries()} retries`
@@ -535,29 +724,45 @@ const Mercury = WebexPlugin.extend({
535
724
  this.logger.info(
536
725
  `${this.namespace}: ${msg}; log statement about next retry was inaccurate; ${err}`
537
726
  );
727
+ if (sessionSocket) {
728
+ sessionSocket.connecting = false;
729
+ sessionSocket.connected = false;
730
+ }
538
731
 
539
732
  return reject(err);
540
733
  }
541
734
 
735
+ // Update overall connected status
736
+ if (sessionSocket) {
737
+ sessionSocket.connecting = false;
738
+ sessionSocket.connected = true;
739
+ }
542
740
  // Default success handling for normal connections
543
741
  if (!isShutdownSwitchover) {
544
- this.connected = true;
742
+ this.connecting = this.hasConnectingSockets();
743
+ this.connected = this.hasConnectedSockets();
545
744
  this.hasEverConnected = true;
546
- this._emit('online');
547
- this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(true);
745
+ this._emit(sid, 'online');
746
+ if (this.connected) {
747
+ this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(true);
748
+ }
548
749
  }
549
750
 
550
751
  return resolve();
551
752
  };
552
-
553
753
  // eslint-disable-next-line prefer-reflect
554
- call = backoff.call((callback) => {
555
- const attemptNum = call.getNumRetries();
556
- const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
754
+ call = backoff.call(
755
+ (callback) => {
756
+ const attemptNum = call.getNumRetries();
757
+ const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
557
758
 
558
- this.logger.info(`${this.namespace}: executing ${logPrefix} attempt ${attemptNum}`);
559
- this._attemptConnection(webSocketUrl, callback, attemptOptions);
560
- }, onComplete);
759
+ this.logger.info(
760
+ `${this.namespace}: executing ${logPrefix} attempt ${attemptNum} for ${sessionId}`
761
+ );
762
+ this._attemptConnection(webSocketUrl, sessionId, callback, attemptOptions);
763
+ },
764
+ (err) => onComplete(err, sessionId)
765
+ );
561
766
 
562
767
  call.setStrategy(
563
768
  new backoff.ExponentialStrategy({
@@ -576,23 +781,32 @@ const Mercury = WebexPlugin.extend({
576
781
  call.failAfter(this.config.maxRetries);
577
782
  }
578
783
 
784
+ // Store the call BEFORE setting up event handlers to prevent race conditions
785
+ // Store backoff call reference BEFORE starting (so it's available in _attemptConnection)
786
+ if (isShutdownSwitchover) {
787
+ this._shutdownSwitchoverBackoffCalls.set(sessionId, call);
788
+ } else {
789
+ this.backoffCalls.set(sessionId, call);
790
+ }
791
+
579
792
  call.on('abort', () => {
580
793
  const msg = isShutdownSwitchover ? 'Shutdown Switchover' : 'Connection';
581
794
 
582
- this.logger.info(`${this.namespace}: ${msg} aborted`);
583
- reject(new Error(`Mercury ${msg} Aborted`));
795
+ this.logger.info(`${this.namespace}: ${msg} aborted for ${sessionId}`);
796
+ reject(new Error(`Mercury ${msg} Aborted for ${sessionId}`));
584
797
  });
585
798
 
586
799
  call.on('callback', (err) => {
587
800
  if (err) {
588
801
  const number = call.getNumRetries();
589
802
  const delay = Math.min(call.strategy_.nextBackoffDelay_, this.config.backoffTimeMax);
803
+
590
804
  const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : '';
591
805
 
592
806
  this.logger.info(
593
807
  `${this.namespace}: ${logPrefix} failed to connect; attempting retry ${
594
808
  number + 1
595
- } in ${delay} ms`
809
+ } in ${delay} ms for ${sessionId}`
596
810
  );
597
811
  /* istanbul ignore if */
598
812
  if (process.env.NODE_ENV === 'development') {
@@ -601,34 +815,55 @@ const Mercury = WebexPlugin.extend({
601
815
 
602
816
  return;
603
817
  }
604
- this.logger.info(`${this.namespace}: connected`);
818
+ this.logger.info(`${this.namespace}: connected ${sessionId}`);
605
819
  });
606
820
 
607
- // Store backoff call reference BEFORE starting (so it's available in _attemptConnection)
608
- if (isShutdownSwitchover) {
609
- this._shutdownSwitchoverBackoffCall = call;
610
- } else {
611
- this.backoffCall = call;
612
- }
613
-
614
821
  call.start();
615
822
  });
616
823
  },
617
824
 
618
825
  _emit(...args) {
619
826
  try {
620
- this.trigger(...args);
827
+ if (!args || args.length === 0) {
828
+ return;
829
+ }
830
+
831
+ // New signature: _emit(sessionId, eventName, ...rest)
832
+ // Backwards compatibility: if the first arg isn't a known sessionId (or defaultSessionId),
833
+ // treat the call as the old signature and forward directly to trigger(...)
834
+ const [first, second, ...rest] = args;
835
+
836
+ if (typeof first === 'string' && typeof second === 'string') {
837
+ const sessionId = first;
838
+ const eventName = second;
839
+ const suffix = sessionId === this.defaultSessionId ? '' : `:${sessionId}`;
840
+
841
+ this.trigger(`${eventName}${suffix}`, ...rest);
842
+ } else {
843
+ // Old usage: _emit(eventName, ...args)
844
+ this.trigger(...args);
845
+ }
621
846
  } catch (error) {
622
- this.logger.error(
623
- `${this.namespace}: error occurred in event handler:`,
624
- error,
625
- ' with args: ',
626
- args
627
- );
847
+ // Safely handle errors without causing additional issues during cleanup
848
+ try {
849
+ this.logger.error(
850
+ `${this.namespace}: error occurred in event handler:`,
851
+ error,
852
+ ' with args: ',
853
+ args
854
+ );
855
+ } catch (logError) {
856
+ // If even logging fails, just ignore to prevent cascading errors during cleanup
857
+ // eslint-disable-next-line no-console
858
+ console.error('Mercury _emit error handling failed:', logError);
859
+ }
628
860
  }
629
861
  },
630
862
 
631
863
  _getEventHandlers(eventType) {
864
+ if (!eventType) {
865
+ return [];
866
+ }
632
867
  const [namespace, name] = eventType.split('.');
633
868
  const handlers = [];
634
869
 
@@ -648,36 +883,68 @@ const Mercury = WebexPlugin.extend({
648
883
  return handlers;
649
884
  },
650
885
 
651
- _onclose(event, sourceSocket) {
886
+ _submitMercuryClose4000Metric(sessionId, event, options) {
887
+ const {action, isActiveSocket, messageType} = options;
888
+
889
+ try {
890
+ this.webex.internal.metrics.submitClientMetrics(MERCURY_CLOSE_4000_METRIC, {
891
+ fields: {
892
+ action,
893
+ close_code: event.code,
894
+ close_reason: event.reason || '',
895
+ is_active_socket: isActiveSocket,
896
+ message_type: messageType,
897
+ session_id: sessionId,
898
+ },
899
+ tags: {
900
+ action,
901
+ message_type: messageType,
902
+ },
903
+ });
904
+ } catch (error) {
905
+ this.logger.warn(`${this.namespace}: failed to submit Mercury 4000 close metric`, error);
906
+ }
907
+ },
908
+
909
+ _onclose(sessionId, event, sourceSocket) {
652
910
  // I don't see any way to avoid the complexity or statement count in here.
653
911
  /* eslint complexity: [0] */
654
912
 
655
913
  try {
656
- const isActiveSocket = sourceSocket === this.socket;
657
914
  const reason = event.reason && event.reason.toLowerCase();
915
+ const sessionSocket = this.sockets.get(sessionId);
916
+ event.sessionId = sessionId;
658
917
 
659
- let socketUrl;
660
- if (isActiveSocket && this.socket) {
661
- // Active socket closed - get URL from current socket reference
662
- socketUrl = this.socket.url;
663
- } else if (sourceSocket) {
664
- // Old socket closed - get URL from the closed socket
665
- socketUrl = sourceSocket.url;
666
- }
918
+ const isActiveSocket = sourceSocket === sessionSocket;
919
+ // Reconnect using the resolved URL this session's socket was
920
+ // opened with, captured in _prepareAndOpenSocket. Do NOT reuse
921
+ // sourceSocket.url: that reflects the URL the native WebSocket was
922
+ // actually opened with, which a lower layer may have rewritten
923
+ // (e.g. via interceptors) into a host that is not in the SDK host
924
+ // catalog. Feeding such a URL back into _prepareUrl fails host-catalog
925
+ // validation and permanently blocks reconnection.
926
+ const socketUrl = this.sessionWebSocketUrls.get(sessionId);
927
+
928
+ this.sockets.delete(sessionId);
667
929
 
668
930
  if (isActiveSocket) {
669
931
  // Only tear down state if the currently active socket closed
670
- if (this.socket) {
671
- this.socket.removeAllListeners();
932
+ if (sessionSocket) {
933
+ sessionSocket.removeAllListeners();
934
+ if (sessionId === this.defaultSessionId) this.unset('socket');
935
+ this._emit(sessionId, 'offline', event);
936
+ }
937
+ // Update overall connected status
938
+ this.connecting = this.hasConnectingSockets();
939
+ this.connected = this.hasConnectedSockets();
940
+
941
+ if (!this.connected) {
942
+ this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false);
672
943
  }
673
- this.unset('socket');
674
- this.connected = false;
675
- this._emit('offline', event);
676
- this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false);
677
944
  } else {
678
945
  // Old socket closed; do not flip connection state
679
946
  this.logger.info(
680
- `${this.namespace}: [shutdown] non-active socket closed, code=${event.code}`
947
+ `${this.namespace}: [shutdown] non-active socket closed, code=${event.code} for ${sessionId}`
681
948
  );
682
949
  // Clean up listeners from old socket now that it's closed
683
950
  if (sourceSocket) {
@@ -689,15 +956,34 @@ const Mercury = WebexPlugin.extend({
689
956
  case 1003:
690
957
  // metric: disconnect
691
958
  this.logger.info(
692
- `${this.namespace}: Mercury service rejected last message; will not reconnect: ${event.reason}`
959
+ `${this.namespace}: Mercury service rejected last message for ${sessionId}; will not reconnect: ${event.reason}`
693
960
  );
694
- if (isActiveSocket) this._emit('offline.permanent', event);
961
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
695
962
  break;
696
963
  case 4000:
697
964
  // metric: disconnect
698
- this.logger.info(`${this.namespace}: socket replaced; will not reconnect`);
699
- if (isActiveSocket) this._emit('offline.replaced', event);
700
- // If not active, nothing to do
965
+ if (reason === 'replaced') {
966
+ this._submitMercuryClose4000Metric(sessionId, event, {
967
+ action: 'no_action',
968
+ isActiveSocket,
969
+ messageType: 'replaced',
970
+ });
971
+ this.logger.info(`${this.namespace}: socket ${sessionId} replaced; will not reconnect`);
972
+ if (isActiveSocket) this._emit(sessionId, 'offline.replaced', event);
973
+ } else {
974
+ this._submitMercuryClose4000Metric(sessionId, event, {
975
+ action: isActiveSocket ? 'reconnect' : 'ignore_non_active',
976
+ isActiveSocket,
977
+ messageType: 'other',
978
+ });
979
+ this.logger.info(
980
+ `${this.namespace}: socket ${sessionId} disconnected with 4000: ${event.reason}; reconnecting`
981
+ );
982
+ if (isActiveSocket) {
983
+ this._emit(sessionId, 'offline.transient', event);
984
+ this._reconnect(socketUrl, sessionId);
985
+ }
986
+ }
701
987
  break;
702
988
  case 4001:
703
989
  // replaced during shutdown
@@ -706,26 +992,28 @@ const Mercury = WebexPlugin.extend({
706
992
  // to be replaced, but the switchover in _handleImminentShutdown failed.
707
993
  // This is a permanent failure - do not reconnect.
708
994
  this.logger.warn(
709
- `${this.namespace}: active socket closed with 4001; shutdown switchover failed`
995
+ `${this.namespace}: active socket closed with 4001; shutdown switchover failed for ${sessionId}`
710
996
  );
711
- this._emit('offline.permanent', event);
997
+ this._emit(sessionId, 'offline.permanent', event);
712
998
  } else {
713
999
  // Expected: old socket closed after successful switchover
714
1000
  this.logger.info(
715
- `${this.namespace}: old socket closed with 4001 (replaced during shutdown); no reconnect needed`
1001
+ `${this.namespace}: old socket closed with 4001 (replaced during shutdown); no reconnect needed for ${sessionId}`
716
1002
  );
717
- this._emit('offline.replaced', event);
1003
+ this._emit(sessionId, 'offline.replaced', event);
718
1004
  }
719
1005
  break;
720
1006
  case 1001:
721
1007
  case 1005:
722
1008
  case 1006:
723
1009
  case 1011:
724
- this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
1010
+ this.logger.info(`${this.namespace}: socket ${sessionId} disconnected; reconnecting`);
725
1011
  if (isActiveSocket) {
726
- this._emit('offline.transient', event);
727
- this.logger.info(`${this.namespace}: [shutdown] reconnecting active socket to recover`);
728
- this._reconnect(socketUrl);
1012
+ this._emit(sessionId, 'offline.transient', event);
1013
+ this.logger.info(
1014
+ `${this.namespace}: [shutdown] reconnecting active socket to recover for ${sessionId}`
1015
+ );
1016
+ this._reconnect(socketUrl, sessionId);
729
1017
  }
730
1018
  // metric: disconnect
731
1019
  // if (code == 1011 && reason !== ping error) metric: unexpected disconnect
@@ -733,55 +1021,71 @@ const Mercury = WebexPlugin.extend({
733
1021
  case 1000:
734
1022
  case 3050: // 3050 indicates logout form of closure, default to old behavior, use config reason defined by consumer to proceed with the permanent block
735
1023
  if (normalReconnectReasons.includes(reason)) {
736
- this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
1024
+ this.logger.info(`${this.namespace}: socket ${sessionId} disconnected; reconnecting`);
737
1025
  if (isActiveSocket) {
738
- this._emit('offline.transient', event);
739
- this.logger.info(`${this.namespace}: [shutdown] reconnecting due to normal close`);
740
- this._reconnect(socketUrl);
1026
+ this._emit(sessionId, 'offline.transient', event);
1027
+ this.logger.info(
1028
+ `${this.namespace}: [shutdown] reconnecting due to normal close for ${sessionId}`
1029
+ );
1030
+ this._reconnect(socketUrl, sessionId);
741
1031
  }
742
1032
  // metric: disconnect
743
1033
  // if (reason === done forced) metric: force closure
744
1034
  } else {
745
1035
  this.logger.info(
746
- `${this.namespace}: socket disconnected; will not reconnect: ${event.reason}`
1036
+ `${this.namespace}: socket ${sessionId} disconnected; will not reconnect: ${event.reason}`
747
1037
  );
748
- if (isActiveSocket) this._emit('offline.permanent', event);
1038
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
749
1039
  }
750
1040
  break;
751
1041
  default:
752
1042
  this.logger.info(
753
- `${this.namespace}: socket disconnected unexpectedly; will not reconnect`
1043
+ `${this.namespace}: socket ${sessionId} disconnected unexpectedly; will not reconnect`
754
1044
  );
755
1045
  // unexpected disconnect
756
- if (isActiveSocket) this._emit('offline.permanent', event);
1046
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
757
1047
  }
758
1048
  } catch (error) {
759
- this.logger.error(`${this.namespace}: error occurred in close handler`, error);
1049
+ this.logger.error(
1050
+ `${this.namespace}: error occurred in close handler for ${sessionId}`,
1051
+ error
1052
+ );
760
1053
  }
761
1054
  },
762
1055
 
763
- _onmessage(event) {
764
- this._setTimeOffset(event);
1056
+ _onmessage(sessionId, event) {
1057
+ this._setTimeOffset(sessionId, event);
765
1058
  const envelope = event.data;
766
1059
 
767
1060
  if (process.env.ENABLE_MERCURY_LOGGING) {
768
- this.logger.debug(`${this.namespace}: message envelope: `, envelope);
1061
+ this.logger.debug(`${this.namespace}: message envelope from ${sessionId}: `, envelope);
769
1062
  }
770
1063
 
1064
+ envelope.sessionId = sessionId;
1065
+
771
1066
  // Handle shutdown message shape: { type: 'shutdown' }
772
1067
  if (envelope && envelope.type === 'shutdown') {
773
- this.logger.info(`${this.namespace}: [shutdown] imminent shutdown message received`);
774
- this._emit('event:mercury_shutdown_imminent', envelope);
1068
+ this.logger.info(
1069
+ `${this.namespace}: [shutdown] imminent shutdown message received for ${sessionId}`
1070
+ );
1071
+ this._emit(sessionId, 'event:mercury_shutdown_imminent', envelope);
775
1072
 
776
- this._handleImminentShutdown();
1073
+ this._handleImminentShutdown(sessionId);
777
1074
 
778
1075
  return Promise.resolve();
779
1076
  }
780
1077
 
1078
+ envelope.sessionId = sessionId;
781
1079
  const {data} = envelope;
782
1080
 
783
1081
  this._applyOverrides(data);
784
1082
 
1083
+ if (!data || !data.eventType) {
1084
+ this._emit(sessionId, 'event', envelope);
1085
+
1086
+ return Promise.resolve();
1087
+ }
1088
+
785
1089
  return this._getEventHandlers(data.eventType)
786
1090
  .reduce(
787
1091
  (promise, handler) =>
@@ -792,7 +1096,7 @@ const Mercury = WebexPlugin.extend({
792
1096
  resolve((this.webex[namespace] || this.webex.internal[namespace])[name](data))
793
1097
  ).catch((reason) =>
794
1098
  this.logger.error(
795
- `${this.namespace}: error occurred in autowired event handler for ${data.eventType}`,
1099
+ `${this.namespace}: error occurred in autowired event handler for ${data.eventType} from ${sessionId}`,
796
1100
  reason
797
1101
  )
798
1102
  );
@@ -800,32 +1104,35 @@ const Mercury = WebexPlugin.extend({
800
1104
  Promise.resolve()
801
1105
  )
802
1106
  .then(() => {
803
- this._emit('event', event.data);
1107
+ this._emit(sessionId, 'event', envelope);
804
1108
  const [namespace] = data.eventType.split('.');
805
1109
 
806
1110
  if (namespace === data.eventType) {
807
- this._emit(`event:${namespace}`, envelope);
1111
+ this._emit(sessionId, `event:${namespace}`, envelope);
808
1112
  } else {
809
- this._emit(`event:${namespace}`, envelope);
810
- this._emit(`event:${data.eventType}`, envelope);
1113
+ this._emit(sessionId, `event:${namespace}`, envelope);
1114
+ this._emit(sessionId, `event:${data.eventType}`, envelope);
811
1115
  }
812
1116
  })
813
1117
  .catch((reason) => {
814
- this.logger.error(`${this.namespace}: error occurred processing socket message`, reason);
1118
+ this.logger.error(
1119
+ `${this.namespace}: error occurred processing socket message from ${sessionId}`,
1120
+ reason
1121
+ );
815
1122
  });
816
1123
  },
817
1124
 
818
- _setTimeOffset(event) {
1125
+ _setTimeOffset(sessionId, event) {
819
1126
  const {wsWriteTimestamp} = event.data;
820
1127
  if (typeof wsWriteTimestamp === 'number' && wsWriteTimestamp > 0) {
821
1128
  this.mercuryTimeOffset = Date.now() - wsWriteTimestamp;
822
1129
  }
823
1130
  },
824
1131
 
825
- _reconnect(webSocketUrl) {
826
- this.logger.info(`${this.namespace}: reconnecting`);
1132
+ _reconnect(webSocketUrl, sessionId = this.defaultSessionId) {
1133
+ this.logger.info(`${this.namespace}: reconnecting ${sessionId}`);
827
1134
 
828
- return this.connect(webSocketUrl);
1135
+ return this.connect(webSocketUrl, sessionId);
829
1136
  },
830
1137
  });
831
1138