Source: index.js

// Require statements

const http = require('http'),
http2 = require('http2'),
url = require('url'),
Stream = require('stream'),
shouldCompress = require('./shouldCompress');

// Object.entries polyfill

const reduce = Function.bind.call(Function.call, Array.prototype.reduce);
const isEnumerable = Function.bind.call(Function.call, Object.prototype.propertyIsEnumerable);
const concat = Function.bind.call(Function.call, Array.prototype.concat);
const keys = Reflect.ownKeys;

if (!Object.values) {
	Object.values = function values(O) {
		return reduce(keys(O), (v, k) => concat(v, typeof k === 'string' && isEnumerable(O, k) ? [O[k]] : []), []);
	};
}

if (!Object.entries) {
	Object.entries = function entries(O) {
		return reduce(keys(O), (e, k) => concat(e, typeof k === 'string' && isEnumerable(O, k) ? [[k, O[k]]] : []), []);
	};
}

const defaultPlugins = ['requestObject.js', 'responseObject.js'];

/**
 * @typedef handler
 * @type {Object}
 * @property {string=} method The method to be listening for, ex. `'HEAD'`. Will default to all request methods.
 * @property {string|RegExp} path The path to be listening for ex. `/\/docs\/.+/`
 */

/**
 * @callback handlerCallback
 * @param {Request} request The request sent by the client. See https://nodejs.org/dist/latest-v6.x/docs/api/http.html#http_class_http_clientrequest.
 * @param {Response} response The response to be sent by the server. See https://nodejs.org/dist/latest-v6.x/docs/api/http.html#http_class_http_serverresponse.
 * @param {Function=} next Call after this plugin has finished loading.
 */

/**
 * A Freon application.
 * @property {string|BufferType} notFoundPage The data to be served when no handlers were found.
 * @property {Object.<string, string>} notFoundPageHeaders The headers to send when no handlers were found.
 * @property {{options: handler, callback: Function}} handlers The applications' handlers.
 * @class
 */
class Application {
	
	/**
	 * Creates a new Freon application.
	 * @param {(string[]|RegExp[])=} domains The list of domains to listen on (ex. `['example.com', /.+\.example.com/]`).
	 * @param {string|BufferType} [notFoundPage=''] The data to be served when no handlers were found.
	 * @param {Object.<string, string>} [notFoundPageHeaders={'Content-Type':'text/plain'}] The headers to send when no handlers were found.
	 * @param {number} maxClientBytes The maximum number of bytes that the client is allowed to send in the body before the connection is destroyed. Use this to prevent denial of service attacks. By default, it is left undefined, allowing an infinite number of bytes.
	 */
	constructor (domains, notFoundPage, notFoundPageHeaders, maxClientBytes) {

		// Set domains to accept everything by default

		domains = domains || [/.+/];

		// Loop through the domains to sort them by RegExps and Strings

		this.domains = {}, this.domains.regExps = [], this.domains.strings = [];

		domains.forEach(element => {

			// Push to the domains.regExps array for RegExps or the domains.strings array for Strings

			if (element instanceof RegExp) {
				this.domains.regExps.push(element);
			} else if (typeof element === 'string') {
				this.domains.strings.push(element);
			} else {
				throw new Error('Domains must be a regular expression or a string.');
			}
		});

		// Set this.notFoundPage and default to an empty string

		this.notFoundPage = notFoundPage || '';

		// Set notFoundPageHeaders

		this.notFoundPageHeaders = notFoundPageHeaders || {
			'Content-Type' : 'text/plain'
		};

		this.maxClientBytes = maxClientBytes;

		// Set this.handlers and this.plugins to an empty array

		this.handlers = [];
		this.plugins = [];

		// Load default plugins

		for (let i = 0; i < defaultPlugins.length; i++) {
			this.plugin(require('./plugins/' + defaultPlugins[i]));
		}
	}

