Définir les phasers sur STUN/TURN avec WebRTC, Node.js, Socket.io et Twilio

December 11, 2014
Rédigé par
Phil Nash
Twilion

Définir les phasers sur STUN/TURN : mise en route avec WebRTC à l'aide de Node.js, Socket.io et du service NAT Traversal de Twilio

Les dernières semaines ont été passionnantes en matière de lancements pour Twilio. Mon favori a été le lancement de notre service Network Traversal. Bien que cela puisse paraître un peu ennuyant, c'est un service important pour les applications WebRTC, car il supprime la surcharge que constitue le déploiement de votre propre réseau de serveurs STUN et TURN. Je mourais d'envie de trouver une excuse pour tester WebRTC, j'avais là une bonne raison de le faire.

Bien évidemment, ce serait manquer à mes devoirs que de garder le code et le processus de mise en place d'une application WebRTC pour moi-même. Tout au long de ce post, je vais vous expliquer comment j'ai commencé à créer une application de chat vidéo avec WebRTC. Vous passerez ainsi moins de nuits blanches à vous demander quel rappel vous avez manqué ou quel message vous n'avez pas encore mis en œuvre et plus de temps à saluer vos amis et à penser à des applications sympas pour cette technologie.

Donnons vie à du WebRTC !

WebRTC, qu'est-ce que c'est ?

Commençons par quelques définitions pour nous assurer que nous savons tous de quoi nous parlons.

WebRTC est un ensemble d'API JavaScript qui permettent la communication de données, audio et vidéo P2P, en temps réel et sans plug-in entre deux navigateurs. Simple, non ? Nous verrons les API JavaScript dans le code plus tard.

WebRTC, qu'est-ce que ça n'est pas ?

Il est également important de parler de ce que WebRTC ne fait pas pour nous, car c'est la partie de l'application que nous avons réellement besoin de construire. Bien qu'une connexion WebRTC entre deux navigateurs soit de pair-à-pair, nous exigeons toujours que les serveurs travaillent pour nous. Les trois parties de l'application requises sont les suivantes :

Configuration réseau

Il s'agit d'informations sur l'adresse IP publique et le numéro de port sur lesquels un navigateur peut être atteint. C'est là que le service Network Traversal de Twilio intervient. Comme expliqué dans la présentation, lorsqu'un pare-feu et un NAT sont impliqués, il n'est pas futile de découvrir comment accéder publiquement à un point de terminaison. Les serveurs STUN et TURN peuvent être utilisés pour découvrir ces informations. Le navigateur fait beaucoup de travail ici, mais nous verrons comment le configurer avec l'accès au service de Twilio plus tard.

Présence

Les navigateurs vivent généralement une vie solitaire, sans aucune prise en compte des autres navigateurs susceptibles de les contacter. Afin de connecter un navigateur à un autre, nous allons devoir découvrir la présence d'un deuxième navigateur d'une quelconque façon. C'est à nous de construire un moyen pour les navigateurs de découvrir d'autres navigateurs qui sont prêts à prendre un appel vidéo.

Signalisation

Enfin, une fois qu'un navigateur décide de contacter un autre homologue, il doit envoyer et recevoir à la fois les informations réseau reçues des serveurs STUN/TURN, ainsi que des informations sur ses propres capacités multimédias. C'est ce que l'on appelle la signalisation, et c'est la majorité du travail que nous devons faire dans cette application.

Pour une vue beaucoup plus approfondie sur WebRTC et les technologies environnantes, je recommande vivement l'introduction de HTML5 Rocks à WebRTC et leur article plus détaillé sur STUN, TURN et la signalisation.

Outils

Pour construire notre WebRTC « Hello World ! » (ce qui, étonnamment, est une application de chat vidéo), nous avons besoin de quelques outils. Puisque nous parlons de JavaScript sur le front-end, j'ai décidé d'utiliser JavaScript pour le back-end aussi. Nous allons donc utiliser Node.js. Nous avons également besoin de quelque chose pour servir notre application et pour ce projet, j'ai choisi Express. Pour la présence et la signalisation, tout canal de communication bidirectionnel peut être utilisé. J'ai choisi WebSockets avec Socket.io pour la simplicité de l'API.

Tout ce dont nous avons besoin pour commencer, c'est d'un compte Twilio, d'un ordinateur avec une webcam et Node.js installé. Oh, et d'un navigateur qui prend en charge WebRTC. En ce moment, c'est le cas de la plupart d'entre eux. Vous avez tout cela ? Bon, écrivons un peu de code.

