what-router 0.11.7 → 0.12.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/README.md +28 -0
- package/dist/index.min.js +15 -1
- package/dist/match.min.js +1 -1
- package/index.d.ts +97 -2
- package/package.json +3 -2
- package/src/index.js +282 -45
- package/src/match.js +48 -6
package/README.md
CHANGED
|
@@ -134,6 +134,27 @@ const requireRole = asyncGuard(
|
|
|
134
134
|
}
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
+
`redirect()` throws a navigation signal. Two places catch it: route middleware,
|
|
138
|
+
and a component body.
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
{
|
|
142
|
+
path: '/admin',
|
|
143
|
+
component: AdminPanel,
|
|
144
|
+
middleware: [() => isLoggedIn() || redirect('/login')],
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function Private() {
|
|
148
|
+
if (!isLoggedIn()) redirect('/login');
|
|
149
|
+
return h(Secret, {});
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
From an event handler, a promise callback or a timer, nothing catches the
|
|
154
|
+
signal and it surfaces as an uncaught error carrying `ERR_REDIRECT_NOT_CAUGHT`.
|
|
155
|
+
Call `navigate(to)` there instead. A `try/catch` around a `redirect()` call
|
|
156
|
+
also swallows the signal, so rethrow anything whose `name` is `RouterRedirect`.
|
|
157
|
+
|
|
137
158
|
## View Transitions
|
|
138
159
|
|
|
139
160
|
Navigation uses the View Transitions API by default when available. Use helpers to customize:
|
|
@@ -165,6 +186,13 @@ enableScrollRestoration(); // call once at app entry
|
|
|
165
186
|
| `navigate(to, opts?)` | Programmatic navigation |
|
|
166
187
|
| `route` | Reactive route state object |
|
|
167
188
|
| `useRoute()` | Hook returning computed route properties |
|
|
189
|
+
| `useParams()` | Current route params |
|
|
190
|
+
| `useSearch()` | Parsed query string of the last matched route (stale on a 404, like `route.query`) |
|
|
191
|
+
| `useNavigate()` | Returns `navigate` |
|
|
192
|
+
| `redirect(to, opts?)` | Abort route matching and navigate, **from route middleware** (throws, never returns) |
|
|
193
|
+
| `prefetchRoute(href)` | Prefetch a route's assets |
|
|
194
|
+
| `beforeNavigate(fn)` | Guard run before each route navigation (not hash links); return `false` to cancel |
|
|
195
|
+
| `afterNavigate(fn)` | Callback run after each committed navigation |
|
|
168
196
|
| `defineRoutes(config)` | Create routes from flat object |
|
|
169
197
|
| `nestedRoutes(base, children, opts?)` | Nested route helper |
|
|
170
198
|
| `routeGroup(name, routes, opts?)` | Group routes without affecting URL |
|
package/dist/index.min.js
CHANGED
|
@@ -1 +1,15 @@
|
|
|
1
|
-
import{signal as x,effect as
|
|
1
|
+
import{signal as x,effect as I,computed as b,batch as M,h as c,ErrorBoundary as X}from"what-core";function O(e){let t=e.replace(/\([\w-]+\)\//g,"").replace(/\[\.\.\.(\w+)\]/g,(i,u)=>`*:${u}`).replace(/\[(\w+)\]/g,":$1"),n=[],r=null,o=t.split("/").map(i=>i.startsWith("*:")?(r=i.slice(2),n.push(r),"(.+)"):i==="*"?(r="rest",n.push("rest"),"(.+)"):i.startsWith(":")?(n.push(i.slice(1)),"([^/]+)"):i.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("/");return{regex:new RegExp(`^${o}$`),paramNames:n,catchAll:r}}var _=new Map,F=1e3;function H(e){let t=_.get(e);return t||(t=O(e),_.size>=F&&_.clear(),_.set(e,t)),t}function K(e,t){let n;try{n=decodeURIComponent(e)}catch{return null}let r=n.split(/[/\\]/);return!t&&r.length>1||r.includes("..")?null:n}function A(e,t){let n=t.filter(r=>r.path);n.sort((r,o)=>{let a=(r.path.match(/:/g)||[]).length+(r.path.includes("*")?100:0),i=(o.path.match(/:/g)||[]).length+(o.path.includes("*")?100:0);return a-i});for(let r of n){let{regex:o,paramNames:a,catchAll:i}=H(r.path),u=e.match(o);if(u){let p={},l=!1;if(a.forEach((s,d)=>{let g=K(u[d+1],s===i);if(g===null){l=!0;return}p[s]=g}),l)continue;return{route:r,params:p}}}return null}function C(e){let t=Object.create(null);if(!e)return t;let n=e.startsWith("?")?e.slice(1):e;for(let r of n.split("&")){let[o,a]=r.split("=");if(!o)continue;let i=decodeURIComponent(o),u=a?decodeURIComponent(a):"";Object.prototype.hasOwnProperty.call(t,i)?Array.isArray(t[i])?t[i].push(u):t[i]=[t[i],u]:t[i]=u}return t}var Q=new Set(["http:","https:","mailto:","tel:"]);function k(e){if(typeof e!="string")return!1;let n=e.trim().replace(/[\s\x00-\x1f]/g,"").toLowerCase();if(n.startsWith("javascript:")||n.startsWith("data:")||n.startsWith("vbscript:")||/^[/\\]{2}/.test(n))return!1;let r=n.match(/^([a-z][a-z0-9+.-]*:)/);return r?Q.has(r[1]):!n.includes("\\")}var h=x(typeof location<"u"?location.pathname+location.search+location.hash:"/"),G=x({}),D=x({}),y=x(!1),R=x(null),V=x(null),m={get url(){return h()},get path(){return h().split("?")[0].split("#")[0]},get params(){return G()},get query(){return D()},get hash(){let e=h().split("#")[1];return e?"#"+e:""},get isNavigating(){return y()},get error(){return V()}},T=[],q=[];function z(e,t){return e.push(t),()=>{let n=e.indexOf(t);n!==-1&&e.splice(n,1)}}function ue(e){return z(T,e)}function le(e){return z(q,e)}async function v(e,t={}){let{replace:n=!1,state:r=null,transition:o=!0,_fromPopstate:a=!1,_redirectChain:i=!1}=t;if(i||(w.length=0),!k(e)){typeof console<"u"&&console.warn("[what-router] Blocked navigation to unsafe URL:",e);return}if(typeof window<"u"&&e.startsWith("#")){let d=h().split("#")[0]+e;history.replaceState(r,"",d),h.set(d);let g=document.querySelector(e);g&&g.scrollIntoView({behavior:"smooth"});return}if(e===h()||y.peek())return;y.set(!0),R.set(e);let u=h();if(T.length){let l=!1;try{for(let s of T.slice())if(await s(e,u)===!1){l=!0;break}}catch(s){throw y.set(!1),R.set(null),s}if(l){y.set(!1),R.set(null),a&&typeof history<"u"&&history.pushState(null,"",u);return}}V.set(null);let p=()=>{a||(typeof window<"u"&&E.set(h(),{x:window.scrollX,y:window.scrollY}),n?history.replaceState(r,"",e):history.pushState(r,"",e)),h.set(e),y.set(!1),R.set(null)};if(o&&typeof document<"u"&&document.startViewTransition)try{await document.startViewTransition(p).finished}catch{}else p();if(q.length)for(let l of q.slice())l(e,u)}var B=Symbol.for("what.router.redirect"),Y=Symbol.for("what.navigation.signal");function fe(e,t={}){if(!k(e)){let r=typeof e=="string"?e:Object.prototype.toString.call(e),o=new Error(`[what-router] redirect() refused an unsafe target: ${r}`);throw o.code="ERR_UNSAFE_REDIRECT",o.suggestion='redirect() accepts same-origin paths and http:, https:, mailto: or tel: URLs only. Protocol-relative ("//host"), backslash-smuggled and javascript:/data: targets are open-redirect vectors. Check a user-supplied target against an allowlist first.',o.codeExample=`// Bad - a user-controlled target can leave your origin:
|
|
2
|
+
redirect(query.next);
|
|
3
|
+
|
|
4
|
+
// Good - allowlist the target first:
|
|
5
|
+
redirect(ALLOWED.has(query.next) ? query.next : '/');`,o}let n=new Error(`[what-router] redirect to ${e}`);throw n.name="RouterRedirect",n.code="ERR_REDIRECT_NOT_CAUGHT",n.suggestion="Seeing this signal in your console means nothing caught it. redirect() works from route middleware and from a component body, where the Router catches it. From an event handler, a promise callback or a timer, call navigate(to) instead. A try/catch around the redirect() call also swallows it.",n.codeExample=`// Bad - an event handler runs long after the render the Router caught:
|
|
6
|
+
<button onclick={() => redirect('/login')}>Sign in</button>
|
|
7
|
+
|
|
8
|
+
// Good - navigate() from a handler:
|
|
9
|
+
<button onclick={() => navigate('/login')}>Sign in</button>
|
|
10
|
+
|
|
11
|
+
// Good - redirect() from a component body, which the Router catches:
|
|
12
|
+
function Private() {
|
|
13
|
+
if (!user()) redirect('/login');
|
|
14
|
+
return <Secret />;
|
|
15
|
+
}`,n[B]=!0,n[Y]=()=>{L(e,t)},n.to=e,n.options=t,n}typeof window<"u"&&window.addEventListener("popstate",()=>{E.set(h(),{x:window.scrollX,y:window.scrollY});let e=location.pathname+location.search+location.hash;v(e,{replace:!0,_fromPopstate:!0,transition:!1}).then(()=>{let t=E.get(e);t&&requestAnimationFrame(()=>window.scrollTo(t.x,t.y))})});function J(e,t){let n=[];if(!e.path)return n;let r=e.path.split("/").filter(Boolean),o="";for(let a of r){o+="/"+a;let i=t.find(u=>u.layout&&u.path===o+"/_layout");i&&n.push(i.layout)}return e.layout&&n.push(e.layout),n}var w=[],Z=10;function W(e){return c("div",{class:"what-redirect-loop"},c("h1",null,"Redirect Loop"),c("p",null,e))}function L(e,t){if(w.push(e),w.length>Z){let o=w.slice(-5).join(" \u2192 ");return w.length=0,console.error(`[what-router] Redirect loop detected: ${o}`),y.set(!1),R.set(null),W("Too many redirects. Check your middleware configuration.")}let n=new Set,r=!1;for(let o of w){if(n.has(o)){r=!0;break}n.add(o)}if(r){let o=w.join(" \u2192 ");return w.length=0,console.error(`[what-router] Redirect cycle detected: ${o}`),y.set(!1),R.set(null),W("Circular redirect detected. Check your middleware configuration.")}return v(e,{replace:!0,...t,_redirectChain:!0}),null}function ee(e,t,n){if(!e)return null;let r=e.split("?")[0].split("#")[0],o=A(r,t);return!o||o.route===n?null:o.route.loading||null}function te({routes:e,fallback:t,globalLayout:n}){let r=()=>{let a=h(),i=a.split("?")[0].split("#")[0],u=a.split("?")[1]?.split("#")[0]||"",p=y(),l=A(i,e);if(l){M(()=>{G.set(l.params),D.set(C(u))});let{route:s,params:d}=l,g=C(u);if(s.middleware&&s.middleware.length>0)for(let P of s.middleware){let N=P({path:i,params:d,query:g,route:s});if(N===!1)return t?c(t,{}):c("div",{class:"what-403"},c("h1",null,"403"),c("p",null,"Access denied"));if(typeof N=="string")return L(N)}let f,S=p?ee(R(),e,s):null;S?f=c(S,{}):s.loading&&p?f=c(s.loading,{}):f=c(s.component,{params:d,query:g,route:s}),s.error&&(f=c(X,{fallback:s.error},f));let j=J(s,e);for(let P of j.reverse())f=c(P,{params:d,query:g},f);return f}return t?c(t,{}):c("div",{class:"what-404"},c("h1",null,"404"),c("p",null,"Page not found"))},o=()=>{let a=null;try{return r()}catch(i){if(!i||!i[B])throw i;a=i}return L(a.to,a.options)};return n?c(n,{},o):o}function ne({href:e,class:t,className:n,children:r,replace:o,prefetch:a=!0,activeClass:i="active",exactActiveClass:u="exact-active",transition:p=!0,...l}){let s=k(e)?e:"about:blank";!k(e)&&typeof console<"u"&&console.warn("[what-router] Link blocked unsafe href:",e);let d=s.split("?")[0].split("#")[0];return c("a",{href:s,class:()=>{let f=m.path,S=d==="/"?f==="/":f===d||f.startsWith(d+"/");return[t||n,S&&i,f===d&&u].filter(Boolean).join(" ")||void 0},onclick:f=>{f.ctrlKey||f.metaKey||f.shiftKey||f.altKey||f.button!==0||(f.preventDefault(),v(s,{replace:o,transition:p}))},onmouseenter:a?()=>U(s):void 0,...l},...Array.isArray(r)?r:[r])}function de(e){return ne(e)}function pe(e){return Object.entries(e).map(([t,n])=>typeof n=="function"?{path:t,component:n}:{path:t,...n})}function re(e,t){let n=e.endsWith("/")?e.slice(0,-1):e,r=!t||t==="/"?"/":t.startsWith("/")?t:`/${t}`;return r==="/"?n||"/":n+r}function he(e,t,n={}){let{layout:r,loading:o,error:a}=n;return t.map(i=>({...i,path:re(e,i.path),layout:i.layout||r,loading:i.loading||o,error:i.error||a}))}function me(e,t,n={}){let{layout:r,middleware:o}=n;return t.map(a=>({...a,_group:e,layout:a.layout||r,middleware:[...a.middleware||[],...o||[]]}))}function ge({to:e}){return v(e,{replace:!0}),null}function ye(e,t){return n=>function(o){let a=e(o);return a instanceof Promise?c("div",{class:"what-guard-loading"},"Loading..."):a?c(n,o):typeof t=="string"?(v(t,{replace:!0}),null):c(t,o)}}function we(e,t={}){let{fallback:n="/login",loading:r=null}=t;return o=>function(i){let u=x("pending"),p=x(null),l=!1;return I(()=>(l=!1,Promise.resolve(e(i)).then(s=>{l||(p.set(s),u.set(s?"allowed":"denied"))}).catch(()=>{l||u.set("denied")}),()=>{l=!0})),()=>{let s=u();return s==="pending"?r?c(r,{}):null:s==="allowed"?c(o,i):typeof n=="string"?(v(n,{replace:!0}),null):c(n,i)}}}var $=new Set;function U(e){if(typeof document>"u"||$.has(e))return;$.add(e);let t=document.createElement("link");t.rel="prefetch",t.href=e,document.head.appendChild(t)}var E=new Map;function xe(){typeof window>"u"||(window.addEventListener("beforeunload",()=>{E.set(location.pathname,window.scrollY)}),I(()=>{let e=m.path,t=E.get(e);requestAnimationFrame(()=>{t!==void 0?window.scrollTo(0,t):m.hash?document.querySelector(m.hash)?.scrollIntoView():window.scrollTo(0,0)})}))}function ve(e){return{style:{viewTransitionName:e}}}function Re(e){typeof document>"u"||(document.documentElement.dataset.transition=e)}function be(){return{path:b(()=>m.path),params:b(()=>m.params),query:b(()=>m.query),hash:b(()=>m.hash),isNavigating:b(()=>m.isNavigating),navigate:v,prefetch:U}}function Ee(){return m.params}function Se(){return m.query}function _e(){return v}function Ae(e){U(e)}function Ce({children:e}){return e||null}function ke({routes:e,layout:t,fallback:n,error:r}){let o=e.map(a=>({path:a.path,component:a.component,layout:a.layout||void 0,loading:a.loading||void 0,error:a.error||r||void 0,_mode:a.mode||"client"}));return te({routes:o,globalLayout:t,fallback:n||oe})}function oe(){return c("div",{style:"text-align:center;padding:60px 20px"},c("h1",{style:"font-size:48px;margin-bottom:8px"},"404"),c("p",{style:"color:#64748b"},"Page not found"))}export{ke as FileRouter,ne as Link,de as NavLink,Ce as Outlet,ge as Redirect,te as Router,le as afterNavigate,we as asyncGuard,ue as beforeNavigate,O as compilePath,pe as defineRoutes,xe as enableScrollRestoration,ye as guard,k as isSafeUrl,A as matchRoute,v as navigate,he as nestedRoutes,C as parseQuery,U as prefetch,Ae as prefetchRoute,fe as redirect,m as route,me as routeGroup,Re as setViewTransition,_e as useNavigate,Ee as useParams,be as useRoute,Se as useSearch,ve as viewTransitionName};
|
package/dist/match.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
function
|
|
1
|
+
function h(n){let t=n.replace(/\([\w-]+\)\//g,"").replace(/\[\.\.\.(\w+)\]/g,(e,s)=>`*:${s}`).replace(/\[(\w+)\]/g,":$1"),c=[],r=null,o=t.split("/").map(e=>e.startsWith("*:")?(r=e.slice(2),c.push(r),"(.+)"):e==="*"?(r="rest",c.push("rest"),"(.+)"):e.startsWith(":")?(c.push(e.slice(1)),"([^/]+)"):e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("/");return{regex:new RegExp(`^${o}$`),paramNames:c,catchAll:r}}var a=new Map,m=1e3;function g(n){let t=a.get(n);return t||(t=h(n),a.size>=m&&a.clear(),a.set(n,t)),t}function x(n,t){let c;try{c=decodeURIComponent(n)}catch{return null}let r=c.split(/[/\\]/);return!t&&r.length>1||r.includes("..")?null:c}function y(n,t){let c=t.filter(r=>r.path);c.sort((r,o)=>{let l=(r.path.match(/:/g)||[]).length+(r.path.includes("*")?100:0),e=(o.path.match(/:/g)||[]).length+(o.path.includes("*")?100:0);return l-e});for(let r of c){let{regex:o,paramNames:l,catchAll:e}=g(r.path),s=n.match(o);if(s){let i={},u=!1;if(l.forEach((p,d)=>{let f=x(s[d+1],p===e);if(f===null){u=!0;return}i[p]=f}),u)continue;return{route:r,params:i}}}return null}function A(n){let t=Object.create(null);if(!n)return t;let c=n.startsWith("?")?n.slice(1):n;for(let r of c.split("&")){let[o,l]=r.split("=");if(!o)continue;let e=decodeURIComponent(o),s=l?decodeURIComponent(l):"";Object.prototype.hasOwnProperty.call(t,e)?Array.isArray(t[e])?t[e].push(s):t[e]=[t[e],s]:t[e]=s}return t}export{h as compilePath,y as matchRoute,A as parseQuery};
|
package/index.d.ts
CHANGED
|
@@ -49,7 +49,7 @@ export interface RouteConfig {
|
|
|
49
49
|
/** Loading component */
|
|
50
50
|
loading?: Component<{}>;
|
|
51
51
|
/** Error component */
|
|
52
|
-
error?: Component<{ error: Error }>;
|
|
52
|
+
error?: Component<{ error: Error; reset: () => void }>;
|
|
53
53
|
/** Route middleware */
|
|
54
54
|
middleware?: RouteMiddleware[];
|
|
55
55
|
}
|
|
@@ -78,6 +78,25 @@ export interface RouterProps {
|
|
|
78
78
|
|
|
79
79
|
export function Router(props: RouterProps): VNode;
|
|
80
80
|
|
|
81
|
+
// --- File-Based Router ---
|
|
82
|
+
|
|
83
|
+
export interface FileRouteConfig {
|
|
84
|
+
path: string;
|
|
85
|
+
component: Component<RouteComponentProps>;
|
|
86
|
+
layout?: Component<LayoutProps>;
|
|
87
|
+
mode?: 'static' | 'server' | 'client' | 'hybrid';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface FileRouterProps {
|
|
91
|
+
routes: FileRouteConfig[];
|
|
92
|
+
layout?: Component<{ children?: VNodeChild }>;
|
|
93
|
+
fallback?: Component<{}>;
|
|
94
|
+
error?: Component<{ error: Error; reset: () => void }>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Router driven by what-compiler's generated route manifest (virtual:what-routes). */
|
|
98
|
+
export function FileRouter(props: FileRouterProps): VNode;
|
|
99
|
+
|
|
81
100
|
// --- Link Component ---
|
|
82
101
|
|
|
83
102
|
export interface LinkProps {
|
|
@@ -160,6 +179,82 @@ export interface UseRouteResult {
|
|
|
160
179
|
|
|
161
180
|
export function useRoute(): UseRouteResult;
|
|
162
181
|
|
|
182
|
+
// --- Route Accessors ---
|
|
183
|
+
|
|
184
|
+
/** Current route params. Subscribes when read inside a tracking scope. */
|
|
185
|
+
export function useParams<T = Record<string, string>>(): T;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Query string of the last successfully matched route, parsed. Subscribes when
|
|
189
|
+
* read inside a tracking scope. Only the Router's match branch writes it, so on
|
|
190
|
+
* an unmatched (404) route this is the previous route's query, not the current
|
|
191
|
+
* URL's. Same value and same caveat as `route.query`.
|
|
192
|
+
*/
|
|
193
|
+
export function useSearch<T = Record<string, string>>(): T;
|
|
194
|
+
|
|
195
|
+
/** The navigate function, for symmetry with useParams/useSearch. */
|
|
196
|
+
export function useNavigate(): typeof navigate;
|
|
197
|
+
|
|
198
|
+
/** Prefetch a route's assets. */
|
|
199
|
+
export function prefetchRoute(href: string): void;
|
|
200
|
+
|
|
201
|
+
// --- Redirect Signal ---
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Abort the current render and navigate.
|
|
205
|
+
*
|
|
206
|
+
* Throws a navigation signal. Two places catch it: route middleware, caught by
|
|
207
|
+
* the Router's matching pass, and a component body, caught by the runtime where
|
|
208
|
+
* it instantiates components. Anywhere else (an event handler, a promise
|
|
209
|
+
* callback, a timer, or a reactive thunk such as `{() => cond() && redirect(to)}`)
|
|
210
|
+
* nothing catches it and the signal surfaces as an uncaught error carrying
|
|
211
|
+
* `ERR_REDIRECT_NOT_CAUGHT`; call `navigate(to)` there instead. In a thunk the
|
|
212
|
+
* first render reports the error, but on a later re-run the navigation simply
|
|
213
|
+
* does not happen and the stale DOM stays, so prefer `navigate(to)` there.
|
|
214
|
+
* A `try/catch` around the call also swallows it, so rethrow anything whose
|
|
215
|
+
* `name` is `RouterRedirect`. On the server the signal escapes `renderToString`
|
|
216
|
+
* to its caller: read `.to` and emit a 302.
|
|
217
|
+
*/
|
|
218
|
+
export function redirect(to: string, options?: NavigateOptions): never;
|
|
219
|
+
|
|
220
|
+
// --- Navigation Hooks ---
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Run before every route navigation; return false to cancel. Returns an
|
|
224
|
+
* unsubscribe. Not consulted for same-page hash navigation (`navigate('#x')`
|
|
225
|
+
* scrolls, it does not change the route). Cancelling a back/forward navigation
|
|
226
|
+
* restores the address bar by pushing the previous URL as a new history entry:
|
|
227
|
+
* the entry the browser moved to is not recovered and its `history.state` is
|
|
228
|
+
* not carried over.
|
|
229
|
+
*/
|
|
230
|
+
export function beforeNavigate(fn: (to: string, from: string) => boolean | Promise<boolean>): () => void;
|
|
231
|
+
|
|
232
|
+
/** Run after every committed navigation. Returns an unsubscribe. */
|
|
233
|
+
export function afterNavigate(fn: (to: string, from: string) => void): () => void;
|
|
234
|
+
|
|
163
235
|
// --- Outlet ---
|
|
164
236
|
|
|
165
|
-
export function Outlet(props: { children?: VNodeChild }):
|
|
237
|
+
export function Outlet(props: { children?: VNodeChild }): VNode;
|
|
238
|
+
|
|
239
|
+
// --- Path Matching ---
|
|
240
|
+
|
|
241
|
+
export interface CompiledPath {
|
|
242
|
+
regex: RegExp;
|
|
243
|
+
paramNames: string[];
|
|
244
|
+
catchAll: string | null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Compile a path pattern (`/users/:id`, `/posts/*`, `/[slug]`) to a matcher. */
|
|
248
|
+
export function compilePath(path: string): CompiledPath;
|
|
249
|
+
|
|
250
|
+
/** Match a pathname against routes, most specific first. */
|
|
251
|
+
export function matchRoute<T extends { path?: string }>(
|
|
252
|
+
path: string,
|
|
253
|
+
routes: T[],
|
|
254
|
+
): { route: T; params: Record<string, string> } | null;
|
|
255
|
+
|
|
256
|
+
/** Parse a query string into a null-prototype object. */
|
|
257
|
+
export function parseQuery(search: string): Record<string, string>;
|
|
258
|
+
|
|
259
|
+
/** Reject javascript:, data:, vbscript: and protocol-relative URLs. */
|
|
260
|
+
export function isSafeUrl(url: string): boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "what-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "What Framework - File-based & programmatic router with View Transitions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
],
|
|
34
34
|
"author": "ZVN DEV (https://zvndev.com)",
|
|
35
35
|
"license": "MIT",
|
|
36
|
+
"dependencies": {},
|
|
36
37
|
"peerDependencies": {
|
|
37
|
-
"what-core": "^0.
|
|
38
|
+
"what-core": "^0.12.0"
|
|
38
39
|
},
|
|
39
40
|
"repository": {
|
|
40
41
|
"type": "git",
|
package/src/index.js
CHANGED
|
@@ -6,7 +6,20 @@ import { signal, effect, computed, batch, h, ErrorBoundary } from 'what-core';
|
|
|
6
6
|
import { compilePath, matchRoute, parseQuery } from './match.js';
|
|
7
7
|
|
|
8
8
|
// --- URL Sanitization ---
|
|
9
|
-
// Rejects javascript:, data:, vbscript: protocols (case-insensitive, trimmed)
|
|
9
|
+
// Rejects javascript:, data:, vbscript: protocols (case-insensitive, trimmed),
|
|
10
|
+
// any scheme outside the allowlist (blob:, about:, filesystem: ...), and
|
|
11
|
+
// protocol-relative / backslash-smuggled paths that resolve to a foreign
|
|
12
|
+
// origin. Browsers treat "\" like "/", so "/\evil.com" is an open redirect.
|
|
13
|
+
//
|
|
14
|
+
// Sibling predicate: safeLocalPath / safeRedirectTarget in
|
|
15
|
+
// packages/server/src/action-handler.js. That one gates a server-issued
|
|
16
|
+
// `Location:` header and must stay strictly narrower (same-origin local paths
|
|
17
|
+
// only), because a form POST target is attacker-controllable in a way a client
|
|
18
|
+
// navigation target is not. This one deliberately allows absolute http(s),
|
|
19
|
+
// mailto: and tel:. Harden one, re-read the other; do not unify them.
|
|
20
|
+
// packages/server/test/redirect-predicate-parity.test.js gates that ordering.
|
|
21
|
+
|
|
22
|
+
const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
|
|
10
23
|
|
|
11
24
|
export function isSafeUrl(url) {
|
|
12
25
|
if (typeof url !== 'string') return false;
|
|
@@ -16,6 +29,13 @@ export function isSafeUrl(url) {
|
|
|
16
29
|
if (normalized.startsWith('javascript:')) return false;
|
|
17
30
|
if (normalized.startsWith('data:')) return false;
|
|
18
31
|
if (normalized.startsWith('vbscript:')) return false;
|
|
32
|
+
if (/^[/\\]{2}/.test(normalized)) return false;
|
|
33
|
+
const scheme = normalized.match(/^([a-z][a-z0-9+.-]*:)/);
|
|
34
|
+
if (scheme) return SAFE_PROTOCOLS.has(scheme[1]);
|
|
35
|
+
// Scheme-less only: browsers normalize a backslash to a forward slash, so
|
|
36
|
+
// `\evil.com` resolves off-origin exactly as `/\evil.com` does. Absolute
|
|
37
|
+
// URLs already passed the protocol allowlist above.
|
|
38
|
+
if (normalized.includes('\\')) return false;
|
|
19
39
|
return true;
|
|
20
40
|
}
|
|
21
41
|
|
|
@@ -25,6 +45,12 @@ const _url = signal(typeof location !== 'undefined' ? location.pathname + locati
|
|
|
25
45
|
const _params = signal({});
|
|
26
46
|
const _query = signal({});
|
|
27
47
|
const _isNavigating = signal(false);
|
|
48
|
+
// The URL a navigation is heading TO, live for the duration of the navigation.
|
|
49
|
+
// _url only commits at the very end, so during an awaited guard the router still
|
|
50
|
+
// matches the route being LEFT. Without this, a destination route's `loading:`
|
|
51
|
+
// component could never be shown: the only `loading:` in scope belonged to the
|
|
52
|
+
// page the user was leaving.
|
|
53
|
+
const _pendingUrl = signal(null);
|
|
28
54
|
const _navigationError = signal(null);
|
|
29
55
|
|
|
30
56
|
export const route = {
|
|
@@ -40,15 +66,49 @@ export const route = {
|
|
|
40
66
|
get error() { return _navigationError(); },
|
|
41
67
|
};
|
|
42
68
|
|
|
69
|
+
// --- Navigation Hooks ---
|
|
70
|
+
// Subscriber lists consulted by navigate(). Guards run before the URL changes
|
|
71
|
+
// and can cancel by returning false; afterNavigate runs once the URL committed.
|
|
72
|
+
// Module singletons on purpose: this module is browser-only (it listens for
|
|
73
|
+
// popstate and mutates history), so its scope is one tab, not one request. The
|
|
74
|
+
// server adapters import what-router/match, never this file, so there is no
|
|
75
|
+
// shared-process path here of the kind b066671 fixed for server actions.
|
|
76
|
+
|
|
77
|
+
const _beforeHooks = [];
|
|
78
|
+
const _afterHooks = [];
|
|
79
|
+
|
|
80
|
+
function subscribe(list, fn) {
|
|
81
|
+
list.push(fn);
|
|
82
|
+
return () => {
|
|
83
|
+
const i = list.indexOf(fn);
|
|
84
|
+
if (i !== -1) list.splice(i, 1);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function beforeNavigate(fn) {
|
|
89
|
+
return subscribe(_beforeHooks, fn);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function afterNavigate(fn) {
|
|
93
|
+
return subscribe(_afterHooks, fn);
|
|
94
|
+
}
|
|
95
|
+
|
|
43
96
|
// --- Navigation with View Transitions ---
|
|
44
97
|
|
|
45
98
|
export async function navigate(to, opts = {}) {
|
|
46
|
-
const { replace = false, state = null, transition = true, _fromPopstate = false } = opts;
|
|
99
|
+
const { replace = false, state = null, transition = true, _fromPopstate = false, _redirectChain = false } = opts;
|
|
100
|
+
|
|
101
|
+
// A navigation the user asked for starts a fresh redirect chain; a hop
|
|
102
|
+
// queued by handleRedirect continues the current one.
|
|
103
|
+
if (!_redirectChain) _redirectHistory.length = 0;
|
|
47
104
|
|
|
48
105
|
// Reject unsafe URLs
|
|
49
106
|
if (!isSafeUrl(to)) {
|
|
50
107
|
if (typeof console !== 'undefined') {
|
|
51
|
-
|
|
108
|
+
// isSafeUrl() rejects non-strings, so the value reaching this warning is
|
|
109
|
+
// exactly the kind that can throw on template-literal coercion (Symbol,
|
|
110
|
+
// null-prototype object). Log it as a separate argument.
|
|
111
|
+
console.warn('[what-router] Blocked navigation to unsafe URL:', to);
|
|
52
112
|
}
|
|
53
113
|
return;
|
|
54
114
|
}
|
|
@@ -68,10 +128,39 @@ export async function navigate(to, opts = {}) {
|
|
|
68
128
|
// Don't navigate if already on the same URL
|
|
69
129
|
if (to === _url()) return;
|
|
70
130
|
|
|
71
|
-
// Prevent concurrent navigations
|
|
131
|
+
// Prevent concurrent navigations: wait for the current one to finish. The
|
|
132
|
+
// flag is claimed in the same tick as the check, because an awaited
|
|
133
|
+
// beforeNavigate hook otherwise leaves a gap two navigations both get through.
|
|
72
134
|
if (_isNavigating.peek()) return;
|
|
73
|
-
|
|
74
135
|
_isNavigating.set(true);
|
|
136
|
+
_pendingUrl.set(to);
|
|
137
|
+
|
|
138
|
+
const from = _url();
|
|
139
|
+
|
|
140
|
+
// A popstate has already moved the browser URL, so cancelling one means
|
|
141
|
+
// pushing the previous entry back to keep the address bar in sync. That entry
|
|
142
|
+
// is a new one: the forward entry is not recoverable and its history.state is
|
|
143
|
+
// not carried over. Documented on beforeNavigate in index.d.ts.
|
|
144
|
+
if (_beforeHooks.length) {
|
|
145
|
+
let cancelled = false;
|
|
146
|
+
try {
|
|
147
|
+
for (const fn of _beforeHooks.slice()) {
|
|
148
|
+
if ((await fn(to, from)) === false) { cancelled = true; break; }
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
151
|
+
// A throwing guard must not leave the router permanently wedged.
|
|
152
|
+
_isNavigating.set(false);
|
|
153
|
+
_pendingUrl.set(null);
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
156
|
+
if (cancelled) {
|
|
157
|
+
_isNavigating.set(false);
|
|
158
|
+
_pendingUrl.set(null);
|
|
159
|
+
if (_fromPopstate && typeof history !== 'undefined') history.pushState(null, '', from);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
75
164
|
_navigationError.set(null);
|
|
76
165
|
|
|
77
166
|
const doNavigation = () => {
|
|
@@ -79,7 +168,7 @@ export async function navigate(to, opts = {}) {
|
|
|
79
168
|
if (!_fromPopstate) {
|
|
80
169
|
// Save scroll position for current URL before navigating away
|
|
81
170
|
if (typeof window !== 'undefined') {
|
|
82
|
-
scrollPositions.set(_url(), { x: scrollX, y: scrollY });
|
|
171
|
+
scrollPositions.set(_url(), { x: window.scrollX, y: window.scrollY });
|
|
83
172
|
}
|
|
84
173
|
if (replace) {
|
|
85
174
|
history.replaceState(state, '', to);
|
|
@@ -89,6 +178,7 @@ export async function navigate(to, opts = {}) {
|
|
|
89
178
|
}
|
|
90
179
|
_url.set(to);
|
|
91
180
|
_isNavigating.set(false);
|
|
181
|
+
_pendingUrl.set(null);
|
|
92
182
|
};
|
|
93
183
|
|
|
94
184
|
// Use View Transitions API if available and enabled
|
|
@@ -101,13 +191,71 @@ export async function navigate(to, opts = {}) {
|
|
|
101
191
|
} else {
|
|
102
192
|
doNavigation();
|
|
103
193
|
}
|
|
194
|
+
|
|
195
|
+
if (_afterHooks.length) {
|
|
196
|
+
for (const fn of _afterHooks.slice()) fn(to, from);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- redirect() ---
|
|
201
|
+
// Throws a navigation signal. Two places catch it:
|
|
202
|
+
//
|
|
203
|
+
// - Route middleware, caught by the Router's own matching pass below.
|
|
204
|
+
// - A component body, caught by core's createComponent, which invokes the
|
|
205
|
+
// handler the signal carries under Symbol.for('what.navigation.signal')
|
|
206
|
+
// rather than reporting the throw to an ErrorBoundary. h() is lazy, so a
|
|
207
|
+
// route component is instantiated after the matching pass has returned;
|
|
208
|
+
// that is why the second catch has to live in core.
|
|
209
|
+
//
|
|
210
|
+
// Anywhere else (an event handler, a promise callback, a timer) nothing catches
|
|
211
|
+
// it and the signal surfaces as an uncaught error. It carries a code, a fix and
|
|
212
|
+
// an example for exactly that case, because that is the only case a human reads
|
|
213
|
+
// it in.
|
|
214
|
+
|
|
215
|
+
const REDIRECT = Symbol.for('what.router.redirect');
|
|
216
|
+
const NAV_SIGNAL = Symbol.for('what.navigation.signal');
|
|
217
|
+
|
|
218
|
+
export function redirect(to, options = {}) {
|
|
219
|
+
if (!isSafeUrl(to)) {
|
|
220
|
+
const target = typeof to === 'string' ? to : Object.prototype.toString.call(to);
|
|
221
|
+
const err = new Error(`[what-router] redirect() refused an unsafe target: ${target}`);
|
|
222
|
+
err.code = 'ERR_UNSAFE_REDIRECT';
|
|
223
|
+
err.suggestion = 'redirect() accepts same-origin paths and http:, https:, mailto: or tel: URLs only. Protocol-relative ("//host"), backslash-smuggled and javascript:/data: targets are open-redirect vectors. Check a user-supplied target against an allowlist first.';
|
|
224
|
+
err.codeExample = `// Bad - a user-controlled target can leave your origin:
|
|
225
|
+
redirect(query.next);
|
|
226
|
+
|
|
227
|
+
// Good - allowlist the target first:
|
|
228
|
+
redirect(ALLOWED.has(query.next) ? query.next : '/');`;
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const sig = new Error(`[what-router] redirect to ${to}`);
|
|
233
|
+
sig.name = 'RouterRedirect';
|
|
234
|
+
sig.code = 'ERR_REDIRECT_NOT_CAUGHT';
|
|
235
|
+
sig.suggestion = 'Seeing this signal in your console means nothing caught it. redirect() works from route middleware and from a component body, where the Router catches it. From an event handler, a promise callback or a timer, call navigate(to) instead. A try/catch around the redirect() call also swallows it.';
|
|
236
|
+
sig.codeExample = `// Bad - an event handler runs long after the render the Router caught:
|
|
237
|
+
<button onclick={() => redirect('/login')}>Sign in</button>
|
|
238
|
+
|
|
239
|
+
// Good - navigate() from a handler:
|
|
240
|
+
<button onclick={() => navigate('/login')}>Sign in</button>
|
|
241
|
+
|
|
242
|
+
// Good - redirect() from a component body, which the Router catches:
|
|
243
|
+
function Private() {
|
|
244
|
+
if (!user()) redirect('/login');
|
|
245
|
+
return <Secret />;
|
|
246
|
+
}`;
|
|
247
|
+
sig[REDIRECT] = true;
|
|
248
|
+
sig[NAV_SIGNAL] = () => { handleRedirect(to, options); };
|
|
249
|
+
sig.to = to;
|
|
250
|
+
sig.options = options;
|
|
251
|
+
throw sig;
|
|
104
252
|
}
|
|
105
253
|
|
|
106
254
|
// Back/forward support — route through navigate() so middleware runs
|
|
107
255
|
if (typeof window !== 'undefined') {
|
|
108
256
|
window.addEventListener('popstate', () => {
|
|
109
257
|
// Save scroll position for the URL we're leaving
|
|
110
|
-
scrollPositions.set(_url(), { x: scrollX, y: scrollY });
|
|
258
|
+
scrollPositions.set(_url(), { x: window.scrollX, y: window.scrollY });
|
|
111
259
|
|
|
112
260
|
const newUrl = location.pathname + location.search + location.hash;
|
|
113
261
|
// Use _fromPopstate flag so navigate() skips pushState (browser already updated URL)
|
|
@@ -164,14 +312,71 @@ function buildLayoutChain(route, routes) {
|
|
|
164
312
|
const _redirectHistory = [];
|
|
165
313
|
const MAX_REDIRECTS = 10;
|
|
166
314
|
|
|
315
|
+
function loopScreen(message) {
|
|
316
|
+
return h('div', { class: 'what-redirect-loop' },
|
|
317
|
+
h('h1', null, 'Redirect Loop'),
|
|
318
|
+
h('p', null, message)
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Shared by the middleware string form and by a thrown redirect() signal.
|
|
323
|
+
// Returns null once the navigation is queued, or the loop screen if the
|
|
324
|
+
// redirect chain is cycling.
|
|
325
|
+
//
|
|
326
|
+
// The chain is scoped to one user navigation: navigate() clears the history
|
|
327
|
+
// unless it is being called from here. Clearing it on a successful match
|
|
328
|
+
// instead would never catch a cycle between two route components, because each
|
|
329
|
+
// hop matches successfully before its component throws the next redirect.
|
|
330
|
+
function handleRedirect(target, options) {
|
|
331
|
+
_redirectHistory.push(target);
|
|
332
|
+
|
|
333
|
+
if (_redirectHistory.length > MAX_REDIRECTS) {
|
|
334
|
+
const cycle = _redirectHistory.slice(-5).join(' → ');
|
|
335
|
+
_redirectHistory.length = 0;
|
|
336
|
+
console.error(`[what-router] Redirect loop detected: ${cycle}`);
|
|
337
|
+
_isNavigating.set(false);
|
|
338
|
+
_pendingUrl.set(null);
|
|
339
|
+
return loopScreen('Too many redirects. Check your middleware configuration.');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const seen = new Set();
|
|
343
|
+
let hasCycle = false;
|
|
344
|
+
for (const url of _redirectHistory) {
|
|
345
|
+
if (seen.has(url)) { hasCycle = true; break; }
|
|
346
|
+
seen.add(url);
|
|
347
|
+
}
|
|
348
|
+
if (hasCycle) {
|
|
349
|
+
const cycle = _redirectHistory.join(' → ');
|
|
350
|
+
_redirectHistory.length = 0;
|
|
351
|
+
console.error(`[what-router] Redirect cycle detected: ${cycle}`);
|
|
352
|
+
_isNavigating.set(false);
|
|
353
|
+
_pendingUrl.set(null);
|
|
354
|
+
return loopScreen('Circular redirect detected. Check your middleware configuration.');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
navigate(target, { replace: true, ...options, _redirectChain: true });
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
167
361
|
// --- Router Component ---
|
|
168
362
|
|
|
363
|
+
// The `loading:` component of the route a navigation is heading to, or null when
|
|
364
|
+
// there is no pending destination, it does not match, it declares no loading
|
|
365
|
+
// component, or it is the route already being rendered.
|
|
366
|
+
function destinationLoading(pendingUrl, routes, currentRoute) {
|
|
367
|
+
if (!pendingUrl) return null;
|
|
368
|
+
const destPath = pendingUrl.split('?')[0].split('#')[0];
|
|
369
|
+
const dest = matchRoute(destPath, routes);
|
|
370
|
+
if (!dest || dest.route === currentRoute) return null;
|
|
371
|
+
return dest.route.loading || null;
|
|
372
|
+
}
|
|
373
|
+
|
|
169
374
|
export function Router({ routes, fallback, globalLayout }) {
|
|
170
375
|
// The Router component runs ONCE. `content` is a reactive function child that
|
|
171
376
|
// re-evaluates whenever _url changes; the fine-grained runtime reconciles only
|
|
172
377
|
// the matched page in place. The globalLayout is rendered ONCE around it (below)
|
|
173
378
|
// so the app shell persists across navigations instead of re-instantiating.
|
|
174
|
-
const
|
|
379
|
+
const renderMatch = () => {
|
|
175
380
|
const currentUrl = _url();
|
|
176
381
|
const path = currentUrl.split('?')[0].split('#')[0];
|
|
177
382
|
const search = currentUrl.split('?')[1]?.split('#')[0] || '';
|
|
@@ -198,48 +403,26 @@ export function Router({ routes, fallback, globalLayout }) {
|
|
|
198
403
|
return h('div', { class: 'what-403' }, h('h1', null, '403'), h('p', null, 'Access denied'));
|
|
199
404
|
}
|
|
200
405
|
if (typeof result === 'string') {
|
|
201
|
-
// Redirect loop detection
|
|
202
|
-
_redirectHistory.push(result);
|
|
203
|
-
if (_redirectHistory.length > MAX_REDIRECTS) {
|
|
204
|
-
const cycle = _redirectHistory.slice(-5).join(' → ');
|
|
205
|
-
_redirectHistory.length = 0;
|
|
206
|
-
console.error(`[what-router] Redirect loop detected: ${cycle}`);
|
|
207
|
-
_isNavigating.set(false);
|
|
208
|
-
return h('div', { class: 'what-redirect-loop' },
|
|
209
|
-
h('h1', null, 'Redirect Loop'),
|
|
210
|
-
h('p', null, 'Too many redirects. Check your middleware configuration.')
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
// Check for direct cycle (A → B → A)
|
|
214
|
-
const seen = new Set();
|
|
215
|
-
let hasCycle = false;
|
|
216
|
-
for (const url of _redirectHistory) {
|
|
217
|
-
if (seen.has(url)) { hasCycle = true; break; }
|
|
218
|
-
seen.add(url);
|
|
219
|
-
}
|
|
220
|
-
if (hasCycle) {
|
|
221
|
-
const cycle = _redirectHistory.join(' → ');
|
|
222
|
-
_redirectHistory.length = 0;
|
|
223
|
-
console.error(`[what-router] Redirect cycle detected: ${cycle}`);
|
|
224
|
-
_isNavigating.set(false);
|
|
225
|
-
return h('div', { class: 'what-redirect-loop' },
|
|
226
|
-
h('h1', null, 'Redirect Loop'),
|
|
227
|
-
h('p', null, 'Circular redirect detected. Check your middleware configuration.')
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
406
|
// Middleware returned a redirect path
|
|
231
|
-
|
|
232
|
-
return null;
|
|
407
|
+
return handleRedirect(result);
|
|
233
408
|
}
|
|
234
409
|
}
|
|
235
410
|
}
|
|
236
|
-
// Successful render — clear redirect history
|
|
237
|
-
_redirectHistory.length = 0;
|
|
238
411
|
|
|
239
412
|
// Build element with loading state support
|
|
240
413
|
let element;
|
|
241
414
|
|
|
242
|
-
|
|
415
|
+
// While a navigation is in flight, a `loading:` declared by the
|
|
416
|
+
// DESTINATION wins. _url has not committed yet, so `r` is still the route
|
|
417
|
+
// being left: without this, declaring `loading:` on the page you are
|
|
418
|
+
// navigating TO could never show it. Falling back to the departing route's
|
|
419
|
+
// `loading:` preserves the existing behaviour for the case where only the
|
|
420
|
+
// page being left declares one.
|
|
421
|
+
const pendingLoading = isNavigating ? destinationLoading(_pendingUrl(), routes, r) : null;
|
|
422
|
+
|
|
423
|
+
if (pendingLoading) {
|
|
424
|
+
element = h(pendingLoading, {});
|
|
425
|
+
} else if (r.loading && isNavigating) {
|
|
243
426
|
element = h(r.loading, {});
|
|
244
427
|
} else {
|
|
245
428
|
element = h(r.component, {
|
|
@@ -271,6 +454,21 @@ export function Router({ routes, fallback, globalLayout }) {
|
|
|
271
454
|
);
|
|
272
455
|
};
|
|
273
456
|
|
|
457
|
+
// Catches a redirect() thrown by route middleware. A redirect() thrown by a
|
|
458
|
+
// route component is caught by core instead, because h() is lazy and the
|
|
459
|
+
// component runs after renderMatch has returned. Anything unbranded
|
|
460
|
+
// propagates to the app's ErrorBoundary unchanged.
|
|
461
|
+
const content = () => {
|
|
462
|
+
let sig = null;
|
|
463
|
+
try {
|
|
464
|
+
return renderMatch();
|
|
465
|
+
} catch (e) {
|
|
466
|
+
if (!e || !e[REDIRECT]) throw e;
|
|
467
|
+
sig = e;
|
|
468
|
+
}
|
|
469
|
+
return handleRedirect(sig.to, sig.options);
|
|
470
|
+
};
|
|
471
|
+
|
|
274
472
|
// Render the global layout ONCE so it — and everything it mounts (sidebars,
|
|
275
473
|
// toasters, command palettes, global key listeners) — PERSISTS across
|
|
276
474
|
// navigations; the reactive `content` child swaps the matched page in place.
|
|
@@ -299,7 +497,7 @@ export function Link({
|
|
|
299
497
|
// Sanitize href — reject dangerous protocols
|
|
300
498
|
const safeHref = isSafeUrl(href) ? href : 'about:blank';
|
|
301
499
|
if (!isSafeUrl(href) && typeof console !== 'undefined') {
|
|
302
|
-
console.warn(
|
|
500
|
+
console.warn('[what-router] Link blocked unsafe href:', href);
|
|
303
501
|
}
|
|
304
502
|
|
|
305
503
|
// Strip query string and hash from href for path comparison
|
|
@@ -356,12 +554,23 @@ export function defineRoutes(config) {
|
|
|
356
554
|
|
|
357
555
|
// --- Nested Route Helper ---
|
|
358
556
|
|
|
557
|
+
// Join a base path with a child path. Naive concatenation turned the index
|
|
558
|
+
// child of `nestedRoutes('/dashboard', [{ path: '/' }, ...])` — the form the
|
|
559
|
+
// README documents — into '/dashboard/', which '/dashboard' does not match, so
|
|
560
|
+
// the documented example 404s on its own index route.
|
|
561
|
+
function joinRoutePath(basePath, childPath) {
|
|
562
|
+
const base = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
|
|
563
|
+
const child = !childPath || childPath === '/' ? '/' : (childPath.startsWith('/') ? childPath : `/${childPath}`);
|
|
564
|
+
if (child === '/') return base || '/';
|
|
565
|
+
return base + child;
|
|
566
|
+
}
|
|
567
|
+
|
|
359
568
|
export function nestedRoutes(basePath, children, options = {}) {
|
|
360
569
|
const { layout, loading, error } = options;
|
|
361
570
|
|
|
362
571
|
return children.map(child => ({
|
|
363
572
|
...child,
|
|
364
|
-
path: basePath
|
|
573
|
+
path: joinRoutePath(basePath, child.path),
|
|
365
574
|
layout: child.layout || layout,
|
|
366
575
|
loading: child.loading || loading,
|
|
367
576
|
error: child.error || error,
|
|
@@ -535,6 +744,29 @@ export function useRoute() {
|
|
|
535
744
|
};
|
|
536
745
|
}
|
|
537
746
|
|
|
747
|
+
// --- Route Accessors ---
|
|
748
|
+
// Read the singleton route state directly. Called inside a tracking scope
|
|
749
|
+
// (a reactive text binding, computed or effect) they subscribe to it.
|
|
750
|
+
|
|
751
|
+
export function useParams() {
|
|
752
|
+
return route.params;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Same singleton `_query` signal as route.query: only the Router's match branch
|
|
756
|
+
// writes it, so on an unmatched (404) route this is the last matched route's
|
|
757
|
+
// query, not the current URL's. Documented on both declarations.
|
|
758
|
+
export function useSearch() {
|
|
759
|
+
return route.query;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export function useNavigate() {
|
|
763
|
+
return navigate;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export function prefetchRoute(href) {
|
|
767
|
+
prefetch(href);
|
|
768
|
+
}
|
|
769
|
+
|
|
538
770
|
// --- Outlet Component ---
|
|
539
771
|
// For nested route rendering
|
|
540
772
|
|
|
@@ -555,11 +787,16 @@ export function FileRouter({
|
|
|
555
787
|
fallback,
|
|
556
788
|
error: globalError,
|
|
557
789
|
}) {
|
|
558
|
-
// Convert file-router route format to Router's expected format
|
|
790
|
+
// Convert file-router route format to Router's expected format.
|
|
791
|
+
// loading/error used to be dropped here, so a route could declare either and
|
|
792
|
+
// never see it rendered, and the `error` prop was destructured and then never
|
|
793
|
+
// read: a documented public prop that silently did nothing.
|
|
559
794
|
const routerRoutes = routes.map(r => ({
|
|
560
795
|
path: r.path,
|
|
561
796
|
component: r.component,
|
|
562
797
|
layout: r.layout || undefined,
|
|
798
|
+
loading: r.loading || undefined,
|
|
799
|
+
error: r.error || globalError || undefined,
|
|
563
800
|
// Attach page mode as metadata for build system
|
|
564
801
|
_mode: r.mode || 'client',
|
|
565
802
|
}));
|
package/src/match.js
CHANGED
|
@@ -42,33 +42,75 @@ export function compilePath(path) {
|
|
|
42
42
|
return { regex, paramNames, catchAll };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Compiled patterns are stable per route path, so cache them instead of
|
|
46
|
+
// rebuilding a RegExp for every route on every request.
|
|
47
|
+
const patternCache = new Map();
|
|
48
|
+
const PATTERN_CACHE_MAX = 1000;
|
|
49
|
+
|
|
50
|
+
function getPattern(path) {
|
|
51
|
+
let compiled = patternCache.get(path);
|
|
52
|
+
if (!compiled) {
|
|
53
|
+
compiled = compilePath(path);
|
|
54
|
+
if (patternCache.size >= PATTERN_CACHE_MAX) patternCache.clear();
|
|
55
|
+
patternCache.set(path, compiled);
|
|
56
|
+
}
|
|
57
|
+
return compiled;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The `([^/]+)` segment guard runs against the still-encoded path, so a param
|
|
61
|
+
// only becomes multi-segment once it is decoded ("%2e%2e%2f" -> "../"). Reject
|
|
62
|
+
// any decoded value that smuggles in a separator or a traversal segment, and
|
|
63
|
+
// treat malformed percent-escapes as a non-match instead of throwing.
|
|
64
|
+
function decodeParam(value, isCatchAll) {
|
|
65
|
+
let decoded;
|
|
66
|
+
try {
|
|
67
|
+
decoded = decodeURIComponent(value);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const segments = decoded.split(/[/\\]/);
|
|
72
|
+
if (!isCatchAll && segments.length > 1) return null;
|
|
73
|
+
if (segments.includes('..')) return null;
|
|
74
|
+
return decoded;
|
|
75
|
+
}
|
|
76
|
+
|
|
45
77
|
export function matchRoute(path, routes) {
|
|
46
78
|
// Filter out routes without a path (layout-only routes, etc.)
|
|
47
|
-
|
|
79
|
+
// `filter` already copies, so sorting below never touches the caller's array.
|
|
80
|
+
const sorted = routes.filter(r => r.path);
|
|
48
81
|
|
|
49
82
|
// Sort routes by specificity (more specific first)
|
|
50
|
-
|
|
83
|
+
sorted.sort((a, b) => {
|
|
51
84
|
const aSpecific = (a.path.match(/:/g) || []).length + (a.path.includes('*') ? 100 : 0);
|
|
52
85
|
const bSpecific = (b.path.match(/:/g) || []).length + (b.path.includes('*') ? 100 : 0);
|
|
53
86
|
return aSpecific - bSpecific;
|
|
54
87
|
});
|
|
55
88
|
|
|
56
89
|
for (const route of sorted) {
|
|
57
|
-
const { regex, paramNames } =
|
|
90
|
+
const { regex, paramNames, catchAll } = getPattern(route.path);
|
|
58
91
|
const match = path.match(regex);
|
|
59
92
|
if (match) {
|
|
60
93
|
const params = {};
|
|
94
|
+
let rejected = false;
|
|
61
95
|
paramNames.forEach((name, i) => {
|
|
62
|
-
|
|
96
|
+
const decoded = decodeParam(match[i + 1], name === catchAll);
|
|
97
|
+
if (decoded === null) {
|
|
98
|
+
rejected = true;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
params[name] = decoded;
|
|
63
102
|
});
|
|
103
|
+
if (rejected) continue;
|
|
64
104
|
return { route, params };
|
|
65
105
|
}
|
|
66
106
|
}
|
|
67
107
|
return null;
|
|
68
108
|
}
|
|
69
109
|
|
|
110
|
+
// Null-prototype so query keys like "toString" or "__proto__" stay plain data
|
|
111
|
+
// instead of colliding with inherited members or replacing the prototype.
|
|
70
112
|
export function parseQuery(search) {
|
|
71
|
-
const params =
|
|
113
|
+
const params = Object.create(null);
|
|
72
114
|
if (!search) return params;
|
|
73
115
|
const qs = search.startsWith('?') ? search.slice(1) : search;
|
|
74
116
|
for (const pair of qs.split('&')) {
|
|
@@ -76,7 +118,7 @@ export function parseQuery(search) {
|
|
|
76
118
|
if (!key) continue;
|
|
77
119
|
const decodedKey = decodeURIComponent(key);
|
|
78
120
|
const decodedVal = val ? decodeURIComponent(val) : '';
|
|
79
|
-
if (decodedKey
|
|
121
|
+
if (Object.prototype.hasOwnProperty.call(params, decodedKey)) {
|
|
80
122
|
// Collect repeated keys into arrays
|
|
81
123
|
if (Array.isArray(params[decodedKey])) {
|
|
82
124
|
params[decodedKey].push(decodedVal);
|