Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
Expand Down
1 change: 1 addition & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5362,6 +5362,7 @@ export const loadServerActionClient = ${
nextConfig?.trailingSlash,
opts.isDataRequest,
pathname,
nextConfig?.skipProxyUrlNormalize,
);

// Forward middleware context to the RSC entry so it can
Expand Down
7 changes: 6 additions & 1 deletion packages/vinext/src/server/middleware-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -360,6 +364,7 @@ export async function executeMiddleware(
options.trailingSlash,
hadBasePath,
options.requestBodyAlreadyIsolated,
options.skipProxyUrlNormalize,
);
if (options.isDataRequest) {
Object.defineProperty(nextRequest, "__isData", {
Expand Down
2 changes: 2 additions & 0 deletions packages/vinext/src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export async function runMiddleware(
trailingSlash?: boolean,
isDataRequest?: boolean,
normalizedPathname?: string,
skipProxyUrlNormalize?: boolean,
): Promise<MiddlewareResult> {
// Load the middleware module via the direct-call ModuleRunner.
// This bypasses the hot channel entirely and is safe with all Vite plugin
Expand All @@ -183,6 +184,7 @@ export async function runMiddleware(
module: mod,
normalizedPathname,
request,
skipProxyUrlNormalize,
trailingSlash,
});
}
126 changes: 95 additions & 31 deletions packages/vinext/src/shims/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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];
Expand All @@ -395,7 +408,7 @@ export class NextURL {
locales: domain.locales ? [...domain.locales] : undefined,
}));
this._configDefaultLocale = i18n.defaultLocale;
this._analyzeI18n();
this._analyzeI18n(nextDataPathname);
}
}

Expand All @@ -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(
Expand All @@ -452,16 +489,41 @@ 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;
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();
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;
}
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`;
Comment thread
Boyeep marked this conversation as resolved.
return this._basePath + dataPathname;
}
const composed = !prefix ? inner : inner === "/" ? prefix : prefix + inner;
return this._applyTrailingSlash(composed);

const pathname = !this._basePath
? composed
: composed === "/"
? this._basePath
: this._basePath + composed;
return this._applyTrailingSlash(pathname);
}

/**
Expand Down Expand Up @@ -491,7 +553,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 {
Expand Down Expand Up @@ -622,6 +685,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
Expand All @@ -642,14 +708,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;
}
}

Expand Down
1 change: 1 addition & 0 deletions tests/entry-templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
53 changes: 53 additions & 0 deletions tests/middleware-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,59 @@ 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
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?.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",
);
});

it("relativizes the Location header for same-host redirects", async () => {
const module = {
default: (req: Request) => {
Expand Down
19 changes: 19 additions & 0 deletions tests/pages-request-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading