@rangojs/router 0.0.0-experimental.fa8a383a → 0.0.0-experimental.fb4fdc18
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 +188 -35
- package/dist/bin/rango.js +130 -47
- package/dist/vite/index.js +1884 -537
- package/dist/vite/index.js.bak +5448 -0
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +7 -5
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +8 -0
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/hooks/SKILL.md +33 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +93 -17
- package/skills/loader/SKILL.md +123 -46
- package/skills/middleware/SKILL.md +36 -3
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/parallel/SKILL.md +133 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/rango/SKILL.md +26 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +75 -0
- package/skills/router-setup/SKILL.md +87 -2
- package/skills/server-actions/SKILL.md +739 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +19 -1
- package/src/__internal.ts +1 -1
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +44 -4
- package/src/browser/navigation-bridge.ts +95 -7
- package/src/browser/navigation-client.ts +128 -53
- package/src/browser/navigation-store.ts +68 -9
- package/src/browser/partial-update.ts +93 -12
- package/src/browser/prefetch/cache.ts +129 -21
- package/src/browser/prefetch/fetch.ts +156 -18
- package/src/browser/prefetch/queue.ts +92 -29
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +72 -8
- package/src/browser/react/NavigationProvider.tsx +82 -21
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/use-handle.ts +9 -58
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +17 -4
- package/src/browser/react/use-router.ts +29 -9
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/rsc-router.tsx +60 -9
- package/src/browser/scroll-restoration.ts +10 -8
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/server-action-bridge.ts +8 -6
- package/src/browser/types.ts +46 -5
- package/src/build/generate-manifest.ts +6 -6
- package/src/build/generate-route-types.ts +3 -0
- package/src/build/route-trie.ts +52 -25
- package/src/build/route-types/include-resolution.ts +8 -1
- package/src/build/route-types/router-processing.ts +211 -72
- package/src/build/route-types/scan-filter.ts +8 -1
- package/src/cache/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +46 -5
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/cache/taint.ts +55 -0
- package/src/client.tsx +84 -230
- package/src/context-var.ts +72 -2
- package/src/handle.ts +40 -0
- package/src/index.rsc.ts +6 -1
- package/src/index.ts +49 -6
- package/src/outlet-context.ts +1 -1
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +28 -2
- package/src/route-definition/dsl-helpers.ts +210 -35
- package/src/route-definition/helpers-types.ts +73 -20
- package/src/route-definition/index.ts +3 -0
- package/src/route-definition/redirect.ts +9 -1
- package/src/route-definition/resolve-handler-use.ts +155 -0
- package/src/route-types.ts +18 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/handler-context.ts +102 -25
- package/src/router/intercept-resolution.ts +9 -4
- package/src/router/lazy-includes.ts +6 -6
- package/src/router/loader-resolution.ts +159 -21
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +128 -192
- package/src/router/match-handlers.ts +1 -0
- package/src/router/match-middleware/background-revalidation.ts +12 -1
- package/src/router/match-middleware/cache-lookup.ts +74 -14
- package/src/router/match-middleware/cache-store.ts +21 -4
- package/src/router/match-middleware/segment-resolution.ts +53 -0
- package/src/router/match-result.ts +112 -9
- package/src/router/metrics.ts +6 -1
- package/src/router/middleware-types.ts +20 -33
- package/src/router/middleware.ts +56 -12
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/pattern-matching.ts +101 -17
- package/src/router/prerender-match.ts +110 -10
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/revalidation.ts +15 -1
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-context.ts +1 -0
- package/src/router/router-interfaces.ts +36 -4
- package/src/router/router-options.ts +37 -11
- package/src/router/segment-resolution/fresh.ts +114 -18
- package/src/router/segment-resolution/helpers.ts +29 -24
- package/src/router/segment-resolution/revalidation.ts +257 -127
- package/src/router/trie-matching.ts +18 -13
- package/src/router/types.ts +1 -0
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +55 -7
- package/src/rsc/handler.ts +478 -383
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +18 -2
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +20 -1
- package/src/rsc/server-action.ts +12 -0
- package/src/rsc/ssr-setup.ts +2 -2
- package/src/rsc/types.ts +15 -1
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +22 -62
- package/src/server/context.ts +76 -4
- package/src/server/handle-store.ts +19 -0
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +185 -57
- package/src/ssr/index.tsx +8 -1
- package/src/static-handler.ts +18 -6
- package/src/types/cache-types.ts +4 -4
- package/src/types/handler-context.ts +145 -68
- package/src/types/loader-types.ts +41 -15
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +12 -1
- package/src/types/segments.ts +18 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +39 -6
- package/src/urls/path-helper.ts +47 -12
- package/src/urls/pattern-types.ts +12 -0
- package/src/urls/response-types.ts +18 -16
- package/src/use-loader.tsx +77 -5
- package/src/vite/debug.ts +184 -0
- package/src/vite/discovery/bundle-postprocess.ts +30 -33
- package/src/vite/discovery/discover-routers.ts +36 -4
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +175 -74
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/discovery/state.ts +13 -4
- package/src/vite/index.ts +4 -0
- package/src/vite/plugin-types.ts +60 -5
- package/src/vite/plugins/cjs-to-esm.ts +5 -0
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +16 -4
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/expose-action-id.ts +52 -28
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +563 -316
- package/src/vite/plugins/performance-tracks.ts +96 -0
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- package/src/vite/plugins/use-cache-transform.ts +56 -43
- package/src/vite/plugins/version-injector.ts +37 -11
- package/src/vite/rango.ts +63 -11
- package/src/vite/router-discovery.ts +732 -86
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +38 -5
- package/src/vite/utils/shared-utils.ts +3 -2
package/src/router/middleware.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { contextGet, contextSet } from "../context-var.js";
|
|
13
|
+
import { safeDecodeURIComponent } from "./url-params.js";
|
|
14
|
+
import { fireAndForgetWaitUntil } from "../types/request-scope.js";
|
|
13
15
|
import type {
|
|
14
16
|
CollectedMiddleware,
|
|
15
17
|
MiddlewareCollectableEntry,
|
|
@@ -22,6 +24,7 @@ import { _getRequestContext } from "../server/request-context.js";
|
|
|
22
24
|
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
23
25
|
import { appendMetric, createMetricsStore } from "./metrics.js";
|
|
24
26
|
import { stripInternalParams } from "./handler-context.js";
|
|
27
|
+
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
25
28
|
|
|
26
29
|
// Re-export types and cookie utilities for backward compatibility
|
|
27
30
|
export type {
|
|
@@ -112,7 +115,12 @@ function escapeRegex(str: string): string {
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
/**
|
|
115
|
-
* Extract params from a pathname using a pattern's regex and param names
|
|
118
|
+
* Extract params from a pathname using a pattern's regex and param names.
|
|
119
|
+
*
|
|
120
|
+
* Values are URL-decoded so apps see the raw string (e.g. "ivo@example.com")
|
|
121
|
+
* instead of the percent-encoded form ("ivo%40example.com"). This matches the
|
|
122
|
+
* contract assumed by ctx.reverse (which re-encodes) and aligns with
|
|
123
|
+
* Express/React Router/Fastify/Koa.
|
|
116
124
|
*/
|
|
117
125
|
export function extractParams(
|
|
118
126
|
pathname: string,
|
|
@@ -124,7 +132,7 @@ export function extractParams(
|
|
|
124
132
|
|
|
125
133
|
const params: Record<string, string> = {};
|
|
126
134
|
for (let i = 0; i < paramNames.length; i++) {
|
|
127
|
-
params[paramNames[i]] = match[i + 1] || "";
|
|
135
|
+
params[paramNames[i]] = safeDecodeURIComponent(match[i + 1] || "");
|
|
128
136
|
}
|
|
129
137
|
return params;
|
|
130
138
|
}
|
|
@@ -179,14 +187,22 @@ export function createMiddlewareContext<TEnv>(
|
|
|
179
187
|
return responseHolder.response;
|
|
180
188
|
};
|
|
181
189
|
|
|
190
|
+
// Capture reqCtx once: the request-scoped platform fields
|
|
191
|
+
// (originalUrl, executionContext, waitUntil) are immutable per request,
|
|
192
|
+
// so snapshotting beats re-reading ALS on every access. The lazy getters
|
|
193
|
+
// below (routeName, theme, setTheme) stay lazy because those can change
|
|
194
|
+
// during `await next()`.
|
|
195
|
+
const reqCtx = _getRequestContext();
|
|
182
196
|
return {
|
|
183
197
|
request,
|
|
184
198
|
url,
|
|
185
|
-
originalUrl: new URL(request.url),
|
|
199
|
+
originalUrl: reqCtx?.originalUrl ?? new URL(request.url),
|
|
186
200
|
pathname: url.pathname,
|
|
187
201
|
searchParams: url.searchParams,
|
|
188
202
|
env: env as MiddlewareContext<TEnv>["env"],
|
|
189
203
|
params,
|
|
204
|
+
executionContext: reqCtx?.executionContext,
|
|
205
|
+
waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil,
|
|
190
206
|
// Getter: re-derives from request context on each access so that global
|
|
191
207
|
// middleware sees the matched route name after await next().
|
|
192
208
|
get routeName(): MiddlewareContext<TEnv>["routeName"] {
|
|
@@ -204,12 +220,9 @@ export function createMiddlewareContext<TEnv>(
|
|
|
204
220
|
get: ((keyOrVar: any) =>
|
|
205
221
|
contextGet(variables, keyOrVar)) as MiddlewareContext<TEnv>["get"],
|
|
206
222
|
|
|
207
|
-
set: ((keyOrVar: any, value: unknown) => {
|
|
208
|
-
contextSet(variables, keyOrVar, value);
|
|
223
|
+
set: ((keyOrVar: any, value: unknown, options?: any) => {
|
|
224
|
+
contextSet(variables, keyOrVar, value, options);
|
|
209
225
|
}) as MiddlewareContext<TEnv>["set"],
|
|
210
|
-
|
|
211
|
-
var: variables as MiddlewareContext<TEnv>["var"],
|
|
212
|
-
|
|
213
226
|
header(name: string, value: string): void {
|
|
214
227
|
// Before next(): delegate to shared RequestContext stub
|
|
215
228
|
if (isPreNext()) {
|
|
@@ -363,6 +376,11 @@ export async function executeMiddleware<TEnv>(
|
|
|
363
376
|
});
|
|
364
377
|
}
|
|
365
378
|
|
|
379
|
+
if (isWebSocketUpgradeResponse(response)) {
|
|
380
|
+
responseHolder.response = response;
|
|
381
|
+
return response;
|
|
382
|
+
}
|
|
383
|
+
|
|
366
384
|
// Clone response with merged headers (mutable for post-next() modifications)
|
|
367
385
|
responseHolder.response = new Response(response.body, {
|
|
368
386
|
status: response.status,
|
|
@@ -429,8 +447,16 @@ export async function executeMiddleware<TEnv>(
|
|
|
429
447
|
try {
|
|
430
448
|
result = await entry.handler(ctx, wrappedNext);
|
|
431
449
|
} catch (error) {
|
|
432
|
-
|
|
433
|
-
|
|
450
|
+
// Thrown Response is short-circuit control flow, not an error.
|
|
451
|
+
// Fall through to the `if (result instanceof Response)` branch below
|
|
452
|
+
// so stub headers and request-context cookies merge as they do for
|
|
453
|
+
// an explicit `return new Response(...)`. Real errors propagate.
|
|
454
|
+
if (error instanceof Response) {
|
|
455
|
+
result = error;
|
|
456
|
+
} else {
|
|
457
|
+
finishMiddleware();
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
434
460
|
}
|
|
435
461
|
finishMiddleware();
|
|
436
462
|
|
|
@@ -454,6 +480,10 @@ export async function executeMiddleware<TEnv>(
|
|
|
454
480
|
// RequestContext stub headers (from ctx.setCookie) into the
|
|
455
481
|
// returned Response so they are not lost.
|
|
456
482
|
if (result instanceof Response) {
|
|
483
|
+
if (isWebSocketUpgradeResponse(result)) {
|
|
484
|
+
responseHolder.response = result;
|
|
485
|
+
return result;
|
|
486
|
+
}
|
|
457
487
|
const mergedHeaders = new Headers(result.headers);
|
|
458
488
|
stubResponse.headers.forEach((value, name) => {
|
|
459
489
|
if (name.toLowerCase() === "set-cookie") {
|
|
@@ -530,8 +560,11 @@ export async function executeMiddleware<TEnv>(
|
|
|
530
560
|
// last merge point (e.g. cookies().set() called after await next()).
|
|
531
561
|
// The reqCtx stub may have already been partially merged during finalHandler
|
|
532
562
|
// or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
|
|
563
|
+
//
|
|
564
|
+
// Skip for upgrade responses: upgrade headers are semantically immutable and
|
|
565
|
+
// set-cookie on an upgrade is not meaningful.
|
|
533
566
|
const reqCtx = _getRequestContext();
|
|
534
|
-
if (reqCtx) {
|
|
567
|
+
if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
|
|
535
568
|
const stubCookies = reqCtx.res.headers.getSetCookie();
|
|
536
569
|
if (stubCookies.length > 0) {
|
|
537
570
|
const existingCookies = new Set(finalResponse.headers.getSetCookie());
|
|
@@ -616,7 +649,18 @@ export async function executeInterceptMiddleware<TEnv>(
|
|
|
616
649
|
return next();
|
|
617
650
|
};
|
|
618
651
|
|
|
619
|
-
|
|
652
|
+
let result: Response | void;
|
|
653
|
+
try {
|
|
654
|
+
result = await middleware(ctx, guardedNext);
|
|
655
|
+
} catch (error) {
|
|
656
|
+
// Thrown Response is short-circuit control flow, parity with the
|
|
657
|
+
// explicit-return path below. Real errors propagate.
|
|
658
|
+
if (error instanceof Response) {
|
|
659
|
+
result = error;
|
|
660
|
+
} else {
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
620
664
|
|
|
621
665
|
if (result instanceof Response) {
|
|
622
666
|
earlyResponse = result;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Snapshot
|
|
3
|
+
*
|
|
4
|
+
* Pure data type representing the navigation-specific state for partial requests.
|
|
5
|
+
* Consolidates the header parsing, previous-route matching, intercept-context
|
|
6
|
+
* detection, and segment ID filtering that previously lived inline in
|
|
7
|
+
* createMatchContextForPartial (match-api.ts).
|
|
8
|
+
*
|
|
9
|
+
* resolveNavigation() is the factory: given a request + URL + current route key,
|
|
10
|
+
* it returns a NavigationSnapshot (or null if no previous URL).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { RouteMatchResult } from "./pattern-matching.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Snapshot of navigation state for a partial (navigation/action) request.
|
|
17
|
+
*
|
|
18
|
+
* Contains the "where are we coming from?" data: previous route, intercept
|
|
19
|
+
* source, client segment state, and derived flags.
|
|
20
|
+
*/
|
|
21
|
+
export interface NavigationSnapshot {
|
|
22
|
+
/** Previous page URL (from X-RSC-Router-Client-Path or Referer) */
|
|
23
|
+
prevUrl: URL;
|
|
24
|
+
/** Params from the previous route match */
|
|
25
|
+
prevParams: Record<string, string>;
|
|
26
|
+
/** Previous route match result (null if prev URL doesn't match any route) */
|
|
27
|
+
prevMatch: RouteMatchResult | null;
|
|
28
|
+
|
|
29
|
+
/** URL used as intercept context source */
|
|
30
|
+
interceptContextUrl: URL;
|
|
31
|
+
/** Route match for the intercept context URL */
|
|
32
|
+
interceptContextMatch: RouteMatchResult | null;
|
|
33
|
+
|
|
34
|
+
/** Raw segment IDs the client currently has */
|
|
35
|
+
clientSegmentIds: string[];
|
|
36
|
+
/** Set version for O(1) lookup */
|
|
37
|
+
clientSegmentSet: Set<string>;
|
|
38
|
+
/** Segment IDs filtered to remove parallel (.@) and loader (D\d+.) entries */
|
|
39
|
+
filteredSegmentIds: string[];
|
|
40
|
+
|
|
41
|
+
/** Whether client considers its cache stale */
|
|
42
|
+
stale: boolean;
|
|
43
|
+
|
|
44
|
+
/** Whether the intercept context route is the same as the current route */
|
|
45
|
+
isSameRouteNavigation: boolean;
|
|
46
|
+
|
|
47
|
+
/** Effective "from" URL (intercept source URL when present, else prevUrl) */
|
|
48
|
+
effectiveFromUrl: URL;
|
|
49
|
+
/** Effective "from" match (intercept source match when present, else prevMatch) */
|
|
50
|
+
effectiveFromMatch: RouteMatchResult | null;
|
|
51
|
+
|
|
52
|
+
/** Whether an intercept source header was present */
|
|
53
|
+
hasInterceptSource: boolean;
|
|
54
|
+
|
|
55
|
+
/** Whether an HMR request header was present */
|
|
56
|
+
isHmr: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ResolveNavigationDeps {
|
|
60
|
+
findMatch: (pathname: string) => RouteMatchResult | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve navigation state from a partial request.
|
|
65
|
+
*
|
|
66
|
+
* Returns null if no previous URL is available (required for partial navigation).
|
|
67
|
+
*
|
|
68
|
+
* @param request - The incoming HTTP request
|
|
69
|
+
* @param url - Parsed URL of the request
|
|
70
|
+
* @param currentRouteKey - Route key of the current (target) route match
|
|
71
|
+
* @param deps - Dependencies (findMatch)
|
|
72
|
+
*/
|
|
73
|
+
export function resolveNavigation(
|
|
74
|
+
request: Request,
|
|
75
|
+
url: URL,
|
|
76
|
+
currentRouteKey: string,
|
|
77
|
+
deps: ResolveNavigationDeps,
|
|
78
|
+
): NavigationSnapshot | null {
|
|
79
|
+
// Parse client state from RSC request params/headers
|
|
80
|
+
const clientSegmentIds =
|
|
81
|
+
url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
|
|
82
|
+
const stale = url.searchParams.get("_rsc_stale") === "true";
|
|
83
|
+
const previousUrl =
|
|
84
|
+
request.headers.get("X-RSC-Router-Client-Path") ||
|
|
85
|
+
request.headers.get("Referer");
|
|
86
|
+
const interceptSourceUrl = request.headers.get(
|
|
87
|
+
"X-RSC-Router-Intercept-Source",
|
|
88
|
+
);
|
|
89
|
+
const isHmr = !!request.headers.get("X-RSC-HMR");
|
|
90
|
+
|
|
91
|
+
if (!previousUrl) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Parse previous URL
|
|
96
|
+
let prevUrl: URL;
|
|
97
|
+
try {
|
|
98
|
+
prevUrl = new URL(previousUrl, url.origin);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Parse intercept context URL
|
|
104
|
+
let interceptContextUrl: URL;
|
|
105
|
+
try {
|
|
106
|
+
interceptContextUrl = interceptSourceUrl
|
|
107
|
+
? new URL(interceptSourceUrl, url.origin)
|
|
108
|
+
: prevUrl;
|
|
109
|
+
} catch {
|
|
110
|
+
interceptContextUrl = prevUrl;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Match previous and intercept context routes
|
|
114
|
+
const prevMatch = deps.findMatch(prevUrl.pathname);
|
|
115
|
+
const prevParams = prevMatch?.params || {};
|
|
116
|
+
const interceptContextMatch = interceptSourceUrl
|
|
117
|
+
? deps.findMatch(interceptContextUrl.pathname)
|
|
118
|
+
: prevMatch;
|
|
119
|
+
|
|
120
|
+
// Derived state
|
|
121
|
+
const isSameRouteNavigation = !!(
|
|
122
|
+
interceptContextMatch && interceptContextMatch.routeKey === currentRouteKey
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const hasInterceptSource = !!interceptSourceUrl;
|
|
126
|
+
const effectiveFromUrl = hasInterceptSource ? interceptContextUrl : prevUrl;
|
|
127
|
+
const effectiveFromMatch = hasInterceptSource
|
|
128
|
+
? interceptContextMatch
|
|
129
|
+
: prevMatch;
|
|
130
|
+
|
|
131
|
+
// Filter segment IDs: remove parallel (.@) and loader (D\d+.) entries
|
|
132
|
+
const filteredSegmentIds = clientSegmentIds.filter((id) => {
|
|
133
|
+
if (id.includes(".@")) return false;
|
|
134
|
+
if (/D\d+\./.test(id)) return false;
|
|
135
|
+
return true;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const clientSegmentSet = new Set(clientSegmentIds);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
prevUrl,
|
|
142
|
+
prevParams,
|
|
143
|
+
prevMatch,
|
|
144
|
+
interceptContextUrl,
|
|
145
|
+
interceptContextMatch,
|
|
146
|
+
clientSegmentIds,
|
|
147
|
+
clientSegmentSet,
|
|
148
|
+
filteredSegmentIds,
|
|
149
|
+
stale,
|
|
150
|
+
isSameRouteNavigation,
|
|
151
|
+
effectiveFromUrl,
|
|
152
|
+
effectiveFromMatch,
|
|
153
|
+
hasInterceptSource,
|
|
154
|
+
isHmr,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Test helper: create a NavigationSnapshot with sensible defaults and overrides.
|
|
160
|
+
*/
|
|
161
|
+
export function createNavigationSnapshot(
|
|
162
|
+
overrides?: Partial<NavigationSnapshot>,
|
|
163
|
+
): NavigationSnapshot {
|
|
164
|
+
const defaultUrl = new URL("http://localhost/");
|
|
165
|
+
return {
|
|
166
|
+
prevUrl: defaultUrl,
|
|
167
|
+
prevParams: {},
|
|
168
|
+
prevMatch: null,
|
|
169
|
+
interceptContextUrl: defaultUrl,
|
|
170
|
+
interceptContextMatch: null,
|
|
171
|
+
clientSegmentIds: [],
|
|
172
|
+
clientSegmentSet: new Set(),
|
|
173
|
+
filteredSegmentIds: [],
|
|
174
|
+
stale: false,
|
|
175
|
+
isSameRouteNavigation: false,
|
|
176
|
+
effectiveFromUrl: defaultUrl,
|
|
177
|
+
effectiveFromMatch: null,
|
|
178
|
+
hasInterceptSource: false,
|
|
179
|
+
isHmr: false,
|
|
180
|
+
...overrides,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { RouteEntry, TrailingSlashMode } from "../types";
|
|
8
8
|
import type { EntryData } from "../server/context";
|
|
9
9
|
import { debugLog, isRouterDebugEnabled } from "./logging.js";
|
|
10
|
+
import { safeDecodeURIComponent } from "./url-params.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Parsed segment info
|
|
@@ -82,6 +83,13 @@ export interface CompiledPattern {
|
|
|
82
83
|
paramNames: string[];
|
|
83
84
|
optionalParams: Set<string>;
|
|
84
85
|
hasTrailingSlash: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Param-name → allowed values for constrained params (e.g. `:lang(en|gb)`).
|
|
88
|
+
* Validated against the **decoded** param value after regex extraction so
|
|
89
|
+
* a URL like `/en%20GB` still matches `:lang(en GB)` — matching the trie
|
|
90
|
+
* path's behavior (trie-matching.ts:validateAndBuild).
|
|
91
|
+
*/
|
|
92
|
+
constraints?: Record<string, string[]>;
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
// Module-level cache for compiled patterns. Route patterns are a finite set
|
|
@@ -142,6 +150,7 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
142
150
|
const segments = parsePattern(normalizedPattern);
|
|
143
151
|
const paramNames: string[] = [];
|
|
144
152
|
const optionalParams = new Set<string>();
|
|
153
|
+
let constraints: Record<string, string[]> | undefined;
|
|
145
154
|
|
|
146
155
|
let regexPattern = "";
|
|
147
156
|
|
|
@@ -152,11 +161,14 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
152
161
|
} else if (segment.type === "param") {
|
|
153
162
|
paramNames.push(segment.value);
|
|
154
163
|
const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
164
|
+
// Constrained params capture anything here; the allowed values are
|
|
165
|
+
// checked post-decode in findMatch so URL-encoded constraint values
|
|
166
|
+
// (e.g. `:lang(en GB)` via `/en%20GB`) still match.
|
|
167
|
+
const valuePattern = segment.suffix ? "([^/]+?)" : "([^/]+)";
|
|
168
|
+
|
|
169
|
+
if (segment.constraint) {
|
|
170
|
+
(constraints ??= {})[segment.value] = segment.constraint;
|
|
171
|
+
}
|
|
160
172
|
|
|
161
173
|
if (segment.optional) {
|
|
162
174
|
optionalParams.add(segment.value);
|
|
@@ -176,6 +188,20 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
176
188
|
regexPattern = "/";
|
|
177
189
|
}
|
|
178
190
|
|
|
191
|
+
// Patterns of only optional segments (e.g. `/:locale?`, `/:a?/:b?`) need
|
|
192
|
+
// an explicit `/` alternative so a bare `/` matches the absent form. The
|
|
193
|
+
// optional template `(?:/X)?` matches `/X` or empty string, but pathnames
|
|
194
|
+
// are never empty. Arises from `include("/:locale?", routes)` + inner
|
|
195
|
+
// `path("/")`. Skip when an explicit trailing slash already anchors the
|
|
196
|
+
// match.
|
|
197
|
+
const hasOnlyOptionalSegments =
|
|
198
|
+
!hasTrailingSlash &&
|
|
199
|
+
segments.length > 0 &&
|
|
200
|
+
segments.every((segment) => segment.type === "param" && segment.optional);
|
|
201
|
+
if (hasOnlyOptionalSegments) {
|
|
202
|
+
regexPattern = `(?:/|${regexPattern})`;
|
|
203
|
+
}
|
|
204
|
+
|
|
179
205
|
// Add trailing slash to regex if pattern has one
|
|
180
206
|
if (hasTrailingSlash) {
|
|
181
207
|
regexPattern += "/";
|
|
@@ -186,9 +212,35 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
186
212
|
paramNames,
|
|
187
213
|
optionalParams,
|
|
188
214
|
hasTrailingSlash,
|
|
215
|
+
...(constraints ? { constraints } : {}),
|
|
189
216
|
};
|
|
190
217
|
}
|
|
191
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Validate decoded params against a compiled pattern's constraints.
|
|
221
|
+
* Returns false if any constrained param has a non-empty value not in the
|
|
222
|
+
* allowed list. Absent optionals (key missing or `undefined`) are allowed;
|
|
223
|
+
* `""` is also tolerated as "absent" so user-provided params or fixtures
|
|
224
|
+
* that pass empty strings explicitly behave the same way.
|
|
225
|
+
*/
|
|
226
|
+
function satisfiesConstraints(
|
|
227
|
+
params: Record<string, string>,
|
|
228
|
+
constraints: Record<string, string[]> | undefined,
|
|
229
|
+
): boolean {
|
|
230
|
+
if (!constraints) return true;
|
|
231
|
+
for (const name in constraints) {
|
|
232
|
+
const value = params[name];
|
|
233
|
+
if (
|
|
234
|
+
value !== undefined &&
|
|
235
|
+
value !== "" &&
|
|
236
|
+
!constraints[name].includes(value)
|
|
237
|
+
) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
192
244
|
/**
|
|
193
245
|
* Escape special regex characters in a string
|
|
194
246
|
*/
|
|
@@ -196,6 +248,27 @@ function escapeRegex(str: string): string {
|
|
|
196
248
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
197
249
|
}
|
|
198
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Build the named-params record from a regex match. Optional segments that
|
|
253
|
+
* didn't capture leave the corresponding group `undefined`; we skip those
|
|
254
|
+
* keys so `ctx.params.<name>` reads as `undefined` rather than `""`. This
|
|
255
|
+
* keeps the runtime aligned with the `ExtractParams` type and matches the
|
|
256
|
+
* trie matcher's contract (see `trie-matching.ts:validateAndBuild`).
|
|
257
|
+
*/
|
|
258
|
+
function buildParamsFromMatch(
|
|
259
|
+
match: RegExpExecArray,
|
|
260
|
+
paramNames: string[],
|
|
261
|
+
): Record<string, string> {
|
|
262
|
+
const params: Record<string, string> = {};
|
|
263
|
+
paramNames.forEach((name, index) => {
|
|
264
|
+
const captured = match[index + 1];
|
|
265
|
+
if (captured !== undefined) {
|
|
266
|
+
params[name] = safeDecodeURIComponent(captured);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
return params;
|
|
270
|
+
}
|
|
271
|
+
|
|
199
272
|
/**
|
|
200
273
|
* Extract the static prefix from a route pattern.
|
|
201
274
|
* Returns everything before the first param/wildcard.
|
|
@@ -247,8 +320,10 @@ export function extractStaticPrefix(pattern: string): string {
|
|
|
247
320
|
/**
|
|
248
321
|
* Match a pathname against registered routes
|
|
249
322
|
*
|
|
250
|
-
* Note: Optional params that are absent in the path
|
|
251
|
-
*
|
|
323
|
+
* Note: Optional params that are absent in the path are omitted from the
|
|
324
|
+
* returned `params` (read as `undefined`), matching the trie matcher and
|
|
325
|
+
* the `ExtractParams<"/:locale?/...">` type. Use the pattern definition or
|
|
326
|
+
* `optionalParams` to determine which keys are optional.
|
|
252
327
|
*
|
|
253
328
|
* Trailing slash handling (priority order):
|
|
254
329
|
* 1. Per-route `trailingSlash` config from route()
|
|
@@ -392,8 +467,13 @@ export function findMatch<TEnv>(
|
|
|
392
467
|
fullPattern = entry.prefix + pattern;
|
|
393
468
|
}
|
|
394
469
|
|
|
395
|
-
const {
|
|
396
|
-
|
|
470
|
+
const {
|
|
471
|
+
regex,
|
|
472
|
+
paramNames,
|
|
473
|
+
optionalParams,
|
|
474
|
+
hasTrailingSlash,
|
|
475
|
+
constraints,
|
|
476
|
+
} = getCompiledPattern(fullPattern);
|
|
397
477
|
|
|
398
478
|
// Get trailing slash mode for this route (per-route config or pattern-based)
|
|
399
479
|
const trailingSlashMode: TrailingSlashMode | undefined =
|
|
@@ -410,10 +490,13 @@ export function findMatch<TEnv>(
|
|
|
410
490
|
// Try exact match first
|
|
411
491
|
const match = regex.exec(pathname);
|
|
412
492
|
if (match) {
|
|
413
|
-
const params
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
493
|
+
const params = buildParamsFromMatch(match, paramNames);
|
|
494
|
+
|
|
495
|
+
// Validate constraints against decoded values; a failure falls
|
|
496
|
+
// through to the next route so other patterns can still match.
|
|
497
|
+
if (!satisfiesConstraints(params, constraints)) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
417
500
|
|
|
418
501
|
if (effectiveDebug) {
|
|
419
502
|
debugLog("findMatch", "matched route", {
|
|
@@ -465,10 +548,11 @@ export function findMatch<TEnv>(
|
|
|
465
548
|
// Try alternate pathname (opposite trailing slash)
|
|
466
549
|
const altMatch = regex.exec(alternatePathname);
|
|
467
550
|
if (altMatch) {
|
|
468
|
-
const params
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
551
|
+
const params = buildParamsFromMatch(altMatch, paramNames);
|
|
552
|
+
|
|
553
|
+
if (!satisfiesConstraints(params, constraints)) {
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
472
556
|
|
|
473
557
|
// Determine redirect behavior based on mode
|
|
474
558
|
if (trailingSlashMode === "ignore") {
|