ulai-js-sdk 0.1.0 → 0.1.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/dist/events.js CHANGED
@@ -8,63 +8,170 @@ const eventemitter3_1 = __importDefault(require("eventemitter3"));
8
8
  class EventsClient extends eventemitter3_1.default {
9
9
  ws = null;
10
10
  url;
11
+ sessionId = null;
12
+ eventsToken = null;
13
+ lastEventId = null;
14
+ isClosedManually = false;
15
+ retryAttempts = 0;
16
+ maxRetries = 5;
17
+ reconnectTimer = null;
18
+ heartbeatTimer = null;
11
19
  constructor(cpBase) {
12
20
  super();
13
- const scheme = cpBase.startsWith('https') ? 'wss' : 'ws';
14
- const host = cpBase.replace(/^https?:\/\//, '').replace(/\/$/, '');
15
- this.url = `${scheme}://${host}/api/v1/events`;
21
+ this.url = this.buildUrl(cpBase);
22
+ }
23
+ buildUrl(cpBase) {
24
+ const cleanHost = cpBase
25
+ .replace(/^https?:\/\//, '')
26
+ .replace(/^wss?:\/\//, '')
27
+ .replace(/\/$/, '');
28
+ const isLocal = cleanHost.startsWith('localhost') ||
29
+ cleanHost.startsWith('127.0.0.1');
30
+ let scheme = 'wss';
31
+ if (isLocal) {
32
+ scheme = 'ws';
33
+ }
34
+ else if (typeof window !== 'undefined' && window.location.protocol === 'http:') {
35
+ scheme = 'ws';
36
+ }
37
+ else if (cpBase.startsWith('http://')) {
38
+ scheme = 'ws';
39
+ }
40
+ return `${scheme}://${cleanHost}/api/v1/events`;
16
41
  }
17
42
  connect(sessionId, eventsToken) {
18
- this.ws = new WebSocket(this.url);
19
- this.ws.onopen = () => {
20
- this.ws?.send(JSON.stringify({
21
- action: 'subscribe',
22
- scope: 'session',
23
- session_id: sessionId,
24
- token: eventsToken
25
- }));
26
- };
27
- this.ws.onmessage = (event) => {
28
- try {
29
- const msg = JSON.parse(event.data);
30
- if (msg.type === 'subscribed') {
31
- this.emit('connected', msg);
32
- }
33
- else if (msg.type === 'transcript_delta') {
34
- this.emit('transcript_delta', msg);
43
+ this.sessionId = sessionId;
44
+ this.eventsToken = eventsToken;
45
+ this.isClosedManually = false;
46
+ this.cleanupSocket();
47
+ try {
48
+ this.ws = new WebSocket(this.url);
49
+ this.ws.onopen = () => {
50
+ this.retryAttempts = 0;
51
+ this.sendSubscribe();
52
+ this.startHeartbeat();
53
+ };
54
+ this.ws.onmessage = (event) => {
55
+ try {
56
+ const msg = JSON.parse(event.data);
57
+ if (msg.id) {
58
+ this.lastEventId = msg.id;
59
+ }
60
+ if (msg.type === 'subscribed') {
61
+ this.emit('connected', msg);
62
+ }
63
+ else if (msg.type === 'transcript_delta') {
64
+ this.emit('transcript_delta', msg);
65
+ }
66
+ else if (msg.type === 'roster') {
67
+ this.emit('roster', msg);
68
+ }
69
+ else if (msg.type === 'session_terminated') {
70
+ this.emit('session_terminated', msg);
71
+ }
72
+ else if (msg.type === 'participant_joined') {
73
+ this.emit('participant_joined', msg);
74
+ }
75
+ else if (msg.type === 'participant_left') {
76
+ this.emit('participant_left', msg);
77
+ }
78
+ else if (msg.type === 'error') {
79
+ this.emit('error', msg);
80
+ }
35
81
  }
36
- else if (msg.type === 'roster') {
37
- this.emit('roster', msg);
82
+ catch (err) {
83
+ console.error('[Events] Failed to parse message', err);
38
84
  }
39
- else if (msg.type === 'session_terminated') {
40
- this.emit('session_terminated', msg);
85
+ };
86
+ this.ws.onclose = (event) => {
87
+ this.stopHeartbeat();
88
+ if (this.isClosedManually) {
89
+ this.emit('disconnected');
90
+ return;
41
91
  }
42
- else if (msg.type === 'participant_joined') {
43
- this.emit('participant_joined', msg);
92
+ if (this.retryAttempts < this.maxRetries) {
93
+ this.retryAttempts++;
94
+ const delay = Math.min(1000 * Math.pow(1.5, this.retryAttempts), 10000);
95
+ console.warn(`[Events] Event socket closed. Reconnecting in ${Math.round(delay)}ms (attempt ${this.retryAttempts}/${this.maxRetries})...`);
96
+ this.emit('reconnecting', { attempt: this.retryAttempts });
97
+ this.reconnectTimer = setTimeout(() => {
98
+ if (!this.isClosedManually && this.sessionId && this.eventsToken) {
99
+ this.connect(this.sessionId, this.eventsToken);
100
+ }
101
+ }, delay);
44
102
  }
45
- else if (msg.type === 'participant_left') {
46
- this.emit('participant_left', msg);
103
+ else {
104
+ console.error('[Events] Max reconnect attempts exceeded for events socket');
105
+ this.emit('disconnected', event);
47
106
  }
48
- else if (msg.type === 'error') {
49
- this.emit('error', msg);
107
+ };
108
+ this.ws.onerror = (error) => {
109
+ this.emit('error', error);
110
+ };
111
+ }
112
+ catch (err) {
113
+ this.emit('error', err);
114
+ }
115
+ }
116
+ sendSubscribe() {
117
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.sessionId || !this.eventsToken) {
118
+ return;
119
+ }
120
+ const payload = {
121
+ action: 'subscribe',
122
+ scope: 'session',
123
+ session_id: this.sessionId,
124
+ token: this.eventsToken,
125
+ };
126
+ if (this.lastEventId) {
127
+ payload.last_event_id = this.lastEventId;
128
+ }
129
+ try {
130
+ this.ws.send(JSON.stringify(payload));
131
+ }
132
+ catch (err) {
133
+ console.error('[Events] Failed to send subscription', err);
134
+ }
135
+ }
136
+ startHeartbeat() {
137
+ this.stopHeartbeat();
138
+ this.heartbeatTimer = setInterval(() => {
139
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
140
+ try {
141
+ this.ws.send(JSON.stringify({ action: 'ping' }));
50
142
  }
143
+ catch (_) { }
51
144
  }
52
- catch (err) {
53
- console.error('Failed to parse events message', err);
54
- }
55
- };
56
- this.ws.onclose = () => {
57
- this.emit('disconnected');
58
- };
59
- this.ws.onerror = (error) => {
60
- this.emit('error', error);
61
- };
145
+ }, 25000);
62
146
  }
63
- close() {
147
+ stopHeartbeat() {
148
+ if (this.heartbeatTimer) {
149
+ clearInterval(this.heartbeatTimer);
150
+ this.heartbeatTimer = null;
151
+ }
152
+ }
153
+ cleanupSocket() {
154
+ this.stopHeartbeat();
155
+ if (this.reconnectTimer) {
156
+ clearTimeout(this.reconnectTimer);
157
+ this.reconnectTimer = null;
158
+ }
64
159
  if (this.ws) {
65
- this.ws.close();
160
+ this.ws.onopen = null;
161
+ this.ws.onmessage = null;
162
+ this.ws.onclose = null;
163
+ this.ws.onerror = null;
164
+ try {
165
+ this.ws.close();
166
+ }
167
+ catch (_) { }
66
168
  this.ws = null;
67
169
  }
68
170
  }
171
+ close() {
172
+ this.isClosedManually = true;
173
+ this.cleanupSocket();
174
+ this.emit('disconnected');
175
+ }
69
176
  }
70
177
  exports.EventsClient = EventsClient;
package/dist/room.d.ts CHANGED
@@ -5,14 +5,40 @@ export declare class Room extends EventEmitter<RoomEvent> {
5
5
  private signaling;
6
6
  private eventsClient;
7
7
  private peerConnection;
8
+ private audioTransceiver;
8
9
  private localStream;
9
10
  private remoteStreams;
10
11
  roomId: string | null;
11
12
  participantId: string | null;
13
+ private readonly isPolite;
14
+ private makingOffer;
15
+ private candidateQueue;
12
16
  constructor(cpBase: string);
13
17
  connect(options: JoinRoomOptions): Promise<void>;
14
18
  private setupEventsClient;
15
19
  private setupSignaling;
16
- publishAudio(): Promise<void>;
20
+ private safeNegotiate;
21
+ private handleIncomingCandidate;
22
+ private drainCandidateQueue;
23
+ /**
24
+ * Publishes local microphone audio to the room.
25
+ */
26
+ publishAudio(): Promise<MediaStream>;
27
+ /**
28
+ * Sets microphone mute state.
29
+ */
30
+ setMuted(muted: boolean): void;
31
+ /**
32
+ * Returns whether the microphone is currently muted.
33
+ */
34
+ isMuted(): boolean;
35
+ /**
36
+ * Gets the active local media stream.
37
+ */
38
+ getLocalStream(): MediaStream | null;
39
+ /**
40
+ * Gets all subscribed remote audio streams.
41
+ */
42
+ getRemoteStreams(): MediaStream[];
17
43
  disconnect(): void;
18
44
  }
package/dist/room.js CHANGED
@@ -12,10 +12,15 @@ class Room extends eventemitter3_1.default {
12
12
  signaling = null;
13
13
  eventsClient = null;
14
14
  peerConnection = null;
15
+ audioTransceiver = null;
15
16
  localStream = null;
16
17
  remoteStreams = new Map();
17
18
  roomId = null;
18
19
  participantId = null;
20
+ // Perfect Negotiation State (Client is the polite peer)
21
+ isPolite = true;
22
+ makingOffer = false;
23
+ candidateQueue = [];
19
24
  constructor(cpBase) {
20
25
  super();
21
26
  this.cpBase = cpBase;
@@ -23,43 +28,69 @@ class Room extends eventemitter3_1.default {
23
28
  async connect(options) {
24
29
  this.roomId = options.roomId;
25
30
  this.participantId = options.participantId || '';
26
- // 1. Setup Control Plane Events
31
+ // 1. Setup Control Plane Events (Transcripts, Roster, Session Lifecycle)
27
32
  if (options.eventsToken) {
28
33
  this.eventsClient = new events_1.EventsClient(this.cpBase);
29
34
  this.setupEventsClient();
30
35
  this.eventsClient.connect(this.roomId, options.eventsToken);
31
36
  }
32
- // 2. Setup WebRTC Signaling if nodeAddress and ticket are provided
37
+ // 2. Setup WebRTC Signaling & Media
33
38
  if (options.nodeAddress && options.ticket) {
34
39
  this.signaling = new signaling_1.SignalingClient(options.nodeAddress, options.ticket);
35
40
  this.setupSignaling();
36
- this.peerConnection = new RTCPeerConnection({
37
- iceServers: [
38
- { urls: 'stun:stun.l.google.com:19302' },
39
- { urls: ['turn:turn.ulai.in:3478', 'turns:turn.ulai.in:5349'], username: 'ulai', credential: 'xulaizrt' }
40
- ]
41
- });
41
+ const defaultIceServers = [
42
+ { urls: 'stun:stun.l.google.com:19302' },
43
+ ];
44
+ const iceServers = options.iceServers && options.iceServers.length > 0
45
+ ? options.iceServers
46
+ : defaultIceServers;
47
+ this.peerConnection = new RTCPeerConnection({ iceServers });
48
+ // Pre-allocate audio transceiver as recvonly so the initial offer immediately
49
+ // negotiates audio codec capabilities (m=audio) and is ready for agent downlink.
50
+ try {
51
+ this.audioTransceiver = this.peerConnection.addTransceiver('audio', {
52
+ direction: 'recvonly',
53
+ });
54
+ }
55
+ catch (err) {
56
+ console.warn('[Room] Error adding initial audio transceiver:', err);
57
+ }
42
58
  this.peerConnection.onicecandidate = (event) => {
43
59
  if (event.candidate && this.signaling) {
44
- this.signaling.send({ type: 'candidate', candidate: JSON.stringify(event.candidate.toJSON()) });
60
+ this.signaling.send({
61
+ type: 'candidate',
62
+ candidate: JSON.stringify(event.candidate.toJSON()),
63
+ });
45
64
  }
46
65
  };
66
+ // Resilient ontrack handler: works with stream-grouped tracks and bare Pion SFU tracks
47
67
  this.peerConnection.ontrack = (event) => {
48
- const stream = event.streams[0];
49
- if (stream) {
50
- this.remoteStreams.set(stream.id, stream);
51
- this.emit('trackSubscribed', event.track, stream);
52
- }
68
+ const stream = event.streams && event.streams[0]
69
+ ? event.streams[0]
70
+ : new MediaStream([event.track]);
71
+ this.remoteStreams.set(stream.id, stream);
72
+ this.emit('trackSubscribed', event.track, stream);
73
+ event.track.onended = () => {
74
+ this.remoteStreams.delete(stream.id);
75
+ this.emit('trackUnsubscribed', event.track, stream);
76
+ };
53
77
  };
54
78
  this.peerConnection.onconnectionstatechange = () => {
55
- switch (this.peerConnection?.connectionState) {
79
+ const state = this.peerConnection?.connectionState;
80
+ switch (state) {
56
81
  case 'connected':
57
82
  this.emit('connected');
58
83
  break;
84
+ case 'connecting':
85
+ this.emit('reconnecting');
86
+ break;
59
87
  case 'disconnected':
60
88
  case 'failed':
61
89
  this.emit('disconnected');
62
90
  break;
91
+ case 'closed':
92
+ this.emit('disconnected');
93
+ break;
63
94
  }
64
95
  };
65
96
  this.signaling.connect();
@@ -82,64 +113,202 @@ class Room extends eventemitter3_1.default {
82
113
  if (!this.signaling)
83
114
  return;
84
115
  this.signaling.on('connected', async () => {
85
- // Create initial offer when signaling connects
86
- if (this.peerConnection) {
87
- const offer = await this.peerConnection.createOffer();
88
- await this.peerConnection.setLocalDescription(offer);
89
- this.signaling.send({ type: 'offer', sdp: this.peerConnection.localDescription?.sdp });
90
- }
116
+ // Initiate negotiation when signaling is ready
117
+ await this.safeNegotiate();
118
+ });
119
+ this.signaling.on('reconnected', async () => {
120
+ this.emit('reconnected');
121
+ await this.safeNegotiate();
91
122
  });
92
123
  this.signaling.on('message', async (msg) => {
93
124
  if (!this.peerConnection)
94
125
  return;
95
126
  try {
96
127
  if (msg.type === 'offer' || msg.type === 'restart-offer') {
97
- await this.peerConnection.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: msg.sdp }));
98
- const answer = await this.peerConnection.createAnswer();
99
- await this.peerConnection.setLocalDescription(answer);
100
- this.signaling.send({ type: 'answer', sdp: this.peerConnection.localDescription?.sdp });
128
+ // Perfect negotiation: detect collision (SDP Glare)
129
+ const offerCollision = this.makingOffer || this.peerConnection.signalingState !== 'stable';
130
+ if (offerCollision && !this.isPolite) {
131
+ console.warn('[Room] Impolite collision: ignoring incoming offer');
132
+ return;
133
+ }
134
+ if (offerCollision && this.isPolite) {
135
+ // Polite peer: rollback local offer to accept the incoming server offer
136
+ console.log('[Room] Polite collision: rolling back local offer to accept server offer');
137
+ await this.peerConnection.setLocalDescription({ type: 'rollback' });
138
+ }
139
+ if (msg.sdp) {
140
+ await this.peerConnection.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: msg.sdp }));
141
+ await this.drainCandidateQueue();
142
+ const answer = await this.peerConnection.createAnswer();
143
+ await this.peerConnection.setLocalDescription(answer);
144
+ this.signaling.send({
145
+ type: 'answer',
146
+ sdp: this.peerConnection.localDescription?.sdp,
147
+ });
148
+ }
101
149
  }
102
150
  else if (msg.type === 'answer') {
103
- await this.peerConnection.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: msg.sdp }));
151
+ if (msg.sdp) {
152
+ await this.peerConnection.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: msg.sdp }));
153
+ await this.drainCandidateQueue();
154
+ }
104
155
  }
105
156
  else if (msg.type === 'candidate' && msg.candidate) {
106
- await this.peerConnection.addIceCandidate(JSON.parse(msg.candidate));
157
+ const candidateInit = JSON.parse(msg.candidate);
158
+ await this.handleIncomingCandidate(candidateInit);
107
159
  }
108
160
  }
109
161
  catch (err) {
110
- console.error('Error handling signaling message', err);
162
+ console.error('[Room] Error handling signaling message:', err);
163
+ this.emit('error', err);
111
164
  }
112
165
  });
113
166
  this.signaling.on('disconnected', () => {
114
167
  this.emit('disconnected');
115
168
  });
116
169
  }
170
+ async safeNegotiate() {
171
+ if (!this.peerConnection || this.makingOffer)
172
+ return;
173
+ try {
174
+ this.makingOffer = true;
175
+ const offer = await this.peerConnection.createOffer();
176
+ // Only set local description if signalingState is stable
177
+ if (this.peerConnection.signalingState !== 'stable') {
178
+ console.warn('[Room] Skip makeOffer: signalingState is', this.peerConnection.signalingState);
179
+ return;
180
+ }
181
+ await this.peerConnection.setLocalDescription(offer);
182
+ this.signaling?.send({
183
+ type: 'offer',
184
+ sdp: this.peerConnection.localDescription?.sdp,
185
+ });
186
+ }
187
+ catch (err) {
188
+ console.error('[Room] safeNegotiate error:', err);
189
+ }
190
+ finally {
191
+ this.makingOffer = false;
192
+ }
193
+ }
194
+ async handleIncomingCandidate(candidate) {
195
+ if (!this.peerConnection?.remoteDescription || !this.peerConnection.remoteDescription.type) {
196
+ // Buffer candidates if remote description is not set yet
197
+ this.candidateQueue.push(candidate);
198
+ return;
199
+ }
200
+ try {
201
+ await this.peerConnection.addIceCandidate(candidate);
202
+ }
203
+ catch (err) {
204
+ console.warn('[Room] Failed to add ICE candidate:', err);
205
+ }
206
+ }
207
+ async drainCandidateQueue() {
208
+ while (this.candidateQueue.length > 0) {
209
+ const candidate = this.candidateQueue.shift();
210
+ if (candidate && this.peerConnection?.remoteDescription) {
211
+ try {
212
+ await this.peerConnection.addIceCandidate(candidate);
213
+ }
214
+ catch (err) {
215
+ console.warn('[Room] Error draining candidate:', err);
216
+ }
217
+ }
218
+ }
219
+ }
220
+ /**
221
+ * Publishes local microphone audio to the room.
222
+ */
117
223
  async publishAudio() {
118
224
  try {
119
- this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
120
- if (this.peerConnection && this.localStream) {
121
- this.localStream.getTracks().forEach((track) => {
122
- this.peerConnection.addTrack(track, this.localStream);
123
- });
124
- // Renegotiate
125
- const offer = await this.peerConnection.createOffer();
126
- await this.peerConnection.setLocalDescription(offer);
127
- this.signaling?.send({ type: 'offer', sdp: this.peerConnection.localDescription?.sdp });
225
+ this.localStream = await navigator.mediaDevices.getUserMedia({
226
+ audio: {
227
+ echoCancellation: true,
228
+ noiseSuppression: true,
229
+ autoGainControl: true,
230
+ },
231
+ video: false,
232
+ });
233
+ const audioTrack = this.localStream.getAudioTracks()[0];
234
+ if (this.peerConnection && audioTrack) {
235
+ if (this.audioTransceiver) {
236
+ this.audioTransceiver.direction = 'sendrecv';
237
+ if (this.audioTransceiver.sender) {
238
+ await this.audioTransceiver.sender.replaceTrack(audioTrack);
239
+ }
240
+ }
241
+ else {
242
+ const transceiver = this.peerConnection
243
+ .getTransceivers()
244
+ .find((t) => t.receiver.track.kind === 'audio');
245
+ if (transceiver) {
246
+ this.audioTransceiver = transceiver;
247
+ this.audioTransceiver.direction = 'sendrecv';
248
+ await this.audioTransceiver.sender.replaceTrack(audioTrack);
249
+ }
250
+ else {
251
+ this.peerConnection.addTrack(audioTrack, this.localStream);
252
+ }
253
+ }
254
+ // Trigger safe renegotiation
255
+ await this.safeNegotiate();
128
256
  }
257
+ return this.localStream;
129
258
  }
130
259
  catch (err) {
131
- console.error('Failed to publish audio:', err);
260
+ console.error('[Room] Failed to publish audio:', err);
132
261
  this.emit('error', err);
262
+ throw err;
133
263
  }
134
264
  }
265
+ /**
266
+ * Sets microphone mute state.
267
+ */
268
+ setMuted(muted) {
269
+ if (this.localStream) {
270
+ this.localStream.getAudioTracks().forEach((track) => {
271
+ track.enabled = !muted;
272
+ });
273
+ this.emit('muteChanged', muted);
274
+ }
275
+ }
276
+ /**
277
+ * Returns whether the microphone is currently muted.
278
+ */
279
+ isMuted() {
280
+ if (!this.localStream)
281
+ return true;
282
+ const tracks = this.localStream.getAudioTracks();
283
+ return tracks.length === 0 || !tracks[0].enabled;
284
+ }
285
+ /**
286
+ * Gets the active local media stream.
287
+ */
288
+ getLocalStream() {
289
+ return this.localStream;
290
+ }
291
+ /**
292
+ * Gets all subscribed remote audio streams.
293
+ */
294
+ getRemoteStreams() {
295
+ return Array.from(this.remoteStreams.values());
296
+ }
135
297
  disconnect() {
136
298
  if (this.localStream) {
137
- this.localStream.getTracks().forEach(track => track.stop());
299
+ this.localStream.getTracks().forEach((track) => track.stop());
300
+ this.localStream = null;
138
301
  }
139
302
  if (this.peerConnection) {
303
+ this.peerConnection.onicecandidate = null;
304
+ this.peerConnection.ontrack = null;
305
+ this.peerConnection.onconnectionstatechange = null;
140
306
  this.peerConnection.close();
141
307
  this.peerConnection = null;
142
308
  }
309
+ this.audioTransceiver = null;
310
+ this.candidateQueue = [];
311
+ this.remoteStreams.clear();
143
312
  if (this.signaling) {
144
313
  this.signaling.close();
145
314
  this.signaling = null;
@@ -3,8 +3,19 @@ import { SignalMessage } from './types';
3
3
  export declare class SignalingClient extends EventEmitter {
4
4
  private ws;
5
5
  private url;
6
+ private outbox;
7
+ private isClosedManually;
8
+ private retryAttempts;
9
+ private readonly maxRetries;
10
+ private reconnectTimer;
11
+ private heartbeatTimer;
6
12
  constructor(nodeAddress: string, ticket: string);
13
+ private buildUrl;
7
14
  connect(): void;
8
15
  send(message: SignalMessage): void;
16
+ private flushOutbox;
17
+ private startHeartbeat;
18
+ private stopHeartbeat;
19
+ private cleanupSocket;
9
20
  close(): void;
10
21
  }