UNPKG

9.22 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: UniversalRouterSync<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) => R
68}
69
70export type RouteResultSync<T> = T | 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 */
78export interface Route<R = any, C extends RouterContext = RouterContext> {
79 /**
80 * A string, array of strings, or a regular expression. Defaults to an empty string.
81 */
82 path?: Path
83 /**
84 * A unique string that can be used to generate the route URL.
85 */
86 name?: string
87 /**
88 * The link to the parent route is automatically populated by the router. Useful for breadcrumbs.
89 */
90 parent?: Route<R, C> | null
91 /**
92 * An array of Route objects. Nested routes are perfect to be used in middleware routes.
93 */
94 children?: Routes<R, C> | null
95 /**
96 * Action method should return anything except `null` or `undefined` to be resolved by router
97 * otherwise router will throw `Page not found` error if all matched routes returned nothing.
98 */
99 action?: (context: RouteContext<R, C>, params: RouteParams) => RouteResultSync<R>
100 /**
101 * The route path match function. Used for internal caching.
102 */
103 match?: MatchFunction<RouteParams>
104}
105
106/**
107 * Routes is an array of type Route.
108 * @template C User context that is made union with RouterContext.
109 * @template R Result that every action function resolves to.
110 */
111export type Routes<R = any, C extends RouterContext = RouterContext> = Array<Route<R, C>>
112
113export type ResolveRoute<R = any, C extends RouterContext = RouterContext> = (
114 context: RouteContext<R, C>,
115 params: RouteParams,
116) => RouteResultSync<R>
117
118export type RouteError = Error & { status?: number }
119
120export type ErrorHandler<R = any> = (
121 error: RouteError,
122 context: ResolveContext,
123) => RouteResultSync<R>
124
125export interface RouterOptions<R = any, C extends RouterContext = RouterContext>
126 extends ParseOptions,
127 TokensToRegexpOptions,
128 RegexpToFunctionOptions {
129 context?: C
130 baseUrl?: string
131 resolveRoute?: ResolveRoute<R, C>
132 errorHandler?: ErrorHandler<R>
133}
134
135export interface RouteMatch<R = any, C extends RouterContext = RouterContext> {
136 route: Route<R, C>
137 baseUrl: string
138 path: string
139 params: RouteParams
140}
141
142function decode(val: string): string {
143 try {
144 return decodeURIComponent(val)
145 } catch (err) {
146 return val
147 }
148}
149
150function matchRoute<R, C>(
151 route: Route<R, C>,
152 baseUrl: string,
153 options: RouterOptions<R, C>,
154 pathname: string,
155 parentParams?: RouteParams,
156): Iterator<RouteMatch<R, C>, false, Route<R, C> | false> {
157 let matchResult: Match<RouteParams>
158 let childMatches: Iterator<RouteMatch<R, C>, false, Route<R, C> | false> | null
159 let childIndex = 0
160
161 return {
162 next(routeToSkip: Route<R, C> | false): IteratorResult<RouteMatch<R, C>, false> {
163 if (route === routeToSkip) {
164 return { done: true, value: false }
165 }
166
167 if (!matchResult) {
168 const rt = route
169 const end = !rt.children
170 if (!rt.match) {
171 rt.match = match<RouteParams>(rt.path || '', { end, ...options })
172 }
173 matchResult = rt.match(pathname)
174
175 if (matchResult) {
176 const { path } = matchResult
177 matchResult.path = !end && path.charAt(path.length - 1) === '/' ? path.substr(1) : path
178 matchResult.params = { ...parentParams, ...matchResult.params }
179 return {
180 done: false,
181 value: {
182 route,
183 baseUrl,
184 path: matchResult.path,
185 params: matchResult.params,
186 },
187 }
188 }
189 }
190
191 if (matchResult && route.children) {
192 while (childIndex < route.children.length) {
193 if (!childMatches) {
194 const childRoute = route.children[childIndex]
195 childRoute.parent = route
196
197 childMatches = matchRoute<R, C>(
198 childRoute,
199 baseUrl + matchResult.path,
200 options,
201 pathname.substr(matchResult.path.length),
202 matchResult.params,
203 )
204 }
205
206 const childMatch = childMatches.next(routeToSkip)
207 if (!childMatch.done) {
208 return {
209 done: false,
210 value: childMatch.value,
211 }
212 }
213
214 childMatches = null
215 childIndex++
216 }
217 }
218
219 return { done: true, value: false }
220 },
221 }
222}
223
224function resolveRoute<R = any, C extends RouterContext = object>(
225 context: RouteContext<R, C>,
226 params: RouteParams,
227): RouteResultSync<R> {
228 if (typeof context.route.action === 'function') {
229 return context.route.action(context, params)
230 }
231 return undefined
232}
233
234function isChildRoute<R = any, C extends RouterContext = object>(
235 parentRoute: Route<R, C> | false,
236 childRoute: Route<R, C>,
237): boolean {
238 let route: Route<R, C> | null | undefined = childRoute
239 while (route) {
240 route = route.parent
241 if (route === parentRoute) {
242 return true
243 }
244 }
245 return false
246}
247
248class UniversalRouterSync<R = any, C extends RouterContext = RouterContext> {
249 root: Route<R, C>
250
251 baseUrl: string
252
253 options: RouterOptions<R, C>
254
255 constructor(routes: Routes<R, C> | Route<R, C>, options?: RouterOptions<R, C>) {
256 if (!routes || typeof routes !== 'object') {
257 throw new TypeError('Invalid routes')
258 }
259
260 this.options = { decode, ...options }
261 this.baseUrl = this.options.baseUrl || ''
262 this.root = Array.isArray(routes) ? { path: '', children: routes, parent: null } : routes
263 this.root.parent = null
264 }
265
266 /**
267 * Traverses the list of routes in the order they are defined until it finds
268 * the first route that matches provided URL path string and whose action function
269 * returns anything other than `null` or `undefined`.
270 */
271 resolve(pathnameOrContext: string | ResolveContext): RouteResultSync<R> {
272 const context: ResolveContext = {
273 router: this,
274 ...this.options.context,
275 ...(typeof pathnameOrContext === 'string'
276 ? { pathname: pathnameOrContext }
277 : pathnameOrContext),
278 }
279 const matchResult = matchRoute(
280 this.root,
281 this.baseUrl,
282 this.options,
283 context.pathname.substr(this.baseUrl.length),
284 )
285 const resolve = this.options.resolveRoute || resolveRoute
286 let matches: IteratorResult<RouteMatch<R, C>, false>
287 let nextMatches: IteratorResult<RouteMatch<R, C>, false> | null
288 let currentContext = context
289
290 function next(
291 resume: boolean,
292 parent: Route<R, C> | false = !matches.done && matches.value.route,
293 prevResult?: RouteResultSync<R>,
294 ): RouteResultSync<R> {
295 const routeToSkip = prevResult === null && !matches.done && matches.value.route
296 matches = nextMatches || matchResult.next(routeToSkip)
297 nextMatches = null
298
299 if (!resume) {
300 if (matches.done || !isChildRoute(parent, matches.value.route)) {
301 nextMatches = matches
302 return null
303 }
304 }
305
306 if (matches.done) {
307 const error: RouteError = new Error('Route not found')
308 error.status = 404
309 throw error
310 }
311
312 currentContext = { ...context, ...matches.value }
313
314 const result = resolve(currentContext as RouteContext<R, C>, matches.value.params)
315 if (result !== null && result !== undefined) {
316 return result
317 }
318 return next(resume, parent, result)
319 }
320
321 context.next = next
322
323 try {
324 return next(true, this.root)
325 } catch (error) {
326 if (this.options.errorHandler) {
327 return this.options.errorHandler(error, currentContext)
328 }
329 throw error
330 }
331 }
332}
333
334export default UniversalRouterSync