UNPKG

@radatek/microserver

Version:
1,285 lines 121 kB
/** * MicroServer * @version 2.1.0 * @package @radatek/microserver * @copyright Darius Kisonas 2022 * @license MIT */ import http from 'http'; import https from 'https'; import net from 'net'; import tls from 'tls'; import querystring from 'querystring'; import { Readable } from 'stream'; import fs from 'fs'; import path, { basename, extname } from 'path'; import crypto from 'crypto'; import zlib from 'zlib'; import { EventEmitter } from 'events'; const defaultToken = 'wx)>:ZUqVc+E,u0EmkPz%ZW@TFDY^3vm'; const defaultExpire = 24 * 60 * 60; const defaultMaxBodySize = 5 * 1024 * 1024; const defaultMethods = 'HEAD,GET,POST,PUT,PATCH,DELETE'; function NOOP(...args) { } export class Warning extends Error { constructor(text) { super(text); } } const commonCodes = { 404: 'Not found', 403: 'Access denied', 422: 'Invalid data' }; const commonTexts = { 'Not found': 404, 'Access denied': 403, 'Permission denied': 422, 'Invalid data': 422, InvalidData: 422, AccessDenied: 403, NotFound: 404, Failed: 422, OK: 200 }; export class ResponseError extends Error { static getStatusCode(text) { return typeof text === 'number' ? text : text && commonTexts[text] || 500; } static getStatusText(text) { return typeof text === 'number' ? commonCodes[text] : text?.toString() || 'Error'; } constructor(text, statusCode) { super(ResponseError.getStatusText(text || statusCode || 500)); this.statusCode = ResponseError.getStatusCode(statusCode || text) || 500; } } export class AccessDenied extends ResponseError { constructor(text) { super(text, 403); } } export class InvalidData extends ResponseError { constructor(text, type) { super(type ? text ? `Invalid ${type}: ${text}` : `Invalid ${type}` : text, 422); } } export class NotFound extends ResponseError { constructor(text) { super(text, 404); } } export class WebSocketError extends Error { constructor(text, code) { super(text); this.statusCode = code || 1002; } } export class Plugin { constructor(router, ...args) { } } /** Extended http.IncomingMessage */ export class ServerRequest extends http.IncomingMessage { constructor(router) { super(new net.Socket()); /** Request whole path */ this.path = '/'; /** Request pathname */ this.pathname = '/'; /** Base url */ this.baseUrl = '/'; this._init(router); } _init(router) { Object.assign(this, { router, protocol: 'encrypted' in this.socket && this.socket.encrypted ? 'https' : 'http', get: {}, params: {}, paramsList: [], path: '/', pathname: '/', baseUrl: '/', rawBody: [], rawBodySize: 0 }); this.updateUrl(this.url || '/'); } /** Update request url */ updateUrl(url) { this.url = url; if (!this.originalUrl) this.originalUrl = url; const parsedUrl = new URL(url || '/', 'body:/'), pathname = parsedUrl.pathname; this.pathname = pathname; this.path = pathname.slice(pathname.lastIndexOf('/')); this.baseUrl = pathname.slice(0, pathname.length - this.path.length); this.query = {}; parsedUrl.searchParams.forEach((v, k) => this.query[k] = v); } /** Rewrite request url */ rewrite(url) { throw new Error('Internal error'); } /** Request body: JSON or POST parameters */ get body() { if (!this._body) { if (this.method === 'GET') this._body = {}; else { const contentType = this.headers['content-type'] || '', charset = contentType.match(/charset=(\S+)/); let bodyString = Buffer.concat(this.rawBody).toString((charset ? charset[1] : 'utf8')); this._body = {}; if (bodyString.startsWith('{') || bodyString.startsWith('[')) { try { this._body = JSON.parse(bodyString); } catch { throw new Error('Invalid request format'); } } else if (contentType.startsWith('application/x-www-form-urlencoded')) { this._body = querystring.parse(bodyString); } } } return this._body; } /** Alias to body */ get post() { return this.body; } /** Get websocket */ get websocket() { if (!this._websocket) { if (!this.headers.upgrade) throw new Error('Invalid WebSocket request'); this._websocket = new WebSocket(this, { permessageDeflate: this.router.server.config.websocketCompress, maxPayload: this.router.server.config.websocketMaxPayload || 1024 * 1024, maxWindowBits: this.router.server.config.websocketMaxWindowBits || 10 }); } if (!this._websocket.ready) throw new Error('Invalid WebSocket request'); return this._websocket; } /** get files list in request */ async files() { this.resume(); delete this.headers.connection; const files = this._files; if (files) { if (files.resolve !== NOOP) throw new Error('Invalid request files usage'); return new Promise((resolve, reject) => { files.resolve = err => { files.done = true; files.resolve = NOOP; if (err) reject(err); else resolve(files.list); }; if (files.done) files.resolve(); }); } } /** Decode request body */ bodyDecode(res, options, next) { const contentType = (this.headers['content-type'] || '').split(';'); const maxSize = options.maxBodySize || defaultMaxBodySize; if (contentType.includes('multipart/form-data')) { const chunkParse = (chunk) => { const files = this._files; if (!files || files.done) return; chunk = files.chunk = files.chunk ? Buffer.concat([files.chunk, chunk]) : chunk; const p = chunk.indexOf(files.boundary) || -1; if (p >= 0 && chunk.length - p >= 2) { if (files.last) { if (p > 0) files.last.write(chunk.subarray(0, p)); files.last.srtream.close(); delete files.last.srtream; files.last = undefined; } let pe = p + files.boundary.length; if (chunk[pe] === 13 && chunk[pe + 1] === 10) { chunk = files.chunk = chunk.subarray(p); // next header pe = chunk.indexOf('\r\n\r\n'); if (pe > 0) { // whole header const header = chunk.toString('utf8', files.boundary.length + 2, pe); chunk = chunk.subarray(pe + 4); const fileInfo = header.match(/content-disposition: ([^\r\n]+)/i); const contentType = header.match(/content-type: ([^\r\n;]+)/i); let fieldName = '', fileName = ''; if (fileInfo) fileInfo[1].replace(/(\w+)="?([^";]+)"?/, (_, n, v) => { if (n === 'name') fieldName = v; if (n === 'filename') fileName = v; return _; }); if (fileName) { let file; do { file = path.resolve(path.join(files.uploadDir, crypto.randomBytes(16).toString('hex') + '.tmp')); } while (fs.existsSync(file)); files.last = { name: fieldName, fileName: fileName, contentType: contentType && contentType[1], file: file, stream: fs.createWriteStream(file) }; files.list.push(files.last); } else if (fieldName) { files.last = { name: fieldName, stream: { write: (chunk) => { if (!this._body) this._body = {}; this._body[fieldName] = (this._body[fieldName] || '') + chunk.toString(); }, close() { } } }; } } } else { files.chunk = undefined; files.done = true; } } else { if (chunk.length > 8096) { if (files.last) files.last.stream.write(chunk.subarray(0, files.boundary.length - 1)); chunk = files.chunk = chunk.subarray(files.boundary.length - 1); } } }; this.pause(); //res.setHeader('Connection', 'close') // TODO: check if this is needed this._body = {}; const files = this._files = { list: [], uploadDir: path.resolve(options.uploadDir || 'upload'), resolve: NOOP, boundary: '' }; if (!contentType.find(l => { const p = l.indexOf('boundary='); if (p >= 0) { files.boundary = '\r\n--' + l.slice(p + 9).trim(); return true; } })) return res.error(400); next(); this.once('error', () => files.resolve(new ResponseError('Request error'))) .on('data', chunk => chunkParse(chunk)) .once('end', () => files.resolve(new Error('Request error'))); res.on('finish', () => this._removeTempFiles()); res.on('error', () => this._removeTempFiles()); res.on('close', () => this._removeTempFiles()); } else { this.once('error', err => console.error(err)) .on('data', chunk => { this.rawBodySize += chunk.length; if (this.rawBodySize >= maxSize) { this.pause(); res.setHeader('Connection', 'close'); res.error(413); } else this.rawBody.push(chunk); }) .once('end', next); } } _removeTempFiles() { if (this._files) { if (!this._files.done) { this.pause(); this._files.resolve(new Error('Invalid request files usage')); } this._files.list.forEach(f => { if (f.stream) f.stream.close(); if (f.file) fs.unlink(f.file, NOOP); }); this._files = undefined; } } } /** Extends http.ServerResponse */ export class ServerResponse extends http.ServerResponse { constructor(router) { super(new http.IncomingMessage(new net.Socket())); this._init(router); } _init(router) { this.router = router; this.isJson = false; this.headersOnly = false; this.statusCode = 200; } /** Send error reponse */ error(error) { let code = 0; let text; if (error instanceof Error) { if ('statusCode' in error) code = error.statusCode; text = error.message; } else if (typeof error === 'number') { code = error; text = commonCodes[code] || 'Error'; } else text = error.toString(); if (!code && text) { code = ResponseError.getStatusCode(text); if (!code) { const m = text.match(/^(Error|Exception)?([\w ]+)(Error|Exception)?:\s*(.+)/i); if (m) { const errorId = m[2].toLowerCase(); code = ResponseError.getStatusCode(m[1]); text = m[2]; if (!code) { if (errorId.includes('access')) code = 403; else if (errorId.includes('valid') || errorId.includes('case') || errorId.includes('param') || errorId.includes('permission')) code = 422; else if (errorId.includes('busy') || errorId.includes('timeout')) code = 408; } } } } code = code || 500; try { if (code === 400 || code === 413) this.setHeader('Connection', 'close'); this.statusCode = code || 200; if (code < 200 || code === 204 || (code >= 300 && code <= 399)) return this.send(); if (this.isJson && (code < 300 || code >= 400)) this.send({ success: false, error: text ?? (commonCodes[this.statusCode] || http.STATUS_CODES[this.statusCode]) }); else this.send(text != null ? text : (this.statusCode + ' ' + (commonCodes[this.statusCode] || http.STATUS_CODES[this.statusCode]))); } catch (e) { this.statusCode = 500; this.send('Internal error'); console.error(e); } } /** Sets Content-Type acording to data and sends response */ send(data = '') { if (data instanceof Readable) return (data.pipe(this, { end: true }), void 0); if (!this.getHeader('Content-Type') && !(data instanceof Buffer)) { if (data instanceof Error) return this.error(data); if (this.isJson || typeof data === 'object') { data = JSON.stringify(typeof data === 'string' ? { message: data } : data); this.setHeader('Content-Type', 'application/json'); } else { data = data.toString(); if (data[0] === '{' || data[1] === '[') this.setHeader('Content-Type', 'application/json'); else if (data[0] === '<' && (data.startsWith('<!DOCTYPE') || data.startsWith('<html'))) this.setHeader('Content-Type', 'text/html'); else this.setHeader('Content-Type', 'text/plain'); } } data = data.toString(); this.setHeader('Content-Length', Buffer.byteLength(data, 'utf8')); if (this.headersOnly) this.end(); else this.end(data, 'utf8'); } /** Send json response */ json(data) { this.isJson = true; if (data instanceof Error) return this.error(data); this.send(data); } /** Send json response in form { success: false, error: err } */ jsonError(error) { this.isJson = true; if (typeof error === 'number') error = http.STATUS_CODES[error] || 'Error'; if (error instanceof Error) return this.json(error); this.json(typeof error === 'string' ? { success: false, error } : { success: false, ...error }); } /** Send json response in form { success: true, ... } */ jsonSuccess(data) { this.isJson = true; if (data instanceof Error) return this.json(data); this.json(typeof data === 'string' ? { success: true, message: data } : { success: true, ...data }); } /** Send redirect response to specified URL with optional status code (default: 302) */ redirect(code, url) { if (typeof code === 'string') { url = code; code = 302; } this.setHeader('Location', url || '/'); this.setHeader('Content-Length', 0); this.statusCode = code || 302; this.end(); } /** Set status code */ status(code) { this.statusCode = code; return this; } download(path, filename) { StaticPlugin.serveFile(this.req, this, { path: path, filename: filename || basename(path), mimeType: StaticPlugin.mimeTypes[extname(path)] || 'application/octet-stream' }); } } const EMPTY_BUFFER = Buffer.alloc(0); const DEFLATE_TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]); /** WebSocket class */ export class WebSocket extends EventEmitter { constructor(req, options) { super(); this._buffers = [EMPTY_BUFFER]; this._buffersLength = 0; this.ready = false; this._socket = req.socket; this._options = { maxPayload: 1024 * 1024, permessageDeflate: false, maxWindowBits: 15, ...options }; const key = req.headers['sec-websocket-key']; const upgrade = req.headers.upgrade; const version = +(req.headers['sec-websocket-version'] || 0); const extensions = req.headers['sec-websocket-extensions']; const headers = []; if (!key || !upgrade || upgrade.toLocaleLowerCase() !== 'websocket' || version !== 13 || req.method !== 'GET') { this._abort('Invalid WebSocket request', 400); return; } if (this._options.permessageDeflate && extensions?.includes('permessage-deflate')) { let header = 'Sec-WebSocket-Extensions: permessage-deflate'; if ((this._options.maxWindowBits || 15) < 15 && extensions.includes('client_max_window_bits')) header += `; client_max_window_bits=${this._options.maxWindowBits}`; headers.push(header); this._options.deflate = true; } this.ready = true; this._upgrade(key, headers); } _upgrade(key, headers = []) { const digest = crypto.createHash('sha1') .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') .digest('base64'); headers = [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', `Sec-WebSocket-Accept: ${digest}`, ...headers, '', '' ]; this._socket.write(headers.join('\r\n')); this._socket.on('error', this._errorHandler.bind(this)); this._socket.on('data', this._dataHandler.bind(this)); this._socket.on('close', () => this.emit('close')); this._socket.on('end', () => this.emit('end')); } /** Close connection */ close(reason, data) { if (reason !== undefined) { const buffer = Buffer.alloc(2 + (data ? data.length : 0)); buffer.writeUInt16BE(reason, 0); if (data) data.copy(buffer, 2); data = buffer; } return this._sendFrame(0x88, data || EMPTY_BUFFER, () => this._socket.destroy()); } /** Generate WebSocket frame from data */ static getFrame(data, options) { let msgType = 8; let dataLength = 0; if (typeof data === 'string') { msgType = 1; dataLength = Buffer.byteLength(data, 'utf8'); } else if (data instanceof Buffer) { msgType = 2; dataLength = data.length; } else if (typeof data === 'number') { msgType = data; } const headerSize = 2 + (dataLength < 126 ? 0 : dataLength < 65536 ? 2 : 8) + (dataLength && options?.mask ? 4 : 0); const frame = Buffer.allocUnsafe(headerSize + dataLength); frame[0] = 0x80 | msgType; frame[1] = dataLength > 65535 ? 127 : dataLength > 125 ? 126 : dataLength; if (dataLength > 65535) frame.writeBigUInt64BE(dataLength, 2); else if (dataLength > 125) frame.writeUInt16BE(dataLength, 2); if (dataLength && frame.length > dataLength) { if (typeof data === 'string') frame.write(data, headerSize, 'utf8'); else data.copy(frame, headerSize); } if (dataLength && options?.mask) { let i = headerSize, h = headerSize - 4; for (let i = 0; i < 4; i++) frame[h + i] = Math.floor(Math.random() * 256); for (let j = 0; j < dataLength; j++, i++) { frame[i] ^= frame[h + (j & 3)]; } } return frame; } /** Send data */ send(data) { let msgType = typeof data === 'string' ? 1 : 2; if (typeof data === 'string') data = Buffer.from(data, 'utf8'); if (this._options.deflate && data.length > 256) { const output = []; const deflate = zlib.createDeflateRaw({ windowBits: this._options.maxWindowBits }); deflate.write(data); deflate.on('data', (chunk) => output.push(chunk)); deflate.flush(() => { if (output.length > 0 && output[output.length - 1].length > 4) output[output.length - 1] = output[output.length - 1].subarray(0, output[output.length - 1].length - 4); this._sendFrame(0xC0 | msgType, Buffer.concat(output)); }); } else return this._sendFrame(0x80 | msgType, data); } _errorHandler(error) { this.emit('error', error); if (this.ready) this.close(error instanceof WebSocketError && error.statusCode || 1002); else this._socket.destroy(); this.ready = false; } _headerLength(buffer) { if (this._frame) return 0; if (!buffer || buffer.length < 2) return 2; let hederInfo = buffer[1]; return 2 + (hederInfo & 0x80 ? 4 : 0) + ((hederInfo & 0x7F) === 126 ? 2 : 0) + ((hederInfo & 0x7F) === 127 ? 8 : 0); } _dataHandler(data) { while (data.length) { let frame = this._frame; if (!frame) { let lastBuffer = this._buffers[this._buffers.length - 1]; this._buffers[this._buffers.length - 1] = lastBuffer = Buffer.concat([lastBuffer, data]); let headerLength = this._headerLength(lastBuffer); if (lastBuffer.length < headerLength) return; const headerBits = lastBuffer[0]; const lengthBits = lastBuffer[1] & 0x7F; this._buffers.pop(); data = lastBuffer.subarray(headerLength); // parse header frame = this._frame = { fin: (headerBits & 0x80) !== 0, rsv1: (headerBits & 0x40) !== 0, opcode: headerBits & 0x0F, mask: (lastBuffer[1] & 0x80) ? lastBuffer.subarray(headerLength - 4, headerLength) : EMPTY_BUFFER, length: lengthBits === 126 ? lastBuffer.readUInt16BE(2) : lengthBits === 127 ? lastBuffer.readBigUInt64BE(2) : lengthBits, lengthReceived: 0, index: this._buffers.length }; } let toRead = frame.length - frame.lengthReceived; if (toRead > data.length) toRead = data.length; if (this._options.maxPayload && this._options.maxPayload < this._buffersLength + frame.length) { this._errorHandler(new WebSocketError('Payload too big', 1009)); return; } // unmask for (let i = 0, j = frame.lengthReceived; i < toRead; i++, j++) data[i] ^= frame.mask[j & 3]; frame.lengthReceived += toRead; if (frame.lengthReceived < frame.length) { this._buffers.push(data); return; } this._buffers.push(data.subarray(0, toRead)); this._buffersLength += toRead; data = data.subarray(toRead); if (frame.opcode >= 8) { const message = Buffer.concat(this._buffers.splice(frame.index)); switch (frame.opcode) { case 8: if (!frame.length) this.emit('close'); else { const code = message.readInt16BE(0); if (frame.length === 2) this.emit('close', code); else this.emit('close', code, message.subarray(2)); } this._socket.destroy(); return; case 9: if (message.length) this.emit('ping', message); else this.emit('ping'); if (this._options.autoPong) this.pong(message); break; case 10: if (message.length) this.emit('pong', message); else this.emit('pong'); break; default: return this._errorHandler(new WebSocketError('Invalid WebSocket frame')); } } else if (frame.fin) { if (!frame.opcode) return this._errorHandler(new WebSocketError('Invalid WebSocket frame')); if (this._options.deflate && frame.rsv1) { const output = []; const inflate = zlib.createInflateRaw({ windowBits: this._options.maxWindowBits }); inflate.on('data', (chunk) => output.push(chunk)); inflate.on('error', (err) => this._errorHandler(err)); for (const buffer of this._buffers) inflate.write(buffer); inflate.write(DEFLATE_TRAILER); inflate.flush(() => { if (this.ready) { const message = Buffer.concat(output); this.emit('message', frame.opcode === 1 ? message.toString('utf8') : message); } }); } else { const message = Buffer.concat(this._buffers); this.emit('message', frame.opcode === 1 ? message.toString('utf8') : message); } this._buffers = []; this._buffersLength = 0; } this._frame = undefined; this._buffers.push(EMPTY_BUFFER); } } _abort(message, code, headers) { code = code || 400; message = message || http.STATUS_CODES[code] || 'Closed'; headers = [ `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}`, 'Connection: close', 'Content-Type: text/html', `Content-Length: ${Buffer.byteLength(message)}`, '', message ]; this._socket.once('finish', () => { this._socket.destroy(); this.emit('close'); }); this._socket.end(headers.join('\r\n')); this.emit('error', new Error(message)); } /** Send ping frame */ ping(buffer) { this._sendFrame(0x89, buffer || EMPTY_BUFFER); } /** Send pong frame */ pong(buffer) { this._sendFrame(0x8A, buffer || EMPTY_BUFFER); } _sendFrame(opcode, data, cb) { if (!this.ready) return; const dataLength = data.length; const headerSize = 2 + (dataLength < 126 ? 0 : dataLength < 65536 ? 2 : 8); const frame = Buffer.allocUnsafe(headerSize + (dataLength < 4096 ? dataLength : 0)); frame[0] = opcode; frame[1] = dataLength > 65535 ? 127 : dataLength > 125 ? 126 : dataLength; if (dataLength > 65535) frame.writeBigUInt64BE(dataLength, 2); else if (dataLength > 125) frame.writeUInt16BE(dataLength, 2); if (dataLength && frame.length > dataLength) { data.copy(frame, headerSize); this._socket.write(frame, cb); } else this._socket.write(frame, () => this._socket.write(data, cb)); } } const server = {}; /** * Controller for dynamic routes * * @example * ```js * class MyController extends Controller { * static model = MyModel; * static acl = 'auth'; * * static 'acl:index' = ''; * static 'url:index' = 'GET /index'; * async index (req, res) { * res.send('Hello World') * } * * //function name prefixes translated to HTTP methods: * // all => GET, get => GET, insert => POST, post => POST, * // update => PUT, put => PUT, delete => DELETE, * // modify => PATCH, patch => PATCH, * // websocket => internal WebSocket * // automatic acl will be: class_name + '/' + function_name_prefix * // automatic url will be: method + ' /' + class_name + '/' + function_name_without_prefix * * //static 'acl:allUsers' = 'MyController/all'; * //static 'url:allUsers' = 'GET /MyController/Users'; * async allUsers () { * return ['usr1', 'usr2', 'usr3'] * } * * //static 'acl:getOrder' = 'MyController/get'; * //static 'url:getOrder' = 'GET /Users/:id/:id1'; * static 'group:getOrder' = 'orders'; * static 'model:getOrder' = OrderModel; * async getOrder (id: string, id1: string) { * return {id, extras: id1, type: 'order'} * } * * //static 'acl:insertOrder' = 'MyController/insert'; * //static 'url:insertOrder' = 'POST /Users/:id'; * static 'model:insertOrder' = OrderModel; * async insertOrder (id: string, id1: string) { * return {id, extras: id1, type: 'order'} * } * * static 'acl:POST /login' = ''; * async 'POST /login' () { * return {id, extras: id1, type: 'order'} * } * } * ``` */ export class Controller { constructor(req, res) { this.req = req; this.res = res; res.isJson = true; } /** Generate routes for this controller */ static routes() { const routes = []; const prefix = Object.getOwnPropertyDescriptor(this, 'name')?.enumerable ? this.name + '/' : ''; // iterate throught decorators Object.getOwnPropertyNames(this.prototype).forEach(key => { if (key === 'constructor' || key.startsWith('_')) return; const func = this.prototype[key]; if (typeof func !== 'function') return; const thisStatic = this; let url = thisStatic['url:' + key]; let acl = thisStatic['acl:' + key] ?? thisStatic['acl']; const user = thisStatic['user:' + key] ?? thisStatic['user']; const group = thisStatic['group:' + key] ?? thisStatic['group']; const model = thisStatic['model:' + key] ?? thisStatic['model']; let method = ''; if (!url) key = key.replaceAll('$', '/'); if (!url && key.startsWith('/')) { method = '*'; url = key; } let keyMatch = !url && key.match(/^(all|get|put|post|patch|insert|update|modify|delete|websocket)[/_]?([\w_/-]*)$/i); if (keyMatch) { method = keyMatch[1]; url = '/' + prefix + keyMatch[2]; } keyMatch = !url && key.match(/^([*\w]+) (.+)$/); if (keyMatch) { method = keyMatch[1]; url = keyMatch[2].startsWith('/') ? keyMatch[2] : ('/' + prefix + keyMatch[1]); } keyMatch = !method && url?.match(/^([*\w]+) (.+)$/); if (keyMatch) { method = keyMatch[1]; url = keyMatch[2].startsWith('/') ? keyMatch[2] : ('/' + prefix + keyMatch[2]); } if (!method) return; let autoAcl = method.toLowerCase(); switch (autoAcl) { case '*': autoAcl = ''; break; case 'post': autoAcl = 'insert'; break; case 'put': autoAcl = 'update'; break; case 'patch': autoAcl = 'modify'; break; } method = method.toUpperCase(); switch (method) { case '*': break; case 'GET': case 'POST': case 'PUT': case 'PATCH': case 'DELETE': case 'WEBSOCKET': break; case 'ALL': method = 'GET'; break; case 'INSERT': method = 'POST'; break; case 'UPDATE': method = 'PUT'; break; case 'MODIFY': method = 'PATCH'; break; default: throw new Error('Invalid url method for: ' + key); } if (user === undefined && group === undefined && acl === undefined) acl = prefix + autoAcl; // add params if not available in url if (func.length && !url.includes(':')) { let args = ['/:id']; for (let i = 1; i < func.length; i++) args.push('/:id' + i); url += args.join(''); } const list = [method + ' ' + url.replace(/\/\//g, '/')]; if (acl) list.push('acl:' + acl); if (user) list.push('user:' + user); if (group) list.push('group:' + group); list.push((req, res) => { res.isJson = true; const obj = new this(req, res); if (model) { req.model = obj.model = model instanceof Model ? model : Model.models[model]; if (!obj.model) throw new InvalidData(model, 'model'); } return func.apply(obj, req.paramsList); }); routes.push(list); }); return routes; } } /** Router */ export class Router extends EventEmitter { /** @param {MicroServer} server */ constructor(server) { super(); this.plugins = {}; this._stack = []; this._stackAfter = []; this._tree = {}; this.server = server; } /** Handler */ handler(req, res, next, method) { const nextAfter = next; next = () => this._walkStack(this._stackAfter, req, res, nextAfter); if (method) return !this._walkTree(this._tree[method], req, res, next) && next(); const walk = () => { if (!this._walkTree(this._tree[req.method || 'GET'], req, res, next) && !this._walkTree(this._tree['*'], req, res, next)) next(); }; req.rewrite = (url) => { if (req.originalUrl) res.error(508); req.updateUrl(url); walk(); }; this._walkStack(this._stack, req, res, walk); } _walkStack(rstack, req, res, next) { let rnexti = 0; const sendData = (data) => { if (!res.headersSent && data !== undefined) { if ((data === null || typeof data === 'string') && !res.isJson) return res.send(data); if (typeof data === 'object' && (data instanceof Buffer || data instanceof Readable || (data instanceof Error && !res.isJson))) return res.send(data); return res.jsonSuccess(data); } }; const rnext = () => { const cb = rstack[rnexti++]; if (cb) { try { req.router = this; const p = cb(req, res, rnext); if (p instanceof Promise) p.catch(e => e).then(sendData); else sendData(p); } catch (e) { sendData(e); } } else return next(); }; return rnext(); } _walkTree(item, req, res, next) { req.params = {}; req.paramsList = []; const rstack = []; const reg = /\/([^/]*)/g; let m; let lastItem, done; while (m = reg.exec(req.pathname)) { const name = m[1]; if (!item || done) { item = undefined; break; } if (lastItem !== item) { lastItem = item; item.hook?.forEach((hook) => rstack.push(hook.bind(item))); } if (!item.tree) { // last if (item.name) { req.params[item.name] += '/' + name; req.paramsList[req.paramsList.length - 1] = req.params[item.name]; } else done = true; } else { item = item.last || item.tree[name] || item.param; if (item && item.name) { req.params[item.name] = name; req.paramsList.push(name); } } } if (lastItem !== item) item?.hook?.forEach((hook) => rstack.push(hook.bind(item))); item?.next?.forEach((cb) => rstack.push(cb)); if (!rstack.length) return; this._walkStack(rstack, req, res, next); return true; } _add(method, url, key, middlewares) { if (key === 'next') this.server.emit('route', { method, url, middlewares }); middlewares = middlewares.map(i => this.server.bind(i)); let item = this._tree[method]; if (!item) item = this._tree[method] = { tree: {} }; if (!url.startsWith('/')) { if (method === '*' && url === '') { this._stack.push(...middlewares); return this; } url = '/' + url; } const reg = /\/(:?)([^/*]+)(\*?)/g; let m; while (m = reg.exec(url)) { const param = m[1], name = m[2], last = m[3]; if (last) { item.last = { name: name }; item = item.last; } else { if (!item.tree) throw new Error('Invalid route path'); if (param) { item = item.param = item.param || { tree: {}, name: name }; } else { let subitem = item.tree[name]; if (!subitem) subitem = item.tree[name] = { tree: {} }; item = subitem; } } } if (!item[key]) item[key] = []; item[key].push(...middlewares); return this; } /** Clear routes and middlewares */ clear() { this._tree = {}; this._stack = []; return this; } /** * Add middleware route. * Middlewares may return promises for res.jsonSuccess(...), throw errors for res.error(...), return string or {} for res.send(...) * * @signature add(plugin: Plugin) * @param {Plugin} plugin plugin module instance * @return {Router} current router * * @signature add(pluginid: string, ...args: any) * @param {string} pluginid pluginid module * @param {...any} args arguments passed to constructor * @return {Router} current router * * @signature add(pluginClass: typeof Plugin, ...args: any) * @param {typeof Plugin} pluginClass plugin class * @param {...any} args arguments passed to constructor * @return {Router} current router * * @signature add(middleware: Middleware) * @param {Middleware} middleware * @return {Router} current router * * @signature add(methodUrl: string, ...middlewares: any) * @param {string} methodUrl 'METHOD /url' or '/url' * @param {...any} middlewares * @return {Router} current router * * @signature add(methodUrl: string, controllerClass: typeof Controller) * @param {string} methodUrl 'METHOD /url' or '/url' * @param {typeof Controller} controllerClass * @return {Router} current router * * @signature add(methodUrl: string, routes: Array<Array<any>>) * @param {string} methodUrl 'METHOD /url' or '/url' * @param {Array<Array<any>>} routes list with subroutes: ['METHOD /suburl', ...middlewares] * @return {Router} current router * * @signature add(methodUrl: string, routes: Array<Array<any>>) * @param {string} methodUrl 'METHOD /url' or '/url' * @param {Array<Array<any>>} routes list with subroutes: ['METHOD /suburl', ...middlewares] * @return {Router} current router * * @signature add(routes: { [key: string]: Array<any> }) * @param { {[key: string]: Array<any>} } routes list with subroutes: 'METHOD /suburl': [...middlewares] * @return {Router} current router * * @signature add(methodUrl: string, routes: { [key: string]: Array<any> }) * @param {string} methodUrl 'METHOD /url' or '/url' * @param { {[key: string]: Array<any>} } routes list with subroutes: 'METHOD /suburl': [...middlewares] * @return {Router} current router */ use(...args) { if (!args[0]) return this; // use(plugin) if (args[0] instanceof Plugin) return this._plugin(args[0]); // use(pluginid, ...args) if (typeof args[0] === 'string' && MicroServer.plugins[args[0]]) { const constructor = MicroServer.plugins[args[0]]; const plugin = new constructor(this, ...args.slice(1)); return this._plugin(plugin); } // use(PluginClass, ...args) if (args[0].prototype instanceof Plugin) { const plugin = new args[0](this, ...args.slice(1)); return this._plugin(plugin); } // use(middleware) if (typeof args[0] === 'function') { return this._middleware(args[0]); } let method = '*', url = '/'; if (typeof args[0] === 'string') { const m = args[0].match(/^([A-Z]+) (.*)/); if (m) [method, url] = [m[1], m[2]]; else url = args[0]; if (!url.startsWith('/')) throw new Error(`Invalid url ${url}`); args = args.slice(1); } // use('/url', ControllerClass) if (typeof args[0] === 'function' && args[0].prototype instanceof Controller) { const routes = args[0].routes(); if (routes) args[0] = routes; } // use('/url', [ ['METHOD /url', ...], {'METHOD } ]) if (Array.isArray(args[0])) { if (method !== '*') throw new Error('Invalid router usage'); args[0].forEach(item => { if (Array.isArray(item)) { // [methodUrl, ...middlewares] if (typeof item[0] !== 'string' || !item[0].match(/^(\w+ )?\//)) throw new Error('Url expected'); return this.use(item[0].replace(/\//, (url === '/' ? '' : url) + '/'), ...item.slice(1)); } else throw new Error('Invalid param'); }); return this; } // use('/url', {'METHOD /url': [...middlewares], ... } ]) if (typeof args[0] === 'object' && args[0].constructor === Object) { if (method !== '*') throw new Error('Invalid router usage'); for (const [subUrl, subArgs] of Object.entries(args[0])) { if (!subUrl.match(/^(\w+ )?\//)) throw new Error('Url expected'); this.use(subUrl.replace(/\//, (url === '/' ? '' : url) + '/'), ...(Array.isArray(subArgs) ? subArgs : [subArgs])); } return this; } // use('/url', ...middleware) return this._add(method, url, 'next', args.filter((o) => o)); } _middleware(middleware) { if (!middleware) return this; const priority = (middleware?.priority || 0) - 1; const stack = priority < -1 ? this._stackAfter : this._stack; const idx = stack.findIndex(f => 'priority' in f && priority >= (f.priority || 0)); stack.splice(idx < 0 ? stack.length : idx, 0, middleware); return this; } _plugin(plugin) { if (plugin.name) { if (this.plugins[plugin.name]) throw new Error(`Plugin ${plugin.name} already added`); this.plugins[plugin.name] = plugin; } if (plugin.handler) { const middleware = plugin.handler.bind(plugin); middleware.plugin = plugin; middleware.priority = plugin.priority; return this._middleware(middleware); } if (plugin.routes) { if (typeof plugin.routes === 'function') this.use(plugin.routes()); else this.use(plugin.routes); } return this; } /** Add hook */ hook(url, ...mid) { const m = url.match(/^([A-Z]+) (.*)/); let method = '*'; if (m) [method, url] = [m[1], m[2]]; return this._add(method, url, 'hook', mid); } /** Check if middleware allready added */ has(mid) { return this._stack.includes(mid) || (mid.name && !!this._stack.find(f => f.name === mid.name)) || false; } } export class MicroServer extends EventEmitter { get plugins() { return this.router.plugins; } constructor(config) { super(); this._ready = false; this._methods = {}; let promise = Promise.resolve(); this._init = (f, ...args) => { promise = promise.then(() => f.apply(this, args)).catch(e => this.emit('error', e)); }; this.config = { maxBodySize: defaultMaxBodySize, methods: defaultMethods, ...config, root: path.normalize(config.root || process.cwd()) }; (config.methods || defaultMethods).split(',').map(s => s.trim()).forEach(m => this._methods[m] = true); this.router = new Router(this); this.servers = new Set(); this.sockets = new Set(); if (config.routes) this.use(config.routes); for (const key in MicroServer.plugins) { if (config[key]) this.router.use(MicroServer.plugins[key], config[key]); } if (config.listen) this._init(() => { this.listen({ tls: config.tls, listen: config.listen || 8080 }); }); } /** Add one time listener or call immediatelly for 'ready' */ once(name, cb) { if (name === 'ready' && this._ready) cb(); else super.once(name, cb); return this; } /** Add listener and call immediatelly for 'ready' */ on(name, cb) { if (name === 'ready' && this._ready) cb(); super.on(name, cb); return this; } /** Listen server, should be used only if config.listen is not set */ listen(config) { const listen = (config?.listen || this.config.listen || 0) + ''; const handler = config?.handler || this.handler.bind(this); const tlsConfig = config ? config.tls : this.config.tls; const readFile = (data) => data && (data.indexOf('\n') > 0 ? data : fs.readFileSync(data)); function tlsOptions() { return { cert: readFile(tlsConfig?.cert), key: readFile(tlsConfig?.key), ca: readFile(tlsConfig?.ca) }; } function tlsOptionsReload(srv) { if (tlsConfig?.cert &