@radatek/microserver
Version:
HTTP MicroServer
1,285 lines • 121 kB
JavaScript
/**
* 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 &