Introduction

Nous verrons tout d’abord ce qu’est la technologie WebRTC et ce qu’elle apporte avec HTML5.

Ensuite, nous passerons à la pratique en mettant en place un site de visio-conférence, où l’on pourra rejoindre différents canaux statiques et participer à la visio-conférence avec les utilisateurs déjà connectés à ce canal.

Nous verrons étape par étape comment mettre en place ce site avec Node.js et Vue 3.

WebRTC, kézako ?

WebRTC, pour Web Real Time Communication, est un ensemble de normes pour le partage de données en temps réel et pour répondre aux besoins d’une utilisation native et standardisée de ces fonctionnalités par les navigateurs via de nouvelles API en HTML5.

On y trouve 3 API :

  • MediaStream pour récupérer le flux d’un média de l’utilisateur
  • RTCPeerConnection pour la communication des flux de données entre navigateurs
  • RTCDataChannel pour le partage de données entre utilisateurs

CORS et https

Avant de commencer la pratique, deux points me semblent important à aborder avant toutes choses.

Tout d’abord, le site utilisera un serveur Node.js et une application Vue 3 qui devront communiquer entre-elles, et cela implique donc l’autorisation de CORS (Cross-Origin Ressource Sharing). La mise en place de CORS de manière propre et adaptée à vos besoins est un tout autre sujet, aussi il ne sera pas abordé dans ce tutoriel et je me contenterai pour ma part d’utiliser un plugin sur navigateur pour les autoriser sur mon application.

De même, le test de l’application sur téléphone nécessitera que le site soit en https, WebRTC bloquant l’accès aux périphériques utilisateurs si le site n’est pas sécurisé. Cela ne sera pas non plus traité dans ce tutoriel.

Mise en place du serveur Node

Nous allons tout d’abord préparer le serveur Node.js avec Express, et nous utiliserons simple-signal-server, une librairie basée sur Socket.io pour simplifier la gestion des sockets.

Enfin, nous créerons une application Vue 3 pour interagir avec le serveur.

Créez un nouveau répertoire server pour le serveur et lancez la commande npm init, puis remplissez les champs selon votre convenance. Pour notre part, nous nommerons le projet webrtc_server et nous utiliserons server.js comme point d’entrée. Ce qui nous donne le package.json suivant :

