@pyreon/router 0.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/LICENSE +21 -0
- package/README.md +90 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +830 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +690 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +322 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +49 -0
- package/src/components.tsx +307 -0
- package/src/index.ts +78 -0
- package/src/loader.ts +97 -0
- package/src/match.ts +264 -0
- package/src/router.ts +451 -0
- package/src/scroll.ts +55 -0
- package/src/tests/router.test.ts +3294 -0
- package/src/tests/setup.ts +3 -0
- package/src/types.ts +242 -0
package/src/match.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import type { ResolvedRoute, RouteMeta, RouteRecord } from "./types"
|
|
2
|
+
|
|
3
|
+
// ─── Query string ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse a query string into key-value pairs. Duplicate keys are overwritten
|
|
7
|
+
* (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
|
|
8
|
+
*/
|
|
9
|
+
export function parseQuery(qs: string): Record<string, string> {
|
|
10
|
+
if (!qs) return {}
|
|
11
|
+
const result: Record<string, string> = {}
|
|
12
|
+
for (const part of qs.split("&")) {
|
|
13
|
+
const eqIdx = part.indexOf("=")
|
|
14
|
+
if (eqIdx < 0) {
|
|
15
|
+
const key = decodeURIComponent(part)
|
|
16
|
+
if (key) result[key] = ""
|
|
17
|
+
} else {
|
|
18
|
+
const key = decodeURIComponent(part.slice(0, eqIdx))
|
|
19
|
+
const val = decodeURIComponent(part.slice(eqIdx + 1))
|
|
20
|
+
if (key) result[key] = val
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return result
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse a query string preserving duplicate keys as arrays.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* parseQueryMulti("color=red&color=blue&size=lg")
|
|
31
|
+
* // → { color: ["red", "blue"], size: "lg" }
|
|
32
|
+
*/
|
|
33
|
+
export function parseQueryMulti(qs: string): Record<string, string | string[]> {
|
|
34
|
+
if (!qs) return {}
|
|
35
|
+
const result: Record<string, string | string[]> = {}
|
|
36
|
+
for (const part of qs.split("&")) {
|
|
37
|
+
const eqIdx = part.indexOf("=")
|
|
38
|
+
let key: string
|
|
39
|
+
let val: string
|
|
40
|
+
if (eqIdx < 0) {
|
|
41
|
+
key = decodeURIComponent(part)
|
|
42
|
+
val = ""
|
|
43
|
+
} else {
|
|
44
|
+
key = decodeURIComponent(part.slice(0, eqIdx))
|
|
45
|
+
val = decodeURIComponent(part.slice(eqIdx + 1))
|
|
46
|
+
}
|
|
47
|
+
if (!key) continue
|
|
48
|
+
const existing = result[key]
|
|
49
|
+
if (existing === undefined) {
|
|
50
|
+
result[key] = val
|
|
51
|
+
} else if (Array.isArray(existing)) {
|
|
52
|
+
existing.push(val)
|
|
53
|
+
} else {
|
|
54
|
+
result[key] = [existing, val]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return result
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function stringifyQuery(query: Record<string, string>): string {
|
|
61
|
+
const parts: string[] = []
|
|
62
|
+
for (const [k, v] of Object.entries(query)) {
|
|
63
|
+
parts.push(v ? `${encodeURIComponent(k)}=${encodeURIComponent(v)}` : encodeURIComponent(k))
|
|
64
|
+
}
|
|
65
|
+
return parts.length ? `?${parts.join("&")}` : ""
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Path matching ────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Match a single route pattern against a path segment.
|
|
72
|
+
* Returns extracted params or null if no match.
|
|
73
|
+
*
|
|
74
|
+
* Supports:
|
|
75
|
+
* - Exact segments: "/about"
|
|
76
|
+
* - Param segments: "/user/:id"
|
|
77
|
+
* - Wildcard: "(.*)" matches everything
|
|
78
|
+
*/
|
|
79
|
+
export function matchPath(pattern: string, path: string): Record<string, string> | null {
|
|
80
|
+
// Wildcard pattern
|
|
81
|
+
if (pattern === "(.*)" || pattern === "*") return {}
|
|
82
|
+
|
|
83
|
+
const patternParts = pattern.split("/").filter(Boolean)
|
|
84
|
+
const pathParts = path.split("/").filter(Boolean)
|
|
85
|
+
|
|
86
|
+
const params: Record<string, string> = {}
|
|
87
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
88
|
+
const pp = patternParts[i] as string
|
|
89
|
+
const pt = pathParts[i] as string
|
|
90
|
+
// Splat param — captures the rest of the path (e.g. ":path*")
|
|
91
|
+
if (pp.endsWith("*") && pp.startsWith(":")) {
|
|
92
|
+
const paramName = pp.slice(1, -1)
|
|
93
|
+
params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/")
|
|
94
|
+
return params
|
|
95
|
+
}
|
|
96
|
+
if (pp.startsWith(":")) {
|
|
97
|
+
params[pp.slice(1)] = decodeURIComponent(pt)
|
|
98
|
+
} else if (pp !== pt) {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (patternParts.length !== pathParts.length) return null
|
|
104
|
+
return params
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if a path starts with a route's prefix (for nested route matching).
|
|
109
|
+
* Returns the remaining path suffix, or null if no match.
|
|
110
|
+
*/
|
|
111
|
+
function matchPrefix(
|
|
112
|
+
pattern: string,
|
|
113
|
+
path: string,
|
|
114
|
+
): { params: Record<string, string>; rest: string } | null {
|
|
115
|
+
if (pattern === "(.*)" || pattern === "*") return { params: {}, rest: path }
|
|
116
|
+
|
|
117
|
+
const patternParts = pattern.split("/").filter(Boolean)
|
|
118
|
+
const pathParts = path.split("/").filter(Boolean)
|
|
119
|
+
|
|
120
|
+
if (pathParts.length < patternParts.length) return null
|
|
121
|
+
|
|
122
|
+
const params: Record<string, string> = {}
|
|
123
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
124
|
+
const pp = patternParts[i] as string
|
|
125
|
+
const pt = pathParts[i] as string
|
|
126
|
+
// Splat param in prefix — captures the rest
|
|
127
|
+
if (pp.endsWith("*") && pp.startsWith(":")) {
|
|
128
|
+
const paramName = pp.slice(1, -1)
|
|
129
|
+
params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/")
|
|
130
|
+
return { params, rest: "/" }
|
|
131
|
+
}
|
|
132
|
+
if (pp.startsWith(":")) {
|
|
133
|
+
params[pp.slice(1)] = decodeURIComponent(pt)
|
|
134
|
+
} else if (pp !== pt) {
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const rest = `/${pathParts.slice(patternParts.length).join("/")}`
|
|
140
|
+
return { params, rest }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Route resolution ─────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve a raw path (including query string and hash) against the route tree.
|
|
147
|
+
* Handles nested routes recursively.
|
|
148
|
+
*/
|
|
149
|
+
export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRoute {
|
|
150
|
+
const qIdx = rawPath.indexOf("?")
|
|
151
|
+
const pathAndHash = qIdx >= 0 ? rawPath.slice(0, qIdx) : rawPath
|
|
152
|
+
const queryPart = qIdx >= 0 ? rawPath.slice(qIdx + 1) : ""
|
|
153
|
+
|
|
154
|
+
const hIdx = pathAndHash.indexOf("#")
|
|
155
|
+
const cleanPath = hIdx >= 0 ? pathAndHash.slice(0, hIdx) : pathAndHash
|
|
156
|
+
const hash = hIdx >= 0 ? pathAndHash.slice(hIdx + 1) : ""
|
|
157
|
+
|
|
158
|
+
const query = parseQuery(queryPart)
|
|
159
|
+
|
|
160
|
+
const match = matchRoutes(cleanPath, routes, [])
|
|
161
|
+
if (match) {
|
|
162
|
+
return {
|
|
163
|
+
path: cleanPath,
|
|
164
|
+
params: match.params,
|
|
165
|
+
query,
|
|
166
|
+
hash,
|
|
167
|
+
matched: match.matched,
|
|
168
|
+
meta: mergeMeta(match.matched),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { path: cleanPath, params: {}, query, hash, matched: [], meta: {} }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface MatchResult {
|
|
176
|
+
params: Record<string, string>
|
|
177
|
+
matched: RouteRecord[]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function matchRoutes(
|
|
181
|
+
path: string,
|
|
182
|
+
routes: RouteRecord[],
|
|
183
|
+
parentMatched: RouteRecord[],
|
|
184
|
+
parentParams: Record<string, string> = {},
|
|
185
|
+
): MatchResult | null {
|
|
186
|
+
for (const route of routes) {
|
|
187
|
+
const result = matchSingleRoute(path, route, parentMatched, parentParams)
|
|
188
|
+
if (result) return result
|
|
189
|
+
}
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function matchSingleRoute(
|
|
194
|
+
path: string,
|
|
195
|
+
route: RouteRecord,
|
|
196
|
+
parentMatched: RouteRecord[],
|
|
197
|
+
parentParams: Record<string, string>,
|
|
198
|
+
): MatchResult | null {
|
|
199
|
+
if (!route.children || route.children.length === 0) {
|
|
200
|
+
const params = matchPath(route.path, path)
|
|
201
|
+
if (params === null) return null
|
|
202
|
+
return { params: { ...parentParams, ...params }, matched: [...parentMatched, route] }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const prefix = matchPrefix(route.path, path)
|
|
206
|
+
if (prefix === null) return null
|
|
207
|
+
|
|
208
|
+
const allParams = { ...parentParams, ...prefix.params }
|
|
209
|
+
const matched = [...parentMatched, route]
|
|
210
|
+
|
|
211
|
+
const childMatch = matchRoutes(prefix.rest, route.children, matched, allParams)
|
|
212
|
+
if (childMatch) return childMatch
|
|
213
|
+
|
|
214
|
+
const exactParams = matchPath(route.path, path)
|
|
215
|
+
if (exactParams === null) return null
|
|
216
|
+
return { params: { ...parentParams, ...exactParams }, matched }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Merge meta from matched routes (leaf takes precedence) */
|
|
220
|
+
function mergeMeta(matched: RouteRecord[]): RouteMeta {
|
|
221
|
+
const meta: RouteMeta = {}
|
|
222
|
+
for (const record of matched) {
|
|
223
|
+
if (record.meta) Object.assign(meta, record.meta)
|
|
224
|
+
}
|
|
225
|
+
return meta
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Build a path string from a named route's pattern and params */
|
|
229
|
+
export function buildPath(pattern: string, params: Record<string, string>): string {
|
|
230
|
+
return pattern.replace(/:([^/]+)\*?/g, (match, key) => {
|
|
231
|
+
const val = params[key] ?? ""
|
|
232
|
+
// Splat params contain slashes — don't encode them
|
|
233
|
+
if (match.endsWith("*")) return val.split("/").map(encodeURIComponent).join("/")
|
|
234
|
+
return encodeURIComponent(val)
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Find a route record by name (recursive, O(n)). Prefer buildNameIndex for repeated lookups. */
|
|
239
|
+
export function findRouteByName(name: string, routes: RouteRecord[]): RouteRecord | null {
|
|
240
|
+
for (const route of routes) {
|
|
241
|
+
if (route.name === name) return route
|
|
242
|
+
if (route.children) {
|
|
243
|
+
const found = findRouteByName(name, route.children)
|
|
244
|
+
if (found) return found
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Pre-build a name → RouteRecord index from a route tree for O(1) named navigation.
|
|
252
|
+
* Called once at router creation time; avoids O(n) depth-first search per push({ name }).
|
|
253
|
+
*/
|
|
254
|
+
export function buildNameIndex(routes: RouteRecord[]): Map<string, RouteRecord> {
|
|
255
|
+
const index = new Map<string, RouteRecord>()
|
|
256
|
+
function walk(list: RouteRecord[]): void {
|
|
257
|
+
for (const route of list) {
|
|
258
|
+
if (route.name) index.set(route.name, route)
|
|
259
|
+
if (route.children) walk(route.children)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
walk(routes)
|
|
263
|
+
return index
|
|
264
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { createContext, useContext } from "@pyreon/core"
|
|
2
|
+
import { computed, signal } from "@pyreon/reactivity"
|
|
3
|
+
import { buildNameIndex, buildPath, resolveRoute } from "./match"
|
|
4
|
+
import { ScrollManager } from "./scroll"
|
|
5
|
+
import {
|
|
6
|
+
type AfterEachHook,
|
|
7
|
+
type ComponentFn,
|
|
8
|
+
isLazy,
|
|
9
|
+
type LoaderContext,
|
|
10
|
+
type NavigationGuard,
|
|
11
|
+
type NavigationGuardResult,
|
|
12
|
+
type ResolvedRoute,
|
|
13
|
+
type RouteRecord,
|
|
14
|
+
type Router,
|
|
15
|
+
type RouterInstance,
|
|
16
|
+
type RouterOptions,
|
|
17
|
+
} from "./types"
|
|
18
|
+
|
|
19
|
+
// Evaluated once at module load — collapses to `true` in browser / happy-dom,
|
|
20
|
+
// `false` on the server. Using a constant avoids per-call `typeof` branches
|
|
21
|
+
// that are uncoverable in test environments.
|
|
22
|
+
const _isBrowser = typeof window !== "undefined"
|
|
23
|
+
|
|
24
|
+
// ─── Router context ───────────────────────────────────────────────────────────
|
|
25
|
+
// Context-based access: isolated per request in SSR (ALS-backed via
|
|
26
|
+
// @pyreon/runtime-server), isolated per component tree in CSR.
|
|
27
|
+
// Falls back to the module-level singleton for code running outside a component
|
|
28
|
+
// tree (e.g. programmatic navigation from event handlers).
|
|
29
|
+
|
|
30
|
+
export const RouterContext = createContext<RouterInstance | null>(null)
|
|
31
|
+
|
|
32
|
+
// Module-level fallback — safe for CSR (single-threaded), not for concurrent SSR.
|
|
33
|
+
// RouterProvider also sets this so legacy useRouter() calls outside the tree work.
|
|
34
|
+
let _activeRouter: RouterInstance | null = null
|
|
35
|
+
|
|
36
|
+
export function getActiveRouter(): RouterInstance | null {
|
|
37
|
+
return useContext(RouterContext) ?? _activeRouter
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function setActiveRouter(router: RouterInstance | null): void {
|
|
41
|
+
if (router) router._viewDepth = 0
|
|
42
|
+
_activeRouter = router
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Hooks ────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export function useRouter(): Router {
|
|
48
|
+
const router = useContext(RouterContext) ?? _activeRouter
|
|
49
|
+
if (!router)
|
|
50
|
+
throw new Error(
|
|
51
|
+
"[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.",
|
|
52
|
+
)
|
|
53
|
+
return router
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useRoute<TPath extends string = string>(): () => ResolvedRoute<
|
|
57
|
+
import("./types").ExtractParams<TPath>,
|
|
58
|
+
Record<string, string>
|
|
59
|
+
> {
|
|
60
|
+
const router = useContext(RouterContext) ?? _activeRouter
|
|
61
|
+
if (!router)
|
|
62
|
+
throw new Error(
|
|
63
|
+
"[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.",
|
|
64
|
+
)
|
|
65
|
+
return router.currentRoute as never
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Factory ──────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
71
|
+
const opts: RouterOptions = Array.isArray(options) ? { routes: options } : options
|
|
72
|
+
const { routes, mode = "hash", scrollBehavior, onError, maxCacheSize = 100 } = opts
|
|
73
|
+
|
|
74
|
+
// Pre-built O(1) name → record index. Computed once at startup.
|
|
75
|
+
const nameIndex = buildNameIndex(routes)
|
|
76
|
+
|
|
77
|
+
const guards: NavigationGuard[] = []
|
|
78
|
+
const afterHooks: AfterEachHook[] = []
|
|
79
|
+
const scrollManager = new ScrollManager(scrollBehavior)
|
|
80
|
+
|
|
81
|
+
// Navigation generation counter — cancels in-flight navigations when a newer
|
|
82
|
+
// one starts. Prevents out-of-order completion from stale async guards.
|
|
83
|
+
let _navGen = 0
|
|
84
|
+
|
|
85
|
+
// ── Initial location ──────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const getInitialLocation = (): string => {
|
|
88
|
+
// SSR: use explicitly provided url
|
|
89
|
+
if (opts.url) return opts.url
|
|
90
|
+
if (!_isBrowser) return "/"
|
|
91
|
+
if (mode === "history") {
|
|
92
|
+
return window.location.pathname + window.location.search
|
|
93
|
+
}
|
|
94
|
+
const hash = window.location.hash
|
|
95
|
+
return hash.startsWith("#") ? hash.slice(1) || "/" : "/"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const getCurrentLocation = (): string => {
|
|
99
|
+
if (!_isBrowser) return currentPath()
|
|
100
|
+
if (mode === "history") {
|
|
101
|
+
return window.location.pathname + window.location.search
|
|
102
|
+
}
|
|
103
|
+
const hash = window.location.hash
|
|
104
|
+
return hash.startsWith("#") ? hash.slice(1) || "/" : "/"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Signals ───────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
const currentPath = signal(getInitialLocation())
|
|
110
|
+
const currentRoute = computed<ResolvedRoute>(() => resolveRoute(currentPath(), routes))
|
|
111
|
+
|
|
112
|
+
// Browser event listeners — stored so destroy() can remove them
|
|
113
|
+
let _popstateHandler: (() => void) | null = null
|
|
114
|
+
let _hashchangeHandler: (() => void) | null = null
|
|
115
|
+
|
|
116
|
+
if (_isBrowser) {
|
|
117
|
+
if (mode === "history") {
|
|
118
|
+
_popstateHandler = () => currentPath.set(getCurrentLocation())
|
|
119
|
+
window.addEventListener("popstate", _popstateHandler)
|
|
120
|
+
} else {
|
|
121
|
+
_hashchangeHandler = () => currentPath.set(getCurrentLocation())
|
|
122
|
+
window.addEventListener("hashchange", _hashchangeHandler)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const componentCache = new Map<RouteRecord, ComponentFn>()
|
|
127
|
+
const loadingSignal = signal(0)
|
|
128
|
+
|
|
129
|
+
// ── Navigation ────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
type GuardOutcome =
|
|
132
|
+
| { action: "continue" }
|
|
133
|
+
| { action: "cancel" }
|
|
134
|
+
| { action: "redirect"; target: string }
|
|
135
|
+
|
|
136
|
+
async function evaluateGuard(
|
|
137
|
+
guard: NavigationGuard,
|
|
138
|
+
to: ResolvedRoute,
|
|
139
|
+
from: ResolvedRoute,
|
|
140
|
+
gen: number,
|
|
141
|
+
): Promise<GuardOutcome> {
|
|
142
|
+
const result = await runGuard(guard, to, from)
|
|
143
|
+
if (gen !== _navGen) return { action: "cancel" }
|
|
144
|
+
if (result === false) return { action: "cancel" }
|
|
145
|
+
if (typeof result === "string") return { action: "redirect", target: result }
|
|
146
|
+
return { action: "continue" }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function runRouteGuards(
|
|
150
|
+
records: RouteRecord[],
|
|
151
|
+
guardKey: "beforeLeave" | "beforeEnter",
|
|
152
|
+
to: ResolvedRoute,
|
|
153
|
+
from: ResolvedRoute,
|
|
154
|
+
gen: number,
|
|
155
|
+
): Promise<GuardOutcome> {
|
|
156
|
+
for (const record of records) {
|
|
157
|
+
const raw = record[guardKey]
|
|
158
|
+
if (!raw) continue
|
|
159
|
+
const routeGuards = Array.isArray(raw) ? raw : [raw]
|
|
160
|
+
for (const guard of routeGuards) {
|
|
161
|
+
const outcome = await evaluateGuard(guard, to, from, gen)
|
|
162
|
+
if (outcome.action !== "continue") return outcome
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { action: "continue" }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function runGlobalGuards(
|
|
169
|
+
globalGuards: NavigationGuard[],
|
|
170
|
+
to: ResolvedRoute,
|
|
171
|
+
from: ResolvedRoute,
|
|
172
|
+
gen: number,
|
|
173
|
+
): Promise<GuardOutcome> {
|
|
174
|
+
for (const guard of globalGuards) {
|
|
175
|
+
const outcome = await evaluateGuard(guard, to, from, gen)
|
|
176
|
+
if (outcome.action !== "continue") return outcome
|
|
177
|
+
}
|
|
178
|
+
return { action: "continue" }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function processLoaderResult(
|
|
182
|
+
result: PromiseSettledResult<unknown>,
|
|
183
|
+
record: RouteRecord,
|
|
184
|
+
ac: AbortController,
|
|
185
|
+
to: ResolvedRoute,
|
|
186
|
+
): boolean {
|
|
187
|
+
if (result.status === "fulfilled") {
|
|
188
|
+
router._loaderData.set(record, result.value)
|
|
189
|
+
return true
|
|
190
|
+
}
|
|
191
|
+
if (ac.signal.aborted) return true
|
|
192
|
+
if (router._onError) {
|
|
193
|
+
const cancel = router._onError(result.reason, to)
|
|
194
|
+
if (cancel === false) return false
|
|
195
|
+
}
|
|
196
|
+
router._loaderData.set(record, undefined)
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function syncBrowserUrl(path: string, replace: boolean): void {
|
|
201
|
+
if (!_isBrowser) return
|
|
202
|
+
const url = mode === "history" ? path : `#${path}`
|
|
203
|
+
if (replace) {
|
|
204
|
+
window.history.replaceState(null, "", url)
|
|
205
|
+
} else {
|
|
206
|
+
window.history.pushState(null, "", url)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function resolveRedirect(to: ResolvedRoute): string | null {
|
|
211
|
+
const leaf = to.matched[to.matched.length - 1]
|
|
212
|
+
if (!leaf?.redirect) return null
|
|
213
|
+
return sanitizePath(typeof leaf.redirect === "function" ? leaf.redirect(to) : leaf.redirect)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function runAllGuards(
|
|
217
|
+
to: ResolvedRoute,
|
|
218
|
+
from: ResolvedRoute,
|
|
219
|
+
gen: number,
|
|
220
|
+
): Promise<GuardOutcome> {
|
|
221
|
+
const leaveOutcome = await runRouteGuards(from.matched, "beforeLeave", to, from, gen)
|
|
222
|
+
if (leaveOutcome.action !== "continue") return leaveOutcome
|
|
223
|
+
|
|
224
|
+
const enterOutcome = await runRouteGuards(to.matched, "beforeEnter", to, from, gen)
|
|
225
|
+
if (enterOutcome.action !== "continue") return enterOutcome
|
|
226
|
+
|
|
227
|
+
return runGlobalGuards(guards, to, from, gen)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function runLoaders(to: ResolvedRoute, gen: number, ac: AbortController): Promise<boolean> {
|
|
231
|
+
const loadableRecords = to.matched.filter((r) => r.loader)
|
|
232
|
+
if (loadableRecords.length === 0) return true
|
|
233
|
+
|
|
234
|
+
const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
|
|
235
|
+
const results = await Promise.allSettled(
|
|
236
|
+
loadableRecords.map((r) => {
|
|
237
|
+
if (!r.loader) return Promise.resolve(undefined)
|
|
238
|
+
return r.loader(loaderCtx)
|
|
239
|
+
}),
|
|
240
|
+
)
|
|
241
|
+
if (gen !== _navGen) return false
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < loadableRecords.length; i++) {
|
|
244
|
+
const result = results[i]
|
|
245
|
+
const record = loadableRecords[i]
|
|
246
|
+
if (!result || !record) continue
|
|
247
|
+
if (!processLoaderResult(result, record, ac, to)) return false
|
|
248
|
+
}
|
|
249
|
+
return true
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function commitNavigation(
|
|
253
|
+
path: string,
|
|
254
|
+
replace: boolean,
|
|
255
|
+
to: ResolvedRoute,
|
|
256
|
+
from: ResolvedRoute,
|
|
257
|
+
): void {
|
|
258
|
+
scrollManager.save(from.path)
|
|
259
|
+
currentPath.set(path)
|
|
260
|
+
syncBrowserUrl(path, replace)
|
|
261
|
+
|
|
262
|
+
if (_isBrowser && to.meta.title) {
|
|
263
|
+
document.title = to.meta.title
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for (const record of router._loaderData.keys()) {
|
|
267
|
+
if (!to.matched.includes(record)) {
|
|
268
|
+
router._loaderData.delete(record)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const hook of afterHooks) {
|
|
273
|
+
try {
|
|
274
|
+
hook(to, from)
|
|
275
|
+
} catch (_err) {
|
|
276
|
+
/* hook errors silently ignored */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (_isBrowser) {
|
|
281
|
+
queueMicrotask(() => scrollManager.restore(to, from))
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function navigate(path: string, replace: boolean, redirectDepth = 0): Promise<void> {
|
|
286
|
+
if (redirectDepth > 10) return
|
|
287
|
+
|
|
288
|
+
const gen = ++_navGen
|
|
289
|
+
loadingSignal.update((n) => n + 1)
|
|
290
|
+
|
|
291
|
+
const to = resolveRoute(path, routes)
|
|
292
|
+
const from = currentRoute()
|
|
293
|
+
|
|
294
|
+
const redirectTarget = resolveRedirect(to)
|
|
295
|
+
if (redirectTarget !== null) {
|
|
296
|
+
loadingSignal.update((n) => n - 1)
|
|
297
|
+
return navigate(redirectTarget, replace, redirectDepth + 1)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const guardOutcome = await runAllGuards(to, from, gen)
|
|
301
|
+
if (guardOutcome.action !== "continue") {
|
|
302
|
+
loadingSignal.update((n) => n - 1)
|
|
303
|
+
if (guardOutcome.action === "redirect") {
|
|
304
|
+
return navigate(sanitizePath(guardOutcome.target), replace, redirectDepth + 1)
|
|
305
|
+
}
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
router._abortController?.abort()
|
|
310
|
+
const ac = new AbortController()
|
|
311
|
+
router._abortController = ac
|
|
312
|
+
|
|
313
|
+
const loadersOk = await runLoaders(to, gen, ac)
|
|
314
|
+
if (!loadersOk) {
|
|
315
|
+
loadingSignal.update((n) => n - 1)
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
commitNavigation(path, replace, to, from)
|
|
320
|
+
loadingSignal.update((n) => n - 1)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Public router object ──────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
const router: RouterInstance = {
|
|
326
|
+
routes,
|
|
327
|
+
mode,
|
|
328
|
+
currentRoute,
|
|
329
|
+
_currentPath: currentPath,
|
|
330
|
+
_currentRoute: currentRoute,
|
|
331
|
+
_componentCache: componentCache,
|
|
332
|
+
_loadingSignal: loadingSignal,
|
|
333
|
+
_scrollPositions: new Map(),
|
|
334
|
+
_scrollBehavior: scrollBehavior,
|
|
335
|
+
_viewDepth: 0,
|
|
336
|
+
_erroredChunks: new Set(),
|
|
337
|
+
_loaderData: new Map(),
|
|
338
|
+
_abortController: null,
|
|
339
|
+
_onError: onError,
|
|
340
|
+
_maxCacheSize: maxCacheSize,
|
|
341
|
+
|
|
342
|
+
async push(
|
|
343
|
+
location:
|
|
344
|
+
| string
|
|
345
|
+
| { name: string; params?: Record<string, string>; query?: Record<string, string> },
|
|
346
|
+
) {
|
|
347
|
+
if (typeof location === "string") return navigate(sanitizePath(location), false)
|
|
348
|
+
const path = resolveNamedPath(
|
|
349
|
+
location.name,
|
|
350
|
+
location.params ?? {},
|
|
351
|
+
location.query ?? {},
|
|
352
|
+
nameIndex,
|
|
353
|
+
)
|
|
354
|
+
return navigate(path, false)
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
async replace(path: string) {
|
|
358
|
+
return navigate(sanitizePath(path), true)
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
back() {
|
|
362
|
+
if (_isBrowser) window.history.back()
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
beforeEach(guard: NavigationGuard) {
|
|
366
|
+
guards.push(guard)
|
|
367
|
+
return () => {
|
|
368
|
+
const idx = guards.indexOf(guard)
|
|
369
|
+
if (idx >= 0) guards.splice(idx, 1)
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
afterEach(hook: AfterEachHook) {
|
|
374
|
+
afterHooks.push(hook)
|
|
375
|
+
return () => {
|
|
376
|
+
const idx = afterHooks.indexOf(hook)
|
|
377
|
+
if (idx >= 0) afterHooks.splice(idx, 1)
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
loading: () => loadingSignal() > 0,
|
|
382
|
+
|
|
383
|
+
destroy() {
|
|
384
|
+
if (_popstateHandler) {
|
|
385
|
+
window.removeEventListener("popstate", _popstateHandler)
|
|
386
|
+
_popstateHandler = null
|
|
387
|
+
}
|
|
388
|
+
if (_hashchangeHandler) {
|
|
389
|
+
window.removeEventListener("hashchange", _hashchangeHandler)
|
|
390
|
+
_hashchangeHandler = null
|
|
391
|
+
}
|
|
392
|
+
guards.length = 0
|
|
393
|
+
afterHooks.length = 0
|
|
394
|
+
componentCache.clear()
|
|
395
|
+
router._loaderData.clear()
|
|
396
|
+
router._abortController?.abort()
|
|
397
|
+
router._abortController = null
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
_resolve: (rawPath: string) => resolveRoute(rawPath, routes),
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return router
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
async function runGuard(
|
|
409
|
+
guard: NavigationGuard,
|
|
410
|
+
to: ResolvedRoute,
|
|
411
|
+
from: ResolvedRoute,
|
|
412
|
+
): Promise<NavigationGuardResult> {
|
|
413
|
+
try {
|
|
414
|
+
return await guard(to, from)
|
|
415
|
+
} catch (_err) {
|
|
416
|
+
return false
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function resolveNamedPath(
|
|
421
|
+
name: string,
|
|
422
|
+
params: Record<string, string>,
|
|
423
|
+
query: Record<string, string>,
|
|
424
|
+
index: Map<string, RouteRecord>,
|
|
425
|
+
): string {
|
|
426
|
+
const record = index.get(name)
|
|
427
|
+
if (!record) {
|
|
428
|
+
return "/"
|
|
429
|
+
}
|
|
430
|
+
let path = buildPath(record.path, params)
|
|
431
|
+
const qs = Object.entries(query)
|
|
432
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
433
|
+
.join("&")
|
|
434
|
+
if (qs) path += `?${qs}`
|
|
435
|
+
return path
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Block unsafe navigation targets: javascript/data/vbscript URIs and absolute URLs. */
|
|
439
|
+
function sanitizePath(path: string): string {
|
|
440
|
+
const trimmed = path.trim()
|
|
441
|
+
if (/^(?:javascript|data|vbscript):/i.test(trimmed)) {
|
|
442
|
+
return "/"
|
|
443
|
+
}
|
|
444
|
+
// Block absolute URLs and protocol-relative URLs — router only handles same-origin paths
|
|
445
|
+
if (/^\/\/|^https?:/i.test(trimmed)) {
|
|
446
|
+
return "/"
|
|
447
|
+
}
|
|
448
|
+
return path
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export { isLazy }
|