diff --git a/README.md b/README.md index cbe0e4d..87c76df 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ The object to pass when creating a new endpoint must refer to the following stru } ``` -Subscribing to the WHEP endpoint via WebRTC can be done by sending either an SDP offer or (non-standard approach) an empty request to the created `/endpoint/` endpoint via HTTP POST, which will interact with Janus on your behalf. Depending on what you sent, if successful, it will return an SDP answer or offer back in the 200 OK, plus an address for the allocated resource; if you sent an empty request initially, to complete the negotiation you'll have to send the SDP answer to the resource URL via PATCH. If you're using [Simple WHEP Client](https://github.com/meetecho/simple-whep-client) to test, the full HTTP path to the endpoint is all you need to provide as the WHEP url. Notice that, to be able to send an offer to the WHP endpoint you'll need to use at least the `1.1.4` version of Janus. +Subscribing to the WHEP endpoint via WebRTC can be done by sending an SDP offer to the created `/endpoint/` endpoint via HTTP POST, which will interact with Janus on your behalf. Both client- and server-sent offers are supported, which means the WHEP server return an SDP answer (via the 201 error code) or offer (via the 406 error code) back in the response, plus an address for the allocated resource; if the server changed stance to use a server-sent offer, to complete the negotiation you'll have to send the SDP answer to the resource URL via PATCH. If you're using [Simple WHEP Client](https://github.com/meetecho/simple-whep-client) to test, the full HTTP path to the endpoint is all you need to provide as the WHEP url. Notice that not all the Janus plugins supported by the WHEP server can handle client-sent offers, which means the server will automatically switch to server-sent offers when nedded. As per the specification, the response to the subscribe request will contain a `Location` header which points to the resource to use to refer to the stream. In this prototype implementation, the resource is handled by the same server instance, and is currently generated automatically as a `/resource/` endpoint (returned as a relative path in the header), where `uuid` is randomly generated and unique for each subscriber. That's the address used for interacting with the session, i.e., for tricking candidates, restarting ICE, and tearing down the session. The server is configured to automatically allow trickle candidates to be sent via HTTP PATCH to the `/resource/` endpoint: if you'd like the server to not allow trickle candidates instead (e.g., to test if your client handles a failure gracefully), you can disable them when creating the server via `allowTrickle`. ICE restarts are currently not supported. Finally, that's also the address you'll need to send the HTTP DELETE request to, in case you want to signal the intention to tear down the WebRTC PeerConnection. diff --git a/examples/web/index.html b/examples/web/index.html index cf42521..1614e07 100644 --- a/examples/web/index.html +++ b/examples/web/index.html @@ -48,6 +48,7 @@

WHEP Endpoint + Server Sent Offer

diff --git a/examples/web/index.js b/examples/web/index.js index 5ce6f62..17583cb 100644 --- a/examples/web/index.js +++ b/examples/web/index.js @@ -16,8 +16,6 @@ function getQueryStringValue(name) { } // Get the endpoint ID to subscribe to const id = getQueryStringValue('id'); -// Check if we should let the endpoint send the offer -const expectOffer = (getQueryStringValue('offer') === 'false') $(document).ready(function() { // Make sure WebRTC is supported by the browser @@ -41,29 +39,28 @@ $(document).ready(function() { // Function to subscribe to the WHEP endpoint async function subscribeToEndpoint() { - let headers = null, offer = null; + let headers = null; if(token) headers = { Authorization: 'Bearer ' + token }; - if(!expectOffer) { - // We need to prepare an offer ourselves, do it now - let iceServers = [{urls: "stun:stun.l.google.com:19302"}]; - createPeerConnectionIfNeeded(iceServers); - let transceiver = await pc.addTransceiver('audio'); - if(transceiver.setDirection) - transceiver.setDirection('recvonly'); - else - transceiver.direction = 'recvonly'; - transceiver = await pc.addTransceiver('video'); - if(transceiver.setDirection) - transceiver.setDirection('recvonly'); - else - transceiver.direction = 'recvonly'; - offer = await pc.createOffer({}); - await pc.setLocalDescription(offer); - // Extract ICE ufrag and pwd (for trickle) - iceUfrag = offer.sdp.match(/a=ice-ufrag:(.*)\r\n/)[1]; - icePwd = offer.sdp.match(/a=ice-pwd:(.*)\r\n/)[1]; - } + // Prepare an offer: in case the server only supports server-sent + // offers by replying with a 406, we'll switch stance on the fly + let iceServers = [{urls: "stun:stun.l.google.com:19302"}]; + createPeerConnectionIfNeeded(iceServers); + let transceiver = await pc.addTransceiver('audio'); + if(transceiver.setDirection) + transceiver.setDirection('recvonly'); + else + transceiver.direction = 'recvonly'; + transceiver = await pc.addTransceiver('video'); + if(transceiver.setDirection) + transceiver.setDirection('recvonly'); + else + transceiver.direction = 'recvonly'; + let offer = await pc.createOffer({}); + await pc.setLocalDescription(offer); + // Extract ICE ufrag and pwd (for trickle) + iceUfrag = offer.sdp.match(/a=ice-ufrag:(.*)\r\n/)[1]; + icePwd = offer.sdp.match(/a=ice-pwd:(.*)\r\n/)[1]; // Contact the WHEP endpoint $.ajax({ url: backend + rest + '/endpoint/' + id, @@ -72,85 +69,104 @@ async function subscribeToEndpoint() { contentType: offer ? 'application/sdp' : null, data: offer ? offer.sdp : {} }).error(function(xhr, textStatus, errorThrown) { + // Check if this is a switch to a server-sent offer + if(xhr.status === 406 && xhr.getResponseHeader('Content-Type') && + xhr.responseText && xhr.responseText.indexOf('v=0') === 0) { + // It is, close the previous PeerConnection handle the offer + try { pc.close(); } catch(e) {}; + pc = null; + handleWhepResponse(xhr, xhr.responseText, true) + return; + } + // If we got here, it's a failure bootbox.alert(xhr.status + ": " + xhr.responseText); }).success(function(sdp, textStatus, request) { - console.log('Got SDP:', sdp); - resource = request.getResponseHeader('Location'); - console.log('WHEP resource:', resource); - // TODO Parse ICE servers - // let ice = request.getResponseHeader('Link'); - let iceServers = [{urls: "stun:stun.l.google.com:19302"}]; - // Create PeerConnection, if needed - createPeerConnectionIfNeeded(iceServers); - // Pass the SDP to the PeerConnection - let jsep = { - type: expectOffer ? 'offer' : 'answer', - sdp: sdp - }; - pc.setRemoteDescription(jsep) - .then(function() { - console.log('Remote description accepted'); - if(!expectOffer) { - // We're done: just check if we have candidates to send - if(candidates.length > 0) { - // FIXME Trickle candidate - let headers = null; - if(token) - headers = { Authorization: 'Bearer ' + token }; - let candidate = - 'a=ice-ufrag:' + iceUfrag + '\r\n' + - 'a=ice-pwd:' + icePwd + '\r\n' + - 'm=audio 9 RTP/AVP 0\r\n'; - for(let c of candidates) - candidate += 'a=' + c + '\r\n'; - candidates = []; - $.ajax({ - url: backend + resource, - type: 'PATCH', - headers: headers, - contentType: 'application/trickle-ice-sdpfrag', - data: candidate - }).error(function(xhr, textStatus, errorThrown) { - bootbox.alert(xhr.status + ": " + xhr.responseText); - }).done(function(response) { - console.log('Candidate sent'); - }); - } - return; + handleWhepResponse(request, sdp, false) + }); +} + +// Helper to process the response, whether it's for client- or server-sent offers +function handleWhepResponse(request, sdp, offer) { + console.log('Got SDP ' + (offer ? 'offer' : 'answer') + ':', sdp); + resource = request.getResponseHeader('Location'); + console.log('WHEP resource:', resource); + // TODO Parse ICE servers + // let ice = request.getResponseHeader('Link'); + let iceServers = [{urls: "stun:stun.l.google.com:19302"}]; + // Create PeerConnection, if needed + createPeerConnectionIfNeeded(iceServers); + // Pass the SDP to the PeerConnection + let jsep = { + type: offer ? 'offer' : 'answer', + sdp: sdp + }; + pc.setRemoteDescription(jsep) + .then(function() { + console.log('Remote description accepted'); + if(!offer) { + // We're done: just check if we have candidates to send + if(candidates.length > 0) { + // FIXME Trickle candidate + let headers = null; + if(token) + headers = { Authorization: 'Bearer ' + token }; + let candidate = + 'a=ice-ufrag:' + iceUfrag + '\r\n' + + 'a=ice-pwd:' + icePwd + '\r\n' + + 'm=audio 9 RTP/AVP 0\r\n'; + for(let c of candidates) + candidate += 'a=' + c + '\r\n'; + candidates = []; + $.ajax({ + url: backend + resource, + type: 'PATCH', + headers: headers, + contentType: 'application/trickle-ice-sdpfrag', + data: candidate + }).error(function(xhr, textStatus, errorThrown) { + bootbox.alert(xhr.status + ": " + xhr.responseText); + }).done(function(response) { + console.log('Candidate sent'); + }); } - // If we got here, we're in the "WHEP server sends offer" mode, - // so we have to prepare an answer to send back via a PATCH - pc.createAnswer({}) - .then(function(answer) { - console.log('Prepared answer:', answer.sdp); - // Extract ICE ufrag and pwd (for trickle) - iceUfrag = answer.sdp.match(/a=ice-ufrag:(.*)\r\n/)[1]; - icePwd = answer.sdp.match(/a=ice-pwd:(.*)\r\n/)[1]; - pc.setLocalDescription(answer) - .then(function() { - console.log('Sending answer to WHEP server'); - // Send the answer to the resource address - $.ajax({ - url: backend + resource, - type: 'PATCH', - headers: headers, - contentType: 'application/sdp', - data: answer.sdp - }).error(function(xhr, textStatus, errorThrown) { - bootbox.alert(xhr.status + ": " + xhr.responseText); - }).done(function(response) { - console.log('Negotiation completed'); - }); - }, function(err) { - bootbox.alert(err.message); + return; + } + // If we got here, we're in the "WHEP server sends offer" mode, + // so we have to prepare an answer to send back via a PATCH + $('#whepsso').removeClass('hide'); + pc.createAnswer({}) + .then(function(answer) { + console.log('Prepared answer:', answer.sdp); + // Extract ICE ufrag and pwd (for trickle) + let headers = null; + if(token) + headers = { Authorization: 'Bearer ' + token }; + iceUfrag = answer.sdp.match(/a=ice-ufrag:(.*)\r\n/)[1]; + icePwd = answer.sdp.match(/a=ice-pwd:(.*)\r\n/)[1]; + pc.setLocalDescription(answer) + .then(function() { + console.log('Sending answer to WHEP server'); + // Send the answer to the resource address + $.ajax({ + url: backend + resource, + type: 'PATCH', + headers: headers, + contentType: 'application/sdp', + data: answer.sdp + }).error(function(xhr, textStatus, errorThrown) { + bootbox.alert(xhr.status + ": " + xhr.responseText); + }).done(function(response) { + console.log('Negotiation completed'); }); - }, function(err) { - bootbox.alert(err.message); - }); - }, function(err) { - bootbox.alert(err.message); - }); - }); + }, function(err) { + bootbox.alert(err.message); + }); + }, function(err) { + bootbox.alert(err.message); + }); + }, function(err) { + bootbox.alert(err.message); + }); } // Helper function to attach a media stream to a video element @@ -222,7 +238,6 @@ function createPeerConnectionIfNeeded(iceServers) { console.log('Handling Remote Track', event); if(!event.streams) return; - console.warn(event.streams[0].getTracks()); if($('#whepvideo').length === 0) { $('#video').removeClass('hide').show(); $('#videoremote').append('