@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.
@@ -0,0 +1,6 @@
1
+ export const SOCKET_READY_STATE = Object.freeze({
2
+ CONNECTING: 0,
3
+ OPEN: 1,
4
+ CLOSING: 2,
5
+ CLOSED: 3,
6
+ });
@@ -0,0 +1,5 @@
1
+ /*!
2
+ * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
3
+ */
4
+
5
+ export {default} from './socket';
@@ -0,0 +1,558 @@
1
+ /*!
2
+ * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
3
+ */
4
+
5
+ import {EventEmitter} from 'events';
6
+
7
+ import {checkRequired} from '@webex/common';
8
+ import {safeSetTimeout} from '@webex/common-timers';
9
+ import {defaults, has, isObject} from 'lodash';
10
+ import uuid from 'uuid';
11
+
12
+ import {
13
+ BadRequest,
14
+ ConnectionError,
15
+ Forbidden,
16
+ NotAuthorized,
17
+ UnknownResponse,
18
+ // NotFound
19
+ } from '../errors';
20
+ import {SOCKET_READY_STATE} from './constants';
21
+
22
+ const sockets = new WeakMap();
23
+
24
+ /**
25
+ * Generalized socket abstraction
26
+ */
27
+ export default class Socket extends EventEmitter {
28
+ /**
29
+ * constructor
30
+ * @returns {Socket}
31
+ */
32
+ constructor() {
33
+ super();
34
+ this._domain = 'unknown-domain';
35
+ this.onmessage = this.onmessage.bind(this);
36
+ this.onclose = this.onclose.bind(this);
37
+ // Increase max listeners to avoid memory leak warning in tests
38
+ this.setMaxListeners(10);
39
+ }
40
+
41
+ /**
42
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
43
+ * @returns {string}
44
+ */
45
+ get binaryType() {
46
+ return sockets.get(this).binaryType;
47
+ }
48
+
49
+ /**
50
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
51
+ * @returns {number}
52
+ */
53
+ get bufferedAmount() {
54
+ return sockets.get(this).bufferedAmount;
55
+ }
56
+
57
+ /**
58
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
59
+ * @returns {string}
60
+ */
61
+ get extensions() {
62
+ return sockets.get(this).extensions;
63
+ }
64
+
65
+ /**
66
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
67
+ * @returns {string}
68
+ */
69
+ get protocol() {
70
+ return sockets.get(this).protocol;
71
+ }
72
+
73
+ /**
74
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
75
+ * @returns {number}
76
+ */
77
+ get readyState() {
78
+ return sockets.get(this).readyState;
79
+ }
80
+
81
+ /**
82
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
83
+ * @returns {string}
84
+ */
85
+ get url() {
86
+ return sockets.get(this).url;
87
+ }
88
+
89
+ /**
90
+ * Provides the environmentally appropriate constructor (ws in NodeJS,
91
+ * WebSocket in browsers)
92
+ * @returns {WebSocket}
93
+ */
94
+ static getWebSocketConstructor() {
95
+ throw new Error(
96
+ 'Socket.getWebSocketConstructor() must be implemented in an environmentally appropriate way'
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Closes the socket
102
+ * @param {Object} options
103
+ * @param {string} options.reason
104
+ * @param {number} options.code
105
+ * @returns {Promise}
106
+ */
107
+ close(options) {
108
+ return new Promise((resolve, reject) => {
109
+ const socket = sockets.get(this);
110
+
111
+ if (!socket) {
112
+ // Open has not been called yet so there is no socket to close
113
+ resolve();
114
+
115
+ return;
116
+ }
117
+ // logger is defined once open is called
118
+ this.logger.info(`socket,${this._domain}: closing`);
119
+
120
+ if (
121
+ socket.readyState === SOCKET_READY_STATE.CLOSING ||
122
+ socket.readyState === SOCKET_READY_STATE.CLOSED
123
+ ) {
124
+ this.logger.info(`socket,${this._domain}: already closed`);
125
+ resolve();
126
+
127
+ return;
128
+ }
129
+
130
+ options = options || {};
131
+ if (options.code && options.code !== 1000 && (options.code < 3000 || options.code > 4999)) {
132
+ reject(new Error('`options.code` must be 1000 or between 3000 and 4999 (inclusive)'));
133
+
134
+ return;
135
+ }
136
+
137
+ const originalCode = options.code;
138
+ const originalReason = options.reason;
139
+
140
+ options = defaults(options, {
141
+ code: 1000,
142
+ reason: 'Done',
143
+ });
144
+
145
+ const closeTimer = safeSetTimeout(() => {
146
+ try {
147
+ this.logger.info(`socket,${this._domain}: no close event received, forcing closure`);
148
+ resolve(
149
+ this.onclose(
150
+ originalCode
151
+ ? {code: originalCode, reason: originalReason || 'Done (unknown)'}
152
+ : {
153
+ code: 1000,
154
+ reason: 'Done (forced)',
155
+ }
156
+ )
157
+ );
158
+ } catch (error) {
159
+ this.logger.warn(`socket,${this._domain}: force-close failed`, error);
160
+ }
161
+ }, this.forceCloseDelay);
162
+
163
+ socket.onclose = (event) => {
164
+ this.logger.info(`socket,${this._domain}: close event fired`, event.code, event.reason);
165
+ clearTimeout(closeTimer);
166
+ this.onclose(event);
167
+ resolve(event);
168
+ };
169
+
170
+ // If socket is still connecting, manually trigger close handler with desired code
171
+ // because calling close() on a CONNECTING socket may not preserve custom codes
172
+ if (socket.readyState === SOCKET_READY_STATE.CONNECTING) {
173
+ this.logger.info(
174
+ `socket,${this._domain}: socket still connecting, triggering close manually`
175
+ );
176
+ clearTimeout(closeTimer);
177
+ const closeEvent = {code: options.code, reason: options.reason};
178
+ this.onclose(closeEvent);
179
+ resolve(closeEvent);
180
+ try {
181
+ socket.close(options.code, options.reason);
182
+ } catch (error) {
183
+ this.logger.info(
184
+ `socket,${this._domain}: error while closing CONNECTING socket, likely due to browser incompatibility with custom close codes`,
185
+ error
186
+ );
187
+ }
188
+ } else {
189
+ socket.close(options.code, options.reason);
190
+ }
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Opens a WebSocket
196
+ * @param {string} url
197
+ * @param {options} options
198
+ * @param {number} options.forceCloseDelay (required)
199
+ * @param {number} options.pingInterval (required)
200
+ * @param {number} options.pongTimeout (required)
201
+ * @param {string} options.token (required)
202
+ * @param {string} options.trackingId (required)
203
+ * @param {Logger} options.logger (required)
204
+ * @param {string} options.logLevelToken
205
+ * @returns {Promise}
206
+ */
207
+ open(url, options) {
208
+ try {
209
+ this._domain = new URL(url).hostname;
210
+ } catch {
211
+ this._domain = url;
212
+ }
213
+
214
+ return new Promise((resolve, reject) => {
215
+ /* eslint complexity: [0] */
216
+ if (!url) {
217
+ reject(new Error('`url` is required'));
218
+
219
+ return;
220
+ }
221
+
222
+ if (sockets.get(this)) {
223
+ reject(new Error('Socket#open() can only be called once per instance'));
224
+
225
+ return;
226
+ }
227
+
228
+ options = options || {};
229
+
230
+ checkRequired(
231
+ ['forceCloseDelay', 'pingInterval', 'pongTimeout', 'token', 'trackingId', 'logger'],
232
+ options
233
+ );
234
+
235
+ Object.keys(options).forEach((key) => {
236
+ Reflect.defineProperty(this, key, {
237
+ enumerable: false,
238
+ value: options[key],
239
+ });
240
+ });
241
+
242
+ const WebSocket = Socket.getWebSocketConstructor();
243
+
244
+ this.logger.info(`socket,${this._domain}: creating WebSocket`);
245
+ const socket = new WebSocket(url, [], options);
246
+
247
+ socket.binaryType = 'arraybuffer';
248
+ socket.onmessage = this.onmessage;
249
+
250
+ socket.onclose = (event) => {
251
+ event = this._fixCloseCode(event);
252
+ this.logger.info(`socket,${this._domain}: closed before open`, event.code, event.reason);
253
+ switch (event.code) {
254
+ case 1005:
255
+ // IE 11 doesn't seem to allow 4XXX codes, so if we get a 1005, assume
256
+ // it's a bad websocket url. That'll trigger a device refresh; if it
257
+ // turns out we had a bad token, the device refresh should 401 and
258
+ // trigger a token refresh.
259
+ return reject(new UnknownResponse(event));
260
+ case 4400:
261
+ return reject(new BadRequest(event));
262
+ case 4401:
263
+ return reject(new NotAuthorized(event));
264
+ case 4403:
265
+ return reject(new Forbidden(event));
266
+ // case 4404:
267
+ // return reject(new NotFound(event));
268
+ default:
269
+ return reject(new ConnectionError(event));
270
+ }
271
+ };
272
+
273
+ socket.onopen = () => {
274
+ this.logger.info(`socket,${this._domain}: connected`);
275
+ this._authorize()
276
+ .then(() => {
277
+ this.logger.info(`socket,${this._domain}: authorized`);
278
+ socket.onclose = this.onclose;
279
+ resolve();
280
+ })
281
+ .catch(reject);
282
+ };
283
+
284
+ socket.onerror = (event) => {
285
+ this.logger.warn(`socket,${this._domain}: error event fired`, event);
286
+ };
287
+
288
+ sockets.set(this, socket);
289
+ this.logger.info(`socket,${this._domain}: waiting for server`);
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Handles incoming CloseEvents
295
+ * @param {CloseEvent} event
296
+ * @returns {undefined}
297
+ */
298
+ onclose(event) {
299
+ this.logger.info(`socket,${this._domain}: closed`, event.code, event.reason);
300
+ clearTimeout(this.pongTimer);
301
+ clearTimeout(this.pingTimer);
302
+
303
+ event = this._fixCloseCode(event);
304
+ this.emit('close', event);
305
+
306
+ // Remove all listeners to (a) avoid reacting to late pongs and (b) ensure
307
+ // we don't have a retain cycle.
308
+ this.removeAllListeners();
309
+ }
310
+
311
+ /**
312
+ * Handles incoming message events
313
+ * @param {MessageEvent} event
314
+ * @returns {undefined}
315
+ */
316
+ onmessage(event) {
317
+ try {
318
+ const data = JSON.parse(event.data);
319
+ const sequenceNumber = parseInt(data.sequenceNumber, 10);
320
+
321
+ this.logger.debug(`socket,${this._domain}: sequence number: `, sequenceNumber);
322
+ if (this.expectedSequenceNumber && sequenceNumber !== this.expectedSequenceNumber) {
323
+ this.logger.debug(
324
+ `socket,${this._domain}: sequence number mismatch indicates lost mercury message. expected: ${this.expectedSequenceNumber}, actual: ${sequenceNumber}`
325
+ );
326
+ this.emit('sequence-mismatch', sequenceNumber, this.expectedSequenceNumber);
327
+ }
328
+ this.expectedSequenceNumber = sequenceNumber + 1;
329
+
330
+ // Yes, it's a little weird looking; we want to emit message events that
331
+ // look like normal socket message events, but event.data cannot be
332
+ // modified and we don't actually care about anything but the data property
333
+ const processedEvent = {data};
334
+
335
+ this._acknowledge(processedEvent);
336
+ if (data.type === 'pong') {
337
+ this.emit('pong', processedEvent);
338
+ } else {
339
+ this.emit('message', processedEvent);
340
+ }
341
+ } catch (error) {
342
+ // The above code should only be able to throw if we receive an unparsable
343
+ // message from Mercury. At this time, the only action we have is to
344
+ // ignore it and move on.
345
+ /* istanbul ignore next */
346
+ this.logger.warn(`socket,${this._domain}: error while receiving WebSocket message`, error);
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Sends a message up the socket
352
+ * @param {mixed} data
353
+ * @returns {Promise}
354
+ */
355
+ send(data) {
356
+ return new Promise((resolve, reject) => {
357
+ if (this.readyState !== SOCKET_READY_STATE.OPEN) {
358
+ return reject(new Error('INVALID_STATE_ERROR'));
359
+ }
360
+
361
+ if (isObject(data)) {
362
+ data = JSON.stringify(data);
363
+ }
364
+
365
+ const socket = sockets.get(this);
366
+
367
+ socket.send(data);
368
+
369
+ return resolve();
370
+ });
371
+ }
372
+
373
+ /**
374
+ * Sends an acknowledgment for a specific event
375
+ * @param {MessageEvent} event
376
+ * @returns {Promise}
377
+ */
378
+ _acknowledge(event) {
379
+ if (!event) {
380
+ return Promise.reject(new Error('`event` is required'));
381
+ }
382
+
383
+ if (!has(event, 'data.id')) {
384
+ return Promise.reject(new Error('`event.data.id` is required'));
385
+ }
386
+
387
+ // Don't try to acknowledge if socket is not in open state
388
+ if (this.readyState !== SOCKET_READY_STATE.OPEN) {
389
+ return Promise.resolve(); // Silently ignore acknowledgment for closed sockets
390
+ }
391
+
392
+ return this.send({
393
+ messageId: event.data.id,
394
+ type: 'ack',
395
+ }).catch((error) => {
396
+ // Gracefully handle send errors (like INVALID_STATE_ERROR) to prevent test issues
397
+ if (error.message === 'INVALID_STATE_ERROR') {
398
+ return Promise.resolve(); // Socket was closed, ignore the acknowledgment
399
+ }
400
+ throw error; // Re-throw other errors
401
+ });
402
+ }
403
+
404
+ /**
405
+ * Sends an auth message up the socket
406
+ * @private
407
+ * @returns {Promise}
408
+ */
409
+ _authorize() {
410
+ return new Promise((resolve) => {
411
+ this.logger.info(`socket,${this._domain}: authorizing`);
412
+ this.send({
413
+ id: uuid.v4(),
414
+ type: 'authorization',
415
+ data: {
416
+ token: this.token,
417
+ },
418
+ trackingId: this.trackingId,
419
+ logLevelToken: this.logLevelToken,
420
+ });
421
+
422
+ const waitForBufferState = (event) => {
423
+ if (
424
+ !event.data.type &&
425
+ (event.data.data.eventType === 'mercury.buffer_state' ||
426
+ event.data.data.eventType === 'mercury.registration_status')
427
+ ) {
428
+ this.removeListener('message', waitForBufferState);
429
+ this._ping();
430
+ resolve();
431
+ }
432
+ };
433
+
434
+ this.once('message', waitForBufferState);
435
+ });
436
+ }
437
+
438
+ /**
439
+ * Deals with the fact that some browsers drop some close codes (but not
440
+ * close reasons).
441
+ * @param {CloseEvent} event
442
+ * @private
443
+ * @returns {CloseEvent}
444
+ */
445
+ _fixCloseCode(event) {
446
+ if (event.code === 1005 && event.reason) {
447
+ switch (event.reason.toLowerCase()) {
448
+ case 'replaced':
449
+ this.logger.info(
450
+ `socket,${this._domain}: fixing CloseEvent code for reason: `,
451
+ event.reason
452
+ );
453
+ event.code = 4000;
454
+ break;
455
+ case 'authentication failed':
456
+ case 'authentication did not happen within the timeout window of 30000 seconds.':
457
+ this.logger.info(
458
+ `socket,${this._domain}: fixing CloseEvent code for reason: `,
459
+ event.reason
460
+ );
461
+ event.code = 1008;
462
+ break;
463
+ default:
464
+ // do nothing
465
+ }
466
+ }
467
+
468
+ return event;
469
+ }
470
+
471
+ /**
472
+ * Sends a ping up the socket and confirms we get it back
473
+ * @param {[type]} id
474
+ * @private
475
+ * @returns {[type]}
476
+ */
477
+ _ping(id) {
478
+ const confirmPongId = (event) => {
479
+ try {
480
+ this.logger.debug(`socket,${this._domain}: pong`, event.data.id);
481
+ if (event.data && event.data.id !== id) {
482
+ this.logger.info(
483
+ `socket,${this._domain}: received pong for wrong ping id, closing socket`
484
+ );
485
+ this.logger.debug(`socket,${this._domain}: expected`, id, 'received', event.data.id);
486
+ this.close({
487
+ code: 1000,
488
+ reason: 'Pong mismatch',
489
+ });
490
+ }
491
+ } catch (error) {
492
+ // This try/catch block was added as a debugging step; to the best of my
493
+ // knowledge, the above can never throw.
494
+ /* istanbul ignore next */
495
+ this.logger.error(`socket,${this._domain}: error occurred in confirmPongId`, error);
496
+ }
497
+ };
498
+
499
+ const onPongNotReceived = () => {
500
+ try {
501
+ this.logger.info(
502
+ `socket,${this._domain}: pong not receive in expected period, closing socket`
503
+ );
504
+ this.close({
505
+ code: 1000,
506
+ reason: 'Pong not received',
507
+ }).catch((reason) => {
508
+ this.logger.warn(
509
+ `socket,${this._domain}: failed to close socket after missed pong`,
510
+ reason
511
+ );
512
+ });
513
+ } catch (error) {
514
+ // This try/catch block was added as a debugging step; to the best of my
515
+ // knowledge, the above can never throw.
516
+ /* istanbul ignore next */
517
+ this.logger.error(`socket,${this._domain}: error occurred in onPongNotReceived`, error);
518
+ }
519
+ };
520
+
521
+ const scheduleNextPingAndCancelPongTimer = () => {
522
+ try {
523
+ clearTimeout(this.pongTimer);
524
+ this.pingTimer = safeSetTimeout(() => this._ping(), this.pingInterval);
525
+ } catch (error) {
526
+ // This try/catch block was added as a debugging step; to the best of my
527
+ // knowledge, the above can never throw.
528
+ /* istanbul ignore next */
529
+ this.logger.error(
530
+ `socket,${this._domain}: error occurred in scheduleNextPingAndCancelPongTimer`,
531
+ error
532
+ );
533
+ }
534
+ };
535
+
536
+ const calculateLatency = (pingTimestamp) => {
537
+ const now = performance.now();
538
+ const latency = now - pingTimestamp;
539
+
540
+ this.logger.debug(`socket,${this._domain}: latency: `, latency);
541
+ this.emit('ping-pong-latency', latency);
542
+ };
543
+
544
+ id = id || uuid.v4();
545
+ const pingTimestamp = performance.now();
546
+
547
+ this.pongTimer = safeSetTimeout(onPongNotReceived, this.pongTimeout);
548
+ this.once('pong', scheduleNextPingAndCancelPongTimer);
549
+ this.once('pong', confirmPongId);
550
+ this.once('pong', () => calculateLatency(pingTimestamp));
551
+ this.logger.debug(`socket,${this._domain}: ping ${id}`);
552
+
553
+ return this.send({
554
+ id,
555
+ type: 'ping',
556
+ });
557
+ }
558
+ }
@@ -0,0 +1,13 @@
1
+ /*!
2
+ * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
3
+ */
4
+
5
+ import WS from 'ws';
6
+
7
+ import Socket from './socket-base';
8
+
9
+ Socket.getWebSocketConstructor = function getWebSocketConstructor() {
10
+ return WS;
11
+ };
12
+
13
+ export default Socket;
@@ -0,0 +1,31 @@
1
+ /* eslint-disable no-restricted-globals */
2
+
3
+ /*!
4
+ * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
5
+ */
6
+
7
+ /* eslint-env browser */
8
+
9
+ import Socket from './socket-base';
10
+
11
+ Socket.getWebSocketConstructor = function getWebSocketConstructor() {
12
+ // Grabed from https://github.com/heineiuo/isomorphic-ws/blob/9b977394ac875638c045fd9cf774ed418484b394/browser.js
13
+ let ws;
14
+
15
+ if (typeof WebSocket !== 'undefined') {
16
+ ws = WebSocket;
17
+ } else if (typeof MozWebSocket !== 'undefined') {
18
+ // eslint-disable-next-line no-undef
19
+ ws = MozWebSocket;
20
+ } else if (typeof global !== 'undefined') {
21
+ ws = global.WebSocket || global.MozWebSocket;
22
+ } else if (typeof window !== 'undefined') {
23
+ ws = window.WebSocket || window.MozWebSocket;
24
+ } else if (typeof self !== 'undefined') {
25
+ ws = self.WebSocket || self.MozWebSocket;
26
+ }
27
+
28
+ return ws;
29
+ };
30
+
31
+ export default Socket;