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