DEV Community

Cover image for Socket.io, WebRTC, Node, Express, MongoDB, and Vue (Final Part)
Kevin Odongo
Kevin Odongo

Posted on

Socket.io, WebRTC, Node, Express, MongoDB, and Vue (Final Part)

Hey Dev's

So today let us complete this tutorial together with some practical work. Those who will be interested to learn more about WebRTC, AWS Websocket, Node, Vue/React Native will enjoy the course when it is out.

This will be quite interesting.

To those who will get lost through the code. This is a simple task just understand the logic, grab a bit of code, and try to do it yourself. I have tried to explain each function.

What we need to achieve in the meeting application is as follows:
This layout looks simple but there is an order to follow.

Alt Text

Backend

Let us create all the files that we will require. This will be our backend structure.

Alt Text

Create your node project by running the following command:

npm init 
Enter fullscreen mode Exit fullscreen mode

Install the required packages.

yarn add mongoose express socket.io body-parser dotenv cors jest 
Enter fullscreen mode Exit fullscreen mode

Create the following files in your application structure.

/config/db_config.js

This will expose our mongo database URL from the environment file. Ensure you create a .env file and save the mongo database URL.

module.exports = { url: process.env.MONGO_DB_URI }; 
Enter fullscreen mode Exit fullscreen mode

/controller/meet.controller.js

This will persist users' session-id to the database.

const db = require("../models"); const Meet = db.meet; // create a meet exports.createonemeet = (req, res) => { const meet = new Meet({ name: req.body.name ? req.body.name : "User", meetingid: req.body.meetingid, sessionid: req.body.sessionid, }); // Save new meet in the database meet .save(meet) .then(data => { res.send(data); }) .catch(err => { res.status(500).send({ message: err.message || "Some error occurred while creating the meeting." }); }); }; // retrieve all meet from the database for that meeting. exports.findallmeet = (req, res) => { const id = req.params.id; var condition = { meetingid: id } Meet.find(condition) .then(data => { res.send(data); }) .catch(err => { res.status(500).send({ message: err.message || "Some error occurred while retrieving meets." }); }); }; // retrieve one meet from the database for that meeting. exports.findonemeet = (req, res) => { const id = req.params.id; var condition = { sessionid: id } Meet.findOne(condition) .then(data => { res.send(data); }) .catch(err => { res.status(500).send({ message: err.message || "Some error occurred while retrieving sessions." }); }); }; // delete a meet with the specified id in the request exports.deleteonemeet = (req, res) => { const id = req.params.id; var condition = { sessionid: id } Meet.deleteOne(condition) .then(data => { if (!data) { res.status(404).send({ message: `Cannot delete meet with id=${id}!` }); } else { res.send({ message: "Meet was deleted successfully!" }); } }) .catch(err => { res.status(500).send({ message: "Could not delete meet with id=" + id }); }); }; 
Enter fullscreen mode Exit fullscreen mode

/controller/session.controller.js

This will handle chat content.

const db = require("../models"); const Session = db.session; // create a session exports.createonesession = (req, res) => { // check for message or attachments if(!req.body.message && !req.body.attachment){ return res.status(404).send({ message: "No message or attachment!" }); } // session const session = new Session({ message: req.body.message, attachment: req.body.attachment, meetingid: req.body.meetingid, sessionid: req.body.sessionid }); // Save new session in the database session .save(session) .then(data => { res.send(data); }) .catch(err => { res.status(500).send({ message: err.message || "Some error occurred while creating the new message." }); }); }; // retrieve all session from the database for that meeting. exports.findallsession = (req, res) => { const id = req.params.id; var condition = { meetingid: id } Session.find(condition) .then(data => { res.send(data); }) .catch(err => { res.status(500).send({ message: err.message || "Some error occurred while retrieving sessions." }); }); }; // retrieve one session from the database for that meeting. exports.findonesession = (req, res) => { const id = req.params.id; //var condition = { sessionid: id } Session.findById(id) .then(data => { res.send(data); }) .catch(err => { res.status(500).send({ message: err.message || "Some error occurred while retrieving sessions." }); }); }; // delete a session with the specified id in the request exports.deleteonesession = (req, res) => { const id = req.params.id; Session.findByIdAndRemove(id) .then(data => { if (!data) { res.status(404).send({ message: `Cannot delete contents with id=${id}!` }); } else { res.send({ message: "Session was deleted successfully!" }); } }) .catch(err => { res.status(500).send({ message: "Could not delete session with id=" + id }); }); }; // delete all session for the meeting. exports.deleteallsession = (req, res) => { const id = req.params.id; var condition = { sessionid: id } Session.deleteMany(condition) .then(data => { res.send({ message: `${data.deletedCount} Sessions were deleted successfully!` }); }) .catch(err => { res.status(500).send({ message: err.message || "Some error occurred while removing all sessions." }); }); }; 
Enter fullscreen mode Exit fullscreen mode