	/**
	 * Freon's request handler. This function should not be called directly.
	 * @param {request} req The incoming request.
	 * @param {response} res The response to be sent.
	 * @private
	 */
	request (req, res) {

		// Set the app property

		res.app = this;
		res.send404 = function() {
			this.writeHead(404, this.app.notFoundPageHeaders);
			if (shouldCompress(this.app.notFoundPage.length)) {
				this.endCompressed(this.app.notFoundPage, null, null, 404);
			} else {
				this.end(this.app.notFoundPage);
			}
		};
		
		// Check if this application is applicable

		let host = req.headers.host;

		// Check strings

		const strings = this.domains.strings;
		if (strings.indexOf(host) === -1) {

			// Check RegExps

			var canHandleRequest = false;

			const regExps = this.domains.regExps;
			for (let i = 0; i < regExps.length; i++) {
				if (regExps[i].test(host)) {

					// We can handle this request, keep going

					canHandleRequest = true;
				}
			}

			if (!canHandleRequest) {

				// We can't handle this request, send a 404 and return

				res.send404();
				return;
			}
		}

		// Load plugins

		var currentPlugin = 0;

		// Set req.url to a parsed version of itself for the static plugin
		// Only parse it if requested

		const oldUrl = req.url;

		Object.defineProperty(req, 'url', {
			'get' : function() {
				if (this.___url) {
					return this.___url;
				} else {
					this.___url = url.parse(oldUrl);
					return this.___url;
				}
			}
		});

		const loadPlugins = callback => {

			// Check if there are no more plugins to load

			if (currentPlugin < this.plugins.length) {

				// Load the plugin

				this.plugins[currentPlugin](req, res, () => {

					// When it is finished loading, load the next one

					currentPlugin++;
					loadPlugins(callback);
				});
			} else {

				// If we are finished, call back

				callback();

			}
		};

		loadPlugins(() => {
			// Check for a handler for this request if the plugin did not modify the request

			if (!req.headerSent) {

				var handlerCallback, regExpResult;

				for (let i = 0; i < this.handlers.length; i++) {
					const currentHandler = this.handlers[i];

					// If a method is specified (it does not accept all methods) and it is not the same as the current request method, skip this handler

					if (currentHandler.options.method && currentHandler.options.method !== req.method) continue;

					// Check if the request path matches

					const requestPath = req.url.pathname;

					if (currentHandler.options.pathname !== requestPath) {

						// If it doesn't, check if it is a RegExp
						// If it isn't a RegExp, skip this handler

						if (!(currentHandler.options.pathname instanceof RegExp)) continue;

						// Check if the RegExp matches

						regExpResult = currentHandler.options.pathname.exec(requestPath);

						// It doesn't match, continue searching
						
						if (!regExpResult) continue;
					}

					// All checks have passed, this is the handler

					handlerCallback = currentHandler.callback;

					// No need to search for more handlers, only one can handle the request anyway

					break;
				}

				// Check that a handler was found

				if (handlerCallback) {

					// Call the handler

					handlerCallback(req, res, regExpResult);
				} else {

					// If no handlers were found, send a 404

					res.send404();
				}
			}
		});
	}

	/** Adds a handler. For example: myApp.on({
	 *	 'path' : /\/.+\//,
	 *	 'method' : 'GET'
	 * }, (req, res) => {
	 *	 // Code...
	 * });
	 * @param {handler} options Options object.
	 * @param {handlerCallback} callback What to call back to when a request is made to this handler.
	*/

	on (options, callback) {

		// Add the handler

		this.handlers.push({
			'options' : options,
			'callback' : callback
		});
	}

	/**
	 * Adds a plugin to the application.
	 * @param {function} plugin The function to be called to load this plugin on a request.
	 */
	plugin(plugin) {
		this.plugins.push(plugin);
	}

