@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/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