ulai-js-sdk 0.1.1 → 0.1.3

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/room.d.ts CHANGED
@@ -13,10 +13,12 @@ export declare class Room extends EventEmitter<RoomEvent> {
13
13
  private readonly isPolite;
14
14
  private makingOffer;
15
15
  private candidateQueue;
16
+ private signalQueue;
16
17
  constructor(cpBase: string);
17
18
  connect(options: JoinRoomOptions): Promise<void>;
18
19
  private setupEventsClient;
19
20
  private setupSignaling;
21
+ private handleSignal;
20
22
  private safeNegotiate;
21
23
  private handleIncomingCandidate;
22
24
  private drainCandidateQueue;
package/dist/room.js CHANGED
@@ -21,6 +21,9 @@ class Room extends eventemitter3_1.default {
21
21
  isPolite = true;
22
22
  makingOffer = false;
23
23
  candidateQueue = [];
24
+ // Signaling messages are handled one at a time, in arrival order. Handled concurrently, a bridge
25
+ // offer could be processed in the middle of our own offer and dropped.
26
+ signalQueue = Promise.resolve();
24
27
  constructor(cpBase) {
25
28
  super();
26
29
  this.cpBase = cpBase;
@@ -45,6 +48,12 @@ class Room extends eventemitter3_1.default {
45
48
  ? options.iceServers
46
49
  : defaultIceServers;
47
50
  this.peerConnection = new RTCPeerConnection({ iceServers });
51
+ // Every local change that needs an offer (the initial transceiver, publishing the mic, an
52
+ // offer rolled back by a collision) fires this. Offers sent before signaling opens wait in
53
+ // its outbox.
54
+ this.peerConnection.onnegotiationneeded = () => {
55
+ void this.safeNegotiate();
56
+ };
48
57
  // Pre-allocate audio transceiver as recvonly so the initial offer immediately
49
58
  // negotiates audio codec capabilities (m=audio) and is ready for agent downlink.
50
59
  try {
@@ -112,73 +121,83 @@ class Room extends eventemitter3_1.default {
112
121
  setupSignaling() {
113
122
  if (!this.signaling)
114
123
  return;
115
- this.signaling.on('connected', async () => {
116
- // Initiate negotiation when signaling is ready
117
- await this.safeNegotiate();
118
- });
124
+ // The initial offer comes from onnegotiationneeded and is flushed from the outbox on connect.
119
125
  this.signaling.on('reconnected', async () => {
120
126
  this.emit('reconnected');
121
127
  await this.safeNegotiate();
122
128
  });
123
- this.signaling.on('message', async (msg) => {
124
- if (!this.peerConnection)
125
- return;
126
- try {
127
- if (msg.type === 'offer' || msg.type === 'restart-offer') {
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
- }
129
+ this.signaling.on('message', (msg) => {
130
+ this.signalQueue = this.signalQueue.then(() => this.handleSignal(msg));
131
+ });
132
+ this.signaling.on('disconnected', () => {
133
+ this.emit('disconnected');
134
+ });
135
+ }
136
+ async handleSignal(msg) {
137
+ const pc = this.peerConnection;
138
+ if (!pc)
139
+ return;
140
+ try {
141
+ if (msg.type === 'offer' || msg.type === 'restart-offer') {
142
+ if (!msg.sdp)
143
+ return;
144
+ // Perfect negotiation: detect collision (SDP Glare)
145
+ const offerCollision = this.makingOffer || pc.signalingState !== 'stable';
146
+ if (offerCollision && !this.isPolite) {
147
+ console.warn('[Room] Impolite collision: ignoring incoming offer');
148
+ return;
149
149
  }
150
- else if (msg.type === 'answer') {
151
- if (msg.sdp) {
152
- await this.peerConnection.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: msg.sdp }));
153
- await this.drainCandidateQueue();
154
- }
150
+ if (offerCollision) {
151
+ console.log('[Room] Polite collision: rolling back local offer to accept server offer');
155
152
  }
156
- else if (msg.type === 'candidate' && msg.candidate) {
157
- const candidateInit = JSON.parse(msg.candidate);
158
- await this.handleIncomingCandidate(candidateInit);
153
+ // On a collision this implicitly rolls back our own offer, even one still being made. An
154
+ // explicit rollback throws if we're stable by the time it runs, which is what happens when
155
+ // the bridge sends its answer and then its own offer right away (someone already talking),
156
+ // and the bridge's offer was dropped. Whatever our offer carried is offered again via
157
+ // negotiationneeded once we're back to stable.
158
+ await pc.setRemoteDescription({ type: 'offer', sdp: msg.sdp });
159
+ await this.drainCandidateQueue();
160
+ await pc.setLocalDescription();
161
+ this.signaling?.send({
162
+ type: 'answer',
163
+ sdp: pc.localDescription?.sdp,
164
+ });
165
+ }
166
+ else if (msg.type === 'answer') {
167
+ if (!msg.sdp)
168
+ return;
169
+ // An answer to an offer we've since rolled back has nothing to apply to.
170
+ if (pc.signalingState !== 'have-local-offer') {
171
+ console.warn('[Room] Ignoring answer in signalingState', pc.signalingState);
172
+ return;
159
173
  }
174
+ await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp });
175
+ await this.drainCandidateQueue();
160
176
  }
161
- catch (err) {
162
- console.error('[Room] Error handling signaling message:', err);
163
- this.emit('error', err);
177
+ else if (msg.type === 'candidate' && msg.candidate) {
178
+ const candidateInit = JSON.parse(msg.candidate);
179
+ await this.handleIncomingCandidate(candidateInit);
164
180
  }
165
- });
166
- this.signaling.on('disconnected', () => {
167
- this.emit('disconnected');
168
- });
181
+ }
182
+ catch (err) {
183
+ console.error('[Room] Error handling signaling message:', err);
184
+ this.emit('error', err);
185
+ }
169
186
  }
170
187
  async safeNegotiate() {
171
188
  if (!this.peerConnection || this.makingOffer)
172
189
  return;
190
+ // Mid-negotiation, setLocalDescription() would make an answer, not an offer. negotiationneeded
191
+ // fires again once the current exchange completes.
192
+ if (this.peerConnection.signalingState !== 'stable')
193
+ return;
173
194
  try {
174
195
  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);
196
+ // Without an argument the offer is created when it's applied, so it can't go stale against
197
+ // an offer from the bridge that arrives meanwhile.
198
+ await this.peerConnection.setLocalDescription();
199
+ if (this.peerConnection.pendingLocalDescription?.type !== 'offer')
200
+ return; // rolled back
182
201
  this.signaling?.send({
183
202
  type: 'offer',
184
203
  sdp: this.peerConnection.localDescription?.sdp,
@@ -251,8 +270,7 @@ class Room extends eventemitter3_1.default {
251
270
  this.peerConnection.addTrack(audioTrack, this.localStream);
252
271
  }
253
272
  }
254
- // Trigger safe renegotiation
255
- await this.safeNegotiate();
273
+ // The direction change (or addTrack) fires negotiationneeded, which sends the offer.
256
274
  }
257
275
  return this.localStream;
258
276
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ulai-js-sdk",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Ulai Cloud JS/TS Client SDK for WebRTC Sessions",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -31,5 +31,9 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "eventemitter3": "^5.0.1"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/ulaidotin/ulai-js-sdk.git"
34
38
  }
35
39
  }