Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,10 @@ jobs:
label: app-front-redirect-issue
shardIndex: 1
shardTotal: 1
- project: app-basepath
label: app-basepath
shardIndex: 1
shardTotal: 1
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
Expand Down
1 change: 1 addition & 0 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export default {
ignoreFiles: [
"tests/e2e/app-router/nextjs-compat/playwright.nextjs-compat.config.ts",
"tests/e2e/app-front-redirect-issue/fixture/**/*.{js,ts,tsx}",
"tests/e2e/app-basepath/fixture/**/*.{js,ts,tsx}",
// stub module loaded via `path.resolve()` as a Vite alias target
"packages/vinext/src/client/empty-module.ts",
],
Expand Down
23 changes: 16 additions & 7 deletions packages/vinext/src/server/app-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type ApplyAppMiddlewareOptions = {
basePath?: string;
cleanPathname: string;
context: AppMiddlewareContext;
hadBasePath?: boolean;
i18nConfig?: NextI18nConfig | null;
/**
* Whether the inbound request was a `_next/data` fetch. Captured from the
Expand All @@ -44,6 +45,13 @@ export type ApplyAppMiddlewareResult =
kind: "continue";
cleanPathname: string;
search: string | null;
/**
* True only when middleware (or a forwarded dev-mode middleware context)
* explicitly rewrote the request to a new pathname. Callers use this to
* decide whether out-of-basePath requests become eligible for filesystem
* route matching.
*/
didRewrite: boolean;
}
| {
kind: "response";
Expand Down Expand Up @@ -212,6 +220,7 @@ export async function applyAppMiddleware(
const middlewareRequest = requestWithoutFlightHeaders(options.request);
let cleanPathname = options.cleanPathname;
let search: string | null = null;
let didRewrite = false;

if (forwarded.rewriteUrl) {
try {
Expand All @@ -228,6 +237,7 @@ export async function applyAppMiddleware(
const rewriteParsed = new URL(forwarded.rewriteUrl, middlewareRequest.url);
cleanPathname = rewriteParsed.pathname;
search = rewriteParsed.search;
didRewrite = true;
} catch (e) {
console.error("[vinext] Failed to apply forwarded middleware rewrite:", e);
forwarded.applied = false;
Expand All @@ -237,12 +247,10 @@ export async function applyAppMiddleware(
if (!forwarded.applied) {
const result = await executeMiddleware({
basePath: options.basePath,
// The App Router only reaches middleware when the request was under
// basePath (already stripped by normalizeRscRequest) or basePath is
// empty — see the basePathState comment in app-rsc-handler.ts. The
// request URL here is basePath-stripped, so hadBasePath cannot be
// derived from it and must be asserted explicitly.
hadBasePath: true,
// App Router requests may be basePath-stripped before middleware, so the
// URL itself is not always enough to infer whether the original request
// carried the configured basePath.
hadBasePath: options.hadBasePath ?? true,
filePath: options.filePath,
i18nConfig: options.i18nConfig,
isDataRequest: options.isDataRequest,
Expand Down Expand Up @@ -288,6 +296,7 @@ export async function applyAppMiddleware(
const rewriteParsed = new URL(result.rewriteUrl, middlewareRequest.url);
cleanPathname = rewriteParsed.pathname;
search = rewriteParsed.search;
didRewrite = true;
}
}

Expand All @@ -297,5 +306,5 @@ export async function applyAppMiddleware(
processMiddlewareHeaders(options.context.headers);
}

return { kind: "continue", cleanPathname, search };
return { kind: "continue", cleanPathname, search, didRewrite };
}
121 changes: 78 additions & 43 deletions packages/vinext/src/server/app-rsc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,12 +330,17 @@ function isExecutionContextLike(value: unknown): value is ExecutionContextLike {
return hasProperty(value, "waitUntil") && typeof value.waitUntil === "function";
}

// TODO(#1333): once App Router supports `basePath: false` rules (see
// `normalizeRscRequest` — it 404s out-of-basePath requests before they
// reach this code), pass `hadBasePath` here and skip the prefix when
// false, mirroring the same guard in `prod-server.ts` and `deploy.ts`.
function redirectDestinationWithBasePath(destination: string, basePath: string): string {
if (!basePath || isExternalUrl(destination) || hasBasePath(destination, basePath)) {
function redirectDestinationWithBasePath(
destination: string,
basePath: string,
hadBasePath: boolean,
): string {
if (
!basePath ||
!hadBasePath ||
isExternalUrl(destination) ||
hasBasePath(destination, basePath)
) {
return destination;
}
return basePath + destination;
Expand Down Expand Up @@ -465,10 +470,13 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
if (originBlock) return originBlock;
}

const normalized = normalizeRscRequest(request, options.basePath);
const normalized = normalizeRscRequest(request, options.basePath, {
allowOutOfBasePath: true,
});
if (normalized instanceof Response) return normalized;

const {
hadBasePath,
url,
isRscRequest,
interceptionContextHeader,
Expand All @@ -488,13 +496,11 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
// "should have the canonical url pathname on rewrite"
const canonicalPathname = cleanPathname;

// The request reached this point so it was either under basePath (stripped
// by normalizeRscRequest) or basePath is empty. In both cases the matcher
// gating below treats default (basePath: true) rules as eligible. The App
// Router does not yet support `basePath: false` rules — they would need a
// pre-strip hook in normalizeRscRequest to fire. Tracked as follow-up to
// issue #1333.
const basePathState = { basePath: options.basePath, hadBasePath: true };
const basePathState = { basePath: options.basePath, hadBasePath };
let configRewriteFired = false;
let didMiddlewareRewrite = false;
const canUseFilesystemRoutes = (): boolean =>
!options.basePath || hadBasePath || didMiddlewareRewrite || configRewriteFired;

if (
pathname === VINEXT_PRERENDER_STATIC_PARAMS_PATH ||
Expand All @@ -513,12 +519,19 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
if (prerenderEndpointResponse) return prerenderEndpointResponse;
}

const trailingSlashRedirect = normalizeTrailingSlash(
pathname,
options.basePath,
options.trailingSlash,
url.search,
);
// Trailing-slash normalisation is a basePath-scoped internal redirect in
// Next.js: the auto-generated `/:path+/` → `/:path+` rule carries the
// basePath (no `basePath: false`), so it compiles to `/base/:path+/` and
// never matches out-of-basePath requests. `normalizeTrailingSlash` instead
// unconditionally rebuilds the Location as `basePath + pathname`, which for a
// delayed-rejection out-of-basePath request (`hadBasePath === false`,
// `pathname` still un-stripped) would emit a bogus `/base/...` redirect and
// push the request back under basePath before its `basePath: false` rewrite
// can match. Gate on `hadBasePath` (true whenever basePath is empty or the
// request was in-basePath) to mirror Next.js load-custom-routes.ts.
const trailingSlashRedirect = hadBasePath
? normalizeTrailingSlash(pathname, options.basePath, options.trailingSlash, url.search)
: null;
if (trailingSlashRedirect) return trailingSlashRedirect;

// Default-locale path normalisation (issue #1336, item 4). Next.js
Expand All @@ -542,7 +555,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
);
if (redirect) {
const destination = sanitizeDestination(
redirectDestinationWithBasePath(redirect.destination, options.basePath),
redirectDestinationWithBasePath(redirect.destination, options.basePath, hadBasePath),
);
// For RSC navigations `createRscRedirectLocation` recomputes the
// cache-busting `_rsc` param onto the Location. For plain (document)
Expand Down Expand Up @@ -580,14 +593,13 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
requestHeaders: null,
status: null,
};
let didMiddlewareRewrite = false;

if (options.middlewareModule) {
const { applyAppMiddleware } = await import("./app-middleware.js");
const middlewareResult = await applyAppMiddleware({
basePath: options.basePath,
cleanPathname,
context: middlewareContext,
hadBasePath,
filePath: options.middlewareFilePath ?? undefined,
i18nConfig: options.i18nConfig,
isDataRequest,
Expand All @@ -606,7 +618,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
}

cleanPathname = middlewareResult.cleanPathname;
didMiddlewareRewrite = cleanPathname !== normalized.cleanPathname;
didMiddlewareRewrite = middlewareResult.didRewrite;
if (middlewareResult.search !== null) {
url.search = middlewareResult.search;
}
Expand Down Expand Up @@ -642,10 +654,11 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
if (beforeFilesRewrite) {
resolvedUrl = mergeRewriteQuery(resolvedUrl, beforeFilesRewrite);
cleanPathname = pathnameForResolvedUrl(resolvedUrl);
configRewriteFired = true;
}
}

if (isImageOptimizationPath(cleanPathname)) {
if (canUseFilesystemRoutes() && isImageOptimizationPath(cleanPathname)) {
const imageRedirect = resolveDevImageRedirect(
url,
[
Expand All @@ -660,7 +673,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
return Response.redirect(new URL(imageRedirect, url.origin).href, 302);
}

if (options.metadataRoutes.length > 0) {
if (canUseFilesystemRoutes() && options.metadataRoutes.length > 0) {
const { handleMetadataRouteRequest } = await import("./metadata-route-response.js");
const metadataRouteResponse = await handleMetadataRouteRequest({
metadataRoutes: options.metadataRoutes,
Expand All @@ -679,13 +692,15 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
}
}

const publicFileResponse = resolvePublicFileRoute({
cleanPathname,
middlewareContext,
pathname,
publicFiles: options.publicFiles,
request,
});
const publicFileResponse = canUseFilesystemRoutes()
? resolvePublicFileRoute({
cleanPathname,
middlewareContext,
pathname,
publicFiles: options.publicFiles,
request,
})
: null;
if (publicFileResponse) {
options.clearRequestContext();
return publicFileResponse;
Expand Down Expand Up @@ -716,7 +731,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
// page renders, so there is no stale-value risk for ordinary page renders.
// For action requests we intentionally do not re-run rewrites — actions
// are always processed against the cleanPathname they were posted to.
const preActionMatch = options.matchRoute(cleanPathname);
const preActionMatch = canUseFilesystemRoutes() ? options.matchRoute(cleanPathname) : null;
if (preActionMatch) {
setRootParams(pickRootParams(preActionMatch.params, preActionMatch.route.rootParamNames));
}
Expand All @@ -726,8 +741,19 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
const contentType = request.headers.get("content-type") || "";

const isPostRequest = request.method.toUpperCase() === "POST";
// Server actions mutate state and load route modules internally via
// matchRoute(cleanPathname), so they must share the same filesystem
// eligibility gate as visible page matching. Without this, an out-of-basePath
// POST with an action id could execute app code that canUseFilesystemRoutes()
// was designed to block.
const canDispatchAppRoute = canUseFilesystemRoutes();
let progressiveActionResult: Response | ProgressiveActionFormStateResult | null = null;
if (isPostRequest && contentType.startsWith("multipart/form-data") && !actionId) {
if (
canDispatchAppRoute &&
isPostRequest &&
contentType.startsWith("multipart/form-data") &&
!actionId
) {
progressiveActionResult = await options.handleProgressiveActionRequest({
actionId,
cleanPathname,
Expand All @@ -749,7 +775,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
const actionError = failedProgressiveActionResult?.actionError;

const serverActionResponse =
isPostRequest && actionId
canDispatchAppRoute && isPostRequest && actionId
? await options.handleServerActionRequest({
actionId,
cleanPathname,
Expand All @@ -768,6 +794,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
const renderPagesForMatchKind = async (
matchKind: "dynamic" | "static",
): Promise<Response | null> => {
if (!canUseFilesystemRoutes()) return null;
const response =
match === null || match.route.isDynamic
? ((await options.renderPagesFallback?.({
Expand Down Expand Up @@ -819,6 +846,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
if (!afterFilesRewrite) continue;
resolvedUrl = mergeRewriteQuery(resolvedUrl, afterFilesRewrite);
cleanPathname = pathnameForResolvedUrl(resolvedUrl);
configRewriteFired = true;
match = options.matchRoute(cleanPathname);
const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static");
if (rewrittenStaticPagesResponse) {
Expand Down Expand Up @@ -861,6 +889,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
if (!fallbackRewrite) continue;
resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite);
cleanPathname = pathnameForResolvedUrl(resolvedUrl);
configRewriteFired = true;
match = options.matchRoute(cleanPathname);
const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static");
if (rewrittenStaticPagesResponse) {
Expand Down Expand Up @@ -894,13 +923,19 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
return new Response("", { status: 404 });
}

const renderedNotFoundResponse = await options.renderNotFound({
isRscRequest,
middlewareContext,
request,
route: null,
scriptNonce,
});
// The not-found page is a filesystem route. An out-of-basePath request that
// no rewrite opted back in must not render it: doing so loads the app's
// client references and (under startProdServer's process-wide RSC globals)
// corrupts the active manifest. Fall through to a plain 404 instead.
const renderedNotFoundResponse = canUseFilesystemRoutes()
? await options.renderNotFound({
isRscRequest,
middlewareContext,
request,
route: null,
scriptNonce,
})
: null;
if (renderedNotFoundResponse) return renderedNotFoundResponse;

options.clearRequestContext();
Expand Down
Loading
Loading