From 48200963c7314f1ced59de34fc295a05dc4d1ed3 Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Tue, 4 Aug 2026 09:10:11 +0700 Subject: [PATCH 1/4] fix(shims): parse Pages data build IDs --- .../vinext/src/entries/pages-server-entry.ts | 1 + packages/vinext/src/index.ts | 1 + .../vinext/src/server/middleware-runtime.ts | 7 +- packages/vinext/src/server/middleware.ts | 2 + packages/vinext/src/shims/server.ts | 117 +++++++++++++----- tests/entry-templates.test.ts | 1 + tests/middleware-runtime.test.ts | 25 ++++ tests/shims.test.ts | 106 +++++++++++----- 8 files changed, 200 insertions(+), 60 deletions(-) diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index f82f5d0c32..85a51a9125 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -193,6 +193,7 @@ export async function runMiddleware(request, ctx, options) { isProxy: ${JSON.stringify(isProxyFile(middlewarePath))}, module: middlewareModule, request, + skipProxyUrlNormalize: vinextConfig.skipProxyUrlNormalize, trailingSlash: vinextConfig.trailingSlash, }); } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 8574f33f8d..d9460bb8b2 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -5362,6 +5362,7 @@ export const loadServerActionClient = ${ nextConfig?.trailingSlash, opts.isDataRequest, pathname, + nextConfig?.skipProxyUrlNormalize, ); // Forward middleware context to the RSC entry so it can diff --git a/packages/vinext/src/server/middleware-runtime.ts b/packages/vinext/src/server/middleware-runtime.ts index d079a62319..5b83b1fc88 100644 --- a/packages/vinext/src/server/middleware-runtime.ts +++ b/packages/vinext/src/server/middleware-runtime.ts @@ -83,6 +83,8 @@ type ExecuteMiddlewareOptions = { */ requestBodyAlreadyIsolated?: boolean; request: Request; + /** Preserve the raw request URL exposed to middleware/proxy. */ + skipProxyUrlNormalize?: boolean; /** * The user's `trailingSlash` config. Plumbed into the NextRequest's NextURL * so `request.nextUrl.toString()` formats with the configured slash policy, @@ -242,6 +244,7 @@ function createNextRequest( trailingSlash?: boolean, hadBasePath?: boolean, requestBodyAlreadyIsolated = false, + skipProxyUrlNormalize = false, ): NextRequest { const url = new URL(request.url); // Middleware gets an isolated body branch; downstream routing keeps owning @@ -266,11 +269,12 @@ function createNextRequest( mwRequest = new Request(mwUrl, mwRequest); } - const hasNextConfig = basePath || i18nConfig || trailingSlash; + const hasNextConfig = basePath || i18nConfig || trailingSlash || skipProxyUrlNormalize; const nextConfig = hasNextConfig ? { basePath: basePath ?? "", i18n: i18nConfig ?? undefined, + skipProxyUrlNormalize: skipProxyUrlNormalize || undefined, trailingSlash: trailingSlash ?? undefined, } : undefined; @@ -360,6 +364,7 @@ export async function executeMiddleware( options.trailingSlash, hadBasePath, options.requestBodyAlreadyIsolated, + options.skipProxyUrlNormalize, ); if (options.isDataRequest) { Object.defineProperty(nextRequest, "__isData", { diff --git a/packages/vinext/src/server/middleware.ts b/packages/vinext/src/server/middleware.ts index 8bbd2ff7fb..9c24eb701d 100644 --- a/packages/vinext/src/server/middleware.ts +++ b/packages/vinext/src/server/middleware.ts @@ -159,6 +159,7 @@ export async function runMiddleware( trailingSlash?: boolean, isDataRequest?: boolean, normalizedPathname?: string, + skipProxyUrlNormalize?: boolean, ): Promise { // Load the middleware module via the direct-call ModuleRunner. // This bypasses the hot channel entirely and is safe with all Vite plugin @@ -183,6 +184,7 @@ export async function runMiddleware( module: mod, normalizedPathname, request, + skipProxyUrlNormalize, trailingSlash, }); } diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 10aaeb6228..0dce2af804 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -109,6 +109,8 @@ export type RequestInit = globalThis.RequestInit & { }>; } | null; trailingSlash?: boolean; + /** @internal Preserve the original URL shape exposed to middleware/proxy. */ + skipProxyUrlNormalize?: boolean; }; signal?: AbortSignal; duplex?: "half"; @@ -168,13 +170,18 @@ export class NextRequest extends Request { const urlConfig: NextURLConfig | undefined = _nextConfig ? { basePath: _nextConfig.basePath, - nextConfig: { i18n, trailingSlash: _nextConfig.trailingSlash }, + nextConfig: { + i18n, + trailingSlash: _nextConfig.trailingSlash, + skipProxyUrlNormalize: _nextConfig.skipProxyUrlNormalize, + }, } : undefined; this._nextUrl = new NextURL(url, undefined, urlConfig); - this._url = process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE - ? url.toString() - : this._nextUrl.toString(); + this._url = + process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE || _nextConfig?.skipProxyUrlNormalize + ? url.toString() + : this._nextUrl.toString(); this._cookies = new RequestCookies(this.headers); } @@ -239,9 +246,8 @@ export class NextRequest extends Request { } /** - * The build ID of the Next.js application. - * Delegates to `nextUrl.buildId` to match Next.js API surface. - * Can be used in middleware to detect deployment skew between client and server. + * The build ID encoded in a Pages Router `/_next/data/` request URL. + * Delegates to `nextUrl.buildId`. */ get buildId(): string | undefined { return this._nextUrl.buildId; @@ -358,12 +364,16 @@ export type NextURLConfig = { * honour the user's `trailingSlash` config. */ trailingSlash?: boolean; + /** @internal Preserve the original URL shape exposed to middleware/proxy. */ + skipProxyUrlNormalize?: boolean; }; }; export class NextURL { /** Internal URL stores the pathname WITHOUT basePath or locale prefix. */ private _url: URL; + /** Build ID parsed from a Pages Router `/_next/data/` URL. */ + private _buildId: string | undefined; /** * The configured basePath (from nextConfig). May differ from the active * `_basePath`: parsing only activates basePath when the URL's pathname @@ -372,6 +382,7 @@ export class NextURL { private _configBasePath: string; private _basePath: string; private _trailingSlash: boolean; + private _skipProxyUrlNormalize: boolean; private _locale: string | undefined; private _configDefaultLocale: string | undefined; private _defaultLocale: string | undefined; @@ -386,7 +397,9 @@ export class NextURL { this._configBasePath = config?.basePath ?? ""; this._basePath = this._configBasePath; this._trailingSlash = config?.nextConfig?.trailingSlash ?? false; + this._skipProxyUrlNormalize = config?.nextConfig?.skipProxyUrlNormalize ?? false; this._stripBasePath(); + const nextDataPathname = this._analyzeNextDataPath(); const i18n = config?.nextConfig?.i18n; if (i18n) { this._locales = [...i18n.locales]; @@ -395,7 +408,7 @@ export class NextURL { locales: domain.locales ? [...domain.locales] : undefined, })); this._configDefaultLocale = i18n.defaultLocale; - this._analyzeI18n(); + this._analyzeI18n(nextDataPathname); } } @@ -420,20 +433,44 @@ export class NextURL { this._url.pathname = stripBasePath(this._url.pathname, this._configBasePath); } - /** Extract locale from pathname, stripping it from the internal URL. */ - private _detectPathnameLocale(locales: string[]): string | undefined { - const segments = this._url.pathname.split("/"); + /** Parse the route identity carried by a Pages Router data URL. */ + private _analyzeNextDataPath(): string | undefined { + this._buildId = undefined; + const pathname = this._url.pathname; + if (!pathname.startsWith("/_next/data/") || !pathname.endsWith(".json")) return; + + const paths = pathname.slice("/_next/data/".length, -".json".length).split("/"); + this._buildId = paths[0]; + const nextDataPathname = paths[1] !== "index" ? `/${paths.slice(1).join("/")}` : "/"; + if (!process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE && !this._skipProxyUrlNormalize) { + this._url.pathname = nextDataPathname; + } + return nextDataPathname; + } + + /** Extract locale and the locale-less route from a pathname. */ + private _detectPathnameLocale( + pathname: string, + locales: string[], + ): { locale: string | undefined; pathname: string } { + const segments = pathname.split("/"); const candidate = segments[1]?.toLowerCase(); const match = locales.find((l) => l.toLowerCase() === candidate); - if (match) { - this._url.pathname = "/" + segments.slice(2).join("/"); - } - return match; + return { + locale: match, + pathname: match ? "/" + segments.slice(2).join("/") : pathname, + }; } - private _analyzeI18n(): void { + private _analyzeI18n(nextDataPathname?: string): void { if (!this._locales || !this._configDefaultLocale) return; - const detectedLocale = this._detectPathnameLocale(this._locales); + const pathnameInfo = this._detectPathnameLocale(this._url.pathname, this._locales); + this._url.pathname = pathnameInfo.pathname; + const detectedLocale = + pathnameInfo.locale ?? + (this._buildId && nextDataPathname + ? this._detectPathnameLocale(nextDataPathname, this._locales).locale + : undefined); const detectedLocaleLower = detectedLocale?.toLowerCase(); const hostname = this._url.hostname.toLowerCase(); this._domainLocale = this._domains?.find( @@ -452,16 +489,32 @@ export class NextURL { * Mirrors Next.js's internal formatNextPathnameInfo(). */ private _formatPathname(): string { - // Build prefix: basePath + locale (skip defaultLocale — Next.js omits it) - let prefix = this._basePath; + let pagePrefix = ""; const inner = this._url.pathname; const innerLower = inner.toLowerCase(); const isApiPath = innerLower === "/api" || innerLower.startsWith("/api/"); - if (!isApiPath && this._locale && this._locale !== this._defaultLocale) { - prefix += "/" + this._locale; + // Data URLs retain the locale, including the default locale, because it is + // part of the data endpoint rather than the visible page pathname. + if (!isApiPath && this._locale && (this._buildId || this._locale !== this._defaultLocale)) { + pagePrefix = "/" + this._locale; } - const composed = !prefix ? inner : inner === "/" ? prefix : prefix + inner; - return this._applyTrailingSlash(composed); + let composed = !pagePrefix ? inner : inner === "/" ? pagePrefix : pagePrefix + inner; + + if (this._buildId) { + composed = composed.endsWith("/") && composed !== "/" ? composed.slice(0, -1) : composed; + const dataPathname = + composed === "/" + ? `/_next/data/${this._buildId}/index.json` + : `/_next/data/${this._buildId}${composed}.json`; + return this._basePath + dataPathname; + } + + const pathname = !this._basePath + ? composed + : composed === "/" + ? this._basePath + : this._basePath + composed; + return this._applyTrailingSlash(pathname); } /** @@ -491,7 +544,8 @@ export class NextURL { set href(value: string) { this._url.href = value; this._stripBasePath(); - this._analyzeI18n(); + const nextDataPathname = this._analyzeNextDataPath(); + this._analyzeI18n(nextDataPathname); } get origin(): string { @@ -622,6 +676,9 @@ export class NextURL { if (this._trailingSlash) { nextConfig.trailingSlash = true; } + if (this._skipProxyUrlNormalize) { + nextConfig.skipProxyUrlNormalize = true; + } const config: NextURLConfig = { // Preserve the configured basePath even when it is not active for the // current pathname. Next.js retains the original constructor options in @@ -642,14 +699,12 @@ export class NextURL { return this.href; } - /** - * The build ID of the Next.js application. - * Set from `generateBuildId` in next.config.js, or a random UUID if not configured. - * Can be used in middleware to detect deployment skew between client and server. - * Matches the Next.js API: `request.nextUrl.buildId`. - */ + /** The build ID encoded in a Pages Router `/_next/data/` URL. */ get buildId(): string | undefined { - return process.env.__VINEXT_BUILD_ID ?? undefined; + return this._buildId; + } + set buildId(value: string | undefined) { + this._buildId = value; } } diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 6de836ac45..e02b1c368b 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -1510,6 +1510,7 @@ describe("Pages Router entry template", () => { ); expect(code).toContain("export const hasMiddleware = true"); expect(code).toContain('"skipProxyUrlNormalize":true'); + expect(code).toContain("skipProxyUrlNormalize: vinextConfig.skipProxyUrlNormalize"); expect(code).not.toContain('request.headers.get("x-nextjs-data")'); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/tests/middleware-runtime.test.ts b/tests/middleware-runtime.test.ts index 24cf2622ce..d7811a7022 100644 --- a/tests/middleware-runtime.test.ts +++ b/tests/middleware-runtime.test.ts @@ -207,6 +207,31 @@ describe("middleware redirect protocol", () => { expect((capturedRequest as NextRequest & { __isData?: boolean }).__isData).toBeUndefined(); }); + // Ported from Next.js: + // test/e2e/skip-trailing-slash-redirect/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/skip-trailing-slash-redirect/index.test.ts + it("preserves the original Pages data URL when proxy URL normalization is disabled", async () => { + let capturedRequest: NextRequest | undefined; + + await executeMiddleware({ + isDataRequest: true, + isProxy: false, + module: { + default: (request: NextRequest) => { + capturedRequest = request; + }, + }, + request: new Request("http://localhost:3000/_next/data/request-build/about.json?from=data"), + skipProxyUrlNormalize: true, + }); + + expect(capturedRequest?.nextUrl.buildId).toBe("request-build"); + expect(capturedRequest?.nextUrl.pathname).toBe("/_next/data/request-build/about.json"); + expect(capturedRequest?.url).toBe( + "http://localhost:3000/_next/data/request-build/about.json?from=data", + ); + }); + it("relativizes the Location header for same-host redirects", async () => { const module = { default: (req: Request) => { diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 167c897605..49689fa9eb 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -11114,13 +11114,16 @@ describe("NextRequest API", () => { expect(req.geo).toBeUndefined(); }); - it("nextUrl.buildId returns process.env.__VINEXT_BUILD_ID when set", async () => { + // Ported from Next.js: test/unit/web-runtime/next-url.test.ts + // https://github.com/vercel/next.js/blob/canary/test/unit/web-runtime/next-url.test.ts + it("nextUrl.buildId is absent for ordinary request URLs", async () => { const original = process.env.__VINEXT_BUILD_ID; try { process.env.__VINEXT_BUILD_ID = "test-build-123"; const { NextRequest } = await import("../packages/vinext/src/shims/server.js"); const req = new NextRequest("http://localhost/"); - expect(req.nextUrl.buildId).toBe("test-build-123"); + expect(req.nextUrl.buildId).toBeUndefined(); + expect(req.buildId).toBeUndefined(); } finally { if (original === undefined) { delete process.env.__VINEXT_BUILD_ID; @@ -11130,34 +11133,81 @@ describe("NextRequest API", () => { } }); - it("nextUrl.buildId returns undefined when __VINEXT_BUILD_ID is not set", async () => { - const original = process.env.__VINEXT_BUILD_ID; - try { - delete process.env.__VINEXT_BUILD_ID; - const { NextRequest } = await import("../packages/vinext/src/shims/server.js"); - const req = new NextRequest("http://localhost/"); - expect(req.nextUrl.buildId).toBeUndefined(); - } finally { - if (original !== undefined) { - process.env.__VINEXT_BUILD_ID = original; - } - } + it("nextUrl parses the build ID and page pathname from a Pages data URL", async () => { + const { NextRequest } = await import("../packages/vinext/src/shims/server.js"); + const req = new NextRequest("http://localhost:3000/_next/data/request-build/about.json"); + + expect(req.nextUrl.buildId).toBe("request-build"); + expect(req.buildId).toBe("request-build"); + expect(req.nextUrl.pathname).toBe("/about"); + expect(req.nextUrl.href).toBe("http://localhost:3000/_next/data/request-build/about.json"); }); - it("buildId pass-through on NextRequest delegates to nextUrl.buildId", async () => { - const original = process.env.__VINEXT_BUILD_ID; - try { - process.env.__VINEXT_BUILD_ID = "test-build-456"; - const { NextRequest } = await import("../packages/vinext/src/shims/server.js"); - const req = new NextRequest("http://localhost/"); - expect(req.buildId).toBe(req.nextUrl.buildId); - } finally { - if (original === undefined) { - delete process.env.__VINEXT_BUILD_ID; - } else { - process.env.__VINEXT_BUILD_ID = original; - } - } + it("nextUrl parses and formats the Pages data URL for the root page", async () => { + const { NextRequest } = await import("../packages/vinext/src/shims/server.js"); + const req = new NextRequest("http://localhost:3000/_next/data/request-build/index.json"); + + expect(req.nextUrl.buildId).toBe("request-build"); + expect(req.nextUrl.pathname).toBe("/"); + expect(req.nextUrl.href).toBe("http://localhost:3000/_next/data/request-build/index.json"); + }); + + it("nextUrl buildId and pathname setters preserve Pages data URL formatting", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost:3000/about/"); + + url.buildId = "request-build"; + expect(url.href).toBe("http://localhost:3000/_next/data/request-build/about.json"); + + url.pathname = "/"; + expect(url.href).toBe("http://localhost:3000/_next/data/request-build/index.json"); + + url.buildId = ""; + expect(url.href).toBe("http://localhost:3000/"); + }); + + it("nextUrl preserves basePath and the default locale in Pages data URLs", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL( + "http://localhost:3000/docs/_next/data/request-build/en/hello.json", + undefined, + { + basePath: "/docs", + nextConfig: { + i18n: { defaultLocale: "en", locales: ["en", "es", "fr"] }, + }, + }, + ); + + expect(url.buildId).toBe("request-build"); + expect(url.basePath).toBe("/docs"); + expect(url.locale).toBe("en"); + expect(url.pathname).toBe("/hello"); + expect(url.href).toBe("http://localhost:3000/docs/_next/data/request-build/en/hello.json"); + }); + + it("nextUrl clones keep their independent Pages data URL identity", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost:3000/_next/data/request-build/about.json"); + const clone = url.clone(); + + clone.buildId = "other-build"; + clone.pathname = "/contact"; + + expect(url.href).toBe("http://localhost:3000/_next/data/request-build/about.json"); + expect(clone.href).toBe("http://localhost:3000/_next/data/other-build/contact.json"); + }); + + it("nextUrl preserves raw Pages data URLs when proxy URL normalization is disabled", async () => { + const { NextRequest } = await import("../packages/vinext/src/shims/server.js"); + const req = new NextRequest( + "http://localhost:3000/_next/data/request-build/about.json?from=data", + { nextConfig: { skipProxyUrlNormalize: true } }, + ); + + expect(req.nextUrl.buildId).toBe("request-build"); + expect(req.nextUrl.pathname).toBe("/_next/data/request-build/about.json"); + expect(req.url).toBe("http://localhost:3000/_next/data/request-build/about.json?from=data"); }); }); From e2d52bd33f84fa33cbf8578b20e0764b5a66d887 Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Tue, 4 Aug 2026 09:29:51 +0700 Subject: [PATCH 2/4] fix(shims): preserve raw Pages data URLs --- packages/vinext/src/shims/server.ts | 9 +++++++++ tests/middleware-runtime.test.ts | 3 +++ tests/shims.test.ts | 9 +++++++++ 3 files changed, 21 insertions(+) diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 0dce2af804..3089fccb2d 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -489,6 +489,15 @@ export class NextURL { * Mirrors Next.js's internal formatNextPathnameInfo(). */ private _formatPathname(): string { + const rawDataPrefix = this._buildId ? `/_next/data/${this._buildId}/` : undefined; + if ( + rawDataPrefix && + this._url.pathname.startsWith(rawDataPrefix) && + this._url.pathname.endsWith(".json") + ) { + return this._basePath + this._url.pathname; + } + let pagePrefix = ""; const inner = this._url.pathname; const innerLower = inner.toLowerCase(); diff --git a/tests/middleware-runtime.test.ts b/tests/middleware-runtime.test.ts index d7811a7022..04944f7ff0 100644 --- a/tests/middleware-runtime.test.ts +++ b/tests/middleware-runtime.test.ts @@ -227,6 +227,9 @@ describe("middleware redirect protocol", () => { expect(capturedRequest?.nextUrl.buildId).toBe("request-build"); expect(capturedRequest?.nextUrl.pathname).toBe("/_next/data/request-build/about.json"); + expect(capturedRequest?.nextUrl.href).toBe( + "http://localhost:3000/_next/data/request-build/about.json?from=data", + ); expect(capturedRequest?.url).toBe( "http://localhost:3000/_next/data/request-build/about.json?from=data", ); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 49689fa9eb..1d34262aa8 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -11207,6 +11207,15 @@ describe("NextRequest API", () => { expect(req.nextUrl.buildId).toBe("request-build"); expect(req.nextUrl.pathname).toBe("/_next/data/request-build/about.json"); + expect(req.nextUrl.href).toBe( + "http://localhost:3000/_next/data/request-build/about.json?from=data", + ); + expect(req.nextUrl.toString()).toBe( + "http://localhost:3000/_next/data/request-build/about.json?from=data", + ); + expect(req.nextUrl.clone().href).toBe( + "http://localhost:3000/_next/data/request-build/about.json?from=data", + ); expect(req.url).toBe("http://localhost:3000/_next/data/request-build/about.json?from=data"); }); }); From 1da5c2c6b63c8a32cead351b9bd8d514904549e3 Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Tue, 4 Aug 2026 09:45:27 +0700 Subject: [PATCH 3/4] chore: rerun CI From 4c655af23a311d654af55c0b8ee3090b8216b0fe Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Tue, 4 Aug 2026 10:12:36 +0700 Subject: [PATCH 4/4] test(shims): cover Pages data middleware normalization --- tests/middleware-runtime.test.ts | 25 +++++++++++++++++++++++++ tests/pages-request-pipeline.test.ts | 19 +++++++++++++++++++ tests/shims.test.ts | 19 +++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/tests/middleware-runtime.test.ts b/tests/middleware-runtime.test.ts index 04944f7ff0..ace8484307 100644 --- a/tests/middleware-runtime.test.ts +++ b/tests/middleware-runtime.test.ts @@ -207,6 +207,31 @@ describe("middleware redirect protocol", () => { expect((capturedRequest as NextRequest & { __isData?: boolean }).__isData).toBeUndefined(); }); + // Ported from Next.js: + // packages/next/src/server/web/adapter.ts + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/web/adapter.ts + it("exposes the normalized page URL without a build ID for Pages data requests", async () => { + let capturedRequest: NextRequest | undefined; + + await executeMiddleware({ + isDataRequest: true, + isProxy: false, + module: { + default: (request: NextRequest) => { + capturedRequest = request; + }, + }, + // Pages request adapters normalize the data endpoint before entering the + // middleware runtime unless skipProxyUrlNormalize is enabled. + request: new Request("http://localhost:3000/about?from=data"), + }); + + expect(capturedRequest?.nextUrl.buildId).toBeUndefined(); + expect(capturedRequest?.nextUrl.pathname).toBe("/about"); + expect(capturedRequest?.nextUrl.href).toBe("http://localhost:3000/about?from=data"); + expect(capturedRequest?.url).toBe("http://localhost:3000/about?from=data"); + }); + // Ported from Next.js: // test/e2e/skip-trailing-slash-redirect/index.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/skip-trailing-slash-redirect/index.test.ts diff --git a/tests/pages-request-pipeline.test.ts b/tests/pages-request-pipeline.test.ts index caf8f52f20..3ae45a250c 100644 --- a/tests/pages-request-pipeline.test.ts +++ b/tests/pages-request-pipeline.test.ts @@ -237,6 +237,25 @@ describe("config redirects", () => { // 4. Middleware redirect short-circuit → {type:"response"} status 307 describe("middleware", () => { + it("presents the normalized page URL to middleware for data requests by default", async () => { + // Next.js clears NextURL.buildId before invoking middleware so user code + // observes the page request rather than the internal data endpoint. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/web/adapter.ts + const runMiddleware = makeMiddleware({ continue: true }); + await runPagesRequest( + makeRequest("/journal?x=1"), + baseDeps({ + isDataReq: true, + isDataRequest: true, + runMiddleware, + matchPageRoute: vi.fn().mockReturnValue({ route: { isDynamic: false } }), + renderPage: makeRenderPage(), + }), + ); + + expect(runMiddleware.mock.calls[0]?.[0].url).toBe("http://localhost/journal?x=1"); + }); + it("can present the raw data URL to middleware while routing the normalized page", async () => { // Ported from Next.js: packages/next/src/server/next-server.ts // (`skipProxyUrlNormalize` selects request meta `initURL` for middleware). diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 1d34262aa8..47111d5f47 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -11186,6 +11186,25 @@ describe("NextRequest API", () => { expect(url.href).toBe("http://localhost:3000/docs/_next/data/request-build/en/hello.json"); }); + it("nextUrl preserves the real localized-root Pages data endpoint", async () => { + // Next's client and middleware matcher tests use /en.json for a localized + // root data request. Its current formatter emits /enindex.json after + // mutation, so vinext deliberately preserves the real request endpoint. + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-matcher/index.test.ts + // https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/format-next-pathname-info.ts + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost:3000/_next/data/request-build/en.json", undefined, { + nextConfig: { + i18n: { defaultLocale: "en", locales: ["en", "fr"] }, + }, + }); + + expect(url.buildId).toBe("request-build"); + expect(url.locale).toBe("en"); + expect(url.pathname).toBe("/"); + expect(url.href).toBe("http://localhost:3000/_next/data/request-build/en.json"); + }); + it("nextUrl clones keep their independent Pages data URL identity", async () => { const { NextURL } = await import("../packages/vinext/src/shims/server.js"); const url = new NextURL("http://localhost:3000/_next/data/request-build/about.json");