@unboundcx/video-sdk-client 2.0.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.
@@ -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
- this.emit('connected', { socketId: this.socket.id });
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
- this.emit('disconnected', { reason });
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
- this.emit('reassignmentRequired', { code: errorCode });
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
- this.emit('error', connectionError);
146
+ super.emit('error', connectionError);
134
147
 
135
148
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
136
149
  reject(connectionError);