	/**
	 * Adds a handler for a GET request. Simply calls the `on` method.
	 * @param {string|RegExp} path The path to listen for.
	 * @param {handlerCallback} callback What to call back to when a request is made to this handler.
	 */
	onGet (pathname, callback) {
		this.on({
			'method' : 'GET',
			'pathname' : pathname
		}, callback);
	}

	/**
	 * Adds a handler for a POST request. Simply calls the `on` method.
	 * @param {string|RegExp} path The path to listen for.
	 * @param {handlerCallback} callback What to call back to when a request is made to this handler.
	 */
	onPost (pathname, callback) {
		this.on({
			'method' : 'POST',
			'pathname' : pathname
		}, callback);
	}

	/**
	 * Adds a handler for a PUT request. Simply calls the `on` method.
	 * @param {string|RegExp} path The path to listen for.
	 * @param {handlerCallback} callback What to call back to when a request is made to this handler.
	 */
	onPut (pathname, callback) {
		this.on({
			'method' : 'PUT',
			'pathname' : pathname
		}, callback);
	}

	/**
	 * Adds a handler for a PUT request. Simply calls the `on` method.
	 * @param {string|RegExp} path The path to listen for.
	 * @param {handlerCallback} callback What to call back to when a request is made to this handler.
	 */
	onDelete (pathname, callback) {
		this.on({
			'method' : 'DELETE',
			'pathname' : pathname
		}, callback);
	}

	/**
	 * Adds a handler for any request method. Simply calls the `on` method.
	 * @param {string|RegExp} path The path to listen for.
	 * @param {handlerCallback} callback What to call back to when a request is made to this handler.
	 */
	onAny (pathname, callback) {
		this.on({
			'pathname' : pathname
		}, callback);
	}

	/**
	 * Should only be used for testing. Sends a 'fake' request.
	 * @param {Object} req The request.
	 * @param {Object} res The response.
	 */
	testRequest(req, res) {
		req = req || { };
		res = res || { };
		req = Object.assign(new Stream.Writable({
			write() { }, read() { }
		}), req);
		req.connection = Object.assign({
			'remoteAddress' : '127.0.0.1'
		}, req.connection);
		req.connection.destroy = req.connection.destroy || function() { };
		req.headers = Object.assign({
			'host' : 'localhost'
		}, req.headers);
		req.url = req.url || 'http://localhost';
		res = Object.assign(new Stream.Writable({
			write() { }, read() { }
		}), res);
		res.writeHead = res.writeHead || function () { };
		res.setHeader = res.setHeader || function() { };
		this.request(req, res);
	}

	/** Starts the server.
	 * @param {Number} port The port to host the HTTP server on.
	 * @param {Function=} callback What to call back to when the server starts.
	 * @param {Number=} httpsPort The port to host the HTTPS server on.
	 * @param {Object=} httpsOptions The 'key' and 'cert' to use in PEM format or the 'pfx' data to use. See https://nodejs.org/docs/latest-v5.x/api/https.html#https_https_createserver_options_requestlistener.
	 * @param {String=} hostname Only accept connections from this IP address. When omitted, connections from all IP addresses are handled.
	*/
	listen (port, callback, httpsPort, httpsOptions, hostname) {

		// Create the request handler

		const requestHandler = this.request.bind(this);

		// Create an HTTP server

		this.httpServer = http.createServer(requestHandler);

		// Create an HTTPS server, if desired

		if (httpsOptions) {

			// Create the server with the options

			this.http2Server = http2.createServer(httpsOptions, requestHandler);

			// Start listening for connections 

			this.http2Server.listen(httpsPort, () => {

				// Start the HTTP server when the HTTPS server is done hosting, then call back

				this.httpServer.listen(port, callback);
			});

			// Prevent the HTTP server from starting before the HTTPS server

			return;
		}

		// Start the HTTP server and call back

		this.httpServer.listen(port, callback);
	}
}

// Export the Freon application class

module.exports = {
	Application,
	static: require('./plugins/static.js')
};