@vobs/router 0.3.0 → 1.1.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/src/index.ts ADDED
@@ -0,0 +1,1173 @@
1
+ import { state, type Signal } from '@vobs/reactivity'
2
+ import {
3
+ createComponent,
4
+ createFragment,
5
+ createInjectionKey,
6
+ inject,
7
+ insertBoundary,
8
+ type InjectionKey,
9
+ type VobsNode,
10
+ type VobsPlugin
11
+ } from '@vobs/vobs'
12
+ import {
13
+ createRouterDebugId,
14
+ emitRouterDebug
15
+ } from './debug'
16
+ import {
17
+ getRuntimeDebugContext,
18
+ runWithRuntimeDebugContext,
19
+ type RuntimeDebugContext
20
+ } from '@vobs/runtime'
21
+
22
+ export { createRouterDebugId, emitRouterDebug, subscribeRouterDebug } from './debug'
23
+ export type { RouterDebugEvent, RouterDebugEventType } from './debug'
24
+
25
+ export type RouteParams = Readonly<Record<string, string>>
26
+ export type RouteQueryValue = string | readonly string[]
27
+ export type RouteQuery = Readonly<Record<string, RouteQueryValue>>
28
+ export type RouteMeta = Readonly<Record<string, unknown>>
29
+
30
+ export interface RouteLocation {
31
+ readonly path: string
32
+ readonly fullPath: string
33
+ readonly params: RouteParams
34
+ readonly query: RouteQuery
35
+ readonly hash: string
36
+ readonly name: string | undefined
37
+ readonly meta: RouteMeta
38
+ readonly record: RouteRecord | null
39
+ readonly matched: readonly RouteRecord[]
40
+ readonly state?: unknown
41
+ }
42
+
43
+ export interface RouteComponentProps {
44
+ readonly route: RouteLocation
45
+ readonly params: RouteParams
46
+ readonly query: RouteQuery
47
+ readonly children?: VobsNode
48
+ }
49
+
50
+ export type RouteComponent = (props: RouteComponentProps) => VobsNode
51
+ export type RouteComponentModule = RouteComponent | { default: RouteComponent }
52
+ export type RouteComponentLoader = () => PromiseLike<RouteComponentModule>
53
+
54
+ export interface LazyRouteComponent {
55
+ readonly kind: 'vobs-lazy-route'
56
+ readonly load: RouteComponentLoader
57
+ }
58
+
59
+ export type RouteComponentDefinition = RouteComponent | LazyRouteComponent
60
+
61
+ export interface RouteLoaderContext {
62
+ readonly route: RouteLocation
63
+ readonly navigationId?: number
64
+ readonly dataRequestId?: number
65
+ }
66
+
67
+ export type RouteLoader = (context: RouteLoaderContext) => unknown | PromiseLike<unknown>
68
+
69
+ export interface RouteRecord {
70
+ readonly path?: string
71
+ readonly component?: RouteComponentDefinition
72
+ readonly source?: string
73
+ readonly name?: string
74
+ readonly meta?: Record<string, unknown>
75
+ readonly loader?: RouteLoader
76
+ readonly action?: RouteLoader
77
+ readonly children?: readonly RouteRecord[]
78
+ }
79
+
80
+ export type RouteQueryInput = Record<string, unknown> | URLSearchParams
81
+
82
+ export interface RouteLocationRaw {
83
+ readonly path?: string
84
+ readonly name?: string
85
+ readonly params?: Record<string, unknown>
86
+ readonly query?: RouteQueryInput
87
+ readonly hash?: string
88
+ readonly state?: unknown
89
+ }
90
+
91
+ export type RouteTarget = string | RouteLocationRaw
92
+
93
+ export type NavigationGuardResult = void | boolean | RouteTarget
94
+ export type NavigationGuard = (
95
+ to: RouteLocation,
96
+ from: RouteLocation
97
+ ) => NavigationGuardResult | PromiseLike<NavigationGuardResult>
98
+
99
+ export class NavigationCancelledError extends Error {
100
+ readonly code = 'NAVIGATION_CANCELLED'
101
+
102
+ constructor() {
103
+ super('Vobs Router: 导航已被更新的导航取消')
104
+ this.name = 'NavigationCancelledError'
105
+ }
106
+ }
107
+
108
+ export class NavigationRedirectError extends Error {
109
+ readonly code = 'NAVIGATION_REDIRECT_LIMIT'
110
+
111
+ constructor() {
112
+ super('Vobs Router: 导航重定向超过最大次数')
113
+ this.name = 'NavigationRedirectError'
114
+ }
115
+ }
116
+
117
+ export interface RouterHistory {
118
+ readonly location: string
119
+ /** 当前 history 条目携带的导航 state(push/replace 时写入,popstate/初始启动时回读)。 */
120
+ readonly state?: unknown
121
+ push(path: string, state?: unknown): void
122
+ replace(path: string, state?: unknown): void
123
+ back(): void
124
+ listen(listener: (path: string, state: unknown) => void): () => void
125
+ }
126
+
127
+ export interface RouterOptions {
128
+ readonly routes: readonly RouteRecord[]
129
+ readonly history?: RouterHistory
130
+ }
131
+
132
+ export interface RouterViewState {
133
+ readonly status: 'ready' | 'loading' | 'error' | 'not-found'
134
+ readonly component?: RouteComponent
135
+ readonly layouts?: readonly RouteComponent[]
136
+ readonly error?: Error
137
+ readonly retry: () => void
138
+ }
139
+
140
+ export interface RouteDebugNode {
141
+ readonly id: string
142
+ readonly path: string
143
+ readonly name?: string
144
+ readonly component: string
145
+ readonly lazy: boolean
146
+ readonly loader: boolean
147
+ readonly action: boolean
148
+ readonly status: 'ready' | 'loading' | 'error'
149
+ readonly meta: RouteMeta
150
+ readonly source?: string
151
+ readonly children: readonly RouteDebugNode[]
152
+ }
153
+
154
+ export interface RouteErrorTrace {
155
+ readonly id: number
156
+ readonly phase: 'navigation' | 'render' | 'lazy' | 'loader' | 'action' | 'fetcher'
157
+ readonly route: string
158
+ readonly message: string
159
+ readonly stack?: string
160
+ readonly timestamp: number
161
+ readonly requestId?: number
162
+ readonly navigationId?: number
163
+ }
164
+
165
+ export interface NavigationTrace {
166
+ readonly id: number
167
+ readonly from: string
168
+ readonly to: string
169
+ readonly status: 'success' | 'redirected' | 'cancelled' | 'error'
170
+ readonly source: 'push' | 'replace' | 'history'
171
+ readonly startedAt: number
172
+ readonly endedAt: number
173
+ readonly duration: number
174
+ readonly redirect?: string
175
+ readonly error?: string
176
+ }
177
+
178
+ export interface NavigationState {
179
+ readonly status: 'idle' | 'loading' | 'error'
180
+ readonly from: string
181
+ readonly to: string
182
+ readonly traceId?: number
183
+ readonly error?: string
184
+ }
185
+
186
+ export interface RouterPerformanceMetrics {
187
+ readonly navigationCount: number
188
+ readonly averageNavigationDuration: number
189
+ readonly slowNavigationCount: number
190
+ }
191
+
192
+ export type RouterDataRequestKind = 'loader' | 'action' | 'fetcher'
193
+
194
+ export interface RouterDataRequestTrace {
195
+ readonly id: number
196
+ readonly kind: RouterDataRequestKind
197
+ readonly key: string
198
+ readonly route?: string
199
+ readonly status: 'loading' | 'success' | 'error' | 'cancelled'
200
+ readonly startedAt: number
201
+ readonly endedAt?: number
202
+ readonly duration?: number
203
+ readonly result?: unknown
204
+ readonly error?: string
205
+ readonly navigationId?: number
206
+ readonly trigger?: 'navigation' | 'revalidate' | 'manual' | 'resource'
207
+ readonly environment?: 'client' | 'server'
208
+ }
209
+
210
+ export interface RouterDataRequestOptions {
211
+ readonly route?: string
212
+ readonly navigationId?: number
213
+ readonly trigger?: 'navigation' | 'revalidate' | 'manual' | 'resource'
214
+ readonly environment?: 'client' | 'server'
215
+ }
216
+
217
+ export type RouterDevToolsEvent = 'navigation:start' | 'navigation:end' | 'route:update' | 'data-request' | 'error'
218
+
219
+ export interface RouterDevToolsAPI {
220
+ getRouteTree(): readonly RouteDebugNode[]
221
+ getCurrentRoute(): RouteLocation
222
+ getNavigationState(): NavigationState
223
+ getNavigationHistory(): readonly NavigationTrace[]
224
+ getPerformanceMetrics(): RouterPerformanceMetrics
225
+ getDataRequests(): readonly RouterDataRequestTrace[]
226
+ getErrors(): readonly RouteErrorTrace[]
227
+ trackDataRequest<T>(
228
+ kind: RouterDataRequestKind,
229
+ key: string,
230
+ task: () => T | PromiseLike<T>,
231
+ options?: RouterDataRequestOptions | string
232
+ ): Promise<T>
233
+ runAction<T>(key: string, task: () => T | PromiseLike<T>): Promise<T>
234
+ runFetcher<T>(key: string, task: () => T | PromiseLike<T>): Promise<T>
235
+ reportError(phase: RouteErrorTrace['phase'], error: unknown, route?: string, context?: { readonly requestId?: number; readonly navigationId?: number }): void
236
+ revalidate(route?: string): Promise<void>
237
+ subscribe(event: RouterDevToolsEvent, callback: (payload: unknown) => void): () => void
238
+ }
239
+
240
+ export interface Router {
241
+ readonly currentRoute: Signal<RouteLocation>
242
+ readonly history: RouterHistory
243
+ resolve(to: RouteTarget): RouteLocation
244
+ push(to: RouteTarget): Promise<RouteLocation | false>
245
+ replace(to: RouteTarget): Promise<RouteLocation | false>
246
+ back(): void
247
+ beforeEach(guard: NavigationGuard): () => void
248
+ getViewState(route: RouteLocation): RouterViewState
249
+ readonly devtools: RouterDevToolsAPI
250
+ destroy(): void
251
+ }
252
+
253
+ export interface RouterViewProps {
254
+ readonly router?: Router
255
+ readonly loading?: () => VobsNode | null | undefined
256
+ readonly notFound?: (route: RouteLocation) => VobsNode | null | undefined
257
+ readonly error?: (error: Error, retry: () => void) => VobsNode | null | undefined
258
+ }
259
+
260
+ export interface RouterPluginOptions {
261
+ readonly router?: Router
262
+ readonly routes?: readonly RouteRecord[]
263
+ readonly history?: RouterHistory
264
+ }
265
+
266
+ export const ROUTER_KEY: InjectionKey<Router> = createInjectionKey<Router>('vobs.router')
267
+
268
+ export function lazy(loader: RouteComponentLoader): LazyRouteComponent {
269
+ return {
270
+ kind: 'vobs-lazy-route',
271
+ load: loader
272
+ }
273
+ }
274
+
275
+ export function createMemoryHistory(initial = '/'): RouterHistory {
276
+ let entries = [{ path: normalizeHistoryPath(initial), state: undefined as unknown }]
277
+ let index = 0
278
+ const listeners = new Set<(path: string, state: unknown) => void>()
279
+
280
+ return {
281
+ get location(): string {
282
+ return entries[index]!.path
283
+ },
284
+
285
+ get state(): unknown {
286
+ return entries[index]!.state
287
+ },
288
+
289
+ push(path: string, state?: unknown): void {
290
+ const next = normalizeHistoryPath(path)
291
+ entries = entries.slice(0, index + 1)
292
+ entries.push({ path: next, state })
293
+ index++
294
+ },
295
+
296
+ replace(path: string, state?: unknown): void {
297
+ entries[index] = { path: normalizeHistoryPath(path), state }
298
+ },
299
+
300
+ back(): void {
301
+ if (index === 0) return
302
+ index--
303
+ notifyListeners(listeners, entries[index]!.path, entries[index]!.state)
304
+ },
305
+
306
+ listen(listener: (path: string, state: unknown) => void): () => void {
307
+ listeners.add(listener)
308
+ return () => listeners.delete(listener)
309
+ }
310
+ }
311
+ }
312
+
313
+ export function createBrowserHistory(base = ''): RouterHistory {
314
+ if (typeof window === 'undefined') {
315
+ throw new Error('Vobs Router: createBrowserHistory 需要浏览器环境')
316
+ }
317
+
318
+ const normalizedBase = normalizeBase(base)
319
+ const listeners = new Set<(path: string, state: unknown) => void>()
320
+ const onPopState = (event: PopStateEvent): void => {
321
+ notifyListeners(listeners, readBrowserLocation(normalizedBase), event.state)
322
+ }
323
+
324
+ return {
325
+ get location(): string {
326
+ return readBrowserLocation(normalizedBase)
327
+ },
328
+
329
+ get state(): unknown {
330
+ return window.history.state
331
+ },
332
+
333
+ push(path: string, state?: unknown): void {
334
+ window.history.pushState(state ?? null, '', withBase(normalizeHistoryPath(path), normalizedBase))
335
+ },
336
+
337
+ replace(path: string, state?: unknown): void {
338
+ window.history.replaceState(state ?? null, '', withBase(normalizeHistoryPath(path), normalizedBase))
339
+ },
340
+
341
+ back(): void {
342
+ window.history.back()
343
+ },
344
+
345
+ listen(listener: (path: string, state: unknown) => void): () => void {
346
+ if (listeners.size === 0) window.addEventListener('popstate', onPopState)
347
+ listeners.add(listener)
348
+ return () => {
349
+ listeners.delete(listener)
350
+ if (listeners.size === 0) window.removeEventListener('popstate', onPopState)
351
+ }
352
+ }
353
+ }
354
+ }
355
+
356
+ export function createRouter(options: RouterOptions): Router {
357
+ const matchers = normalizeRoutes(options.routes)
358
+ const history = options.history ?? defaultHistory()
359
+ const routerDebugId = createRouterDebugId()
360
+ matchers.sort(compareMatchers)
361
+ const currentRoute = state<RouteLocation>(resolvePath(history.location))
362
+ const lazyStates = new Map<RouteRecord, LazyState>()
363
+ const guards: NavigationGuard[] = []
364
+ const navigationHistory: NavigationTrace[] = []
365
+ const dataRequests: RouterDataRequestTrace[] = []
366
+ const errors: RouteErrorTrace[] = []
367
+ const dataLoaders = new Map<string, { kind: RouterDataRequestKind; route: string; task: () => unknown | PromiseLike<unknown> }>()
368
+ const dataRequestContexts = new Map<number, RuntimeDebugContext>()
369
+ const routerListeners = new Map<RouterDevToolsEvent, Set<(payload: unknown) => void>>()
370
+ let navigationState: NavigationState = { status: 'idle', from: currentRoute.value.fullPath, to: currentRoute.value.fullPath }
371
+ let navigationCount = 0
372
+ let totalNavigationDuration = 0
373
+ let slowNavigationCount = 0
374
+ let nextDataRequestId = 1
375
+ let nextErrorId = 1
376
+ let navigationId = 0
377
+ let destroyed = false
378
+ const viewRevision = state(0)
379
+
380
+ function resolve(to: RouteTarget): RouteLocation {
381
+ ensureActive()
382
+ const target = typeof to === 'string' ? parseTargetString(to) : normalizeTarget(to, matchers)
383
+ return resolvePath(buildTargetPath(target.path, target.query, target.hash), target.state)
384
+ }
385
+
386
+ function resolvePath(rawPath: string, state?: unknown): RouteLocation {
387
+ const parsed = parseTargetString(rawPath)
388
+ const matched = matchers.find(matcher => matcher.regex.exec(parsed.path))
389
+ const params = matched ? extractParams(matched, parsed.path) : {}
390
+ const record = matched?.record ?? null
391
+ const query = parsed.query
392
+ const hash = parsed.hash
393
+ return {
394
+ path: parsed.path,
395
+ fullPath: buildTargetPath(parsed.path, query, hash),
396
+ params,
397
+ query,
398
+ hash,
399
+ name: record?.name,
400
+ meta: matched?.meta ?? {},
401
+ record,
402
+ matched: matched?.chain ?? EMPTY_MATCHED,
403
+ state
404
+ }
405
+ }
406
+
407
+ async function navigate(
408
+ to: RouteTarget,
409
+ replaceHistory: boolean,
410
+ fromHistory: boolean,
411
+ historyState?: unknown
412
+ ): Promise<RouteLocation | false> {
413
+ ensureActive()
414
+ const id = ++navigationId
415
+ const from = currentRoute.value
416
+ let target = resolve(to)
417
+ // popstate 回读的 state 保存在 history 条目上,不在目标描述里,这里回填。
418
+ if (fromHistory && historyState !== undefined) target = { ...target, state: historyState }
419
+ const source: NavigationTrace['source'] = fromHistory ? 'history' : replaceHistory ? 'replace' : 'push'
420
+ const startedAt = now()
421
+ const initialTarget = target.fullPath
422
+ let terminalRecorded = false
423
+ navigationState = { status: 'loading', from: from.fullPath, to: target.fullPath, traceId: id }
424
+ emitRouter('navigation:start', navigationState)
425
+ if (target.fullPath === from.fullPath && !fromHistory) {
426
+ navigationState = { status: 'idle', from: from.fullPath, to: target.fullPath }
427
+ return from
428
+ }
429
+
430
+ try {
431
+ for (let redirectCount = 0; ; redirectCount++) {
432
+ ensureNavigationIsCurrent(id)
433
+ let redirect: RouteTarget | undefined
434
+ for (const guard of [...guards]) {
435
+ let result: NavigationGuardResult
436
+ try {
437
+ result = await guard(target, from)
438
+ } catch (reason) {
439
+ if (reason instanceof NavigationCancelledError) throw reason
440
+ const error = toError(reason)
441
+ reportError('navigation', error, target.fullPath)
442
+ recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'error', source, startedAt, endedAt: now(), duration: now() - startedAt, error: error.message })
443
+ terminalRecorded = true
444
+ throw reason
445
+ }
446
+ ensureNavigationIsCurrent(id)
447
+ if (result === false) {
448
+ recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'cancelled', source, startedAt, endedAt: now(), duration: now() - startedAt })
449
+ terminalRecorded = true
450
+ return false
451
+ }
452
+ if (typeof result === 'string' || isRouteLocationRaw(result)) {
453
+ redirect = result
454
+ break
455
+ }
456
+ }
457
+
458
+ for (const record of target.matched) {
459
+ if (!record.loader) continue
460
+ // 上一 loader 期间被新导航抢占时立即取消,跳过剩余 loader。
461
+ ensureNavigationIsCurrent(id)
462
+ await trackDataRequest(
463
+ 'loader',
464
+ `${target.fullPath}#${record.path ?? record.name ?? 'route'}`,
465
+ context => record.loader!({ route: target, navigationId: id, dataRequestId: context.dataRequestId }),
466
+ { route: target.fullPath, navigationId: id, trigger: 'navigation' }
467
+ )
468
+ }
469
+
470
+ // loader 完成后、提交前必须重新校验:飞行期间被抢占的导航不允许覆盖 currentRoute 与 history。
471
+ ensureNavigationIsCurrent(id)
472
+
473
+ if (redirect !== undefined) {
474
+ if (redirectCount >= 10) {
475
+ const error = new NavigationRedirectError()
476
+ recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'error', source, startedAt, endedAt: now(), duration: now() - startedAt, error: error.message })
477
+ terminalRecorded = true
478
+ throw error
479
+ }
480
+ const redirected = resolve(redirect)
481
+ if (redirected.fullPath === target.fullPath) return false
482
+ recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'redirected', source, startedAt, endedAt: now(), duration: now() - startedAt, redirect: redirected.fullPath })
483
+ target = redirected
484
+ continue
485
+ }
486
+
487
+ if (target.fullPath === from.fullPath) return from
488
+ if (!fromHistory) {
489
+ if (replaceHistory) history.replace(target.fullPath, target.state)
490
+ else history.push(target.fullPath, target.state)
491
+ }
492
+ currentRoute.value = target
493
+ recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'success', source, startedAt, endedAt: now(), duration: now() - startedAt, redirect: target.fullPath !== initialTarget ? target.fullPath : undefined })
494
+ terminalRecorded = true
495
+ return target
496
+ }
497
+ } catch (reason) {
498
+ if (reason instanceof NavigationCancelledError) {
499
+ recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'cancelled', source, startedAt, endedAt: now(), duration: now() - startedAt })
500
+ terminalRecorded = true
501
+ } else if (!terminalRecorded) {
502
+ const error = toError(reason)
503
+ reportError('navigation', error, target.fullPath)
504
+ recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'error', source, startedAt, endedAt: now(), duration: now() - startedAt, error: error.message })
505
+ terminalRecorded = true
506
+ }
507
+ throw reason
508
+ }
509
+ }
510
+
511
+ function now(): number {
512
+ return typeof performance === 'undefined' ? Date.now() : performance.now()
513
+ }
514
+
515
+ function emitRouter(event: RouterDevToolsEvent, payload: unknown): void {
516
+ for (const callback of routerListeners.get(event) ?? []) {
517
+ try { callback(payload) } catch { /* diagnostics must not affect navigation */ }
518
+ }
519
+ emitRouterDebug(routerDebugId, event, payload, getRuntimeDebugContext() ?? undefined)
520
+ }
521
+
522
+ function recordNavigation(trace: NavigationTrace): void {
523
+ navigationHistory.push(Object.freeze(trace))
524
+ if (navigationHistory.length > 100) navigationHistory.shift()
525
+ navigationCount++
526
+ totalNavigationDuration += trace.duration
527
+ if (trace.duration >= 16) slowNavigationCount++
528
+ if (trace.id === navigationId) {
529
+ navigationState = trace.status === 'error'
530
+ ? { status: 'error', from: trace.from, to: trace.to, traceId: trace.id, error: trace.error }
531
+ : { status: 'idle', from: trace.from, to: trace.to, traceId: trace.id }
532
+ }
533
+ emitRouter('navigation:end', trace)
534
+ emitRouter('route:update', currentRoute.value)
535
+ }
536
+
537
+ async function trackDataRequest<T>(
538
+ kind: RouterDataRequestKind,
539
+ key: string,
540
+ task: ((context: { readonly dataRequestId: number }) => T | PromiseLike<T>) | (() => T | PromiseLike<T>),
541
+ optionsOrRoute: RouterDataRequestOptions | string = {}
542
+ ): Promise<T> {
543
+ ensureActive()
544
+ const options = typeof optionsOrRoute === 'string' ? { route: optionsOrRoute } : optionsOrRoute
545
+ const id = nextDataRequestId++
546
+ const startedAt = now()
547
+ const context = getRuntimeDebugContext()
548
+ const route = options.route ?? currentRoute.value.fullPath
549
+ const requestContext: RuntimeDebugContext = {
550
+ ...context,
551
+ environment: options.environment ?? context?.environment,
552
+ route,
553
+ navigationId: options.navigationId ?? context?.navigationId,
554
+ dataRequestId: id,
555
+ source: kind
556
+ }
557
+ const loading: RouterDataRequestTrace = {
558
+ id,
559
+ kind,
560
+ key,
561
+ route,
562
+ status: 'loading',
563
+ startedAt,
564
+ navigationId: requestContext.navigationId,
565
+ trigger: options.trigger,
566
+ environment: requestContext.environment
567
+ }
568
+ dataRequests.push(loading)
569
+ if (dataRequests.length > 100) dataRequests.shift()
570
+ dataRequestContexts.set(id, requestContext)
571
+ emitRouter('route:update', currentRoute.value)
572
+ emitRouter('data-request', loading)
573
+ emitRouterDebug(routerDebugId, 'data-request', { phase: 'start', trace: loading }, requestContext)
574
+ dataLoaders.set(key, { kind, route, task: task as () => unknown | PromiseLike<unknown> })
575
+ try {
576
+ const result = await runWithRuntimeDebugContext(requestContext, () => (task as (context: { readonly dataRequestId: number }) => T | PromiseLike<T>)({ dataRequestId: id }))
577
+ const endedAt = now()
578
+ replaceDataRequest(id, { ...loading, status: 'success', endedAt, duration: endedAt - startedAt, result })
579
+ return result
580
+ } catch (reason) {
581
+ const endedAt = now()
582
+ const error = toError(reason)
583
+ const status = isAbortError(reason) ? 'cancelled' : 'error'
584
+ if (status === 'error') reportError(kind, error, route, { requestId: id, navigationId: requestContext.navigationId })
585
+ replaceDataRequest(id, { ...loading, status, endedAt, duration: endedAt - startedAt, error: status === 'error' ? error.message : undefined })
586
+ throw reason
587
+ }
588
+ }
589
+
590
+ function replaceDataRequest(id: number, trace: RouterDataRequestTrace): void {
591
+ const index = dataRequests.findIndex(item => item.id === id)
592
+ if (index >= 0) dataRequests[index] = Object.freeze(trace)
593
+ emitRouter('route:update', currentRoute.value)
594
+ emitRouter('data-request', trace)
595
+ emitRouterDebug(routerDebugId, 'data-request', { phase: 'end', trace }, dataRequestContexts.get(id))
596
+ dataRequestContexts.delete(id)
597
+ }
598
+
599
+ function reportError(
600
+ phase: RouteErrorTrace['phase'],
601
+ reason: unknown,
602
+ route = currentRoute.value.fullPath,
603
+ context: { readonly requestId?: number; readonly navigationId?: number } = {}
604
+ ): void {
605
+ const error = toError(reason)
606
+ errors.push(Object.freeze({
607
+ id: nextErrorId++,
608
+ phase,
609
+ route,
610
+ message: error.message,
611
+ stack: error.stack,
612
+ timestamp: now(),
613
+ requestId: context.requestId,
614
+ navigationId: context.navigationId
615
+ }))
616
+ if (errors.length > 100) errors.shift()
617
+ emitRouter('error', errors[errors.length - 1]!)
618
+ emitRouter('route:update', currentRoute.value)
619
+ }
620
+
621
+ function routeTree(): readonly RouteDebugNode[] {
622
+ const statuses = new Map<string, LazyState['status']>()
623
+ for (const matcher of matchers) {
624
+ for (const record of matcher.chain) {
625
+ if (record.component && isLazyRouteComponent(record.component)) {
626
+ const debugId = routeDebugIds.get(record)
627
+ if (debugId) statuses.set(debugId, lazyStates.get(record)?.status ?? 'loading')
628
+ }
629
+ }
630
+ }
631
+ return buildRouteDebugTree(options.routes, statuses)
632
+ }
633
+
634
+ function handleHistoryNavigation(path: string, state: unknown): void {
635
+ void navigate(path, false, true, state).then(result => {
636
+ if (destroyed) return
637
+ if (result === false) {
638
+ history.replace(currentRoute.value.fullPath, currentRoute.value.state)
639
+ } else if (result.fullPath !== normalizeHistoryPath(path)) {
640
+ history.replace(result.fullPath, result.state)
641
+ }
642
+ }).catch(error => {
643
+ if (!(error instanceof NavigationCancelledError) && !destroyed) {
644
+ const current = currentRoute.value.fullPath
645
+ const failed = toError(error)
646
+ reportError('navigation', failed, normalizeHistoryPath(path))
647
+ navigationState = { status: 'error', from: current, to: normalizeHistoryPath(path), error: failed.message }
648
+ emitRouter('navigation:end', { status: 'error', from: current, to: normalizeHistoryPath(path), error: failed.message })
649
+ history.replace(currentRoute.value.fullPath, currentRoute.value.state)
650
+ }
651
+ })
652
+ }
653
+
654
+ const stopHistory = history.listen(handleHistoryNavigation)
655
+
656
+ const router: Router = {
657
+ currentRoute,
658
+ history,
659
+
660
+ resolve,
661
+
662
+ push(to: RouteTarget): Promise<RouteLocation | false> {
663
+ return navigate(to, false, false)
664
+ },
665
+
666
+ replace(to: RouteTarget): Promise<RouteLocation | false> {
667
+ return navigate(to, true, false)
668
+ },
669
+
670
+ back(): void {
671
+ ensureActive()
672
+ history.back()
673
+ },
674
+
675
+ beforeEach(guard: NavigationGuard): () => void {
676
+ ensureActive()
677
+ guards.push(guard)
678
+ return () => {
679
+ const index = guards.indexOf(guard)
680
+ if (index >= 0) guards.splice(index, 1)
681
+ }
682
+ },
683
+
684
+ getViewState(route: RouteLocation): RouterViewState {
685
+ viewRevision.value
686
+ const records = route.matched.length > 0 ? route.matched : route.record ? [route.record] : []
687
+ const entries = records
688
+ .map(record => ({ record, definition: record.component }))
689
+ .filter((entry): entry is { record: RouteRecord; definition: RouteComponentDefinition } =>
690
+ isRouteComponentDefinition(entry.definition))
691
+ if (!route.record || entries.length === 0) return { status: 'not-found', retry: () => undefined }
692
+
693
+ const loaded: RouteComponent[] = []
694
+ const lazyRecords: RouteRecord[] = []
695
+ for (const entry of entries) {
696
+ const { record, definition } = entry
697
+ if (!isLazyRouteComponent(definition)) {
698
+ loaded.push(definition)
699
+ continue
700
+ }
701
+ lazyRecords.push(record)
702
+ const lazyState = ensureLazyState(record, definition)
703
+ if (lazyState.status === 'loading') return { status: 'loading', retry: () => retryLazyRoutes(lazyRecords) }
704
+ if (lazyState.status === 'error') {
705
+ return { status: 'error', error: lazyState.error, retry: () => retryLazyRoutes(lazyRecords) }
706
+ }
707
+ if (lazyState.component) loaded.push(lazyState.component)
708
+ }
709
+
710
+ const component = loaded[loaded.length - 1]
711
+ if (!component) return { status: 'not-found', retry: () => undefined }
712
+ return {
713
+ status: 'ready',
714
+ component,
715
+ layouts: loaded.slice(0, -1),
716
+ retry: () => retryLazyRoutes(lazyRecords)
717
+ }
718
+ },
719
+
720
+ devtools: {
721
+ getRouteTree: routeTree,
722
+ getCurrentRoute: () => currentRoute.value,
723
+ getNavigationState: () => navigationState,
724
+ getNavigationHistory: () => [...navigationHistory],
725
+ getPerformanceMetrics: () => ({
726
+ navigationCount,
727
+ averageNavigationDuration: navigationCount === 0 ? 0 : totalNavigationDuration / navigationCount,
728
+ slowNavigationCount
729
+ }),
730
+ getDataRequests: () => [...dataRequests],
731
+ getErrors: () => [...errors],
732
+ trackDataRequest,
733
+ runAction: (key, task) => trackDataRequest('action', key, task, { trigger: 'manual' }),
734
+ runFetcher: (key, task) => trackDataRequest('fetcher', key, task, { trigger: 'manual' }),
735
+ reportError,
736
+ revalidate: async (route) => {
737
+ await Promise.all([...dataLoaders.entries()]
738
+ .filter(([, loader]) => loader.kind === 'loader' && (route === undefined || loader.route === route))
739
+ .map(([key, loader]) => trackDataRequest(loader.kind, key, loader.task, { route: loader.route, trigger: 'revalidate' })))
740
+ },
741
+ subscribe(event, callback) {
742
+ let listeners = routerListeners.get(event)
743
+ if (!listeners) { listeners = new Set(); routerListeners.set(event, listeners) }
744
+ listeners.add(callback)
745
+ return () => listeners?.delete(callback)
746
+ }
747
+ },
748
+
749
+ destroy(): void {
750
+ if (destroyed) return
751
+ destroyed = true
752
+ navigationId++
753
+ stopHistory()
754
+ guards.length = 0
755
+ routerListeners.clear()
756
+ lazyStates.clear()
757
+ errors.length = 0
758
+ dataRequestContexts.clear()
759
+ currentRoute.dispose()
760
+ viewRevision.dispose()
761
+ }
762
+ }
763
+
764
+ function ensureLazyState(record: RouteRecord, definition: LazyRouteComponent): LazyState {
765
+ let lazyState = lazyStates.get(record)
766
+ if (!lazyState) {
767
+ lazyState = { status: 'loading' }
768
+ lazyStates.set(record, lazyState)
769
+ void loadRouteComponent(definition).then(component => {
770
+ if (destroyed) return
771
+ lazyState!.status = 'ready'
772
+ lazyState!.component = component
773
+ viewRevision.value++
774
+ emitRouter('route:update', currentRoute.value)
775
+ }).catch(reason => {
776
+ if (destroyed) return
777
+ lazyState!.status = 'error'
778
+ lazyState!.error = toError(reason)
779
+ reportError('lazy', reason, currentRoute.value.fullPath)
780
+ viewRevision.value++
781
+ emitRouter('route:update', currentRoute.value)
782
+ })
783
+ }
784
+ return lazyState
785
+ }
786
+
787
+ function retryLazyRoutes(records: readonly RouteRecord[]): void {
788
+ for (const record of records) lazyStates.delete(record)
789
+ viewRevision.value++
790
+ }
791
+
792
+ function ensureActive(): void {
793
+ if (destroyed) throw new Error('Vobs Router: 已销毁的 Router 不能继续使用')
794
+ }
795
+
796
+ function ensureNavigationIsCurrent(id: number): void {
797
+ if (id !== navigationId) throw new NavigationCancelledError()
798
+ }
799
+
800
+ return router
801
+ }
802
+
803
+ export function RouterView(props: RouterViewProps = {}): VobsNode {
804
+ const router = props.router ?? inject(ROUTER_KEY)
805
+ if (!router) throw new Error('Vobs Router: RouterView 找不到 Router,请安装 routerPlugin')
806
+
807
+ return createFragment((parent, anchor) => {
808
+ let routeRetry: () => void = () => undefined
809
+ insertBoundary(parent, anchor, {
810
+ resetKey: () => router.currentRoute.value.fullPath,
811
+ onRetry: () => routeRetry(),
812
+ fallback: (error, retry) => {
813
+ router.devtools.reportError('render', error, router.currentRoute.value.fullPath)
814
+ return props.error?.(error, () => { void retry() }) ?? null
815
+ },
816
+ children: () => {
817
+ const route = router.currentRoute.value
818
+ const view = router.getViewState(route)
819
+ routeRetry = view.retry
820
+ if (view.status === 'loading') return props.loading?.() ?? null
821
+ if (view.status === 'not-found') return props.notFound?.(route) ?? null
822
+ if (view.status === 'error') {
823
+ throw view.error ?? new Error('路由组件加载失败')
824
+ }
825
+ if (!view.component) return null
826
+ let node = createComponent(view.component, {
827
+ route,
828
+ params: route.params,
829
+ query: route.query
830
+ })
831
+ for (let index = (view.layouts?.length ?? 0) - 1; index >= 0; index--) {
832
+ node = createComponent(view.layouts![index]!, {
833
+ route,
834
+ params: route.params,
835
+ query: route.query,
836
+ children: node
837
+ })
838
+ }
839
+ return node
840
+ }})
841
+ })
842
+ }
843
+
844
+ export function useRouter(): Router {
845
+ const router = inject(ROUTER_KEY)
846
+ if (!router) throw new Error('Vobs Router: useRouter 找不到 Router,请安装 routerPlugin')
847
+ return router
848
+ }
849
+
850
+ export function useRoute(): Signal<RouteLocation> {
851
+ return useRouter().currentRoute
852
+ }
853
+
854
+ export function routerPlugin(options: RouterPluginOptions = {}): VobsPlugin {
855
+ return {
856
+ name: '@vobs/router',
857
+ version: '0.1.0',
858
+ install(context) {
859
+ const ownedRouter = options.router ? undefined : createRouter({
860
+ routes: options.routes ?? [],
861
+ history: options.history
862
+ })
863
+ const router = options.router ?? ownedRouter!
864
+ context.provide(ROUTER_KEY, router)
865
+ return () => ownedRouter?.destroy()
866
+ }
867
+ }
868
+ }
869
+
870
+ interface RouteMatcher {
871
+ readonly record: RouteRecord
872
+ readonly debugId: string
873
+ readonly chain: readonly RouteRecord[]
874
+ readonly meta: RouteMeta
875
+ readonly regex: RegExp
876
+ readonly keys: readonly string[]
877
+ readonly score: number
878
+ readonly order: number
879
+ }
880
+
881
+ interface LazyState {
882
+ status: 'loading' | 'ready' | 'error'
883
+ component?: RouteComponent
884
+ error?: Error
885
+ }
886
+
887
+ interface ParsedTarget {
888
+ readonly path: string
889
+ readonly query: RouteQuery
890
+ readonly hash: string
891
+ readonly state?: unknown
892
+ }
893
+
894
+ function defaultHistory(): RouterHistory {
895
+ return typeof window === 'undefined' ? createMemoryHistory('/') : createBrowserHistory()
896
+ }
897
+
898
+ const EMPTY_MATCHED: readonly RouteRecord[] = Object.freeze([])
899
+ const routeDebugIds = new WeakMap<RouteRecord, string>()
900
+
901
+ function buildRouteDebugTree(routes: readonly RouteRecord[], lazyStatuses: ReadonlyMap<string, LazyState['status']> = new Map()): readonly RouteDebugNode[] {
902
+ const visit = (records: readonly RouteRecord[], parentPath: string, parentId: string): RouteDebugNode[] => records.map((record, index) => {
903
+ const path = record.path === undefined ? parentPath || '/' : resolveChildPath(parentPath, record.path)
904
+ const id = `${parentId}.${index}`
905
+ const definition = record.component
906
+ const lazyDefinition = definition !== undefined && isLazyRouteComponent(definition)
907
+ const componentName = definition === undefined
908
+ ? 'Route'
909
+ : lazyDefinition
910
+ ? 'lazy(...)'
911
+ : typeof definition === 'function'
912
+ ? definition.name || 'Anonymous'
913
+ : 'Route'
914
+ return {
915
+ id,
916
+ path,
917
+ name: record.name,
918
+ component: componentName,
919
+ source: record.source,
920
+ lazy: lazyDefinition,
921
+ loader: record.loader !== undefined,
922
+ action: record.action !== undefined,
923
+ status: lazyDefinition ? (lazyStatuses.get(id) ?? 'loading') : 'ready',
924
+ meta: Object.freeze({ ...(record.meta ?? {}) }),
925
+ children: visit(record.children ?? [], path, id)
926
+ }
927
+ })
928
+ return Object.freeze(visit(routes, '', 'route'))
929
+ }
930
+
931
+ function normalizeRoutes(routes: readonly RouteRecord[]): RouteMatcher[] {
932
+ const matchers: RouteMatcher[] = []
933
+ let order = 0
934
+
935
+ function visit(records: readonly RouteRecord[], parentPath: string, parentChain: readonly RouteRecord[], parentMeta: RouteMeta, parentId = 'route'): void {
936
+ records.forEach((record, index) => {
937
+ const debugId = `${parentId}.${index}`
938
+ const children = record.children ?? []
939
+ const path = record.path === undefined
940
+ ? parentPath
941
+ : resolveChildPath(parentPath, record.path)
942
+ const normalized: RouteRecord = {
943
+ ...record,
944
+ path: record.path === undefined
945
+ ? (children.length > 0 ? undefined : (path || '/'))
946
+ : path,
947
+ meta: record.meta ? { ...record.meta } : {}
948
+ }
949
+ routeDebugIds.set(normalized, debugId)
950
+ const chain = [...parentChain, normalized]
951
+ const meta = Object.freeze({ ...parentMeta, ...(normalized.meta ?? {}) })
952
+ if (children.length > 0) {
953
+ visit(children, path, chain, meta, debugId)
954
+ } else if (normalized.component) {
955
+ matchers.push(createMatcher(normalized, chain, meta, order++, debugId))
956
+ } else if (normalized.path === undefined) {
957
+ throw new Error(`Vobs Router: 第 ${index + 1} 个路由缺少 path 或 children`)
958
+ }
959
+ })
960
+ }
961
+
962
+ visit(routes, '', [], {})
963
+ return matchers
964
+ }
965
+
966
+ function resolveChildPath(parentPath: string, childPath: string): string {
967
+ const normalizedChild = normalizePath(childPath)
968
+ if (!parentPath || normalizedChild === '/') return normalizedChild === '/' ? (parentPath || '/') : normalizedChild
969
+ if (childPath.startsWith('/')) return normalizedChild
970
+ return normalizePath(`${parentPath}/${childPath}`)
971
+ }
972
+
973
+ function createMatcher(record: RouteRecord, chain: readonly RouteRecord[], meta: RouteMeta, order: number, debugId: string): RouteMatcher {
974
+ const path = record.path ?? '/'
975
+ const segments = path === '/' ? [] : path.slice(1).split('/')
976
+ const keys: string[] = []
977
+ let score = 0
978
+ const pattern = segments.map(segment => {
979
+ if (segment === '*') {
980
+ keys.push('pathMatch')
981
+ return '(.*)'
982
+ }
983
+ if (segment.startsWith(':')) {
984
+ const key = segment.slice(1)
985
+ if (!key) throw new Error(`Vobs Router: 路由 ${path} 的参数名不能为空`)
986
+ if (keys.includes(key)) throw new Error(`Vobs Router: 路由 ${path} 存在重复参数 ${key}`)
987
+ keys.push(key)
988
+ score += 1
989
+ return '([^/]+)'
990
+ }
991
+ score += 3
992
+ return escapeRegExp(segment)
993
+ }).join('/')
994
+
995
+ return {
996
+ record,
997
+ debugId,
998
+ chain: Object.freeze([...chain]),
999
+ meta,
1000
+ regex: new RegExp(segments.length === 0 ? '^/?$' : `^/${pattern}/?$`),
1001
+ keys,
1002
+ score,
1003
+ order
1004
+ }
1005
+ }
1006
+
1007
+ function compareMatchers(left: RouteMatcher, right: RouteMatcher): number {
1008
+ return right.score - left.score || left.order - right.order
1009
+ }
1010
+
1011
+ function extractParams(matcher: RouteMatcher, path: string): RouteParams {
1012
+ const match = matcher.regex.exec(path)
1013
+ if (!match) return {}
1014
+ const params: Record<string, string> = {}
1015
+ matcher.keys.forEach((key, index) => {
1016
+ params[key] = decodeRoutePart(match[index + 1] ?? '')
1017
+ })
1018
+ return Object.freeze(params)
1019
+ }
1020
+
1021
+ function parseTargetString(raw: string): ParsedTarget {
1022
+ const hashIndex = raw.indexOf('#')
1023
+ const hash = hashIndex >= 0 ? normalizeHash(raw.slice(hashIndex + 1)) : ''
1024
+ const withoutHash = hashIndex >= 0 ? raw.slice(0, hashIndex) : raw
1025
+ const queryIndex = withoutHash.indexOf('?')
1026
+ const path = normalizePath(queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash)
1027
+ const query = queryIndex >= 0 ? parseQuery(withoutHash.slice(queryIndex + 1)) : {}
1028
+ return { path, query, hash }
1029
+ }
1030
+
1031
+ function normalizeTarget(target: RouteLocationRaw, matchers: readonly RouteMatcher[]): ParsedTarget {
1032
+ let path = target.path
1033
+ if (!path && target.name) {
1034
+ const matcher = matchers.find(candidate => candidate.record.name === target.name)
1035
+ if (!matcher) throw new Error(`Vobs Router: 找不到名为 ${target.name} 的路由`)
1036
+ path = fillRouteParams(matcher.record.path ?? '/', target.params ?? {})
1037
+ }
1038
+ if (!path) throw new Error('Vobs Router: 导航目标必须提供 path 或 name')
1039
+
1040
+ const parsed = parseTargetString(path)
1041
+ const filledPath = fillRouteParams(parsed.path, target.params ?? {})
1042
+ const query = target.query === undefined ? parsed.query : normalizeQuery(target.query)
1043
+ const hash = target.hash === undefined ? parsed.hash : normalizeHash(target.hash)
1044
+ return { path: filledPath, query, hash, state: target.state }
1045
+ }
1046
+
1047
+ function fillRouteParams(path: string, params: Record<string, unknown>): string {
1048
+ return path.replace(/:([A-Za-z0-9_]+)|\*/g, (token, key: string | undefined) => {
1049
+ const value = key ? params[key] : params.pathMatch
1050
+ if (value === undefined || value === null) return token
1051
+ return encodeURIComponent(String(value))
1052
+ })
1053
+ }
1054
+
1055
+ function buildTargetPath(path: string, query: RouteQuery, hash: string): string {
1056
+ const params = new URLSearchParams()
1057
+ for (const key of Object.keys(query).sort()) {
1058
+ const value = query[key]
1059
+ if (typeof value === 'string') {
1060
+ params.set(key, value)
1061
+ } else {
1062
+ for (const item of value) params.append(key, item)
1063
+ }
1064
+ }
1065
+ const serialized = params.toString()
1066
+ return `${path}${serialized ? `?${serialized}` : ''}${hash}`
1067
+ }
1068
+
1069
+ function parseQuery(raw: string): RouteQuery {
1070
+ const params = new URLSearchParams(raw)
1071
+ const result: Record<string, RouteQueryValue> = {}
1072
+ params.forEach((value, key) => {
1073
+ const previous = result[key]
1074
+ if (previous === undefined) result[key] = value
1075
+ else if (typeof previous === 'string') result[key] = [previous, value]
1076
+ else result[key] = [...previous, value]
1077
+ })
1078
+ for (const key of Object.keys(result)) {
1079
+ if (Array.isArray(result[key])) result[key] = Object.freeze(result[key] as string[])
1080
+ }
1081
+ return Object.freeze(result)
1082
+ }
1083
+
1084
+ function normalizeQuery(input: RouteQueryInput): RouteQuery {
1085
+ if (input instanceof URLSearchParams) return parseQuery(input.toString())
1086
+ const result: Record<string, RouteQueryValue> = {}
1087
+ for (const [key, value] of Object.entries(input)) {
1088
+ if (value === undefined || value === null) continue
1089
+ if (Array.isArray(value)) result[key] = Object.freeze(value.map(item => String(item)))
1090
+ else result[key] = String(value)
1091
+ }
1092
+ return Object.freeze(result)
1093
+ }
1094
+
1095
+ function normalizePath(path: string): string {
1096
+ if (!path) return '/'
1097
+ const withoutQuery = path.split(/[?#]/, 1)[0] || '/'
1098
+ const withLeadingSlash = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`
1099
+ if (withLeadingSlash === '/*' || withLeadingSlash === '/') return withLeadingSlash
1100
+ return withLeadingSlash.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
1101
+ }
1102
+
1103
+ function normalizeHistoryPath(path: string): string {
1104
+ const parsed = parseTargetString(path)
1105
+ return buildTargetPath(parsed.path, parsed.query, parsed.hash)
1106
+ }
1107
+
1108
+ function normalizeHash(hash: string): string {
1109
+ if (!hash) return ''
1110
+ return hash.startsWith('#') ? hash : `#${hash}`
1111
+ }
1112
+
1113
+ function normalizeBase(base: string): string {
1114
+ if (!base || base === '/') return ''
1115
+ return `/${base.replace(/^\/+|\/+$/g, '')}`
1116
+ }
1117
+
1118
+ function readBrowserLocation(base: string): string {
1119
+ const pathname = window.location.pathname
1120
+ const path = base && (pathname === base || pathname.startsWith(`${base}/`))
1121
+ ? pathname.slice(base.length) || '/'
1122
+ : pathname
1123
+ return normalizeHistoryPath(`${path}${window.location.search}${window.location.hash}`)
1124
+ }
1125
+
1126
+ function withBase(path: string, base: string): string {
1127
+ return `${base}${path === '/' ? '/' : path}` || '/'
1128
+ }
1129
+
1130
+ function notifyListeners(listeners: Set<(path: string, state: unknown) => void>, path: string, state: unknown): void {
1131
+ for (const listener of [...listeners]) listener(path, state)
1132
+ }
1133
+
1134
+ function escapeRegExp(value: string): string {
1135
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1136
+ }
1137
+
1138
+ function decodeRoutePart(value: string): string {
1139
+ try {
1140
+ return decodeURIComponent(value)
1141
+ } catch {
1142
+ return value
1143
+ }
1144
+ }
1145
+
1146
+ function isLazyRouteComponent(value: RouteComponentDefinition): value is LazyRouteComponent {
1147
+ return typeof value === 'object' && value !== null && value.kind === 'vobs-lazy-route'
1148
+ }
1149
+
1150
+ function isRouteComponentDefinition(value: unknown): value is RouteComponentDefinition {
1151
+ return typeof value === 'function' || isLazyRouteComponent(value as RouteComponentDefinition)
1152
+ }
1153
+
1154
+ async function loadRouteComponent(loader: LazyRouteComponent): Promise<RouteComponent> {
1155
+ const module = await loader.load()
1156
+ const component = typeof module === 'function' ? module : module.default
1157
+ if (typeof component !== 'function') throw new Error('Vobs Router: 懒加载模块没有默认组件导出')
1158
+ return component
1159
+ }
1160
+
1161
+ function isRouteLocationRaw(value: unknown): value is RouteLocationRaw {
1162
+ return Boolean(value) && typeof value === 'object'
1163
+ }
1164
+
1165
+ function toError(reason: unknown): Error {
1166
+ return reason instanceof Error ? reason : new Error(String(reason))
1167
+ }
1168
+
1169
+ function isAbortError(reason: unknown): boolean {
1170
+ return Boolean(reason) && typeof reason === 'object'
1171
+ && ((reason as { readonly name?: unknown }).name === 'AbortError'
1172
+ || (reason as { readonly code?: unknown }).code === 'ERR_CANCELED')
1173
+ }