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