xref: /xiu/protocol/webrtc/src/clients/whep.js (revision 80f20d70)
1const Extensions = {
2    Core: {
3        ServerSentEvents: "urn:ietf:params:whep:ext:core:server-sent-events",
4        Layer: "urn:ietf:params:whep:ext:core:layer",
5    }
6}
7
8
9class WHEPClient extends EventTarget {
10    constructor() {
11        super();
12        //Ice properties
13        this.iceUsername = null;
14        this.icePassword = null;
15        //Pending candidadtes
16        this.candidates = [];
17        this.endOfcandidates = false;
18    }
19
20    async view(pc, url, token) {
21        //If already publishing
22        if (this.pc)
23            throw new Error("Already viewing")
24
25        //Store pc object and token
26        this.token = token;
27        this.pc = pc;
28
29        //Listen for state change events
30        pc.onconnectionstatechange = (event) => {
31            switch (pc.connectionState) {
32                case "connected":
33                    // The connection has become fully connected
34                    break;
35                case "disconnected":
36                case "failed":
37                    // One or more transports has terminated unexpectedly or in an error
38                    break;
39                case "closed":
40                    // The connection has been closed
41                    break;
42            }
43        }
44
45        //Listen for candidates
46        pc.onicecandidate = (event) => {
47
48            if (event.candidate) {
49                //Ignore candidates not from the first m line
50                if (event.candidate.sdpMLineIndex > 0)
51                    //Skip
52                    return;
53                //Store candidate
54                this.candidates.push(event.candidate);
55            } else {
56                //No more candidates
57                this.endOfcandidates = true;
58            }
59            //Schedule trickle on next tick
60            if (!this.iceTrickeTimeout)
61                this.iceTrickeTimeout = setTimeout(() => this.trickle(), 0);
62        }
63        //Create SDP offer
64        const offer = await pc.createOffer();
65
66        //Request headers
67        const headers = {
68            "Content-Type": "application/sdp"
69        };
70
71        //If token is set
72        if (token)
73            headers["Authorization"] = "Bearer " + token;
74
75        //Do the post request to the WHEP endpoint with the SDP offer
76        const fetched = await fetch(url, {
77            method: "POST",
78            body: offer.sdp,
79            headers
80        });
81
82        if (!fetched.ok)
83            throw new Error("Request rejected with status " + fetched.status)
84        if (!fetched.headers.get("location"))
85            throw new Error("Response missing location header")
86
87        //Get the resource url
88        this.resourceURL = new URL(fetched.headers.get("location"), url);
89
90        //Get the links
91        const links = {};
92
93        //If the response contained any
94        if (fetched.headers.has("link")) {
95            //Get all links headers
96            const linkHeaders = fetched.headers.get("link").split(/,\s+(?=<)/)
97
98            //For each one
99            for (const header of linkHeaders) {
100                try {
101                    let rel, params = {};
102                    //Split in parts
103                    const items = header.split(";");
104                    //Create url server
105                    const url = items[0].trim().replace(/<(.*)>/, "$1").trim();
106                    //For each other item
107                    for (let i = 1; i < items.length; ++i) {
108                        //Split into key/val
109                        const subitems = items[i].split(/=(.*)/);
110                        //Get key
111                        const key = subitems[0].trim();
112                        //Unquote value
113                        const value = subitems[1]
114                            ? subitems[1]
115                                .trim()
116                                .replaceAll('"', '')
117                                .replaceAll("'", "")
118                            : subitems[1];
119                        //Check if it is the rel attribute
120                        if (key == "rel")
121                            //Get rel value
122                            rel = value;
123                        else
124                            //Unquote value and set them
125                            params[key] = value
126                    }
127                    //Ensure it is an ice server
128                    if (!rel)
129                        continue;
130                    if (!links[rel])
131                        links[rel] = [];
132                    //Add to config
133                    links[rel].push({ url, params });
134                } catch (e) {
135                    console.error(e)
136                }
137            }
138        }
139
140        //Get extensions url
141        if (links.hasOwnProperty(Extensions.Core.ServerSentEvents))
142            //Get url
143            this.eventsUrl = new URL(links[Extensions.Core.ServerSentEvents][0].url, url);
144        if (links.hasOwnProperty(Extensions.Core.Layer))
145            this.layerUrl = new URL(links[Extensions.Core.Layer][0].url, url);
146
147        //If we have an event url
148        if (this.eventsUrl) {
149            //Get supported events
150            const events = links[Extensions.Core.ServerSentEvents]["events"]
151                ? links[Extensions.Core.ServerSentEvents]["events"].split(" ")
152                : ["active", "inactive", "layers", "viewercount"];
153            //Request headers
154            const headers = {
155                "Content-Type": "application/json"
156            };
157
158            //If token is set
159            if (this.token)
160                headers["Authorization"] = "Bearer " + this.token;
161
162            //Do the post request to the whep resource
163            fetch(this.eventsUrl, {
164                method: "POST",
165                body: JSON.stringify(events),
166                headers
167            }).then((fetched) => {
168                //If the event channel could be created
169                if (!fetched.ok)
170                    return;
171                //Get the resource url
172                const sseUrl = new URL(fetched.headers.get("location"), this.eventsUrl);
173                //Open it
174                this.eventSource = new EventSource(sseUrl);
175                this.eventSource.onopen = (event) => console.log(event);
176                this.eventSource.onerror = (event) => console.log(event);
177                //Listen for events
178                this.eventSource.onmessage = (event) => {
179                    console.dir(event);
180                    this.dispatchEvent(event);
181                };
182            });
183        }
184
185        //Get current config
186        const config = pc.getConfiguration();
187
188        //If it has ice server info and it is not overriden by the client
189        if ((!config.iceServer || !config.iceServer.length) && links.hasOwnProperty("ice-server")) {
190            //ICe server config
191            config.iceServers = [];
192
193            //For each one
194            for (const server of links["ice-server"]) {
195                try {
196                    //Create ice server
197                    const iceServer = {
198                        urls: server.url
199                    }
200                    //For each other param
201                    for (const [key, value] of Object.entries(server.params)) {
202                        //Get key in cammel case
203                        const cammelCase = key.replace(/([-_][a-z])/ig, $1 => $1.toUpperCase().replace('-', '').replace('_', ''))
204                        //Unquote value and set them
205                        iceServer[cammelCase] = value;
206                    }
207                    //Add to config
208                    //config.iceServers.push(iceServer);
209                } catch (e) {
210                }
211            }
212
213            //If any configured
214            if (config.iceServers.length)
215                //Set it
216                pc.setConfiguration(config);
217        }
218
219        //Get the SDP answer
220        const answer = await fetched.text();
221
222        //Schedule trickle on next tick
223        if (!this.iceTrickeTimeout)
224            this.iceTrickeTimeout = setTimeout(() => this.trickle(), 0);
225
226        //Set local description
227        await pc.setLocalDescription(offer);
228
229        // TODO: chrome is returning a wrong value, so don't use it for now
230        //try {
231        //	//Get local ice properties
232        //	const local = this.pc.getTransceivers()[0].sender.transport.iceTransport.getLocalParameters();
233        //	//Get them for transport
234        //	this.iceUsername = local.usernameFragment;
235        //	this.icePassword = local.password;
236        //} catch (e) {
237        //Fallback for browsers not supporting ice transport
238        this.iceUsername = offer.sdp.match(/a=ice-ufrag:(.*)\r\n/)[1];
239        this.icePassword = offer.sdp.match(/a=ice-pwd:(.*)\r\n/)[1];
240        //}
241
242        //And set remote description
243        await pc.setRemoteDescription({ type: "answer", sdp: answer });
244    }
245
246    restart() {
247        //Set restart flag
248        this.restartIce = true;
249
250        //Schedule trickle on next tick
251        if (!this.iceTrickeTimeout)
252            this.iceTrickeTimeout = setTimeout(() => this.trickle(), 0);
253    }
254
255    async trickle() {
256        return;
257        //Clear timeout
258        this.iceTrickeTimeout = null;
259
260        //Check if there is any pending data
261        if (!(this.candidates.length || this.endOfcandidates || this.restartIce) || !this.resourceURL)
262            //Do nothing
263            return;
264
265        //Get data
266        const candidates = this.candidates;
267        let endOfcandidates = this.endOfcandidates;
268        const restartIce = this.restartIce;
269
270        //Clean pending data before async operation
271        this.candidates = [];
272        this.endOfcandidates = false;
273        this.restartIce = false;
274
275        //If we need to restart
276        if (restartIce) {
277            //Restart ice
278            this.pc.restartIce();
279            //Create a new offer
280            const offer = await this.pc.createOffer({ iceRestart: true });
281            //Update ice
282            this.iceUsername = offer.sdp.match(/a=ice-ufrag:(.*)\r\n/)[1];
283            this.icePassword = offer.sdp.match(/a=ice-pwd:(.*)\r\n/)[1];
284            //Set it
285            await this.pc.setLocalDescription(offer);
286            //Clean end of candidates flag as new ones will be retrieved
287            endOfcandidates = false;
288        }
289        //Prepare fragment
290        let fragment =
291            "a=ice-ufrag:" + this.iceUsername + "\r\n" +
292            "a=ice-pwd:" + this.icePassword + "\r\n";
293        //Get peerconnection transceivers
294        const transceivers = this.pc.getTransceivers();
295        //Get medias
296        const medias = {};
297        //If doing something else than a restart
298        if (candidates.length || endOfcandidates)
299            //Create media object for first media always
300            medias[transceivers[0].mid] = {
301                mid: transceivers[0].mid,
302                kind: transceivers[0].receiver.track.kind,
303                candidates: [],
304            };
305        //For each candidate
306        for (const candidate of candidates) {
307            //Get mid for candidate
308            const mid = candidate.sdpMid
309            //Get associated transceiver
310            const transceiver = transceivers.find(t => t.mid == mid);
311            //Get media
312            let media = medias[mid];
313            //If not found yet
314            if (!media)
315                //Create media object
316                media = medias[mid] = {
317                    mid,
318                    kind: transceiver.receiver.track.kind,
319                    candidates: [],
320                };
321            //Add candidate
322            media.candidates.push(candidate);
323        }
324        //For each media
325        for (const media of Object.values(medias)) {
326            //Add media to fragment
327            fragment +=
328                "m=" + media.kind + " 9 RTP/AVP 0\r\n" +
329                "a=mid:" + media.mid + "\r\n";
330            //Add candidate
331            for (const candidate of media.candidates)
332                fragment += "a=" + candidate.candidate + "\r\n";
333            if (endOfcandidates)
334                fragment += "a=end-of-candidates\r\n";
335        }
336
337        //Request headers
338        const headers = {
339            "Content-Type": "application/trickle-ice-sdpfrag"
340        };
341
342        //If token is set
343        if (this.token)
344            headers["Authorization"] = "Bearer " + this.token;
345
346        //Do the post request to the WHEP resource
347        const fetched = await fetch(this.resourceURL, {
348            method: "PATCH",
349            body: fragment,
350            headers
351        });
352        if (!fetched.ok)
353            throw new Error("Request rejected with status " + fetched.status)
354
355        //If we have got an answer
356        if (fetched.status == 200) {
357            //Get the SDP answer
358            const answer = await fetched.text();
359            //Get remote icename and password
360            const iceUsername = answer.match(/a=ice-ufrag:(.*)\r\n/)[1];
361            const icePassword = answer.match(/a=ice-pwd:(.*)\r\n/)[1];
362
363            //Get current remote rescription
364            const remoteDescription = this.pc.remoteDescription;
365
366            //Patch
367            remoteDescription.sdp = remoteDescription.sdp.replaceAll(/(a=ice-ufrag:)(.*)\r\n/gm, "$1" + iceUsername + "\r\n");
368            remoteDescription.sdp = remoteDescription.sdp.replaceAll(/(a=ice-pwd:)(.*)\r\n/gm, "$1" + icePassword + "\r\n");
369
370            //Set it
371            await this.pc.setRemoteDescription(remoteDescription);
372        }
373    }
374
375    async mute(muted) {
376        //Request headers
377        const headers = {
378            "Content-Type": "application/json"
379        };
380
381        //If token is set
382        if (this.token)
383            headers["Authorization"] = "Bearer " + this.token;
384
385        //Do the post request to the whep resource
386        const fetched = await fetch(this.resourceURL, {
387            method: "POST",
388            body: JSON.stringify(muted),
389            headers
390        });
391    }
392
393    async selectLayer(layer) {
394        if (!this.layerUrl)
395            throw new Error("whep resource does not support layer selection");
396
397        //Request headers
398        const headers = {
399            "Content-Type": "application/json"
400        };
401
402        //If token is set
403        if (this.token)
404            headers["Authorization"] = "Bearer " + this.token;
405
406        //Do the post request to the whep resource
407        const fetched = await fetch(this.layerUrl, {
408            method: "POST",
409            body: JSON.stringify(layer),
410            headers
411        });
412    }
413
414    async unselectLayer() {
415        if (!this.layerUrl)
416            throw new Error("whep resource does not support layer selection");
417
418
419        //Request headers
420        const headers = {};
421
422        //If token is set
423        if (this.token)
424            headers["Authorization"] = "Bearer " + this.token;
425
426        //Do the post request to the whep resource
427        const fetched = await fetch(this.layerUrl, {
428            method: "DELETE",
429            headers
430        });
431    }
432
433    async stop() {
434        if (!this.pc) {
435            // Already stopped
436            return
437        }
438
439        //Cancel any pending timeout
440        this.iceTrickeTimeout = clearTimeout(this.iceTrickeTimeout);
441
442        //Close peerconnection
443        this.pc.close();
444
445        //Null
446        this.pc = null;
447
448        //If we don't have the resource url
449        if (!this.resourceURL)
450            throw new Error("WHEP resource url not available yet");
451
452        //Request headers
453        const headers = {
454        };
455
456        //If token is set
457        if (this.token)
458            headers["Authorization"] = "Bearer " + this.token;
459
460        //Send a delete
461        await fetch(this.resourceURL, {
462            method: "DELETE",
463            headers
464        });
465    }
466};