what-router 0.11.7 → 0.11.8
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 +96 -1
- package/package.json +3 -2
- package/src/index.js +228 -40
- 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
|
|
1
|
+
import{signal as v,effect as I,computed as R,batch as H,h as c,ErrorBoundary as K}from"what-core";function U(e){let t=e.replace(/\([\w-]+\)\//g,"").replace(/\[\.\.\.(\w+)\]/g,(a,u)=>`*:${u}`).replace(/\[(\w+)\]/g,":$1"),r=[],n=null,o=t.split("/").map(a=>a.startsWith("*:")?(n=a.slice(2),r.push(n),"(.+)"):a==="*"?(n="rest",r.push("rest"),"(.+)"):a.startsWith(":")?(r.push(a.slice(1)),"([^/]+)"):a.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("/");return{regex:new RegExp(`^${o}$`),paramNames:r,catchAll:n}}var S=new Map,z=1e3;function B(e){let t=S.get(e);return t||(t=U(e),S.size>=z&&S.clear(),S.set(e,t)),t}function F(e,t){let r;try{r=decodeURIComponent(e)}catch{return null}let n=r.split(/[/\\]/);return!t&&n.length>1||n.includes("..")?null:r}function k(e,t){let r=t.filter(n=>n.path);r.sort((n,o)=>{let i=(n.path.match(/:/g)||[]).length+(n.path.includes("*")?100:0),a=(o.path.match(/:/g)||[]).length+(o.path.includes("*")?100:0);return i-a});for(let n of r){let{regex:o,paramNames:i,catchAll:a}=B(n.path),u=e.match(o);if(u){let p={},l=!1;if(i.forEach((s,d)=>{let g=F(u[d+1],s===a);if(g===null){l=!0;return}p[s]=g}),l)continue;return{route:n,params:p}}}return null}function _(e){let t=Object.create(null);if(!e)return t;let r=e.startsWith("?")?e.slice(1):e;for(let n of r.split("&")){let[o,i]=n.split("=");if(!o)continue;let a=decodeURIComponent(o),u=i?decodeURIComponent(i):"";Object.prototype.hasOwnProperty.call(t,a)?Array.isArray(t[a])?t[a].push(u):t[a]=[t[a],u]:t[a]=u}return t}var M=new Set(["http:","https:","mailto:","tel:"]);function A(e){if(typeof e!="string")return!1;let r=e.trim().replace(/[\s\x00-\x1f]/g,"").toLowerCase();if(r.startsWith("javascript:")||r.startsWith("data:")||r.startsWith("vbscript:")||/^[/\\]{2}/.test(r))return!1;let n=r.match(/^([a-z][a-z0-9+.-]*:)/);return n?M.has(n[1]):!r.includes("\\")}var h=v(typeof location<"u"?location.pathname+location.search+location.hash:"/"),$=v({}),G=v({}),y=v(!1),W=v(null),m={get url(){return h()},get path(){return h().split("?")[0].split("#")[0]},get params(){return $()},get query(){return G()},get hash(){let e=h().split("#")[1];return e?"#"+e:""},get isNavigating(){return y()},get error(){return W()}},N=[],T=[];function D(e,t){return e.push(t),()=>{let r=e.indexOf(t);r!==-1&&e.splice(r,1)}}function ae(e){return D(N,e)}function ie(e){return D(T,e)}async function x(e,t={}){let{replace:r=!1,state:n=null,transition:o=!0,_fromPopstate:i=!1,_redirectChain:a=!1}=t;if(a||(w.length=0),!A(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(n,"",d),h.set(d);let g=document.querySelector(e);g&&g.scrollIntoView({behavior:"smooth"});return}if(e===h()||y.peek())return;y.set(!0);let u=h();if(N.length){let l=!1;try{for(let s of N.slice())if(await s(e,u)===!1){l=!0;break}}catch(s){throw y.set(!1),s}if(l){y.set(!1),i&&typeof history<"u"&&history.pushState(null,"",u);return}}W.set(null);let p=()=>{i||(typeof window<"u"&&b.set(h(),{x:scrollX,y:scrollY}),r?history.replaceState(n,"",e):history.pushState(n,"",e)),h.set(e),y.set(!1)};if(o&&typeof document<"u"&&document.startViewTransition)try{await document.startViewTransition(p).finished}catch{}else p();if(T.length)for(let l of T.slice())l(e,u)}var V=Symbol.for("what.router.redirect"),X=Symbol.for("what.navigation.signal");function se(e,t={}){if(!A(e)){let n=typeof e=="string"?e:Object.prototype.toString.call(e),o=new Error(`[what-router] redirect() refused an unsafe target: ${n}`);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 r=new Error(`[what-router] redirect to ${e}`);throw r.name="RouterRedirect",r.code="ERR_REDIRECT_NOT_CAUGHT",r.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.",r.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
|
+
}`,r[V]=!0,r[X]=()=>{q(e,t)},r.to=e,r.options=t,r}typeof window<"u"&&window.addEventListener("popstate",()=>{b.set(h(),{x:scrollX,y:scrollY});let e=location.pathname+location.search+location.hash;x(e,{replace:!0,_fromPopstate:!0,transition:!1}).then(()=>{let t=b.get(e);t&&requestAnimationFrame(()=>window.scrollTo(t.x,t.y))})});function Q(e,t){let r=[];if(!e.path)return r;let n=e.path.split("/").filter(Boolean),o="";for(let i of n){o+="/"+i;let a=t.find(u=>u.layout&&u.path===o+"/_layout");a&&r.push(a.layout)}return e.layout&&r.push(e.layout),r}var w=[],Y=10;function O(e){return c("div",{class:"what-redirect-loop"},c("h1",null,"Redirect Loop"),c("p",null,e))}function q(e,t){if(w.push(e),w.length>Y){let o=w.slice(-5).join(" \u2192 ");return w.length=0,console.error(`[what-router] Redirect loop detected: ${o}`),y.set(!1),O("Too many redirects. Check your middleware configuration.")}let r=new Set,n=!1;for(let o of w){if(r.has(o)){n=!0;break}r.add(o)}if(n){let o=w.join(" \u2192 ");return w.length=0,console.error(`[what-router] Redirect cycle detected: ${o}`),y.set(!1),O("Circular redirect detected. Check your middleware configuration.")}return x(e,{replace:!0,...t,_redirectChain:!0}),null}function J({routes:e,fallback:t,globalLayout:r}){let n=()=>{let i=h(),a=i.split("?")[0].split("#")[0],u=i.split("?")[1]?.split("#")[0]||"",p=y(),l=k(a,e);if(l){H(()=>{$.set(l.params),G.set(_(u))});let{route:s,params:d}=l,g=_(u);if(s.middleware&&s.middleware.length>0)for(let E of s.middleware){let P=E({path:a,params:d,query:g,route:s});if(P===!1)return t?c(t,{}):c("div",{class:"what-403"},c("h1",null,"403"),c("p",null,"Access denied"));if(typeof P=="string")return q(P)}let f;s.loading&&p?f=c(s.loading,{}):f=c(s.component,{params:d,query:g,route:s}),s.error&&(f=c(K,{fallback:s.error},f));let C=Q(s,e);for(let E of C.reverse())f=c(E,{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 i=null;try{return n()}catch(a){if(!a||!a[V])throw a;i=a}return q(i.to,i.options)};return r?c(r,{},o):o}function Z({href:e,class:t,className:r,children:n,replace:o,prefetch:i=!0,activeClass:a="active",exactActiveClass:u="exact-active",transition:p=!0,...l}){let s=A(e)?e:"about:blank";!A(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,C=d==="/"?f==="/":f===d||f.startsWith(d+"/");return[t||r,C&&a,f===d&&u].filter(Boolean).join(" ")||void 0},onclick:f=>{f.ctrlKey||f.metaKey||f.shiftKey||f.altKey||f.button!==0||(f.preventDefault(),x(s,{replace:o,transition:p}))},onmouseenter:i?()=>L(s):void 0,...l},...Array.isArray(n)?n:[n])}function ce(e){return Z(e)}function ue(e){return Object.entries(e).map(([t,r])=>typeof r=="function"?{path:t,component:r}:{path:t,...r})}function le(e,t,r={}){let{layout:n,loading:o,error:i}=r;return t.map(a=>({...a,path:e+a.path,layout:a.layout||n,loading:a.loading||o,error:a.error||i}))}function fe(e,t,r={}){let{layout:n,middleware:o}=r;return t.map(i=>({...i,_group:e,layout:i.layout||n,middleware:[...i.middleware||[],...o||[]]}))}function de({to:e}){return x(e,{replace:!0}),null}function pe(e,t){return r=>function(o){let i=e(o);return i instanceof Promise?c("div",{class:"what-guard-loading"},"Loading..."):i?c(r,o):typeof t=="string"?(x(t,{replace:!0}),null):c(t,o)}}function he(e,t={}){let{fallback:r="/login",loading:n=null}=t;return o=>function(a){let u=v("pending"),p=v(null),l=!1;return I(()=>(l=!1,Promise.resolve(e(a)).then(s=>{l||(p.set(s),u.set(s?"allowed":"denied"))}).catch(()=>{l||u.set("denied")}),()=>{l=!0})),()=>{let s=u();return s==="pending"?n?c(n,{}):null:s==="allowed"?c(o,a):typeof r=="string"?(x(r,{replace:!0}),null):c(r,a)}}}var j=new Set;function L(e){if(typeof document>"u"||j.has(e))return;j.add(e);let t=document.createElement("link");t.rel="prefetch",t.href=e,document.head.appendChild(t)}var b=new Map;function me(){typeof window>"u"||(window.addEventListener("beforeunload",()=>{b.set(location.pathname,window.scrollY)}),I(()=>{let e=m.path,t=b.get(e);requestAnimationFrame(()=>{t!==void 0?window.scrollTo(0,t):m.hash?document.querySelector(m.hash)?.scrollIntoView():window.scrollTo(0,0)})}))}function ge(e){return{style:{viewTransitionName:e}}}function ye(e){typeof document>"u"||(document.documentElement.dataset.transition=e)}function we(){return{path:R(()=>m.path),params:R(()=>m.params),query:R(()=>m.query),hash:R(()=>m.hash),isNavigating:R(()=>m.isNavigating),navigate:x,prefetch:L}}function xe(){return m.params}function ve(){return m.query}function Re(){return x}function be(e){L(e)}function Ee({children:e}){return e||null}function Se({routes:e,layout:t,fallback:r,error:n}){let o=e.map(i=>({path:i.path,component:i.component,layout:i.layout||void 0,_mode:i.mode||"client"}));return J({routes:o,globalLayout:t,fallback:r||ee})}function ee(){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{Se as FileRouter,Z as Link,ce as NavLink,Ee as Outlet,de as Redirect,J as Router,ie as afterNavigate,he as asyncGuard,ae as beforeNavigate,U as compilePath,ue as defineRoutes,me as enableScrollRestoration,pe as guard,A as isSafeUrl,k as matchRoute,x as navigate,le as nestedRoutes,_ as parseQuery,L as prefetch,be as prefetchRoute,se as redirect,m as route,fe as routeGroup,ye as setViewTransition,Re as useNavigate,xe as useParams,we as useRoute,ve as useSearch,ge 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
|
@@ -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 }>;
|
|
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.11.
|
|
3
|
+
"version": "0.11.8",
|
|
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.11.
|
|
38
|
+
"what-core": "^0.11.8"
|
|
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
|
|
|
@@ -40,15 +60,49 @@ export const route = {
|
|
|
40
60
|
get error() { return _navigationError(); },
|
|
41
61
|
};
|
|
42
62
|
|
|
63
|
+
// --- Navigation Hooks ---
|
|
64
|
+
// Subscriber lists consulted by navigate(). Guards run before the URL changes
|
|
65
|
+
// and can cancel by returning false; afterNavigate runs once the URL committed.
|
|
66
|
+
// Module singletons on purpose: this module is browser-only (it listens for
|
|
67
|
+
// popstate and mutates history), so its scope is one tab, not one request. The
|
|
68
|
+
// server adapters import what-router/match, never this file, so there is no
|
|
69
|
+
// shared-process path here of the kind b066671 fixed for server actions.
|
|
70
|
+
|
|
71
|
+
const _beforeHooks = [];
|
|
72
|
+
const _afterHooks = [];
|
|
73
|
+
|
|
74
|
+
function subscribe(list, fn) {
|
|
75
|
+
list.push(fn);
|
|
76
|
+
return () => {
|
|
77
|
+
const i = list.indexOf(fn);
|
|
78
|
+
if (i !== -1) list.splice(i, 1);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function beforeNavigate(fn) {
|
|
83
|
+
return subscribe(_beforeHooks, fn);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function afterNavigate(fn) {
|
|
87
|
+
return subscribe(_afterHooks, fn);
|
|
88
|
+
}
|
|
89
|
+
|
|
43
90
|
// --- Navigation with View Transitions ---
|
|
44
91
|
|
|
45
92
|
export async function navigate(to, opts = {}) {
|
|
46
|
-
const { replace = false, state = null, transition = true, _fromPopstate = false } = opts;
|
|
93
|
+
const { replace = false, state = null, transition = true, _fromPopstate = false, _redirectChain = false } = opts;
|
|
94
|
+
|
|
95
|
+
// A navigation the user asked for starts a fresh redirect chain; a hop
|
|
96
|
+
// queued by handleRedirect continues the current one.
|
|
97
|
+
if (!_redirectChain) _redirectHistory.length = 0;
|
|
47
98
|
|
|
48
99
|
// Reject unsafe URLs
|
|
49
100
|
if (!isSafeUrl(to)) {
|
|
50
101
|
if (typeof console !== 'undefined') {
|
|
51
|
-
|
|
102
|
+
// isSafeUrl() rejects non-strings, so the value reaching this warning is
|
|
103
|
+
// exactly the kind that can throw on template-literal coercion (Symbol,
|
|
104
|
+
// null-prototype object). Log it as a separate argument.
|
|
105
|
+
console.warn('[what-router] Blocked navigation to unsafe URL:', to);
|
|
52
106
|
}
|
|
53
107
|
return;
|
|
54
108
|
}
|
|
@@ -68,10 +122,36 @@ export async function navigate(to, opts = {}) {
|
|
|
68
122
|
// Don't navigate if already on the same URL
|
|
69
123
|
if (to === _url()) return;
|
|
70
124
|
|
|
71
|
-
// Prevent concurrent navigations
|
|
125
|
+
// Prevent concurrent navigations: wait for the current one to finish. The
|
|
126
|
+
// flag is claimed in the same tick as the check, because an awaited
|
|
127
|
+
// beforeNavigate hook otherwise leaves a gap two navigations both get through.
|
|
72
128
|
if (_isNavigating.peek()) return;
|
|
73
|
-
|
|
74
129
|
_isNavigating.set(true);
|
|
130
|
+
|
|
131
|
+
const from = _url();
|
|
132
|
+
|
|
133
|
+
// A popstate has already moved the browser URL, so cancelling one means
|
|
134
|
+
// pushing the previous entry back to keep the address bar in sync. That entry
|
|
135
|
+
// is a new one: the forward entry is not recoverable and its history.state is
|
|
136
|
+
// not carried over. Documented on beforeNavigate in index.d.ts.
|
|
137
|
+
if (_beforeHooks.length) {
|
|
138
|
+
let cancelled = false;
|
|
139
|
+
try {
|
|
140
|
+
for (const fn of _beforeHooks.slice()) {
|
|
141
|
+
if ((await fn(to, from)) === false) { cancelled = true; break; }
|
|
142
|
+
}
|
|
143
|
+
} catch (e) {
|
|
144
|
+
// A throwing guard must not leave the router permanently wedged.
|
|
145
|
+
_isNavigating.set(false);
|
|
146
|
+
throw e;
|
|
147
|
+
}
|
|
148
|
+
if (cancelled) {
|
|
149
|
+
_isNavigating.set(false);
|
|
150
|
+
if (_fromPopstate && typeof history !== 'undefined') history.pushState(null, '', from);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
75
155
|
_navigationError.set(null);
|
|
76
156
|
|
|
77
157
|
const doNavigation = () => {
|
|
@@ -101,6 +181,64 @@ export async function navigate(to, opts = {}) {
|
|
|
101
181
|
} else {
|
|
102
182
|
doNavigation();
|
|
103
183
|
}
|
|
184
|
+
|
|
185
|
+
if (_afterHooks.length) {
|
|
186
|
+
for (const fn of _afterHooks.slice()) fn(to, from);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --- redirect() ---
|
|
191
|
+
// Throws a navigation signal. Two places catch it:
|
|
192
|
+
//
|
|
193
|
+
// - Route middleware, caught by the Router's own matching pass below.
|
|
194
|
+
// - A component body, caught by core's createComponent, which invokes the
|
|
195
|
+
// handler the signal carries under Symbol.for('what.navigation.signal')
|
|
196
|
+
// rather than reporting the throw to an ErrorBoundary. h() is lazy, so a
|
|
197
|
+
// route component is instantiated after the matching pass has returned;
|
|
198
|
+
// that is why the second catch has to live in core.
|
|
199
|
+
//
|
|
200
|
+
// Anywhere else (an event handler, a promise callback, a timer) nothing catches
|
|
201
|
+
// it and the signal surfaces as an uncaught error. It carries a code, a fix and
|
|
202
|
+
// an example for exactly that case, because that is the only case a human reads
|
|
203
|
+
// it in.
|
|
204
|
+
|
|
205
|
+
const REDIRECT = Symbol.for('what.router.redirect');
|
|
206
|
+
const NAV_SIGNAL = Symbol.for('what.navigation.signal');
|
|
207
|
+
|
|
208
|
+
export function redirect(to, options = {}) {
|
|
209
|
+
if (!isSafeUrl(to)) {
|
|
210
|
+
const target = typeof to === 'string' ? to : Object.prototype.toString.call(to);
|
|
211
|
+
const err = new Error(`[what-router] redirect() refused an unsafe target: ${target}`);
|
|
212
|
+
err.code = 'ERR_UNSAFE_REDIRECT';
|
|
213
|
+
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.';
|
|
214
|
+
err.codeExample = `// Bad - a user-controlled target can leave your origin:
|
|
215
|
+
redirect(query.next);
|
|
216
|
+
|
|
217
|
+
// Good - allowlist the target first:
|
|
218
|
+
redirect(ALLOWED.has(query.next) ? query.next : '/');`;
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const sig = new Error(`[what-router] redirect to ${to}`);
|
|
223
|
+
sig.name = 'RouterRedirect';
|
|
224
|
+
sig.code = 'ERR_REDIRECT_NOT_CAUGHT';
|
|
225
|
+
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.';
|
|
226
|
+
sig.codeExample = `// Bad - an event handler runs long after the render the Router caught:
|
|
227
|
+
<button onclick={() => redirect('/login')}>Sign in</button>
|
|
228
|
+
|
|
229
|
+
// Good - navigate() from a handler:
|
|
230
|
+
<button onclick={() => navigate('/login')}>Sign in</button>
|
|
231
|
+
|
|
232
|
+
// Good - redirect() from a component body, which the Router catches:
|
|
233
|
+
function Private() {
|
|
234
|
+
if (!user()) redirect('/login');
|
|
235
|
+
return <Secret />;
|
|
236
|
+
}`;
|
|
237
|
+
sig[REDIRECT] = true;
|
|
238
|
+
sig[NAV_SIGNAL] = () => { handleRedirect(to, options); };
|
|
239
|
+
sig.to = to;
|
|
240
|
+
sig.options = options;
|
|
241
|
+
throw sig;
|
|
104
242
|
}
|
|
105
243
|
|
|
106
244
|
// Back/forward support — route through navigate() so middleware runs
|
|
@@ -164,6 +302,50 @@ function buildLayoutChain(route, routes) {
|
|
|
164
302
|
const _redirectHistory = [];
|
|
165
303
|
const MAX_REDIRECTS = 10;
|
|
166
304
|
|
|
305
|
+
function loopScreen(message) {
|
|
306
|
+
return h('div', { class: 'what-redirect-loop' },
|
|
307
|
+
h('h1', null, 'Redirect Loop'),
|
|
308
|
+
h('p', null, message)
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Shared by the middleware string form and by a thrown redirect() signal.
|
|
313
|
+
// Returns null once the navigation is queued, or the loop screen if the
|
|
314
|
+
// redirect chain is cycling.
|
|
315
|
+
//
|
|
316
|
+
// The chain is scoped to one user navigation: navigate() clears the history
|
|
317
|
+
// unless it is being called from here. Clearing it on a successful match
|
|
318
|
+
// instead would never catch a cycle between two route components, because each
|
|
319
|
+
// hop matches successfully before its component throws the next redirect.
|
|
320
|
+
function handleRedirect(target, options) {
|
|
321
|
+
_redirectHistory.push(target);
|
|
322
|
+
|
|
323
|
+
if (_redirectHistory.length > MAX_REDIRECTS) {
|
|
324
|
+
const cycle = _redirectHistory.slice(-5).join(' → ');
|
|
325
|
+
_redirectHistory.length = 0;
|
|
326
|
+
console.error(`[what-router] Redirect loop detected: ${cycle}`);
|
|
327
|
+
_isNavigating.set(false);
|
|
328
|
+
return loopScreen('Too many redirects. Check your middleware configuration.');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const seen = new Set();
|
|
332
|
+
let hasCycle = false;
|
|
333
|
+
for (const url of _redirectHistory) {
|
|
334
|
+
if (seen.has(url)) { hasCycle = true; break; }
|
|
335
|
+
seen.add(url);
|
|
336
|
+
}
|
|
337
|
+
if (hasCycle) {
|
|
338
|
+
const cycle = _redirectHistory.join(' → ');
|
|
339
|
+
_redirectHistory.length = 0;
|
|
340
|
+
console.error(`[what-router] Redirect cycle detected: ${cycle}`);
|
|
341
|
+
_isNavigating.set(false);
|
|
342
|
+
return loopScreen('Circular redirect detected. Check your middleware configuration.');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
navigate(target, { replace: true, ...options, _redirectChain: true });
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
167
349
|
// --- Router Component ---
|
|
168
350
|
|
|
169
351
|
export function Router({ routes, fallback, globalLayout }) {
|
|
@@ -171,7 +353,7 @@ export function Router({ routes, fallback, globalLayout }) {
|
|
|
171
353
|
// re-evaluates whenever _url changes; the fine-grained runtime reconciles only
|
|
172
354
|
// the matched page in place. The globalLayout is rendered ONCE around it (below)
|
|
173
355
|
// so the app shell persists across navigations instead of re-instantiating.
|
|
174
|
-
const
|
|
356
|
+
const renderMatch = () => {
|
|
175
357
|
const currentUrl = _url();
|
|
176
358
|
const path = currentUrl.split('?')[0].split('#')[0];
|
|
177
359
|
const search = currentUrl.split('?')[1]?.split('#')[0] || '';
|
|
@@ -198,43 +380,11 @@ export function Router({ routes, fallback, globalLayout }) {
|
|
|
198
380
|
return h('div', { class: 'what-403' }, h('h1', null, '403'), h('p', null, 'Access denied'));
|
|
199
381
|
}
|
|
200
382
|
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
383
|
// Middleware returned a redirect path
|
|
231
|
-
|
|
232
|
-
return null;
|
|
384
|
+
return handleRedirect(result);
|
|
233
385
|
}
|
|
234
386
|
}
|
|
235
387
|
}
|
|
236
|
-
// Successful render — clear redirect history
|
|
237
|
-
_redirectHistory.length = 0;
|
|
238
388
|
|
|
239
389
|
// Build element with loading state support
|
|
240
390
|
let element;
|
|
@@ -271,6 +421,21 @@ export function Router({ routes, fallback, globalLayout }) {
|
|
|
271
421
|
);
|
|
272
422
|
};
|
|
273
423
|
|
|
424
|
+
// Catches a redirect() thrown by route middleware. A redirect() thrown by a
|
|
425
|
+
// route component is caught by core instead, because h() is lazy and the
|
|
426
|
+
// component runs after renderMatch has returned. Anything unbranded
|
|
427
|
+
// propagates to the app's ErrorBoundary unchanged.
|
|
428
|
+
const content = () => {
|
|
429
|
+
let sig = null;
|
|
430
|
+
try {
|
|
431
|
+
return renderMatch();
|
|
432
|
+
} catch (e) {
|
|
433
|
+
if (!e || !e[REDIRECT]) throw e;
|
|
434
|
+
sig = e;
|
|
435
|
+
}
|
|
436
|
+
return handleRedirect(sig.to, sig.options);
|
|
437
|
+
};
|
|
438
|
+
|
|
274
439
|
// Render the global layout ONCE so it — and everything it mounts (sidebars,
|
|
275
440
|
// toasters, command palettes, global key listeners) — PERSISTS across
|
|
276
441
|
// navigations; the reactive `content` child swaps the matched page in place.
|
|
@@ -299,7 +464,7 @@ export function Link({
|
|
|
299
464
|
// Sanitize href — reject dangerous protocols
|
|
300
465
|
const safeHref = isSafeUrl(href) ? href : 'about:blank';
|
|
301
466
|
if (!isSafeUrl(href) && typeof console !== 'undefined') {
|
|
302
|
-
console.warn(
|
|
467
|
+
console.warn('[what-router] Link blocked unsafe href:', href);
|
|
303
468
|
}
|
|
304
469
|
|
|
305
470
|
// Strip query string and hash from href for path comparison
|
|
@@ -535,6 +700,29 @@ export function useRoute() {
|
|
|
535
700
|
};
|
|
536
701
|
}
|
|
537
702
|
|
|
703
|
+
// --- Route Accessors ---
|
|
704
|
+
// Read the singleton route state directly. Called inside a tracking scope
|
|
705
|
+
// (a reactive text binding, computed or effect) they subscribe to it.
|
|
706
|
+
|
|
707
|
+
export function useParams() {
|
|
708
|
+
return route.params;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Same singleton `_query` signal as route.query: only the Router's match branch
|
|
712
|
+
// writes it, so on an unmatched (404) route this is the last matched route's
|
|
713
|
+
// query, not the current URL's. Documented on both declarations.
|
|
714
|
+
export function useSearch() {
|
|
715
|
+
return route.query;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function useNavigate() {
|
|
719
|
+
return navigate;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export function prefetchRoute(href) {
|
|
723
|
+
prefetch(href);
|
|
724
|
+
}
|
|
725
|
+
|
|
538
726
|
// --- Outlet Component ---
|
|
539
727
|
// For nested route rendering
|
|
540
728
|
|
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);
|