Mise en route

Sur la ligne de commande, préparez votre application :

$ mkdir video-chat
$ cd video-chat
$ npm init

Entrez les informations demandées par npm init (vous pouvez appuyer sur Entrée ici pour la plupart d'entre elles). Maintenant, installez vos dépendances :

$ npm install express socket.io twilio

Créez les fichiers et les répertoires dont vous allez avoir besoin.

$ mkdir public
$ touch index.js public/index.html public/app.js

Ouvrez ensuite public/index.html et entrez la page HTML basique suivante :

<!doctype html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Video Chat</title>
</head>
<body>
  <h1>Video Chat</h1>
  <video id="local-video" height="150" autoplay></video>
  <video id="remote-video" height="150" autoplay></video>

  <div>
    <button id="get-video">Get Video</button>
    <button id="call" disabled="disabled">Call</button>
  </div>

  <script src="/socket.io/socket.io.js"></script>
  <script src="/app.js"></script>
</body>
</html>

Comme vous pouvez le voir, cela inclut deux éléments <video> vides, quelques boutons que nous allons utiliser pour contrôler nos appels et les fichiers JavaScript que nous avons définis précédemment avec la bibliothèque client Socket.io.

Enfin, nous allons configurer notre serveur. Ouvrez index.js et saisissez les informations suivantes :

// index.js
var express = require('express');
var app = express();
var http = require('http').createServer(app);
var io = require('socket.io')(http);

var twilio = require('twilio')(
  process.env.TWILIO_ACCOUNT_SID,
  process.env.TWILIO_AUTH_TOKEN
);

app.use(express.static('public'));

http.listen(3000, function() {
  console.log('listening on *:3000');
});

Il s'agit d'une configuration de base pour Express, nous ne faisons rien de spécial ici, si ce n'est relier le processus Socket.io à l'objet serveur Express.

Nous avons également chargé la bibliothèque de nœuds Twilio ici, et vous pouvez voir que j'ai inclus les identifiants API de l'environnement. Avant d'exécuter le serveur, vous devez vous assurer que vous disposez de ces identifiants dans l'environnement.

$ export TWILIO_ACCOUNT_SID=ACXXXXXXXXXX
$ export TWILIO_AUTH_TOKEN=YYYYYYYYY

Maintenant, exécutez le serveur et assurez-vous que tout fonctionne.

$ node index.js

Ouvrez http://localhost:3000 et vérifiez que vous avez un titre, des éléments vidéo vides et deux boutons. Est-ce que tout est là ? Continuons.

Flux vidéo et audio

Nous sommes prêts. La première chose que nous devons faire pour démarrer le processus d'appel vidéo est de prendre en main les flux vidéo et audio de l'utilisateur. Pour cela, nous utiliserons l'API Navigator.getUserMedia()

Nous allons écouter pour un clic sur le premier élément <button> que nous avons ajouté à la page et demander les flux de la webcam et du microphone de l'utilisateur. Ouvrez public/app.js et saisissez les informations suivantes :

// app.js
var VideoChat = {
  requestMediaStream: function(event) {
    navigator.mediaDevices
      .getUserMedia({ video: true, audio: true })
      .then(stream => {
        VideoChat.onMediaStream(stream);
      })
      .catch(error => {
        VideoChat.noMediaStream(error);
      });
  },

  onMediaStream: function(stream) {
    VideoChat.localVideo = document.getElementById('local-video');
    VideoChat.localVideo.volume = 0;
    VideoChat.localStream = stream;
    VideoChat.videoButton.setAttribute('disabled', 'disabled');
    VideoChat.localVideo.srcObject = stream;
  },

  noMediaStream: function() {
    console.log('No media stream for us.');
    // Sad trombone.
  }
};

VideoChat.videoButton = document.getElementById('get-video');

VideoChat.videoButton.addEventListener(
  'click',
  VideoChat.requestMediaStream,
  false
);

Le code ci-dessus réalise quelques actions, alors parlons-en. J'ai d'abord configuré un objet VideoChat, pour stocker quelques objets et fonctions que nous allons définir tout au long du processus. Le premier objet que nous saisissons est le bouton vidéo, auquel nous attachons un écouteur d'événement de clic (ce n'est malheureusement pas jQuery ici, seulement des API DOM vanilla). Lorsque nous cliquons sur le bouton, nous faisons la demande d'accès aux flux vidéo et audio via la fonction getUserMedia.

