/*
* Copyright (C) 2022 Kraft, Royapally, Sarthi, Ramaswamy, Maduru, Harde, Gomes, Bellam, Reddy, Craine, Gupta - All Rights Reserved
* You may use, distribute and modify this code under the
* terms of the MIT license that can be found in the LICENSE file or
* at https://opensource.org/licenses/MIT.
* You should have received a copy of the MIT license with
* this file. If not, please write to: develop.nak@gmail.com, or visit https://github.com/SiddarthR56/spark/blob/main/README.md.
*/
/**
* Logic for setting and starting up the server, setting recognized routes, and
* setting which server-side socket signals to respond to with server-side functionality.
*
* @requires express
* @requires cors
* @requires socket.io
* @requires peer
* @requires uuid
* @requires express-rate-limit
*/
/**
* Express module for starting route-based apps.
*
* @const
*/
const express = require('express');
/**
* The app instance for serving webpage and routing data to any client.
* Requires no params for instantiation in the context of the server
* and its default settings.
*
* @type {Object}
* @constructor
* @fires app#use
* @fires app#set
* @fires app#get
* @fires app#on
*/
const app = express();
/**
* Module for cross-origin resource sharing; needed so that the protocol default CORS
* policy can be overridden.
*
* @const
*/
const cors = require('cors');
/**
* The HTTP server module used for starting Spark's server to serve webpage content.
*
* @type {Object}
* @param {Object} [app] Reference to instantiated express app that server module can interface on
* @const
* @constructor
*
* @listen server#listen
*/
const server = require('http').Server(app);
/**
* The socket module that allows for peer-to-peer connection support.
*
* @type {Object}
* @const
* @param {Object} [server] Reference to the HTTP server that will be used to listen for new connections
* @param {Object} [config] Settings for the socket connection; used for setting CORS origin for connection
* @constructor
*
* @fires io#on
*/
const io = require('socket.io')(server, {
cors: {
origin: '*',
},
});
/**
* Module for peer.js that allows for express app support for peer-to-peer connections.
*
* @const
*/
const ExpressPeerServer = require('peer').ExpressPeerServer;
/**
* The app instance for managing new multi-peer connections from clients using rooms.
* Requires no params for instantiation in the context of the server
* and its default settings.
*
* @const
* @type {Object}
* @constructor
* @fires peerApp#use
*/
const peerApp = express();
/**
* The HTTP server module used for starting Spark's server to listen for new peers and their activity; uses createServer
* to set up the peer.js functionality.
*
* @type {Object}
* @param {Object} [peerApp] Reference to instantiated peer.js express app that server module can interface on
* @const
* @method
*
* @listens peerServer#listen
*/
const peerServer = require('http').createServer(peerApp);
/**
* Module for generating UUIDs, using v4 specifically to generate securely.
*
* @const
*/
const { v4: uuidV4 } = require('uuid');
/**
* Module for enforcing rate limit on an express app
*
* @const
*/
const RateLimit = require('express-rate-limit');
/*
**
* Sets up the rate limit rules to be used for the express app
*
* @constructor
* @param {Object} [configs] Settings used for the rate limiting rules
*/
const limiter = RateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 20,
});
/**
* Variable to keep track of room hosts for determining host-only actions
*
* @type {Object}
*/
var roomHostsMap = {};
/**
* Variable to keep track of room participants for determining host-only actions
*
* @type {Object}
*/
var roomParticipantsMap = {};
/**
* Variable to keep track of breakout rooms for determining where to place participants.
*
* @type {Object}
*/
var participantBreakOutRoomMap = {};
/**
* Sets various configs for the express app; in the context of Spark, it's only used to set
* the view engine from HTML to EJS files.
*
* @event app#set
*/
app.set('view engine', 'ejs');
/**
* Applies CORS for all requests.
*
* @event app#use
*/
app.use(cors());
/**
* Tells the express app to reference any static files to serve under the
* project's public directory.
*
* @event app#use
*/
app.use(express.static('public'));
/**
* Tells the express app to reference the instantiated rate limiter rules.
*
* @event app#use
*/
app.use(limiter);
/**
* Sets up a route to listen to peer connections with debug options.
*
* @event peerApp#use
*/
peerApp.use(
'/peerjs',
ExpressPeerServer(peerServer, { debug: true })
);
/**
* GET / : redirects the user to a room with an ID generated by UUID v4
*
* @event app#get
* @param {string} [path] The path for which the GET will act on
* @param {function} [callback] The function with request and response pair that runs when the endpoint is hit
*/
app.get('/', (req, res) => {
res.redirect(`/${uuidV4()}`);
});
/**
* GET /:room : Parametrized room route that serves back the view/room.ejs file with the given room ID
*
* @event app#get
* @param {string} [path] The path for which the GET will act on, parametrized with :
* @param {function} [callback] The function with request and response pair that runs when the endpoint is hit
*/
app.get('/:room', (req, res) => {
res.render('room', { roomId: req.params.room });
// Initialize the creation of roomParticipantsMap and roomHostsMap
if (!roomHostsMap[req.params.room]) {
roomParticipantsMap[req.params.room] = [];
roomHostsMap[req.params.room] = null;
}
});
/**
* GET /:room/close : Route that shuts down the server when hit
*
* @event app#get
* @param {string} [path] The path for which the GET will act on, parametrized with :
* @param {function} [callback] The function with request and response pair that runs when the endpoint is hit
*/
app.get('/:room/close', (req, res) => {
server.close();
peerServer.close();
res.send('Http closed');
});
/**
* Handles setting up initial server-side functions for new socket.io connections that it picks up.
*
* @method
* @param {string} [label] Label of the signal that io picks up
* @param {function} [callback] Function that acts on a connecting socket that is called when hit
* @event io#on
* @listens io#connect
*/
io.on('connection', (socket) => {
/**
* Handles when a client socket calls join-room.
*
* @param {string} [label] Label of the signal that io picks up
* @param {function} [callback] Function that acts on a connecting socket that is called when hit
* @listens socket#emit
*/
socket.on(
'join-room',
/* istanbul ignore next */ (roomId, userId) => {
/*
* Note: This entire callback function will be ignored by nyc for purposes of unit testing
* and coverage, since nyc can't keep track of the server-side functions given by the
* socket.io server, and can only keep track of the client-side functions of the socket
* defined in the tests.
*/
// initialize roomParticipants for a room
if (!roomParticipantsMap[roomId]) {
roomParticipantsMap[roomId] = [];
}
// Make the socket join a channel under roomId that the io server will broadcast messages to
socket.join(roomId);
// First person to join the room is assumed to be the host
if (roomHostsMap[roomId] === null) {
roomHostsMap[roomId] = userId;
}
// Broadcast to all existing clients in the room that a user has connected
socket.to(roomId).emit('user-connected', userId);
// Add anyone who joins the room to the roomParticipantsMap
roomParticipantsMap[roomId].push(userId);
/**
* Handles when a client socket sends a message signal.
*
* @param {string} [label] Label of the signal that io picks up
* @param {function} [callback] Function that acts on a connecting socket that is called when hit
* @listens socket#emit
*/
socket.on('message', (message) => {
// Send message to the same room
io.to(roomId).emit('createMessage', message);
});
/**
* Handles when a client socket sends a filetransfer signal.
*
* @param {string} [label] Label of the signal that io picks up
* @param {function} [callback] Function that acts on a connecting socket that is called when hit
* @listens socket#emit
*/
socket.on('filetransfer', (blob) => {
io.to(roomId).emit('downloadFile', blob);
});
/**
* Handles when a client socket sends a mute-all signal.
*
* @param {string} [label] Label of the signal that io picks up
* @param {function} [callback] Function that acts on a connecting socket that is called when hit
* @listens socket#emit
*/
socket.on('muteAllUsers', (userId, roomId) => {
// Check if the provided userId is the ID of a host, and if it is, broadcast the muteAll
if (roomHostsMap[roomId].includes(userId)) {
io.to(roomId).emit('muteAll', userId);
}
});
/**
* Handles when a host socket sends a breakout-room signal.
*
* @param {string} [label] Label of the signal that io picks up
* @param {function} [callback] Function that acts on a connecting socket that is called when hit
* @listens socket#emit
*/
socket.on('createBreakoutRooms', (userId, roomId, numRooms) => {
// send peers into breakout rooms created by the host
if (roomHostsMap[roomId].includes(userId)) {
var newRoomIds = Array(numRooms);
// create new room ids to send people
for (let i = 0; i < numRooms; i++) {
newRoomIds[i] = `${uuidV4()}`;
}
// function call to create a map of which participant goes to which room
participantBreakOutRoomMap = (function (
roomHostsMap,
roomParticipantsMap,
currentRoom,
numRooms,
newRoomIds
) {
var participantBreakOutRoomMap = {};
// get host and remove host from participants
const host_index = roomParticipantsMap[currentRoom].indexOf(roomHostsMap[currentRoom]);
roomParticipantsMap[currentRoom].splice(host_index, 1);
// get how many partcipants should usually be allocated
var numParticipantsEach = roomParticipantsMap[currentRoom].length / numRooms;
// initialize variables
var numPartitipantsInEachRoom = [];
numPartitipantsInEachRoom = Array(numRooms).fill(0);
var numLoop = numRooms;
// when number of peers in room is divisble by the number of rooms to create
if (roomParticipantsMap[currentRoom].length % numRooms != 0) {
while (numLoop != -1) {
var pos = numLoop % numRooms;
numPartitipantsInEachRoom[pos] += 1;
numLoop -= 1;
}
} else {
numPartitipantsInEachRoom = Array(numRooms).fill(numParticipantsEach);
}
// randomize the array to allocate peers randomly
const shuffledParticipants = (function (array) {
let currentIndex = array.length,
randomIndex;
// While there remain elements to shuffle.
while (currentIndex != 0) {
// Pick a remaining element.
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
// And swap it with the current element.
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
}
return array;
})(roomParticipantsMap[currentRoom]);
// create a map of userId: newRoom
var currIndex = 0;
for (let i = 0; i < numRooms; i++) {
for (let j = 0; j < numPartitipantsInEachRoom[i]; j++) {
participantBreakOutRoomMap[shuffledParticipants[currIndex]] = newRoomIds[i];
currIndex += 1;
}
}
return participantBreakOutRoomMap;
})(roomHostsMap, roomParticipantsMap, roomId, numRooms, newRoomIds);
// broadcast to the peers in the room to join breakout room
io.to(roomId).emit('joinBreakOutRoom', participantBreakOutRoomMap, roomHostsMap);
}
});
/**
* Handles when a host socket sends an exit breakout-room signal.
*
* @param {string} [label] Label of the signal that io picks up
* @param {function} [callback] Function that acts on a connecting socket that is called when hit
* @listens socket#emit
*/
socket.on('exitBreakoutRooms', (userId, roomId) => {
// disconnect users from their rooms
if (roomHostsMap[roomId].includes(userId)) {
const maps = [roomHostsMap, roomParticipantsMap];
// broadcast peers to exit the room
io.to(roomId).emit('exitBreakRoom', roomId, maps);
// delete entries in breakout room map
for (let eachUserId in participantBreakOutRoomMap) {
delete participantBreakOutRoomMap[eachUserId];
}
}
});
/**
* Handles when a client socket sends a disconnect signal.
*
* @param {string} [label] Label of the signal that io picks up
* @param {function} [callback] Function that acts on a connecting socket that is called when hit
* @listens socket#emit
*/
socket.on('disconnect', () => {
socket.to(roomId).emit('user-disconnected', userId);
// remove participant from the room's map
const index = roomParticipantsMap[roomId].indexOf(userId);
roomParticipantsMap[roomId].splice(index, 1);
// check if the user is exiting a break out room
if (participantBreakOutRoomMap[userId]) {
delete participantBreakOutRoomMap[userId];
}
// when a user is not in any breakout room
else {
// remove participant from the room's map
const index = roomParticipantsMap[roomId].indexOf(userId);
roomParticipantsMap[roomId].splice(index, 1);
if (roomHostsMap[roomId]) {
// if the user is host, then assign a random person in the participants as the host
if (roomParticipantsMap[roomId].length > 0 && roomHostsMap[roomId].includes(userId)) {
const randomElement =
roomParticipantsMap[roomId][Math.floor(Math.random() * roomParticipantsMap[roomId].length)];
roomHostsMap[roomId] = randomElement;
}
}
}
});
}
);
});
/**
* Makes the app server listen to requests to the given port
*
* @method
* @param {number} [port] The port number to listen to requests from
* @event server#listen
*/
server.listen(process.env.PORT || 3030);
/**
* Makes the peer server listen to requests to the given port
*
* @method
* @param {number} [port] The port number to listen to requests from
* @event peerServer#listen
*/
peerServer.listen(process.env.PEER_PORT || 3001);
/* Needed for testing purposes */
module.exports = { app, io };