@webex/internal-plugin-mercury 3.9.0-webinar5k.1 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mercury.js CHANGED
@@ -68,6 +68,106 @@ const Mercury = WebexPlugin.extend({
68
68
  this.webex.internal.feature.updateFeature(envelope.data.featureToggle);
69
69
  }
70
70
  });
71
+ /*
72
+ * When Cluster Migrations, notify clients using ActiveClusterStatusEvent via mercury
73
+ * https://wwwin-github.cisco.com/pages/Webex/crr-docs/techdocs/rr-002.html#wip-notifying-clients-of-cluster-migrations
74
+ * */
75
+ this.on('event:ActiveClusterStatusEvent', (envelope) => {
76
+ if (
77
+ typeof this.webex.internal.services?.switchActiveClusterIds === 'function' &&
78
+ envelope &&
79
+ envelope.data
80
+ ) {
81
+ this.webex.internal.services.switchActiveClusterIds(envelope.data?.activeClusters);
82
+ }
83
+ });
84
+ /*
85
+ * Using cache-invalidation via mercury to instead the method of polling via the new /timestamp endpoint from u2c
86
+ * https://wwwin-github.cisco.com/pages/Webex/crr-docs/techdocs/rr-005.html#websocket-notifications
87
+ * */
88
+ this.on('event:u2c.cache-invalidation', (envelope) => {
89
+ if (
90
+ typeof this.webex.internal.services?.invalidateCache === 'function' &&
91
+ envelope &&
92
+ envelope.data
93
+ ) {
94
+ this.webex.internal.services.invalidateCache(envelope.data?.timestamp);
95
+ }
96
+ });
97
+ },
98
+
99
+ /**
100
+ * Attach event listeners to a socket.
101
+ * @param {Socket} socket - The socket to attach listeners to
102
+ * @returns {void}
103
+ */
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));
110
+ },
111
+
112
+ /**
113
+ * Handle imminent shutdown by establishing a new connection while keeping
114
+ * the current one alive (make-before-break).
115
+ * Idempotent: will no-op if already in progress.
116
+ * @returns {void}
117
+ */
118
+ _handleImminentShutdown() {
119
+ try {
120
+ if (this._shutdownSwitchoverInProgress) {
121
+ this.logger.info(`${this.namespace}: [shutdown] switchover already in progress`);
122
+
123
+ return;
124
+ }
125
+ this._shutdownSwitchoverInProgress = true;
126
+ this._shutdownSwitchoverId = `${Date.now()}`;
127
+ this.logger.info(
128
+ `${this.namespace}: [shutdown] switchover start, id=${this._shutdownSwitchoverId}`
129
+ );
130
+
131
+ this._connectWithBackoff(undefined, {
132
+ isShutdownSwitchover: true,
133
+ attemptOptions: {
134
+ isShutdownSwitchover: true,
135
+ onSuccess: (newSocket, webSocketUrl) => {
136
+ this.logger.info(
137
+ `${this.namespace}: [shutdown] switchover connected, url: ${webSocketUrl}`
138
+ );
139
+
140
+ const oldSocket = this.socket;
141
+ // Atomically switch active socket reference
142
+ this.socket = newSocket;
143
+ this.connected = true; // remain connected throughout
144
+
145
+ this._emit('event:mercury_shutdown_switchover_complete', {url: webSocketUrl});
146
+
147
+ if (oldSocket) {
148
+ this.logger.info(
149
+ `${this.namespace}: [shutdown] old socket retained; server will close with 4001`
150
+ );
151
+ }
152
+ },
153
+ },
154
+ })
155
+ .then(() => {
156
+ this.logger.info(`${this.namespace}: [shutdown] switchover completed successfully`);
157
+ })
158
+ .catch((err) => {
159
+ this.logger.info(
160
+ `${this.namespace}: [shutdown] switchover exhausted retries; will fall back to normal reconnection`,
161
+ err
162
+ );
163
+ this._emit('event:mercury_shutdown_switchover_failed', {reason: err});
164
+ // Old socket will eventually close with 4001, triggering normal reconnection
165
+ });
166
+ } 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});
170
+ }
71
171
  },
72
172
 
