import http from 'http' import { URL } from 'url' import stream from 'stream' /** * Router */ class Branch { constructor() { this.children = new Map() this.paramName = null this.fullparamName = null this.handler = null this.middlewares = [] } } export const ErrorCodes = { ERR_CONNECTION_ABORTED: 'ERR_CON_ABORTED' } // Taken from https://github.com/nfp-projects/koa-lite/blob/master/lib/statuses.js const statuses = { 100: 'Continue', 101: 'Switching Protocols', 102: 'Processing', 103: 'Early Hints', 200: 'OK', 201: 'Created', 202: 'Accepted', 203: 'Non-Authoritative Information', 204: 'No Content', 205: 'Reset Content', 206: 'Partial Content', 207: 'Multi-Status', 208: 'Already Reported', 226: 'IM Used', 300: 'Multiple Choices', 301: 'Moved Permanently', 302: 'Found', 303: 'See Other', 304: 'Not Modified', 305: 'Use Proxy', 306: '(Unused)', 307: 'Temporary Redirect', 308: 'Permanent Redirect', 400: 'Bad Request', 401: 'Unauthorized', 402: 'Payment Required', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed', 406: 'Not Acceptable', 407: 'Proxy Authentication Required', 408: 'Request Timeout', 409: 'Conflict', 410: 'Gone', 411: 'Length Required', 412: 'Precondition Failed', 413: 'Payload Too Large', 414: 'URI Too Long', 415: 'Unsupported Media Type', 416: 'Range Not Satisfiable', 417: 'Expectation Failed', 418: 'I\'m a teapot', 421: 'Misdirected Request', 422: 'Unprocessable Entity', 423: 'Locked', 424: 'Failed Dependency', 425: 'Too Early', 426: 'Upgrade Required', 428: 'Precondition Required', 429: 'Too Many Requests', 431: 'Request Header Fields Too Large', 451: 'Unavailable For Legal Reasons', 500: 'Internal Server Error', 501: 'Not Implemented', 502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Timeout', 505: 'HTTP Version Not Supported', 506: 'Variant Also Negotiates', 507: 'Insufficient Storage', 508: 'Loop Detected', 509: 'Bandwidth Limit Exceeded', 510: 'Not Extended', 511: 'Network Authentication Required', redirect: { 300: true, 301: true, 302: true, 303: true, 305: true, 307: true, 308: true }, empty: { 204: true, 205: true, 304: true } } const __paramMapName = '__param' const __fullParamMapName = '__fullparam' function assertIsHandler(handler, name) { if (typeof(handler) !== 'function') { throw new Error(`${name} was called with a handler that was not a function`) } } export function QueryHandler() { return function(ctx) { ctx.query = (new URL(ctx.req.url, 'http://localhost')).searchParams } } export function JsonHandler() { return async function(ctx) { const buffers = []; for await (const chunk of ctx.req) { buffers.push(chunk); } const data = Buffer.concat(buffers).toString(); ctx.req.body = JSON.parse(data) } } export class HttpError extends Error { constructor(statusCode, message, body = null) { super(message); Error.captureStackTrace(this, HttpError); let proto = Object.getPrototypeOf(this); proto.name = 'HttpError'; this.status = statusCode } } export class FlaskaRouter { constructor() { this.root = new Branch() } addRoute(route, orgMiddlewares, orgHandler) { if (route[0] !== '/') throw new Error(`route "${route}" must start with forward slash`) let middlewares = orgMiddlewares let handler = orgHandler if (!orgHandler) { handler = orgMiddlewares middlewares = [] } if (middlewares && typeof(middlewares) === 'function') { middlewares = [middlewares] } assertIsHandler(handler, 'addRoute()') let start = 1 let end = 1 let name = '' let param = '' let isParam = false let isFullParam = false let branch = this.root if (route.indexOf(':') < 0) { let name = route if (name.length > 1 && name[name.length - 1] === '/') { name = name.slice(0, -1) } let child = new Branch() branch.children.set(name, child) child.handler = handler child.middlewares = middlewares } for (let i = 1; i <= route.length; i++) { if ((i === route.length || route[i] === '/') && end > start) { if (branch.fullparamName) { throw new Error(`route "${route}" conflicts with a sub-branch that has a full param child`) } let child name = route.substring(start, end) if (isFullParam) { param = name name = __fullParamMapName } else if (isParam) { param = name name = __paramMapName } if (branch.children.has(name)) { child = branch.children.get(name) } else if (isParam && !isFullParam && branch.children.has(__fullParamMapName)) { throw new Error(`route "${route}" conflicts with a sub-branch that has a full param child`) } else if (isFullParam && branch.children.has(__paramMapName)) { throw new Error(`route "${route}" conflicts with a sub-branch that has a partial param child`) } else { child = new Branch() branch.children.set(name, child) } branch = child end = i start = i if (isParam) { if (branch.paramName && branch.paramName !== param) { throw new Error(`route "${route}" conflicts with pre-existing param name of ${branch.paramName} instead of ${param}`) } if (isFullParam) { branch.fullparamName = param } else { branch.paramName = param } isParam = false } } else if (route[i] === '/' && end === start) { throw new Error(`route "${route}" has missing path name inbetween slashes`) } if (i === route.length) { branch.handler = handler branch.middlewares = middlewares continue } if (route[i] === ':') { if (isParam) { isFullParam = true } isParam = true end = start = i + 1 } else if (route[i] === '/') { end = start = i + 1 } else { end++ } } } match(orgUrl) { let url = orgUrl if (url.length > 1 && url[url.length - 1] === '/') { url = url.slice(0, -1) } let branch = this.root let start = 1 let end = 1 let output let name let char let params = {} if (output = branch.children.get(url)) { return { handler: output.handler, middlewares: output.middlewares, params: params, } } for (let i = 1; i <= url.length; i++) { char = url[i] if ((i === url.length || char === '/') && end > start) { name = url.slice(start, end) if (output = branch.children.get(name)) { branch = output } else if (output = branch.children.get(__paramMapName)) { branch = output params[branch.paramName] = name } else if (output = branch.children.get(__fullParamMapName)) { params[output.fullparamName] = url.slice(start) return { handler: output.handler, middlewares: output.middlewares, params: params, } } else { if (output = this.root.children.get(__fullParamMapName)) { params = { [output.fullparamName]: url.slice(1) } return { handler: output.handler, middlewares: output.middlewares, params: params, } } return null } i++ end = start = i char = url[i] } // Check branch.handler. This can happen if route /::path is added // and request is '/' it will attempt to match root which will fail if (i >= url.length && branch.handler) { return { handler: branch.handler, middlewares: branch.middlewares, params: params, } } if (char === '/') { end = start = i + 1 } else { end++ } } if (output = this.root.children.get(__fullParamMapName)) { params = { [output.fullparamName]: url.slice(1) } return { handler: output.handler, middlewares: output.middlewares, params: params, } } return null } } /** * Flaska */ export class Flaska { constructor(opts, orgHttp = http, orgStream = stream) { this._before = [] this._beforeCompiled = null this._beforeAsync = [] this._beforeAsyncCompiled = null this._after = [] this._afterCompiled = null this._afterAsync = [] this._afterAsyncCompiled = null this._on404 = function(ctx) { ctx.status = 404 ctx.body = { status: 404, message: statuses[404], } } this._backuperror = this._onerror = function(err, ctx) { ctx.log.error(err) ctx.status = 500 ctx.body = { status: 500, message: statuses[500], } } this._onreqerror = function(err, ctx) { ctx.log.error(err) ctx.res.statusCode = ctx.statusCode = 400 ctx.res.end() } this._onreserror = function(err, ctx) { ctx.log.error(err) } let options = opts || {} this.log = options.log || console this.http = orgHttp this.stream = orgStream this.server = null this.routers = { 'GET': new FlaskaRouter(), 'POST': new FlaskaRouter(), 'PUT': new FlaskaRouter(), 'DELETE': new FlaskaRouter(), 'OPTIONS': new FlaskaRouter(), 'PATCH': new FlaskaRouter(), } this.get = this.routers.GET.addRoute.bind(this.routers.GET) this.post = this.routers.POST.addRoute.bind(this.routers.POST) this.put = this.routers.PUT.addRoute.bind(this.routers.PUT) this.delete = this.routers.DELETE.addRoute.bind(this.routers.DELETE) this.options = this.routers.OPTIONS.addRoute.bind(this.routers.OPTIONS) this.patch = this.routers.PATCH.addRoute.bind(this.routers.PATCH) } _assertIsHandler(handler, name) { if (typeof(handler) !== 'function') { throw new Error(`${name} was called with a handler that was not a function`) } } devMode() { this._backuperror = this._onerror = function(err, ctx) { ctx.log.error(err) ctx.status = 500 ctx.body = { status: 500, message: `${statuses[500]}: ${err.message}`, stack: err.stack || '', } } } on404(handler) { assertIsHandler(handler, 'on404()') this._on404 = handler } onerror(handler) { assertIsHandler(handler, 'onerror()') this._onerror = handler } onreqerror(handler) { assertIsHandler(handler, 'onreqerror()') this._onreqerror = handler } onreserror(handler) { assertIsHandler(handler, 'onreserror()') this._onreserror = handler } before(handler) { assertIsHandler(handler, 'before()') this._before.push(handler) } beforeAsync(handler) { assertIsHandler(handler, 'beforeAsync()') this._beforeAsync.push(handler) } after(handler) { assertIsHandler(handler, 'after()') this._after.push(handler) } afterAsync(handler) { assertIsHandler(handler, 'afterAsync()') this._afterAsync.push(handler) } requestStart(req, res) { let url = req.url let search = '' let hasSearch = url.indexOf('?') if (hasSearch > 0) { search = url.slice(hasSearch) url = url.slice(0, hasSearch) } let ctx = { log: this.log, req: req, res: res, method: req.method, url: url, search: search, state: {}, status: 200, query: new Map(), body: null, type: null, length: null, } req.on('error', (err) => { this._onreqerror(err, ctx) this.requestEnded(ctx) }) res.on('error', (err) => { this._onreserror(err, ctx) }) req.on('aborted', () => { ctx.aborted = true this.requestEnded(ctx) }) res.on('finish', () => { this.requestEnded(ctx) }) try { this._beforeCompiled(ctx) if (this._beforeAsyncCompiled) { return this._beforeAsyncCompiled(ctx) .then(() => { this.requestStartInternal(ctx) }).catch(err => { this.requestEnd(err, ctx) }) } this.requestStartInternal(ctx) } catch (err) { this.requestEnd(err, ctx) } } requestStartInternal(ctx) { let route = this.routers[ctx.method].match(ctx.url) if (!route) { let middle = this._on404(ctx) if (middle && middle.then) { return middle.then(() => { this.requestEnd(null, ctx) }, err => { this.requestEnd(err, ctx) }) } return this.requestEnd(null, ctx) } ctx.params = route.params if (route.middlewares.length) { let middle = this.handleMiddleware(ctx, route.middlewares, 0) if (middle && middle.then) { return middle.then(() => { return route.handler(ctx) }) .then(() => { this.requestEnd(null, ctx) }, err => { this.requestEnd(err, ctx) }) } } let handler = route.handler(ctx) if (handler && handler.then) { return handler.then(() => { this.requestEnd(null, ctx) }, err => { this.requestEnd(err, ctx) }) } this.requestEnd(null, ctx) } handleMiddleware(ctx, middles, index) { for (let i = index; i < middles.length; i++) { let res = middles[i](ctx) if (res && res.then) { return res.then(() => { return this.handleMiddleware(ctx, middles, i + 1) }) } } } requestEnd(err, ctx) { if (err) { try { this._onerror(err, ctx) } catch (err) { this._backuperror(err, ctx) } } if (ctx.res.writableEnded) { return } ctx.res.statusCode = ctx.status if (statuses.empty[ctx.status]) { return ctx.res.end() } let body = ctx.body let length = 0 if (body && typeof(body.pipe) === 'function') { ctx.res.setHeader('Content-Type', ctx.type || 'application/octet-stream') return this.stream.pipeline(body, ctx.res, function() {}) } if (typeof(body) === 'object') { body = JSON.stringify(body) length = Buffer.byteLength(body) ctx.res.setHeader('Content-Type', 'application/json; charset=utf-8') } else { body = body.toString() length = Buffer.byteLength(body) ctx.res.setHeader('Content-Type', ctx.type || 'text/plain; charset=utf-8') } ctx.res.setHeader('Content-Length', length) ctx.res.end(body) } requestEnded(ctx) { if (ctx.finished) return ctx.finished = true this._afterCompiled(ctx) if (this._afterAsyncCompiled) { return this._afterAsyncCompiled(ctx).then() } } compile() { let types = ['before', 'after'] for (let i = 0; i < types.length; i++) { let type = types[i] let args = '' let body = '' for (let i = 0; i < this['_' + type].length; i++) { args += `a${i}, ` body += `a${i}(ctx);` } args += 'ctx' let func = new Function(args, body) this[`_${type}Compiled`] = func.bind(this, ...this['_' + type]) if (this[`_${type}Async`].length) { args = '' body = 'return Promise.all([' for (let i = 0; i < this[`_${type}Async`].length; i++) { args += `a${i}, ` body += `a${i}(ctx),` } args += 'ctx' body += '])' func = new Function(args, body) this[`_${type}AsyncCompiled`] = func.bind(this, ...this[`_${type}Async`]) } } } listen(port, orgIp, orgcb) { let ip = orgIp let cb = orgcb if (!cb && typeof(orgIp) === 'function') { ip = '::' cb = orgIp } if (typeof(port) !== 'number') { throw new Error('Flaska.listen() called with non-number in port') } this.compile() this.server = this.http.createServer(this.requestStart.bind(this)) this.server.listen(port, ip, cb) } listenAsync(port, ip = '::') { if (typeof(port) !== 'number') { return Promise.reject(new Error('Flaska.listen() called with non-number in port')) } this.compile() this.server = this.http.createServer(this.requestStart.bind(this)) return new Promise((res, rej) => { this.server.listen(port, ip, function(err) { if (err) return rej(err) return res() }) }) } closeAsync() { if (!this.server) return Promise.resolve() return new Promise((res, rej) => { this.server.close(function(err) { if (err) { return rej(err) } // Waiting 0.1 second for it to close down setTimeout(function() { res() }, 100) }) }) } }