@ditojs/router
Advanced tools
Comparing version 1.24.0 to 1.25.0
{ | ||
"name": "@ditojs/router", | ||
"version": "1.24.0", | ||
"version": "1.25.0", | ||
"type": "module", | ||
@@ -24,3 +24,3 @@ "description": "Dito.js Router – Dito.js is a declarative and modern web framework, based on Objection.js, Koa.js and Vue.js", | ||
"dependencies": { | ||
"@ditojs/utils": "^1.24.0" | ||
"@ditojs/utils": "^1.25.0" | ||
}, | ||
@@ -34,4 +34,4 @@ "keywords": [ | ||
], | ||
"gitHead": "0ca96ce0335509daa9ebbac278ae2d346015baad", | ||
"gitHead": "29e7f8d8c613c48a856d90d1ee876e85c8d84c08", | ||
"readme": "# Dito.js Router\n\nDito.js is a declarative and modern web framework with a focus on API driven\ndevelopment, based on Koa.js, Objection.js and Vue.js\n\nReleased in 2018 under the MIT license, with support by https://lineto.com/\n\nDito.js Router is a high performance, tree-based, framework agnostic HTTP\nrouter, based on [trek-router](https://github.com/trekjs/router), which in turn\nis inspired by [Echo](https://github.com/labstack/echo)'s Router.\n\n## How does it work?\n\nThe router relies on a tree structure which makes heavy use of common\nprefixes, essentially a [prefix tree](https://en.wikipedia.org/wiki/Trie).\n\n## Usage\n\n```js\nimport Koa from 'koa'\nimport compose from 'koa-compose'\nimport Router from '@ditojs/router'\n\nconst app = new Koa()\nconst router = new Router()\n\n// static route\nrouter.get('/folders/files/bolt.gif', ctx => {\n ctx.body = `this ain't no GIF!`\n})\n\n// param route\nrouter.get('/users/:id', ctx => {\n ctx.body = `requesting user ${ctx.params.id}`\n})\n\n// match-any route\nrouter.get('/books/*', ctx => {\n ctx.body = `sub-route: ${ctx.params['*']}`\n})\n\n// Handler found\nlet { handler, params } = router.find('get', '/users/233')\nconsole.log(handler) // ctx => { ... }\n\n// Entry not Found\nlet { handler, params } = router.find('get', '/photos/233')\nconsole.log(handler) // null\n\n// Install router middleware\napp.use(async (ctx, next) => {\n const { method, path } = ctx\n const result = router.find(method, path)\n const { handler, params } = result\n if (handler) {\n ctx.params = params || {}\n return handler(ctx, next)\n } else {\n try {\n await next()\n } finally {\n if (ctx.body === undefined && ctx.status === 404) {\n ctx.status = result.status || 404\n if (ctx.status !== 404 && result.allowed) {\n ctx.set('Allow', result.allowed.join(', '))\n }\n }\n }\n }\n})\n\napp.listen(4040, () => console.log('Koa app listening on 4040'))\n```\n" | ||
} |
255
src/Node.js
import { getCommonOffset } from '@ditojs/utils' | ||
// Node Types: | ||
const TYPE_STATIC = 0 | ||
const TYPE_PARAM = 1 | ||
const TYPE_MATCH_ANY = 2 | ||
const TYPE_STATIC = 0 // /static/patch | ||
const TYPE_PARAM = 1 // /prefix/:param/suffix | ||
const TYPE_PLACEHOLDER = 2 // /prefix/*/suffix (a.k.a shallow wildcard) | ||
const TYPE_MATCH_ANY = 3 // /prefix/**/suffix (a.k.a deep wildcard) | ||
// Char Codes | ||
const CHAR_PARAM = ':'.charCodeAt(0) | ||
const CHAR_MATCH_ANY = '*'.charCodeAt(0) | ||
const CHAR_SLASH = '/'.charCodeAt(0) | ||
const CHAR_SLASH = '/'[0] | ||
const CHAR_PARAM = ':'[0] | ||
const CHAR_WILDCARD = '*'[0] | ||
@@ -19,57 +20,53 @@ export default class Node { | ||
initialize( | ||
type = TYPE_STATIC, | ||
prefix = '/', | ||
type = TYPE_STATIC, | ||
children = [], | ||
handler = null, | ||
paramNames = null | ||
parameters = null, | ||
handler = null | ||
) { | ||
this.label = prefix.charCodeAt(0) | ||
this.type = type | ||
this.label = prefix[0] | ||
this.prefix = prefix | ||
this.type = type | ||
this.children = children | ||
this.parameters = parameters | ||
this.handler = handler | ||
this.paramNames = paramNames | ||
this.paramName = null | ||
this.paramKey = null | ||
this.hasMatchAny = false | ||
} | ||
addChild(child) { | ||
this.children.push(child) | ||
} | ||
findChild(label, type) { | ||
for (const child of this.children) { | ||
if (child.label === label && child.type === type) { | ||
return child | ||
} | ||
setup(parameters, handler) { | ||
this.parameters = parameters | ||
this.handler = handler | ||
if (this.type === TYPE_MATCH_ANY) { | ||
parameters.setupPathPattern(this.prefix) | ||
} | ||
} | ||
findChildWithLabel(label) { | ||
for (const child of this.children) { | ||
if (child.label === label) { | ||
return child | ||
} | ||
} | ||
addChild(child) { | ||
this.children.push(child) | ||
this.hasMatchAny ||= child.type === TYPE_MATCH_ANY | ||
} | ||
findChildWithType(type) { | ||
for (const child of this.children) { | ||
if (child.type === type) { | ||
return child | ||
} | ||
} | ||
} | ||
add(path, handler) { | ||
const paramNames = [] | ||
const parameters = new Parameters() | ||
for (let pos = 0, length = path.length; pos < length; pos++) { | ||
const ch = path.charCodeAt(pos) | ||
if (ch === CHAR_PARAM) { | ||
this.insert(path.slice(0, pos), TYPE_STATIC) | ||
pos++ // Skip colon. | ||
const ch = path[pos] | ||
if (ch === CHAR_WILDCARD && path[pos + 1] === CHAR_WILDCARD) { | ||
// Deep wildcard (**): matches any path, with a optional suffix: | ||
this.insert(TYPE_STATIC, path.slice(0, pos)) | ||
pos += 2 // Skip '**'. | ||
this.insert(TYPE_MATCH_ANY, path, parameters, handler) | ||
return | ||
} else if (ch === CHAR_PARAM || ch === CHAR_WILDCARD) { | ||
// Param (:param) or shallow wildcard (*): | ||
const isWildcard = ch === CHAR_WILDCARD | ||
const type = isWildcard ? TYPE_PLACEHOLDER : TYPE_PARAM | ||
this.insert(TYPE_STATIC, path.slice(0, pos)) | ||
pos++ // Skip colon or wildcard. | ||
const start = pos | ||
// Move pos to the next occurrence of the slash or the end: | ||
pos += path.slice(pos).match(/^([^/]*)/)[1].length | ||
paramNames.push(path.slice(start, pos)) | ||
parameters.add( | ||
isWildcard ? parameters.getPlaceholderKey() : path.slice(start, pos) | ||
) | ||
// Chop out param name from path, but keep colon. | ||
@@ -80,43 +77,41 @@ path = path.slice(0, start) + path.slice(pos) | ||
if (start === length) { | ||
return this.insert(path, TYPE_PARAM, paramNames, handler) | ||
this.insert(type, path, parameters, handler) | ||
return | ||
} | ||
pos = start | ||
this.insert(path.slice(0, pos), TYPE_PARAM, paramNames) | ||
} else if (ch === CHAR_MATCH_ANY) { | ||
this.insert(path.slice(0, pos), TYPE_STATIC) | ||
paramNames.push('*') | ||
return this.insert(path, TYPE_MATCH_ANY, paramNames, handler) | ||
this.insert(type, path.slice(0, pos), parameters) | ||
} | ||
} | ||
this.insert(path, TYPE_STATIC, paramNames, handler) | ||
this.insert(TYPE_STATIC, path, parameters, handler) | ||
} | ||
insert(path, type, paramNames, handler) { | ||
insert(type, prefix, parameters = null, handler = null) { | ||
let current = this | ||
while (true) { | ||
// Find the position where the path and the node's prefix start diverging. | ||
const pos = getCommonOffset(current.prefix, path) | ||
const { prefix } = current | ||
if (pos < prefix.length) { | ||
const curPrefix = current.prefix | ||
const pos = getCommonOffset(curPrefix, prefix) | ||
if (pos < curPrefix.length) { | ||
// Split node | ||
const node = new Node( | ||
prefix.slice(pos), | ||
current.type, | ||
curPrefix.slice(pos), | ||
current.children, | ||
current.handler, | ||
current.paramNames | ||
current.parameters, | ||
current.handler | ||
) | ||
// Reset parent node and add new node as child to it: | ||
current.initialize(prefix.slice(0, pos)) | ||
// Reset parent node to a static and add new node as child to it: | ||
current.initialize(TYPE_STATIC, curPrefix.slice(0, pos)) | ||
current.addChild(node) | ||
if (pos < path.length) { | ||
if (pos < prefix.length) { | ||
// Create child node | ||
const node = new Node(path.slice(pos), type) | ||
const node = new Node(type, prefix.slice(pos)) | ||
current.addChild(node) | ||
current = node // Switch to child to set handler and paramNames | ||
current = node // Switch to child to set handler and parameters | ||
} | ||
} else if (pos < path.length) { | ||
path = path.slice(pos) | ||
const child = current.findChildWithLabel(path.charCodeAt(0)) | ||
if (child !== undefined) { | ||
} else if (pos < prefix.length) { | ||
prefix = prefix.slice(pos) | ||
const label = prefix[0] | ||
const child = current.children.find(child => child.label === label) | ||
if (child !== undefined && child.type !== TYPE_MATCH_ANY) { | ||
// Go deeper | ||
@@ -127,14 +122,13 @@ current = child | ||
// Create child node | ||
const node = new Node(path, type) | ||
const node = new Node(type, prefix) | ||
current.addChild(node) | ||
current = node // Switch to child to set handler and paramNames | ||
current = node // Switch to child to set handler and parameters | ||
} | ||
if (handler) { | ||
current.handler = handler | ||
current.paramNames = paramNames | ||
current.setup(parameters, handler) | ||
} | ||
if (paramNames) { | ||
if (parameters) { | ||
// Remember the last entry from the list of param names that keeps | ||
// growing during parsing as the name of the current node. | ||
current.paramName = paramNames[paramNames.length - 1] | ||
current.paramKey = parameters.getLastKey() | ||
} | ||
@@ -151,8 +145,3 @@ break | ||
if (handler) { | ||
// Convert paramNames and values to params. | ||
const params = {} | ||
let i = 0 | ||
for (const name of this.paramNames) { | ||
params[name] = paramValues[i++] | ||
} | ||
const params = this.parameters.getObject(paramValues) | ||
// Support HTTP status on found entries. | ||
@@ -169,3 +158,3 @@ return { handler, params, status: 200 } | ||
path = path.slice(prefixLength) | ||
} else if (this.type !== TYPE_PARAM) { | ||
} else if (this.type !== TYPE_PARAM && this.type !== TYPE_PLACEHOLDER) { | ||
// If the path doesn't fully match the prefix, we only need to look | ||
@@ -179,3 +168,6 @@ // further on param nodes, which can have overlapping static children. | ||
// Static node | ||
const staticChild = this.findChild(path.charCodeAt(0), TYPE_STATIC) | ||
const label = path[0] | ||
const staticChild = this.children.find( | ||
child => child.type === TYPE_STATIC && child.label === label | ||
) | ||
if (staticChild) { | ||
@@ -189,8 +181,8 @@ const result = staticChild.find(path, paramValues) | ||
// Node not found | ||
if (!fullMatch) { | ||
return null | ||
} | ||
if (!fullMatch) return null | ||
// Param node | ||
const paramChild = this.findChildWithType(TYPE_PARAM) | ||
// Param / placeholder node | ||
const paramChild = this.children.find( | ||
child => child.type === TYPE_PARAM || child.type === TYPE_PLACEHOLDER | ||
) | ||
if (paramChild) { | ||
@@ -200,3 +192,3 @@ // Find the position of the next slash: | ||
const max = path.length | ||
while (pos < max && path.charCodeAt(pos) !== CHAR_SLASH) { | ||
while (pos < max && path[pos] !== CHAR_SLASH) { | ||
pos++ | ||
@@ -212,7 +204,11 @@ } | ||
// Match-any node | ||
const matchAnyChild = this.findChildWithType(TYPE_MATCH_ANY) | ||
if (matchAnyChild) { | ||
paramValues.push(path) | ||
return matchAnyChild.find('', paramValues) // '' == End | ||
// Match-any nodes | ||
if (this.hasMatchAny) { | ||
for (const child of this.children) { | ||
const match = child.parameters?.matchPathPattern(path) | ||
if (match) { | ||
paramValues.push(...Object.values(match.groups)) | ||
return child.find('', paramValues) // '' == End | ||
} | ||
} | ||
} | ||
@@ -231,3 +227,3 @@ | ||
this.type === TYPE_PARAM | ||
? `${this.prefix}${this.paramName}` | ||
? `${this.prefix}${this.paramKey}` | ||
: this.prefix | ||
@@ -248,1 +244,76 @@ }${ | ||
} | ||
class Parameters { | ||
constructor() { | ||
this.keys = [] | ||
this.pathPattern = null | ||
this.matchAnyIndex = 0 | ||
this.placeholderIndex = 0 | ||
} | ||
add(...keys) { | ||
this.keys.push(...keys) | ||
} | ||
getMatchAnyKey() { | ||
return `$$${this.matchAnyIndex++}` | ||
} | ||
getPlaceholderKey() { | ||
return `$${this.placeholderIndex++}` | ||
} | ||
getLastKey() { | ||
return this.keys[this.keys.length - 1] | ||
} | ||
matchPathPattern(path) { | ||
return this.pathPattern?.exec(path) | ||
} | ||
setupPathPattern(path) { | ||
// Replace all '**' with '.+?' and all '*' with '[^/]+'. | ||
// Use named groups to merge multiple ** or * into one. | ||
const pattern = [] | ||
const keys = [] | ||
for (const token of path.split('/')) { | ||
if (token === '**') { | ||
const key = this.getMatchAnyKey() | ||
pattern.push(`(?<${key}>.+?)`) | ||
keys.push(key) | ||
} else if (token === '*') { | ||
const key = this.getPlaceholderKey() | ||
pattern.push(`(?<${key}>[^/]+)`) | ||
keys.push(key) | ||
} else if (token.startsWith(':')) { | ||
const key = token.slice(1) | ||
pattern.push(`(?<${key}>[^/]+)`) | ||
keys.push(key) | ||
} else { | ||
pattern.push(token) | ||
} | ||
} | ||
this.pathPattern = new RegExp(`^${pattern.join('/')}$`) | ||
this.add(...keys) | ||
} | ||
getObject(values) { | ||
// Convert parameters and values to a params object, but rename path pattern | ||
// groups back to param names | ||
const params = {} | ||
let i = 0 | ||
for (const key of this.keys) { | ||
const name = key.startsWith('$$') | ||
? this.matchAnyIndex === 1 | ||
? '$$' | ||
: key | ||
: key.startsWith('$') | ||
? this.placeholderIndex === 1 | ||
? '$' | ||
: key | ||
: key | ||
params[name] = values[i++] | ||
} | ||
return params | ||
} | ||
} |
@@ -124,4 +124,4 @@ import Router from './Router.js' | ||
it('handles match-any nodes (catch-all / wildcard)', () => { | ||
router.add('GET', '/static/*', handler) | ||
it('handles match-any nodes (catch-all)', () => { | ||
router.add('GET', '/static/**', handler) | ||
@@ -131,3 +131,3 @@ expect(router.toString()).toBe(deindent` | ||
└── static/ children=1 | ||
└── * handler() children=0 | ||
└── ** handler() children=0 | ||
`.trim()) | ||
@@ -144,3 +144,3 @@ | ||
expect(result.params).toEqual({ | ||
'*': 'js' | ||
$$: 'js' | ||
}) | ||
@@ -151,6 +151,102 @@ | ||
expect(result.params).toEqual({ | ||
'*': 'css' | ||
$$: 'css' | ||
}) | ||
}) | ||
it('handles match-any nodes (wildcard)', () => { | ||
router.add('GET', '/prefix/*/suffix', handler) | ||
expect(router.toString()).toBe(deindent` | ||
/ children=1 | ||
└── prefix/ children=1 | ||
└── * children=1 | ||
└── /suffix handler() children=0 | ||
`.trim()) | ||
result = router.find('GET', '/prefix') | ||
expect(result.handler).toBeUndefined() | ||
result = router.find('GET', '/prefix/*') | ||
expect(result.handler).toBeUndefined() | ||
result = router.find('GET', '/prefix/hello/there') | ||
expect(result.handler).toBeUndefined() | ||
result = router.find('GET', '/prefix/hello/suffix') | ||
expect(result.handler).toBe(handler) | ||
expect(result.params).toEqual({ | ||
$: 'hello' | ||
}) | ||
}) | ||
it('handles match-any nodes with suffixes', () => { | ||
router.add('GET', '/static/**/suffix', handler) | ||
router.add('GET', '/static/**/suffix/**/more', handler) | ||
router.add('GET', '/static/**/suffix/*/1/:param/2', handler) | ||
router.add('GET', '/static/**/suffix/*/1/:param/2/**/end', handler) | ||
expect(router.toString()).toBe(deindent` | ||
/ children=1 | ||
└── static/ children=4 | ||
├── **/suffix handler() children=0 | ||
├── **/suffix/**/more handler() children=0 | ||
├── **/suffix/*/1/:param/2 handler() children=0 | ||
└── **/suffix/*/1/:param/2/**/end handler() children=0 | ||
`.trim()) | ||
result = router.find('GET', '/static') | ||
expect(result.handler).toBeUndefined() | ||
result = router.find('GET', '/static/js') | ||
expect(result.handler).toBeUndefined() | ||
result = router.find('GET', '/static/one/prefix') | ||
expect(result.handler).toBeUndefined() | ||
result = router.find('GET', '/static/one/two/prefix') | ||
expect(result.handler).toBeUndefined() | ||
result = router.find('GET', '/static/one/suffix') | ||
expect(result.handler).toBe(handler) | ||
expect(result.params).toEqual({ | ||
$$: 'one' | ||
}) | ||
result = router.find('GET', '/static/one/two/suffix') | ||
expect(result.handler).toBe(handler) | ||
expect(result.params).toEqual({ | ||
$$: 'one/two' | ||
}) | ||
result = router.find('GET', '/static/one/two/suffix/three/four') | ||
expect(result.handler).toBeUndefined() | ||
result = router.find('GET', '/static/one/two/suffix/three/four/more') | ||
expect(result.handler).toBe(handler) | ||
expect(result.params).toEqual({ | ||
$$0: 'one/two', | ||
$$1: 'three/four' | ||
}) | ||
result = router.find('GET', '/static/one/two/suffix/three/1/bla/2') | ||
expect(result.handler).toBe(handler) | ||
expect(result.params).toEqual({ | ||
$$: 'one/two', | ||
$: 'three', | ||
param: 'bla' | ||
}) | ||
result = router.find('GET', '/static/one/two/suffix/three/1/bla/3') | ||
expect(result.handler).toBeUndefined() | ||
result = router.find('GET', `/static/one/two/suffix/three/1/bla/2/what/ever/end`) | ||
expect(result.handler).toBe(handler) | ||
expect(result.params).toEqual({ | ||
$: 'three', | ||
param: 'bla', | ||
$$0: 'one/two', | ||
$$1: 'what/ever' | ||
}) | ||
}) | ||
it('handles resources', () => { | ||
@@ -171,3 +267,3 @@ createRoutes(router, [ | ||
['/geocoder/:action', 'actionGeocoder'], | ||
['/geocoder/*', 'anyGeocoder'] | ||
['/geocoder/**', 'anyGeocoder'] | ||
]) | ||
@@ -193,3 +289,3 @@ | ||
│ └── /echo echoGeocoder() children=0 | ||
└── * anyGeocoder() children=0 | ||
└── ** anyGeocoder() children=0 | ||
`.trim()) | ||
@@ -212,3 +308,3 @@ result = router.find('GET', '') | ||
expect(result.handler.name).toBe('anyGeocoder') | ||
expect(result.params).toEqual({ '*': 'delete/any' }) | ||
expect(result.params).toEqual({ $$: 'delete/any' }) | ||
@@ -218,3 +314,3 @@ result = router.find('GET', '/geocoder/any/action') | ||
expect(result.handler.name).toBe('anyGeocoder') | ||
expect(result.params).toEqual({ '*': 'any/action' }) | ||
expect(result.params).toEqual({ $$: 'any/action' }) | ||
@@ -281,3 +377,4 @@ result = router.find('GET', '/geocoder/exchange/trekjs') | ||
['/users/new', 'newUser'], | ||
['/users/nnw', 'newUser'], | ||
['/users/noi', 'newUser'], | ||
['/users/nei', 'newUser'], | ||
['/users/:id', 'user'], | ||
@@ -289,3 +386,3 @@ ['/users/:id/edit', 'editUser'], | ||
['/users/:userId/books/:id', 'book'], | ||
['/users/*', 'anyUser'] | ||
['/users/**', 'anyUser'] | ||
]) | ||
@@ -298,4 +395,6 @@ | ||
├── n children=2 | ||
│ ├── ew newUser() children=0 | ||
│ └── nw newUser() children=0 | ||
│ ├── e children=2 | ||
│ │ ├── w newUser() children=0 | ||
│ │ └── i newUser() children=0 | ||
│ └── oi newUser() children=0 | ||
├── :userId user() children=1 | ||
@@ -314,3 +413,3 @@ │ └── / children=4 | ||
│ └── :id book() children=0 | ||
└── * anyUser() children=0 | ||
└── ** anyUser() children=0 | ||
`.trim()) | ||
@@ -321,3 +420,3 @@ | ||
expect(result.handler.name).toBe('anyUser') | ||
expect(result.params).toEqual({ '*': '610/books/987/edit' }) | ||
expect(result.params).toEqual({ $$: '610/books/987/edit' }) | ||
@@ -324,0 +423,0 @@ result = router.find('GET', '/users/610/books/987') |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
46866
1045
Updated@ditojs/utils@^1.25.0