-
-
Notifications
You must be signed in to change notification settings - Fork 106
/
Copy pathUniversalRouter.ts
336 lines (297 loc) · 9.32 KB
/
UniversalRouter.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
/**
* Universal Router (https://www.kriasoft.com/universal-router/)
*
* Copyright (c) 2015-present Kriasoft.
*
* This source code is licensed under the MIT license found in the
* LICENSE.txt file in the root directory of this source tree.
*/
import {
match,
Path,
Match,
MatchFunction,
ParseOptions,
TokensToRegexpOptions,
RegexpToFunctionOptions,
} from 'path-to-regexp'
/**
* In addition to a URL path string, any arbitrary data can be passed to
* the `router.resolve()` method, that becomes available inside action functions.
*/
export interface RouterContext {
[propName: string]: any
}
export interface ResolveContext extends RouterContext {
/**
* URL which was transmitted to `router.resolve()`.
*/
pathname: string
}
/**
* Params is a key/value object that represents extracted URL parameters.
*/
export interface RouteParams {
[paramName: string]: string | string[]
}
export type RouteResult<T> = T | null | undefined | Promise<T | null | undefined>
export interface RouteContext<R = any, C extends RouterContext = RouterContext>
extends ResolveContext {
/**
* Current router instance.
*/
router: UniversalRouter<R, C>
/**
* Matched route object.
*/
route: Route<R, C>
/**
* Base URL path relative to the path of the current route.
*/
baseUrl: string
/**
* Matched path.
*/
path: string
/**
* Matched path params.
*/
params: RouteParams
/**
* Middleware style function which can continue resolving.
*/
next: (resume?: boolean) => Promise<R>
}
/**
* A Route is a singular route in your application. It contains a path, an
* action function, and optional children which are an array of Route.
* @template C User context that is made union with RouterContext.
* @template R Result that every action function resolves to.
* If the action returns a Promise, R can be the type the Promise resolves to.
*/
export interface Route<R = any, C extends RouterContext = RouterContext> {
/**
* A string, array of strings, or a regular expression. Defaults to an empty string.
*/
path?: Path
/**
* A unique string that can be used to generate the route URL.
*/
name?: string
/**
* The link to the parent route is automatically populated by the router. Useful for breadcrumbs.
*/
parent?: Route<R, C> | null
/**
* An array of Route objects. Nested routes are perfect to be used in middleware routes.
*/
children?: Routes<R, C> | null
/**
* Action method should return anything except `null` or `undefined` to be resolved by router
* otherwise router will throw `Page not found` error if all matched routes returned nothing.
*/
action?: (context: RouteContext<R, C>, params: RouteParams) => RouteResult<R>
/**
* The route path match function. Used for internal caching.
*/
match?: MatchFunction<RouteParams>
}
/**
* Routes is an array of type Route.
* @template C User context that is made union with RouterContext.
* @template R Result that every action function resolves to.
* If the action returns a Promise, R can be the type the Promise resolves to.
*/
export type Routes<R = any, C extends RouterContext = RouterContext> = Array<Route<R, C>>
export type ResolveRoute<R = any, C extends RouterContext = RouterContext> = (
context: RouteContext<R, C>,
params: RouteParams,
) => RouteResult<R>
export type RouteError = Error & { status?: number }
export type ErrorHandler<R = any> = (error: RouteError, context: ResolveContext) => RouteResult<R>
export interface RouterOptions<R = any, C extends RouterContext = RouterContext>
extends ParseOptions,
TokensToRegexpOptions,
RegexpToFunctionOptions {
context?: C
baseUrl?: string
resolveRoute?: ResolveRoute<R, C>
errorHandler?: ErrorHandler<R>
}
export interface RouteMatch<R = any, C extends RouterContext = RouterContext> {
route: Route<R, C>
baseUrl: string
path: string
params: RouteParams
}
function decode(val: string): string {
try {
return decodeURIComponent(val)
} catch (err) {
return val
}
}
function matchRoute<R, C extends RouterContext>(
route: Route<R, C>,
baseUrl: string,
options: RouterOptions<R, C>,
pathname: string,
parentParams?: RouteParams,
): Iterator<RouteMatch<R, C>, false, Route<R, C> | false> {
let matchResult: Match<RouteParams>
let childMatches: Iterator<RouteMatch<R, C>, false, Route<R, C> | false> | null
let childIndex = 0
return {
next(routeToSkip: Route<R, C> | false): IteratorResult<RouteMatch<R, C>, false> {
if (route === routeToSkip) {
return { done: true, value: false }
}
if (!matchResult) {
const rt = route
const end = !rt.children
if (!rt.match) {
rt.match = match<RouteParams>(rt.path || '', { end, ...options })
}
matchResult = rt.match(pathname)
if (matchResult) {
const { path } = matchResult
matchResult.path = !end && path.charAt(path.length - 1) === '/' ? path.substr(1) : path
matchResult.params = { ...parentParams, ...matchResult.params }
return {
done: false,
value: {
route,
baseUrl,
path: matchResult.path,
params: matchResult.params,
},
}
}
}
if (matchResult && route.children) {
while (childIndex < route.children.length) {
if (!childMatches) {
const childRoute = route.children[childIndex]!
childRoute.parent = route
childMatches = matchRoute<R, C>(
childRoute,
baseUrl + matchResult.path,
options,
pathname.substr(matchResult.path.length),
matchResult.params,
)
}
const childMatch = childMatches.next(routeToSkip)
if (!childMatch.done) {
return {
done: false,
value: childMatch.value,
}
}
childMatches = null
childIndex++
}
}
return { done: true, value: false }
},
}
}
function resolveRoute<R = any, C extends RouterContext = object>(
context: RouteContext<R, C>,
params: RouteParams,
): RouteResult<R> {
if (typeof context.route.action === 'function') {
return context.route.action(context, params)
}
return undefined
}
function isChildRoute<R = any, C extends RouterContext = object>(
parentRoute: Route<R, C> | false,
childRoute: Route<R, C>,
): boolean {
let route: Route<R, C> | null | undefined = childRoute
while (route) {
route = route.parent
if (route === parentRoute) {
return true
}
}
return false
}
class UniversalRouter<R = any, C extends RouterContext = RouterContext> {
root: Route<R, C>
baseUrl: string
options: RouterOptions<R, C>
constructor(routes: Routes<R, C> | Route<R, C>, options?: RouterOptions<R, C>) {
if (!routes || typeof routes !== 'object') {
throw new TypeError('Invalid routes')
}
this.options = { decode, ...options }
this.baseUrl = this.options.baseUrl || ''
this.root = Array.isArray(routes) ? { path: '', children: routes, parent: null } : routes
this.root.parent = null
}
/**
* Traverses the list of routes in the order they are defined until it finds
* the first route that matches provided URL path string and whose action function
* returns anything other than `null` or `undefined`.
*/
resolve(pathnameOrContext: string | ResolveContext): Promise<RouteResult<R>> {
const context: ResolveContext = {
router: this,
...this.options.context,
...(typeof pathnameOrContext === 'string'
? { pathname: pathnameOrContext }
: pathnameOrContext),
}
const matchResult = matchRoute(
this.root,
this.baseUrl,
this.options,
context.pathname.substr(this.baseUrl.length),
)
const resolve = this.options.resolveRoute || resolveRoute
let matches: IteratorResult<RouteMatch<R, C>, false>
let nextMatches: IteratorResult<RouteMatch<R, C>, false> | null
let currentContext = context
function next(
resume: boolean,
parent: Route<R, C> | false = !matches.done && matches.value.route,
prevResult?: RouteResult<R>,
): Promise<RouteResult<R>> {
const routeToSkip = prevResult === null && !matches.done && matches.value.route
matches = nextMatches || matchResult.next(routeToSkip)
nextMatches = null
if (!resume) {
if (matches.done || !isChildRoute(parent, matches.value.route)) {
nextMatches = matches
return Promise.resolve(null)
}
}
if (matches.done) {
const error: RouteError = new Error('Route not found')
error.status = 404
return Promise.reject(error)
}
currentContext = { ...context, ...matches.value }
return Promise.resolve(
resolve(currentContext as RouteContext<R, C>, matches.value.params),
).then((result) => {
if (result !== null && result !== undefined) {
return result
}
return next(resume, parent, result)
})
}
context['next'] = next
return Promise.resolve()
.then(() => next(true, this.root))
.catch((error) => {
if (this.options.errorHandler) {
return this.options.errorHandler(error, currentContext)
}
throw error
})
}
}
export default UniversalRouter