server/lib/server.js

const express = require("express");
const portscanner = require("portscanner");

const MiddlewareManager = require("./middleware/MiddlewareManager");

const ui5Fs = require("@ui5/fs");
const resourceFactory = ui5Fs.resourceFactory;
const ReaderCollectionPrioritized = ui5Fs.ReaderCollectionPrioritized;

/**
 * Returns a promise resolving by starting the server.
 *
 * @param {object} app The express application object
 * @param {number} port Desired port to listen to
 * @param {boolean} changePortIfInUse If true and the port is already in use, an unused port is searched
 * @param {boolean} acceptRemoteConnections If true, listens to remote connections and not only to localhost connections
 * @returns {Promise<object>} Returns an object containing server related information like (selected port, protocol)
 * @private
 */
function _listen(app, port, changePortIfInUse, acceptRemoteConnections) {
	return new Promise(function(resolve, reject) {
		const options = {};

		if (!acceptRemoteConnections) {
			options.host = "localhost";
		}

		const host = options.host || "127.0.0.1";
		let portMax;
		if (changePortIfInUse) {
			portMax = port + 30;
		} else {
			portMax = port;
		}

		portscanner.findAPortNotInUse(port, portMax, host, function(error, foundPort) {
			if (error) {
				reject(error);
				return;
			}

			if (!foundPort) {
				if (changePortIfInUse) {
					const error = new Error(
						`EADDRINUSE: Could not find available ports between ${port} and ${portMax}.`);
					error.code = "EADDRINUSE";
					error.errno = "EADDRINUSE";
					error.address = host;
					error.port = portMax;
					reject(error);
					return;
				} else {
					const error = new Error(`EADDRINUSE: Port ${port} is already in use.`);
					error.code = "EADDRINUSE";
					error.errno = "EADDRINUSE";
					error.address = host;
					error.port = portMax;
					reject(error);
					return;
				}
			}

			options.port = foundPort;
			const server = app.listen(options, function() {
				resolve({port: options.port, server});
			});

			server.on("error", function(err) {
				reject(err);
			});
		});
	});
}

/**
 * Adds SSL support to an express application.
 *
 * @param {object} parameters
 * @param {object} parameters.app The original express application
 * @param {string} parameters.key Path to private key to be used for https
 * @param {string} parameters.cert Path to certificate to be used for for https
 * @returns {object} The express application with SSL support
 * @private
 */
function _addSsl({app, key, cert}) {
	// Using spdy as http2 server as the native http2 implementation
	// from Node v8.4.0 doesn't seem to work with express
	return require("spdy").createServer({cert, key}, app);
}


/**
 * SAP target CSP middleware options
 *
 * @public
 * @typedef {object} module:@ui5/server.server.SAPTargetCSPOptions
 * @property {string} [defaultPolicy="sap-target-level-1"]
 * @property {string} [defaultPolicyIsReportOnly=true]
 * @property {string} [defaultPolicy2="sap-target-level-2"]
 * @property {string} [defaultPolicy2IsReportOnly=true]
 * @property {string[]} [ignorePaths=["test-resources/sap/ui/qunit/testrunner.html"]]
 */


/**
 * @public
 * @namespace
 * @alias module:@ui5/server.server
 */
module.exports = {
	/**
	 * Start a server for the given project (sub-)tree.
	 *
	 * @public
	 * @param {object} tree A (sub-)tree
	 * @param {object} options Options
	 * @param {number} options.port Port to listen to
	 * @param {boolean} [options.changePortIfInUse=false] If true, change the port if it is already in use
	 * @param {boolean} [options.h2=false] Whether HTTP/2 should be used - defaults to <code>http</code>
	 * @param {string} [options.key] Path to private key to be used for https
	 * @param {string} [options.cert] Path to certificate to be used for for https
	 * @param {boolean} [options.simpleIndex=false] Use a simplified view for the server directory listing
	 * @param {boolean} [options.acceptRemoteConnections=false] If true, listens to remote connections and
	 * 															not only to localhost connections
	 * @param {boolean|module:@ui5/server.server.SAPTargetCSPOptions} [options.sendSAPTargetCSP=false]
	 * 										If set to <code>true</code> or an object, then the default (or configured)
	 * 										set of security policies that SAP and UI5 aim for (AKA 'target policies'),
	 * 										are send for any requested <code>*.html</code> file
	 * @param {boolean} [options.serveCSPReports=false] Enable CSP reports serving for request url
	 * 										'/.ui5/csp/csp-reports.json'
	 * @returns {Promise<object>} Promise resolving once the server is listening.
	 * 							It resolves with an object containing the <code>port</code>,
	 * 							<code>h2</code>-flag and a <code>close</code> function,
	 * 							which can be used to stop the server.
	 */
	async serve(tree, {
		port: requestedPort, changePortIfInUse = false, h2 = false, key, cert,
		acceptRemoteConnections = false, sendSAPTargetCSP = false, simpleIndex = false, serveCSPReports = false
	}) {
		const projectResourceCollections = resourceFactory.createCollectionsForTree(tree);


		// TODO change to ReaderCollection once duplicates are sorted out
		const combo = new ReaderCollectionPrioritized({
			name: "server - prioritize workspace over dependencies",
			readers: [projectResourceCollections.source, projectResourceCollections.dependencies]
		});

		const resources = {
			rootProject: projectResourceCollections.source,
			dependencies: projectResourceCollections.dependencies,
			all: combo
		};

		const middlewareManager = new MiddlewareManager({
			tree,
			resources,
			options: {
				sendSAPTargetCSP,
				serveCSPReports,
				simpleIndex
			}
		});

		let app = express();
		await middlewareManager.applyMiddleware(app);

		if (h2) {
			app = _addSsl({app, key, cert});
		}

		const {port, server} = await _listen(app, requestedPort, changePortIfInUse, acceptRemoteConnections);

		return {
			h2,
			port,
			close: function(callback) {
				server.close(callback);
			}
		};
	}
};