ZoomやTeams, Skypeのようなオンラインミーティング用のビデオチャットアプリケーションを構築してみたいけどJavaScriptを使ってできないかなと思っている人向けにNode.js、Express.js、Socket.io, PeerJs, Vue.jsを利用してZoomのようなアプリケーションの構築方法を説明しています。

リアルタイムでチャットを実装してみたいという人向けに最初は主にSocket.ioを利用してブラウザ間でリアルタイムにメッセージの送受信ができることを確認します。確認後はPeeJSを利用してピア・トゥ・ピア通信により映像、音声データをブラウザ間で送受信します。Vue.jsも使っていますが、これは必須ではありません。

本文書を読み終えるとローカルネットワーク上の複数台のPCで映像、音声データを送受信することができますが、PeerJSによるブラウザ間の接続がうまくいかないことがあります(1回目の接続ではうまくいかないが2回目の接続だとうまくいく等)。原因の特定はできていませんので100%完璧に動作することを前提に本書を公開しておりませんのでその点をご理解の上本書を参考に構築にチャレンジしてください。

Expressサーバの設定

データをブラウザ間で送受信するためにはその仲介役となるサーバが必要となります。最初にNode.jsを使ってExpressサーバの構築を行います。任意の名前のフォルダを作成してください。ここではzoom_cloneという名前をつけています。

zoom_cloneフォルダに移動してnpm init -yコマンドを実行します。


% mkdir web_chat
% cd zoom_clone
% npm init -y

npm init -yコマンドを実行したzoom_cloneフォルダには、package.jsonファイルが作成されます。

package.jsonにはインストールを行うJavaScriptのライブラリの情報だけではなくプロジェクトに必要な情報が保存されます。

Expressライブラリのインストールを行います。


 % npm install express

インストールが完了したら、zoom_cloneの下にindex.jsファイルを作成しExpressサーバを稼働させるために必要となるコードを記述します。コードはExpressの公式ドキュメントを参考に作成しています。動作内容はポート番号の3000で起動して”/”(ルート)にブラウザからアクセスがあると”Hello World”を戻します。


const express = require('express')
const app = express()
const port = 3000

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

app.listen(port, () => {
  console.log('listening on *:3000');
})

Expressサーバの起動はnodeコマンドを利用して行います。


 % node index.js
listening on *:3000

起動後はExpressサーバに対してブラウザからアクセスすることができます。ブラウザを起動しURLにlocalhost:3000を指定してください。Hello World!がブラウザ上に表示されたらExpressサーバは正常に稼働しています。

Hello Worldが表示
Hello Worldが表示

nodemonのインストール

index.jsファイルの更新を自動で反映してくれるnodemonのインストールを行います。

nodemonをインストールしない場合はindex.jsファイルを更新する度にnode index.jsを再実行し、index.jsを再読み込みする必要があります。

 % npm install --save-dev nodemon

インストール後はnodemonを使ってExpressサーバを起動します。


 % npx nodemon index.js
