558 lines
15 KiB
JavaScript
558 lines
15 KiB
JavaScript
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 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, cb) {
|
|
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, cb)
|
|
}
|
|
}
|