@tribe-nest/media-client 0.1.0 → 0.2.1

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 (38) hide show
  1. package/README.md +20 -4
  2. package/build/core/reconnect.d.ts +8 -2
  3. package/build/core/reconnect.d.ts.map +1 -1
  4. package/build/core/reconnect.js +8 -2
  5. package/build/core/reconnect.js.map +1 -1
  6. package/build/core/signal.d.ts +10 -1
  7. package/build/core/signal.d.ts.map +1 -1
  8. package/build/core/signal.js +39 -10
  9. package/build/core/signal.js.map +1 -1
  10. package/build/core/state.d.ts +3 -0
  11. package/build/core/state.d.ts.map +1 -1
  12. package/build/core/state.js +21 -4
  13. package/build/core/state.js.map +1 -1
  14. package/build/react/index.d.ts +38 -7
  15. package/build/react/index.d.ts.map +1 -1
  16. package/build/react/index.js +86 -7
  17. package/build/react/index.js.map +1 -1
  18. package/build/room/browserDevice.d.ts.map +1 -1
  19. package/build/room/browserDevice.js +13 -4
  20. package/build/room/browserDevice.js.map +1 -1
  21. package/build/room/device.d.ts +13 -0
  22. package/build/room/device.d.ts.map +1 -1
  23. package/build/room/room.d.ts +129 -5
  24. package/build/room/room.d.ts.map +1 -1
  25. package/build/room/room.js +330 -58
  26. package/build/room/room.js.map +1 -1
  27. package/package.json +3 -1
  28. package/src/core/_tests/signal.spec.ts +115 -22
  29. package/src/core/_tests/state.spec.ts +68 -2
  30. package/src/core/reconnect.ts +8 -2
  31. package/src/core/signal.ts +48 -12
  32. package/src/core/state.ts +31 -5
  33. package/src/react/_tests/hooks.spec.tsx +91 -3
  34. package/src/react/index.tsx +117 -20
  35. package/src/room/_tests/room.spec.ts +747 -12
  36. package/src/room/browserDevice.ts +14 -4
  37. package/src/room/device.ts +13 -0
  38. package/src/room/room.ts +416 -61
@@ -1,7 +1,9 @@
1
+ import { MediaError, type EventFrame, type RequestFrame } from "@tribe-nest/media-protocol";
1
2
  import { describe, expect, it, vi } from "vitest";
2
3
 
