From d8de64773be0285c67efa27717391f2b58bf47ee Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 18 Jun 2026 01:36:29 +1000 Subject: [PATCH 1/5] fix(app-router): honor basePath false rewrites outside basePath App Router requests outside a configured basePath were rejected during request normalization before next.config rewrites could match. This made same-origin server-action redirects to an absolute URL outside basePath fall through to 404 even when a basePath:false external rewrite should proxy it. The broken invariant was that basePath membership has to remain visible through rewrite matching, not be collapsed into an early 404. Normalization now records whether the original request had the basePath and lets the App Router delay rejection; filesystem routes stay gated until a config or middleware rewrite makes the request routable. Adds focused handler and normalization coverage plus an E2E port of the upstream app-basepath server action redirect cases. --- .github/workflows/ci.yml | 4 + knip.ts | 1 + packages/vinext/src/server/app-middleware.ts | 11 +- packages/vinext/src/server/app-rsc-handler.ts | 75 +++++++----- .../server/app-rsc-request-normalization.ts | 26 ++++- playwright.config.ts | 12 ++ tests/app-rsc-handler.test.ts | 72 ++++++++++++ tests/app-rsc-request-normalization.test.ts | 10 ++ tests/e2e/app-basepath/app-basepath.spec.ts | 107 ++++++++++++++++++ .../app-basepath/fixture/app/another/page.tsx | 3 + .../app-basepath/fixture/app/client/action.ts | 9 ++ .../app-basepath/fixture/app/client/page.tsx | 31 +++++ tests/e2e/app-basepath/fixture/app/layout.tsx | 7 ++ tests/e2e/app-basepath/fixture/app/page.tsx | 3 + tests/e2e/app-basepath/fixture/next.config.js | 15 +++ tests/e2e/app-basepath/fixture/package.json | 5 + tests/e2e/app-basepath/fixture/vite.config.ts | 6 + 17 files changed, 355 insertions(+), 42 deletions(-) create mode 100644 tests/e2e/app-basepath/app-basepath.spec.ts create mode 100644 tests/e2e/app-basepath/fixture/app/another/page.tsx create mode 100644 tests/e2e/app-basepath/fixture/app/client/action.ts create mode 100644 tests/e2e/app-basepath/fixture/app/client/page.tsx create mode 100644 tests/e2e/app-basepath/fixture/app/layout.tsx create mode 100644 tests/e2e/app-basepath/fixture/app/page.tsx create mode 100644 tests/e2e/app-basepath/fixture/next.config.js create mode 100644 tests/e2e/app-basepath/fixture/package.json create mode 100644 tests/e2e/app-basepath/fixture/vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a28a0d435..125eb4872 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -346,6 +346,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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: diff --git a/knip.ts b/knip.ts index b847945f2..a520c3b9a 100644 --- a/knip.ts +++ b/knip.ts @@ -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", ], diff --git a/packages/vinext/src/server/app-middleware.ts b/packages/vinext/src/server/app-middleware.ts index 06eb9c482..b604da30e 100644 --- a/packages/vinext/src/server/app-middleware.ts +++ b/packages/vinext/src/server/app-middleware.ts @@ -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 @@ -236,12 +237,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, i18nConfig: options.i18nConfig, isDataRequest: options.isDataRequest, isProxy: options.isProxy, diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index bae292256..4fb6393be 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -323,12 +323,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; @@ -458,10 +463,13 @@ async function handleAppRscRequest( 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, @@ -481,13 +489,11 @@ async function handleAppRscRequest( // "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; const prerenderEndpointResponse = await handleAppPrerenderEndpoint(request, { isPrerenderEnabled() { @@ -529,7 +535,7 @@ async function handleAppRscRequest( ); 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) @@ -567,13 +573,12 @@ async function handleAppRscRequest( requestHeaders: null, status: null, }; - let didMiddlewareRewrite = false; - if (options.middlewareModule) { const middlewareResult = await applyAppMiddleware({ basePath: options.basePath, cleanPathname, context: middlewareContext, + hadBasePath, i18nConfig: options.i18nConfig, isDataRequest, isProxy: options.isMiddlewareProxy, @@ -627,10 +632,11 @@ async function handleAppRscRequest( if (beforeFilesRewrite) { resolvedUrl = mergeRewriteQuery(resolvedUrl, beforeFilesRewrite); cleanPathname = pathnameForResolvedUrl(resolvedUrl); + configRewriteFired = true; } } - if (isImageOptimizationPath(cleanPathname)) { + if (canUseFilesystemRoutes() && isImageOptimizationPath(cleanPathname)) { const imageRedirect = resolveDevImageRedirect( url, [ @@ -645,11 +651,13 @@ async function handleAppRscRequest( return Response.redirect(new URL(imageRedirect, url.origin).href, 302); } - const metadataRouteResponse = await handleMetadataRouteRequest({ - metadataRoutes: options.metadataRoutes, - cleanPathname, - makeThenableParams: options.makeThenableParams, - }); + const metadataRouteResponse = canUseFilesystemRoutes() + ? await handleMetadataRouteRequest({ + metadataRoutes: options.metadataRoutes, + cleanPathname, + makeThenableParams: options.makeThenableParams, + }) + : null; if (metadataRouteResponse) { applyConfigHeadersToResponse(metadataRouteResponse.headers, { basePathState, @@ -661,13 +669,15 @@ async function handleAppRscRequest( return applyMiddlewareContextToResponse(metadataRouteResponse, middlewareContext); } - 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; @@ -698,7 +708,7 @@ async function handleAppRscRequest( // 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)); } @@ -741,6 +751,7 @@ async function handleAppRscRequest( const renderPagesForMatchKind = async ( matchKind: "dynamic" | "static", ): Promise => { + if (!canUseFilesystemRoutes()) return null; const response = match === null || match.route.isDynamic ? ((await options.renderPagesFallback?.({ @@ -792,6 +803,7 @@ async function handleAppRscRequest( if (!afterFilesRewrite) continue; resolvedUrl = mergeRewriteQuery(resolvedUrl, afterFilesRewrite); cleanPathname = pathnameForResolvedUrl(resolvedUrl); + configRewriteFired = true; match = options.matchRoute(cleanPathname); const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static"); if (rewrittenStaticPagesResponse) { @@ -834,6 +846,7 @@ async function handleAppRscRequest( if (!fallbackRewrite) continue; resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite); cleanPathname = pathnameForResolvedUrl(resolvedUrl); + configRewriteFired = true; match = options.matchRoute(cleanPathname); const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static"); if (rewrittenStaticPagesResponse) { diff --git a/packages/vinext/src/server/app-rsc-request-normalization.ts b/packages/vinext/src/server/app-rsc-request-normalization.ts index 1caa5e4b3..922a7d534 100644 --- a/packages/vinext/src/server/app-rsc-request-normalization.ts +++ b/packages/vinext/src/server/app-rsc-request-normalization.ts @@ -28,6 +28,8 @@ export { normalizeMountedSlotsHeader } from "./app-mounted-slots-header.js"; export type NormalizedRscRequest = { /** Parsed URL. Callers may mutate `url.search` after middleware runs. */ url: URL; + /** True when the incoming pathname was originally under the configured basePath. */ + hadBasePath: boolean; /** Normalized pathname with basePath stripped. Used for all internal routing. */ pathname: string; /** Pathname with `.rsc` suffix removed. Used for route matching and navigation context. */ @@ -59,8 +61,9 @@ export type NormalizedRscRequest = { * 3. Strict percent-decode each segment — throws on malformed sequences (→ 400). Must * run before basePath check so %2F-encoded slashes cannot create fake basePath prefixes. * 4. Collapse double-slashes, resolve `.` and `..` segments (normalizePath) - * 5. basePath check + strip — 404 when pathname lacks the basePath prefix. - * `/__vinext/` bypasses this for internal prerender endpoints. + * 5. basePath check + strip — 404 when pathname lacks the basePath prefix, + * unless the caller opts into delayed rejection so config `basePath:false` + * rules can match. `/__vinext/` bypasses this for internal prerender endpoints. * 6. RSC detection: `.rsc` suffix, or Next-style `RSC: 1` plus the internal * `_rsc` cache-busting query. The header alone does not select payload * rendering at the canonical HTML URL, so caches that ignore Vary cannot @@ -77,6 +80,7 @@ export type NormalizedRscRequest = { export function normalizeRscRequest( request: Request, basePath: string, + options: { allowOutOfBasePath?: boolean } = {}, ): Response | NormalizedRscRequest { const url = new URL(request.url); @@ -101,13 +105,24 @@ export function normalizeRscRequest( // Step 5: basePath check and strip. // Skipped when basePath is empty (no basePath configured). + // When allowOutOfBasePath is set, keep out-of-basePath paths unstripped and + // report hadBasePath=false so the caller can evaluate basePath:false rules + // before deciding whether to 404. // /__vinext/ prefix bypasses the check for internal prerender endpoints // that must be reachable regardless of basePath configuration. + let hadBasePath = true; if (basePath) { - if (!hasBasePath(pathname, basePath) && !pathname.startsWith("/__vinext/")) { - return notFoundResponse(); + hadBasePath = hasBasePath(pathname, basePath); + if (!hadBasePath && !pathname.startsWith("/__vinext/")) { + if (!options.allowOutOfBasePath) { + return notFoundResponse(); + } + } else if (hadBasePath) { + pathname = stripBasePath(pathname, basePath); + } + if (pathname.startsWith("/__vinext/")) { + hadBasePath = true; } - pathname = stripBasePath(pathname, basePath); } // Steps 6-7: RSC detection and cleanPathname. @@ -141,6 +156,7 @@ export function normalizeRscRequest( return { clientReuseManifest, + hadBasePath, url, pathname, cleanPathname, diff --git a/playwright.config.ts b/playwright.config.ts index 525f4f180..3bba00a9e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -263,6 +263,18 @@ const projectServers = { timeout: 60_000, }, }, + "app-basepath": { + testDir: "./tests/e2e/app-basepath", + use: { baseURL: "http://localhost:4190" }, + server: { + command: + "(test -e node_modules || test -L node_modules || ln -s ../../../fixtures/app-basic/node_modules node_modules) && npx vp run vinext#build && node ../../../../packages/vinext/dist/cli.js build && node ../../../../packages/vinext/dist/cli.js start --port 4190", + cwd: "./tests/e2e/app-basepath/fixture", + port: 4190, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + }, "pages-router-basepath-dev": { testDir: "./tests/e2e/pages-router-basepath-dev", use: { baseURL: "http://localhost:4189" }, diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 22afef120..07f45268d 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -900,6 +900,46 @@ describe("createAppRscHandler", () => { } }); + it("applies basePath:false external rewrites to App Router requests outside basePath", async () => { + // Ported from Next.js: test/e2e/app-dir/app-basepath/index.test.ts + // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app-basepath/index.test.ts + const receivedUrls: string[] = []; + const server = createServer((req, res) => { + receivedUrls.push(req.url ?? ""); + res.writeHead(200, { "content-type": "text/plain" }); + res.end("outside basePath"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + const upstreamBase = `http://127.0.0.1:${address.port}`; + + try { + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [], + afterFiles: [ + { + source: "/outsideBasePath", + destination: `${upstreamBase}/`, + basePath: false, + }, + ], + fallback: [], + }, + matchRoute: () => null, + }); + + const response = await handler(new Request("https://example.test/outsideBasePath"), null); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("outside basePath"); + expect(receivedUrls).toEqual(["/"]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("preserves Node route handler RSC URLs while hiding internal parsed params", async () => { // Ported from Next.js: // test/e2e/app-dir/front-redirect-issue/front-redirect-issue.test.ts @@ -1814,6 +1854,38 @@ describe("createAppRscHandler", () => { expect(matchRoute).not.toHaveBeenCalled(); }); + it("does not expose filesystem endpoints for out-of-basePath App Router requests", async () => { + const handler = createHandler({ + configHeaders: [], + imageConfig: { deviceSizes: [320], imageSizes: [], qualities: [75] }, + matchRoute: () => null, + metadataRoutes: [ + { + type: "favicon", + isDynamic: false, + filePath: "/tmp/app/favicon.ico", + routePrefix: "", + routeSegments: [], + servedUrl: "/favicon.ico", + contentType: "image/x-icon", + fileDataBase64: btoa("icon-bytes"), + }, + ], + publicFiles: new Set(["/logo.svg"]), + }); + + const publicFileResponse = await handler(new Request("https://example.test/logo.svg"), null); + const imageResponse = await handler( + new Request("https://example.test/_next/image?url=%2Fimg.jpg&w=320&q=75"), + null, + ); + const metadataResponse = await handler(new Request("https://example.test/favicon.ico"), null); + + expect(publicFileResponse.status).toBe(404); + expect(imageResponse.status).toBe(404); + expect(metadataResponse.status).toBe(404); + }); + it("lets middleware Cache-Control override static metadata route defaults", async () => { // Ported from Next.js: test/e2e/app-dir/no-duplicate-headers-middleware/no-duplicate-headers-middleware.test.ts // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/no-duplicate-headers-middleware/no-duplicate-headers-middleware.test.ts diff --git a/tests/app-rsc-request-normalization.test.ts b/tests/app-rsc-request-normalization.test.ts index 50e848339..b01795771 100644 --- a/tests/app-rsc-request-normalization.test.ts +++ b/tests/app-rsc-request-normalization.test.ts @@ -135,8 +135,18 @@ describe("normalizeRscRequest — basePath", () => { expect((result as Response).status).toBe(404); }); + it("can preserve out-of-basePath pathname for delayed basePath:false rule evaluation", () => { + const result = normalized( + normalizeRscRequest(req("/outsideBasePath"), "/app", { allowOutOfBasePath: true }), + ); + expect(result.hadBasePath).toBe(false); + expect(result.pathname).toBe("/outsideBasePath"); + expect(result.cleanPathname).toBe("/outsideBasePath"); + }); + it("strips basePath prefix so internal routing sees basePath-free pathname", () => { const result = normalized(normalizeRscRequest(req("/app/dashboard"), "/app")); + expect(result.hadBasePath).toBe(true); expect(result.pathname).toBe("/dashboard"); }); diff --git a/tests/e2e/app-basepath/app-basepath.spec.ts b/tests/e2e/app-basepath/app-basepath.spec.ts new file mode 100644 index 000000000..abaf56f9e --- /dev/null +++ b/tests/e2e/app-basepath/app-basepath.spec.ts @@ -0,0 +1,107 @@ +import { createServer, type Server } from "node:http"; +import type { Request, Response } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { waitForAppRouterHydration } from "../helpers"; + +// Ported from Next.js: test/e2e/app-dir/app-basepath/index.test.ts +// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app-basepath/index.test.ts + +const BASE = "http://localhost:4190"; +const EXTERNAL_PORT = 4191; + +let externalServer: Server; + +test.beforeAll(async () => { + externalServer = createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end("

outside basePath

"); + }); + await new Promise((resolve) => externalServer.listen(EXTERNAL_PORT, "127.0.0.1", resolve)); +}); + +test.afterAll(async () => { + await new Promise((resolve, reject) => { + externalServer.close((error) => (error ? reject(error) : resolve())); + }); +}); + +test.describe("app dir - basepath", () => { + test("streams internal server action redirect() responses under basePath", async ({ page }) => { + for (const buttonId of ["redirect-relative", "redirect-absolute-internal"]) { + const requests: Request[] = []; + const responses: Response[] = []; + const initialPagePath = "/base/client"; + const destinationPagePath = "/base/another"; + + await page.goto(`${BASE}${initialPagePath}`); + await waitForAppRouterHydration(page); + + const onRequest = (req: Request) => { + const url = req.url(); + if (url.includes(initialPagePath) || url.includes(destinationPagePath)) { + requests.push(req); + } + }; + const onResponse = (res: Response) => { + const url = res.url(); + if (url.includes(initialPagePath) || url.includes(destinationPagePath)) { + responses.push(res); + } + }; + page.on("request", onRequest); + page.on("response", onResponse); + + await page.locator(`#${buttonId}`).click(); + await expect(page).toHaveURL(new RegExp(`${BASE}/base/another$`)); + await expect(page.locator("#page-2")).toHaveText("Page 2"); + + expect(requests).toHaveLength(1); + expect(responses).toHaveLength(1); + expect(requests[0].url()).toBe(`${BASE}${initialPagePath}`); + expect(requests[0].method()).toBe("POST"); + expect(responses[0].status()).toBe(303); + + page.off("request", onRequest); + page.off("response", onResponse); + } + }); + + test("redirects externally for absolute same-origin URLs outside basePath", async ({ page }) => { + const initialPagePath = "/base/client"; + const destinationPagePath = "/outsideBasePath"; + const requests: Request[] = []; + const responses: Response[] = []; + + await page.goto(`${BASE}${initialPagePath}`); + await waitForAppRouterHydration(page); + + page.on("request", (req) => { + if (!req.url().includes("_next")) { + requests.push(req); + } + }); + page.on("response", (res) => { + if (!res.url().includes("_next")) { + responses.push(res); + } + }); + + await page.locator("#redirect-absolute-external").click(); + await expect(page).toHaveURL(new RegExp(`${BASE}${destinationPagePath}$`)); + + expect(requests).toHaveLength(2); + expect(responses).toHaveLength(2); + + const [firstRequest, secondRequest] = requests; + const [firstResponse, secondResponse] = responses; + + expect(firstRequest.url()).toBe(`${BASE}${initialPagePath}`); + expect(firstRequest.method()).toBe("POST"); + + expect(secondRequest.url()).toBe(`${BASE}${destinationPagePath}`); + expect(secondRequest.method()).toBe("GET"); + + expect(firstResponse.status()).toBe(303); + expect(secondResponse.status()).toBe(200); + }); +}); diff --git a/tests/e2e/app-basepath/fixture/app/another/page.tsx b/tests/e2e/app-basepath/fixture/app/another/page.tsx new file mode 100644 index 000000000..82c96789d --- /dev/null +++ b/tests/e2e/app-basepath/fixture/app/another/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Page 2
; +} diff --git a/tests/e2e/app-basepath/fixture/app/client/action.ts b/tests/e2e/app-basepath/fixture/app/client/action.ts new file mode 100644 index 000000000..f797e4021 --- /dev/null +++ b/tests/e2e/app-basepath/fixture/app/client/action.ts @@ -0,0 +1,9 @@ +"use server"; + +import "server-only"; + +import { redirect } from "next/navigation"; + +export async function redirectAction(path: string) { + redirect(path); +} diff --git a/tests/e2e/app-basepath/fixture/app/client/page.tsx b/tests/e2e/app-basepath/fixture/app/client/page.tsx new file mode 100644 index 000000000..71452fc7e --- /dev/null +++ b/tests/e2e/app-basepath/fixture/app/client/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { redirectAction } from "./action"; + +export default function Page() { + return ( +
+
+ +
+
+ +
+
+ +
+
+ ); +} diff --git a/tests/e2e/app-basepath/fixture/app/layout.tsx b/tests/e2e/app-basepath/fixture/app/layout.tsx new file mode 100644 index 000000000..f3ef34cd8 --- /dev/null +++ b/tests/e2e/app-basepath/fixture/app/layout.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/e2e/app-basepath/fixture/app/page.tsx b/tests/e2e/app-basepath/fixture/app/page.tsx new file mode 100644 index 000000000..a209fbba4 --- /dev/null +++ b/tests/e2e/app-basepath/fixture/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Test Page

; +} diff --git a/tests/e2e/app-basepath/fixture/next.config.js b/tests/e2e/app-basepath/fixture/next.config.js new file mode 100644 index 000000000..b803d9126 --- /dev/null +++ b/tests/e2e/app-basepath/fixture/next.config.js @@ -0,0 +1,15 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + basePath: "/base", + async rewrites() { + return [ + { + source: "/outsideBasePath", + destination: "http://127.0.0.1:4191/", + basePath: false, + }, + ]; + }, +}; + +module.exports = nextConfig; diff --git a/tests/e2e/app-basepath/fixture/package.json b/tests/e2e/app-basepath/fixture/package.json new file mode 100644 index 000000000..5646a57d0 --- /dev/null +++ b/tests/e2e/app-basepath/fixture/package.json @@ -0,0 +1,5 @@ +{ + "name": "app-basepath-fixture", + "private": true, + "type": "module" +} diff --git a/tests/e2e/app-basepath/fixture/vite.config.ts b/tests/e2e/app-basepath/fixture/vite.config.ts new file mode 100644 index 000000000..1c4e64dbb --- /dev/null +++ b/tests/e2e/app-basepath/fixture/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import vinext from "../../../../packages/vinext/src/index.js"; + +export default defineConfig({ + plugins: [vinext({ appDir: import.meta.dirname })], +}); From 4ae6f90cf053c676ac7dea60b5a593238560480e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:36:46 +1000 Subject: [PATCH 2/5] fix(app-router): gate not-found render behind filesystem eligibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Out-of-basePath App Router requests with no matching rewrite fell through to renderNotFound(), rendering the app's not-found page. That page is a filesystem route, so the render loaded the app's client references. Under startProdServer's process-wide RSC globals, a second build (the basePath prod-server test) thereby corrupted the active client-reference manifest, breaking later requests on the shared server with "client reference not found" — 13 failures in tests/app-router-production-server.test.ts. normalizeRscRequest previously 404'd these requests before they reached the handler, so the not-found render was never exercised. The PR's allowOutOfBasePath path removed that early exit but left renderNotFound ungated, unlike the metadata, public-file, and page handlers. Gate renderNotFound behind canUseFilesystemRoutes() and fall through to a plain 404, matching the other filesystem handlers and Next.js behavior for paths outside basePath with no basePath:false rewrite. --- packages/vinext/src/server/app-rsc-handler.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 4fb6393be..5668da170 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -880,13 +880,19 @@ async function handleAppRscRequest( 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(); From 6b246e38b8393435af177859ccafb8c70541b216 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:22:10 +1000 Subject: [PATCH 3/5] fix(app-router): gate server action dispatch behind basePath filesystem eligibility Out-of-basePath POST requests with an action id could still execute server actions and progressive form actions because handleServerActionRequest and handleProgressiveActionRequest were dispatched based only on method and content-type checks, without consulting the canUseFilesystemRoutes() gate that protects every other filesystem entrypoint. Both action handlers call matchRoute() internally, so they could load and execute app route code for paths that image optimisation, metadata routes, and public files are correctly blocked from reaching. Gate both action dispatch paths behind canUseFilesystemRoutes() so an out-of-basePath request that no middleware or config rewrite opted back in gets 404 before any action code runs. A middleware rewrite that changes the pathname still opens the gate, preserving the basePath:false rewrite flow. Also make applyAppMiddleware return an explicit didRewrite boolean instead of having the caller infer it by comparing cleanPathname before and after. The forensic comparison was serviceable but brittle: it proved the pathname changed, not that middleware explicitly rewrote. The explicit signal makes the boundary mechanical and robust against future code that might modify cleanPathname for non-rewrite reasons. Tests: out-of-basePath server action POST, progressive form POST, and middleware-rewrite-opts-back-in action dispatch. --- packages/vinext/src/server/app-middleware.ts | 12 +++- packages/vinext/src/server/app-rsc-handler.ts | 17 ++++- tests/app-rsc-handler.test.ts | 70 +++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/server/app-middleware.ts b/packages/vinext/src/server/app-middleware.ts index b604da30e..aea2dbcd8 100644 --- a/packages/vinext/src/server/app-middleware.ts +++ b/packages/vinext/src/server/app-middleware.ts @@ -44,6 +44,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"; @@ -212,6 +219,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 { @@ -228,6 +236,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; @@ -285,6 +294,7 @@ export async function applyAppMiddleware( const rewriteParsed = new URL(result.rewriteUrl, middlewareRequest.url); cleanPathname = rewriteParsed.pathname; search = rewriteParsed.search; + didRewrite = true; } } @@ -294,5 +304,5 @@ export async function applyAppMiddleware( processMiddlewareHeaders(options.context.headers); } - return { kind: "continue", cleanPathname, search }; + return { kind: "continue", cleanPathname, search, didRewrite }; } diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index ba280f56b..1b55d0a0b 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -597,7 +597,7 @@ async function handleAppRscRequest( } cleanPathname = middlewareResult.cleanPathname; - didMiddlewareRewrite = cleanPathname !== normalized.cleanPathname; + didMiddlewareRewrite = middlewareResult.didRewrite; if (middlewareResult.search !== null) { url.search = middlewareResult.search; } @@ -719,8 +719,19 @@ async function handleAppRscRequest( 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, @@ -742,7 +753,7 @@ async function handleAppRscRequest( const actionError = failedProgressiveActionResult?.actionError; const serverActionResponse = - isPostRequest && actionId + canDispatchAppRoute && isPostRequest && actionId ? await options.handleServerActionRequest({ actionId, cleanPathname, diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 25df73807..f5077838c 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -1886,6 +1886,76 @@ describe("createAppRscHandler", () => { expect(metadataResponse.status).toBe(404); }); + it("does not dispatch server actions for out-of-basePath POST requests", async () => { + // Server actions mutate state and load route modules internally via + // matchRoute(cleanPathname). They must share the same filesystem + // eligibility gate as visible page matching — an out-of-basePath POST + // with an action id must not execute app code. + const handleServerActionRequest = vi.fn(async () => null); + const handler = createHandler({ + configHeaders: [], + handleServerActionRequest, + }); + + const response = await handler( + new Request("https://example.test/about", { + method: "POST", + headers: { "next-action": "action_123" }, + }), + null, + ); + + expect(response.status).toBe(404); + expect(handleServerActionRequest).not.toHaveBeenCalled(); + }); + + it("does not dispatch progressive form actions for out-of-basePath POST requests", async () => { + const handleProgressiveActionRequest = vi.fn(async () => null); + const handler = createHandler({ + configHeaders: [], + handleProgressiveActionRequest, + }); + + const response = await handler( + new Request("https://example.test/about", { + method: "POST", + headers: { "content-type": "multipart/form-data; boundary=vinext" }, + }), + null, + ); + + expect(response.status).toBe(404); + expect(handleProgressiveActionRequest).not.toHaveBeenCalled(); + }); + + it("allows server actions after a middleware rewrite opts into basePath scope", async () => { + // A middleware rewrite that changes the pathname must make the request + // eligible for action dispatch again — the gate should open, not stay + // closed forever for requests that started out-of-basePath. + const handleServerActionRequest = vi.fn(async () => new Response(null, { status: 200 })); + const handler = createHandler({ + configHeaders: [], + handleServerActionRequest, + middlewareModule: { + default: () => + new Response(null, { + headers: { "x-middleware-rewrite": "https://example.test/docs/about" }, + }), + }, + }); + + const response = await handler( + new Request("https://example.test/about", { + method: "POST", + headers: { "next-action": "action_123" }, + }), + null, + ); + + expect(response.status).toBe(200); + expect(handleServerActionRequest).toHaveBeenCalledTimes(1); + }); + it("lets middleware Cache-Control override static metadata route defaults", async () => { // Ported from Next.js: test/e2e/app-dir/no-duplicate-headers-middleware/no-duplicate-headers-middleware.test.ts // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/no-duplicate-headers-middleware/no-duplicate-headers-middleware.test.ts From a13aeb467f9e443e2df26828135f51ffabeeee46 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:35:51 +1000 Subject: [PATCH 4/5] test(e2e): filter external redirect requests by path under test The external redirect test counted all non-_next requests and expected exactly two. A stray /favicon.ico fetch from Chromium after document navigation would break the toHaveLength(2) assertion even though the behaviour under test was correct. Filter by the two specific paths under test (/base/client and /outsideBasePath), matching the pattern the first test already uses. --- tests/e2e/app-basepath/app-basepath.spec.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/e2e/app-basepath/app-basepath.spec.ts b/tests/e2e/app-basepath/app-basepath.spec.ts index abaf56f9e..2b1ef20e2 100644 --- a/tests/e2e/app-basepath/app-basepath.spec.ts +++ b/tests/e2e/app-basepath/app-basepath.spec.ts @@ -75,16 +75,20 @@ test.describe("app dir - basepath", () => { await page.goto(`${BASE}${initialPagePath}`); await waitForAppRouterHydration(page); - page.on("request", (req) => { - if (!req.url().includes("_next")) { + const onRequest = (req: Request) => { + const url = req.url(); + if (url.includes(initialPagePath) || url.includes(destinationPagePath)) { requests.push(req); } - }); - page.on("response", (res) => { - if (!res.url().includes("_next")) { + }; + const onResponse = (res: Response) => { + const url = res.url(); + if (url.includes(initialPagePath) || url.includes(destinationPagePath)) { responses.push(res); } - }); + }; + page.on("request", onRequest); + page.on("response", onResponse); await page.locator("#redirect-absolute-external").click(); await expect(page).toHaveURL(new RegExp(`${BASE}${destinationPagePath}$`)); From 48b8fe497b9d908c4055fe3a386247214fc05eae Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:12:58 +1000 Subject: [PATCH 5/5] fix(app-router): scope trailing-slash redirect to in-basePath requests Out-of-basePath App Router requests are preserved (not 404'd) so config `basePath: false` rewrites can still match them. But the handler ran `normalizeTrailingSlash` unconditionally, and that helper always rebuilds the Location as `basePath + pathname`. For a delayed-rejection out-of-basePath request `hadBasePath` is false and `pathname` is still un-stripped, so e.g. `GET /outsideBasePath/` (basePath `/base`, trailingSlash false) returned `308 Location: /base/outsideBasePath`, pushing the request back under basePath before its `basePath: false` rewrite could fire. The mirror case happens with `trailingSlash: true` and a request missing the slash. Next.js models trailing-slash normalisation as a basePath-scoped internal redirect: the auto-generated `/:path+/` rule carries the basePath (no `basePath: false`), so it compiles to `/base/:path+/` and structurally never matches out-of-basePath paths. Gate the redirect on `hadBasePath` to match, preserving out-of-basePath requests for their explicit rewrites. Adds handler tests covering both trailingSlash settings, each triggered by the request shape that exercises its redirect branch. --- packages/vinext/src/server/app-rsc-handler.ts | 19 ++++-- tests/app-rsc-handler.test.ts | 59 +++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index c8bc12a1e..bd0451e9d 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -519,12 +519,19 @@ async function handleAppRscRequest( 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 diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 0b76d4338..7e94bb8c7 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -941,6 +941,65 @@ describe("createAppRscHandler", () => { } }); + // Ported from Next.js: test/e2e/app-dir/app-basepath/index.test.ts + // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app-basepath/index.test.ts + // + // Next.js generates its trailing-slash redirect as a basePath-scoped rule + // (`/:path+/` with no `basePath: false`, so it compiles to `/docs/:path+/`). + // It therefore never matches an out-of-basePath request, and such requests + // are not pushed back under basePath before `basePath: false` rewrites run. + // See packages/next/src/lib/load-custom-routes.ts trailing-slash redirects. + // + // Each trailingSlash setting triggers the redirect from the opposite request + // shape: `false` strips a trailing slash, `true` adds one. Both previously + // emitted a bogus `308 Location: /docs/outsideBasePath[/]`. + for (const { trailingSlash, requestPath } of [ + { trailingSlash: false, requestPath: "/outsideBasePath/" }, + { trailingSlash: true, requestPath: "/outsideBasePath" }, + ]) { + it(`does not prepend basePath to out-of-basePath trailing-slash requests before basePath:false rewrites match (trailingSlash:${trailingSlash})`, async () => { + const receivedUrls: string[] = []; + const server = createServer((req, res) => { + receivedUrls.push(req.url ?? ""); + res.writeHead(200, { "content-type": "text/plain" }); + res.end("outside basePath"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + const upstreamBase = `http://127.0.0.1:${address.port}`; + + try { + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [], + afterFiles: [ + { + source: "/outsideBasePath", + destination: `${upstreamBase}/`, + basePath: false, + }, + ], + fallback: [], + }, + matchRoute: () => null, + trailingSlash, + }); + + const response = await handler(new Request(`https://example.test${requestPath}`), null); + + // Must not 308 to `/docs/outsideBasePath[/]`; the basePath:false rewrite + // owns this path and proxies it instead. + expect(response.status).toBe(200); + expect(response.headers.get("location")).toBeNull(); + expect(await response.text()).toBe("outside basePath"); + expect(receivedUrls).toEqual(["/"]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + } + it("preserves Node route handler RSC URLs while hiding internal parsed params", async () => { // Ported from Next.js: // test/e2e/app-dir/front-redirect-issue/front-redirect-issue.test.ts