Suite à l'appel à getUserMedia, le navigateur invite l'utilisateur à accepter ou à refuser la demande d'utilisation des éléments multimédias par la page. Dans Firefox, cela se présente sous cette forme :

Autorisations getUserMedia dans Firefox

Dans Chrome, cela se présente sous cette forme :

Autorisations getUserMedia dans Chrome

Si vous acceptez, la promesse getUserMedia est résolue et la fonction onMediaStream() est appelée. Si vous refusez les autorisations, la promesse est rejetée et la fonction noMediaStream() est appelée. Lorsque nous recevrons le flux, nous l'enregistrerons dans notre objet VideoChat et l'ajouterons à l'élément vidéo pour que vous puissiez vous voir (nous diminuons également le volume à 0 pour éviter les échos). Pour ce faire, nous devons affecter le flux renvoyé par getUserMedia à la propriété srcObject de l'élément vidéo. Nous désactivons également le bouton « Get Video » (Obtenir la vidéo), car nous n'en avons plus besoin.

Enregistrez cela, rechargez la page, cliquez sur « Get Video » (Obtenir la vidéo) et vous devriez voir la fenêtre contextuelle des autorisations. Acceptez et vous devriez vous voir !

Moi faisant signe à la caméra !

Présence de l'utilisateur

Ensuite, nous devons établir un moyen de savoir que nous avons un autre utilisateur à l'autre bout prêt à passer un appel. À la fin de cette section, nous aurons activé le bouton « Call » (Appeler) lorsque nous saurons qu'il y a quelqu'un à l'autre bout.

Afin de commencer à transmettre des messages entre les navigateurs dans le cadre de notre signalisation, nous devons commencer à utiliser nos WebSockets. Ouvrez à nouveau index.js et copiez et collez le code suivant avant la fonction server.start.

// index.js
io.on('connection', function(socket){
  socket.on('join', function(room){
    var clients = io.sockets.adapter.rooms[room];
    var numClients = typeof clients !== 'undefined' ? clients.length : 0;
    if(numClients == 0){
      socket.join(room);
    }else if(numClients == 1){
      socket.join(room);
      socket.emit('ready', room);
      socket.broadcast.emit('ready', room);
    }else{
      socket.emit('full', room);
    }
  });
});

C'est une idée très basique d'une salle et d'une présence. Seuls deux utilisateurs peuvent rejoindre la salle à la fois. Lorsqu'un client tente de rejoindre une salle, nous comptabilisons le nombre de clients actuellement présents dans la salle. Si le nombre est égal à zéro, le client peut s'y joindre. S'il est égal à un, il peut s'y joindre et le socket indique aux deux clients qu'ils sont prêts. S'il y a déjà deux clients dans la salle, elle a atteint sa capacité maximale et aucun autre client ne peut s'y joindre pour le moment.

Nous devons maintenant rejoindre la salle du client. Nous avons besoin de démarrer une connexion au serveur socket. Nous pouvons le faire en appelant simplement io(). Affectez-le à notre objet VideoChat pour que nous puissions l'utiliser ultérieurement. Ensuite, à la fin de la fonction onMediaStream, ajoutez deux lignes supplémentaires, l'une pour rejoindre la salle et l'autre pour écouter l'événement. Nous avons alors besoin d'une fonction de rappel une fois que nous avons entendu que la salle est prête. Dans ce rappel, nous allons activer le bouton « Call » (Appeler).

// app.js
var VideoChat = {
  socket: io(),
  //...
  onMediaStream: function(stream) {
    VideoChat.localVideo = document.getElementById('local-video');
    VideoChat.localVideo.volume = 0;
    VideoChat.localStream = stream;
    VideoChat.videoButton.setAttribute('disabled', 'disabled');
    VideoChat.localVideo.srcObject = stream;
    VideoChat.socket.emit('join', 'test');
    VideoChat.socket.on('ready', VideoChat.readyToCall);
  },

  readyToCall: function(event){
    VideoChat.callButton.removeAttribute('disabled');
  },
  //...
};

Il vaut mieux également contrôler ce bouton « Call » (Appeler). Au bas du fichier où nous avons saisi le bouton « Get Video » (Obtenir la vidéo), nous allons faire de même pour le bouton « Call » (Appeler).

// app.js
VideoChat.callButton = document.getElementById('call');

VideoChat.callButton.addEventListener(
  'click',
  VideoChat.startCall,
  false
);