73
173
  /**
@@ -126,6 +226,11 @@ const Mercury = WebexPlugin.extend({
126
226
  this.backoffCall.abort();
127
227
  }
128
228
 
229
+ if (this._shutdownSwitchoverBackoffCall) {
230
+ this.logger.info(`${this.namespace}: aborting shutdown switchover`);
231
+ this._shutdownSwitchoverBackoffCall.abort();
232
+ }
233
+
129
234
  if (this.socket) {
130
235
  this.socket.removeAllListeners('message');
131
236
  this.once('offline', resolve);
@@ -207,55 +312,63 @@ const Mercury = WebexPlugin.extend({
207
312
  });
208
313
  },
209
314
 
210
- _attemptConnection(socketUrl, callback) {
315
+ _attemptConnection(socketUrl, callback, options = {}) {
316
+ const {isShutdownSwitchover = false, onSuccess = null} = options;
317
+
211
318
  const socket = new Socket();
212
- let attemptWSUrl;
319
+ let newWSUrl;
213
320
 
214
- socket.on('close', (...args) => this._onclose(...args));
215
- socket.on('message', (...args) => this._onmessage(...args));
216
- socket.on('pong', (...args) => this._setTimeOffset(...args));
217
- socket.on('sequence-mismatch', (...args) => this._emit('sequence-mismatch', ...args));
218
- socket.on('ping-pong-latency', (...args) => this._emit('ping-pong-latency', ...args));
321
+ this._attachSocketEventListeners(socket);
219
322
 
220
- Promise.all([this._prepareUrl(socketUrl), this.webex.credentials.getUserToken()])
221
- .then(([webSocketUrl, token]) => {
222
- if (!this.backoffCall) {
223
- const msg = `${this.namespace}: prevent socket open when backoffCall no longer defined`;
323
+ // Check appropriate backoff call based on connection type
324
+ if (isShutdownSwitchover && !this._shutdownSwitchoverBackoffCall) {
325
+ const msg = `${this.namespace}: prevent socket open when switchover backoff call no longer defined`;
326
+ const err = new Error(msg);
224
327
 
225
- this.logger.info(msg);
328
+ this.logger.info(msg);
226
329
 
227
- return Promise.reject(new Error(msg));
228
- }
330
+ // Call the callback with the error before rejecting
331
+ callback(err);
229
332
 
230
- attemptWSUrl = webSocketUrl;
333
+ return Promise.reject(err);
334
+ }
231
335
 
232
- let options = {
233
- forceCloseDelay: this.config.forceCloseDelay,
234
- pingInterval: this.config.pingInterval,
235
- pongTimeout: this.config.pongTimeout,
236
- token: token.toString(),
237
- trackingId: `${this.webex.sessionId}_${Date.now()}`,
238
- logger: this.logger,
239
- };
336
+ if (!isShutdownSwitchover && !this.backoffCall) {
337
+ const msg = `${this.namespace}: prevent socket open when backoffCall no longer defined`;
338
+ const err = new Error(msg);
240
339
 
241
- // if the consumer has supplied request options use them
242
- if (this.webex.config.defaultMercuryOptions) {
243
- this.logger.info(`${this.namespace}: setting custom options`);
244
- options = {...options, ...this.webex.config.defaultMercuryOptions};
245
- }
340
+ this.logger.info(msg);
246
341
 
247
- // Set the socket before opening it. This allows a disconnect() to close
248
- // the socket if it is in the process of being opened.
249
- this.socket = socket;
342
+ // Call the callback with the error before rejecting
343
+ callback(err);
250
344
 
251
- this.logger.info(`${this.namespace} connection url: ${webSocketUrl}`);
345
+ return Promise.reject(err);
346
+ }
252
347
 
253
- return socket.open(webSocketUrl, options);
254
- })
255
- .then(() => {
348
+ // For shutdown switchover, don't set socket yet (make-before-break)
349
+ // For normal connection, set socket before opening to allow disconnect() to close it
350
+ if (!isShutdownSwitchover) {
351
+ this.socket = socket;
352
+ }
353
+
354
+ return this._prepareAndOpenSocket(socket, socketUrl, isShutdownSwitchover)
355
+ .then((webSocketUrl) => {
356
+ newWSUrl = webSocketUrl;
256
357
  this.logger.info(
257
- `${this.namespace}: connected to mercury, success, action: connected, url: ${attemptWSUrl}`
358
+ `${this.namespace}: ${
359
+ isShutdownSwitchover ? '[shutdown] switchover' : ''
360
+ } connected to mercury, success, action: connected, url: ${newWSUrl}`
258
361
  );
362
+
363
+ // Custom success handler for shutdown switchover
364
+ if (onSuccess) {
365
+ onSuccess(socket, webSocketUrl);
366
+ callback();
367
+
368
+ return Promise.resolve();
369
+ }
370
+
371
+ // Default behavior for normal connection
259
372
  callback();
260
373
 
261
374
  return this.webex.internal.feature
@@ -269,6 +382,14 @@ const Mercury = WebexPlugin.extend({
269
382
  });
270
383
  })
271
384
  .catch((reason) => {
385
+ // For shutdown, simpler error handling - just callback for retry
386
+ if (isShutdownSwitchover) {
387
+ this.logger.info(`${this.namespace}: [shutdown] switchover attempt failed`, reason);
388
+
389
+ return callback(reason);
390
+ }
391
+
392
+ // Normal connection error handling (existing complex logic)
272
393
  this.lastError = reason; // remember the last error
273
394
 
274
395
  // Suppress connection errors that appear to be network related. This
@@ -318,10 +439,10 @@ const Mercury = WebexPlugin.extend({
318
439
  .then((haMessagingEnabled) => {
319
440
  if (haMessagingEnabled) {
320
441
  this.logger.info(
321
- `${this.namespace}: received a generic connection error, will try to connect to another datacenter. failed, action: 'failed', url: ${attemptWSUrl} error: ${reason.message}`
442
+ `${this.namespace}: received a generic connection error, will try to connect to another datacenter. failed, action: 'failed', url: ${newWSUrl} error: ${reason.message}`
322
443
  );
323
444
 
324
- return this.webex.internal.services.markFailedUrl(attemptWSUrl);
445
+ return this.webex.internal.services.markFailedUrl(newWSUrl);
325
446
  }
326
447
 
327
448
  return null;
@@ -337,36 +458,83 @@ const Mercury = WebexPlugin.extend({
337
458
  });
338
459
  },
339
460
 
340
- _connectWithBackoff(webSocketUrl) {
461
+ _prepareAndOpenSocket(socket, socketUrl, isShutdownSwitchover = false) {
462
+ const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
463
+
464
+ return Promise.all([this._prepareUrl(socketUrl), this.webex.credentials.getUserToken()]).then(
465
+ ([webSocketUrl, token]) => {
466
+ let options = {
467
+ forceCloseDelay: this.config.forceCloseDelay,
468
+ pingInterval: this.config.pingInterval,
469
+ pongTimeout: this.config.pongTimeout,
470
+ token: token.toString(),
471
+ trackingId: `${this.webex.sessionId}_${Date.now()}`,
472
+ logger: this.logger,
473
+ };
474
+
475
+ if (this.webex.config.defaultMercuryOptions) {
476
+ const customOptionsMsg = isShutdownSwitchover
477
+ ? 'setting custom options for switchover'
478
+ : 'setting custom options';
479
+
480
+ this.logger.info(`${this.namespace}: ${customOptionsMsg}`);
481
+ options = {...options, ...this.webex.config.defaultMercuryOptions};
482
+ }
483
+
484
+ this.logger.info(`${this.namespace}: ${logPrefix} url: ${webSocketUrl}`);
485
+
486
+ return socket.open(webSocketUrl, options).then(() => webSocketUrl);
487
+ }
488
+ );
489
+ },
490
+
491
+ _connectWithBackoff(webSocketUrl, context = {}) {
492
+ const {isShutdownSwitchover = false, attemptOptions = {}} = context;
493
+
341
494
  return new Promise((resolve, reject) => {
342
495
  // eslint gets confused about whether or not call is actually used
343
496
  // eslint-disable-next-line prefer-const
344
497
  let call;
345
498
  const onComplete = (err) => {
346
- this.connecting = false;
499
+ // Clear state flags based on connection type
500
+ if (isShutdownSwitchover) {
501
+ this._shutdownSwitchoverInProgress = false;
502
+ this._shutdownSwitchoverBackoffCall = undefined;
503
+ } else {
504
+ this.connecting = false;
505
+ this.backoffCall = undefined;
506
+ }
347
507
 
348
- this.backoffCall = undefined;
349
508
  if (err) {
509
+ const msg = isShutdownSwitchover
510
+ ? `[shutdown] switchover failed after ${call.getNumRetries()} retries`
511
+ : `failed to connect after ${call.getNumRetries()} retries`;
512
+
350
513
  this.logger.info(
351
- `${
352
- this.namespace
353
- }: failed to connect after ${call.getNumRetries()} retries; log statement about next retry was inaccurate; ${err}`
514
+ `${this.namespace}: ${msg}; log statement about next retry was inaccurate; ${err}`
354
515
  );
355
516
 
356
517
  return reject(err);
357
518
  }
358
- this.connected = true;
359
- this.hasEverConnected = true;
360
- this._emit('online');
361
- this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(true);
519
+
520
+ // Default success handling for normal connections
521
+ if (!isShutdownSwitchover) {
522
+ this.connected = true;
523
+ this.hasEverConnected = true;
524
+ this._emit('online');
525
+ this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(true);
526
+ }
362
527
 
363
528
  return resolve();
364
529
  };
365
530
 
366
531
  // eslint-disable-next-line prefer-reflect
367
532
  call = backoff.call((callback) => {
368
- this.logger.info(`${this.namespace}: executing connection attempt ${call.getNumRetries()}`);
369
- this._attemptConnection(webSocketUrl, callback);
533
+ const attemptNum = call.getNumRetries();
534
+ const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
535
+
536
+ this.logger.info(`${this.namespace}: executing ${logPrefix} attempt ${attemptNum}`);
537
+ this._attemptConnection(webSocketUrl, callback, attemptOptions);
370
538
  }, onComplete);
371
539
 
372
540
  call.setStrategy(
@@ -376,24 +544,33 @@ const Mercury = WebexPlugin.extend({
376
544
  })
377
545
  );
378
546
 
379
- if (this.config.initialConnectionMaxRetries && !this.hasEverConnected) {
547
+ if (
548
+ this.config.initialConnectionMaxRetries &&
549
+ !this.hasEverConnected &&
550
+ !isShutdownSwitchover
551
+ ) {
380
552
  call.failAfter(this.config.initialConnectionMaxRetries);
381
553
  } else if (this.config.maxRetries) {
382
554
  call.failAfter(this.config.maxRetries);
383
555
  }
384
556
 
385
557
  call.on('abort', () => {
386
- this.logger.info(`${this.namespace}: connection aborted`);
387
- reject(new Error('Mercury Connection Aborted'));
558
+ const msg = isShutdownSwitchover ? 'Shutdown Switchover' : 'Connection';
559
+
560
+ this.logger.info(`${this.namespace}: ${msg} aborted`);
561
+ reject(new Error(`Mercury ${msg} Aborted`));
388
562
  });
389
563
 
390
564
  call.on('callback', (err) => {
391
565
  if (err) {
392
566
  const number = call.getNumRetries();
393
567
  const delay = Math.min(call.strategy_.nextBackoffDelay_, this.config.backoffTimeMax);
568
+ const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : '';
394
569
 
395
570
  this.logger.info(
396
- `${this.namespace}: failed to connect; attempting retry ${number + 1} in ${delay} ms`
571
+ `${this.namespace}: ${logPrefix} failed to connect; attempting retry ${
572
+ number + 1
573
+ } in ${delay} ms`
397
574
  );
398
575
  /* istanbul ignore if */
