@tribe-nest/media-client 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.
Files changed (65) hide show
  1. package/README.md +68 -0
  2. package/build/core/index.d.ts +17 -0
  3. package/build/core/index.d.ts.map +1 -0
  4. package/build/core/index.js +41 -0
  5. package/build/core/index.js.map +1 -0
  6. package/build/core/reconnect.d.ts +95 -0
  7. package/build/core/reconnect.d.ts.map +1 -0
  8. package/build/core/reconnect.js +160 -0
  9. package/build/core/reconnect.js.map +1 -0
  10. package/build/core/signal.d.ts +184 -0
  11. package/build/core/signal.d.ts.map +1 -0
  12. package/build/core/signal.js +416 -0
  13. package/build/core/signal.js.map +1 -0
  14. package/build/core/socket.d.ts +57 -0
  15. package/build/core/socket.d.ts.map +1 -0
  16. package/build/core/socket.js +37 -0
  17. package/build/core/socket.js.map +1 -0
  18. package/build/core/state.d.ts +67 -0
  19. package/build/core/state.d.ts.map +1 -0
  20. package/build/core/state.js +193 -0
  21. package/build/core/state.js.map +1 -0
  22. package/build/index.d.ts +29 -0
  23. package/build/index.d.ts.map +1 -0
  24. package/build/index.js +51 -0
  25. package/build/index.js.map +1 -0
  26. package/build/protocol.d.ts +10 -0
  27. package/build/protocol.d.ts.map +1 -0
  28. package/build/protocol.js +26 -0
  29. package/build/protocol.js.map +1 -0
  30. package/build/react/index.d.ts +147 -0
  31. package/build/react/index.d.ts.map +1 -0
  32. package/build/react/index.js +319 -0
  33. package/build/react/index.js.map +1 -0
  34. package/build/room/browserDevice.d.ts +3 -0
  35. package/build/room/browserDevice.d.ts.map +1 -0
  36. package/build/room/browserDevice.js +94 -0
  37. package/build/room/browserDevice.js.map +1 -0
  38. package/build/room/device.d.ts +114 -0
  39. package/build/room/device.d.ts.map +1 -0
  40. package/build/room/device.js +3 -0
  41. package/build/room/device.js.map +1 -0
  42. package/build/room/room.d.ts +219 -0
  43. package/build/room/room.d.ts.map +1 -0
  44. package/build/room/room.js +438 -0
  45. package/build/room/room.js.map +1 -0
  46. package/package.json +69 -0
  47. package/src/_tests/clientBoundary.spec.ts +110 -0
  48. package/src/core/_tests/coreBoundary.spec.ts +70 -0
  49. package/src/core/_tests/fakeSignalServer.ts +188 -0
  50. package/src/core/_tests/reconnect.spec.ts +180 -0
  51. package/src/core/_tests/signal.spec.ts +347 -0
  52. package/src/core/_tests/state.spec.ts +226 -0
  53. package/src/core/index.ts +63 -0
  54. package/src/core/reconnect.ts +233 -0
  55. package/src/core/signal.ts +527 -0
  56. package/src/core/socket.ts +58 -0
  57. package/src/core/state.ts +251 -0
  58. package/src/index.ts +54 -0
  59. package/src/protocol.ts +9 -0
  60. package/src/react/_tests/hooks.spec.tsx +509 -0
  61. package/src/react/index.tsx +439 -0
  62. package/src/room/_tests/room.spec.ts +595 -0
  63. package/src/room/browserDevice.ts +114 -0
  64. package/src/room/device.ts +119 -0
  65. package/src/room/room.ts +600 -0
