@protobase/client 0.58.5 → 0.58.6
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/dist/calls/index.d.ts +4 -0
- package/dist/calls/index.js +2 -0
- package/dist/calls/join-call.d.ts +64 -0
- package/dist/calls/join-call.js +177 -0
- package/dist/calls/media-link.d.ts +11 -0
- package/dist/calls/media-link.js +4 -0
- package/dist/calls/msid-tracks.js +16 -0
- package/dist/calls/negotiation.js +52 -0
- package/dist/calls/peer-link.js +194 -0
- package/dist/calls/sfu-link.js +261 -0
- package/package.json +5 -4
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { CallTrack, PublishOptions } from "./media-link.js";
|
|
2
|
+
import { Call, CallState, JoinCallOptions, joinCall } from "./join-call.js";
|
|
3
|
+
import { CallLeftReason, CallMode, CallTrackSource } from "@protobase/schema/internal";
|
|
4
|
+
export { type Call, type CallLeftReason, type CallMode, type CallState, type CallTrack, type CallTrackSource, type JoinCallOptions, type PublishOptions, joinCall };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { CallTrack, PublishOptions } from "./media-link.js";
|
|
2
|
+
import { CallLeftReason, CallTrackSource } from "@protobase/schema/internal";
|
|
3
|
+
//#region src/calls/join-call.d.ts
|
|
4
|
+
/** Where a call stands: `resuming` while the link to Protobase is restored, with media still flowing. */
|
|
5
|
+
export type CallState = {
|
|
6
|
+
state: 'connecting';
|
|
7
|
+
} | {
|
|
8
|
+
state: 'joined';
|
|
9
|
+
} | {
|
|
10
|
+
state: 'resuming';
|
|
11
|
+
} | {
|
|
12
|
+
state: 'left';
|
|
13
|
+
reason: CallLeftReason;
|
|
14
|
+
error?: unknown;
|
|
15
|
+
};
|
|
16
|
+
export type JoinCallOptions = {
|
|
17
|
+
/**
|
|
18
|
+
* The socket of the app's function that admits the member, such as `/api/functions/meeting?call=42`: a path on the
|
|
19
|
+
* page's origin, or an absolute `http(s)` or `ws(s)` URL. The browser sends its session cookie with it.
|
|
20
|
+
*/
|
|
21
|
+
url: string;
|
|
22
|
+
/** Every track of other members the member hears, whenever one comes, goes or its media arrives. */
|
|
23
|
+
onTracks?: (tracks: CallTrack[]) => void;
|
|
24
|
+
/** Who speaks how loudly, from 0 to 1, for the members whose level changed. */
|
|
25
|
+
onSpeakers?: (speakers: {
|
|
26
|
+
member: string;
|
|
27
|
+
level: number;
|
|
28
|
+
}[]) => void;
|
|
29
|
+
onState?: (state: CallState) => void;
|
|
30
|
+
/** The media server stopped one of the member's own tracks, such as after a demotion. */
|
|
31
|
+
onUnpublished?: (source: CallTrackSource) => void;
|
|
32
|
+
/** How long to wait before the `attempt`th try to restore a lost link. */
|
|
33
|
+
retryDelayMs?: (attempt: number) => number;
|
|
34
|
+
/** How many tries in a row to restore a lost link before the call is left. Default 8. */
|
|
35
|
+
maxRetries?: number;
|
|
36
|
+
WebSocket?: typeof WebSocket;
|
|
37
|
+
createPeerConnection?: (configuration: RTCConfiguration) => RTCPeerConnection;
|
|
38
|
+
};
|
|
39
|
+
export type Call = {
|
|
40
|
+
/** The member's participant in the media server. */
|
|
41
|
+
readonly participant: string;
|
|
42
|
+
/** Sends tracks under the sources they carry, such as `{ camera, microphone }`; rejects when the member may not. */
|
|
43
|
+
publish: (tracks: Partial<Record<CallTrackSource, MediaStreamTrack>>, options?: PublishOptions) => Promise<void>;
|
|
44
|
+
unpublish: (source: CallTrackSource) => Promise<void>;
|
|
45
|
+
mute: (source: CallTrackSource, muted: boolean) => void;
|
|
46
|
+
/** How large remote video tracks are shown, so the media server sends a layer that fits, or `hidden` for none. */
|
|
47
|
+
view: (sids: string[], size: {
|
|
48
|
+
width: number;
|
|
49
|
+
height: number;
|
|
50
|
+
} | 'hidden') => void;
|
|
51
|
+
/** New ICE credentials, such as after a network change; a failed connection restarts by itself. */
|
|
52
|
+
restartIce: () => Promise<void>;
|
|
53
|
+
leave: () => void;
|
|
54
|
+
/** The tracks of other members the member hears now. */
|
|
55
|
+
tracks: () => CallTrack[];
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Joins a call through the app's function socket and resolves once the member is in it. Media flows over plain
|
|
59
|
+
* `RTCPeerConnection`s, to the media server or straight to the other members, as the app's calls are configured;
|
|
60
|
+
* Protobase relays their offers and answers and decides who is in the call and who hears whom. A lost socket, such as during a deploy, is restored with the browser's own state while
|
|
61
|
+
* media keeps flowing; when the media server no longer knows the member, the call is joined again with the same tracks.
|
|
62
|
+
*/
|
|
63
|
+
export declare const joinCall: (options: JoinCallOptions) => Promise<Call>;
|
|
64
|
+
//#endregion
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { peerLink } from "./peer-link.js";
|
|
2
|
+
import { sfuLink } from "./sfu-link.js";
|
|
3
|
+
//#region src/calls/join-call.ts
|
|
4
|
+
const backoff = (attempt) => Math.min(1e4, 500 * 2 ** attempt);
|
|
5
|
+
const socketUrl = (url) => {
|
|
6
|
+
const resolved = new URL(url, globalThis.location?.href);
|
|
7
|
+
resolved.protocol = resolved.protocol.replace(/^http/, "ws");
|
|
8
|
+
return resolved.href;
|
|
9
|
+
};
|
|
10
|
+
/** Close codes after which the member is out of the call: left, or refused by the app. */
|
|
11
|
+
const leftCodes = {
|
|
12
|
+
1e3: "ended",
|
|
13
|
+
1008: "removed"
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Joins a call through the app's function socket and resolves once the member is in it. Media flows over plain
|
|
17
|
+
* `RTCPeerConnection`s, to the media server or straight to the other members, as the app's calls are configured;
|
|
18
|
+
* Protobase relays their offers and answers and decides who is in the call and who hears whom. A lost socket, such as during a deploy, is restored with the browser's own state while
|
|
19
|
+
* media keeps flowing; when the media server no longer knows the member, the call is joined again with the same tracks.
|
|
20
|
+
*/
|
|
21
|
+
const joinCall = (options) => {
|
|
22
|
+
const WebSocketOf = options.WebSocket ?? WebSocket;
|
|
23
|
+
const retryDelay = options.retryDelayMs ?? backoff;
|
|
24
|
+
const maxRetries = options.maxRetries ?? 8;
|
|
25
|
+
const url = socketUrl(options.url);
|
|
26
|
+
let socket;
|
|
27
|
+
let link;
|
|
28
|
+
let participant = "";
|
|
29
|
+
let remote = [];
|
|
30
|
+
let ended = false;
|
|
31
|
+
let rejoin = false;
|
|
32
|
+
let opened = false;
|
|
33
|
+
let resumedSinceOpen = false;
|
|
34
|
+
let failures = 0;
|
|
35
|
+
let chain = Promise.resolve();
|
|
36
|
+
let joined;
|
|
37
|
+
const result = new Promise((resolve, reject) => joined = {
|
|
38
|
+
resolve,
|
|
39
|
+
reject
|
|
40
|
+
});
|
|
41
|
+
const send = (message) => {
|
|
42
|
+
if (socket.readyState === WebSocketOf.OPEN) socket.send(JSON.stringify(message));
|
|
43
|
+
};
|
|
44
|
+
const setState = (state) => options.onState?.(state);
|
|
45
|
+
const end = (reason, error) => {
|
|
46
|
+
if (ended) return;
|
|
47
|
+
ended = true;
|
|
48
|
+
link?.close();
|
|
49
|
+
link = void 0;
|
|
50
|
+
if (socket.readyState === WebSocketOf.OPEN || socket.readyState === WebSocketOf.CONNECTING) socket.close(1e3);
|
|
51
|
+
joined.reject(error ?? /* @__PURE__ */ new Error(`The call was left: ${reason}`));
|
|
52
|
+
setState({
|
|
53
|
+
state: "left",
|
|
54
|
+
reason,
|
|
55
|
+
...error !== void 0 && { error }
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
const newLink = (joined) => {
|
|
59
|
+
const input = {
|
|
60
|
+
iceServers: joined.iceServers,
|
|
61
|
+
send,
|
|
62
|
+
onTracks: (tracks) => {
|
|
63
|
+
remote = tracks;
|
|
64
|
+
options.onTracks?.(tracks);
|
|
65
|
+
},
|
|
66
|
+
onUnpublished: (source) => options.onUnpublished?.(source),
|
|
67
|
+
...options.createPeerConnection && { createPeerConnection: options.createPeerConnection }
|
|
68
|
+
};
|
|
69
|
+
return joined.mode === "peer" ? peerLink(joined.participant, input) : sfuLink(input);
|
|
70
|
+
};
|
|
71
|
+
const handle = async (message) => {
|
|
72
|
+
switch (message.type) {
|
|
73
|
+
case "joined": {
|
|
74
|
+
const before = link?.sending();
|
|
75
|
+
link?.close();
|
|
76
|
+
participant = message.participant;
|
|
77
|
+
link = newLink(message);
|
|
78
|
+
failures = 0;
|
|
79
|
+
setState({ state: "joined" });
|
|
80
|
+
joined.resolve(call);
|
|
81
|
+
for (const [source, { track, simulcast }] of before ?? []) call.publish({ [source]: track }, { simulcast }).catch((error) => end("ended", error));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
case "resumed":
|
|
85
|
+
resumedSinceOpen = true;
|
|
86
|
+
failures = 0;
|
|
87
|
+
setState({ state: "joined" });
|
|
88
|
+
await link?.resumed();
|
|
89
|
+
return;
|
|
90
|
+
case "speakers":
|
|
91
|
+
options.onSpeakers?.(message.speakers);
|
|
92
|
+
return;
|
|
93
|
+
case "left":
|
|
94
|
+
if (message.reason === "rejoin") rejoin = true;
|
|
95
|
+
else end(message.reason);
|
|
96
|
+
return;
|
|
97
|
+
default: await link?.receive(message);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const connect = (mode) => {
|
|
101
|
+
opened = false;
|
|
102
|
+
resumedSinceOpen = false;
|
|
103
|
+
socket = new WebSocketOf(url);
|
|
104
|
+
socket.onopen = () => {
|
|
105
|
+
opened = true;
|
|
106
|
+
chain = chain.then(async () => {
|
|
107
|
+
const resume = mode === "resume" && link ? await link.resumeState(participant) : void 0;
|
|
108
|
+
send(resume ? {
|
|
109
|
+
type: "resume",
|
|
110
|
+
...resume
|
|
111
|
+
} : { type: "join" });
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
socket.onmessage = (event) => {
|
|
115
|
+
const message = JSON.parse(String(event.data));
|
|
116
|
+
chain = chain.then(() => handle(message)).catch((error) => end("ended", error));
|
|
117
|
+
};
|
|
118
|
+
socket.onclose = (event) => {
|
|
119
|
+
chain = chain.then(() => closed(mode, event.code));
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
const closed = (mode, code) => {
|
|
123
|
+
if (ended) return;
|
|
124
|
+
if (rejoin) {
|
|
125
|
+
rejoin = false;
|
|
126
|
+
return connect("join");
|
|
127
|
+
}
|
|
128
|
+
const left = leftCodes[code];
|
|
129
|
+
if (left) return end(left);
|
|
130
|
+
if (!participant) return end("refused");
|
|
131
|
+
failures++;
|
|
132
|
+
if (failures > maxRetries) return end("ended");
|
|
133
|
+
setState({ state: "resuming" });
|
|
134
|
+
const next = mode === "resume" && opened && !resumedSinceOpen ? "join" : "resume";
|
|
135
|
+
setTimeout(() => {
|
|
136
|
+
if (!ended) connect(next);
|
|
137
|
+
}, retryDelay(failures - 1));
|
|
138
|
+
};
|
|
139
|
+
const call = {
|
|
140
|
+
get participant() {
|
|
141
|
+
return participant;
|
|
142
|
+
},
|
|
143
|
+
publish: async (tracks, publishOptions) => {
|
|
144
|
+
if (!link) throw new Error("The call was left");
|
|
145
|
+
await link.publish(tracks, publishOptions);
|
|
146
|
+
},
|
|
147
|
+
unpublish: async (source) => {
|
|
148
|
+
await link?.unpublish(source);
|
|
149
|
+
},
|
|
150
|
+
mute: (source, muted) => link?.mute(source, muted),
|
|
151
|
+
view: (sids, size) => send(size === "hidden" ? {
|
|
152
|
+
type: "view",
|
|
153
|
+
sids,
|
|
154
|
+
width: 0,
|
|
155
|
+
height: 0,
|
|
156
|
+
hidden: true
|
|
157
|
+
} : {
|
|
158
|
+
type: "view",
|
|
159
|
+
sids,
|
|
160
|
+
...size
|
|
161
|
+
}),
|
|
162
|
+
restartIce: async () => {
|
|
163
|
+
await link?.restartIce();
|
|
164
|
+
},
|
|
165
|
+
leave: () => {
|
|
166
|
+
if (ended) return;
|
|
167
|
+
send({ type: "leave" });
|
|
168
|
+
end("ended");
|
|
169
|
+
},
|
|
170
|
+
tracks: () => remote
|
|
171
|
+
};
|
|
172
|
+
setState({ state: "connecting" });
|
|
173
|
+
connect("join");
|
|
174
|
+
return result;
|
|
175
|
+
};
|
|
176
|
+
//#endregion
|
|
177
|
+
export { joinCall };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { CallRemoteTrack, CallTrackSource } from "@protobase/schema/internal";
|
|
2
|
+
//#region src/calls/media-link.d.ts
|
|
3
|
+
/** A track of another member, with its media once the browser receives it. */
|
|
4
|
+
export type CallTrack = CallRemoteTrack & {
|
|
5
|
+
track?: MediaStreamTrack;
|
|
6
|
+
};
|
|
7
|
+
/** How a published video track is sent: with a media server, simulcast sends it in three sizes the server picks from. */
|
|
8
|
+
export type PublishOptions = {
|
|
9
|
+
simulcast?: boolean;
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/calls/msid-tracks.ts
|
|
2
|
+
/**
|
|
3
|
+
* The `mid` of each media section of a description by the sending browser's track id, which its `a=msid` line names: a
|
|
4
|
+
* receiver's own track may have another id, when the browser reused one of its transceivers for the section.
|
|
5
|
+
*/
|
|
6
|
+
const midsByTrackId = (sdp) => {
|
|
7
|
+
const mids = /* @__PURE__ */ new Map();
|
|
8
|
+
for (const section of sdp.split(/\r?\nm=/).slice(1)) {
|
|
9
|
+
const mid = /^a=mid:(\S+)/m.exec(section)?.[1];
|
|
10
|
+
const track = /^a=msid:\S+ (\S+)/m.exec(section)?.[1];
|
|
11
|
+
if (mid !== void 0 && track !== void 0) mids.set(track, mid);
|
|
12
|
+
}
|
|
13
|
+
return mids;
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { midsByTrackId };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
//#region src/calls/negotiation.ts
|
|
2
|
+
/**
|
|
3
|
+
* One offer in flight at a time: a change while an offer waits for its answer offers once more after it. `settled`
|
|
4
|
+
* resolves once no offer is in flight or queued, so a caller can wait for its change to be agreed.
|
|
5
|
+
*/
|
|
6
|
+
const negotiation = (offer) => {
|
|
7
|
+
let inFlight = false;
|
|
8
|
+
let again = false;
|
|
9
|
+
const waiting = [];
|
|
10
|
+
const start = async () => {
|
|
11
|
+
inFlight = true;
|
|
12
|
+
again = false;
|
|
13
|
+
await offer();
|
|
14
|
+
};
|
|
15
|
+
const settle = () => {
|
|
16
|
+
for (const resolve of waiting.splice(0)) resolve();
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
/** Offers now, or once the offer in flight is answered. */
|
|
20
|
+
request: async () => {
|
|
21
|
+
if (inFlight) {
|
|
22
|
+
again = true;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
await start();
|
|
26
|
+
},
|
|
27
|
+
/** The answer to the offer in flight arrived: offers again when a change came meanwhile. */
|
|
28
|
+
answered: async () => {
|
|
29
|
+
inFlight = false;
|
|
30
|
+
if (again) return start();
|
|
31
|
+
settle();
|
|
32
|
+
},
|
|
33
|
+
/** The offer in flight will not be answered, such as on a lost session: it is made again by the next `request`. */
|
|
34
|
+
abandon: () => {
|
|
35
|
+
if (inFlight) again = true;
|
|
36
|
+
inFlight = false;
|
|
37
|
+
},
|
|
38
|
+
/** Whether an offer waits to be made again after `abandon`. */
|
|
39
|
+
get pending() {
|
|
40
|
+
return again;
|
|
41
|
+
},
|
|
42
|
+
settled: () => inFlight || again ? new Promise((resolve) => waiting.push(resolve)) : Promise.resolve(),
|
|
43
|
+
/** Forgets every offer, for a new peer connection; whoever waited is let go. */
|
|
44
|
+
reset: () => {
|
|
45
|
+
inFlight = false;
|
|
46
|
+
again = false;
|
|
47
|
+
settle();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { negotiation };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { createPeerConnection } from "./media-link.js";
|
|
2
|
+
import { midsByTrackId } from "./msid-tracks.js";
|
|
3
|
+
//#region src/calls/peer-link.ts
|
|
4
|
+
/**
|
|
5
|
+
* The media link without a media server: one `RTCPeerConnection` to each other member, whose browser answers, so either
|
|
6
|
+
* side may offer. Protobase passes the descriptions and candidates between the two ("perfect negotiation": the member
|
|
7
|
+
* whose id sorts first gives way when both offer at once) and says who is in the call and which tracks each member
|
|
8
|
+
* hears. A track goes by the id the sending browser gave it, which the receiving browser sees too.
|
|
9
|
+
*/
|
|
10
|
+
const peerLink = (self, { iceServers, send, onTracks, createPeerConnection: createPeerConnection$1 = createPeerConnection }) => {
|
|
11
|
+
const peers = /* @__PURE__ */ new Map();
|
|
12
|
+
const local = /* @__PURE__ */ new Map();
|
|
13
|
+
let remote = [];
|
|
14
|
+
let publishing;
|
|
15
|
+
let publishes = Promise.resolve();
|
|
16
|
+
const sendOn = (pc, track) => pc.addTransceiver(track, { direction: "sendonly" }).sender;
|
|
17
|
+
const receivedTrack = ({ member, sid }) => {
|
|
18
|
+
const pc = peers.get(member)?.pc;
|
|
19
|
+
const mid = pc?.remoteDescription && midsByTrackId(pc.remoteDescription.sdp).get(sid);
|
|
20
|
+
return mid === void 0 ? void 0 : pc.getTransceivers().find((transceiver) => transceiver.mid === mid)?.receiver.track;
|
|
21
|
+
};
|
|
22
|
+
const emitTracks = () => onTracks(remote.map((track) => ({
|
|
23
|
+
...track,
|
|
24
|
+
...receivedTrack(track) && { track: receivedTrack(track) }
|
|
25
|
+
})));
|
|
26
|
+
const connect = (member) => {
|
|
27
|
+
const pc = createPeerConnection$1({ iceServers });
|
|
28
|
+
const peer = {
|
|
29
|
+
pc,
|
|
30
|
+
polite: self < member,
|
|
31
|
+
makingOffer: false,
|
|
32
|
+
ignoreOffer: false,
|
|
33
|
+
senders: /* @__PURE__ */ new Map()
|
|
34
|
+
};
|
|
35
|
+
peers.set(member, peer);
|
|
36
|
+
pc.onnegotiationneeded = async () => {
|
|
37
|
+
peer.makingOffer = true;
|
|
38
|
+
try {
|
|
39
|
+
await pc.setLocalDescription();
|
|
40
|
+
send({
|
|
41
|
+
type: "signal",
|
|
42
|
+
to: member,
|
|
43
|
+
description: pc.localDescription.toJSON()
|
|
44
|
+
});
|
|
45
|
+
} finally {
|
|
46
|
+
peer.makingOffer = false;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
pc.onicecandidate = (event) => {
|
|
50
|
+
if (event.candidate) send({
|
|
51
|
+
type: "signal",
|
|
52
|
+
to: member,
|
|
53
|
+
candidate: event.candidate.toJSON()
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
pc.oniceconnectionstatechange = () => {
|
|
57
|
+
if (pc.iceConnectionState === "failed") pc.restartIce();
|
|
58
|
+
};
|
|
59
|
+
pc.ontrack = () => emitTracks();
|
|
60
|
+
for (const [source, { track }] of local) peer.senders.set(source, sendOn(pc, track));
|
|
61
|
+
return peer;
|
|
62
|
+
};
|
|
63
|
+
const disconnect = (member) => {
|
|
64
|
+
peers.get(member)?.pc.close();
|
|
65
|
+
peers.delete(member);
|
|
66
|
+
};
|
|
67
|
+
const signal = async (from, description, candidate) => {
|
|
68
|
+
const peer = peers.get(from) ?? connect(from);
|
|
69
|
+
const { pc } = peer;
|
|
70
|
+
if (description) {
|
|
71
|
+
const collision = description.type === "offer" && (peer.makingOffer || pc.signalingState !== "stable");
|
|
72
|
+
peer.ignoreOffer = !peer.polite && collision;
|
|
73
|
+
if (peer.ignoreOffer) return;
|
|
74
|
+
await pc.setRemoteDescription(description);
|
|
75
|
+
if (description.type === "offer") {
|
|
76
|
+
await pc.setLocalDescription();
|
|
77
|
+
send({
|
|
78
|
+
type: "signal",
|
|
79
|
+
to: from,
|
|
80
|
+
description: pc.localDescription.toJSON()
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
emitTracks();
|
|
84
|
+
}
|
|
85
|
+
if (candidate) await pc.addIceCandidate(candidate).catch((error) => {
|
|
86
|
+
if (!peer.ignoreOffer) throw error;
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
const receive = async (message) => {
|
|
90
|
+
switch (message.type) {
|
|
91
|
+
case "peers": {
|
|
92
|
+
const present = new Set(message.members);
|
|
93
|
+
for (const member of peers.keys()) if (!present.has(member)) disconnect(member);
|
|
94
|
+
for (const member of present) if (!peers.has(member)) connect(member);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
case "signal": return signal(message.from, message.description, message.candidate);
|
|
98
|
+
case "tracks":
|
|
99
|
+
remote = message.tracks;
|
|
100
|
+
emitTracks();
|
|
101
|
+
return;
|
|
102
|
+
case "published":
|
|
103
|
+
publishing?.resolve();
|
|
104
|
+
return;
|
|
105
|
+
case "publish-refused":
|
|
106
|
+
publishing?.reject(new Error(message.reason));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const publishNow = async (tracks) => {
|
|
111
|
+
const added = Object.entries(tracks);
|
|
112
|
+
for (const [source] of added) if (local.has(source)) throw new Error(`The call already sends a ${source} track: unpublish it first`);
|
|
113
|
+
if (added.length === 0) return;
|
|
114
|
+
const answered = new Promise((resolve, reject) => publishing = {
|
|
115
|
+
resolve,
|
|
116
|
+
reject
|
|
117
|
+
});
|
|
118
|
+
send({
|
|
119
|
+
type: "publish",
|
|
120
|
+
tracks: added.map(([source, track]) => ({
|
|
121
|
+
cid: track.id,
|
|
122
|
+
kind: track.kind,
|
|
123
|
+
source
|
|
124
|
+
}))
|
|
125
|
+
});
|
|
126
|
+
try {
|
|
127
|
+
await answered;
|
|
128
|
+
} finally {
|
|
129
|
+
publishing = void 0;
|
|
130
|
+
}
|
|
131
|
+
for (const [source, track] of added) {
|
|
132
|
+
local.set(source, {
|
|
133
|
+
track,
|
|
134
|
+
sid: track.id
|
|
135
|
+
});
|
|
136
|
+
for (const peer of peers.values()) peer.senders.set(source, sendOn(peer.pc, track));
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
receive,
|
|
141
|
+
publish: (tracks) => {
|
|
142
|
+
const next = publishes.then(() => publishNow(tracks));
|
|
143
|
+
publishes = next.catch(() => {});
|
|
144
|
+
return next;
|
|
145
|
+
},
|
|
146
|
+
unpublish: async (source) => {
|
|
147
|
+
const sending = local.get(source);
|
|
148
|
+
if (!sending) return;
|
|
149
|
+
local.delete(source);
|
|
150
|
+
for (const peer of peers.values()) {
|
|
151
|
+
const sender = peer.senders.get(source);
|
|
152
|
+
if (sender && peer.pc.signalingState !== "closed") peer.pc.removeTrack(sender);
|
|
153
|
+
peer.senders.delete(source);
|
|
154
|
+
}
|
|
155
|
+
send({
|
|
156
|
+
type: "unpublish",
|
|
157
|
+
sid: sending.sid
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
mute: (source, muted) => {
|
|
161
|
+
const sending = local.get(source);
|
|
162
|
+
if (!sending) return;
|
|
163
|
+
sending.track.enabled = !muted;
|
|
164
|
+
send({
|
|
165
|
+
type: "mute",
|
|
166
|
+
sid: sending.sid,
|
|
167
|
+
muted
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
restartIce: async () => {
|
|
171
|
+
for (const peer of peers.values()) peer.pc.restartIce();
|
|
172
|
+
},
|
|
173
|
+
resumeState: async (participant) => ({
|
|
174
|
+
participant,
|
|
175
|
+
tracks: [...local].map(([source, { track, sid }]) => ({
|
|
176
|
+
cid: track.id,
|
|
177
|
+
sid,
|
|
178
|
+
kind: track.kind,
|
|
179
|
+
source
|
|
180
|
+
}))
|
|
181
|
+
}),
|
|
182
|
+
resumed: async () => {},
|
|
183
|
+
sending: () => new Map([...local].map(([source, { track }]) => [source, {
|
|
184
|
+
track,
|
|
185
|
+
simulcast: false
|
|
186
|
+
}])),
|
|
187
|
+
close: () => {
|
|
188
|
+
publishing?.reject(/* @__PURE__ */ new Error("The call ended"));
|
|
189
|
+
for (const member of [...peers.keys()]) disconnect(member);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
//#endregion
|
|
194
|
+
export { peerLink };
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { createPeerConnection } from "./media-link.js";
|
|
2
|
+
import { negotiation } from "./negotiation.js";
|
|
3
|
+
//#region src/calls/sfu-link.ts
|
|
4
|
+
const simulcastLayers = [
|
|
5
|
+
{
|
|
6
|
+
rid: "q",
|
|
7
|
+
quality: "low",
|
|
8
|
+
scale: 4,
|
|
9
|
+
maxBitrate: 15e4
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
rid: "h",
|
|
13
|
+
quality: "medium",
|
|
14
|
+
scale: 2,
|
|
15
|
+
maxBitrate: 5e5
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
rid: "f",
|
|
19
|
+
quality: "high",
|
|
20
|
+
scale: 1,
|
|
21
|
+
maxBitrate: 12e5
|
|
22
|
+
}
|
|
23
|
+
];
|
|
24
|
+
const singleBitrate = 12e5;
|
|
25
|
+
const trackRequest = (source, track, simulcast) => {
|
|
26
|
+
if (track.kind === "audio") return {
|
|
27
|
+
cid: track.id,
|
|
28
|
+
kind: "audio",
|
|
29
|
+
source
|
|
30
|
+
};
|
|
31
|
+
const { width = 0, height = 0 } = track.getSettings();
|
|
32
|
+
const layers = simulcast ? simulcastLayers.map((layer) => ({
|
|
33
|
+
quality: layer.quality,
|
|
34
|
+
width: Math.round(width / layer.scale),
|
|
35
|
+
height: Math.round(height / layer.scale),
|
|
36
|
+
bitrate: layer.maxBitrate
|
|
37
|
+
})) : [];
|
|
38
|
+
return {
|
|
39
|
+
cid: track.id,
|
|
40
|
+
kind: "video",
|
|
41
|
+
source,
|
|
42
|
+
width,
|
|
43
|
+
height,
|
|
44
|
+
...simulcast && { layers }
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const sendEncodings = (track, simulcast) => {
|
|
48
|
+
if (track.kind === "audio") return void 0;
|
|
49
|
+
if (!simulcast) return [{ maxBitrate: singleBitrate }];
|
|
50
|
+
return simulcastLayers.map((layer) => ({
|
|
51
|
+
rid: layer.rid,
|
|
52
|
+
scaleResolutionDownBy: layer.scale,
|
|
53
|
+
maxBitrate: layer.maxBitrate
|
|
54
|
+
}));
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The media link to a media server, over a single `RTCPeerConnection`: the browser always offers, one offer at a time,
|
|
58
|
+
* and the server's answer maps each receiving section's `mid` to the track it carries. The link holds no media library;
|
|
59
|
+
* everything it says goes through `send` to Protobase, which speaks to the media server.
|
|
60
|
+
*/
|
|
61
|
+
const sfuLink = ({ iceServers, send, onTracks, onUnpublished, createPeerConnection: createPeerConnection$1 = createPeerConnection }) => {
|
|
62
|
+
const pc = createPeerConnection$1({
|
|
63
|
+
iceServers,
|
|
64
|
+
bundlePolicy: "max-bundle"
|
|
65
|
+
});
|
|
66
|
+
const local = /* @__PURE__ */ new Map();
|
|
67
|
+
const early = [];
|
|
68
|
+
let mids = {};
|
|
69
|
+
let remote = [];
|
|
70
|
+
let offerId = 0;
|
|
71
|
+
let publishing;
|
|
72
|
+
let publishes = Promise.resolve();
|
|
73
|
+
const offers = negotiation(async () => {
|
|
74
|
+
const offer = await pc.createOffer();
|
|
75
|
+
await pc.setLocalDescription(offer);
|
|
76
|
+
send({
|
|
77
|
+
type: "offer",
|
|
78
|
+
sdp: offer.sdp,
|
|
79
|
+
id: ++offerId
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
const offer = async () => {
|
|
83
|
+
if (pc.getTransceivers().length > 0) await offers.request();
|
|
84
|
+
};
|
|
85
|
+
const negotiate = async () => {
|
|
86
|
+
await offer();
|
|
87
|
+
await offers.settled();
|
|
88
|
+
};
|
|
89
|
+
const receivedTrack = (sid) => {
|
|
90
|
+
const mid = Object.keys(mids).find((key) => mids[key] === sid);
|
|
91
|
+
return mid === void 0 ? void 0 : pc.getTransceivers().find((transceiver) => transceiver.mid === mid)?.receiver.track;
|
|
92
|
+
};
|
|
93
|
+
const emitTracks = () => onTracks(remote.map((track) => ({
|
|
94
|
+
...track,
|
|
95
|
+
...receivedTrack(track.sid) && { track: receivedTrack(track.sid) }
|
|
96
|
+
})));
|
|
97
|
+
pc.onicecandidate = (event) => {
|
|
98
|
+
if (event.candidate) send({
|
|
99
|
+
type: "candidate",
|
|
100
|
+
candidate: event.candidate.toJSON()
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
pc.oniceconnectionstatechange = () => {
|
|
104
|
+
if (pc.iceConnectionState === "failed") restartIce();
|
|
105
|
+
};
|
|
106
|
+
pc.ontrack = () => emitTracks();
|
|
107
|
+
const restartIce = async () => {
|
|
108
|
+
pc.restartIce();
|
|
109
|
+
await negotiate();
|
|
110
|
+
};
|
|
111
|
+
const stopSending = (source) => {
|
|
112
|
+
const sending = local.get(source);
|
|
113
|
+
if (!sending) return false;
|
|
114
|
+
local.delete(source);
|
|
115
|
+
if (pc.signalingState !== "closed") pc.removeTrack(sending.transceiver.sender);
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
118
|
+
const receive = async (message) => {
|
|
119
|
+
switch (message.type) {
|
|
120
|
+
case "answer":
|
|
121
|
+
if (message.id !== offerId) return;
|
|
122
|
+
mids = {
|
|
123
|
+
...mids,
|
|
124
|
+
...message.mids
|
|
125
|
+
};
|
|
126
|
+
await pc.setRemoteDescription({
|
|
127
|
+
type: "answer",
|
|
128
|
+
sdp: message.sdp
|
|
129
|
+
});
|
|
130
|
+
for (const candidate of early.splice(0)) await pc.addIceCandidate(candidate);
|
|
131
|
+
emitTracks();
|
|
132
|
+
await offers.answered();
|
|
133
|
+
return;
|
|
134
|
+
case "candidate":
|
|
135
|
+
if (pc.remoteDescription) await pc.addIceCandidate(message.candidate);
|
|
136
|
+
else early.push(message.candidate);
|
|
137
|
+
return;
|
|
138
|
+
case "sections":
|
|
139
|
+
for (let i = 0; i < message.audio; i++) pc.addTransceiver("audio", { direction: "recvonly" });
|
|
140
|
+
for (let i = 0; i < message.video; i++) pc.addTransceiver("video", { direction: "recvonly" });
|
|
141
|
+
await offer();
|
|
142
|
+
return;
|
|
143
|
+
case "tracks":
|
|
144
|
+
remote = message.tracks;
|
|
145
|
+
emitTracks();
|
|
146
|
+
return;
|
|
147
|
+
case "unpublished": {
|
|
148
|
+
const sending = [...local.values()].find((track) => track.sid === message.sid);
|
|
149
|
+
if (!sending) return;
|
|
150
|
+
stopSending(sending.source);
|
|
151
|
+
await offer();
|
|
152
|
+
onUnpublished(sending.source);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
case "published":
|
|
156
|
+
publishing?.resolve(message.tracks);
|
|
157
|
+
return;
|
|
158
|
+
case "publish-refused":
|
|
159
|
+
publishing?.reject(new Error(message.reason));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const publishNow = async (tracks, options) => {
|
|
164
|
+
const added = Object.entries(tracks).map(([source, track]) => {
|
|
165
|
+
if (local.has(source)) throw new Error(`The call already sends a ${source} track: unpublish it first`);
|
|
166
|
+
const simulcast = track.kind === "video" && Boolean(options.simulcast);
|
|
167
|
+
const encodings = sendEncodings(track, simulcast);
|
|
168
|
+
return {
|
|
169
|
+
source,
|
|
170
|
+
track,
|
|
171
|
+
transceiver: pc.addTransceiver(track, {
|
|
172
|
+
direction: "sendonly",
|
|
173
|
+
...encodings && { sendEncodings: encodings }
|
|
174
|
+
}),
|
|
175
|
+
simulcast
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
if (added.length === 0) return;
|
|
179
|
+
const answered = new Promise((resolve, reject) => publishing = {
|
|
180
|
+
resolve,
|
|
181
|
+
reject
|
|
182
|
+
});
|
|
183
|
+
send({
|
|
184
|
+
type: "publish",
|
|
185
|
+
tracks: added.map((track) => trackRequest(track.source, track.track, track.simulcast))
|
|
186
|
+
});
|
|
187
|
+
try {
|
|
188
|
+
const sids = await answered;
|
|
189
|
+
for (const track of added) local.set(track.source, {
|
|
190
|
+
...track,
|
|
191
|
+
sid: sids.find((sid) => sid.cid === track.track.id).sid
|
|
192
|
+
});
|
|
193
|
+
} catch (error) {
|
|
194
|
+
for (const track of added) track.transceiver.stop();
|
|
195
|
+
throw error;
|
|
196
|
+
} finally {
|
|
197
|
+
publishing = void 0;
|
|
198
|
+
}
|
|
199
|
+
await negotiate();
|
|
200
|
+
};
|
|
201
|
+
return {
|
|
202
|
+
receive,
|
|
203
|
+
/** Sends the tracks, under the sources they carry; rejects when the member may not send them. One publish at a time. */
|
|
204
|
+
publish: (tracks, options = {}) => {
|
|
205
|
+
const next = publishes.then(() => publishNow(tracks, options));
|
|
206
|
+
publishes = next.catch(() => {});
|
|
207
|
+
return next;
|
|
208
|
+
},
|
|
209
|
+
/** Stops sending the source's track; the track itself goes on until the app stops it. */
|
|
210
|
+
unpublish: async (source) => {
|
|
211
|
+
if (stopSending(source)) await negotiate();
|
|
212
|
+
},
|
|
213
|
+
mute: (source, muted) => {
|
|
214
|
+
const sending = local.get(source);
|
|
215
|
+
if (!sending?.sid) return;
|
|
216
|
+
sending.track.enabled = !muted;
|
|
217
|
+
send({
|
|
218
|
+
type: "mute",
|
|
219
|
+
sid: sending.sid,
|
|
220
|
+
muted
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
restartIce,
|
|
224
|
+
/** What a new server session needs to resume this link, after an offer still in flight is taken back. */
|
|
225
|
+
resumeState: async (participant) => {
|
|
226
|
+
if (pc.signalingState === "have-local-offer") {
|
|
227
|
+
await pc.setLocalDescription({ type: "rollback" });
|
|
228
|
+
offers.abandon();
|
|
229
|
+
}
|
|
230
|
+
if (!pc.localDescription || !pc.remoteDescription) return void 0;
|
|
231
|
+
const tracks = [...local.values()].flatMap((track) => track.sid ? [{
|
|
232
|
+
cid: track.track.id,
|
|
233
|
+
sid: track.sid,
|
|
234
|
+
kind: track.track.kind,
|
|
235
|
+
source: track.source
|
|
236
|
+
}] : []);
|
|
237
|
+
return {
|
|
238
|
+
participant,
|
|
239
|
+
offer: pc.localDescription.sdp,
|
|
240
|
+
answer: pc.remoteDescription.sdp,
|
|
241
|
+
tracks
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
/** After a resume: offers what changed while the session was lost. */
|
|
245
|
+
resumed: async () => {
|
|
246
|
+
if (offers.pending) await offer();
|
|
247
|
+
},
|
|
248
|
+
/** The tracks the member sends, by source, to publish again after a rejoin. */
|
|
249
|
+
sending: () => new Map([...local.values()].map((track) => [track.source, {
|
|
250
|
+
track: track.track,
|
|
251
|
+
simulcast: track.simulcast
|
|
252
|
+
}])),
|
|
253
|
+
close: () => {
|
|
254
|
+
publishing?.reject(/* @__PURE__ */ new Error("The call ended"));
|
|
255
|
+
offers.reset();
|
|
256
|
+
pc.close();
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
};
|
|
260
|
+
//#endregion
|
|
261
|
+
export { sfuLink };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/client",
|
|
3
|
-
"version": "0.58.
|
|
3
|
+
"version": "0.58.6",
|
|
4
4
|
"description": "Typed fetch client for the Protobase REST API.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"node": ">=22.12.0"
|
|
16
16
|
},
|
|
17
17
|
"exports": {
|
|
18
|
-
".": "./dist/index.js"
|
|
18
|
+
".": "./dist/index.js",
|
|
19
|
+
"./calls": "./dist/calls/index.js"
|
|
19
20
|
},
|
|
20
21
|
"types": "./dist/index.d.ts",
|
|
21
22
|
"files": [
|
|
@@ -30,8 +31,8 @@
|
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@better-auth/passkey": "^1.7.12",
|
|
33
|
-
"@protobase/layout": "0.58.
|
|
34
|
-
"@protobase/schema": "0.58.
|
|
34
|
+
"@protobase/layout": "0.58.6",
|
|
35
|
+
"@protobase/schema": "0.58.6",
|
|
35
36
|
"@simplewebauthn/browser": "^13.3.0",
|
|
36
37
|
"better-auth": "^1.7.12"
|
|
37
38
|
}
|