Firewood

Description

The project is essentially a very minimal and easy-to-use video calling website, wherein users can create cabins and invite other users to them.

The site has a variety of error correction methods and quality-of-life features such as:

  • Password validation
  • User validation
  • Cookie validation
  • Reset password (Forgot password)

The Basic Premise

Users sign up for an account with a Username, E-mail, and a Password, and can create new cabins.

The cabin pages require you to be logged in to access them and will redirect you back to the '/' route if you are not logged in.

How it works

General functionality:

  • The site uses Node.js and Express.js as the web server environment and web framework respectively.
  • MongoDB, Mongoose and bcrypt, along with JSON Web Token (JWT) are used to store and validator users.
  • HTML and CSS are used for the front-end, along with JavaScript for client-side code.
  • EJS is used as the templating engine.
  • Nodemailer is used for sending mails. Other outside packages are also used and are mentioned here along with the packages above
Note: Due to the death of Heroku's free tier - Firewood now uses Railway for the server - the video demo is outdated in this way.

The site makes use of the above in tandem for the general functionality and usability of the site; and for other quality-of-life features.

Video calling:

  • PeerJS is used to send and receive streams of video and audio peer to peer, and to manage them.
  • Socket.IO is used to manage connections and disconnections from cabins, and to communicate back and forth with the server.

Diving in further

The Database:

Storage of users and their data is done on the database. The Mongoose schema for a User can be seen below.

const UserSchema = new mongoose.Schema(
{
    username: { type: String, required: true, unique: true },
    email: { type: String, required: true, unique: true },
    password: { type: String, required: true },
    friends: [{ type: ObjectId, ref: 'Friends'}],
    enemies: [{ type: ObjectId , ref: 'User'}]
}, { collection: 'users' , timestamps: true})

Each user has the following fields:

  • ID (Required and is always unique - generated by MongoDB. )
  • Username (Required, and must be unique)
  • E-mail (Required, and must be unique)
  • Password (Required)
  • Friends
  • Enemies

The fields of Friends and Enemies are unused at the moment, but the ability to add and remove friends can be added later.

How video calling works:

All communication between users is P2P - and the server doesn't do anything other than manage the connections and disconnections of users.

The way it works is that, when you make a new cabin, a random UUID is generated and you are redirected to a Cabin route with that URL.

app.get('/new-cabin', loginRequired, (req, res) => {
    const cabinAddress = uuidV4()
    res.redirect(`/cabin/${cabinAddress}`)
})

Once you are redirected to '/cabin/{address}', the client-side code gets our media (video and audio)

const stream = navigator.mediaDevices.getUserMedia({
    video: {
        width: {ideal: ideal.width},
        height: {ideal: ideal.height}
    },
    audio: true
})

Then the stream is passed to a const

// Turning received stream into new UserStream - a custom class that contains data about a stream
const userStream = new UserStream(USER_ID, USERNAME, ourStream, true /* true here means that this is our own stream*/)

The video element on the page is constructed on screen with our UserStream, and is appended to the onscreen grid

// Constructing the video element
const constructedLocalVideo = new Video(userStream.constructLocalVideo(/* This is the text that the text-box should have ->*/ `You (${USERNAME})`))
// Appending the constructed video, to the grid
constructedLocalVideo.appendGrid(`${videoGrid}${gridNumber}`)

After this is all done, the client-side emits the fact that we have joined the cabin (a 'join-cabin' event), with a specific address, to the server side socket (Socket.IO), along with our details.

// Emitting that we (the current user, which is us on the client side) have joined this specific cabin; and here are our details (id, username)
socket.emit('join-cabin', CABIN_ADDRESS, USER_ID, USERNAME)

Whenever the server receives this event, it will join the cabin (read more here), and then emits the fact that 'we', the new user, have connected to the cabin with the address we emitted. Thus, if there are any users already in the cabin, they will receive our 'user-connected' event aswell as our details (our username and ID). It will also log the connection.

// Joining the room (cabin)
socket.join(cabinAddress)
// Emitting to the room that we have joint (along with our userId, and username)
socket.to(cabinAddress).emit('user-connected', userId, username)
// Logging the connection
console.log(`${username} (${userId}) connected to '${cabinAddress}'`)

When a new user connects to the room - after going through the same process we did above - we call the new user, using PeerJS.

  • The connectToNewUser function calls up the user, and asks the user for their stream, and constructs a video element once it receives it. It also sends the user our own stream.
// Whenever a new user connects, the code inside is run - the serve also passes through to us the username and id of the new user who connected.
socket.on('user-connected', (userId, username) => {
    // Log the connection
    console.log(`${username} (${userId}) connected.`)
    // Play the join sound
    userJoinSound.play()
    // See connectToNewUser() in the source code.
    connectToNewUser(USERNAME, USER_ID, userId, username,/* Our stream -> */ stream)
})

Now, let's say that there's already a user in a cabin who is waiting for us to join. In that case, whenever we join their cabin and emit our own 'join-cabin' event, the user waiting for us will receive our 'user-connected' event and then try to call/connect to us as above (in the previous step). And then, we will respond with our stream, and the other user will send us their own stream.

// Whenever we are called - by a user already in the room: run this code.
peer.on('call', call => {
    // Answer the call, by sending the callee our stream
    call.answer(localStream)

    // Now, whenever we receive the caller's stream - run this code
    call.on('stream', userVideoStream => {
        // Create a new stream out of the data of the caller
        const newUserStream = new UserStream(call.metadata.id, call.metadata.username, userVideoStream)
        //Create a constructed video element out of the data
        const constructedNewVideo = new Video(newUserStream.constructLocalVideo())
        //Adding the constructed video to the grid - checking if the user is already added to the grid/alraedy initialized in our 'users' object
        if (!users[call.metadata.id]) {constructedNewVideo.appendGrid(`${videoGrid}${gridNumber}`); calculateGrid()}
        // Add the users video to the list (object, really) of 'users' so that we don't accidentally add multiple of his video elements.
        users[call.metadata.id] = constructedNewVideo
    })

And that's pretty much it!

Final Thoughts

This project was insanely fun to do. There were many times when I got stuck for hours on end, or the code was functioning but was buggy - and the feeling is fantastic when you finally fix something that has been driving you crazy for so long! Anyways, I hope this project brings to life everything that I've learned. Goodbye, world!

Acknowledgements