diff --git a/.travis.yml b/.travis.yml index c764b8d..cb0dc3c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,3 @@ language: node_js node_js: - - 8.11.3 + - 10.15.3 diff --git a/Dockerfile b/Dockerfile index 98519ca..811d7bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,10 +2,9 @@ FROM node:alpine RUN mkdir /peer-server WORKDIR /peer-server COPY package.json . -COPY bin ./bin -COPY lib ./lib +COPY src ./src COPY app.json . RUN npm install EXPOSE 9000 -ENTRYPOINT ["node", "bin/peerjs"] +ENTRYPOINT ["node", "index.js"] CMD [ "--port", "9000" ] diff --git a/bin/peerjs b/bin/peerjs deleted file mode 100755 index a78c47a..0000000 --- a/bin/peerjs +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env node - -var path = require('path') - , pkg = require('../package.json') - , fs = require('fs') - , version = pkg.version - , PeerServer = require('../lib').PeerServer - , util = require('../lib/util') - , opts = require('optimist') - .usage('Usage: $0') - .options({ - debug: { - demand: false, - alias: 'd', - description: 'debug', - default: false - }, - timeout: { - demand: false, - alias: 't', - description: 'timeout (milliseconds)', - default: 5000 - }, - ip_limit: { - demand: false, - alias: 'i', - description: 'IP limit', - default: 5000 - }, - concurrent_limit: { - demand: false, - alias: 'c', - description: 'concurrent limit', - default: 5000 - }, - key: { - demand: false, - alias: 'k', - description: 'connection key', - default: 'peerjs' - }, - sslkey: { - demand: false, - description: 'path to SSL key' - }, - sslcert: { - demand: false, - description: 'path to SSL certificate' - }, - port: { - demand: true, - alias: 'p', - description: 'port' - }, - path: { - demand: false, - description: 'custom path', - default: '/' - }, - allow_discovery: { - demand: false, - description: 'allow discovery of peers' - } - }) - .boolean('allow_discovery') - .argv; - -process.on('uncaughtException', function(e) { - console.error('Error: ' + e); -}); - -if (opts.sslkey || opts.sslcert) { - if (opts.sslkey && opts.sslcert) { - opts.ssl = { - key: fs.readFileSync(path.resolve(opts.sslkey)), - cert: fs.readFileSync(path.resolve(opts.sslcert)) - } - - delete opts.sslkey; - delete opts.sslcert; - } else { - util.prettyError('Warning: PeerServer will not run because either ' + - 'the key or the certificate has not been provided.'); - process.exit(1); - } -} - - -var userPath = opts.path; -var server = PeerServer(opts, function(server) { - var host = server.address().address; - var port = server.address().port; - - console.log( - 'Started PeerServer on %s, port: %s, path: %s (v. %s)', - host, port, userPath || '/', version - ); -}); diff --git a/config/index.js b/config/index.js new file mode 100644 index 0000000..d669d77 --- /dev/null +++ b/config/index.js @@ -0,0 +1,5 @@ +const config = require('./schema'); + +config.validate({ allowed: 'strict' }); + +module.exports = config; diff --git a/config/schema.js b/config/schema.js new file mode 100644 index 0000000..b4eba1f --- /dev/null +++ b/config/schema.js @@ -0,0 +1,74 @@ +const convict = require('convict'); + +module.exports = convict({ + debug: { + doc: 'Enable debug mode', + format: Boolean, + default: false + }, + env: { + doc: 'The application environment.', + format: ['prod', 'dev', 'test'], + default: 'dev', + env: 'NODE_ENV' + }, + port: { + doc: 'The port to bind.', + format: 'port', + default: 9000, + env: 'PORT', + arg: 'port' + }, + timeout: { + doc: '', + format: 'duration', + default: 5000 + }, + key: { + doc: 'The key to check incoming clients', + format: String, + default: 'peerjs' + }, + path: { + doc: '', + format: String, + default: '/' + }, + ip_limit: { + doc: 'Max connections per ip', + format: 'duration', + default: 100 + }, + concurrent_limit: { + doc: 'Max connections', + format: 'duration', + default: 5000 + }, + allow_discovery: { + doc: 'Allow discovery of peers', + format: Boolean, + default: false + }, + proxied: { + doc: 'Set true if server running behind proxy', + format: Boolean, + default: false + }, + cleanup_out_msgs: { + doc: '', + format: 'duration', + default: 5000 + }, + ssl: { + key_path: { + doc: 'The path to the private key file', + format: String, + default: '' + }, + cert_path: { + doc: 'The path to the cert file', + format: String, + default: '' + } + } +}); diff --git a/lib/index.js b/lib/index.js deleted file mode 100644 index 5c928fb..0000000 --- a/lib/index.js +++ /dev/null @@ -1,99 +0,0 @@ -var express = require('express'); -var proto = require('./server'); -var util = require('./util'); -var http = require('http'); -var https = require('https'); - -function ExpressPeerServer(server, options) { - var app = express(); - - util.extend(app, proto); - - options = app._options = util.extend({ - debug: false, - timeout: 5000, - key: 'peerjs', - ip_limit: 5000, - concurrent_limit: 5000, - allow_discovery: false, - proxied: false - }, options); - - // Connected clients - app._clients = {}; - - // Messages waiting for another peer. - app._outstanding = {}; - - // Mark concurrent users per ip - app._ips = {}; - - if (options.proxied) { - app.set('trust proxy', options.proxied); - } - - app.on('mount', function() { - if (!server) { - throw new Error('Server is not passed to constructor - '+ - 'can\'t start PeerServer'); - } - - // Initialize HTTP routes. This is only used for the first few milliseconds - // before a socket is opened for a Peer. - app._initializeHTTP(); - app._setCleanupIntervals(); - app._initializeWSS(server); - }); - - return app; -} - -function PeerServer(options, callback) { - var app = express(); - - options = options || {}; - var path = options.path || '/'; - var port = options.port || 80; - - delete options.path; - - if (path[0] !== '/') { - path = '/' + path; - } - - if (path[path.length - 1] !== '/') { - path += '/'; - } - - var server; - if (options.ssl) { - if (options.ssl.certificate) { - // Preserve compatibility with 0.2.7 API - options.ssl.cert = options.ssl.certificate; - delete options.ssl.certificate; - } - - server = https.createServer(options.ssl, app); - delete options.ssl; - } else { - server = http.createServer(app); - } - - var peerjs = ExpressPeerServer(server, options); - app.use(path, peerjs); - - if (callback) { - server.listen(port, function() { - callback(server); - }); - } else { - server.listen(port); - } - - return peerjs; -} - -exports = module.exports = { - ExpressPeerServer: ExpressPeerServer, - PeerServer: PeerServer -}; diff --git a/lib/server.js b/lib/server.js deleted file mode 100644 index b0e75c3..0000000 --- a/lib/server.js +++ /dev/null @@ -1,422 +0,0 @@ -var util = require("./util"); -var bodyParser = require("body-parser"); -var WebSocketServer = require("ws").Server; -var url = require("url"); -var cors = require("cors"); - -var app = (exports = module.exports = {}); - -/** Initialize WebSocket server. */ -app._initializeWSS = function(server) { - var self = this; - - if (this.mountpath instanceof Array) { - throw new Error("This app can only be mounted on a single path"); - } - - var path = this.mountpath; - var path = path + (path[path.length - 1] != "/" ? "/" : "") + "peerjs"; - - // Create WebSocket server as well. - this._wss = new WebSocketServer({ path: path, server: server }); - - this._wss.on("connection", function(socket, req) { - var query = url.parse(req.url, true).query; - var id = query.id; - var token = query.token; - var key = query.key; - var ip = req.socket.remoteAddress; - - if (!id || !token || !key) { - socket.send( - JSON.stringify({ - type: "ERROR", - payload: { msg: "No id, token, or key supplied to websocket server" } - }) - ); - socket.close(); - return; - } - - if (!self._clients[key] || !self._clients[key][id]) { - self._checkKey(key, ip, function(err) { - if (!err) { - if (!self._clients[key][id]) { - self._clients[key][id] = { token: token, ip: ip }; - self._ips[ip]++; - socket.send(JSON.stringify({ type: "OPEN" })); - } - self._configureWS(socket, key, id, token); - } else { - socket.send(JSON.stringify({ type: "ERROR", payload: { msg: err } })); - } - }); - } else { - self._configureWS(socket, key, id, token); - } - }); - - this._wss.on("error", function (err) { - // handle error - }) -}; - -app._configureWS = function(socket, key, id, token) { - var self = this; - var client = this._clients[key][id]; - - if (token === client.token) { - // res 'close' event will delete client.res for us - client.socket = socket; - // Client already exists - if (client.res) { - client.res.end(); - } - } else { - // ID-taken, invalid token - socket.send( - JSON.stringify({ type: "ID-TAKEN", payload: { msg: "ID is taken" } }) - ); - socket.close(); - return; - } - - this._processOutstanding(key, id); - - // Cleanup after a socket closes. - socket.on("close", function() { - self._log("Socket closed:", id); - if (client.socket == socket) { - self._removePeer(key, id); - } - }); - - // Handle messages from peers. - socket.on("message", function(data) { - try { - var message = JSON.parse(data); - - if ( - ["LEAVE", "CANDIDATE", "OFFER", "ANSWER"].indexOf(message.type) !== -1 - ) { - self._handleTransmission(key, { - type: message.type, - src: id, - dst: message.dst, - payload: message.payload - }); - } else if (message.type === 'HEARTBEAT') { - // Ignore - nothing needs doing here. - } else { - util.prettyError("Message unrecognized"); - } - } catch (e) { - self._log("Invalid message", data); - throw e; - } - }); - - // We're going to emit here, because for XHR we don't *know* when someone - // disconnects. - this.emit("connection", id); -}; - -app._checkAllowsDiscovery = function(key, cb) { - cb(this._options.allow_discovery); -}; - -app._checkKey = function(key, ip, cb) { - if (key == this._options.key) { - if (!this._clients[key]) { - this._clients[key] = {}; - } - if (!this._outstanding[key]) { - this._outstanding[key] = {}; - } - if (!this._ips[ip]) { - this._ips[ip] = 0; - } - // Check concurrent limit - if ( - Object.keys(this._clients[key]).length >= this._options.concurrent_limit - ) { - cb("Server has reached its concurrent user limit"); - return; - } - if (this._ips[ip] >= this._options.ip_limit) { - cb(ip + " has reached its concurrent user limit"); - return; - } - cb(null); - } else { - cb("Invalid key provided"); - } -}; - -/** Initialize HTTP server routes. */ -app._initializeHTTP = function() { - var self = this; - - this.use(cors()); - - this.get("/", function(req, res, next) { - res.send(require("../app.json")); - }); - - // Retrieve guaranteed random ID. - this.get("/:key/id", function(req, res, next) { - res.contentType = "text/html"; - res.send(self._generateClientId(req.params.key)); - }); - - // Server sets up HTTP streaming when you get post an ID. - this.post("/:key/:id/:token/id", function(req, res, next) { - var id = req.params.id; - var token = req.params.token; - var key = req.params.key; - var ip = req.connection.remoteAddress; - - if (!self._clients[key] || !self._clients[key][id]) { - self._checkKey(key, ip, function(err) { - if (!err && !self._clients[key][id]) { - self._clients[key][id] = { token: token, ip: ip }; - self._ips[ip]++; - self._startStreaming(res, key, id, token, true); - } else { - res.send(JSON.stringify({ type: "HTTP-ERROR" })); - } - }); - } else { - self._startStreaming(res, key, id, token); - } - }); - - // Get a list of all peers for a key, enabled by the `allowDiscovery` flag. - this.get("/:key/peers", function(req, res, next) { - var key = req.params.key; - if (self._clients[key]) { - self._checkAllowsDiscovery(key, function(isAllowed) { - if (isAllowed) { - res.send(Object.keys(self._clients[key])); - } else { - res.sendStatus(401); - } - }); - } else { - res.sendStatus(404); - } - }); - - var handle = function(req, res, next) { - var key = req.params.key; - var id = req.params.id; - - var client; - if (!self._clients[key] || !(client = self._clients[key][id])) { - if (req.params.retry) { - res.sendStatus(401); - return; - } else { - // Retry this request - req.params.retry = true; - setTimeout(handle, 25, req, res); - return; - } - } - - // Auth the req - if (client.token && req.params.token !== client.token) { - res.sendStatus(401); - return; - } else { - self._handleTransmission(key, { - type: req.body.type, - src: id, - dst: req.body.dst, - payload: req.body.payload - }); - res.sendStatus(200); - } - }; - - var jsonParser = bodyParser.json(); - - this.post("/:key/:id/:token/offer", jsonParser, handle); - - this.post("/:key/:id/:token/candidate", jsonParser, handle); - - this.post("/:key/:id/:token/answer", jsonParser, handle); - - this.post("/:key/:id/:token/leave", jsonParser, handle); -}; - -/** Saves a streaming response and takes care of timeouts and headers. */ -app._startStreaming = function(res, key, id, token, open) { - var self = this; - - res.writeHead(200, { "Content-Type": "application/octet-stream" }); - - var pad = "00"; - for (var i = 0; i < 10; i++) { - pad += pad; - } - res.write(pad + "\n"); - - if (open) { - res.write(JSON.stringify({ type: "OPEN" }) + "\n"); - } - - var client = this._clients[key][id]; - - if (token === client.token) { - // Client already exists - res.on("close", function() { - if (client.res === res) { - if (!client.socket) { - // No new request yet, peer dead - self._removePeer(key, id); - return; - } - delete client.res; - } - }); - client.res = res; - this._processOutstanding(key, id); - } else { - // ID-taken, invalid token - res.end(JSON.stringify({ type: "HTTP-ERROR" })); - } -}; - -app._pruneOutstanding = function() { - var keys = Object.keys(this._outstanding); - for (var k = 0, kk = keys.length; k < kk; k += 1) { - var key = keys[k]; - var dsts = Object.keys(this._outstanding[key]); - for (var i = 0, ii = dsts.length; i < ii; i += 1) { - var offers = this._outstanding[key][dsts[i]]; - var seen = {}; - for (var j = 0, jj = offers.length; j < jj; j += 1) { - var message = offers[j]; - if (!seen[message.src]) { - this._handleTransmission(key, { - type: "EXPIRE", - src: message.dst, - dst: message.src - }); - seen[message.src] = true; - } - } - } - this._outstanding[key] = {}; - } -}; - -/** Cleanup */ -app._setCleanupIntervals = function() { - var self = this; - - // Clean up ips every 10 minutes - setInterval(function() { - var keys = Object.keys(self._ips); - for (var i = 0, ii = keys.length; i < ii; i += 1) { - var key = keys[i]; - if (self._ips[key] === 0) { - delete self._ips[key]; - } - } - }, 600000); - - // Clean up outstanding messages every 5 seconds - setInterval(function() { - self._pruneOutstanding(); - }, 5000); -}; - -/** Process outstanding peer offers. */ -app._processOutstanding = function(key, id) { - var offers = this._outstanding[key][id]; - if (!offers) { - return; - } - for (var j = 0, jj = offers.length; j < jj; j += 1) { - this._handleTransmission(key, offers[j]); - } - delete this._outstanding[key][id]; -}; - -app._removePeer = function(key, id) { - if (this._clients[key] && this._clients[key][id]) { - this._ips[this._clients[key][id].ip]--; - delete this._clients[key][id]; - this.emit("disconnect", id); - } -}; - -/** Handles passing on a message. */ -app._handleTransmission = function(key, message) { - var type = message.type; - var src = message.src; - var dst = message.dst; - var data = JSON.stringify(message); - - var destination = this._clients[key][dst]; - - // User is connected! - if (destination) { - try { - this._log(type, "from", src, "to", dst); - if (destination.socket) { - destination.socket.send(data); - } else if (destination.res) { - data += "\n"; - destination.res.write(data); - } else { - // Neither socket no res available. Peer dead? - throw "Peer dead"; - } - } catch (e) { - // This happens when a peer disconnects without closing connections and - // the associated WebSocket has not closed. - // Tell other side to stop trying. - this._removePeer(key, dst); - this._handleTransmission(key, { - type: "LEAVE", - src: dst, - dst: src - }); - } - } else { - // Wait for this client to connect/reconnect (XHR) for important - // messages. - if (type !== "LEAVE" && type !== "EXPIRE" && dst) { - var self = this; - if (!this._outstanding[key][dst]) { - this._outstanding[key][dst] = []; - } - this._outstanding[key][dst].push(message); - } else if (type === "LEAVE" && !dst) { - this._removePeer(key, src); - } else { - // Unavailable destination specified with message LEAVE or EXPIRE - // Ignore - } - } -}; - -app._generateClientId = function(key) { - var clientId = util.randomId(); - if (!this._clients[key]) { - return clientId; - } - while (!!this._clients[key][clientId]) { - clientId = util.randomId(); - } - return clientId; -}; - -app._log = function() { - if (this._options.debug) { - console.log.apply(console, arguments); - } -}; diff --git a/lib/util.js b/lib/util.js deleted file mode 100644 index 38f1b58..0000000 --- a/lib/util.js +++ /dev/null @@ -1,31 +0,0 @@ -var util = { - debug: false, - inherits: function(ctor, superCtor) { - ctor.super_ = superCtor; - ctor.prototype = Object.create(superCtor.prototype, { - constructor: { - value: ctor, - enumerable: false, - writable: true, - configurable: true - } - }); - }, - extend: function(dest, source) { - source = source || {}; - for(var key in source) { - if(source.hasOwnProperty(key)) { - dest[key] = source[key]; - } - } - return dest; - }, - randomId: function () { - return (Math.random().toString(36) + '0000000000000000000').substr(2, 16); - }, - prettyError: function (msg) { - console.log('ERROR PeerServer: ', msg); - } -}; - -module.exports = util; diff --git a/package.json b/package.json index 7fd56ee..9d430fc 100644 --- a/package.json +++ b/package.json @@ -2,33 +2,32 @@ "name": "peer", "version": "0.2.9", "description": "PeerJS server component", - "main": "lib/index.js", - "bin": { - "peerjs": "./bin/peerjs" - }, + "main": "src/index.js", "repository": { "type": "git", "url": "git://github.com/peers/peerjs-server.git" }, "author": "Michelle Bu, Eric Zhang", "license": "MIT", - "dependencies": { - "body-parser": "^1.18.3", - "express": "^4.16.3", - "optimist": "~0.6.1", - "ws": "6.0.0", - "cors": "~2.8.4" - }, - "devDependencies": { - "expect.js": "*", - "sinon": "*", - "mocha": "*" - }, - "engines": { - "node": ">=0.8" - }, "scripts": { "test": "mocha test", - "start": "bin/peerjs --port ${PORT:=9000}" + "start": "node ./src/index.js" + }, + "dependencies": { + "body-parser": "^1.18.3", + "convict": "^4.4.1", + "cors": "~2.8.4", + "express": "^4.16.3", + "log4js": "^4.1.0", + "ws": "6.0.0" + }, + "devDependencies": { + "mocha": "^6.0.2", + "chai": "^4.2.0", + "semistandard": "^13.0.1", + "sinon": "^7.3.1" + }, + "engines": { + "node": "^10" } } diff --git a/src/api/index.js b/src/api/index.js new file mode 100644 index 0000000..a251da8 --- /dev/null +++ b/src/api/index.js @@ -0,0 +1,18 @@ +const express = require('express'); +const cors = require('cors'); +const bodyParser = require('body-parser'); +const authMiddleware = require('./middleware/auth'); +const publicContent = require('../../app.json'); + +const app = module.exports = express.Router(); + +const jsonParser = bodyParser.json(); + +app.use(cors()); + +app.get('/', (req, res, next) => { + res.send(publicContent); +}); + +app.use('/:key', authMiddleware, require('./v1/public')); +app.use('/:key/:id/:token', authMiddleware, jsonParser, require('./v1/calls')); diff --git a/src/api/middleware/auth/index.js b/src/api/middleware/auth/index.js new file mode 100644 index 0000000..0cdd470 --- /dev/null +++ b/src/api/middleware/auth/index.js @@ -0,0 +1,23 @@ +const realm = require('../../../services/realm'); + +module.exports = (req, res, next) => { + const { id, token } = req.params; + + const sendAuthError = () => res.sendStatus(401); + + if (!id) { + return next(); + } + + const client = realm.getRealmByKey(id); + + if (!realm) { + return sendAuthError(); + } + + if (client.getToken() && token !== client.getToken()) { + return sendAuthError(); + } + + next(); +}; diff --git a/src/api/v1/calls/index.js b/src/api/v1/calls/index.js new file mode 100644 index 0000000..172e816 --- /dev/null +++ b/src/api/v1/calls/index.js @@ -0,0 +1,42 @@ +const express = require('express'); +// const realm = require('../../../realm'); + +const app = module.exports = express.Router(); + +// const handle = (req, res, next) => { +// var id = req.params.id; + +// let client; +// if (!(client = realm.getClientById(id))) { +// if (req.params.retry) { +// res.sendStatus(401); +// return; +// } else { +// // Retry this request +// req.params.retry = true; +// setTimeout(handle, 25, req, res); +// return; +// } +// } + +// // Auth the req +// if (client.token && req.params.token !== client.token) { +// res.sendStatus(401); +// } else { +// self._handleTransmission(key, { +// type: req.body.type, +// src: id, +// dst: req.body.dst, +// payload: req.body.payload +// }); +// res.sendStatus(200); +// } +// }; + +// app.post('/:key/:id/:token/offer', jsonParser, handle); + +// app.post('/:key/:id/:token/candidate', jsonParser, handle); + +// app.post('/:key/:id/:token/answer', jsonParser, handle); + +// app.post('/:key/:id/:token/leave', jsonParser, handle); diff --git a/src/api/v1/public/index.js b/src/api/v1/public/index.js new file mode 100644 index 0000000..1215651 --- /dev/null +++ b/src/api/v1/public/index.js @@ -0,0 +1,65 @@ +const express = require('express'); +const realm = require('../../../services/realm'); +const config = require('../../../../config'); + +const app = module.exports = express.Router(); + +const randomId = () => { + return (Math.random().toString(36) + '0000000000000000000').substr(2, 16); +}; + +const generateClientId = (key) => { + let clientId = randomId(); + + const realm = realmsCache.getRealmByKey(key); + if (!realm) { + return clientId; + } + + while (realm.getClientById(clientId)) { + clientId = randomId(); + } + + return clientId; +}; + +// Retrieve guaranteed random ID. +app.get('/id', (req, res, next) => { + const { key } = req.params; + + res.contentType = 'text/html'; + res.send(generateClientId(key)); +}); + +// Get a list of all peers for a key, enabled by the `allowDiscovery` flag. +app.get('/peers', (req, res, next) => { + if (config.get('allow_discovery')) { + const clientsIds = realm.getClientsIds(); + + return res.send(clientsIds); + } + + res.sendStatus(401); +}); + +// Server sets up HTTP streaming when you get post an ID. +// app.post('/:id/:token/id', (req, res, next) => { +// var id = req.params.id; +// var token = req.params.token; +// var key = req.params.key; +// var ip = req.connection.remoteAddress; + +// if (!self._clients[key] || !self._clients[key][id]) { +// self._checkKey(key, ip, function (err) { +// if (!err && !self._clients[key][id]) { +// self._clients[key][id] = { token: token, ip: ip }; +// self._ips[ip]++; +// self._startStreaming(res, key, id, token, true); +// } else { +// res.send(JSON.stringify({ type: 'HTTP-ERROR' })); +// } +// }); +// } else { +// self._startStreaming(res, key, id, token); +// } +// }); diff --git a/src/enums.js b/src/enums.js new file mode 100644 index 0000000..ea608db --- /dev/null +++ b/src/enums.js @@ -0,0 +1,10 @@ +module.exports.Errors = {}; + +module.exports.MessageType = { + LEAVE: 'LEAVE', + CANDIDATE: 'CANDIDATE', + OFFER: 'OFFER', + ANSWER: 'ANSWER', + EXPIRE: 'EXPIRE', + HEARTBEAT: 'HEARTBEAT' +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..98dc85e --- /dev/null +++ b/src/index.js @@ -0,0 +1,124 @@ +const express = require('express'); +const http = require('http'); +const https = require('https'); +const fs = require('fs'); + +const config = require('../config'); +const WebSocketServer = require('./services/webSocketServer'); +const logger = require('./services/logger'); +const api = require('./api'); +const messageHandler = require('./messageHandler'); +const realm = require('./services/realm'); +const MessageType = require('./enums'); + +// parse config +let path = config.get('path'); +const port = config.get('port'); + +if (path[0] !== '/') { + path = '/' + path; +} + +if (path[path.length - 1] !== '/') { + path += '/'; +} + +const app = express(); + +if (config.get('proxied')) { + app.set('trust proxy', config.get('proxied')); +} + +app.on('mount', () => { + if (!server) { + throw new Error('Server is not passed to constructor - ' + + 'can\'t start PeerServer'); + } + + // TODO + app._setCleanupIntervals(); + + const wss = new WebSocketServer(server, app.mountpath); + + wss.on('connection', client => { + const messages = realm.getMessageQueueById(client.getId()); + + messages.forEach(message => messageHandler(client, message)); + + realm.clearMessageQueue(client.getId()); + + logger.info(`client ${client.getId()} was connected`); + }); + + wss.on('message', (client, message) => { + messageHandler(client, message); + }); + + wss.on('close', client => { + logger.info(`client ${client.getId()} was disconnected`); + }); + + wss.on('error', error => { + logger.error(error); + }); + + app._wss = wss; +}); + +let server; + +if (config.get('ssl.key_path') && config.get('ssl.cert_path')) { + const keyPath = config.get('ssl.key_path'); + const certPath = config.get('ssl.cert_path'); + + const opts = { + key: fs.readFileSync(path.resolve(keyPath)), + cert: fs.readFileSync(path.resolve(certPath)) + }; + + server = https.createServer(opts, app); +} else { + server = http.createServer(app); +} + +app.use(path, api); + +server.listen(port, () => { + const host = server.address().address; + const port = server.address().port; + + logger.info( + 'Started PeerServer on %s, port: %s', + host, port + ); +}); + +const pruneOutstanding = () => { + const destinationClientsIds = realm.messageQueue.keys(); + + for (const destinationClientId of destinationClientsIds) { + const messages = realm.getMessageQueueById(destinationClientId); + + const seen = {}; + + for (const message of messages) { + if (!seen[message.src]) { + messageHandler(null, { + type: MessageType.EXPIRE, + src: message.dst, + dst: message.src + }); + seen[message.src] = true; + } + } + } + + realm.messageQueue.clear(); + + logger.debug(`message queue was cleared`); +}; + +// Clean up outstanding messages +setInterval(() => { + pruneOutstanding(); +}, config.get('cleanup_out_msgs')); diff --git a/src/messageHandler/handlers/transmission/index.js b/src/messageHandler/handlers/transmission/index.js new file mode 100644 index 0000000..5eae6e5 --- /dev/null +++ b/src/messageHandler/handlers/transmission/index.js @@ -0,0 +1,55 @@ +const realm = require('../../../services/realm'); +const logger = require('../../../services/logger'); +const MessageType = require('../../../enums'); + +const handler = (client, message) => { + const type = message.type; + const srcId = message.src; + const dstId = message.dst; + + const destinationClient = realm.getClientById(dstId); + + // User is connected! + if (destinationClient) { + try { + logger.debug(type, 'from', srcId, 'to', dstId); + + if (destinationClient.socket) { + const data = JSON.stringify(message); + + destinationClient.socket.send(data); + } else { + // Neither socket no res available. Peer dead? + throw new Error('Peer dead'); + } + } catch (e) { + // This happens when a peer disconnects without closing connections and + // the associated WebSocket has not closed. + // Tell other side to stop trying. + if (destinationClient.socket) { + destinationClient.socket.close(); + } else { + realm.removeClientById(destinationClient.getId()); + } + + handler(client, { + type: MessageType.LEAVE, + src: dstId, + dst: srcId + }); + } + } else { + // Wait for this client to connect/reconnect (XHR) for important + // messages. + if (type !== MessageType.LEAVE && type !== MessageType.EXPIRE && dstId) { + realm.addMessageToQueue(dstId, message); + } else if (type === MessageType.LEAVE && !dstId) { + realm.removeClientById(srcId); + } else { + // Unavailable destination specified with message LEAVE or EXPIRE + // Ignore + } + } +}; + +module.exports = handler; diff --git a/src/messageHandler/index.js b/src/messageHandler/index.js new file mode 100644 index 0000000..f83af67 --- /dev/null +++ b/src/messageHandler/index.js @@ -0,0 +1,40 @@ +const logger = require('../services/logger'); +const MessageType = require('../enums'); +const transmissionHandler = require('./handlers/transmission'); + +const handlers = {}; + +const registerHandler = (messageType, handler) => { + handlers[messageType] = handler; +}; + +module.exports = (client, message) => { + const { type } = message; + + const handler = handlers[type]; + + if (!handler) { + return logger.error('Message unrecognized'); + } + + handler(client, message); +}; + +const handleTransmission = (client, message) => { + transmissionHandler(client, { + type: message.type, + src: client.getId(), + dst: message.dst, + payload: message.payload + }); +}; + +const handleHeartbeat = (client, message) => { + +}; + +registerHandler(MessageType.HEARTBEAT, handleHeartbeat); +registerHandler(MessageType.OFFER, handleTransmission); +registerHandler(MessageType.ANSWER, handleTransmission); +registerHandler(MessageType.CANDIDATE, handleTransmission); +registerHandler(MessageType.LEAVE, handleTransmission); diff --git a/src/models/client.js b/src/models/client.js new file mode 100644 index 0000000..e69fb7b --- /dev/null +++ b/src/models/client.js @@ -0,0 +1,30 @@ +class Client { + constructor ({ id, token, ip }) { + this.id = id; + this.token = token; + this.ip = ip; + this.socket = null; + } + + getId () { + return this.id; + } + + getToken () { + return this.token; + } + + getIp () { + return this.ip; + } + + setSocket (socket) { + this.socket = socket; + } + + send (data) { + this.socket.send(JSON.stringify(data)); + } +} + +module.exports = Client; diff --git a/src/services/errors/index.js b/src/services/errors/index.js new file mode 100644 index 0000000..a6735a7 --- /dev/null +++ b/src/services/errors/index.js @@ -0,0 +1,3 @@ +module.exports = { + INVALID_KEY: 'Invalid key provided' +}; diff --git a/src/services/logger/index.js b/src/services/logger/index.js new file mode 100644 index 0000000..50f79b7 --- /dev/null +++ b/src/services/logger/index.js @@ -0,0 +1,6 @@ +const log4js = require('log4js'); + +const logger = log4js.getLogger(); +logger.level = 'ALL'; + +module.exports = logger; diff --git a/src/services/realm/index.js b/src/services/realm/index.js new file mode 100644 index 0000000..006d244 --- /dev/null +++ b/src/services/realm/index.js @@ -0,0 +1,44 @@ +class Realm { + constructor () { + this.clients = new Map(); + this.messageQueue = new Map(); + } + + getClientsIds () { + return [...this.clients.keys()]; + } + + getClientById (clientId) { + return this.clients.get(clientId); + } + + setClient (client, id) { + this.clients.set(id, client); + } + + removeClientById (id) { + const client = this.getClientById(id); + + if (!client) return false; + + this.clients.delete(id); + } + + getMessageQueueById (id) { + return this.messageQueue.get(id); + } + + addMessageToQueue (id, message) { + if (!this.getMessageQueueById(id)) { + this.messageQueue.set(id, []); + } + + this.getMessageQueueById(id).push(message); + } + + clearMessageQueue (id) { + this.messageQueue.delete(id); + } +} + +module.exports = new Realm(); diff --git a/src/services/webSocketServer/index.js b/src/services/webSocketServer/index.js new file mode 100644 index 0000000..e18213c --- /dev/null +++ b/src/services/webSocketServer/index.js @@ -0,0 +1,154 @@ +const WSS = require('ws').Server; +const url = require('url'); +const EventEmitter = require('events'); +const logger = require('../logger'); + +const config = require('../../../config'); +const realm = require('../realm'); +const Client = require('../../models/client'); + +class WebSocketServer extends EventEmitter { + constructor (server, mountpath) { + super(); + this.setMaxListeners(0); + + this._ips = {}; + + if (mountpath instanceof Array) { + throw new Error('This app can only be mounted on a single path'); + } + + let path = mountpath; + path = path + (path[path.length - 1] !== '/' ? '/' : '') + 'peerjs'; + + this._wss = new WSS({ path, server }); + + this._wss.on('connection', this._onSocketConnection); + this._wss.on('error', this._onSocketError); + } + + _onSocketConnection (socket, req) { + const { query = {} } = url.parse(req.url, true); + + const { id, token, key } = query; + + if (!id || !token || !key) { + return this._sendErrorAndClose(socket, 'No id, token, or key supplied to websocket server'); + } + + if (key !== config.get('key')) { + return this._sendErrorAndClose(socket, 'Invalid key provided'); + } + + const client = realm.getClientById(id); + + if (client) { + if (token !== client.getToken()) { + // ID-taken, invalid token + socket.send(JSON.stringify({ + type: 'ID-TAKEN', + payload: { msg: 'ID is taken' } + })); + + return socket.close(); + } + + return this._configureWS(socket, client); + } + + this._registerClient({ socket, id, token }); + } + + _onSocketError (error) { + // handle error + this.emit('error', error); + } + + _registerClient ({ socket, id, token }) { + const ip = socket.remoteAddress; + + if (!this._ips[ip]) { + this._ips[ip] = 0; + } + + // Check concurrent limit + const clientsCount = realm.getClientsIds().length; + + if (clientsCount >= config.get('concurrent_limit')) { + return this._sendErrorAndClose(socket, 'Server has reached its concurrent user limit'); + } + + const connectionsPerIP = this._ips[ip]; + + if (connectionsPerIP >= config.get('ip_limit')) { + return this._sendErrorAndClose(socket, `${ip} has reached its concurrent user limit`); + } + + const oldClient = realm.getClientById(id); + + if (oldClient) { + return this._sendErrorAndClose(socket, `${id} already registered`); + } + + const newClient = new Client({ id, token, ip }); + realm.setClient(newClient, id); + socket.send(JSON.stringify({ type: 'OPEN' })); + this._ips[ip]++; + this._configureWS(socket, newClient); + } + + _configureWS (socket, client) { + if (client.socket && socket !== client.socket) { + // TODO remove old ip, add new ip + } + + client.setSocket(socket); + + // Cleanup after a socket closes. + socket.on('close', () => { + logger.info('Socket closed:', client.getId()); + + const ip = socket.remoteAddress; + + if (this._ips[ip]) { + this._ips[ip]--; + + if (this._ips[ip] === 0) { + delete this._ips[ip]; + } + } + + if (client.socket === socket) { + realm.removeClientById(client.getId()); + this.emit('close', client); + } + }); + + // Handle messages from peers. + socket.on('message', (data) => { + try { + const message = JSON.parse(data); + + this.emit('message', client, message); + } catch (e) { + logger.error('Invalid message', data); + throw e; + } + }); + + this.emit('connection', client); + } + + _sendErrorAndClose (socket, msg) { + socket.send( + JSON.stringify({ + type: 'ERROR', + payload: { msg } + }) + ); + + socket.close(); + } +} + +module.exports = WebSocketServer; diff --git a/test/server.js b/test/server.js index 4e167ad..a0ea0d3 100644 --- a/test/server.js +++ b/test/server.js @@ -1,56 +1,56 @@ -var ExpressPeerServer = require('../').ExpressPeerServer; -var expect = require('expect.js'); -var sinon = require('sinon'); +const ExpressPeerServer = require('../').ExpressPeerServer; +const { expect } = require('chai'); +const sinon = require('sinon'); -describe('ExpressPeerServer', function() { - describe('method', function() { +describe('ExpressPeerServer', function () { + describe('method', function () { var p; - before(function() { - p = ExpressPeerServer(undefined, {port: 8000}); + before(function () { + p = ExpressPeerServer(undefined, { port: 8000 }); }); - describe('#_checkKey', function() { - it('should reject keys that are not the default', function(done) { - p._checkKey('bad key', null, function(response) { + describe('#_checkKey', function () { + it('should reject keys that are not the default', function (done) { + p._checkKey('bad key', null, function (response) { expect(response).to.be('Invalid key provided'); done(); }); }); - it('should accept valid key/ip pairs', function(done) { - p._checkKey('peerjs', 'myip', function(response) { + it('should accept valid key/ip pairs', function (done) { + p._checkKey('peerjs', 'myip', function (response) { expect(response).to.be(null); done(); }); }); - it('should reject ips that are at their limit', function(done) { + it('should reject ips that are at their limit', function (done) { p._options.ip_limit = 0; - p._checkKey('peerjs', 'myip', function(response) { + p._checkKey('peerjs', 'myip', function (response) { expect(response).to.be('myip has reached its concurrent user limit'); done(); }); }); - it('should reject when the server is at its limit', function(done) { + it('should reject when the server is at its limit', function (done) { p._options.concurrent_limit = 0; - p._checkKey('peerjs', 'myip', function(response) { + p._checkKey('peerjs', 'myip', function (response) { expect(response).to.be('Server has reached its concurrent user limit'); done(); }); }); }); - describe('#_removePeer', function() { - before(function() { - var fake = {ip: '0.0.0.0'}; + describe('#_removePeer', function () { + before(function () { + var fake = { ip: '0.0.0.0' }; p._ips[fake.ip] = 1; p._clients['peerjs'] = {}; p._clients['peerjs']['test'] = fake; }); - it('should decrement the number of ips being used and remove the connection', function() { + it('should decrement the number of ips being used and remove the connection', function () { expect(p._ips['0.0.0.0']).to.be(1); p._removePeer('peerjs', 'test'); expect(p._ips['0.0.0.0']).to.be(0); @@ -58,94 +58,94 @@ describe('ExpressPeerServer', function() { }); }); - describe('#_handleTransmission', function() { + describe('#_handleTransmission', function () { var KEY = 'peerjs'; var ID = 'test'; - before(function() { + before(function () { p._clients[KEY] = {}; }); - it('should send to the socket when appropriate', function() { + it('should send to the socket when appropriate', function () { var send = sinon.spy(); var write = sinon.spy(); - var message = {dst: ID}; + var message = { dst: ID }; p._clients[KEY][ID] = { socket: { - send: send + send: send }, res: { - write: write + write: write } - } + }; p._handleTransmission(KEY, message); expect(send.calledWith(JSON.stringify(message))).to.be(true); expect(write.calledWith(JSON.stringify(message))).to.be(false); }); - it('should write to the response with a newline when appropriate', function() { + it('should write to the response with a newline when appropriate', function () { var write = sinon.spy(); - var message = {dst: ID}; + var message = { dst: ID }; p._clients[KEY][ID] = { res: { - write: write + write: write } - } + }; p._handleTransmission(KEY, message); expect(write.calledWith(JSON.stringify(message) + '\n')).to.be(true); }); // no destination. - it('should push to outstanding messages if the destination is not found', function() { - var message = {dst: ID}; + it('should push to outstanding messages if the destination is not found', function () { + var message = { dst: ID }; p._outstanding[KEY] = {}; p._clients[KEY] = {}; p._handleTransmission(KEY, message); expect(p._outstanding[KEY][ID][0]).to.be(message); }); - it('should not push to outstanding messages if the message is a LEAVE or EXPIRE', function() { - var message = {dst: ID, type: 'LEAVE'}; + it('should not push to outstanding messages if the message is a LEAVE or EXPIRE', function () { + var message = { dst: ID, type: 'LEAVE' }; p._outstanding[KEY] = {}; p._clients[KEY] = {}; p._handleTransmission(KEY, message); expect(p._outstanding[KEY][ID]).to.be(undefined); - message = {dst: ID, type: 'EXPIRE'}; + message = { dst: ID, type: 'EXPIRE' }; p._handleTransmission(KEY, message); expect(p._outstanding[KEY][ID]).to.be(undefined); }); - it('should remove the peer if there is no dst in the message', function() { - var message = {type: 'LEAVE'}; + it('should remove the peer if there is no dst in the message', function () { + var message = { type: 'LEAVE' }; p._removePeer = sinon.spy(); p._outstanding[KEY] = {}; p._handleTransmission(KEY, message); expect(p._removePeer.calledWith(KEY, undefined)).to.be(true); }); - it('should remove the peer and send a LEAVE message if the socket appears to be closed', function() { + it('should remove the peer and send a LEAVE message if the socket appears to be closed', function () { var send = sinon.stub().throws(); - var message = {dst: ID}; - var leaveMessage = {type: 'LEAVE', dst: undefined, src: ID}; + var message = { dst: ID }; + var leaveMessage = { type: 'LEAVE', dst: undefined, src: ID }; var oldHandleTransmission = p._handleTransmission; - p._removePeer = function() { + p._removePeer = function () { // Hacks! p._handleTransmission = sinon.spy(); }; p._clients[KEY][ID] = { socket: { - send: send + send: send } - } + }; p._handleTransmission(KEY, message); expect(p._handleTransmission.calledWith(KEY, leaveMessage)).to.be(true); }); }); - describe('#_generateClientId', function() { - it('should generate a 16-character ID', function() { - expect(p._generateClientId('anykey').length).to.be(16); + describe('#_generateClientId', function () { + it('should generate a 16-character ID', function () { + expect(p._generateClientId('anykey').length).to.be(16); }); }); }); diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index 9c31d06..0000000 --- a/yarn.lock +++ /dev/null @@ -1,581 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@sinonjs/commons@^1.0.1": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.0.2.tgz#3e0ac737781627b8844257fadc3d803997d0526e" - dependencies: - type-detect "4.0.8" - -"@sinonjs/formatio@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-2.0.0.tgz#84db7e9eb5531df18a8c5e0bfb6e449e55e654b2" - dependencies: - samsam "1.3.0" - -"@sinonjs/samsam@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-2.0.0.tgz#9163742ac35c12d3602dece74317643b35db6a80" - -accepts@~1.3.5: - version "1.3.5" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2" - dependencies: - mime-types "~2.1.18" - negotiator "0.6.1" - -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - -async-limiter@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" - -balanced-match@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - -body-parser@1.18.2: - version "1.18.2" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.2.tgz#87678a19d84b47d859b83199bd59bce222b10454" - dependencies: - bytes "3.0.0" - content-type "~1.0.4" - debug "2.6.9" - depd "~1.1.1" - http-errors "~1.6.2" - iconv-lite "0.4.19" - on-finished "~2.3.0" - qs "6.5.1" - raw-body "2.3.2" - type-is "~1.6.15" - -body-parser@^1.18.3: - version "1.18.3" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.3.tgz#5b292198ffdd553b3a0f20ded0592b956955c8b4" - dependencies: - bytes "3.0.0" - content-type "~1.0.4" - debug "2.6.9" - depd "~1.1.2" - http-errors "~1.6.3" - iconv-lite "0.4.23" - on-finished "~2.3.0" - qs "6.5.2" - raw-body "2.3.3" - type-is "~1.6.16" - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -browser-stdout@1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" - -bytes@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" - -commander@2.15.1: - version "2.15.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f" - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - -content-disposition@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" - -content-type@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - -cookie-signature@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - -cookie@0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" - -cors@~2.8.4: - version "2.8.4" - resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.4.tgz#2bd381f2eb201020105cd50ea59da63090694686" - dependencies: - object-assign "^4" - vary "^1" - -debug@2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - dependencies: - ms "2.0.0" - -debug@3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" - dependencies: - ms "2.0.0" - -depd@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359" - -depd@~1.1.1, depd@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" - -destroy@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" - -diff@3.5.0, diff@^3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - -escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - -escape-string-regexp@1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - -etag@~1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - -expect.js@*: - version "0.3.1" - resolved "https://registry.yarnpkg.com/expect.js/-/expect.js-0.3.1.tgz#b0a59a0d2eff5437544ebf0ceaa6015841d09b5b" - -express@^4.16.3: - version "4.16.3" - resolved "https://registry.yarnpkg.com/express/-/express-4.16.3.tgz#6af8a502350db3246ecc4becf6b5a34d22f7ed53" - dependencies: - accepts "~1.3.5" - array-flatten "1.1.1" - body-parser "1.18.2" - content-disposition "0.5.2" - content-type "~1.0.4" - cookie "0.3.1" - cookie-signature "1.0.6" - debug "2.6.9" - depd "~1.1.2" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "1.1.1" - fresh "0.5.2" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "~2.3.0" - parseurl "~1.3.2" - path-to-regexp "0.1.7" - proxy-addr "~2.0.3" - qs "6.5.1" - range-parser "~1.2.0" - safe-buffer "5.1.1" - send "0.16.2" - serve-static "1.13.2" - setprototypeof "1.1.0" - statuses "~1.4.0" - type-is "~1.6.16" - utils-merge "1.0.1" - vary "~1.1.2" - -finalhandler@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.1.tgz#eebf4ed840079c83f4249038c9d703008301b105" - dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "~2.3.0" - parseurl "~1.3.2" - statuses "~1.4.0" - unpipe "~1.0.0" - -forwarded@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" - -fresh@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - -glob@7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -growl@1.10.5: - version "1.10.5" - resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - -he@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" - -http-errors@1.6.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736" - dependencies: - depd "1.1.1" - inherits "2.0.3" - setprototypeof "1.0.3" - statuses ">= 1.3.1 < 2" - -http-errors@1.6.3, http-errors@~1.6.2, http-errors@~1.6.3: - version "1.6.3" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" - dependencies: - depd "~1.1.2" - inherits "2.0.3" - setprototypeof "1.1.0" - statuses ">= 1.4.0 < 2" - -iconv-lite@0.4.19: - version "0.4.19" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" - -iconv-lite@0.4.23: - version "0.4.23" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" - dependencies: - safer-buffer ">= 2.1.2 < 3" - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - -ipaddr.js@1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.8.0.tgz#eaa33d6ddd7ace8f7f6fe0c9ca0440e706738b1e" - -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - -just-extend@^1.1.27: - version "1.1.27" - resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-1.1.27.tgz#ec6e79410ff914e472652abfa0e603c03d60e905" - -lodash.get@^4.4.2: - version "4.4.2" - resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" - -lolex@^2.3.2, lolex@^2.7.1: - version "2.7.1" - resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.7.1.tgz#e40a8c4d1f14b536aa03e42a537c7adbaf0c20be" - -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - -methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - -mime-db@~1.35.0: - version "1.35.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.35.0.tgz#0569d657466491283709663ad379a99b90d9ab47" - -mime-types@~2.1.18: - version "2.1.19" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.19.tgz#71e464537a7ef81c15f2db9d97e913fc0ff606f0" - dependencies: - mime-db "~1.35.0" - -mime@1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" - -minimatch@3.0.4, minimatch@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" - dependencies: - brace-expansion "^1.1.7" - -minimist@0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" - -minimist@~0.0.1: - version "0.0.10" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" - -mkdirp@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" - dependencies: - minimist "0.0.8" - -mocha@*: - version "5.2.0" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-5.2.0.tgz#6d8ae508f59167f940f2b5b3c4a612ae50c90ae6" - dependencies: - browser-stdout "1.3.1" - commander "2.15.1" - debug "3.1.0" - diff "3.5.0" - escape-string-regexp "1.0.5" - glob "7.1.2" - growl "1.10.5" - he "1.1.1" - minimatch "3.0.4" - mkdirp "0.5.1" - supports-color "5.4.0" - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - -negotiator@0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" - -nise@^1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/nise/-/nise-1.4.2.tgz#a9a3800e3994994af9e452333d549d60f72b8e8c" - dependencies: - "@sinonjs/formatio" "^2.0.0" - just-extend "^1.1.27" - lolex "^2.3.2" - path-to-regexp "^1.7.0" - text-encoding "^0.6.4" - -object-assign@^4: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - -on-finished@~2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" - dependencies: - ee-first "1.1.1" - -once@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - dependencies: - wrappy "1" - -optimist@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" - dependencies: - minimist "~0.0.1" - wordwrap "~0.0.2" - -parseurl@~1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3" - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - -path-to-regexp@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d" - dependencies: - isarray "0.0.1" - -proxy-addr@~2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.4.tgz#ecfc733bf22ff8c6f407fa275327b9ab67e48b93" - dependencies: - forwarded "~0.1.2" - ipaddr.js "1.8.0" - -qs@6.5.1: - version "6.5.1" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" - -qs@6.5.2: - version "6.5.2" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" - -range-parser@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" - -raw-body@2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89" - dependencies: - bytes "3.0.0" - http-errors "1.6.2" - iconv-lite "0.4.19" - unpipe "1.0.0" - -raw-body@2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.3.tgz#1b324ece6b5706e153855bc1148c65bb7f6ea0c3" - dependencies: - bytes "3.0.0" - http-errors "1.6.3" - iconv-lite "0.4.23" - unpipe "1.0.0" - -safe-buffer@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" - -"safer-buffer@>= 2.1.2 < 3": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - -samsam@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50" - -send@0.16.2: - version "0.16.2" - resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1" - dependencies: - debug "2.6.9" - depd "~1.1.2" - destroy "~1.0.4" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "~1.6.2" - mime "1.4.1" - ms "2.0.0" - on-finished "~2.3.0" - range-parser "~1.2.0" - statuses "~1.4.0" - -serve-static@1.13.2: - version "1.13.2" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.13.2.tgz#095e8472fd5b46237db50ce486a43f4b86c6cec1" - dependencies: - encodeurl "~1.0.2" - escape-html "~1.0.3" - parseurl "~1.3.2" - send "0.16.2" - -setprototypeof@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04" - -setprototypeof@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" - -sinon@*: - version "6.1.5" - resolved "https://registry.yarnpkg.com/sinon/-/sinon-6.1.5.tgz#41451502d43cd5ffb9d051fbf507952400e81d09" - dependencies: - "@sinonjs/commons" "^1.0.1" - "@sinonjs/formatio" "^2.0.0" - "@sinonjs/samsam" "^2.0.0" - diff "^3.5.0" - lodash.get "^4.4.2" - lolex "^2.7.1" - nise "^1.4.2" - supports-color "^5.4.0" - type-detect "^4.0.8" - -"statuses@>= 1.3.1 < 2", "statuses@>= 1.4.0 < 2": - version "1.5.0" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" - -statuses@~1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" - -supports-color@5.4.0, supports-color@^5.4.0: - version "5.4.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.4.0.tgz#1c6b337402c2137605efe19f10fec390f6faab54" - dependencies: - has-flag "^3.0.0" - -text-encoding@^0.6.4: - version "0.6.4" - resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19" - -type-detect@4.0.8, type-detect@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" - -type-is@~1.6.15, type-is@~1.6.16: - version "1.6.16" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.16.tgz#f89ce341541c672b25ee7ae3c73dee3b2be50194" - dependencies: - media-typer "0.3.0" - mime-types "~2.1.18" - -unpipe@1.0.0, unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - -utils-merge@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - -vary@^1, vary@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - -wordwrap@~0.0.2: - version "0.0.3" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - -ws@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-6.0.0.tgz#eaa494aded00ac4289d455bac8d84c7c651cef35" - dependencies: - async-limiter "~1.0.0"