[nodemon] 2.0.7
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node index.js`
listening on *:3000

テンプレートエンジンejsのインストール

Express.jsから変数を渡す必要がでた場合に対応できるようにテンプレートエンジンのejsをインストールします。index.js内で生成された値を変数をブラウザ側に表示させる内容(HTML)が記載されているXXX.ejs(XXX:任意の名前)ファイルに渡すことができます。


 % npm install ejs

ejsをExpressで利用できるようにapp.setメソッドでejsを指定します。


app.set('view engine','ejs')

プロジェクトフォルダzoom_cloneの下にviewsフォルダを作成しその下にzoom.ejsファイルを作成します。拡張子がejsテンプレートのejsとなっていますが、通常のHTMLを使って記述することができます。


<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Zoom meeting</title>
</head>
<body>
  <h1>Zoomミーティング</h1>
</body>
</html>

ルーティングzoomをindex.jsファイルに追加します。/zoomにアクセスするとzoom.ejsの内容が表示されます。


app.get('/zoom', (req, res) => {
  res.render('zoom');
});

ブラウザからhttp://localhost:3000/zoomにアクセスします。zoom.ejsに記述した内容がブラウザに表示されます。

ejsファイルの中身を表示
ejsファイルの中身を表示

スタイルシートの読み込み

CSSの設定を行うためにスタイルシートstyle.cssを利用します。style.cssを使ってCSSをzoom.ejsファイルに適用できるように設定を行います。

publicフォルダをプロジェクトフォルダのzoom_cloneの直下に作成し、その下にstyle.cssファイルを作成します。

Expressサーバからpublicフォルダにアクセスできるようにindex.jsに下記の1行を追加します。


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

zoom.ejsファイルにlinkタグを追加し、style.cssを読み込みます。


<head>
  <meta charset="UTF-8">
  <title>Zoom meeting</title>
  <link rel="stylesheet" href="style.css">
</head>

style.cssファイルに下記を追加します。


* {
  margin: 0;
  padding: 0;
 box-sizing: border-box;
}

ビデオチャットページの作成

メッセージ、映像の送受信を行う機能を追加する前にメッセージや映像を表示させるページを作成しておきます。作成後/zoomにアクセスすると下記の画面が表示されます。

初期画面
初期画面

ページの構成は以下のように設定しています。各大文字の名前と要素のidに設定した値が一致します。

ページの構成
ページの構成

zoom.ejsファイルの中身は下記の通りです。


<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <title>Zoom meeting</title>
  <link rel="stylesheet" href="style.css">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.2/css/all.min.css">
</head>

<body>
  <div id="app">
    <div id="container">
      <div id="main">
        <div id="video">
        </div>
        <div id="menu">
          <div style="display: flex;">
            <div class="menu_item">
              <div>
              <i class="fa fa-2x fa-microphone"></i>
              <i class="fa fa-2x fa-microphone-slash"></i>
              </div>
              <div>
                <span>ミュート</span>
                <span>アンミュート</span>
              </div>
            </div>
            <div class="menu_item">
              <div>
                <i class="fa fa-2x fa-video"></i>
                <i class="fa fa-2x fa-video-slash"></i>
              </div>
              <div>
                <span>ビデオの停止</span>
                <span>ビデオの開始</span>
              </div>
            </div>
          </div>
          <div>
            <button class="end">終了</button>
          </div>
        </div>
      </div>
      <div id="sub">
        <div id="paticipate">
          <p class="sub_list">参加者</p>
            <div class="message_wrap">
              <p></p>
            </div>
        </div>
        <div id="chat">
          <p class="sub_list">チャット</p>
            <div class="message_wrap">
              <p></p>
            </div>
            <div>
              <div>
                <textarea
                  class="text_area"
                  placeholder="メッセージを入力します"
                  ></textarea>
              </div>
              <div>
                <button class="btn">メッセージ送信</button>
              </div>
            </div>
        </div>
      </div>
    </div>
  </div>
</body>
</html>

style.cssファイルの中身を下記の通りです。


* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
body {
  height: 100vh;
}
#app {
  display: flex;
  flex-direction: column;
  height: 100%;
}
#title {
  font-size: 1em;
  padding: 0.2em;
  flex-basis: 30px;
}
#container {
  display: flex;
  flex-grow: 1;
}
#main {
  background-color: black;
  color: white;
  display: flex;
  flex-direction: column;
  flex: 1;
}
#video {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-wrap: wrap;
  flex-grow: 1;
}
#video > video {
  width: 30%;
  color: black;
  background-color: white;
  margin: 5px;
}

#sub {
  flex-basis: 300px;
  background-color: white;
  display: flex;
  flex-direction: column;
  margin: 5px;
}

.sub_list {
  font-weight: 900;
  text-align: center;
}

#menu {
  display: flex;
  margin: 10px;
  justify-content: space-between;
}

.menu_item {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  color: white;
}

#paticipate {
  flex: 1;
}

#chat {
  flex: 2;
  display: flex;
  flex-direction: column;
  overflow-y: scroll;
}

.message_wrap {
  flex-grow: 1;
  overflow-y: scroll;
}

.btn {
  padding: 0.5em;
  margin: 2px;
}

.text_area {
  padding: 5px;
  width: 100%;
}

#join {
  display: flex;
  justify-content: center;
}

.select_room {
  margin: 5px 0;
}

input {
  width: 100%;
  padding: 0.5em;
}

.end {
  font-weight: bold;
  color: white;
  background-color: red;
  padding: 1em 2em;
}

画面左下にあるアイコンはFont-Awesomeを利用しています。Font-Awesomeにアクセスして検索バーにvideoと入力するとフリーのアイコンを取得できます。

font-awesomeでvideo検索
font-awesomeでvideo検索
font-awesomeを利用するためにlinkタグでfont-awesomeのcssを読み込んでいます。

アイコンがマイクとビデオで2つずつ表示されていますが後ほどVue.jsで制御します。

テキストチャット機能の追加

socket.ioのインストールと動作確認

ブラウザ間のチャットのメッセージ送受信はsocket.ioを利用して行います。socket.ioを利用するとExpressサーバを中心に情報の送受信を接続したブラウザ全体でリアルタイムに行うことができます。


 % npm install socket.io

インストール後はindex.jsファイルにsocket.ioの設定を行います。socket.ioのインスタンスを作成し、connectionイベントを設定します。ブラウザからアクセスがあると”ユーザが接続しました”とExpressサーバにメッセージが表示されます。


const express = require('express')
const app = express()
const server = require('http').createServer(app);
const io = require('socket.io')(server)
const port = 3000

app.set('view engine','ejs');
app.use(express.static('public'));

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

app.get('/zoom', (req, res) => {
  res.render('zoom');
});

io.on('connection', (socket) => {
  console.log('ユーザが接続しました。');
});

server.listen(port, () => {
  console.log('listening on *:3000');
})

上記はサーバ側の処理なのでクライアントであるブラウザからの接続処理はzoom.ejsファイルから読み込むapp.jsファイルに記述します。

publicフォルダにapp.jsファイルを作成し、zoom.ejsファイルでクライアント用のsocket.io.jsとapp.jsファイルを読み込みます。


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

io()でサーバへの接続が行われます。ioの引数にはサーバのURLやオプションを指定することができますがデフォルトのURLはwindow.locationから取得しているためサーバへの接続に関する設定は記述していません。


const socket = io();
window.locationを確認したい場合はconst socket = io()の下にconsole.log(window.location)と記述しデベロッパーツールのコンソールに表示されるメッセージを確認してください。

設定が完了したら、localhost:3000/zoomにブラウザからアクセスしてください。nodemonを実行した端末には”ユーザが接続しました。”のメッセージが表示されます。


[nodemon] starting `node index.js`
listening on *:3000
ユーザが接続しました。

複数のブラウザを起動してlocalhost:3000/zoomを実行すると”ユーザが接続しました。”のメッセージが追加表示されます。

同じネットワークに接続している物理的に異なるPCやノートPC、スマホがある場合はExpressサーバが起動しているIPアドレスを確認して、そのアドレスに対してブラウザからアクセスしてください。

IPアドレスはmacOSであればifconfig -aコマンドで確認できます。Windowsであればipconfig /allコマンドです。

socket.ioを使ってサーバに接続することができたので次はブラウザからメッセージを送信して他のブラウザが送信したメッセージを受信できることを確認していきます。メッセージ入力と送受信するメッセージの表示をVue.jsを利用して行います。

Vue.jsの設定

Vue.jsはcdnを利用して行います。使っているVue.jsのバージョンは3です。


<script src="https://unpkg.com/vue@next" defer></script>

zoom.ejsにあるメッセージ入力用のtextarea要素にv-modelを追加し入力した文字列をVue.jsで扱えるように設定を行います。

app.jsファイルにVue.jsの設定を追加します。


  const app = Vue.createApp({
    data(){
      message:''
    },
    methods:{
      sendMessage(){
        //メッセージをsocket.ioを使って送信する
      }
    }
  })

button要素にクリックイベントを設定し、文字列入力後に送信ボタンを押すとsendMessageメソッドが実行できるようにします。

sendMessageメソッドの中ではsocket.ioを利用したメッセージの送信処理を記述します。


<div>
  <textarea
    class="text_area"
    placeholder="メッセージを入力します"
    v-model="message"></textarea>
</div>
<div>
  <button class="btn" @click="sendMessage">メッセージ送信</button>
</div>

sendMessageメソッドの中ではsocket.emitメソッドを利用してExpressサーバへメッセージを送信します。socket.emitの最初の引数には、イベント名を設定します。このイベント名を使ってサーバ側ではメッセージを取得します。第2引数には送信するメッセージを指定します。これでブラウザ側(socket.ioクライアント)からの送信処理は完了です。


const app = Vue.createApp({
  data(){
    message:''
  },
  methods:{
    sendMessage(){
      socket.emit('message',this.message);
      this.message = '';
    }
  }
})

ブラウザ側から送信したメッセージはサーバで受け取る必要があり受け取ったメッセージを再びブラウザ側に送信します。サーバが受け取ったメッセージを送信することで接続しているすべてのブラウザに同じメッセージを送信することができます。

Expressサーバのindex.jsファイルのsocket.onにブラウザ側で設定したイベント名のmessageを設定します。msgの中に受け取ったメッセージが入っているのでio.emitを使ってすべてのブラウザに受け取ったメッセージを送信します。その際にもイベント名messageを設定しています。ブラウザ側でもこのイベント名を利用してメッセージと取り出します。


io.on('connection', (socket) => {
  console.log('a user connected');
  socket.on('message',(msg)=> {
    io.emit('message',msg)
  })
});
socket.ioではメッセージを送信する際はemitメソッドにイベント名を指定します。受け取る場合はonメソッドにイベント名を指定します。

ブラウザ側のapp.jsではExpressサーバから送信してきたメッセージを受け取れるようにsocket.onを追加します。後ほど受け取ったメッセージをVue.jsのデータプロパティに保存するためライフサイクルフックで設定を行います。


mounted(){
  socket.on('message', (msg) => {
    console.log('message: ' + msg);
  });
}

ブラウザを2つ起動してどちらかのブラウザでメッセージを入力して送信ボタンをクリックしてください。両方のブラウザのコンソールに入力したメッセージが表示されるはずです。socket.ioを利用することですべてのブラウザにリアルタイムで同時にメッセージが受信できることが確認できました。

メッセージのリスト化

socket.ioを利用することでメッセージの送受信ができることが確認できましたが、コンソールへの表示でした。コンソールではなく送受信したメッセージをブラウザ上に表示させます。

Vue.jsに新たにデータプロパティのmessagesを追加します。messagesは配列で受信したメッセージをすべてmessages配列の中に挿入していきます。


data() {
  return { 
    message: "",
    messages:[], //追加
  }
},

socket.onでサーバからのメッセージを受け取るのでpushメソッドで受け取ったメッセージをmessages配列に追加していきます。


mounted(){
  socket.on('message',(msg) => {
    this.messages.push(msg)
  });
}

追加したメッセージはv-forディレクティブをを利用してブラウザ上に表示させます。


<p class="sub_list">チャット</p>
  <div class="message_wrap">
    <p v-for="(message,index) in messages" :key="index">{{ message }}</p>
  </div>

文字をinput要素に入力するとExpressサーバに接続しているすべてのブラウザ上のチャット欄に同じメッセージが表示されることが確認できます。

入力した文字列を保存したい場合はデータベースなどを準備する必要があります。本文書ではデータベースの導入は行わないのでメッセージを受信したブラウザもリロードするとメッセージはすべて消えます。また他のブラウザで入力した文字列はその後接続したブラウザでは見ることができません。

サーバからのメッセージの送信方法

Expressサーバ側のio.emitでは接続しているすべてのブラウザにメッセージを送信していました。io.broadcast.emitを利用すると接続してきたブラウザ以外のブラウザに対してのみメッセージを送信することができます。

下記のようにbroadcastを利用することでブラウザからの新しいアクセスが行われる度にアクセスを行ったブラウザ以外のメッセージリストに”新しいユーザが接続されました”というメッセージが表示されます。


io.on('connection', (socket) => {
  socket.broadcast.emit('message','新しいユーザが接続されました。')
  socket.on('message',(msg)=>{
    io.emit('message',msg)
  })
});

新たに接続してきたブラウザのみにメッセージを送信したい場合はsocket.emitを利用します。


io.on('connection', (socket) => {
  console.log('a user connected');
  socket.emit('message','Zoomクローンにようこそ!');
  socket.broadcast.emit('message','新しいユーザが接続されました。');
  socket.on('message',(msg)=> {
    io.emit('message',msg)
  })
});

実際にブラウザで動作確認を行うと最初にアクセスするブラウザには”Zoomクローンにようこそ!”のみ表示されます。

zoomクローンにようこそメッセージ
zoomクローンにようこそメッセージ

別のブラウザを起動しアクセスするとアクセスしたブラウザには”Zoomクローンにようこそ!”のみ表示されますが、接続ずみのブラウザには”新しいユーザが接続されました。”というメッセージが表示されます。

ユーザの接続メッセージ
ユーザの接続メッセージ

このようにメッセージの送信方法が複数存在するので用途によって使い分けることができます。

接続が切れた場合のメッセージ

ユーザの接続が切れた場合にもメッセージを送信することができます。その場合イベント名にdisconnectを設定します。


io.on('connection', (socket) => {
  console.log('a user connected');
  socket.emit('message','ビデオチャットにようこそ!');
  socket.broadcast.emit('message','新しいユーザが接続されました。');
  socket.on('message',(msg)=>{
    io.emit('message',msg)
  })
  socket.on('disconnect',()=> {
    io.emit('message','ユーザからの接続が切れました。')
  }); 
});

ブラウザを閉じると他のブラウザのメッセージに”ユーザからの接続が切れました。”が表示されます。

Roomの設定

ここまでの設定ではブラウザで/zoomにアクセスするとだれでもメッセージの送受信を行うことができます。通常チャットでは所属する組織、グループ内でメッセージの送受信を行います。socket.ioではroomを利用することでそれぞれ独立した場所を作り、その場所にアクセスしたユーザ間のみでメッセージを送受信することができます。

/zoomにアクセスするとまず最初にルームを選択できるように選択画面を追加します。また選択時にユーザ名を入力できる画面を追加します。ユーザ名を入力することで誰がメッセージを送信したのか誰がそのルールにいるのかをわかるようにします。また以下ではルームに入ることを”入室”、出ることを”退室”という言葉を使っています。

通常であればアプリケーションへのログインが必要となるのでユーザ名の入力は必要ありません。

ルーム選択画面

入室したルームのIDを保持するroomIdとユーザ名を保持するnameをVue.jsのデータプロパティに追加します。


data(){
  return {
    message:'',
    messages:[],
    name:'',
    roomId:'',
  }
},

roomIdを選択する画面を作成します。ここでは2つだけルームを準備します。名前を入力してどちらかのボタンをクリックすると入力した名前で選択したルームに入室します。

入室の処理はjoinRoomメソッドで行います。ルーム選択画面を表示するかビデオチャットの画面を表示するかはv-ifディレクティブを利用してroomIdに値があるかで判断しています。


<div id="app">
  <h1 id="title">Zoomミーティング</h1>
  <div id="join" v-if="!roomId">
    <div style="width:25%">
      <h2>ルームの選択</h2>
      <div>
        <input v-model="name" placeholder="ユーザ名を入力してください" required/>
      </div>
      <div>
        <button @click="joinRoom('room_1')" class="btn">Room 1</button>
        <button @click="joinRoom('room_2')" class="btn">Room 2</button>
      </div>
    </div>
  </div>
  <div id="container" v-else >
  //略

app.jsにjoinRoomメソッドを追加しますが、その他にもいくつか変更を加えます。socket.io()の引数に何も指定していなかったのでブラウザでアクセスした瞬間にsocket.ioからサーバへの接続が行われていました。オプションにautoConnectを追加し値をfalseに設定し、ルームをユーザが選択した後にsocket.openメソッドでサーバへの接続を行います。

joinRoomメソッドの中でsocket.emitでjoin-roomというイベント名でサーバにroomIdとnamaを送信します。


const socket = io({
  autoConnect:false,
});

const app = Vue.createApp({
  data(){
    return {
      message:'',
      messages:[],
      name:'',
      roomId:'',
    }
  },
  methods:{
    sendMessage(){
      socket.emit('message',this.message);
      this.message = '';
    },
    joinRoom(roomId){
      this.roomId = roomId;
      socket.open();
      socket.emit('join-room', this.roomId, this.name);      
    }
  },
  mounted(){
    socket.on('message', (msg) => {
      this.messages.push(msg)
    });
  }
}).mount('#app')

join-roomイベントを検知するためサーバ側の設定を行います。サーバ側でもルームに接続しているユーザの情報を保持するためroomsを定義します。join-roomのメッセージを受け取ったら、roomsにroomId, nameさらにsocket.idを保存します。socket.idは一意のため登録したルームデータを識別することが可能になります。

socket.joinメソッドでルームへの入室を行います。これまではアクセスしたブラウザ以外にはbroadcast.emitを利用していましたが、そのルールに在室するブラウザのみにメッセージを送信する必要があるのでsocket.to(roomId).broadcast.emitとtoメソッドでroomIdを指定しています。メッセージの内容にブラウザから送られたきた名前も追加しています。Bot:は誰かのメッセージではなくボットの自動メッセージであることを表しています。


const rooms = []

io.on('connection', (socket) => {
  socket.on('join-room',(roomId, name) => {
    rooms.push({
      roomId,
      name,
      id:socket.id,
    })
    socket.join(roomId);
    socket.emit('message',`Bot: ${name}さん、Zoomクローンにようこそ!`);
    socket.to(roomId).broadcast.emit('message',`${name}さんが接続しました。`)
  })
});

一度ここでブラウザを使って動作確認を行います。

ユーザ名を入力して、Room1ボタンをクリックします。

ユーザ名を入力してボタンをクリック
ユーザ名を入力してボタンをクリック

ビデオチャットの画面が表示され、チャット欄にサーバからのメッセージが表示されていることが確認できます。

ルーム選択後のメッセージ
ルーム選択後のメッセージ

別のブラウザを使って異なる名前を入力して、room1に入室してください。

接続したユーザの名前が表示されることが確認できます。

他のユーザがroom1に入室
他のユーザがroom1に入室

さらに別のブラウザを起動してルームの選択を行いますが、今度はRoom2を選択してください。

2つのブラウザにはRoom2を選択したユーザの接続情報は表示されません。独立した別々のルームに入室できることが確認できました。

メッセージの送受信

メッセージを送受信する際はroomIdが必要となります。ブラウザからサーバに送信する際にはroomIdは必要ではありませんがサーバから返信する際にどのroomIdのルームに送信するのか知っておく必要があるためroomIdはルーム情報を保存するroomsから取り出します。

roomIdをroomsから取り出すためにsocketのidを利用します。roomsに保存されているid(socket.id)と受信に利用されたsocket.idが一致したroomを取得し、roomIdをioのtoメソッドに指定しています。


io.on('connection', (socket) => {
  socket.on('join-room',(roomId, name) => {
    rooms.push({
      roomId,
      name,
      id:socket.id,
    })
    socket.join(roomId);
    socket.emit('message',`Bot: ${name}さん、Zoomクローンにようこそ!`);
    socket.to(roomId).broadcast.emit('message',`Bot: ${name}さんが接続しました。`)
  })
  socket.on('message',(msg)=> {
    const room = rooms.find(room => room.id == socket.id)
    if(room) io.to(room.roomId).emit('message',`${room.name}: ${msg}`)
  })
});

この設定により、同じルームにいるブラウザ間でメッセージの送受信を行うことができます。

ルームに在室するメンバーの確認

現在どのユーザが同じルーム内にいるのか確認できるように新たにVue.jsのデータプロパティにmembersを追加します。


data(){
  return {
    message:'',
    messages:[],
    name:'',
    roomId:'',
    members:[],
  }
},

membersは配列なのでv-forディレクティブで参加者の要素の下で展開します。


<div id="paticipate">
  <p class="sub_list">参加者</p>
    <div class="message_wrap">
      <p v-for="(member,index) in members" :key="index">{{ member.name }}</p>
    </div>
</div>

ルーム内のメンバーについてはユーザ名の情報を保存している変数のroomsの情報を利用します。memberの情報はユーザが入室した時にサーバから送信されるようにjoin-roomイベントからの返信のメッセージとしてサーバからブラウザに送信します。


socket.on('join-room',(roomId, name) => {
  rooms.push({
    roomId,
    name,
    id:socket.id,
  })
  socket.join(roomId);
  socket.emit('message',`Bot: ${name}さん、Zoomクローンにようこそ!`);
  socket.to(roomId).broadcast.emit('message',`Bot: ${name}さんが接続しました。`)
  const members = rooms.filter(room => room.roomId == roomId);
  io.to(roomId).emit('members',members);
})

ブラウザ側ではライフサイクルフックの中でイベントmemberに対する処理を設定します。


mounted(){
  socket.on('message', (msg) => {
    this.messages.push(msg)
  });
  socket.on('members', (members) => {
    this.members = members;
  });   
}

2つのブラウザで別々のユーザ名でRoom1に入室すると参加者の下に入室したユーザ名が表示されます。別のルームに入室したユーザが表示されることはありません。

入室したユーザの表示
入室したユーザの表示

入室したユーザを取得することができましたが退室したユーザに対する機能がないため、入室すればするほどユーザが増えていきます。次は退室の処理を追加します。

ルームからの退室処理

ブラウザを閉じるとサーバ側にはdisconnectイベントが発生するためユーザが退室することはわかります。ブラウザを閉じるだけではなく意図的に退室する場合の機能も必要となります。画面右下にある終了ボタンをユーザがクリックした場合に退室の処理を行います。clickイベントを設定しクリックするとleaveRoomメソッドを実行します。


<button class="end" @click="leaveRoom">終了</button>

leaveRoomメソッドでは退室するためroomId, name, messages, membersの値をすべて空にし、socketをcloseメソッドで閉じます。


leaveRoom(){
  this.roomId = "";
  this.name = "";
  this.messages = [];
  this.members = [];
  socket.close();
}

socket.closeをするとサーバ側でdisconnectイベントで検知できるのか確認します。


io.on('connection', (socket) => {
  socket.on('join-room',(roomId, name) => {
    rooms.push({
      roomId,
      name,
      id:socket.id,
    })
    socket.join(roomId);
    socket.emit('message',`Bot: ${name}さん、Zoomクローンにようこそ!`);
    socket.to(roomId).broadcast.emit('message',`Bot: ${name}さんが接続しました。`)
    const members = rooms.filter(room => room.roomId == roomId);
    io.to(roomId).emit('members',members);
  })
  socket.on('message',(msg)=> {
    const room = rooms.find(room => room.id == socket.id)
    if(room) io.to(room.roomId).emit('message',`${room.name}: ${msg}`)
  })
  socket.on('disconnect',()=> {
    console.log('ユーザの接続が切れました')
  }); 
});

ブラウザ上で終了ボタンをクリックするとExpressサーバを起動したターミナルに”ユーザの接続が切れました”のメッセージが表示されます。

disconnectイベントで退室を検知することがわかったのでユーザが退室したメッセージとルームに入室しているメンバー(退室したユーザを除く)をブラウザに送信します。

indexを利用してroomsの中から退室したユーザの情報を削除し、退室したユーザの削除処理が完了したroomsからmember情報を取得してブラウザにメッセージを送信しています。


socket.on('disconnect',()=> {
  const room = rooms.find(room => room.id == socket.id)
  const index = rooms.findIndex(room => room.id == socket.id)
  if(index !== -1) rooms.splice(index,1)
  if(room){
    io.to(room.roomId).emit('message',`Bot :${room.name}が退出しました。`)
    const members = rooms.filter(rm => rm.roomId == room.roomId);
    io.to(room.roomId).emit('members',members);
  }
}); 

複数のブラウザでroom1に入室させ、終了ボタンをクリックすると他のブラウザに退室したユーザのメッセージが送信され、参加者も更新されます。ブラウザを閉じても同様に退室のメッセージが送信され参加者が更新されます。

PeerJSサーバと初期設定

Socket.ioを利用することでブラウザ間でテキストメッセージが送信できることやイベントを使い入退室の管理ができることがわかりました。次はZoomクロンにとってもっとも重要なWEBカメラの映像を送信する機能を追加していきます。

PeerJSサーバのインストール

WEBカメラで撮影したデータはPeerJSライブラリを利用して行います。PeerJSを利用することで映像データの送受信はサーバを介さずブラウザ間で行うことができます。

npmコマンドを利用してインストールを行います。


 % npm install peer

PeerJSのサーバ設定

PeerJSのサーバ設定はindex.jsファイルの中で行います。下記のようにPeerサーバの設定を行います。


const express = require('express')
const app = express()
const server = require('http').createServer(app);
const io = require('socket.io')(server)
const { ExpressPeerServer } = require('peer');
const port = 3000

const peerServer = ExpressPeerServer(server,{
  debug:true,
})

app.use('/peerjs',peerServer)
app.set('view engine','ejs');
app.use(express.static('public'));
}); 
Peerサーバの起動はコマンドでも行うことができます。

クライアントの設定とサーバへの接続

ブラウザ側でもクライアント用のPeerJSが必要となります。socket.ioと同様にcdnを利用します。


<script src="/socket.io/socket.io.js" defer></script>
<script src="https://unpkg.com/vue@next" defer></script>
<script src="https://unpkg.com/peerjs@1.3.1/dist/peerjs.min.js" defer></script>
<script src="app.js" defer></script>

Vue.jsのデータプロパティにmyPeerを追加し、Peerで作成するインスタンスの値を保存します。

PeerJSのインスタンスの作成はユーザがルームに入室するjoinRoomメソッドの中で行います。

PeerJSを使ってブラウザ間で通信を行う時に相手の名前を利用します。インスタンス作成時には相手の名前はわからないのでundefinedを設定しています。portやpathについてはサーバ側のindex.jsで設定した値を指定します。コンソールに詳細なメッセージを出力させたい場合はdebugを3を設定してください。ブラウザ(クライアント)からPeerJSサーバへの接続が成功するとopenイベントが発火します。PeerJSサーバに接続できたらsocket.emitでメッセージを送信します。


  data(){
    return {
      message:'',
      messages:[],
      name:'John',
      roomId:'',
      members:[],
      myPeer, //追加
    }
  },
//略
joinRoom(roomId){
  this.roomId = roomId;
  this.myPeer  = new Peer(undefined,{
    host: '/',
    port: 3000,
    path: '/peerjs',
    debug:3
  });
  socket.open();
  this.myPeer.on('open',peerId => {
    socket.emit('join-room', this.roomId, this.name);
  })   
},

ブラウザからルームに入室してチャット欄にメッセージが表示されたらPeerJSサーバとの接続は成功しています。PeerJSのクライアントとサーバの接続ができないとopenイベントが発火しないためjoin-roomイベントの情報が送れないためです。

接続に必要な名前の共有

PeerJSではブラウザ同時で通信を行う際に名前が必要となります。任意の名前をつけることはできすが、サーバに接続した際に受け取るPeer IDを名前として利用します。

join-roomのイベントの中でroomIdとnameとPeer IDをサーバに渡し、サーバからルームに入室しているブラウザにPeer IDを送信します。ブラウザはPeer IDをサーバから受け取ることができるのでこのPeer IDを使って他のブラウザと映像のデータ通信が可能となります。


this.myPeer.on('open',peerId => {
  socket.emit('join-room', this.roomId, this.name, peerId);
}) 

サーバ側でjoin-roomからpeerIDを取り出し、user-connectedという新しいイベントを使ってルームに入室しているブラウザにpeerIdとsocket.idを送信します。


socket.on('join-room',(roomId, name, peerId) => {
  rooms.push({
    roomId,
    name,
    id:socket.id,
  peerId
  })
  socket.join(roomId);
  socket.emit('message',`Bot: ${name}さん、Zoomクローンにようこそ!`);
  socket.to(roomId).broadcast.emit('message',`Bot: ${name}さんが接続しました。`)
  socket.to(roomId).broadcast.emit('user-connected',peerId,socket.id)
  const members = rooms.filter(room => room.roomId == roomId);
  io.to(roomId).emit('members',members);
})

ブラウザ側ではuser-connectedイベントを処理するコードを追加します。処理はライフサイクルフックmountedの中に設定しますが後ほど別の場所に移動します。peerIdがサーバから送信され取得できるか確認を行います。


mounted(){
  socket.on('message', (msg) => {
    this.messages.push(msg)
  });
  socket.on('members', (members) => {
    this.members = members;
  }); 
  socket.on('user-connected', (peerId) => {
    console.log(peerId) 
  });
}

入室済のブラウザがいる状態で別のブウラザで同じルームに入室するとコンソールにpeerIdが表示されたらuser-connectedの処理は正常に動作しています。下記のような桁数のIDです。


7c9fe379-21ac-4fa8-80b9-af812c4d704

WEBカメラの映像の送受信

PeerJSのサーバとクライアントの接続、Peer IDの共有を行うことができたのでPeerJSを利用して映像の送受信を行います。

1つのPCを利用して動作確認を行うので、ブラウザ間で映像を送受信しても同じ映像が複数表示されることになります。ローカルネットワークで映像を送受信するためにはhttpsの設定が必要となるので本文書後半で説明を行います。

Webカメラの映像の表示

Webカメラの映像を取得する方法を確認していきます。ブラウザ上にWEBカメラの映像を表示させる方法については以下の文書で公開しています。

自分のPCに接続されているWEBカメラの映像をブラウザに表示させます。自分のPCのビデオ情報を保存するmyVideoをVue.jsのデータプロパティに追加します。


data(){
  return {
    message:'',
    messages:[],
    name:'John',
    roomId:'',
    members:[],
    myPeer:'',
    myVideo:'',
  }
},

映像はjoinRoomメソッドの中で設定します。video要素を作成し、video要素に映像データを設定し、appendメソッドでref=”video”の設定されている要素に追加を行っています。$refsはVue.jsの機能で直接指定した要素にVue.jsからアクセスすることができます。videoがtrue, audioがfalseになっている場合はブラウザでアクセスした場合許可をするかどうかの選択がビデオのみ表示されるのでマイクは利用できません。どちらも利用する際はtrueに設定してください。


this.myVideo = document.createElement('video')
this.myVideo.muted = true

navigator.mediaDevices.getUserMedia({
  video: true,
  audio: false,
}).then(stream => {
  this.myVideo.srcObject = stream;
  this.myVideo.play()
  this.$refs.video.append(this.myVideo)
}).catch(e => {
    console.log(e)
})

HTMLの中でビデオを表示させたい要素にref=”video”を設定しています。


<div id="video" ref="video">

ブラウザでルームに入室すると画面の中心に手元のPCのWEBカメラの映像が表示されれば設定は正常に行われています。

WEBカメラの画像
WEBカメラの画像
意図的にぼかしていますが実際にはくっきりとした映像が表示されています。

映像の送信と受信

映像を送信するためにはPeerJSのcallメソッドを利用します。callメソッドの引数はPeer IDとstream(映像、音声データ)です。callメソッドを実行するタイミングは接続を行いたいブラウザが持つPeer IDがわかった時なのでuser-connectedの処理の中でcallメソッドを実行します。

user-connectedの処理は一時的にライフサイクルフックmountedの中に入れていましたが、navigator.mediaDevices.getUserMediaのcallback関数の中に移動します。navigator.mediaDevices.getUserMediaの中では自PCで撮影した映像のstreamにアクセスすることができるためです。


navigator.mediaDevices.getUserMedia({
  video: true,
  audio: false,
}).then(stream => {
  this.myVideo.srcObject = stream;
  this.myVideo.play()
  this.$refs.video.append(this.myVideo)

  socket.on('user-connected', (peerId) => {
    this.myPeer.call(peerId, stream)
  });

}).catch(e => {
    console.log(e)
})

callメソッドが実行されるとcallイベントを使ってcallがあったことを検知します。callを受け取ることで他のPCからの映像データを受け取ることができますが、双方向で映像を送受信するためには自PCのデータを送り返す必要があるので処理は先ほどと同様にnavigator.mediaDevices.getUserMediaのcallback関数の中に記述します。

callイベントを検知すると送られてくる映像を表示するためにvideo要素を作成します。こちらからも映像を送信するためcall.answer(stream)で映像を送ります。受け取った映像データを作成したvideo要素に設定し、$refsを使って送られてきた映像を要素に追加します。


navigator.mediaDevices.getUserMedia({
  video: true,
  audio: false,
}).then(stream => {
  this.myVideo.srcObject = stream;
  this.myVideo.play()
  this.$refs.video.append(this.myVideo)

  this.myPeer.on('call', call => {
    const video = document.createElement('video')
    call.answer(stream);
    call.on('stream', stream => {
      video.srcObject = stream;
      video.play()
      this.$refs.video.append(video)
    });
  });	

  socket.on('user-connected', (peerId) => {
    this.myPeer.call(peerId, stream)
  });

}).catch(e => {
    console.log(e)
})

ここまでの流れで新たにルームに入室したブラウザの画面には自PCの映像と送られたきた映像の2つが表示されます。

映像を送受信
映像を送受信

先に入室していたブラウザはこの時点では送られてくる映像に対する処理を行っていないので自PCの映像しかブラウザ上に表示されていません。送られてくる映像を表示する機能を追加します。

callメソッドを実行した後にcallが成功するとanswerメソッドにより映像が送信されてくるのでstreamメソッドでその映像をvideo要素に設定して表示させています。


socket.on('user-connected', (peerId) => {
  const call = this.myPeer.call(peerId, stream)
  const video = document.createElement('video')
  call.on('stream', stream => {
    video.srcObject = stream;
    video.play()
    this.$refs.video.append(video)
  });  
});

ここまでの設定でお互いの映像をブラウザ上に表示させることができます。3つのブラウザを開いて同じルームに入室すると3つの映像が各ブラウザに表示されます。他のルームに入室するとそのルームに入室しているブラウザ間のみで映像の送受信を行います。

3つのブラウザで送受信
3つのブラウザで送受信

映像の非表示処理

映像を表示させることはできましたが、退室した場合は退室したブラウザからは映像が送信されなくなるため非表示にする必要があります。退室する場合はleaveRoomメソッドを実行するのでPeerJsの接続情報を削除するためにdestroryメソッドを追加します。


leaveRoom(){
  this.roomId = "";
  this.name = "";
  this.messages = [];
  this.members = [];
  this.myPeer.destroy();
  socket.close();
}

PeerJSにはcloseイベントも存在し接続がなくなると実行されるのでその中で映像の非表示処理を実行します。映像をcallする場合、callにanswerする場合それぞれでcloseイベントを設定します。


navigator.mediaDevices.getUserMedia({
  video: true,
  audio: false,
}).then(stream => {
  this.myVideo.srcObject = stream;
  this.myVideo.play()
  this.$refs.video.append(this.myVideo)

  this.myPeer.on('call', call => {
    const video = document.createElement('video')
    call.answer(stream);
    call.on('stream', stream => {
      video.srcObject = stream;
      video.play()
      this.$refs.video.append(video)
    });
    //answerした方が実行するイベント
    call.on('close', () => {
      console.log('answerしたブラウザが退出')
      video.remove()
    })
  });	

  socket.on('user-connected', (peerId) => {
    const call = this.myPeer.call(peerId, stream)
    const video = document.createElement('video')
    call.on('stream', stream => {
      video.srcObject = stream;
      video.play()
      this.$refs.video.append(video)
    });
    //callした方が退室する際に実行
    call.on('close',()=>{
      console.log('callしたブラウザで退出')
      video.remove()
    })
  });

}).catch(e => {
    console.log(e)
})

上記のイベントを設定すると退室したブラウザ側では映像は非表示になりますが、相手には接続が切れたことを伝えるイベントではないので退室しても相手側の映像を非表示にすることができません。Socket.ioのメッセージを利用して非表示にする仕組みを作ります。

PeerJSにも相手に接続が切れたことを伝えるイベントがあるかもしれませんが、closeだけではうまくいかなかったのでsocket.ioを利用しています。実際にはイベントが存在するかもしれないので存在する場合はそちらも利用してください。

退室を行うとsocketの接続も切れるのでサーバ側のdisconnectイベントを利用します。disconnectを検知後に入室しているブラウザに対してuser-disconnectedで接続が切れたPeer IDを送ります。


socket.on('disconnect',()=> {
  const room = rooms.find(room => room.id == socket.id)
  const index = rooms.findIndex(room => room.id == socket.id)
  if(index !== -1) rooms.splice(index,1)
  if(room){
    io.to(room.roomId).emit('message',`Bot :${room.name}が退出しました。`)
    socket.to(room.roomId).broadcast.emit('user-disconnected',room.peerId)
    const members = rooms.filter(rm => rm.roomId == room.roomId);
    io.to(room.roomId).emit('members',members);
  }
}); 

user-disconnectedをブラウザ側で設定することで他のブラウザが退室したことを検知することができます。


navigator.mediaDevices.getUserMedia({
  video: true,
  audio: false,
}).then(stream => {
//略
socket.on('user-disconnected', (peerId) => {
  console.log('他のユーザの接続が切れました')
})
},

接続が切れたことを検知することで行いたいことは退室した映像をブラウザ上から非表示にすることです。そのためには映像の情報を管理しておく必要があります。


const socket = io({
  autoConnect:false,
});

const videos = [];

videosへのvideo情報は他のブラウザからの映像を受け取る際に作成するvideo要素を作成した直後です。app.jsでは2箇所あります。videoの情報だけではなく識別子としてpeeerIDも一緒に保存します。

他のブラウザからcallがあり、video要素を作成した直後が1つ目です。


this.myPeer.on('call', call => {
  const video = document.createElement('video')
  videos.push({
    video:video,
    peerId:call.peer,
  });
  call.answer(stream);
  call.on('stream', stream => {
    video.srcObject = stream;
    video.play()
    this.$refs.video.append(video)
  });
  //answerした方が実行するイベント
  call.on('close', () => {
    console.log('answerしたブラウザが退出')
    video.remove()
  })
});	

他のブラウザにcallし、その返信があった時にvideo要素を作成した直後がもう一つの場所です。


socket.on('user-connected', (peerId) => {
  const call = this.myPeer.call(peerId, stream)
  const video = document.createElement('video')
  videos.push({
    video: video,
    peerId: peerId,
  });
  call.on('stream', stream => {
    video.srcObject = stream;
    video.play()
    this.$refs.video.append(video)
  });
  //callした方が退室する際に実行
  call.on('close',()=>{
    console.log('callしたブラウザで退出')
    video.remove()
  }	

videosからvideo情報を削除するタイミングはuser-disconnectedのイベントが実行された時です。user-disconnectedではpeer IDを送信してもらっているのでそのpeer IDで削除するvideoを特定します。


socket.on('user-disconnected', (peerId) => {
  const video = videos.find(video => video.peerId == peerId)
  if(video) video.video.remove();
})

socket.ioのイベントを利用することでルームを退室したブラウザから送信されてきた映像をブラウザ上で非表示にすることができます。

ミュートとビデオの停止

自PCの映像を一時的に停止、再開、音声を一時的に停止、再開(ミュート機能)する機能を追加します。

ビデオの停止、再開、音声の停止、再開の情報を保持するonVideo, onMuteをVue.jsのデータプロパティに追加します。

ビデオがついていたらonVideoの値はtrue, ミュート(音声を停止)であればonMuteはtrueとなります。またビデオ、音声の停止はstreamによって操作することができるのでmyStreamをデータプロパティに追加します。


data(){
  return {
    message:'',
    messages:[],
    name:'John',
    roomId:'',
    members:[],
    myPeer:'',
    myVideo:'',
    onVideo:true,
    onMute:false,
    myStream:'',
  }
},

v-ifディレクティブによって表示されているビデオ、マイクのアイコンの切り替えを行います。また文言もonMute, onVideoの値によって表示・非表示を切り替えます。アイコンをクリックするとクリックイベントが実行できるように4つのメソッド(startVideo, stopVideo, startAudio, stopAutio)を追加します。


<div style="display: flex;">
  <div class="menu_item">
    <div>
    <i class="fa fa-2x fa-microphone" @click="stopAudio" v-if="!onMute"></i>
    <i class="fa fa-2x fa-microphone-slash" @click="startAudio" v-else></i>
    </div>
    <div>
      <span v-if="!onMute">ミュート</span>
      <span v-else>アンミュート</span>
    </div>
  </div>
  <div class="menu_item">
    <div>
      <i class="fa fa-2x fa-video" @click="stopVideo" v-if="onVideo"></i>
      <i class="fa fa-2x fa-video-slash"@click="startVideo" v-else></i>
    </div>
    <div>
      <span v-if="onVideo">ビデオの停止</span>
      <span v-else>ビデオの開始</span>
    </div>
  </div>
</div>

映像、音声の制御はsteamを利用するため、自PCのWebカメラの映像であるsteramをmyStreamに設定します。


navigator.mediaDevices.getUserMedia({
  video: true,
  audio: false,
}).then(stream => {
  this.myStream = stream;
  this.myVideo.srcObject = this.myStream;
  this.myVideo.play()
  this.$refs.video.append(this.myVideo)

streamのtracksの情報はgetVideoTracksメソッドで取得できます。tracksの情報をconsole.log(tracks)で見ると以下の情報が確認できます。

trackの中身
trackの中身

enabledの値によってvideoを表示されるかさせないかを設定できます。同様にmuteの値によってミュートの設定を行うことができます。


startVideo(){
  this.onVideo = true;
  const tracks = this.myStream.getVideoTracks();
  tracks[0].enabled = true;
},
stopVideo(){
  this.onVideo = false;
  const tracks = this.myStream.getVideoTracks();
  tracks[0].enabled = false;
},
startAudio(){
  this.onMute = false;
  const tracks = this.myStream.getVideoTracks();
  tracks[0].mute = false;
},
stopAudio(){
  this.onMute = true;
  const tracks = this.myStream.getVideoTracks();
  tracks[0].mute = true;
}

これらの設定を行うことでビデオの停止ボタンをクリックするとブラウザ上からビデオの映像が非表示になるだけではなくルームに入室している他のブラウザのビデオの映像も非表示になります。

ローカルネットワーク上の機器間での動作確認

複数台のPCがローカルネットワーク上にある場合はローカルネットワーク上にある機器間でメッセージの送受信、映像の送受信ができるか確認を行ってみましょう。

そのためにはExpressサーバを起動するPCのローカルネットワークのIPアドレスを知る必要があります。macOSであればifconfig -a、Windowsであればipconfig /allでわかります。

動作確認をした環境ではサーバのIPアドレスが192.168.2.176だったので別PCからはそのIPアドレスを利用します。

他のPCから接続を行うとチャットによるメッセージの送受信は行うことができますが、映像は送受信することはできません。Expressサーバがhttpで起動しているためでhttpsへと変更する必要があります。

httpsへの設定変更

httpsで稼働させるためには証明書が必要となります。ここではローカルネットワークなので自己署名証明書を利用します。プロジェクトフォルダにcertフォルダを作成します。

certフォルダに移動して、opensslコマンドを利用して秘密鍵(privatekey.pem)と自己署名証明書(cert.pem)を作成します。対話形式でContry Nameなど聞かれますが開発用で一時的にhttpsを利用するために設定を行うのでいずれかの質問の一つを入力してください。後はEnterを押してください。ここではContry Nameに”JP”を入力しています。


 % openssl req -x509 -newkey rsa:2048 -keyout privatekey.pem -out cert.pem -nodes -days 365
Generating a 2048 bit RSA private key
.....................................................................................+++
...................+++
writing new private key to 'privatekey.pem'
-----
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) []:JP
State or Province Name (full name) []:
Locality Name (eg, city) []:
Organization Name (eg, company) []:
Organizational Unit Name (eg, section) []:
Common Name (eg, fully qualified host name) []:
Email Address []:

実行すると実行フォルダにprivatekey.pemとcert.pemファイルが作成されます。

作成した秘密鍵と自己署名証明書をindex.jsファイルで指定します。秘密鍵と自己署名証明書はcertフォルダに保存しているので./privatekey.pemを設定しています。パスには気をつけてください。

Expressサーバ側ではhttpからhttpsへの設定変更と秘密鍵と自己署名証明書の読み込みを行います。


const express = require('express');
const app = express()
const fs = require('fs');
const { ExpressPeerServer } = require('peer');

const server = require('https').createServer({
  key: fs.readFileSync('./cert/privatekey.pem'),
  cert: fs.readFileSync('./cert/cert.pem'),
},app);

const peerServer = ExpressPeerServer(server,{
  debug:true,
})

const io = require('socket.io')(server)
const port = 3000

httpsに設定変更を行うとhttps://localhost:3000でhttpからhttpsに変更する必要があります。

Chromeを利用している場合は自己証明書を利用しているので下記の画面が表示されます。詳細設定ボタンをクリックしてください。

プライバシーのメッセージ表示
プライバシーのメッセージ表示

下記の画面が表示されたらthisisunsafeとキーボードを叩いてください。

詳細を確認
詳細を確認

これまで通り下記の画面が表示されます。

httpsでの接続
httpsでの接続

他のPCでも同様に接続を行いますが、他のPCの場合はlocalhostではなくhttps://192.168.2.176:3000/zoomというようにIPアドレスを指定してください。

原因は特定できていませんが、ローカルネットワークで接続をした場合はPeerJSのcallがうまく動作しない場合があります。その場合はサーバ側で動作しているブラウザの退室、入室するとつながる場合があります。つながった場合は別々の映像がブラウザ上に表示されます。

実用で利用するにはほど遠いかもしれませんが複数のJavaScriptライブラリを活用することでZoomのようなビデオチャットのアプリケーションを構築することができました。