ulai-js-sdk 0.1.1 → 0.1.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/dist/room.d.ts +2 -0
- package/dist/room.js +72 -54
- package/package.json +1 -1
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
|
-
|
|
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',
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
this.
|
|
177
|
+
else if (msg.type === 'candidate' && msg.candidate) {
|
|
178
|
+
const candidateInit = JSON.parse(msg.candidate);
|
|
179
|
+
await this.handleIncomingCandidate(candidateInit);
|
|
164
180
|
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
//
|
|
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
|
}
|