ICE Fails using the Websocket API

I’ve create a simple Echotest demo that uses janus.js. It’s simple, bare bones and works successfully everytime.
I’m trying to recreate the sam application, without janus.js, using the websocket API.

I have analyzed janus.js, and I’m following all the steps that it does to communicate with the server.
I can see the same messages going in and out when I use janus.js vs straight websockets. But the program using straight websockets fails to connect with an ICE failure on the server side.

It’s using the same ICE servers and Janus server.

The biggest difference I notice is that when the janus.js application receives ICE Candidates, it receives one more than the websocket application does. I assume that’s the problem, but as far as I can tell, everything is written correctly.

I assume that I’m missing a step somewhere, but I can’t determine where,

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="utf-8">
		<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
		<title>Janus WebSockets</title>
		<link rel="icon" href="data:;base64,iVBORwOKGO=" />
		<style>
			video {width:320px; height:240px; border: 1px solid black;}
			.localVideo {border: 3px solid red;}
			.remoteVideo {border: 3px solid blue;}
			.hidden {display: none;}
		</style>
	</head>
	<body>
		<button id="buttonOne">Button1</button>
		<div id="videos">
			<video id="localVideo" class="localVideo" autoplay playsinline muted></video>
			<video id="remoteVideo" class="remoteVideo" autoplay playsinline></video>
		</div>
		<script type="text/javascript">
		'use strict';
		function $(id) {return document.getElementById(id);}
		function $hide(id) {$(id).classList.add("hidden");}
		function $show(id) {$(id).classList.remove("hidden");}
		
		const janusUrl = "wss://MYJANUSSERVER";
		const iceServers = [{"urls": "turn:MYRURNSERVER"}];
		const adminSecret = "MYADMINSECRET";
		const apiSecret = "MYAPISECRET"
		
		var webSocket;
		var sessionId;
		var pluginHandle;
		const myRoomId = 1234;
		
		const keepAlivePeriod = 25000;
		var keepAliveTimer;
		
		const localVideo = $('localVideo');
		const localStream = new MediaStream();

		const remoteVideo = $('remoteVideo');
		const remoteStream = new MediaStream();
		
		var peerConnection;
		
		var answered = false;
		const candidates = [];

		//window.addEventListener('load', (event) => {
		$('buttonOne').addEventListener('click', (event) => {
			localVideo.srcObject = localStream;
			remoteVideo.srcObject = remoteStream;
			
			console.log(`Opening websocket ${janusUrl}`);
			webSocket = new WebSocket(janusUrl, 'janus-protocol');
			webSocket.addEventListener('open', wsOpen);
			webSocket.addEventListener('close', wsClose);
			webSocket.addEventListener('message', wsMessage);

		});
		
		function wsOpen(event) {
			console.log(`Web Socket Opened ${janusUrl}`);
			
			console.log(`Creating Janus Session`);
			const message = JSON.stringify({
				'janus' : 'create',
				'transaction' : 'createSession',
				'apisecret' : apiSecret
			});
			
			console.log(`Creating session\n${message}`)
			webSocket.send(message);
		}
		
		function wsClose(event) {
		
			clearInterval(keepAliveTimer);
			console.log(`Web Socket Closed ${janusUrl}`);
		}
		
		function wsKeepAlive(event) {
		
			const message = JSON.stringify({
				"janus" : "keepalive",
				"session_id" : sessionId,
				"transaction" : "keepAlive",
				'apisecret' : apiSecret
			});

			webSocket.send(message);
		}
		
		function wsMessage(event) {
			const message = JSON.parse(event.data);
			
			const janus = message.janus;
			const transaction = message.transaction;
			const data = message.data;
			
			if (transaction == 'keepAlive' && janus == 'ack') return;	// don't want to see a constant stream of keep alive messages

			console.log(`Web Socket message received \n ${JSON.stringify(message)}`);

			if (message.jsep) {
				peerConnection.setRemoteDescription(message.jsep)
				.then(() => {
					console.log(`Description received, peer Connection set`);
					answered = true;
					
					//while (candidates.length > 0) {
					//	const candidate = candidates.shift();
					//	peerConnection.addIceCandidate(message.candidate);
					//}
					//peerConnection.addIceCandidate(null);
					
				});
			}

			//if (message.candidate) {
			//
			//	if (answered) {
			//		peerConnection.addIceCandidate(message.candidate);
			//	} else {
			//		candidates.push(message.candidates);
			//	}
			//}

			if (transaction == 'createSession' && janus == 'success') {
			
				sessionId = data.id;
				console.log(`session ${sessionId} created`);
				
				
				keepAliveTimer = setInterval(wsKeepAlive, keepAlivePeriod);
				console.log(`keepAlive started`);
				
				const message = JSON.stringify({
					'janus' : 'attach',
					'session_id' : sessionId,
					'plugin' : 'janus.plugin.echotest',
					'transaction' : 'attachPlugin',
					'apisecret' : apiSecret
				});
				
				console.log(`attaching plugin\n${message}`);
				webSocket.send(message);
				
			}
			
			if (transaction == 'attachPlugin' && message.janus == 'success') {
				pluginHandle = data.id;
				sendJanusMessage('media', {"audio":true,"video":true});
			}
			
			if (transaction == 'media' && message.janus == 'event') {
				openPeerConnection();
			}
		}

		function sendJanusMessage(subject, body, jsep) {
		
			const request = {
				"janus" : "message",
				"session_id" : sessionId,
				"handle_id" : pluginHandle,
				"transaction" : subject,
				"body" : body,
				'apisecret' : apiSecret
			};
			
			if (jsep) {
				request.jsep = {
					type: jsep.type,
					sdp: jsep.sdp
				}
			};
			
			const message = JSON.stringify(request);
			console.log(`sending message\n${message}`);
			webSocket.send(message);
		}

		function trickleCandidate(candidate) {

			const request = {
				'janus' : 'trickle'
			};
			
			if (candidate) {
			
				request.candidate = { 
					candidate: candidate.candidate,
					sdpMid: candidate.sdpMid,
					sdpMLineIndex: candidate.sdpMLineIndex
				};
				request.transaction = 'iceCandidate';
					
			} else {
				request.candidate = { 'completed' : true };
				request.transaction = 'iceComplete';

			}
			
			request.session_id = sessionId;
			request.handle_id = pluginHandle;
			request.apisecret = apiSecret;
	
			const message = JSON.stringify(request);
			console.log(`sending candidate\n${message}`);
			webSocket.send(message);
		}


		function openPeerConnection() {

			peerConnection = new RTCPeerConnection(iceServers);
			peerConnection.addEventListener('connectionstatechange', onPeerConnection);
			peerConnection.addEventListener('datachannel', onPeerConnection);
			peerConnection.addEventListener('icecandidate', onPeerConnection);
			peerConnection.addEventListener('icecandidateerror', onPeerConnection);
			peerConnection.addEventListener('iceconnectionstatechange', onPeerConnection);
			peerConnection.addEventListener('icegatheringstatechange', onPeerConnection);
			peerConnection.addEventListener('negotiationneeded', onPeerConnection);
			peerConnection.addEventListener('signalingstatechange', onPeerConnection);
			peerConnection.addEventListener('track', onPeerConnection);

			console.log(`Created peer connection`);
			
			const mediaStreamConstraints = {video: true, audio: true};
		
			navigator.mediaDevices.getUserMedia(mediaStreamConstraints)
			.then((mediaStream) => {
				for (const track of mediaStream.getTracks()) {
					localStream.addTrack(track);
					peerConnection.addTrack(track);					// triggers negotiation needed
				}
			});		
		}
		
		function makeOffer() {
		
			const mediaConstraints = {};
			const offerOptions = {'offerToReceiveVideo':1, 'offerToReceiveAudio':1};
				
			peerConnection.createOffer(offerOptions)
			.then((offer) =>{
			
				peerConnection.setLocalDescription(offer);
			
				const jsep = {
					type: 'offer',
					sdp: offer.sdp
				};
				const body = { 'audio': true, 'video': true, 'trickle': true };
				sendJanusMessage('makeOffer', body, jsep);
				
			});
		}

		function onPeerConnection(event) {
		
			if (event.type == 'connectionstatechange')		{console.log(`${event.type} - ${peerConnection.connectionState}`); return;}
			if (event.type == 'iceconnectionstatechange')	{console.log(`${event.type} - ${peerConnection.iceConnectionState}`); return;}
			if (event.type == 'icegatheringstatechange')	{console.log(`${event.type} - ${peerConnection.iceGatheringState}`); return;}
			if (event.type == 'signalingstatechange')		{console.log(`${event.type} - ${peerConnection.signalingState}`); return;}
			
			console.log(`onPeerConnection: ${event.type}`);

			if (event.type == 'icecandidate')				{
				
				const candidate = event.candidate;
				console.log(`${event.type} - ${candidate}`);
				trickleCandidate(candidate)
				return;
			}
			
			
			if (event.type == 'negotiationneeded') makeOffer();

			if (event.type == 'track') {
			
				const track = event.track;
				console.log(`adding ${track.kind} track to remoteStream`);
				remoteStream.addTrack(track);
			}
			
		}

		</script>
	</body>
	