/models/meet.model.js

This will be the schema for the meet database.

module.exports = mongoose => { const Meet = mongoose.model( "Meet", mongoose.Schema( { name: String, // session name meetingid: String, // meeting id sessionid: String, // socket id }, { timestamps: true } ) ); return Meet; }; 
Enter fullscreen mode Exit fullscreen mode

/model/session.model.js

This will be the schema for the session database.

module.exports = mongoose => { const Session = mongoose.model( "Session", mongoose.Schema( { message: String, attachment: String, sessionid: String, // user session id meetingid: String // meeting id }, { timestamps: true } ) ); return Session; }; 
Enter fullscreen mode Exit fullscreen mode

/models/index.js

This will expose both the session and meet database.

const dbConfig = require("../config/db.config"); const mongoose = require("mongoose"); mongoose.Promise = global.Promise; const db = {}; db.mongoose = mongoose; db.url = dbConfig.url; // databases db.meet = require("./meet.model.js")(mongoose); // db.user = require("./user.model")(mongoose); db.session = require("./session.model")(mongoose); module.exports = db; 
Enter fullscreen mode Exit fullscreen mode

/routes/meet.js

This folder will hold the routes for the meeting. Ensure you import this file in the index.js file.

const express = require("express") const router = express.Router() const meet = require("../controller/meet.controller"); // Create a new blog router.post("/", meet.createonemeet); // retrieve all meets for the meeting router.get("/all/:id", meet.findallmeet); // retrieve one meets for the meeting router.get("/:id", meet.findonemeet); // delete a single meet router.delete("/:id", meet.deleteonemeet) module.exports = router 
Enter fullscreen mode Exit fullscreen mode

/routes/session.js

This folder will hold the routes for the meeting. Ensure you import this file in the index.js file.

const express = require("express") const router = express.Router() const session = require("../controller/session.controller"); // Create a new session router.post("/", session.createonesession); // find all session router.get("/all/:id", session.findallsession); // retrieve one session for the meeting router.get("/:id", session.findonesession); // delete a single session router.delete("/:id", session.deleteonesession) // delete all session for spefic session router.delete("/all/:id", session.deleteallsession); module.exports = router 
Enter fullscreen mode Exit fullscreen mode

index.js

This file will be the entry point for our application. In this file, we will define the socket.io logic. You can either put the logic in the front-end or back-end. For chat content, the request will have to come from the front-end because we want to add attachments capability. Remember attachment will be saved elsewhere while the file name or id will be saved in MongoDB.

Our first connection will come from the socket listening to the joined channel/message. Once a user joins they will get a unique socket id. Let us persist this socket id and the meeting name they have joined. I want you to NOTE In this channel, we are using socket.to while sending back our message. This is because we want everyone to be notified of a new user joining except the sender. The person who joins will not receive this notification.

This process will take place as follows. When Peer A joined they notified everyone but because no one was available in the meeting they did not receive any message. When Peer B joins, Peer A will get the notification of Peer A joining. This will trigger the rest of the exchanges in the meeting.

Below is an example of how you can add the logic to persist session-id and meeting name in the backend. Ensure you send the meeting name from the front-end.

// join a new meeting socket.on('joined', async (data) => { let meetingid = JSON.parse(data).meetingid let username = JSON.parse(data).username console.log("joined", meetingid) // persist socket id const new_meet = { name: username, meetingid: meetingid, sessionid: socket.id } await Meet.createonemeet(meet) if(meetingid !== null){ socket.join(meetingid); // notify everyone of a new user socket.to(`${meetingid}`).emit("joined", `${socket.id}`) } }); 
Enter fullscreen mode Exit fullscreen mode

The other channels/messages in the socket.io will be offer_message and answer_message. This will be broadcasted to individuals and not the entire meeting so we will be using io.to instead of socket.to.

The last one will be sendmessage this will notify everyone of a new message which will trigger a call to the database to fetch for that specific message. In this channel, we can either use io.in or socket.to to send to everyone including the sender or everyone excluding the sender (if you use this then you need to update the sender message array from the front-end when they send that particular message).

index.js

const express = require("express"); const bodyParser = require("body-parser"); const cors = require("cors"); require('dotenv').config() const app = express(); // parse application/json app.use(bodyParser.json()) // parse application/x-www-form-urlencoded app.use(bodyParser.urlencoded({ extended: true })) // use cors options app.use(cors()) app.use(require('serve-static')(__dirname + '/../../public')); // socket io const httpServer = require("http").createServer(app); const io = require("socket.io")(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } }); const Meet = Meet.db io.on("connection", (socket) => { // join a new meeting socket.on('joined', async (data) => { let meetingid = JSON.parse(data).meetingid //let username = JSON.parse(data).username //console.log("joined", meetingid) //const new_meet = { //name: username, //meetingid: meetingid, //sessionid: socket.id //} //await Meet.createonemeet(meet) if(meetingid !== null){ socket.join(meetingid); // notify everyone of a new user socket.to(`${meetingid}`).emit("joined", `${socket.id}`) } }); socket.on('offer_message', (data) => { let sessionid = JSON.parse(data).offerto console.log("[OFFER] Send to session id", sessionid) if(data !== null){ // notify everyone of a new user io.to(`${sessionid}`).emit("offer_message", `${data}`) } }); socket.on('answer_message', (data) => { let sessionid = JSON.parse(data).offerto console.log("[ANSWER] Send to session id", sessionid) if(data !== null){ // notify everyone of a new user io.to(`${sessionid}`).emit("answer_message", `${data}`) } }); // send a message socket.on('send', (data) => { let meetingid = JSON.parse(data).meetingid let sessionid = JSON.parse(data).sessionid if(data !== null){ socket.join(meetingid); // notify everyone of a new message socket.to(`${meetingid}`).emit("sendmessage", `${sessionid}`) } }); // disconnect socket.on("disconnect", (data) => { if(data !== null){ // notify everyone of a user has exited socket.to(`${data}`).emit("exitmeeting", 'someone has exited') } }); }); // mongo db database connection const db = require("./app/models"); db.mongoose .connect(db.url, { useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false, useCreateIndex: true }) .then(() => { console.log("Connected to the database!"); }) .catch(err => { console.log("Cannot connect to the database!", err); process.exit(); }); // routes const meet = require('./app/routes/meet') const session = require('./app/routes/session') app.use('/meet', meet) app.use('/session', session) // listening port const PORT = process.env.PORT || 3000; httpServer.listen(PORT); // app.listen(PORT, () => { // console.log(`Server is running on port ${PORT}.`); // }); 
Enter fullscreen mode Exit fullscreen mode

Front-End

In the front-end, I used Vue for my application, and to avoid being bias I will explain how the front-end major functions work so anyone following this tutorial with a different framework can follow along. Install the socket.io client-side package.

yarn add socket.io-client 
Enter fullscreen mode Exit fullscreen mode

Home Component

Once a user inputs the name let the following function handle the submit of your form. The function will get your hostname using windows.location.href and add the meeting name (This will create the meeting URL), thereafter re-direct the user to the session component.

submit(){ let application_url = window.location.href; // get the current  href let meeting_url = `${application_url}session/${meeting_name}`; this.$router.push("/sessions") } 
Enter fullscreen mode Exit fullscreen mode

Session component

Once a user clicks to join a meeting. Have a function to validate the meeting name. I used encryption and decryption to handle this. You can use a different approach according to your application needs.

// data in vue data(){ item: { user_name: "" // meeting URL }, messageContent: { message: null, attachment: null } socket: { signalClient: null peerConnection: null localStream: null remoteSteam: [] } } // join meeting joinmeeting(){ this.loading = true; // * get the meeting meeting let split_meeting_url = this.meeting_code.split("/"); let meeting_name = split_meeting_url.pop(); this.socket.signalClient = io( "http://localhost:3000/" ); // let us listen to joined message this.socket.signalClient.on("connect", async(data) => { // This is if you will be persisting user session id from front end //let value = { // user_name: this.item.user_name, // // meeting_url: meeting_name, // meeting id // socket_id: this.socket.signalClient.id // socket id //}; //if (value.user_name !== null) { //await saveonemeeting(value); // persist session in db //this.item.user_name = null; //} // * Joining meeting this will handle duplication of route when changing from one child component to another. this.status = "Joining"; let path = `/session/${meeting_name}`; if (this.$route.path !== path) this.$router.replace(path); // * Fetch all that have joined the meeting const sessions_response = await fetchallmeetings(`${meeting_name}`); this.sessions = [...sessions_response]; // * Fetch all messages and attachments (sessions) const contents_response = await fetchallsessions(`${meeting_name}`); this.messsages = [...contents_response] // * this will render the meeting component from the join component. Remember both are children in the session component. this.loading = false; this.meeting = true; this.status = "Processing"; // * notify joining meeting this.socket.signalClient.emit( "joined", JSON.stringify({ name: this.item.user_name // ensure its not cleared if you persisted from the front end meetingid: `${meeting_name}`, sessionid: `${this.socket.signalClient.id}` }) ); // * initialize the camera // * ensure dom is ready in vue we use this.$nextTick() this.$nextTick(async () => { let localView = document.getElementById("local_view"); //console.log(localView); this.socket.localStream = await navigator.mediaDevices.getUserMedia( this.constraints ); localView.srcObject = this.socket.localStream; // this will initialize the side section which has all the videos this.initializemeeting( this.socket.signalClient.id, this.socket.localStream ); // !end // * Get the video and audio tracks streams const audioTracks = localView.srcObject.getAudioTracks(); const videoTracks = localView.srcObject.getVideoTracks(); if (audioTracks.length > 0) { //console.log(`Using audio device: ${audioTracks[0].label}`); } if (videoTracks.length > 0) { //console.log(`Using video device: ${videoTracks[0].label}`); } }); //!end this.socket.signalClient.connect(); } // listen to new users joining this.socket.signalClient.on("joined", data => { .... }) } 
Enter fullscreen mode Exit fullscreen mode

Joined message channel

NOTE the following at the end. Once we have connected we are listening to joined channel/message.

this.socket.signalClient.on("joined", data => { .... }) 
Enter fullscreen mode Exit fullscreen mode

Once we have connected and emitted to everyone the socket id we end by listening for joined messages(notification). When a new user (Peer B) joins, (Peer A) will get a notification and this will lead to the next stage of the process.

Peer A will start a RTCpeerConnection, create an Offer and set it to his/her locaDescription, and start gathering Ice Candidates.

There are two ways of handling this. Send the ice Candidates as they are gathered which is called the Trickle or wait for all ice Candidates to be gathered and send an offer containing everything.

The pros of Trickle are it's fast but will require you to be careful with Peer B steps or else you will be getting errors all the time. Without trickle enabled the process has a slight delay but you are guaranteed connection.

Without Trickle

You will NOTE we are listening to icegatheringstatechange and once it is complete we send the whole localDescription. You will also NOTE we are sending back offerfrom and offerto so we can redirect this to the right user (offerto) in the backend to the user who joined.

this.socket.on("joined", data => { // * fetch the user who has joined details const joined_user = await fetchonemeeting(data); this.sessions.push(joined_user); // * create rtc session const configuration = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }; this.socket.peerConnection = new RTCPeerConnection(configuration); let localView = document.getElementById("local_view"); //console.log("[user]", localView, localView.srcObject); localView.srcObject .getTracks() .forEach(track => this.socket.peerConnection.addTrack(track, localView.srcObject) ); // ! remote tracks this.socket.peerConnection.ontrack = event => { // Have a function to initialize the meeting in the side. this.initializemeeting(data, event.streams[0]); }; //!end // ? ICE GATHERING WITHOUT TRICKLE this.socket.peerConnection.addEventListener( "icegatheringstatechange", event => { if (event.target.iceGatheringState === "complete") { this.socket.signalClient.emit( "offer_message", JSON.stringify({ desc: this.socket.peerConnection.localDescription, offerfrom: `${this.socket.signalClient.id}`, offerto: `${data}` // ? send offer to }) ); } } ); }) 
Enter fullscreen mode Exit fullscreen mode

With Trickle

With this, we will be sending the offer and iceCandidates as they are generated. Replace all the code from // ? ICE GATHERING WITHOUT TRICKLE with below.

// ? WITH TRICLE ENABLED // * create an offer and send await this.socket.peerConnection.setLocalDescription( await this.socket.peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true }) ); this.socket.peerConnection.onicecandidate = ({ candidate }) => this.socket.signalClient.emit( "offer_message", JSON.stringify({ desc: { offer: this.socket.peerConnection.localDescription, icecandidate: { candidate } }, offerfrom: `${this.socket.signalClient.id}`, offerto: `${data}` // ? send offer to }) ); } 
Enter fullscreen mode Exit fullscreen mode

Offer message

Once Peer B receives an offer they will need to set it to the remoteDescription of their peer Connection.

Without trickle once they set it to remoteDescription they will have the offer and iceCandidates from Peer A. What they will do is just generate an answer, set it to their localDescription, gather iceCandidates and send it to Peer A.

With trickle enabled it is tricky and be careful with this part. The iceCandidates might arrive before the offer has arrived and therefore if you try adding them to your peer Connection before setting the offer to your remoteDescription this connection will fail. Some browsers might not allow trickle

The trick to handle this is to add an event listener to listen on canTrickleIceCandidates. Once this is true then you can addiceCandidates.

// * listen to users offers and create an answer this.socket.signalClient.on("offer_message", async data => { const response = JSON.parse(data); this.$nextTick(async () => { // * Get the video and audio tracks streams let localView = document.getElementById("local_view"); this.socket.localStream = await navigator.mediaDevices.getUserMedia( this.constraints ); localView.srcObject = this.socket.localStream; const audioTracks = localView.srcObject.getAudioTracks(); const videoTracks = localView.srcObject.getVideoTracks(); const peerTracks = localView.srcObject.getTracks(); if (audioTracks.length > 0) { //console.log(`Using audio device: ${audioTracks[0].label}`); } if (videoTracks.length > 0) { //console.log(`Using video device: ${videoTracks[0].label}`); } //!end // * create rtc connection const configuration = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }; this.socket.peerConnection = new RTCPeerConnection(configuration); // ! remote tracks this.socket.peerConnection.ontrack = event => { this.initializemeeting(response.offerfrom, event.streams[0]); }; //!end if (response.desc) { //console.log("[user] Offer", response); // * set offer to remote if (response.desc.offer) { await this.socket.peerConnection .setRemoteDescription(response.desc.offer) .catch(error => { if (error) return; }); } peerTracks.forEach(track => this.socket.peerConnection.addTrack(track, localView.srcObject) ); // * create an answer set to local description and send await this.socket.peerConnection.setLocalDescription( await this.socket.peerConnection.createAnswer({ offerToReceiveAudio: true, offerToReceiveVideo: true }) ); // * send a answer and candidate this.socket.peerConnection.onicecandidate = ({ candidate }) => this.socket.signalClient.emit( "answer_message", JSON.stringify({ desc: { answer: this.socket.peerConnection.localDescription, icecandidate: { candidate } }, offerfrom: `${this.socket.signalingClient.id}`, offerto: `${response.offerfrom}` // ? send answer to }) ); // * add ice candidates if (this.socket.peerConnection.canTrickleIceCandidates === true) { //console.log("[user] Candidate", response.desc.icecandidate); await this.socket.peerConnection .addIceCandidate(response.desc.icecandidate) .catch(error => { if (error) return; }); } // ? ICE GATHERING WITHOUT TRICKLE // this.socket.peerConnection.addEventListener( // "icegatheringstatechange", // event => { // if (event.target.iceGatheringState === "complete") { // this.socket.signalClient.emit( // "answer_message", // JSON.stringify({ // desc: this.socket.peerConnection.localDescription, // offerfrom: `${this.socket.signalingClient.id}`, // offerto: `${response.offerfrom}` // ? send answer to // }) // ); // } // } // ); // console.log("[user] peer connection", this.socket.peerConnection); } }); this.socket.signalClient.connect(); }); 
Enter fullscreen mode Exit fullscreen mode

Answer message

Once peer A receives an answer they will set it to their remoteDescription and add the ice Candidates from Peer B. At this stage, the connection will be complete. Both parties will be receiving video and audio from each other.

 // * listen to answers and set to remote description this.socket.signalClient.on("answer_message", async data => { const response = JSON.parse(data); if (response.desc) { // * set remote description and ice //console.log("[user] Answer", response); if (response.desc.answer) { await this.socket.peerConnection .setRemoteDescription(response.desc.answer) .catch(error => { if (error) return; }); } if (this.socket.peerConnection.canTrickleIceCandidates === true) { //console.log("[user] Candidate", response.desc.icecandidate); await this.socket.peerConnection .addIceCandidate(response.desc.icecandidate) .catch(error => { if (error) return; }); } } this.socket.signalClient.connect(); }); 
Enter fullscreen mode Exit fullscreen mode

sendmessage.

For attachments, you can use AWS Simple Storage or any product you can use. Just ensure you save the attachment filename in your database so you can use it to retrieve the attachment from wherever you have decided to store it.

// * send message async sendmessage() { // * retrive session details let split_meeting_url = window.location.href.split("/"); let value = { message: this.messageContent.message, sessionid: this.user.signalClient.id, // user session id meetingid: split_meeting_url.pop() // meeting id }; const session_response = await saveonesession(value); this.messages.push(session_response); this.messageContent = { message: null, attachment: null }; // * send a message this.socket.signalClient.emit( "send", JSON.stringify({ sessionid: session_response._id, meetingid: value.meetingid // meeting id }) ); this.socket.signalClient.connect(); }, 
Enter fullscreen mode Exit fullscreen mode

To exit the meeting you need to disconnect the video tracks. You can either delete everything from the front end or backend.

// * exit meetings async exitsession() { // * disconnect media await this.disconnectmedia(); // * delete all contents with session id if (this.user.signalingClient) { await deleteonemeeting(this.socket.signalClient.id); await deleteallsession(this.socket.signalClient.id); // * disconnect from meeting this.socket.signalClient.on("disconnect"); } let path = "/"; if (this.$route.path !== path) this.$router.push(path); }, // * disconnect media disconnectmedia() { let localView = document.getElementById("local_view"); if (localView !== null) { if (localView.srcObject) { localView.srcObject.getTracks().forEach(track => track.stop()); } } let meeting_views = document.querySelectorAll(".meeting-streams"); meeting_views.forEach(e => { if (e.srcObject) { e.srcObject.getTracks().forEach(track => track.stop()); } }); this.sessions = []; }, 
Enter fullscreen mode Exit fullscreen mode

That's it you will have both video and chat capability. For many users, you will need to have a good backend infrastructure that can scale.

Here is documentation about WebRTC https://webrtc.org/.
Here is documentation about socket.io https://socket.io/docs/v3.

With socket.io the trick is you can listen to any changes and let that trigger a function in the front end or backend. This can be a great way of achieving real-time data streaming.

WebRTC is quite powerful you can achieve a lot with it. Dig through the documentation.

If you are working on a project and need assistance reach out. (Vue, React, and React Native)

Have a good weekend.

Thank you.

Top comments (5)

Collapse
 
masharsamue profile image
Samuel Mashar

nomare so unamind kutuekea link ya netlify tutest hii stuff

Collapse
 
kevin_odongo35 profile image
Kevin Odongo

Go to part one of the article you will get the link

Collapse
 
masharsamue profile image
Samuel Mashar

Am speaking about the deployed link not the codebase, but ni sawa tu. Great stuff.

Thread Thread
 
kevin_odongo35 profile image
Kevin Odongo

main.d37i78548hx7uj.amplifyapp.com/

This is link was in the first article. Play around with it to understand how it works

Thread Thread
 
masharsamue profile image
Samuel Mashar

I understand it i just wanted to see it in action