├── .gitignore ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── config └── index.js ├── controller ├── common.js ├── index.js └── room.js ├── data └── rooms.js ├── index.js ├── package.json ├── public ├── css │ └── main.css ├── images │ ├── apprtc-128.png │ ├── apprtc-16.png │ ├── apprtc-22.png │ ├── apprtc-32.png │ ├── apprtc-48.png │ └── webrtc-icon-192x192.png └── js │ ├── apprtc.debug.js │ └── loopback.js ├── route └── index.js └── view └── index_template.html /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 MidEnd Project 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node index.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-apprtc 2 | Nodejs based AppRTC server 3 | 4 | ## About 5 | node-apprtc is a port of AppRTC from the Google WebRTC Demo to run entirely in the nodejs environment 6 | 7 | ## Demo 8 | See the demo [here](https://demo-node-apprtc.herokuapp.com) 9 | 10 | ## Setup 11 | ``` 12 | $ git clone https://github.com/MidEndProject/node-apprtc 13 | $ cd node-apprtc 14 | $ npm install 15 | ``` 16 | 17 | ## Run node-apprtc 18 | ``` 19 | $ node index.js 20 | ``` 21 | 22 | Open your browser and navigate to http://localhost:4567 23 | 24 | ##To Do 25 | - [ ] Implementing memcached or redis options 26 | - [ ] Adding built-in websocket server 27 | 28 | ## License 29 | MIT 30 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-apprtc", 3 | "description": "Nodejs based AppRTC server", 4 | "repository": "https://github.com/MidEndProject/node-apprtc", 5 | "keywords": ["node", "webrtc", "apprtc"], 6 | "image": "heroku/nodejs" 7 | } 8 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | constant: { 3 | LOOPBACK_CLIENT_ID: 'LOOPBACK_CLIENT_ID', 4 | TURN_BASE_URL: 'https://demo-node-apprtc.herokuapp.com', 5 | TURN_URL_TEMPLATE: '%s/turn', 6 | WSS_HOST_PORT_PAIRS: ['node-apprtc-ws.herokuapp.com'], 7 | RESPONSE_UNKNOWN_ROOM: 'UNKNOWN_ROOM', 8 | RESPONSE_UNKNOWN_CLIENT: 'UNKNOWN_CLIENT', 9 | RESPONSE_ROOM_FULL: 'FULL', 10 | RESPONSE_DUPLICATE_CLIENT: 'DUPLICATE_CLIENT', 11 | }, 12 | server: { 13 | host: '127.0.0.1', 14 | port: process.env.PORT || 4567 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /controller/common.js: -------------------------------------------------------------------------------- 1 | var Url = require('url'); 2 | var Util = require('util'); 3 | var Querystring = require('querystring'); 4 | var Config = require('../config'); 5 | 6 | var getHdDefault = function (userAgent) { 7 | if (typeof userAgent !== 'undefined') { 8 | if (userAgent.indexOf('Android') > -1 || userAgent.indexOf('Chrome') == -1) { 9 | return false; 10 | } 11 | } 12 | 13 | return true; 14 | }; 15 | 16 | var makePcConfig = function (iceTransports) { 17 | var pcConfig = { 18 | iceServers: [], 19 | bundlePolicy: 'max-bundle', 20 | rtcpMuxPolicy: 'require' 21 | }; 22 | 23 | if (iceTransports) { 24 | pcConfig.iceTransports = iceTransports; 25 | } 26 | 27 | return pcConfig; 28 | }; 29 | 30 | var maybeAddConstraint = function (constraints, param, constraint) { 31 | var object = {}; 32 | 33 | if (param && param.toLowerCase() == 'true') { 34 | object[constraint] = true; 35 | constraints['optional'].push(object); 36 | } else if (param && param.toLowerCase() == 'false') { 37 | object[constraint] = false; 38 | constraints['optional'].push(object); 39 | } 40 | 41 | return constraints; 42 | }; 43 | 44 | var makePcConstraints = function (dtls, dscp, ipv6) { 45 | var constraints = { optional: [] }; 46 | maybeAddConstraint(constraints, dtls, 'DtlsSrtpKeyAgreement'); 47 | maybeAddConstraint(constraints, dscp, 'googDscp'); 48 | maybeAddConstraint(constraints, ipv6, 'googIPv6'); 49 | 50 | return constraints; 51 | }; 52 | 53 | function addMediaTrackConstraint(trackConstraints, constraintString) { 54 | var tokens = constraintString.split(':'); 55 | var mandatory = true; 56 | 57 | if (tokens.length == 2) { 58 | mandatory = (tokens[0] == 'mandatory'); 59 | } else { 60 | mandatory = !tokens[0].indexOf('goog') == 0; 61 | } 62 | 63 | tokens = tokens[tokens.length-1].split('='); 64 | 65 | if (tokens.length == 2) { 66 | if (mandatory) { 67 | trackConstraints.mandatory[tokens[0]] = tokens[1]; 68 | } else { 69 | var object = {}; 70 | object[tokens[0]] = tokens[1]; 71 | trackConstraints.optional.push(object); 72 | } 73 | } else { 74 | console.error('Ignoring malformed constraint: ' + constraintString); 75 | } 76 | }; 77 | 78 | var makeMediaTrackConstraints = function (constraintsString) { 79 | var trackConstraints; 80 | 81 | if (!constraintsString || constraintsString.toLowerCase() == 'true') { 82 | trackConstraints = true; 83 | } else if (constraintsString.toLowerCase() == 'false') { 84 | trackConstraints = false; 85 | } else { 86 | trackConstraints = { mandatory: {}, optional: [] }; 87 | var constraintsArray = constraintsString.split(','); 88 | 89 | for (var i in constraintsArray) { 90 | var constraintString = constraintsArray[i]; 91 | addMediaTrackConstraint(trackConstraints, constraintString); 92 | } 93 | } 94 | 95 | return trackConstraints; 96 | }; 97 | 98 | var makeMediaStreamConstraints = function (audio, video, firefoxFakeDevice) { 99 | var streamConstraints = { 100 | audio: makeMediaTrackConstraints(audio), 101 | video: makeMediaTrackConstraints(video) 102 | }; 103 | 104 | if (firefoxFakeDevice) streamConstraints.fake = true; 105 | 106 | console.log('Applying media constraints: ' + JSON.stringify(streamConstraints)) 107 | 108 | return streamConstraints; 109 | }; 110 | 111 | var getWSSParameters = function (request) { 112 | var wssHostPortPair = request.params.wshpp; 113 | var wssTLS = request.query.wstls; 114 | 115 | if (!wssHostPortPair) { 116 | wssHostPortPair = Config.constant.WSS_HOST_PORT_PAIRS[0]; 117 | } 118 | 119 | if (wssTLS && wssTLS == 'false') { 120 | return { 121 | wssUrl: 'ws://' + wssHostPortPair + '/ws', 122 | wssPostUrl: 'http://' + wssHostPortPair 123 | } 124 | } else { 125 | return { 126 | wssUrl: 'wss://' + wssHostPortPair + '/ws', 127 | wssPostUrl: 'https://' + wssHostPortPair 128 | } 129 | } 130 | }; 131 | 132 | var getVersionInfo = function () { 133 | return null; 134 | }; 135 | 136 | var generateRandom = function (length) { 137 | var word = ''; 138 | 139 | for (var i = 0; i < length; i++) { 140 | word += Math.floor((Math.random() * 10)); 141 | } 142 | 143 | return word; 144 | }; 145 | 146 | exports.getRoomParameters = function (request, roomId, clientId, isInitiator) { 147 | var errorMessages = []; 148 | var warningMessages = []; 149 | 150 | var userAgent = request.headers['user-agent']; 151 | var responseType = request.params.t; 152 | var iceTransports = request.params.it; 153 | var turnTransports = request.params.tt; 154 | var turnBaseUrl = request.params.ts || Config.constant.TURN_BASE_URL; 155 | 156 | var audio = request.params.audio; 157 | var video = request.params.video; 158 | var firefoxFakeDevice = request.params.firefox_fake_device; 159 | var hd = request.params.hd ? request.params.hd.toLowerCase() : null; 160 | 161 | if (video && hd) { 162 | var message = 'The "hd" parameter has overridden video=' + video; 163 | errorMessages.push(message); 164 | console.log(message); 165 | } 166 | 167 | if (hd == 'true') { 168 | video = 'mandatory:minWidth=1280,mandatory:minHeight=720'; 169 | } else if (!video && !hd && getHdDefault(userAgent)) { 170 | video = 'optional:minWidth=1280,optional:minHeight=720'; 171 | } 172 | 173 | if (request.params.minre || request.params.maxre) { 174 | var message = 'The "minre" and "maxre" parameters are no longer supported. Use "video" instead.'; 175 | errorMessages.push(message); 176 | console.error(message); 177 | } 178 | 179 | var dtls = request.params.dtls; 180 | var dscp = request.params.dscp; 181 | var ipv6 = request.params.ipv6; 182 | 183 | var debug = request.params.debug; 184 | 185 | if (debug == 'loopback') { 186 | var includeLoopbackJS = ''; 187 | dtls = 'false'; 188 | } else { 189 | var includeLoopbackJS = ''; 190 | } 191 | 192 | var username = clientId ? clientId : generateRandom(9); 193 | var turnUrl = turnBaseUrl.length > 0 ? Util.format(Config.constant.TURN_URL_TEMPLATE, turnBaseUrl) : null; 194 | 195 | var pcConfig = makePcConfig(iceTransports); 196 | var pcConstraints = makePcConstraints(dtls, dscp, ipv6); 197 | var offerOptions = {}; 198 | var mediaConstraints = makeMediaStreamConstraints(audio, video, firefoxFakeDevice); 199 | 200 | var wssParams = getWSSParameters(request); 201 | var wssUrl = wssParams.wssUrl; 202 | var wssPostUrl = wssParams.wssPostUrl; 203 | 204 | var bypassJoinConfirmation = false; 205 | 206 | var params = { 207 | 'error_messages': errorMessages, 208 | 'warning_messages': warningMessages, 209 | 'is_loopback' : JSON.stringify(debug == 'loopback'), 210 | 'pc_config': JSON.stringify(pcConfig), 211 | 'pc_constraints': JSON.stringify(pcConstraints), 212 | 'offer_options': JSON.stringify(offerOptions), 213 | 'media_constraints': JSON.stringify(mediaConstraints), 214 | 'turn_url': turnUrl, 215 | 'turn_transports': turnTransports, 216 | 'include_loopback_js' : includeLoopbackJS, 217 | 'wss_url': wssUrl, 218 | 'wss_post_url': wssPostUrl, 219 | 'bypass_join_confirmation': JSON.stringify(bypassJoinConfirmation), 220 | 'version_info': JSON.stringify(getVersionInfo()) 221 | }; 222 | 223 | var protocol = request.headers['x-forwarded-proto']; 224 | 225 | if (request.headers['origin']) { 226 | protocol = protocol || Url.parse(request.headers['origin']).protocol || 'http:'; 227 | } 228 | 229 | if (roomId) { 230 | params['room_id'] = roomId; 231 | params['room_link'] = protocol + '//' + request.headers['host'] + '/r/' + roomId; 232 | } 233 | 234 | if (clientId) { 235 | params['client_id'] = clientId; 236 | } 237 | 238 | if (typeof isInitiator === 'boolean') { 239 | params['is_initiator'] = JSON.stringify(isInitiator); 240 | } 241 | 242 | return params; 243 | }; 244 | 245 | exports.getCacheKeyForRoom = function (host, roomId) { 246 | return host + '/' + roomId; 247 | }; 248 | 249 | exports.generateRandom = generateRandom; 250 | exports.getWSSParameters = getWSSParameters; 251 | -------------------------------------------------------------------------------- /controller/index.js: -------------------------------------------------------------------------------- 1 | var Https = require('https'); 2 | var Common = require('./common'); 3 | 4 | exports.main = { 5 | handler: function (request, reply) { 6 | var params = Common.getRoomParameters(request, null, null, null); 7 | 8 | reply.view('index_template', params); 9 | } 10 | }; 11 | 12 | exports.turn = { 13 | handler: function (request, reply) { 14 | var getOptions = { 15 | host: 'instant.io', 16 | port: 443, 17 | path: '/__rtcConfig__', 18 | method: 'GET' 19 | }; 20 | Https.get(getOptions, function (result) { 21 | console.log(result.statusCode == 200); 22 | 23 | var body = ''; 24 | 25 | result.on('data', function (data) { 26 | body += data; 27 | }); 28 | result.on('end', function () { 29 | reply(body); 30 | }); 31 | }).on('error', function (e) { 32 | reply({ 33 | "username":"webrtc", 34 | "password":"webrtc", 35 | "uris":[ 36 | "stun:stun.l.google.com:19302", 37 | "stun:stun1.l.google.com:19302", 38 | "stun:stun2.l.google.com:19302", 39 | "stun:stun3.l.google.com:19302", 40 | "stun:stun4.l.google.com:19302", 41 | "stun:stun.services.mozilla.com", 42 | "turn:turn.anyfirewall.com:443?transport=tcp" 43 | ] 44 | }); 45 | }); 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /controller/room.js: -------------------------------------------------------------------------------- 1 | var Url = require('url'); 2 | var Http = require('http'); 3 | var Https = require('https'); 4 | var Config = require('../config'); 5 | var Common = require('./common'); 6 | var Rooms = require('../data/rooms'); 7 | 8 | var rooms = new Rooms(); 9 | 10 | var addClientToRoom = function (request, roomId, clientId, isLoopback, callback) { 11 | var key = Common.getCacheKeyForRoom(request.headers.host, roomId); 12 | 13 | rooms.createIfNotExist(key, function (error, room) { 14 | var error = null; 15 | var isInitiator = false; 16 | var messages = []; 17 | var occupancy = room.getOccupancy(); 18 | 19 | if (occupancy >= 2) { 20 | error = Config.constant.RESPONSE_ROOM_FULL; 21 | callback(error, { 22 | messages: messages, 23 | room_state: room.toString() 24 | }); 25 | 26 | return; 27 | } else if (room.hasClient(clientId)) { 28 | error = Config.constant.RESPONSE_DUPLICATE_CLIENT; 29 | callback(error, { 30 | messages: messages, 31 | room_state: room.toString() 32 | }); 33 | 34 | return; 35 | } else { 36 | room.join(clientId, function (error, client, otherClient) { 37 | if (error) { 38 | callback(error, { 39 | messages: messages, 40 | room_state: null 41 | }); 42 | 43 | return; 44 | } 45 | 46 | if (client.isInitiator && isLoopback) { 47 | room.join(Config.constant.LOOPBACK_CLIENT_ID); 48 | } 49 | 50 | var messages = otherClient ? otherClient.messages : messages; 51 | 52 | if (otherClient) otherClient.clearMessages(); 53 | 54 | console.log('Added client ' + clientId + ' in room ' + roomId); 55 | callback(null, { 56 | is_initiator: client.isInitiator, 57 | messages: messages, 58 | room_state: room.toString() 59 | }); 60 | }); 61 | } 62 | }); 63 | }; 64 | 65 | var saveMessageFromClient = function (host, roomId, clientId, message, callback) { 66 | var text = message; 67 | var key = Common.getCacheKeyForRoom(host, roomId); 68 | 69 | rooms.get(key, function (error, room) { 70 | if (!room) { 71 | console.warn('Unknown room: ' + roomId); 72 | callback({ 73 | error: Config.constant.RESPONSE_UNKNOWN_ROOM 74 | }); 75 | 76 | return; 77 | } else if (!room.hasClient(clientId)) { 78 | console.warn('Unknown client: ' + clientId); 79 | callback({ 80 | error: Config.constant.RESPONSE_UNKNOWN_CLIENT 81 | }); 82 | 83 | return; 84 | } else if (room.getOccupancy() > 1) { 85 | callback(null, false); 86 | } else { 87 | var client = room.getClient(clientId); 88 | client.addMessage(text); 89 | console.log('Saved message for client ' + clientId + ':' + client.toString() + ' in room ' + roomId); 90 | callback(null, true); 91 | 92 | return; 93 | } 94 | }); 95 | }; 96 | 97 | var sendMessageToCollider = function (request, roomId, clientId, message, callback) { 98 | console.log('Forwarding message to collider from room ' + roomId + ' client ' + clientId); 99 | var wssParams = Common.getWSSParameters(request); 100 | var wssHost = Url.parse(wssParams.wssPostUrl); 101 | var postOptions = { 102 | host: wssHost.hostname, 103 | port: wssHost.port, 104 | path: '/' + roomId + '/' + clientId, 105 | method: 'POST' 106 | }; 107 | var postRequest = Https.request(postOptions, function (result) { 108 | if (result.statusCode == 200) { 109 | callback(null, { 110 | result: 'SUCCESS' 111 | }); 112 | 113 | return; 114 | } else { 115 | console.error('Failed to send message to collider: ' + result.statusCode); 116 | callback(result.statusCode); 117 | 118 | return; 119 | } 120 | }); 121 | 122 | postRequest.write(message); 123 | postRequest.end(); 124 | }; 125 | 126 | var removeClientFromRoom = function (host, roomId, clientId, callback) { 127 | var key = Common.getCacheKeyForRoom(host, roomId); 128 | 129 | rooms.get(key, function (error, room) { 130 | if (!room) { 131 | console.warn('remove_client_from_room: Unknown room: ' + roomId); 132 | callback(Config.constant.RESPONSE_UNKNOWN_ROOM, { 133 | room_state: null 134 | }); 135 | 136 | return; 137 | } else if (!room.hasClient(clientId)) { 138 | console.warn('remove_client_from_room: Unknown client: ' + clientId); 139 | callback(Config.constant.RESPONSE_UNKNOWN_CLIENT, { 140 | room_state: null 141 | }); 142 | 143 | return; 144 | } else { 145 | room.removeClient(clientId, function (error, isRemoved, otherClient) { 146 | if (room.hasClient(Config.constant.LOOPBACK_CLIENT_ID)) { 147 | room.removeClient(Config.constant.LOOPBACK_CLIENT_ID, function (error, isRemoved) { 148 | return; 149 | }); 150 | } else { 151 | if (otherClient) { 152 | otherClient.isInitiator = true; 153 | } 154 | } 155 | 156 | callback(null, { 157 | room_state: room.toString() 158 | }); 159 | }); 160 | } 161 | }); 162 | }; 163 | 164 | exports.main = { 165 | handler: function (request, reply) { 166 | var roomId = request.params.roomId; 167 | var key = Common.getCacheKeyForRoom(request.headers['host'], roomId); 168 | 169 | rooms.get(key, function (error, room) { 170 | if (room) { 171 | console.log('Room ' + roomId + ' has state ' + room.toString()); 172 | 173 | if (room.getOccupancy() >= 2) { 174 | console.log('Room ' + roomId + ' is full'); 175 | reply.view('full_template', {}); 176 | 177 | return; 178 | } 179 | } 180 | 181 | var params = Common.getRoomParameters(request, roomId, null, null); 182 | reply.view('index_template', params); 183 | }); 184 | } 185 | }; 186 | 187 | exports.join = { 188 | handler: function (request, reply) { 189 | var roomId = request.params.roomId; 190 | var clientId = Common.generateRandom(8); 191 | var isLoopback = request.params.debug == 'loopback'; 192 | var response = null; 193 | 194 | addClientToRoom(request, roomId, clientId, isLoopback, function(error, result) { 195 | if (error) { 196 | console.error('Error adding client to room: ' + error + ', room_state=' + result.room_state); 197 | response = { 198 | result: error, 199 | params: result 200 | }; 201 | reply(JSON.stringify(response)); 202 | 203 | return; 204 | } 205 | 206 | var params = Common.getRoomParameters(request, roomId, clientId, result.is_initiator); 207 | params.messages = result.messages; 208 | response = { 209 | result: 'SUCCESS', 210 | params: params 211 | }; 212 | reply(JSON.stringify(response)); 213 | 214 | console.log('User ' + clientId + ' joined room ' + roomId); 215 | console.log('Room ' + roomId + ' has state ' + result.room_state); 216 | }); 217 | } 218 | }; 219 | 220 | exports.message = { 221 | handler: function (request, reply) { 222 | var userAgent = request.headers['user-agent']; 223 | var roomId = request.params.roomId; 224 | var clientId = request.params.clientId; 225 | var message = null; 226 | var response = null; 227 | 228 | console.log('User ' + clientId + ' - ' + userAgent); 229 | if (userAgent.indexOf('CFNetwork') > -1) { 230 | var malformed_sdp = request.payload; 231 | var keys = Object.keys(malformed_sdp); 232 | var key = keys[0]; 233 | var value = malformed_sdp[key]; 234 | var sdp = key + '=' + value; 235 | message = sdp; 236 | 237 | if (message.slice(-1) == '=') { 238 | message = message.slice(0, -1); 239 | } 240 | } else { 241 | message = request.payload; 242 | } 243 | 244 | saveMessageFromClient(request.headers['host'], roomId, clientId, message, function (error, result) { 245 | if (error) { 246 | response = { 247 | result: error 248 | }; 249 | reply(JSON.stringify(response)); 250 | 251 | return; 252 | } 253 | 254 | if (result) { 255 | response = { 256 | result: 'SUCCESS' 257 | }; 258 | reply(JSON.stringify(response)); 259 | } else { 260 | sendMessageToCollider(request, roomId, clientId, message, function (error, result) { 261 | if (error) { 262 | reply('').code(500); 263 | } 264 | 265 | if (result) { 266 | reply(JSON.stringify(result)); 267 | } 268 | }); 269 | } 270 | }); 271 | } 272 | }; 273 | 274 | exports.leave = { 275 | handler: function (request, reply) { 276 | var roomId = request.params.roomId; 277 | var clientId = request.params.clientId; 278 | 279 | removeClientFromRoom(request.headers['host'], roomId, clientId, function (error, result) { 280 | if (error) { 281 | console.log('Room ' + roomId + ' has state ' + result.room_state); 282 | } 283 | 284 | console.log('Room ' + roomId + ' has state ' + result.room_state); 285 | reply(''); 286 | }); 287 | } 288 | }; 289 | -------------------------------------------------------------------------------- /data/rooms.js: -------------------------------------------------------------------------------- 1 | Client = function (isInitiator) { 2 | var self = this; 3 | this.isInitiator = isInitiator; 4 | this.messages = []; 5 | 6 | this.addMessage = function (message) { 7 | self.messages.push(message); 8 | }; 9 | 10 | this.clearMessages = function () { 11 | self.messages = []; 12 | }; 13 | 14 | this.toString = function () { 15 | return '{ '+ self.isInitiator +', '+ self.messages.length +' }'; 16 | } 17 | } 18 | 19 | Room = function () { 20 | var self = this; 21 | var clientMap = {}; 22 | 23 | this.getOccupancy = function () { 24 | var keys = Object.keys(clientMap); 25 | return keys.length; 26 | }; 27 | 28 | this.hasClient = function (clientId) { 29 | return clientMap[clientId]; 30 | } 31 | 32 | this.join = function (clientId, callback) { 33 | var clientIds = Object.keys(clientMap); 34 | var otherClient = clientIds.length > 0 ? clientMap[clientIds[0]] : null; 35 | var isInitiator = otherClient == null; 36 | var client = new Client(isInitiator); 37 | clientMap[clientId] = client; 38 | 39 | if (callback) callback(null, client, otherClient); 40 | }; 41 | 42 | this.removeClient = function (clientId, callback) { 43 | delete clientMap[clientId]; 44 | var clientIds = Object.keys(clientMap); 45 | var otherClient = clientIds.length > 0 ? clientMap[clientIds[0]] : null; 46 | callback(null, true, otherClient); 47 | }; 48 | 49 | this.getClient = function (clientId) { 50 | return clientMap[clientId]; 51 | } 52 | 53 | this.toString = function () { 54 | return JSON.stringify(Object.keys(clientMap)); 55 | }; 56 | }; 57 | 58 | Rooms = function () { 59 | var self = this; 60 | var roomMap = {}; 61 | 62 | this.get = function (roomCacheKey, callback) { 63 | var room = roomMap[roomCacheKey]; 64 | callback(null, room); 65 | }; 66 | 67 | this.create = function (roomCacheKey, callback) { 68 | var room = new Room; 69 | roomMap[roomCacheKey] = room; 70 | callback(null, room); 71 | }; 72 | 73 | this.createIfNotExist = function (roomCacheKey, callback) { 74 | self.get(roomCacheKey, function (error, room) { 75 | if (!room) { 76 | self.create(roomCacheKey, callback); 77 | } else { 78 | callback(null, room); 79 | } 80 | }); 81 | } 82 | }; 83 | 84 | module.exports = Rooms; 85 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var Hapi = require('hapi'); 2 | var Route = require('./route'); 3 | var Config = require('./config'); 4 | 5 | var app = {}; 6 | app.config = Config; 7 | 8 | var server = new Hapi.Server(); 9 | 10 | server.connection({ routes: { cors: true }, port: app.config.server.port }); 11 | 12 | server.register(require('inert')); 13 | server.register(require('vision'), function (error) { 14 | if (error) { 15 | console.log('Failed to load vision.'); 16 | } 17 | }); 18 | 19 | server.route(Route.endpoints); 20 | 21 | server.views({ 22 | engines: { 23 | html: require('handlebars') 24 | }, 25 | relativeTo: __dirname, 26 | path: './view' 27 | }); 28 | 29 | server.start(function() { 30 | console.log('Server started at ' + server.info.uri + '.'); 31 | }); 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-apprtc", 3 | "version": "0.1.0", 4 | "description": "Nodejs based AppRTC server", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [ 10 | "webrtc", 11 | "apprtc" 12 | ], 13 | "author": "Fitra Aditya", 14 | "license": "MIT", 15 | "engines": { 16 | "node": "4.2.0" 17 | }, 18 | "dependencies": { 19 | "handlebars": "^4.0.5", 20 | "hapi": "^12.1.0", 21 | "inert": "^3.2.0", 22 | "vision": "^4.0.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/css/main.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014 The WebRTC project authors. All Rights Reserved. 3 | * 4 | * Use of this source code is governed by a BSD-style license 5 | * that can be found in the LICENSE file in the root of the source 6 | * tree. 7 | */ 8 | 9 | .hidden { 10 | display: none; 11 | } 12 | 13 | a { 14 | color: #4285F4; 15 | text-decoration: none; 16 | } 17 | 18 | a:hover { 19 | color: #3B78E7; 20 | text-decoration: underline; 21 | } 22 | 23 | #room-link a { 24 | white-space: nowrap; 25 | } 26 | 27 | body { 28 | background-color: #333; 29 | font-family: 'Roboto', 'Open Sans', 'Lucida Grande', sans-serif; 30 | height: 100%; 31 | margin: 0; 32 | padding: 0; 33 | width: 100%; 34 | color: #fff; 35 | } 36 | 37 | #remote-canvas { 38 | display: none; 39 | height: 100%; 40 | margin: 0 auto; 41 | width: 100%; 42 | } 43 | 44 | div.warning { 45 | background-color: #a80202; 46 | color: black; 47 | font-weight: 400; 48 | opacity: .9; 49 | } 50 | 51 | #container { 52 | height: 100%; 53 | position: absolute; 54 | } 55 | 56 | #info-div { 57 | z-index: 3; 58 | } 59 | 60 | #room-link { 61 | margin: 0 0 29px 0; 62 | } 63 | 64 | #status { 65 | z-index: 4; 66 | } 67 | 68 | #videos { 69 | font-size: 0; /* to fix whitespace/scrollbars problem */ 70 | height: 100%; 71 | pointer-events: none; 72 | position: absolute; 73 | transition: all 1s; 74 | width: 100%; 75 | } 76 | 77 | #videos.active { 78 | -moz-transform: rotateY(180deg); 79 | -ms-transform: rotateY(180deg); 80 | -o-transform: rotateY(180deg); 81 | -webkit-transform: rotateY(180deg); 82 | transform: rotateY(180deg); 83 | } 84 | 85 | footer > div { 86 | background-color: black; 87 | bottom: 0; 88 | color: white; 89 | display: none; 90 | font-size: .9em; 91 | font-weight: 300; 92 | line-height: 2em; 93 | max-height: 80%; 94 | opacity: 0; 95 | overflow-y: auto; 96 | padding: 10px; 97 | position: absolute; 98 | transition: opacity 1s; 99 | width: calc(100% - 20px); 100 | } 101 | 102 | footer > div.active { 103 | display: block; 104 | opacity: .8; 105 | } 106 | 107 | html { 108 | height: 100%; 109 | margin: 0; 110 | width: 100%; 111 | } 112 | 113 | label { 114 | margin: 0 10px 0 0; 115 | } 116 | 117 | #local-video { 118 | height: 100%; 119 | max-height: 100%; 120 | max-width: 100%; 121 | object-fit: cover; /* no letterboxing */ 122 | transition: opacity 1s; 123 | -moz-transform: scale(-1, 1); 124 | -ms-transform: scale(-1, 1); 125 | -o-transform: scale(-1, 1); 126 | -webkit-transform: scale(-1, 1); 127 | transform: scale(-1, 1); 128 | width: 100%; 129 | } 130 | 131 | #mini-video { 132 | border: 1px solid gray; 133 | bottom: 20px; 134 | left: 20px; 135 | /* video div is flipped horizontally when active*/ 136 | max-height: 17%; 137 | max-width: 17%; 138 | opacity: 0; 139 | position: absolute; 140 | transition: opacity 1s; 141 | } 142 | 143 | #mini-video.active { 144 | opacity: 1; 145 | z-index: 2; 146 | } 147 | 148 | #remote-video { 149 | display: block; 150 | height: 100%; 151 | max-height: 100%; 152 | max-width: 100%; 153 | object-fit: cover; /* no letterboxing */ 154 | opacity: 0; 155 | position: absolute; 156 | -moz-transform: rotateY(180deg); 157 | -ms-transform: rotateY(180deg); 158 | -o-transform: rotateY(180deg); 159 | -webkit-transform: rotateY(180deg); 160 | transform: rotateY(180deg); 161 | transition: opacity 1s; 162 | width: 100%; 163 | } 164 | 165 | #remote-video.active { 166 | opacity: 1; 167 | z-index: 1; 168 | } 169 | 170 | #confirm-join-div { 171 | z-index: 5; 172 | position: absolute; 173 | top: 80%; 174 | width: 100%; 175 | text-align: center; 176 | } 177 | 178 | #confirm-join-div div { 179 | margin-bottom: 10px; 180 | font-size: 1.5em; 181 | } 182 | 183 | /*////// room selection start ///////////////////*/ 184 | #recent-rooms-list { 185 | list-style-type: none; 186 | padding: 0 15px; 187 | } 188 | 189 | button { 190 | background-color: #4285F4; 191 | border: none; 192 | border-radius: 2px; 193 | color: white; 194 | font-size: 0.8em; 195 | margin: 0 5px 20px 5px; 196 | width: 8em; 197 | height: 2.75em; 198 | padding: 0.5em 0.7em 0.5em 0.7em; 199 | -webkit-box-shadow: 1px 1px 5px 0 rgba(0,0,0,.5); 200 | -moz-box-shadow: 1px 1px 5px 0 rgba(0,0,0,.5); 201 | box-shadow: 1px 1px 5px 0 rgba(0,0,0,.5); 202 | } 203 | 204 | button:active { 205 | background-color: #3367D6; 206 | } 207 | 208 | button:hover { 209 | background-color: #3B78E7; 210 | } 211 | 212 | button:focus { 213 | outline: none; 214 | -webkit-box-shadow: 0 10px 15px 0 rgba(0,0,0,.5); 215 | -moz-box-shadow: 0 10px 15px 0 rgba(0,0,0,.5); 216 | box-shadow: 0 10px 15px 0 rgba(0,0,0,.5); 217 | } 218 | 219 | button[disabled] { 220 | color: rgb(76, 76, 76); 221 | color: rgba(255, 255, 255, 0.3); 222 | background-color: rgb(30, 30, 30); 223 | background-color: rgba(255, 255, 255, 0.12); 224 | } 225 | 226 | input[type=text] { 227 | border: none; 228 | border-bottom: solid 1px #4c4c4f; 229 | font-size: 1em; 230 | background-color: transparent; 231 | color: #fff; 232 | padding:.4em 0; 233 | margin-right: 20px; 234 | width: 100%; 235 | display: block; 236 | } 237 | 238 | input[type="text"]:focus { 239 | border-bottom: solid 2px #4285F4; 240 | outline: none; 241 | } 242 | 243 | input[type="text"].invalid { 244 | border-bottom: solid 2px #F44336; 245 | } 246 | 247 | label.error-label { 248 | color: #F44336; 249 | font-size: .85em; 250 | font-weight: 200; 251 | margin: 0; 252 | } 253 | 254 | #room-id-input-div { 255 | margin: 15px; 256 | } 257 | 258 | #room-id-input-buttons { 259 | margin: 15px; 260 | } 261 | 262 | h1 { 263 | font-weight: 300; 264 | margin: 0 0 0.8em 0; 265 | padding: 0 0 0.2em 0; 266 | } 267 | 268 | div#room-selection { 269 | margin: 3em auto 0 auto; 270 | width: 25em; 271 | padding: 1em 1.5em 1.3em 1.5em; 272 | } 273 | 274 | p { 275 | color: #eee; 276 | font-weight: 300; 277 | line-height: 1.6em; 278 | } 279 | 280 | /*////// room selection end /////////////////////*/ 281 | 282 | /*////// icons CSS start ////////////////////////*/ 283 | 284 | #icons { 285 | bottom: 77px; 286 | left: 6vw; 287 | position: absolute; 288 | } 289 | 290 | circle { 291 | fill: #666; 292 | fill-opacity: 0.6; 293 | } 294 | 295 | svg.on circle { 296 | fill-opacity: 0; 297 | } 298 | 299 | /* on icons are hidden by default */ 300 | path.on { 301 | display: none; 302 | } 303 | 304 | /* off icons are displayed by default */ 305 | path.off { 306 | display: block; 307 | } 308 | 309 | /* on icons are displayed when parent svg has class 'on' */ 310 | svg.on path.on { 311 | display: block; 312 | } 313 | 314 | /* off icons are hidden when parent svg has class 'on' */ 315 | svg.on path.off { 316 | display: none; 317 | } 318 | 319 | svg { 320 | box-shadow: 2px 2px 24px #444; 321 | border-radius: 48px; 322 | display: block; 323 | margin: 0 0 3vh 0; 324 | transform: translateX(calc(-6vw - 96px)); 325 | transition: all .1s; 326 | transition-timing-function: ease-in-out; 327 | } 328 | 329 | svg:hover { 330 | box-shadow: 4px 4px 48px #666; 331 | } 332 | 333 | #icons.active svg { 334 | transform: translateX(0); 335 | } 336 | 337 | #mute-audio { 338 | transition: 40ms; 339 | } 340 | 341 | #mute-audio:hover, 342 | #mute-audio.on { 343 | background: #407cf7; 344 | } 345 | 346 | #mute-audio:hover circle { 347 | fill: #407cf7; 348 | } 349 | 350 | #mute-video { 351 | transition: 120ms; 352 | } 353 | 354 | #mute-video:hover, 355 | #mute-video.on { 356 | background: #407cf7; 357 | } 358 | 359 | #mute-video:hover circle { 360 | fill: #407cf7; 361 | } 362 | 363 | #switch-video { 364 | transition: 200ms; 365 | } 366 | 367 | #switch-video:hover { 368 | background: #407cf7; 369 | } 370 | 371 | #switch-video:hover circle { 372 | fill: #407cf7; 373 | } 374 | 375 | #fullscreen { 376 | transition: 280ms; 377 | } 378 | 379 | #fullscreen:hover, 380 | #fullscreen.on { 381 | background: #407cf7; 382 | } 383 | 384 | #fullscreen:hover circle { 385 | fill: #407cf7; 386 | } 387 | 388 | #hangup { 389 | transition: 360ms; 390 | } 391 | 392 | #hangup:hover { 393 | background: #dd2c00; 394 | } 395 | #hangup:hover circle { 396 | fill: #dd2c00; 397 | } 398 | 399 | /*////// icons CSS end /////////////////////////*/ 400 | 401 | -------------------------------------------------------------------------------- /public/images/apprtc-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MidEndProject/node-apprtc/2690212a76caef7f78a169078a4d0e02e0f27a80/public/images/apprtc-128.png -------------------------------------------------------------------------------- /public/images/apprtc-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MidEndProject/node-apprtc/2690212a76caef7f78a169078a4d0e02e0f27a80/public/images/apprtc-16.png -------------------------------------------------------------------------------- /public/images/apprtc-22.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MidEndProject/node-apprtc/2690212a76caef7f78a169078a4d0e02e0f27a80/public/images/apprtc-22.png -------------------------------------------------------------------------------- /public/images/apprtc-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MidEndProject/node-apprtc/2690212a76caef7f78a169078a4d0e02e0f27a80/public/images/apprtc-32.png -------------------------------------------------------------------------------- /public/images/apprtc-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MidEndProject/node-apprtc/2690212a76caef7f78a169078a4d0e02e0f27a80/public/images/apprtc-48.png -------------------------------------------------------------------------------- /public/images/webrtc-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MidEndProject/node-apprtc/2690212a76caef7f78a169078a4d0e02e0f27a80/public/images/webrtc-icon-192x192.png -------------------------------------------------------------------------------- /public/js/loopback.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014 The WebRTC project authors. All Rights Reserved. 3 | * 4 | * Use of this source code is governed by a BSD-style license 5 | * that can be found in the LICENSE file in the root of the source 6 | * tree. 7 | */ 8 | 9 | /* More information about these options at jshint.com/docs/options */ 10 | 11 | /* exported setupLoopback */ 12 | 13 | 'use strict'; 14 | 15 | // We handle the loopback case by making a second connection to the WSS so that 16 | // we receive the same messages that we send out. When receiving an offer we 17 | // convert that offer into an answer message. When receiving candidates we 18 | // echo back the candidate. Answer is ignored because we should never receive 19 | // one while in loopback. Bye is ignored because there is no work to do. 20 | var loopbackWebSocket = null; 21 | var LOOPBACK_CLIENT_ID = 'loopback_client_id'; 22 | function setupLoopback(wssUrl, roomId) { 23 | if (loopbackWebSocket) { 24 | loopbackWebSocket.close(); 25 | } 26 | trace('Setting up loopback WebSocket.'); 27 | // TODO(tkchin): merge duplicate code once SignalingChannel abstraction 28 | // exists. 29 | loopbackWebSocket = new WebSocket(wssUrl); 30 | 31 | var sendLoopbackMessage = function(message) { 32 | var msgString = JSON.stringify({ 33 | cmd: 'send', 34 | msg: JSON.stringify(message) 35 | }); 36 | loopbackWebSocket.send(msgString); 37 | }; 38 | 39 | loopbackWebSocket.onopen = function() { 40 | trace('Loopback WebSocket opened.'); 41 | var registerMessage = { 42 | cmd: 'register', 43 | roomid: roomId, 44 | clientid: LOOPBACK_CLIENT_ID 45 | }; 46 | loopbackWebSocket.send(JSON.stringify(registerMessage)); 47 | }; 48 | 49 | loopbackWebSocket.onmessage = function(event) { 50 | var wssMessage; 51 | var message; 52 | try { 53 | wssMessage = JSON.parse(event.data); 54 | message = JSON.parse(wssMessage.msg); 55 | } catch (e) { 56 | trace('Error parsing JSON: ' + event.data); 57 | return; 58 | } 59 | if (wssMessage.error) { 60 | trace('WSS error: ' + wssMessage.error); 61 | return; 62 | } 63 | if (message.type === 'offer') { 64 | var loopbackAnswer = wssMessage.msg; 65 | loopbackAnswer = loopbackAnswer.replace('"offer"', '"answer"'); 66 | loopbackAnswer = 67 | loopbackAnswer.replace('a=ice-options:google-ice\\r\\n', ''); 68 | sendLoopbackMessage(JSON.parse(loopbackAnswer)); 69 | } else if (message.type === 'candidate') { 70 | sendLoopbackMessage(message); 71 | } 72 | }; 73 | 74 | loopbackWebSocket.onclose = function(event) { 75 | trace('Loopback WebSocket closed with code:' + event.code + ' reason:' + 76 | event.reason); 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /route/index.js: -------------------------------------------------------------------------------- 1 | var Index = require('../controller'); 2 | var Room = require('../controller/room'); 3 | 4 | exports.endpoints = [ 5 | { method: 'GET', path: '/', config: Index.main }, 6 | { method: 'POST', path: '/join/{roomId}', config: Room.join }, 7 | { method: 'POST', path: '/message/{roomId}/{clientId}', config: Room.message }, 8 | { method: 'GET', path: '/r/{roomId}', config: Room.main }, 9 | { method: 'POST', path: '/leave/{roomId}/{clientId}', config: Room.leave }, 10 | { method: 'POST', path: '/turn', config: Index.turn }, 11 | { method: 'GET', path: '/{param*}', handler: { 12 | directory: { 13 | path: 'public', 14 | listing: false 15 | } 16 | } 17 | } 18 | ]; 19 | -------------------------------------------------------------------------------- /view/index_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Node AppRTC 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 |
28 | 46 | 50 | 58 | 88 |
89 | {{include_loopback_js}} 90 | 91 | 143 | 144 | 145 | 146 | --------------------------------------------------------------------------------