Créons une méthode startCall factice dans l'objet VideoChat pour nous assurer que les choses se déroulent comme prévu.

// app.js
var VideoChat = {
  //...
  startCall: function(event){
    console.log("Things are going as planned!");
  }
};

Redémarrez maintenant le serveur de nœud (Ctrl + C pour arrêter le processus et $ node index.js pour le redémarrer), ouvrez deux fenêtres de navigateur sur http://localhost:3000 et cliquez sur « Get Video » (Obtenir la vidéo) dans les deux. Une fois que les deux vidéos sont en cours de lecture, les boutons « Call » (Appeler) de chaque fenêtre doivent être actifs, et un clic sur le bouton « Call » (Appeler) devrait enregistrer un message adéquat sur la console de votre navigateur.

Démarrage de la signalisation

Notre bouton « Call » (Appeler) est très important, car il va lancer le reste du processus WebRTC. Ce sont les dernières interactions que l'utilisateur doit exécuter pour démarrer l'appel.

Le bouton « Call » (Appeler) va configurer un certain nombre de processus. Il va créer l'objet RTCPeerConnection qui gérera la création de la connexion entre les deux navigateurs. Cela consiste à produire des informations sur les capacités multimédias du navigateur et la configuration réseau. C'est notre travail de les envoyer à l'autre navigateur.

Signalisation de la configuration réseau

Pour configurer l'objet RTCPeerConnection, nous devons lui donner des détails sur les serveurs STUN et TURN qu'il utilisera pour découvrir la configuration réseau. Pour cela, nous allons utiliser les nouveaux serveurs Twilio STUN/TURN. La méthode la plus simple consiste à utiliser simplement les serveurs STUN. Ils sont libres et ne nécessitent aucune autorisation. Les iceServers (et les iceCandidates que vous verrez plus tard) font référence au protocole global d'établissement de connectivité interactive qui utilise les serveurs STUN et TURN.

// app.js
var VideoChat = {
  //...
  startCall: function(event){
    VideoChat.peerConnection = new RTCPeerConnection({
      iceServers: [{url: "stun:global.stun.twilio.com:3478?transport=udp" }]
    });
  }
};

Afin d'obtenir les meilleures chances possibles d'une connexion, nous souhaiterons également utiliser les serveurs TURN. Pour ce faire, nous aurons besoin de demander un token éphémère à Twilio en utilisant le nouveau point de terminaison de tokens qui nous donnera accès aux serveurs TURN à partir de notre front-end JavaScript. Nous allons devoir demander ce token à notre serveur et remettre les résultats au navigateur. Comme nous avons déjà une connexion WebSocket configurée, nous allons l'utiliser. Voici le flux que nous allons utiliser dans la section suivante :

Le navigateur demande le jeton au serveur via WebSockets, le serveur le demande à Twilio et, lorsqu&#x27;il l&#x27;obtient, il le renvoie au navigateur via WebSocket.

Retournez sur index.js et, dans le rappel de l'événement de connexion du socket, placez le code suivant :

// index.js
io.on('connection', function(socket){
  //...
  socket.on('token', function(){
    twilio.tokens.create(function(err, response){
      if(err){
        console.log(err);
      }else{
        socket.emit('token', response);
      }
    });
  });
});

Ici, lorsque le socket reçoit un message de token, il envoie une requête à l'API REST Twilio. Lorsqu'il reçoit le token dans le rappel de la demande, il le retransmet au front-end. Construisons maintenant la partie front-end.

Notre fonction startCall doit maintenant utiliser le socket pour obtenir un token. Nous configurons donc simplement pour écouter un message de token du serveur et en émettre un nous-mêmes.

// app.js
var VideoChat = {
  //...
  startCall: function(event){
    VideoChat.socket.on('token', VideoChat.onToken);
    VideoChat.socket.emit('token');
  },
  //...
};

À présent, nous devons définir la méthode onToken pour initialiser notre RTCPeerConnection avec les iceServers renvoyés par l'API. Cela lance le processus d'obtention de la configuration réseau. Nous devons donc ajouter une fonction de rappel à peerConnection pour traiter les résultats de cette opération. Il s'agit du rappel onicecandidate, et il est appelé chaque fois que peerConnection génère un moyen potentiel de s'y connecter depuis le monde extérieur. En tant que développeur, notre travail consiste à partager ce candidat avec l'autre navigateur. Nous allons donc l'envoyer par la connexion WebSocket.

