Что такое WebRTC?

Короче говоря, WebRTC — это библиотека javascript с открытым исходным кодом, которая позволяет браузеру делать то, для чего он никогда не был предназначен. Возможность обмена данными напрямую с одного клиента на другой. Это довольно большое дело, и оно открыло двери для очень надежных p2p-приложений в браузере.

Как это работает?

Магия

Давайте что-нибудь закодируем 💯

Видеопоток p2p.

Технический стек

  • ES2015
  • Узел
  • Socket.io

Я не буду предоставлять весь код для выполнения этой работы, но я сделаю все возможное, чтобы подсказать вам, как это сделать самостоятельно.

Наша сигнализация ⚡

Представьте, что наш сервер запущен и работает на localhost:3000, где мы будем обмениваться информацией между клиентами, чтобы разрешить p2p-соединение. Другими словами, каждый клиент должен передавать данные от одного к другому, содержащие их собственные localDescription, которые будут установлены в качестве remoteDescription их одноранговых узлов. Существует также процесс добавления ICECandidates, чтобы обойти любой установленный брандмауэр. Следующий серверный код может быть посредником в этом процессе.

io.on('connection', (socket) => {  
  io.broadcast.emit('call:join', {
    initiatorId: socket.id
  });
  socket.on('call:signal', (signal) => {
    io.to(signal.to).emit('call:signal', signal);
  });
});

В приведенном выше коде событие call:join генерируется при соединении, содержащем уникальный идентификатор этого конкретного сокета. Имейте в виду, что мы используем метод broadcast.emit, чтобы сигнализировать об этом событии всем клиентам, кроме нас самих. Мы также прослушиваем событие call:signal, которое, в свою очередь, передается одному клиенту, используя концепцию комнат socket.io.

Наш клиент 💻

Вот где настоящее веселье! Мы будем использовать синтаксис класса ES2015 для инкапсуляции нашей логики на стороне клиента, передав конструктору connected socket и localStream.

Получить localStream через навигатор и в большинстве случаев нам нужно подключить адаптер webrtc, этот аккуратный.

class WebRTC {  
  constructor(socket, localStream) {
    this.socket = socket;
    this.localStream = localStream;
    this.socket.on('call:join', (call) => {
      this.peerId = call.initiatorId;
      this.makeOffer()
    });
    this.socket.on('call:signal', (signal) =>   this.handleSignal(signal))
  }
......

Здесь мы привязываем аргументы конструктора к this, делая их доступными для создаваемых нами методов. Мы также прослушиваем события сокетов call:join и call:signal, которые будут поступать с сервера.

Далее нам нужен метод, который мы вызовем позже, для управления созданием и настройкой объекта RTCPeerConnection.

......
  createPc() {
    let servers = {'iceServers': [
      {'url': 'stun:stun.ekiga.net'}
    ]};
    this.pc = new RTCPeerConnection(servers);
    this.pc.addStream(this.localStream);
    this.pc.onicecandidate = (evt) => {
      if(evt.candidate != null) {
        this.socket.emit('call:signal', {
          by: this.socket.socket.id,
          to: this.peerId,
          ice: evt.candidate,
          type: 'ice'
        });
      }
    };
    this.pc.onaddstream = (evt) => {
      if(evt.stream != null) {
        this.remoteStream = window.URL.createObjectURL(evt.stream));
      }
    };
  }
......

В этом методе мы создаем наш RTCPeerConnection, передавая ему stun server, а затем добавляя наш собственный поток. Мы также установили обработчики событий, необходимые для этого урезанного примера, два из них — onicecandidate и onaddstream. Обратите внимание, как при добавлении успешного ледяного кандидата мы передаем его нашему партнеру через сокет.

Идя дальше, нам нужно сделать предложение нашему коллеге. Кому, помните, нужно, чтобы наши localDescription устанавливались как их remoteDescription и наоборот.

......
  makeOffer() {
    this.createPc();
    this.pc.createOffer(sdp => {
      this.pc.setLocalDescription(sdp, () => {
        this.socket.emit('call:signal', {
          by: this.socket.socket.id,
          to: this.peerId,
          sdp: sdp,
          type: 'sdp-offer'
        });
      }, (err) => this.handleErrors(err));
    }, (err) => this.handleErrors(err));
  }
......

Что происходит наверху? Во-первых, мы создаем наш объект pc, вызывая метод createPc, который мы написали ранее. После чего мы вызываем метод createOffer, который теперь доступен на this.pc, который принимает два обратных вызова в качестве аргументов. Первый из этих обратных вызовов содержит описание нашего сеанса или sdp, а второй будет содержать любые ошибки, если они возникнут. В случае отсутствия ошибок мы устанавливаем наше локальное описание на вновь созданное описание сеанса. Наконец, в обратном вызове setLocalDescription мы испускаем наш sdp вместе с информацией, необходимой для передачи его нашему партнеру.

Хорошо, если вы чувствуете себя немного ошеломленным в этот момент, я полностью понимаю. Я знаю, что был, но давайте перейдем к самой важной части. Для обработки нашего события call:signal мы установили прослушиватель еще в конструкторе.

......
  handleSignal(signal) {
    switch(signal.type) {
      case 'sdp-offer':
        this.pc.setRemoteDescription(new RTCSessionDescription(signal.sdp), () => {
          console.log('Setting remote description by offer');
          this.pc.createAnswer(sdp => {
            this.pc.setLocalDescription(sdp, () => {
              this.socket.emit('call:signal', {
                by: signal.to,
                to: signal.by,
                sdp: sdp,
                type: 'sdp-answer'
              });
            }, (err) => this.handleErrors(err));
          }, (err) => this.handleErrors(err));
        }, (err) => this.handleErrors(err));
        break;
      case 'sdp-answer':
        this.pc.setRemoteDescription(new RTCSessionDescription(signal.sdp), () => {
          console.log('Setting remote description by answer');
        }, (err) => this.handleErrors(err));
        break;
      case 'ice':
        if(signal.ice) {
          console.log('Adding ice candidates');
          this.pc.addIceCandidate(new RTCIceCandidate(signal.ice));
        }
        break;
    }
  }
  handleErrors(err) {
    // Do something with your errors :x
  }
}

Чтобы обработать наш сигнал, мы включаем тип перехватываемого сигнала с учетом трех типов сигналов, которые мы можем получить. В случае sdp-offer мы устанавливаем наш remoteDescription так, чтобы он отражал описание сеанса, которое мы только что получили от нашего партнера. Затем мы вызываем createAnswer, который возвращает нам наше собственное описание сеанса, которое должно быть установлено как наше localDescription. В свою очередь, передавая его обратно нашему партнеру, имейте в виду, что мы устанавливаем тип sdp-answer, чтобы наш коммутатор знал, что с ним делать. Далее у нас есть случай для вышеупомянутого типа сигнала spd-answer. Где мы просто устанавливаем описание сеанса, доставляемое, опять же через сервер, от нашего партнера к нашему собственному remoteDescription. Последний случай, ice, обрабатывает процесс обхода NAT, разрешая подключение даже при наличии брандмауэра.

На данный момент, если мы не сталкиваемся ни с какими ошибками, наши одноранговые узлы подключены. Это вызовет событие onaddstream, для которого мы установили обработчик ранее, предоставив нам видеопоток нашего однорангового узла, который может быть легко установлен в атрибут src видеоэлемента. Однако я не собираюсь вдаваться в это в этом посте.