find-my-way
Version:
Crazy fast http radix based router
829 lines (680 loc) • 25.4 kB
JavaScript
'use strict'
/*
Char codes:
'!': 33 - !
'#': 35 - %23
'$': 36 - %24
'%': 37 - %25
'&': 38 - %26
''': 39 - '
'(': 40 - (
')': 41 - )
'*': 42 - *
'+': 43 - %2B
',': 44 - %2C
'-': 45 - -
'.': 46 - .
'/': 47 - %2F
':': 58 - %3A
';': 59 - %3B
'=': 61 - %3D
'?': 63 - %3F
'@': 64 - %40
'_': 95 - _
'~': 126 - ~
*/
const assert = require('node:assert')
const querystring = require('fast-querystring')
const isRegexSafe = require('safe-regex2')
const deepEqual = require('fast-deep-equal')
const { prettyPrintTree } = require('./lib/pretty-print')
const { StaticNode, NODE_TYPES } = require('./lib/node')
const Constrainer = require('./lib/constrainer')
const httpMethods = require('./lib/http-methods')
const httpMethodStrategy = require('./lib/strategies/http-method')
const { safeDecodeURI, safeDecodeURIComponent } = require('./lib/url-sanitizer')
const FULL_PATH_REGEXP = /^https?:\/\/.*?\//
const OPTIONAL_PARAM_REGEXP = /(\/:[^/()]*?)\?(\/?)/
const ESCAPE_REGEXP = /[.*+?^${}()|[\]\\]/g
const REMOVE_DUPLICATE_SLASHES_REGEXP = /\/\/+/g
if (!isRegexSafe(FULL_PATH_REGEXP)) {
throw new Error('the FULL_PATH_REGEXP is not safe, update this module')
}
if (!isRegexSafe(OPTIONAL_PARAM_REGEXP)) {
throw new Error('the OPTIONAL_PARAM_REGEXP is not safe, update this module')
}
if (!isRegexSafe(ESCAPE_REGEXP)) {
throw new Error('the ESCAPE_REGEXP is not safe, update this module')
}
if (!isRegexSafe(REMOVE_DUPLICATE_SLASHES_REGEXP)) {
throw new Error('the REMOVE_DUPLICATE_SLASHES_REGEXP is not safe, update this module')
}
function Router (opts) {
if (!(this instanceof Router)) {
return new Router(opts)
}
opts = opts || {}
this._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
}
if (opts.buildPrettyMeta) {
assert(typeof opts.buildPrettyMeta === 'function', 'buildPrettyMeta must be a function')
this.buildPrettyMeta = opts.buildPrettyMeta
} else {
this.buildPrettyMeta = defaultBuildPrettyMeta
}
if (opts.querystringParser) {
assert(typeof opts.querystringParser === 'function', 'querystringParser must be a function')
this.querystringParser = opts.querystringParser
} else {
this.querystringParser = (query) => query.length === 0 ? {} : querystring.parse(query)
}
this.caseSensitive = opts.caseSensitive === undefined ? true : opts.caseSensitive
this.ignoreTrailingSlash = opts.ignoreTrailingSlash || false
this.ignoreDuplicateSlashes = opts.ignoreDuplicateSlashes || false
this.maxParamLength = opts.maxParamLength || 100
this.allowUnsafeRegex = opts.allowUnsafeRegex || false
this.constrainer = new Constrainer(opts.constraints)
this.useSemicolonDelimiter = opts.useSemicolonDelimiter || false
this.routes = []
this.trees = {}
}
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')
// path ends with optional parameter
const optionalParamMatch = path.match(OPTIONAL_PARAM_REGEXP)
if (optionalParamMatch) {
assert(path.length === optionalParamMatch.index + optionalParamMatch[0].length, 'Optional Parameter needs to be the last parameter of the path')
const pathFull = path.replace(OPTIONAL_PARAM_REGEXP, '$1$2')
const pathOptional = path.replace(OPTIONAL_PARAM_REGEXP, '$2') || '/'
this.on(method, pathFull, opts, handler, store)
this.on(method, pathOptional, opts, handler, store)
return
}
const route = path
if (this.ignoreDuplicateSlashes) {
path = removeDuplicateSlashes(path)
}
if (this.ignoreTrailingSlash) {
path = trimLastSlash(path)
}
const methods = Array.isArray(method) ? method : [method]
for (const method of methods) {
assert(typeof method === 'string', 'Method should be a string')
assert(httpMethods.includes(method), `Method '${method}' is not an http method.`)
this._on(method, path, opts, handler, store, route)
}
}
Router.prototype._on = function _on (method, path, opts, handler, store) {
let constraints = {}
if (opts.constraints !== undefined) {
assert(typeof opts.constraints === 'object' && opts.constraints !== null, 'Constraints should be an object')
if (Object.keys(opts.constraints).length !== 0) {
constraints = opts.constraints
}
}
this.constrainer.validateConstraints(constraints)
// Let the constrainer know if any constraints are being used now
this.constrainer.noteUsage(constraints)
// Boot the tree for this method if it doesn't exist yet
if (this.trees[method] === undefined) {
this.trees[method] = new StaticNode('/')
}
let pattern = path
if (pattern === '*' && this.trees[method].prefix.length !== 0) {
const currentRoot = this.trees[method]
this.trees[method] = new StaticNode('')
this.trees[method].staticChildren['/'] = currentRoot
}
let currentNode = this.trees[method]
let parentNodePathIndex = currentNode.prefix.length
const params = []
for (let i = 0; i <= pattern.length; i++) {
if (pattern.charCodeAt(i) === 58 && pattern.charCodeAt(i + 1) === 58) {
// It's a double colon
i++
continue
}
const isParametricNode = pattern.charCodeAt(i) === 58 && pattern.charCodeAt(i + 1) !== 58
const isWildcardNode = pattern.charCodeAt(i) === 42
if (isParametricNode || isWildcardNode || (i === pattern.length && i !== parentNodePathIndex)) {
let staticNodePath = pattern.slice(parentNodePathIndex, i)
if (!this.caseSensitive) {
staticNodePath = staticNodePath.toLowerCase()
}
staticNodePath = staticNodePath.replaceAll('::', ':')
staticNodePath = staticNodePath.replaceAll('%', '%25')
// add the static part of the route to the tree
currentNode = currentNode.createStaticChild(staticNodePath)
}
if (isParametricNode) {
let isRegexNode = false
let isParamSafe = true
let backtrack = ''
const regexps = []
let lastParamStartIndex = i + 1
for (let j = lastParamStartIndex; ; j++) {
const charCode = pattern.charCodeAt(j)
const isRegexParam = charCode === 40
const isStaticPart = charCode === 45 || charCode === 46
const isEndOfNode = charCode === 47 || j === pattern.length
if (isRegexParam || isStaticPart || isEndOfNode) {
const paramName = pattern.slice(lastParamStartIndex, j)
params.push(paramName)
isRegexNode = isRegexNode || isRegexParam || isStaticPart
if (isRegexParam) {
const endOfRegexIndex = getClosingParenthensePosition(pattern, j)
const regexString = pattern.slice(j, endOfRegexIndex + 1)
if (!this.allowUnsafeRegex) {
assert(isRegexSafe(new RegExp(regexString)), `The regex '${regexString}' is not safe!`)
}
regexps.push(trimRegExpStartAndEnd(regexString))
j = endOfRegexIndex + 1
isParamSafe = true
} else {
regexps.push(isParamSafe ? '(.*?)' : `(${backtrack}|(?:(?!${backtrack}).)*)`)
isParamSafe = false
}
const staticPartStartIndex = j
for (; j < pattern.length; j++) {
const charCode = pattern.charCodeAt(j)
if (charCode === 47) break
if (charCode === 58) {
const nextCharCode = pattern.charCodeAt(j + 1)
if (nextCharCode === 58) j++
else break
}
}
let staticPart = pattern.slice(staticPartStartIndex, j)
if (staticPart) {
staticPart = staticPart.replaceAll('::', ':')
staticPart = staticPart.replaceAll('%', '%25')
regexps.push(backtrack = escapeRegExp(staticPart))
}
lastParamStartIndex = j + 1
if (isEndOfNode || pattern.charCodeAt(j) === 47 || j === pattern.length) {
const nodePattern = isRegexNode ? '()' + staticPart : staticPart
const nodePath = pattern.slice(i, j)
pattern = pattern.slice(0, i + 1) + nodePattern + pattern.slice(j)
i += nodePattern.length
const regex = isRegexNode ? new RegExp('^' + regexps.join('') + '$') : null
currentNode = currentNode.createParametricChild(regex, staticPart || null, nodePath)
parentNodePathIndex = i + 1
break
}
}
}
} else if (isWildcardNode) {
// add the wildcard parameter
params.push('*')
currentNode = currentNode.createWildcardChild()
parentNodePathIndex = i + 1
if (i !== pattern.length - 1) {
throw new Error('Wildcard must be the last character in the route')
}
}
}
if (!this.caseSensitive) {
pattern = pattern.toLowerCase()
}
if (pattern === '*') {
pattern = '/*'
}
for (const existRoute of this.routes) {
const routeConstraints = existRoute.opts.constraints || {}
if (
existRoute.method === method &&
existRoute.pattern === pattern &&
deepEqual(routeConstraints, constraints)
) {
throw new Error(`Method '${method}' already declared for route '${pattern}' with constraints '${JSON.stringify(constraints)}'`)
}
}
const route = { method, path, pattern, params, opts, handler, store }
this.routes.push(route)
currentNode.addRoute(route, this.constrainer)
}
Router.prototype.hasRoute = function hasRoute (method, path, constraints) {
const route = this.findRoute(method, path, constraints)
return route !== null
}
Router.prototype.findRoute = function findNode (method, path, constraints = {}) {
if (this.trees[method] === undefined) {
return null
}
let pattern = path
let currentNode = this.trees[method]
let parentNodePathIndex = currentNode.prefix.length
const params = []
for (let i = 0; i <= pattern.length; i++) {
if (pattern.charCodeAt(i) === 58 && pattern.charCodeAt(i + 1) === 58) {
// It's a double colon
i++
continue
}
const isParametricNode = pattern.charCodeAt(i) === 58 && pattern.charCodeAt(i + 1) !== 58
const isWildcardNode = pattern.charCodeAt(i) === 42
if (isParametricNode || isWildcardNode || (i === pattern.length && i !== parentNodePathIndex)) {
let staticNodePath = pattern.slice(parentNodePathIndex, i)
if (!this.caseSensitive) {
staticNodePath = staticNodePath.toLowerCase()
}
staticNodePath = staticNodePath.replaceAll('::', ':')
staticNodePath = staticNodePath.replaceAll('%', '%25')
// add the static part of the route to the tree
currentNode = currentNode.getStaticChild(staticNodePath)
if (currentNode === null) {
return null
}
}
if (isParametricNode) {
let isRegexNode = false
let isParamSafe = true
let backtrack = ''
const regexps = []
let lastParamStartIndex = i + 1
for (let j = lastParamStartIndex; ; j++) {
const charCode = pattern.charCodeAt(j)
const isRegexParam = charCode === 40
const isStaticPart = charCode === 45 || charCode === 46
const isEndOfNode = charCode === 47 || j === pattern.length
if (isRegexParam || isStaticPart || isEndOfNode) {
const paramName = pattern.slice(lastParamStartIndex, j)
params.push(paramName)
isRegexNode = isRegexNode || isRegexParam || isStaticPart
if (isRegexParam) {
const endOfRegexIndex = getClosingParenthensePosition(pattern, j)
const regexString = pattern.slice(j, endOfRegexIndex + 1)
if (!this.allowUnsafeRegex) {
assert(isRegexSafe(new RegExp(regexString)), `The regex '${regexString}' is not safe!`)
}
regexps.push(trimRegExpStartAndEnd(regexString))
j = endOfRegexIndex + 1
isParamSafe = false
} else {
regexps.push(isParamSafe ? '(.*?)' : `(${backtrack}|(?:(?!${backtrack}).)*)`)
isParamSafe = false
}
const staticPartStartIndex = j
for (; j < pattern.length; j++) {
const charCode = pattern.charCodeAt(j)
if (charCode === 47) break
if (charCode === 58) {
const nextCharCode = pattern.charCodeAt(j + 1)
if (nextCharCode === 58) j++
else break
}
}
let staticPart = pattern.slice(staticPartStartIndex, j)
if (staticPart) {
staticPart = staticPart.replaceAll('::', ':')
staticPart = staticPart.replaceAll('%', '%25')
regexps.push(backtrack = escapeRegExp(staticPart))
}
lastParamStartIndex = j + 1
if (isEndOfNode || pattern.charCodeAt(j) === 47 || j === pattern.length) {
const nodePattern = isRegexNode ? '()' + staticPart : staticPart
const nodePath = pattern.slice(i, j)
pattern = pattern.slice(0, i + 1) + nodePattern + pattern.slice(j)
i += nodePattern.length
const regex = isRegexNode ? new RegExp('^' + regexps.join('') + '$') : null
currentNode = currentNode.getParametricChild(regex, staticPart || null, nodePath)
if (currentNode === null) {
return null
}
parentNodePathIndex = i + 1
break
}
}
}
} else if (isWildcardNode) {
// add the wildcard parameter
params.push('*')
currentNode = currentNode.getWildcardChild()
parentNodePathIndex = i + 1
if (i !== pattern.length - 1) {
throw new Error('Wildcard must be the last character in the route')
}
}
}
if (!this.caseSensitive) {
pattern = pattern.toLowerCase()
}
for (const existRoute of this.routes) {
const routeConstraints = existRoute.opts.constraints || {}
if (
existRoute.method === method &&
existRoute.pattern === pattern &&
deepEqual(routeConstraints, constraints)
) {
return {
handler: existRoute.handler,
store: existRoute.store,
params: existRoute.params
}
}
}
return null
}
Router.prototype.hasConstraintStrategy = function (strategyName) {
return this.constrainer.hasConstraintStrategy(strategyName)
}
Router.prototype.addConstraintStrategy = function (constraints) {
this.constrainer.addConstraintStrategy(constraints)
this._rebuild(this.routes)
}
Router.prototype.reset = function reset () {
this.trees = {}
this.routes = []
}
Router.prototype.off = function off (method, path, constraints) {
// 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 `*`')
// options validation
assert(
typeof constraints === 'undefined' ||
(typeof constraints === 'object' && !Array.isArray(constraints) && constraints !== null),
'Constraints should be an object or undefined.')
// path ends with optional parameter
const optionalParamMatch = path.match(OPTIONAL_PARAM_REGEXP)
if (optionalParamMatch) {
assert(path.length === optionalParamMatch.index + optionalParamMatch[0].length, 'Optional Parameter needs to be the last parameter of the path')
const pathFull = path.replace(OPTIONAL_PARAM_REGEXP, '$1$2')
const pathOptional = path.replace(OPTIONAL_PARAM_REGEXP, '$2')
this.off(method, pathFull, constraints)
this.off(method, pathOptional, constraints)
return
}
if (this.ignoreDuplicateSlashes) {
path = removeDuplicateSlashes(path)
}
if (this.ignoreTrailingSlash) {
path = trimLastSlash(path)
}
const methods = Array.isArray(method) ? method : [method]
for (const method of methods) {
this._off(method, path, constraints)
}
}
Router.prototype._off = function _off (method, path, constraints) {
// method validation
assert(typeof method === 'string', 'Method should be a string')
assert(httpMethods.includes(method), `Method '${method}' is not an http method.`)
function matcherWithoutConstraints (route) {
return method !== route.method || path !== route.path
}
function matcherWithConstraints (route) {
return matcherWithoutConstraints(route) || !deepEqual(constraints, route.opts.constraints || {})
}
const predicate = constraints ? matcherWithConstraints : matcherWithoutConstraints
// Rebuild tree without the specific route
const newRoutes = this.routes.filter(predicate)
this._rebuild(newRoutes)
}
Router.prototype.lookup = function lookup (req, res, ctx, done) {
if (typeof ctx === 'function') {
done = ctx
ctx = undefined
}
if (done === undefined) {
const constraints = this.constrainer.deriveConstraints(req, ctx)
const handle = this.find(req.method, req.url, constraints)
return this.callHandler(handle, req, res, ctx)
}
this.constrainer.deriveConstraints(req, ctx, (err, constraints) => {
if (err !== null) {
done(err)
return
}
try {
const handle = this.find(req.method, req.url, constraints)
const result = this.callHandler(handle, req, res, ctx)
done(null, result)
} catch (err) {
done(err)
}
})
}
Router.prototype.callHandler = function callHandler (handle, req, res, ctx) {
if (handle === null) return this._defaultRoute(req, res, ctx)
return ctx === undefined
? handle.handler(req, res, handle.params, handle.store, handle.searchParams)
: handle.handler.call(ctx, req, res, handle.params, handle.store, handle.searchParams)
}
Router.prototype.find = function find (method, path, derivedConstraints) {
let currentNode = this.trees[method]
if (currentNode === undefined) return null
if (path.charCodeAt(0) !== 47) { // 47 is '/'
path = path.replace(FULL_PATH_REGEXP, '/')
}
// This must be run before sanitizeUrl as the resulting function
// .sliceParameter must be constructed with same URL string used
// throughout the rest of this function.
if (this.ignoreDuplicateSlashes) {
path = removeDuplicateSlashes(path)
}
let sanitizedUrl
let querystring
let shouldDecodeParam
try {
sanitizedUrl = safeDecodeURI(path, this.useSemicolonDelimiter)
path = sanitizedUrl.path
querystring = sanitizedUrl.querystring
shouldDecodeParam = sanitizedUrl.shouldDecodeParam
} catch (error) {
return this._onBadUrl(path)
}
if (this.ignoreTrailingSlash) {
path = trimLastSlash(path)
}
const originPath = path
if (this.caseSensitive === false) {
path = path.toLowerCase()
}
const maxParamLength = this.maxParamLength
let pathIndex = currentNode.prefix.length
const params = []
const pathLen = path.length
const brothersNodesStack = []
while (true) {
if (pathIndex === pathLen && currentNode.isLeafNode) {
const handle = currentNode.handlerStorage.getMatchingHandler(derivedConstraints)
if (handle !== null) {
return {
handler: handle.handler,
store: handle.store,
params: handle._createParamsObject(params),
searchParams: this.querystringParser(querystring)
}
}
}
let node = currentNode.getNextNode(path, pathIndex, brothersNodesStack, params.length)
if (node === null) {
if (brothersNodesStack.length === 0) {
return null
}
const brotherNodeState = brothersNodesStack.pop()
pathIndex = brotherNodeState.brotherPathIndex
params.splice(brotherNodeState.paramsCount)
node = brotherNodeState.brotherNode
}
currentNode = node
// static route
if (currentNode.kind === NODE_TYPES.STATIC) {
pathIndex += currentNode.prefix.length
continue
}
if (currentNode.kind === NODE_TYPES.WILDCARD) {
let param = originPath.slice(pathIndex)
if (shouldDecodeParam) {
param = safeDecodeURIComponent(param)
}
params.push(param)
pathIndex = pathLen
continue
}
// parametric node
let paramEndIndex = originPath.indexOf('/', pathIndex)
if (paramEndIndex === -1) {
paramEndIndex = pathLen
}
let param = originPath.slice(pathIndex, paramEndIndex)
if (shouldDecodeParam) {
param = safeDecodeURIComponent(param)
}
if (currentNode.isRegex) {
const matchedParameters = currentNode.regex.exec(param)
if (matchedParameters === null) continue
for (let i = 1; i < matchedParameters.length; i++) {
const matchedParam = matchedParameters[i]
if (matchedParam.length > maxParamLength) {
return null
}
params.push(matchedParam)
}
} else {
if (param.length > maxParamLength) {
return null
}
params.push(param)
}
pathIndex = paramEndIndex
}
}
Router.prototype._rebuild = function (routes) {
this.reset()
for (const route of routes) {
const { method, path, opts, handler, store } = route
this._on(method, path, opts, handler, store)
}
}
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) {
if (this.onBadUrl === null) {
return null
}
const onBadUrl = this.onBadUrl
return {
handler: (req, res, ctx) => onBadUrl(path, req, res),
params: {},
store: null
}
}
Router.prototype.prettyPrint = function (options = {}) {
const method = options.method
options.buildPrettyMeta = this.buildPrettyMeta.bind(this)
let tree = null
if (method === undefined) {
const { version, host, ...constraints } = this.constrainer.strategies
constraints[httpMethodStrategy.name] = httpMethodStrategy
const mergedRouter = new Router({ ...this._opts, constraints })
const mergedRoutes = this.routes.map(route => {
const constraints = {
...route.opts.constraints,
[httpMethodStrategy.name]: route.method
}
return { ...route, method: 'MERGED', opts: { constraints } }
})
mergedRouter._rebuild(mergedRoutes)
tree = mergedRouter.trees.MERGED
} else {
tree = this.trees[method]
}
if (tree == null) return '(empty tree)'
return prettyPrintTree(tree, options)
}
for (const i in httpMethods) {
/* eslint no-prototype-builtins: "off" */
if (!httpMethods.hasOwnProperty(i)) continue
const m = httpMethods[i]
const methodName = m.toLowerCase()
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 escapeRegExp (string) {
return string.replace(ESCAPE_REGEXP, '\\$&')
}
function removeDuplicateSlashes (path) {
return path.indexOf('//') !== -1 ? path.replace(REMOVE_DUPLICATE_SLASHES_REGEXP, '/') : path
}
function trimLastSlash (path) {
if (path.length > 1 && path.charCodeAt(path.length - 1) === 47) {
return path.slice(0, -1)
}
return path
}
function trimRegExpStartAndEnd (regexString) {
// removes chars that marks start "^" and end "$" of regexp
if (regexString.charCodeAt(1) === 94) {
regexString = regexString.slice(0, 1) + regexString.slice(2)
}
if (regexString.charCodeAt(regexString.length - 2) === 36) {
regexString = regexString.slice(0, regexString.length - 2) + regexString.slice(regexString.length - 1)
}
return regexString
}
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
let parentheses = 1
while (idx < path.length) {
idx++
// ignore skipped chars "\"
if (path.charCodeAt(idx) === 92) {
idx++
continue
}
if (path.charCodeAt(idx) === 41) {
parentheses--
} else if (path.charCodeAt(idx) === 40) {
parentheses++
}
if (!parentheses) return idx
}
throw new TypeError('Invalid regexp expression in "' + path + '"')
}
function defaultBuildPrettyMeta (route) {
// buildPrettyMeta function must return an object, which will be parsed into key/value pairs for display
if (!route) return {}
if (!route.store) return {}
return Object.assign({}, route.store)
}