399
576
  if (process.env.NODE_ENV === 'development') {
@@ -405,9 +582,14 @@ const Mercury = WebexPlugin.extend({
405
582
  this.logger.info(`${this.namespace}: connected`);
406
583
  });
407
584
 
408
- call.start();
585
+ // Store backoff call reference BEFORE starting (so it's available in _attemptConnection)
586
+ if (isShutdownSwitchover) {
587
+ this._shutdownSwitchoverBackoffCall = call;
588
+ } else {
589
+ this.backoffCall = call;
590
+ }
409
591
 
410
- this.backoffCall = call;
592
+ call.start();
411
593
  });
412
594
  },
413
595
 
@@ -444,19 +626,42 @@ const Mercury = WebexPlugin.extend({
444
626
  return handlers;
445
627
  },
446
628
 
447
- _onclose(event) {
629
+ _onclose(event, sourceSocket) {
448
630
  // I don't see any way to avoid the complexity or statement count in here.
449
631
  /* eslint complexity: [0] */
450
632
 
451
633
  try {
634
+ const isActiveSocket = sourceSocket === this.socket;
452
635
  const reason = event.reason && event.reason.toLowerCase();
453
- const socketUrl = this.socket.url;
454
636
 
455
- this.socket.removeAllListeners();
456
- this.unset('socket');
457
- this.connected = false;
458
- this._emit('offline', event);
459
- this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false);
637
+ let socketUrl;
638
+ if (isActiveSocket && this.socket) {
639
+ // Active socket closed - get URL from current socket reference
640
+ socketUrl = this.socket.url;
641
+ } else if (sourceSocket) {
642
+ // Old socket closed - get URL from the closed socket
643
+ socketUrl = sourceSocket.url;
644
+ }
645
+
646
+ if (isActiveSocket) {
647
+ // Only tear down state if the currently active socket closed
648
+ if (this.socket) {
649
+ this.socket.removeAllListeners();
650
+ }
651
+ this.unset('socket');
652
+ this.connected = false;
653
+ this._emit('offline', event);
654
+ this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false);
655
+ } else {
656
+ // Old socket closed; do not flip connection state
657
+ this.logger.info(
658
+ `${this.namespace}: [shutdown] non-active socket closed, code=${event.code}`
659
+ );
660
+ // Clean up listeners from old socket now that it's closed
661
+ if (sourceSocket) {
662
+ sourceSocket.removeAllListeners();
663
+ }
664
+ }
460
665
 
461
666
  switch (event.code) {
462
667
  case 1003:
@@ -464,20 +669,42 @@ const Mercury = WebexPlugin.extend({
464
669
  this.logger.info(
465
670
  `${this.namespace}: Mercury service rejected last message; will not reconnect: ${event.reason}`
466
671
  );
467
- this._emit('offline.permanent', event);
672
+ if (isActiveSocket) this._emit('offline.permanent', event);
468
673
  break;
469
674
  case 4000:
470
675
  // metric: disconnect
471
676
  this.logger.info(`${this.namespace}: socket replaced; will not reconnect`);
472
- this._emit('offline.replaced', event);
677
+ if (isActiveSocket) this._emit('offline.replaced', event);
678
+ // If not active, nothing to do
679
+ break;
680
+ case 4001:
681
+ // replaced during shutdown
682
+ if (isActiveSocket) {
683
+ // Server closed active socket with 4001, meaning it expected this connection
684
+ // to be replaced, but the switchover in _handleImminentShutdown failed.
685
+ // This is a permanent failure - do not reconnect.
686
+ this.logger.warn(
687
+ `${this.namespace}: active socket closed with 4001; shutdown switchover failed`
688
+ );
689
+ this._emit('offline.permanent', event);
690
+ } else {
691
+ // Expected: old socket closed after successful switchover
692
+ this.logger.info(
693
+ `${this.namespace}: old socket closed with 4001 (replaced during shutdown); no reconnect needed`
694
+ );
695
+ this._emit('offline.replaced', event);
696
+ }
473
697
  break;
474
698
  case 1001:
475
699
  case 1005:
476
700
  case 1006:
477
701
  case 1011:
478
702
  this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
479
- this._emit('offline.transient', event);
480
- this._reconnect(socketUrl);
703
+ if (isActiveSocket) {
704
+ this._emit('offline.transient', event);
705
+ this.logger.info(`${this.namespace}: [shutdown] reconnecting active socket to recover`);
706
+ this._reconnect(socketUrl);
707
+ }
481
708
  // metric: disconnect
482
709
  // if (code == 1011 && reason !== ping error) metric: unexpected disconnect
483
710
  break;
@@ -485,15 +712,18 @@ const Mercury = WebexPlugin.extend({
485
712
  case 3050: // 3050 indicates logout form of closure, default to old behavior, use config reason defined by consumer to proceed with the permanent block
486
713
  if (normalReconnectReasons.includes(reason)) {
487
714
  this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
488
- this._emit('offline.transient', event);
489
- this._reconnect(socketUrl);
715
+ if (isActiveSocket) {
716
+ this._emit('offline.transient', event);
717
+ this.logger.info(`${this.namespace}: [shutdown] reconnecting due to normal close`);
718
+ this._reconnect(socketUrl);
719
+ }
490
720
  // metric: disconnect
491
721
  // if (reason === done forced) metric: force closure
492
722
  } else {
493
723
  this.logger.info(
494
724
  `${this.namespace}: socket disconnected; will not reconnect: ${event.reason}`
495
725
  );
496
- this._emit('offline.permanent', event);
726
+ if (isActiveSocket) this._emit('offline.permanent', event);
497
727
  }
498
728
  break;
499
729
  default:
@@ -501,7 +731,7 @@ const Mercury = WebexPlugin.extend({
501
731
  `${this.namespace}: socket disconnected unexpectedly; will not reconnect`
502
732
  );
503
733
  // unexpected disconnect
504
- this._emit('offline.permanent', event);
734
+ if (isActiveSocket) this._emit('offline.permanent', event);
505
735
  }
506
736
  } catch (error) {
507
737
  this.logger.error(`${this.namespace}: error occurred in close handler`, error);
@@ -516,6 +746,16 @@ const Mercury = WebexPlugin.extend({
516
746
  this.logger.debug(`${this.namespace}: message envelope: `, envelope);
517
747
  }
518
748
 
749
+ // Handle shutdown message shape: { type: 'shutdown' }
750
+ if (envelope && envelope.type === 'shutdown') {
751
+ this.logger.info(`${this.namespace}: [shutdown] imminent shutdown message received`);
752
+ this._emit('event:mercury_shutdown_imminent', envelope);
753
+
754
+ this._handleImminentShutdown();
755
+
756
+ return Promise.resolve();
757
+ }
758
+
519
759
  const {data} = envelope;
520
760
 
521
761
  this._applyOverrides(data);