@voltkit-rtc/client 0.2.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/src/index.ts ADDED
@@ -0,0 +1,945 @@
1
+ /**
2
+ * Thin client SDK over the orchestrator's signaling protocol.
3
+ *
4
+ * The point of shipping this is that consuming projects never touch
5
+ * mediasoup-client directly -- otherwise every project re-implements transport
6
+ * setup, consumer resume ordering and reconnection, each slightly wrong.
7
+ */
8
+ import { Device, type types as ms } from 'mediasoup-client';
9
+
10
+ // From the package root rather than 'mediasoup-client/types', so the
11
+ // published declarations resolve under every TypeScript module resolution,
12
+ // including the older "node" one that ignores package exports.
13
+ type Consumer = ms.Consumer;
14
+ type Producer = ms.Producer;
15
+ type RtpCapabilities = ms.RtpCapabilities;
16
+ type Transport = ms.Transport;
17
+ type TransportOptions = ms.TransportOptions;
18
+
19
+ import type { ChatEvents, ChatMessage, ChatState, SendMessageOptions } from './chat.js';
20
+ import { MusicSource, type MusicInput, type MusicOptions, type TrackSource } from './music.js';
21
+
22
+ export { BroadcastPublisher, WhipError, whipEnd, whipOffer } from './broadcast.js';
23
+ export { AudioMixer, MusicSource } from './music.js';
24
+ export type { MusicInput, MusicOptions, TrackSource } from './music.js';
25
+ export type { BroadcastEvents, BroadcastStartOptions, BroadcastState } from './broadcast.js';
26
+ export { ChatClient, ChatRpcError } from './chat.js';
27
+ export type {
28
+ ChatClientEvents,
29
+ ChatEvents,
30
+ ChatJoinResult,
31
+ ChatMessage,
32
+ ChatState,
33
+ SendMessageOptions,
34
+ } from './chat.js';
35
+
36
+ export type Role = 'host' | 'cohost' | 'moderator' | 'viewer';
37
+
38
+ /** Sent when the account behind a session can no longer be used. */
39
+ export interface UsageLimitNotice {
40
+ reason: 'quota-exhausted' | 'daily-limit-reached' | 'monthly-limit-reached' | 'suspended' | string;
41
+ graceSeconds: number;
42
+ /** When the session will be ended, in ms since the epoch. */
43
+ endsAt: number;
44
+ }
45
+
46
+ export interface JoinResult {
47
+ roomId: string;
48
+ identity: string;
49
+ role: Role;
50
+ nodeId: string;
51
+ isOrigin: boolean;
52
+ routerRtpCapabilities: RtpCapabilities;
53
+ participants: ParticipantInfo[];
54
+ producers: RemoteProducer[];
55
+ iceServers: RTCIceServer[];
56
+ /** Room metadata set by your backend or a moderator's setRoomMetadata. */
57
+ metadata?: unknown;
58
+ /** Exclusive slots (seats) and who holds them, to seed a seat board. */
59
+ claims?: SlotClaim[];
60
+ /** Slots a moderator has locked. */
61
+ blockedSlots?: string[];
62
+ }
63
+
64
+ export interface SlotClaim {
65
+ key: string;
66
+ identity: string;
67
+ /** `away`: the holder's connection dropped and the seat is kept for them. */
68
+ state: 'held' | 'away';
69
+ /** When an `away` claim expires, in unix milliseconds. */
70
+ awayUntil?: number;
71
+ }
72
+
73
+ export interface ParticipantInfo {
74
+ identity: string;
75
+ role: Role;
76
+ handRaised: boolean;
77
+ muted: boolean;
78
+ metadata?: unknown;
79
+ }
80
+
81
+ export interface RemoteProducer {
82
+ producerId: string;
83
+ identity: string;
84
+ kind: 'audio' | 'video';
85
+ paused: boolean;
86
+ /** What the track carries. A host playing music publishes two audio tracks: `mic` and `music`. */
87
+ source?: TrackSource;
88
+ }
89
+
90
+ const defaultSource = (kind: 'audio' | 'video'): TrackSource => (kind === 'audio' ? 'mic' : 'camera');
91
+
92
+ export interface ClientEvents extends ChatEvents {
93
+ connected: () => void;
94
+ disconnected: (reason: string) => void;
95
+ joined: (result: JoinResult) => void;
96
+ track: (info: {
97
+ identity: string;
98
+ kind: 'audio' | 'video';
99
+ track: MediaStreamTrack;
100
+ producerId: string;
101
+ /** `music` is a host's in-app music. Play it through its own element to give viewers a music switch. */
102
+ source: TrackSource;
103
+ }) => void;
104
+ trackEnded: (info: { producerId: string; identity: string }) => void;
105
+ /** Someone paused or resumed one of their tracks: muted their mic, turned their music off. */
106
+ trackMuted: (info: { identity: string; kind: 'audio' | 'video'; source: TrackSource; producerId: string; paused: boolean }) => void;
107
+ participantJoined: (p: ParticipantInfo) => void;
108
+ participantLeft: (p: { identity: string; reason?: string }) => void;
109
+ roleChanged: (e: { identity: string; role: Role }) => void;
110
+ handRaised: (e: { identity: string; handRaised: boolean }) => void;
111
+ /**
112
+ * A moderator switched your microphone (`audio`), camera (`video`) or both
113
+ * (no kind) off, or released it (`paused: false`). While paused you cannot
114
+ * switch it back on.
115
+ */
116
+ forceMuted: (e: { by: string; kind?: 'audio' | 'video' | ''; paused: boolean }) => void;
117
+ mustReconnect: (e: { reason: string; token?: string; retry: boolean }) => void;
118
+ roomClosed: (e: { reason: string }) => void;
119
+ kicked: (e: { by: string; reason?: string }) => void;
120
+ error: (err: Error) => void;
121
+ localMediaChanged: (state: { micEnabled: boolean; camEnabled: boolean; musicEnabled: boolean }) => void;
122
+ /** The local stream was replaced, typically after reacquiring devices on a reconnect. */
123
+ localStreamChanged: (stream: MediaStream | null) => void;
124
+ /** Media stopped arriving but the connection is intact -- the publisher is reconnecting. */
125
+ streamInterrupted: (e: { reason: string }) => void;
126
+ /** The session ended and will not recover. Nothing further will be emitted. */
127
+ closed: (reason: 'replaced' | 'kicked' | 'room-closed' | 'gave-up' | 'left') => void;
128
+ /**
129
+ * The account behind this session ran out of minutes or reached a usage
130
+ * limit. The session ends at `endsAt` (after `graceSeconds`) unless the
131
+ * account is topped up first -- tell the user, and stop asking them to wait.
132
+ */
133
+ usageLimit: (e: UsageLimitNotice) => void;
134
+ }
135
+
136
+ type Handler = (...args: never[]) => void;
137
+
138
+ /** An error returned by the server, carrying its machine-readable code. */
139
+ export class RpcError extends Error {
140
+ constructor(readonly code: string, message: string) {
141
+ super(`${code}: ${message}`);
142
+ this.name = 'RpcError';
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Failures that reconnecting cannot fix. A room that has ended is not coming
148
+ * back, and a token that is wrong will be wrong next time too -- retrying
149
+ * these just floods the log and burns the attempt budget that a genuine
150
+ * network blip needs.
151
+ */
152
+ const TERMINAL_JOIN_ERRORS = new Set([
153
+ 'no-room',
154
+ 'room-ended',
155
+ 'wrong-tenant',
156
+ 'bad-token',
157
+ 'no-token',
158
+ 'forbidden',
159
+ // The account cannot use the service until it is topped up or restored.
160
+ 'quota-exhausted',
161
+ 'daily-limit-reached',
162
+ 'monthly-limit-reached',
163
+ 'tenant-suspended',
164
+ ]);
165
+
166
+ interface Pending {
167
+ resolve: (v: unknown) => void;
168
+ reject: (e: Error) => void;
169
+ timer: ReturnType<typeof setTimeout>;
170
+ }
171
+
172
+ const RPC_TIMEOUT_MS = 15_000;
173
+ const MAX_RECONNECT_ATTEMPTS = 8;
174
+ /** Server close code meaning "another socket took over this identity". */
175
+ const CLOSE_REPLACED = 4001;
176
+ /** The server ended the session on purpose and said why first: do not reconnect. */
177
+ const CLOSE_ENDED = 4003;
178
+
179
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
180
+
181
+ export class StreamClient {
182
+ private ws: WebSocket | null = null;
183
+ private device: Device | null = null;
184
+ private sendTransport: Transport | null = null;
185
+ private recvTransport: Transport | null = null;
186
+ // In-flight creations. Two `newProducer` events arriving back to back (a peer
187
+ // publishing audio and video) both call ensureRecvTransport before either
188
+ // await resolves, so a plain null check creates two transports -- and the
189
+ // server only remembers the last one, rejecting consumes on the first.
190
+ private sendTransportPending: Promise<Transport> | null = null;
191
+ private recvTransportPending: Promise<Transport> | null = null;
192
+ private readonly pending = new Map<number, Pending>();
193
+ private readonly listeners = new Map<string, Set<Handler>>();
194
+ private readonly consumers = new Map<string, Consumer>();
195
+ private readonly producers = new Map<string, Producer>();
196
+ private nextId = 1;
197
+ private token: string;
198
+ private joinResult: JoinResult | null = null;
199
+ private localStream: MediaStream | null = null;
200
+ // A reconnect tears down every transport and producer. Without remembering
201
+ // that this client was publishing -- and what it was publishing with -- a
202
+ // broadcaster that reconnects rejoins silently, which is worse than being
203
+ // disconnected: the room looks healthy and shows nothing.
204
+ private wasPublishing = false;
205
+ private publishConstraints: MediaStreamConstraints | null = null;
206
+ /** In-app music being published, kept across reconnects so it can be published again. */
207
+ private music: MusicSource | null = null;
208
+ private musicProducerId: string | null = null;
209
+ /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
210
+ private readonly remoteSources = new Map<string, TrackSource>();
211
+ private closedByUs = false;
212
+ private reconnecting = false;
213
+ private reconnectAttempt = 0;
214
+
215
+ constructor(private readonly wsUrl: string, token: string) {
216
+ this.token = token;
217
+ }
218
+
219
+ on<K extends keyof ClientEvents>(event: K, fn: ClientEvents[K]): () => void {
220
+ let set = this.listeners.get(event);
221
+ if (!set) {
222
+ set = new Set();
223
+ this.listeners.set(event, set);
224
+ }
225
+ set.add(fn as Handler);
226
+ return () => set!.delete(fn as Handler);
227
+ }
228
+
229
+ private emit<K extends keyof ClientEvents>(event: K, ...args: Parameters<ClientEvents[K]>): void {
230
+ for (const fn of this.listeners.get(event) ?? []) {
231
+ try {
232
+ (fn as (...a: unknown[]) => void)(...args);
233
+ } catch (err) {
234
+ console.error(`listener for ${event} threw`, err);
235
+ }
236
+ }
237
+ }
238
+
239
+ // ------------------------------------------------------------- connection
240
+
241
+ async connect(): Promise<JoinResult> {
242
+ await this.openSocket();
243
+ const result = (await this.rpc('join', { token: this.token })) as JoinResult;
244
+ this.joinResult = result;
245
+
246
+ this.device = new Device();
247
+ await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
248
+
249
+ this.emit('joined', result);
250
+
251
+ // Subscribe to everything already publishing before we announce readiness.
252
+ for (const p of result.producers) {
253
+ await this.consume(p).catch((err) => this.emit('error', err as Error));
254
+ }
255
+
256
+ // Restore our own broadcast. This is what makes an origin failover
257
+ // survivable for the host: it reconnects onto the new origin and the
258
+ // stream comes back on its own.
259
+ if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
260
+ await this.republish().catch((err) => this.emit('error', err as Error));
261
+ }
262
+ return result;
263
+ }
264
+
265
+ private openSocket(): Promise<void> {
266
+ // Abandon any previous socket first. Its close handler checks identity
267
+ // against `this.ws`, so once it is detached it can no longer trigger a
268
+ // reconnect -- which is what let multiple reconnect chains pile up.
269
+ this.detachSocket();
270
+
271
+ return new Promise((resolve, reject) => {
272
+ const url = new URL(this.wsUrl);
273
+ url.searchParams.set('token', this.token);
274
+ const ws = new WebSocket(url.toString());
275
+ this.ws = ws;
276
+
277
+ const onOpen = () => {
278
+ this.reconnectAttempt = 0;
279
+ this.emit('connected');
280
+ resolve();
281
+ };
282
+ const onError = () => reject(new Error('websocket failed to open'));
283
+
284
+ ws.addEventListener('open', onOpen, { once: true });
285
+ ws.addEventListener('error', onError, { once: true });
286
+ ws.addEventListener('message', (ev) => {
287
+ if (this.ws === ws) this.onMessage(ev.data as string);
288
+ });
289
+ ws.addEventListener('close', (ev) => {
290
+ ws.removeEventListener('error', onError);
291
+ // Only the socket we currently consider ours may drive reconnection.
292
+ if (this.ws !== ws) return;
293
+ this.ws = null;
294
+ this.failAllPending(new Error(`socket closed (${ev.code})`));
295
+ this.emit('disconnected', ev.reason || String(ev.code));
296
+
297
+ if (this.closedByUs) return;
298
+ if (ev.code === CLOSE_REPLACED) {
299
+ // Another session for this identity is live. Reconnecting would
300
+ // displace it, and it would displace us right back, forever.
301
+ this.emit('error', new Error('this identity connected from somewhere else'));
302
+ this.closedByUs = true;
303
+ this.emit('closed', 'replaced');
304
+ return;
305
+ }
306
+ if (ev.code === CLOSE_ENDED) {
307
+ // Normally roomClosed has already ended the session; this covers a
308
+ // close that arrived without it.
309
+ this.closedByUs = true;
310
+ this.emit('closed', 'room-closed');
311
+ return;
312
+ }
313
+ void this.scheduleReconnect();
314
+ });
315
+ });
316
+ }
317
+
318
+ private detachSocket(): void {
319
+ const ws = this.ws;
320
+ this.ws = null;
321
+ if (ws && ws.readyState <= WebSocket.OPEN) {
322
+ try {
323
+ ws.close(1000, 'superseded');
324
+ } catch {
325
+ /* already closing */
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Reconnection is mandatory, not optional: nodes get drained, relays die and
332
+ * promotions move you to a different node. Always re-ask the orchestrator
333
+ * where to connect -- never assume the same node.
334
+ */
335
+ private async scheduleReconnect(): Promise<void> {
336
+ // Single-flight. The previous version recursed from its own catch block
337
+ // while the failed socket's close handler started a second chain, so each
338
+ // failure doubled the number of chains until the server was being hammered.
339
+ if (this.reconnecting || this.closedByUs) return;
340
+ this.reconnecting = true;
341
+ try {
342
+ while (!this.closedByUs) {
343
+ this.reconnectAttempt += 1;
344
+ if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
345
+ this.emit('error', new Error(`gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
346
+ this.closedByUs = true;
347
+ this.emit('closed', 'gave-up');
348
+ return;
349
+ }
350
+ const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
351
+ await sleep(backoff + Math.random() * 400);
352
+ if (this.closedByUs) return;
353
+ try {
354
+ this.teardownMedia();
355
+ await this.connect();
356
+ return; // connect() resets the attempt counter once the socket opens
357
+ } catch (err) {
358
+ this.emit('error', err as Error);
359
+ // Stop on failures a retry cannot resolve. Hammering a room that has
360
+ // ended produces one error per attempt forever and drowns out
361
+ // whatever actually went wrong.
362
+ if (err instanceof RpcError && TERMINAL_JOIN_ERRORS.has(err.code)) {
363
+ this.closedByUs = true;
364
+ this.emit('closed', 'gave-up');
365
+ return;
366
+ }
367
+ }
368
+ }
369
+ } finally {
370
+ this.reconnecting = false;
371
+ }
372
+ }
373
+
374
+ close(): void {
375
+ // Only an app closing a live session is 'left'; kicked, room-closed and the
376
+ // others have already reported themselves and set closedByUs.
377
+ const wasOpen = !this.closedByUs;
378
+ this.closedByUs = true;
379
+ this.wasPublishing = false;
380
+ this.teardownMedia();
381
+ // Producers no longer stop their tracks, so releasing the camera and
382
+ // microphone is ours to do -- otherwise the device light stays on.
383
+ this.localStream?.getTracks().forEach((t) => t.stop());
384
+ this.localStream = null;
385
+ const ws = this.ws;
386
+ this.ws = null;
387
+ ws?.close(1000, 'client closed');
388
+ if (wasOpen) this.emit('closed', 'left');
389
+ }
390
+
391
+ private teardownMedia(): void {
392
+ for (const c of this.consumers.values()) c.close();
393
+ this.consumers.clear();
394
+ for (const p of this.producers.values()) p.close();
395
+ this.producers.clear();
396
+ this.musicProducerId = null;
397
+ this.sendTransport?.close();
398
+ this.recvTransport?.close();
399
+ this.sendTransport = null;
400
+ this.recvTransport = null;
401
+ this.sendTransportPending = null;
402
+ this.recvTransportPending = null;
403
+ this.device = null;
404
+ }
405
+
406
+ /** Everything currently known about the room, for rendering. */
407
+ get info(): JoinResult | null {
408
+ return this.joinResult;
409
+ }
410
+
411
+ // -------------------------------------------------------------------- rpc
412
+
413
+ private rpc(method: string, params: unknown = {}): Promise<unknown> {
414
+ return new Promise((resolve, reject) => {
415
+ if (this.ws?.readyState !== WebSocket.OPEN) {
416
+ reject(new Error('socket is not open'));
417
+ return;
418
+ }
419
+ const id = this.nextId++;
420
+ const timer = setTimeout(() => {
421
+ this.pending.delete(id);
422
+ reject(new Error(`${method} timed out`));
423
+ }, RPC_TIMEOUT_MS);
424
+ this.pending.set(id, { resolve, reject, timer });
425
+ this.ws.send(JSON.stringify({ id, method, params }));
426
+ });
427
+ }
428
+
429
+ private failAllPending(err: Error): void {
430
+ for (const p of this.pending.values()) {
431
+ clearTimeout(p.timer);
432
+ p.reject(err);
433
+ }
434
+ this.pending.clear();
435
+ }
436
+
437
+ private onMessage(raw: string): void {
438
+ let msg: Record<string, unknown>;
439
+ try {
440
+ msg = JSON.parse(raw);
441
+ } catch {
442
+ return;
443
+ }
444
+
445
+ if (typeof msg.id === 'number') {
446
+ const p = this.pending.get(msg.id);
447
+ if (!p) return;
448
+ clearTimeout(p.timer);
449
+ this.pending.delete(msg.id);
450
+ if (msg.ok) p.resolve(msg.result);
451
+ else {
452
+ const e = msg.error as { code?: string; message?: string } | undefined;
453
+ p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
454
+ }
455
+ return;
456
+ }
457
+
458
+ if (typeof msg.event === 'string') void this.onEvent(msg.event, msg.data);
459
+ }
460
+
461
+ private async onEvent(event: string, data: unknown): Promise<void> {
462
+ const d = (data ?? {}) as Record<string, never>;
463
+ switch (event) {
464
+ case 'newProducer':
465
+ await this.consume(data as RemoteProducer).catch((err) => this.emit('error', err as Error));
466
+ break;
467
+ case 'producerClosed': {
468
+ const { producerId, identity } = data as { producerId: string; identity: string };
469
+ const consumer = this.consumers.get(producerId);
470
+ if (!consumer) break; // never consumed it, or already cleaned up
471
+ consumer.close();
472
+ this.consumers.delete(producerId);
473
+ this.remoteSources.delete(producerId);
474
+ this.emit('trackEnded', { producerId, identity });
475
+ break;
476
+ }
477
+ case 'producerPaused': {
478
+ const p = data as { producerId: string; identity: string; kind: 'audio' | 'video'; paused: boolean };
479
+ this.emit('trackMuted', {
480
+ identity: p.identity,
481
+ kind: p.kind,
482
+ source: this.remoteSources.get(p.producerId) ?? defaultSource(p.kind),
483
+ producerId: p.producerId,
484
+ paused: p.paused,
485
+ });
486
+ break;
487
+ }
488
+ case 'participantJoined':
489
+ this.emit('participantJoined', data as ParticipantInfo);
490
+ break;
491
+ case 'participantLeft':
492
+ this.emit('participantLeft', data as { identity: string; reason?: string });
493
+ break;
494
+ case 'roleChanged': {
495
+ const e = data as { identity: string; role: Role };
496
+ this.emit('roleChanged', e);
497
+ // Demoted: the server has already closed our producers. Release the
498
+ // camera and microphone too, instead of capturing for nobody.
499
+ const canPublish = e.role === 'host' || e.role === 'cohost';
500
+ if (e.identity === this.joinResult?.identity && !canPublish && this.producers.size > 0) {
501
+ void this.unpublish().catch(() => undefined);
502
+ }
503
+ break;
504
+ }
505
+ case 'handRaised':
506
+ case 'handLowered':
507
+ this.emit('handRaised', data as { identity: string; handRaised: boolean });
508
+ break;
509
+ case 'forceMuted': {
510
+ // The server has already paused that kind (an audio mute includes
511
+ // in-app music: it travels as audio). Reflect it locally; a release
512
+ // changes nothing here -- switching back on is the user's choice.
513
+ const e = data as { by: string; kind?: 'audio' | 'video' | ''; paused: boolean };
514
+ if (e.paused) {
515
+ for (const p of this.producers.values()) {
516
+ if (!e.kind || p.kind === e.kind) p.pause();
517
+ }
518
+ this.emitLocalMedia();
519
+ }
520
+ this.emit('forceMuted', e);
521
+ break;
522
+ }
523
+ case 'tokenRefreshed': {
524
+ // A room outlives its join token many times over. The server hands us a
525
+ // fresh one periodically so a reconnect at any point still works --
526
+ // without this, any disconnect after the token's lifetime is terminal.
527
+ const e = data as { token?: string; role?: Role };
528
+ if (e.token) this.token = e.token;
529
+ break;
530
+ }
531
+ case 'mustReconnect': {
532
+ const e = data as { reason: string; token?: string; retry: boolean };
533
+ // A promotion hands us a fresh token carrying the new role.
534
+ if (e.token) this.token = e.token;
535
+ this.emit('mustReconnect', e);
536
+ if (e.retry) {
537
+ this.teardownMedia();
538
+ this.ws?.close(1000, 'reassigned');
539
+ }
540
+ break;
541
+ }
542
+ case 'streamInterrupted':
543
+ // Our node survived a failover; the origin did not. Stay connected --
544
+ // pictures come back when the host republishes and the pipe rebuilds.
545
+ this.emit('streamInterrupted', d as never);
546
+ break;
547
+ case 'usageLimit':
548
+ this.emit('usageLimit', d as never);
549
+ break;
550
+ case 'roomClosed':
551
+ this.closedByUs = true;
552
+ this.emit('roomClosed', d as never);
553
+ this.emit('closed', 'room-closed');
554
+ this.close();
555
+ break;
556
+ case 'kicked':
557
+ this.closedByUs = true;
558
+ this.emit('kicked', d as never);
559
+ this.emit('closed', 'kicked');
560
+ this.close();
561
+ break;
562
+ case 'message':
563
+ this.emit('message', data as ChatMessage);
564
+ break;
565
+ case 'messageDeleted':
566
+ this.emit('messageDeleted', data as { id: string; by: string });
567
+ break;
568
+ case 'chatMuted':
569
+ this.emit('chatMuted', data as { identity: string; muted: boolean; by: string });
570
+ break;
571
+ case 'typing':
572
+ this.emit('typing', data as { identity: string; typing: boolean });
573
+ break;
574
+ default:
575
+ break;
576
+ }
577
+ }
578
+
579
+ // ------------------------------------------------------------------ media
580
+
581
+ private ensureSendTransport(): Promise<Transport> {
582
+ if (this.sendTransport) return Promise.resolve(this.sendTransport);
583
+ if (this.sendTransportPending) return this.sendTransportPending;
584
+ this.sendTransportPending = this.createSendTransport().finally(() => {
585
+ this.sendTransportPending = null;
586
+ });
587
+ return this.sendTransportPending;
588
+ }
589
+
590
+ private async createSendTransport(): Promise<Transport> {
591
+ if (!this.device) throw new Error('not connected');
592
+
593
+ const params = (await this.rpc('createTransport', { direction: 'send' })) as TransportOptions;
594
+ const transport = this.device.createSendTransport({
595
+ ...params,
596
+ iceServers: this.joinResult?.iceServers,
597
+ });
598
+
599
+ transport.on('connect', ({ dtlsParameters }, ok, fail) => {
600
+ this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
601
+ .then(() => ok())
602
+ .catch(fail);
603
+ });
604
+ transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
605
+ this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source })
606
+ .then((res) => ok({ id: (res as { producerId: string }).producerId }))
607
+ .catch(fail);
608
+ });
609
+
610
+ this.sendTransport = transport;
611
+ return transport;
612
+ }
613
+
614
+ private ensureRecvTransport(): Promise<Transport> {
615
+ if (this.recvTransport) return Promise.resolve(this.recvTransport);
616
+ if (this.recvTransportPending) return this.recvTransportPending;
617
+ this.recvTransportPending = this.createRecvTransport().finally(() => {
618
+ this.recvTransportPending = null;
619
+ });
620
+ return this.recvTransportPending;
621
+ }
622
+
623
+ private async createRecvTransport(): Promise<Transport> {
624
+ if (!this.device) throw new Error('not connected');
625
+
626
+ const params = (await this.rpc('createTransport', { direction: 'recv' })) as TransportOptions;
627
+ const transport = this.device.createRecvTransport({
628
+ ...params,
629
+ iceServers: this.joinResult?.iceServers,
630
+ });
631
+ transport.on('connect', ({ dtlsParameters }, ok, fail) => {
632
+ this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
633
+ .then(() => ok())
634
+ .catch(fail);
635
+ });
636
+
637
+ this.recvTransport = transport;
638
+ return transport;
639
+ }
640
+
641
+ /**
642
+ * Publish local camera + mic. Only host/cohost may; others get a server error.
643
+ *
644
+ * Pass the constraints used to obtain the stream and they will be reused to
645
+ * reacquire the devices if a reconnect finds the tracks have ended.
646
+ */
647
+ async publish(stream: MediaStream, constraints?: MediaStreamConstraints): Promise<void> {
648
+ const transport = await this.ensureSendTransport();
649
+ this.localStream = stream;
650
+ this.wasPublishing = true;
651
+ if (constraints) this.publishConstraints = constraints;
652
+
653
+ const audio = stream.getAudioTracks()[0];
654
+ if (audio) {
655
+ const producer = await transport.produce({
656
+ track: audio,
657
+ // Keep the track alive when the producer closes: a reconnect closes
658
+ // every producer, and a stopped track cannot be republished.
659
+ stopTracks: false,
660
+ codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
661
+ appData: { source: 'mic' },
662
+ });
663
+ this.producers.set(producer.id, producer);
664
+ }
665
+
666
+ const video = stream.getVideoTracks()[0];
667
+ if (video) {
668
+ // Simulcast from day one: retrofitting it later is genuinely annoying,
669
+ // and it is what lets one node serve far more viewers.
670
+ const producer = await transport.produce({
671
+ track: video,
672
+ stopTracks: false,
673
+ encodings: [
674
+ { rid: 'q', scaleResolutionDownBy: 4, maxBitrate: 200_000, scalabilityMode: 'L1T3' },
675
+ { rid: 'h', scaleResolutionDownBy: 2, maxBitrate: 600_000, scalabilityMode: 'L1T3' },
676
+ { rid: 'f', scaleResolutionDownBy: 1, maxBitrate: 1_800_000, scalabilityMode: 'L1T3' },
677
+ ],
678
+ codecOptions: { videoGoogleStartBitrate: 1000 },
679
+ appData: { source: 'camera' },
680
+ });
681
+ this.producers.set(producer.id, producer);
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Re-establish this client's broadcast after a reconnect.
687
+ *
688
+ * Reuses the existing tracks when they are still live -- the common case, and
689
+ * it avoids a second camera permission prompt. Only reacquires the devices
690
+ * when the tracks have genuinely ended.
691
+ */
692
+ private async republish(): Promise<void> {
693
+ // Music is not a device and cannot be "gone": publish it again whatever
694
+ // happens to the camera and microphone below.
695
+ if (this.music) await this.produceMusic(this.music).catch((err) => this.emit('error', err as Error));
696
+
697
+ let stream = this.localStream;
698
+ if (!stream && !this.publishConstraints) return; // music was all that was being published
699
+ const live = stream?.getTracks().some((t) => t.readyState === 'live') ?? false;
700
+
701
+ if (!live) {
702
+ if (!this.publishConstraints) {
703
+ this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
704
+ return;
705
+ }
706
+ stream = await navigator.mediaDevices.getUserMedia(this.publishConstraints);
707
+ this.localStream = stream;
708
+ this.emit('localStreamChanged', stream);
709
+ }
710
+ if (!stream) return;
711
+
712
+ await this.publish(stream);
713
+ }
714
+
715
+ // ------------------------------------------------------------------ music
716
+
717
+ /**
718
+ * Publish music playing inside the page, as its own track.
719
+ *
720
+ * The audience gets it clean -- it never passes through the microphone --
721
+ * and it has its own switch: `setMusicEnabled(false)` silences the music and
722
+ * leaves the host talking, `setMicEnabled(false)` does the opposite.
723
+ *
724
+ * Call it from a click. Browsers will not start audio processing for a page
725
+ * the user has not interacted with.
726
+ */
727
+ async publishMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
728
+ if (this.music) await this.stopMusic();
729
+ const music = new MusicSource(input, options);
730
+ try {
731
+ await this.produceMusic(music);
732
+ } catch (err) {
733
+ music.close();
734
+ throw err;
735
+ }
736
+ this.music = music;
737
+ this.wasPublishing = true;
738
+ this.emitLocalMedia();
739
+ }
740
+
741
+ private async produceMusic(music: MusicSource): Promise<void> {
742
+ const transport = await this.ensureSendTransport();
743
+ const producer = await transport.produce({
744
+ track: music.track,
745
+ stopTracks: false,
746
+ // Music, not speech. Stereo, no silence suppression (it would chop the
747
+ // quiet parts of a song), and enough bitrate that it sounds like music.
748
+ codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
749
+ appData: { source: 'music' },
750
+ });
751
+ this.producers.set(producer.id, producer);
752
+ this.musicProducerId = producer.id;
753
+ }
754
+
755
+ /** True while music is published, whether or not it is switched on. */
756
+ get musicPublishing(): boolean {
757
+ return this.music !== null;
758
+ }
759
+
760
+ get musicEnabled(): boolean {
761
+ const producer = this.musicProducerId ? this.producers.get(this.musicProducerId) : undefined;
762
+ return producer ? !producer.paused : false;
763
+ }
764
+
765
+ /** 0 to 1, for the audience. The host's own listening level is the element's. */
766
+ setMusicVolume(volume: number): void {
767
+ this.music?.setVolume(volume);
768
+ }
769
+
770
+ /** Switch the music off or on for the audience. The microphone is untouched. */
771
+ async setMusicEnabled(enabled: boolean): Promise<void> {
772
+ const producer = this.musicProducerId ? this.producers.get(this.musicProducerId) : undefined;
773
+ if (!producer) return;
774
+ if (enabled) producer.resume();
775
+ else producer.pause();
776
+ await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
777
+ this.emitLocalMedia();
778
+ }
779
+
780
+ /** Stop publishing music. The element or stream it came from is left as it was. */
781
+ async stopMusic(): Promise<void> {
782
+ const id = this.musicProducerId;
783
+ this.musicProducerId = null;
784
+ this.music?.close();
785
+ this.music = null;
786
+ if (id) {
787
+ this.producers.get(id)?.close();
788
+ this.producers.delete(id);
789
+ await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
790
+ }
791
+ this.emitLocalMedia();
792
+ }
793
+
794
+ private emitLocalMedia(): void {
795
+ this.emit('localMediaChanged', {
796
+ micEnabled: this.micEnabled,
797
+ camEnabled: this.camEnabled,
798
+ musicEnabled: this.musicEnabled,
799
+ });
800
+ }
801
+
802
+ private async consume(remote: RemoteProducer): Promise<void> {
803
+ if (!this.device) throw new Error('not connected');
804
+ if (this.consumers.has(remote.producerId)) return;
805
+ // Your own camera is already local; pulling it back off the SFU wastes a
806
+ // consumer and shows you a delayed copy of yourself.
807
+ if (remote.identity === this.joinResult?.identity) return;
808
+
809
+ const transport = await this.ensureRecvTransport();
810
+ const params = (await this.rpc('consume', {
811
+ transportId: transport.id,
812
+ producerId: remote.producerId,
813
+ rtpCapabilities: this.device.rtpCapabilities,
814
+ })) as { id: string; producerId: string; kind: 'audio' | 'video'; rtpParameters: never };
815
+
816
+ const consumer = await transport.consume(params);
817
+ this.consumers.set(remote.producerId, consumer);
818
+
819
+ // The server created it paused. Resume only now that our end exists,
820
+ // otherwise the first frames arrive before there is anything to decode them.
821
+ await this.rpc('resumeConsumer', { consumerId: consumer.id });
822
+
823
+ const kind = consumer.kind as 'audio' | 'video';
824
+ const source = remote.source ?? defaultSource(kind);
825
+ this.remoteSources.set(remote.producerId, source);
826
+ this.emit('track', {
827
+ identity: remote.identity,
828
+ kind,
829
+ track: consumer.track,
830
+ producerId: remote.producerId,
831
+ source,
832
+ });
833
+ }
834
+
835
+ // ------------------------------------------------------------- moderation
836
+
837
+ // ------------------------------------------------------------ local media
838
+
839
+ /** True once this client has at least one live producer. */
840
+ get publishing(): boolean {
841
+ return this.producers.size > 0;
842
+ }
843
+
844
+ get micEnabled(): boolean {
845
+ return this.trackEnabled('audio');
846
+ }
847
+
848
+ get camEnabled(): boolean {
849
+ return this.trackEnabled('video');
850
+ }
851
+
852
+ private trackEnabled(kind: 'audio' | 'video'): boolean {
853
+ for (const p of this.producers.values()) {
854
+ if (p.kind === kind && p.id !== this.musicProducerId) return !p.paused;
855
+ }
856
+ return false;
857
+ }
858
+
859
+ /**
860
+ * Pausing the producer stops RTP at the source, so it costs nothing on the
861
+ * SFU and every viewer is told. Muting only the local track would keep
862
+ * sending silence and leave viewers showing you as unmuted.
863
+ */
864
+ async setMicEnabled(enabled: boolean): Promise<void> {
865
+ await this.setKindEnabled('audio', enabled);
866
+ }
867
+
868
+ async setCamEnabled(enabled: boolean): Promise<void> {
869
+ await this.setKindEnabled('video', enabled);
870
+ }
871
+
872
+ private async setKindEnabled(kind: 'audio' | 'video', enabled: boolean): Promise<void> {
873
+ for (const producer of this.producers.values()) {
874
+ // The music has its own switch. Muting the microphone must not stop the song.
875
+ if (producer.kind !== kind || producer.id === this.musicProducerId) continue;
876
+ if (enabled) producer.resume();
877
+ else producer.pause();
878
+ await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
879
+ }
880
+ this.emitLocalMedia();
881
+ }
882
+
883
+ /** Stop publishing entirely and release the local devices. */
884
+ async unpublish(): Promise<void> {
885
+ const ids = [...this.producers.keys()];
886
+ for (const producer of this.producers.values()) producer.close();
887
+ this.producers.clear();
888
+ for (const id of ids) {
889
+ await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
890
+ }
891
+ this.localStream?.getTracks().forEach((t) => t.stop());
892
+ this.localStream = null;
893
+ this.music?.close();
894
+ this.music = null;
895
+ this.musicProducerId = null;
896
+ this.wasPublishing = false;
897
+ this.emit('localStreamChanged', null);
898
+ this.emit('localMediaChanged', { micEnabled: false, camEnabled: false, musicEnabled: false });
899
+ }
900
+
901
+ get local(): MediaStream | null {
902
+ return this.localStream;
903
+ }
904
+
905
+ // ------------------------------------------------------------- moderation
906
+
907
+ raiseHand(): Promise<unknown> { return this.rpc('raiseHand'); }
908
+ lowerHand(): Promise<unknown> { return this.rpc('lowerHand'); }
909
+ muteParticipant(identity: string): Promise<unknown> { return this.rpc('requestMute', { identity }); }
910
+ /**
911
+ * Moderating roles: switch someone's microphone (`audio`), camera (`video`)
912
+ * or both (no kind) off at the server, where they cannot switch it back on.
913
+ * `paused: false` releases it; they then turn it on themselves.
914
+ */
915
+ forceMute(identity: string, kind?: 'audio' | 'video', paused = true): Promise<unknown> {
916
+ return this.rpc('forceMute', { identity, kind: kind ?? '', paused });
917
+ }
918
+ promote(identity: string, role: Role): Promise<unknown> { return this.rpc('promote', { identity, role }); }
919
+ kick(identity: string, reason?: string): Promise<unknown> { return this.rpc('kick', { identity, reason }); }
920
+ endRoom(): Promise<unknown> { return this.rpc('endRoom'); }
921
+ getRoomState(): Promise<unknown> { return this.rpc('getRoomState'); }
922
+
923
+ // ------------------------------------------------------------------- chat
924
+ //
925
+ // The room's chat rides this same socket. Someone on a chat-only socket
926
+ // (ChatClient) for the same channel is in the same conversation.
927
+
928
+ sendMessage(text: string, opts: SendMessageOptions = {}): Promise<{ id: string; ts: number }> {
929
+ return this.rpc('sendMessage', { text, ...opts }) as Promise<{ id: string; ts: number }>;
930
+ }
931
+
932
+ /** Older messages, oldest first. Pass the oldest id you hold as `before`. */
933
+ async getMessages(opts: { limit?: number; before?: string } = {}): Promise<ChatMessage[]> {
934
+ const res = (await this.rpc('getMessages', opts)) as { messages: ChatMessage[] };
935
+ return res.messages;
936
+ }
937
+
938
+ /** Your own message, or anyone's if you are a moderator. */
939
+ deleteMessage(messageId: string): Promise<unknown> { return this.rpc('deleteMessage', { messageId }); }
940
+ /** Moderators only. A mute outlasts the muted socket reconnecting. */
941
+ muteChat(identity: string, muted = true): Promise<unknown> { return this.rpc('muteChat', { identity, muted }); }
942
+ /** Advisory, and throttled by the server; safe to call on every keystroke. */
943
+ setTyping(typing: boolean): Promise<unknown> { return this.rpc('setTyping', { typing }); }
944
+ getChatState(): Promise<ChatState> { return this.rpc('getChatState') as Promise<ChatState>; }
945
+ }