UNPKG

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