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.
Backend
Let us create all the files that we will require. This will be our backend structure.
Create your node project by running the following command:
npm init
Install the required packages.
yarn add mongoose express socket.io body-parser dotenv cors jest
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
};
/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
});
});
};
/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."
});
});
};
/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;
};
/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;
};
/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;
/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
/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
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}`)
}
});
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}.`);
// });
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
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")
}
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 => {
....
})
}
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 => {
....
})
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
})
);
}
}
);
})
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
})
);
}
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();
});
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();
});
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();
},
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 = [];
},
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)
nomare so unamind kutuekea link ya netlify tutest hii stuff
Go to part one of the article you will get the link
Am speaking about the deployed link not the codebase, but ni sawa tu. Great stuff.
main.d37i78548hx7uj.amplifyapp.com/
This is link was in the first article. Play around with it to understand how it works
I understand it i just wanted to see it in action