@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.
@@ -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
- // Match the old working videoCreateSocket.js pattern exactly
48
- // Keep it simple: just withCredentials and auth
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
- console.log('ConnectionManager :: Auth object being sent:', auth);
55
- console.log('ConnectionManager :: Full socket options:', socketOptions);
56
-
57
- this.logger.info('Creating socket.io connection with options:', {
58
- serverUrl: this.serverUrl,
59
- ...socketOptions
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(this.serverUrl, socketOptions);
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
- this.emit('connected', { socketId: this.socket.id });
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
- this.emit('disconnected', { reason });
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
- this.logger.error('Connection error:', error);
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
- this.emit('error', connectionError);
146
+ super.emit('error', connectionError);
91
147
 
92
148
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
93
149
  reject(connectionError);