Le rappel reçoit un candidat et l&#x27;appelant partage le candidat avec l&#x27;autre navigateur via WebSocket.
// app.js
var VideoChat = {
  //...
  onToken: function(token){
    VideoChat.peerConnection = new RTCPeerConnection({
      iceServers: token.iceServers
    });

    VideoChat.peerConnection.onicecandidate = VideoChat.onIceCandidate;
  },

  onIceCandidate: function(event){
    if(event.candidate){
      console.log('Generated candidate!');
      VideoChat.socket.emit('candidate', JSON.stringify(event.candidate));
    }
  }
};

Sur le serveur, nous devons envoyer ce candidat directement à l'autre navigateur :

// index.js
io.on('connection', function(socket){
  //...
  socket.on('candidate', function(candidate){
    socket.broadcast.emit('candidate', candidate);
  });  
});

Ensuite, nous devons être en mesure de recevoir ces messages dans le front-end, cette fois au nom de l'autre navigateur. Nous avons configuré l'écouteur pour le socket dans la fonction onToken, car c'est à ce moment que nous créons peerConnection et nous serons prêts à traiter les candidats.

// app.js
var VideoChat = {
  //...
  onToken: function(token){
    VideoChat.peerConnection = new RTCPeerConnection({
      iceServers: token.iceServers
    });
    VideoChat.peerConnection.onicecandidate = VideoChat.onIceCandidate;
    VideoChat.socket.on('candidate', VideoChat.onCandidate);
  },
  //...
  onCandidate: function(candidate){
    rtcCandidate = new RTCIceCandidate(JSON.parse(candidate));
    VideoChat.peerConnection.addIceCandidate(rtcCandidate);
  }
};

La méthode onCandidate reçoit le candidat stringify sur le socket, le transforme en RTCIceCandidate et l'ajoute au peerConnection du navigateur. Vous vous demandez peut-être où le deuxième navigateur a obtenu un objet peerConnection puisque nous avons créé cet objet uniquement lorsque l'utilisateur a cliqué sur le bouton « Call » (Appeler) dans le premier navigateur. Vous avez raison de vous poser la question, mais ne vous inquiétez pas, nous allons y répondre très bientôt.

Nous ne pouvons pas encore procéder à un test, car l'objet peerConnection ne commence pas à générer des candidats tant que la partie suivante n'est pas terminée. Nous avançons bien, mais nous devons partager davantage d'informations entre les navigateurs.

Partage de la configuration des éléments multimédias

Dans la dernière section, nous avons défini comment l'initiateur d'appel commence à partager sa configuration réseau. Nous devons maintenant régler le partage des informations multimédias. Les objets peerConnection de chaque navigateur devront générer des descriptions de leurs capacités multimédia. L'appelant va créer une offre détaillant ces fonctionnalités et l'envoyer via la connexion WebSocket. L'autre navigateur prend cette offre et crée une réponse contenant ses propres capacités et la renvoie à l'appelant. Nous allons mettre en œuvre ce processus ci-dessous, mais voici un diagramme pour illustrer ce qui devrait se passer.

L&#x27;appelant crée une offre et l&#x27;envoie via WebSocket, le récepteur crée une réponse et la renvoie.

Envoi de l'offre

Commençons ce processus par l'offre. Une fois que nous avons créé l'objet peerConnection, nous y ajoutons notre localStream. Cela fait, nous appelons createOffer sur peerConnection. Ceci génère la configuration des éléments multimédia et rappelle la fonction transmise. Dans le rappel, nous appelons setLocalDescription avec l'offre sur peerConnection et nous envoyons l'offre sur le socket à l'autre navigateur. Nous avons également besoin d'un rappel pour les erreurs si createOffer n'aboutit pas.

// app.js
var VideoChat = {
  //...
  onToken: function(token){
    VideoChat.peerConnection = new RTCPeerConnection({
      iceServers: token.iceServers
    });
    VideoChat.peerConnection.onicecandidate = VideoChat.onIceCandidate;
    VideoChat.socket.on('candidate', VideoChat.onCandidate);
    VideoChat.peerConnection.addStream(VideoChat.localStream);
    VideoChat.peerConnection.createOffer(
      function(offer){
        VideoChat.peerConnection.setLocalDescription(offer);
        socket.emit('offer', JSON.stringify(offer));
      },
      function(err){
        console.log(err);
      }
    );
  },
  //...
};

Sur le serveur, nous devons à nouveau transmettre ce message.

// index.js
io.on('connection', function(socket){
  //...
  socket.on('offer', function(offer){
    socket.broadcast.emit('offer', offer);
  });
});