1
2
3
4
5
6
7
8
9
10
11
{
  "name": "webrtc_server",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

Installez maintenant Node.js, Express, Socket.io, et simple-signal-server avec npm i --save node express socket.io simple-signal-server.

Créons ensuite un fichier server.js qui sera le point d’entrée de notre serveur :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"use strict";

const express = require("express");

// Constants
const PORT = 3000;

// App
const app = express();

app.get("/", (req, res) => {
  res.send("Hello World");
});

app.listen(PORT);

Si vous lancez le serveur avec npm run start et que vous accédez à http://localhost:8090/, vous devriez voir le “Hello world” de votre fichier server.js.

Maintenant que nous savons que notre serveur est bien configuré sur le port 8090, nous pouvons supprimer la réponse par défaut du app.get(.... et commencer l’initialisation de la gestion des sockets.

Dans les grandes lignes, le serveur aura pour rôle de mettre en relation les différentes sockets des utilisateurs pour communiquer entre eux. Il devra donc stocker les informations des utilisateurs de chaque canal de discussion que l’on va créer.

Tout d’abord, nous allons créer notre élément de signalisation de la librairie simple-signal-server.

1
2
3
4
5
6
// initialisation du serveur
const server = require("http").Server(app);
// initialisation de Socket.io
const io = require("socket.io")(server);
// initialisation de simple-signal-server
const signalServer = require("simple-signal-server")(io);

Ce n’est plus app que nous exposons, mais server sur le port configuré :

1
server.listen(PORT);

Ajoutons ensuite une variable pour stocker les identifiants des sockets utilisateurs.

1
const channels = {};

Notre élément signalServer, lors de l’interception d’un évènement discover qui sera envoyé lors de la connexion d’un nouvel utilisateur à un canal de discussion, va enregistrer l’identifiant du canal dans channels s’il n’existe pas encore, et va ajouter l’identifiant du client dans la liste des identifiants du canal puis renvoyer une réponse discover au client de l’utilisateur souhaitant se connecter avec la liste des identifiants du canal.

1
2
3
4
5
6
7
8
9
signalServer.on("discover", (request) => {
  const channelId = request.discoveryData;
  const clientID = request.socket.id; // clients are uniquely identified by socket.id
  if (!channels[channelId]) {
    channels[channelId] = new Set();
  }
  channels[channelId].add(clientID); // keep track of all connected peers
  request.discover(Array.from(channels[channelId])); // respond with id and list of other peers
});

Lors d’un évènement disconnect reçu, on va tout simplement supprimer l’identifiant du client des canaux.

1
2
3
4
5
6
signalServer.on("disconnect", (socket) => {
  const clientID = socket.id;
  Object.keys(channels).forEach((channelId) => {
    channels[channelId].delete(clientID);
  });
});

Enfin, lors d’un évènement request reçu lors d’une demande de connexion, on va juste faire suivre la requête au client ciblé.

1
2
3
signalServer.on("request", (request) => {
  request.forward();
});

Mise en place de l’application Vue 3

Créons maintenant l’application Vue 3 avec la commande suivante depuis la racine du projet :

1
vue create client

Ici, nous choisirons l’option Default (Vue 3).

Ensuite, nous allons installer les paquets client de nos outils pour la gestion des sockets, socket.io-client et simple-signal-client :

1
npm i --save socket.io-client simple-signal-client

Renommons le composant HelloWorld.vue en Channel.vue et changeons le code du composant pour avoir ceci :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<template>
  <div class="video-list">
    <div
      v-for="item in videoList"
      :video="item"
      :key="item.id"
      class="video-item"
      :class="{ 'my-video': item.isLocal }"
    >
      <video
        controls
        autoplay
        playsinline
        ref="videos"
        :muted="item.muted"
        :id="item.id"
      ></video>
    </div>
  </div>
</template>

<script>
import { io } from "socket.io-client";
const SimpleSignalClient = require("simple-signal-client");

export default {
  name: "Channel",
  data() {
    return {
      socket: undefined,
      signalClient: undefined,
      videoList: [],
      channelId: undefined,
    };
  },
};
</script>
<style scoped></style>

Nous avons 4 variables de composant :

  • socket : la socket du client que l’on aura créé
  • signalClient : l’instance de la librairie simple-signal-client
  • videoList : la liste des vidéos à afficher
  • channelId : l’identifiant du canal rejoint

Le template parcourt la liste des vidéos videoList pour les afficher et ajoute la classe my-video si la propriété isLocal est true, ce qui signifiera que cette vidéo est tout simplement la nôtre. On a également une référence sur les balises vidéos videos, qui nous permettra plus tard de récupérer la liste des vidéos dans le template pour paramétrer leur source par la suite.

Avant de continuer sur ce composant, nous allons modifier App.vue pour l’implémentation de Channel.vue.

Nous allons dans le script importer le composant Channel.vue, ajouter une liste de canaux de discussion channelList, et l’identifiant du canal sélectionné selectedChannelId.

On va également ajouter deux fonctions, selectChannel et leave, qui vont juste mettre à jour selectedChannelId pour l’instant.

Voici ce que l’on a dans le script du composant :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<script>
import Channel from "./components/Channel.vue";

export default {
  name: "App",
  components: {
    Channel,
  },
  data() {
    return {
      selectedChannelId: undefined,
      channelList: [
        { title: "Channel 1", id: "1" },
        { title: "Channel 2", id: "2" },
        { title: "Channel 3", id: "3" },
        { title: "Channel 4", id: "4" },
        { title: "Channel 5", id: "5" },
      ],
    };
  },
  methods: {
    selectChannel(channelId) {
      this.selectedChannelId = channelId;
    },
    leave() {
      this.selectedChannelId = null;
    },
  },
};
</script>

Ensuite, dans le template, on va :

  • créer un header juste pour l’habillage
  • afficher la liste des canaux sur le côté si aucun canal n’est sélectionné, et on déclenche la fonction selectChannel au clic
  • afficher un bouton leave sur le côté si un canal est sélectionné, et on déclenche la fonction leave au clic
  • intégrer le composant Channel dans la fenêtre centrale avec la référence channel

Nous avons donc cela dans le template :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
  <div class="page-container">
    <div class="header"></div>
    <div class="content">
      <div class="channels-list">
        <template v-for="channel in channelList" :key="channel.id">
          <div
            v-if="!selectedChannelId"
            class="channel"
            :class="{ active: channel.id === selectedChannelId }"
            @click="selectChannel(channel.id)"
          >
            
          </div>
        </template>
        <div v-if="selectedChannelId" class="channel" @click="leave">Leave</div>
      </div>
      <div class="channel-container">
        <Channel ref="channel" />
      </div>
    </div>
  </div>
</template>

Et un peu de style pour que cela ressemble un peu à quelque chose :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
html,
body,
#app {
  margin: 0;
  padding: 0;
  height: 100%;
  width: 100%;
}
.page-container {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}

.header {
  width: 100%;
  height: 60px;
  background-color: #243252;
  color: white;
}

.content {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: row;
}
.channels-list {
  height: 100%;
  width: 250px;
  border-right: 5px solid #fc766a;
}
.channel {
  width: 100%;
  padding: 20px;
  color: #fc766a;
  cursor: pointer;
  box-sizing: border-box;
  font-weight: bold;
}
.channel:hover,
.channel.active {
  background-color: #fc766a;
  color: white;
}
.channel-container {
  width: 100%;
  height: 100%;
}

Vous devriez avoir ceci en lançant le client :

Visualisation App.vue

Maintenant que nous avons notre template avec une référence sur notre composant Channel, nous allons modifier nos deux méthodes selectChannel et leave pour appeler des fonctions de Channel.vue comme ceci :

1
2
3
4
5
6
7
8
9
10
  methods: {
    selectChannel(channelId) {
      this.selectedChannelId = channelId;
      this.$refs.channel.join(channelId);
    },
    leave() {
      this.$refs.channel.leave();
      this.selectedChannelId = null;
    },
  },

Nous en avons fini avec App.vue, retournons maintenant dans Channel.vue implémenter ces deux fonctions join et leave.

Commençons par la plus simple, la fonction leave. Cette fonction fera 3 choses :

  • Stopper les flux locaux vers les autres utilisateurs
  • Supprimer les listeners de chaque paire de connexion et supprimer l’instance signalClient
  • Supprimer la socket du client

Cela nous donne ceci :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    leave() {
      this.videoList.forEach((v) =>
        v.stream.getTracks().forEach((t) => t.stop())
      );
      this.videoList = [];
      if (this.signalClient) {
        this.signalClient.peers().forEach((peer) => peer.removeAllListeners());
        this.signalClient.destroy();
        this.signalClient = null;
      }
      if (this.socket) {
        this.socket.destroy();
        this.socket = null;
      }
    }

Passons maintenant à la fonction join. Cette fonction aura tout d’abord besoin de trois autres fonctions :

  • joinedChannel : cette fonction sera lancée lorsque qu’un nouvel utilisateur se connectera au canal
  • onPeer : cette fonction sera lancée lorsque qu’une nouvelle paire de connexion sera réalisée
  • connectToPeer : fonction de lancement d’une connexion de paire

Voyons tout d’abord joinedChannel. Cette fonction prendra en paramètre un flux de données stream et un booléen isLocal.

isLocal nous permettra d’appeler cette fonction sur notre propre flux de données mais en désactivant le son et en ajoutant une propriété isLocal pour retrouver notre vidéo dans la liste des vidéos videoList.

Dans cette fonction, on va :

  • générer l’objet video que l’on va enregistrer dans videoList si ce flux de données n’est pas déjà présent.
  • ajouter le flux de données en tant que source de la vidéo dans notre template grâce à sa référence.

Cela va nous donner le code ci-dessous :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
    joinedChannel(stream, isLocal) {
      const currentContext = this;

      // create video on videoList if not exist
      const found = currentContext.videoList.some((video) => {
        return video.id === stream.id;
      });
      if (!found) {
        const video = {
          id: stream.id,
          muted: isLocal,
          stream: stream,
          isLocal: isLocal,
        };

        currentContext.videoList.push(video);
      }

      // set stream to video srcObject property
      setTimeout(() => {
      const { videos } = currentContext.$refs;
        for (
          let i = 0, len = videos.length;
          i < len;
          i++
        ) {
          if (videos[i].id === stream.id) {
            videos[i].srcObject = stream;
            break;
          }
        }
      }, 500);
    },

Maintenant intéressons-nous à onPeer.

Lors d’une connexion de paire, on va ajouter notre flux de données local et écouter l’évènement stream qui sera déclenché lors de l’ajout d’un flux de données de l’autre utilisateur lié par cette paire. Lors de l’écoute de cet évènement, on appellera joinedChannel pour ajouter la vidéo de cet utilisateur, et on écoutera l’évènement close en cas d’arrêt du flux pour supprimer la vidéo.

Cela nous donne :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    onPeer(peer, localStream) {
      const currentContext = this;
      // add our local stream to the peer
      peer.addStream(localStream);
      // receive remote stream
      peer.on("stream", (remoteStream) => {
        // add new user stream to the videos list
        currentContext.joinedChannel(remoteStream, false);
        // listen on stream closure
        peer.on("close", () => {
          // remove remote user video from videos list
          const newList = [];
          currentContext.videoList.forEach(function (item) {
            if (item.id !== remoteStream.id) {
              newList.push(item);
            }
          });
          currentContext.videoList = newList;
        });
      });
    },

Regardons connectToPeer. Cette fonction prend en paramètre l’identifiant de la socket avec laquelle on souhaite créer cette paire de connexion.

Si l’identifiant correspond à notre propre socket, on ne fait rien. Si l’identifiant est valide, on lance une connexion et lance onPeer pour envoyer notre flux de données et réceptionner les évènements de cette paire.

Cela nous donne :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    async connectToPeer(peerID) {
      if (peerID == this.socket.id) return;
      try {
        // peer connexion
        const { peer } = await this.signalClient.connect(
          peerID,
          this.channelId
        );
        this.videoList.forEach((v) => {
          if (v.isLocal) {
            // add our stream to the peer and listen to event
            this.onPeer(peer, v.stream);
          }
        });
      } catch (e) {
        console.error(e);
      }
    },

Revenons donc maintenant à la fonction join. Elle prend en paramètre l’identifiant du canal sélectionné dans App.vue.

On commence par initialiser channelId, socket, et signalClient.

On récupère ensuite le flux de données local via getUserMedia que l’on sauvegarde dans localStream.

On ajoute le flux de données local dans la liste des vidéos via joinedChannel.

Lors de la réponse du serveur lors de la découverte d’un canal (l’évènement discover) , on lance une connexion de paire avec chaque identifiant de socket que le serveur nous renvoie (donc une connexion pour chaque utilisateur déjà connecté sur le canal).

Lors d’une demande de connexion de paire (l’évènement request), on accepte la demande et on lance onPeer pour à nouveau envoyer notre flux de données et réceptionner les évènements sur cette paire.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    async join(channelId) {
      this.channelId = channelId;
      const currentContext = this;
      this.socket = io("http://localhost:3000");
      this.signalClient = new SimpleSignalClient(this.socket);

      this.localStream = await navigator.mediaDevices.getUserMedia({
        video: true,
        audio: true,
      });

      this.joinedChannel(this.localStream, true);

      this.signalClient.once("discover", (discoveryData) => {
        discoveryData.forEach((peerID) => currentContext.connectToPeer(peerID));
      });
      this.signalClient.on("request", async (request) => {
        const { peer } = await request.accept({}, currentContext.peerOptions);
        currentContext.videoList.forEach((v) => {
          if (v.isLocal) {
            currentContext.onPeer(peer, v.stream);
          }
        });
      });
      this.signalClient.discover(currentContext.channelId);
    },

Finissons maintenant ce composant avec un peu de style :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.video-list {
  display: flex;
  flex-direction: row;
  align-items: center;
}
.video-item {
  width: 150px;
  height: 150px;
  margin: 20px;
  border-radius: 50%;
  border: 5px solid #2c3e50;
  position: relative;
  overflow: hidden;
}
.video-item.my-video {
  border-color: #fc766a;
}
video {
  position: absolute;
  left: -20%;
  top: -20%;
  width: 140%;
  height: 140%;
}

En lançant le serveur et le client, vous devriez pouvoir rejoindre un canal et voir votre propre vidéo. Si vous ouvrez une autre fenêtre de navigateur et rejoignez le même canal, vous devriez voir les deux vidéos.

Deux utilisateurs sur le channel

N’oubliez pas que pour que cela fonctionne autrement qu’avec localhost, votre site doit être sécurisé (HTTPS) car les navigateurs modernes n’autorisent pas l’accès à la caméra et au microphone depuis des sites non sécurisés.

Ressources