@unboundcx/video-sdk-client 2.0.0 → 2.0.2
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 +1651 -1488
- package/managers/ConnectionHealthMonitor.js +278 -0
- package/managers/ConnectionManager.js +17 -4
- package/managers/MediasoupManager.js +1061 -859
- package/managers/QualityMonitor.js +845 -0
- package/managers/RemoteMediaManager.js +17 -15
- package/managers/StatsCollector.js +35 -4
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -95,16 +95,29 @@ export class ConnectionManager extends EventEmitter {
|
|
|
95
95
|
this.logger.info('Connected to server');
|
|
96
96
|
this.isConnected = true;
|
|
97
97
|
this.reconnectAttempts = 0;
|
|
98
|
-
|
|
98
|
+
super.emit('connected', { socketId: this.socket.id });
|
|
99
99
|
resolve();
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
this.socket.on('disconnect', (reason) => {
|
|
103
103
|
this.logger.warn('Disconnected from server:', reason);
|
|
104
104
|
this.isConnected = false;
|
|
105
|
-
|
|
105
|
+
super.emit('disconnected', { reason });
|
|
106
106
|
});
|
|
107
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
|
+
|
|
108
121
|
this.socket.on('connect_error', (error) => {
|
|
109
122
|
// Socket.IO surfaces server-side middleware rejections as
|
|
110
123
|
// connect_error with the Error's message set to the code
|
|
@@ -118,7 +131,7 @@ export class ConnectionManager extends EventEmitter {
|
|
|
118
131
|
// assignment is stale and will reject every time.
|
|
119
132
|
// VideoMeetingClient handles the re-join flow.
|
|
120
133
|
this.socket?.disconnect();
|
|
121
|
-
|
|
134
|
+
super.emit('reassignmentRequired', { code: errorCode });
|
|
122
135
|
reject(new ConnectionError(`Reassignment required: ${errorCode}`, { code: errorCode }));
|
|
123
136
|
return;
|
|
124
137
|
}
|
|
@@ -130,7 +143,7 @@ export class ConnectionManager extends EventEmitter {
|
|
|
130
143
|
{ error: error.message, attempts: this.reconnectAttempts }
|
|
131
144
|
);
|
|
132
145
|
|
|
133
|
-
|
|
146
|
+
super.emit('error', connectionError);
|
|
134
147
|
|
|
135
148
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
136
149
|
reject(connectionError);
|