UNPKG

@radatek/microserver

Version:
1,275 lines (1,274 loc) 125 kB
/** * MicroServer * @version 2.3.11 * @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) { } function isFunction(fn) { if (typeof fn !== 'function') return false; const descriptor = Object.getOwnPropertyDescriptor(fn, 'prototype'); return !descriptor || descriptor.writable === true; } 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, auth: router.auth, protocol: 'encrypted' in this.socket && this.socket.encrypted ? 'https' : 'http', query: {}, 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; } file(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, timeout: 120000, ...options }; this._socket.setTimeout(this._options.timeout || 120000); 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: ' + message.startsWith('<') ? 'text/html' : 'text/plain', `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 { get model() { return this.req.model; } 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 modelName = 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 (modelName) { req.model = modelName instanceof Model ? modelName : Model.models[modelName]; if (!obj.model) throw new InvalidData(modelName, 'model'); req.model = Model.dynamic(req.model, { controller: obj }); } return func.apply(obj, req.paramsList); }); routes.push(list); }); return routes; } } class WaiterJob { constructor() { this._waiters = []; this._busy = 0; } start() { this._busy++; } end() { this._busy--; for (const resolve of this._waiters.splice(0)) resolve(); } async wait() { if (!this._busy) return; return new Promise(resolve => this._waiters.push(resolve)); } } class Waiter { constructor() { this._id = 0; this._waiters = {}; } isBusy(id) { const waiter = this._waiters[id || 'ready']; return !!waiter?._busy; } startJob(id) { let waiter = this._waiters[id || 'ready']; if (!waiter) waiter = this._waiters[id || 'ready'] = new WaiterJob(); waiter._busy++; } endJob(id) { const waiter = this._waiters[id || 'ready']; if (waiter) waiter.end(); } get nextId() { return (++this._id).toString(); } async wait(id) { const waiter = this._waiters[id || 'ready']; if (!waiter) return; return waiter.wait(); } } /** Router */ export class Router extends EventEmitter { /** @param {MicroServer} server */ constructor(server) { super(); this.plugins = {}; this._stack = []; this._stackAfter = []; this._tree = {}; this._waiter = new Waiter(); this.server = server; } /** bind middleware or create one from string like: 'redirect:302,https://redirect.to', 'error:422', 'param:name=value', 'acl:users/get', 'model:User', 'group:Users', 'user:admin' */ bind(fn) { if (typeof fn === 'string') { let name = fn; let idx = name.indexOf(':'); if (idx < 0 && name.includes('=')) { name = 'param:' + name; idx = 5; } if (name === 'json') return (req, res, next) => { res.isJson = true; return next(); }; if (idx >= 0) { const v = name.slice(idx + 1); const type = name.slice(0, idx); // predefined middlewares switch (type) { // redirect:302,https://redirect.to case 'redirect': { let redirect = v.split(','), code = parseInt(v[0]); if (!code || code < 301 || code > 399) code = 302; return (req, res) => res.redirect(code, redirect[1] || v); } // error:422 case 'error': return (req, res) => res.error(parseInt(v) || 422); // param:name=value case 'param': { idx = v.indexOf('='); if (idx > 0) { const prm = v.slice(0, idx), val = v.slice(idx + 1); return (req, res, next) => { req.params[prm] = val; return next(); }; } break; } case 'model': { const model = v; return (req, res) => { res.isJson = true; req.params.model = model; req.model = Model.models[model]; if (!req.model) { console.error(`Data model ${model} not defined for request ${req.path}`); return res.error(422); } return req.model.handler(req, res); }; } // user:userid // group:user_groupid // acl:validacl case 'user': case 'group': case 'acl': return (req, res, next) => { if (type === 'user' && v === req.user?.id) return next(); if (type === 'acl') { req.params.acl = v; if (req.auth?.acl(v)) return next(); } if (type === 'group') { req.params.group = v; if (req.user?.group === v) return next(); } const accept = req.headers.accept || ''; if (!res.isJson && req.auth?.options.redirect && req.method === 'GET' && !accept.includes('json') && (accept.includes('html') || accept.includes('*/*'))) { if (req.auth.options.redirect && req.url !== req.auth.options.redirect) return res.redirect(302, req.auth.options.redirect); else if (req.auth.options.mode !== 'cookie') { res.setHeader('WWW-Authenticate', `Basic realm="${req.auth.options.realm}"`); return res.error(401); } } return res.error('Permission denied'); }; } } throw new Error('Invalid option: ' + name); } if (fn && typeof fn === 'object' && 'handler' in fn && typeof fn.handler === 'function') return fn.handler.bind(fn); if (typeof fn !== 'function') throw new Error('Invalid middleware: ' + String.toString.call(fn)); return fn.bind(this); } /** Handler */ handler(req, res, next, method) { const nextAfter = next; next = () => this._walkStack(this._stackAfter, req, res, nextAfter); const walkTree = (method) => this._walkTree(this._tree[method], req, res, next); const walk = method ? () => { !walkTree(method) && next(); } : () => { !walkTree(req.method || 'GET') && !walkTree('*') && 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) { // TODO: walk recursively and add to stack all possibilities: /api/user/:id, /api/:last*. set params and paramsList pro stack record 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.tree[name] || item.param || item.last; 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(m => this.bind(m)); 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); } /** 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 {Promise<>} * * @signature add(pluginid: string, ...args: any) * @param {string} pluginid pluginid module * @param {...any} args arguments passed to constructor * @return {Promise<>} * * @signature add(pluginClass: typeof Plugin, ...args: any) * @param {typeof Plugin} pluginClass plugin class * @param {...any} args arguments passed to constructor * @return {Promise<>} * * @signature add(middleware: Middleware) * @param {Middleware} middleware * @return {Promise<>} * * @signature add(methodUrl: string, ...middlewares: any) * @param {string} methodUrl 'METHOD /url' or '/url' * @param {...any} middlewares * @return {Promise<>} * * @signature add(methodUrl: string, controllerClass: typeof Controller) * @param {string} methodUrl 'METHOD /url' or '/url' * @param {typeof Controller} controllerClass * @return {Promise<>} * * @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 {Promise<>} * * @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 {Promise<>} * * @signature add(routes: { [key: string]: Array<any> }) * @param { {[key: string]: Array<any>} } routes list with subroutes: 'METHOD /suburl': [...middlewares] * @return {Promise<>} * * @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 {Promise<>} */ async use(...args) { if (!args[0]) return; this.server._waiter.startJob(); for (let i = 0; i < args.length; i++) args[i] = await args[i]; // use(plugin) if (args[0] instanceof Plugin) { await this._plugin(args[0]); return this.server._waiter.endJob(); } // 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)); await this._plugin(plugin);