You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
642 lines
20 KiB
642 lines
20 KiB
'use strict'
|
|
|
|
/*
|
|
Char codes:
|
|
'#': 35
|
|
'*': 42
|
|
'-': 45
|
|
'/': 47
|
|
':': 58
|
|
';': 59
|
|
'?': 63
|
|
*/
|
|
|
|
const assert = require('assert')
|
|
const http = require('http')
|
|
const fastDecode = require('fast-decode-uri-component')
|
|
const isRegexSafe = require('safe-regex2')
|
|
const Node = require('./node')
|
|
const NODE_TYPES = Node.prototype.types
|
|
const httpMethods = http.METHODS
|
|
const FULL_PATH_REGEXP = /^https?:\/\/.*?\//
|
|
|
|
if (!isRegexSafe(FULL_PATH_REGEXP)) {
|
|
throw new Error('the FULL_PATH_REGEXP is not safe, update this module')
|
|
}
|
|
|
|
const acceptVersionStrategy = require('./lib/accept-version')
|
|
|
|
function Router (opts) {
|
|
if (!(this instanceof Router)) {
|
|
return new Router(opts)
|
|
}
|
|
opts = opts || {}
|
|
|
|
if (opts.defaultRoute) {
|
|
assert(typeof opts.defaultRoute === 'function', 'The default route must be a function')
|
|
this.defaultRoute = opts.defaultRoute
|
|
} else {
|
|
this.defaultRoute = null
|
|
}
|
|
|
|
if (opts.onBadUrl) {
|
|
assert(typeof opts.onBadUrl === 'function', 'The bad url handler must be a function')
|
|
this.onBadUrl = opts.onBadUrl
|
|
} else {
|
|
this.onBadUrl = null
|
|
}
|
|
|
|
this.caseSensitive = opts.caseSensitive === undefined ? true : opts.caseSensitive
|
|
this.ignoreTrailingSlash = opts.ignoreTrailingSlash || false
|
|
this.maxParamLength = opts.maxParamLength || 100
|
|
this.allowUnsafeRegex = opts.allowUnsafeRegex || false
|
|
this.versioning = opts.versioning || acceptVersionStrategy
|
|
this.tree = new Node({ versions: this.versioning.storage() })
|
|
this.routes = []
|
|
}
|
|
|
|
Router.prototype.on = function on (method, path, opts, handler, store) {
|
|
if (typeof opts === 'function') {
|
|
if (handler !== undefined) {
|
|
store = handler
|
|
}
|
|
handler = opts
|
|
opts = {}
|
|
}
|
|
// path validation
|
|
assert(typeof path === 'string', 'Path should be a string')
|
|
assert(path.length > 0, 'The path could not be empty')
|
|
assert(path[0] === '/' || path[0] === '*', 'The first character of a path should be `/` or `*`')
|
|
// handler validation
|
|
assert(typeof handler === 'function', 'Handler should be a function')
|
|
|
|
this._on(method, path, opts, handler, store)
|
|
|
|
if (this.ignoreTrailingSlash && path !== '/' && !path.endsWith('*')) {
|
|
if (path.endsWith('/')) {
|
|
this._on(method, path.slice(0, -1), opts, handler, store)
|
|
} else {
|
|
this._on(method, path + '/', opts, handler, store)
|
|
}
|
|
}
|
|
}
|
|
|
|
Router.prototype._on = function _on (method, path, opts, handler, store) {
|
|
if (Array.isArray(method)) {
|
|
for (var k = 0; k < method.length; k++) {
|
|
this._on(method[k], path, opts, handler, store)
|
|
}
|
|
return
|
|
}
|
|
|
|
// method validation
|
|
assert(typeof method === 'string', 'Method should be a string')
|
|
assert(httpMethods.indexOf(method) !== -1, `Method '${method}' is not an http method.`)
|
|
|
|
const params = []
|
|
var j = 0
|
|
|
|
this.routes.push({
|
|
method: method,
|
|
path: path,
|
|
opts: opts,
|
|
handler: handler,
|
|
store: store
|
|
})
|
|
|
|
const version = opts.version
|
|
|
|
for (var i = 0, len = path.length; i < len; i++) {
|
|
// search for parametric or wildcard routes
|
|
// parametric route
|
|
if (path.charCodeAt(i) === 58) {
|
|
var nodeType = NODE_TYPES.PARAM
|
|
j = i + 1
|
|
var staticPart = path.slice(0, i)
|
|
|
|
if (this.caseSensitive === false) {
|
|
staticPart = staticPart.toLowerCase()
|
|
}
|
|
|
|
// add the static part of the route to the tree
|
|
this._insert(method, staticPart, NODE_TYPES.STATIC, null, null, null, null, version)
|
|
|
|
// isolate the parameter name
|
|
var isRegex = false
|
|
while (i < len && path.charCodeAt(i) !== 47) {
|
|
isRegex = isRegex || path[i] === '('
|
|
if (isRegex) {
|
|
i = getClosingParenthensePosition(path, i) + 1
|
|
break
|
|
} else if (path.charCodeAt(i) !== 45) {
|
|
i++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
if (isRegex && (i === len || path.charCodeAt(i) === 47)) {
|
|
nodeType = NODE_TYPES.REGEX
|
|
} else if (i < len && path.charCodeAt(i) !== 47) {
|
|
nodeType = NODE_TYPES.MULTI_PARAM
|
|
}
|
|
|
|
var parameter = path.slice(j, i)
|
|
var regex = isRegex ? parameter.slice(parameter.indexOf('('), i) : null
|
|
if (isRegex) {
|
|
regex = new RegExp(regex)
|
|
if (!this.allowUnsafeRegex) {
|
|
assert(isRegexSafe(regex), `The regex '${regex.toString()}' is not safe!`)
|
|
}
|
|
}
|
|
params.push(parameter.slice(0, isRegex ? parameter.indexOf('(') : i))
|
|
|
|
path = path.slice(0, j) + path.slice(i)
|
|
i = j
|
|
len = path.length
|
|
|
|
// if the path is ended
|
|
if (i === len) {
|
|
var completedPath = path.slice(0, i)
|
|
if (this.caseSensitive === false) {
|
|
completedPath = completedPath.toLowerCase()
|
|
}
|
|
return this._insert(method, completedPath, nodeType, params, handler, store, regex, version)
|
|
}
|
|
// add the parameter and continue with the search
|
|
staticPart = path.slice(0, i)
|
|
if (this.caseSensitive === false) {
|
|
staticPart = staticPart.toLowerCase()
|
|
}
|
|
this._insert(method, staticPart, nodeType, params, null, null, regex, version)
|
|
|
|
i--
|
|
// wildcard route
|
|
} else if (path.charCodeAt(i) === 42) {
|
|
this._insert(method, path.slice(0, i), NODE_TYPES.STATIC, null, null, null, null, version)
|
|
// add the wildcard parameter
|
|
params.push('*')
|
|
return this._insert(method, path.slice(0, len), NODE_TYPES.MATCH_ALL, params, handler, store, null, version)
|
|
}
|
|
}
|
|
|
|
if (this.caseSensitive === false) {
|
|
path = path.toLowerCase()
|
|
}
|
|
|
|
// static route
|
|
this._insert(method, path, NODE_TYPES.STATIC, params, handler, store, null, version)
|
|
}
|
|
|
|
Router.prototype._insert = function _insert (method, path, kind, params, handler, store, regex, version) {
|
|
const route = path
|
|
var currentNode = this.tree
|
|
var prefix = ''
|
|
var pathLen = 0
|
|
var prefixLen = 0
|
|
var len = 0
|
|
var max = 0
|
|
var node = null
|
|
|
|
while (true) {
|
|
prefix = currentNode.prefix
|
|
prefixLen = prefix.length
|
|
pathLen = path.length
|
|
len = 0
|
|
|
|
// search for the longest common prefix
|
|
max = pathLen < prefixLen ? pathLen : prefixLen
|
|
while (len < max && path[len] === prefix[len]) len++
|
|
|
|
// the longest common prefix is smaller than the current prefix
|
|
// let's split the node and add a new child
|
|
if (len < prefixLen) {
|
|
node = new Node(
|
|
{ prefix: prefix.slice(len),
|
|
children: currentNode.children,
|
|
kind: currentNode.kind,
|
|
handlers: new Node.Handlers(currentNode.handlers),
|
|
regex: currentNode.regex,
|
|
versions: currentNode.versions }
|
|
)
|
|
if (currentNode.wildcardChild !== null) {
|
|
node.wildcardChild = currentNode.wildcardChild
|
|
}
|
|
|
|
// reset the parent
|
|
currentNode
|
|
.reset(prefix.slice(0, len), this.versioning.storage())
|
|
.addChild(node)
|
|
|
|
// if the longest common prefix has the same length of the current path
|
|
// the handler should be added to the current node, to a child otherwise
|
|
if (len === pathLen) {
|
|
if (version) {
|
|
assert(!currentNode.getVersionHandler(version, method), `Method '${method}' already declared for route '${route}' version '${version}'`)
|
|
currentNode.setVersionHandler(version, method, handler, params, store)
|
|
} else {
|
|
assert(!currentNode.getHandler(method), `Method '${method}' already declared for route '${route}'`)
|
|
currentNode.setHandler(method, handler, params, store)
|
|
}
|
|
currentNode.kind = kind
|
|
} else {
|
|
node = new Node({
|
|
prefix: path.slice(len),
|
|
kind: kind,
|
|
handlers: null,
|
|
regex: regex,
|
|
versions: this.versioning.storage()
|
|
})
|
|
if (version) {
|
|
node.setVersionHandler(version, method, handler, params, store)
|
|
} else {
|
|
node.setHandler(method, handler, params, store)
|
|
}
|
|
currentNode.addChild(node)
|
|
}
|
|
|
|
// the longest common prefix is smaller than the path length,
|
|
// but is higher than the prefix
|
|
} else if (len < pathLen) {
|
|
// remove the prefix
|
|
path = path.slice(len)
|
|
// check if there is a child with the label extracted from the new path
|
|
node = currentNode.findByLabel(path)
|
|
// there is a child within the given label, we must go deepen in the tree
|
|
if (node) {
|
|
currentNode = node
|
|
continue
|
|
}
|
|
// there are not children within the given label, let's create a new one!
|
|
node = new Node({ prefix: path, kind: kind, handlers: null, regex: regex, versions: this.versioning.storage() })
|
|
if (version) {
|
|
node.setVersionHandler(version, method, handler, params, store)
|
|
} else {
|
|
node.setHandler(method, handler, params, store)
|
|
}
|
|
|
|
currentNode.addChild(node)
|
|
|
|
// the node already exist
|
|
} else if (handler) {
|
|
if (version) {
|
|
assert(!currentNode.getVersionHandler(version, method), `Method '${method}' already declared for route '${route}' version '${version}'`)
|
|
currentNode.setVersionHandler(version, method, handler, params, store)
|
|
} else {
|
|
assert(!currentNode.getHandler(method), `Method '${method}' already declared for route '${route}'`)
|
|
currentNode.setHandler(method, handler, params, store)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
Router.prototype.reset = function reset () {
|
|
this.tree = new Node({ versions: this.versioning.storage() })
|
|
this.routes = []
|
|
}
|
|
|
|
Router.prototype.off = function off (method, path) {
|
|
var self = this
|
|
|
|
if (Array.isArray(method)) {
|
|
return method.map(function (method) {
|
|
return self.off(method, path)
|
|
})
|
|
}
|
|
|
|
// method validation
|
|
assert(typeof method === 'string', 'Method should be a string')
|
|
assert(httpMethods.indexOf(method) !== -1, `Method '${method}' is not an http method.`)
|
|
// path validation
|
|
assert(typeof path === 'string', 'Path should be a string')
|
|
assert(path.length > 0, 'The path could not be empty')
|
|
assert(path[0] === '/' || path[0] === '*', 'The first character of a path should be `/` or `*`')
|
|
|
|
// Rebuild tree without the specific route
|
|
const ignoreTrailingSlash = this.ignoreTrailingSlash
|
|
var newRoutes = self.routes.filter(function (route) {
|
|
if (!ignoreTrailingSlash) {
|
|
return !(method === route.method && path === route.path)
|
|
}
|
|
if (path.endsWith('/')) {
|
|
const routeMatches = path === route.path || path.slice(0, -1) === route.path
|
|
return !(method === route.method && routeMatches)
|
|
}
|
|
const routeMatches = path === route.path || (path + '/') === route.path
|
|
return !(method === route.method && routeMatches)
|
|
})
|
|
if (ignoreTrailingSlash) {
|
|
newRoutes = newRoutes.filter(function (route, i, ar) {
|
|
if (route.path.endsWith('/') && i < ar.length - 1) {
|
|
return route.path.slice(0, -1) !== ar[i + 1].path
|
|
} else if (route.path.endsWith('/') === false && i < ar.length - 1) {
|
|
return (route.path + '/') !== ar[i + 1].path
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
self.reset()
|
|
newRoutes.forEach(function (route) {
|
|
self.on(route.method, route.path, route.opts, route.handler, route.store)
|
|
})
|
|
}
|
|
|
|
Router.prototype.lookup = function lookup (req, res, ctx) {
|
|
var handle = this.find(req.method, sanitizeUrl(req.url), this.versioning.deriveVersion(req, ctx))
|
|
if (handle === null) return this._defaultRoute(req, res, ctx)
|
|
return ctx === undefined
|
|
? handle.handler(req, res, handle.params, handle.store)
|
|
: handle.handler.call(ctx, req, res, handle.params, handle.store)
|
|
}
|
|
|
|
Router.prototype.find = function find (method, path, version) {
|
|
if (path.charCodeAt(0) !== 47) { // 47 is '/'
|
|
path = path.replace(FULL_PATH_REGEXP, '/')
|
|
}
|
|
|
|
var originalPath = path
|
|
var originalPathLength = path.length
|
|
|
|
if (this.caseSensitive === false) {
|
|
path = path.toLowerCase()
|
|
}
|
|
|
|
var maxParamLength = this.maxParamLength
|
|
var currentNode = this.tree
|
|
var wildcardNode = null
|
|
var pathLenWildcard = 0
|
|
var decoded = null
|
|
var pindex = 0
|
|
var params = []
|
|
var i = 0
|
|
var idxInOriginalPath = 0
|
|
|
|
while (true) {
|
|
var pathLen = path.length
|
|
var prefix = currentNode.prefix
|
|
var prefixLen = prefix.length
|
|
var len = 0
|
|
var previousPath = path
|
|
// found the route
|
|
if (pathLen === 0 || path === prefix) {
|
|
var handle = version === undefined
|
|
? currentNode.handlers[method]
|
|
: currentNode.getVersionHandler(version, method)
|
|
if (handle !== null && handle !== undefined) {
|
|
var paramsObj = {}
|
|
if (handle.paramsLength > 0) {
|
|
var paramNames = handle.params
|
|
|
|
for (i = 0; i < handle.paramsLength; i++) {
|
|
paramsObj[paramNames[i]] = params[i]
|
|
}
|
|
}
|
|
|
|
return {
|
|
handler: handle.handler,
|
|
params: paramsObj,
|
|
store: handle.store
|
|
}
|
|
}
|
|
}
|
|
|
|
// search for the longest common prefix
|
|
i = pathLen < prefixLen ? pathLen : prefixLen
|
|
while (len < i && path.charCodeAt(len) === prefix.charCodeAt(len)) len++
|
|
|
|
if (len === prefixLen) {
|
|
path = path.slice(len)
|
|
pathLen = path.length
|
|
idxInOriginalPath += len
|
|
}
|
|
|
|
var node = version === undefined
|
|
? currentNode.findChild(path, method)
|
|
: currentNode.findVersionChild(version, path, method)
|
|
|
|
if (node === null) {
|
|
node = currentNode.parametricBrother
|
|
if (node === null) {
|
|
return this._getWildcardNode(wildcardNode, method, originalPath, pathLenWildcard)
|
|
}
|
|
|
|
var goBack = this.ignoreTrailingSlash ? previousPath : '/' + previousPath
|
|
if (originalPath.indexOf(goBack) === -1) {
|
|
// we need to know the outstanding path so far from the originalPath since the last encountered "/" and assign it to previousPath.
|
|
// e.g originalPath: /aa/bbb/cc, path: bb/cc
|
|
// outstanding path: /bbb/cc
|
|
var pathDiff = originalPath.slice(0, originalPathLength - pathLen)
|
|
previousPath = pathDiff.slice(pathDiff.lastIndexOf('/') + 1, pathDiff.length) + path
|
|
}
|
|
idxInOriginalPath = idxInOriginalPath -
|
|
(previousPath.length - path.length)
|
|
path = previousPath
|
|
pathLen = previousPath.length
|
|
len = prefixLen
|
|
}
|
|
|
|
var kind = node.kind
|
|
|
|
// static route
|
|
if (kind === NODE_TYPES.STATIC) {
|
|
// if exist, save the wildcard child
|
|
if (currentNode.wildcardChild !== null && currentNode.wildcardChild.handlers[method] !== null) {
|
|
wildcardNode = currentNode.wildcardChild
|
|
pathLenWildcard = pathLen
|
|
}
|
|
currentNode = node
|
|
continue
|
|
}
|
|
|
|
if (len !== prefixLen) {
|
|
return this._getWildcardNode(wildcardNode, method, originalPath, pathLenWildcard)
|
|
}
|
|
|
|
// if exist, save the wildcard child
|
|
if (currentNode.wildcardChild !== null && currentNode.wildcardChild.handlers[method] !== null) {
|
|
wildcardNode = currentNode.wildcardChild
|
|
pathLenWildcard = pathLen
|
|
}
|
|
|
|
// parametric route
|
|
if (kind === NODE_TYPES.PARAM) {
|
|
currentNode = node
|
|
i = path.indexOf('/')
|
|
if (i === -1) i = pathLen
|
|
if (i > maxParamLength) return null
|
|
decoded = fastDecode(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
|
|
if (decoded === null) {
|
|
return this.onBadUrl !== null
|
|
? this._onBadUrl(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
|
|
: null
|
|
}
|
|
params[pindex++] = decoded
|
|
path = path.slice(i)
|
|
idxInOriginalPath += i
|
|
continue
|
|
}
|
|
|
|
// wildcard route
|
|
if (kind === NODE_TYPES.MATCH_ALL) {
|
|
decoded = fastDecode(originalPath.slice(idxInOriginalPath))
|
|
if (decoded === null) {
|
|
return this.onBadUrl !== null
|
|
? this._onBadUrl(originalPath.slice(idxInOriginalPath))
|
|
: null
|
|
}
|
|
params[pindex] = decoded
|
|
currentNode = node
|
|
path = ''
|
|
continue
|
|
}
|
|
|
|
// parametric(regex) route
|
|
if (kind === NODE_TYPES.REGEX) {
|
|
currentNode = node
|
|
i = path.indexOf('/')
|
|
if (i === -1) i = pathLen
|
|
if (i > maxParamLength) return null
|
|
decoded = fastDecode(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
|
|
if (decoded === null) {
|
|
return this.onBadUrl !== null
|
|
? this._onBadUrl(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
|
|
: null
|
|
}
|
|
if (!node.regex.test(decoded)) return null
|
|
params[pindex++] = decoded
|
|
path = path.slice(i)
|
|
idxInOriginalPath += i
|
|
continue
|
|
}
|
|
|
|
// multiparametric route
|
|
if (kind === NODE_TYPES.MULTI_PARAM) {
|
|
currentNode = node
|
|
i = 0
|
|
if (node.regex !== null) {
|
|
var matchedParameter = path.match(node.regex)
|
|
if (matchedParameter === null) return null
|
|
i = matchedParameter[1].length
|
|
} else {
|
|
while (i < pathLen && path.charCodeAt(i) !== 47 && path.charCodeAt(i) !== 45) i++
|
|
if (i > maxParamLength) return null
|
|
}
|
|
decoded = fastDecode(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
|
|
if (decoded === null) {
|
|
return this.onBadUrl !== null
|
|
? this._onBadUrl(originalPath.slice(idxInOriginalPath, idxInOriginalPath + i))
|
|
: null
|
|
}
|
|
params[pindex++] = decoded
|
|
path = path.slice(i)
|
|
idxInOriginalPath += i
|
|
continue
|
|
}
|
|
|
|
wildcardNode = null
|
|
}
|
|
}
|
|
|
|
Router.prototype._getWildcardNode = function (node, method, path, len) {
|
|
if (node === null) return null
|
|
var decoded = fastDecode(path.slice(-len))
|
|
if (decoded === null) {
|
|
return this.onBadUrl !== null
|
|
? this._onBadUrl(path.slice(-len))
|
|
: null
|
|
}
|
|
var handle = node.handlers[method]
|
|
if (handle !== null && handle !== undefined) {
|
|
return {
|
|
handler: handle.handler,
|
|
params: { '*': decoded },
|
|
store: handle.store
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
Router.prototype._defaultRoute = function (req, res, ctx) {
|
|
if (this.defaultRoute !== null) {
|
|
return ctx === undefined
|
|
? this.defaultRoute(req, res)
|
|
: this.defaultRoute.call(ctx, req, res)
|
|
} else {
|
|
res.statusCode = 404
|
|
res.end()
|
|
}
|
|
}
|
|
|
|
Router.prototype._onBadUrl = function (path) {
|
|
const onBadUrl = this.onBadUrl
|
|
return {
|
|
handler: (req, res, ctx) => onBadUrl(path, req, res),
|
|
params: {},
|
|
store: null
|
|
}
|
|
}
|
|
|
|
Router.prototype.prettyPrint = function () {
|
|
return this.tree.prettyPrint('', true)
|
|
}
|
|
|
|
for (var i in http.METHODS) {
|
|
if (!http.METHODS.hasOwnProperty(i)) continue
|
|
const m = http.METHODS[i]
|
|
const methodName = m.toLowerCase()
|
|
|
|
if (Router.prototype[methodName]) throw new Error('Method already exists: ' + methodName)
|
|
|
|
Router.prototype[methodName] = function (path, handler, store) {
|
|
return this.on(m, path, handler, store)
|
|
}
|
|
}
|
|
|
|
Router.prototype.all = function (path, handler, store) {
|
|
this.on(httpMethods, path, handler, store)
|
|
}
|
|
|
|
module.exports = Router
|
|
|
|
function sanitizeUrl (url) {
|
|
for (var i = 0, len = url.length; i < len; i++) {
|
|
var charCode = url.charCodeAt(i)
|
|
// Some systems do not follow RFC and separate the path and query
|
|
// string with a `;` character (code 59), e.g. `/foo;jsessionid=123456`.
|
|
// Thus, we need to split on `;` as well as `?` and `#`.
|
|
if (charCode === 63 || charCode === 59 || charCode === 35) {
|
|
return url.slice(0, i)
|
|
}
|
|
}
|
|
return url
|
|
}
|
|
|
|
function getClosingParenthensePosition (path, idx) {
|
|
// `path.indexOf()` will always return the first position of the closing parenthese,
|
|
// but it's inefficient for grouped or wrong regexp expressions.
|
|
// see issues #62 and #63 for more info
|
|
|
|
var parentheses = 1
|
|
|
|
while (idx < path.length) {
|
|
idx++
|
|
|
|
// ignore skipped chars
|
|
if (path[idx] === '\\') {
|
|
idx++
|
|
continue
|
|
}
|
|
|
|
if (path[idx] === ')') {
|
|
parentheses--
|
|
} else if (path[idx] === '(') {
|
|
parentheses++
|
|
}
|
|
|
|
if (!parentheses) return idx
|
|
}
|
|
|
|
throw new TypeError('Invalid regexp expression in "' + path + '"')
|
|
}
|