UNPKG

@alwatr/nano-server

Version:

Elegant powerful nodejs server for nanoservice use cases, written in tiny TypeScript module.

517 lines 17 kB
import { createServer } from 'node:http'; import { createLogger, definePackage } from '@alwatr/logger'; import { isNumber } from '@alwatr/math'; definePackage('nano-server', '1.x'); export class AlwatrNanoServer { /** * Create a server for nanoservice use cases. * * Example: * * ```ts * import {AlwatrNanoServer} from '@alwatr/nano-server'; * const nanoServer = new AlwatrNanoServer(); * * nanoServer.route('GET', '/', async (connection) => { * ok: true, * data: { * app: 'Alwatr Nanoservice Starter Kit', * message: 'Hello ;)', * }, * }; * ); * ``` */ constructor(config) { // prettier-ignore this.middlewareList = { ALL: {}, }; this._notFoundListener = (connection) => { return (connection.serverResponse, { ok: false, statusCode: 404, errorCode: 'not_found', meta: { method: connection.method, route: connection.url.pathname, }, }); }; this._config = { host: '0.0.0.0', port: 80, requestTimeout: 10000, headersTimeout: 130000, keepAliveTimeout: 120000, healthRoute: true, allowAllOrigin: false, prefixPattern: 'api', ...config, }; this._logger = createLogger('alwatr/nano-server' + (this._config.port !== 80 ? ':' + this._config.port : '')); this._logger.logMethodArgs?.('constructor', { config: this._config }); this._requestListener = this._requestListener.bind(this); this._errorListener = this._errorListener.bind(this); this._clientErrorListener = this._clientErrorListener.bind(this); this._onHealthCheckRequest = this._onHealthCheckRequest.bind(this); this.httpServer = createServer({ keepAlive: true, keepAliveInitialDelay: 0, noDelay: true, }, this._requestListener); this.httpServer.requestTimeout = this._config.requestTimeout; this.httpServer.keepAliveTimeout = this._config.keepAliveTimeout; this.httpServer.headersTimeout = this._config.headersTimeout; this.httpServer.on('error', this._errorListener); this.httpServer.on('clientError', this._clientErrorListener); if (this._config.healthRoute === true) { this.route('GET', '/health', this._onHealthCheckRequest); } if (this._config.allowAllOrigin === true) { this.route('OPTIONS', 'all', this._onHOptionRequest); } this.httpServer.listen(this._config.port, this._config.host, () => { this._logger.logOther?.(`listening on ${this._config.host}:${this._config.port}`); }); } /** * Stops the HTTP server from accepting new connections. * * Example: * * ```ts * nanoserver.close(); * ``` */ close() { this._logger.logMethod?.('close'); this.httpServer.close(); } /** * Refers to how an application’s endpoints (URIs) respond to client requests. * * @param method - Acceptable methods. * @param route - Acceptable request path. * @param middleware - Request handler. * * Example: * * ```ts * nanoServer.route('GET', '/', async (connection) => { * return { * ok: true, * data: { * app: 'Alwatr Nanoservice Starter Kit', * message: 'Hello ;)', * }, * }); * }; * ``` */ route(method, route, middleware) { this._logger.logMethodArgs?.('route', { method, route }); if (this.middlewareList[method] == null) this.middlewareList[method] = {}; if (typeof this.middlewareList[method][route] === 'function') { this._logger.accident('route', 'route_already_exists', { method, route, }); throw new Error('route_already_exists'); } this.middlewareList[method][route] = middleware; } /** * Responds to the request. * * Example: * ```ts * nanoServer.route('GET', '/', async (connection) => { * return { * ok: true, * data: { * app: 'Alwatr Nanoservice Starter Kit', * message: 'Hello ;)', * }, * }; * }); * ``` */ reply(serverResponse, content) { content.statusCode ?? (content.statusCode = 200); this._logger.logMethodArgs?.('reply', { ok: content.ok, statusCode: content.statusCode }); if (serverResponse.headersSent) { this._logger.error('reply', 'http_header_sent', 'Response headers already sent'); if (content.ok === false) return; // prevent loop. throw new Error('http_header_sent'); } if (!this._logger.devMode && !content.ok && content.meta) { // clear debug info from client for security reasons. delete content.meta; } let buffer; try { buffer = Buffer.from(JSON.stringify(content), 'utf8'); } catch (err) { this._logger.accident('responseData', 'data_stringify_failed', err); return this.reply(serverResponse, content.ok === false ? { ok: false, statusCode: content.statusCode, errorCode: content.errorCode, } : { ok: false, statusCode: 500, errorCode: 'data_stringify_failed', }); } const headers = { 'Content-Length': buffer.byteLength, 'Content-Type': 'application/json', Server: 'Alwatr NanoServer', }; if (this._config.allowAllOrigin === true) { headers['Access-Control-Allow-Origin'] = '*'; } try { serverResponse.writeHead(content.statusCode ?? 200, headers); serverResponse.end(buffer, 'binary'); } catch (err) { this._logger.error('reply', 'reply_failed', err); } } _errorListener(err) { if (err.code === 'EADDRINUSE') { this._logger.incident?.('server.onError', 'address_in_use', err); setTimeout(() => { this.httpServer.close(); this.httpServer.listen(this._config.port, this._config.host, () => { this._logger.logOther?.(`listening on ${this._config.host}:${this._config.port}`); }); }, 2000); } else { this._logger.error('server.onError', 'http_server_catch_error', err.message || 'HTTP server catch an error', err); } } _clientErrorListener(err, socket) { this._logger.accident('server.clientError', 'http_server_catch_client_error', { errCode: err.code, errMessage: err.message, }); socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); } _onHealthCheckRequest(connection) { const body = 'ok'; connection.serverResponse.writeHead(200, { 'Content-Length': body.length, 'Content-Type': 'plain/text', Server: 'Alwatr NanoServer', }); connection.serverResponse.end(body); return null; } _onHOptionRequest(connection) { connection.serverResponse.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': '*', 'Access-Control-Allow-Headers': '*', }); connection.serverResponse.end(); return null; } async _requestListener(incomingMessage, serverResponse) { this._logger.logMethod?.('handleRequest'); if (incomingMessage.url == null) { this._logger.accident('handleRequest', 'http_server_url_undefined'); return; } if (incomingMessage.method == null) { this._logger.accident('handleRequest', 'http_server_method_undefined'); return; } const connection = new AlwatrConnection(incomingMessage, serverResponse, { allowAllOrigin: this._config.allowAllOrigin, prefixPattern: this._config.prefixPattern, }); const route = connection.url.pathname; // TODO: handled open remained connections. const middleware = this.middlewareList[connection.method]?.[route] || this.middlewareList.ALL[route] || this.middlewareList[connection.method]?.all || this.middlewareList.ALL.all || this._notFoundListener; try { const content = await middleware(connection); if (content !== null) { this.reply(serverResponse, content); } } catch (errorObject) { if (typeof errorObject === 'object' && errorObject != null && 'ok' in errorObject) { this.reply(serverResponse, errorObject); } else { const err = errorObject; // 500 status code this._logger.error('handleRequest', 'http_server_middleware_error', err, { method: connection.method, route, }); this.reply(serverResponse, { ok: false, statusCode: 500, errorCode: 'http_server_middleware_error', meta: { name: err?.name, message: err?.message, cause: err?.cause, }, }); } } } } /** * Alwatr Connection */ export class AlwatrConnection { constructor(incomingMessage, serverResponse, _config) { this.incomingMessage = incomingMessage; this.serverResponse = serverResponse; this._config = _config; /** * Request URL. */ this.url = new URL((this.incomingMessage.url ?? '') .replace(new RegExp('^/' + this._config.prefixPattern), '') .replace(AlwatrConnection._versionPattern, ''), 'http://localhost/'); /** * Request method. */ this.method = (this.incomingMessage.method ?? 'GET').toUpperCase(); this._logger = createLogger('alwatr/nano-server-connection'); this._logger.logMethodArgs?.('constructor', { method: incomingMessage.method, url: incomingMessage.url }); } /** * Get the token placed in the request header. */ getAuthBearer() { const auth = this.incomingMessage.headers.authorization?.split(' '); if (auth == null || auth[0].toLowerCase() !== 'bearer') { return null; } return auth[1]; } /** * Get request body for POST, PUT and POST methods. * * Example: * ```ts * const body = await connection.getBody(); * ``` */ async getBody() { // method must be POST or PUT or PATCH if (!(this.method === 'POST' || this.method === 'PUT' || this.method === 'PATCH')) { return null; } let body = ''; this.incomingMessage.on('data', (chunk) => { body += chunk; }); await new Promise((resolve) => this.incomingMessage.once('end', resolve)); return body; } /** * Parse request body. * * @returns Request body. * * Example: * ```ts * const bodyData = await connection.requireJsonBody(); * ``` */ async requireJsonBody() { // if request content type is json if (this.incomingMessage.headers['content-type'] !== 'application/json') { // eslint-disable-next-line no-throw-literal throw { ok: false, statusCode: 400, errorCode: 'require_json_body', }; } const body = await this.getBody(); if (body == null || body.length === 0) { // eslint-disable-next-line no-throw-literal throw { ok: false, statusCode: 400, errorCode: 'require_body', }; } try { return JSON.parse(body); } catch (err) { // eslint-disable-next-line no-throw-literal throw { ok: false, statusCode: 400, errorCode: 'invalid_json', }; } } /** * Parse and validate request token. * * @returns Request token. * * Example: * ```ts * const token = connection.requireToken((token) => token.length > 12); * if (token == null) return; * ``` */ requireToken(validator) { const token = this.getAuthBearer(); if (token == null) { throw { ok: false, statusCode: 401, errorCode: 'authorization_required', }; } else if (validator === undefined) { return token; } else if (typeof validator === 'string') { if (token === validator) return token; } else if (Array.isArray(validator)) { if (validator.includes(token)) return token; } else if (typeof validator === 'function') { if (validator(token) === true) return token; } throw { ok: false, statusCode: 403, errorCode: 'access_denied', }; } /** * Parse and get request user auth (include id and token). * * Example: * ```ts * const userAuth = connection.requireUserAuth(); * ``` */ getUserAuth() { const auth = this.getAuthBearer() ?.split('/') .filter((item) => item.trim() !== ''); return auth == null || auth.length !== 2 ? null : { id: auth[0], token: auth[1], }; } /** * Parse query param and validate with param type. */ _sanitizeParam(name, type) { let value = this.url.searchParams.get(name); if (value == null || value === '') { return null; } if (type === 'string') { return value; } if (type === 'number') { return isNumber(value) ? +value : null; } if (type === 'boolean') { value = value.trim(); if (value === 'true' || value === '1') { return true; } else if (value === 'false' || value === '0') { return false; } else return null; } return null; } /** * Parse and validate query params. * * @returns Query params object. * * Example: * ```ts * const params = connection.requireQueryParams<{id: string}>({id: 'string'}); * console.log(params.id); * ``` */ requireQueryParams(params) { const parsedParams = {}; for (const paramName in params) { if (!Object.prototype.hasOwnProperty.call(params, paramName)) continue; const paramType = params[paramName]; const paramValue = (parsedParams[paramName] = this._sanitizeParam(paramName, paramType)); if (paramValue == null) { // eslint-disable-next-line no-throw-literal throw { ok: false, statusCode: 406, errorCode: 'query_parameter_required', meta: { paramName, paramType, paramValue, }, }; } } return parsedParams; } getRemoteAddress() { // prettier-ignore return (this.incomingMessage.headers['x-forwarded-for'] ?.split(',') .pop() ?.trim() || this.incomingMessage.socket.remoteAddress || 'unknown'); } requireClientId() { const clientId = this.incomingMessage.headers['client-id']; if (!clientId) { // eslint-disable-next-line no-throw-literal throw { ok: false, statusCode: 401, errorCode: 'client_denied', }; } return clientId; } } AlwatrConnection._versionPattern = new RegExp('^/v[0-9]+'); //# sourceMappingURL=nano-server.js.map