@pyreon/router 0.24.5 → 0.24.6

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/types.ts DELETED
@@ -1,517 +0,0 @@
1
- import type { ComponentFn } from '@pyreon/core'
2
-
3
- export type { ComponentFn }
4
-
5
- // ─── Path param extraction ────────────────────────────────────────────────────
6
-
7
- /**
8
- * Extracts typed params from a path string at compile time.
9
- * Supports optional params via `:param?` — their type is `string | undefined`.
10
- *
11
- * @example
12
- * ExtractParams<'/user/:id/posts/:postId'>
13
- * // → { id: string; postId: string }
14
- *
15
- * ExtractParams<'/user/:id?'>
16
- * // → { id?: string | undefined }
17
- */
18
- export type ExtractParams<T extends string> = T extends `${string}:${infer Param}*/${infer Rest}`
19
- ? { [K in Param]: string } & ExtractParams<`/${Rest}`>
20
- : T extends `${string}:${infer Param}*`
21
- ? { [K in Param]: string }
22
- : T extends `${string}:${infer Param}?/${infer Rest}`
23
- ? { [K in Param]?: string | undefined } & ExtractParams<`/${Rest}`>
24
- : T extends `${string}:${infer Param}?`
25
- ? { [K in Param]?: string | undefined }
26
- : T extends `${string}:${infer Param}/${infer Rest}`
27
- ? { [K in Param]: string } & ExtractParams<`/${Rest}`>
28
- : T extends `${string}:${infer Param}`
29
- ? { [K in Param]: string }
30
- : Record<never, never>
31
-
32
- // ─── Route meta ───────────────────────────────────────────────────────────────
33
-
34
- /**
35
- * Route metadata interface. Extend it via module augmentation to add custom fields:
36
- *
37
- * @example
38
- * // globals.d.ts
39
- * declare module "@pyreon/router" {
40
- * interface RouteMeta {
41
- * requiresRole?: "admin" | "user"
42
- * pageTitle?: string
43
- * }
44
- * }
45
- */
46
- export interface RouteMeta {
47
- /** Sets document.title on navigation */
48
- title?: string
49
- /** Page description (for meta tags) */
50
- description?: string
51
- /** If true, guards can redirect to login */
52
- requiresAuth?: boolean
53
- /** Scroll behavior for this route */
54
- scrollBehavior?: 'top' | 'restore' | 'none'
55
- /** Set to false to disable View Transitions API for this route. Default: true */
56
- viewTransition?: boolean
57
- }
58
-
59
- // ─── Resolved route ───────────────────────────────────────────────────────────
60
-
61
- export interface ResolvedRoute<
62
- P extends Record<string, string | undefined> = Record<string, string>,
63
- Q extends Record<string, string> = Record<string, string>,
64
- > {
65
- path: string
66
- params: P
67
- query: Q
68
- hash: string
69
- /** All matched records from root to leaf (one per nesting level) */
70
- matched: RouteRecord[]
71
- meta: RouteMeta
72
- /**
73
- * Validated search params — populated when the matched route has `validateSearch`.
74
- * Contains the typed result of `validateSearch(query)`. Use `useValidatedSearch()`
75
- * to access this in components with full type inference.
76
- * Empty object `{}` when no `validateSearch` is configured.
77
- */
78
- search?: Record<string, unknown> | undefined
79
- /** Middleware data attached during navigation (populated by middleware chain) */
80
- _middlewareData?: Record<string, unknown> | undefined
81
- /**
82
- * `true` when the URL didn't match any route AND a parent record's
83
- * `notFoundComponent` was used as a synthetic fallback leaf. The
84
- * `matched` chain ends with a synthetic `RouteRecord` rendering the
85
- * not-found component INSIDE all its ancestor layouts — so 404 pages
86
- * carry the same chrome (headers, footers, navigation) as regular
87
- * pages. SSR handlers read this to set HTTP status 404.
88
- */
89
- isNotFound?: boolean
90
- }
91
-
92
- // ─── Lazy component ───────────────────────────────────────────────────────────
93
-
94
- export const LAZY_SYMBOL = Symbol('pyreon.lazy')
95
-
96
- export interface LazyComponent {
97
- readonly [LAZY_SYMBOL]: true
98
- readonly loader: () => Promise<ComponentFn | { default: ComponentFn }>
99
- /** Optional component shown while the lazy chunk is loading */
100
- readonly loadingComponent?: ComponentFn
101
- /** Optional component shown after all retries have failed */
102
- readonly errorComponent?: ComponentFn
103
- /**
104
- * Dev-only module id, emitted by `@pyreon/zero`'s fs-router codegen as
105
- * `lazy(() => import("/abs/X"), { hmrId: "/abs/X" })`. The HMR coordinator
106
- * keys the active route's matched records by this id so a hot-updated
107
- * module can be swapped IN PLACE (no page reload) using the fresh module
108
- * Vite hands the `import.meta.hot.accept` callback — sidestepping the
109
- * stale-`?t=` problem where re-running the dynamic-import thunk inside a
110
- * non-invalidated virtual routes module would return the OLD module.
111
- * Inert in production (no coordinator is registered when not in dev).
112
- */
113
- readonly _hmrId?: string
114
- }
115
-
116
- export function lazy(
117
- loader: () => Promise<ComponentFn | { default: ComponentFn }>,
118
- options?: { loading?: ComponentFn; error?: ComponentFn; hmrId?: string },
119
- ): LazyComponent {
120
- return {
121
- [LAZY_SYMBOL]: true,
122
- loader,
123
- ...(options?.loading ? { loadingComponent: options.loading } : {}),
124
- ...(options?.error ? { errorComponent: options.error } : {}),
125
- ...(options?.hmrId ? { _hmrId: options.hmrId } : {}),
126
- }
127
- }
128
-
129
- export function isLazy(c: RouteComponent): c is LazyComponent {
130
- return typeof c === 'object' && c !== null && (c as LazyComponent)[LAZY_SYMBOL] === true
131
- }
132
-
133
- export type RouteComponent = ComponentFn | LazyComponent
134
-
135
- // ─── Navigation guard ─────────────────────────────────────────────────────────
136
-
137
- export type NavigationGuardResult = boolean | string | undefined
138
- export type NavigationGuard = (
139
- to: ResolvedRoute,
140
- from: ResolvedRoute,
141
- ) => NavigationGuardResult | Promise<NavigationGuardResult>
142
-
143
- export type AfterEachHook = (to: ResolvedRoute, from: ResolvedRoute) => void
144
-
145
- // ─── Route middleware ────────────────────────────────────────────────────────
146
-
147
- /**
148
- * Context object passed through the middleware chain.
149
- * Middleware can read/write arbitrary data on `ctx.data`.
150
- */
151
- export interface RouteMiddlewareContext {
152
- /** The route being navigated to. */
153
- to: ResolvedRoute
154
- /** The route being navigated from. */
155
- from: ResolvedRoute
156
- /** Shared data — middleware can accumulate state here for downstream middleware/components. */
157
- data: Record<string, unknown>
158
- }
159
-
160
- /**
161
- * Route middleware function. Called before guards.
162
- * - Return nothing/undefined to continue
163
- * - Return `false` to cancel navigation
164
- * - Return a string to redirect
165
- */
166
- export type RouteMiddleware = (
167
- ctx: RouteMiddlewareContext,
168
- ) => void | false | string | Promise<void | false | string>
169
-
170
- // ─── Navigation blockers ──────────────────────────────────────────────────────
171
-
172
- /**
173
- * Called before each navigation. Return `true` to block, `false` to allow.
174
- * Async blockers are supported (e.g. to show a confirmation dialog).
175
- */
176
- export type BlockerFn = (to: ResolvedRoute, from: ResolvedRoute) => boolean | Promise<boolean>
177
-
178
- export interface Blocker {
179
- /** Unregister this blocker so future navigations proceed freely. */
180
- remove(): void
181
- }
182
-
183
- // ─── Route loaders ────────────────────────────────────────────────────────────
184
-
185
- export interface LoaderContext {
186
- params: Record<string, string>
187
- query: Record<string, string>
188
- /** Aborted when a newer navigation supersedes this one */
189
- signal: AbortSignal
190
- /**
191
- * The incoming HTTP `Request` — populated only when the loader runs during
192
- * SSR (via `prefetchLoaderData`); `undefined` on every CSR navigation.
193
- * Lets server-side loaders read cookies / auth headers and decide whether
194
- * to `throw redirect('/login')` BEFORE the layout renders.
195
- *
196
- * @example
197
- * loader: ({ request }) => {
198
- * const cookie = request?.headers.get('cookie') ?? ''
199
- * const sid = cookie.match(/sid=([^;]+)/)?.[1]
200
- * if (!sid) redirect('/login')
201
- * return { sid }
202
- * }
203
- */
204
- request?: Request
205
- }
206
-
207
- export type RouteLoaderFn = (ctx: LoaderContext) => Promise<unknown>
208
-
209
- // ─── Route record ─────────────────────────────────────────────────────────────
210
-
211
- export interface RouteRecord<TPath extends string = string> {
212
- /** Path pattern — supports `:param` segments and `(.*)` wildcard */
213
- path: TPath
214
- component: RouteComponent
215
- /** Optional route name for named navigation */
216
- name?: string
217
- /** Metadata attached to this route */
218
- meta?: RouteMeta
219
- /**
220
- * Redirect target. Evaluated before guards.
221
- * String: redirect to that path.
222
- * Function: called with the resolved route, return path string.
223
- */
224
- redirect?: string | ((to: ResolvedRoute) => string)
225
- /** Guard(s) run only for this route, before global beforeEach guards */
226
- beforeEnter?: NavigationGuard | NavigationGuard[]
227
- /** Guard(s) run before leaving this route. Return false to cancel. */
228
- beforeLeave?: NavigationGuard | NavigationGuard[]
229
- /**
230
- * Alternative path(s) for this route. Alias paths render the same component
231
- * and share guards, loaders, and metadata with the primary path.
232
- *
233
- * @example
234
- * { path: "/user/:id", alias: ["/profile/:id"], component: UserPage }
235
- */
236
- alias?: string | string[]
237
- /** Child routes rendered inside this route's component via <RouterView /> */
238
- children?: RouteRecord[]
239
- /**
240
- * Data loader — runs before navigation commits, in parallel with sibling loaders.
241
- * The result is accessible via `useLoaderData()` inside the route component.
242
- * Receives an AbortSignal that fires if a newer navigation supersedes this one.
243
- */
244
- loader?: RouteLoaderFn
245
- /**
246
- * When true, the router shows cached loader data immediately (stale) and
247
- * revalidates in the background. The component re-renders once fresh data arrives.
248
- * Only applies when navigating to a route that already has cached loader data.
249
- */
250
- staleWhileRevalidate?: boolean
251
- /**
252
- * Cache key function for loader data. Returns a string key derived from
253
- * route params/query. When the key matches cached data, the loader is
254
- * skipped (cache hit). Default: `path + JSON.stringify(params)`.
255
- *
256
- * @example
257
- * ```ts
258
- * loaderKey: ({ params }) => `user-${params.id}`
259
- * ```
260
- */
261
- loaderKey?: (ctx: Pick<LoaderContext, 'params' | 'query'>) => string
262
- /**
263
- * Time in ms to keep cached loader data before garbage collection.
264
- * Default: 300000 (5 minutes). Set to 0 to disable caching.
265
- * Stale data is still served immediately if `staleWhileRevalidate` is true.
266
- */
267
- gcTime?: number
268
- /** Component rendered when this route's loader throws an error */
269
- errorComponent?: ComponentFn
270
- /**
271
- * Component rendered when a URL doesn't match any descendant route under
272
- * this record's path. Acts as a "404 within layout" — the matched chain
273
- * is `[...ancestors, this, syntheticLeaf]` so the not-found component
274
- * renders INSIDE this layout's chrome. fs-router attaches this when it
275
- * detects a `_404.tsx` / `_not-found.tsx` file under this layout.
276
- */
277
- notFoundComponent?: ComponentFn
278
- /**
279
- * Component rendered while this route's loader is running.
280
- * Only shown after `pendingMs` (default: 0) to avoid flash on fast loads.
281
- * Once shown, displayed for at least `pendingMinMs` (default: 200) to avoid flicker.
282
- */
283
- pendingComponent?: ComponentFn
284
- /** Delay in ms before showing pendingComponent (default: 0). Prevents flash on fast loaders. */
285
- pendingMs?: number
286
- /** Minimum display time in ms for pendingComponent once shown (default: 200). Prevents flicker. */
287
- pendingMinMs?: number
288
- /**
289
- * Validate and transform raw query string parameters into typed values.
290
- * Receives the raw `Record<string, string>` from the URL and returns
291
- * a typed object. The validated result is available via `useValidatedSearch()`.
292
- *
293
- * Accepts any function — use Zod `.parse`, Valibot, or a plain function:
294
- *
295
- * @example
296
- * ```ts
297
- * // Plain function:
298
- * validateSearch: (raw) => ({
299
- * page: Number(raw.page) || 1,
300
- * q: raw.q ?? '',
301
- * })
302
- *
303
- * // With Zod:
304
- * validateSearch: z.object({
305
- * page: z.coerce.number().default(1),
306
- * q: z.string().default(''),
307
- * }).parse
308
- * ```
309
- */
310
- validateSearch?: (raw: Record<string, string>) => Record<string, unknown>
311
- /** Per-route middleware — runs before guards, can accumulate context data. */
312
- middleware?: RouteMiddleware | RouteMiddleware[]
313
- }
314
-
315
- // ─── Router options ───────────────────────────────────────────────────────────
316
-
317
- export type ScrollBehaviorFn = (
318
- to: ResolvedRoute,
319
- from: ResolvedRoute,
320
- savedPosition: number | null,
321
- ) => 'top' | 'restore' | 'none' | number
322
-
323
- export interface RouterOptions {
324
- routes: RouteRecord[]
325
- /** "hash" (default) uses location.hash; "history" uses pushState */
326
- mode?: 'hash' | 'history'
327
- /**
328
- * Base path for the application. Used when deploying to a sub-path
329
- * (e.g. `"/app"` for `https://example.com/app/`).
330
- * Only applies in history mode. Must start with `/`.
331
- * Default: `""` (no base path).
332
- */
333
- base?: string
334
- /**
335
- * Global scroll behavior. Per-route meta.scrollBehavior takes precedence.
336
- * Default: "top"
337
- */
338
- scrollBehavior?: ScrollBehaviorFn | 'top' | 'restore' | 'none'
339
- /**
340
- * Initial URL for SSR. On the server, window.location is unavailable;
341
- * pass the request URL here so the router resolves the correct route.
342
- *
343
- * @example
344
- * // In your SSR handler:
345
- * const router = createRouter({ routes, url: req.url })
346
- */
347
- url?: string
348
- /**
349
- * Called when a route loader throws. If not provided, errors are logged
350
- * and the navigation continues with `undefined` data for the failed loader.
351
- * Return `false` to cancel the navigation.
352
- */
353
- onError?: (err: unknown, route: ResolvedRoute) => undefined | false
354
- /**
355
- * Maximum number of resolved lazy components to cache.
356
- * When exceeded, the oldest entry is evicted.
357
- * Default: 100.
358
- */
359
- maxCacheSize?: number
360
- /**
361
- * Trailing slash handling:
362
- * - `"strip"` — removes trailing slashes before matching (default)
363
- * - `"add"` — ensures paths always end with `/`
364
- * - `"ignore"` — no normalization
365
- */
366
- trailingSlash?: 'strip' | 'add' | 'ignore'
367
- }
368
-
369
- // ─── Router interface ─────────────────────────────────────────────────────────
370
-
371
- /**
372
- * Router interface. Parameterized by route name union for type-safe named navigation.
373
- *
374
- * @example
375
- * ```ts
376
- * type MyRoutes = 'home' | 'user' | 'settings'
377
- * const router: Router<MyRoutes> = createRouter({ routes })
378
- * router.push({ name: 'user', params: { id: '42' } }) // ✓
379
- * router.push({ name: 'typo' }) // TS error
380
- * ```
381
- */
382
- export interface Router<TNames extends string = string> {
383
- /** Navigate to a path */
384
- push(path: string): Promise<void>
385
- /** Navigate to a named route */
386
- push(location: {
387
- name: TNames
388
- params?: Record<string, string>
389
- query?: Record<string, string>
390
- }): Promise<void>
391
- /** Replace current history entry */
392
- replace(path: string): Promise<void>
393
- /** Replace current history entry using a named route */
394
- replace(location: {
395
- name: TNames
396
- params?: Record<string, string>
397
- query?: Record<string, string>
398
- }): Promise<void>
399
- /** Go back one step in history */
400
- back(): void
401
- /** Go forward one step in history */
402
- forward(): void
403
- /** Navigate forward or backward by `delta` steps in the history stack */
404
- go(delta: number): void
405
- /** Register a global before-navigation guard. Returns an unregister function. */
406
- beforeEach(guard: NavigationGuard): () => void
407
- /** Register a global after-navigation hook. Returns an unregister function. */
408
- afterEach(hook: AfterEachHook): () => void
409
- /** Current resolved route (reactive signal) */
410
- readonly currentRoute: () => ResolvedRoute
411
- /** True while a navigation (guards + loaders) is in flight */
412
- readonly loading: () => boolean
413
- /**
414
- * Promise that resolves once the initial navigation is complete.
415
- * Useful for SSR and for delaying rendering until the first route is resolved.
416
- */
417
- isReady(): Promise<void>
418
- /**
419
- * Resolve `path` and prepare everything needed to render it: load any lazy
420
- * route components into the router's cache and run the matched routes'
421
- * loaders. After this resolves, a `RouterView` rendered against this router
422
- * for `path` will produce final HTML synchronously — no loading fallbacks,
423
- * no `useLoaderData()` returning `undefined`.
424
- *
425
- * Used by SSR/SSG to hydrate the route tree before `renderToString`.
426
- * The router's `currentRoute` is NOT changed by `preload` — pass the path
427
- * separately when creating the router (`createRouter({ url, ... })`) or
428
- * call this for the same `url` you initialised the router with.
429
- */
430
- preload(
431
- path: string,
432
- request?: Request,
433
- options?: { skipLoaders?: boolean },
434
- ): Promise<void>
435
- /**
436
- * Invalidate cached loader data. Forces loaders to re-run on next navigation.
437
- * - No args: invalidate ALL cached loader data
438
- * - String: invalidate by cache key (as returned by `loaderKey`)
439
- * - Function: invalidate entries where the predicate returns true
440
- */
441
- invalidateLoader(keyOrPredicate?: string | ((key: string) => boolean)): void
442
- /** Remove all event listeners, clear caches, and abort in-flight navigations. */
443
- destroy(): void
444
- }
445
-
446
- // ─── Internal router instance ─────────────────────────────────────────────────
447
-
448
- import type { Computed, Signal } from '@pyreon/reactivity'
449
-
450
- export interface RouterInstance extends Router {
451
- routes: RouteRecord[]
452
- mode: 'hash' | 'history'
453
- /** Normalized base path (e.g. "/app"), empty string if none */
454
- _base: string
455
- _currentPath: Signal<string>
456
- _currentRoute: Computed<ResolvedRoute>
457
- _componentCache: Map<RouteRecord, ComponentFn>
458
- _loadingSignal: Signal<number>
459
- _resolve(rawPath: string): ResolvedRoute
460
- _scrollPositions: Map<string, number>
461
- _scrollBehavior: RouterOptions['scrollBehavior']
462
- _onError: RouterOptions['onError']
463
- _maxCacheSize: number
464
- /**
465
- * Current RouterView nesting depth. Incremented by each RouterView as it
466
- * mounts (in tree order = depth-first), so each view knows which level of
467
- * `matched[]` to render. Reset to 0 by RouterProvider.
468
- */
469
- _viewDepth: number
470
- /** Route records whose lazy chunk permanently failed (all retries exhausted) */
471
- _erroredChunks: Set<RouteRecord>
472
- /** Loader data keyed by route record — populated before each navigation commits */
473
- _loaderData: Map<RouteRecord, unknown>
474
- /** AbortController for the in-flight loader batch — aborted when a newer navigation starts */
475
- _abortController: AbortController | null
476
- /** Registered navigation blockers */
477
- _blockers: Set<BlockerFn>
478
- /** Resolves the isReady() promise after initial navigation completes */
479
- _readyResolve: (() => void) | null
480
- /** The isReady() promise instance */
481
- _readyPromise: Promise<void>
482
- /** Timestamp when the current navigation started — used for pendingMs timing */
483
- _navigationStartTime: number
484
- /** Key-based loader cache: cacheKey → { data, timestamp } */
485
- _loaderCache: Map<string, { data: unknown; timestamp: number }>
486
- /**
487
- * In-flight loader dedup: cacheKey → { promise, signal }.
488
- * Tracking the signal lets dedup skip an in-flight entry whose signal is
489
- * already aborted — otherwise nav-2 would inherit nav-1's aborted promise
490
- * (`router.push` aborts the previous nav's controller before starting the
491
- * next, so back-to-back nav to the same path could resolve nav-2 against
492
- * nav-1's aborted fetch).
493
- */
494
- _loaderInflight: Map<string, { promise: Promise<unknown>; signal: AbortSignal }>
495
- /**
496
- * Dev-only HMR coordinator. Given a hot-updated module's id and the FRESH
497
- * module namespace Vite handed `import.meta.hot.accept`, swaps the new
498
- * component into every matched record whose lazy `_hmrId` equals `id`,
499
- * then bumps `_loadingSignal` so `RouterView` re-renders ONLY that subtree
500
- * in place — no page reload, so `__pyreon_hmr_registry__` (module-scope
501
- * signal values) survives and `__hmr_signal` restores them.
502
- *
503
- * Using the namespace Vite passed (not a re-run of the lazy thunk)
504
- * sidesteps the stale-`?t=` trap: the dynamic-import thunk lives in the
505
- * virtual routes module, which is NOT invalidated when a leaf route
506
- * self-accepts, so re-importing it would return the OLD module.
507
- *
508
- * Returns `true` when at least one matched component was swapped. `false`
509
- * tells `@pyreon/vite-plugin`'s accept handler the edit was outside the
510
- * active route tree (a nested non-route component, an unrelated route, a
511
- * signal-only module) so it falls back to `import.meta.hot.invalidate()`
512
- * → an automatic full reload (no manual refresh either way).
513
- *
514
- * Present only when the router is created in a dev browser context.
515
- */
516
- _hmrSwap?: (id: string, mod: unknown) => boolean
517
- }