@@ -0,0 +1,219 @@
1
+ import type { MediaGrants } from "@tribe-nest/media-protocol";
2
+ import { type DisconnectCause, type MediaCoreCredentials, type MediaWebSocketFactory, type ReconnectOptions, type RoomState, type SignalLogLevel } from "../core";
3
+ import type { MediaDeviceFactory, MediaProducerHandle } from "./device";
4
+ /**
5
+ * The room: a live call, as an application sees it.
6
+ *
7
+ * ## The ordering rules, which are the whole content of this file
8
+ *
9
+ * Four of them, each with a failure that is invisible until somebody is on a
10
+ * call:
11
+ *
12
+ * 1. **The device loads before anything else.** `consume` on the node needs our
13
+ * `rtpCapabilities`, and those do not exist until the device has been loaded
14
+ * with the router's. Getting this wrong produces a `consume` that is refused
15
+ * for a reason that reads like a codec problem.
16
+ * 2. **A transport is created lazily and ONCE per direction.** Creating one
17
+ * eagerly costs an ICE gather for a participant who may only ever watch.
18
+ * Creating two costs a second DTLS handshake and splits the media across
19
+ * transports for no reason.
20
+ * 3. **A consumer is resumed only after its track is attached.** The node
21
+ * creates every consumer paused, deliberately: media arriving before there
22
+ * is anywhere to put it is dropped, and for video that means waiting for the
23
+ * next keyframe to see anything. So the resume belongs after the application
24
+ * has the track, not at consume time.
25
+ * 4. **`autoSubscribe` follows the ACTIVE SET, not the producer list.** The
26
+ * node refuses a consume outside it, so a client that subscribed to
27
+ * everything would generate a refusal per producer in any room over the
28
+ * threshold and show nothing for its trouble.
29
+ *
30
+ * ## Coming back is part of the room, not part of the application
31
+ *
32
+ * A socket that dies mid-call leaves the room in `reconnecting`, and a state
33
+ * called "reconnecting" that nothing reconnects is worse than one called
34
+ * "closed": the screen above it draws a spinner and promises a recovery no code
35
+ * is performing, so the person waits instead of reloading. So the room itself
36
+ * drives `core/reconnect.ts`'s policy - fresh credentials through `MEDIA_URL`
37
+ * every attempt, the node's own drain window honoured, a closed room never
38
+ * rejoined - and publishes whether an attempt is actually booked
39
+ * (`isRecovering`) so a UI can tell "wait" from "press something".
40
+ *
41
+ * ## What this deliberately does not do
42
+ *
43
+ * Decide whether it is allowed to see somebody. `grants` are carried for
44
+ * RENDERING - so a UI does not offer a control the node is about to refuse -
45
+ * and the node enforces independently. A client copy of an authorization
46
+ * decision is a UI hint; treating it as the decision is how a barrier ends up
47
+ * enforced in the one place an attacker controls.
48
+ */
49
+ export type MediaRoomCredentials = MediaCoreCredentials;
50
+ export type ConnectionState = "idle" | "connecting" | "connected" | "reconnecting" | "closed";
51
+ export type MediaTrack = {
52
+ producerId: string;
53
+ identity: string;
54
+ kind: "audio" | "video";
55
+ track: MediaStreamTrack;
56
+ /** Paused at the SOURCE, as the publisher left it. */
57
+ paused: boolean;
58
+ };
59
+ export type LocalPublication = {
60
+ producerId: string;
61
+ kind: "audio" | "video";
62
+ source: string;
63
+ track: MediaStreamTrack;
64
+ handle: MediaProducerHandle;
65
+ };
66
+ export type MediaRoomOptions = {
67
+ /**
68
+ * Called before EVERY attempt. A join ticket expires in minutes and a call
69
+ * lasts an hour, so `{ url, token }` passed once is a defect rather than a
70
+ * naming choice: the first reconnect would present an expired token.
71
+ */
72
+ getCredentials: () => Promise<MediaRoomCredentials> | MediaRoomCredentials;
73
+ /** Follow the node's active set automatically. On by default: a client that
74
+ * does not follow it shows black tiles in any room over the threshold. */
75
+ autoSubscribe?: boolean;
76
+ /**
77
+ * Required, and supplied by the entry point rather than defaulted here.
78
+ *
79
+ * `room.ts` importing the browser device - even lazily - would make this file
80
+ * un-loadable outside a browser and drag `mediasoup-client` into any bundle
81
+ * that wanted the room TYPES. The root barrel is the browser entry and is
82
+ * where `createBrowserDevice` is wired in.
83
+ */
84
+ device: MediaDeviceFactory;
85
+ webSocket?: MediaWebSocketFactory;
86
+ /**
87
+ * How hard to try to get back in after a drop. Defaults to
88
+ * `DEFAULT_RECONNECT_OPTIONS`; `{ maxAttempts: 0 }` turns automatic recovery
89
+ * off, which leaves `isRecovering` false and is what tells a UI to offer a
90
+ * control instead of a spinner.
91
+ */
92
+ reconnect?: Partial<ReconnectOptions>;
93
+ onLog?: (level: SignalLogLevel, message: string, detail?: unknown) => void;
94
+ };
95
+ type Listener = () => void;
96
+ export declare class MediaRoom {
97
+ private readonly options;
98
+ private readonly signal;
99
+ private readonly device;
100
+ private sendTransport;
101
+ private recvTransport;
102
+ private iceServers;
103
+ private grantsValue;
104
+ private readonly consumers;
105
+ private readonly tracksByProducer;
106
+ private readonly publications;
107
+ private readonly listeners;
108
+ /** One in-flight subscribe per producer, so a burst of activeSpeakers frames
109
+ * does not race itself into two consumers for one producer. */
110
+ private readonly subscribing;
111
+ private stateValue;
112
+ private connection;
113
+ private lastError;
114
+ /** Consecutive failed attempts since the last successful join. */
115
+ private reconnectAttempt;
116
+ private reconnectTimer;
117
+ private recovering;
118
+ /** `close()` has been called. Nothing may open a socket after that. */
119
+ private disposed;
120
+ constructor(options: MediaRoomOptions);
121
+ get state(): RoomState;
122
+ get connectionState(): ConnectionState;
123
+ get error(): DisconnectCause | undefined;
124
+ /**
125
+ * Is another attempt booked?
126
+ *
127
+ * The difference between "wait" and "press something", and the room is the
128
+ * only side that knows: `connectionState` is `reconnecting` both while a
129
+ * retry is in flight and after the policy has given up, and a screen that
130
+ * cannot tell them apart spins forever over a call that is over.
131
+ */
132
+ get isRecovering(): boolean;
133
+ /** What the NODE says this token may do. For rendering only. */
134
+ get grants(): MediaGrants | undefined;
135
+ /**
136
+ * Snapshots, rebuilt only when something changed.
137
+ *
138
+ * These are read by `useSyncExternalStore`, which compares with `Object.is`.
139
+ * A getter that spread the map on every call would hand it a NEW array every
140
+ * check, so React would see a change every time and re-render for ever -
141
+ * "The result of getSnapshot should be cached to avoid an infinite loop".
142
+ *
143
+ * The reducer in `core/state.ts` returns the same object when nothing changed
144
+ * for exactly this reason; these getters have to hold the same property.
145
+ */
146
+ private trackSnapshot;
147
+ private publicationSnapshot;
148
+ get tracks(): MediaTrack[];
149
+ get localPublications(): LocalPublication[];
150
+ /** Subscribe to changes. Returns an unsubscribe. */
151
+ onChange(listener: Listener): () => void;
152
+ connect(): Promise<void>;
153
+ close(): Promise<void>;
154
+ /**
155
+ * Decide whether to come back, and book the attempt. Returns whether one was.
156
+ *
157
+ * The policy is `core/reconnect.ts`'s rather than a second copy of it here:
158
+ * "a drained node is left alone for the window it named", "a closed room is
159
+ * never rejoined", "a terminal refusal is not retried" and "jitter or the
160
+ * whole fleet lands on one replacement node together" each have an outage
161
+ * behind them, and a restatement is a restatement that drifts.
162
+ *
163
+ * The attempt goes through `connect()`, which fetches CREDENTIALS afresh: a
164
+ * join ticket lives minutes, so the one that opened this call is already
165
+ * stale, and the node placement has to be resolved again anyway because the
166
+ * node we just lost is the one node that cannot serve us.
167
+ */
168
+ private scheduleReconnect;
169
+ private cancelReconnect;
170
+ /**
171
+ * Publish a track.
172
+ *
173
+ * `source` travels in `appData` and is what the node checks against
174
+ * `publishKinds`: a token granting audio only must not be able to publish a
175
+ * screen share by relabelling it, and the node is where that is decided.
176
+ */
177
+ publish(track: MediaStreamTrack, source: "camera" | "microphone" | "screen"): Promise<LocalPublication>;
178
+ unpublish(producerId: string): Promise<void>;
179
+ setPaused(producerId: string, paused: boolean): Promise<void>;
180
+ /**
181
+ * Bring subscriptions in line with what the node says we should be receiving.
182
+ *
183
+ * RULE 4. The target is the ACTIVE SET, not the producer list: the node
184
+ * refuses anything outside it, so subscribing to every producer in a large
185
+ * room produces one refusal per producer and nothing to show.
186
+ *
187
+ * Below the threshold the node reports every producer as active, so the two
188
+ * are the same thing and this needs no special case.
189
+ */
190
+ syncSubscriptions(): Promise<void>;
191
+ subscribe(producerId: string): Promise<void>;
192
+ private doSubscribe;
193
+ /** RULE 2: lazily, and once. */
194
+ private ensureSendTransport;
195
+ private ensureRecvTransport;
196
+ private onFrame;
197
+ /**
198
+ * Everything the connection was carrying, released.
199
+ *
200
+ * The local CAPTURES go with it, and that is the part worth stating. A
201
+ * publication whose transport has closed sends nothing, but the
202
+ * `getUserMedia` track behind it is still capturing: the recording indicator
203
+ * stays lit on the machine and the controls above still read "Mute", claiming
204
+ * a microphone is publishing into a room that has ended. Leaving them running
205
+ * is retained personal capture after the session it was consented to was
206
+ * terminated by the other party, so the room stops them wherever the
207
+ * connection goes - a leave, a drop, a drain, or the host closing the room -
208
+ * rather than only on the one path a person happens to take deliberately.
209
+ *
210
+ * The node is not told: this runs when the socket is already gone, and the
211
+ * node tears the session down on close anyway. Ending one publication on a
212
+ * LIVE connection is `unpublish`, which does tell it.
213
+ */
214
+ private teardownMedia;
215
+ private emit;
216
+ }
217
+ export declare function connectToRoom(options: MediaRoomOptions): Promise<MediaRoom>;
218
+ export {};
219
+ //# sourceMappingURL=room.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"room.d.ts","sourceRoot":"","sources":["../../src/room/room.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,438 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MediaRoom = void 0;
4
+ exports.connectToRoom = connectToRoom;
5
+ const core_1 = require("../core");
6
+ class MediaRoom {
7
+ options;
8
+ signal;
9
+ device;
10
+ sendTransport;
11
+ recvTransport;
12
+ iceServers = [];
13
+ grantsValue;
14
+ consumers = new Map();
15
+ tracksByProducer = new Map();
16
+ publications = new Map();
17
+ listeners = new Set();
18
+ /** One in-flight subscribe per producer, so a burst of activeSpeakers frames
19
+ * does not race itself into two consumers for one producer. */
20
+ subscribing = new Map();
21
+ stateValue = core_1.initialRoomState;
22
+ connection = "idle";
23
+ lastError;
24
+ /** Consecutive failed attempts since the last successful join. */
25
+ reconnectAttempt = 0;
26
+ reconnectTimer;
27
+ recovering = false;
28
+ /** `close()` has been called. Nothing may open a socket after that. */
29
+ disposed = false;
30
+ constructor(options) {
31
+ this.options = options;
32
+ this.device = options.device();
33
+ this.signal = new core_1.MediaSignal({
34
+ getCredentials: options.getCredentials,
35
+ ...(options.webSocket ? { webSocket: options.webSocket } : {}),
36
+ ...(options.onLog ? { onLog: options.onLog } : {}),
37
+ });
38
+ this.signal.onAny((frame) => this.onFrame(frame));
39
+ this.signal.onClose((cause) => {
40
+ this.lastError = cause;
41
+ // Before the decision, and unconditionally. The transports are dead
42
+ // whatever happens next, and so is every capture that was feeding them.
43
+ this.teardownMedia();
44
+ if (cause.type === "closed_by_client") {
45
+ this.connection = "closed";
46
+ this.recovering = false;
47
+ this.emit();
48
+ return;
49
+ }
50
+ this.connection = "reconnecting";
51
+ this.recovering = this.scheduleReconnect(cause);
52
+ this.emit();
53
+ });
54
+ }
55
+ get state() {
56
+ return this.stateValue;
57
+ }
58
+ get connectionState() {
59
+ return this.connection;
60
+ }
61
+ get error() {
62
+ return this.lastError;
63
+ }
64
+ /**
65
+ * Is another attempt booked?
66
+ *
67
+ * The difference between "wait" and "press something", and the room is the
68
+ * only side that knows: `connectionState` is `reconnecting` both while a
69
+ * retry is in flight and after the policy has given up, and a screen that
70
+ * cannot tell them apart spins forever over a call that is over.
71
+ */
72
+ get isRecovering() {
73
+ return this.recovering;
74
+ }
75
+ /** What the NODE says this token may do. For rendering only. */
76
+ get grants() {
77
+ return this.grantsValue;
78
+ }
79
+ /**
80
+ * Snapshots, rebuilt only when something changed.
81
+ *
82
+ * These are read by `useSyncExternalStore`, which compares with `Object.is`.
83
+ * A getter that spread the map on every call would hand it a NEW array every
84
+ * check, so React would see a change every time and re-render for ever -
85
+ * "The result of getSnapshot should be cached to avoid an infinite loop".
86
+ *
87
+ * The reducer in `core/state.ts` returns the same object when nothing changed
88
+ * for exactly this reason; these getters have to hold the same property.
89
+ */
90
+ trackSnapshot = [];
91
+ publicationSnapshot = [];
92
+ get tracks() {
93
+ return this.trackSnapshot;
94
+ }
95
+ get localPublications() {
96
+ return this.publicationSnapshot;
97
+ }
98
+ /** Subscribe to changes. Returns an unsubscribe. */
99
+ onChange(listener) {
100
+ this.listeners.add(listener);
101
+ return () => this.listeners.delete(listener);
102
+ }
103
+ async connect() {
104
+ this.cancelReconnect();
105
+ // A retry is not a first connection, and saying "Connecting to the call"
106
+ // over a call somebody is already in reads as though they had been thrown
107
+ // out of it.
108
+ this.connection = this.reconnectAttempt > 0 ? "reconnecting" : "connecting";
109
+ this.emit();
110
+ const joined = await this.signal.connect();
111
+ // RULE 1: the device loads first. Everything below needs its capabilities.
112
+ if (!this.device.loaded) {
113
+ await this.device.load(joined.routerRtpCapabilities);
114
+ }
115
+ this.iceServers = (joined.iceServers ?? []);
116
+ this.grantsValue = joined.grants;
117
+ this.stateValue = (0, core_1.reduceRoomState)(this.stateValue, joined);
118
+ this.connection = "connected";
119
+ this.lastError = undefined;
120
+ // A successful join resets the ladder: an hour-long call that drops once
121
+ // should not start at a 30-second delay because of a blip at minute two.
122
+ this.reconnectAttempt = 0;
123
+ this.recovering = false;
124
+ this.emit();
125
+ if (this.options.autoSubscribe !== false)
126
+ await this.syncSubscriptions();
127
+ }
128
+ async close() {
129
+ this.disposed = true;
130
+ this.cancelReconnect();
131
+ this.recovering = false;
132
+ this.connection = "closed";
133
+ await this.signal.leave().catch(() => undefined);
134
+ this.teardownMedia();
135
+ this.emit();
136
+ }
137
+ // -------------------------------------------------------------------------
138
+ // getting back in
139
+ // -------------------------------------------------------------------------
140
+ /**
141
+ * Decide whether to come back, and book the attempt. Returns whether one was.
142
+ *
143
+ * The policy is `core/reconnect.ts`'s rather than a second copy of it here:
144
+ * "a drained node is left alone for the window it named", "a closed room is
145
+ * never rejoined", "a terminal refusal is not retried" and "jitter or the
146
+ * whole fleet lands on one replacement node together" each have an outage
147
+ * behind them, and a restatement is a restatement that drifts.
148
+ *
149
+ * The attempt goes through `connect()`, which fetches CREDENTIALS afresh: a
150
+ * join ticket lives minutes, so the one that opened this call is already
151
+ * stale, and the node placement has to be resolved again anyway because the
152
+ * node we just lost is the one node that cannot serve us.
153
+ */
154
+ scheduleReconnect(cause) {
155
+ if (this.disposed)
156
+ return false;
157
+ const decision = (0, core_1.decideReconnect)({
158
+ cause,
159
+ attempt: this.reconnectAttempt,
160
+ ...(this.options.reconnect ? { options: this.options.reconnect } : {}),
161
+ });
162
+ if (decision.action === "stop") {
163
+ this.options.onLog?.("warn", `not reconnecting: ${decision.reason}`, cause);
164
+ return false;
165
+ }
166
+ this.reconnectTimer = setTimeout(() => {
167
+ this.reconnectTimer = undefined;
168
+ if (this.disposed)
169
+ return;
170
+ this.reconnectAttempt += 1;
171
+ // A failed attempt comes back through `signal.onClose`, which is the only
172
+ // place that decides. Swallowed here so a retry does not surface as an
173
+ // unhandled rejection in the host application's console.
174
+ void this.connect().catch(() => undefined);
175
+ }, decision.delayMs);
176
+ return true;
177
+ }
178
+ cancelReconnect() {
179
+ if (this.reconnectTimer === undefined)
180
+ return;
181
+ clearTimeout(this.reconnectTimer);
182
+ this.reconnectTimer = undefined;
183
+ }
184
+ // -------------------------------------------------------------------------
185
+ // publishing
186
+ // -------------------------------------------------------------------------
187
+ /**
188
+ * Publish a track.
189
+ *
190
+ * `source` travels in `appData` and is what the node checks against
191
+ * `publishKinds`: a token granting audio only must not be able to publish a
192
+ * screen share by relabelling it, and the node is where that is decided.
193
+ */
194
+ async publish(track, source) {
195
+ const kind = track.kind === "audio" ? "audio" : "video";
196
+ if (!this.device.canProduce(kind)) {
197
+ throw new Error(`this browser cannot produce ${kind}`);
198
+ }
199
+ const transport = await this.ensureSendTransport();
200
+ const handle = await transport.produce({ track, appData: { source } });
201
+ const publication = {
202
+ producerId: handle.id,
203
+ kind,
204
+ source,
205
+ track,
206
+ handle,
207
+ };
208
+ this.publications.set(handle.id, publication);
209
+ this.emit();
210
+ return publication;
211
+ }
212
+ async unpublish(producerId) {
213
+ const publication = this.publications.get(producerId);
214
+ if (!publication)
215
+ return;
216
+ publication.handle.close();
217
+ publication.track.stop();
218
+ this.publications.delete(producerId);
219
+ // Told to the node, because closing a local handle stops OUR sending and
220
+ // leaves the node holding a producer nobody is feeding.
221
+ await this.signal.request({ method: "closeProducer", producerId }).catch(() => undefined);
222
+ this.emit();
223
+ }
224
+ async setPaused(producerId, paused) {
225
+ const publication = this.publications.get(producerId);
226
+ if (!publication)
227
+ return;
228
+ if (paused)
229
+ publication.handle.pause();
230
+ else
231
+ publication.handle.resume();
232
+ await this.signal
233
+ .request({ method: paused ? "pauseProducer" : "resumeProducer", producerId })
234
+ .catch(() => undefined);
235
+ this.emit();
236
+ }
237
+ // -------------------------------------------------------------------------
238
+ // subscribing
239
+ // -------------------------------------------------------------------------
240
+ /**
241
+ * Bring subscriptions in line with what the node says we should be receiving.
242
+ *
243
+ * RULE 4. The target is the ACTIVE SET, not the producer list: the node
244
+ * refuses anything outside it, so subscribing to every producer in a large
245
+ * room produces one refusal per producer and nothing to show.
246
+ *
247
+ * Below the threshold the node reports every producer as active, so the two
248
+ * are the same thing and this needs no special case.
249
+ */
250
+ async syncSubscriptions() {
251
+ const wanted = new Set(this.stateValue.activeSpeakers);
252
+ for (const [producerId, consumer] of this.consumers) {
253
+ if (wanted.has(producerId))
254
+ continue;
255
+ // Dropped rather than paused. A consumer the node has moved out of the
256
+ // active set is one it may refuse to keep feeding, and holding it costs
257
+ // the node a consumer object for a tile nobody is looking at.
258
+ consumer.close();
259
+ this.consumers.delete(producerId);
260
+ this.tracksByProducer.delete(producerId);
261
+ await this.signal.request({ method: "closeConsumer", consumerId: consumer.id }).catch(() => undefined);
262
+ }
263
+ await Promise.all([...wanted].map((producerId) => this.subscribe(producerId)));
264
+ this.emit();
265
+ }
266
+ async subscribe(producerId) {
267
+ if (this.consumers.has(producerId))
268
+ return;
269
+ const inFlight = this.subscribing.get(producerId);
270
+ // Two `activeSpeakers` frames in the same tick would otherwise both pass the
271
+ // check above and build two consumers for one producer.
272
+ if (inFlight)
273
+ return inFlight;
274
+ const work = this.doSubscribe(producerId).finally(() => this.subscribing.delete(producerId));
275
+ this.subscribing.set(producerId, work);
276
+ return work;
277
+ }
278
+ async doSubscribe(producerId) {
279
+ const entry = this.stateValue.producers.find((p) => p.producerId === producerId);
280
+ if (!entry)
281
+ return;
282
+ const transport = await this.ensureRecvTransport();
283
+ const response = (await this.signal.request({
284
+ method: "consume",
285
+ transportId: transport.id,
286
+ producerId,
287
+ rtpCapabilities: this.device.rtpCapabilities,
288
+ }));
289
+ const consumer = await transport.consume({
290
+ id: response.consumerId,
291
+ producerId: response.producerId,
292
+ kind: response.kind,
293
+ rtpParameters: response.rtpParameters,
294
+ });
295
+ this.consumers.set(producerId, consumer);
296
+ this.tracksByProducer.set(producerId, {
297
+ producerId,
298
+ identity: entry.identity,
299
+ kind: response.kind,
300
+ track: consumer.track,
301
+ paused: entry.paused,
302
+ });
303
+ // RULE 3: resume LAST. The node creates every consumer paused, so media
304
+ // arriving before the application has the track is dropped - and for video
305
+ // that means a black tile until the next keyframe.
306
+ await this.signal.request({ method: "resumeConsumer", consumerId: consumer.id });
307
+ consumer.resume();
308
+ this.emit();
309
+ }
310
+ // -------------------------------------------------------------------------
311
+ // transports
312
+ // -------------------------------------------------------------------------
313
+ /** RULE 2: lazily, and once. */
314
+ async ensureSendTransport() {
315
+ if (this.sendTransport)
316
+ return this.sendTransport;
317
+ const description = (await this.signal.request({
318
+ method: "createTransport",
319
+ direction: "send",
320
+ }));
321
+ this.sendTransport = this.device.createSendTransport({
322
+ description,
323
+ iceServers: this.iceServers,
324
+ handlers: {
325
+ onConnect: async (dtlsParameters) => {
326
+ await this.signal.request({ method: "connectTransport", transportId: description.transportId, dtlsParameters });
327
+ },
328
+ onProduce: async ({ kind, rtpParameters, appData }) => {
329
+ const produced = (await this.signal.request({
330
+ method: "produce",
331
+ transportId: description.transportId,
332
+ kind,
333
+ rtpParameters,
334
+ ...(appData ? { appData } : {}),
335
+ }));
336
+ return produced.producerId;
337
+ },
338
+ },
339
+ });
340
+ return this.sendTransport;
341
+ }
342
+ async ensureRecvTransport() {
343
+ if (this.recvTransport)
344
+ return this.recvTransport;
345
+ const description = (await this.signal.request({
346
+ method: "createTransport",
347
+ direction: "recv",
348
+ }));
349
+ this.recvTransport = this.device.createRecvTransport({
350
+ description,
351
+ iceServers: this.iceServers,
352
+ handlers: {
353
+ onConnect: async (dtlsParameters) => {
354
+ await this.signal.request({ method: "connectTransport", transportId: description.transportId, dtlsParameters });
355
+ },
356
+ },
357
+ });
358
+ return this.recvTransport;
359
+ }
360
+ // -------------------------------------------------------------------------
361
+ // events
362
+ // -------------------------------------------------------------------------
363
+ onFrame(frame) {
364
+ const next = (0, core_1.reduceRoomState)(this.stateValue, frame);
365
+ const changed = next !== this.stateValue;
366
+ this.stateValue = next;
367
+ if (frame.event === "joined")
368
+ this.grantsValue = frame.grants ?? this.grantsValue;
369
+ if (frame.event === "producerClosed") {
370
+ const consumer = this.consumers.get(frame.producerId);
371
+ consumer?.close();
372
+ this.consumers.delete(frame.producerId);
373
+ this.tracksByProducer.delete(frame.producerId);
374
+ }
375
+ // The node has changed what we should be receiving. Following it is the
376
+ // client's whole job in a large room.
377
+ const followsSet = frame.event === "activeSpeakers" || frame.event === "producerAppeared";
378
+ if (followsSet && this.options.autoSubscribe !== false && this.connection === "connected") {
379
+ void this.syncSubscriptions().catch((err) => this.options.onLog?.("warn", "subscription sync failed", err));
380
+ }
381
+ if (changed)
382
+ this.emit();
383
+ }
384
+ /**
385
+ * Everything the connection was carrying, released.
386
+ *
387
+ * The local CAPTURES go with it, and that is the part worth stating. A
388
+ * publication whose transport has closed sends nothing, but the
389
+ * `getUserMedia` track behind it is still capturing: the recording indicator
390
+ * stays lit on the machine and the controls above still read "Mute", claiming
391
+ * a microphone is publishing into a room that has ended. Leaving them running
392
+ * is retained personal capture after the session it was consented to was
393
+ * terminated by the other party, so the room stops them wherever the
394
+ * connection goes - a leave, a drop, a drain, or the host closing the room -
395
+ * rather than only on the one path a person happens to take deliberately.
396
+ *
397
+ * The node is not told: this runs when the socket is already gone, and the
398
+ * node tears the session down on close anyway. Ending one publication on a
399
+ * LIVE connection is `unpublish`, which does tell it.
400
+ */
401
+ teardownMedia() {
402
+ for (const consumer of this.consumers.values())
403
+ consumer.close();
404
+ this.consumers.clear();
405
+ this.tracksByProducer.clear();
406
+ for (const publication of this.publications.values()) {
407
+ publication.handle.close();
408
+ publication.track.stop();
409
+ }
410
+ this.publications.clear();
411
+ this.sendTransport?.close();
412
+ this.recvTransport?.close();
413
+ this.sendTransport = undefined;
414
+ this.recvTransport = undefined;
415
+ }
416
+ emit() {
417
+ // Rebuilt HERE, once per change, rather than per read. Every mutation
418
+ // already funnels through this, so nothing can change without the snapshot
419
+ // following it.
420
+ this.trackSnapshot = [...this.tracksByProducer.values()];
421
+ this.publicationSnapshot = [...this.publications.values()];
422
+ for (const listener of this.listeners) {
423
+ try {
424
+ listener();
425
+ }
426
+ catch {
427
+ // One subscriber throwing must not stop the others from being told.
428
+ }
429
+ }
430
+ }
431
+ }
432
+ exports.MediaRoom = MediaRoom;
433
+ async function connectToRoom(options) {
434
+ const room = new MediaRoom(options);
435
+ await room.connect();
436
+ return room;
437
+ }
438
+ //# sourceMappingURL=room.js.map