@unboundcx/video-sdk-client 1.1.0 → 2.0.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/VideoMeetingClient.js +1644 -1190
- package/managers/ConnectionHealthMonitor.js +278 -0
- package/managers/ConnectionManager.js +71 -15
- package/managers/MediasoupManager.js +1061 -775
- package/managers/QualityMonitor.js +845 -0
- package/managers/RemoteMediaManager.js +17 -15
- package/managers/StatsCollector.js +35 -4
- package/package.json +2 -2
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { Logger } from '../utils/Logger.js';
|
|
2
|
+
|
|
3
|
+
// ConnectionHealthMonitor — unifies three signals into a single user-facing
|
|
4
|
+
// connection state so the host UI doesn't have to stitch them together:
|
|
5
|
+
//
|
|
6
|
+
// 1) Transport events (event-driven) — fast path for clean failures
|
|
7
|
+
// mediasoup transport connectionstatechange → failed/disconnected
|
|
8
|
+
// 2) Socket disconnect (event-driven) — Socket.IO has died
|
|
9
|
+
// 3) Liveness heartbeat (poll-driven) — catches degraded networks where
|
|
10
|
+
// the OS/socket layer hasn't admitted the problem yet. If we haven't
|
|
11
|
+
// heard *anything* from the server in N seconds while we still think
|
|
12
|
+
// we're connected, we are not actually connected.
|
|
13
|
+
//
|
|
14
|
+
// This is critical for genuinely bad networks: ICE can sit in `disconnected`
|
|
15
|
+
// for 30+ seconds before flipping to `failed`, and Socket.IO heartbeats are
|
|
16
|
+
// slow (default ~25s). The user is suffering long before any event arrives.
|
|
17
|
+
// The heartbeat ensures we surface an `unstable` state in ~5s of silence.
|
|
18
|
+
//
|
|
19
|
+
// Emits a single 'connection' event with shape:
|
|
20
|
+
// { state, prevState, reason, durationMs, at }
|
|
21
|
+
// where state ∈ { connected, unstable, reconnecting, failed }
|
|
22
|
+
//
|
|
23
|
+
// connected — happy path
|
|
24
|
+
// unstable — silence detected by heartbeat, OR transport disconnected
|
|
25
|
+
// transiently; expect auto-recovery
|
|
26
|
+
// reconnecting — socket gone OR transport failed; SDK is actively trying
|
|
27
|
+
// failed — give-up threshold exceeded (still trying, but tell the
|
|
28
|
+
// user it's not transient)
|
|
29
|
+
//
|
|
30
|
+
// The monitor never closes anything itself. It only observes and reports.
|
|
31
|
+
|
|
32
|
+
const DEFAULTS = {
|
|
33
|
+
heartbeatIntervalMs: 2000,
|
|
34
|
+
// If we've heard nothing in this long while nominally connected, flip
|
|
35
|
+
// to 'unstable'. Set above Socket.IO's default engine ping interval
|
|
36
|
+
// (~25s) so quiet rooms don't get false unstable flips — solo /
|
|
37
|
+
// muted-but-fine sessions only see traffic every 10–25s, and
|
|
38
|
+
// anything shorter than that produces a yellow banner that
|
|
39
|
+
// flashes on/off as packets dribble in.
|
|
40
|
+
silenceUnstableMs: 30000,
|
|
41
|
+
// Pure silence alone never drives us past 'unstable' — escalation to
|
|
42
|
+
// 'reconnecting' requires corroborating evidence (socket dead or
|
|
43
|
+
// transport failed) unless silence exceeds this hard cap, which is
|
|
44
|
+
// well past Socket.IO's own ping timeout (~25s) plus a safety
|
|
45
|
+
// margin, and so means the engine itself has truly gone quiet.
|
|
46
|
+
silenceReconnectingMs: 60000,
|
|
47
|
+
// Time in reconnecting before we declare failed (still trying — UI
|
|
48
|
+
// should escalate copy from "Reconnecting…" to "Connection lost").
|
|
49
|
+
failedAfterMs: 15000,
|
|
50
|
+
// After we re-enter connected, wait this long before emitting the
|
|
51
|
+
// recovery event. Prevents flapping spam.
|
|
52
|
+
recoveryHoldMs: 1500,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export class ConnectionHealthMonitor {
|
|
56
|
+
constructor({
|
|
57
|
+
connectionManager,
|
|
58
|
+
mediasoupManager,
|
|
59
|
+
statsCollector,
|
|
60
|
+
onConnectionEvent,
|
|
61
|
+
options = {},
|
|
62
|
+
debug = false,
|
|
63
|
+
} = {}) {
|
|
64
|
+
this.logger = new Logger('SDK:ConnectionHealthMonitor', debug);
|
|
65
|
+
this.connection = connectionManager;
|
|
66
|
+
this.mediasoup = mediasoupManager || null;
|
|
67
|
+
this.statsCollector = statsCollector || null;
|
|
68
|
+
this.onEvent = typeof onConnectionEvent === 'function' ? onConnectionEvent : () => {};
|
|
69
|
+
this.opts = { ...DEFAULTS, ...options };
|
|
70
|
+
|
|
71
|
+
this._state = 'connected';
|
|
72
|
+
this._stateSince = Date.now();
|
|
73
|
+
this._lastActivityAt = Date.now();
|
|
74
|
+
this._timer = null;
|
|
75
|
+
this._started = false;
|
|
76
|
+
this._unsubs = [];
|
|
77
|
+
|
|
78
|
+
// Track transport-level state independently of socket-level state so
|
|
79
|
+
// we can report the right reason in the event payload.
|
|
80
|
+
this._transportState = { send: 'new', recv: 'new' };
|
|
81
|
+
this._socketAlive = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
start() {
|
|
85
|
+
if (this._started) return;
|
|
86
|
+
this._started = true;
|
|
87
|
+
this._lastActivityAt = Date.now();
|
|
88
|
+
this._wireConnection();
|
|
89
|
+
this._wireStats();
|
|
90
|
+
this._wireMediasoup();
|
|
91
|
+
this._timer = setInterval(() => this._tick(), this.opts.heartbeatIntervalMs);
|
|
92
|
+
this.logger.info('start');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
stop() {
|
|
96
|
+
if (!this._started) return;
|
|
97
|
+
this._started = false;
|
|
98
|
+
if (this._timer) clearInterval(this._timer);
|
|
99
|
+
this._timer = null;
|
|
100
|
+
for (const off of this._unsubs) {
|
|
101
|
+
try { off(); } catch {}
|
|
102
|
+
}
|
|
103
|
+
this._unsubs = [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Public: call this whenever we know we just heard from the server
|
|
107
|
+
// (any socket message, any stats sample). The wiring below already
|
|
108
|
+
// does this for the common cases — exposed for hosts that want to
|
|
109
|
+
// mark explicit liveness pings.
|
|
110
|
+
markActivity() {
|
|
111
|
+
this._lastActivityAt = Date.now();
|
|
112
|
+
// Hearing from the server is sufficient evidence the connection is
|
|
113
|
+
// alive. If we'd flipped to unstable purely on silence, recover.
|
|
114
|
+
if (this._state === 'unstable' && this._socketAlive && !this._transportFailed()) {
|
|
115
|
+
this._transition('connected', 'activity-resumed');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
getSnapshot() {
|
|
120
|
+
return {
|
|
121
|
+
state: this._state,
|
|
122
|
+
stateSince: this._stateSince,
|
|
123
|
+
durationMs: Date.now() - this._stateSince,
|
|
124
|
+
lastActivityAt: this._lastActivityAt,
|
|
125
|
+
silenceMs: Date.now() - this._lastActivityAt,
|
|
126
|
+
socketAlive: this._socketAlive,
|
|
127
|
+
transportState: { ...this._transportState },
|
|
128
|
+
thresholds: { ...this.opts },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- wiring -----------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
_wireConnection() {
|
|
135
|
+
if (!this.connection || typeof this.connection.on !== 'function') return;
|
|
136
|
+
|
|
137
|
+
const onConnected = () => {
|
|
138
|
+
this._socketAlive = true;
|
|
139
|
+
this._lastActivityAt = Date.now();
|
|
140
|
+
// Don't immediately flip to connected — let recoveryHold gate it
|
|
141
|
+
// so we don't spam connect→reconnect→connect during a flap.
|
|
142
|
+
if (this._state !== 'connected') {
|
|
143
|
+
this._transition('connected', 'socket-reconnected');
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const onDisconnected = (data) => {
|
|
147
|
+
this._socketAlive = false;
|
|
148
|
+
this._transition('reconnecting', `socket-disconnected:${data?.reason || 'unknown'}`);
|
|
149
|
+
};
|
|
150
|
+
// Treat any socket message as proof of life. ConnectionManager re-emits
|
|
151
|
+
// 'message' for arbitrary payloads; if not present, the heartbeat alone
|
|
152
|
+
// still catches silence.
|
|
153
|
+
const onMessage = () => this.markActivity();
|
|
154
|
+
|
|
155
|
+
this.connection.on('connected', onConnected);
|
|
156
|
+
this.connection.on('disconnected', onDisconnected);
|
|
157
|
+
if (typeof this.connection.on === 'function') {
|
|
158
|
+
this.connection.on('message', onMessage);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this._unsubs.push(() => {
|
|
162
|
+
try { this.connection.off?.('connected', onConnected); } catch {}
|
|
163
|
+
try { this.connection.off?.('disconnected', onDisconnected); } catch {}
|
|
164
|
+
try { this.connection.off?.('message', onMessage); } catch {}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_wireStats() {
|
|
169
|
+
if (!this.statsCollector) return;
|
|
170
|
+
// Every stats sample = proof of life from the WebRTC side.
|
|
171
|
+
const cb = () => this.markActivity();
|
|
172
|
+
if (typeof this.statsCollector.addInternalCallback === 'function') {
|
|
173
|
+
const off = this.statsCollector.addInternalCallback(cb);
|
|
174
|
+
this._unsubs.push(off);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_wireMediasoup() {
|
|
179
|
+
if (!this.mediasoup || typeof this.mediasoup.on !== 'function') return;
|
|
180
|
+
const onTransportState = ({ direction, state }) => {
|
|
181
|
+
if (direction === 'send' || direction === 'recv') {
|
|
182
|
+
this._transportState[direction] = state;
|
|
183
|
+
}
|
|
184
|
+
if (state === 'failed') {
|
|
185
|
+
this._transition('reconnecting', `transport-${direction}-failed`);
|
|
186
|
+
} else if (state === 'disconnected') {
|
|
187
|
+
// Transient — escalate to unstable, let heartbeat decide if it
|
|
188
|
+
// becomes reconnecting.
|
|
189
|
+
if (this._state === 'connected') {
|
|
190
|
+
this._transition('unstable', `transport-${direction}-disconnected`);
|
|
191
|
+
}
|
|
192
|
+
} else if (state === 'connected' || state === 'completed') {
|
|
193
|
+
if (this._state === 'unstable' && !this._transportFailed() && this._socketAlive) {
|
|
194
|
+
this._transition('connected', `transport-${direction}-recovered`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
this.mediasoup.on('transport:state', onTransportState);
|
|
199
|
+
// MediasoupManager also emits transport:closed for the failed case
|
|
200
|
+
// — surface that too.
|
|
201
|
+
const onTransportClosed = ({ direction, state }) => {
|
|
202
|
+
if (direction === 'send' || direction === 'recv') {
|
|
203
|
+
this._transportState[direction] = state || 'closed';
|
|
204
|
+
}
|
|
205
|
+
if (state === 'failed') {
|
|
206
|
+
this._transition('reconnecting', `transport-${direction}-closed`);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
this.mediasoup.on('transport:closed', onTransportClosed);
|
|
210
|
+
this._unsubs.push(() => {
|
|
211
|
+
try { this.mediasoup.off?.('transport:state', onTransportState); } catch {}
|
|
212
|
+
try { this.mediasoup.off?.('transport:closed', onTransportClosed); } catch {}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- heartbeat --------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
_tick() {
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
const silence = now - this._lastActivityAt;
|
|
221
|
+
const inState = now - this._stateSince;
|
|
222
|
+
|
|
223
|
+
// Silence-based escalation. 'unstable' is a soft hint we'll fire on
|
|
224
|
+
// pure silence, but escalation past that requires corroboration:
|
|
225
|
+
// either the socket actually dropped, a transport failed, or silence
|
|
226
|
+
// has run long past Socket.IO's own ping timeout (the engine itself
|
|
227
|
+
// is quiet, which is real evidence). This prevents an idle-but-fine
|
|
228
|
+
// session (e.g. host alone in the room) from cascading to "failed".
|
|
229
|
+
const hasCorroboration =
|
|
230
|
+
!this._socketAlive ||
|
|
231
|
+
this._transportFailed() ||
|
|
232
|
+
silence >= this.opts.silenceReconnectingMs;
|
|
233
|
+
|
|
234
|
+
if (this._state === 'connected' && silence >= this.opts.silenceUnstableMs) {
|
|
235
|
+
this._transition('unstable', `silence:${silence}ms`);
|
|
236
|
+
} else if (this._state === 'unstable' && hasCorroboration) {
|
|
237
|
+
this._transition(
|
|
238
|
+
'reconnecting',
|
|
239
|
+
!this._socketAlive
|
|
240
|
+
? 'socket-dead'
|
|
241
|
+
: this._transportFailed()
|
|
242
|
+
? 'transport-failed'
|
|
243
|
+
: `silence:${silence}ms`,
|
|
244
|
+
);
|
|
245
|
+
} else if (
|
|
246
|
+
this._state === 'reconnecting' &&
|
|
247
|
+
inState >= this.opts.failedAfterMs
|
|
248
|
+
) {
|
|
249
|
+
this._transition('failed', `still-down-after:${inState}ms`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_transportFailed() {
|
|
254
|
+
return (
|
|
255
|
+
this._transportState.send === 'failed' ||
|
|
256
|
+
this._transportState.recv === 'failed' ||
|
|
257
|
+
this._transportState.send === 'closed' ||
|
|
258
|
+
this._transportState.recv === 'closed'
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
_transition(next, reason) {
|
|
263
|
+
if (next === this._state) return;
|
|
264
|
+
// Don't downgrade from failed straight back to connected without going
|
|
265
|
+
// through reconnecting → connected; emit recovery explicitly.
|
|
266
|
+
const prev = this._state;
|
|
267
|
+
const at = Date.now();
|
|
268
|
+
const durationMs = at - this._stateSince;
|
|
269
|
+
this._state = next;
|
|
270
|
+
this._stateSince = at;
|
|
271
|
+
this.logger.info(`state ${prev} → ${next} (${reason}, ${durationMs}ms in ${prev})`);
|
|
272
|
+
try {
|
|
273
|
+
this.onEvent({ state: next, prevState: prev, reason, durationMs, at });
|
|
274
|
+
} catch (err) {
|
|
275
|
+
this.logger.error('onEvent threw', err);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -3,6 +3,11 @@ import { EventEmitter } from '../utils/EventEmitter.js';
|
|
|
3
3
|
import { Logger } from '../utils/Logger.js';
|
|
4
4
|
import { ConnectionError, TimeoutError } from '../utils/errors.js';
|
|
5
5
|
|
|
6
|
+
// Server-side rejection codes that mean "this token/room/pod assignment is
|
|
7
|
+
// stale and the client must re-fetch a fresh assignment via sdk.video.joinRoom".
|
|
8
|
+
// Emitted by app1-socket's authorizeVideoSocketConnection middleware.
|
|
9
|
+
const REASSIGNMENT_ERROR_CODES = new Set(['pod_reassign_required', 'room_mismatch']);
|
|
10
|
+
|
|
6
11
|
/**
|
|
7
12
|
* Manages Socket.io connection and signaling with the video server
|
|
8
13
|
*
|
|
@@ -11,17 +16,26 @@ import { ConnectionError, TimeoutError } from '../utils/errors.js';
|
|
|
11
16
|
* - 'disconnected' - Disconnected from server
|
|
12
17
|
* - 'error' - Connection error
|
|
13
18
|
* - 'message' - Received message from server
|
|
19
|
+
* - 'reassignmentRequired' - Server signaled the room's pod assignment is
|
|
20
|
+
* stale (pod_reassign_required or room_mismatch). Consumer (VideoMeetingClient)
|
|
21
|
+
* re-fetches joinRoom() and reconnects transparently.
|
|
14
22
|
*/
|
|
15
23
|
export class ConnectionManager extends EventEmitter {
|
|
16
24
|
/**
|
|
17
25
|
* @param {Object} options
|
|
18
|
-
* @param {string} options.serverUrl - WebSocket server URL
|
|
26
|
+
* @param {string} options.serverUrl - WebSocket server URL. If a namespace
|
|
27
|
+
* is needed (e.g. `/video`), pass it via `options.namespace` rather than
|
|
28
|
+
* embedding it in `serverUrl`.
|
|
29
|
+
* @param {string} [options.namespace] - Socket.IO namespace path, e.g.
|
|
30
|
+
* `/video`. Appended to `serverUrl` when constructing the io() call.
|
|
31
|
+
* Comes from the API's `connectionInfo.socket.namespace` response field.
|
|
19
32
|
* @param {boolean} options.debug - Enable debug logging
|
|
20
33
|
*/
|
|
21
34
|
constructor(options) {
|
|
22
35
|
super();
|
|
23
36
|
|
|
24
37
|
this.serverUrl = options.serverUrl;
|
|
38
|
+
this.namespace = options.namespace || null;
|
|
25
39
|
this.logger = new Logger('SDK:ConnectionManager', options.debug);
|
|
26
40
|
this.socket = null;
|
|
27
41
|
this.isConnected = false;
|
|
@@ -44,22 +58,35 @@ export class ConnectionManager extends EventEmitter {
|
|
|
44
58
|
|
|
45
59
|
return new Promise((resolve, reject) => {
|
|
46
60
|
try {
|
|
47
|
-
//
|
|
48
|
-
//
|
|
61
|
+
// Normalize the handshake auth field. The server-side middleware
|
|
62
|
+
// in app1-socket expects `accountNamespace` (disambiguates from
|
|
63
|
+
// Socket.IO's own `/video` namespace concept). Older callers may
|
|
64
|
+
// still pass the field as `namespace`; translate transparently.
|
|
65
|
+
const normalizedAuth = { ...auth };
|
|
66
|
+
if (normalizedAuth.namespace !== undefined && normalizedAuth.accountNamespace === undefined) {
|
|
67
|
+
normalizedAuth.accountNamespace = normalizedAuth.namespace;
|
|
68
|
+
delete normalizedAuth.namespace;
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
const socketOptions = {
|
|
50
72
|
withCredentials: true,
|
|
51
|
-
auth,
|
|
73
|
+
auth: normalizedAuth,
|
|
52
74
|
};
|
|
53
75
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
// Build the connection URL. Socket.IO reads the namespace from
|
|
77
|
+
// the URL path, so for the `/video` namespace we append it to
|
|
78
|
+
// the base server URL.
|
|
79
|
+
const connectionUrl = this.namespace
|
|
80
|
+
? `${this.serverUrl}${this.namespace}`
|
|
81
|
+
: this.serverUrl;
|
|
82
|
+
|
|
83
|
+
this.logger.info('Creating socket.io connection', {
|
|
84
|
+
connectionUrl,
|
|
85
|
+
namespace: this.namespace,
|
|
86
|
+
authKeys: Object.keys(normalizedAuth),
|
|
60
87
|
});
|
|
61
88
|
|
|
62
|
-
this.socket = io(
|
|
89
|
+
this.socket = io(connectionUrl, socketOptions);
|
|
63
90
|
|
|
64
91
|
this.logger.info('Socket.io instance created, waiting for connect event');
|
|
65
92
|
|
|
@@ -68,18 +95,47 @@ export class ConnectionManager extends EventEmitter {
|
|
|
68
95
|
this.logger.info('Connected to server');
|
|
69
96
|
this.isConnected = true;
|
|
70
97
|
this.reconnectAttempts = 0;
|
|
71
|
-
|
|
98
|
+
super.emit('connected', { socketId: this.socket.id });
|
|
72
99
|
resolve();
|
|
73
100
|
});
|
|
74
101
|
|
|
75
102
|
this.socket.on('disconnect', (reason) => {
|
|
76
103
|
this.logger.warn('Disconnected from server:', reason);
|
|
77
104
|
this.isConnected = false;
|
|
78
|
-
|
|
105
|
+
super.emit('disconnected', { reason });
|
|
79
106
|
});
|
|
80
107
|
|
|
108
|
+
// Liveness signal for ConnectionHealthMonitor. Re-emit any
|
|
109
|
+
// inbound socket traffic — including Socket.IO engine pongs
|
|
110
|
+
// surfaced via the reserved 'pong' event in newer clients —
|
|
111
|
+
// as a generic 'message' so the monitor can treat it as
|
|
112
|
+
// proof of life. Without this, an idle room (e.g. host
|
|
113
|
+
// alone) starves the silence detector and gets flagged as
|
|
114
|
+
// reconnecting/failed even though the connection is fine.
|
|
115
|
+
if (typeof this.socket.onAny === 'function') {
|
|
116
|
+
this.socket.onAny((event) => {
|
|
117
|
+
super.emit('message', { event });
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
81
121
|
this.socket.on('connect_error', (error) => {
|
|
82
|
-
|
|
122
|
+
// Socket.IO surfaces server-side middleware rejections as
|
|
123
|
+
// connect_error with the Error's message set to the code
|
|
124
|
+
// we called next(new Error(code)) with on the server.
|
|
125
|
+
const errorCode = error?.data?.code || error?.message;
|
|
126
|
+
|
|
127
|
+
this.logger.error('Connection error:', error, { errorCode });
|
|
128
|
+
|
|
129
|
+
if (REASSIGNMENT_ERROR_CODES.has(errorCode)) {
|
|
130
|
+
// Stop Socket.IO from auto-retrying — the token/pod
|
|
131
|
+
// assignment is stale and will reject every time.
|
|
132
|
+
// VideoMeetingClient handles the re-join flow.
|
|
133
|
+
this.socket?.disconnect();
|
|
134
|
+
super.emit('reassignmentRequired', { code: errorCode });
|
|
135
|
+
reject(new ConnectionError(`Reassignment required: ${errorCode}`, { code: errorCode }));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
83
139
|
this.reconnectAttempts++;
|
|
84
140
|
|
|
85
141
|
const connectionError = new ConnectionError(
|
|
@@ -87,7 +143,7 @@ export class ConnectionManager extends EventEmitter {
|
|
|
87
143
|
{ error: error.message, attempts: this.reconnectAttempts }
|
|
88
144
|
);
|
|
89
145
|
|
|
90
|
-
|
|
146
|
+
super.emit('error', connectionError);
|
|
91
147
|
|
|
92
148
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
93
149
|
reject(connectionError);
|