@ditojs/router 1.23.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ditojs/router",
3
- "version": "1.23.0",
3
+ "version": "1.25.0",
4
4
  "type": "module",
5
5
  "description": "Dito.js Router – Dito.js is a declarative and modern web framework, based on Objection.js, Koa.js and Vue.js",
6
6
  "repository": "https://github.com/ditojs/dito/tree/master/packages/router",
@@ -21,7 +21,7 @@
21
21
  "node >= 18"
22
22
  ],
23
23
  "dependencies": {
24
- "@ditojs/utils": "^1.23.0"
24
+ "@ditojs/utils": "^1.25.0"
25
25
  },
26
26
  "keywords": [
27
27
  "route",
@@ -30,6 +30,6 @@
30
30
  "koa",
31
31
  "dito"
32
32
  ],
33
- "gitHead": "3258fd030226062efa6837bfc5e5a761e80cead5",
33
+ "gitHead": "29e7f8d8c613c48a856d90d1ee876e85c8d84c08",
34
34
  "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"
35
35
  }
package/src/Node.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { getCommonOffset } from '@ditojs/utils'
2
2
 
3
3
  // Node Types:
4
- const TYPE_STATIC = 0
5
- const TYPE_PARAM = 1
6
- const TYPE_MATCH_ANY = 2
4
+ const TYPE_STATIC = 0 // /static/patch
5
+ const TYPE_PARAM = 1 // /prefix/:param/suffix
6
+ const TYPE_PLACEHOLDER = 2 // /prefix/*/suffix (a.k.a shallow wildcard)
7
+ const TYPE_MATCH_ANY = 3 // /prefix/**/suffix (a.k.a deep wildcard)
7
8
 
8
9
  // Char Codes
9
- const CHAR_PARAM = ':'.charCodeAt(0)
10
- const CHAR_MATCH_ANY = '*'.charCodeAt(0)
11
- const CHAR_SLASH = '/'.charCodeAt(0)
10
+ const CHAR_SLASH = '/'[0]
11
+ const CHAR_PARAM = ':'[0]
12
+ const CHAR_WILDCARD = '*'[0]
12
13
 
