@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.
- package/README.md +68 -0
- package/build/core/index.d.ts +17 -0
- package/build/core/index.d.ts.map +1 -0
- package/build/core/index.js +41 -0
- package/build/core/index.js.map +1 -0
- package/build/core/reconnect.d.ts +95 -0
- package/build/core/reconnect.d.ts.map +1 -0
- package/build/core/reconnect.js +160 -0
- package/build/core/reconnect.js.map +1 -0
- package/build/core/signal.d.ts +184 -0
- package/build/core/signal.d.ts.map +1 -0
- package/build/core/signal.js +416 -0
- package/build/core/signal.js.map +1 -0
- package/build/core/socket.d.ts +57 -0
- package/build/core/socket.d.ts.map +1 -0
- package/build/core/socket.js +37 -0
- package/build/core/socket.js.map +1 -0
- package/build/core/state.d.ts +67 -0
- package/build/core/state.d.ts.map +1 -0
- package/build/core/state.js +193 -0
- package/build/core/state.js.map +1 -0
- package/build/index.d.ts +29 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +51 -0
- package/build/index.js.map +1 -0
- package/build/protocol.d.ts +10 -0
- package/build/protocol.d.ts.map +1 -0
- package/build/protocol.js +26 -0
- package/build/protocol.js.map +1 -0
- package/build/react/index.d.ts +147 -0
- package/build/react/index.d.ts.map +1 -0
- package/build/react/index.js +319 -0
- package/build/react/index.js.map +1 -0
- package/build/room/browserDevice.d.ts +3 -0
- package/build/room/browserDevice.d.ts.map +1 -0
- package/build/room/browserDevice.js +94 -0
- package/build/room/browserDevice.js.map +1 -0
- package/build/room/device.d.ts +114 -0
- package/build/room/device.d.ts.map +1 -0
- package/build/room/device.js +3 -0
- package/build/room/device.js.map +1 -0
- package/build/room/room.d.ts +219 -0
- package/build/room/room.d.ts.map +1 -0
- package/build/room/room.js +438 -0
- package/build/room/room.js.map +1 -0
- package/package.json +69 -0
- package/src/_tests/clientBoundary.spec.ts +110 -0
- package/src/core/_tests/coreBoundary.spec.ts +70 -0
- package/src/core/_tests/fakeSignalServer.ts +188 -0
- package/src/core/_tests/reconnect.spec.ts +180 -0
- package/src/core/_tests/signal.spec.ts +347 -0
- package/src/core/_tests/state.spec.ts +226 -0
- package/src/core/index.ts +63 -0
- package/src/core/reconnect.ts +233 -0
- package/src/core/signal.ts +527 -0
- package/src/core/socket.ts +58 -0
- package/src/core/state.ts +251 -0
- package/src/index.ts +54 -0
- package/src/protocol.ts +9 -0
- package/src/react/_tests/hooks.spec.tsx +509 -0
- package/src/react/index.tsx +439 -0
- package/src/room/_tests/room.spec.ts +595 -0
- package/src/room/browserDevice.ts +114 -0
- package/src/room/device.ts +119 -0
- package/src/room/room.ts +600 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { MEDIA_PROTOCOL_VERSION, MediaError } from "@tribe-nest/media-protocol";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { MediaSignal, assertTokenNotInUrl, type DisconnectCause, type SignalLogLevel } from "../signal";
|
|
5
|
+
import { FakeSignalServer, flush } from "./fakeSignalServer";
|
|
6
|
+
|
|
7
|
+
const MEDIA_URL = "wss://media.example/signal";
|
|
8
|
+
|
|
9
|
+
type Harness = {
|
|
10
|
+
server: FakeSignalServer;
|
|
11
|
+
signal: MediaSignal;
|
|
12
|
+
logs: { level: SignalLogLevel; message: string }[];
|
|
13
|
+
closes: DisconnectCause[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function harness(options: ConstructorParameters<typeof FakeSignalServer>[0] = {}, mediaUrl = MEDIA_URL): Harness {
|
|
17
|
+
const server = new FakeSignalServer(options);
|
|
18
|
+
const logs: { level: SignalLogLevel; message: string }[] = [];
|
|
19
|
+
const closes: DisconnectCause[] = [];
|
|
20
|
+
const signal = new MediaSignal({
|
|
21
|
+
getCredentials: () => ({ mediaUrl, token: "join-ticket" }),
|
|
22
|
+
webSocket: server.factory,
|
|
23
|
+
onLog: (level, message) => logs.push({ level, message }),
|
|
24
|
+
});
|
|
25
|
+
signal.onClose((cause) => closes.push(cause));
|
|
26
|
+
return { server, signal, logs, closes };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.useRealTimers();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("the handshake", () => {
|
|
34
|
+
it("sends the token in the FIRST frame and never in the URL", async () => {
|
|
35
|
+
const { server, signal } = harness();
|
|
36
|
+
await signal.connect();
|
|
37
|
+
|
|
38
|
+
// The whole reason the token is not a query parameter: a query string lands
|
|
39
|
+
// in load-balancer access logs, and a join ticket in a log is a join ticket
|
|
40
|
+
// for anyone who can read logs.
|
|
41
|
+
expect(server.urls).toEqual([MEDIA_URL]);
|
|
42
|
+
expect(server.urls[0]).not.toContain("join-ticket");
|
|
43
|
+
|
|
44
|
+
const first = server.received[0];
|
|
45
|
+
expect(first?.method).toBe("join");
|
|
46
|
+
expect(first).toMatchObject({ method: "join", id: 0, token: "join-ticket", protocolVersion: MEDIA_PROTOCOL_VERSION });
|
|
47
|
+
expect(server.invalidFrames).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("refuses a mediaUrl that carries a token in the query string", async () => {
|
|
51
|
+
const { server, signal } = harness({}, `${MEDIA_URL}?token=leaked`);
|
|
52
|
+
await expect(signal.connect()).rejects.toThrow(/first frame/);
|
|
53
|
+
// Refused BEFORE opening anything: nothing must reach the balancer's log.
|
|
54
|
+
expect(server.urls).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("guards every query-parameter spelling of a credential", () => {
|
|
58
|
+
expect(() => assertTokenNotInUrl("wss://m/s?access_token=x")).toThrow();
|
|
59
|
+
expect(() => assertTokenNotInUrl("wss://m/s?jwt=x")).toThrow();
|
|
60
|
+
expect(() => assertTokenNotInUrl("wss://m/s?ticket=x")).toThrow();
|
|
61
|
+
expect(() => assertTokenNotInUrl("wss://m/s?authorization=x")).toThrow();
|
|
62
|
+
expect(() => assertTokenNotInUrl("wss://m/s?region=eu")).not.toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("resolves only when BOTH the reply and the joined event have arrived, in either order", async () => {
|
|
66
|
+
for (const joinOrder of ["reply-first", "event-first"] as const) {
|
|
67
|
+
const { signal } = harness({ joinOrder });
|
|
68
|
+
const joined = await signal.connect();
|
|
69
|
+
expect(joined.event).toBe("joined");
|
|
70
|
+
expect(joined.room).toBe("room-1");
|
|
71
|
+
expect(signal.identity).toBe("u-1");
|
|
72
|
+
expect(signal.phase).toBe("joined");
|
|
73
|
+
signal.close();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not resolve on the reply alone", async () => {
|
|
78
|
+
// The reply says the token was accepted; the EVENT carries the router
|
|
79
|
+
// capabilities, the peer snapshot and the recording flag. Resolving early
|
|
80
|
+
// hands the caller a room it knows nothing about.
|
|
81
|
+
const { server, signal } = harness({ autoJoin: false, onRequest: (frame, socket, srv) => {
|
|
82
|
+
if (frame.method === "join") srv.reply(frame.id, { accepted: true }, socket);
|
|
83
|
+
} });
|
|
84
|
+
|
|
85
|
+
let settled = false;
|
|
86
|
+
const pending = signal.connect().then(() => (settled = true));
|
|
87
|
+
await flush(8);
|
|
88
|
+
expect(settled).toBe(false);
|
|
89
|
+
|
|
90
|
+
server.event(server.joinedFrame());
|
|
91
|
+
await pending;
|
|
92
|
+
expect(settled).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects with the refusal code and closes the socket", async () => {
|
|
96
|
+
const { server, signal, closes } = harness({ autoJoin: false, onRequest: (frame, socket, srv) => {
|
|
97
|
+
if (frame.method === "join") srv.fail(frame.id, "unauthorized", "no", socket);
|
|
98
|
+
} });
|
|
99
|
+
|
|
100
|
+
await expect(signal.connect()).rejects.toMatchObject({ name: "MediaError", code: "unauthorized" });
|
|
101
|
+
expect(signal.phase).toBe("closed");
|
|
102
|
+
expect(closes).toEqual([{ type: "refused", code: "unauthorized", message: "no" }]);
|
|
103
|
+
expect(server.socket.readyState).toBe(3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `close()` during the credential fetch.
|
|
108
|
+
*
|
|
109
|
+
* This is the one window in a connect that has no socket in it yet, so it is
|
|
110
|
+
* the one window `close()` cannot reach: it flips the phase and returns. The
|
|
111
|
+
* window is not small, either, because fetching a ticket is a network round
|
|
112
|
+
* trip of its own. Everything after it is cancelled by `handleClose`, which
|
|
113
|
+
* is why the check is here and not repeated at every step.
|
|
114
|
+
*/
|
|
115
|
+
it("opens no socket when close() lands inside the credential fetch", async () => {
|
|
116
|
+
const server = new FakeSignalServer();
|
|
117
|
+
let release: (() => void) | undefined;
|
|
118
|
+
const held = new Promise<void>((resolve) => {
|
|
119
|
+
release = resolve;
|
|
120
|
+
});
|
|
121
|
+
const signal = new MediaSignal({
|
|
122
|
+
getCredentials: async () => {
|
|
123
|
+
await held;
|
|
124
|
+
return { mediaUrl: MEDIA_URL, token: "join-ticket" };
|
|
125
|
+
},
|
|
126
|
+
webSocket: server.factory,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const connecting = signal.connect().then(
|
|
130
|
+
() => null,
|
|
131
|
+
(error: Error) => error,
|
|
132
|
+
);
|
|
133
|
+
await flush();
|
|
134
|
+
|
|
135
|
+
signal.close();
|
|
136
|
+
release?.();
|
|
137
|
+
const error = await connecting;
|
|
138
|
+
await flush();
|
|
139
|
+
|
|
140
|
+
expect(error).toBeInstanceOf(MediaError);
|
|
141
|
+
expect(error?.message).toMatch(/abandoned/);
|
|
142
|
+
// Not "a socket that was then closed": no socket, and no `join`. Otherwise
|
|
143
|
+
// the caller has closed and moved on while this attempt goes on to JOIN
|
|
144
|
+
// with a live token, leaving a participant in the room that nothing holds a
|
|
145
|
+
// reference to and nothing will ever close.
|
|
146
|
+
expect(server.sockets).toEqual([]);
|
|
147
|
+
expect(server.received).toEqual([]);
|
|
148
|
+
expect(signal.phase).toBe("closed");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("opens one socket, not two, when a second connect starts inside the first credential fetch", async () => {
|
|
152
|
+
const server = new FakeSignalServer();
|
|
153
|
+
const gates: (() => void)[] = [];
|
|
154
|
+
const signal = new MediaSignal({
|
|
155
|
+
getCredentials: () =>
|
|
156
|
+
new Promise((resolve) => {
|
|
157
|
+
gates.push(() => resolve({ mediaUrl: MEDIA_URL, token: "join-ticket" }));
|
|
158
|
+
}),
|
|
159
|
+
webSocket: server.factory,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const abandoned = signal.connect().then(
|
|
163
|
+
() => null,
|
|
164
|
+
(error: Error) => error,
|
|
165
|
+
);
|
|
166
|
+
await flush();
|
|
167
|
+
// The reconnect policy gives up on the first attempt and starts another.
|
|
168
|
+
// `connect()` permits that once the phase is `closed`.
|
|
169
|
+
signal.close();
|
|
170
|
+
const second = signal.connect();
|
|
171
|
+
await flush();
|
|
172
|
+
|
|
173
|
+
gates[1]?.();
|
|
174
|
+
gates[0]?.();
|
|
175
|
+
await flush(8);
|
|
176
|
+
|
|
177
|
+
expect(await abandoned).toBeInstanceOf(MediaError);
|
|
178
|
+
await expect(second).resolves.toMatchObject({ event: "joined" });
|
|
179
|
+
// The abandoned attempt must not open a socket of its own, and must not
|
|
180
|
+
// close the one the live attempt is using either.
|
|
181
|
+
expect(server.sockets).toHaveLength(1);
|
|
182
|
+
expect(signal.phase).toBe("joined");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("request and reply correlation", () => {
|
|
187
|
+
it("routes each reply to its own request, whatever order they arrive in", async () => {
|
|
188
|
+
const ids: number[] = [];
|
|
189
|
+
const { server, signal } = harness({ onRequest: (frame) => {
|
|
190
|
+
if (frame.method !== "join") ids.push(frame.id);
|
|
191
|
+
} });
|
|
192
|
+
await signal.connect();
|
|
193
|
+
|
|
194
|
+
const first = signal.request({ method: "pauseProducer", producerId: "p-1" });
|
|
195
|
+
const second = signal.request({ method: "resumeProducer", producerId: "p-2" });
|
|
196
|
+
await flush();
|
|
197
|
+
|
|
198
|
+
const [idA, idB] = ids as [number, number];
|
|
199
|
+
expect(idA).not.toBe(idB);
|
|
200
|
+
// Deliberately answered out of order.
|
|
201
|
+
server.reply(idB, { producerId: "p-2" });
|
|
202
|
+
server.reply(idA, { producerId: "p-1" });
|
|
203
|
+
|
|
204
|
+
expect(await first).toEqual({ producerId: "p-1" });
|
|
205
|
+
expect(await second).toEqual({ producerId: "p-2" });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("turns an ok:false reply into a MediaError carrying the code", async () => {
|
|
209
|
+
const { signal } = harness({ onRequest: (frame, socket, srv) => {
|
|
210
|
+
if (frame.method === "consume") srv.fail(frame.id, "not_subscribable", undefined, socket);
|
|
211
|
+
} });
|
|
212
|
+
await signal.connect();
|
|
213
|
+
|
|
214
|
+
const error = await signal
|
|
215
|
+
.request({ method: "consume", transportId: "t-1", producerId: "p-9", rtpCapabilities: {} })
|
|
216
|
+
.catch((e: unknown) => e);
|
|
217
|
+
|
|
218
|
+
expect(error).toBeInstanceOf(MediaError);
|
|
219
|
+
expect((error as MediaError).code).toBe("not_subscribable");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("ignores a second reply to an id it has already settled", async () => {
|
|
223
|
+
// "exactly one reply with that id" is the protocol. A node that breaks it
|
|
224
|
+
// must not corrupt the client, and must not do it quietly either.
|
|
225
|
+
const { signal, logs } = harness({ onRequest: (frame, socket, srv) => {
|
|
226
|
+
if (frame.method === "closeProducer") {
|
|
227
|
+
srv.reply(frame.id, { closed: true }, socket);
|
|
228
|
+
srv.reply(frame.id, { closed: "again" }, socket);
|
|
229
|
+
}
|
|
230
|
+
} });
|
|
231
|
+
await signal.connect();
|
|
232
|
+
|
|
233
|
+
expect(await signal.request({ method: "closeProducer", producerId: "p-1" })).toEqual({ closed: true });
|
|
234
|
+
await flush();
|
|
235
|
+
expect(logs.some((l) => l.message.includes("unknown request id"))).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("times out a request that is never answered", async () => {
|
|
239
|
+
vi.useFakeTimers();
|
|
240
|
+
const { signal } = harness();
|
|
241
|
+
await signal.connect();
|
|
242
|
+
|
|
243
|
+
const pending = signal.request({ method: "requestKeyFrame", consumerId: "c-1" }, 5_000);
|
|
244
|
+
// Attached before the clock moves: the rejection lands inside
|
|
245
|
+
// `advanceTimersByTimeAsync`, and an unattached one is an unhandled error.
|
|
246
|
+
const settled = expect(pending).rejects.toThrow(/no reply to requestKeyFrame/);
|
|
247
|
+
await vi.advanceTimersByTimeAsync(5_001);
|
|
248
|
+
await settled;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("refuses to send once the connection is closed", async () => {
|
|
252
|
+
const { signal } = harness();
|
|
253
|
+
await signal.connect();
|
|
254
|
+
signal.close();
|
|
255
|
+
await expect(signal.request({ method: "leave" })).rejects.toMatchObject({ code: "internal" });
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("events", () => {
|
|
260
|
+
it("dispatches to typed and catch-all handlers, in arrival order", async () => {
|
|
261
|
+
const { server, signal } = harness();
|
|
262
|
+
await signal.connect();
|
|
263
|
+
|
|
264
|
+
const appeared: string[] = [];
|
|
265
|
+
const all: string[] = [];
|
|
266
|
+
signal.on("producerAppeared", (frame) => appeared.push(frame.producerId));
|
|
267
|
+
signal.onAny((frame) => all.push(frame.event));
|
|
268
|
+
|
|
269
|
+
server.event({ event: "producerAppeared", producerId: "p-1", identity: "u-2", kind: "audio" });
|
|
270
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-1"] });
|
|
271
|
+
await flush();
|
|
272
|
+
|
|
273
|
+
expect(appeared).toEqual(["p-1"]);
|
|
274
|
+
expect(all).toEqual(["producerAppeared", "activeSpeakers"]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("drops a frame the protocol does not describe without killing the session", async () => {
|
|
278
|
+
// A node that gains a frame in a later version must not end an older
|
|
279
|
+
// client's live call.
|
|
280
|
+
const { server, signal, logs } = harness();
|
|
281
|
+
await signal.connect();
|
|
282
|
+
const seen: string[] = [];
|
|
283
|
+
signal.onAny((frame) => seen.push(frame.event));
|
|
284
|
+
|
|
285
|
+
server.raw({ event: "somethingNewer", detail: 1 });
|
|
286
|
+
server.raw("not json at all");
|
|
287
|
+
server.event({ event: "recordingChanged", recording: true });
|
|
288
|
+
await flush();
|
|
289
|
+
|
|
290
|
+
expect(seen).toEqual(["recordingChanged"]);
|
|
291
|
+
expect(logs.filter((l) => l.level === "warn")).toHaveLength(2);
|
|
292
|
+
expect(signal.phase).toBe("joined");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("why the connection ended", () => {
|
|
297
|
+
it("reports draining, with the window the node asked for", async () => {
|
|
298
|
+
const { server, signal, closes } = harness();
|
|
299
|
+
await signal.connect();
|
|
300
|
+
|
|
301
|
+
server.event({ event: "draining", reconnectAfterMs: 5_000 });
|
|
302
|
+
await flush();
|
|
303
|
+
server.socket.dropFromServer();
|
|
304
|
+
await flush();
|
|
305
|
+
|
|
306
|
+
// Recorded from the event, because by the time the socket closes the reason
|
|
307
|
+
// is gone - and "retry this node" is exactly the wrong response.
|
|
308
|
+
expect(closes).toEqual([{ type: "draining", reconnectAfterMs: 5_000 }]);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("reports a closed room rather than a dropped socket", async () => {
|
|
312
|
+
const { server, signal, closes } = harness();
|
|
313
|
+
await signal.connect();
|
|
314
|
+
|
|
315
|
+
server.event({ event: "roomClosed", reason: "ended_by_host" });
|
|
316
|
+
await flush();
|
|
317
|
+
server.socket.dropFromServer();
|
|
318
|
+
await flush();
|
|
319
|
+
|
|
320
|
+
expect(closes).toEqual([{ type: "room_closed", reason: "ended_by_host" }]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("reports a bare socket drop, and rejects everything still in flight", async () => {
|
|
324
|
+
const { server, signal, closes } = harness({ onRequest: () => undefined });
|
|
325
|
+
await signal.connect();
|
|
326
|
+
|
|
327
|
+
const pending = signal.request({ method: "createTransport", direction: "send" });
|
|
328
|
+
await flush();
|
|
329
|
+
server.socket.dropFromServer(1006, "gone");
|
|
330
|
+
await flush();
|
|
331
|
+
|
|
332
|
+
await expect(pending).rejects.toThrow(/closed before a reply arrived/);
|
|
333
|
+
expect(closes).toEqual([{ type: "socket_closed", code: 1006, reason: "gone" }]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("reports a client-initiated close, and leave() sends the frame first", async () => {
|
|
337
|
+
const { server, signal, closes } = harness({ onRequest: (frame, socket, srv) => {
|
|
338
|
+
if (frame.method === "leave") srv.reply(frame.id, {}, socket);
|
|
339
|
+
} });
|
|
340
|
+
await signal.connect();
|
|
341
|
+
|
|
342
|
+
await signal.leave();
|
|
343
|
+
expect(server.lastRequest("leave")).toBeDefined();
|
|
344
|
+
expect(closes).toEqual([{ type: "closed_by_client" }]);
|
|
345
|
+
expect(signal.phase).toBe("closed");
|
|
346
|
+
});
|
|
347
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { EventFrame } from "@tribe-nest/media-protocol";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
visibleProducers,
|
|
6
|
+
activeProducers,
|
|
7
|
+
initialRoomState,
|
|
8
|
+
producerById,
|
|
9
|
+
producersOf,
|
|
10
|
+
reduceRoomState,
|
|
11
|
+
reduceRoomStateAll,
|
|
12
|
+
type RoomState,
|
|
13
|
+
} from "../state";
|
|
14
|
+
|
|
15
|
+
const joined = (overrides: Partial<Extract<EventFrame, { event: "joined" }>> = {}): EventFrame => ({
|
|
16
|
+
event: "joined",
|
|
17
|
+
identity: "me",
|
|
18
|
+
room: "room-1",
|
|
19
|
+
routerRtpCapabilities: {},
|
|
20
|
+
peers: [
|
|
21
|
+
{ identity: "a", name: "Ada", kind: "human" },
|
|
22
|
+
{ identity: "b", kind: "human" },
|
|
23
|
+
],
|
|
24
|
+
producers: [
|
|
25
|
+
{ producerId: "pa", identity: "a", kind: "audio" },
|
|
26
|
+
{ producerId: "pb", identity: "b", kind: "video" },
|
|
27
|
+
],
|
|
28
|
+
iceServers: [],
|
|
29
|
+
recording: false,
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const afterJoin = (overrides?: Partial<Extract<EventFrame, { event: "joined" }>>): RoomState =>
|
|
34
|
+
reduceRoomState(initialRoomState, joined(overrides));
|
|
35
|
+
|
|
36
|
+
describe("joining", () => {
|
|
37
|
+
it("replaces the whole room rather than merging", () => {
|
|
38
|
+
// `joined` also arrives after a reconnect. Merging would leave peers who
|
|
39
|
+
// left while we were away on screen for the rest of the call.
|
|
40
|
+
const stale = reduceRoomState(afterJoin(), { event: "peerJoined", peer: { identity: "ghost", kind: "human" } });
|
|
41
|
+
const rejoined = reduceRoomState(stale, joined({ peers: [{ identity: "a", kind: "human" }], producers: [] }));
|
|
42
|
+
|
|
43
|
+
expect(rejoined.peers.map((p) => p.identity)).toEqual(["a"]);
|
|
44
|
+
expect(rejoined.producers).toEqual([]);
|
|
45
|
+
expect(rejoined.identity).toBe("me");
|
|
46
|
+
expect(rejoined.phase).toBe("joined");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("clears a drain flag from the previous connection", () => {
|
|
50
|
+
const draining = reduceRoomState(afterJoin(), { event: "draining", reconnectAfterMs: 3_000 });
|
|
51
|
+
expect(draining.draining).toEqual({ reconnectAfterMs: 3_000 });
|
|
52
|
+
expect(reduceRoomState(draining, joined()).draining).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("carries the recording flag from the snapshot and follows it", () => {
|
|
56
|
+
const state = afterJoin({ recording: true });
|
|
57
|
+
expect(state.recording).toBe(true);
|
|
58
|
+
expect(reduceRoomState(state, { event: "recordingChanged", recording: false }).recording).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("peers and producers", () => {
|
|
63
|
+
it("drops a departing peer's producers, and the speaker slots holding them", () => {
|
|
64
|
+
// A `producerClosed` for each is NOT guaranteed when a peer's socket simply
|
|
65
|
+
// dies. A producer left behind is a tile that never clears.
|
|
66
|
+
const state = reduceRoomState(afterJoin(), { event: "activeSpeakers", producerIds: ["pa", "pb"] });
|
|
67
|
+
const after = reduceRoomState(state, { event: "peerLeft", identity: "a" });
|
|
68
|
+
|
|
69
|
+
expect(after.peers.map((p) => p.identity)).toEqual(["b"]);
|
|
70
|
+
expect(after.producers.map((p) => p.producerId)).toEqual(["pb"]);
|
|
71
|
+
expect(after.activeSpeakers).toEqual(["pb"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("ignores a duplicate producerAppeared and a repeat peerLeft", () => {
|
|
75
|
+
const state = afterJoin();
|
|
76
|
+
const again = reduceRoomState(state, { event: "producerAppeared", producerId: "pa", identity: "a", kind: "audio" });
|
|
77
|
+
expect(again).toBe(state);
|
|
78
|
+
|
|
79
|
+
const left = reduceRoomState(state, { event: "peerLeft", identity: "a" });
|
|
80
|
+
expect(reduceRoomState(left, { event: "peerLeft", identity: "a" })).toBe(left);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("tracks pause state and returns the same state when nothing changed", () => {
|
|
84
|
+
const state = afterJoin();
|
|
85
|
+
const paused = reduceRoomState(state, { event: "producerPaused", producerId: "pa", paused: true });
|
|
86
|
+
expect(producerById(paused, "pa")?.paused).toBe(true);
|
|
87
|
+
expect(reduceRoomState(paused, { event: "producerPaused", producerId: "pa", paused: true })).toBe(paused);
|
|
88
|
+
expect(reduceRoomState(paused, { event: "producerPaused", producerId: "nope", paused: true })).toBe(paused);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("closes a producer and prunes it from the active set", () => {
|
|
92
|
+
const state = reduceRoomState(afterJoin(), { event: "activeSpeakers", producerIds: ["pa", "pb"] });
|
|
93
|
+
const after = reduceRoomState(state, { event: "producerClosed", producerId: "pa" });
|
|
94
|
+
expect(after.producers.map((p) => p.producerId)).toEqual(["pb"]);
|
|
95
|
+
expect(after.activeSpeakers).toEqual(["pb"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("keeps an active-speaker id it has not seen a producer for yet", () => {
|
|
99
|
+
// The node decides the active set. Dropping an id because our view is a
|
|
100
|
+
// frame behind would discard a stream we were told to take.
|
|
101
|
+
const state = reduceRoomState(afterJoin(), { event: "activeSpeakers", producerIds: ["pc", "pa"] });
|
|
102
|
+
expect(state.activeSpeakers).toEqual(["pc", "pa"]);
|
|
103
|
+
expect(activeProducers(state).map((p) => p.producerId)).toEqual(["pa"]);
|
|
104
|
+
|
|
105
|
+
const arrived = reduceRoomState(state, { event: "producerAppeared", producerId: "pc", identity: "c", kind: "video" });
|
|
106
|
+
expect(activeProducers(arrived).map((p) => p.producerId)).toEqual(["pc", "pa"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("looks producers up by id and by identity", () => {
|
|
110
|
+
const state = afterJoin();
|
|
111
|
+
expect(producerById(state, "pb")).toMatchObject({ identity: "b", kind: "video" });
|
|
112
|
+
expect(producersOf(state, "a").map((p) => p.producerId)).toEqual(["pa"]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("the information barrier", () => {
|
|
117
|
+
it("drops what a narrowed rule no longer permits, mid-call", () => {
|
|
118
|
+
// The rule is pushed because a rule that only applied at join would not
|
|
119
|
+
// apply to anyone already in the room. The node has already stopped the
|
|
120
|
+
// media; this is the tile going away with it.
|
|
121
|
+
const state = reduceRoomState(afterJoin(), { event: "activeSpeakers", producerIds: ["pa", "pb"] });
|
|
122
|
+
const narrowed = reduceRoomState(state, {
|
|
123
|
+
event: "subscribeRuleChanged",
|
|
124
|
+
subscribe: { mode: "deny", identities: ["a"] },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Visibility is DERIVED: `producers` still holds everything the node
|
|
128
|
+
// announced, which is what makes a later widening reversible.
|
|
129
|
+
expect(visibleProducers(narrowed).map((p) => p.producerId)).toEqual(["pb"]);
|
|
130
|
+
expect(narrowed.producers.map((p) => p.producerId)).toEqual(["pa", "pb"]);
|
|
131
|
+
expect(narrowed.activeSpeakers).toEqual(["pb"]);
|
|
132
|
+
expect(narrowed.subscribeRule).toEqual({ mode: "deny", identities: ["a"] });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("keeps only the named identities under an allow rule, and nothing under none", () => {
|
|
136
|
+
const state = afterJoin();
|
|
137
|
+
const allowed = reduceRoomState(state, { event: "subscribeRuleChanged", subscribe: { mode: "allow", identities: ["b"] } });
|
|
138
|
+
expect(visibleProducers(allowed).map((p) => p.identity)).toEqual(["b"]);
|
|
139
|
+
|
|
140
|
+
const none = reduceRoomState(state, { event: "subscribeRuleChanged", subscribe: { mode: "none" } });
|
|
141
|
+
expect(visibleProducers(none)).toEqual([]);
|
|
142
|
+
|
|
143
|
+
const all = reduceRoomState(state, { event: "subscribeRuleChanged", subscribe: { mode: "all" } });
|
|
144
|
+
expect(visibleProducers(all)).toHaveLength(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The bug a destructive prune caused. Narrowing DELETED producers, so a
|
|
149
|
+
* widening had nothing to restore and there is no `producerAppeared` replay
|
|
150
|
+
* to bring them back. The wire carries any SubscribeRule, `{mode:"all"}`
|
|
151
|
+
* included, so a node lifting a barrier mid-call is a real event.
|
|
152
|
+
*/
|
|
153
|
+
it("RESTORES producers when a narrowed rule is widened again", () => {
|
|
154
|
+
const joined = afterJoin();
|
|
155
|
+
const narrowed = reduceRoomState(joined, {
|
|
156
|
+
event: "subscribeRuleChanged",
|
|
157
|
+
subscribe: { mode: "deny", identities: ["a"] },
|
|
158
|
+
});
|
|
159
|
+
expect(visibleProducers(narrowed).map((p) => p.identity)).toEqual(["b"]);
|
|
160
|
+
|
|
161
|
+
const widened = reduceRoomState(narrowed, { event: "subscribeRuleChanged", subscribe: { mode: "all" } });
|
|
162
|
+
expect(visibleProducers(widened).map((p) => p.identity)).toEqual(["a", "b"]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("closing", () => {
|
|
167
|
+
it("keeps who was in the room and drops what is no longer consumable", () => {
|
|
168
|
+
const state = reduceRoomState(afterJoin(), { event: "activeSpeakers", producerIds: ["pa"] });
|
|
169
|
+
const closed = reduceRoomState(state, { event: "roomClosed", reason: "ended_by_host" });
|
|
170
|
+
|
|
171
|
+
expect(closed.phase).toBe("closed");
|
|
172
|
+
expect(closed.closedReason).toBe("ended_by_host");
|
|
173
|
+
expect(closed.producers).toEqual([]);
|
|
174
|
+
expect(closed.activeSpeakers).toEqual([]);
|
|
175
|
+
expect(closed.peers).toHaveLength(2);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("purity", () => {
|
|
180
|
+
it("never mutates the state it was given", () => {
|
|
181
|
+
const frames: EventFrame[] = [
|
|
182
|
+
joined(),
|
|
183
|
+
{ event: "peerJoined", peer: { identity: "c", kind: "agent" } },
|
|
184
|
+
{ event: "producerAppeared", producerId: "pc", identity: "c", kind: "audio" },
|
|
185
|
+
{ event: "activeSpeakers", producerIds: ["pc", "pa"] },
|
|
186
|
+
{ event: "producerPaused", producerId: "pa", paused: true },
|
|
187
|
+
{ event: "recordingChanged", recording: true },
|
|
188
|
+
{ event: "peerLeft", identity: "b" },
|
|
189
|
+
{ event: "subscribeRuleChanged", subscribe: { mode: "deny", identities: ["c"] } },
|
|
190
|
+
{ event: "draining", reconnectAfterMs: 1_000 },
|
|
191
|
+
{ event: "roomClosed", reason: "done" },
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
let state = deepFreeze(initialRoomState);
|
|
195
|
+
for (const frame of frames) {
|
|
196
|
+
state = deepFreeze(reduceRoomState(state, frame));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
expect(state.phase).toBe("closed");
|
|
200
|
+
expect(state.recording).toBe(true);
|
|
201
|
+
// Frozen input plus a reducer that mutates would have thrown by here in
|
|
202
|
+
// strict mode; asserting the end state as well keeps the test honest.
|
|
203
|
+
expect(state.peers.map((p) => p.identity)).toEqual(["a", "c"]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns the same state for an event it does not know", () => {
|
|
207
|
+
const state = afterJoin();
|
|
208
|
+
expect(reduceRoomState(state, { event: "somethingNewer" } as unknown as EventFrame)).toBe(state);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("folds a stream of frames", () => {
|
|
212
|
+
const state = reduceRoomStateAll(initialRoomState, [
|
|
213
|
+
joined({ peers: [], producers: [] }),
|
|
214
|
+
{ event: "peerJoined", peer: { identity: "z", kind: "sip" } },
|
|
215
|
+
]);
|
|
216
|
+
expect(state.peers.map((p) => p.identity)).toEqual(["z"]);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
function deepFreeze<T>(value: T): T {
|
|
221
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
222
|
+
const child = (value as Record<string, unknown>)[key];
|
|
223
|
+
if (child && typeof child === "object" && !Object.isFrozen(child)) deepFreeze(child);
|
|
224
|
+
}
|
|
225
|
+
return Object.freeze(value);
|
|
226
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@tribe-nest/media-client/core` - the headless half of the SDK.
|
|
3
|
+
*
|
|
4
|
+
* No DOM, no `mediasoup-client`, no React, and that is load-bearing rather than
|
|
5
|
+
* tidy: the load harness, the egress client and the SIP gateway all need a
|
|
6
|
+
* protocol client and none of them is a browser. Without this subpath each of
|
|
7
|
+
* them hand-rolls a socket and the three drift.
|
|
8
|
+
*
|
|
9
|
+
* What lives here: the signalling client, the reconnect policy and the room
|
|
10
|
+
* state reducer. The room API, `mediasoup-client` and the React and Forge
|
|
11
|
+
* wrappers are P3b and are exported from other subpaths.
|
|
12
|
+
*/
|
|
13
|
+
export {
|
|
14
|
+
MediaSignal,
|
|
15
|
+
assertTokenNotInUrl,
|
|
16
|
+
causeFromError,
|
|
17
|
+
type DisconnectCause,
|
|
18
|
+
type MediaCoreCredentials,
|
|
19
|
+
type MediaSignalOptions,
|
|
20
|
+
type SignalLogLevel,
|
|
21
|
+
type SignalPhase,
|
|
22
|
+
type SignalRequest,
|
|
23
|
+
} from "./signal";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
DEFAULT_RECONNECT_OPTIONS,
|
|
27
|
+
RECONNECT_CODE_CLASSES,
|
|
28
|
+
backoffDelay,
|
|
29
|
+
classifyCode,
|
|
30
|
+
decideReconnect,
|
|
31
|
+
superviseConnection,
|
|
32
|
+
type JitterMode,
|
|
33
|
+
type ReconnectDecision,
|
|
34
|
+
type ReconnectOptions,
|
|
35
|
+
type StopReason,
|
|
36
|
+
} from "./reconnect";
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
activeProducers,
|
|
40
|
+
initialRoomState,
|
|
41
|
+
producerById,
|
|
42
|
+
producersOf,
|
|
43
|
+
reduceRoomState,
|
|
44
|
+
reduceRoomStateAll,
|
|
45
|
+
visibleProducers,
|
|
46
|
+
type ProducerEntry,
|
|
47
|
+
type RoomState,
|
|
48
|
+
} from "./state";
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
SOCKET_CLOSED,
|
|
52
|
+
SOCKET_CLOSING,
|
|
53
|
+
SOCKET_CONNECTING,
|
|
54
|
+
SOCKET_OPEN,
|
|
55
|
+
defaultWebSocketFactory,
|
|
56
|
+
type MediaWebSocketFactory,
|
|
57
|
+
type SocketCloseEvent,
|
|
58
|
+
type SocketErrorEvent,
|
|
59
|
+
type SocketEventMap,
|
|
60
|
+
type SocketMessageEvent,
|
|
61
|
+
type SocketOpenEvent,
|
|
62
|
+
type WebSocketLike,
|
|
63
|
+
} from "./socket";
|