</html>

This is my echo test, it works perfectly.
On this Janus server we run multiple applications, implementing several different plugins, so I know the server is set up correctly.

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="utf-8">
		<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
		<title>My Echo Test</title>
		<link rel="icon" href="data:;base64,iVBORwOKGO=" />
		<style>
			video {width:320px; height:240px;}
		</style>
	</head>
	<body>
		<p><a href=".">back</a></p>
		<button id="goButton">go</button>
		<video id="localVideo" muted autoplay playsinline></video>
		<video id="remoteVideo" muted autoplay playsinline></video>
	
		<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/webrtc-adapter/8.2.2/adapter.min.js" ></script>
		<script type="text/javascript" src="janus.js" ></script>
		<script type="text/javascript">
		'use strict';
		
		const goButton = document.getElementById('goButton');
		const localVideo = document.getElementById('localVideo');
		const remoteVideo = document.getElementById('remoteVideo');

		var janus;
		var echoTest;
		
		const localStream = new MediaStream();
		const remoteStream = new MediaStream();
		
		goButton.addEventListener('click', function(event){
			Janus.init({
				debug: true, 
				callback: () => janusOnInit(),
			});		
		});
		
		function janusOnInit() {

			if(!Janus.isWebrtcSupported()) {
				alert("No WebRTC support... ");
				return;
			}
		
			janus = new Janus({
		
				server:  "wss://MYJANUSSERVER",
				iceServers: [{urls: "turn:MYTURNSERVER"}],
				apisecret: "MYAPISECRET",
				
				success: () => janusOnSuccess(),
				error: function(error) {
					alert(error);
					janus.destroy();
				},
				destroyed: function() {}
			});
		}
		
		function janusOnSuccess() {
				
			console.log('janus server connected');
			
			janus.attach({
				plugin: 'janus.plugin.echotest',
				success: (pluginHandle) => echoTestOnSuccess(pluginHandle),
				error: function(error) {
					Janus.error(error);
					alert(error);
					janus.destroy();
				},
				onmessage: (message, jsep) => echoTestOnMessage(message, jsep),
				onlocaltrack: (track, on) =>  echoTestOnLocalTrack(track, on),
				onremotetrack: (track, mid, on, metadata) =>  echoTestOnRemoteTrack(track, mid, on, metadata)
			});
		}
		
		function echoTestOnSuccess(pluginHandle) {
		
			console.log('plugin attached');
		
			echoTest = pluginHandle;
			
			let body = { audio: true, video: true };
			echoTest.send({ message: body });

			echoTest.createOffer({
				tracks: [
					{ type: 'audio', capture: true, recv: true },
					{ type: 'video', capture: true, recv: true },
					{ type: 'data' },
				],
				success: function(jsep) {
					echoTest.send({ message: body, jsep: jsep });
				},
				error: function(error) {
					alert("WebRTC error... " + error.message);
				}
			});
		}
		
		function echoTestOnMessage(message, jsep){
		
			if(jsep) {
				echoTest.handleRemoteJsep({ jsep: jsep });
			}
		}
		
		function echoTestOnLocalTrack(track, on) {
		
			console.log("Local track " + (on ? "added" : "removed") + ":", track);
			
			if (on) {
				
				localStream.addTrack(track);
				localVideo.srcObject = localStream;
			}
		}

		function echoTestOnRemoteTrack(track, mid, on, metadata) {
		
			console.log("Remote track " + (on ? "added" : "removed") + ":", track);
		
			if (on) {
				remoteStream.addTrack(track);
				remoteVideo.srcObject = remoteStream;
			}
		}

		</script>
	</body>
</html>

Still experiencing the same problem. I get all the same Janus messages with straight websockets, that I get using Janus.js. And, I get all the same peerconnection events.

Except, I get one fewer icecandidate with websockets, and in the end websockets gets a disonnected message from janus, while hanus.js gets a webrtcup message.

In case anyone is interested, I figured it out, as a difference in the peerconnections settings.
Oringially I had:

const pcOptions = iceServers;
pcOptions.sdpSemantics = 'unified-plan';

But, after studying the connection settings in Janus.js, I changed to this:

const pcOptions = {
	iceServers: iceServers,
	sdpSemantics: 'unified-plan'
};

I’ll post the link to my github once I upload the final version, along with several other Janus examples.