@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,251 @@
|
|
|
1
|
+
import { maySubscribe, type EventFrame, type MediaGrants, type Peer, type SubscribeRule } from "@tribe-nest/media-protocol";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The room as the event frames describe it, as a pure reducer.
|
|
5
|
+
*
|
|
6
|
+
* Pure because this is the part that is worth testing exhaustively and the part
|
|
7
|
+
* a socket makes untestable. Every ordering that matters (a peer leaving before
|
|
8
|
+
* its producers close, an active-speaker set naming a producer that has just
|
|
9
|
+
* gone, a rule narrowing mid-call) is a two-line test here and a flaky
|
|
10
|
+
* integration run anywhere else.
|
|
11
|
+
*
|
|
12
|
+
* It holds no tracks, no consumers and no mediasoup objects. Those belong to
|
|
13
|
+
* P3b's room API, keyed by the `producerId` this state carries.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type ProducerEntry = {
|
|
17
|
+
producerId: string;
|
|
18
|
+
identity: string;
|
|
19
|
+
kind: "audio" | "video";
|
|
20
|
+
paused: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type RoomState = {
|
|
24
|
+
phase: "idle" | "joined" | "closed";
|
|
25
|
+
/** Our own identity, as the node reported it. */
|
|
26
|
+
identity: string | null;
|
|
27
|
+
room: string | null;
|
|
28
|
+
/** In arrival order, which is the only order the wire gives. */
|
|
29
|
+
peers: readonly Peer[];
|
|
30
|
+
/**
|
|
31
|
+
* EVERY producer the node has announced, whether or not the current
|
|
32
|
+
* subscribe rule permits it. Visibility is derived by `visibleProducers`;
|
|
33
|
+
* see the note on `subscribeRuleChanged` for why this is not pruned.
|
|
34
|
+
*/
|
|
35
|
+
producers: readonly ProducerEntry[];
|
|
36
|
+
/** The set the NODE decided we should consume, in its order. */
|
|
37
|
+
activeSpeakers: readonly string[];
|
|
38
|
+
/** True while any `kind: "egress"` participant is present. A consent flag. */
|
|
39
|
+
recording: boolean;
|
|
40
|
+
/** The latest rule pushed for us, if one ever was. */
|
|
41
|
+
subscribeRule: SubscribeRule | null;
|
|
42
|
+
/** Set by `draining`, cleared by the next `joined`. */
|
|
43
|
+
draining: { reconnectAfterMs: number } | null;
|
|
44
|
+
closedReason: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const initialRoomState: RoomState = {
|
|
48
|
+
phase: "idle",
|
|
49
|
+
identity: null,
|
|
50
|
+
room: null,
|
|
51
|
+
peers: [],
|
|
52
|
+
producers: [],
|
|
53
|
+
activeSpeakers: [],
|
|
54
|
+
recording: false,
|
|
55
|
+
subscribeRule: null,
|
|
56
|
+
draining: null,
|
|
57
|
+
closedReason: null,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Apply one event frame.
|
|
62
|
+
*
|
|
63
|
+
* Never throws and never mutates. An event it does not recognise returns the
|
|
64
|
+
* SAME reference, so a caller can use identity to skip a re-render.
|
|
65
|
+
*/
|
|
66
|
+
export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState {
|
|
67
|
+
switch (frame.event) {
|
|
68
|
+
case "joined":
|
|
69
|
+
// A wholesale replace, not a merge. `joined` also arrives after a
|
|
70
|
+
// reconnect, and merging would leave peers who left while we were away
|
|
71
|
+
// on screen for the rest of the call.
|
|
72
|
+
return {
|
|
73
|
+
phase: "joined",
|
|
74
|
+
identity: frame.identity,
|
|
75
|
+
room: frame.room,
|
|
76
|
+
peers: frame.peers,
|
|
77
|
+
producers: frame.producers.map((p) => ({ ...p, paused: false })),
|
|
78
|
+
activeSpeakers: [],
|
|
79
|
+
recording: frame.recording,
|
|
80
|
+
subscribeRule: null,
|
|
81
|
+
draining: null,
|
|
82
|
+
closedReason: null,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
case "peerJoined": {
|
|
86
|
+
const without = state.peers.filter((p) => p.identity !== frame.peer.identity);
|
|
87
|
+
return { ...state, peers: [...without, frame.peer] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
case "peerLeft": {
|
|
91
|
+
if (
|
|
92
|
+
!state.peers.some((p) => p.identity === frame.identity) &&
|
|
93
|
+
!state.producers.some((p) => p.identity === frame.identity)
|
|
94
|
+
) {
|
|
95
|
+
return state;
|
|
96
|
+
}
|
|
97
|
+
// Their producers go with them. A `producerClosed` for each is NOT
|
|
98
|
+
// guaranteed when a peer's socket simply dies, and a producer left behind
|
|
99
|
+
// is a tile that never clears.
|
|
100
|
+
const producers = state.producers.filter((p) => p.identity !== frame.identity);
|
|
101
|
+
return {
|
|
102
|
+
...state,
|
|
103
|
+
peers: state.peers.filter((p) => p.identity !== frame.identity),
|
|
104
|
+
producers,
|
|
105
|
+
activeSpeakers: pruneSpeakers(state.activeSpeakers, producers),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
case "producerAppeared": {
|
|
110
|
+
if (state.producers.some((p) => p.producerId === frame.producerId)) return state;
|
|
111
|
+
return {
|
|
112
|
+
...state,
|
|
113
|
+
producers: [
|
|
114
|
+
...state.producers,
|
|
115
|
+
{ producerId: frame.producerId, identity: frame.identity, kind: frame.kind, paused: false },
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case "producerClosed": {
|
|
121
|
+
if (!state.producers.some((p) => p.producerId === frame.producerId)) return state;
|
|
122
|
+
const producers = state.producers.filter((p) => p.producerId !== frame.producerId);
|
|
123
|
+
return { ...state, producers, activeSpeakers: pruneSpeakers(state.activeSpeakers, producers) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
case "producerPaused": {
|
|
127
|
+
let changed = false;
|
|
128
|
+
const producers = state.producers.map((p) => {
|
|
129
|
+
if (p.producerId !== frame.producerId || p.paused === frame.paused) return p;
|
|
130
|
+
changed = true;
|
|
131
|
+
return { ...p, paused: frame.paused };
|
|
132
|
+
});
|
|
133
|
+
return changed ? { ...state, producers } : state;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case "activeSpeakers":
|
|
137
|
+
// Stored as sent, INCLUDING ids we have not seen a `producerAppeared` for
|
|
138
|
+
// yet. The node decides the active set; dropping an id because our view
|
|
139
|
+
// is a frame behind would silently discard a stream we were told to take.
|
|
140
|
+
return { ...state, activeSpeakers: frame.producerIds };
|
|
141
|
+
|
|
142
|
+
case "recordingChanged":
|
|
143
|
+
return state.recording === frame.recording ? state : { ...state, recording: frame.recording };
|
|
144
|
+
|
|
145
|
+
case "draining":
|
|
146
|
+
return { ...state, draining: { reconnectAfterMs: frame.reconnectAfterMs } };
|
|
147
|
+
|
|
148
|
+
case "roomClosed":
|
|
149
|
+
// Producers and speakers are gone for good; peers are kept so a caller can
|
|
150
|
+
// still say who was in the room on the "call ended" screen.
|
|
151
|
+
return { ...state, phase: "closed", closedReason: frame.reason, producers: [], activeSpeakers: [] };
|
|
152
|
+
|
|
153
|
+
case "subscribeRuleChanged":
|
|
154
|
+
/**
|
|
155
|
+
* A rule that only applied at join would not apply to anyone already in
|
|
156
|
+
* the room, which is why the node pushes it.
|
|
157
|
+
*
|
|
158
|
+
* The rule is STORED and visibility is DERIVED, never pruned. Filtering
|
|
159
|
+
* `producers` here loses them permanently: the wire carries any
|
|
160
|
+
* `SubscribeRule`, `{ mode: "all" }` included, so a node WIDENING a
|
|
161
|
+
* barrier mid-call could never restore what a narrowing had deleted -
|
|
162
|
+
* there is no `producerAppeared` replay to bring them back.
|
|
163
|
+
*
|
|
164
|
+
* Active speakers are still pruned, because that set is the node's
|
|
165
|
+
* instruction about what to consume RIGHT NOW rather than a record of
|
|
166
|
+
* what exists.
|
|
167
|
+
*/
|
|
168
|
+
return {
|
|
169
|
+
...state,
|
|
170
|
+
subscribeRule: frame.subscribe,
|
|
171
|
+
activeSpeakers: pruneSpeakers(
|
|
172
|
+
state.activeSpeakers,
|
|
173
|
+
visibleUnder(frame.subscribe, state.identity, state.producers),
|
|
174
|
+
),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
default:
|
|
178
|
+
return state;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Convenience for a caller feeding a stream of frames, e.g. `signal.onAny`. */
|
|
183
|
+
export function reduceRoomStateAll(state: RoomState, frames: readonly EventFrame[]): RoomState {
|
|
184
|
+
return frames.reduce(reduceRoomState, state);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function producerById(state: RoomState, producerId: string): ProducerEntry | undefined {
|
|
188
|
+
return state.producers.find((p) => p.producerId === producerId);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function producersOf(state: RoomState, identity: string): readonly ProducerEntry[] {
|
|
192
|
+
return state.producers.filter((p) => p.identity === identity);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The active set intersected with what we actually know about, in the node's order. */
|
|
196
|
+
export function activeProducers(state: RoomState): readonly ProducerEntry[] {
|
|
197
|
+
const entries: ProducerEntry[] = [];
|
|
198
|
+
for (const id of state.activeSpeakers) {
|
|
199
|
+
const found = producerById(state, id);
|
|
200
|
+
if (found) entries.push(found);
|
|
201
|
+
}
|
|
202
|
+
return entries;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Producers permitted under `rule`. Pure. */
|
|
206
|
+
function visibleUnder(
|
|
207
|
+
rule: SubscribeRule | null,
|
|
208
|
+
self: string | null,
|
|
209
|
+
producers: readonly ProducerEntry[],
|
|
210
|
+
): readonly ProducerEntry[] {
|
|
211
|
+
if (self === null || rule === null) return producers;
|
|
212
|
+
return producers.filter((p) => permits(rule, self, p.identity));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What this participant may actually receive.
|
|
217
|
+
*
|
|
218
|
+
* Derived rather than stored, so a barrier that narrows and later widens is
|
|
219
|
+
* reversible. The reducer keeps every producer it has been told about; this is
|
|
220
|
+
* what a UI should render and what a client should consume from.
|
|
221
|
+
*/
|
|
222
|
+
export function visibleProducers(state: RoomState): readonly ProducerEntry[] {
|
|
223
|
+
return visibleUnder(state.subscribeRule, state.identity, state.producers);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function pruneSpeakers(speakers: readonly string[], producers: readonly ProducerEntry[]): readonly string[] {
|
|
227
|
+
const live = new Set(producers.map((p) => p.producerId));
|
|
228
|
+
const kept = speakers.filter((id) => live.has(id));
|
|
229
|
+
return kept.length === speakers.length ? speakers : kept;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Asks the CONTRACT's decision function rather than reading the rule here.
|
|
234
|
+
*
|
|
235
|
+
* Re-implementing "deny wins, an absent rule means everyone" locally is how the
|
|
236
|
+
* client and the node end up disagreeing about who is visible, and the client's
|
|
237
|
+
* version is the one a user sees. The producer half (`visibleTo`) is enforced by
|
|
238
|
+
* the node from the owner's stored grants and is not knowable here, so this
|
|
239
|
+
* applies our half only.
|
|
240
|
+
*/
|
|
241
|
+
function permits(rule: SubscribeRule, selfIdentity: string, producerIdentity: string): boolean {
|
|
242
|
+
if (selfIdentity === producerIdentity) return true;
|
|
243
|
+
const consumerGrants: MediaGrants = {
|
|
244
|
+
canPublish: false,
|
|
245
|
+
canSubscribe: true,
|
|
246
|
+
canPublishData: false,
|
|
247
|
+
subscribe: rule,
|
|
248
|
+
};
|
|
249
|
+
const ownerGrants: MediaGrants = { canPublish: true, canSubscribe: false, canPublishData: false };
|
|
250
|
+
return maySubscribe({ consumerIdentity: selfIdentity, consumerGrants, producerIdentity, ownerGrants });
|
|
251
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createBrowserDevice } from "./room/browserDevice";
|
|
2
|
+
import { MediaRoom, type MediaRoomOptions } from "./room/room";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `@tribe-nest/media-client` - the browser SDK.
|
|
6
|
+
*
|
|
7
|
+
* This is the entry point that KNOWS about the browser, and the only one that
|
|
8
|
+
* pulls in `mediasoup-client`. Importing it from a Node process works right up
|
|
9
|
+
* until a transport is created, which is why the harness, the egress client and
|
|
10
|
+
* the SIP gateway import `./core` instead.
|
|
11
|
+
*
|
|
12
|
+
* Three subpaths, three audiences:
|
|
13
|
+
*
|
|
14
|
+
* `.` browsers. Room, publish, subscribe, tracks.
|
|
15
|
+
* `./react` the same, as hooks.
|
|
16
|
+
* `./core` anything that is not a browser. No DOM, no mediasoup-client.
|
|
17
|
+
* `./protocol` the wire types, for whoever only needs to speak it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export * from "./room/room";
|
|
21
|
+
export {
|
|
22
|
+
type IceServer,
|
|
23
|
+
type MediaConsumerHandle,
|
|
24
|
+
type MediaDevice,
|
|
25
|
+
type MediaDeviceFactory,
|
|
26
|
+
type MediaProducerHandle,
|
|
27
|
+
type MediaTransport,
|
|
28
|
+
type TransportDescription,
|
|
29
|
+
type TransportHandlers,
|
|
30
|
+
} from "./room/device";
|
|
31
|
+
export { createBrowserDevice } from "./room/browserDevice";
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
type DisconnectCause,
|
|
35
|
+
type MediaCoreCredentials,
|
|
36
|
+
type ProducerEntry,
|
|
37
|
+
type RoomState,
|
|
38
|
+
type SignalLogLevel,
|
|
39
|
+
} from "./core";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Connect to a room in a browser.
|
|
43
|
+
*
|
|
44
|
+
* The device is defaulted HERE rather than inside `room.ts`, so that the room
|
|
45
|
+
* logic never imports `mediasoup-client` and stays testable without a browser.
|
|
46
|
+
* Pass `device` explicitly to substitute one.
|
|
47
|
+
*/
|
|
48
|
+
export async function connectToRoom(
|
|
49
|
+
options: Omit<MediaRoomOptions, "device"> & Partial<Pick<MediaRoomOptions, "device">>,
|
|
50
|
+
): Promise<MediaRoom> {
|
|
51
|
+
const room = new MediaRoom({ ...options, device: options.device ?? createBrowserDevice });
|
|
52
|
+
await room.connect();
|
|
53
|
+
return room;
|
|
54
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@tribe-nest/media-client/protocol` is a re-export of the frozen contract and
|
|
3
|
+
* nothing else.
|
|
4
|
+
*
|
|
5
|
+
* It exists so an SDK consumer needs one dependency rather than two, and so
|
|
6
|
+
* there is a visible place that would have to be edited if anyone ever tried to
|
|
7
|
+
* "extend" a frame locally. Nothing is added here. Ever.
|
|
8
|
+
*/
|
|
9
|
+
export * from "@tribe-nest/media-protocol";
|