3
- import { FakeSignalServer, flush } from "../../core/_tests/fakeSignalServer";
4
- import { MediaRoom } from "../room";
4
+ import type { DisconnectCause } from "../../core";
5
+ import { FakeSignalServer, flush, type FakeSocket } from "../../core/_tests/fakeSignalServer";
6
+ import { MediaRoom, type ConnectionState } from "../room";
5
7
  import type {
6
8
  IceServer,
7
9
  MediaConsumerHandle,
@@ -24,7 +26,15 @@ import type {
24
26
  type Recorded = { calls: string[]; transports: FakeTransport[] };
25
27
 
26
28
  class FakeTransport implements MediaTransport {
27
- readonly produced: { appData?: Record<string, unknown> }[] = [];
29
+ readonly produced: { appData?: Record<string, unknown>; codec?: "h264" | "vp8"; encodings?: unknown[] }[] = [];
30
+ /** Records what the room asked the sender for. */
31
+ readonly senderParams: Record<string, unknown> = {};
32
+ readonly sender = {
33
+ getParameters: () => ({ encodings: [] }) as unknown as RTCRtpSendParameters,
34
+ setParameters: async (params: RTCRtpSendParameters) => {
35
+ Object.assign(this.senderParams, params);
36
+ },
37
+ } as unknown as RTCRtpSender;
28
38
  readonly consumed: string[] = [];
29
39
  closed = false;
30
40
  handlers: Partial<TransportHandlers> = {};
@@ -35,8 +45,19 @@ class FakeTransport implements MediaTransport {
35
45
  private readonly recorded: Recorded,
36
46
  ) {}
37
47
 
38
- async produce(input: { track: MediaStreamTrack; appData?: Record<string, unknown> }): Promise<MediaProducerHandle> {
39
- this.produced.push({ ...(input.appData ? { appData: input.appData } : {}) });
48
+ async produce(input: {
49
+ track: MediaStreamTrack;
50
+ appData?: Record<string, unknown>;
51
+ encodings?: unknown[];
52
+ codec?: "h264" | "vp8";
53
+ }): Promise<MediaProducerHandle> {
54
+ // Recorded EXACTLY as given: a key that is absent and a key set to
55
+ // undefined are different things to mediasoup-client.
56
+ this.produced.push({
57
+ ...(input.appData ? { appData: input.appData } : {}),
58
+ ...("codec" in input ? { codec: input.codec } : {}),
59
+ ...("encodings" in input ? { encodings: input.encodings } : {}),
60
+ });
40
61
  // The real transport asks the application to tell the node, and the node's
41
62
  // answer is the producer id. Modelled, because the round trip is the thing
42
63
  // that can be got wrong.
@@ -53,6 +74,10 @@ class FakeTransport implements MediaTransport {
53
74
  pause: vi.fn(),
54
75
  resume: vi.fn(),
55
76
  close: vi.fn(),
77
+ replaceTrack: vi.fn(async () => undefined),
78
+ // A real browser producer carries one, and the program feed's
79
+ // degradation preference is set through it.
80
+ rtpSender: this.sender,
56
81
  };
57
82
  }
58
83
 
@@ -93,7 +118,14 @@ function fakeDevice(recorded: Recorded) {
93
118
  return { codecs: [] } as never;
94
119
  },
95
120
  canProduce: () => true,
96
- createSendTransport({ description, handlers }: { description: TransportDescription; iceServers: IceServer[]; handlers: TransportHandlers }) {
121
+ createSendTransport({
122
+ description,
123
+ handlers,
124
+ }: {
125
+ description: TransportDescription;
126
+ iceServers: IceServer[];
127
+ handlers: TransportHandlers;
128
+ }) {
97
129
  const transport = new FakeTransport(description.transportId, "send", recorded);
98
130
  transport.handlers = handlers;
99
131
  recorded.transports.push(transport);
@@ -118,14 +150,24 @@ function fakeDevice(recorded: Recorded) {
118
150
  * the room is exercised against the same wire the signal client is.
119
151
  */
120
152
  function nodeWith(input: {
121
- producers?: { producerId: string; identity: string; kind: "audio" | "video" }[];
153
+ producers?: { producerId: string; identity: string; kind: "audio" | "video"; source?: string }[];
122
154
  iceServers?: IceServer[];
155
+ /**
156
+ * Events delivered on EVERY connection after the join reply and BEFORE the
157
+ * `joined` snapshot. The contract fixes no order between `joined` and the
158
+ * node's first broadcasts, and a real node sends its active set the moment a
159
+ * participant is in the room.
160
+ */
161
+ beforeJoined?: EventFrame[];
162
+ /** Answers a request itself by returning true; anything else falls through to the defaults. */
163
+ intercept?: (frame: RequestFrame, socket: FakeSocket, server: FakeSignalServer) => boolean;
123
164
  }): FakeSignalServer {
124
165
  let transportSeq = 0;
125
166
  let producerSeq = 0;
126
167
  let consumerSeq = 0;
127
168
 
128
169
  const server: FakeSignalServer = new FakeSignalServer({
170
+ ...(input.beforeJoined ? { autoJoin: false } : {}),
129
171
  joined: {
130
172
  identity: "me",
131
173
  room: "matter-1",
@@ -135,6 +177,7 @@ function nodeWith(input: {
135
177
  grants: { canPublish: true, canSubscribe: true, canPublishData: false },
136
178
  },
137
179
  onRequest: (frame, socket) => {
180
+ if (input.intercept?.(frame, socket, server)) return;
138
181
  switch (frame.method) {
139
182
  case "createTransport":
140
183
  return server.reply(
@@ -153,6 +196,11 @@ function nodeWith(input: {
153
196
  socket,
154
197
  );
155
198
  case "join":
199
+ if (input.beforeJoined) {
200
+ server.reply(frame.id, { accepted: true }, socket);
201
+ for (const event of input.beforeJoined) server.event(event, socket);
202
+ server.event(server.joinedFrame(), socket);
203
+ }
156
204
  return;
157
205
  default:
158
206
  return server.reply(frame.id, {}, socket);
@@ -203,7 +251,8 @@ const recoveringRoom = (
203
251
  return { room, tickets };
204
252
  };
205
253
 
206
- const track = () => ({ kind: "audio", stop: vi.fn() }) as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
254
+ const track = () =>
255
+ ({ kind: "audio", stop: vi.fn() }) as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
207
256
 
208
257
  describe("rule 1: the device loads before anything needs its capabilities", () => {
209
258
  it("loads on join, before any transport", async () => {
@@ -257,6 +306,39 @@ describe("rule 2: one transport per direction, created lazily", () => {
257
306
  await room.close();
258
307
  });
259
308
 
309
+ it("keeps the PROGRAM feed's resolution, giving up frame rate instead", async () => {
310
+ /**
311
+ * Chrome's default scales the encode down under a low bandwidth estimate
312
+ * or CPU pressure. For the program feed that is wrong twice: it is the
313
+ * broadcast itself, so 1080p arriving as 270p is the product; and the RTMP
314
+ * leg muxes it with `-c:v copy`, where an FLV header carries the dimensions
315
+ * once. A mid-stream resolution change leaves the SPS describing one size
316
+ * and the header another, and the player refuses it with "error #3000"
317
+ * while ffmpeg reports a healthy bitrate and zero dropped frames.
318
+ */
319
+ const recorded: Recorded = { calls: [], transports: [] };
320
+ const room = roomFor(nodeWith({}), recorded, false);
321
+ await room.connect();
322
+
323
+ await room.publish({ kind: "video", stop: vi.fn() } as never, "program");
324
+
325
+ const transport = recorded.transports[0] as unknown as { senderParams: Record<string, unknown> };
326
+ expect(transport.senderParams.degradationPreference).toBe("maintain-resolution");
327
+ await room.close();
328
+ });
329
+
330
+ it("leaves a CAMERA alone, which is better off shrinking than freezing", async () => {
331
+ const recorded: Recorded = { calls: [], transports: [] };
332
+ const room = roomFor(nodeWith({}), recorded, false);
333
+ await room.connect();
334
+
335
+ await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
336
+
337
+ const transport = recorded.transports[0] as unknown as { senderParams: Record<string, unknown> };
338
+ expect(transport.senderParams.degradationPreference).toBeUndefined();
339
+ await room.close();
340
+ });
341
+
260
342
  it("reuses the recv transport across subscriptions", async () => {
261
343
  const recorded: Recorded = { calls: [], transports: [] };
262
344
  const server = nodeWith({
@@ -274,6 +356,92 @@ describe("rule 2: one transport per direction, created lazily", () => {
274
356
  expect(recorded.calls.filter((c) => c === "create-recv-transport")).toHaveLength(1);
275
357
  await room.close();
276
358
  });
359
+
360
+ /**
361
+ * "Once" under CONCURRENCY, which is the shape that actually occurs.
362
+ *
363
+ * `syncSubscriptions` fans out with `Promise.all`, so one `activeSpeakers`
364
+ * frame naming four producers puts four subscribes through
365
+ * `ensureRecvTransport` in the same tick. Checking the finished transport and
366
+ * then awaiting the round trip let every one of them pass the check and send
367
+ * its own `createTransport(recv)`. The node caps transports per session, so
368
+ * the burst spent the whole allowance on duplicates and the person's own
369
+ * Unmute was refused with `capacity` for the rest of the call.
370
+ */
371
+ it("sends ONE createTransport per direction when several subscribes and publishes land in the same tick", async () => {
372
+ const recorded: Recorded = { calls: [], transports: [] };
373
+ const server = nodeWith({
374
+ producers: [
375
+ { producerId: "p-a", identity: "a", kind: "audio" },
376
+ { producerId: "p-b", identity: "b", kind: "audio" },
377
+ ],
378
+ });
379
+ const room = roomFor(server, recorded, false);
380
+ await room.connect();
381
+
382
+ // Fired, not awaited in turn: all four are in flight before any reply.
383
+ await Promise.all([
384
+ room.subscribe("p-a"),
385
+ room.subscribe("p-b"),
386
+ room.publish({ kind: "audio", stop: vi.fn() } as never, "microphone"),
387
+ room.publish({ kind: "video", stop: vi.fn() } as never, "camera"),
388
+ ]);
389
+
390
+ // Asserted on the WIRE, because that is what the node counts.
391
+ const created = requestsOf(server, "createTransport") as { direction: "send" | "recv" }[];
392
+ expect(created.filter((r) => r.direction === "recv")).toHaveLength(1);
393
+ expect(created.filter((r) => r.direction === "send")).toHaveLength(1);
394
+ expect(recorded.calls.filter((c) => c === "create-recv-transport")).toHaveLength(1);
395
+ expect(recorded.calls.filter((c) => c === "create-send-transport")).toHaveLength(1);
396
+ // And everything still landed on the one transport per direction.
397
+ expect(room.tracks.map((t) => t.producerId).sort()).toEqual(["p-a", "p-b"]);
398
+ expect(room.localPublications).toHaveLength(2);
399
+ await room.close();
400
+ });
401
+
402
+ it("sends ONE createTransport(recv) for an activeSpeakers frame naming several producers", async () => {
403
+ const recorded: Recorded = { calls: [], transports: [] };
404
+ const producers = ["p-1", "p-2", "p-3", "p-4"].map((producerId) => ({
405
+ producerId,
406
+ identity: producerId,
407
+ kind: "audio" as const,
408
+ }));
409
+ const server = nodeWith({ producers });
410
+ const room = roomFor(server, recorded);
411
+ await room.connect();
412
+
413
+ server.event({ event: "activeSpeakers", producerIds: producers.map((p) => p.producerId) });
414
+ await vi.waitFor(() => expect(room.tracks).toHaveLength(4));
415
+
416
+ expect(requestsOf(server, "createTransport")).toHaveLength(1);
417
+ await room.close();
418
+ });
419
+
420
+ it("forgets a creation the node refused, so the next caller tries again", async () => {
421
+ const recorded: Recorded = { calls: [], transports: [] };
422
+ let refuseNext = true;
423
+ const server = nodeWith({
424
+ producers: [{ producerId: "p-a", identity: "a", kind: "audio" }],
425
+ intercept: (frame, socket, srv) => {
426
+ if (frame.method !== "createTransport" || !refuseNext) return false;
427
+ refuseNext = false;
428
+ srv.fail(frame.id, "capacity", "no", socket);
429
+ return true;
430
+ },
431
+ });
432
+ const room = roomFor(server, recorded, false);
433
+ await room.connect();
434
+
435
+ await expect(room.subscribe("p-a")).rejects.toMatchObject({ code: "capacity" });
436
+ // A memo that kept the rejection would refuse every later subscribe with
437
+ // the same stale answer, when the node might well say yes now.
438
+ await room.subscribe("p-a");
439
+
440
+ expect(requestsOf(server, "createTransport")).toHaveLength(2);
441
+ expect(recorded.calls.filter((c) => c === "create-recv-transport")).toHaveLength(1);
442
+ expect(room.tracks).toHaveLength(1);
443
+ await room.close();
444
+ });
277
445
  });
278
446
 
279
447
  describe("rule 3: a consumer resumes only after its track exists", () => {
@@ -309,6 +477,48 @@ describe("rule 3: a consumer resumes only after its track exists", () => {
309
477
  });
310
478
  });
311
479
 
480
+ /**
481
+ * The publisher's label reaches the tile.
482
+ *
483
+ * The node relays `appData.source` as `source` on the snapshot and on every
484
+ * `producerAppeared`, and the room keeps it on the entry. A consumed track
485
+ * carries it so a screen share, a camera and a program feed can be laid out
486
+ * differently; a producer the node relayed no label for carries none.
487
+ */
488
+ describe("a consumed track carries the publisher's source", () => {
489
+ it("from the join snapshot", async () => {
490
+ const recorded: Recorded = { calls: [], transports: [] };
491
+ const server = nodeWith({
492
+ producers: [
493
+ { producerId: "p-a", identity: "anwalt", kind: "video", source: "screen" },
494
+ { producerId: "p-b", identity: "anwalt", kind: "audio" },
495
+ ],
496
+ });
497
+ const room = roomFor(server, recorded, false);
498
+ await room.connect();
499
+ await room.subscribe("p-a");
500
+ await room.subscribe("p-b");
501
+
502
+ expect(room.tracks.find((t) => t.producerId === "p-a")).toMatchObject({ identity: "anwalt", source: "screen" });
503
+ expect(room.tracks.find((t) => t.producerId === "p-b")).not.toHaveProperty("source");
504
+ await room.close();
505
+ });
506
+
507
+ it("from a producerAppeared announced after the join", async () => {
508
+ const recorded: Recorded = { calls: [], transports: [] };
509
+ const server = nodeWith({});
510
+ const room = roomFor(server, recorded, false);
511
+ await room.connect();
512
+
513
+ server.event({ event: "producerAppeared", producerId: "p-c", identity: "studio", kind: "video", source: "program" });
514
+ await flush();
515
+ await room.subscribe("p-c");
516
+
517
+ expect(room.tracks[0]).toMatchObject({ producerId: "p-c", identity: "studio", source: "program" });
518
+ await room.close();
519
+ });
520
+ });
521
+
312
522
  describe("rule 4: autoSubscribe follows the ACTIVE SET", () => {
313
523
  it("subscribes only to what the node put in the set", async () => {
314
524
  const recorded: Recorded = { calls: [], transports: [] };
@@ -330,10 +540,11 @@ describe("rule 4: autoSubscribe follows the ACTIVE SET", () => {
330
540
 
331
541
  // Subscribing to everything would produce one `subscription_limit` refusal
332
542
  // per producer outside the set, and show nothing for the trouble.
333
- expect(requestsOf(server, "consume").map((r) => (r as { producerId: string }).producerId).sort()).toEqual([
334
- "p-1",
335
- "p-3",
336
- ]);
543
+ expect(
544
+ requestsOf(server, "consume")
545
+ .map((r) => (r as { producerId: string }).producerId)
546
+ .sort(),
547
+ ).toEqual(["p-1", "p-3"]);
337
548
  await room.close();
338
549
  });
339
550
 
@@ -408,6 +619,53 @@ describe("publishing", () => {
408
619
  });
409
620
  });
410
621
 
622
+ /**
623
+ * A publication can say how it wants to be encoded.
624
+ *
625
+ * A camera takes the browser's defaults and that is right. A composed program
626
+ * feed does not: an RTMP restream needs H.264, because RTMP carries nothing
627
+ * else and the egress copies rather than transcodes, and a 1080p canvas at the
628
+ * default bitrate is a smear. Both are facts about ONE publication, so they
629
+ * travel with the `publish` call rather than with the room.
630
+ */
631
+ describe("publishing with options", () => {
632
+ it("passes the codec and a bitrate cap to the transport", async () => {
633
+ const recorded: Recorded = { calls: [], transports: [] };
634
+ const server = nodeWith({});
635
+ const room = roomFor(server, recorded, false);
636
+ await room.connect();
637
+
638
+ await room.publish({ kind: "video", stop: vi.fn() } as never, "program", { codec: "h264", maxBitrateKbps: 3500 });
639
+
640
+ const [produced] = recorded.transports.find((t) => t.direction === "send")!.produced;
641
+ expect(produced).toEqual({
642
+ appData: { source: "program" },
643
+ codec: "h264",
644
+ // kbit/s at the API, bit/s at the encoder. The unit is the whole bug.
645
+ encodings: [{ maxBitrate: 3_500_000 }],
646
+ });
647
+ // And the node still hears the label it checks grants against.
648
+ const produce = requestsOf(server, "produce")[0] as { appData?: { source?: string } };
649
+ expect(produce.appData?.source).toBe("program");
650
+ await room.close();
651
+ });
652
+
653
+ it("passes neither when no options are given, so the defaults stay the browser's", async () => {
654
+ const recorded: Recorded = { calls: [], transports: [] };
655
+ const server = nodeWith({});
656
+ const room = roomFor(server, recorded, false);
657
+ await room.connect();
658
+
659
+ await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
660
+
661
+ const [produced] = recorded.transports.find((t) => t.direction === "send")!.produced;
662
+ expect(produced).toEqual({ appData: { source: "camera" } });
663
+ expect(produced).not.toHaveProperty("codec");
664
+ expect(produced).not.toHaveProperty("encodings");
665
+ await room.close();
666
+ });
667
+ });
668
+
411
669
  /**
412
670
  * A state called "reconnecting" has to mean something is reconnecting.
413
671
  *
@@ -513,6 +771,146 @@ describe("the room gets itself back in", () => {
513
771
  });
514
772
  });
515
773
 
774
+ /**
775
+ * A ticket the room could not get is a failed attempt, not a frozen one.
776
+ *
777
+ * `getCredentials` is a network round trip to the ticket endpoint, and it
778
+ * fails for ordinary reasons: a 409 at the edge of the booking window because
779
+ * the client's clock is ahead, a 401 after the session expired, a flaky
780
+ * network. Before this, that rejection happened before a socket existed, so
781
+ * nothing ever closed, `onClose` never fired, and the room stayed on
782
+ * "connecting" (first join) or "reconnecting" (ladder) with no retry booked, no
783
+ * error to show and every later `connect()` refused as "already connecting".
784
+ * The screen above drew a spinner for the rest of the session.
785
+ */
786
+ describe("a credential fetch that fails", () => {
787
+ /** A ticket endpoint that fails on the attempts named, and counts its calls. */
788
+ const ticketEndpoint = (failOn: number[], error: () => unknown = () => new Error("HTTP 409: window not open")) => {
789
+ let calls = 0;
790
+ return {
791
+ get calls() {
792
+ return calls;
793
+ },
794
+ getCredentials: async () => {
795
+ calls += 1;
796
+ if (failOn.includes(calls)) throw error();
797
+ return { mediaUrl: "wss://media.example", token: `t-${calls}` };
798
+ },
799
+ };
800
+ };
801
+
802
+ it("on the FIRST join surfaces the error and books a retry rather than wedging on connecting", async () => {
803
+ const recorded: Recorded = { calls: [], transports: [] };
804
+ const server = nodeWith({});
805
+ const tickets = ticketEndpoint([1]);
806
+ const { room } = recoveringRoom(server, recorded, { getCredentials: tickets.getCredentials });
807
+
808
+ await expect(room.connect()).rejects.toThrow(/HTTP 409/);
809
+
810
+ // Not "connecting": nothing is connecting. A retryable failure with a retry
811
+ // booked, and the cause on the room for the screen to show.
812
+ expect(room.connectionState).toBe("reconnecting");
813
+ expect(room.error).toEqual({ type: "socket_closed", reason: "HTTP 409: window not open" });
814
+ expect(room.isRecovering).toBe(true);
815
+ // No socket was ever opened for the failed attempt.
816
+ expect(server.sockets).toHaveLength(0);
817
+
818
+ // The booked retry fetches a FRESH ticket and gets in.
819
+ await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
820
+ expect(tickets.calls).toBe(2);
821
+ expect(server.sockets).toHaveLength(1);
822
+ expect(room.error).toBeUndefined();
823
+ await room.close();
824
+ });
825
+
826
+ it("with retries off leaves the room retryable BY HAND rather than refusing the next connect", async () => {
827
+ const recorded: Recorded = { calls: [], transports: [] };
828
+ const server = nodeWith({});
829
+ const tickets = ticketEndpoint([1]);
830
+ const { room } = recoveringRoom(server, recorded, {
831
+ getCredentials: tickets.getCredentials,
832
+ reconnect: { maxAttempts: 0 },
833
+ });
834
+
835
+ await expect(room.connect()).rejects.toThrow(/HTTP 409/);
836
+ expect(room.connectionState).toBe("reconnecting");
837
+ expect(room.isRecovering).toBe(false);
838
+ expect(room.error).toMatchObject({ type: "socket_closed" });
839
+
840
+ // The person presses "Try again". Before the fix this was refused with
841
+ // "already connecting or connected" for the rest of the session.
842
+ await room.connect();
843
+
844
+ expect(room.connectionState).toBe("connected");
845
+ expect(room.error).toBeUndefined();
846
+ expect(tickets.calls).toBe(2);
847
+ await room.close();
848
+ });
849
+
850
+ it("on a LATER rung of the ladder is one failed attempt, and the ladder carries on", async () => {
851
+ const recorded: Recorded = { calls: [], transports: [] };
852
+ const server = nodeWith({});
853
+ const tickets = ticketEndpoint([2]);
854
+ const { room } = recoveringRoom(server, recorded, { getCredentials: tickets.getCredentials });
855
+ const seen: { connectionState: ConnectionState; error: DisconnectCause | undefined; recovering: boolean }[] = [];
856
+ room.onChange(() =>
857
+ seen.push({ connectionState: room.connectionState, error: room.error, recovering: room.isRecovering }),
858
+ );
859
+ await room.connect();
860
+
861
+ server.socket.dropFromServer();
862
+
863
+ await vi.waitFor(() => expect(server.sockets.length).toBe(2));
864
+ await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
865
+ // Three tickets: the original, the one the endpoint refused, and the one
866
+ // that got back in.
867
+ expect(tickets.calls).toBe(3);
868
+ // While the endpoint was refusing, the room said so and said it was still
869
+ // trying, rather than sitting on the drop's cause with nothing booked.
870
+ expect(seen).toContainEqual({
871
+ connectionState: "reconnecting",
872
+ error: { type: "socket_closed", reason: "HTTP 409: window not open" },
873
+ recovering: true,
874
+ });
875
+ await room.close();
876
+ });
877
+
878
+ it("with a refusal the policy will not retry, shows the refusal and stops", async () => {
879
+ const recorded: Recorded = { calls: [], transports: [] };
880
+ const server = nodeWith({});
881
+ // The endpoint saying "you may not join this room" is `refused`, and a
882
+ // terminal code is not retried: a loop against the ticket endpoint would
883
+ // only repeat the answer.
884
+ const tickets = ticketEndpoint([1, 2, 3], () => new MediaError("forbidden", "this seat is sealed"));
885
+ const { room } = recoveringRoom(server, recorded, { getCredentials: tickets.getCredentials });
886
+
887
+ await expect(room.connect()).rejects.toMatchObject({ code: "forbidden" });
888
+
889
+ expect(room.connectionState).toBe("reconnecting");
890
+ expect(room.isRecovering).toBe(false);
891
+ expect(room.error).toEqual({ type: "refused", code: "forbidden", message: "this seat is sealed" });
892
+ await new Promise((resolve) => setTimeout(resolve, 30));
893
+ expect(tickets.calls).toBe(1);
894
+ expect(server.sockets).toHaveLength(0);
895
+ await room.close();
896
+ });
897
+
898
+ it("does not put a live room back to connecting when connect() is called twice", async () => {
899
+ const recorded: Recorded = { calls: [], transports: [] };
900
+ const room = roomFor(nodeWith({}), recorded, false);
901
+ await room.connect();
902
+
903
+ await expect(room.connect()).rejects.toThrow(/already connecting or connected/);
904
+
905
+ // The refusal is not a disconnect: the first connection is still up and the
906
+ // room has to keep saying so.
907
+ expect(room.connectionState).toBe("connected");
908
+ expect(room.error).toBeUndefined();
909
+ expect(room.state.phase).toBe("joined");
910
+ await room.close();
911
+ });
912
+ });
913
+
516
914
  /**
517
915
  * The camera light goes out whenever the connection does.
518
916
  *
@@ -593,3 +991,340 @@ describe("what the room carries for rendering", () => {
593
991
  await room.close();
594
992
  });
595
993
  });
994
+
995
+ /**
996
+ * A track that can end on its own, the way a browser's does.
997
+ *
998
+ * `MediaStreamTrack` is an `EventTarget`, and `ended` is what fires when the
999
+ * SOURCE goes away: the browser's own "Stop sharing" bar, a camera unplugged.
1000
+ * It does not fire for our own `stop()`, which the fake also honours.
1001
+ */
1002
+ const endableTrack = (kind: "audio" | "video" = "video") => {
1003
+ const target = new EventTarget() as EventTarget & { kind: string; stop: ReturnType<typeof vi.fn> };
1004
+ target.kind = kind;
1005
+ target.stop = vi.fn();
1006
+ return target as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
1007
+ };
1008
+
1009
+ /**
1010
+ * The node's first active set can land BEFORE the join snapshot.
1011
+ *
1012
+ * The reducer used to reset `activeSpeakers` to `[]` on `joined`, so a set the
1013
+ * node had already sent was discarded and the client consumed nothing until
1014
+ * the next speaker change. In a quiet 1:1 call that change is the other person
1015
+ * starting to talk, which is precisely when a missing subscription is noticed.
1016
+ */
1017
+ describe("an active set that arrives ahead of joined", () => {
1018
+ it("is applied once the room is in, rather than discarded", async () => {
1019
+ const recorded: Recorded = { calls: [], transports: [] };
1020
+ const server = nodeWith({
1021
+ producers: [{ producerId: "p-a", identity: "a", kind: "audio" }],
1022
+ beforeJoined: [{ event: "activeSpeakers", producerIds: ["p-a"] }],
1023
+ });
1024
+ const room = roomFor(server, recorded);
1025
+
1026
+ await room.connect();
1027
+
1028
+ expect(room.state.activeSpeakers).toEqual(["p-a"]);
1029
+ await vi.waitFor(() => expect(room.tracks.map((t) => t.producerId)).toEqual(["p-a"]));
1030
+ await room.close();
1031
+ });
1032
+
1033
+ it("does not carry a set from BEFORE a drop into the rejoin", async () => {
1034
+ const recorded: Recorded = { calls: [], transports: [] };
1035
+ const server = nodeWith({ producers: [{ producerId: "p-a", identity: "a", kind: "audio" }] });
1036
+ const { room } = recoveringRoom(server, recorded);
1037
+ await room.connect();
1038
+ server.event({ event: "activeSpeakers", producerIds: ["p-a"] });
1039
+ await flush();
1040
+ expect(room.state.activeSpeakers).toEqual(["p-a"]);
1041
+
1042
+ server.socket.dropFromServer();
1043
+ await flush();
1044
+ // Gone with the consumers it described. Kept, the next `joined` would read
1045
+ // it as a set the NEW node had sent ahead of its snapshot.
1046
+ expect(room.state.activeSpeakers).toEqual([]);
1047
+
1048
+ await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
1049
+ expect(room.state.activeSpeakers).toEqual([]);
1050
+ await room.close();
1051
+ });
1052
+ });
1053
+
1054
+ /**
1055
+ * Leave means leave, even while the node is asking to be left.
1056
+ *
1057
+ * `draining` is recorded as the terminal cause the moment the frame arrives,
1058
+ * seconds before the node hangs up. A person who pressed Leave inside that
1059
+ * window had their close reported as the DRAIN: the room went to
1060
+ * "reconnecting", the policy declined because the room was disposed, and the
1061
+ * screen read "Could not move you to another server" with a Try again, over a
1062
+ * call they had just deliberately ended.
1063
+ */
1064
+ describe("a close inside a drain window", () => {
1065
+ it("is a client close, and the room stays closed", async () => {
1066
+ const recorded: Recorded = { calls: [], transports: [] };
1067
+ const server = nodeWith({});
1068
+ const { room } = recoveringRoom(server, recorded);
1069
+ await room.connect();
1070
+
1071
+ server.event({ event: "draining", reconnectAfterMs: 5_000 });
1072
+ await flush();
1073
+ await room.close();
1074
+
1075
+ expect(room.connectionState).toBe("closed");
1076
+ expect(room.error).toEqual({ type: "closed_by_client" });
1077
+ expect(room.isRecovering).toBe(false);
1078
+ await new Promise((resolve) => setTimeout(resolve, 20));
1079
+ expect(server.sockets).toHaveLength(1);
1080
+ });
1081
+ });
1082
+
1083
+ /**
1084
+ * A recovered connection is not a recovered call: the microphone is off.
1085
+ *
1086
+ * The drop stops every local capture, and that is right (a camera light left
1087
+ * on over a dead session is retained capture). What was wrong is that nothing
1088
+ * then said so. The room came back, the screen read "Connected", the button
1089
+ * read "Unmute", and a coach who had been talking for ten minutes was talking
1090
+ * to nobody until the client asked whether they were still there. The room
1091
+ * cannot republish by itself: capture is a browser permission, and turning a
1092
+ * camera back on without a press is not this layer's decision. So it says what
1093
+ * was lost, and stops saying it once each source is back.
1094
+ */
1095
+ describe("publications lost to a drop", () => {
1096
+ it("names what was being sent, and clears each source once it is republished", async () => {
1097
+ const recorded: Recorded = { calls: [], transports: [] };
1098
+ const server = nodeWith({});
1099
+ const { room } = recoveringRoom(server, recorded);
1100
+ await room.connect();
1101
+ expect(room.lostPublicationSources).toEqual([]);
1102
+
1103
+ await room.publish(track(), "microphone");
1104
+ await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
1105
+ server.socket.dropFromServer();
1106
+ await flush();
1107
+ expect(room.connectionState).toBe("reconnecting");
1108
+
1109
+ await vi.waitFor(() => expect(server.sockets.length).toBe(2));
1110
+ await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
1111
+ expect(room.localPublications).toHaveLength(0);
1112
+ expect(room.lostPublicationSources).toEqual(["microphone", "camera"]);
1113
+
1114
+ // The person presses Unmute. The microphone is no longer lost; the camera is.
1115
+ await room.publish(track(), "microphone");
1116
+ expect(room.lostPublicationSources).toEqual(["camera"]);
1117
+ await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
1118
+ expect(room.lostPublicationSources).toEqual([]);
1119
+ await room.close();
1120
+ });
1121
+
1122
+ it("never names a program feed, which is a studio's output and not a person's capture", async () => {
1123
+ // Nobody should be asked to "turn the program back on": it is not a
1124
+ // device, and `useLocalMedia`'s controls never touch it. Only the camera
1125
+ // is a lost source here, and republishing the program clears nothing.
1126
+ const recorded: Recorded = { calls: [], transports: [] };
1127
+ const server = nodeWith({});
1128
+ const { room } = recoveringRoom(server, recorded);
1129
+ await room.connect();
1130
+
1131
+ await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
1132
+ await room.publish({ kind: "video", stop: vi.fn() } as never, "program", { codec: "h264" });
1133
+ server.socket.dropFromServer();
1134
+ await flush();
1135
+
1136
+ await vi.waitFor(() => expect(server.sockets.length).toBe(2));
1137
+ await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
1138
+ expect(room.lostPublicationSources).toEqual(["camera"]);
1139
+
1140
+ await room.publish({ kind: "video", stop: vi.fn() } as never, "program", { codec: "h264" });
1141
+ expect(room.lostPublicationSources).toEqual(["camera"]);
1142
+ await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
1143
+ expect(room.lostPublicationSources).toEqual([]);
1144
+ await room.close();
1145
+ });
1146
+
1147
+ it("says nothing after a leave, which lost nothing", async () => {
1148
+ const recorded: Recorded = { calls: [], transports: [] };
1149
+ const server = nodeWith({});
1150
+ const { room } = recoveringRoom(server, recorded);
1151
+ await room.connect();
1152
+ await room.publish(track(), "microphone");
1153
+
1154
+ await room.close();
1155
+
1156
+ expect(room.lostPublicationSources).toEqual([]);
1157
+ });
1158
+ });
1159
+
1160
+ /**
1161
+ * The browser can end a track without asking us.
1162
+ *
1163
+ * Chrome and Firefox draw their own "Stop sharing" bar over a screen share, and
1164
+ * a camera can be unplugged. Neither goes anywhere near `unpublish`, so the
1165
+ * node kept a producer nobody was feeding, every other participant kept a
1166
+ * frozen tile for it, and the control above read "Stop sharing" about a share
1167
+ * that had ended.
1168
+ */
1169
+ describe("a track that ends on its own", () => {
1170
+ it("is unpublished, and the node is told", async () => {
1171
+ const recorded: Recorded = { calls: [], transports: [] };
1172
+ const server = nodeWith({});
1173
+ const room = roomFor(server, recorded, false);
1174
+ await room.connect();
1175
+
1176
+ const screen = endableTrack("video");
1177
+ await room.publish(screen, "screen");
1178
+ expect(room.localPublications).toHaveLength(1);
1179
+
1180
+ screen.dispatchEvent(new Event("ended"));
1181
+
1182
+ await vi.waitFor(() => expect(room.localPublications).toHaveLength(0));
1183
+ expect(requestsOf(server, "closeProducer")).toHaveLength(1);
1184
+ await room.close();
1185
+ });
1186
+
1187
+ it("is not listened to after an ordinary unpublish or a teardown", async () => {
1188
+ const recorded: Recorded = { calls: [], transports: [] };
1189
+ const server = nodeWith({});
1190
+ const room = roomFor(server, recorded, false);
1191
+ await room.connect();
1192
+
1193
+ const screen = endableTrack("video");
1194
+ const publication = await room.publish(screen, "screen");
1195
+ await room.unpublish(publication.producerId);
1196
+ // Our own `stop()` does not fire `ended`; this is a source ending later,
1197
+ // after the room has already let go of the track. Nothing must happen.
1198
+ screen.dispatchEvent(new Event("ended"));
1199
+ await flush();
1200
+ expect(requestsOf(server, "closeProducer")).toHaveLength(1);
1201
+
1202
+ const camera = endableTrack("video");
1203
+ await room.publish(camera, "camera");
1204
+ await room.close();
1205
+ camera.dispatchEvent(new Event("ended"));
1206
+ await flush();
1207
+ // Two: the ordinary unpublish above and nothing else. A watcher that
1208
+ // outlived the teardown would have tried a third against a dead socket.
1209
+ expect(requestsOf(server, "closeProducer")).toHaveLength(1);
1210
+ });
1211
+ });
1212
+
1213
+ /**
1214
+ * Mute keeps the microphone; it does not give it back.
1215
+ *
1216
+ * A toggle built as unpublish-then-publish starts every unmute with
1217
+ * `getUserMedia`, and Safari asks permission on EVERY call to it. So the room's
1218
+ * pause is what a Mute button uses: the producer is paused, the node is told,
1219
+ * the capture stays open, and the snapshot says which is which so a screen can
1220
+ * draw a muted state without a second source of truth.
1221
+ */
1222
+ describe("pausing a publication", () => {
1223
+ it("keeps the capture and the producer, tells the node, and moves the snapshot", async () => {
1224
+ const recorded: Recorded = { calls: [], transports: [] };
1225
+ const server = nodeWith({});
1226
+ const room = roomFor(server, recorded, false);
1227
+ await room.connect();
1228
+
1229
+ const microphone = track();
1230
+ const publication = await room.publish(microphone, "microphone");
1231
+ const before = room.localPublications;
1232
+ expect(before[0]?.paused).toBe(false);
1233
+
1234
+ await room.setPaused(publication.producerId, true);
1235
+
1236
+ expect(publication.handle.pause).toHaveBeenCalledTimes(1);
1237
+ expect(microphone.stop).not.toHaveBeenCalled();
1238
+ expect(requestsOf(server, "pauseProducer")).toHaveLength(1);
1239
+ expect(requestsOf(server, "closeProducer")).toHaveLength(0);
1240
+ // A NEW snapshot with the flag, so `useSyncExternalStore` sees the change.
1241
+ expect(room.localPublications).not.toBe(before);
1242
+ expect(room.localPublications[0]?.paused).toBe(true);
1243
+ // And the way back asks nobody anything.
1244
+ await room.setPaused(publication.producerId, false);
1245
+ expect(publication.handle.resume).toHaveBeenCalledTimes(1);
1246
+ expect(requestsOf(server, "resumeProducer")).toHaveLength(1);
1247
+ expect(room.localPublications[0]?.paused).toBe(false);
1248
+ await room.close();
1249
+ // Leaving still releases the capture: pause is a mute, not a way of keeping
1250
+ // a microphone open past the call.
1251
+ expect(microphone.stop).toHaveBeenCalledTimes(1);
1252
+ });
1253
+
1254
+ it("is idempotent on the wire", async () => {
1255
+ const recorded: Recorded = { calls: [], transports: [] };
1256
+ const server = nodeWith({});
1257
+ const room = roomFor(server, recorded, false);
1258
+ await room.connect();
1259
+ const publication = await room.publish(track(), "microphone");
1260
+
1261
+ await room.setPaused(publication.producerId, true);
1262
+ await room.setPaused(publication.producerId, true);
1263
+
1264
+ expect(requestsOf(server, "pauseProducer")).toHaveLength(1);
1265
+ await room.close();
1266
+ });
1267
+ });
1268
+
1269
+ describe("replaceTrack: a device switch keeps its producer", () => {
1270
+ it("swaps the capture on the handle, stops the old track, keeps the producer id", async () => {
1271
+ const server = nodeWith({ producers: [] });
1272
+ const room = roomFor(server, { calls: [], transports: [] });
1273
+ await room.connect();
1274
+ const first = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
1275
+ const second = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
1276
+ const published = await room.publish(first, "camera");
1277
+
1278
+ await room.replaceTrack(published.producerId, second);
1279
+
1280
+ expect(published.handle.replaceTrack).toHaveBeenCalledWith(second);
1281
+ expect(first.stop).toHaveBeenCalled();
1282
+ expect(second.stop).not.toHaveBeenCalled();
1283
+ expect(room.localPublications).toHaveLength(1);
1284
+ expect(room.localPublications[0]).toMatchObject({ producerId: published.producerId, track: second, source: "camera" });
1285
+ });
1286
+
1287
+ it("publishes three layers when simulcast is asked for, splitting the caller's cap", async () => {
1288
+ const server = nodeWith({ producers: [] });
1289
+ const recorded: Recorded = { calls: [], transports: [] };
1290
+ const room = roomFor(server, recorded);
1291
+ await room.connect();
1292
+ const track = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
1293
+
1294
+ await room.publish(track, "program", { codec: "vp8", simulcast: true, maxBitrateKbps: 3500 });
1295
+
1296
+ const produced = recorded.transports.find((t) => t.direction === "send")!.produced[0]!;
1297
+ expect(produced.codec).toBe("vp8");
1298
+ expect(produced.encodings).toEqual([
1299
+ { rid: "q", scaleResolutionDownBy: 4, scalabilityMode: "L1T1", maxBitrate: 350_000 },
1300
+ { rid: "h", scaleResolutionDownBy: 2, scalabilityMode: "L1T1", maxBitrate: 875_000 },
1301
+ { rid: "f", scaleResolutionDownBy: 1, scalabilityMode: "L1T1", maxBitrate: 3_500_000 },
1302
+ ]);
1303
+ });
1304
+
1305
+ it("still publishes ONE encoding when simulcast is not asked for", async () => {
1306
+ // The small-call case: everybody can carry the one encoding, and layers
1307
+ // nobody consumes are encoder time spent on nobody.
1308
+ const server = nodeWith({ producers: [] });
1309
+ const recorded: Recorded = { calls: [], transports: [] };
1310
+ const room = roomFor(server, recorded);
1311
+ await room.connect();
1312
+ const track = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
1313
+
1314
+ await room.publish(track, "camera", { maxBitrateKbps: 1200 });
1315
+
1316
+ const produced = recorded.transports.find((t) => t.direction === "send")!.produced[0]!;
1317
+ expect(produced.encodings).toEqual([{ maxBitrate: 1_200_000 }]);
1318
+ });
1319
+
1320
+ it("refuses a track of the other kind and an unknown publication", async () => {
1321
+ const server = nodeWith({ producers: [] });
1322
+ const room = roomFor(server, { calls: [], transports: [] });
1323
+ await room.connect();
1324
+ const video = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
1325
+ const audio = { kind: "audio", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
1326
+ const published = await room.publish(video, "camera");
1327
+ await expect(room.replaceTrack(published.producerId, audio)).rejects.toThrow(/cannot carry/);
1328
+ await expect(room.replaceTrack("nope", video)).rejects.toThrow(/no such publication/);
1329
+ });
1330
+ });