@intentjs/hyper-express
Version:
A fork of hyper-express to suit IntentJS requirements. High performance Node.js webserver with a simple-to-use API powered by uWebsockets.js under the hood.
487 lines (425 loc) • 18.5 kB
JavaScript
'use strict';
const { merge_relative_paths } = require('../../shared/operators.js');
/**
* @typedef {import('../compatibility/NodeRequest.js')} NodeRequest
* @typedef {import('../compatibility/NodeResponse.js').NodeResponseTypes} NodeResponse
* @typedef {import('../compatibility/ExpressRequest.js')} ExpressRequest
* @typedef {import('../compatibility/ExpressResponse.js')} ExpressResponse
* @typedef {import('../http/Request.js')} NativeRequest
* @typedef {import('../http/Response.js')} NativeResponse
* @typedef {NativeRequest & NodeRequest & ExpressRequest & import('stream').Stream} Request
* @typedef {NativeResponse & NodeResponse & ExpressResponse & import('stream').Stream} Response
* @typedef {function(Request, Response, Function):any|Promise<any>} MiddlewareHandler
*/
class Router {
#is_app = false;
#context_pattern;
#subscribers = [];
#records = {
routes: [],
middlewares: [],
};
constructor() {}
/**
* Used by the server to declare self as an app instance.
*
* @private
* @param {Boolean} value
*/
_is_app(value) {
this.#is_app = value;
}
/**
* Sets context pattern for this router which will auto define the pattern of each route called on this router.
* This is called by the .route() returned Router instance which allows for omission of pattern to be passed to route() method.
* Example: Router.route('/something/else').get().post().delete() etc will all be bound to pattern '/something/else'
* @private
* @param {string} path
*/
_set_context_pattern(path) {
this.#context_pattern = path;
}
/**
* Registers a route in the routes array for this router.
*
* @private
* @param {String} method Supported: any, get, post, delete, head, options, patch, put, trace
* @param {String} pattern Example: "/api/v1"
* @param {Object} options Route processor options (Optional)
* @param {Function} handler Example: (request, response) => {}
* @returns {this} Chainable instance
*/
_register_route() {
// The first argument will always be the method (in lowercase)
const method = arguments[0];
// The pattern, options and handler must be dynamically parsed depending on the arguments provided and router behavior
let pattern, options, handler;
// Iterate through the remaining arguments to find the above values and also build an Array of middleware / handler callbacks
// The route handler will be the last one in the array
const callbacks = [];
for (let i = 1; i < arguments.length; i++) {
const argument = arguments[i];
// The second argument should be the pattern. If it is a string, it is the pattern. If it is anything else and we do not have a context pattern, throw an error as that means we have no pattern.
if (i === 1) {
if (typeof argument === 'string') {
if (this.#context_pattern) {
// merge the provided pattern with the context pattern
pattern = merge_relative_paths(this.#context_pattern, argument);
} else {
// The path is as is
pattern = argument;
}
// Continue to the next argument as this is not the pattern but we have a context pattern
continue;
} else if (!this.#context_pattern) {
throw new Error(
'HyperExpress.Router: Route pattern is required unless created from a chainable route instance using Route.route() method.',
);
} else {
// The path is the context pattern
pattern = this.#context_pattern;
}
}
// Look for options, middlewares and handler in the remaining arguments
if (typeof argument == 'function') {
// Scenario: Single function
callbacks.push(argument);
} else if (Array.isArray(argument)) {
// Scenario: Array of functions
callbacks.push(...argument);
} else if (argument && typeof argument == 'object') {
// Scenario: Route options object
options = argument;
}
}
// Write the route handler and route options object with fallback to the default options
handler = callbacks.pop();
options = {
streaming: {},
middlewares: [],
...(options || {}),
};
// Make a shallow copy of the options object to avoid mutating the original
options = Object.assign({}, options);
// Enforce a leading slash on the pattern if it begins with a catchall star
// This is because uWebsockets.js does not treat non-leading slashes as catchall stars
if (pattern.startsWith('*')) pattern = '/' + pattern;
// Parse the middlewares into a new array to prevent mutating the original
const middlewares = [];
// Push all the options provided middlewares into the middlewares array
if (Array.isArray(options.middlewares)) middlewares.push(...options.middlewares);
// Push all the callback provided middlewares into the middlewares array
if (callbacks.length > 0) middlewares.push(...callbacks);
// Write the middlewares into the options object
options.middlewares = middlewares;
// Initialize the record object which will hold information about this route
const record = {
method,
pattern,
options,
handler,
};
// Store record for future subscribers
this.#records.routes.push(record);
// Create route if this is a Server extended Router instance (ROOT)
if (this.#is_app) return this._create_route(record);
// Alert all subscribers of the new route that was created
this.#subscribers.forEach((subscriber) => subscriber('route', record));
// Return this to make the Router chainable
return this;
}
/**
* Registers a middleware from use() method and recalibrates.
*
* @private
* @param {String} pattern
* @param {Function} middleware
*/
_register_middleware(pattern, middleware) {
const record = {
pattern: pattern.endsWith('/') ? pattern.slice(0, -1) : pattern, // Do not allow trailing slash in middlewares
middleware,
};
// Store record for future subscribers
this.#records.middlewares.push(record);
// Create middleware if this is a Server extended Router instance (ROOT)
if (this.#is_app) return this._create_middleware(record);
// Alert all subscribers of the new middleware that was created
this.#subscribers.forEach((subscriber) => subscriber('middleware', record));
}
/**
* Registers a router from use() method and recalibrates.
*
* @private
* @param {String} pattern
* @param {this} router
*/
_register_router(pattern, router) {
const reference = this;
router._subscribe((event, object) => {
switch (event) {
case 'records':
// Destructure records from router
const { routes, middlewares } = object;
// Register routes from router locally with adjusted pattern
routes.forEach((record) =>
reference._register_route(
record.method,
merge_relative_paths(pattern, record.pattern),
record.options,
record.handler,
),
);
// Register middlewares from router locally with adjusted pattern
return middlewares.forEach((record) =>
reference._register_middleware(
merge_relative_paths(pattern, record.pattern),
record.middleware,
),
);
case 'route':
// Register route from router locally with adjusted pattern
return reference._register_route(
object.method,
merge_relative_paths(pattern, object.pattern),
object.options,
object.handler,
);
case 'middleware':
// Register middleware from router locally with adjusted pattern
return reference._register_middleware(
merge_relative_paths(pattern, object.patch),
object.middleware,
);
}
});
}
/* Router public methods */
/**
* Subscribes a handler which will be invocated with changes.
*
* @private
* @param {*} handler
*/
_subscribe(handler) {
// Pipe all records on first subscription to synchronize
handler('records', this.#records);
// Register subscriber handler for future updates
this.#subscribers.push(handler);
}
/**
* Registers middlewares and router instances on the specified pattern if specified.
* If no pattern is specified, the middleware/router instance will be mounted on the '/' root path by default of this instance.
*
* @param {...(String|MiddlewareHandler|Router)} args (request, response, next) => {} OR (request, response) => new Promise((resolve, reject) => {})
* @returns {this} Chainable instance
*/
use() {
// If we have a context pattern, then this is a contextual Chainable and should not allow middlewares or routers to be bound to it
if (this.#context_pattern)
throw new Error(
'HyperExpress.Router.use() -> Cannot bind middlewares or routers to a contextual router created using Router.route() method.',
);
// Parse a pattern for this use call with a fallback to the local-global scope aka. '/' pattern
const pattern = arguments[0] && typeof arguments[0] == 'string' ? arguments[0] : '/';
// Validate that the pattern value does not contain any wildcard or path parameter prefixes which are not allowed
if (pattern.indexOf('*') > -1 || pattern.indexOf(':') > -1)
throw new Error(
'HyperExpress: Server/Router.use() -> Wildcard "*" & ":parameter" prefixed paths are not allowed when binding middlewares or routers using this method.',
);
// Register each candidate individually depending on the type of candidate value
for (let i = 0; i < arguments.length; i++) {
const candidate = arguments[i];
if (typeof candidate == 'function') {
// Scenario: Single function
this._register_middleware(pattern, candidate);
} else if (Array.isArray(candidate)) {
// Scenario: Array of functions
candidate.forEach((middleware) => this._register_middleware(pattern, middleware));
} else if (typeof candidate == 'object' && candidate.constructor.name === 'Router') {
// Scenario: Router instance
this._register_router(pattern, candidate);
} else if (candidate && typeof candidate == 'object' && typeof candidate.middleware == 'function') {
// Scenario: Inferred middleware for third-party middlewares which support the Middleware.middleware property
this._register_middleware(pattern, candidate.middleware);
}
}
// Return this to make the Router chainable
return this;
}
/**
* @typedef {Object} RouteOptions
* @property {Number} max_body_length Overrides the global maximum body length specified in Server constructor options.
* @property {Array.<MiddlewareHandler>} middlewares Route specific middlewares
* @property {Object} streaming Global content streaming options.
* @property {import('stream').ReadableOptions} streaming.readable Global content streaming options for Readable streams.
* @property {import('stream').WritableOptions} streaming.writable Global content streaming options for Writable streams.
*/
/**
* Returns a Chainable instance which can be used to bind multiple method routes or middlewares on the same path easily.
* Example: `Router.route('/api/v1').get(getHandler).post(postHandler).delete(destroyHandler)`
* Example: `Router.route('/api/v1').use(middleware).user(middleware2)`
* @param {String} pattern
* @returns {this} A Chainable instance with a context pattern set to this router's pattern.
*/
route(pattern) {
// Ensure that the pattern is a string
if (!pattern || typeof pattern !== 'string')
throw new Error('HyperExpress.Router.route(pattern) -> pattern must be a string.');
// Create a new router instance with the context pattern set to the provided pattern
const router = new Router();
router._set_context_pattern(pattern);
this.use(router);
// Return the router instance to allow for chainable bindings
return router;
}
/**
* Creates an HTTP route that handles any HTTP method requests.
* Note! ANY routes do not support route specific middlewares.
*
* @param {String} pattern
* @param {...(RouteOptions|MiddlewareHandler)} args
*/
any() {
return this._register_route('any', ...arguments);
}
/**
* Alias of any() method.
* Creates an HTTP route that handles any HTTP method requests.
* Note! ANY routes do not support route specific middlewares.
*
* @param {String} pattern
* @param {...(RouteOptions|MiddlewareHandler)} args
*/
all() {
// Alias of any() method
return this.any(...arguments);
}
/**
* Creates an HTTP route that handles GET method requests.
*
* @param {String} pattern
* @param {...(RouteOptions|MiddlewareHandler)} args
*/
get() {
return this._register_route('get', ...arguments);
}
/**
* Creates an HTTP route that handles POST method requests.
*
* @param {String} pattern
* @param {...(RouteOptions|MiddlewareHandler)} args
*/
post() {
return this._register_route('post', ...arguments);
}
/**
* Creates an HTTP route that handles PUT method requests.
*
* @param {String} pattern
* @param {...(RouteOptions|MiddlewareHandler)} args
*/
put() {
return this._register_route('put', ...arguments);
}
/**
* Creates an HTTP route that handles DELETE method requests.
*
* @param {String} pattern
* @param {...(RouteOptions|MiddlewareHandler)} args
*/
delete() {
return this._register_route('del', ...arguments);
}
/**
* Creates an HTTP route that handles HEAD method requests.
*
* @param {String} pattern
* @param {...(RouteOptions|MiddlewareHandler)} args
*/
head() {
return this._register_route('head', ...arguments);
}
/**
* Creates an HTTP route that handles OPTIONS method requests.
*
* @param {String} pattern
* @param {...(RouteOptions|MiddlewareHandler)} args
*/
options() {
return this._register_route('options', ...arguments);
}
/**
* Creates an HTTP route that handles PATCH method requests.
*
* @param {String} pattern
* @param {...(RouteOptions|MiddlewareHandler)} args
*/
patch() {
return this._register_route('patch', ...arguments);
}
/**
* Creates an HTTP route that handles TRACE method requests.
*
* @param {String} pattern
* @param {...(RouteOptions|MiddlewareHandler)} args
*/
trace() {
return this._register_route('trace', ...arguments);
}
/**
* Creates an HTTP route that handles CONNECT method requests.
*
* @param {String} pattern
* @param {...(RouteOptions|MiddlewareHandler)} args
*/
connect() {
return this._register_route('connect', ...arguments);
}
/**
* Intercepts and handles upgrade requests for incoming websocket connections.
* Note! You must call response.upgrade(data) at some point in this route to open a websocket connection.
*
* @param {String} pattern
* @param {...(RouteOptions|MiddlewareHandler)} args
*/
upgrade() {
return this._register_route('upgrade', ...arguments);
}
/**
* @typedef {Object} WSRouteOptions
* @property {('String'|'Buffer'|'ArrayBuffer')} message_type Specifies data type in which to provide incoming websocket messages. Default: 'String'
* @property {Number} compression Specifies preset for permessage-deflate compression. Specify one from HyperExpress.compressors.PRESET
* @property {Number} idle_timeout Specifies interval to automatically timeout/close idle websocket connection in seconds. Default: 32
* @property {Number} max_backpressure Specifies maximum websocket backpressure allowed in character length. Default: 1024 * 1024
* @property {Number} max_payload_length Specifies maximum length allowed on incoming messages. Default: 32 * 1024
*/
/**
* @typedef WSRouteHandler
* @type {function(import('../ws/Websocket.js')):void}
*/
/**
* @param {String} pattern
* @param {WSRouteOptions|WSRouteHandler} options
* @param {WSRouteHandler} handler
*/
ws(pattern, options, handler) {
return this._register_route('ws', pattern, options, handler);
}
/* Route getters */
/**
* Returns All routes in this router in the order they were registered.
* @returns {Array}
*/
get routes() {
return this.#records.routes;
}
/**
* Returns all middlewares in this router in the order they were registered.
* @returns {Array}
*/
get middlewares() {
return this.#records.middlewares;
}
}
module.exports = Router;