hyper-express
Version:
High performance Node.js webserver with a simple-to-use API powered by uWebsockets.js under the hood.
609 lines (526 loc) • 24.7 kB
JavaScript
'use strict';
const path = require('path');
const fs = require('fs/promises');
const uWebSockets = require('uWebSockets.js');
const Route = require('./router/Route.js');
const Router = require('./router/Router.js');
const Request = require('./http/Request.js');
const Response = require('./http/Response.js');
const HostManager = require('./plugins/HostManager.js');
const WebsocketRoute = require('./ws/WebsocketRoute.js');
const { wrap_object, to_forward_slashes } = require('../shared/operators.js');
class Server extends Router {
#port;
#hosts;
#uws_instance;
#listen_socket;
#options = {
is_ssl: false,
auto_close: true,
fast_abort: false,
trust_proxy: false,
fast_buffers: false,
max_body_buffer: 16 * 1024,
max_body_length: 250 * 1024,
streaming: {},
};
/**
* Server instance options.
* @returns {Object}
*/
_options = null;
/**
* @param {Object} options Server Options
* @param {String=} options.cert_file_name Path to SSL certificate file to be used for SSL/TLS.
* @param {String=} options.key_file_name Path to SSL private key file to be used for SSL/TLS.
* @param {String=} options.passphrase Strong passphrase for SSL cryptographic purposes.
* @param {String=} options.dh_params_file_name Path to SSL Diffie-Hellman parameters file.
* @param {Boolean=} options.ssl_prefer_low_memory_usage Specifies uWebsockets to prefer lower memory usage while serving SSL.
* @param {Boolean=} options.fast_buffers Buffer.allocUnsafe is used when set to true for faster performance.
* @param {Boolean=} options.fast_abort Determines whether HyperExpress will abrubptly close bad requests. This can be much faster but the client does not receive an HTTP status code as it is a premature connection closure.
* @param {Boolean=} options.trust_proxy Specifies whether to trust incoming request data from intermediate proxy(s)
* @param {Number=} options.max_body_buffer Maximum body content to buffer in memory before a request data is handled. Behaves similar to `highWaterMark` in Node.js streams.
* @param {Number=} options.max_body_length Maximum body content length allowed in bytes. For Reference: 1kb = 1024 bytes and 1mb = 1024kb.
* @param {Boolean=} options.auto_close Whether to automatically close the server instance when the process exits. Default: true
* @param {Object} options.streaming Global content streaming options.
* @param {import('stream').ReadableOptions=} options.streaming.readable Global content streaming options for Readable streams.
* @param {import('stream').WritableOptions=} options.streaming.writable Global content streaming options for Writable streams.
*/
constructor(options = {}) {
// Only accept object as a parameter type for options
if (options == null || typeof options !== 'object')
throw new Error(
'HyperExpress: HyperExpress.Server constructor only accepts an object type for the options parameter.'
);
// Initialize extended Router instance
super();
super._is_app(true);
// Store options locally for access throughout processing
wrap_object(this.#options, options);
// Expose the options object for future use
this._options = this.#options;
try {
// Create underlying uWebsockets App or SSLApp to power HyperExpress
const { cert_file_name, key_file_name } = options;
this.#options.is_ssl = cert_file_name && key_file_name; // cert and key are required for SSL
if (this.#options.is_ssl) {
// Convert the certificate and key file names to absolute system paths
this.#options.cert_file_name = to_forward_slashes(path.resolve(cert_file_name));
this.#options.key_file_name = to_forward_slashes(path.resolve(key_file_name));
// Create an SSL app with the provided SSL options
this.#uws_instance = uWebSockets.SSLApp(this.#options);
} else {
// Create a non-SSL app since no SSL options were provided
this.#uws_instance = uWebSockets.App(this.#options);
}
} catch (error) {
// Convert all the options to string values for logging purposes
const _options = Object.keys(options)
.map((key) => `options.${key}: "${options[key]}"`)
.join('\n');
// Throw error if uWebsockets.js fails to initialize
throw new Error(
`new HyperExpress.Server(): Failed to create new Server instance due to an invalid configuration in options.\n${_options}`
);
}
// Initialize the HostManager for this Server instance
this.#hosts = new HostManager(this);
}
/**
* This object can be used to store properties/references local to this Server instance.
*/
locals = {};
/**
* @private
* This method binds a cleanup handler which automatically closes this Server instance.
*/
_bind_auto_close() {
const reference = this;
['exit', 'SIGINT', 'SIGUSR1', 'SIGUSR2', 'SIGTERM'].forEach((type) =>
process.once(type, () => reference.close())
);
}
/**
* Starts HyperExpress webserver on specified port and host, or unix domain socket.
*
* @param {Number|String} first Required. Port or unix domain socket path to listen on. Example: 80 or "/run/listener.sock"
* @param {(String|function(import('uWebSockets.js').listen_socket):void)=} second Optional. Host or callback to be called when the server is listening. Default: "0.0.0.0"
* @param {(function(import('uWebSockets.js').us_listen_socket):void)=} third Optional. Callback to be called when the server is listening.
* @returns {Promise<import('uWebSockets.js').us_listen_socket>} Promise which resolves to the listen socket when the server is listening.
*/
async listen(first, second, third) {
let port;
let path;
// Determine if first argument is a number or string castable to a port number
if (typeof first == 'number' || (+first > 0 && +first < 65536)) {
// Parse the port number
port = typeof first == 'string' ? +first : first;
} else if (typeof first == 'string') {
// Parse the path to a UNIX domain socket
path = first;
}
let host = '0.0.0.0'; // Host by default is 0.0.0.0
let callback; // Callback may be optionally provided as second or third argument
if (second) {
// If second argument is a function then it is the callback or else it is the host
if (typeof second === 'function') {
callback = second;
} else {
// Ensure the second argument is a string
if (typeof second == 'string') {
host = second;
} else {
throw new Error(
`HyperExpress.Server.listen(): The second argument must either be a callback function or a string as a hostname.`
);
}
// If we have a third argument and it is a function then it is the callback
if (third && typeof third === 'function') callback = third;
}
}
// If the server is using SSL then verify that the provided SSL certificate and key files exist and are readable
if (this.#options.is_ssl) {
const { cert_file_name, key_file_name } = this.#options;
try {
// Verify that both the key and cert files exist and are readable
await Promise.all([fs.access(key_file_name), fs.access(cert_file_name)]);
} catch (error) {
throw new Error(
`HyperExpress.Server.listen(): The provided SSL certificate file at "${cert_file_name}" or private key file at "${key_file_name}" does not exist or is not readable.\n${error}`
);
}
}
// Bind the server to the specified port or unix domain socket with uWS.listen() or uWS.listen_unix()
const reference = this;
return await new Promise((resolve, reject) => {
// Define a callback to handle the listen socket from a listen event
const on_listen_socket = (listen_socket) => {
// Compile the Server instance to cache the routes and middlewares
reference._compile();
// Determine if we received a listen socket
if (listen_socket) {
// Store the listen socket for future closure
reference.#listen_socket = listen_socket;
// Bind the auto close handler if enabled from constructor options
if (reference.#options.auto_close) reference._bind_auto_close();
// Serve the list socket over callback if provided
if (callback) callback(listen_socket);
// Resolve the listen socket
resolve(listen_socket);
} else {
reject(
'HyperExpress.Server.listen(): No Socket Received From uWebsockets.js likely due to an invalid host or busy port.'
);
}
};
// Determine whether to bind on a port or unix domain socket with priority to port
if (port !== undefined) {
reference.#uws_instance.listen(host, port, on_listen_socket);
} else {
reference.#uws_instance.listen_unix(on_listen_socket, path);
}
});
}
#shutdown_promise;
/**
* Performs a graceful shutdown of the server and closes the listen socket once all pending requests have been completed.
* @param {uWebSockets.us_listen_socket=} listen_socket Optional
* @returns {Promise<boolean>}
*/
shutdown(listen_socket) {
// If we already have a shutdown promise in flight, return it
if (this.#shutdown_promise) return this.#shutdown_promise;
// If we have no pending requests, we can shutdown immediately
if (!this.#pending_requests_count) return Promise.resolve(this.close(listen_socket));
// Create a promise which resolves once all pending requests have been completed
const scope = this;
this.#shutdown_promise = new Promise((resolve) => {
// Bind a zero pending request handler to close the server
scope.#pending_requests_zero_handler = () => {
// Close the server and resolve the returned boolean
resolve(scope.close(listen_socket));
};
});
return this.#shutdown_promise;
}
/**
* Stops/Closes HyperExpress webserver instance.
*
* @param {uWebSockets.us_listen_socket=} listen_socket Optional
* @returns {Boolean}
*/
close(listen_socket) {
// Fall back to self listen socket if none provided by user
const socket = listen_socket || this.#listen_socket;
if (socket) {
// Close the determined socket
uWebSockets.us_listen_socket_close(socket);
// Nullify the local socket reference if it was used
if (!listen_socket) this.#listen_socket = null;
return true;
}
return false;
}
#routes_locked = false;
#handlers = {
on_not_found: (request, response) => response.status(404).send(),
on_error: (request, response, error) => {
// Log the error to the console
console.error(error);
// Throw on default if user has not bound an error handler
return response.status(500).send('HyperExpress: Uncaught Exception Occured');
},
};
/**
* @typedef RouteErrorHandler
* @type {function(Request, Response, Error):void}
*/
/**
* Sets a global error handler which will catch most uncaught errors across all routes/middlewares.
*
* @param {RouteErrorHandler} handler
*/
set_error_handler(handler) {
if (typeof handler !== 'function') throw new Error('HyperExpress: handler must be a function');
this.#handlers.on_error = handler;
}
/**
* @typedef RouteHandler
* @type {function(Request, Response):void}
*/
/**
* Sets a global not found handler which will handle all requests that are unhandled by any registered route.
*
* @param {RouteHandler} handler
*/
set_not_found_handler(handler) {
if (typeof handler !== 'function') throw new Error('HyperExpress: handler must be a function');
this.#handlers.on_not_found = handler;
}
/**
* Publish a message to a topic in MQTT syntax to all WebSocket connections on this Server instance.
* You cannot publish using wildcards, only fully specified topics.
*
* @param {String} topic
* @param {String|Buffer|ArrayBuffer} message
* @param {Boolean=} is_binary
* @param {Boolean=} compress
* @returns {Boolean}
*/
publish(topic, message, is_binary, compress) {
return this.#uws_instance.publish(topic, message, is_binary, compress);
}
/**
* Returns the number of subscribers to a topic across all WebSocket connections on this Server instance.
*
* @param {String} topic
* @returns {Number}
*/
num_of_subscribers(topic) {
return this.#uws_instance.numSubscribers(topic);
}
/* Server Routes & Middlewares Logic */
#middlewares = {
'/': [], // This will contain global middlewares
};
#routes = {
any: {},
get: {},
post: {},
del: {},
head: {},
options: {},
patch: {},
put: {},
trace: {},
upgrade: {},
ws: {},
};
#incremented_id = 0;
/**
* Returns an incremented ID unique to this Server instance.
*
* @private
* @returns {Number}
*/
_get_incremented_id() {
return this.#incremented_id++;
}
/**
* Binds route to uWS server instance and begins handling incoming requests.
*
* @private
* @param {Object} record { method, pattern, options, handler }
*/
_create_route(record) {
// Destructure record into route options
const { method, pattern, options, handler } = record;
// Do not allow route creation once it is locked after a not found handler has been bound
if (this.#routes_locked === true)
throw new Error(
`HyperExpress: Routes/Routers must not be created or used after the Server.listen() has been called. [${method.toUpperCase()} ${pattern}]`
);
// Do not allow duplicate routes for performance/stability reasons
// We make an exception for 'upgrade' routes as they must replace the default route added by WebsocketRoute
if (method !== 'upgrade' && this.#routes[method][pattern])
throw new Error(
`HyperExpress: Failed to create route as duplicate routes are not allowed. Ensure that you do not have any routers or routes that try to handle requests with the same pattern. [${method.toUpperCase()} ${pattern}]`
);
// Create a Route object to contain route information through handling process
const route = new Route({
app: this,
method,
pattern,
options,
handler,
});
// Mark route as temporary if specified from options
if (options._temporary === true) route._temporary = true;
// Handle websocket/upgrade routes separately as they follow a different lifecycle
switch (method) {
case 'ws':
// Create a WebsocketRoute which initializes uWS.ws() route
this.#routes[method][pattern] = new WebsocketRoute({
app: this,
pattern,
handler,
options,
});
break;
case 'upgrade':
// Throw an error if an upgrade route already exists that was not created by WebsocketRoute
const current = this.#routes[method][pattern];
if (current && current._temporary !== true)
throw new Error(
`HyperExpress: Failed to create upgrade route as an upgrade route with the same pattern already exists and duplicate routes are not allowed. [${method.toUpperCase()} ${pattern}]`
);
// Overwrite the upgrade route that exists from WebsocketRoute with this custom route
this.#routes[method][pattern] = route;
// Assign route to companion WebsocketRoute
const companion = this.#routes['ws'][pattern];
if (companion) companion._set_upgrade_route(route);
break;
default:
// Store route in routes object for structural tracking
this.#routes[method][pattern] = route;
// Bind the uWS route handler which pipes all incoming uWS requests to the HyperExpress request lifecycle
return this.#uws_instance[method](pattern, (response, request) => {
this._handle_uws_request(route, request, response, null);
});
}
}
/**
* Binds middleware to server instance and distributes over all created routes.
*
* @private
* @param {Object} record
*/
_create_middleware(record) {
// Destructure record from Router
const { pattern, middleware } = record;
// Do not allow route creation once it is locked after a not found handler has been bound
if (this.#routes_locked === true)
throw new Error(
`HyperExpress: Routes/Routers must not be created or used after the Server.listen() has been called. [${method.toUpperCase()} ${pattern}]`
);
// Initialize middlewares array for specified pattern
if (this.#middlewares[pattern] == undefined) this.#middlewares[pattern] = [];
// Create a middleware object with an appropriate priority
const object = {
id: this._get_incremented_id(),
pattern,
handler: middleware,
};
// Store middleware object in its pattern branch
this.#middlewares[pattern].push(object);
}
/**
* Compiles the route and middleware structures for this instance for use in the uWS server.
* Note! This method will lock any future creation of routes or middlewares.
* @private
*/
_compile() {
// Bind the not found handler as a catchall route if the user did not already bind a global ANY catchall route
if (this.#handlers.on_not_found) {
const exists = this.#routes.any['/*'] !== undefined;
if (!exists) this.any('/*', (request, response) => this.#handlers.on_not_found(request, response));
}
// Iterate through all routes
Object.keys(this.#routes).forEach((method) =>
Object.keys(this.#routes[method]).forEach((pattern) => this.#routes[method][pattern].compile())
);
// Lock routes from further creation
this.#routes_locked = true;
}
/* uWS -> Server Request/Response Handling Logic */
#pending_requests_count = 0;
#pending_requests_zero_handler = null;
/**
* Resolves a single pending request and ticks sthe pending request handler if one exists.
*/
_resolve_pending_request() {
// Ensure we have at least one pending request
if (this.#pending_requests_count > 0) {
// Decrement the pending request count
this.#pending_requests_count--;
// If we have no more pending requests and a zero pending request handler was set, execute it
if (this.#pending_requests_count === 0 && this.#pending_requests_zero_handler)
this.#pending_requests_zero_handler();
}
}
/**
* This method is used to handle incoming requests from uWS and pass them to the appropriate route through the HyperExpress request lifecycle.
*
* @private
* @param {Route} route
* @param {uWebSockets.HttpRequest} uws_request
* @param {uWebSockets.HttpResponse} uws_response
* @param {uWebSockets.us_socket_context_t=} socket
*/
_handle_uws_request(route, uws_request, uws_response, socket) {
// Construct the wrapper Request around uWS.HttpRequest
const request = new Request(route, uws_request);
request._raw_response = uws_response;
// Construct the wrapper Response around uWS.Response
const response = new Response(uws_response);
response.route = route;
response._wrapped_request = request;
response._upgrade_socket = socket || null;
// If we are in the process of gracefully shutting down, we must immediately close the request
if (this.#pending_requests_zero_handler) return response.close();
// Increment the pending request count
this.#pending_requests_count++;
// Attempt to start the body parser for this request
// This method will return false If the request body is larger than the max_body_length
if (request._body_parser_run(response, route.max_body_length)) {
// Handle this request with the associated route
route.handle(request, response);
// If by this point the response has not been sent then this is request is being asynchronously handled hence we must cork when the response is sent
if (!response.completed) response._cork = true;
}
}
/* Safe Server Getters */
/**
* Returns the local server listening port of the server instance.
* @returns {Number}
*/
get port() {
// Initialize port if it does not exist yet
// Ensure there is a listening socket before returning port
if (this.#port === undefined) {
// Throw error if listening socket does not exist
if (!this.#listen_socket)
throw new Error(
'HyperExpress: Server.port is not available as the server is not listening. Please ensure you called already Server.listen() OR have not yet called Server.close() when accessing this property.'
);
// Cache the resolved port
this.#port = uWebSockets.us_socket_local_port(this.#listen_socket);
}
// Return port
return this.#port;
}
/**
* Returns the server's internal uWS listening socket.
* @returns {uWebSockets.us_listen_socket=}
*/
get socket() {
return this.#listen_socket;
}
/**
* Underlying uWS instance.
* @returns {uWebSockets.TemplatedApp}
*/
get uws_instance() {
return this.#uws_instance;
}
/**
* Returns the Server Hostnames manager for this instance.
* Use this to support multiple hostnames on the same server with different SSL configurations.
* @returns {HostManager}
*/
get hosts() {
return this.#hosts;
}
/**
* Server instance global handlers.
* @returns {Object}
*/
get handlers() {
return this.#handlers;
}
/**
* Server instance routes.
* @returns {Object}
*/
get routes() {
return this.#routes;
}
/**
* Server instance middlewares.
* @returns {Object}
*/
get middlewares() {
return this.#middlewares;
}
}
module.exports = Server;