Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/app-router-cloudflare/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function AdminPage() {
return <main>Protected admin content</main>;
}
5 changes: 4 additions & 1 deletion examples/app-router-cloudflare/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { NextRequest, NextResponse } from "next/server";
* Middleware for app-router-cloudflare example.
*/
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname === "/admin") {
return new Response("Blocked by middleware", { status: 403 });
}
if (request.nextUrl.pathname === "/_next/static/middleware-rewrite.js") {
return new Response("rewritten missing asset", {
headers: { "content-type": "text/plain" },
Expand All @@ -22,5 +25,5 @@ export function middleware(request: NextRequest) {
}

export const config = {
matcher: ["/api/:path*", "/", "/_next/static/middleware-rewrite.js"],
matcher: ["/api/:path*", "/", "/admin", "/_next/static/middleware-rewrite.js"],
};
13 changes: 11 additions & 2 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,10 @@ function matchRoute(url) {
return __routeMatcher.matchRoute(url);
}

function matchRequestRoute(url) {
return __routeMatcher.matchRequestRoute(url);
}

/**
* Check if a pathname matches any intercepting route.
* Returns the match info or null.
Expand Down Expand Up @@ -737,6 +741,7 @@ export default createAppRscHandler({
actionFailed,
handlerStart,
interceptionContext,
interceptionPathname,
isProgressiveActionRender,
isRscRequest,
middlewareContext,
Expand Down Expand Up @@ -821,7 +826,10 @@ export default createAppRscHandler({
fetchCache: __segmentConfig.fetchCache ?? null,
isEdgeRuntime: __isEdgeRuntime(__segmentConfig.runtime),
findIntercept(pathname) {
return findIntercept(pathname, interceptionContext);
return findIntercept(
pathname === cleanPathname ? interceptionPathname : pathname,
interceptionContext,
);
},
generateStaticParams: __generateStaticParams,
getFontLinks: _getSSRFontLinks,
Expand Down Expand Up @@ -873,7 +881,7 @@ export default createAppRscHandler({
});
},
async probePage(probeSearchParams = searchParams) {
const __probeIntercept = findIntercept(cleanPathname, interceptionContext);
const __probeIntercept = findIntercept(interceptionPathname, interceptionContext);
// The intercepting-route page module is lazy (page: null + __pageLoader).
// Resolve it before probing so buildAppPageProbes inspects the real page
// component for dynamic bailout — matching the render path, which also
Expand Down Expand Up @@ -1188,6 +1196,7 @@ export default createAppRscHandler({
: ""
}
matchRoute,
matchRequestRoute,
${
middlewarePath
? `runMiddleware({ cleanPathname, context, hadBasePath, isDataRequest, request }) {
Expand Down
2 changes: 2 additions & 0 deletions packages/vinext/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,8 @@ declare module "node:http" {
* the final response status.
*/
__vinextMiddlewareStatus?: number;
/** Encoded request URL captured before Vite normalizes the pathname. */
__vinextOriginalEncodedUrl?: string;
}
}

Expand Down
17 changes: 16 additions & 1 deletion packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4074,6 +4074,11 @@ export const loadServerActionClient = ${
},

configureServer(server: ViteDevServer) {
server.middlewares.use((req, _res, next) => {
req.__vinextOriginalEncodedUrl ??= req.url;
next();
});

// Watch route files for additions/removals to invalidate route cache.
const pageExtensions = fileMatcher.extensionRegex;

Expand Down Expand Up @@ -4710,6 +4715,7 @@ export const loadServerActionClient = ${
url = url.replace(/\.html(?=\?|$)/, "");
}

let middlewareUrl = req.__vinextOriginalEncodedUrl ?? url;
let pathname = url.split("?")[0];

// Guard against protocol-relative URL open redirects.
Expand Down Expand Up @@ -4759,6 +4765,13 @@ export const loadServerActionClient = ${
const qs = url.includes("?") ? url.slice(url.indexOf("?")) : "";
url = stripped + qs;
pathname = stripped;
const middlewarePathname = middlewareUrl.split("?")[0];
if (middlewarePathname.startsWith(bp)) {
const middlewareQs = middlewareUrl.includes("?")
? middlewareUrl.slice(middlewareUrl.indexOf("?"))
: "";
middlewareUrl = (middlewarePathname.slice(bp.length) || "/") + middlewareQs;
}
}

if (nextConfig) {
Expand Down Expand Up @@ -4813,6 +4826,7 @@ export const loadServerActionClient = ${
capturedMiddlewarePath !== null && nextConfig?.trailingSlash === true,
);
url = pagePathname + qs;
middlewareUrl = url;
pathname = pagePathname;
// Rewrite req.url so downstream middleware sees the page
// path, not the raw _next/data URL.
Expand Down Expand Up @@ -4947,7 +4961,7 @@ export const loadServerActionClient = ${
const mwProto =
rawProto === "https" || rawProto === "http" ? rawProto : "http";
const mwOrigin = `${mwProto}://${requestHost}`;
const middlewareRequest = new Request(new URL(url, mwOrigin), {
const middlewareRequest = new Request(new URL(middlewareUrl, mwOrigin), {
method: req.method,
headers: nodeRequestHeaders,
});
Expand All @@ -4959,6 +4973,7 @@ export const loadServerActionClient = ${
nextConfig?.basePath,
nextConfig?.trailingSlash,
opts.isDataRequest,
pathname,
);

// Forward middleware context to the RSC entry so it can
Expand Down
13 changes: 10 additions & 3 deletions packages/vinext/src/routing/route-pattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ export function fillRoutePatternSegments(
export function matchRoutePattern(
urlParts: readonly string[],
patternParts: readonly string[],
): RoutePatternParams | null {
const params = matchRoutePatternRaw(urlParts, patternParts);
if (params) decodeMatchedParams(params);
return params;
}

export function matchRoutePatternRaw(
urlParts: readonly string[],
patternParts: readonly string[],
): RoutePatternParams | null {
const params: RoutePatternParams = Object.create(null);

Expand Down Expand Up @@ -137,9 +146,7 @@ export function matchRoutePattern(
return matchFrom(urlIndex + 1, patternIndex + 1);
}

if (!matchFrom(0, 0)) return null;
decodeMatchedParams(params);
return params;
return matchFrom(0, 0) ? params : null;
}

export function matchRoutePatternPrefix(
Expand Down
9 changes: 8 additions & 1 deletion packages/vinext/src/routing/route-trie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,20 @@ export function trieMatch<R>(
root: TrieNode<R>,
urlParts: string[],
): { route: R; params: Record<string, string | string[]> } | null {
const result = match(root, urlParts, 0, []);
const result = trieMatchRaw(root, urlParts);
if (result) {
decodeMatchedParams(result.params);
}
return result;
}

export function trieMatchRaw<R>(
root: TrieNode<R>,
urlParts: string[],
): { route: R; params: Record<string, string | string[]> } | null {
return match(root, urlParts, 0, []);
}

function match<R>(
node: TrieNode<R>,
urlParts: string[],
Expand Down
11 changes: 9 additions & 2 deletions packages/vinext/src/server/app-page-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,13 @@ function areStaticParamsAllowed(
staticParams: readonly Record<string, unknown>[],
): boolean {
const paramKeys = Object.keys(params);
const stringParamMatches = (value: string, staticValue: string): boolean => {
const normalizedValue = value.toLowerCase();
return (
normalizedValue === staticValue.toLowerCase() ||
normalizedValue === encodeURIComponent(staticValue).toLowerCase()
);
};

return staticParams.some((staticParamSet) =>
paramKeys.every((key) => {
Expand All @@ -372,14 +379,14 @@ function areStaticParamsAllowed(
value.length === staticValue.length &&
value.every((part, index) =>
typeof staticValue[index] === "string"
? part.toLowerCase() === staticValue[index].toLowerCase()
? stringParamMatches(part, staticValue[index])
: part === staticValue[index],
)
);
}

if (typeof staticValue === "string") {
return value.toLowerCase() === staticValue.toLowerCase();
return stringParamMatches(value, staticValue);
}

if (typeof staticValue === "number" || typeof staticValue === "boolean") {
Expand Down
25 changes: 20 additions & 5 deletions packages/vinext/src/server/app-rsc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ type DispatchMatchedPageOptions<TRoute> = {
actionFailed?: boolean;
handlerStart: number;
interceptionContext: string | null;
interceptionPathname: string;
isProgressiveActionRender: boolean;
isRscRequest: boolean;
middlewareContext: AppRscMiddlewareContext;
Expand Down Expand Up @@ -322,6 +323,7 @@ type CreateAppRscHandlerOptions<TRoute extends AppRscHandlerRoute> = {
isDev: boolean;
loadPrerenderPagesRoutes?: () => Promise<unknown>;
matchRoute: (pathname: string) => AppRscRouteMatch<TRoute> | null;
matchRequestRoute?: (pathname: string) => AppRscRouteMatch<TRoute> | null;
runMiddleware?: (options: RunAppMiddlewareOptions) => Promise<ApplyAppMiddlewareResult>;
publicFiles: ReadonlySet<string>;
prefetchInlining?: PrefetchInliningConfig;
Expand Down Expand Up @@ -522,6 +524,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
clientReuseManifest,
hadBasePath,
} = normalized;
const { requestCleanPathname } = normalized;
let { pathname, cleanPathname } = normalized;
let resolvedUrl = cleanPathname + url.search;
const originalResolvedUrl = resolvedUrl;
Expand All @@ -535,6 +538,11 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
const canonicalPathname = cleanPathname;

const basePathState = { basePath: options.basePath, hadBasePath };
let cleanPathnameIsRequestPathname = true;
const matchCleanPathname = () =>
cleanPathnameIsRequestPathname && options.matchRequestRoute
? options.matchRequestRoute(requestCleanPathname)
: options.matchRoute(cleanPathname);

if (
pathname === VINEXT_PRERENDER_STATIC_PARAMS_PATH ||
Expand Down Expand Up @@ -634,6 +642,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
}

cleanPathname = middlewareResult.cleanPathname;
if (middlewareResult.rewritten) cleanPathnameIsRequestPathname = false;
didMiddlewareRewrite = middlewareResult.rewritten;
didMiddlewareRewritePathname = cleanPathname !== normalized.cleanPathname;
if (middlewareResult.search !== null) {
Expand Down Expand Up @@ -676,6 +685,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
if (beforeFilesRewrite) {
resolvedUrl = mergeRewriteQuery(resolvedUrl, beforeFilesRewrite);
cleanPathname = pathnameForResolvedUrl(resolvedUrl);
cleanPathnameIsRequestPathname = false;
filesystemRouteEligible = true;
}
}
Expand Down Expand Up @@ -711,8 +721,9 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
if (!rewritten) continue;
resolvedUrl = mergeRewriteQuery(resolvedUrl, rewritten);
cleanPathname = pathnameForResolvedUrl(resolvedUrl);
cleanPathnameIsRequestPathname = false;
filesystemRouteEligible = true;
actionMatch = options.matchRoute(cleanPathname);
actionMatch = matchCleanPathname();
if (actionMatch) break;
}
if (!actionMatch) {
Expand All @@ -735,8 +746,9 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
if (!rewritten) continue;
resolvedUrl = mergeRewriteQuery(resolvedUrl, rewritten);
cleanPathname = pathnameForResolvedUrl(resolvedUrl);
cleanPathnameIsRequestPathname = false;
filesystemRouteEligible = true;
actionMatch = options.matchRoute(cleanPathname);
actionMatch = matchCleanPathname();
if (actionMatch) break;
}
}
Expand Down Expand Up @@ -813,7 +825,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
// where the second `setRootParams` call replaces this value before rendering.
// Out-of-basePath Server Actions resolve those late rewrites above so this
// match already uses their claimed destination.
const preActionMatch = filesystemRouteEligible ? options.matchRoute(cleanPathname) : null;
const preActionMatch = filesystemRouteEligible ? matchCleanPathname() : null;
if (preActionMatch) {
setRootParams(pickRootParams(preActionMatch.params, preActionMatch.route.rootParamNames));
}
Expand Down Expand Up @@ -951,10 +963,11 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
if (!afterFilesRewrite) continue;
resolvedUrl = mergeRewriteQuery(resolvedUrl, afterFilesRewrite);
cleanPathname = pathnameForResolvedUrl(resolvedUrl);
cleanPathnameIsRequestPathname = false;
filesystemRouteEligible = true;
const claimedRscCacheBustingRedirect = await validateClaimedOutsideBasePathRsc();
if (claimedRscCacheBustingRedirect) return claimedRscCacheBustingRedirect;
match = options.matchRoute(cleanPathname);
match = matchCleanPathname();
const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static");
if (rewrittenStaticPagesResponse) {
options.clearRequestContext();
Expand Down Expand Up @@ -996,10 +1009,11 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
if (!fallbackRewrite) continue;
resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite);
cleanPathname = pathnameForResolvedUrl(resolvedUrl);
cleanPathnameIsRequestPathname = false;
filesystemRouteEligible = true;
const claimedRscCacheBustingRedirect = await validateClaimedOutsideBasePathRsc();
if (claimedRscCacheBustingRedirect) return claimedRscCacheBustingRedirect;
match = options.matchRoute(cleanPathname);
match = matchCleanPathname();
const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static");
if (rewrittenStaticPagesResponse) {
options.clearRequestContext();
Expand Down Expand Up @@ -1157,6 +1171,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
actionFailed,
handlerStart,
interceptionContext: interceptionContextHeader,
interceptionPathname: cleanPathnameIsRequestPathname ? requestCleanPathname : cleanPathname,
isProgressiveActionRender,
isRscRequest,
middlewareContext,
Expand Down
10 changes: 9 additions & 1 deletion packages/vinext/src/server/app-rsc-request-normalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export type NormalizedRscRequest = {
pathname: string;
/** Pathname with `.rsc` suffix removed. Used for route matching and navigation context. */
cleanPathname: string;
/** Original encoded request pathname with basePath and `.rsc` removed. */
requestCleanPathname: string;
/** True when the request targets a canonical `.rsc` payload URL. */
isRscRequest: boolean;
/** Sanitized X-Vinext-Interception-Context header (null bytes stripped). null when absent. */
Expand Down Expand Up @@ -100,6 +102,7 @@ export function normalizeRscRequest(

// Step 4: Collapse double-slashes and resolve . / .. segments.
let pathname = normalizePath(decoded);
let requestPathname = url.pathname;
let hadBasePath = true;

// Step 5: basePath check and strip.
Expand All @@ -111,12 +114,16 @@ export function normalizeRscRequest(
if (!hadBasePath && !pathname.startsWith("/__vinext/") && !allowOutsideBasePath) {
return notFoundResponse();
}
if (hadBasePath) pathname = stripBasePath(pathname, basePath);
if (hadBasePath) {
pathname = stripBasePath(pathname, basePath);
requestPathname = stripBasePath(requestPathname, basePath);
}
}

// Steps 6-7: RSC detection and cleanPathname.
const isRscRequest = pathname.endsWith(".rsc") || request.headers.get(RSC_HEADER) === "1";
const cleanPathname = stripRscSuffix(pathname);
const requestCleanPathname = stripRscSuffix(requestPathname);

// Step 8: Validate and sanitize X-Vinext-Interception-Context.
//
Expand Down Expand Up @@ -146,6 +153,7 @@ export function normalizeRscRequest(
url,
pathname,
cleanPathname,
requestCleanPathname,
isRscRequest,
interceptionContextHeader,
mountedSlotsHeader,
Expand Down
Loading
Loading