Alamo is a real-time communications web application that combines the strengths of two very popular web applications currently available today, Discord and Twitch. Alamo provides gaming enthusiasts with a platform to share and experience their favourite online video games together.
Alamo is a concept that places a massive amount of emphasis on collaboration. Using Alamo Rooms, users simply create their room, put on their favourite Twitch streamer, invite their friends and hang out, talk and watch show together.
- Create an alamo room and invite up to 5 friends friends
- Use alamo's WebRTC voice chat
- Browse twitch.tv using alamo search
- Trust your friends taste in streams? Allow them to call the shots by making them a room admin
- Prefer democracy over authoritarianism? Cast an alamo vote to change to your favourite streamer
To install alamo locally, simply run the following command in the alamo working directory.
chmod +x build.sh; ./build.sh
In the event this fails, users can install using the following:
Node / Express
npm install --save && npm start
PeerJS
npm install --save peerjs && peerjs --port 8081
React
cd client && npm install --save && npm run dev
Alamo is built upon the popular MERN stack - MongoDB, Express, React and Node.js.
Alamos implements a session based authentication strategy and uses PassportJS middleware. The decision to go with PassportJS as a authentication library was easy, it provides excellent documentation and supports various login types that can be easily plugged in at a later date, allowing users to sign in using their Google, Facebook or Twitch credentials. However for now, alamo uses PassportJS's local-strategy
as a primary means of authentication.
PassportJS, along with BCrypt
is used to handle all sensitive information. All passwords are hashed and salted using the BCrypt
library before being stored in the database.
Error Handling is handled using Reacts Error Boundary. This is simply set up to catch and major errors and report back to the user that something has gone wrong. Initially, the idea what to allow users to report issues to a dedicated mailbox. However, due to time constraints this was not completed in time.
Alamos Third Party API integration consists primarily of Twitch.tv public API.
Unfortunately, Twitch's API is quite limited in what it can do. This limitation was unknown at the start of the project. For example, there is no relationship between Twitch.tv channels and streams. As a result, if a user wishes to query a channel, that channels stream cannot be easily identified. However, one potential work around may be to allow users to simply copy and paste a URL of a choosen stream.
Twitch provides its access tokens with an expiration. As a result, authentication for Twitches API is set on a setTimeout
and recursively calls itself to ensure alamo has a valid access token at all time.
authTwitch = () => {
axios.post(twitchUrl)
.then((response) => {
let access_token = response.data.access_token;
app.locals.client_id = client_id;
app.locals.access_token = access_token;
setTimeout(() => {
authTwitch();
}, (response.data.expires_in - 100))
})
.catch((err) => console.log(err))
}
File uploading is handled using Cloudinary. As file uploading is not necessarily a key feature of alamo, besides allowing users to upload their own custom avatar image, outsourcing file handling to a cloud hosting service made sense. All uploads are performed on the clients side using Cloudinary's API and upon a successful upload, a url is then stored in the users metadata object. Cloudinary allows for custom presets to be configured, meaning all images uploaded are resized to a fixed width and height of 128x128px
. Similiarly, files uploaded are checked for appropriate file formats and a max file size of 5MB
, thanks to react-image-upload.
Password resets and forgotten passwords are handled using a token based system. In the event a user forget the accounts password, users can reset it. On reset request, a email is sent to the user along with a timestamped UUID V1 token. This token is store in the users document in MongoDB. When a user follows the url to reset password, the token is checked to see if valid. A token is only valid if it is the most recent token created and is less than an hour old. The new passport is checked to ensure its level of security is appropriate (must container uppercase letter & number), and then passed through PassportJS
, where it is hashed, salted and updated in the database. All emails for password reset are handled using Nodemailer
.
Alamo's WebRTC is faciliated using PeerJS and socket.io. Both libraries provide fantastic documentation and stream line WebRTC signalling and peer-to-peer communications.
The first challenge to acheive WebRTC was to configure and implement a web signalling protocol. All WebRTC signalling is handled using socket.io's websocket library.
Socket.io's server configuration is basic, straight forward and follows socket.io's documentation. No additional additional configuration options were used. Socket.io's client configuration take place at a high level in ReactJS's App.js
file and passed down as a prop to React Components as required. This ensures that a client would only attempt to establish a WebSocket connection by triggering socket.io's io.connect()
function once.
Upon the client successfully establishing a WebSocket connection, the clients user ID is passed to Node and stored in a clients
object. clients
helps keeps track of all connected client, along with their respective socket.id
and current activity status. Due to the nature of socket.io, once a client refreshes the page, a new socket.id
will be randomly generate. As a result, it is important to ensure that any new socket.id
is updated on behalf of that user in Node's clients
object.
io.on('connection', (socket) => {
socket.on('online', (userId, callback) => {
//Add user to list of connected clients and broadcast that user is online
if (!(userId in clients)) {
clients[userId] = {socketId: socket.id, status: ''}
io.sockets.emit('new-user-online', userId, clients);
} else {
//Update socket id but do not broadcast new user online
clients[userId] = {socketId: socket.id }
}
//Send back list of active clients when user logs on
callback(clients)
})
})
Alamo rooms provide the necessary means of establishing a WebRTC connection between users. When a user enters an alamo room, that users emits to join a socket.io
room also. Upon joining the socket.io
room, other sockets in the rooms are notified and all parties are able to communicate freely. However, users in the room do not necessary now who else may be in there with them, only the server knows this information. As a result, the server keeps other users informed of the current state of the room.
socket.on('join-room', (roomId, userId) => {
//Join new room
socket.join(roomId)
//If room is newly created or empty, add first peer
if (typeof rooms[roomId] == 'undefined') {
rooms[roomId] = [userId]
} else {
//If room is already populated with a peer(s), append new peer to room
//Prevent user being added to same room twice
if (!(rooms[roomId].includes(userId, 0))) {
let updateRoomPeers;
updateRoomPeers = rooms[roomId].concat(userId);
rooms[roomId] = updateRoomPeers;
}
}
//Broadcast to anyone that may have this room favourited that a user has joined and update room size
socket.broadcast.emit('user-joined-room', roomId, rooms[roomId])
//Need to send a direct message to the client of peers list, emit does not seem to work
socket.emit('client-connected', userId, rooms[roomId]);
//Broadcast to other users in room, that a new user has connected
socket.to(roomId).broadcast.emit('user-connected', userId, rooms[roomId])
socket.on('disconnect', () => {
//On disconnect remove peer from list of connected peers
let updateRoomPeers;
updateRoomPeers = rooms[roomId];
updateRoomPeers = updateRoomPeers.filter(item => item !== userId)
rooms[roomId] = updateRoomPeers;
//Send updated peers list minus disconnected user
socket.to(roomId).broadcast.emit('user-disconnected', userId, rooms[roomId])
})
})
One of the biggest challenges was dynamically creating audio elements for each user. User audio elements are created and managed using React.Refs. Upon joining a user requests a update list of current peers in a room. This is necessary to happen before any WebRTC takes places, as a audio element must exist in order for a WebRTC connection.
//Request updated peers list before routing call
this.props.socket.emit('request-peers', this.props.activeRoom, (peers) => {
this.updatePeersInRoom(peers)
.then(() => {
this.playUserAudio(call.peer, userAudioStream)
})
})
The client then generates a list of audio elements with Refs linked to each peer or user in the room. This allows for audio elements to be created and removed based upon the current state of the room.
updatePeersInRoom = async (peers) => {
const updatePeers = new Promise((resolve, reject) => {
//Create Ref of updatePeersList
peers.forEach(thing => {
this[`${thing}_ref`] = React.createRef()
});
//Add the peers state
this.setState({
peers: peers
}, () => {
resolve()
})
})
return updatePeers;
}
{this.state.peers.map((userId) => {
return(
<div data-userid={userId}>
<audio id={userId} key={'audio'+userId} ref={this[`${userId}_ref`]} controls volume="true" autoPlay/>
</div>
)
})}
Once joined, a client then receive a PeerJS call from each user in the room, along with their audio stream. Thankfully, PeerJS
allow for peer.ids
to be defined, which allowed for users UUID to be used as a primary means of identifying and calling other users.
this.peer.on('call', call => {
call.answer(stream);
call.on('stream', userAudioStream => {
//Request updated peers list before routing call
this.props.socket.emit('request-peers', this.props.activeRoom, (peers) => {
this.updatePeersInRoom(peers)
.then(() => {
this.playUserAudio(call.peer, userAudioStream)
})
})
})
})
Upon receiving a call, a user answers by returning their own audio stream. At this point, both users have each other audio streams, all that is left to do is output the audio. This is were React.Refs
come in handy. All the has to be done is reference the audio element that was created earlier, and apply the new ObjectSRC with callers stream.
playUserAudio = (userId, stream) => {
//If connected user is client, then we want to mute that audio ref element
if (userId === clientId) {
this[`${userId}_ref`].current.srcObject = stream;
this[`${userId}_ref`].current.muted = true;
} else {
this[`${userId}_ref`].current.srcObject = stream;
}
}
On creation, a unique UUID (Universally Unique Identifier) is generated and stored in a MongoDB database. This UUID is the primary means of room identification and is used as a primary key of each room document. The decision to overwrite MongoDB native ObjectId was made to distungish between users and rooms. Each room Id would begin with room
followed by a version 4 UUID number. For example, /room/0e446e3d-8dd2-4e0b-886b-5b5f3c8fb182
. Using a UUID library ensures all rooms generated have a unique primary key.
Once a user has created a room and navigated to the room UUID URI, they essentially broadcast or emit that they would like to join a socket.io room. Socket.io rooms are no different than alamo rooms. They are a named space that sockets can join and leave. As a result, this allows for easy bi directional communication back and forth between each users in the room and between Alamo's Node server.
this.props.socket.emit('join-room', this.props.activeRoom, localStorage.getItem('userId'))
Using Reacts ComponentDidMount lifecycle, a user emits to Node that they would like to join this room.
During development, a PeerJS server was running locally on a local machine. However, this was unsuitable once alamo was pushed to its own Heroku server. PeerJS would required a dedicated server to handle all alamos Peer-to-Peer WebRTC. Thankfully, Heroku makes this easy and provide a dedicated PeerJS button to quickly deloy a Peer server in only a few minutes. Configuration was straight forward, having only to update PeerJS host and port number on client-side.
this.peer = new Peer(localStorage.getItem('userId'), {
host: 'https://alamo-peerjs.herokuapp.com',
secure: true,
host: 'alamo-peerjs.herokuapp.com',
port: 443
})