13
14
  export default class Node {
14
15
  constructor(...args) {
@@ -16,124 +17,117 @@ export default class Node {
16
17
  }
17
18
 
18
19
  initialize(
19
- prefix = '/',
20
20
  type = TYPE_STATIC,
21
+ prefix = '/',
21
22
  children = [],
22
- handler = null,
23
- paramNames = null
23
+ parameters = null,
24
+ handler = null
24
25
  ) {
25
- this.label = prefix.charCodeAt(0)
26
- this.prefix = prefix
27
26
  this.type = type
27
+ this.label = prefix[0]
28
+ this.prefix = prefix
28
29
  this.children = children
30
+ this.parameters = parameters
29
31
  this.handler = handler
30
- this.paramNames = paramNames
31
- this.paramName = null
32
- }
33
-
34
- addChild(child) {
35
- this.children.push(child)
36
- }
37
-
38
- findChild(label, type) {
39
- for (const child of this.children) {
40
- if (child.label === label && child.type === type) {
41
- return child
42
- }
43
- }
32
+ this.paramKey = null
33
+ this.hasMatchAny = false
44
34
  }
45
35
 
46
- findChildWithLabel(label) {
47
- for (const child of this.children) {
48
- if (child.label === label) {
49
- return child
50
- }
36
+ setup(parameters, handler) {
37
+ this.parameters = parameters
38
+ this.handler = handler
39
+ if (this.type === TYPE_MATCH_ANY) {
40
+ parameters.setupPathPattern(this.prefix)
51
41
  }
52
42
  }
53
43
 
54
- findChildWithType(type) {
55
- for (const child of this.children) {
56
- if (child.type === type) {
57
- return child
58
- }
59
- }
44
+ addChild(child) {
45
+ this.children.push(child)
46
+ this.hasMatchAny ||= child.type === TYPE_MATCH_ANY
60
47
  }
61
48
 
62
49
  add(path, handler) {
63
- const paramNames = []
50
+ const parameters = new Parameters()
64
51
  for (let pos = 0, length = path.length; pos < length; pos++) {
65
- const ch = path.charCodeAt(pos)
66
- if (ch === CHAR_PARAM) {
67
- this.insert(path.slice(0, pos), TYPE_STATIC)
68
- pos++ // Skip colon.
52
+ const ch = path[pos]
53
+ if (ch === CHAR_WILDCARD && path[pos + 1] === CHAR_WILDCARD) {
54
+ // Deep wildcard (**): matches any path, with a optional suffix:
55
+ this.insert(TYPE_STATIC, path.slice(0, pos))
56
+ pos += 2 // Skip '**'.
57
+ this.insert(TYPE_MATCH_ANY, path, parameters, handler)
58
+ return
59
+ } else if (ch === CHAR_PARAM || ch === CHAR_WILDCARD) {
60
+ // Param (:param) or shallow wildcard (*):
61
+ const isWildcard = ch === CHAR_WILDCARD
62
+ const type = isWildcard ? TYPE_PLACEHOLDER : TYPE_PARAM
63
+ this.insert(TYPE_STATIC, path.slice(0, pos))
64
+ pos++ // Skip colon or wildcard.
69
65
  const start = pos
70
66
  // Move pos to the next occurrence of the slash or the end:
71
67
  pos += path.slice(pos).match(/^([^/]*)/)[1].length
72
-
73
- paramNames.push(path.slice(start, pos))
68
+ parameters.add(
69
+ isWildcard ? parameters.getPlaceholderKey() : path.slice(start, pos)
70
+ )
74
71
  // Chop out param name from path, but keep colon.
75
72
  path = path.slice(0, start) + path.slice(pos)
76
73
  length = path.length // Update length after changing path.
77
74
 
78
75
  if (start === length) {
79
- return this.insert(path, TYPE_PARAM, paramNames, handler)
76
+ this.insert(type, path, parameters, handler)
77
+ return
80
78
  }
81
79
  pos = start
82
- this.insert(path.slice(0, pos), TYPE_PARAM, paramNames)
83
- } else if (ch === CHAR_MATCH_ANY) {
84
- this.insert(path.slice(0, pos), TYPE_STATIC)
85
- paramNames.push('*')
86
- return this.insert(path, TYPE_MATCH_ANY, paramNames, handler)
80
+ this.insert(type, path.slice(0, pos), parameters)
87
81
  }
88
82
  }
89
- this.insert(path, TYPE_STATIC, paramNames, handler)
83
+ this.insert(TYPE_STATIC, path, parameters, handler)
90
84
  }
91
85
 
92
- insert(path, type, paramNames, handler) {
86
+ insert(type, prefix, parameters = null, handler = null) {
93
87
  let current = this
94
88
  while (true) {
95
89
  // Find the position where the path and the node's prefix start diverging.
96
- const pos = getCommonOffset(current.prefix, path)
97
- const { prefix } = current
98
- if (pos < prefix.length) {
90
+ const curPrefix = current.prefix
91
+ const pos = getCommonOffset(curPrefix, prefix)
92
+ if (pos < curPrefix.length) {
99
93
  // Split node
100
94
  const node = new Node(
101
- prefix.slice(pos),
102
95
  current.type,
96
+ curPrefix.slice(pos),
103
97
  current.children,
104
- current.handler,
105
- current.paramNames
98
+ current.parameters,
99
+ current.handler
106
100
  )
107
- // Reset parent node and add new node as child to it:
108
- current.initialize(prefix.slice(0, pos))
101
+ // Reset parent node to a static and add new node as child to it:
102
+ current.initialize(TYPE_STATIC, curPrefix.slice(0, pos))
109
103
  current.addChild(node)
110
- if (pos < path.length) {
104
+ if (pos < prefix.length) {
111
105
  // Create child node
112
- const node = new Node(path.slice(pos), type)
106
+ const node = new Node(type, prefix.slice(pos))
113
107
  current.addChild(node)
114
- current = node // Switch to child to set handler and paramNames
108
+ current = node // Switch to child to set handler and parameters
115
109
  }
116
- } else if (pos < path.length) {
117
- path = path.slice(pos)
118
- const child = current.findChildWithLabel(path.charCodeAt(0))
119
- if (child !== undefined) {
110
+ } else if (pos < prefix.length) {
111
+ prefix = prefix.slice(pos)
112
+ const label = prefix[0]
113
+ const child = current.children.find(child => child.label === label)
114
+ if (child !== undefined && child.type !== TYPE_MATCH_ANY) {
120
115
  // Go deeper
121
116
  current = child
122
117
  continue
123
118
  }
124
119
  // Create child node
125
- const node = new Node(path, type)
120
+ const node = new Node(type, prefix)
126
121
  current.addChild(node)
127
- current = node // Switch to child to set handler and paramNames
122
+ current = node // Switch to child to set handler and parameters
128
123
  }
129
124
  if (handler) {
130
- current.handler = handler
131
- current.paramNames = paramNames
125
+ current.setup(parameters, handler)
132
126
  }
133
- if (paramNames) {
127
+ if (parameters) {
134
128
  // Remember the last entry from the list of param names that keeps
135
129
  // growing during parsing as the name of the current node.
136
- current.paramName = paramNames[paramNames.length - 1]
130
+ current.paramKey = parameters.getLastKey()
137
131
  }
138
132
  break
139
133
  }
@@ -145,12 +139,7 @@ export default class Node {
145
139
  // It's a match!
146
140
  const { handler } = this
147
141
  if (handler) {
148
- // Convert paramNames and values to params.
149
- const params = {}
150
- let i = 0
151
- for (const name of this.paramNames) {
152
- params[name] = paramValues[i++]
153
- }
142
+ const params = this.parameters.getObject(paramValues)
154
143
  // Support HTTP status on found entries.
155
144
  return { handler, params, status: 200 }
156
145
  }
@@ -162,7 +151,7 @@ export default class Node {
162
151
  const fullMatch = pos === prefixLength
163
152
  if (fullMatch) {
164
153
  path = path.slice(prefixLength)
165
- } else if (this.type !== TYPE_PARAM) {
154
+ } else if (this.type !== TYPE_PARAM && this.type !== TYPE_PLACEHOLDER) {
166
155
  // If the path doesn't fully match the prefix, we only need to look
167
156
  // further on param nodes, which can have overlapping static children.
168
157
  return null
@@ -171,7 +160,10 @@ export default class Node {
171
160
  // Search order: Static > Param > Match-any
172
161
 
173
162
  // Static node
174
- const staticChild = this.findChild(path.charCodeAt(0), TYPE_STATIC)
163
+ const label = path[0]
164
+ const staticChild = this.children.find(
165
+ child => child.type === TYPE_STATIC && child.label === label
166
+ )
175
167
  if (staticChild) {
176
168
  const result = staticChild.find(path, paramValues)
177
169
  if (result) {
@@ -180,17 +172,17 @@ export default class Node {
180
172
  }
181
173
 
182
174
  // Node not found
183
- if (!fullMatch) {
184
- return null
185
- }
175
+ if (!fullMatch) return null
186
176
 
187
- // Param node
188
- const paramChild = this.findChildWithType(TYPE_PARAM)
177
+ // Param / placeholder node
178
+ const paramChild = this.children.find(
179
+ child => child.type === TYPE_PARAM || child.type === TYPE_PLACEHOLDER
180
+ )
189
181
  if (paramChild) {
190
182
  // Find the position of the next slash:
191
183
  let pos = 0
192
184
  const max = path.length
193
- while (pos < max && path.charCodeAt(pos) !== CHAR_SLASH) {
185
+ while (pos < max && path[pos] !== CHAR_SLASH) {
194
186
  pos++
195
187
  }
196
188
  paramValues.push(path.slice(0, pos))
@@ -201,11 +193,15 @@ export default class Node {
201
193
  paramValues.pop()
202
194
  }
203
195
 
204
- // Match-any node
205
- const matchAnyChild = this.findChildWithType(TYPE_MATCH_ANY)
206
- if (matchAnyChild) {
207
- paramValues.push(path)
208
- return matchAnyChild.find('', paramValues) // '' == End
196
+ // Match-any nodes
197
+ if (this.hasMatchAny) {
198
+ for (const child of this.children) {
199
+ const match = child.parameters?.matchPathPattern(path)
200
+ if (match) {
201
+ paramValues.push(...Object.values(match.groups))
202
+ return child.find('', paramValues) // '' == End
203
+ }
204
+ }
209
205
  }
210
206
 
211
207
  return null
@@ -219,7 +215,7 @@ export default class Node {
219
215
  const lines = [
220
216
  `${format(prefix, tail, '└── ', '├── ')}${
221
217
  this.type === TYPE_PARAM
222
- ? `${this.prefix}${this.paramName}`
218
+ ? `${this.prefix}${this.paramKey}`
223
219
  : this.prefix
224
220
  }${
225
221
  handler ? ` ${handler}` : ''
@@ -235,3 +231,78 @@ export default class Node {
235
231
  return lines.join('\n')
236
232
  }
237
233
  }
234
+
235
+ class Parameters {
236
+ constructor() {
237
+ this.keys = []
238
+ this.pathPattern = null
239
+ this.matchAnyIndex = 0
240
+ this.placeholderIndex = 0
241
+ }
242
+
243
+ add(...keys) {
244
+ this.keys.push(...keys)
245
+ }
246
+
247
+ getMatchAnyKey() {
248
+ return `$$${this.matchAnyIndex++}`
249
+ }
250
+
251
+ getPlaceholderKey() {
252
+ return `$${this.placeholderIndex++}`
253
+ }
254
+
255
+ getLastKey() {
256
+ return this.keys[this.keys.length - 1]
257
+ }
258
+
259
+ matchPathPattern(path) {
260
+ return this.pathPattern?.exec(path)
261
+ }
262
+
263
+ setupPathPattern(path) {
264
+ // Replace all '**' with '.+?' and all '*' with '[^/]+'.
265
+ // Use named groups to merge multiple ** or * into one.
266
+ const pattern = []
267
+ const keys = []
268
+ for (const token of path.split('/')) {
269
+ if (token === '**') {
270
+ const key = this.getMatchAnyKey()
271
+ pattern.push(`(?<${key}>.+?)`)
272
+ keys.push(key)
273
+ } else if (token === '*') {
274
+ const key = this.getPlaceholderKey()
275
+ pattern.push(`(?<${key}>[^/]+)`)
276
+ keys.push(key)
277
+ } else if (token.startsWith(':')) {
278
+ const key = token.slice(1)
279
+ pattern.push(`(?<${key}>[^/]+)`)
280
+ keys.push(key)
281
+ } else {
282
+ pattern.push(token)
283
+ }
284
+ }
285
+ this.pathPattern = new RegExp(`^${pattern.join('/')}$`)
286
+ this.add(...keys)
287
+ }
288
+
289
+ getObject(values) {
290
+ // Convert parameters and values to a params object, but rename path pattern
291
+ // groups back to param names
292
+ const params = {}
293
+ let i = 0
294
+ for (const key of this.keys) {
295
+ const name = key.startsWith('$$')
296
+ ? this.matchAnyIndex === 1
297
+ ? '$$'
298
+ : key
299
+ : key.startsWith('$')
300
+ ? this.placeholderIndex === 1
301
+ ? '$'
302
+ : key
303
+ : key
304
+ params[name] = values[i++]
305
+ }
306
+ return params
307
+ }
308
+ }
@@ -121,13 +121,13 @@ describe('Router', () => {
121
121
  expect(result.allowed).toStrictEqual(['GET', 'PUT'])
122
122
  })
123
123
 
124
- it('handles match-any nodes (catch-all / wildcard)', () => {
125
- router.add('GET', '/static/*', handler)
124
+ it('handles match-any nodes (catch-all)', () => {
125
+ router.add('GET', '/static/**', handler)
126
126
 
127
127
  expect(router.toString()).toBe(deindent`
128
128
  / children=1
129
129
  └── static/ children=1
130
- └── * handler() children=0
130
+ └── ** handler() children=0
131
131
  `.trim())
132
132
 
133
133
  result = router.find('GET', '/static')
@@ -139,13 +139,109 @@ describe('Router', () => {
139
139
  result = router.find('GET', '/static/js')
140
140
  expect(result.handler).toBe(handler)
141
141
  expect(result.params).toEqual({
142
- '*': 'js'
142
+ $$: 'js'
143
143
  })
144
144
 
145
145
  result = router.find('GET', '/static/css')
146
146
  expect(result.handler).toBe(handler)
147
147
  expect(result.params).toEqual({
148
- '*': 'css'
148
+ $$: 'css'
149
+ })
150
+ })
151
+
152
+ it('handles match-any nodes (wildcard)', () => {
153
+ router.add('GET', '/prefix/*/suffix', handler)
154
+
155
+ expect(router.toString()).toBe(deindent`
156
+ / children=1
157
+ └── prefix/ children=1
158
+ └── * children=1
159
+ └── /suffix handler() children=0
160
+ `.trim())
161
+
162
+ result = router.find('GET', '/prefix')
163
+ expect(result.handler).toBeUndefined()
164
+
165
+ result = router.find('GET', '/prefix/*')
166
+ expect(result.handler).toBeUndefined()
167
+
168
+ result = router.find('GET', '/prefix/hello/there')
169
+ expect(result.handler).toBeUndefined()
170
+
171
+ result = router.find('GET', '/prefix/hello/suffix')
172
+ expect(result.handler).toBe(handler)
173
+ expect(result.params).toEqual({
174
+ $: 'hello'
175
+ })
176
+ })
177
+
178
+ it('handles match-any nodes with suffixes', () => {
179
+ router.add('GET', '/static/**/suffix', handler)
180
+ router.add('GET', '/static/**/suffix/**/more', handler)
181
+ router.add('GET', '/static/**/suffix/*/1/:param/2', handler)
182
+ router.add('GET', '/static/**/suffix/*/1/:param/2/**/end', handler)
183
+
184
+ expect(router.toString()).toBe(deindent`
185
+ / children=1
186
+ └── static/ children=4
187
+ ├── **/suffix handler() children=0
188
+ ├── **/suffix/**/more handler() children=0
189
+ ├── **/suffix/*/1/:param/2 handler() children=0
190
+ └── **/suffix/*/1/:param/2/**/end handler() children=0
191
+ `.trim())
192
+
193
+ result = router.find('GET', '/static')
194
+ expect(result.handler).toBeUndefined()
195
+
196
+ result = router.find('GET', '/static/js')
197
+ expect(result.handler).toBeUndefined()
198
+
199
+ result = router.find('GET', '/static/one/prefix')
200
+ expect(result.handler).toBeUndefined()
201
+
202
+ result = router.find('GET', '/static/one/two/prefix')
203
+ expect(result.handler).toBeUndefined()
204
+
205
+ result = router.find('GET', '/static/one/suffix')
206
+ expect(result.handler).toBe(handler)
207
+ expect(result.params).toEqual({
208
+ $$: 'one'
209
+ })
210
+
211
+ result = router.find('GET', '/static/one/two/suffix')
212
+ expect(result.handler).toBe(handler)
213
+ expect(result.params).toEqual({
214
+ $$: 'one/two'
215
+ })
216
+
217
+ result = router.find('GET', '/static/one/two/suffix/three/four')
218
+ expect(result.handler).toBeUndefined()
219
+
220
+ result = router.find('GET', '/static/one/two/suffix/three/four/more')
221
+ expect(result.handler).toBe(handler)
222
+ expect(result.params).toEqual({
223
+ $$0: 'one/two',
224
+ $$1: 'three/four'
225
+ })
226
+
227
+ result = router.find('GET', '/static/one/two/suffix/three/1/bla/2')
228
+ expect(result.handler).toBe(handler)
229
+ expect(result.params).toEqual({
230
+ $$: 'one/two',
231
+ $: 'three',
232
+ param: 'bla'
233
+ })
234
+
235
+ result = router.find('GET', '/static/one/two/suffix/three/1/bla/3')
236
+ expect(result.handler).toBeUndefined()
237
+
238
+ result = router.find('GET', `/static/one/two/suffix/three/1/bla/2/what/ever/end`)
239
+ expect(result.handler).toBe(handler)
240
+ expect(result.params).toEqual({
241
+ $: 'three',
242
+ param: 'bla',
243
+ $$0: 'one/two',
244
+ $$1: 'what/ever'
149
245
  })
150
246
  })
151
247
 
@@ -164,7 +260,7 @@ describe('Router', () => {
164
260
  ['/geocoder/exchange/:item', 'exchangeItemGeocoder'],
165
261
  ['/geocoder/:id/echo', 'echoGeocoder'],
166
262
  ['/geocoder/:action', 'actionGeocoder'],
167
- ['/geocoder/*', 'anyGeocoder']
263
+ ['/geocoder/**', 'anyGeocoder']
168
264
  ])
169
265
 
170
266
  expect(router.toString()).toBe(deindent`
@@ -185,7 +281,7 @@ describe('Router', () => {
185
281
  │ └── :item exchangeItemGeocoder() children=0
186
282
  ├── :action actionGeocoder() children=1
187
283
  │ └── /echo echoGeocoder() children=0
188
- └── * anyGeocoder() children=0
284
+ └── ** anyGeocoder() children=0
189
285
  `.trim())
190
286
  result = router.find('GET', '')
191
287
  expect(result.handler).not.toBeUndefined()
@@ -203,12 +299,12 @@ describe('Router', () => {
203
299
 
204
300
  result = router.find('GET', '/geocoder/delete/any')
205
301
  expect(result.handler.name).toBe('anyGeocoder')
206
- expect(result.params).toEqual({ '*': 'delete/any' })
302
+ expect(result.params).toEqual({ $$: 'delete/any' })
207
303
 
208
304
  result = router.find('GET', '/geocoder/any/action')
209
305
  expect(result.handler).not.toBeUndefined()
210
306
  expect(result.handler.name).toBe('anyGeocoder')
211
- expect(result.params).toEqual({ '*': 'any/action' })
307
+ expect(result.params).toEqual({ $$: 'any/action' })
212
308
 
213
309
  result = router.find('GET', '/geocoder/exchange/trekjs')
214
310
  expect(result.handler).not.toBeUndefined()
@@ -270,14 +366,15 @@ describe('Router', () => {
270
366
  createRoutes(router, [
271
367
  ['/users', 'users'],
272
368
  ['/users/new', 'newUser'],
273
- ['/users/nnw', 'newUser'],
369
+ ['/users/noi', 'newUser'],
370
+ ['/users/nei', 'newUser'],
274
371
  ['/users/:id', 'user'],
275
372
  ['/users/:id/edit', 'editUser'],
276
373
  ['/users/:id/:hello/:good/:bad/ddd', 'editUser'],
277
374
  ['/users/:id/:action', 'actionUser'],
278
375
  ['/users/:userId/photos/:id', 'photo'],
279
376
  ['/users/:userId/books/:id', 'book'],
280
- ['/users/*', 'anyUser']
377
+ ['/users/**', 'anyUser']
281
378
  ])
282
379
 
283
380
  expect(router.toString()).toBe(deindent`
@@ -285,8 +382,10 @@ describe('Router', () => {
285
382
  └── users users() children=1
286
383
  └── / children=3
287
384
  ├── n children=2
288
- │ ├── ew newUser() children=0
289
- │ └── nw newUser() children=0
385
+ │ ├── e children=2
386
+ │ │ ├── w newUser() children=0
387
+ │ │ └── i newUser() children=0
388
+ │ └── oi newUser() children=0
290
389
  ├── :userId user() children=1
291
390
  │ └── / children=4
292
391
  │ ├── edit editUser() children=0
@@ -300,13 +399,13 @@ describe('Router', () => {
300
399
  │ │ └── :id photo() children=0
301
400
  │ └── books/ children=1
302
401
  │ └── :id book() children=0
303
- └── * anyUser() children=0
402
+ └── ** anyUser() children=0
304
403
  `.trim())
305
404
 
306
405
  result = router.find('GET', '/users/610/books/987/edit')
307
406
  expect(result.handler).not.toBeUndefined()
308
407
  expect(result.handler.name).toBe('anyUser')
309
- expect(result.params).toEqual({ '*': '610/books/987/edit' })
408
+ expect(result.params).toEqual({ $$: '610/books/987/edit' })
310
409
 
311
410
  result = router.find('GET', '/users/610/books/987')
312
411
  expect(result.handler).not.toBeUndefined()