Réception de l'offre

Ensuite, dans le front-end, nous devons recevoir l'offre. Cette fois-ci, nous allons configurer l'écouteur dans onMediaStream, car il déclenchera la création de peerConnection dans l'autre navigateur.

// app.js
var VideoChat = {
  //...
  onMediaStream: function(stream){
    VideoChat.localVideo = document.getElementById('local-video');
    VideoChat.localStream = stream;
    VideoChat.videoButton.setAttribute('disabled', 'disabled');
    VideoChat.localVideo.srcObject = stream;   
    VideoChat.socket.emit('join', 'test');
    VideoChat.socket.on('ready', VideoChat.readyToCall);
    VideoChat.socket.on('offer', VideoChat.onOffer);
  },

  onOffer: function(offer){
    console.log('Got an offer')
    console.log(offer);
  },    
  //...
};

Exécutez cela pour vous assurer que vous êtes sur la bonne voie jusqu'à présent. Redémarrez le serveur et revenez aux fenêtres de navigateur. Actualisez-les, cliquez sur « Get Video » (Obtenir la vidéo) dans les deux et acceptez la demande d'autorisation. Ouvrez une console de développeur dans une fenêtre et cliquez sur « Call » (Appeler) dans l'autre navigateur. Vous devriez voir « Got an offer » (Offre reçue) affiché sur la console, suivi d'une chaîne JSON de l'offre envoyée. L'un des côtés de notre signalisation fonctionne !

Il y a beaucoup d'informations dans l'offre, mais heureusement, nous n'avons pas besoin d'examiner cela en profondeur pour le moment. Elles doivent juste être passées entre les objets peerConnection dans chaque navigateur. Poursuivez la construction.

À ce stade, nous pourrions passer un appel à VideoChat.startCall, mais en fin de compte, cela créerait une offre envoyée par le biais du socket au premier navigateur qui reprendrait ce processus en boucle. Ce que nous voulons vraiment faire ici, c'est créer une réponse et la retourner au premier navigateur. Je pense que nous avons besoin d'une refactorisation à ce stade.

Refactorisation

Il nous faut un moyen de créer un objet peerConnection pour nous-mêmes et de configurer les écouteurs, mais il s'agit de décider si nous créons une offre ou une réponse à envoyer à l'autre navigateur.

Pour ce faire, je vais mettre à jour la fonction onToken pour prendre une fonction de rappel qui nous permettra de décrire ce qui se passe une fois peerConnection configuré. Étant donné que onToken est également utilisé comme rappel, la définition de fonction renvoie désormais une fonction qui deviendra le rappel :

// app.js
var VideoChat = {
  //...
  onToken: function(callback){
    return function(token){
      VideoChat.peerConnection = new RTCPeerConnection({
        iceServers: token.iceServers
      });
      VideoChat.peerConnection.addStream(VideoChat.localStream);
      VideoChat.peerConnection.onicecandidate = VideoChat.onIceCandidate;
      VideoChat.socket.on('candidate', VideoChat.onCandidate);
      callback();
    }
  },
  //...
};

Ainsi, la fonction de rappel remplace notre méthode originale de création de l'offre, pour laquelle nous aurons besoin d'une nouvelle fonction :

// app.js
var VideoChat = {
  //...
  createOffer: function(){
    VideoChat.peerConnection.createOffer(
      function(offer){
        VideoChat.peerConnection.setLocalDescription(offer);
        VideoChat.socket.emit('offer', JSON.stringify(offer));
      },
      function(err){
        console.log(err);
      }
    );
  },
  //...
};

Ensuite, nous changeons startCall pour configurer les rappels comme suit :

// app.js
var VideoChat = {
  //...
  startCall: function(event){
    VideoChat.socket.on('token', VideoChat.onToken(VideoChat.createOffer));
    VideoChat.socket.emit('token');
  },
  //...
};

Nous pouvons maintenant commencer à définir les fonctions de création d'une réponse.

// app.js
var VideoChat = {
  //...
  createAnswer: function(offer){
    return function(){
      rtcOffer = new RTCSessionDescription(JSON.parse(offer));
      VideoChat.peerConnection.setRemoteDescription(rtcOffer);
      VideoChat.peerConnection.createAnswer(
        function(answer){
          VideoChat.peerConnection.setLocalDescription(answer);
          VideoChat.socket.emit('answer', JSON.stringify(answer));
        },
        function(err){
          console.log(err);
        }
      );
    }
  },
  //...
};

