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};