@ravenkash/rtc 0.1.0
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/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/index.cjs +2333 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +622 -0
- package/dist/index.d.ts +622 -0
- package/dist/index.js +2319 -0
- package/dist/index.js.map +1 -0
- package/dist/metafile-cjs.json +1 -0
- package/dist/metafile-esm.json +1 -0
- package/package.json +65 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2333 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/errors.ts
|
|
4
|
+
var RTCError = class extends Error {
|
|
5
|
+
constructor(code, message, cause) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "RTCError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.cause = cause;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
function isRTCError(value) {
|
|
13
|
+
return value instanceof RTCError;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/config.ts
|
|
17
|
+
function decodeTokenPayload(token) {
|
|
18
|
+
const parts = token.split(".");
|
|
19
|
+
if (parts.length !== 3) {
|
|
20
|
+
throw new RTCError("INVALID_TOKEN", "RTC token is malformed (expected a JWT with 3 parts)");
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
24
|
+
const json = JSON.parse(atob(base64));
|
|
25
|
+
return {
|
|
26
|
+
roomId: typeof json?.rid === "string" ? json.rid : void 0,
|
|
27
|
+
roomName: typeof json?.rnm === "string" ? json.rnm : void 0,
|
|
28
|
+
exp: typeof json?.exp === "number" ? json.exp : void 0,
|
|
29
|
+
sub: typeof json?.sub === "string" ? json.sub : void 0
|
|
30
|
+
};
|
|
31
|
+
} catch (error) {
|
|
32
|
+
throw new RTCError("INVALID_TOKEN", "RTC token payload could not be decoded", error);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function validateConfig(config) {
|
|
36
|
+
if (!config || typeof config !== "object") {
|
|
37
|
+
throw new RTCError("INVALID_TOKEN", "createRTCClient(config) requires a configuration object");
|
|
38
|
+
}
|
|
39
|
+
if (!config.token || typeof config.token !== "string") {
|
|
40
|
+
throw new RTCError("INVALID_TOKEN", "config.token is required; the RTC token from your backend");
|
|
41
|
+
}
|
|
42
|
+
if (!config.endpoint || typeof config.endpoint !== "string") {
|
|
43
|
+
throw new RTCError(
|
|
44
|
+
"INVALID_TOKEN",
|
|
45
|
+
'config.endpoint is required; the "endpoint" field from the same token-mint response as config.token'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const { exp } = decodeTokenPayload(config.token);
|
|
49
|
+
if (exp !== void 0 && exp * 1e3 <= Date.now()) {
|
|
50
|
+
throw new RTCError("TOKEN_EXPIRED", "RTC token has already expired");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
token: config.token,
|
|
54
|
+
endpoint: config.endpoint,
|
|
55
|
+
iceServers: config.iceServers,
|
|
56
|
+
logLevel: config.logLevel ?? "silent",
|
|
57
|
+
autoReconnect: config.autoReconnect ?? true,
|
|
58
|
+
telemetryUrl: config.telemetryUrl,
|
|
59
|
+
telemetry: config.telemetry ?? true
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function assertTokenMatchesRoom(token, room) {
|
|
63
|
+
const { roomId, roomName } = decodeTokenPayload(token);
|
|
64
|
+
if (!roomId && !roomName) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (room === roomId || room === roomName) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const minted = roomName ?? roomId;
|
|
71
|
+
throw new RTCError("ROOM_NOT_FOUND", `This token was minted for room "${minted}", not "${room}"`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/logger.ts
|
|
75
|
+
var LEVELS = ["silent", "error", "warn", "info", "debug"];
|
|
76
|
+
function createLogger(level = "silent") {
|
|
77
|
+
const rank = LEVELS.indexOf(level);
|
|
78
|
+
const enabled = (l) => LEVELS.indexOf(l) <= rank;
|
|
79
|
+
return {
|
|
80
|
+
error: (...args) => {
|
|
81
|
+
if (enabled("error")) console.error("[raven-rtc]", ...args);
|
|
82
|
+
},
|
|
83
|
+
warn: (...args) => {
|
|
84
|
+
if (enabled("warn")) console.warn("[raven-rtc]", ...args);
|
|
85
|
+
},
|
|
86
|
+
info: (...args) => {
|
|
87
|
+
if (enabled("info")) console.info("[raven-rtc]", ...args);
|
|
88
|
+
},
|
|
89
|
+
debug: (...args) => {
|
|
90
|
+
if (enabled("debug")) console.debug("[raven-rtc]", ...args);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/internal/devices/enumerate.ts
|
|
96
|
+
async function listDevices(kind) {
|
|
97
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices?.enumerateDevices) {
|
|
98
|
+
throw new RTCError(
|
|
99
|
+
"NOT_SUPPORTED",
|
|
100
|
+
"Device enumeration is not available in this environment (no navigator.mediaDevices)"
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
104
|
+
return devices.filter((device) => !kind || device.kind === kind).map((device) => ({
|
|
105
|
+
deviceId: device.deviceId,
|
|
106
|
+
label: device.label,
|
|
107
|
+
kind: device.kind
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/internal/telemetry/track-stats.ts
|
|
112
|
+
var MS_PER_SECOND = 1e3;
|
|
113
|
+
function normalizeTrackStats(raw, previous, kind, direction) {
|
|
114
|
+
const stats = { kind, direction };
|
|
115
|
+
if (typeof raw.jitter === "number") {
|
|
116
|
+
stats.jitterMs = raw.jitter * MS_PER_SECOND;
|
|
117
|
+
}
|
|
118
|
+
if (typeof raw.roundTripTime === "number") {
|
|
119
|
+
stats.roundTripTimeMs = raw.roundTripTime * MS_PER_SECOND;
|
|
120
|
+
}
|
|
121
|
+
if (typeof raw.mimeType === "string") {
|
|
122
|
+
stats.codec = raw.mimeType;
|
|
123
|
+
}
|
|
124
|
+
if (typeof raw.frameWidth === "number") stats.frameWidth = raw.frameWidth;
|
|
125
|
+
if (typeof raw.frameHeight === "number") stats.frameHeight = raw.frameHeight;
|
|
126
|
+
if (typeof raw.framesPerSecond === "number") stats.framesPerSecond = raw.framesPerSecond;
|
|
127
|
+
if (typeof raw.packetsLost === "number") {
|
|
128
|
+
stats.packetsLost = raw.packetsLost;
|
|
129
|
+
const attempted = direction === "send" ? raw.packetsSent : raw.packetsReceived;
|
|
130
|
+
if (typeof attempted === "number" && attempted + raw.packetsLost > 0) {
|
|
131
|
+
stats.packetLossPercent = raw.packetsLost / (attempted + raw.packetsLost) * 100;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const bytesField = direction === "send" ? "bytesSent" : "bytesReceived";
|
|
135
|
+
const currentBytes = raw[bytesField];
|
|
136
|
+
const previousBytes = previous?.[bytesField];
|
|
137
|
+
if (typeof currentBytes === "number" && typeof previousBytes === "number") {
|
|
138
|
+
const elapsedSeconds = (raw.timestamp - previous.timestamp) / MS_PER_SECOND;
|
|
139
|
+
if (elapsedSeconds > 0 && currentBytes >= previousBytes) {
|
|
140
|
+
stats.bitrateBps = (currentBytes - previousBytes) * 8 / elapsedSeconds;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return stats;
|
|
144
|
+
}
|
|
145
|
+
function pickBestLayer(layers) {
|
|
146
|
+
return layers.reduce((best, layer) => {
|
|
147
|
+
const bestWidth = best?.frameWidth ?? -1;
|
|
148
|
+
const layerWidth = layer.frameWidth ?? -1;
|
|
149
|
+
return layerWidth > bestWidth ? layer : best;
|
|
150
|
+
}, void 0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/track.ts
|
|
154
|
+
var Track = class {
|
|
155
|
+
constructor(delegate, kind) {
|
|
156
|
+
this.delegate = delegate;
|
|
157
|
+
this.kind = kind;
|
|
158
|
+
}
|
|
159
|
+
/** The underlying native track, for the rare occasion you need to go deeper. */
|
|
160
|
+
get mediaStreamTrack() {
|
|
161
|
+
return this.delegate.mediaStreamTrack;
|
|
162
|
+
}
|
|
163
|
+
get mediaStream() {
|
|
164
|
+
return this.delegate.mediaStream;
|
|
165
|
+
}
|
|
166
|
+
get isMuted() {
|
|
167
|
+
return this.delegate.isMuted;
|
|
168
|
+
}
|
|
169
|
+
/** Attaches this track to a `<video>`/`<audio>` element, creating one if omitted. */
|
|
170
|
+
attach(element) {
|
|
171
|
+
return this.delegate.attach(element);
|
|
172
|
+
}
|
|
173
|
+
/** Detaches this track from one element, or from all elements if omitted. */
|
|
174
|
+
detach(element) {
|
|
175
|
+
const result = this.delegate.detach(element);
|
|
176
|
+
return Array.isArray(result) ? result : [result];
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
var LocalTrack = class extends Track {
|
|
180
|
+
constructor(delegate, kind) {
|
|
181
|
+
super(delegate, kind);
|
|
182
|
+
this.localDelegate = delegate;
|
|
183
|
+
}
|
|
184
|
+
async mute() {
|
|
185
|
+
await this.localDelegate.mute();
|
|
186
|
+
}
|
|
187
|
+
async unmute() {
|
|
188
|
+
await this.localDelegate.unmute();
|
|
189
|
+
}
|
|
190
|
+
/** Stops the underlying device capture. Publish state belongs to Room.unpublish(). */
|
|
191
|
+
stop() {
|
|
192
|
+
this.mediaStreamTrack.stop();
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Where Raven Effects (`@ravenkash/effects`) plugs in. The chain is
|
|
196
|
+
* Camera → Raven Video Track → Effects Pipeline → Processed Video Track →
|
|
197
|
+
* Raven RTC.
|
|
198
|
+
*
|
|
199
|
+
* Runs `pipeline` against this track's live camera feed and, if the track
|
|
200
|
+
* is already published, swaps the sender's `MediaStreamTrack` in place
|
|
201
|
+
* through the adapter's `replaceTrack()`. No renegotiation, no reconnect,
|
|
202
|
+
* audio and the rest of the room untouched. Camera only for now; screen
|
|
203
|
+
* share and microphone aren't supported.
|
|
204
|
+
*
|
|
205
|
+
* Can't run the pipeline on this device (no WebGL2, Canvas2D or
|
|
206
|
+
* captureStream)? It falls back to the original track on its own. The
|
|
207
|
+
* call keeps working either way.
|
|
208
|
+
*/
|
|
209
|
+
async attachEffects(pipeline) {
|
|
210
|
+
if (this.kind !== "camera") {
|
|
211
|
+
throw new RTCError("MEDIA_ERROR", `attachEffects() is only supported on camera tracks, not "${this.kind}".`);
|
|
212
|
+
}
|
|
213
|
+
if (!this.localDelegate.replaceTrack) {
|
|
214
|
+
throw new RTCError("MEDIA_ERROR", "This track cannot be swapped in place; the current adapter does not support replaceTrack().");
|
|
215
|
+
}
|
|
216
|
+
if (this.attachedEffectsPipeline) {
|
|
217
|
+
await this.detachEffects();
|
|
218
|
+
}
|
|
219
|
+
const original = this.mediaStreamTrack;
|
|
220
|
+
const processed = await pipeline.attachToTrack(original);
|
|
221
|
+
if (processed !== original) {
|
|
222
|
+
await this.localDelegate.replaceTrack(processed, true);
|
|
223
|
+
}
|
|
224
|
+
this.attachedEffectsPipeline = pipeline;
|
|
225
|
+
this.preEffectsMediaStreamTrack = original;
|
|
226
|
+
}
|
|
227
|
+
/** Goes back to the unmodified camera track and frees the pipeline's engine resources. */
|
|
228
|
+
async detachEffects() {
|
|
229
|
+
if (!this.attachedEffectsPipeline) return;
|
|
230
|
+
this.attachedEffectsPipeline.detach();
|
|
231
|
+
if (this.preEffectsMediaStreamTrack && this.localDelegate.replaceTrack) {
|
|
232
|
+
await this.localDelegate.replaceTrack(this.preEffectsMediaStreamTrack, true);
|
|
233
|
+
}
|
|
234
|
+
this.attachedEffectsPipeline = void 0;
|
|
235
|
+
this.preEffectsMediaStreamTrack = void 0;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Live send-side stats for this track: bitrate, packet loss, jitter, RTT
|
|
239
|
+
* (audio only), resolution and fps (video only).
|
|
240
|
+
*
|
|
241
|
+
* You get `undefined`, not a zeroed-out object, when the adapter can't
|
|
242
|
+
* supply them, either because the delegate has no `getSenderStats` or
|
|
243
|
+
* because the underlying call resolved to nothing. The module doc covers
|
|
244
|
+
* why that distinction matters.
|
|
245
|
+
*
|
|
246
|
+
* Call it periodically instead of once. `Room.getConnectionStats()` does,
|
|
247
|
+
* every few seconds. Bitrate needs two samples to compute, so the first
|
|
248
|
+
* call after a track starts always omits it.
|
|
249
|
+
*/
|
|
250
|
+
async getStats() {
|
|
251
|
+
const raw = await this.localDelegate.getSenderStats?.();
|
|
252
|
+
if (!raw) {
|
|
253
|
+
return void 0;
|
|
254
|
+
}
|
|
255
|
+
const sample = Array.isArray(raw) ? pickBestLayer(raw) : raw;
|
|
256
|
+
if (!sample) {
|
|
257
|
+
return void 0;
|
|
258
|
+
}
|
|
259
|
+
const stats = normalizeTrackStats(sample, this.lastSample, this.kind, "send");
|
|
260
|
+
this.lastSample = sample;
|
|
261
|
+
return stats;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
var RemoteTrack = class extends Track {
|
|
265
|
+
constructor(delegate, kind) {
|
|
266
|
+
super(delegate, kind);
|
|
267
|
+
this.remoteDelegate = delegate;
|
|
268
|
+
}
|
|
269
|
+
/** Live receive-side stats. `LocalTrack.getStats()` covers the shape and the caveats. */
|
|
270
|
+
async getStats() {
|
|
271
|
+
const raw = await this.remoteDelegate.getReceiverStats?.();
|
|
272
|
+
if (!raw) {
|
|
273
|
+
return void 0;
|
|
274
|
+
}
|
|
275
|
+
const stats = normalizeTrackStats(raw, this.lastSample, this.kind, "receive");
|
|
276
|
+
this.lastSample = raw;
|
|
277
|
+
return stats;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// src/internal/media/errors.ts
|
|
282
|
+
function toMediaError(error, kind) {
|
|
283
|
+
const name = errorName(error);
|
|
284
|
+
switch (name) {
|
|
285
|
+
case "NotAllowedError":
|
|
286
|
+
case "SecurityError":
|
|
287
|
+
return new RTCError(
|
|
288
|
+
permissionDeniedCode(kind),
|
|
289
|
+
`Permission to use the ${label(kind)} was denied`,
|
|
290
|
+
error
|
|
291
|
+
);
|
|
292
|
+
case "NotFoundError":
|
|
293
|
+
case "OverconstrainedError":
|
|
294
|
+
return new RTCError("DEVICE_NOT_FOUND", `No ${label(kind)} device matched`, error);
|
|
295
|
+
case "NotReadableError":
|
|
296
|
+
return new RTCError(
|
|
297
|
+
"MEDIA_ERROR",
|
|
298
|
+
`The ${label(kind)} is already in use by another application`,
|
|
299
|
+
error
|
|
300
|
+
);
|
|
301
|
+
case "AbortError":
|
|
302
|
+
return new RTCError("MEDIA_ERROR", `Capturing the ${label(kind)} was aborted`, error);
|
|
303
|
+
case "TypeError":
|
|
304
|
+
return new RTCError("MEDIA_ERROR", `Invalid ${label(kind)} capture constraints`, error);
|
|
305
|
+
default:
|
|
306
|
+
return new RTCError("MEDIA_ERROR", `Could not access the ${label(kind)}`, error);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function errorName(error) {
|
|
310
|
+
if (typeof DOMException !== "undefined" && error instanceof DOMException) {
|
|
311
|
+
return error.name;
|
|
312
|
+
}
|
|
313
|
+
if (error && typeof error === "object" && typeof error.name === "string") {
|
|
314
|
+
return error.name;
|
|
315
|
+
}
|
|
316
|
+
return void 0;
|
|
317
|
+
}
|
|
318
|
+
function label(kind) {
|
|
319
|
+
return kind === "screenShare" ? "screen" : kind;
|
|
320
|
+
}
|
|
321
|
+
function permissionDeniedCode(kind) {
|
|
322
|
+
if (kind === "camera") return "CAMERA_PERMISSION_DENIED";
|
|
323
|
+
if (kind === "microphone") return "MICROPHONE_PERMISSION_DENIED";
|
|
324
|
+
return "PERMISSION_DENIED";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// src/internal/telemetry/rtc-stats.ts
|
|
328
|
+
function rawStatsFromReport(report, wanted) {
|
|
329
|
+
const codecs = /* @__PURE__ */ new Map();
|
|
330
|
+
const remoteInbound = [];
|
|
331
|
+
const rtpEntries = [];
|
|
332
|
+
report.forEach((entry) => {
|
|
333
|
+
const stats = entry;
|
|
334
|
+
switch (stats.type) {
|
|
335
|
+
case "codec":
|
|
336
|
+
if (typeof stats.id === "string" && typeof stats.mimeType === "string") {
|
|
337
|
+
codecs.set(stats.id, stats.mimeType);
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
case "remote-inbound-rtp":
|
|
341
|
+
remoteInbound.push(stats);
|
|
342
|
+
break;
|
|
343
|
+
case wanted:
|
|
344
|
+
rtpEntries.push(stats);
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
return rtpEntries.map((rtp) => toRawStats(rtp, wanted, codecs, remoteInbound));
|
|
349
|
+
}
|
|
350
|
+
function toRawStats(rtp, direction, codecs, remoteInbound) {
|
|
351
|
+
const kind = rtp.kind ?? rtp.mediaType;
|
|
352
|
+
const sample = {
|
|
353
|
+
type: kind === "audio" ? "audio" : kind === "video" ? "video" : void 0,
|
|
354
|
+
// `RTCStats.timestamp` is a DOMHighResTimeStamp relative to the time
|
|
355
|
+
// origin, and `normalizeTrackStats` only ever uses it as a delta
|
|
356
|
+
// against a previous sample, so the epoch is irrelevant. Falling back
|
|
357
|
+
// to Date.now() keeps the delta usable on the rare browser that omits
|
|
358
|
+
// it.
|
|
359
|
+
timestamp: typeof rtp.timestamp === "number" ? rtp.timestamp : Date.now()
|
|
360
|
+
};
|
|
361
|
+
if (typeof rtp.jitter === "number") sample.jitter = rtp.jitter;
|
|
362
|
+
if (typeof rtp.frameWidth === "number") sample.frameWidth = rtp.frameWidth;
|
|
363
|
+
if (typeof rtp.frameHeight === "number") sample.frameHeight = rtp.frameHeight;
|
|
364
|
+
if (typeof rtp.framesPerSecond === "number") sample.framesPerSecond = rtp.framesPerSecond;
|
|
365
|
+
const mimeType = rtp.mimeType ?? (rtp.codecId ? codecs.get(rtp.codecId) : void 0);
|
|
366
|
+
if (mimeType) sample.mimeType = mimeType;
|
|
367
|
+
if (direction === "outbound-rtp") {
|
|
368
|
+
if (typeof rtp.bytesSent === "number") sample.bytesSent = rtp.bytesSent;
|
|
369
|
+
if (typeof rtp.packetsSent === "number") sample.packetsSent = rtp.packetsSent;
|
|
370
|
+
const feedback = remoteInbound.find((remote) => rtp.id && remote.localId === rtp.id) ?? remoteInbound.find((remote) => rtp.ssrc !== void 0 && remote.ssrc === rtp.ssrc);
|
|
371
|
+
if (feedback) {
|
|
372
|
+
if (typeof feedback.roundTripTime === "number") sample.roundTripTime = feedback.roundTripTime;
|
|
373
|
+
if (typeof feedback.packetsLost === "number") sample.packetsLost = feedback.packetsLost;
|
|
374
|
+
if (sample.jitter === void 0 && typeof feedback.jitter === "number") {
|
|
375
|
+
sample.jitter = feedback.jitter;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return sample;
|
|
379
|
+
}
|
|
380
|
+
if (typeof rtp.bytesReceived === "number") sample.bytesReceived = rtp.bytesReceived;
|
|
381
|
+
if (typeof rtp.packetsReceived === "number") sample.packetsReceived = rtp.packetsReceived;
|
|
382
|
+
if (typeof rtp.packetsLost === "number") sample.packetsLost = rtp.packetsLost;
|
|
383
|
+
return sample;
|
|
384
|
+
}
|
|
385
|
+
async function connectionRoundTripTimeMs(connection) {
|
|
386
|
+
const report = await connection.getStats();
|
|
387
|
+
let rttSeconds;
|
|
388
|
+
report.forEach((entry) => {
|
|
389
|
+
const stats = entry;
|
|
390
|
+
if (stats.type !== "candidate-pair") {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (stats.state !== "succeeded" || stats.nominated !== true) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (typeof stats.currentRoundTripTime === "number") {
|
|
397
|
+
rttSeconds = stats.currentRoundTripTime;
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
return rttSeconds === void 0 ? void 0 : rttSeconds * 1e3;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/internal/media/native-track.ts
|
|
404
|
+
var NativeTrackDelegate = class {
|
|
405
|
+
constructor(mediaStreamTrack) {
|
|
406
|
+
this.attachedElements = /* @__PURE__ */ new Set();
|
|
407
|
+
this.mediaStreamTrack = mediaStreamTrack;
|
|
408
|
+
}
|
|
409
|
+
get mediaStream() {
|
|
410
|
+
if (!this.stream && typeof MediaStream !== "undefined") {
|
|
411
|
+
this.stream = new MediaStream([this.mediaStreamTrack]);
|
|
412
|
+
}
|
|
413
|
+
return this.stream;
|
|
414
|
+
}
|
|
415
|
+
attach(element) {
|
|
416
|
+
const target = element ?? this.createElement();
|
|
417
|
+
const stream = this.mediaStream;
|
|
418
|
+
if (stream) {
|
|
419
|
+
target.srcObject = stream;
|
|
420
|
+
}
|
|
421
|
+
target.autoplay = true;
|
|
422
|
+
if (target instanceof HTMLVideoElement) {
|
|
423
|
+
target.playsInline = true;
|
|
424
|
+
}
|
|
425
|
+
this.attachedElements.add(target);
|
|
426
|
+
return target;
|
|
427
|
+
}
|
|
428
|
+
detach(element) {
|
|
429
|
+
if (element) {
|
|
430
|
+
element.srcObject = null;
|
|
431
|
+
this.attachedElements.delete(element);
|
|
432
|
+
return element;
|
|
433
|
+
}
|
|
434
|
+
const detached = Array.from(this.attachedElements);
|
|
435
|
+
for (const attached of detached) {
|
|
436
|
+
attached.srcObject = null;
|
|
437
|
+
}
|
|
438
|
+
this.attachedElements.clear();
|
|
439
|
+
return detached;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Swaps out the track this delegate wraps.
|
|
443
|
+
*
|
|
444
|
+
* Called once `replaceTrack` on the sender has succeeded, so attached
|
|
445
|
+
* elements and `mediaStreamTrack` describe what's actually going out
|
|
446
|
+
* rather than the track we just replaced.
|
|
447
|
+
*/
|
|
448
|
+
swapMediaStreamTrack(next) {
|
|
449
|
+
this.mediaStreamTrack = next;
|
|
450
|
+
this.stream = typeof MediaStream !== "undefined" ? new MediaStream([next]) : void 0;
|
|
451
|
+
const stream = this.stream;
|
|
452
|
+
if (!stream) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
for (const element of this.attachedElements) {
|
|
456
|
+
element.srcObject = stream;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
createElement() {
|
|
460
|
+
if (typeof document === "undefined") {
|
|
461
|
+
throw new Error("attach() without an element requires a DOM");
|
|
462
|
+
}
|
|
463
|
+
return document.createElement(this.mediaStreamTrack.kind === "video" ? "video" : "audio");
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
var NativeLocalTrackDelegate = class extends NativeTrackDelegate {
|
|
467
|
+
constructor() {
|
|
468
|
+
super(...arguments);
|
|
469
|
+
this.muted = false;
|
|
470
|
+
}
|
|
471
|
+
get isMuted() {
|
|
472
|
+
return this.muted;
|
|
473
|
+
}
|
|
474
|
+
/** @internal Called by the adapter once the track has a sender. */
|
|
475
|
+
setSender(sender) {
|
|
476
|
+
this.sender = sender;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Mutes by disabling the underlying track, not by removing it.
|
|
480
|
+
*
|
|
481
|
+
* `track.enabled = false` has the browser send silence or black frames.
|
|
482
|
+
* The RTP stream keeps going, the transceiver stays put, and unmuting is
|
|
483
|
+
* instant. Stopping the track instead releases the device, which does
|
|
484
|
+
* turn the camera light off (users read that as "off"), but undoing it
|
|
485
|
+
* then costs a fresh `getUserMedia` and a renegotiation.
|
|
486
|
+
*
|
|
487
|
+
* We tell the SFU separately over signaling, so it can stop forwarding
|
|
488
|
+
* the silence to every subscriber instead of paying to relay nothing.
|
|
489
|
+
*/
|
|
490
|
+
async mute() {
|
|
491
|
+
this.mediaStreamTrack.enabled = false;
|
|
492
|
+
this.muted = true;
|
|
493
|
+
return void 0;
|
|
494
|
+
}
|
|
495
|
+
async unmute() {
|
|
496
|
+
this.mediaStreamTrack.enabled = true;
|
|
497
|
+
this.muted = false;
|
|
498
|
+
return void 0;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Replaces the outgoing track without renegotiating.
|
|
502
|
+
*
|
|
503
|
+
* This is the trick that makes Raven Effects work mid-call.
|
|
504
|
+
* `RTCRtpSender.replaceTrack` swaps the source of an established stream,
|
|
505
|
+
* so a processed video track takes over from the raw camera with no
|
|
506
|
+
* offer/answer and nobody else in the room noticing.
|
|
507
|
+
*/
|
|
508
|
+
async replaceTrack(track, _userProvidedTrack) {
|
|
509
|
+
if (this.sender) {
|
|
510
|
+
await this.sender.replaceTrack(track);
|
|
511
|
+
}
|
|
512
|
+
this.swapMediaStreamTrack(track);
|
|
513
|
+
if (this.muted) {
|
|
514
|
+
track.enabled = false;
|
|
515
|
+
}
|
|
516
|
+
return void 0;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Send-side stats for this track.
|
|
520
|
+
*
|
|
521
|
+
* Video gets an array, because a simulcast sender reports one
|
|
522
|
+
* `outbound-rtp` per encoding layer, and `LocalTrack.getStats()` picks
|
|
523
|
+
* the highest-resolution one. You get `undefined` when there's no sender
|
|
524
|
+
* yet. An unpublished track has no send statistics, and reporting zeroes
|
|
525
|
+
* would claim it was sending nothing when really it isn't sending.
|
|
526
|
+
*/
|
|
527
|
+
async getSenderStats() {
|
|
528
|
+
if (!this.sender) {
|
|
529
|
+
return void 0;
|
|
530
|
+
}
|
|
531
|
+
const report = await this.sender.getStats();
|
|
532
|
+
const samples = rawStatsFromReport(report, "outbound-rtp");
|
|
533
|
+
if (samples.length === 0) {
|
|
534
|
+
return void 0;
|
|
535
|
+
}
|
|
536
|
+
return samples.length === 1 ? samples[0] : samples;
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
var NativeRemoteTrackDelegate = class extends NativeTrackDelegate {
|
|
540
|
+
constructor(mediaStreamTrack, receiver) {
|
|
541
|
+
super(mediaStreamTrack);
|
|
542
|
+
this.publisherMuted = false;
|
|
543
|
+
this.receiver = receiver;
|
|
544
|
+
}
|
|
545
|
+
get isMuted() {
|
|
546
|
+
return this.publisherMuted;
|
|
547
|
+
}
|
|
548
|
+
/** @internal Set from the SFU's `track.muted` / `track.unmuted` events. */
|
|
549
|
+
setPublisherMuted(muted) {
|
|
550
|
+
this.publisherMuted = muted;
|
|
551
|
+
}
|
|
552
|
+
async getReceiverStats() {
|
|
553
|
+
const report = await this.receiver.getStats();
|
|
554
|
+
const [sample] = rawStatsFromReport(report, "inbound-rtp");
|
|
555
|
+
return sample;
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
// src/internal/media/capture.ts
|
|
560
|
+
var DEFAULT_AUDIO_CONSTRAINTS = {
|
|
561
|
+
echoCancellation: true,
|
|
562
|
+
noiseSuppression: true,
|
|
563
|
+
autoGainControl: true
|
|
564
|
+
};
|
|
565
|
+
var DEFAULT_VIDEO_CONSTRAINTS = {
|
|
566
|
+
width: { ideal: 1280 },
|
|
567
|
+
height: { ideal: 720 },
|
|
568
|
+
frameRate: { ideal: 30 }
|
|
569
|
+
};
|
|
570
|
+
var VIDEO_PROFILES = {
|
|
571
|
+
"360p": { width: { ideal: 640 }, height: { ideal: 360 }, frameRate: { ideal: 30 } },
|
|
572
|
+
"480p": { width: { ideal: 854 }, height: { ideal: 480 }, frameRate: { ideal: 30 } },
|
|
573
|
+
"720p": { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
|
|
574
|
+
"1080p": { width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 30 } }
|
|
575
|
+
};
|
|
576
|
+
async function createCameraTrack(options = {}) {
|
|
577
|
+
const constraints = {
|
|
578
|
+
...DEFAULT_VIDEO_CONSTRAINTS,
|
|
579
|
+
...options.profile ? VIDEO_PROFILES[options.profile] : {},
|
|
580
|
+
...options.facingMode ? { facingMode: options.facingMode } : {},
|
|
581
|
+
...options.deviceId ? { deviceId: { exact: options.deviceId } } : {},
|
|
582
|
+
...options.constraints
|
|
583
|
+
};
|
|
584
|
+
const stream = await getUserMedia({ video: constraints, audio: false }, "camera");
|
|
585
|
+
return trackFromStream(stream, "camera");
|
|
586
|
+
}
|
|
587
|
+
async function createMicrophoneTrack(options = {}) {
|
|
588
|
+
const constraints = {
|
|
589
|
+
...DEFAULT_AUDIO_CONSTRAINTS,
|
|
590
|
+
...options.deviceId ? { deviceId: { exact: options.deviceId } } : {},
|
|
591
|
+
...options.constraints
|
|
592
|
+
};
|
|
593
|
+
const stream = await getUserMedia({ audio: constraints, video: false }, "microphone");
|
|
594
|
+
return trackFromStream(stream, "microphone");
|
|
595
|
+
}
|
|
596
|
+
async function createScreenShareTrack() {
|
|
597
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
|
|
598
|
+
throw new RTCError(
|
|
599
|
+
"NOT_SUPPORTED",
|
|
600
|
+
"Screen sharing is not available on this platform (no getDisplayMedia)"
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
let stream;
|
|
604
|
+
try {
|
|
605
|
+
stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
|
|
606
|
+
} catch (error) {
|
|
607
|
+
throw toMediaError(error, "screenShare");
|
|
608
|
+
}
|
|
609
|
+
const [videoTrack] = stream.getVideoTracks();
|
|
610
|
+
if (!videoTrack) {
|
|
611
|
+
throw new RTCError("MEDIA_ERROR", "Screen capture returned no video track");
|
|
612
|
+
}
|
|
613
|
+
return new LocalTrack(new NativeLocalTrackDelegate(videoTrack), "screenShare");
|
|
614
|
+
}
|
|
615
|
+
async function getUserMedia(constraints, kind) {
|
|
616
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
|
|
617
|
+
throw new RTCError(
|
|
618
|
+
"NOT_SUPPORTED",
|
|
619
|
+
"Media capture is not available in this environment (no navigator.mediaDevices)"
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
try {
|
|
623
|
+
return await navigator.mediaDevices.getUserMedia(constraints);
|
|
624
|
+
} catch (error) {
|
|
625
|
+
throw toMediaError(error, kind);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
function trackFromStream(stream, kind) {
|
|
629
|
+
const [track] = kind === "microphone" ? stream.getAudioTracks() : stream.getVideoTracks();
|
|
630
|
+
if (!track) {
|
|
631
|
+
throw new RTCError("MEDIA_ERROR", `Capture returned no ${kind} track`);
|
|
632
|
+
}
|
|
633
|
+
return new LocalTrack(new NativeLocalTrackDelegate(track), kind);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// src/events.ts
|
|
637
|
+
var TypedEventEmitter = class {
|
|
638
|
+
constructor() {
|
|
639
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
640
|
+
}
|
|
641
|
+
on(event, handler) {
|
|
642
|
+
let set = this.listeners.get(event);
|
|
643
|
+
if (!set) {
|
|
644
|
+
set = /* @__PURE__ */ new Set();
|
|
645
|
+
this.listeners.set(event, set);
|
|
646
|
+
}
|
|
647
|
+
set.add(handler);
|
|
648
|
+
return this;
|
|
649
|
+
}
|
|
650
|
+
off(event, handler) {
|
|
651
|
+
this.listeners.get(event)?.delete(handler);
|
|
652
|
+
return this;
|
|
653
|
+
}
|
|
654
|
+
once(event, handler) {
|
|
655
|
+
const wrapped = ((...args) => {
|
|
656
|
+
this.off(event, wrapped);
|
|
657
|
+
handler(...args);
|
|
658
|
+
});
|
|
659
|
+
return this.on(event, wrapped);
|
|
660
|
+
}
|
|
661
|
+
removeAllListeners(event) {
|
|
662
|
+
if (event) {
|
|
663
|
+
this.listeners.delete(event);
|
|
664
|
+
} else {
|
|
665
|
+
this.listeners.clear();
|
|
666
|
+
}
|
|
667
|
+
return this;
|
|
668
|
+
}
|
|
669
|
+
emit(event, ...args) {
|
|
670
|
+
const set = this.listeners.get(event);
|
|
671
|
+
if (!set) return;
|
|
672
|
+
for (const handler of Array.from(set)) {
|
|
673
|
+
handler(...args);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
// src/participant.ts
|
|
679
|
+
var Participant = class {
|
|
680
|
+
constructor(identity, metadata) {
|
|
681
|
+
this._identity = identity;
|
|
682
|
+
this.metadata = metadata;
|
|
683
|
+
}
|
|
684
|
+
/** The RTC token's participant identity. Stable for the whole session. */
|
|
685
|
+
get identity() {
|
|
686
|
+
return this._identity;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* @internal Called once by the SFU adapter just after connect() resolves.
|
|
690
|
+
* The constructor runs before the server has confirmed identity, so this
|
|
691
|
+
* patches it in afterwards.
|
|
692
|
+
*/
|
|
693
|
+
_setIdentity(identity) {
|
|
694
|
+
this._identity = identity;
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
var LocalParticipant = class extends Participant {
|
|
698
|
+
constructor() {
|
|
699
|
+
super(...arguments);
|
|
700
|
+
/** Tracks this participant has published, in publish order. Mutated by Room. */
|
|
701
|
+
this.tracks = [];
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
var RemoteParticipant = class extends Participant {
|
|
705
|
+
constructor() {
|
|
706
|
+
super(...arguments);
|
|
707
|
+
/** Tracks subscribed from this participant, in subscribe order. Mutated by Room. */
|
|
708
|
+
this.tracks = [];
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// src/internal/signaling/protocol.ts
|
|
713
|
+
var ClientMessageType = {
|
|
714
|
+
ROOM_JOIN: "room.join",
|
|
715
|
+
ROOM_LEAVE: "room.leave",
|
|
716
|
+
SDP_ANSWER: "sdp.answer",
|
|
717
|
+
SDP_OFFER: "sdp.offer",
|
|
718
|
+
ICE_CANDIDATE: "ice.candidate",
|
|
719
|
+
TRACK_MUTE: "track.mute",
|
|
720
|
+
/**
|
|
721
|
+
* Declares what a track being published is *of*.
|
|
722
|
+
*
|
|
723
|
+
* Needed because WebRTC has no such concept, and a page can't choose the
|
|
724
|
+
* `MediaStream` or `MediaStreamTrack` id the SDP will carry; both are
|
|
725
|
+
* read-only. Without this the SFU can only guess the source from codec
|
|
726
|
+
* kind, and that can't tell a screen share from a camera.
|
|
727
|
+
*/
|
|
728
|
+
TRACK_PUBLISH: "track.publish",
|
|
729
|
+
SUBSCRIPTION_UPDATE: "subscription.update",
|
|
730
|
+
PING: "ping"
|
|
731
|
+
};
|
|
732
|
+
var ServerMessageType = {
|
|
733
|
+
ROOM_JOINED: "room.joined",
|
|
734
|
+
PARTICIPANT_JOINED: "participant.joined",
|
|
735
|
+
PARTICIPANT_LEFT: "participant.left",
|
|
736
|
+
TRACK_PUBLISHED: "track.published",
|
|
737
|
+
TRACK_UNPUBLISHED: "track.unpublished",
|
|
738
|
+
TRACK_MUTED: "track.muted",
|
|
739
|
+
TRACK_UNMUTED: "track.unmuted",
|
|
740
|
+
SDP_OFFER: "sdp.offer",
|
|
741
|
+
SDP_ANSWER: "sdp.answer",
|
|
742
|
+
ICE_CANDIDATE: "ice.candidate",
|
|
743
|
+
CONNECTION_STATE: "connection.state",
|
|
744
|
+
ERROR: "error"};
|
|
745
|
+
var FATAL_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
746
|
+
"INVALID_TOKEN",
|
|
747
|
+
"TOKEN_EXPIRED",
|
|
748
|
+
"UNAUTHORIZED",
|
|
749
|
+
"ROOM_NOT_FOUND",
|
|
750
|
+
"PERMISSION_DENIED"
|
|
751
|
+
]);
|
|
752
|
+
|
|
753
|
+
// src/internal/signaling/signaling-client.ts
|
|
754
|
+
var OPEN_TIMEOUT_MS = 1e4;
|
|
755
|
+
var JOIN_TIMEOUT_MS = 15e3;
|
|
756
|
+
var RECONNECT_BASE_MS = 300;
|
|
757
|
+
var RECONNECT_MAX_MS = 1e4;
|
|
758
|
+
var RECONNECT_MAX_ATTEMPTS = 12;
|
|
759
|
+
var SignalingClient = class extends TypedEventEmitter {
|
|
760
|
+
constructor(options) {
|
|
761
|
+
super();
|
|
762
|
+
this.reconnectAttempts = 0;
|
|
763
|
+
this.closedByCaller = false;
|
|
764
|
+
this.joined = false;
|
|
765
|
+
this.options = options;
|
|
766
|
+
this.token = options.token;
|
|
767
|
+
this.logger = options.logger;
|
|
768
|
+
}
|
|
769
|
+
get isJoined() {
|
|
770
|
+
return this.joined;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Opens the socket and joins the room.
|
|
774
|
+
*
|
|
775
|
+
* Resolves when `room.joined` arrives, not when the socket opens. Resolve
|
|
776
|
+
* on socket-open and the caller still has to sit waiting on an event to
|
|
777
|
+
* find out whether they're actually in the room, which is the same wait
|
|
778
|
+
* with an extra step bolted on.
|
|
779
|
+
*/
|
|
780
|
+
async connect() {
|
|
781
|
+
this.closedByCaller = false;
|
|
782
|
+
return this.openAndJoin();
|
|
783
|
+
}
|
|
784
|
+
async openAndJoin() {
|
|
785
|
+
const socket = await this.openSocket();
|
|
786
|
+
this.socket = socket;
|
|
787
|
+
return this.join(socket);
|
|
788
|
+
}
|
|
789
|
+
openSocket() {
|
|
790
|
+
const url = this.buildUrl();
|
|
791
|
+
return new Promise((resolve, reject) => {
|
|
792
|
+
let socket;
|
|
793
|
+
try {
|
|
794
|
+
socket = new WebSocket(url);
|
|
795
|
+
} catch (error) {
|
|
796
|
+
reject(new RTCError("SIGNALING_ERROR", "Could not open a signaling connection", error));
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const timeout = setTimeout(() => {
|
|
800
|
+
socket.close();
|
|
801
|
+
reject(new RTCError("TIMEOUT", `Signaling connection to ${this.options.endpoint} timed out`));
|
|
802
|
+
}, OPEN_TIMEOUT_MS);
|
|
803
|
+
socket.onopen = () => {
|
|
804
|
+
clearTimeout(timeout);
|
|
805
|
+
this.logger.debug("signaling socket open");
|
|
806
|
+
resolve(socket);
|
|
807
|
+
};
|
|
808
|
+
socket.onerror = () => {
|
|
809
|
+
clearTimeout(timeout);
|
|
810
|
+
reject(
|
|
811
|
+
new RTCError(
|
|
812
|
+
"NETWORK_ERROR",
|
|
813
|
+
`Could not reach the signaling endpoint at ${this.options.endpoint}`
|
|
814
|
+
)
|
|
815
|
+
);
|
|
816
|
+
};
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
join(socket) {
|
|
820
|
+
return new Promise((resolve, reject) => {
|
|
821
|
+
const timeout = setTimeout(() => {
|
|
822
|
+
reject(new RTCError("TIMEOUT", "The server did not confirm the room join"));
|
|
823
|
+
}, JOIN_TIMEOUT_MS);
|
|
824
|
+
let settled = false;
|
|
825
|
+
const settle = (fn) => {
|
|
826
|
+
if (settled) return;
|
|
827
|
+
settled = true;
|
|
828
|
+
clearTimeout(timeout);
|
|
829
|
+
fn();
|
|
830
|
+
};
|
|
831
|
+
socket.onmessage = (event) => {
|
|
832
|
+
const message = this.parse(event.data);
|
|
833
|
+
if (!message) {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (!settled) {
|
|
837
|
+
if (message.type === ServerMessageType.ROOM_JOINED) {
|
|
838
|
+
const payload = {
|
|
839
|
+
roomId: message.roomId,
|
|
840
|
+
participants: message.participants,
|
|
841
|
+
rtcServer: message.rtcServer,
|
|
842
|
+
region: message.region
|
|
843
|
+
};
|
|
844
|
+
this.joined = true;
|
|
845
|
+
this.reconnectAttempts = 0;
|
|
846
|
+
this.logger.info(
|
|
847
|
+
"joined room",
|
|
848
|
+
message.roomId,
|
|
849
|
+
message.rtcServer ? `via ${message.rtcServer}` : ""
|
|
850
|
+
);
|
|
851
|
+
settle(() => resolve(payload));
|
|
852
|
+
this.emit("joined", payload);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (message.type === ServerMessageType.ERROR) {
|
|
856
|
+
settle(() => reject(this.toError(message.code, message.message)));
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
this.handleMessage(message);
|
|
861
|
+
};
|
|
862
|
+
socket.onclose = (event) => {
|
|
863
|
+
this.joined = false;
|
|
864
|
+
settle(
|
|
865
|
+
() => reject(
|
|
866
|
+
new RTCError(
|
|
867
|
+
"SIGNALING_ERROR",
|
|
868
|
+
`The signaling connection closed before the room was joined (code ${event.code})`
|
|
869
|
+
)
|
|
870
|
+
)
|
|
871
|
+
);
|
|
872
|
+
this.handleClose(event);
|
|
873
|
+
};
|
|
874
|
+
socket.onerror = () => {
|
|
875
|
+
};
|
|
876
|
+
this.send({
|
|
877
|
+
type: ClientMessageType.ROOM_JOIN,
|
|
878
|
+
roomId: this.options.roomId,
|
|
879
|
+
region: this.options.region
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
handleMessage(message) {
|
|
884
|
+
if (message.type === ServerMessageType.ERROR) {
|
|
885
|
+
const error = this.toError(message.code, message.message);
|
|
886
|
+
this.logger.warn("signaling error", message.code, message.message);
|
|
887
|
+
if (FATAL_ERROR_CODES.has(message.code)) {
|
|
888
|
+
this.closedByCaller = true;
|
|
889
|
+
this.socket?.close();
|
|
890
|
+
this.emit("failed", error);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
this.emit("message", message);
|
|
895
|
+
}
|
|
896
|
+
handleClose(event) {
|
|
897
|
+
if (this.closedByCaller) {
|
|
898
|
+
this.emit("closed");
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (!this.options.autoReconnect) {
|
|
902
|
+
this.emit(
|
|
903
|
+
"failed",
|
|
904
|
+
new RTCError("NETWORK_ERROR", `The signaling connection closed (code ${event.code})`)
|
|
905
|
+
);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const authFailed = event.code === 4001;
|
|
909
|
+
void this.scheduleReconnect(authFailed);
|
|
910
|
+
}
|
|
911
|
+
async scheduleReconnect(refreshFirst) {
|
|
912
|
+
if (this.reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
|
|
913
|
+
this.emit(
|
|
914
|
+
"failed",
|
|
915
|
+
new RTCError(
|
|
916
|
+
"CONNECTION_FAILED",
|
|
917
|
+
`Could not re-establish signaling after ${RECONNECT_MAX_ATTEMPTS} attempts`
|
|
918
|
+
)
|
|
919
|
+
);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
this.reconnectAttempts++;
|
|
923
|
+
this.emit("reconnecting");
|
|
924
|
+
if (refreshFirst || this.reconnectAttempts === 1) {
|
|
925
|
+
await this.tryRefreshToken();
|
|
926
|
+
}
|
|
927
|
+
const backoff = Math.min(RECONNECT_BASE_MS * 2 ** (this.reconnectAttempts - 1), RECONNECT_MAX_MS);
|
|
928
|
+
const delay = Math.random() * backoff;
|
|
929
|
+
this.logger.info(
|
|
930
|
+
`signaling reconnect attempt ${this.reconnectAttempts} in ${Math.round(delay)}ms`
|
|
931
|
+
);
|
|
932
|
+
this.reconnectTimer = setTimeout(() => {
|
|
933
|
+
void this.openAndJoin().catch((error) => {
|
|
934
|
+
this.logger.warn("signaling reconnect failed", error.message);
|
|
935
|
+
void this.scheduleReconnect(false);
|
|
936
|
+
});
|
|
937
|
+
}, delay);
|
|
938
|
+
}
|
|
939
|
+
async tryRefreshToken() {
|
|
940
|
+
if (!this.options.refreshToken) {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
try {
|
|
944
|
+
this.token = await this.options.refreshToken();
|
|
945
|
+
this.logger.debug("rtc token refreshed");
|
|
946
|
+
} catch (error) {
|
|
947
|
+
this.logger.warn("rtc token refresh failed", error.message);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
/** Replaces the token used by future reconnects (spec §21). */
|
|
951
|
+
setToken(token) {
|
|
952
|
+
this.token = token;
|
|
953
|
+
}
|
|
954
|
+
send(message) {
|
|
955
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
956
|
+
this.logger.debug("dropping signaling message, socket not open", message.type);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
this.socket.send(JSON.stringify(message));
|
|
960
|
+
}
|
|
961
|
+
/** Leaves the room and closes the socket. No reconnect afterwards. */
|
|
962
|
+
close() {
|
|
963
|
+
this.closedByCaller = true;
|
|
964
|
+
if (this.reconnectTimer) {
|
|
965
|
+
clearTimeout(this.reconnectTimer);
|
|
966
|
+
this.reconnectTimer = void 0;
|
|
967
|
+
}
|
|
968
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
969
|
+
this.send({ type: ClientMessageType.ROOM_LEAVE });
|
|
970
|
+
this.socket.close(1e3, "client left");
|
|
971
|
+
}
|
|
972
|
+
this.joined = false;
|
|
973
|
+
}
|
|
974
|
+
buildUrl() {
|
|
975
|
+
const base = this.options.endpoint.replace(/\/$/, "");
|
|
976
|
+
return `${base}?token=${encodeURIComponent(this.token)}`;
|
|
977
|
+
}
|
|
978
|
+
parse(data) {
|
|
979
|
+
if (typeof data !== "string") {
|
|
980
|
+
this.logger.warn("ignoring non-text signaling frame");
|
|
981
|
+
return void 0;
|
|
982
|
+
}
|
|
983
|
+
try {
|
|
984
|
+
return JSON.parse(data);
|
|
985
|
+
} catch {
|
|
986
|
+
this.logger.warn("ignoring unparseable signaling frame");
|
|
987
|
+
return void 0;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
toError(code, message) {
|
|
991
|
+
switch (code) {
|
|
992
|
+
case "INVALID_TOKEN":
|
|
993
|
+
return new RTCError("INVALID_TOKEN", message);
|
|
994
|
+
case "TOKEN_EXPIRED":
|
|
995
|
+
return new RTCError("TOKEN_EXPIRED", message);
|
|
996
|
+
case "ROOM_NOT_FOUND":
|
|
997
|
+
return new RTCError("ROOM_NOT_FOUND", message);
|
|
998
|
+
case "UNAUTHORIZED":
|
|
999
|
+
case "PERMISSION_DENIED":
|
|
1000
|
+
return new RTCError("PERMISSION_DENIED", message);
|
|
1001
|
+
case "ROOM_FULL":
|
|
1002
|
+
case "NO_RTC_CAPACITY":
|
|
1003
|
+
case "RTC_SERVER_UNREACHABLE":
|
|
1004
|
+
return new RTCError("CONNECTION_FAILED", message);
|
|
1005
|
+
case "RATE_LIMITED":
|
|
1006
|
+
return new RTCError("NETWORK_ERROR", message);
|
|
1007
|
+
default:
|
|
1008
|
+
return new RTCError("SIGNALING_ERROR", message);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
// src/internal/sfu/raven-adapter.ts
|
|
1014
|
+
var DATA_CHANNEL_LABEL = "raven-data";
|
|
1015
|
+
var MAX_DATA_PAYLOAD_BYTES = 64 * 1024;
|
|
1016
|
+
var ICE_GATHER_HINT_MS = 0;
|
|
1017
|
+
function trackKindFromSource(source, kind) {
|
|
1018
|
+
switch (source) {
|
|
1019
|
+
case "camera":
|
|
1020
|
+
return "camera";
|
|
1021
|
+
case "microphone":
|
|
1022
|
+
return "microphone";
|
|
1023
|
+
case "screenShare":
|
|
1024
|
+
return "screenShare";
|
|
1025
|
+
default:
|
|
1026
|
+
return kind === "audio" ? "microphone" : "camera";
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
var RavenAdapter = class extends TypedEventEmitter {
|
|
1030
|
+
constructor(logger, autoReconnect) {
|
|
1031
|
+
super();
|
|
1032
|
+
this.remoteParticipants = /* @__PURE__ */ new Map();
|
|
1033
|
+
this.iceServers = [];
|
|
1034
|
+
this._connectionState = "disconnected";
|
|
1035
|
+
this.intentionalDisconnect = false;
|
|
1036
|
+
/** Published tracks by kind, so `enableCamera(false)` knows what to kill. */
|
|
1037
|
+
this.published = /* @__PURE__ */ new Map();
|
|
1038
|
+
/** Subscribed tracks by `publisherId/trackId`. */
|
|
1039
|
+
this.subscribed = /* @__PURE__ */ new Map();
|
|
1040
|
+
/**
|
|
1041
|
+
* What the server says each participant publishes, ahead of the media
|
|
1042
|
+
* actually turning up. `ontrack` and `track.published` race and either
|
|
1043
|
+
* can win, so both paths check in here and whichever lands second
|
|
1044
|
+
* finishes the subscription.
|
|
1045
|
+
*/
|
|
1046
|
+
this.announcedTracks = /* @__PURE__ */ new Map();
|
|
1047
|
+
/** Tracks whose media arrived before the announcement. */
|
|
1048
|
+
this.pendingMedia = /* @__PURE__ */ new Map();
|
|
1049
|
+
/** Publishes that glare pushed back, retried once we've answered the server. */
|
|
1050
|
+
this.deferredPublishes = [];
|
|
1051
|
+
this.logger = logger;
|
|
1052
|
+
this.autoReconnect = autoReconnect;
|
|
1053
|
+
this.localParticipant = new LocalParticipant("");
|
|
1054
|
+
}
|
|
1055
|
+
get connectionState() {
|
|
1056
|
+
return this._connectionState;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* The SFU's read on this connection's health.
|
|
1060
|
+
*
|
|
1061
|
+
* Right now that's `'unknown'` unless the SFU has reported a failure.
|
|
1062
|
+
*
|
|
1063
|
+
* TODO: return a real verdict once the SFU computes one.
|
|
1064
|
+
*
|
|
1065
|
+
* Known gap, not an oversight. The old adapter passed through LiveKit's
|
|
1066
|
+
* server-computed verdict, which had a vantage point no client can get
|
|
1067
|
+
* near: the SFU sees loss and jitter on every leg of the room, not just
|
|
1068
|
+
* this one. Raven's SFU doesn't work out an equivalent yet. Dressing a
|
|
1069
|
+
* client-side guess up as a server verdict is precisely the fabricated
|
|
1070
|
+
* metric spec §19 rules out, so this says "unknown" until the SFU can
|
|
1071
|
+
* answer honestly. `room.getConnectionStats()` gives you real per-track
|
|
1072
|
+
* numbers in the meantime.
|
|
1073
|
+
*/
|
|
1074
|
+
getConnectionQuality() {
|
|
1075
|
+
if (this.sfuPeerState === "failed" || this._connectionState === "failed") {
|
|
1076
|
+
return "lost";
|
|
1077
|
+
}
|
|
1078
|
+
return "unknown";
|
|
1079
|
+
}
|
|
1080
|
+
/** Diagnostics the LiveKit adapter never could give us (see `Room.getDiagnostics()`). */
|
|
1081
|
+
getIceConnectionState() {
|
|
1082
|
+
return this.pc?.iceConnectionState;
|
|
1083
|
+
}
|
|
1084
|
+
getSignalingState() {
|
|
1085
|
+
return this.pc?.signalingState;
|
|
1086
|
+
}
|
|
1087
|
+
/** The SFU's own view. It can disagree with the local one, and that disagreement is usually the interesting bit. */
|
|
1088
|
+
getRemoteConnectionState() {
|
|
1089
|
+
return { iceState: this.sfuIceState, peerState: this.sfuPeerState };
|
|
1090
|
+
}
|
|
1091
|
+
async getConnectionRoundTripTimeMs() {
|
|
1092
|
+
return this.pc ? connectionRoundTripTimeMs(this.pc) : void 0;
|
|
1093
|
+
}
|
|
1094
|
+
// --- Connection --------------------------------------------------------
|
|
1095
|
+
async connect(endpoint, token, iceServers) {
|
|
1096
|
+
this.iceServers = iceServers ?? [];
|
|
1097
|
+
this.setConnectionState("connecting");
|
|
1098
|
+
const roomId = roomIdFromToken(token);
|
|
1099
|
+
const signaling = new SignalingClient({
|
|
1100
|
+
endpoint,
|
|
1101
|
+
token,
|
|
1102
|
+
roomId,
|
|
1103
|
+
autoReconnect: this.autoReconnect,
|
|
1104
|
+
logger: this.logger
|
|
1105
|
+
});
|
|
1106
|
+
this.signaling = signaling;
|
|
1107
|
+
signaling.on("message", (message) => void this.handleSignalingMessage(message));
|
|
1108
|
+
signaling.on("reconnecting", () => {
|
|
1109
|
+
this.setConnectionState("reconnecting");
|
|
1110
|
+
this.teardownPeerConnection();
|
|
1111
|
+
});
|
|
1112
|
+
signaling.on("joined", (payload) => void this.handleJoined(payload));
|
|
1113
|
+
signaling.on("failed", (error) => {
|
|
1114
|
+
this.logger.error("signaling failed", error.message);
|
|
1115
|
+
this.setConnectionState("failed");
|
|
1116
|
+
});
|
|
1117
|
+
signaling.on("closed", () => {
|
|
1118
|
+
this.setConnectionState(this.intentionalDisconnect ? "disconnected" : "failed");
|
|
1119
|
+
});
|
|
1120
|
+
try {
|
|
1121
|
+
const joined = await signaling.connect();
|
|
1122
|
+
this.localParticipant._setIdentity(participantIdFromToken(token));
|
|
1123
|
+
await this.handleJoined(joined);
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
this.setConnectionState("failed");
|
|
1126
|
+
throw error instanceof RTCError ? error : new RTCError("CONNECTION_FAILED", "Could not join the room", error);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Applies whatever room state the server reported at join.
|
|
1131
|
+
*
|
|
1132
|
+
* Runs on first join and after every reconnect. On a reconnect the
|
|
1133
|
+
* server's participant list wins outright and the old one goes in the
|
|
1134
|
+
* bin: anyone who left during the outage must not linger, anyone who
|
|
1135
|
+
* joined during it must show up.
|
|
1136
|
+
*/
|
|
1137
|
+
async handleJoined(payload) {
|
|
1138
|
+
this.logger.debug(
|
|
1139
|
+
"room state at join",
|
|
1140
|
+
`${payload.participants.length} participant(s)`,
|
|
1141
|
+
payload.rtcServer ? `on ${payload.rtcServer}` : ""
|
|
1142
|
+
);
|
|
1143
|
+
const present = new Set(payload.participants.map((participant) => participant.id));
|
|
1144
|
+
for (const [id, participant] of this.remoteParticipants) {
|
|
1145
|
+
if (!present.has(id)) {
|
|
1146
|
+
this.remoteParticipants.delete(id);
|
|
1147
|
+
this.emit("participantLeft", participant);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
for (const entry of payload.participants) {
|
|
1151
|
+
let participant = this.remoteParticipants.get(entry.id);
|
|
1152
|
+
if (!participant) {
|
|
1153
|
+
participant = new RemoteParticipant(entry.id);
|
|
1154
|
+
this.remoteParticipants.set(entry.id, participant);
|
|
1155
|
+
this.emit("participantJoined", participant);
|
|
1156
|
+
}
|
|
1157
|
+
for (const track of entry.tracks ?? []) {
|
|
1158
|
+
this.announceTrack(entry.id, track);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
async handleSignalingMessage(message) {
|
|
1163
|
+
switch (message.type) {
|
|
1164
|
+
case ServerMessageType.ROOM_JOINED:
|
|
1165
|
+
await this.handleJoined({
|
|
1166
|
+
roomId: message.roomId,
|
|
1167
|
+
participants: message.participants,
|
|
1168
|
+
rtcServer: message.rtcServer,
|
|
1169
|
+
region: message.region
|
|
1170
|
+
});
|
|
1171
|
+
return;
|
|
1172
|
+
case ServerMessageType.SDP_OFFER:
|
|
1173
|
+
await this.handleOffer(message.sdp);
|
|
1174
|
+
return;
|
|
1175
|
+
case ServerMessageType.SDP_ANSWER:
|
|
1176
|
+
await this.handleAnswer(message.sdp);
|
|
1177
|
+
return;
|
|
1178
|
+
case ServerMessageType.ICE_CANDIDATE:
|
|
1179
|
+
await this.handleRemoteCandidate(message);
|
|
1180
|
+
return;
|
|
1181
|
+
case ServerMessageType.PARTICIPANT_JOINED: {
|
|
1182
|
+
const existing = this.remoteParticipants.get(message.participant.id);
|
|
1183
|
+
if (existing) {
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
const participant = new RemoteParticipant(message.participant.id);
|
|
1187
|
+
this.remoteParticipants.set(participant.identity, participant);
|
|
1188
|
+
this.emit("participantJoined", participant);
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
case ServerMessageType.PARTICIPANT_LEFT: {
|
|
1192
|
+
const participant = this.remoteParticipants.get(message.participant.id);
|
|
1193
|
+
if (!participant) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
this.remoteParticipants.delete(participant.identity);
|
|
1197
|
+
for (const [key, subscription] of this.subscribed) {
|
|
1198
|
+
if (subscription.participantId === participant.identity) {
|
|
1199
|
+
this.subscribed.delete(key);
|
|
1200
|
+
this.emit("trackUnsubscribed", subscription.track, participant);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
this.emit("participantLeft", participant);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
case ServerMessageType.TRACK_PUBLISHED:
|
|
1207
|
+
this.announceTrack(message.participantId, message.track);
|
|
1208
|
+
return;
|
|
1209
|
+
case ServerMessageType.TRACK_UNPUBLISHED: {
|
|
1210
|
+
const key = subscriptionKey(message.participantId, message.trackId);
|
|
1211
|
+
this.announcedTracks.delete(key);
|
|
1212
|
+
this.pendingMedia.delete(key);
|
|
1213
|
+
const subscription = this.subscribed.get(key);
|
|
1214
|
+
const participant = this.remoteParticipants.get(message.participantId);
|
|
1215
|
+
if (subscription && participant) {
|
|
1216
|
+
this.subscribed.delete(key);
|
|
1217
|
+
this.emit("trackUnpublished", subscription.track.kind, participant);
|
|
1218
|
+
this.emit("trackUnsubscribed", subscription.track, participant);
|
|
1219
|
+
}
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
case ServerMessageType.TRACK_MUTED:
|
|
1223
|
+
case ServerMessageType.TRACK_UNMUTED: {
|
|
1224
|
+
const muted = message.type === ServerMessageType.TRACK_MUTED;
|
|
1225
|
+
const key = subscriptionKey(message.participantId, message.trackId);
|
|
1226
|
+
const subscription = this.subscribed.get(key);
|
|
1227
|
+
const participant = this.remoteParticipants.get(message.participantId);
|
|
1228
|
+
if (!subscription || !participant) {
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
subscription.delegate.setPublisherMuted(muted);
|
|
1232
|
+
this.emit(muted ? "trackMuted" : "trackUnmuted", subscription.track.kind, participant);
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
case ServerMessageType.CONNECTION_STATE:
|
|
1236
|
+
this.sfuIceState = message.iceState;
|
|
1237
|
+
this.sfuPeerState = message.peerState;
|
|
1238
|
+
this.logger.debug("sfu connection state", message.iceState, message.peerState);
|
|
1239
|
+
return;
|
|
1240
|
+
case ServerMessageType.ERROR:
|
|
1241
|
+
if (message.code === "NEGOTIATION_GLARE") {
|
|
1242
|
+
this.logger.debug("publish deferred by glare; will retry after the next offer");
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
this.emit("mediaError", new Error(message.message));
|
|
1246
|
+
return;
|
|
1247
|
+
default:
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
// --- Negotiation -------------------------------------------------------
|
|
1252
|
+
ensurePeerConnection() {
|
|
1253
|
+
if (this.pc) {
|
|
1254
|
+
return this.pc;
|
|
1255
|
+
}
|
|
1256
|
+
const pc = new RTCPeerConnection({ iceServers: this.iceServers });
|
|
1257
|
+
this.pc = pc;
|
|
1258
|
+
pc.onicecandidate = (event) => {
|
|
1259
|
+
if (!event.candidate) {
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
this.signaling?.send({
|
|
1263
|
+
type: ClientMessageType.ICE_CANDIDATE,
|
|
1264
|
+
candidate: event.candidate.candidate,
|
|
1265
|
+
sdpMid: event.candidate.sdpMid ?? void 0,
|
|
1266
|
+
sdpMLineIndex: event.candidate.sdpMLineIndex ?? void 0,
|
|
1267
|
+
usernameFragment: event.candidate.usernameFragment ?? void 0
|
|
1268
|
+
});
|
|
1269
|
+
};
|
|
1270
|
+
pc.onconnectionstatechange = () => {
|
|
1271
|
+
this.logger.debug("peer connection state", pc.connectionState);
|
|
1272
|
+
switch (pc.connectionState) {
|
|
1273
|
+
case "connected":
|
|
1274
|
+
this.setConnectionState("connected");
|
|
1275
|
+
break;
|
|
1276
|
+
case "failed":
|
|
1277
|
+
this.setConnectionState(this.autoReconnect ? "reconnecting" : "failed");
|
|
1278
|
+
break;
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
pc.ontrack = (event) => this.handleIncomingTrack(event);
|
|
1282
|
+
pc.ondatachannel = (event) => {
|
|
1283
|
+
if (event.channel.label !== DATA_CHANNEL_LABEL) {
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
this.attachDataChannel(event.channel);
|
|
1287
|
+
};
|
|
1288
|
+
return pc;
|
|
1289
|
+
}
|
|
1290
|
+
async handleOffer(sdp) {
|
|
1291
|
+
const pc = this.ensurePeerConnection();
|
|
1292
|
+
try {
|
|
1293
|
+
await pc.setRemoteDescription({ type: "offer", sdp });
|
|
1294
|
+
const answer = await pc.createAnswer();
|
|
1295
|
+
await pc.setLocalDescription(answer);
|
|
1296
|
+
this.signaling?.send({
|
|
1297
|
+
type: ClientMessageType.SDP_ANSWER,
|
|
1298
|
+
sdp: pc.localDescription?.sdp ?? answer.sdp ?? ""
|
|
1299
|
+
});
|
|
1300
|
+
this.flushDeferredPublishes();
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
this.logger.error("failed to answer offer", error.message);
|
|
1303
|
+
this.emit("mediaError", new Error("Could not answer the server's offer"));
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
async handleAnswer(sdp) {
|
|
1307
|
+
if (!this.pc) {
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
try {
|
|
1311
|
+
await this.pc.setRemoteDescription({ type: "answer", sdp });
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
this.logger.error("failed to apply answer", error.message);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
async handleRemoteCandidate(message) {
|
|
1317
|
+
if (!this.pc) {
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
try {
|
|
1321
|
+
await this.pc.addIceCandidate({
|
|
1322
|
+
candidate: message.candidate,
|
|
1323
|
+
sdpMid: message.sdpMid,
|
|
1324
|
+
sdpMLineIndex: message.sdpMLineIndex,
|
|
1325
|
+
usernameFragment: message.usernameFragment
|
|
1326
|
+
});
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
this.logger.debug("ignoring ICE candidate", error.message);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
flushDeferredPublishes() {
|
|
1332
|
+
const pending = this.deferredPublishes;
|
|
1333
|
+
this.deferredPublishes = [];
|
|
1334
|
+
for (const retry of pending) {
|
|
1335
|
+
retry();
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Offers, so the server hears about a track we just added.
|
|
1340
|
+
*
|
|
1341
|
+
* Only needed when adding the track created a new transceiver, which is
|
|
1342
|
+
* the first publish of each kind. Later publishes of the same kind reuse
|
|
1343
|
+
* the transceiver and hitch a ride on the server's next offer.
|
|
1344
|
+
*/
|
|
1345
|
+
async negotiatePublish() {
|
|
1346
|
+
const pc = this.pc;
|
|
1347
|
+
const signaling = this.signaling;
|
|
1348
|
+
if (!pc || !signaling) {
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
if (pc.signalingState !== "stable") {
|
|
1352
|
+
this.logger.debug("deferring publish negotiation until stable");
|
|
1353
|
+
this.deferredPublishes.push(() => void this.negotiatePublish());
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
try {
|
|
1357
|
+
const offer = await pc.createOffer();
|
|
1358
|
+
await pc.setLocalDescription(offer);
|
|
1359
|
+
await waitTick();
|
|
1360
|
+
signaling.send({
|
|
1361
|
+
type: ClientMessageType.SDP_OFFER,
|
|
1362
|
+
sdp: pc.localDescription?.sdp ?? offer.sdp ?? ""
|
|
1363
|
+
});
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
this.logger.error("publish negotiation failed", error.message);
|
|
1366
|
+
throw new RTCError("MEDIA_ERROR", "Could not negotiate the published track", error);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
// --- Incoming media ----------------------------------------------------
|
|
1370
|
+
/**
|
|
1371
|
+
* Matches an arriving track up with whatever signaling said about it.
|
|
1372
|
+
*
|
|
1373
|
+
* The SFU forwards every subscription carrying the *publisher's* track id
|
|
1374
|
+
* as the SDP `msid` track id, and that's what makes attribution possible
|
|
1375
|
+
* without a side channel. `ontrack` and `track.published` race, so this
|
|
1376
|
+
* only completes a subscription when both halves are in, parking
|
|
1377
|
+
* whichever showed up first.
|
|
1378
|
+
*
|
|
1379
|
+
* # Why the id comes from the SDP, not the track
|
|
1380
|
+
*
|
|
1381
|
+
* `RTCTrackEvent.track.id` is **not** the remote track id. Chrome mints
|
|
1382
|
+
* a brand-new local id for a received track and pays no attention to the
|
|
1383
|
+
* `msid`; the id in `a=msid:<stream> <track>` is the remote one. So
|
|
1384
|
+
* matching on `event.track.id` never matched anything, ever. And because
|
|
1385
|
+
* the unmatched track got parked as "media arrived early", it failed in
|
|
1386
|
+
* total silence: a subscription that simply never completed, not an
|
|
1387
|
+
* error anybody could see. Reading the `msid` is the standards-defined
|
|
1388
|
+
* way to get the id the remote peer actually picked.
|
|
1389
|
+
*/
|
|
1390
|
+
handleIncomingTrack(event) {
|
|
1391
|
+
const [stream] = event.streams;
|
|
1392
|
+
const trackId = this.remoteTrackIdFor(event) ?? event.track.id;
|
|
1393
|
+
const announcement = this.findAnnouncementForTrack(trackId);
|
|
1394
|
+
if (!announcement) {
|
|
1395
|
+
this.logger.debug(
|
|
1396
|
+
"media arrived before its announcement",
|
|
1397
|
+
`resolved=${trackId}`,
|
|
1398
|
+
`local=${event.track.id}`,
|
|
1399
|
+
`stream=${stream?.id ?? "none"}`,
|
|
1400
|
+
`mid=${event.transceiver?.mid ?? "none"}`,
|
|
1401
|
+
`announced=[${Array.from(this.announcedTracks.values()).map((entry) => `${entry.participantId}:${entry.track.trackId}`).join(", ")}]`
|
|
1402
|
+
);
|
|
1403
|
+
this.pendingMedia.set(pendingKey(trackId), {
|
|
1404
|
+
stream: stream ?? new MediaStream([event.track]),
|
|
1405
|
+
track: event.track,
|
|
1406
|
+
receiver: event.receiver
|
|
1407
|
+
});
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
this.completeSubscription(
|
|
1411
|
+
announcement.participantId,
|
|
1412
|
+
announcement.track,
|
|
1413
|
+
event.track,
|
|
1414
|
+
event.receiver
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* The remote track id for an arriving track, read out of the remote SDP.
|
|
1419
|
+
*
|
|
1420
|
+
* Found via the transceiver's `mid` rather than by scanning every
|
|
1421
|
+
* `a=msid:` line. Someone publishing both a camera and a screen share
|
|
1422
|
+
* has two video m-sections, and picking the wrong one labels a screen
|
|
1423
|
+
* share as somebody's face.
|
|
1424
|
+
*
|
|
1425
|
+
* Returns undefined when the SDP doesn't say, either an `msid`-less
|
|
1426
|
+
* offer or a transceiver with no mid yet, so the caller can fall back
|
|
1427
|
+
* instead of guessing.
|
|
1428
|
+
*/
|
|
1429
|
+
remoteTrackIdFor(event) {
|
|
1430
|
+
const mid = event.transceiver?.mid;
|
|
1431
|
+
const sdp = this.pc?.remoteDescription?.sdp;
|
|
1432
|
+
if (!mid || !sdp) {
|
|
1433
|
+
return void 0;
|
|
1434
|
+
}
|
|
1435
|
+
const sections = sdp.split(/\r?\nm=/).slice(1);
|
|
1436
|
+
for (const section of sections) {
|
|
1437
|
+
const lines = section.split(/\r?\n/);
|
|
1438
|
+
if (!lines.some((line) => line.trim() === `a=mid:${mid}`)) {
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1441
|
+
const msid = lines.find((line) => line.startsWith("a=msid:"));
|
|
1442
|
+
const trackId = msid?.slice("a=msid:".length).trim().split(/\s+/)[1];
|
|
1443
|
+
return trackId && trackId.length > 0 ? trackId : void 0;
|
|
1444
|
+
}
|
|
1445
|
+
return void 0;
|
|
1446
|
+
}
|
|
1447
|
+
findAnnouncementForTrack(trackId) {
|
|
1448
|
+
for (const announcement of this.announcedTracks.values()) {
|
|
1449
|
+
if (announcement.track.trackId === trackId) {
|
|
1450
|
+
return announcement;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
return void 0;
|
|
1454
|
+
}
|
|
1455
|
+
announceTrack(participantId, track) {
|
|
1456
|
+
const key = subscriptionKey(participantId, track.trackId);
|
|
1457
|
+
this.announcedTracks.set(key, { participantId, track });
|
|
1458
|
+
this.logger.debug("track announced", `${participantId}:${track.trackId}`, track.kind, track.source);
|
|
1459
|
+
const participant = this.remoteParticipants.get(participantId);
|
|
1460
|
+
if (participant) {
|
|
1461
|
+
this.emit("trackPublished", trackKindFromSource(track.source, track.kind), participant);
|
|
1462
|
+
}
|
|
1463
|
+
const pending = this.pendingMedia.get(pendingKey(track.trackId));
|
|
1464
|
+
if (pending) {
|
|
1465
|
+
this.pendingMedia.delete(pendingKey(track.trackId));
|
|
1466
|
+
this.completeSubscription(participantId, track, pending.track, pending.receiver);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
completeSubscription(participantId, serverTrack, mediaStreamTrack, receiver) {
|
|
1470
|
+
const participant = this.remoteParticipants.get(participantId);
|
|
1471
|
+
if (!participant) {
|
|
1472
|
+
this.logger.debug("track for an unknown participant", participantId);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
const key = subscriptionKey(participantId, serverTrack.trackId);
|
|
1476
|
+
if (this.subscribed.has(key)) {
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const delegate = new NativeRemoteTrackDelegate(mediaStreamTrack, receiver);
|
|
1480
|
+
delegate.setPublisherMuted(serverTrack.muted);
|
|
1481
|
+
const kind = trackKindFromSource(serverTrack.source, serverTrack.kind);
|
|
1482
|
+
const track = new RemoteTrack(delegate, kind);
|
|
1483
|
+
this.subscribed.set(key, { track, delegate, participantId, trackId: serverTrack.trackId });
|
|
1484
|
+
participant.tracks.push(track);
|
|
1485
|
+
this.emit("trackSubscribed", track, participant);
|
|
1486
|
+
mediaStreamTrack.onended = () => {
|
|
1487
|
+
const subscription = this.subscribed.get(key);
|
|
1488
|
+
if (!subscription) {
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
this.subscribed.delete(key);
|
|
1492
|
+
const index = participant.tracks.indexOf(subscription.track);
|
|
1493
|
+
if (index !== -1) {
|
|
1494
|
+
participant.tracks.splice(index, 1);
|
|
1495
|
+
}
|
|
1496
|
+
this.emit("trackUnsubscribed", subscription.track, participant);
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
// --- Publishing --------------------------------------------------------
|
|
1500
|
+
async enableCamera(enabled) {
|
|
1501
|
+
return enabled ? this.publishKind("camera", () => createCameraTrack()) : this.unpublishKind("camera");
|
|
1502
|
+
}
|
|
1503
|
+
async enableMicrophone(enabled) {
|
|
1504
|
+
return enabled ? this.publishKind("microphone", () => createMicrophoneTrack()) : this.unpublishKind("microphone");
|
|
1505
|
+
}
|
|
1506
|
+
async enableScreenShare(enabled) {
|
|
1507
|
+
return enabled ? this.publishKind("screenShare", () => createScreenShareTrack()) : this.unpublishKind("screenShare");
|
|
1508
|
+
}
|
|
1509
|
+
async publishKind(kind, capture) {
|
|
1510
|
+
const existing = this.published.get(kind);
|
|
1511
|
+
if (existing) {
|
|
1512
|
+
await existing.track.unmute();
|
|
1513
|
+
this.signaling?.send({
|
|
1514
|
+
type: ClientMessageType.TRACK_MUTE,
|
|
1515
|
+
trackId: existing.trackId,
|
|
1516
|
+
muted: false
|
|
1517
|
+
});
|
|
1518
|
+
return existing.track;
|
|
1519
|
+
}
|
|
1520
|
+
const track = await capture();
|
|
1521
|
+
await this.publish(track);
|
|
1522
|
+
return track;
|
|
1523
|
+
}
|
|
1524
|
+
async unpublishKind(kind) {
|
|
1525
|
+
const existing = this.published.get(kind);
|
|
1526
|
+
if (!existing) {
|
|
1527
|
+
return void 0;
|
|
1528
|
+
}
|
|
1529
|
+
await this.unpublish(existing.track);
|
|
1530
|
+
return void 0;
|
|
1531
|
+
}
|
|
1532
|
+
async publish(track) {
|
|
1533
|
+
const pc = this.ensurePeerConnection();
|
|
1534
|
+
const delegate = track["delegate"];
|
|
1535
|
+
if (!(delegate instanceof NativeLocalTrackDelegate)) {
|
|
1536
|
+
throw new RTCError(
|
|
1537
|
+
"MEDIA_ERROR",
|
|
1538
|
+
"This track was not created by the Raven SDK and cannot be published"
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
const stream = typeof MediaStream !== "undefined" ? new MediaStream([track.mediaStreamTrack]) : void 0;
|
|
1542
|
+
let sender;
|
|
1543
|
+
try {
|
|
1544
|
+
sender = stream ? pc.addTrack(track.mediaStreamTrack, stream) : pc.addTrack(track.mediaStreamTrack);
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
throw new RTCError("MEDIA_ERROR", "Could not add the track to the connection", error);
|
|
1547
|
+
}
|
|
1548
|
+
const source = declaredSourceFor(track.kind);
|
|
1549
|
+
if (source) {
|
|
1550
|
+
this.signaling?.send({
|
|
1551
|
+
type: ClientMessageType.TRACK_PUBLISH,
|
|
1552
|
+
trackId: track.mediaStreamTrack.id,
|
|
1553
|
+
source
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
delegate.setSender(sender);
|
|
1557
|
+
await this.applySimulcast(sender, track.kind);
|
|
1558
|
+
this.published.set(track.kind, {
|
|
1559
|
+
track,
|
|
1560
|
+
delegate,
|
|
1561
|
+
sender,
|
|
1562
|
+
trackId: track.mediaStreamTrack.id
|
|
1563
|
+
});
|
|
1564
|
+
if (!this.localParticipant.tracks.includes(track)) {
|
|
1565
|
+
this.localParticipant.tracks.push(track);
|
|
1566
|
+
}
|
|
1567
|
+
track.mediaStreamTrack.onended = () => {
|
|
1568
|
+
void this.unpublish(track).catch(() => void 0);
|
|
1569
|
+
};
|
|
1570
|
+
await this.negotiatePublish();
|
|
1571
|
+
this.emit("localTrackPublished", track);
|
|
1572
|
+
}
|
|
1573
|
+
async unpublish(track) {
|
|
1574
|
+
const entry = this.published.get(track.kind);
|
|
1575
|
+
if (!entry || entry.track !== track) {
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
this.published.delete(track.kind);
|
|
1579
|
+
const index = this.localParticipant.tracks.indexOf(track);
|
|
1580
|
+
if (index !== -1) {
|
|
1581
|
+
this.localParticipant.tracks.splice(index, 1);
|
|
1582
|
+
}
|
|
1583
|
+
entry.delegate.setSender(void 0);
|
|
1584
|
+
try {
|
|
1585
|
+
this.pc?.removeTrack(entry.sender);
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
this.logger.debug("removeTrack failed", error.message);
|
|
1588
|
+
}
|
|
1589
|
+
track.mediaStreamTrack.stop();
|
|
1590
|
+
await this.negotiatePublish();
|
|
1591
|
+
this.emit("localTrackUnpublished", track);
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Sets up simulcast on a video sender (spec §15).
|
|
1595
|
+
*
|
|
1596
|
+
* Three spatial layers, each a quarter of the previous one's pixel count.
|
|
1597
|
+
* That's the standard ladder and the one browsers actually implement
|
|
1598
|
+
* well. Applied with `setParameters` after `addTrack` instead of through
|
|
1599
|
+
* `addTransceiver`'s `sendEncodings`, because the transceiver may already
|
|
1600
|
+
* exist from the SFU's offer and re-adding it would renegotiate for
|
|
1601
|
+
* nothing at all.
|
|
1602
|
+
*
|
|
1603
|
+
* Audio gets left alone. There's no spatial layering to do, and Opus
|
|
1604
|
+
* already sorts its own bitrate out.
|
|
1605
|
+
*/
|
|
1606
|
+
async applySimulcast(sender, kind) {
|
|
1607
|
+
if (kind === "microphone" || sender.track?.kind !== "video") {
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
if (kind === "screenShare") {
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
try {
|
|
1614
|
+
const parameters = sender.getParameters();
|
|
1615
|
+
if (!parameters.encodings || parameters.encodings.length === 0) {
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
parameters.encodings = [
|
|
1619
|
+
{ rid: "low", scaleResolutionDownBy: 4, maxBitrate: 15e4 },
|
|
1620
|
+
{ rid: "medium", scaleResolutionDownBy: 2, maxBitrate: 5e5 },
|
|
1621
|
+
{ rid: "high", scaleResolutionDownBy: 1, maxBitrate: 15e5 }
|
|
1622
|
+
];
|
|
1623
|
+
await sender.setParameters(parameters);
|
|
1624
|
+
} catch (error) {
|
|
1625
|
+
this.logger.debug("simulcast not applied", error.message);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
// --- Data channel ------------------------------------------------------
|
|
1629
|
+
attachDataChannel(channel) {
|
|
1630
|
+
this.dataChannel = channel;
|
|
1631
|
+
channel.binaryType = "arraybuffer";
|
|
1632
|
+
channel.onmessage = (event) => {
|
|
1633
|
+
const payload = toUint8Array(event.data);
|
|
1634
|
+
if (payload) {
|
|
1635
|
+
this.emit("dataReceived", payload, void 0);
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
channel.onclose = () => {
|
|
1639
|
+
if (this.dataChannel === channel) {
|
|
1640
|
+
this.dataChannel = void 0;
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
async sendData(payload) {
|
|
1645
|
+
if (payload.byteLength > MAX_DATA_PAYLOAD_BYTES) {
|
|
1646
|
+
throw new RTCError(
|
|
1647
|
+
"MEDIA_ERROR",
|
|
1648
|
+
`Data payload is ${payload.byteLength} bytes, over the ${MAX_DATA_PAYLOAD_BYTES}-byte limit`
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
const channel = this.dataChannel ?? this.openDataChannel();
|
|
1652
|
+
if (!channel) {
|
|
1653
|
+
throw new RTCError("CONNECTION_FAILED", "sendData() requires an active connection");
|
|
1654
|
+
}
|
|
1655
|
+
if (channel.readyState !== "open") {
|
|
1656
|
+
throw new RTCError("CONNECTION_FAILED", "The data channel is not open yet");
|
|
1657
|
+
}
|
|
1658
|
+
try {
|
|
1659
|
+
channel.send(payload);
|
|
1660
|
+
} catch (error) {
|
|
1661
|
+
throw new RTCError("PERMISSION_DENIED", "Could not send data; check the token grants publishData", error);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Opens the data channel when something actually wants it.
|
|
1666
|
+
*
|
|
1667
|
+
* Not at connect time. A channel costs an SCTP association and most
|
|
1668
|
+
* calls never send a byte of data. The client creates it, not the
|
|
1669
|
+
* server, because the client is the side that knows it needs one.
|
|
1670
|
+
*/
|
|
1671
|
+
openDataChannel() {
|
|
1672
|
+
if (!this.pc) {
|
|
1673
|
+
return void 0;
|
|
1674
|
+
}
|
|
1675
|
+
const channel = this.pc.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true });
|
|
1676
|
+
this.attachDataChannel(channel);
|
|
1677
|
+
return channel;
|
|
1678
|
+
}
|
|
1679
|
+
// --- Devices -----------------------------------------------------------
|
|
1680
|
+
async getDevices(kind) {
|
|
1681
|
+
return listDevices(kind);
|
|
1682
|
+
}
|
|
1683
|
+
async setDevice(kind, deviceId) {
|
|
1684
|
+
switch (kind) {
|
|
1685
|
+
case "videoinput":
|
|
1686
|
+
return this.replaceDevice("camera", () => createCameraTrack({ deviceId }));
|
|
1687
|
+
case "audioinput":
|
|
1688
|
+
return this.replaceDevice("microphone", () => createMicrophoneTrack({ deviceId }));
|
|
1689
|
+
case "audiooutput":
|
|
1690
|
+
return this.setAudioOutput(deviceId);
|
|
1691
|
+
default:
|
|
1692
|
+
throw new RTCError("DEVICE_NOT_FOUND", `Unknown device kind "${String(kind)}"`);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Swaps the device behind a published track without renegotiating.
|
|
1697
|
+
*
|
|
1698
|
+
* `replaceTrack` is what makes it seamless. Transceiver, SSRC, and every
|
|
1699
|
+
* subscriber's view of the track all stay put, so nobody else in the
|
|
1700
|
+
* room notices a thing.
|
|
1701
|
+
*/
|
|
1702
|
+
async replaceDevice(kind, capture) {
|
|
1703
|
+
const entry = this.published.get(kind);
|
|
1704
|
+
if (!entry) {
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
const replacement = await capture();
|
|
1708
|
+
const previous = entry.track.mediaStreamTrack;
|
|
1709
|
+
await entry.delegate.replaceTrack(replacement.mediaStreamTrack);
|
|
1710
|
+
previous.stop();
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Points this room's remote audio at a different output device.
|
|
1714
|
+
*
|
|
1715
|
+
* `setSinkId` works per element, so this walks whatever elements each
|
|
1716
|
+
* remote audio track is attached to. Safari doesn't have `setSinkId` at
|
|
1717
|
+
* all. `Room.setSpeakerDevice()` checks and throws before we get here,
|
|
1718
|
+
* so an unsupported browser gets a real error instead of a silent no-op.
|
|
1719
|
+
*/
|
|
1720
|
+
async setAudioOutput(deviceId) {
|
|
1721
|
+
const failures = [];
|
|
1722
|
+
for (const subscription of this.subscribed.values()) {
|
|
1723
|
+
if (subscription.track.kind === "camera" || subscription.track.kind === "screenShare") {
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
for (const element of subscription.track.detach()) {
|
|
1727
|
+
const withSink = element;
|
|
1728
|
+
try {
|
|
1729
|
+
await withSink.setSinkId?.(deviceId);
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
failures.push(error);
|
|
1732
|
+
}
|
|
1733
|
+
subscription.track.attach(element);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
if (failures.length > 0) {
|
|
1737
|
+
throw new RTCError("DEVICE_NOT_FOUND", "Could not switch the audio output device", failures[0]);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
// --- Teardown ----------------------------------------------------------
|
|
1741
|
+
async disconnect() {
|
|
1742
|
+
this.intentionalDisconnect = true;
|
|
1743
|
+
for (const entry of this.published.values()) {
|
|
1744
|
+
entry.track.mediaStreamTrack.stop();
|
|
1745
|
+
}
|
|
1746
|
+
this.published.clear();
|
|
1747
|
+
this.localParticipant.tracks.length = 0;
|
|
1748
|
+
this.signaling?.close();
|
|
1749
|
+
this.teardownPeerConnection();
|
|
1750
|
+
this.remoteParticipants.clear();
|
|
1751
|
+
this.subscribed.clear();
|
|
1752
|
+
this.announcedTracks.clear();
|
|
1753
|
+
this.pendingMedia.clear();
|
|
1754
|
+
this.setConnectionState("disconnected");
|
|
1755
|
+
}
|
|
1756
|
+
teardownPeerConnection() {
|
|
1757
|
+
if (this.dataChannel) {
|
|
1758
|
+
try {
|
|
1759
|
+
this.dataChannel.close();
|
|
1760
|
+
} catch {
|
|
1761
|
+
}
|
|
1762
|
+
this.dataChannel = void 0;
|
|
1763
|
+
}
|
|
1764
|
+
if (!this.pc) {
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
this.pc.onicecandidate = null;
|
|
1768
|
+
this.pc.onconnectionstatechange = null;
|
|
1769
|
+
this.pc.ontrack = null;
|
|
1770
|
+
this.pc.ondatachannel = null;
|
|
1771
|
+
try {
|
|
1772
|
+
this.pc.close();
|
|
1773
|
+
} catch {
|
|
1774
|
+
}
|
|
1775
|
+
this.pc = void 0;
|
|
1776
|
+
}
|
|
1777
|
+
setConnectionState(state) {
|
|
1778
|
+
if (this._connectionState === state) {
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
this._connectionState = state;
|
|
1782
|
+
this.emit("connectionStateChanged", state);
|
|
1783
|
+
}
|
|
1784
|
+
};
|
|
1785
|
+
function subscriptionKey(participantId, trackId) {
|
|
1786
|
+
return `${participantId}/${trackId}`;
|
|
1787
|
+
}
|
|
1788
|
+
function pendingKey(trackId) {
|
|
1789
|
+
return `media:${trackId}`;
|
|
1790
|
+
}
|
|
1791
|
+
function declaredSourceFor(kind) {
|
|
1792
|
+
switch (kind) {
|
|
1793
|
+
case "camera":
|
|
1794
|
+
case "microphone":
|
|
1795
|
+
case "screenShare":
|
|
1796
|
+
return kind;
|
|
1797
|
+
default:
|
|
1798
|
+
return void 0;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
function toUint8Array(data) {
|
|
1802
|
+
if (typeof data === "string") {
|
|
1803
|
+
return new TextEncoder().encode(data);
|
|
1804
|
+
}
|
|
1805
|
+
if (ArrayBuffer.isView(data)) {
|
|
1806
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
1807
|
+
}
|
|
1808
|
+
if (isArrayBufferLike(data)) {
|
|
1809
|
+
return new Uint8Array(data);
|
|
1810
|
+
}
|
|
1811
|
+
return void 0;
|
|
1812
|
+
}
|
|
1813
|
+
function isArrayBufferLike(value) {
|
|
1814
|
+
const tag = Object.prototype.toString.call(value);
|
|
1815
|
+
return tag === "[object ArrayBuffer]" || tag === "[object SharedArrayBuffer]";
|
|
1816
|
+
}
|
|
1817
|
+
function waitTick() {
|
|
1818
|
+
return new Promise((resolve) => setTimeout(resolve, ICE_GATHER_HINT_MS));
|
|
1819
|
+
}
|
|
1820
|
+
function roomIdFromToken(token) {
|
|
1821
|
+
const claims = decodeClaims(token);
|
|
1822
|
+
const roomId = claims?.rid;
|
|
1823
|
+
if (typeof roomId !== "string" || roomId.length === 0) {
|
|
1824
|
+
throw new RTCError("INVALID_TOKEN", "RTC token does not name a room");
|
|
1825
|
+
}
|
|
1826
|
+
return roomId;
|
|
1827
|
+
}
|
|
1828
|
+
function participantIdFromToken(token) {
|
|
1829
|
+
const claims = decodeClaims(token);
|
|
1830
|
+
return typeof claims?.sub === "string" ? claims.sub : "";
|
|
1831
|
+
}
|
|
1832
|
+
function decodeClaims(token) {
|
|
1833
|
+
const parts = token.split(".");
|
|
1834
|
+
if (parts.length !== 3) {
|
|
1835
|
+
return void 0;
|
|
1836
|
+
}
|
|
1837
|
+
try {
|
|
1838
|
+
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
1839
|
+
return JSON.parse(atob(base64));
|
|
1840
|
+
} catch {
|
|
1841
|
+
return void 0;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// src/internal/telemetry/connection-id.ts
|
|
1846
|
+
function generateConnectionId() {
|
|
1847
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
1848
|
+
return `conn_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
1849
|
+
}
|
|
1850
|
+
let id = "";
|
|
1851
|
+
for (let i = 0; i < 32; i++) {
|
|
1852
|
+
id += Math.floor(Math.random() * 16).toString(16);
|
|
1853
|
+
}
|
|
1854
|
+
return `conn_${id}`;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// src/internal/telemetry/platform.ts
|
|
1858
|
+
function detectPlatform() {
|
|
1859
|
+
if (typeof navigator === "undefined") {
|
|
1860
|
+
return { platform: "unknown", browser: "unknown" };
|
|
1861
|
+
}
|
|
1862
|
+
const ua = navigator.userAgent ?? "";
|
|
1863
|
+
let browser = "unknown";
|
|
1864
|
+
if (/edg\//i.test(ua)) browser = "edge";
|
|
1865
|
+
else if (/firefox|fxios/i.test(ua)) browser = "firefox";
|
|
1866
|
+
else if (/chrome|crios/i.test(ua)) browser = "chrome";
|
|
1867
|
+
else if (/safari/i.test(ua)) browser = "safari";
|
|
1868
|
+
let platform = "web";
|
|
1869
|
+
if (/android/i.test(ua)) platform = "android";
|
|
1870
|
+
else if (/iphone|ipad|ipod/i.test(ua)) platform = "ios";
|
|
1871
|
+
const connection = navigator.connection;
|
|
1872
|
+
const networkType = typeof connection?.effectiveType === "string" ? connection.effectiveType : void 0;
|
|
1873
|
+
return { platform, browser, networkType };
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// src/internal/telemetry/telemetry-client.ts
|
|
1877
|
+
function createTelemetryClient(options) {
|
|
1878
|
+
const connectionId = generateConnectionId();
|
|
1879
|
+
if (!options.enabled || !options.telemetryUrl) {
|
|
1880
|
+
return { connectionId, send: () => {
|
|
1881
|
+
} };
|
|
1882
|
+
}
|
|
1883
|
+
return new HttpTelemetryClient(connectionId, options);
|
|
1884
|
+
}
|
|
1885
|
+
var HttpTelemetryClient = class {
|
|
1886
|
+
constructor(connectionId, options) {
|
|
1887
|
+
this.connectionId = connectionId;
|
|
1888
|
+
this.options = options;
|
|
1889
|
+
}
|
|
1890
|
+
send(type, data = {}) {
|
|
1891
|
+
const body = {
|
|
1892
|
+
connectionId: this.connectionId,
|
|
1893
|
+
type,
|
|
1894
|
+
data: { sdkVersion: this.options.sdkVersion, ...detectPlatform(), ...data }
|
|
1895
|
+
};
|
|
1896
|
+
try {
|
|
1897
|
+
fetch(`${this.options.telemetryUrl}/v1/telemetry/events`, {
|
|
1898
|
+
method: "POST",
|
|
1899
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.options.token}` },
|
|
1900
|
+
body: JSON.stringify(body),
|
|
1901
|
+
keepalive: true
|
|
1902
|
+
}).then((res) => {
|
|
1903
|
+
if (!res.ok) {
|
|
1904
|
+
this.options.logger.debug("telemetry event rejected", type, res.status);
|
|
1905
|
+
}
|
|
1906
|
+
}).catch((error) => {
|
|
1907
|
+
this.options.logger.debug("telemetry event failed", type, error.message);
|
|
1908
|
+
});
|
|
1909
|
+
} catch (error) {
|
|
1910
|
+
this.options.logger.debug("telemetry send threw synchronously", type, error.message);
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
};
|
|
1914
|
+
|
|
1915
|
+
// src/version.ts
|
|
1916
|
+
var SDK_VERSION = "0.1.0";
|
|
1917
|
+
|
|
1918
|
+
// src/room.ts
|
|
1919
|
+
var _Room = class _Room extends TypedEventEmitter {
|
|
1920
|
+
/** @internal Use `client.join(roomId)`. The telemetry client defaults to a no-op, so tests and advanced setups can build a Room directly without wiring one up. */
|
|
1921
|
+
constructor(adapter, roomId, logger, telemetry = createTelemetryClient({ enabled: false, token: "", sdkVersion: SDK_VERSION, logger })) {
|
|
1922
|
+
super();
|
|
1923
|
+
this.reconnectCount = 0;
|
|
1924
|
+
this.adapter = adapter;
|
|
1925
|
+
this.roomId = roomId;
|
|
1926
|
+
this.logger = logger;
|
|
1927
|
+
this.telemetry = telemetry;
|
|
1928
|
+
this.connectionId = telemetry.connectionId;
|
|
1929
|
+
this.localParticipant = adapter.localParticipant;
|
|
1930
|
+
this.wireAdapterEvents();
|
|
1931
|
+
}
|
|
1932
|
+
get remoteParticipants() {
|
|
1933
|
+
return Array.from(this.adapter.remoteParticipants.values());
|
|
1934
|
+
}
|
|
1935
|
+
get connectionState() {
|
|
1936
|
+
return this.adapter.connectionState;
|
|
1937
|
+
}
|
|
1938
|
+
wireAdapterEvents() {
|
|
1939
|
+
let prevState = this.adapter.connectionState;
|
|
1940
|
+
this.adapter.on("connectionStateChanged", (state) => {
|
|
1941
|
+
this.emit("connectionStateChanged", state);
|
|
1942
|
+
if (state === "connected") {
|
|
1943
|
+
if (prevState === "reconnecting") {
|
|
1944
|
+
this.reconnectCount++;
|
|
1945
|
+
this.telemetry.send("reconnected");
|
|
1946
|
+
this.emit("reconnected");
|
|
1947
|
+
} else {
|
|
1948
|
+
this.telemetry.send("connected");
|
|
1949
|
+
this.emit("connected");
|
|
1950
|
+
}
|
|
1951
|
+
this.startStatsMonitor();
|
|
1952
|
+
} else if (state === "reconnecting" && prevState !== "reconnecting") {
|
|
1953
|
+
this.telemetry.send("reconnecting");
|
|
1954
|
+
this.emit("reconnecting");
|
|
1955
|
+
} else if (state === "disconnected" || state === "failed") {
|
|
1956
|
+
this.stopStatsMonitor();
|
|
1957
|
+
this.telemetry.send(state === "failed" ? "connection_failed" : "disconnected");
|
|
1958
|
+
this.emit("disconnected");
|
|
1959
|
+
if (state === "failed") {
|
|
1960
|
+
const error = new RTCError("CONNECTION_FAILED", "Connection failed after exhausting reconnect attempts");
|
|
1961
|
+
this.telemetry.send("error", { code: error.code, message: error.message });
|
|
1962
|
+
this.emit("error", error);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
prevState = state;
|
|
1966
|
+
});
|
|
1967
|
+
this.adapter.on("participantJoined", (participant) => {
|
|
1968
|
+
this.telemetry.send("participant_joined", { participantIdentity: participant.identity });
|
|
1969
|
+
this.emit("participantJoined", participant);
|
|
1970
|
+
});
|
|
1971
|
+
this.adapter.on("participantLeft", (participant) => {
|
|
1972
|
+
this.telemetry.send("participant_left", { participantIdentity: participant.identity });
|
|
1973
|
+
this.emit("participantLeft", participant);
|
|
1974
|
+
});
|
|
1975
|
+
this.adapter.on("trackPublished", (kind, participant) => this.emit("trackPublished", kind, participant));
|
|
1976
|
+
this.adapter.on("trackUnpublished", (kind, participant) => this.emit("trackUnpublished", kind, participant));
|
|
1977
|
+
this.adapter.on("trackSubscribed", (track, participant) => this.emit("trackSubscribed", track, participant));
|
|
1978
|
+
this.adapter.on("trackUnsubscribed", (track, participant) => this.emit("trackUnsubscribed", track, participant));
|
|
1979
|
+
this.adapter.on("trackMuted", (kind, participant) => this.emit("trackMuted", kind, participant));
|
|
1980
|
+
this.adapter.on("trackUnmuted", (kind, participant) => this.emit("trackUnmuted", kind, participant));
|
|
1981
|
+
this.adapter.on("localTrackPublished", (track) => {
|
|
1982
|
+
this.telemetry.send("track_published", { kind: track.kind });
|
|
1983
|
+
this.emit("localTrackPublished", track);
|
|
1984
|
+
});
|
|
1985
|
+
this.adapter.on("localTrackUnpublished", (track) => {
|
|
1986
|
+
this.telemetry.send("track_unpublished", { kind: track.kind });
|
|
1987
|
+
this.emit("localTrackUnpublished", track);
|
|
1988
|
+
});
|
|
1989
|
+
this.adapter.on("dataReceived", (payload, participant) => this.emit("dataReceived", payload, participant));
|
|
1990
|
+
this.adapter.on("mediaError", (error) => {
|
|
1991
|
+
this.logger.warn("media device error", error.message);
|
|
1992
|
+
const rtcError = new RTCError("MEDIA_ERROR", error.message, error);
|
|
1993
|
+
this.telemetry.send("error", { code: rtcError.code, message: rtcError.message });
|
|
1994
|
+
this.emit("error", rtcError);
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
/**
|
|
1998
|
+
* A non-secret diagnostic snapshot for support and debugging.
|
|
1999
|
+
*
|
|
2000
|
+
* Synchronous and cheap by design, so you can call it from anywhere at
|
|
2001
|
+
* any time, error handlers included. Live media stats are a separate
|
|
2002
|
+
* `async` call; see `getConnectionStats()`.
|
|
2003
|
+
*/
|
|
2004
|
+
getDiagnostics() {
|
|
2005
|
+
const { platform, browser } = detectPlatform();
|
|
2006
|
+
const remote = this.adapter.getRemoteConnectionState?.() ?? {};
|
|
2007
|
+
return {
|
|
2008
|
+
connectionState: this.connectionState,
|
|
2009
|
+
iceConnectionState: this.adapter.getIceConnectionState?.(),
|
|
2010
|
+
signalingState: this.adapter.getSignalingState?.(),
|
|
2011
|
+
remoteIceConnectionState: remote.iceState,
|
|
2012
|
+
remotePeerConnectionState: remote.peerState,
|
|
2013
|
+
reconnectCount: this.reconnectCount,
|
|
2014
|
+
sdkVersion: SDK_VERSION,
|
|
2015
|
+
platform,
|
|
2016
|
+
browser
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
/**
|
|
2020
|
+
* Live media-quality stats for every published and subscribed track.
|
|
2021
|
+
* RTT, jitter, packet loss, bitrate, codec, resolution/fps, and the
|
|
2022
|
+
* SFU's own connection-quality read. `ConnectionStats` explains why this
|
|
2023
|
+
* is kept apart from `getDiagnostics()`.
|
|
2024
|
+
*
|
|
2025
|
+
* Call it whenever you like, including before anything is published or
|
|
2026
|
+
* subscribed. You just get empty `local`/`remote` arrays then, not an
|
|
2027
|
+
* error.
|
|
2028
|
+
*/
|
|
2029
|
+
async getConnectionStats() {
|
|
2030
|
+
const localTracks = this.localParticipant.tracks;
|
|
2031
|
+
const remoteTracks = this.remoteParticipants.flatMap((participant) => participant.tracks);
|
|
2032
|
+
const [local, remote] = await Promise.all([
|
|
2033
|
+
Promise.all(localTracks.map((track) => track.getStats())),
|
|
2034
|
+
Promise.all(remoteTracks.map((track) => track.getStats()))
|
|
2035
|
+
]);
|
|
2036
|
+
return {
|
|
2037
|
+
connectionState: this.connectionState,
|
|
2038
|
+
connectionQuality: this.adapter.getConnectionQuality(),
|
|
2039
|
+
local: local.filter((stats) => stats !== void 0),
|
|
2040
|
+
remote: remote.filter((stats) => stats !== void 0)
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Polls `getConnectionStats()` on a timer and ships the result as
|
|
2045
|
+
* telemetry, so the dashboard's RTC view (spec §23) has numbers to show
|
|
2046
|
+
* without every developer wiring it up by hand. Best-effort, same as
|
|
2047
|
+
* every other telemetry event here: failures get swallowed. A hiccup
|
|
2048
|
+
* collecting stats is no reason to disturb the call it's describing.
|
|
2049
|
+
*/
|
|
2050
|
+
startStatsMonitor() {
|
|
2051
|
+
if (this.statsTimer) {
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
this.statsTimer = setInterval(() => {
|
|
2055
|
+
this.getConnectionStats().then((stats) => this.telemetry.send("stats", stats)).catch(() => {
|
|
2056
|
+
});
|
|
2057
|
+
}, _Room.STATS_INTERVAL_MS);
|
|
2058
|
+
}
|
|
2059
|
+
stopStatsMonitor() {
|
|
2060
|
+
if (this.statsTimer) {
|
|
2061
|
+
clearInterval(this.statsTimer);
|
|
2062
|
+
this.statsTimer = void 0;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
/** Captures and publishes the camera in one go. Resolves to the published track. */
|
|
2066
|
+
async enableCamera() {
|
|
2067
|
+
return this.adapter.enableCamera(true);
|
|
2068
|
+
}
|
|
2069
|
+
/** Stops publishing and releases the camera. */
|
|
2070
|
+
async disableCamera() {
|
|
2071
|
+
await this.adapter.enableCamera(false);
|
|
2072
|
+
}
|
|
2073
|
+
/** Captures and publishes the microphone in one call. Resolves to the published track. */
|
|
2074
|
+
async enableMicrophone() {
|
|
2075
|
+
return this.adapter.enableMicrophone(true);
|
|
2076
|
+
}
|
|
2077
|
+
/** Stops publishing and releases the microphone. */
|
|
2078
|
+
async disableMicrophone() {
|
|
2079
|
+
await this.adapter.enableMicrophone(false);
|
|
2080
|
+
}
|
|
2081
|
+
/** Captures and publishes a screen share in one call. */
|
|
2082
|
+
async enableScreenShare() {
|
|
2083
|
+
return this.adapter.enableScreenShare(true);
|
|
2084
|
+
}
|
|
2085
|
+
async disableScreenShare() {
|
|
2086
|
+
await this.adapter.enableScreenShare(false);
|
|
2087
|
+
}
|
|
2088
|
+
/** Publishes a track you made with `client.createCameraTrack()` and friends. */
|
|
2089
|
+
async publish(track) {
|
|
2090
|
+
await this.adapter.publish(track);
|
|
2091
|
+
}
|
|
2092
|
+
async unpublish(track) {
|
|
2093
|
+
await this.adapter.unpublish(track);
|
|
2094
|
+
}
|
|
2095
|
+
/** Switches the active camera without republishing. */
|
|
2096
|
+
async setCameraDevice(deviceId) {
|
|
2097
|
+
await this.adapter.setDevice("videoinput", deviceId);
|
|
2098
|
+
}
|
|
2099
|
+
/** Switches the active microphone without republishing. */
|
|
2100
|
+
async setMicrophoneDevice(deviceId) {
|
|
2101
|
+
await this.adapter.setDevice("audioinput", deviceId);
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* Switches the audio output ("speaker") device for this room's remote
|
|
2105
|
+
* audio elements. Phase 11 addition. Not supported everywhere: Safari
|
|
2106
|
+
* has no `HTMLMediaElement.setSinkId`. Browsers that don't implement it
|
|
2107
|
+
* get a `DEVICE_NOT_FOUND` throw instead of a silent no-op.
|
|
2108
|
+
*/
|
|
2109
|
+
async setSpeakerDevice(deviceId) {
|
|
2110
|
+
if (typeof document !== "undefined") {
|
|
2111
|
+
const probe = document.createElement("audio");
|
|
2112
|
+
if (typeof probe.setSinkId !== "function") {
|
|
2113
|
+
throw new RTCError("DEVICE_NOT_FOUND", "This browser doesn't support selecting an audio output device (no setSinkId)");
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
await this.adapter.setDevice("audiooutput", deviceId);
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Sends a small payload to everyone, or to specific people if the
|
|
2120
|
+
* underlying SFU adapter supports targeting. Requires the token's
|
|
2121
|
+
* `publishData` grant; throws PERMISSION_DENIED without it.
|
|
2122
|
+
*/
|
|
2123
|
+
async sendData(payload) {
|
|
2124
|
+
const bytes = typeof payload === "string" ? new TextEncoder().encode(payload) : new Uint8Array(payload);
|
|
2125
|
+
await this.adapter.sendData(bytes);
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* Resolves once the media connection is genuinely up.
|
|
2129
|
+
*
|
|
2130
|
+
* # Why this exists
|
|
2131
|
+
*
|
|
2132
|
+
* `client.join()` resolves when the **control plane** lets you in: room
|
|
2133
|
+
* joined, you know who else is here, you can publish. The media
|
|
2134
|
+
* connection finishes a moment later, once ICE and DTLS are done, which
|
|
2135
|
+
* means `connectionState` sits at `'connecting'` for a short window
|
|
2136
|
+
* after `join()` returns. That's the honest shape of an SFU connection,
|
|
2137
|
+
* and it's why `'connected'` is an event, not something joining
|
|
2138
|
+
* guarantees you.
|
|
2139
|
+
*
|
|
2140
|
+
* Most callers need none of this. `enableCamera()` and
|
|
2141
|
+
* `enableMicrophone()` work fine inside that window, and the `connected`
|
|
2142
|
+
* event is what you want driving a UI. This is for code that genuinely
|
|
2143
|
+
* has to block: a test, or a flow that mustn't move on until media is
|
|
2144
|
+
* live.
|
|
2145
|
+
*
|
|
2146
|
+
* Resolves straight away if already connected. Rejects on `'failed'` and
|
|
2147
|
+
* on timeout, rather than handing back a connection that isn't there.
|
|
2148
|
+
*
|
|
2149
|
+
* Careful: a subscriber joining a room where nobody is publishing can
|
|
2150
|
+
* quite legitimately stay `'connecting'`. With no tracks on either side
|
|
2151
|
+
* there's nothing to negotiate, so waiting here times out on a
|
|
2152
|
+
* connection that isn't broken at all. Where you can, drive the UI off
|
|
2153
|
+
* the `connected` event instead of blocking on this.
|
|
2154
|
+
*/
|
|
2155
|
+
waitUntilConnected(timeoutMs = 15e3) {
|
|
2156
|
+
if (this.connectionState === "connected") {
|
|
2157
|
+
return Promise.resolve();
|
|
2158
|
+
}
|
|
2159
|
+
return new Promise((resolve, reject) => {
|
|
2160
|
+
const settle = (fn) => {
|
|
2161
|
+
clearTimeout(timer);
|
|
2162
|
+
this.off("connectionStateChanged", onState);
|
|
2163
|
+
fn();
|
|
2164
|
+
};
|
|
2165
|
+
const onState = (state) => {
|
|
2166
|
+
if (state === "connected") {
|
|
2167
|
+
settle(resolve);
|
|
2168
|
+
} else if (state === "failed") {
|
|
2169
|
+
settle(() => reject(new RTCError("CONNECTION_FAILED", "The connection failed while waiting for it")));
|
|
2170
|
+
}
|
|
2171
|
+
};
|
|
2172
|
+
const timer = setTimeout(() => {
|
|
2173
|
+
settle(
|
|
2174
|
+
() => reject(
|
|
2175
|
+
new RTCError(
|
|
2176
|
+
"CONNECTION_FAILED",
|
|
2177
|
+
`Still ${this.connectionState} after ${timeoutMs}ms; the media connection did not establish`
|
|
2178
|
+
)
|
|
2179
|
+
)
|
|
2180
|
+
);
|
|
2181
|
+
}, timeoutMs);
|
|
2182
|
+
this.on("connectionStateChanged", onState);
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
/** Leaves the room, stops local tracks and closes the underlying connection. */
|
|
2186
|
+
async leave() {
|
|
2187
|
+
this.stopStatsMonitor();
|
|
2188
|
+
await this.adapter.disconnect();
|
|
2189
|
+
}
|
|
2190
|
+
};
|
|
2191
|
+
/**
|
|
2192
|
+
* How often the stats monitor samples and reports. Often enough that a
|
|
2193
|
+
* dashboard showing "now" isn't showing you five minutes ago, rarely
|
|
2194
|
+
* enough that it doesn't hammer the telemetry endpoint on a call where
|
|
2195
|
+
* dozens of participants are all doing exactly this.
|
|
2196
|
+
*/
|
|
2197
|
+
_Room.STATS_INTERVAL_MS = 5e3;
|
|
2198
|
+
var Room = _Room;
|
|
2199
|
+
|
|
2200
|
+
// src/client.ts
|
|
2201
|
+
var defaultAdapterFactory = (logger, autoReconnect) => new RavenAdapter(logger, autoReconnect);
|
|
2202
|
+
var RTCClient = class {
|
|
2203
|
+
/**
|
|
2204
|
+
* @internal Use `createRTCClient(config)`. The second param exists purely
|
|
2205
|
+
* so tests can inject a fake SFUAdapter without a real browser and WebRTC
|
|
2206
|
+
* stack. Not part of the public config.
|
|
2207
|
+
*/
|
|
2208
|
+
constructor(config, adapterFactory = defaultAdapterFactory) {
|
|
2209
|
+
this.config = config;
|
|
2210
|
+
this.logger = createLogger(config.logLevel);
|
|
2211
|
+
this.adapterFactory = adapterFactory;
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Joins the room this client's token was minted for. `roomId` has to
|
|
2215
|
+
* match that room; pass a different one and you get `ROOM_NOT_FOUND`
|
|
2216
|
+
* straight away, before any connection is attempted.
|
|
2217
|
+
*/
|
|
2218
|
+
async join(roomId) {
|
|
2219
|
+
assertTokenMatchesRoom(this.config.token, roomId);
|
|
2220
|
+
this.logger.info("joining room", roomId);
|
|
2221
|
+
const telemetry = createTelemetryClient({
|
|
2222
|
+
enabled: this.config.telemetry,
|
|
2223
|
+
telemetryUrl: this.config.telemetryUrl,
|
|
2224
|
+
token: this.config.token,
|
|
2225
|
+
sdkVersion: SDK_VERSION,
|
|
2226
|
+
logger: this.logger
|
|
2227
|
+
});
|
|
2228
|
+
telemetry.send("connection_started");
|
|
2229
|
+
const adapter = this.adapterFactory(this.logger, this.config.autoReconnect);
|
|
2230
|
+
const room = new Room(adapter, roomId, this.logger, telemetry);
|
|
2231
|
+
try {
|
|
2232
|
+
await adapter.connect(this.config.endpoint, this.config.token, this.config.iceServers);
|
|
2233
|
+
} catch (error) {
|
|
2234
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2235
|
+
const code = error instanceof RTCError ? error.code : "CONNECTION_FAILED";
|
|
2236
|
+
telemetry.send("error", { code, message });
|
|
2237
|
+
telemetry.send("connection_failed");
|
|
2238
|
+
throw error;
|
|
2239
|
+
}
|
|
2240
|
+
this.currentRoom = room;
|
|
2241
|
+
return room;
|
|
2242
|
+
}
|
|
2243
|
+
/** Leaves the most recently joined room, if there is one. Same as `.leave()` on that `Room`. */
|
|
2244
|
+
async leave() {
|
|
2245
|
+
await this.currentRoom?.leave();
|
|
2246
|
+
this.currentRoom = void 0;
|
|
2247
|
+
}
|
|
2248
|
+
/** Captures a camera track without joining or publishing. Pair it with `room.publish(track)`. */
|
|
2249
|
+
async createCameraTrack(deviceId) {
|
|
2250
|
+
return createCameraTrack(deviceId ? { deviceId } : {});
|
|
2251
|
+
}
|
|
2252
|
+
/** Captures a microphone track without joining or publishing. Pair it with `room.publish(track)`. */
|
|
2253
|
+
async createMicrophoneTrack(deviceId) {
|
|
2254
|
+
return createMicrophoneTrack(deviceId ? { deviceId } : {});
|
|
2255
|
+
}
|
|
2256
|
+
/** Captures a screen-share track without joining or publishing. Pair it with `room.publish(track)`. */
|
|
2257
|
+
async createScreenShareTrack() {
|
|
2258
|
+
return createScreenShareTrack();
|
|
2259
|
+
}
|
|
2260
|
+
/** Lists available devices. Labels only fill in once permission has been granted at least once. */
|
|
2261
|
+
async getDevices(kind) {
|
|
2262
|
+
return listDevices(kind);
|
|
2263
|
+
}
|
|
2264
|
+
/**
|
|
2265
|
+
* Subscribes to devices coming and going (Phase 11 addition), like a USB
|
|
2266
|
+
* webcam being plugged in or yanked out. Returns an unsubscribe function.
|
|
2267
|
+
*
|
|
2268
|
+
* In an environment with no `navigator.mediaDevices` this is a no-op with
|
|
2269
|
+
* an immediately-callable unsubscribe, rather than a throw. It's an
|
|
2270
|
+
* optional convenience, not a capability anything depends on.
|
|
2271
|
+
*/
|
|
2272
|
+
onDeviceChange(callback) {
|
|
2273
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices) {
|
|
2274
|
+
return () => {
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
navigator.mediaDevices.addEventListener("devicechange", callback);
|
|
2278
|
+
return () => navigator.mediaDevices.removeEventListener("devicechange", callback);
|
|
2279
|
+
}
|
|
2280
|
+
/** Switches the active camera on the currently joined room. */
|
|
2281
|
+
async setCamera(deviceId) {
|
|
2282
|
+
if (!this.currentRoom) {
|
|
2283
|
+
throw new RTCError("CONNECTION_FAILED", "setCamera() requires an active room; call join() first");
|
|
2284
|
+
}
|
|
2285
|
+
await this.currentRoom.setCameraDevice(deviceId);
|
|
2286
|
+
}
|
|
2287
|
+
/** Switches the active microphone on the currently joined room. */
|
|
2288
|
+
async setMicrophone(deviceId) {
|
|
2289
|
+
if (!this.currentRoom) {
|
|
2290
|
+
throw new RTCError("CONNECTION_FAILED", "setMicrophone() requires an active room; call join() first");
|
|
2291
|
+
}
|
|
2292
|
+
await this.currentRoom.setMicrophoneDevice(deviceId);
|
|
2293
|
+
}
|
|
2294
|
+
/** Diagnostic snapshot of the currently joined room. See `Room.getDiagnostics()`. */
|
|
2295
|
+
getDiagnostics() {
|
|
2296
|
+
if (!this.currentRoom) {
|
|
2297
|
+
throw new RTCError("CONNECTION_FAILED", "getDiagnostics() requires an active room; call join() first");
|
|
2298
|
+
}
|
|
2299
|
+
return this.currentRoom.getDiagnostics();
|
|
2300
|
+
}
|
|
2301
|
+
};
|
|
2302
|
+
function createRTCClient(config) {
|
|
2303
|
+
const resolved = validateConfig(config);
|
|
2304
|
+
return new RTCClient(resolved);
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
// src/browser-support.ts
|
|
2308
|
+
function getBrowserSupportDetails() {
|
|
2309
|
+
const missing = [];
|
|
2310
|
+
if (typeof RTCPeerConnection === "undefined") missing.push("RTCPeerConnection");
|
|
2311
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) missing.push("navigator.mediaDevices.getUserMedia");
|
|
2312
|
+
if (typeof WebSocket === "undefined") missing.push("WebSocket");
|
|
2313
|
+
return { supported: missing.length === 0, missing };
|
|
2314
|
+
}
|
|
2315
|
+
function isBrowserSupported() {
|
|
2316
|
+
return getBrowserSupportDetails().supported;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
exports.LocalParticipant = LocalParticipant;
|
|
2320
|
+
exports.LocalTrack = LocalTrack;
|
|
2321
|
+
exports.Participant = Participant;
|
|
2322
|
+
exports.RTCClient = RTCClient;
|
|
2323
|
+
exports.RTCError = RTCError;
|
|
2324
|
+
exports.RemoteParticipant = RemoteParticipant;
|
|
2325
|
+
exports.RemoteTrack = RemoteTrack;
|
|
2326
|
+
exports.Room = Room;
|
|
2327
|
+
exports.Track = Track;
|
|
2328
|
+
exports.createRTCClient = createRTCClient;
|
|
2329
|
+
exports.getBrowserSupportDetails = getBrowserSupportDetails;
|
|
2330
|
+
exports.isBrowserSupported = isBrowserSupported;
|
|
2331
|
+
exports.isRTCError = isRTCError;
|
|
2332
|
+
//# sourceMappingURL=index.cjs.map
|
|
2333
|
+
//# sourceMappingURL=index.cjs.map
|