Dans ce cas, nous voulons utiliser createAnswer comme rappel pour la création de peerConnection, mais nous devons également utiliser l'offre pour définir la description distante sur peerConnection. Cette fois, nous allons créer une clôture en appelant la fonction avec l'offre et renvoyer une fonction à utiliser comme rappel. À présent, lorsque peerConnection est créé, nous retournons à la fonction interne et transformons l'offre que nous avons reçue via le socket en un objet RTCSessionDescription et la définissions comme description distante. Nous créons ensuite la réponse sur l'objet peerConnection, ce qui est à peu près la même chose que lorsque nous avons créé l'offre en premier lieu, et nous l'envoyons à nouveau sur le socket.

Voici comment nous configurons notre fonction onOffer à présent :

// app.js
var VideoChat = {
  //...
  onOffer: function(offer){
    VideoChat.socket.on('token', VideoChat.onToken(VideoChat.createAnswer(offer)));
    VideoChat.socket.emit('token');
  },
  //...    
};

Établissement de la connexion

Maintenant que nous envoyons une réponse sur le socket, il nous suffit de la transmettre à l'appelant d'origine, puis d'attendre que le navigateur fasse son tour de magie.

Dans index.js, nous allons configurer le relais pour la réponse.

// index.js
io.on('connection', function(socket){
  //...
  socket.on('answer', function(answer){
    socket.broadcast.emit('answer', answer);
  });
});

Ensuite, nous devons configurer la réception de la réponse dans le navigateur. Nous allons ajouter un autre écouteur au socket lors de la création de peerConnection et construire la fonction de rappel pour enregistrer la réponse en tant que description distante de peerConnection.

// app.js
var VideoChat = {
  //...
  onToken: function(callback){
    return function(token){
      VideoChat.peerConnection = new RTCPeerConnection({
        iceServers: token.iceServers
      });
      VideoChat.peerConnection.addStream(VideoChat.localStream);
      VideoChat.peerConnection.onicecandidate = VideoChat.onIceCandidate;
      VideoChat.peerConnection.onaddstream = VideoChat.onAddStream;
      VideoChat.socket.on('candidate', VideoChat.onCandidate);
      VideoChat.socket.on('answer', VideoChat.onAnswer);
      callback();
    }
  },

  onAnswer: function(answer){
    var rtcAnswer = new RTCSessionDescription(JSON.parse(answer));
    VideoChat.peerConnection.setRemoteDescription(rtcAnswer);
  },
  //...
};

Les navigateurs transmettent désormais des fonctionnalités multimédia et des informations de connexion entre eux. Il ne reste donc plus qu'une autre chose à faire. Lorsqu'une connexion est établie, peerConnection reçoit un événement onaddstream avec le flux du média du pair. Il nous suffit de le connecter à notre autre élément <video> et le chat vidéo sera activé. Nous allons ajouter le rappel onaddstream dans lequel nous créerons l'élément peerConnection.

// app.js
var VideoChat = {
  //...
  onToken: function(callback){
    return function(token){
      VideoChat.peerConnection = new RTCPeerConnection({
        iceServers: token.iceServers
      });
      VideoChat.peerConnection.addStream(VideoChat.localStream);
      VideoChat.peerConnection.onicecandidate = VideoChat.onIceCandidate;
      VideoChat.peerConnection.onaddstream = VideoChat.onAddStream;
      VideoChat.socket.on('candidate', VideoChat.onCandidate);
      VideoChat.socket.on('answer', VideoChat.onAnswer);      
      callback();
    }
  },

  onAddStream: function(event){
    VideoChat.remoteVideo = document.getElementById('remote-video');
    VideoChat.remoteVideo.srcObject = event.stream;
  },
  //...
};

Chargez deux navigateurs l'un à côté de l'autre, ouvrez votre URL de développement sur localhost:3000 sur les deux navigateurs, obtenez le flux vidéo dans les deux navigateurs et cliquez sur « Call » (Appeler) à partir de l'un d'eux. Vous devriez vous voir vous-même. Quatre fois !

Moi, me faisant signe, deux fois !

La touche finale

Avant de mettre en œuvre le dernier code, résumons ce qui se passe lors de la sélection du bouton « Call » (Appeler) : 

  1. Le client A (le client qui passe l'appel) demande et obtient un token auprès du serveur
  2. Le client A utilise les informations contenues dans le token pour ajouter les serveurs Twilio ICE à une nouvelle RTCPeerConnection
  3. Le client A envoie une offre sur le serveur pour qu'elle soit envoyée à l'autre pair 
  4. Le client A peut maintenant commencer à recevoir des candidats ICE et à les envoyer à l'autre pair via le serveur
  5. Le client B (le client qui reçoit l'appel), lorsqu'il reçoit une offre, demande et obtient un token auprès du serveur
  6. Une fois que le client B reçoit un token, il crée une nouvelle RTCPeerConnection et une réponse, puis les envoie au client A via le serveur
  7. Le client B peut maintenant enregistrer un rappel pour les candidats des serveurs ICE locaux (onIceCandidate) et distants (onCandidate)

Voyez-vous le problème ici ? À l'étape 4, le client A peut potentiellement envoyer des candidats ICE avant que le client B ne soit prêt à les recevoir (après l'étape 7). Lors de vos tests, cela a fonctionné car les deux clients étaient sur le même localhost. Cependant, si vous essayez sur deux périphériques se trouvant sur deux réseaux différents, vous verrez que le client B ne peut pas atteindre le client A.

Pour résoudre ce problème (et vous permettre de passer des appels vidéo avec vos amis), nous allons mettre en œuvre un tampon des candidats ICE sur le Client A

// app.js
var VideoChat = {
  var connected = false;
  var localICECandidates = [];
  //..

  onIceCandidate: function(event){
    if(event.candidate){
      if (VideoChat.connected) {
        console.log('Generated candidate!');
        VideoChat.socket.emit('candidate', JSON.stringify(event.candidate));
      } else {
        VideoChat.localICECandidates.push(event.candidate);
      }
    }
  },

//..
}

Nous vérifions ici si VideoChat est connecté avant d'envoyer le candidat au serveur. Si VideoChat n'est pas connecté, nous l'ajoutons à un tampon ou buffer local (le tableau localICECandidates). 

Mais comment pouvons-nous détecter si VideoChat est connecté ? Comment le client A sait-il que le client B est prêt à recevoir des candidats ? Sur le client A, cela se produit lorsqu'il reçoit une réponse du client B

// app.js
var VideoChat = {
  //..

  onAnswer: function(answer) {
    var rtcAnswer = new RTCSessionDescription(JSON.parse(answer));
    VideoChat.peerConnection.setRemoteDescription(rtcAnswer);
    VideoChat.connected = true;
    VideoChat.localICECandidates.forEach(candidate => {
      VideoChat.socket.emit('candidate', JSON.stringify(candidate));
    });
    VideoChat.localICECandidates = [];
  },

//..
}

Sur le client B, nous pouvons définir l'état connecté, juste avant de renvoyer la réponse à l'autre pair. 

// app.js
var VideoChat = {
  //..

  createAnswer: function(offer) {
    return function() {
      connected = true;
      rtcOffer = new RTCSessionDescription(JSON.parse(offer));
      VideoChat.peerConnection.setRemoteDescription(rtcOffer);
     //..
   }
  },

//..
}

Si vous souhaitez le tester avec une personne sur un autre réseau, vous pouvez exposer votre localhost sur une adresse publique à l'aide d'un service gratuit tel que ngrok. N'oubliez pas d'utiliser l'URL HTTPS pour vous connecter à l'adresse publique, sinon le navigateur ne pourra pas acquérir les entrées vidéo et audio (ce n'est pas un problème lorsque vous vous connectez au localhost)

Ce n'est que le début

Il ne s'agit que de la première étape du développement de toutes sortes d'applications WebRTC potentielles. Une fois que vous avez pris le temps de configurer la connexion entre deux navigateurs, libre à vous de décider ce que vous allez en faire. Dans cet exemple, la création d'un moyen pour les utilisateurs de raccrocher, ou la création d'une salle d'attente avec des contrôles de présence beaucoup plus efficaces, peuvent être un début.

Et puis, il y a davantage de choses amusantes que vous pourriez essayer. Vous pourriez transmettre les flux vidéo à une toile pour les modifier, utiliser l'API WebAudio pour changer le son ou encore, utiliser le canal de données (que je n'ai pas abordé dans ce post) pour transmettre toutes les données de votre choix entre les pairs.

Tout le code de ce post, entièrement commenté, est disponible sur GitHub.

J'aimerais beaucoup voir ce que vous allez faire ou ce que vous avez déjà fait avec WebRTC. Contactez-moi sur Twitter ou envoyez-moi un e-mail à philnash@twilio.com.