Files
cloudflare__vinext/tests/pages-router.test.ts
T
Nathan Nguyen a3d2f92152 fix(router): honor hybrid pages route priority (#1997)
* fix(router): honor hybrid pages route priority

* fix(router): share hybrid owner decision with client navigation

PR #1997 fixed the server-side route ownership for direct document loads,
but the same invariant broke for client-side soft navigations and the
matching prefetch path:

- navigateClientSide() always delegated to the App runtime's RSC fetch,
  even when the Pages route had higher priority. renderPagesFallback()
  short-circuits RSC requests with null, so the App catch-all won.
- prefetchUrl() prefetched an RSC stream for any URL that matched an App
  route, again ignoring Pages ownership.
- The Pages entry was loaded on every hybrid request, even when a static
  App route had already matched (Pages can never win in that case).

Expose the Pages route manifest on the client via a new
__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ window global emitted by both the
App and Pages browser entries. The link shim consults a shared
resolveHybridClientRouteOwner helper that mirrors the server-side
pagesRouteHasPriorityOverAppRoute comparison. When Pages owns the URL,
the click handler issues a window.location navigation and the prefetch
path returns early.

Gate the renderPagesFallback call behind a static-App-route check in
handleAppRscRequest: when a static App route matches, the bridge
cannot win, so skip the eager Pages entry load. Centralise the
comparison in a new resolveHybridRouteOwner helper so server and
client reach the same answer for the same (URL, route pair).

Adds the missing client-navigation coverage to the use-params e2e
fixture (Link from /app/ to /pages-dir/foobar) and unit tests for
the shared owner decision.

* fix(router): mirror server hybrid owner decision on the client

PR review flagged two split-brain bugs in the previous hybrid
ownership fix: a hand-copied client comparator and a Link-only
ownership gate that left programmatic App Router navigations on
the wrong path.

The hand-copied routePrecedence in hybrid-client-route-owner.ts
omitted the static-prefix reduction that lives in
routing/utils.ts#routePrecedence, and used a strict-less-than
comparison that returned App for identical dynamic patterns. The
server returns Pages for both cases (Pages providers sort ahead
of App providers, and routePrecedence subtracts 50 per static
prefix segment). The split produced a real ownership disagreement
on overlapping patterns like /_sites/:slug* (Pages) vs /:slug*
(App).

Add a shared compareHybridRoutePatterns to routing/utils.ts as
the single source of truth: the static/dynamic short-circuits plus
a sortRoutes call (which carries the static-prefix reduction and
the Pages-first equal-pattern tiebreak). The server
pagesRouteHasPriorityOverAppRoute and the client
resolveHybridClientRouteOwner both delegate to it, so they
cannot diverge. Drop the hand-copied routePrecedence entirely.

Wire the ownership check at the App navigation runtime boundary
so useRouter().push, useRouter().replace, gesturePush, and form
submits all get the same hard-nav contract as the Link click
handler. Specifically:

- navigateClientSide: after same-origin normalization, if Pages
  owns the URL, hard-navigate via window.location and return
  (matching the existing external-URL branch).
- _appRouter.prefetch: short-circuit RSC URL construction for
  Pages-owned targets so we do not warm an unusable cache entry.

Centralise the existing two inline hard-nav branches in
navigateClientSide into a hardNavigateTo helper for clarity.

Tests:

- Direct unit tests for compareHybridRoutePatterns covering
  identical-dynamic tiebreak, static-prefix dynamic overlap,
  static-prefix catch-all overlap, and infix-static bonus.
- Direct unit tests for resolveHybridClientRouteOwner mirroring
  the server assertions plus a basePath-stripping test.
- e2e: useRouter().push('/pages-dir/foobar') from an App page
  resolves to the Pages document.
- e2e: useRouter().prefetch('/pages-dir/foobar') issues zero
  RSC requests for the target URL.

* docs(router): fix stale score in static-prefix catch-all test comment

The hand-copied comparator note quoted the dynamic-segment scores
(51 / 1000) for the optional-catch-all example (/_sites/:slug*
vs /:slug*). The current optional-catch-all scoring actually
produces 1951 vs 2000. The test assertion was correct; only the
comment was stale.

* test(use-params): fix direct-load single dynamic param to use /a instead of /a/b

* fix(router): compare hybrid routes structurally

* fix(router): reject hybrid route conflicts

* fix(router): refresh hybrid route ownership

* fix(router): preserve hybrid routing lifecycle

* fix(router): recheck pages routes after rewrites

* fix(router): preserve rewritten pages queries

* fix(router): preserve rewritten route ownership

* fix(router): resolve client rewrites sequentially

* fix(router): apply rewrite phases sequentially

* fix(router): preserve rewrite params and endpoints

* fix(router): hand off endpoint navigations

* fix(router): preserve rewrite fragment params

* test(e2e): avoid hybrid fixture route conflicts

* test(pages): await async config validation

* test(hybrid): avoid duplicate page fixtures

* chore(router): clarify hybrid priority semantics

* refactor(router): remove duplicate app route matcher

* docs(router): clarify client hybrid comparator

---------

Co-authored-by: James <james@eli.cx>
2026-06-15 00:22:00 +01:00

6681 lines
261 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, vi } from "vite-plus/test";
import { createServer, build, type ViteDevServer } from "vite-plus";
import { request as httpRequest, type IncomingHttpHeaders } from "node:http";
import path from "node:path";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import { Readable } from "node:stream";
import { pathToFileURL } from "node:url";
import zlib from "node:zlib";
import vinext from "../packages/vinext/src/index.js";
import {
PHASE_DEVELOPMENT_SERVER,
PHASE_PRODUCTION_BUILD,
} from "../packages/vinext/src/shims/constants.js";
import { PAGES_FIXTURE_DIR, buildPagesFixture, startFixtureServer } from "./helpers.js";
const FIXTURE_DIR = PAGES_FIXTURE_DIR;
const PAGES_APP_COMPONENT = `export default function App({ Component, pageProps }) {
return <Component {...pageProps} />;
}
`;
type ClientBuildManifestEntry = {
file?: string;
css?: string[];
assets?: string[];
};
function getBuildBundlerOptions(result: any) {
return result.build?.rolldownOptions ?? result.build?.rollupOptions;
}
/**
* Fixture: a Pages Router app with both `pages/index.tsx` (static) and
* `pages/[id].tsx` (dynamic root catch). Models the
* `test/e2e/middleware-trailing-slash` Next.js fixture: a static `ssr-page`
* route, a `[id]` dynamic root, plus next.config.js afterFiles rewrites
* (`/rewrite-1` → `/ssr-page?from=config`) and a middleware that rewrites
* `/rewrite-me` to `/`. After any rewrite the rewrite target must go
* through full route resolution — static routes must beat the `[id]`
* dynamic root.
*/
function writeMiddlewareRewritePriorityFixture(rootDir: string): void {
fs.mkdirSync(path.join(rootDir, "pages"), { recursive: true });
const nmLink = path.join(rootDir, "node_modules");
if (!fs.existsSync(nmLink)) {
fs.symlinkSync(path.join(process.cwd(), "node_modules"), nmLink);
}
fs.writeFileSync(path.join(rootDir, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
fs.writeFileSync(
path.join(rootDir, "pages", "index.tsx"),
`export default function Home() {
return <p id="home">Hello World</p>;
}
`,
);
fs.writeFileSync(
path.join(rootDir, "pages", "[id].tsx"),
`export const getServerSideProps = ({ params, query }) => ({
props: { id: params.id ?? null, q: query.id ?? null },
});
export default function Dynamic({ id, q }: { id: string | null; q: string | null }) {
return (
<div>
<p id="dynamic">Dynamic route</p>
<p id="id">{id}</p>
<p id="q">{q}</p>
</div>
);
}
`,
);
fs.writeFileSync(
path.join(rootDir, "pages", "about.tsx"),
`export default function About() {
return <p id="about">About Page</p>;
}
`,
);
fs.writeFileSync(
path.join(rootDir, "pages", "ssr-page.tsx"),
`export const getServerSideProps = ({ query }) => ({
props: { from: query.from ?? null },
});
export default function SsrPage({ from }: { from: string | null }) {
return (
<div>
<p id="ssr">Hello World</p>
<p id="from">{from ?? ""}</p>
</div>
);
}
`,
);
fs.writeFileSync(
path.join(rootDir, "next.config.js"),
`module.exports = {
trailingSlash: true,
rewrites() {
return [
{ source: "/rewrite-1", destination: "/ssr-page?from=config" },
];
},
};
`,
);
fs.writeFileSync(
path.join(rootDir, "middleware.ts"),
`import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export default function middleware(request: NextRequest) {
const url = new URL(request.url);
if (url.pathname === "/rewrite-me" || url.pathname === "/rewrite-me/") {
return NextResponse.rewrite(new URL("/", request.url));
}
if (url.pathname === "/rewrite-to-about" || url.pathname === "/rewrite-to-about/") {
return NextResponse.rewrite(new URL("/about", request.url));
}
return NextResponse.next();
}
`,
);
}
function writeEncodedSlashPagesFixture(rootDir: string): void {
fs.mkdirSync(path.join(rootDir, "pages", "a"), { recursive: true });
const nmLink = path.join(rootDir, "node_modules");
if (!fs.existsSync(nmLink)) {
fs.symlinkSync(path.join(process.cwd(), "node_modules"), nmLink);
}
fs.writeFileSync(path.join(rootDir, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
fs.writeFileSync(
path.join(rootDir, "pages", "a", "b.tsx"),
"export default function Page() { return <div>nested pages route</div>; }\n",
);
fs.writeFileSync(
path.join(rootDir, "middleware.ts"),
`export const config = { matcher: "/a/b" };
export default function middleware() {
return new Response("nested blocked", { status: 418 });
}
`,
);
}
function writeGsspAppInitialPropsContextFixture(rootDir: string): void {
fs.mkdirSync(path.join(rootDir, "pages", "blog", "[post]"), { recursive: true });
fs.mkdirSync(path.join(rootDir, "pages", "rewrite-target"), { recursive: true });
const nmLink = path.join(rootDir, "node_modules");
if (!fs.existsSync(nmLink)) {
fs.symlinkSync(path.join(process.cwd(), "node_modules"), nmLink);
}
fs.writeFileSync(
path.join(rootDir, "next.config.js"),
`module.exports = {
generateBuildId: () => "test-build-id",
async rewrites() {
return [
{ source: "/blog-post-1", destination: "/blog/post-1" },
{ source: "/blog-post-2", destination: "/blog/post-2?hello=world" },
{ source: "/blog-:param", destination: "/blog/post-3" },
{ source: "/rewrite-source/:path+", destination: "/rewrite-target" },
];
},
};
`,
);
fs.writeFileSync(
path.join(rootDir, "pages", "_app.jsx"),
`import App from "next/app";
class MyApp extends App {
static async getInitialProps(ctx) {
const { req, query, pathname, asPath } = ctx.ctx;
let pageProps = {};
if (ctx.Component.getInitialProps) {
pageProps = await ctx.Component.getInitialProps(ctx.ctx);
}
return {
appProps: {
url: (req || {}).url,
query,
pathname,
asPath,
},
pageProps,
};
}
render() {
const { Component, pageProps, appProps, router } = this.props;
return <Component {...pageProps} appProps={appProps} appRouter={router} />;
}
}
export default MyApp;
`,
);
fs.writeFileSync(
path.join(rootDir, "pages", "blog", "[post]", "index.jsx"),
`import { useRouter } from "next/router";
export async function getServerSideProps({ params, resolvedUrl }) {
return {
props: {
params,
resolvedUrl,
post: params.post,
},
};
}
export default function BlogPost({ post, params, appProps, appRouter, resolvedUrl }) {
const router = useRouter();
return (
<>
<p>Post: {post}</p>
<div id="params">{JSON.stringify(params)}</div>
<div id="query">{JSON.stringify(router.query)}</div>
<div id="app-query">{JSON.stringify(appProps.query)}</div>
<div id="app-url">{appProps.url}</div>
<div id="app-router-pathname">{appRouter.pathname}</div>
<div id="resolved-url">{resolvedUrl}</div>
<div id="as-path">{router.asPath}</div>
</>
);
}
`,
);
fs.writeFileSync(
path.join(rootDir, "pages", "something.jsx"),
`import { useRouter } from "next/router";
export async function getServerSideProps({ params, query, resolvedUrl }) {
return {
props: {
resolvedUrl,
world: "world",
query: query || {},
params: params || {},
},
};
}
export default function Something({ world, params, query, appProps, resolvedUrl }) {
const router = useRouter();
return (
<>
<p>hello: {world}</p>
<div id="params">{JSON.stringify(params)}</div>
<div id="initial-query">{JSON.stringify(query)}</div>
<div id="query">{JSON.stringify(router.query)}</div>
<div id="app-query">{JSON.stringify(appProps.query)}</div>
<div id="app-url">{appProps.url}</div>
<div id="resolved-url">{resolvedUrl}</div>
<div id="as-path">{router.asPath}</div>
</>
);
}
`,
);
fs.writeFileSync(
path.join(rootDir, "pages", "rewrite-target", "index.jsx"),
`import { useRouter } from "next/router";
export async function getServerSideProps({ req }) {
return { props: { url: req.url } };
}
export default function RewriteTarget({ url }) {
const router = useRouter();
return (
<>
<h1>rewrite-target</h1>
<p id="as-path">{router.asPath}</p>
<p id="req-url">{url}</p>
</>
);
}
`,
);
}
async function buildPagesFixtureToOutDir(rootDir: string, outDir: string): Promise<void> {
await build({
root: rootDir,
configFile: false,
plugins: [vinext({ disableAppRouter: true })],
logLevel: "silent",
build: {
outDir: path.join(outDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: { output: { entryFileNames: "entry.js" } },
},
});
await build({
root: rootDir,
configFile: false,
plugins: [vinext({ disableAppRouter: true })],
logLevel: "silent",
build: {
outDir: path.join(outDir, "client"),
manifest: true,
ssrManifest: true,
rollupOptions: { input: "virtual:vinext-client-entry" },
},
});
}
function unwrapStartedProdServer(
result: import("node:http").Server | { server: import("node:http").Server },
): import("node:http").Server {
return "server" in result ? result.server : result;
}
type CapturedStreamResponse = {
body: Buffer;
headers: IncomingHttpHeaders;
statusCode: number;
firstChunkMs: number;
endMs: number;
snapshot: Buffer;
rawBody: Buffer;
rawSnapshot: Buffer;
};
function createResponseDecoder(
contentEncoding: string | string[] | undefined,
): zlib.BrotliDecompress | zlib.Gunzip | zlib.Inflate | null {
const encoding = Array.isArray(contentEncoding) ? contentEncoding[0] : contentEncoding;
switch (encoding) {
case undefined:
return null;
case "br":
return zlib.createBrotliDecompress();
case "gzip":
return zlib.createGunzip();
case "deflate":
return zlib.createInflate();
default:
return null;
}
}
async function captureStreamedResponse(
url: string,
options: { headers?: Record<string, string>; snapshotDelayMs?: number } = {},
): Promise<CapturedStreamResponse> {
const { headers = {}, snapshotDelayMs = 120 } = options;
return await new Promise<CapturedStreamResponse>((resolve, reject) => {
const startedAt = Date.now();
const req = httpRequest(url, { headers }, (res) => {
const rawChunks: Buffer[] = [];
const decodedChunks: Buffer[] = [];
let firstChunkMs = -1;
let snapshot = Buffer.alloc(0);
let rawSnapshot = Buffer.alloc(0);
let snapshotCaptured = false;
let snapshotTimer: ReturnType<typeof setTimeout> | undefined;
const decoder = createResponseDecoder(res.headers["content-encoding"]);
const captureSnapshot = () => {
if (snapshotCaptured) return;
snapshotCaptured = true;
rawSnapshot = Buffer.concat(rawChunks);
snapshot = Buffer.concat(decodedChunks);
};
const observeDecodedChunk = (chunk: Buffer) => {
decodedChunks.push(Buffer.from(chunk));
if (firstChunkMs !== -1) return;
firstChunkMs = Date.now() - startedAt;
snapshotTimer = setTimeout(captureSnapshot, snapshotDelayMs);
};
res.on("data", (chunk: Buffer) => {
const rawChunk = Buffer.from(chunk);
rawChunks.push(rawChunk);
if (decoder) {
decoder.write(rawChunk);
} else {
observeDecodedChunk(rawChunk);
}
});
res.on("error", reject);
if (decoder) {
decoder.on("data", (chunk: Buffer) => {
observeDecodedChunk(Buffer.from(chunk));
});
decoder.on("error", reject);
}
const finishResponse = async () => {
try {
if (decoder) {
decoder.end();
await new Promise<void>((resolveDecoder, rejectDecoder) => {
decoder.once("end", () => resolveDecoder());
decoder.once("error", rejectDecoder);
});
}
if (snapshotTimer) clearTimeout(snapshotTimer);
captureSnapshot();
resolve({
body: Buffer.concat(decodedChunks),
headers: res.headers,
statusCode: res.statusCode ?? 0,
firstChunkMs,
endMs: Date.now() - startedAt,
snapshot,
rawBody: Buffer.concat(rawChunks),
rawSnapshot,
});
} catch (error) {
reject(error);
}
};
res.on("end", () => {
void finishResponse();
});
});
req.on("error", reject);
req.end();
});
}
function findBuildManifestEntries(
buildManifest: Record<string, ClientBuildManifestEntry>,
moduleId: string,
): Array<[string, ClientBuildManifestEntry]> {
return Object.entries(buildManifest).filter(
([key]) => key === moduleId || key.endsWith(`/${moduleId}`),
);
}
describe("Pages Router integration", () => {
let server: ViteDevServer;
let baseUrl: string;
beforeAll(async () => {
({ server, baseUrl } = await startFixtureServer(FIXTURE_DIR));
});
afterAll(async () => {
await server?.close();
});
it("renders the index page with correct HTML", async () => {
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/html");
const html = await res.text();
expect(html).toContain("Hello, vinext!");
expect(html).toContain("This is a Pages Router app running on Vite.");
expect(html).toContain("Go to About");
});
it("sets optimizeDeps.entries for pages and instrumentation hooks so deps are discovered at startup", () => {
const entries = server.config.optimizeDeps?.entries;
expect(entries).toBeDefined();
expect(Array.isArray(entries)).toBe(true);
const glob = (entries as string[]).join(",");
expect(glob).toMatch(/pages\/\*\*\/\*\.\{tsx,ts,jsx,js\}/);
expect(glob).toContain("instrumentation.ts");
expect(glob).toContain("instrumentation-client.ts");
});
it("resolves tsconfig path aliases (@/ imports)", async () => {
const res = await fetch(`${baseUrl}/alias-test`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Pages Alias Test");
// Component imported via @/components/heavy
expect(html).toContain("Loaded via alias");
});
it("renders the about page", async () => {
const res = await fetch(`${baseUrl}/about`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
expect(html).toContain("This is the about page.");
});
// Refs #1463: Pages Router should reject non-GET/HEAD methods to static
// (no `getServerSideProps`) pages with a 405 + `Allow: GET, HEAD`.
// Ported from Next.js: test/e2e/prerender.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/prerender.test.ts
// ('should respond with 405 for POST to static page').
it("returns 405 with Allow: GET, HEAD on POST to a static Pages page", async () => {
const res = await fetch(`${baseUrl}/about`, { method: "POST" });
expect(res.status).toBe(405);
expect(res.headers.get("allow")).toBe("GET, HEAD");
expect(await res.text()).toContain("Method Not Allowed");
});
// Refs #1463: GSP (getStaticProps) pages are also "static" from the
// routing perspective; POST should produce 405. Mirrors the Next.js
// condition `(typeof components.Component === 'string' || isSSG)` in
// `.nextjs-ref/packages/next/src/server/base-server.ts` around L2287.
it("returns 405 with Allow: GET, HEAD on POST to a GSP page", async () => {
const res = await fetch(`${baseUrl}/isr-test`, { method: "POST" });
expect(res.status).toBe(405);
expect(res.headers.get("allow")).toBe("GET, HEAD");
});
// GET/HEAD must continue to work — guards against an over-broad fix.
it("HEAD on a static Pages page still returns 200", async () => {
const res = await fetch(`${baseUrl}/about`, { method: "HEAD" });
expect(res.status).toBe(200);
});
// SSR pages (those with getServerSideProps) must NOT be blocked: the
// page may legitimately read req.method inside getServerSideProps.
// Next.js gates 405 on `(typeof components.Component === 'string' || isSSG)`
// — gSSP routes are neither.
it("does not return 405 on POST to a getServerSideProps page", async () => {
const res = await fetch(`${baseUrl}/ssr`, { method: "POST" });
expect(res.status).not.toBe(405);
});
// Tests that React 19 SSR preserves literal string action attributes.
// Note: This is NOT testing server action invocation (unlike the upstream
// Next.js test action-in-pages-router.test.ts which tests "use server" functions).
// Regression test for issue #1476.
it("preserves literal action:foo in form action attribute", async () => {
const res = await fetch(`${baseUrl}/action-string-test`);
expect(res.status).toBe(200);
const html = await res.text();
// React 19 may strip action: strings if it mistakes them for server action IDs.
expect(html).toContain('action="action:foo"');
});
// Ported from Next.js: test/e2e/async-modules/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/async-modules/index.test.ts
it("renders pages that use top-level await (async modules)", async () => {
const res = await fetch(`${baseUrl}/async-modules-test`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('<div id="app-value">hello</div>');
expect(html).toContain('<div id="page-value">42</div>');
});
it("adds middleware CSP nonces to Pages Router next data", async () => {
const res = await fetch(`${baseUrl}/dynamic-page?mw-csp-nonce=pages-response`);
expect(res.status).toBe(200);
expect(res.headers.get("content-security-policy")).toBe(
"script-src 'nonce-pages-response' 'strict-dynamic';",
);
const html = await res.text();
expect(html).toContain('<script nonce="pages-response">window.__NEXT_DATA__ = ');
});
it("does not serve cached Pages ISR HTML to CSP nonce requests", async () => {
const first = await fetch(`${baseUrl}/isr-test`);
expect(first.status).toBe(200);
expect(first.headers.get("x-vinext-cache")).toBe("MISS");
expect(first.headers.get("x-nextjs-cache")).toBe("MISS");
const firstHtml = await first.text();
expect(firstHtml).not.toContain("nonce=");
const cached = await fetch(`${baseUrl}/isr-test`);
expect(cached.status).toBe(200);
expect(cached.headers.get("x-vinext-cache")).toBe("HIT");
expect(cached.headers.get("x-nextjs-cache")).toBe("HIT");
await cached.text();
const second = await fetch(`${baseUrl}/isr-test?mw-csp-nonce=pages-isr`);
expect(second.status).toBe(200);
expect(second.headers.get("content-security-policy")).toBe(
"script-src 'nonce-pages-isr' 'strict-dynamic';",
);
expect(second.headers.get("cache-control")).toBe("no-store, must-revalidate");
expect(second.headers.get("x-vinext-cache")).toBeNull();
const secondHtml = await second.text();
expect(secondHtml).toContain('<script nonce="pages-isr">window.__NEXT_DATA__ = ');
});
it("renders the SSR page with getServerSideProps data", async () => {
const res = await fetch(`${baseUrl}/ssr`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Server-Side Rendered");
expect(html).toContain("Hello from getServerSideProps");
// Should have a timestamp
expect(html).toContain("Rendered at:");
});
// Regression test for #1459: Next.js explicitly supports a Promise value
// for `getServerSideProps` `props`. vinext must `await` the value before
// serialising — otherwise pageProps end up as a Promise and the rendered
// page shows empty values.
it("awaits Promise-shaped getServerSideProps props", async () => {
const res = await fetch(`${baseUrl}/ssr-promise-props`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("SSR Promise Props");
expect(html).toContain("world");
// React SSR inserts a `<!-- -->` comment between text and expressions.
expect(html).toMatch(/count:\s*(<!--\s*-->)?\s*42/);
// The serialized __NEXT_DATA__ payload must contain the resolved values
// (not an empty pageProps object).
expect(html).toMatch(/"pageProps":\s*\{[^}]*"hello":\s*"world"/);
});
// Regression test for #1354: when a page declares `getServerSideProps` as
// a local `const` and exports it via `export { getServerSideProps }`, the
// client-bundle transform must strip the export specifier without
// redeclaring the identifier. Prior to the fix, the build failed with
// `Identifier 'getServerSideProps' has already been declared` under OXC.
it("renders a page that exports gSSP via `export { ... }` named re-export", async () => {
const res = await fetch(`${baseUrl}/gssp-named-export`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("gSSP via named export");
expect(html).toContain("Hello from named-export gSSP");
});
it("getServerSideProps headers and status are applied to the response", async () => {
const res = await fetch(`${baseUrl}/ssr-headers`);
// gSSP sets statusCode = 201
expect(res.status).toBe(201);
const html = await res.text();
expect(html).toContain("Headers were set");
// Custom header set via res.setHeader
expect(res.headers.get("x-custom-header")).toBe("hello-from-gssp");
// Cookie set via res.setHeader("set-cookie", ...)
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toContain("gssp_token=abc123");
});
// Regression for #1461: gSSP responses must carry the default Cache-Control
// header that Next.js applies for getServerSideProps pages so CDNs and
// browsers do not cache the per-request payload.
it("sets the default Cache-Control header on getServerSideProps responses", async () => {
const res = await fetch(`${baseUrl}/ssr`);
expect(res.status).toBe(200);
expect(res.headers.get("cache-control")).toBe(
"private, no-cache, no-store, max-age=0, must-revalidate",
);
});
// Regression for #1461: when getServerSideProps overrides Cache-Control via
// res.setHeader, the user-provided value must reach the final HTTP response
// instead of being clobbered by the default.
it("preserves res.setHeader Cache-Control overrides set in getServerSideProps", async () => {
const res = await fetch(`${baseUrl}/ssr-cache-control`);
expect(res.status).toBe(200);
expect(res.headers.get("cache-control")).toBe("public, max-age=42");
});
it("getServerSideProps calling res.end() short-circuits the response", async () => {
const res = await fetch(`${baseUrl}/ssr-res-end`);
// gSSP calls res.end() with a JSON body and status 202
expect(res.status).toBe(202);
expect(res.headers.get("content-type")).toBe("application/json");
expect(res.headers.get("content-length")).toBe("35");
const body = await res.json();
expect(body).toEqual({ ok: true, source: "gssp-res-end" });
});
it("getServerSideProps returning notFound renders custom 404 page", async () => {
const res = await fetch(`${baseUrl}/posts/missing`);
expect(res.status).toBe(404);
const html = await res.text();
// Should render the custom 404 page (pages/404.tsx), not plain text
expect(html).toContain("Page Not Found");
// Should be wrapped in the _app layout
expect(html).toContain("app-wrapper");
});
// Regression for #1465: a getServerSideProps `{ redirect }` on an HTML
// request emits a real HTTP redirect (so a hard navigation lands on the
// destination).
it("getServerSideProps returning redirect emits an HTTP redirect on HTML requests", async () => {
const res = await fetch(`${baseUrl}/gssp-redirect`, { redirect: "manual" });
expect(res.status).toBe(307);
expect(res.headers.get("location")).toBe("/gssp-redirect-target");
});
// Regression for #1465: a getServerSideProps `{ redirect }` on a `_next/data`
// request must NOT emit an HTTP redirect (fetch would follow it to non-JSON
// HTML). Instead Next.js returns a 200 JSON envelope carrying `__N_REDIRECT`
// / `__N_REDIRECT_STATUS` in pageProps, which the client router uses to
// re-dispatch a fresh navigation (superseding the in-flight one). See
// packages/next/src/server/render.tsx (`__N_REDIRECT`).
it("getServerSideProps redirect returns __N_REDIRECT JSON on _next/data requests", async () => {
// The dev `_next/data` endpoint matches the build id
// `nextConfig.buildId ?? __VINEXT_BUILD_ID ?? "development"`; this fixture's
// next.config.mjs sets `generateBuildId: () => "test-build-id"` (see
// index.ts `_next/data` normalization).
const res = await fetch(`${baseUrl}/_next/data/test-build-id/gssp-redirect.json`, {
headers: { Accept: "application/json", "x-nextjs-data": "1" },
redirect: "manual",
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
expect(res.headers.get("location")).toBeNull();
const body = (await res.json()) as {
__N_SSP?: boolean;
appProps?: Record<string, unknown>;
pageProps?: Record<string, unknown>;
};
expect((body as { __N_SSP?: boolean }).__N_SSP).toBe(true);
expect(body.pageProps?.__N_REDIRECT).toBe("/gssp-redirect-target");
expect(body.pageProps?.__N_REDIRECT_STATUS).toBe(307);
});
// The data envelope must carry an EXTERNAL redirect destination verbatim so
// the client router hard-navigates to the right place (the client must not
// fall back to the originating page URL — that would loop). See
// handleDataRedirect() in shims/router.ts.
it("preserves an external destination in __N_REDIRECT on _next/data requests", async () => {
const res = await fetch(`${baseUrl}/_next/data/test-build-id/gssp-redirect-external.json`, {
headers: { Accept: "application/json", "x-nextjs-data": "1" },
redirect: "manual",
});
expect(res.status).toBe(200);
const body = (await res.json()) as { pageProps?: Record<string, unknown> };
expect(body.pageProps?.__N_REDIRECT).toBe("https://example.com/landing");
});
// Regression for #1458: when getServerSideProps throws, dev (and prod) must
// render the user's custom pages/500.tsx with status 500 rather than the
// plain "Internal Server Error" text. Mirrors Next.js test/e2e/getserversideprops
// "should handle throw ENOENT correctly".
it("getServerSideProps throwing renders custom 500 page (dev)", async () => {
const res = await fetch(`${baseUrl}/gssp-throw`);
expect(res.status).toBe(500);
const html = await res.text();
expect(html).toContain("custom pages/500");
expect(html).not.toBe("Internal Server Error");
});
it("renders dynamic routes with params", async () => {
const res = await fetch(`${baseUrl}/posts/42`);
expect(res.status).toBe(200);
const html = await res.text();
// React SSR inserts comment nodes between text and expressions:
// "Post: <!-- -->42" — so we match with a regex instead
expect(html).toMatch(/Post:\s*(<!--\s*-->)?\s*42/);
expect(html).toContain("post-title");
// Router should have correct pathname and query during SSR
expect(html).toMatch(/Pathname:\s*(<!--\s*-->)?\s*\/posts\/\[id\]/);
expect(html).toMatch(/Query ID:\s*(<!--\s*-->)?\s*42/);
});
it("keeps dynamic route params ahead of same-key search params during SSR", async () => {
const res = await fetch(`${baseUrl}/posts/42?id=evil`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toMatch(/Post:\s*(<!--\s*-->)?\s*42/);
expect(html).toMatch(/Query ID:\s*(<!--\s*-->)?\s*42/);
expect(html).not.toMatch(/Query ID:\s*(<!--\s*-->)?\s*evil/);
});
it("next/compat/router: useRouter returns router object in Pages Router context", async () => {
const res = await fetch(`${baseUrl}/compat-router-test`);
expect(res.status).toBe(200);
const html = await res.text();
// The shared component detects Pages Router context (router !== null)
expect(html).toContain('data-testid="router-context"');
expect(html).toContain("pages-router");
// The router pathname should reflect the current page
expect(html).toContain('data-testid="router-pathname"');
expect(html).toContain("/compat-router-test");
});
// Ported from Next.js: test/e2e/app-dir/params-hooks-compat/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/params-hooks-compat/index.test.ts
// Under a static Pages Router SSR render, `next/navigation` sees the same
// pre-ready Pages router state that the client uses for hydration. The ready
// browser transition is covered by the app-router/pages-router-use-params e2e.
it("next/navigation useParams is null for a pre-ready static Pages Router render", async () => {
const res = await fetch(`${baseUrl}/nav-compat/foobar?a=pages`);
expect(res.status).toBe(200);
const html = await res.text();
const paramsMatch = html.match(/<pre id="use-params">([^<]*)<\/pre>/);
expect(paramsMatch).not.toBeNull();
expect(paramsMatch![1]).toBe("null");
});
it("next/navigation useSearchParams is empty for a pre-ready static Pages Router render", async () => {
const res = await fetch(`${baseUrl}/nav-compat/foobar?q=pages`);
expect(res.status).toBe(200);
const html = await res.text();
const searchMatch = html.match(/<pre id="use-search-params">([^<]*)<\/pre>/);
expect(searchMatch).not.toBeNull();
const search = JSON.parse(searchMatch![1].replaceAll("&quot;", '"'));
expect(search).toEqual({});
});
it("next/navigation defers a dynamic getStaticProps Pages route when rewrites are configured", async () => {
const res = await fetch(`${baseUrl}/nav-compat-gsp/foobar`);
expect(res.status).toBe(200);
const html = await res.text();
const paramsMatch = html.match(/<pre id="use-params">([^<]*)<\/pre>/);
expect(paramsMatch).not.toBeNull();
expect(paramsMatch![1]).toBe("null");
const searchMatch = html.match(/<pre id="use-search-params">([^<]*)<\/pre>/);
expect(searchMatch).not.toBeNull();
const search = JSON.parse(searchMatch![1].replaceAll("&quot;", '"'));
expect(search).toEqual({});
});
it("next/navigation treats Page.getInitialProps Pages routes as ready during SSR", async () => {
const res = await fetch(`${baseUrl}/nav-compat-gip/foobar?q=pages`);
expect(res.status).toBe(200);
const html = await res.text();
const paramsMatch = html.match(/<pre id="use-params">([^<]*)<\/pre>/);
expect(paramsMatch).not.toBeNull();
expect(JSON.parse(paramsMatch![1].replaceAll("&quot;", '"'))).toEqual({ slug: "foobar" });
const searchMatch = html.match(/<pre id="use-search-params">([^<]*)<\/pre>/);
expect(searchMatch).not.toBeNull();
const search = JSON.parse(searchMatch![1].replaceAll("&quot;", '"'));
expect(search).toEqual({ q: "pages" });
});
it("does not collapse encoded slashes onto nested routes in dev", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-encoded-dev-"));
writeEncodedSlashPagesFixture(tmpDir);
let tempServer: ViteDevServer | undefined;
try {
const started = await startFixtureServer(tmpDir);
tempServer = started.server;
const encodedRes = await fetch(`${started.baseUrl}/a%2Fb`);
expect(encodedRes.status).toBe(404);
expect(await encodedRes.text()).not.toContain("nested blocked");
const nestedRes = await fetch(`${started.baseUrl}/a/b`);
expect(nestedRes.status).toBe(418);
expect(await nestedRes.text()).toBe("nested blocked");
} finally {
await tempServer?.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("returns 404 with custom 404 page for non-existent routes", async () => {
const res = await fetch(`${baseUrl}/nonexistent`);
expect(res.status).toBe(404);
const html = await res.text();
// Should render the custom 404 page
expect(html).toContain("404 - Page Not Found");
expect(html).toContain("does not exist");
});
it("renders next/head tags in SSR HTML <head>", async () => {
const res = await fetch(`${baseUrl}/`);
const html = await res.text();
// Index page has <Head><title>Hello vinext</title></Head>
// This should appear in the actual <head> of the HTML
expect(html).toContain("<title");
expect(html).toContain("Hello vinext");
// The title tag should be in <head>, not in <body>
const headSection = html.split("</head>")[0];
expect(headSection).toContain("Hello vinext");
});
it("caches the streamed ISR render without carrying prior render state", async () => {
const firstRes = await fetch(`${baseUrl}/isr-second-render-state`);
expect(firstRes.status).toBe(200);
expect(firstRes.headers.get("x-vinext-cache")).toBe("MISS");
const firstHtml = await firstRes.text();
expect(firstHtml).toContain('data-testid="head-before">0<');
expect(firstHtml).toContain('data-testid="private-cache-before">0<');
expect(firstHtml).toContain('data-testid="inserted-html-before">0<');
const secondRes = await fetch(`${baseUrl}/isr-second-render-state`);
expect(secondRes.status).toBe(200);
expect(secondRes.headers.get("x-vinext-cache")).toBe("HIT");
const secondHtml = await secondRes.text();
expect(secondHtml).toContain('data-testid="head-before">0<');
expect(secondHtml).toContain('data-testid="private-cache-before">0<');
expect(secondHtml).toContain('data-testid="inserted-html-before">0<');
});
it("includes __NEXT_DATA__ script tag", async () => {
const res = await fetch(`${baseUrl}/`);
const html = await res.text();
expect(html).toContain("__NEXT_DATA__");
});
// Dev/prod parity: the production client entry exposes
// `window.__VINEXT_PAGE_PATTERNS__` so the next/navigation compat hooks can
// resolve a dynamic route pattern from a resolved path. Dev must expose the
// same global (in Next.js bracket format, including dynamic patterns) so the
// hooks behave identically in both runtimes.
it("exposes __VINEXT_PAGE_PATTERNS__ in dev for next/navigation compat", async () => {
const res = await fetch(`${baseUrl}/`);
const html = await res.text();
// Route patterns contain `]` (e.g. "/posts/[slug]"), so anchor the capture
// on the closing `</script>` rather than the first `]`.
const match = html.match(/window\.__VINEXT_PAGE_PATTERNS__=(\[.*?\])<\/script>/);
expect(match).toBeTruthy();
const patterns = JSON.parse(match![1]!) as string[];
expect(Array.isArray(patterns)).toBe(true);
// pages-basic has dynamic routes — they must be serialized in bracket form.
expect(patterns.some((p) => p.includes("["))).toBe(true);
});
it("includes the Vite client script for HMR", async () => {
const res = await fetch(`${baseUrl}/`);
const html = await res.text();
expect(html).toContain("@vite/client");
});
it("installs the vinext dev error overlay in the hydration script", async () => {
const res = await fetch(`${baseUrl}/`);
const html = await res.text();
const hydrationProxyPath = html.match(
/<script type="module" src="([^"]*html-proxy[^"]*)"><\/script>/,
)?.[1];
expect(hydrationProxyPath).toBeDefined();
const hydrationProxy = await fetch(new URL(hydrationProxyPath!, baseUrl)).then((response) =>
response.text(),
);
expect(hydrationProxy).toContain("dev-error-overlay");
expect(hydrationProxy).toContain("overlay.installDevErrorOverlay()");
expect(hydrationProxy).toContain("overlay.installViteHmrErrorHandler(import.meta.hot)");
expect(hydrationProxy).toContain("overlay.reportInitialDevServerErrors()");
expect(hydrationProxy).toContain(
'hydrateRoot(document.getElementById("__next"), element, hydrateRootOptions)',
);
});
it("wraps pages with custom _app.tsx", async () => {
const res = await fetch(`${baseUrl}/`);
const html = await res.text();
// _app.tsx wraps with an #app-wrapper div and a global nav
expect(html).toContain("app-wrapper");
expect(html).toContain("My App");
});
it("_app.tsx wrapping works on all pages", async () => {
const res = await fetch(`${baseUrl}/about`);
const html = await res.text();
expect(html).toContain("app-wrapper");
expect(html).toContain("About");
});
it("uses custom _document.tsx for HTML shell", async () => {
const res = await fetch(`${baseUrl}/`);
const html = await res.text();
// Custom _document sets lang="en" on <html>
expect(html).toContain('lang="en"');
// Custom _document adds a meta description
expect(html).toContain("A vinext test app");
// Custom _document sets className on body
expect(html).toContain("custom-body");
});
// --- API Routes ---
it("handles API routes returning JSON", async () => {
const res = await fetch(`${baseUrl}/api/hello`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
const data = await res.json();
expect(data).toEqual({ message: "Hello from API!" });
});
it("handles dynamic API routes with query params", async () => {
const res = await fetch(`${baseUrl}/api/users/123`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({ user: { id: "123", name: "User 123" } });
});
it("keeps dynamic API route params ahead of same-key query params", async () => {
const res = await fetch(`${baseUrl}/api/users/123?id=evil`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({ user: { id: "123", name: "User 123" } });
});
// Ported from Next.js: test/integration/api-support/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/integration/api-support/test/index.test.ts
it("returns 400 for invalid JSON bodies on Pages API routes", async () => {
const res = await fetch(`${baseUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: `{"message":Invalid"}`,
});
expect(res.status).toBe(400);
expect(res.statusText).toBe("Invalid JSON");
expect(await res.text()).toBe("Invalid JSON");
});
it("parses empty JSON bodies on Pages API routes as {}", async () => {
const res = await fetch(`${baseUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "",
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({});
});
it("preserves duplicate urlencoded body keys on Pages API routes", async () => {
const res = await fetch(`${baseUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "tag=a&tag=b&tag=c",
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ tag: ["a", "b", "c"] });
});
it("parses empty urlencoded bodies on Pages API routes as {}", async () => {
const res = await fetch(`${baseUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "",
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({});
});
it("parses application/ld+json bodies on Pages API routes", async () => {
const res = await fetch(`${baseUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/ld+json; charset=utf-8" },
body: JSON.stringify({ title: "doc" }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ title: "doc" });
});
it("sends Buffer payloads from res.send() as raw bytes", async () => {
const res = await fetch(`${baseUrl}/api/send-buffer`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/octet-stream");
expect(res.headers.get("content-length")).toBe("3");
const body = Buffer.from(await res.arrayBuffer());
expect(body.equals(Buffer.from([1, 2, 3]))).toBe(true);
});
it("returns 404 for non-existent API routes", async () => {
const res = await fetch(`${baseUrl}/api/nonexistent`);
expect(res.status).toBe(404);
});
// Regression coverage for cloudflare/vinext#1338 — Pages Router edge
// runtime API routes (`export const config = { runtime: 'edge' }`) must
// execute and return the user's Web Response (200), not 500.
//
// Ported from Next.js: test/e2e/edge-pages-support/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/edge-pages-support/index.test.ts
it("serves Pages Router edge runtime API routes (export const config = { runtime: 'edge' })", async () => {
const res = await fetch(`${baseUrl}/api/edge-hello?a=b`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
const data = await res.json();
expect(data).toEqual({
hello: "world",
query: { a: "b" },
});
});
// Ported from Next.js: test/e2e/middleware-general/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-general/test/index.test.ts
it("passes middleware rewrite search params to Pages Router edge API nextUrl", async () => {
const res = await fetch(`${baseUrl}/api/edge-search-params?a=b`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
a: "b",
foo: "bar",
});
});
// Ported from Next.js: test/e2e/edge-pages-support/index.test.ts and
// packages/next/src/server/next-server.ts (`runEdgeFunction`).
it("preserves the original pathname and adds route params for rewritten edge APIs", async () => {
const res = await fetch(`${baseUrl}/edge-api-rewrite/id-1?a=b`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
pathname: "/edge-api-rewrite/id-1",
query: {
a: "b",
foo: "bar",
id: "id-1",
},
});
});
// Regression coverage for cloudflare/vinext#1338 — Pages Router OG image
// routes using `next/og` ImageResponse with `runtime: 'edge'` must execute
// and return image/png, not 404.
//
// Ported from Next.js: test/e2e/og-api/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/og-api/index.test.ts
it("serves Pages Router OG image routes (next/og + edge runtime)", async () => {
const res = await fetch(`${baseUrl}/api/og`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("image/png");
const body = await res.blob();
expect(body.size).toBeGreaterThan(0);
});
// --- Client Hydration ---
it("includes hydration script for client-side rendering", async () => {
const res = await fetch(`${baseUrl}/`);
const html = await res.text();
// Vite extracts inline module scripts into html-proxy modules.
// The hydration script becomes a <script type="module" src="...html-proxy...">
expect(html).toMatch(/html-proxy.*\.js/);
});
// --- Catch-all Routes ---
it("renders catch-all routes with multiple segments", async () => {
const res = await fetch(`${baseUrl}/docs/getting-started/install`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Docs");
expect(html).toMatch(/Path:\s*(<!--\s*-->)?\s*getting-started\/install/);
});
it("renders catch-all routes with single segment", async () => {
const res = await fetch(`${baseUrl}/docs/intro`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toMatch(/Path:\s*(<!--\s*-->)?\s*intro/);
});
// --- Hyphenated param names (issue #71) ---
it("renders optional catch-all with hyphenated param name [[...sign-up]]", async () => {
const res = await fetch(`${baseUrl}/sign-up`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Sign Up");
expect(html).toContain('data-testid="sign-up-page"');
expect(html).toMatch(/Segments:.*0/);
expect(html).toContain("(root)");
});
it("renders hyphenated optional catch-all with segments", async () => {
const res = await fetch(`${baseUrl}/sign-up/step/2`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Sign Up");
expect(html).toMatch(/Segments:.*2/);
});
// --- Hydration ---
// --- next.config.js ---
it("applies redirects from next.config.js", async () => {
const res = await fetch(`${baseUrl}/old-about`, { redirect: "manual" });
expect(res.status).toBe(308);
expect(res.headers.get("location")).toBe("/about");
});
// Ported from Next.js:
// test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts
// and
// test/e2e/middleware-rewrites/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
it("applies next.config.js headers using the pre-middleware pathname after a rewrite in dev", async () => {
const res = await fetch(`${baseUrl}/headers-before-middleware-rewrite`);
expect(res.status).toBe(200);
expect(res.headers.get("x-rewrite-source-header")).toBe("1");
const html = await res.text();
expect(html).toContain("Server-Side Rendered");
});
// Ported from Next.js:
// test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts
// and
// test/e2e/middleware-rewrites/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
it("applies next.config.js redirects before middleware rewrites in dev", async () => {
const res = await fetch(`${baseUrl}/redirect-before-middleware-rewrite`, {
redirect: "manual",
});
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/about");
});
// Ported from Next.js:
// test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts
it("applies next.config.js redirects before middleware responses in dev", async () => {
const res = await fetch(`${baseUrl}/redirect-before-middleware-response`, {
redirect: "manual",
});
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/about");
});
it("applies redirects with repeated dynamic params in the destination", async () => {
const res = await fetch(`${baseUrl}/repeat-redirect/hello`, { redirect: "manual" });
expect(res.status).toBe(307);
expect(res.headers.get("location")).toBe("/docs/hello/hello");
});
it("applies custom headers from next.config.js", async () => {
const res = await fetch(`${baseUrl}/api/hello`);
expect(res.headers.get("x-custom-header")).toBe("vinext");
});
// Ported from PR #47 by @ibruno
it("applies has/missing conditions for next.config.js headers", async () => {
const guestRes = await fetch(`${baseUrl}/about`);
expect(guestRes.status).toBe(200);
expect(guestRes.headers.get("x-guest-only-header")).toBe("1");
expect(guestRes.headers.get("x-auth-only-header")).toBeNull();
const authRes = await fetch(`${baseUrl}/about`, {
headers: { Cookie: "logged-in=1" },
});
expect(authRes.status).toBe(200);
expect(authRes.headers.get("x-auth-only-header")).toBe("1");
expect(authRes.headers.get("x-guest-only-header")).toBeNull();
});
it("applies beforeFiles rewrites from next.config.js", async () => {
const res = await fetch(`${baseUrl}/before-rewrite`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
});
it("applies rewrites with repeated dynamic params in the destination", async () => {
const res = await fetch(`${baseUrl}/repeat-rewrite/hello`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("hello/hello");
});
it("applies afterFiles rewrites from next.config.js", async () => {
const res = await fetch(`${baseUrl}/after-rewrite`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
});
it("does not let afterFiles rewrites override static page routes in dev", async () => {
const res = await fetch(`${baseUrl}/nav-test`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Navigation Test");
expect(html).not.toContain("This is the about page.");
});
it("applies fallback rewrites from next.config.js", async () => {
const res = await fetch(`${baseUrl}/fallback-rewrite`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
});
// ── Percent-encoded paths should be decoded before config matching ──
it("percent-encoded redirect path is decoded before config matching (dev)", async () => {
// /%6Fld-%61bout decodes to /old-about → /about (permanent redirect)
const res = await fetch(`${baseUrl}/%6Fld-%61bout`, { redirect: "manual" });
expect(res.status).toBe(308);
expect(res.headers.get("location")).toBe("/about");
});
it("percent-encoded header path is decoded before config matching (dev)", async () => {
// /%61pi/hello decodes to /api/hello → X-Custom-Header: vinext
const res = await fetch(`${baseUrl}/%61pi/hello`);
expect(res.headers.get("x-custom-header")).toBe("vinext");
});
it("percent-encoded rewrite path is decoded before config matching (dev)", async () => {
// /%62efore-rewrite decodes to /before-rewrite → /about
const res = await fetch(`${baseUrl}/%62efore-rewrite`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
});
// --- getStaticPaths ---
it("renders pages with getStaticPaths + getStaticProps", async () => {
const res = await fetch(`${baseUrl}/blog/hello-world`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Hello World");
expect(html).toContain("Blog post slug:");
expect(html).toMatch(/slug:\s*(<!--\s*-->)?\s*hello-world/);
});
it("returns 404 for paths not in getStaticPaths when fallback is false", async () => {
const res = await fetch(`${baseUrl}/blog/nonexistent-post`);
expect(res.status).toBe(404);
});
it("renders pre-listed paths with getStaticPaths fallback: blocking", async () => {
const res = await fetch(`${baseUrl}/articles/1`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("First Article");
expect(html).toMatch(/Article ID:\s*(<!--\s*-->)?\s*1/);
});
it("renders unlisted paths with getStaticPaths fallback: blocking (on-demand SSR)", async () => {
// Article 99 is not in getStaticPaths but fallback: blocking allows rendering
const res = await fetch(`${baseUrl}/articles/99`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Article 99");
expect(html).toMatch(/Article ID:\s*(<!--\s*-->)?\s*99/);
});
// --- next/dynamic ---
it("renders dynamically imported components during SSR", async () => {
const res = await fetch(`${baseUrl}/dynamic-page`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Dynamic Import Page");
// The heavy component should be rendered server-side (ssr: true by default)
expect(html).toContain("Heavy Component");
expect(html).toContain("Loaded dynamically");
});
// --- Hydration ---
// --- next/config ---
it("renders pages that use next/config getConfig()", async () => {
const res = await fetch(`${baseUrl}/config-test`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Config Test");
// publicRuntimeConfig is empty by default, so it should show the fallback
// React SSR inserts <!-- --> between text and expressions
expect(html).toMatch(/App:.*default-app/);
});
// --- next/script ---
it("renders Script with beforeInteractive strategy as <script> tag in SSR", async () => {
const res = await fetch(`${baseUrl}/script-test`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Script Test");
expect(html).toContain("Page with scripts");
// beforeInteractive should render a <script> tag in the SSR output
expect(html).toContain('src="https://example.com/analytics.js"');
});
// --- next/server ---
it("resolves next/server imports in API routes", async () => {
const res = await fetch(`${baseUrl}/api/middleware-test`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({ ok: true, message: "middleware-test works" });
});
// --- Middleware ---
it("middleware adds custom headers to responses", async () => {
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(200);
expect(res.headers.get("x-custom-middleware")).toBe("active");
});
it("middleware redirects /old-page to /about", async () => {
const res = await fetch(`${baseUrl}/old-page`, { redirect: "manual" });
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/about");
});
// Regression for #1331: after a middleware rewrite, the rewrite target
// must go through full route resolution where static routes win over
// dynamic catch-alls. Without the fix the `[id]` dynamic page captures
// the rewrite target and renders "Dynamic route" with id="rewrite-me".
it("middleware rewrite to / resolves to static index over [id] dynamic route (dev)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-mw-rewrite-priority-dev-"));
writeMiddlewareRewritePriorityFixture(tmpDir);
let tempServer: ViteDevServer | undefined;
try {
const started = await startFixtureServer(tmpDir);
tempServer = started.server;
const res = await fetch(`${started.baseUrl}/rewrite-me/`);
expect(res.status).toBe(200);
const html = await res.text();
// `id="home"` is unique to `pages/index.tsx`; ssr-page also says
// "Hello World" so this disambiguates that the index rendered.
expect(html).toContain('id="home"');
expect(html).toContain("Hello World");
expect(html).not.toContain("Dynamic route");
} finally {
await tempServer?.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("middleware rewrite to /about resolves to static about over [id] dynamic route (dev)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-mw-rewrite-priority-dev-about-"));
writeMiddlewareRewritePriorityFixture(tmpDir);
let tempServer: ViteDevServer | undefined;
try {
const started = await startFixtureServer(tmpDir);
tempServer = started.server;
const res = await fetch(`${started.baseUrl}/rewrite-to-about/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About Page");
expect(html).not.toContain("Dynamic route");
} finally {
await tempServer?.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
// Regression for #1331: next.config.js rewrites with `trailingSlash: true`
// and a `[id].tsx` dynamic root catch — the `[id]` route is also matched
// by the rewrite source, so afterFiles rewrites must still be considered
// (the matched route is dynamic), and the rewrite target must resolve to
// the static page, not back into `[id]`.
it("config afterFiles rewrite target resolves static page over [id] dynamic root (dev)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-mw-rewrite-priority-dev-cfg-"));
writeMiddlewareRewritePriorityFixture(tmpDir);
let tempServer: ViteDevServer | undefined;
try {
const started = await startFixtureServer(tmpDir);
tempServer = started.server;
const res = await fetch(`${started.baseUrl}/rewrite-1/`);
expect(res.status).toBe(200);
const html = await res.text();
// `id="ssr"` only lives on the rewrite target (`pages/ssr-page.tsx`) —
// `pages/index.tsx` also says "Hello World" so this disambiguates that
// the rewrite target is what rendered.
expect(html).toContain('id="ssr"');
expect(html).toContain("Hello World");
expect(html).not.toContain("Dynamic route");
} finally {
await tempServer?.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("middleware rewrites /rewritten to /ssr", async () => {
const res = await fetch(`${baseUrl}/rewritten`);
expect(res.status).toBe(200);
const html = await res.text();
// Should get the SSR page content (rewritten from /rewritten to /ssr)
expect(html).toContain("Server-Side Rendered");
});
// Ported from Next.js:
// test/e2e/getserversideprops/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/getserversideprops/test/index.test.ts
it("passes original req.url, query, asPath, and resolvedUrl through _app.getInitialProps on GSSP pages", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-gssp-app-context-dev-"));
writeGsspAppInitialPropsContextFixture(tmpDir);
let tempServer: Awaited<ReturnType<typeof startFixtureServer>>["server"] | undefined;
try {
const started = await startFixtureServer(tmpDir);
tempServer = started.server;
const fixtureUrl = started.baseUrl;
const dynamicRes = await fetch(`${fixtureUrl}/blog/post-1`);
expect(dynamicRes.status).toBe(200);
const dynamicHtml = await dynamicRes.text();
const elementText = (html: string, id: string) => {
const match = html.match(new RegExp(`<[^>]+id="${id}"[^>]*>(.*?)</[^>]+>`));
expect(match).not.toBeNull();
return match?.[1]?.replaceAll("&quot;", '"') ?? "";
};
const expectElementText = (html: string, id: string, expected: string) => {
expect(elementText(html, id)).toBe(expected);
};
const expectElementJson = (html: string, id: string, expected: unknown) => {
expect(JSON.parse(elementText(html, id))).toEqual(expected);
};
expect(dynamicHtml).toMatch(/Post:\s*(<!--\s*-->)?\s*post-1/);
expectElementJson(dynamicHtml, "params", { post: "post-1" });
expectElementJson(dynamicHtml, "query", { post: "post-1" });
expectElementJson(dynamicHtml, "app-query", { post: "post-1" });
expectElementText(dynamicHtml, "app-url", "/blog/post-1");
expectElementText(dynamicHtml, "app-router-pathname", "/blog/[post]");
expectElementText(dynamicHtml, "resolved-url", "/blog/post-1");
expectElementText(dynamicHtml, "as-path", "/blog/post-1");
const dynamicNextDataMatch = dynamicHtml.match(
/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/,
);
expect(dynamicNextDataMatch).toBeTruthy();
const dynamicNextData = JSON.parse(dynamicNextDataMatch![1]!);
expect(dynamicNextData.props.__N_SSP).toBe(true);
expect(dynamicNextData.props.appProps).toEqual({
url: "/blog/post-1",
query: { post: "post-1" },
asPath: "/blog/post-1",
pathname: "/blog/[post]",
});
const dataRes = await fetch(
`${fixtureUrl}/_next/data/test-build-id/blog/post-1.json?hello=world`,
);
expect(dataRes.status).toBe(200);
const data = await dataRes.json();
expect(data.pageProps.resolvedUrl).toEqual("/blog/post-1?hello=world");
expect(data.__N_SSP).toBe(true);
expect(data.appProps).toEqual({
url: "/_next/data/test-build-id/blog/post-1.json?hello=world",
query: { post: "post-1", hello: "world" },
asPath: "/blog/post-1?hello=world",
pathname: "/blog/[post]",
});
const queryRes = await fetch(`${fixtureUrl}/something?hello=world`);
expect(queryRes.status).toBe(200);
const queryHtml = await queryRes.text();
expect(queryHtml).toMatch(/hello:\s*(<!--\s*-->)?\s*world/);
expectElementJson(queryHtml, "params", {});
expectElementJson(queryHtml, "initial-query", { hello: "world" });
expectElementJson(queryHtml, "query", { hello: "world" });
expectElementJson(queryHtml, "app-query", { hello: "world" });
expectElementText(queryHtml, "app-url", "/something?hello=world");
expectElementText(queryHtml, "resolved-url", "/something?hello=world");
expectElementText(queryHtml, "as-path", "/something?hello=world");
const rewriteRes = await fetch(`${fixtureUrl}/blog-post-2`);
expect(rewriteRes.status).toBe(200);
const rewriteHtml = await rewriteRes.text();
expectElementText(rewriteHtml, "app-url", "/blog-post-2");
expectElementJson(rewriteHtml, "app-query", { post: "post-2", hello: "world" });
expectElementText(rewriteHtml, "resolved-url", "/blog/post-2");
expectElementText(rewriteHtml, "as-path", "/blog-post-2");
const rewriteParamRes = await fetch(`${fixtureUrl}/blog-post-3`);
expect(rewriteParamRes.status).toBe(200);
const rewriteParamHtml = await rewriteParamRes.text();
expectElementText(rewriteParamHtml, "app-url", "/blog-post-3");
expectElementJson(rewriteParamHtml, "app-query", {
post: "post-3",
param: "post-3",
});
expectElementText(rewriteParamHtml, "resolved-url", "/blog/post-3");
expectElementText(rewriteParamHtml, "as-path", "/blog-post-3");
const sourceRewriteRes = await fetch(`${fixtureUrl}/rewrite-source/foo`);
expect(sourceRewriteRes.status).toBe(200);
const sourceRewriteHtml = await sourceRewriteRes.text();
expect(sourceRewriteHtml).toContain("<h1>rewrite-target</h1>");
expect(sourceRewriteHtml).toContain('<p id="as-path">/rewrite-source/foo</p>');
expect(sourceRewriteHtml).toContain('<p id="req-url">/rewrite-source/foo</p>');
} finally {
await tempServer?.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
// Regression for cloudflare/vinext#1471: when a query value itself contains
// a query string (e.g. `?href=/about?hello=world`), the embedded `?hello=world`
// is part of the `href` value per RFC 3986 — only the first `?` separates the
// path from the query string. `getServerSideProps({ query })` must surface
// the full value so `<Link href={query.href}>` renders the complete target.
// Mirrors `test/e2e/trailing-slashes/pages/linker.js` from the Next.js suite.
it("Pages Router Link preserves an embedded query string in the href prop", async () => {
const res = await fetch(`${baseUrl}/linker?href=/about?hello=world`);
expect(res.status).toBe(200);
const html = await res.text();
// The rendered link target must include the embedded `?hello=world`. The
// anchor uses `id="link"` to match Next.js's linker fixture; the literal
// anchor href is what `<Link>` resolves through normalizePathTrailingSlash
// and withBasePath. With trailingSlash:false and no basePath this is the
// exact source string.
expect(html).toContain('href="/about?hello=world"');
});
it("Pages Router Link strips trailing slash before an embedded query string", async () => {
const res = await fetch(`${baseUrl}/linker?href=/about/?hello=world`);
expect(res.status).toBe(200);
const html = await res.text();
// trailingSlash defaults to false — `/about/?hello=world` collapses to
// `/about?hello=world` while preserving the query.
expect(html).toContain('href="/about?hello=world"');
});
// Ported from Next.js: test/e2e/edge-pages-support/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/edge-pages-support/index.test.ts
// Closes cloudflare/vinext#1342: original query params must survive a
// middleware rewrite. Next.js merges via
// Object.assign(parsedUrl.query, rewrittenParsedUrl.query) — original first,
// rewrite-target overrides on key conflicts.
it("middleware rewrite preserves original query params to getServerSideProps", async () => {
const res = await fetch(`${baseUrl}/mw-rewrite-query?hello=world`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("SSR Query");
const nextDataMatch = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
expect(nextDataMatch).toBeTruthy();
const nextData = JSON.parse(nextDataMatch![1]!);
expect(nextData.props.pageProps.query).toMatchObject({ hello: "world" });
});
it("middleware rewrite to a dynamic route merges original query with route params", async () => {
const res = await fetch(`${baseUrl}/mw-rewrite-dynamic-query?hello=world`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toMatch(/Post:\s*(<!--\s*-->)?\s*first/);
const nextDataMatch = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
expect(nextDataMatch).toBeTruthy();
const nextData = JSON.parse(nextDataMatch![1]!);
expect(nextData.props.pageProps.query).toMatchObject({ id: "first", hello: "world" });
});
it("middleware rewrite with target-side query lets rewrite-target win on key conflicts", async () => {
// Original ?hello=world, rewrite target is /ssr-query?hello=from-rewrite —
// rewrite-target query should win, matching Next.js Object.assign semantics.
const res = await fetch(`${baseUrl}/mw-rewrite-merge-query?hello=world&other=keep`);
expect(res.status).toBe(200);
const html = await res.text();
const nextDataMatch = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
expect(nextDataMatch).toBeTruthy();
const nextData = JSON.parse(nextDataMatch![1]!);
expect(nextData.props.pageProps.query).toMatchObject({
hello: "from-rewrite",
other: "keep",
});
});
it("middleware rewrite without any original query still renders correctly", async () => {
const res = await fetch(`${baseUrl}/mw-rewrite-query`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("SSR Query");
const nextDataMatch = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
expect(nextDataMatch).toBeTruthy();
const nextData = JSON.parse(nextDataMatch![1]!);
expect(nextData.props.pageProps.query).toEqual({});
});
// Regression for cloudflare/vinext#1342: middleware that explicitly deletes
// search params from `request.nextUrl` and rewrites to it must observe only
// the keys it kept — vinext must NOT silently re-merge the original query.
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
// ("should clear query parameters")
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
it("middleware rewrite respects searchParams.delete on the rewrite-target URL", async () => {
const res = await fetch(`${baseUrl}/mw-clear-query-params?a=1&b=2&foo=bar&allowed=kept`);
expect(res.status).toBe(200);
const html = await res.text();
const nextDataMatch = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
expect(nextDataMatch).toBeTruthy();
const nextData = JSON.parse(nextDataMatch![1]!);
expect(nextData.props.pageProps.query).toEqual({ allowed: "kept" });
});
it("middleware blocks /blocked with 403", async () => {
const res = await fetch(`${baseUrl}/blocked`);
expect(res.status).toBe(403);
const text = await res.text();
expect(text).toContain("Access Denied");
});
it("middleware custom response preserves binary body", async () => {
const res = await fetch(`${baseUrl}/binary-response`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("image/png");
const buf = new Uint8Array(await res.arrayBuffer());
// PNG magic bytes
expect(buf[0]).toBe(0x89);
expect(buf[1]).toBe(0x50); // P
expect(buf[2]).toBe(0x4e); // N
expect(buf[3]).toBe(0x47); // G
});
it("middleware custom response preserves multiple Set-Cookie headers", async () => {
const res = await fetch(`${baseUrl}/multi-cookie-response`);
expect(res.status).toBe(200);
const setCookies = res.headers.getSetCookie();
expect(setCookies).toContain("a=1; Path=/");
expect(setCookies).toContain("b=2; Path=/");
expect(setCookies).toContain("c=3; Path=/");
});
it("object-form matcher requires has and missing conditions", async () => {
const noHeaderRes = await fetch(`${baseUrl}/mw-object-gated`);
expect(noHeaderRes.status).toBe(200);
expect(noHeaderRes.headers.get("x-custom-middleware")).toBeNull();
const blockedRes = await fetch(`${baseUrl}/mw-object-gated`, {
headers: {
"x-mw-allow": "1",
Cookie: "mw-blocked=1",
},
});
expect(blockedRes.status).toBe(200);
expect(blockedRes.headers.get("x-custom-middleware")).toBeNull();
const allowedRes = await fetch(`${baseUrl}/mw-object-gated`, {
headers: { "x-mw-allow": "1" },
});
expect(allowedRes.status).toBe(200);
expect(allowedRes.headers.get("x-custom-middleware")).toBe("active");
});
it("middleware request header overrides can delete credential headers before page handling", async () => {
// Ported from Next.js: test/e2e/middleware-request-header-overrides/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-request-header-overrides/test/index.test.ts
const res = await fetch(`${baseUrl}/header-override-delete`, {
headers: {
authorization: "Bearer secret",
cookie: "a=1; b=2",
},
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('id="authorization">null<');
expect(html).toContain('id="cookie">null<');
expect(html).toContain('id="middleware-header">hello-from-middleware<');
});
// --- Hydration ---
it("hydration proxy script is fetchable", async () => {
// Fetch the index page, find the proxy script URL, fetch it,
// and verify it contains our hydration code
const res = await fetch(`${baseUrl}/`);
const html = await res.text();
const proxyMatch = html.match(/src="([^"]*html-proxy[^"]*)"/);
expect(proxyMatch).toBeTruthy();
const scriptRes = await fetch(`${baseUrl}${proxyMatch![1]}`);
expect(scriptRes.status).toBe(200);
const scriptContent = await scriptRes.text();
// The proxy module should contain our hydration imports
expect(scriptContent).toContain("hydrateRoot");
expect(scriptContent).toContain("__NEXT_DATA__");
});
it("renders Suspense + React.lazy content via streaming SSR", async () => {
// With progressive streaming SSR (onShellReady), if the Suspense
// content resolves before the shell finishes, React inlines it
// directly (no fallback in the wire HTML). If it resolves after,
// the fallback appears with streaming replacement scripts.
// Our lazy component resolves synchronously in tests.
const res = await fetch(`${baseUrl}/suspense-test`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Suspense Test");
// The lazy component's content should be in the response
expect(html).toContain("Hello from lazy component");
});
// --- getStaticPaths tests ---
it("renders blog post with getStaticPaths fallback: false for listed path", async () => {
const res = await fetch(`${baseUrl}/blog/hello-world`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Hello World");
expect(html).toMatch(/Blog post slug:.*hello-world/);
});
it("returns 404 for unlisted path with getStaticPaths fallback: false", async () => {
const res = await fetch(`${baseUrl}/blog/nonexistent`);
expect(res.status).toBe(404);
});
it("renders article with getStaticPaths fallback: 'blocking' for listed path", async () => {
const res = await fetch(`${baseUrl}/articles/1`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("First Article");
expect(html).toMatch(/Article ID:.*1/);
});
it("SSR renders unlisted path with getStaticPaths fallback: 'blocking'", async () => {
const res = await fetch(`${baseUrl}/articles/99`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toMatch(/Article\s*(<!-- -->)?\s*99/);
expect(html).toMatch(/Article ID:.*99/);
});
it("renders product with getStaticPaths fallback: true for listed path", async () => {
const res = await fetch(`${baseUrl}/products/widget`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Super Widget");
expect(html).toMatch(/Product ID:.*widget/);
expect(html).toMatch(/isFallback:.*false/);
});
it("renders fallback shell for unlisted path with getStaticPaths fallback: true", async () => {
// Next.js parity: when `fallback: true` and the path isn't pre-rendered,
// skip getStaticProps, render with `useRouter().isFallback === true`, and
// ship a loading shell that the client later swaps for the full data.
// See: .nextjs-ref/packages/next/src/server/render.tsx — `if (isSSG && !isFallback)`.
const res = await fetch(`${baseUrl}/products/unknown`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Loading product...");
// The full-content branch must NOT render — getStaticProps was skipped.
expect(html).not.toMatch(/Product ID:.*unknown/);
const match = html.match(/__NEXT_DATA__\s*=\s*(\{.*?\})\s*[;<]/);
expect(match).toBeTruthy();
const nextData = JSON.parse(match![1]);
expect(nextData.isFallback).toBe(true);
// Empty pageProps on the fallback shell — client fetches them later.
expect(nextData.props).toEqual({ pageProps: {} });
});
it("resolves real props for the data URL of an unlisted fallback: true path", async () => {
// Counterpart to the fallback-shell test: the page HTML ships empty props,
// but the client follows up with `/_next/data/<buildId>/products/unknown.json`
// to fetch the actual props. That request must invoke getStaticProps.
const res = await fetch(`${baseUrl}/_next/data/test-build-id/products/unknown.json`, {
headers: { "x-nextjs-data": "1" },
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.pageProps).toMatchObject({ pid: "unknown" });
});
it("serves generated content after fallback data hydration", async () => {
const slug = `hydrated-${Math.random().toString(36).slice(2)}`;
const initialRes = await fetch(`${baseUrl}/products/${slug}`);
expect(await initialRes.text()).toContain("Loading product...");
const dataRes = await fetch(`${baseUrl}/_next/data/test-build-id/products/${slug}.json`, {
headers: { "x-nextjs-data": "1" },
});
expect(dataRes.status).toBe(200);
const finalRes = await fetch(`${baseUrl}/products/${slug}`);
const finalHtml = await finalRes.text();
expect(finalHtml).not.toContain("Loading product...");
expect(finalHtml).toMatch(new RegExp(`Product ID:.*${slug}`));
});
// Refs #1543: bot/crawler requests must bypass the `fallback: true` loading
// shell and synchronously render real content so crawlers index the page,
// not `Loading...`. Mirrors Next.js's bot check in
// `.nextjs-ref/packages/next/src/server/route-modules/pages/pages-handler.ts`
// and the Next.js e2e regression test
// `.nextjs-ref/test/e2e/prerender-crawler.test.ts`.
it("renders synchronously (not the fallback shell) for crawler UAs on unlisted fallback: true paths", async () => {
const userAgents = [
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
"Mozilla/5.0 (compatible; Bingbot/2.0; +http://www.bing.com/bingbot.htm)",
"DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)",
"Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)",
"facebookexternalhit/1.0 (+http://www.facebook.com/externalhit_uatext.php)",
];
for (const userAgent of userAgents) {
const slug = `bot-slug-${Math.random().toString(36).slice(2)}`;
const res = await fetch(`${baseUrl}/products/${slug}`, {
headers: { "user-agent": userAgent },
});
expect(res.status, `UA: ${userAgent}`).toBe(200);
const html = await res.text();
// Bot should see the real rendered page, not the loading shell.
expect(html, `UA: ${userAgent}`).not.toContain("Loading product...");
expect(html, `UA: ${userAgent}`).toMatch(new RegExp(`Product ID:.*${slug}`));
const match = html.match(/__NEXT_DATA__\s*=\s*(\{.*?\})\s*[;<]/);
expect(match, `UA: ${userAgent}`).toBeTruthy();
const nextData = JSON.parse(match![1]);
expect(nextData.isFallback, `UA: ${userAgent}`).toBe(false);
expect(nextData.props.pageProps).toMatchObject({ pid: slug });
}
});
it("still ships the fallback shell for normal browser UAs on unlisted fallback: true paths", async () => {
// Counterpart of the crawler test — the bot-flip must not catch real
// browsers. Plain Chrome UA should still receive the loading shell.
const res = await fetch(`${baseUrl}/products/non-bot-slug`, {
headers: {
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36",
},
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Loading product...");
const match = html.match(/__NEXT_DATA__\s*=\s*(\{.*?\})\s*[;<]/);
expect(match).toBeTruthy();
const nextData = JSON.parse(match![1]);
expect(nextData.isFallback).toBe(true);
});
it("includes isFallback: false in __NEXT_DATA__", async () => {
const res = await fetch(`${baseUrl}/products/widget`);
const html = await res.text();
const match = html.match(/__NEXT_DATA__\s*=\s*(\{.*?\})\s*[;<]/);
expect(match).toBeTruthy();
const nextData = JSON.parse(match![1]);
expect(nextData.isFallback).toBe(false);
});
// ── Cross-origin request protection ─────────────────────────────────
it("blocks page requests with cross-origin Origin header", async () => {
const res = await fetch(`${baseUrl}/`, {
headers: {
Origin: "https://evil.com",
Host: new URL(baseUrl).host,
},
});
expect(res.status).toBe(403);
const text = await res.text();
expect(text).toBe("Forbidden");
});
it("blocks API requests with cross-origin Origin header", async () => {
const res = await fetch(`${baseUrl}/api/hello`, {
headers: {
Origin: "https://external.io",
Host: new URL(baseUrl).host,
},
});
expect(res.status).toBe(403);
});
it("blocks requests with cross-site Sec-Fetch headers", async () => {
// Node.js fetch overrides Sec-Fetch-* headers (they're forbidden headers
// in the Fetch spec). Use raw HTTP to simulate browser behavior.
const http = await import("node:http");
const url = new URL(baseUrl);
const status = await new Promise<number>((resolve, reject) => {
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: "/",
method: "GET",
headers: {
"sec-fetch-site": "cross-site",
"sec-fetch-mode": "no-cors",
},
},
(res) => resolve(res.statusCode ?? 0),
);
req.on("error", reject);
req.end();
});
expect(status).toBe(403);
});
it("allows page requests from localhost origin", async () => {
const res = await fetch(`${baseUrl}/`, {
headers: {
Origin: baseUrl,
Host: new URL(baseUrl).host,
},
});
expect(res.status).toBe(200);
});
it("allows page requests without Origin header", async () => {
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(200);
});
// ── /_next/data JSON endpoint (issue #1330) ──────────────────────
// Ported from Next.js: test/e2e/middleware-general/test/index.test.ts
// ("should trigger middleware for data requests").
describe("/_next/data JSON endpoint", () => {
// pages-basic's next.config.mjs pins the build id to "test-build-id".
// In dev the plugin now reads this from the resolved config so the
// value matches the prod-server's embedded buildId.
const BUILD_ID = "test-build-id";
it("returns { pageProps } JSON for a getServerSideProps page", async () => {
const res = await fetch(`${baseUrl}/_next/data/${BUILD_ID}/ssr.json`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
const json = (await res.json()) as { pageProps: { message: string } };
expect(json.pageProps.message).toBe("Hello from getServerSideProps");
});
it("returns { pageProps } JSON for a getStaticProps page", async () => {
// /isr-test uses getStaticProps with revalidate; the data endpoint
// must bypass the HTML ISR cache and surface the props as JSON
// (mirroring Next.js' `isNextDataRequest` cache-bypass path).
const res = await fetch(`${baseUrl}/_next/data/${BUILD_ID}/isr-test.json`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
const json = (await res.json()) as { pageProps: Record<string, unknown> };
expect(json).toHaveProperty("pageProps");
expect(typeof json.pageProps).toBe("object");
});
it("normalizes the URL to /<page> BEFORE middleware runs", async () => {
const res = await fetch(`${baseUrl}/_next/data/${BUILD_ID}/ssr.json`);
expect(res.status).toBe(200);
// Middleware exposes the pathname it observed via `x-mw-pathname`.
// The raw `/_next/data/...` should never reach the middleware function —
// Next.js normalizes it to `/ssr` first.
expect(res.headers.get("x-mw-pathname")).toBe("/ssr");
// The middleware also sets `x-custom-middleware: active` on every match,
// proving the middleware actually executed for this request.
expect(res.headers.get("x-custom-middleware")).toBe("active");
});
it("returns 404 JSON for an unknown page", async () => {
const res = await fetch(`${baseUrl}/_next/data/${BUILD_ID}/totally-missing-page.json`);
expect(res.status).toBe(404);
expect(res.headers.get("content-type")).toContain("application/json");
// Body must still be valid JSON so naive clients calling `.json()` do
// not throw before checking the status code.
expect(await res.json()).toEqual({});
});
it("returns JSON 404 when getStaticPaths fallback:false rejects the path", async () => {
// /blog/[slug] has `fallback: false` and only allows the slugs listed
// in getStaticPaths. An unlisted slug must produce a JSON 404 for
// data requests (not the HTML 404 page) so the client router can
// hard-navigate instead of failing to parse HTML as JSON.
const res = await fetch(
`${baseUrl}/_next/data/${BUILD_ID}/blog/this-slug-does-not-exist.json`,
);
expect(res.status).toBe(404);
expect(res.headers.get("content-type")).toContain("application/json");
expect(await res.json()).toEqual({});
});
it("returns JSON 404 for a stale buildId (dev)", async () => {
// Mirrors the prod-server path: when the buildId in the URL doesn't
// match the resolved buildId we surface a JSON 404 right away so the
// client can hard-navigate (instead of parsing Vite's HTML 404).
const res = await fetch(`${baseUrl}/_next/data/wrong-build-id/ssr.json`);
expect(res.status).toBe(404);
expect(res.headers.get("content-type")).toContain("application/json");
expect(await res.json()).toEqual({});
});
// ── x-nextjs-deployment-id on dev _next/data exits (issue #1829) ──
// The fixture server runs in-process, so the dev middleware (index.ts)
// and SSR handler (dev-server.ts) read the real `process.env` at request
// time. Set NEXT_DEPLOYMENT_ID per-test to exercise the deployment-skew
// header on the dev-only exits that have no prod/worker equivalent test.
describe("x-nextjs-deployment-id (dev)", () => {
const DEPLOYMENT_ID = "dev-deploy-abc";
/** Run `fn` with NEXT_DEPLOYMENT_ID set, restoring the env after. */
async function withDeploymentId(fn: () => Promise<void>): Promise<void> {
const saved = process.env.NEXT_DEPLOYMENT_ID;
process.env.NEXT_DEPLOYMENT_ID = DEPLOYMENT_ID;
try {
await fn();
} finally {
if (saved === undefined) {
delete process.env.NEXT_DEPLOYMENT_ID;
} else {
process.env.NEXT_DEPLOYMENT_ID = saved;
}
}
}
it("sets the header on the stale-buildId JSON 404", async () => {
// Exercises the wrong-buildId data 404 in the plugin middleware
// (index.ts `_next/data` normalization) — the primary skew trigger:
// a stale client whose buildId no longer matches the server.
await withDeploymentId(async () => {
const res = await fetch(`${baseUrl}/_next/data/wrong-build-id/ssr.json`);
expect(res.status).toBe(404);
expect(res.headers.get("content-type")).toContain("application/json");
expect(res.headers.get("x-nextjs-deployment-id")).toBe(DEPLOYMENT_ID);
expect(await res.json()).toEqual({});
});
});
it("sets the header on the route-miss JSON 404", async () => {
// Exercises createSSRHandler's `!match` data exit (dev-server.ts):
// the page was removed under a new deployment, so a stale client's
// data fetch must still see the header to hard-navigate.
await withDeploymentId(async () => {
const res = await fetch(`${baseUrl}/_next/data/${BUILD_ID}/totally-missing-page.json`);
expect(res.status).toBe(404);
expect(res.headers.get("content-type")).toContain("application/json");
expect(res.headers.get("x-nextjs-deployment-id")).toBe(DEPLOYMENT_ID);
expect(await res.json()).toEqual({});
});
});
it("sets the header on the success { pageProps } response", async () => {
// Exercises createSSRHandler's data success short-circuit
// (dev-server.ts), matching the prod createPagesPageHandler tests.
await withDeploymentId(async () => {
const res = await fetch(`${baseUrl}/_next/data/${BUILD_ID}/ssr.json`);
expect(res.status).toBe(200);
expect(res.headers.get("x-nextjs-deployment-id")).toBe(DEPLOYMENT_ID);
const json = (await res.json()) as { pageProps: { message: string } };
expect(json.pageProps.message).toBe("Hello from getServerSideProps");
});
});
it("omits the header on the /500 data success response", async () => {
// Next.js pages-handler.ts guards the success-path header with
// `!isErrorPage && !is500Page`, so /_error and /500 data responses
// must not carry it even when a deployment id is configured.
await withDeploymentId(async () => {
const res = await fetch(`${baseUrl}/_next/data/${BUILD_ID}/500.json`);
expect(res.status).toBe(200);
expect(res.headers.get("x-nextjs-deployment-id")).toBeNull();
});
});
it("omits the header when no deployment id is configured", async () => {
// Without NEXT_DEPLOYMENT_ID / a configured deploymentId the header
// must be absent on every exit — mirroring Next.js, which only sets
// NEXT_NAV_DEPLOYMENT_ID_HEADER when `deploymentId` is configured.
const staleRes = await fetch(`${baseUrl}/_next/data/wrong-build-id/ssr.json`);
expect(staleRes.status).toBe(404);
expect(staleRes.headers.get("x-nextjs-deployment-id")).toBeNull();
const missRes = await fetch(`${baseUrl}/_next/data/${BUILD_ID}/totally-missing-page.json`);
expect(missRes.status).toBe(404);
expect(missRes.headers.get("x-nextjs-deployment-id")).toBeNull();
const okRes = await fetch(`${baseUrl}/_next/data/${BUILD_ID}/ssr.json`);
expect(okRes.status).toBe(200);
expect(okRes.headers.get("x-nextjs-deployment-id")).toBeNull();
});
});
});
});
describe("Pages Router dev server origin check", () => {
let server: ViteDevServer;
let baseUrl: string;
beforeAll(async () => {
({ server, baseUrl } = await startFixtureServer(FIXTURE_DIR));
}, 30000);
afterAll(async () => {
await server?.close();
});
it("allows requests with no Origin header (direct navigation)", async () => {
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(200);
});
it("allows same-origin requests", async () => {
const res = await fetch(`${baseUrl}/`, {
headers: { Origin: baseUrl },
});
expect(res.status).toBe(200);
});
it("blocks cross-origin requests", async () => {
const res = await fetch(`${baseUrl}/`, {
headers: { Origin: "http://evil.com" },
});
expect(res.status).toBe(403);
});
it("blocks cross-origin requests to /@* Vite internal paths", async () => {
const res = await fetch(`${baseUrl}/@fs/etc/passwd`, {
headers: { Origin: "http://evil.com" },
});
expect(res.status).toBe(403);
});
it("blocks cross-origin requests to /__vite internal paths", async () => {
const res = await fetch(`${baseUrl}/__vite_ping`, {
headers: { Origin: "http://evil.com" },
});
expect(res.status).toBe(403);
});
it("blocks cross-origin requests to /node_modules paths", async () => {
const res = await fetch(`${baseUrl}/node_modules/.vite/deps/react.js`, {
headers: { Origin: "http://evil.com" },
});
expect(res.status).toBe(403);
});
it("blocks requests with malformed Origin header", async () => {
const res = await fetch(`${baseUrl}/`, {
headers: { Origin: "not-a-url" },
});
expect(res.status).toBe(403);
});
it("blocks image endpoint redirect to /@* internal paths", async () => {
const res = await fetch(`${baseUrl}/_next/image?url=/@fs/etc/passwd&w=100&q=75`, {
redirect: "manual",
});
expect(res.status).toBe(400);
});
it("blocks image endpoint redirect to /__vite internal paths", async () => {
const res = await fetch(`${baseUrl}/_next/image?url=/__vite_hmr&w=100&q=75`, {
redirect: "manual",
});
expect(res.status).toBe(400);
});
it("blocks image endpoint redirect to /node_modules paths", async () => {
const res = await fetch(
`${baseUrl}/_next/image?url=/node_modules/.vite/manifest.json&w=100&q=75`,
{
redirect: "manual",
},
);
expect(res.status).toBe(400);
});
});
// Ported from Next.js: test/development/basic/allowed-dev-origins.test.ts
// https://github.com/vercel/next.js/blob/canary/test/development/basic/allowed-dev-origins.test.ts
describe("Pages Router allowedDevOrigins config", () => {
let server: ViteDevServer;
let baseUrl: string;
let tmpDir: string;
beforeAll(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-allowed-dev-origins-"));
await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
await fsp.symlink(
path.resolve(import.meta.dirname, "../node_modules"),
path.join(tmpDir, "node_modules"),
"junction",
);
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`export default function Home() { return <div>allowed-dev-origins-pages</div>; }`,
);
await fsp.writeFile(
path.join(tmpDir, "next.config.mjs"),
`export default {
allowedDevOrigins: ["allowed.example.com"],
experimental: {
serverActions: {
allowedOrigins: ["actions.example.com"],
},
},
};
`,
);
({ server, baseUrl } = await startFixtureServer(tmpDir));
}, 30000);
afterAll(async () => {
try {
(
server?.httpServer as
| {
closeAllConnections?: () => void;
}
| undefined
)?.closeAllConnections?.();
await Promise.race([server?.close(), new Promise((resolve) => setTimeout(resolve, 5000))]);
} catch {
// Best-effort cleanup: the temp directory removal below is the durable assertion.
}
await fsp.rm(tmpDir, { recursive: true, force: true });
}, 30000);
it("allows cross-origin requests from allowedDevOrigins", async () => {
const res = await fetch(`${baseUrl}/`, {
headers: { Origin: "http://allowed.example.com" },
});
await res.text();
expect(res.status).toBe(200);
});
it("does not treat serverActions.allowedOrigins as allowedDevOrigins", async () => {
const res = await fetch(`${baseUrl}/`, {
headers: { Origin: "http://actions.example.com" },
});
await res.text();
expect(res.status).toBe(403);
});
});
describe("Virtual server entry generation", () => {
it("generates valid JavaScript for the server entry", async () => {
// Create a minimal server just to access the plugin's virtual module
const testServer = await createServer({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext()],
server: { port: 0 },
logLevel: "silent",
});
try {
// Load the virtual module through Vite's SSR pipeline
const entry = await testServer.ssrLoadModule("virtual:vinext-server-entry");
// Verify it exports the expected functions
expect(typeof entry.renderPage).toBe("function");
expect(typeof entry.handleApiRoute).toBe("function");
} finally {
await testServer.close();
}
});
it("client entry uses Next.js bracket format for dynamic route keys", async () => {
// The client entry generates a pageLoaders map keyed by route pattern.
// These keys MUST match __NEXT_DATA__.page (which uses Next.js bracket
// format like "/posts/[id]"), not the internal Express-style ":id" format.
// A mismatch prevents client-side hydration for dynamic route pages.
const testServer = await createServer({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext()],
server: { port: 0 },
logLevel: "silent",
});
try {
const resolved = await testServer.pluginContainer.resolveId("virtual:vinext-client-entry");
expect(resolved).toBeTruthy();
const loaded = await testServer.pluginContainer.load(resolved!.id);
expect(loaded).toBeTruthy();
const code = typeof loaded === "string" ? loaded : ((loaded as any)?.code ?? "");
// Dynamic routes should use [param] format, not :param
// The fixture has pages/posts/[id].tsx
expect(code).toContain('"/posts/[id]"');
// Catch-all routes: pages/docs/[...slug].tsx
expect(code).toContain('"/docs/[...slug]"');
// Should NOT contain Express-style :param patterns for any route
expect(code).not.toMatch(/["']\/(posts|blog|articles|docs|products)\/:[\w]+["']/);
// Strip the `__VINEXT_PAGES_LINK_PREFETCH_ROUTES__` manifest before the
// next two assertions. The manifest is exempt because it carries the
// internal pattern shape (with `:slug+` / `:slug*`) so the client-side
// hybrid owner resolver can rebuild a pattern from `patternParts` to
// feed `routePrecedence`. The pageLoaders map (above) still uses
// Next.js bracket format for hydration keys.
const codeWithoutPrefetchManifest = code.replace(
/__VINEXT_PAGES_LINK_PREFETCH_ROUTES__\s*=\s*(\[[\s\S]*?\]);/,
"__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = /* stripped for test */;",
);
expect(codeWithoutPrefetchManifest).not.toContain(":slug+");
expect(codeWithoutPrefetchManifest).not.toContain(":slug*");
} finally {
await testServer.close();
}
});
// Issue #1329 — `window.next = { version, router, ... }` must be exposed
// before the Next.js deploy test suite can run `next.router.push(...)`
// via `browser.eval()`. The installer (shims/router.ts → installWindowNext)
// only runs once next/router is imported, so the client entry must
// statically import next/router at the top, not lazily inside hydrate().
//
// Mirrors Next.js: .nextjs-ref/packages/next/src/client/next.ts (line 5),
// which statically imports the router from './' before initialize/hydrate.
it("client entry statically imports next/router so window.next.router is set before hydration", async () => {
const testServer = await createServer({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext()],
server: { port: 0 },
logLevel: "silent",
});
try {
const resolved = await testServer.pluginContainer.resolveId("virtual:vinext-client-entry");
expect(resolved).toBeTruthy();
const loaded = await testServer.pluginContainer.load(resolved!.id);
expect(loaded).toBeTruthy();
const code = typeof loaded === "string" ? loaded : ((loaded as any)?.code ?? "");
// Static import — module-level side effect installs window.next.router.
expect(code).toMatch(
/^import\s+Router,\s*\{[^}]*\bwrapWithRouterContext\b[^}]*\}\s+from\s+["']next\/router["']/m,
);
// Defense-in-depth: the original lazy `await import("next/router")`
// inside hydrate() must NOT remain, otherwise the static import is
// dead-code and the side effect can be tree-shaken or deferred.
expect(code).not.toMatch(/await\s+import\(\s*["']next\/router["']\s*\)/);
} finally {
await testServer.close();
}
});
});
describe("Plugin config", () => {
it("uses inline nextConfig instead of root next.config and warns once", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-inline-config-"));
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
await fsp.writeFile(
path.join(tmpDir, "next.config.mjs"),
`export default { basePath: "/disk", env: { CONFIG_SOURCE: "disk" } };`,
);
try {
const plugins = vinext({
nextConfig: {
basePath: "/inline",
env: { CONFIG_SOURCE: "inline" },
},
}) as any[];
const configPlugin = plugins.find((p) => p.name === "vinext:config");
expect(configPlugin).toBeDefined();
const result = await configPlugin.config(
{ root: tmpDir, plugins: [] },
{ command: "serve", mode: "development" },
);
expect(result.base).toBe("/inline/");
expect(result.define["process.env.CONFIG_SOURCE"]).toBe(JSON.stringify("inline"));
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining("vinext({ nextConfig }) overrides next.config.mjs"),
);
} finally {
consoleWarn.mockRestore();
await fsp.rm(tmpDir, { recursive: true, force: true });
}
});
it("passes the current phase to inline function-form nextConfig", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-inline-phase-"));
await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
try {
const buildPlugins = vinext({
nextConfig: async (phase) => ({ env: { RECEIVED_PHASE: phase } }),
}) as any[];
const buildConfigPlugin = buildPlugins.find((p) => p.name === "vinext:config");
expect(buildConfigPlugin).toBeDefined();
const buildResult = await buildConfigPlugin.config(
{ root: tmpDir, plugins: [] },
{ command: "build", mode: "production" },
);
expect(buildResult.define["process.env.RECEIVED_PHASE"]).toBe(
JSON.stringify(PHASE_PRODUCTION_BUILD),
);
const servePlugins = vinext({
nextConfig: (phase) => ({ env: { RECEIVED_PHASE: phase } }),
}) as any[];
const serveConfigPlugin = servePlugins.find((p) => p.name === "vinext:config");
expect(serveConfigPlugin).toBeDefined();
const serveResult = await serveConfigPlugin.config(
{ root: tmpDir, plugins: [] },
{ command: "serve", mode: "development" },
);
expect(serveResult.define["process.env.RECEIVED_PHASE"]).toBe(
JSON.stringify(PHASE_DEVELOPMENT_SERVER),
);
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true });
}
});
it("injects an opaque App Router RSC compatibility ID instead of the raw build ID", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-rsc-compat-id-"));
const buildId = "release-2026-05-15";
await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
try {
const plugins = vinext({
nextConfig: {
generateBuildId: () => buildId,
},
}) as any[];
const configPlugin = plugins.find((p) => p.name === "vinext:config");
expect(configPlugin).toBeDefined();
const result = await configPlugin.config(
{ root: tmpDir, plugins: [] },
{ command: "build", mode: "production" },
);
const repeatedResult = await configPlugin.config(
{ root: tmpDir, plugins: [] },
{ command: "build", mode: "production" },
);
expect(result.define["process.env.__VINEXT_BUILD_ID"]).toBe(JSON.stringify(buildId));
expect(result.define["process.env.__VINEXT_RSC_COMPATIBILITY_ID"]).not.toBe(
JSON.stringify(buildId),
);
expect(JSON.parse(result.define["process.env.__VINEXT_RSC_COMPATIBILITY_ID"])).toMatch(
/^[0-9a-f-]{36}$/,
);
expect(repeatedResult.define["process.env.__VINEXT_RSC_COMPATIBILITY_ID"]).toBe(
result.define["process.env.__VINEXT_RSC_COMPATIBILITY_ID"],
);
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true });
}
});
it("uses deploymentId as the App Router RSC compatibility ID when configured", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-rsc-deployment-id-"));
await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
try {
const plugins = vinext({
nextConfig: {
deploymentId: "public-deployment-id",
generateBuildId: () => "release-2026-05-15",
},
}) as any[];
const configPlugin = plugins.find((p) => p.name === "vinext:config");
expect(configPlugin).toBeDefined();
const result = await configPlugin.config(
{ root: tmpDir, plugins: [] },
{ command: "build", mode: "production" },
);
expect(result.define["process.env.__VINEXT_RSC_COMPATIBILITY_ID"]).toBe(
JSON.stringify("public-deployment-id"),
);
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true });
}
});
it("loads .env before evaluating inline function-form nextConfig", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-inline-env-"));
const envKey = "VINEXT_INLINE_NEXT_CONFIG_ENV";
delete process.env[envKey];
await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
await fsp.writeFile(path.join(tmpDir, ".env"), `${envKey}=loaded-before-inline-config\n`);
try {
const plugins = vinext({
nextConfig: () => ({
env: {
INLINE_ENV_VALUE: process.env[envKey] ?? "missing",
},
}),
}) as any[];
const configPlugin = plugins.find((p) => p.name === "vinext:config");
expect(configPlugin).toBeDefined();
const result = await configPlugin.config(
{ root: tmpDir, plugins: [] },
{ command: "serve", mode: "development" },
);
expect(result.define["process.env.INLINE_ENV_VALUE"]).toBe(
JSON.stringify("loaded-before-inline-config"),
);
} finally {
delete process.env[envKey];
await fsp.rm(tmpDir, { recursive: true, force: true });
}
});
it("auto-injects @vitejs/plugin-react as a top-level async plugin", async () => {
const plugins = vinext() as any[];
const resolvedPlugins = (
await Promise.all(
plugins.map(async (plugin) => {
if (plugin && typeof plugin.then === "function") {
return await plugin;
}
return plugin;
}),
)
).flat();
const hasReactPlugin = resolvedPlugins.some(
(plugin) => plugin && typeof plugin.name === "string" && plugin.name.startsWith("vite:react"),
);
expect(hasReactPlugin).toBe(true);
});
it("throws when user double-registers react() alongside auto-registration", async () => {
const plugins = vinext() as any[];
const configPlugin = plugins.find((p) => p.name === "vinext:config");
expect(configPlugin).toBeDefined();
await configPlugin.config(
{ root: FIXTURE_DIR, plugins: [] },
{ command: "serve", mode: "development" },
);
await expect(
configPlugin.configResolved({
command: "serve",
configFile: false,
plugins: [
{ name: "vite:react-babel" },
{ name: "vite:react-refresh" },
{ name: "vite:react-babel" },
{ name: "vite:react-refresh" },
],
}),
).rejects.toThrow("Duplicate @vitejs/plugin-react detected");
});
it("adds resolve.dedupe for React packages to prevent dual instance errors", async () => {
const plugins = vinext() as any[];
const configPlugin = plugins.find((p) => p.name === "vinext:config");
expect(configPlugin).toBeDefined();
// Call the config hook with a minimal config
const result = await configPlugin.config({ root: FIXTURE_DIR, plugins: [] });
expect(result.resolve).toBeDefined();
expect(result.resolve.dedupe).toBeDefined();
expect(result.resolve.dedupe).toContain("react");
expect(result.resolve.dedupe).toContain("react-dom");
expect(result.resolve.dedupe).toContain("react/jsx-runtime");
expect(result.resolve.dedupe).toContain("react/jsx-dev-runtime");
});
it("suppresses MODULE_LEVEL_DIRECTIVE warnings from Rollup", async () => {
const plugins = vinext() as any[];
const configPlugin = plugins.find((p) => p.name === "vinext:config");
expect(configPlugin).toBeDefined();
const result = await configPlugin.config({ root: FIXTURE_DIR, plugins: [] });
expect(result.build).toBeDefined();
const bundlerOptions = getBuildBundlerOptions(result);
expect(bundlerOptions).toBeDefined();
expect(bundlerOptions.onwarn).toBeDefined();
const defaultHandler = vi.fn();
// "use client" MODULE_LEVEL_DIRECTIVE warnings should be silenced
bundlerOptions.onwarn(
{ code: "MODULE_LEVEL_DIRECTIVE", message: '"use client" was ignored' },
defaultHandler,
);
expect(defaultHandler).not.toHaveBeenCalled();
// "use server" MODULE_LEVEL_DIRECTIVE warnings should be silenced
bundlerOptions.onwarn(
{ code: "MODULE_LEVEL_DIRECTIVE", message: '"use server" was ignored' },
defaultHandler,
);
expect(defaultHandler).not.toHaveBeenCalled();
// MODULE_LEVEL_DIRECTIVE warnings for other directives should pass through
const otherDirectiveWarning = {
code: "MODULE_LEVEL_DIRECTIVE",
message: '"use strict" was ignored',
};
bundlerOptions.onwarn(otherDirectiveWarning, defaultHandler);
expect(defaultHandler).toHaveBeenCalledWith(otherDirectiveWarning);
// Other warning codes should pass through to the default handler
defaultHandler.mockClear();
const otherWarning = { code: "CIRCULAR_DEPENDENCY", message: "circular" };
bundlerOptions.onwarn(otherWarning, defaultHandler);
expect(defaultHandler).toHaveBeenCalledWith(otherWarning);
});
it("suppresses IMPORT_IS_UNDEFINED noise for generated proxy/middleware fallback probes", async () => {
const plugins = vinext() as any[];
const configPlugin = plugins.find((p) => p.name === "vinext:config");
expect(configPlugin).toBeDefined();
const result = await configPlugin.config({ root: FIXTURE_DIR, plugins: [] });
expect(result.build).toBeDefined();
const bundlerOptions = getBuildBundlerOptions(result);
expect(bundlerOptions).toBeDefined();
expect(bundlerOptions.onwarn).toBeDefined();
const defaultHandler = vi.fn();
bundlerOptions.onwarn(
{
code: "IMPORT_IS_UNDEFINED",
message:
"[IMPORT_IS_UNDEFINED] Warning: Import `default` will always be undefined because there is no matching export in 'proxy.ts'\\n ╭─[ \\0virtual:vinext-rsc-entry:2632:34 ]",
},
defaultHandler,
);
expect(defaultHandler).not.toHaveBeenCalled();
bundlerOptions.onwarn(
{
code: "IMPORT_IS_UNDEFINED",
message:
"[IMPORT_IS_UNDEFINED] Warning: Import `default` will always be undefined because there is no matching export in 'middleware.ts'\\n ╭─[ \\0virtual:vinext-server-entry:168:34 ]",
},
defaultHandler,
);
expect(defaultHandler).not.toHaveBeenCalled();
bundlerOptions.onwarn(
{
code: "IMPORT_IS_UNDEFINED",
message:
"[IMPORT_IS_UNDEFINED] Warning: Import `proxy` will always be undefined because there is no matching export in 'proxy.tsx'\\n ╭─[ \\0virtual:vinext-rsc-entry:2632:34 ]",
},
defaultHandler,
);
expect(defaultHandler).not.toHaveBeenCalled();
bundlerOptions.onwarn(
{
code: "IMPORT_IS_UNDEFINED",
message:
"[IMPORT_IS_UNDEFINED] Warning: Import `middleware` will always be undefined because there is no matching export in 'middleware.jsx'\\n ╭─[ \\0virtual:vinext-server-entry:168:34 ]",
},
defaultHandler,
);
expect(defaultHandler).not.toHaveBeenCalled();
bundlerOptions.onwarn(
{
code: "IMPORT_IS_UNDEFINED",
message:
"[IMPORT_IS_UNDEFINED] Warning: Import `default` will always be undefined because there is no matching export in 'some-user-file.ts'",
},
defaultHandler,
);
expect(defaultHandler).toHaveBeenCalledTimes(1);
bundlerOptions.onwarn(
{
code: "IMPORT_IS_UNDEFINED",
message:
"[IMPORT_IS_UNDEFINED] Warning: Import `proxy` will always be undefined because there is no matching export in 'some-user-file.ts'",
},
defaultHandler,
);
expect(defaultHandler).toHaveBeenCalledTimes(2);
});
it("preserves user-supplied build.rollupOptions.onwarn", async () => {
const plugins = vinext() as any[];
const configPlugin = plugins.find((p) => p.name === "vinext:config");
expect(configPlugin).toBeDefined();
const userOnwarn = vi.fn();
const result = await configPlugin.config({
root: FIXTURE_DIR,
plugins: [],
build: { rollupOptions: { onwarn: userOnwarn } },
});
const bundlerOptions = getBuildBundlerOptions(result);
const defaultHandler = vi.fn();
// "use client" should still be suppressed (user handler NOT called)
bundlerOptions.onwarn(
{ code: "MODULE_LEVEL_DIRECTIVE", message: '"use client" was ignored' },
defaultHandler,
);
expect(userOnwarn).not.toHaveBeenCalled();
expect(defaultHandler).not.toHaveBeenCalled();
// Other warnings should be forwarded to the user's handler
const otherWarning = { code: "CIRCULAR_DEPENDENCY", message: "circular" };
bundlerOptions.onwarn(otherWarning, defaultHandler);
expect(userOnwarn).toHaveBeenCalledWith(otherWarning, defaultHandler);
expect(defaultHandler).not.toHaveBeenCalled();
});
it("registers vinext:mdx proxy plugin with enforce pre for correct ordering", async () => {
const plugins = vinext() as any[];
const mdxProxy = plugins.find((p) => p.name === "vinext:mdx");
expect(mdxProxy).toBeDefined();
expect(mdxProxy.enforce).toBe("pre");
// Proxy forwards config and transform to the delegate (@mdx-js/rollup)
expect(typeof mdxProxy.config).toBe("function");
expect(typeof mdxProxy.transform).toBe("function");
// Proxy should be inert when no MDX files are detected (mdxDelegate is null)
expect(mdxProxy.config({}, { command: "build", mode: "production" })).toBeUndefined();
await expect(mdxProxy.transform("code", "./foo.ts", {})).resolves.toBeUndefined();
});
it("vinext:mdx transform skips ids that contain a query string (regression: ?raw)", async () => {
// @mdx-js/rollup strips the query before matching the file extension, so
// it would compile "foo.mdx?raw" as MDX and return compiled JSX instead of
// raw text. The proxy must short-circuit on any id that contains "?".
const plugins = vinext() as any[];
const mdxProxy = plugins.find((p: any) => p.name === "vinext:mdx");
// Common query-param import patterns that must be skipped
await expect(
mdxProxy.transform("# hello", "/app/content.mdx?raw", {}),
).resolves.toBeUndefined();
await expect(mdxProxy.transform("# hello", "/app/page.mdx?url", {})).resolves.toBeUndefined();
await expect(
mdxProxy.transform("# hello", "/app/page.mdx?inline", {}),
).resolves.toBeUndefined();
// Additional query variations
await expect(mdxProxy.transform("# hello", "/app/page.mdx?v=123", {})).resolves.toBeUndefined();
await expect(mdxProxy.transform("# hello", "/app/page.mdx?mdx", {})).resolves.toBeUndefined();
// Edge case: query value contains .mdx but isn't the extension
await expect(
mdxProxy.transform("# hello", "/app/page.mdx?something.mdx", {}),
).resolves.toBeUndefined();
});
it("vinext:mdx lazily compiles plain .mdx imports that were not pre-detected", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-lazy-"));
try {
await fsp.writeFile(
path.join(tmpDir, "package.json"),
JSON.stringify({ name: "vinext-mdx-lazy", private: true, type: "module" }),
);
const plugins = vinext({ appDir: tmpDir }) as any[];
const configPlugin = plugins.find((p) => p.name === "vinext:config");
const mdxProxy = plugins.find((p) => p.name === "vinext:mdx");
await configPlugin.config(
{ root: tmpDir, plugins: [] },
{ command: "build", mode: "production" },
);
const result = await mdxProxy.transform(
`---
title: "Second Post"
---
export const marker = "mdx-evaluated";
# Hello <span>world</span>
`,
path.join(tmpDir, "content", "post.mdx"),
{},
);
expect(result).toBeDefined();
expect(result.code).toContain("mdx-evaluated");
expect(result.code).not.toContain('title: "Second Post"');
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true });
}
});
it("vinext:mdx proxy logic — ?raw guard prevents delegate from compiling query imports", () => {
// Self-contained unit test that exercises the guard independently of whether
// mdxDelegate is set. Without the guard, @mdx-js/rollup silently compiles
// ?raw imports into JSX; with it, the proxy returns undefined (pass-through).
const mockTransformResult = { code: "/* compiled mdx */", map: null };
const mockDelegate = {
transform: vi.fn().mockReturnValue(mockTransformResult),
};
// Proxy WITHOUT the query guard — reproduces the bug
function transformWithoutGuard(code: string, id: string) {
if (!mockDelegate.transform) return;
return (mockDelegate.transform as any).call({}, code, id, {});
}
// Proxy WITH the query guard — the fix
function transformWithGuard(code: string, id: string) {
// Skip ?raw and other query imports — @mdx-js/rollup ignores the query
// and would compile the file as MDX instead of returning raw text.
if (id.includes("?")) return;
if (!mockDelegate.transform) return;
return (mockDelegate.transform as any).call({}, code, id, {});
}
// Without the guard: ?raw import is incorrectly handed to the MDX compiler
expect(transformWithoutGuard("", "/app/content.mdx?raw")).toEqual(mockTransformResult);
expect(mockDelegate.transform).toHaveBeenCalledWith("", "/app/content.mdx?raw", {});
mockDelegate.transform.mockClear();
// With the guard: ?raw import is skipped (undefined = Vite pass-through)
expect(transformWithGuard("", "/app/content.mdx?raw")).toBeUndefined();
expect(mockDelegate.transform).not.toHaveBeenCalled();
// Plain .mdx (no query) still goes through the delegate
expect(transformWithGuard("", "/app/content.mdx")).toEqual(mockTransformResult);
expect(mockDelegate.transform).toHaveBeenCalledWith("", "/app/content.mdx", {});
});
});
describe("Production build", () => {
const outDir = path.resolve(FIXTURE_DIR, "dist");
afterAll(() => {
// Clean up build output
fs.rmSync(outDir, { recursive: true, force: true });
});
it("produces SSR server entry via vite build --ssr", async () => {
// Build the SSR bundle using the virtual server entry
await build({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(outDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: {
output: {
entryFileNames: "entry.js",
},
},
},
});
// Verify the server entry was produced
const entryPath = path.join(outDir, "server", "entry.js");
expect(fs.existsSync(entryPath)).toBe(true);
const entryContent = fs.readFileSync(entryPath, "utf-8");
// Should export renderPage and handleApiRoute
expect(entryContent).toContain("renderPage");
expect(entryContent).toContain("handleApiRoute");
// Should contain route patterns from our fixture pages
expect(entryContent).toContain("/about");
expect(entryContent).toContain("/ssr");
});
it("runMiddleware in generated pages prod entry executes named proxy export", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-proxy-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
const fixtureOutDir = path.join(tmpRoot, "dist");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpRoot, "pages", "index.tsx"),
"export default function Page() { return <div>ok</div>; }\n",
);
await fsp.writeFile(
path.join(tmpRoot, "proxy.js"),
`import { NextResponse } from "next/server";
export function proxy(request) {
const url = new URL(request.url);
if (url.pathname === "/protected") {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = { matcher: ["/protected"] };
`,
);
await build({
root: tmpRoot,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(fixtureOutDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: {
output: {
entryFileNames: "entry.js",
},
},
},
});
const entryPath = path.join(fixtureOutDir, "server", "entry.js");
const entryModule = await import(pathToFileURL(entryPath).href);
const result = await entryModule.runMiddleware(new Request("http://localhost/protected"));
expect(result.continue).toBe(false);
expect(result.redirectStatus).toBe(307);
expect(result.redirectUrl).toContain("/login");
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("runMiddleware in generated pages prod entry prefers named proxy export over default (matching Next.js)", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-proxy-precedence-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
const fixtureOutDir = path.join(tmpRoot, "dist");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpRoot, "pages", "index.tsx"),
"export default function Page() { return <div>ok</div>; }\n",
);
await fsp.writeFile(
path.join(tmpRoot, "proxy.js"),
`import { NextResponse } from "next/server";
export default function defaultProxy(request) {
const url = new URL(request.url);
if (url.pathname === "/protected") {
return NextResponse.redirect(new URL("/from-default", request.url));
}
return NextResponse.next();
}
export function proxy(request) {
const url = new URL(request.url);
if (url.pathname === "/protected") {
return NextResponse.redirect(new URL("/from-proxy", request.url));
}
return NextResponse.next();
}
export function middleware(request) {
const url = new URL(request.url);
if (url.pathname === "/protected") {
return NextResponse.redirect(new URL("/from-middleware", request.url));
}
return NextResponse.next();
}
export const config = { matcher: ["/protected"] };
`,
);
await build({
root: tmpRoot,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(fixtureOutDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: {
output: {
entryFileNames: "entry.js",
},
},
},
});
const entryPath = path.join(fixtureOutDir, "server", "entry.js");
const entryModule = await import(pathToFileURL(entryPath).href);
const result = await entryModule.runMiddleware(new Request("http://localhost/protected"));
expect(result.continue).toBe(false);
expect(result.redirectStatus).toBe(307);
expect(result.redirectUrl).toContain("/from-proxy");
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("produces client bundle with page chunks and SSR manifest", async () => {
// Build the client bundle
await build({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(outDir, "client"),
manifest: true,
ssrManifest: true,
rollupOptions: {
input: "virtual:vinext-client-entry",
},
},
});
// Verify client JS output exists under Next.js's canonical
// `_next/static/chunks/` directory.
const assetsDir = path.join(outDir, "client", "_next", "static", "chunks");
expect(fs.existsSync(assetsDir)).toBe(true);
// Verify SSR manifest was produced
const manifestPath = path.join(outDir, "client", ".vite", "ssr-manifest.json");
expect(fs.existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as Record<string, string[]>;
// Manifest should have entries (module IDs -> asset URLs)
expect(Object.keys(manifest).length).toBeGreaterThan(0);
// Verify build manifest was also produced (needed for lazy chunk computation)
const buildManifestPath = path.join(outDir, "client", ".vite", "manifest.json");
expect(fs.existsSync(buildManifestPath)).toBe(true);
const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8")) as Record<
string,
ClientBuildManifestEntry
>;
const counterBuildManifestEntries = findBuildManifestEntries(
buildManifest,
"pages/counter.tsx",
);
expect(counterBuildManifestEntries.length).toBeGreaterThan(0);
expect(counterBuildManifestEntries.some(([, entry]) => typeof entry.file === "string")).toBe(
true,
);
// There should be JS files in the assets directory
const assets = fs.readdirSync(assetsDir);
const jsFiles = assets.filter((f: string) => f.endsWith(".js"));
expect(jsFiles.length).toBeGreaterThan(0);
// Client bundle should be code-split: framework (React/ReactDOM) in its
// own chunk, vinext runtime in another, and the entry bootstrap should be
// small (not a monolithic bundle containing all vendor code).
const frameworkChunk = jsFiles.find((f: string) => f.startsWith("framework-"));
const vinextChunk = jsFiles.find((f: string) => f.startsWith("vinext-"));
const entryChunk = jsFiles.find((f: string) => f.includes("vinext-client-entry"));
expect(frameworkChunk).toBeDefined();
expect(vinextChunk).toBeDefined();
expect(entryChunk).toBeDefined();
// The entry chunk should be small (just the hydration bootstrap, not the
// entire React framework). Before code-splitting this was ~200KB+.
if (entryChunk) {
const entrySize = fs.statSync(path.join(assetsDir, entryChunk)).size;
expect(entrySize).toBeLessThan(25 * 1024); // < 25 KB
}
const counterManifestEntry = Object.entries(manifest).find(
([key]) => key.endsWith("/pages/counter.tsx") || key === "pages/counter.tsx",
);
expect(counterManifestEntry).toBeDefined();
expect(counterManifestEntry?.[1].some((file: string) => file.endsWith(".js"))).toBe(true);
});
it("preserves basePath on backfilled SSR manifest entries and emitted asset tags", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-basepath-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
const fixtureOutDir = path.join(tmpRoot, "dist");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(
path.join(tmpRoot, "next.config.mjs"),
`export default { basePath: "/docs" };\n`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "counter.tsx"),
`import { useState } from "react";
export default function CounterPage() {
const [count, setCount] = useState(0);
return (
<button data-testid="increment" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
}
`,
);
await build({
root: tmpRoot,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(fixtureOutDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: { output: { entryFileNames: "entry.js" } },
},
});
await build({
root: tmpRoot,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(fixtureOutDir, "client"),
manifest: true,
ssrManifest: true,
rollupOptions: { input: "virtual:vinext-client-entry" },
},
});
const buildManifestPath = path.join(fixtureOutDir, "client", ".vite", "manifest.json");
const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8")) as Record<
string,
ClientBuildManifestEntry
>;
const counterBuildManifestEntries = findBuildManifestEntries(
buildManifest,
"pages/counter.tsx",
);
expect(counterBuildManifestEntries.length).toBeGreaterThan(0);
expect(counterBuildManifestEntries.some(([, entry]) => typeof entry.file === "string")).toBe(
true,
);
const manifestPath = path.join(fixtureOutDir, "client", ".vite", "ssr-manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as Record<
string,
string[]
>;
const counterManifestEntry = Object.entries(manifest).find(
([key]) => key.endsWith("/pages/counter.tsx") || key === "pages/counter.tsx",
);
expect(counterManifestEntry).toBeDefined();
// Next.js parity: when `basePath` is set and `assetPrefix` is unset,
// `assetPrefix` falls back to `basePath`. The on-disk layout therefore
// mirrors `<basePath>/_next/static/...` rather than the legacy
// `<basePath>/assets/...` Vite default.
// See packages/next/src/server/config.ts:528-531.
//
// Every entry should be anchored under basePath. With the parity
// fallback in effect, entries land under `<basePath>/_next/static/`
// (Vite's raw SSR manifest may produce duplicate-prefixed entries
// alongside the backfilled ones — both forms start with `docs/` so
// the prod-server's URL→file lookup is unaffected. The
// user-visible HTML asserts below are the source of truth).
expect(counterManifestEntry?.[1].every((file: string) => file.startsWith("docs/"))).toBe(
true,
);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir: fixtureOutDir,
}),
);
try {
const addr = prodServer.address() as { port: number };
const baseUrl = `http://127.0.0.1:${addr.port}`;
const res = await fetch(`${baseUrl}/docs/counter`);
expect(res.status).toBe(200);
const html = await res.text();
// Asset URLs land under `<basePath>/_next/static/` per Next.js
// parity (basePath→assetPrefix fallback). Stylesheets and scripts
// both share the same prefix.
expect(html).toContain('href="/docs/_next/static/');
expect(html).toContain('src="/docs/_next/static/');
// Every emitted asset URL must actually resolve to 200 from the
// prod server. The previous version of this test only asserted
// the URLs APPEAR in HTML, not that they were served correctly.
// The Pages Router asset lookup was stripping basePath BEFORE
// matching against the assetPrefix, so requests for
// `/docs/_next/static/...` were 404ing when assetPrefix fell
// back to basePath (round-5 review feedback on #1311).
const assetUrls = new Set<string>();
for (const m of html.matchAll(
/<(?:script|link)[^>]+(?:src|href)="(\/docs\/_next\/[^"]+)"/g,
)) {
assetUrls.add(m[1]);
}
expect(assetUrls.size).toBeGreaterThan(0);
for (const url of assetUrls) {
const assetRes = await fetch(`${baseUrl}${url}`);
expect(assetRes.status, `expected 200 for ${url}`).toBe(200);
}
} finally {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("emits Pages asset tags under a distinct assetPrefix (not basePath) and serves them 200", async () => {
// Regression guard for the basePath + distinct path-style assetPrefix bug:
// collectAssetTags used to emit modulepreload/script hrefs as
// /<basePath>/<assetPrefix>/_next/... (404) because the SSR-manifest values
// are base-anchored. assetPrefix REPLACES basePath for asset URLs, so the
// emitted hrefs must be /<assetPrefix>/_next/... — which is what actually
// serves. This test fetches every emitted asset URL and asserts 200.
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-baseprefix-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
const fixtureOutDir = path.join(tmpRoot, "dist");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(
path.join(tmpRoot, "next.config.mjs"),
`export default { basePath: "/docs", assetPrefix: "/cdn" };\n`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "counter.tsx"),
`import { useState } from "react";
export default function CounterPage() {
const [count, setCount] = useState(0);
return (
<button data-testid="increment" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
}
`,
);
await build({
root: tmpRoot,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(fixtureOutDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: { output: { entryFileNames: "entry.js" } },
},
});
await build({
root: tmpRoot,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(fixtureOutDir, "client"),
manifest: true,
ssrManifest: true,
rollupOptions: { input: "virtual:vinext-client-entry" },
},
});
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const prodServer = unwrapStartedProdServer(
await startProdServer({ port: 0, host: "127.0.0.1", outDir: fixtureOutDir }),
);
try {
const addr = prodServer.address() as { port: number };
const baseUrl = `http://127.0.0.1:${addr.port}`;
// Route is under basePath; assets are under assetPrefix.
const res = await fetch(`${baseUrl}/docs/counter`);
expect(res.status).toBe(200);
const html = await res.text();
// Asset hrefs are anchored under the assetPrefix, NOT basePath, and NOT
// the buggy base+prefix combination.
expect(html).toContain('src="/cdn/_next/static/');
expect(html).not.toContain("/docs/cdn/");
expect(html).not.toContain('src="/docs/_next/static/');
// The definitive guard: every emitted asset URL must serve 200.
const assetUrls = new Set<string>();
for (const m of html.matchAll(
/<(?:script|link)[^>]+(?:src|href)="(\/cdn\/_next\/[^"]+)"/g,
)) {
assetUrls.add(m[1]);
}
expect(assetUrls.size).toBeGreaterThan(0);
for (const url of assetUrls) {
const assetRes = await fetch(`${baseUrl}${url}`);
expect(assetRes.status, `expected 200 for ${url}`).toBe(200);
}
} finally {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("renders pages/404 for basePath route misses after stripping one basePath segment", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-basepath-404-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
const fixtureOutDir = path.join(tmpRoot, "dist");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(
path.join(tmpRoot, "next.config.mjs"),
`export default { basePath: "/docs" };\n`,
);
await fsp.writeFile(path.join(tmpRoot, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
await fsp.writeFile(
path.join(tmpRoot, "pages", "404.tsx"),
`export default function Custom404() {
return <main id="custom-404">This page could not be found</main>;
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "hello.tsx"),
`export default function Hello() {
return <main id="hello">Hello World</main>;
}
`,
);
await buildPagesFixtureToOutDir(tmpRoot, fixtureOutDir);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir: fixtureOutDir,
}),
);
try {
const addr = prodServer.address() as { port: number };
const baseUrl = `http://127.0.0.1:${addr.port}`;
const res = await fetch(`${baseUrl}/docs/docs/other-page`);
expect(res.status).toBe(404);
const html = await res.text();
expect(html).toContain('id="custom-404"');
expect(html).toContain("This page could not be found");
expect(html).toContain('"page":"/404"');
} finally {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("applies fallback rewrites before rendering custom 404 pages", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-fallback-before-404-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
const fixtureOutDir = path.join(tmpRoot, "dist");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(
path.join(tmpRoot, "next.config.mjs"),
`export default {
basePath: "/docs",
async rewrites() {
return {
fallback: [{ source: "/:path*", destination: "/fallback" }],
};
},
};
`,
);
await fsp.writeFile(path.join(tmpRoot, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
await fsp.writeFile(
path.join(tmpRoot, "pages", "404.tsx"),
`export default function Custom404() {
const shouldThrow = Boolean(
(globalThis as { __VINEXT_FALLBACK_REWRITE_TEST_RUNTIME?: boolean })
.__VINEXT_FALLBACK_REWRITE_TEST_RUNTIME,
);
if (shouldThrow) {
throw new Error("pages/404 should not execute before fallback rewrites");
}
return <main id="custom-404">This page could not be found</main>;
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "fallback.tsx"),
`export default function Fallback() {
return <main id="fallback">Fallback rewrite</main>;
}
`,
);
await buildPagesFixtureToOutDir(tmpRoot, fixtureOutDir);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir: fixtureOutDir,
}),
);
try {
const addr = prodServer.address() as { port: number };
const baseUrl = `http://127.0.0.1:${addr.port}`;
const explicitNotFoundRes = await fetch(`${baseUrl}/docs/404`);
expect(explicitNotFoundRes.status).toBe(404);
const explicitNotFoundHtml = await explicitNotFoundRes.text();
expect(explicitNotFoundHtml).toContain('id="custom-404"');
expect(explicitNotFoundHtml).toContain("This page could not be found");
expect(explicitNotFoundHtml).toContain('"page":"/404"');
expect(explicitNotFoundHtml).not.toContain('id="fallback"');
(
globalThis as { __VINEXT_FALLBACK_REWRITE_TEST_RUNTIME?: boolean }
).__VINEXT_FALLBACK_REWRITE_TEST_RUNTIME = true;
const res = await fetch(`${baseUrl}/docs/missing`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('id="fallback"');
expect(html).toContain("Fallback rewrite");
expect(html).toContain('"page":"/fallback"');
expect(html).not.toContain("pages/404 should not execute before fallback rewrites");
} finally {
delete (globalThis as { __VINEXT_FALLBACK_REWRITE_TEST_RUNTIME?: boolean })
.__VINEXT_FALLBACK_REWRITE_TEST_RUNTIME;
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("falls back to pages/_error for route misses when pages/404 is absent", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-basepath-error-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
const fixtureOutDir = path.join(tmpRoot, "dist");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(
path.join(tmpRoot, "next.config.mjs"),
`export default { basePath: "/docs" };\n`,
);
await fsp.writeFile(path.join(tmpRoot, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
await fsp.writeFile(
path.join(tmpRoot, "pages", "_error.tsx"),
`export default function ErrorPage({ statusCode }: { statusCode?: number }) {
return <main id="custom-error">Error status: {statusCode}</main>;
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "hello.tsx"),
`export default function Hello() {
return <main id="hello">Hello World</main>;
}
`,
);
await buildPagesFixtureToOutDir(tmpRoot, fixtureOutDir);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir: fixtureOutDir,
}),
);
try {
const addr = prodServer.address() as { port: number };
const baseUrl = `http://127.0.0.1:${addr.port}`;
const res = await fetch(`${baseUrl}/docs/docs/other-page`);
expect(res.status).toBe(404);
const html = await res.text();
expect(html).toContain('id="custom-error"');
expect(html).toContain("Error status:");
expect(html).toContain("404</main>");
expect(html).toContain('"page":"/_error"');
} finally {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
// Ported from Next.js: test/e2e/error-handler-not-found-req-url/error-handler-not-found-req-url.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/error-handler-not-found-req-url/error-handler-not-found-req-url.test.ts
it("passes the original request URL and asPath to _error.getInitialProps for getStaticProps notFound", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-gsp-notfound-error-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
const fixtureOutDir = path.join(tmpRoot, "dist");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(path.join(tmpRoot, "next.config.mjs"), `export default {};\n`);
await fsp.writeFile(path.join(tmpRoot, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
await fsp.writeFile(
path.join(tmpRoot, "pages", "_error.tsx"),
`import type { NextPageContext } from "next";
type ErrorProps = { reqUrl?: string; asPath?: string };
ErrorPage.getInitialProps = (ctx: NextPageContext): ErrorProps => {
return {
reqUrl: ctx.req?.url,
asPath: ctx.asPath,
};
};
export default function ErrorPage({ reqUrl, asPath }: ErrorProps) {
return <p>reqUrl: {reqUrl}, asPath: {asPath}</p>;
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "[slug].tsx"),
`export default function Page() {
return <p>hello world</p>;
}
export async function getStaticProps() {
return {
notFound: true,
};
}
export async function getStaticPaths() {
return {
paths: [],
fallback: "blocking",
};
}
`,
);
await buildPagesFixtureToOutDir(tmpRoot, fixtureOutDir);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir: fixtureOutDir,
}),
);
try {
const addr = prodServer.address() as { port: number };
const baseUrl = `http://127.0.0.1:${addr.port}`;
const res = await fetch(`${baseUrl}/3`);
expect(res.status).toBe(404);
const html = await res.text();
const visibleText = html
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/<[^>]*>/g, "")
.replace(/\s+/g, " ")
.trim();
expect(visibleText).toContain("reqUrl: /3, asPath: /3");
expect(html).toContain('"page":"/_error"');
} finally {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("renders explicit pages/404 over pages/_error when getStaticProps returns notFound", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-gsp-notfound-404-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
const fixtureOutDir = path.join(tmpRoot, "dist");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(path.join(tmpRoot, "next.config.mjs"), `export default {};\n`);
await fsp.writeFile(path.join(tmpRoot, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
await fsp.writeFile(
path.join(tmpRoot, "pages", "_error.tsx"),
`export default function ErrorPage() {
return <p id="error">_error page</p>;
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "404.tsx"),
`export default function Custom404() {
return <p id="custom-404">custom 404</p>;
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "[slug].tsx"),
`export default function Page() {
return <p>hello world</p>;
}
export async function getStaticProps() {
return {
notFound: true,
};
}
export async function getStaticPaths() {
return {
paths: [],
fallback: "blocking",
};
}
`,
);
await buildPagesFixtureToOutDir(tmpRoot, fixtureOutDir);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir: fixtureOutDir,
}),
);
try {
const addr = prodServer.address() as { port: number };
const baseUrl = `http://127.0.0.1:${addr.port}`;
const res = await fetch(`${baseUrl}/3`);
expect(res.status).toBe(404);
const html = await res.text();
expect(html).toContain('id="custom-404"');
expect(html).toContain("custom 404");
expect(html).not.toContain('id="error"');
expect(html).toContain('"page":"/404"');
} finally {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("passes the original error to _error.getInitialProps when getServerSideProps throws", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-gssp-throw-error-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
const fixtureOutDir = path.join(tmpRoot, "dist");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(path.join(tmpRoot, "next.config.mjs"), `export default {};\n`);
await fsp.writeFile(path.join(tmpRoot, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
await fsp.writeFile(
path.join(tmpRoot, "pages", "_error.tsx"),
`import type { NextPageContext } from "next";
type ErrorProps = { errMessage?: string };
ErrorPage.getInitialProps = (ctx: NextPageContext): ErrorProps => {
return {
errMessage: ctx.err instanceof Error ? ctx.err.message : String(ctx.err),
};
};
export default function ErrorPage({ errMessage }: ErrorProps) {
return <p>errMessage: {errMessage}</p>;
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "index.tsx"),
`export default function Page() {
return <p>hello world</p>;
}
export async function getServerSideProps() {
throw new Error("intentional gssp throw");
}
`,
);
await buildPagesFixtureToOutDir(tmpRoot, fixtureOutDir);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir: fixtureOutDir,
}),
);
try {
const addr = prodServer.address() as { port: number };
const baseUrl = `http://127.0.0.1:${addr.port}`;
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(500);
const html = await res.text();
const visibleText = html
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/<[^>]*>/g, "")
.replace(/\s+/g, " ")
.trim();
expect(visibleText).toContain("errMessage: intentional gssp throw");
} finally {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("preserves 404 status for cached ISR custom 404 route misses", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-isr-404-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
const fixtureOutDir = path.join(tmpRoot, "dist");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(path.join(tmpRoot, "next.config.mjs"), `export default {};\n`);
await fsp.writeFile(path.join(tmpRoot, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
await fsp.writeFile(
path.join(tmpRoot, "pages", "404.tsx"),
`export async function getStaticProps() {
return { props: { marker: "custom ISR 404" }, revalidate: 60 };
}
export default function Custom404({ marker }: { marker: string }) {
return <main id="custom-404">{marker}</main>;
}
`,
);
await buildPagesFixtureToOutDir(tmpRoot, fixtureOutDir);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir: fixtureOutDir,
}),
);
try {
const addr = prodServer.address() as { port: number };
const baseUrl = `http://127.0.0.1:${addr.port}`;
const missingUrl = `${baseUrl}/cached-custom-404-miss`;
const first = await fetch(missingUrl);
expect(first.status).toBe(404);
expect(first.headers.get("x-vinext-cache")).toBe("MISS");
const firstHtml = await first.text();
expect(firstHtml).toContain('id="custom-404"');
expect(firstHtml).toContain("custom ISR 404");
const second = await fetch(missingUrl);
expect(second.status).toBe(404);
expect(second.headers.get("x-vinext-cache")).toBe("HIT");
const secondHtml = await second.text();
expect(secondHtml).toContain('id="custom-404"');
expect(secondHtml).toContain("custom ISR 404");
} finally {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("emits stylesheet and static asset URLs for backfilled inlined pages", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-inline-assets-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
const fixtureOutDir = path.join(tmpRoot, "dist");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(path.join(tmpRoot, "next.config.mjs"), `export default {};\n`);
await fsp.writeFile(
path.join(tmpRoot, "pages", "counter.module.css"),
`.button { color: red; background-image: url("./dot.svg"); }\n`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "dot.svg"),
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5"/></svg>\n`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "counter.tsx"),
`import { useState } from "react";
import styles from "./counter.module.css";
export default function CounterPage() {
const [count, setCount] = useState(0);
return (
<button className={styles.button} data-testid="increment" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
}
`,
);
await build({
root: tmpRoot,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(fixtureOutDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: { output: { entryFileNames: "entry.js" } },
},
});
await build({
root: tmpRoot,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(fixtureOutDir, "client"),
manifest: true,
ssrManifest: true,
rollupOptions: { input: "virtual:vinext-client-entry" },
},
});
const buildManifestPath = path.join(fixtureOutDir, "client", ".vite", "manifest.json");
const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8")) as Record<
string,
ClientBuildManifestEntry
>;
const counterBuildManifestEntries = findBuildManifestEntries(
buildManifest,
"pages/counter.tsx",
);
expect(counterBuildManifestEntries.length).toBeGreaterThan(0);
expect(
counterBuildManifestEntries.some(
([, entry]) =>
typeof entry.file === "string" ||
(Array.isArray(entry.css) && entry.css.length > 0) ||
(Array.isArray(entry.assets) && entry.assets.length > 0),
),
).toBe(true);
const manifestPath = path.join(fixtureOutDir, "client", ".vite", "ssr-manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as Record<
string,
string[]
>;
const counterManifestEntries = Object.entries(manifest).filter(
([key]) => key.endsWith("/pages/counter.tsx") || key === "pages/counter.tsx",
);
expect(counterManifestEntries.length).toBeGreaterThan(0);
const populatedCounterManifestEntry = counterManifestEntries.find(([, files]) =>
files.some((file: string) => file.endsWith(".css")),
);
expect(populatedCounterManifestEntry).toBeDefined();
const cssFile = populatedCounterManifestEntry?.[1].find((file: string) =>
file.endsWith(".css"),
);
expect(cssFile).toBeDefined();
const cssContent = fs.readFileSync(path.join(fixtureOutDir, "client", cssFile!), "utf-8");
expect(cssContent).toContain("url(");
expect(cssContent).toMatch(/data:image\/svg\+xml|\.svg/);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir: fixtureOutDir,
}),
);
try {
const addr = prodServer.address() as { port: number };
const res = await fetch(`http://127.0.0.1:${addr.port}/counter`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('rel="stylesheet"');
expect(html).toContain(".css");
} finally {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("serves pages from production build end-to-end", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const manifestPath = path.join(outDir, "client", ".vite", "ssr-manifest.json");
// Both should exist from prior tests
if (!fs.existsSync(serverEntryPath) || !fs.existsSync(manifestPath)) {
// Build if needed (tests may run in isolation)
await build({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(outDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: { output: { entryFileNames: "entry.js" } },
},
});
await build({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(outDir, "client"),
manifest: true,
ssrManifest: true,
rollupOptions: { input: "virtual:vinext-client-entry" },
},
});
}
// Import the server entry
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
// Create a minimal HTTP server using the built entry.
// The server entry uses Web-standard Request/Response, so we bridge
// from Node.js HTTP objects.
const { createServer: createHttpServer } = await import("node:http");
const httpServer = createHttpServer((req, res) => {
void (async () => {
const url = req.url ?? "/";
const pathname = url.split("?")[0];
// Convert Node.js req to Web Request
const headers = new Headers();
for (const [k, v] of Object.entries(req.headers)) {
if (v) headers.set(k, Array.isArray(v) ? v.join(", ") : v);
}
const host = req.headers.host ?? "localhost";
const method = req.method ?? "GET";
const init: RequestInit & { duplex?: "half" } = {
method,
headers,
};
if (method !== "GET" && method !== "HEAD") {
init.body = Readable.toWeb(req) as ReadableStream;
init.duplex = "half";
}
const webRequest = new Request(`http://${host}${url}`, init);
let response: Response;
if (pathname.startsWith("/api/") || pathname === "/api") {
response = await serverEntry.handleApiRoute(webRequest, url);
} else {
response = await serverEntry.renderPage(webRequest, url, manifest);
}
// Pipe Web Response back to Node.js res
const body = await response.text();
const resHeaders: Record<string, string> = {};
response.headers.forEach((v: string, k: string) => {
resHeaders[k] = v;
});
res.writeHead(response.status, response.statusText || undefined, resHeaders);
res.end(body);
})().catch((error: unknown) => {
res.destroy(error instanceof Error ? error : new Error(String(error)));
});
});
// Start on a random port
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
const addr = httpServer.address() as { port: number };
const prodUrl = `http://localhost:${addr.port}`;
try {
// Test: index page renders
const indexRes = await fetch(`${prodUrl}/`);
expect(indexRes.status).toBe(200);
const indexHtml = await indexRes.text();
expect(indexHtml).toContain("Hello, vinext!");
expect(indexHtml).toContain("__NEXT_DATA__");
// Test: about page renders
const aboutRes = await fetch(`${prodUrl}/about`);
expect(aboutRes.status).toBe(200);
const aboutHtml = await aboutRes.text();
expect(aboutHtml).toContain("About");
const isrFirstRes = await fetch(`${prodUrl}/isr-second-render-state`);
expect(isrFirstRes.status).toBe(200);
expect(isrFirstRes.headers.get("x-vinext-cache")).toBe("MISS");
const isrFirstHtml = await isrFirstRes.text();
expect(isrFirstHtml).toContain('data-testid="head-before">0<');
expect(isrFirstHtml).toContain('data-testid="private-cache-before">0<');
expect(isrFirstHtml).toContain('data-testid="inserted-html-before">0<');
const isrSecondRes = await fetch(`${prodUrl}/isr-second-render-state`);
expect(isrSecondRes.status).toBe(200);
expect(isrSecondRes.headers.get("x-vinext-cache")).toBe("HIT");
const isrSecondHtml = await isrSecondRes.text();
expect(isrSecondHtml).toContain('data-testid="head-before">0<');
expect(isrSecondHtml).toContain('data-testid="private-cache-before">0<');
expect(isrSecondHtml).toContain('data-testid="inserted-html-before">0<');
// Test: SSR page with getServerSideProps
const ssrRes = await fetch(`${prodUrl}/ssr`);
expect(ssrRes.status).toBe(200);
// Regression for #1461: gssp pages get the default Cache-Control header.
expect(ssrRes.headers.get("cache-control")).toBe(
"private, no-cache, no-store, max-age=0, must-revalidate",
);
const ssrHtml = await ssrRes.text();
expect(ssrHtml).toContain("Server-Side Rendered");
// Regression for #1461: user-set Cache-Control via res.setHeader sticks.
const ssrCcRes = await fetch(`${prodUrl}/ssr-cache-control`);
expect(ssrCcRes.status).toBe(200);
expect(ssrCcRes.headers.get("cache-control")).toBe("public, max-age=42");
await ssrCcRes.text();
// Regression test for #1354: a page that exports `getServerSideProps`
// via a separate `export { getServerSideProps }` re-export must build
// and render in production. Previously, the client bundle transform
// emitted a stub `export const getServerSideProps = undefined;` that
// collided with the user's local `const getServerSideProps = ...`
// binding and broke the Rolldown/OXC parse step.
const gsspNamedRes = await fetch(`${prodUrl}/gssp-named-export`);
expect(gsspNamedRes.status).toBe(200);
const gsspNamedHtml = await gsspNamedRes.text();
expect(gsspNamedHtml).toContain("gSSP via named export");
expect(gsspNamedHtml).toContain("Hello from named-export gSSP");
// Test: API route
const apiRes = await fetch(`${prodUrl}/api/hello`);
expect(apiRes.status).toBe(200);
const apiData = await apiRes.json();
expect(apiData).toEqual({ message: "Hello from API!" });
const invalidJsonRes = await fetch(`${prodUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: `{"message":Invalid"}`,
});
expect(invalidJsonRes.status).toBe(400);
expect(invalidJsonRes.statusText).toBe("Invalid JSON");
expect(await invalidJsonRes.text()).toBe("Invalid JSON");
const duplicateFormRes = await fetch(`${prodUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "tag=a&tag=b&tag=c",
});
expect(duplicateFormRes.status).toBe(200);
expect(await duplicateFormRes.json()).toEqual({ tag: ["a", "b", "c"] });
const emptyJsonRes = await fetch(`${prodUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "",
});
expect(emptyJsonRes.status).toBe(200);
expect(await emptyJsonRes.json()).toEqual({});
const ldJsonRes = await fetch(`${prodUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/ld+json; charset=utf-8" },
body: JSON.stringify({ title: "doc" }),
});
expect(ldJsonRes.status).toBe(200);
expect(await ldJsonRes.json()).toEqual({ title: "doc" });
// Test: Pages Router edge runtime API route. Regression coverage for
// cloudflare/vinext#1338 — edge runtime API routes were reported as
// returning 500 against the Next.js deploy suite. Verifies the
// production server entry correctly dispatches edge handlers.
const edgeApiRes = await fetch(`${prodUrl}/api/edge-hello?a=b`);
expect(edgeApiRes.status).toBe(200);
expect(edgeApiRes.headers.get("content-type")).toContain("application/json");
expect(await edgeApiRes.json()).toEqual({ hello: "world", query: { a: "b" } });
// Test: Pages Router edge runtime OG image route. Regression coverage
// for cloudflare/vinext#1338 — OG routes were reported as returning
// 404 against the Next.js deploy suite.
const ogRes = await fetch(`${prodUrl}/api/og`);
expect(ogRes.status).toBe(200);
expect(ogRes.headers.get("content-type")).toContain("image/png");
expect((await ogRes.blob()).size).toBeGreaterThan(0);
// Test: 404 for unknown route
const notFoundRes = await fetch(`${prodUrl}/nonexistent`);
expect(notFoundRes.status).toBe(404);
// Test: page using top-level await (async module).
// Ported from Next.js: test/e2e/async-modules/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/async-modules/index.test.ts
const asyncModRes = await fetch(`${prodUrl}/async-modules-test`);
expect(asyncModRes.status).toBe(200);
const asyncModHtml = await asyncModRes.text();
expect(asyncModHtml).toContain('<div id="app-value">hello</div>');
expect(asyncModHtml).toContain('<div id="page-value">42</div>');
// Regression for #1458: when getServerSideProps throws, the production
// server must render the user's custom pages/500.tsx with status 500
// instead of returning a plain "Internal Server Error" text response.
// Mirrors Next.js test/e2e/getserversideprops "should handle throw
// ENOENT correctly" (.nextjs-ref/test/e2e/getserversideprops/test/index.test.ts:377).
const gsspThrowRes = await fetch(`${prodUrl}/gssp-throw`);
expect(gsspThrowRes.status).toBe(500);
const gsspThrowHtml = await gsspThrowRes.text();
expect(gsspThrowHtml).toContain("custom pages/500");
expect(gsspThrowHtml).not.toBe("Internal Server Error");
} finally {
httpServer.close();
}
});
it("server entry exports runMiddleware function", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
expect(typeof serverEntry.runMiddleware).toBe("function");
});
it("runMiddleware skips non-matching paths", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
// The middleware matcher is /((?!api|_next|favicon\.ico).*) so /api should not match
const request = new Request("http://localhost/api/hello");
const result = await serverEntry.runMiddleware(request);
expect(result.continue).toBe(true);
expect(result.redirectUrl).toBeUndefined();
});
it("runMiddleware handles redirect (/old-page -> /about)", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
const request = new Request("http://localhost/old-page");
const result = await serverEntry.runMiddleware(request);
expect(result.continue).toBe(false);
expect(result.redirectUrl).toContain("/about");
expect(result.redirectStatus).toBe(307);
});
it("runMiddleware preserves responseHeaders on redirect (/redirect-with-cookies)", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
const request = new Request("http://localhost/redirect-with-cookies");
const result = await serverEntry.runMiddleware(request);
expect(result.continue).toBe(false);
expect(result.redirectUrl).toContain("/about");
expect(result.redirectStatus).toBe(307);
// The inline runMiddleware codegen must collect non-internal headers
// (e.g. Set-Cookie) on redirect responses, just like it does for
// next() and rewrite() responses.
expect(result.responseHeaders).toBeDefined();
const cookies = [...result.responseHeaders.entries()]
.filter(([k]: [string, string]) => k === "set-cookie")
.map(([, v]: [string, string]) => v);
expect(cookies.some((c: string) => c.includes("mw-session=abc123"))).toBe(true);
expect(cookies.some((c: string) => c.includes("mw-theme=dark"))).toBe(true);
});
it("runMiddleware handles rewrite (/rewritten -> /ssr)", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
const request = new Request("http://localhost/rewritten");
const result = await serverEntry.runMiddleware(request);
expect(result.continue).toBe(true);
expect(result.rewriteUrl).toContain("/ssr");
});
it("runMiddleware preserves internal middleware cookie headers on rewrites", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
const request = new Request("http://localhost/rewrite-with-cookie");
const result = await serverEntry.runMiddleware(request);
expect(result.continue).toBe(true);
expect(result.rewriteUrl).toContain("/ssr");
expect(result.responseHeaders.get("x-middleware-set-cookie")).toContain(
"rewrite-cookie=visible",
);
});
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
it("runMiddleware preserves external middleware rewrite destinations", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
if (!fs.existsSync(serverEntryPath)) {
await build({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(outDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: {
output: {
entryFileNames: "entry.js",
},
},
},
});
}
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
const request = new Request("http://localhost/external-middleware-rewrite");
const result = await serverEntry.runMiddleware(request);
expect(result.continue).toBe(true);
expect(result.rewriteUrl).toBe("https://api.example.com/from-middleware?ok=1");
});
it("runMiddleware handles block (/blocked -> 403)", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
const request = new Request("http://localhost/blocked");
const result = await serverEntry.runMiddleware(request);
expect(result.continue).toBe(false);
expect(result.response).toBeInstanceOf(Response);
expect(result.response.status).toBe(403);
});
it("runMiddleware strips internal cookie headers from custom responses", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
const request = new Request("http://localhost/blocked-with-cookie");
const result = await serverEntry.runMiddleware(request);
expect(result.continue).toBe(false);
expect(result.response).toBeInstanceOf(Response);
expect(result.response.status).toBe(403);
expect(result.response.headers.get("x-middleware-set-cookie")).toBeNull();
expect(result.response.headers.get("set-cookie")).toContain("blocked=1");
});
it("runMiddleware sets x-custom-middleware header on matched paths", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
// /about matches the middleware but doesn't redirect/rewrite/block
const request = new Request("http://localhost/about");
const result = await serverEntry.runMiddleware(request);
expect(result.continue).toBe(true);
expect(result.responseHeaders).toBeDefined();
expect(result.responseHeaders.get("x-custom-middleware")).toBe("active");
});
it("runMiddleware preserves x-middleware-request-* headers from NextResponse.next({ request: { headers } })", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
// /header-override triggers NextResponse.next({ request: { headers } }) which sets
// x-middleware-request-x-custom-injected header. The runMiddleware codegen must
// preserve these so the downstream consumer can unpack them into actual request headers.
const request = new Request("http://localhost/header-override");
const result = await serverEntry.runMiddleware(request);
expect(result.continue).toBe(true);
expect(result.responseHeaders).toBeDefined();
// x-middleware-request-* headers must be preserved (the fix)
expect(result.responseHeaders.get("x-middleware-request-x-custom-injected")).toBe(
"from-middleware",
);
// Other x-middleware-* internal headers must be stripped
expect(result.responseHeaders.get("x-middleware-next")).toBeNull();
});
it("runMiddleware returns 500 when middleware throws", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
const request = new Request("http://localhost/middleware-throw");
const result = await serverEntry.runMiddleware(request);
expect(result.continue).toBe(false);
expect(result.response).toBeInstanceOf(Response);
expect(result.response.status).toBe(500);
});
});
describe("Production server middleware (Pages Router)", () => {
const outDir = path.resolve(FIXTURE_DIR, "dist");
let prodServer: import("node:http").Server | undefined;
let prodUrl: string;
beforeAll(async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const manifestPath = path.join(outDir, "client", ".vite", "ssr-manifest.json");
// Build if needed (tests may run in isolation)
if (!fs.existsSync(serverEntryPath) || !fs.existsSync(manifestPath)) {
await build({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(outDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: { output: { entryFileNames: "entry.js" } },
},
});
await build({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(outDir, "client"),
manifest: true,
ssrManifest: true,
rollupOptions: { input: "virtual:vinext-client-entry" },
},
});
}
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir,
}),
);
const addr = prodServer.address() as { port: number };
prodUrl = `http://127.0.0.1:${addr.port}`;
});
afterAll(async () => {
if (prodServer) {
await new Promise<void>((resolve) => prodServer!.close(() => resolve()));
}
});
it("redirects /old-page to /about via middleware", async () => {
const res = await fetch(`${prodUrl}/old-page`, { redirect: "manual" });
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/about");
});
// Ported from Next.js: test/e2e/middleware-general/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-general/test/index.test.ts
it("passes middleware rewrite search params to Pages Router edge API nextUrl in production", async () => {
const res = await fetch(`${prodUrl}/api/edge-search-params?a=b`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
a: "b",
foo: "bar",
});
});
it("preserves the original pathname and adds route params for rewritten edge APIs in production", async () => {
const res = await fetch(`${prodUrl}/edge-api-rewrite/id-1?a=b`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
pathname: "/edge-api-rewrite/id-1",
query: {
a: "b",
foo: "bar",
id: "id-1",
},
});
});
// Refs #1463: prod-server parity for the dev-server 405 check. POST to a
// static Pages Router page must return 405 + Allow: GET, HEAD.
// Ported from Next.js: test/e2e/prerender.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/prerender.test.ts
// ('should respond with 405 for POST to static page').
it("returns 405 with Allow: GET, HEAD on POST to a static Pages page (prod)", async () => {
const res = await fetch(`${prodUrl}/about`, { method: "POST" });
expect(res.status).toBe(405);
expect(res.headers.get("allow")).toBe("GET, HEAD");
expect(await res.text()).toContain("Method Not Allowed");
});
// Regression for #1331: after a middleware rewrite, the rewrite target
// must go through full route resolution where static routes win over
// dynamic catch-alls. Without the fix the `[id]` dynamic page captures
// the rewrite target and renders "Dynamic route" with id="rewrite-me".
it("middleware rewrite resolves static index over [id] dynamic route in production", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-mw-rewrite-priority-prod-"));
writeMiddlewareRewritePriorityFixture(tmpDir);
let prodServer: import("node:http").Server | undefined;
try {
await build({
root: tmpDir,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(tmpDir, "dist", "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: { output: { entryFileNames: "entry.js" } },
},
});
await build({
root: tmpDir,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(tmpDir, "dist", "client"),
manifest: true,
ssrManifest: true,
rollupOptions: { input: "virtual:vinext-client-entry" },
},
});
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir: path.join(tmpDir, "dist"),
}),
);
const addr = prodServer.address() as { port: number };
const tempProdUrl = `http://127.0.0.1:${addr.port}`;
const indexRes = await fetch(`${tempProdUrl}/rewrite-me/`);
expect(indexRes.status).toBe(200);
const indexHtml = await indexRes.text();
// `id="home"` is unique to `pages/index.tsx`; ssr-page also says
// "Hello World" so this disambiguates that the index rendered.
expect(indexHtml).toContain('id="home"');
expect(indexHtml).toContain("Hello World");
expect(indexHtml).not.toContain("Dynamic route");
const aboutRes = await fetch(`${tempProdUrl}/rewrite-to-about/`);
expect(aboutRes.status).toBe(200);
const aboutHtml = await aboutRes.text();
expect(aboutHtml).toContain("About Page");
expect(aboutHtml).not.toContain("Dynamic route");
// Next.js parity: with trailingSlash: true and a [id] dynamic root,
// `/rewrite-1/` matches `[id]` but afterFiles config rewrites must
// still rewrite it to /ssr-page, and the rewrite target must resolve
// to the static ssr-page rather than back into [id].
const cfgRes = await fetch(`${tempProdUrl}/rewrite-1/`);
expect(cfgRes.status).toBe(200);
const cfgHtml = await cfgRes.text();
// `id="ssr"` is unique to `pages/ssr-page.tsx`; `pages/index.tsx`
// also says "Hello World" so this disambiguates that the rewrite
// target rendered (not the index, not the dynamic [id]).
expect(cfgHtml).toContain('id="ssr"');
expect(cfgHtml).toContain("Hello World");
expect(cfgHtml).not.toContain("Dynamic route");
} finally {
await new Promise<void>((resolve) => prodServer?.close(() => resolve()) ?? resolve());
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("does not collapse encoded slashes onto nested routes in production", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-encoded-prod-"));
writeEncodedSlashPagesFixture(tmpDir);
let prodServer: import("node:http").Server | undefined;
try {
await build({
root: tmpDir,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(tmpDir, "dist", "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: { output: { entryFileNames: "entry.js" } },
},
});
await build({
root: tmpDir,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(tmpDir, "dist", "client"),
manifest: true,
ssrManifest: true,
rollupOptions: { input: "virtual:vinext-client-entry" },
},
});
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir: path.join(tmpDir, "dist"),
}),
);
const addr = prodServer.address() as { port: number };
const tempProdUrl = `http://127.0.0.1:${addr.port}`;
const encodedRes = await fetch(`${tempProdUrl}/a%2Fb`);
expect(encodedRes.status).toBe(404);
expect(await encodedRes.text()).not.toContain("nested blocked");
const nestedRes = await fetch(`${tempProdUrl}/a/b`);
expect(nestedRes.status).toBe(418);
expect(await nestedRes.text()).toBe("nested blocked");
} finally {
await new Promise<void>((resolve) => prodServer?.close(() => resolve()) ?? resolve());
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("preserves Set-Cookie headers on middleware redirect", async () => {
const res = await fetch(`${prodUrl}/redirect-with-cookies`, {
redirect: "manual",
});
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/about");
// Middleware sets mw-session and mw-theme cookies on this redirect.
// These must survive into the production response — not be dropped.
const cookies = res.headers.getSetCookie();
expect(cookies.some((c: string) => c.includes("mw-session=abc123"))).toBe(true);
expect(cookies.some((c: string) => c.includes("mw-theme=dark"))).toBe(true);
});
it("adds middleware CSP nonces to production Pages Router scripts and preloads", async () => {
const res = await fetch(`${prodUrl}/dynamic-page?mw-csp-nonce=pages-prod`);
expect(res.status).toBe(200);
expect(res.headers.get("content-security-policy")).toBe(
"script-src 'nonce-pages-prod' 'strict-dynamic';",
);
const html = await res.text();
expect(html).toContain('<script nonce="pages-prod">window.__NEXT_DATA__ = ');
expect(html).toMatch(/<script type="module" defer nonce="pages-prod" src="\/[^"]+"/);
expect(html).toMatch(/<link rel="modulepreload" nonce="pages-prod" href="\/[^"]+"/);
});
// Ported from Next.js: test/e2e/optimized-loading/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/optimized-loading/test/index.test.ts
//
// Regression for #1519: optimized loading is enabled by default in Next.js
// (`experimental.disableOptimizedLoading: false`). Page scripts must be
// emitted with `defer` in <head>, not as plain scripts at the end of <body>
// (and never as `async`). The Next.js E2E asserts both `script[async]
// .length === 0` and `head script[defer].length > 0`.
it("emits page scripts with defer in <head> by default (optimized loading)", async () => {
const res = await fetch(`${prodUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
// No async script tags (matches `script[async].length === 0` upstream).
expect(html).not.toMatch(/<script[^>]*\sasync(\s|>|=)/);
// Locate the head so we can search just that slice for defer scripts.
const headEnd = html.indexOf("</head>");
expect(headEnd).toBeGreaterThan(-1);
const head = html.slice(0, headEnd);
// Matches `head script[defer].length > 0` upstream.
const deferInHead = head.match(/<script[^>]*\sdefer(\s|>|=)[^>]*>/g) ?? [];
expect(deferInHead.length).toBeGreaterThan(0);
});
it("does not serve cached production Pages ISR HTML to CSP nonce requests", async () => {
const first = await fetch(`${prodUrl}/isr-test`);
expect(first.status).toBe(200);
expect(first.headers.get("x-vinext-cache")).toBe("MISS");
const firstHtml = await first.text();
expect(firstHtml).not.toContain("nonce=");
const second = await fetch(`${prodUrl}/isr-test?mw-csp-nonce=pages-prod-isr`);
expect(second.status).toBe(200);
expect(second.headers.get("content-security-policy")).toBe(
"script-src 'nonce-pages-prod-isr' 'strict-dynamic';",
);
expect(second.headers.get("cache-control")).toBe("no-store, must-revalidate");
expect(second.headers.get("x-vinext-cache")).toBeNull();
const secondHtml = await second.text();
expect(secondHtml).toContain('<script nonce="pages-prod-isr">window.__NEXT_DATA__ = ');
});
it("rewrites /rewritten to render /ssr content", async () => {
const res = await fetch(`${prodUrl}/rewritten`);
expect(res.status).toBe(200);
const html = await res.text();
// /rewritten should serve the content of /ssr page
expect(html).toContain("Server-Side Rendered");
});
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
// ('should rewrite to fallback: true page successfully').
// Refs #1331: post-rewrite fallback: true must render the loading shell.
it("renders the loading shell when middleware/route targets an unlisted fallback: true path", async () => {
const res = await fetch(`${prodUrl}/products/never-built`);
expect(res.status).toBe(200);
const html = await res.text();
// Page renders its fallback branch (the slug is not in getStaticPaths).
expect(html).toContain("Loading product...");
// Full-data branch must not have rendered — getStaticProps was skipped.
expect(html).not.toMatch(/Product ID:.*never-built/);
const match = html.match(/__NEXT_DATA__\s*=\s*(\{.*?\})\s*[;<]/);
expect(match).toBeTruthy();
const nextData = JSON.parse(match![1]);
expect(nextData.isFallback).toBe(true);
expect(nextData.props).toEqual({ pageProps: {} });
});
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
// ('should handle middleware rewrite with body correctly').
// Refs #1331: POST bodies must reach the upstream when middleware
// externally rewrites the request.
it("forwards the POST body to the upstream on external middleware rewrites", async () => {
const { createServer: createHttpServer } = await import("node:http");
const upstream = createHttpServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const received = Buffer.concat(chunks);
res.writeHead(200, { "content-type": "application/json" });
res.end(received);
});
});
try {
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
const addr = upstream.address();
if (typeof addr === "string" || addr === null) throw new Error("Expected upstream port");
const body = JSON.stringify({ hello: "world" });
const res = await fetch(`${prodUrl}/external-middleware-rewrite-body`, {
method: "POST",
body,
headers: {
"content-type": "application/json",
"x-middleware-test-rewrite-target": `http://127.0.0.1:${addr.port}/echo-body`,
},
});
expect(res.status).toBe(200);
expect(await res.text()).toBe(body);
} finally {
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
// ('should handle middleware rewrite with body and headers correctly').
// Refs #1331: `NextResponse.rewrite(url, { request: { headers } })` request
// header overrides must propagate to the proxied upstream request.
it("forwards middleware-overridden request headers on external middleware rewrites", async () => {
const { createServer: createHttpServer } = await import("node:http");
const upstream = createHttpServer((req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ headers: req.headers }));
});
try {
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
const addr = upstream.address();
if (typeof addr === "string" || addr === null) throw new Error("Expected upstream port");
const res = await fetch(`${prodUrl}/external-middleware-rewrite-with-headers`, {
headers: {
"x-middleware-test-rewrite-target": `http://127.0.0.1:${addr.port}/echo-headers`,
},
});
expect(res.status).toBe(200);
const json = (await res.json()) as { headers: Record<string, string> };
expect(json.headers["x-hello-from-middleware1"]).toBe("hello");
} finally {
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
// ('should rewrite to the external url for incoming data request
// externally rewritten'). Refs #1331: a `_next/data/<buildId>/<page>.json`
// request whose middleware rewrites to an external URL must proxy through
// — the data-request path is not allowed to short-circuit external rewrites.
it("proxies through to upstream when an external middleware rewrite hits a data request", async () => {
const { createServer: createHttpServer } = await import("node:http");
const upstream = createHttpServer((_, res) => {
res.writeHead(200, { "content-type": "text/html" });
res.end("<!doctype html><html><body>External Domain</body></html>");
});
try {
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
const addr = upstream.address();
if (typeof addr === "string" || addr === null) throw new Error("Expected upstream port");
const res = await fetch(`${prodUrl}/_next/data/test-build-id/data-external-rewrite.json`, {
headers: {
"x-nextjs-data": "1",
"x-middleware-test-rewrite-target": `http://127.0.0.1:${addr.port}/data`,
},
});
expect(res.status).toBe(200);
expect(await res.text()).toContain("External Domain");
} finally {
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
it("preserves upstream status for external middleware rewrites in production", async () => {
const { createServer: createHttpServer } = await import("node:http");
const upstream = createHttpServer((_, res) => {
res.writeHead(418, { "content-type": "text/plain" });
res.end("upstream status");
});
try {
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
const addr = upstream.address();
if (typeof addr === "string" || addr === null) throw new Error("Expected upstream port");
const res = await fetch(`${prodUrl}/external-middleware-rewrite-status`, {
headers: {
"x-middleware-test-rewrite-target": `http://127.0.0.1:${addr.port}/external`,
},
});
expect(res.status).toBe(418);
expect(await res.text()).toBe("upstream status");
} finally {
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
// Ported from Next.js:
// test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts
// and
// test/e2e/middleware-rewrites/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
it("applies next.config.js headers using the pre-middleware pathname after a rewrite", async () => {
const res = await fetch(`${prodUrl}/headers-before-middleware-rewrite`);
expect(res.status).toBe(200);
expect(res.headers.get("x-rewrite-source-header")).toBe("1");
const html = await res.text();
expect(html).toContain("Server-Side Rendered");
});
// Regression for cloudflare/vinext#1342: original request query params must
// survive a middleware rewrite into the rewrite target's getServerSideProps.
// Mirrors the dev-server coverage so the production prod-server is exercised.
// Ported from Next.js: test/e2e/edge-pages-support/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/edge-pages-support/index.test.ts
it("middleware rewrite preserves original query params into getServerSideProps in production", async () => {
const res = await fetch(`${prodUrl}/mw-rewrite-query?hello=world`);
expect(res.status).toBe(200);
const html = await res.text();
const nextDataMatch = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
expect(nextDataMatch).toBeTruthy();
const nextData = JSON.parse(nextDataMatch![1]!);
expect(nextData.props.pageProps.query).toMatchObject({ hello: "world" });
});
it("middleware rewrite to a dynamic route merges original query with route params in production", async () => {
const res = await fetch(`${prodUrl}/mw-rewrite-dynamic-query?hello=world`);
expect(res.status).toBe(200);
const html = await res.text();
const nextDataMatch = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
expect(nextDataMatch).toBeTruthy();
const nextData = JSON.parse(nextDataMatch![1]!);
expect(nextData.props.pageProps.query).toMatchObject({ id: "first", hello: "world" });
});
// Regression for cloudflare/vinext#1342 (production): middleware that
// explicitly deletes search params from `request.nextUrl` and rewrites to
// it must observe only the keys it kept. Dev coverage of the same shared
// code path exists in the integration describe above; this proves the
// prod-server path agrees.
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
// ("should clear query parameters")
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
it("middleware rewrite respects searchParams.delete on the rewrite-target URL in production", async () => {
const res = await fetch(`${prodUrl}/mw-clear-query-params?a=1&b=2&foo=bar&allowed=kept`);
expect(res.status).toBe(200);
const html = await res.text();
const nextDataMatch = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
expect(nextDataMatch).toBeTruthy();
const nextData = JSON.parse(nextDataMatch![1]!);
expect(nextData.props.pageProps.query).toEqual({ allowed: "kept" });
});
// /_next/data fetch for a middleware-rewritten page must also surface the
// original request query params in the JSON props envelope. Client-side
// navigations go through this code path, so a regression here would silently
// break query state after a rewrite even when the HTML render is correct.
it("middleware rewrite preserves original query params on _next/data JSON in production", async () => {
const res = await fetch(
`${prodUrl}/_next/data/test-build-id/mw-rewrite-query.json?hello=world`,
{ headers: { "x-nextjs-data": "1" } },
);
expect(res.status).toBe(200);
const data = (await res.json()) as {
pageProps: { query: Record<string, string | string[]> };
};
expect(data.pageProps.query).toMatchObject({ hello: "world" });
});
// Ported from Next.js:
// test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts
// and
// test/e2e/middleware-rewrites/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
it("applies next.config.js redirects before middleware rewrites in production", async () => {
const res = await fetch(`${prodUrl}/redirect-before-middleware-rewrite`, {
redirect: "manual",
});
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/about");
});
// Ported from Next.js:
// test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts
it("applies next.config.js redirects before middleware responses in production", async () => {
const res = await fetch(`${prodUrl}/redirect-before-middleware-response`, {
redirect: "manual",
});
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/about");
});
it("blocks /blocked with 403 via middleware", async () => {
const res = await fetch(`${prodUrl}/blocked`);
expect(res.status).toBe(403);
expect(res.statusText).toBe("Blocked by Middleware");
const text = await res.text();
expect(text).toContain("Access Denied");
});
it("returns 500 when middleware throws", async () => {
const res = await fetch(`${prodUrl}/middleware-throw`);
expect(res.status).toBe(500);
});
it("sets x-custom-middleware header on matched requests", async () => {
const res = await fetch(`${prodUrl}/about`);
expect(res.status).toBe(200);
expect(res.headers.get("x-custom-middleware")).toBe("active");
});
it("middleware request header overrides can delete credential headers before page handling", async () => {
const res = await fetch(`${prodUrl}/header-override-delete`, {
headers: {
authorization: "Bearer secret",
cookie: "a=1; b=2",
},
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('id="authorization">null<');
expect(html).toContain('id="cookie">null<');
expect(html).toContain('id="middleware-header">hello-from-middleware<');
});
it("does not run middleware on /api routes", async () => {
const res = await fetch(`${prodUrl}/api/hello`);
expect(res.status).toBe(200);
// Middleware matcher excludes /api, so no x-custom-middleware header
expect(res.headers.get("x-custom-middleware")).toBeNull();
});
it("preserves invalid JSON failures for Pages API routes in production", async () => {
const res = await fetch(`${prodUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: `{"message":Invalid"}`,
});
expect(res.status).toBe(400);
expect(res.statusText).toBe("Invalid JSON");
expect(await res.text()).toBe("Invalid JSON");
});
it("preserves duplicate urlencoded body keys for Pages API routes in production", async () => {
const res = await fetch(`${prodUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "tag=a&tag=b&tag=c",
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ tag: ["a", "b", "c"] });
});
it("parses empty urlencoded bodies for Pages API routes in production as {}", async () => {
const res = await fetch(`${prodUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "",
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({});
});
it("parses empty JSON bodies for Pages API routes in production as {}", async () => {
const res = await fetch(`${prodUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "",
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({});
});
it("parses application/ld+json bodies for Pages API routes in production", async () => {
const res = await fetch(`${prodUrl}/api/parse`, {
method: "POST",
headers: { "Content-Type": "application/ld+json; charset=utf-8" },
body: JSON.stringify({ title: "doc" }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ title: "doc" });
});
it("production object-form matcher requires has and missing conditions", async () => {
const noHeaderRes = await fetch(`${prodUrl}/mw-object-gated`);
expect(noHeaderRes.status).toBe(200);
expect(noHeaderRes.headers.get("x-custom-middleware")).toBeNull();
const blockedRes = await fetch(`${prodUrl}/mw-object-gated`, {
headers: {
"x-mw-allow": "1",
Cookie: "mw-blocked=1",
},
});
expect(blockedRes.status).toBe(200);
expect(blockedRes.headers.get("x-custom-middleware")).toBeNull();
const allowedRes = await fetch(`${prodUrl}/mw-object-gated`, {
headers: { "x-mw-allow": "1" },
});
expect(allowedRes.status).toBe(200);
expect(allowedRes.headers.get("x-custom-middleware")).toBe("active");
});
it("preserves binary API response bytes", async () => {
const res = await fetch(`${prodUrl}/api/binary`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/octet-stream");
const body = Buffer.from(await res.arrayBuffer());
// Must match exactly: invalid UTF-8-leading bytes + null + ASCII tail.
// This catches any accidental text() decode/re-encode in prod-server.
expect(body.equals(Buffer.from([0xff, 0xfe, 0xfd, 0x00, 0x61, 0x62, 0x63]))).toBe(true);
});
it("preserves repeated urlencoded API body keys in production", async () => {
const res = await fetch(`${prodUrl}/api/echo-body`, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
},
body: "a=1&a=2&b=3",
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({ body: { a: ["1", "2"], b: "3" } });
});
it("returns 400 for malformed JSON API bodies in production", async () => {
const res = await fetch(`${prodUrl}/api/echo-body`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: "{invalid json",
});
expect(res.status).toBe(400);
expect(await res.text()).toBe("Invalid JSON");
});
it("sends Buffer payloads from res.send() as raw bytes in production", async () => {
const res = await fetch(`${prodUrl}/api/send-buffer`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/octet-stream");
expect(res.headers.get("content-length")).toBe("3");
const body = Buffer.from(await res.arrayBuffer());
expect(body.equals(Buffer.from([1, 2, 3]))).toBe(true);
});
it("defaults to application/octet-stream for API routes without Content-Type", async () => {
const res = await fetch(`${prodUrl}/api/no-content-type`);
expect(res.status).toBe(200);
const ct = res.headers.get("content-type") ?? "";
// Must NOT default to text/html, which would cause browsers to render
// the response body as HTML. When the handler passes a string to
// res.end(), the Response constructor sets text/plain automatically,
// so we verify the dangerous text/html default is gone.
expect(ct).not.toContain("text/html");
});
it("serves normal pages without middleware interference", async () => {
const res = await fetch(`${prodUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Hello, vinext!");
});
it("preserves content-length for getServerSideProps res.end() short-circuit responses in production", async () => {
const res = await fetch(`${prodUrl}/ssr-res-end`);
expect(res.status).toBe(202);
expect(res.headers.get("content-type")).toBe("application/json");
expect(res.headers.get("content-length")).toBe("35");
expect(await res.json()).toEqual({ ok: true, source: "gssp-res-end" });
});
// Regression test for #1459: Next.js supports a Promise value for `props`
// returned from getServerSideProps. The prod worker entry must await it
// before serialising into __NEXT_DATA__ / pageProps.
it("awaits Promise-shaped getServerSideProps props in production", async () => {
const res = await fetch(`${prodUrl}/ssr-promise-props`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("SSR Promise Props");
expect(html).toContain("world");
// React SSR inserts a `<!-- -->` comment between text and expressions.
expect(html).toMatch(/count:\s*(<!--\s*-->)?\s*42/);
expect(html).toMatch(/"pageProps":\s*\{[^}]*"hello":\s*"world"/);
});
it("returns 400 for malformed percent-encoded path (not crash)", async () => {
const res = await fetch(`${prodUrl}/%E0%A4%A`);
expect(res.status).toBe(400);
const body = await res.text();
expect(body).toContain("Bad Request");
});
it("returns 400 for bare percent sign in path (not crash)", async () => {
const res = await fetch(`${prodUrl}/%`);
expect(res.status).toBe(400);
const body = await res.text();
expect(body).toContain("Bad Request");
});
it("blocks access to .vite/ build metadata directory", async () => {
// The .vite/ directory contains build manifests (ssr-manifest.json,
// manifest.json) that should not be publicly accessible.
const res = await fetch(`${prodUrl}/.vite/ssr-manifest.json`);
expect(res.status).toBe(404);
});
it("blocks access to .vite/ with percent-encoded dot", async () => {
// Ensure encoded variants like /%2Evite/ are also blocked
const res = await fetch(`${prodUrl}/%2Evite/ssr-manifest.json`);
expect(res.status).toBe(404);
});
// ── /_next/data JSON endpoint in production (issue #1330) ─────────
// Ported from Next.js: test/e2e/middleware-general/test/index.test.ts
// ("should trigger middleware for data requests", "should normalize data
// requests into page requests").
describe("/_next/data JSON endpoint", () => {
// pages-basic's next.config.mjs pins the build id to "test-build-id".
const BUILD_ID = "test-build-id";
it("returns { pageProps } JSON for a getServerSideProps page", async () => {
const res = await fetch(`${prodUrl}/_next/data/${BUILD_ID}/ssr.json`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
const json = (await res.json()) as { pageProps: { message: string } };
expect(json.pageProps.message).toBe("Hello from getServerSideProps");
});
it("returns { pageProps } JSON for a getStaticProps page (bypasses HTML cache)", async () => {
// /isr-test uses getStaticProps with revalidate. The data endpoint
// must bypass the cached HTML body and surface pageProps as JSON —
// mirrors Next.js' `isNextDataRequest` cache-bypass logic in
// base-server.ts.
const res = await fetch(`${prodUrl}/_next/data/${BUILD_ID}/isr-test.json`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
const json = (await res.json()) as { pageProps: Record<string, unknown> };
expect(json).toHaveProperty("pageProps");
expect(typeof json.pageProps).toBe("object");
});
it("normalizes the URL to /<page> BEFORE middleware runs", async () => {
const res = await fetch(`${prodUrl}/_next/data/${BUILD_ID}/ssr.json`);
expect(res.status).toBe(200);
// The middleware fixture sets `x-mw-pathname` to whatever pathname it
// observed. If `_next/data` is not normalized first, middleware sees
// the raw `/_next/data/.../ssr.json` URL — which is the failure mode
// tracked in issue #1330 and surfaced by `middleware-general` tests
// in the deploy suite.
expect(res.headers.get("x-mw-pathname")).toBe("/ssr");
expect(res.headers.get("x-custom-middleware")).toBe("active");
});
it("returns JSON 404 for an unknown page", async () => {
const res = await fetch(`${prodUrl}/_next/data/${BUILD_ID}/totally-missing-page.json`);
expect(res.status).toBe(404);
expect(res.headers.get("content-type")).toContain("application/json");
expect(await res.json()).toEqual({});
});
it("returns JSON 404 for a stale buildId", async () => {
const res = await fetch(`${prodUrl}/_next/data/wrong-build-id/ssr.json`);
expect(res.status).toBe(404);
expect(res.headers.get("content-type")).toContain("application/json");
expect(await res.json()).toEqual({});
});
});
});
describe("Production Pages Router SSR streaming", () => {
let outDir: string;
let prodServer: import("node:http").Server;
let prodUrl: string;
async function withFreshStreamingProdServer<T>(
run: (freshProdUrl: string) => Promise<T>,
): Promise<T> {
const freshOutDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-streaming-fresh-"));
let freshServer: import("node:http").Server | undefined;
try {
await fsp.symlink(
path.resolve(import.meta.dirname, "../node_modules"),
path.join(freshOutDir, "node_modules"),
"junction",
);
await buildPagesFixtureToOutDir(FIXTURE_DIR, freshOutDir);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
freshServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir: freshOutDir,
}),
);
const addr = freshServer.address() as { port: number };
return await run(`http://127.0.0.1:${addr.port}`);
} finally {
const serverToClose = freshServer;
if (serverToClose) {
await new Promise<void>((resolve) => serverToClose.close(() => resolve()));
}
fs.rmSync(freshOutDir, { recursive: true, force: true });
}
}
beforeAll(async () => {
outDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-streaming-prod-"));
await fsp.symlink(
path.resolve(import.meta.dirname, "../node_modules"),
path.join(outDir, "node_modules"),
"junction",
);
await buildPagesFixtureToOutDir(FIXTURE_DIR, outDir);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir,
}),
);
const addr = prodServer.address() as { port: number };
prodUrl = `http://127.0.0.1:${addr.port}`;
}, 60000);
afterAll(async () => {
if (prodServer) {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
if (outDir) {
fs.rmSync(outDir, { recursive: true, force: true });
}
});
it("streams Pages SSR responses incrementally in production with br compression", async () => {
// Parity target: Next.js streams Node responses via sendResponse() ->
// pipeToNodeResponse() instead of buffering the full HTML first, while
// still leaving compression enabled under next start.
// https://raw.githubusercontent.com/vercel/next.js/canary/packages/next/src/server/send-response.ts
// https://raw.githubusercontent.com/vercel/next.js/canary/packages/next/src/server/pipe-readable.ts
const response = await captureStreamedResponse(`${prodUrl}/streaming-ssr`, {
headers: { "accept-encoding": "br" },
});
const partialHtml = response.snapshot.toString("utf8");
const finalHtml = response.body.toString("utf8");
const contentType = response.headers["content-type"];
const contentEncoding = response.headers["content-encoding"];
const middlewareHeader = response.headers["x-custom-middleware"];
const transferEncoding = response.headers["transfer-encoding"];
expect(response.statusCode).toBe(200);
expect(String(contentType)).toContain("text/html");
expect(String(contentEncoding)).toBe("br");
expect(String(middlewareHeader)).toBe("active");
expect(response.headers["content-length"]).toBeUndefined();
expect(String(transferEncoding)).toBe("chunked");
expect(response.firstChunkMs).toBeGreaterThanOrEqual(0);
expect(response.firstChunkMs).toBeLessThan(400);
expect(response.endMs).toBeGreaterThanOrEqual(400);
expect(response.rawBody.byteLength).toBeGreaterThan(0);
expect(response.rawSnapshot.byteLength).toBeGreaterThan(0);
expect(partialHtml).toContain("Streaming SSR Test");
expect(partialHtml).toContain("Loading delayed chunk...");
expect(partialHtml).not.toContain("Delayed stream content loaded");
expect(finalHtml).toContain("Streaming SSR Test");
expect(finalHtml).toContain("Delayed stream content loaded");
expect(finalHtml).toContain("__NEXT_DATA__");
});
it("streams Pages SSR responses incrementally in production with gzip compression", async () => {
const response = await withFreshStreamingProdServer((freshProdUrl) =>
captureStreamedResponse(`${freshProdUrl}/streaming-ssr`, {
headers: { "accept-encoding": "gzip" },
}),
);
const partialHtml = response.snapshot.toString("utf8");
const finalHtml = response.body.toString("utf8");
expect(response.statusCode).toBe(200);
expect(String(response.headers["content-encoding"])).toBe("gzip");
expect(response.headers["content-length"]).toBeUndefined();
expect(String(response.headers["transfer-encoding"])).toBe("chunked");
expect(response.firstChunkMs).toBeGreaterThanOrEqual(0);
expect(response.firstChunkMs).toBeLessThan(400);
expect(response.endMs).toBeGreaterThanOrEqual(400);
expect(partialHtml).toContain("Loading delayed chunk...");
expect(partialHtml).not.toContain("Delayed stream content loaded");
expect(finalHtml).toContain("Delayed stream content loaded");
});
it("preserves streamed SSR bodies when middleware rewrites are merged into the response", async () => {
const res = await fetch(`${prodUrl}/streaming-ssr`);
expect(res.status).toBe(200);
expect(res.headers.get("x-custom-middleware")).toBe("active");
const html = await res.text();
expect(html).toContain("Delayed stream content loaded");
});
it("serves streamed Pages SSR HEAD requests as headers-only responses in production", async () => {
const startedAt = Date.now();
const res = await fetch(`${prodUrl}/streaming-ssr`, {
method: "HEAD",
headers: { "accept-encoding": "br" },
});
expect(res.status).toBe(200);
expect(res.headers.get("x-custom-middleware")).toBe("active");
expect(res.headers.get("content-length")).toBeNull();
expect(await res.text()).toBe("");
expect(Date.now() - startedAt).toBeLessThan(400);
});
it("strips stale content-length from streamed Pages SSR responses when gSSP sets one", async () => {
// Parity target: Next.js only sets Content-Length for unchunked render
// payloads; streamed HTML is sent without one.
// https://raw.githubusercontent.com/vercel/next.js/canary/packages/next/src/server/send-payload.ts
const response = await captureStreamedResponse(`${prodUrl}/streaming-gssp-content-length`, {
headers: { "accept-encoding": "br" },
});
const partialHtml = response.snapshot.toString("utf8");
const finalHtml = response.body.toString("utf8");
expect(response.statusCode).toBe(200);
expect(String(response.headers["content-encoding"])).toBe("br");
expect(response.headers["content-length"]).toBeUndefined();
expect(String(response.headers["transfer-encoding"])).toBe("chunked");
expect(response.firstChunkMs).toBeGreaterThanOrEqual(0);
expect(response.firstChunkMs).toBeLessThan(400);
expect(partialHtml).toContain("Loading delayed gSSP chunk...");
expect(partialHtml).not.toContain("Delayed gSSP stream content loaded");
expect(finalHtml).toContain("Streaming gSSP Content-Length Test");
expect(finalHtml).toContain("Delayed gSSP stream content loaded");
});
it("strips middleware-provided content-length when rewriting to a streamed Pages SSR response", async () => {
// Parity target: Next.js route resolution explicitly skips forwarding
// middleware content-length headers.
// https://raw.githubusercontent.com/vercel/next.js/canary/packages/next/src/server/lib/router-utils/resolve-routes.ts
const response = await withFreshStreamingProdServer((freshProdUrl) =>
captureStreamedResponse(`${freshProdUrl}/middleware-bad-content-length`, {
headers: { "accept-encoding": "br" },
}),
);
const partialHtml = response.snapshot.toString("utf8");
const finalHtml = response.body.toString("utf8");
expect(response.statusCode).toBe(200);
expect(String(response.headers["content-encoding"])).toBe("br");
expect(response.headers["content-length"]).toBeUndefined();
expect(String(response.headers["transfer-encoding"])).toBe("chunked");
expect(partialHtml).toContain("Loading delayed chunk...");
expect(partialHtml).not.toContain("Delayed stream content loaded");
expect(finalHtml).toContain("Streaming SSR Test");
expect(finalHtml).toContain("Delayed stream content loaded");
});
});
describe("Production server next.config.js features (Pages Router)", () => {
const outDir = path.resolve(FIXTURE_DIR, "dist");
let prodServer: import("node:http").Server | undefined;
let prodUrl: string;
beforeAll(async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const manifestPath = path.join(outDir, "client", ".vite", "ssr-manifest.json");
// Build if needed (tests may run in isolation)
if (!fs.existsSync(serverEntryPath) || !fs.existsSync(manifestPath)) {
await build({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(outDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: { output: { entryFileNames: "entry.js" } },
},
});
await build({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(outDir, "client"),
manifest: true,
ssrManifest: true,
rollupOptions: { input: "virtual:vinext-client-entry" },
},
});
}
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir,
}),
);
const addr = prodServer.address() as { port: number };
prodUrl = `http://127.0.0.1:${addr.port}`;
});
afterAll(async () => {
if (prodServer) {
await new Promise<void>((resolve) => prodServer!.close(() => resolve()));
}
});
it("server entry exports vinextConfig with correct shape", async () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const serverEntry = await import(pathToFileURL(serverEntryPath).href);
expect(serverEntry.vinextConfig).toBeDefined();
expect(serverEntry.vinextConfig.redirects).toBeInstanceOf(Array);
expect(serverEntry.vinextConfig.rewrites).toBeDefined();
expect(serverEntry.vinextConfig.headers).toBeInstanceOf(Array);
expect(typeof serverEntry.vinextConfig.basePath).toBe("string");
expect(typeof serverEntry.vinextConfig.trailingSlash).toBe("boolean");
});
it("applies redirects from next.config.js (/old-about -> /about)", async () => {
const res = await fetch(`${prodUrl}/old-about`, { redirect: "manual" });
expect(res.status).toBe(308); // permanent redirect
expect(res.headers.get("location")).toContain("/about");
});
it("applies redirects with repeated dynamic params in production", async () => {
const res = await fetch(`${prodUrl}/repeat-redirect/hello`, { redirect: "manual" });
expect(res.status).toBe(307);
expect(res.headers.get("location")).toBe("/docs/hello/hello");
});
it("applies beforeFiles rewrites from next.config.js (/before-rewrite -> /about)", async () => {
const res = await fetch(`${prodUrl}/before-rewrite`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
});
it("applies rewrites with repeated dynamic params in production", async () => {
const res = await fetch(`${prodUrl}/repeat-rewrite/hello`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("hello/hello");
});
it("applies afterFiles rewrites from next.config.js (/after-rewrite -> /about)", async () => {
const res = await fetch(`${prodUrl}/after-rewrite`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
});
it("does not let afterFiles rewrites override static page routes in production", async () => {
const res = await fetch(`${prodUrl}/nav-test`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Navigation Test");
expect(html).not.toContain("This is the about page.");
});
it("applies custom headers from next.config.js on /api routes", async () => {
const res = await fetch(`${prodUrl}/api/hello`);
expect(res.status).toBe(200);
expect(res.headers.get("x-custom-header")).toBe("vinext");
});
// Ported from PR #47 by @ibruno
it("applies has/missing conditions for next.config.js headers", async () => {
const guestRes = await fetch(`${prodUrl}/about`);
expect(guestRes.status).toBe(200);
expect(guestRes.headers.get("x-guest-only-header")).toBe("1");
expect(guestRes.headers.get("x-auth-only-header")).toBeNull();
const authRes = await fetch(`${prodUrl}/about`, {
headers: { Cookie: "logged-in=1" },
});
expect(authRes.status).toBe(200);
expect(authRes.headers.get("x-auth-only-header")).toBe("1");
expect(authRes.headers.get("x-guest-only-header")).toBeNull();
});
it("has/missing conditions do not see middleware-injected cookies", async () => {
// When ?inject-login is present, middleware injects logged-in=1 cookie
// into the request headers. The config has/missing conditions should
// evaluate against the updated request, not the original.
const res = await fetch(`${prodUrl}/about?inject-login`);
expect(res.status).toBe(200);
// The has:[cookie:logged-in] condition should match
expect(res.headers.get("x-auth-only-header")).toBeNull();
// The missing:[cookie:logged-in] condition should NOT match
expect(res.headers.get("x-guest-only-header")).toBe("1");
});
it("config Vary header appends instead of replacing existing values", async () => {
// The /ssr page has config headers: [{ key: "Vary", value: "Accept-Language" }].
// If the response already has a Vary header (e.g. from compression),
// the config value should be appended, not replace it.
const res = await fetch(`${prodUrl}/ssr`);
expect(res.status).toBe(200);
const vary = res.headers.get("vary") ?? "";
expect(vary).toContain("Accept-Language");
});
// afterFiles rewrites run after middleware in the App Router execution order.
// has/missing conditions on afterFiles rules should evaluate against
// middleware-modified headers, not the original pre-middleware request.
it("afterFiles rewrite has/missing conditions see middleware-injected cookies", async () => {
// Without ?mw-auth, middleware does NOT inject mw-user=1.
// The has:[cookie:mw-user] afterFiles rule should NOT match → no rewrite.
const noAuthRes = await fetch(`${prodUrl}/mw-gated-rewrite`);
expect(noAuthRes.status).toBe(404);
// With ?mw-auth, middleware injects mw-user=1 into request cookies.
// The has:[cookie:mw-user] afterFiles rule SHOULD match → rewrite to /about.
const authRes = await fetch(`${prodUrl}/mw-gated-rewrite?mw-auth`);
expect(authRes.status).toBe(200);
const html = await authRes.text();
expect(html).toContain("About");
});
// beforeFiles rewrites run after middleware per the Next.js execution order:
// headers → redirects → Middleware → beforeFiles → filesystem → afterFiles → fallback.
// has/missing conditions on beforeFiles rules should evaluate against
// middleware-modified headers, not the original pre-middleware request.
it("beforeFiles rewrite has/missing conditions see middleware-injected cookies", async () => {
// Without ?mw-auth, middleware does NOT inject mw-before-user=1.
// The has:[cookie:mw-before-user] beforeFiles rule should NOT match → 404.
const noAuthRes = await fetch(`${prodUrl}/mw-gated-before`);
expect(noAuthRes.status).toBe(404);
// With ?mw-auth, middleware injects mw-before-user=1 into request cookies.
// The has:[cookie:mw-before-user] beforeFiles rule SHOULD match → rewrite to /about.
const authRes = await fetch(`${prodUrl}/mw-gated-before?mw-auth`);
expect(authRes.status).toBe(200);
const html = await authRes.text();
expect(html).toContain("About");
});
it("serves normal pages unaffected by config rules", async () => {
const res = await fetch(`${prodUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Hello, vinext!");
});
// ── Percent-encoded paths should be decoded before config matching ──
// Config matchers must receive decoded paths so that encoded variants
// like /%6Fld-%61bout still match the /old-about redirect rule.
it("percent-encoded redirect path is decoded before config matching (prod)", async () => {
// /old-about → /about (permanent redirect). /%6Fld-%61bout decodes to /old-about.
const res = await fetch(`${prodUrl}/%6Fld-%61bout`, { redirect: "manual" });
expect(res.status).toBe(308);
expect(res.headers.get("location")).toContain("/about");
});
it("percent-encoded header path is decoded before config matching (prod)", async () => {
// /api/(.*) should receive X-Custom-Header: vinext.
// /%61pi/hello decodes to /api/hello.
const res = await fetch(`${prodUrl}/%61pi/hello`);
expect(res.headers.get("x-custom-header")).toBe("vinext");
});
it("percent-encoded rewrite path is decoded before config matching (prod)", async () => {
// /before-rewrite → /about (beforeFiles rewrite).
// /%62efore-rewrite decodes to /before-rewrite.
const res = await fetch(`${prodUrl}/%62efore-rewrite`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
});
});
describe("Static export (Pages Router)", () => {
let pagesBundlePath: string;
const exportDir = path.resolve(FIXTURE_DIR, "out");
beforeAll(async () => {
pagesBundlePath = await buildPagesFixture(FIXTURE_DIR);
}, 60_000);
afterAll(() => {
fs.rmSync(exportDir, { recursive: true, force: true });
});
it("exports static pages to HTML files", async () => {
const { staticExportPages } = await import("../packages/vinext/src/build/static-export.js");
const { pagesRouter, apiRouter } =
await import("../packages/vinext/src/routing/pages-router.js");
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const pagesDir = path.resolve(FIXTURE_DIR, "pages");
const routes = await pagesRouter(pagesDir);
const apiRoutes = await apiRouter(pagesDir);
const config = await resolveNextConfig({ output: "export" });
const result = await staticExportPages({
pagesBundlePath,
routes,
apiRoutes,
pagesDir,
outDir: exportDir,
config,
});
// Should have generated HTML files
expect(result.pageCount).toBeGreaterThan(0);
// Index page
expect(result.files).toContain("index.html");
const indexHtml = fs.readFileSync(path.join(exportDir, "index.html"), "utf-8");
expect(indexHtml).toContain("<!DOCTYPE html>");
expect(indexHtml).toContain("Hello, vinext!");
// About page
expect(result.files).toContain("about.html");
const aboutHtml = fs.readFileSync(path.join(exportDir, "about.html"), "utf-8");
expect(aboutHtml).toContain("About");
});
it("pre-renders dynamic routes from getStaticPaths", async () => {
// blog/[slug] has getStaticPaths returning hello-world and getting-started
expect(fs.existsSync(path.join(exportDir, "blog", "hello-world.html"))).toBe(true);
expect(fs.existsSync(path.join(exportDir, "blog", "getting-started.html"))).toBe(true);
const blogHtml = fs.readFileSync(path.join(exportDir, "blog", "hello-world.html"), "utf-8");
expect(blogHtml).toContain("Hello World");
expect(blogHtml).toContain("hello-world");
});
it("generates 404.html", async () => {
expect(fs.existsSync(path.join(exportDir, "404.html"))).toBe(true);
const html404 = fs.readFileSync(path.join(exportDir, "404.html"), "utf-8");
expect(html404).toContain("404");
});
it("escapes meta refresh URL to prevent HTML injection", async () => {
expect(fs.existsSync(path.join(exportDir, "redirect-xss.html"))).toBe(true);
const html = fs.readFileSync(path.join(exportDir, "redirect-xss.html"), "utf-8");
expect(html).toContain(
'content="0;url=foo&quot; /&gt;&lt;script&gt;alert(1)&lt;/script&gt;&lt;meta x=&quot;"',
);
expect(html).not.toContain("<script>alert(1)</script>");
});
it("reports errors for pages using getServerSideProps", async () => {
// The result from the first test should have errors for SSR-only pages
const { staticExportPages } = await import("../packages/vinext/src/build/static-export.js");
const { pagesRouter, apiRouter } =
await import("../packages/vinext/src/routing/pages-router.js");
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const pagesDir = path.resolve(FIXTURE_DIR, "pages");
const routes = await pagesRouter(pagesDir);
const apiRoutes = await apiRouter(pagesDir);
const config = await resolveNextConfig({ output: "export" });
const tempDir = path.resolve(FIXTURE_DIR, "out-temp");
try {
const result = await staticExportPages({
pagesBundlePath,
routes,
apiRoutes,
pagesDir,
outDir: tempDir,
config,
});
// Should report errors for getServerSideProps pages
const ssrErrors = result.errors.filter((e) => e.error.includes("getServerSideProps"));
expect(ssrErrors.length).toBeGreaterThan(0);
// Should warn about API routes
expect(result.warnings.some((w) => w.includes("API route"))).toBe(true);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("includes __NEXT_DATA__ in exported HTML", async () => {
const indexHtml = fs.readFileSync(path.join(exportDir, "index.html"), "utf-8");
expect(indexHtml).toContain("__NEXT_DATA__");
});
it("respects trailingSlash config", async () => {
const { staticExportPages } = await import("../packages/vinext/src/build/static-export.js");
const { pagesRouter, apiRouter } =
await import("../packages/vinext/src/routing/pages-router.js");
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const pagesDir = path.resolve(FIXTURE_DIR, "pages");
const routes = await pagesRouter(pagesDir);
const apiRoutes = await apiRouter(pagesDir);
const config = await resolveNextConfig({
output: "export",
trailingSlash: true,
});
const trailingDir = path.resolve(FIXTURE_DIR, "out-trailing");
try {
const result = await staticExportPages({
pagesBundlePath,
routes,
apiRoutes,
pagesDir,
outDir: trailingDir,
config,
});
// With trailingSlash, about → about/index.html
expect(result.files).toContain("about/index.html");
expect(fs.existsSync(path.join(trailingDir, "about", "index.html"))).toBe(true);
} finally {
fs.rmSync(trailingDir, { recursive: true, force: true });
}
});
});
describe("Pages Router production rewrite status reason phrases", () => {
it("drops stale statusText when middleware rewrite status overrides an API response status", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-rewrite-status-text-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
const outDir = path.join(tmpRoot, "dist");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages", "api"), { recursive: true });
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(path.join(tmpRoot, "next.config.mjs"), `export default {};\n`);
await fsp.writeFile(
path.join(tmpRoot, "middleware.ts"),
`import { NextResponse } from "next/server";
export function middleware(request) {
const url = new URL(request.url);
if (url.pathname === "/blocked") {
return NextResponse.rewrite(new URL("/api/parse", request.url), { status: 403 });
}
return NextResponse.next();
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "api", "parse.ts"),
`export default function handler(req, res) {
res.status(200).json(req.body ?? null);
}
`,
);
await build({
root: tmpRoot,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(outDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: { output: { entryFileNames: "entry.js" } },
},
});
await build({
root: tmpRoot,
configFile: false,
plugins: [vinext()],
logLevel: "silent",
build: {
outDir: path.join(outDir, "client"),
manifest: true,
ssrManifest: true,
rollupOptions: { input: "virtual:vinext-client-entry" },
},
});
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir,
}),
);
try {
const addr = prodServer.address() as { port: number };
const res = await fetch(`http://127.0.0.1:${addr.port}/blocked`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: `{"message":Invalid"}`,
});
expect(res.status).toBe(403);
expect(res.statusText).toBe("Forbidden");
expect(await res.text()).toBe("Invalid JSON");
} finally {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
});
describe("Pages Router production no-body rewrite statuses", () => {
let tmpRoot: string;
let outDir: string;
let prodServer: import("node:http").Server;
let prodUrl: string;
beforeAll(async () => {
tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-no-body-rewrite-"));
outDir = path.join(tmpRoot, "dist");
await fsp.symlink(
path.resolve(import.meta.dirname, "../node_modules"),
path.join(tmpRoot, "node_modules"),
"junction",
);
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(path.join(tmpRoot, "next.config.mjs"), `export default {};\n`);
await fsp.writeFile(
path.join(tmpRoot, "middleware.ts"),
`import { NextResponse } from "next/server";
export function middleware(request) {
const url = new URL(request.url);
const match = url.pathname.match(/^\\/status-(204|205|304)$/);
if (match) {
const response = NextResponse.rewrite(new URL("/target", request.url), {
status: Number(match[1]),
});
response.headers.set("x-custom-middleware", "active");
return response;
}
const apiMatch = url.pathname.match(/^\\/api-status-(204|205|304)$/);
if (!apiMatch) return NextResponse.next();
const response = NextResponse.rewrite(new URL("/api/target", request.url), {
status: Number(apiMatch[1]),
});
response.headers.set("x-custom-middleware", "active");
return response;
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "index.tsx"),
`export default function Home() {
return <div>home</div>;
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "target.tsx"),
`export default function TargetPage() {
return <div>TARGET PAGE</div>;
}
`,
);
await fsp.mkdir(path.join(tmpRoot, "pages", "api"), { recursive: true });
await fsp.writeFile(
path.join(tmpRoot, "pages", "api", "target.ts"),
`export default function handler(req, res) {
res.status(200).json({ ok: true });
}
`,
);
await buildPagesFixtureToOutDir(tmpRoot, outDir);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir,
noCompression: true,
}),
);
const addr = prodServer.address() as { port: number };
prodUrl = `http://127.0.0.1:${addr.port}`;
}, 60000);
afterAll(async () => {
if (prodServer) {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
if (tmpRoot) {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
for (const statusCode of [204, 205, 304]) {
it(`preserves middleware rewrite status ${statusCode} for Pages SSR responses in production`, async () => {
const res = await fetch(`${prodUrl}/status-${statusCode}`);
expect(res.status).toBe(statusCode);
expect(res.headers.get("x-custom-middleware")).toBe("active");
expect(await res.text()).toBe("");
});
}
for (const statusCode of [204, 205, 304]) {
it(`drops body headers for middleware rewrite status ${statusCode} on Pages API responses in production`, async () => {
// Parity targets:
// - Next.js skips forwarding middleware content-length in route resolution.
// https://raw.githubusercontent.com/vercel/next.js/canary/packages/next/src/server/lib/router-utils/resolve-routes.ts
// - Next.js sends bodyless responses by ending the Node response without piping the body.
// https://raw.githubusercontent.com/vercel/next.js/canary/packages/next/src/server/send-response.ts
const res = await fetch(`${prodUrl}/api-status-${statusCode}`);
expect(res.status).toBe(statusCode);
expect(res.headers.get("x-custom-middleware")).toBe("active");
expect(res.headers.get("content-type")).toBeNull();
expect(res.headers.get("content-length")).toBeNull();
expect(await res.text()).toBe("");
});
}
});
// Ported from Next.js: test/e2e/async-modules/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/async-modules/index.test.ts
//
// Verifies that page modules using top-level await (async modules) render
// their resolved data, not empty content. This covers the Pages Router
// production build path where `_app.tsx` and the page module each contain
// `await` at the module top level. Vite/Rolldown must propagate TLA through
// the generated SSR entry's static imports so the entry awaits these modules
// before reading their default exports.
describe("Pages Router top-level await (async modules) in production", () => {
let tmpRoot: string;
let outDir: string;
let prodServer: import("node:http").Server;
let prodUrl: string;
beforeAll(async () => {
tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-async-modules-"));
outDir = path.join(tmpRoot, "dist");
await fsp.symlink(
path.resolve(import.meta.dirname, "../node_modules"),
path.join(tmpRoot, "node_modules"),
"junction",
);
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.mkdir(path.join(tmpRoot, "pages", "api"), { recursive: true });
await fsp.writeFile(
path.join(tmpRoot, "pages", "_app.tsx"),
`const appValue = await Promise.resolve("hello");
export default function MyApp({ Component, pageProps }: any) {
return <Component {...pageProps} appValue={appValue} />;
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "index.tsx"),
`const value = await Promise.resolve(42);
export default function Index({ appValue }: any) {
return (
<main>
<div id="app-value">{appValue}</div>
<div id="page-value">{value}</div>
</main>
);
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "gssp.tsx"),
`const gsspValue = await Promise.resolve(42);
export async function getServerSideProps() {
return { props: { gsspValue } };
}
export default function Page({ gsspValue }: any) {
return <div id="gssp-value">{gsspValue}</div>;
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "gsp.tsx"),
`const gspValue = await Promise.resolve(42);
export async function getStaticProps() {
return { props: { gspValue } };
}
export default function Page({ gspValue }: any) {
return <div id="gsp-value">{gspValue}</div>;
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "api", "hello.ts"),
`const value = await Promise.resolve(42);
export default function handler(_req: any, res: any) {
res.status(200).json({ value });
}
`,
);
// Class-based Document. Mirrors the original Next.js async-modules
// fixture (pages/_document.jsx) which uses `class MyDocument extends
// Document` and provides `docValue` through a `static async
// getInitialProps()` override that itself uses top-level `await`. This
// requires (a) the `next/document` default export to be a class, not a
// function — otherwise React refuses to construct MyDocument and throws
// "Class constructor cannot be invoked without 'new'", and (b) the SSR
// pipeline to invoke `Document.getInitialProps()` and pass the resolved
// props to the Document element, so `this.props.docValue` is defined at
// render time (Next.js's render.tsx does this in `documentElement`).
await fsp.writeFile(
path.join(tmpRoot, "pages", "_document.tsx"),
`import Document, { Html, Head, Main, NextScript } from "next/document";
const docValue = await Promise.resolve("doc value");
export default class MyDocument extends Document<{ docValue: string }> {
static async getInitialProps(ctx: any) {
const initialProps = await Document.getInitialProps(ctx);
return { ...initialProps, docValue };
}
render() {
return (
<Html>
<Head />
<body>
<div id="doc-value">{(this.props as any).docValue}</div>
<Main />
<NextScript />
</body>
</Html>
);
}
}
`,
);
// Custom 404 page using top-level await. Mirrors Next.js async-modules
// pages/404.jsx — the page module must resolve its top-level await before
// the 404 handler renders it.
await fsp.writeFile(
path.join(tmpRoot, "pages", "404.tsx"),
`const content = await Promise.resolve("hi y'all");
export default function Custom404() {
return <h1 id="content-404">{content}</h1>;
}
`,
);
await buildPagesFixtureToOutDir(tmpRoot, outDir);
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir,
}),
);
const addr = prodServer.address() as { port: number };
prodUrl = `http://127.0.0.1:${addr.port}`;
}, 120000);
afterAll(async () => {
if (prodServer) {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
if (tmpRoot) {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("renders an index page whose _app and page both use top-level await", async () => {
const res = await fetch(`${prodUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('<div id="app-value">hello</div>');
expect(html).toContain('<div id="page-value">42</div>');
});
it("renders a page whose module-level await runs before getServerSideProps", async () => {
const res = await fetch(`${prodUrl}/gssp`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('<div id="gssp-value">42</div>');
});
it("renders a page whose module-level await runs before getStaticProps", async () => {
const res = await fetch(`${prodUrl}/gsp`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('<div id="gsp-value">42</div>');
});
it("serves an API route whose module uses top-level await", async () => {
const res = await fetch(`${prodUrl}/api/hello`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ value: 42 });
});
it("renders an async class-based _document.tsx with resolved TLA values", async () => {
const res = await fetch(`${prodUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('<div id="doc-value">doc value</div>');
});
// Ported from Next.js: test/e2e/async-modules/index.test.ts
// ('can render async 404 pages')
// The 404 page module uses top-level `await`. When the prod server falls
// through to the custom 404 it must render that module's resolved content.
it("renders a custom 404.tsx whose module uses top-level await", async () => {
const res = await fetch(`${prodUrl}/dhiuhefoiahjeoij`);
expect(res.status).toBe(404);
const html = await res.text();
expect(html).toContain(`<h1 id="content-404">hi y&#x27;all</h1>`);
});
});
// Ported from Next.js: test/e2e/import-meta/import-meta.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/import-meta/import-meta.test.ts
describe("Pages Router import.meta.url in production", () => {
let tmpRoot: string;
let outDir: string;
let prodServer: import("node:http").Server;
let prodUrl: string;
function decodeHtmlText(text: string): string {
return text.replace(/&amp;/g, "&").replace(/&quot;/g, '"');
}
function collectJavaScriptFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectJavaScriptFiles(fullPath));
} else if (entry.isFile() && entry.name.endsWith(".js")) {
files.push(fullPath);
}
}
return files;
}
beforeAll(async () => {
tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-import-meta-url-"));
outDir = path.join(tmpRoot, "dist");
await fsp.symlink(
path.resolve(import.meta.dirname, "../node_modules"),
path.join(tmpRoot, "node_modules"),
"junction",
);
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpRoot, "pages", "_app.tsx"),
`export default function MyApp({ Component, pageProps }: any) {
return <Component {...pageProps} />;
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "index.tsx"),
`export default function Page() {
const data = { url: import.meta.url };
return <div id="test-data">{JSON.stringify(data)}</div>;
}
`,
);
await buildPagesFixtureToOutDir(tmpRoot, outDir);
const { runPrerender } = await import("../packages/vinext/src/build/run-prerender.js");
await runPrerender({
root: tmpRoot,
pagesBundlePath: path.join(outDir, "server", "entry.js"),
concurrency: 1,
});
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
prodServer = unwrapStartedProdServer(
await startProdServer({
port: 0,
host: "127.0.0.1",
outDir,
}),
);
const addr = prodServer.address() as { port: number };
prodUrl = `http://127.0.0.1:${addr.port}`;
}, 120000);
afterAll(async () => {
if (prodServer) {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
if (tmpRoot) {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("preserves the page module file URL during server rendering", async () => {
const res = await fetch(`${prodUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
const match = html.match(/<div id="test-data">([^<]*)<\/div>/);
expect(match).not.toBeNull();
const data = JSON.parse(decodeHtmlText(match![1])) as { url: string };
expect(data.url).toMatch(/^file:\/\/\//);
expect(data.url).toMatch(/\/pages\/index\.tsx$/);
expect(data.url).not.toContain("/dist/server/entry.js");
});
it("normalizes the page module file URL in the client page chunk", () => {
const jsFiles = collectJavaScriptFiles(path.join(outDir, "client"));
const clientCode = jsFiles.map((file) => fs.readFileSync(file, "utf8")).join("\n");
expect(clientCode).toContain("file:///ROOT/pages/index.tsx");
expect(clientCode).not.toContain("/dist/server/entry.js");
});
});
describe("router __NEXT_DATA__ correctness (Pages Router)", () => {
let routerServer: ViteDevServer;
let routerBaseUrl: string;
beforeAll(async () => {
({ server: routerServer, baseUrl: routerBaseUrl } = await startFixtureServer(FIXTURE_DIR));
});
afterAll(async () => {
await routerServer?.close();
});
it("dynamic route params are included in __NEXT_DATA__.query", async () => {
const res = await fetch(`${routerBaseUrl}/blog/hello-world`);
expect(res.status).toBe(200);
const html = await res.text();
const match = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
expect(match).toBeTruthy();
const nextData = JSON.parse(match![1]);
expect(nextData.query).toEqual({ slug: "hello-world" });
expect(nextData.page).toBe("/blog/[slug]");
});
it("__NEXT_DATA__.page is the route pattern, not the actual path", async () => {
const res = await fetch(`${routerBaseUrl}/posts/hello-world`);
expect(res.status).toBe(200);
const html = await res.text();
const match = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
const nextData = JSON.parse(match![1]);
expect(nextData.page).toBe("/posts/[id]");
expect(nextData.query.id).toBe("hello-world");
});
it("catch-all route pattern in __NEXT_DATA__.page", async () => {
const res = await fetch(`${routerBaseUrl}/docs/a/b/c`);
expect(res.status).toBe(200);
const html = await res.text();
const match = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
const nextData = JSON.parse(match![1]);
expect(nextData.page).toBe("/docs/[...slug]");
});
it("__NEXT_DATA__ includes isFallback: false", async () => {
const res = await fetch(`${routerBaseUrl}/blog/hello-world`);
const html = await res.text();
const match = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
const nextData = JSON.parse(match![1]);
expect(nextData.isFallback).toBe(false);
});
it("static page __NEXT_DATA__.page is the pathname", async () => {
const res = await fetch(`${routerBaseUrl}/about`);
const html = await res.text();
const match = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
const nextData = JSON.parse(match![1]);
expect(nextData.page).toBe("/about");
});
it("shallow-test page returns correct __NEXT_DATA__ with GSSP props", async () => {
const res = await fetch(`${routerBaseUrl}/shallow-test`);
expect(res.status).toBe(200);
const html = await res.text();
const match = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
const nextData = JSON.parse(match![1]);
expect(nextData.page).toBe("/shallow-test");
expect(nextData.props.pageProps.gsspCallId).toBeGreaterThan(0);
});
// Ported from Next.js: test/e2e/middleware-dynamic-basepath-matcher-rewrites
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-dynamic-basepath-matcher-rewrites
// Regression test for GitHub issue #1196 — catch-all + basePath + rewrites + middleware.
it("catch-all route params are preserved with basePath + rewrites + middleware", async () => {
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-catchall-basepath-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
try {
await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true });
await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(
path.join(tmpRoot, "next.config.mjs"),
`export default {
basePath: "/docs",
async rewrites() {
return {
beforeFiles: [
{ source: "/before-rewrite", destination: "/about" },
],
};
},
};\n`,
);
await fsp.writeFile(
path.join(tmpRoot, "middleware.ts"),
`import { NextResponse } from "next/server";
export const config = { matcher: "/:path*" };
export default function middleware() {
return NextResponse.next();
}
`,
);
await fsp.writeFile(
path.join(tmpRoot, "pages", "[...path].tsx"),
`export default function CatchAllPage({ path }: { path: string[] }) {
return (
<div>
<h1 data-testid="page-title">CatchAll</h1>
<p data-testid="query-path">{JSON.stringify(path)}</p>
</div>
);
}
export async function getServerSideProps({ params }: { params: { path: string[] } }) {
return { props: { path: params.path } };
}
`,
);
const { server, baseUrl } = await startFixtureServer(tmpRoot);
try {
const res = await fetch(`${baseUrl}/docs/first`);
expect(res.status).toBe(200);
const html = await res.text();
const match = html.match(/<script>window\.__NEXT_DATA__\s*=\s*({.*?})<\/script>/);
expect(match).toBeTruthy();
const nextData = JSON.parse(match![1]);
expect(nextData.page).toBe("/[...path]");
expect(nextData.query).toEqual({ path: ["first"] });
expect(html).toContain("CatchAll");
} finally {
await server.close();
}
} finally {
await fsp.rm(tmpRoot, { recursive: true, force: true });
}
});
});
describe("Pages Router dev ISR regeneration", () => {
it("wraps stale regeneration in a fresh unified request context", async () => {
vi.resetModules();
let regenPromise: Promise<void> | null = null;
const isrSetSpy = vi.fn().mockResolvedValue(undefined);
vi.doMock("../packages/vinext/src/server/isr-cache.js", async () => {
const actual = await vi.importActual<
typeof import("../packages/vinext/src/server/isr-cache.js")
>("../packages/vinext/src/server/isr-cache.js");
return {
...actual,
getRevalidateDuration: vi.fn(() => 1),
isrGet: vi.fn().mockResolvedValue({
isStale: true,
value: {
value: actual.buildPagesCacheValue("<html><body>stale</body></html>", {
timestamp: 1,
message: "stale",
}),
cacheState: "stale",
},
}),
isrSet: isrSetSpy,
triggerBackgroundRegeneration: vi.fn((_key: string, renderFn: () => Promise<void>) => {
regenPromise = renderFn();
}),
};
});
try {
const [
{ createSSRHandler },
{ getRequestContext, isInsideUnifiedScope },
{ getRequestExecutionContext, runWithExecutionContext },
] = await Promise.all([
import("../packages/vinext/src/server/dev-server.js"),
import("../packages/vinext/src/shims/unified-request-context.js"),
import("../packages/vinext/src/shims/request-context.js"),
]);
let parentRequestTags: string[] = [];
let regenSawUnifiedScope = false;
let regenTags: string[] = [];
let regenExecutionContext: unknown;
let regenUnifiedExecutionContext: unknown;
let regenSsrContext: unknown;
let regenI18nContext: unknown;
let appTreeWrapCount = 0;
const App = Object.assign(({ Component, pageProps }: any) => Component(pageProps), {
getInitialProps: vi.fn(async () => ({
appLevel: "preserved",
pageProps: { fromApp: true },
})),
});
const outerExecutionContext = {
waitUntil() {},
};
const routeFile = path.join(FIXTURE_DIR, "pages", "isr-test.tsx");
const loadModule = async (id: string) => {
// ALS registration side-effects loaded at createSSRHandler startup
if (id === "vinext/head-state" || id === "vinext/router-state") {
return {};
}
if (id === "next/router") {
return {
setSSRContext() {
getRequestContext().currentRequestTags.push("outer-tag");
parentRequestTags = [...getRequestContext().currentRequestTags];
},
wrapWithRouterContext(element: unknown) {
appTreeWrapCount += 1;
return element;
},
};
}
if (id === path.join(FIXTURE_DIR, "pages", "_app")) {
return { default: App };
}
if (id === routeFile) {
return {
default() {
return null;
},
async getStaticProps() {
regenSawUnifiedScope = isInsideUnifiedScope();
regenTags = [...getRequestContext().currentRequestTags];
regenExecutionContext = getRequestExecutionContext();
regenUnifiedExecutionContext = getRequestContext().executionContext;
regenSsrContext = getRequestContext().ssrContext;
regenI18nContext = getRequestContext().i18nContext;
return {
props: {
timestamp: Date.now(),
message: "fresh",
},
revalidate: 1,
};
},
};
}
throw new Error(`Unexpected module load: ${id}`);
};
const server = {
transformIndexHtml: vi.fn(async (_url: string, html: string) => html),
} as unknown as ViteDevServer;
const runner = { import: loadModule };
const handler = createSSRHandler(
server,
runner,
[
{
pattern: "/isr-test",
patternParts: ["isr-test"],
filePath: routeFile,
isDynamic: false,
params: [],
},
],
path.join(FIXTURE_DIR, "pages"),
);
const finishListeners: Array<() => void> = [];
const res = {
statusCode: 200,
on(event: string, listener: () => void) {
if (event === "finish") {
finishListeners.push(listener);
}
return this;
},
writeHead: vi.fn(function (this: { statusCode: number }, status: number) {
this.statusCode = status;
return this;
}),
end: vi.fn(() => {
for (const listener of finishListeners) {
listener();
}
}),
} as any;
await runWithExecutionContext(outerExecutionContext, () =>
handler({ method: "GET", headers: {} } as any, res, "/isr-test"),
);
expect(parentRequestTags).toEqual(["outer-tag"]);
expect(res.writeHead).toHaveBeenCalledWith(
200,
expect.objectContaining({
"X-Vinext-Cache": "STALE",
"x-nextjs-cache": "STALE",
}),
);
if (!regenPromise) {
throw new Error("expected stale ISR request to start background regeneration");
}
const pendingRegen = regenPromise;
await Promise.resolve(pendingRegen);
expect(regenSawUnifiedScope).toBe(true);
expect(regenTags).toEqual([]);
expect(regenExecutionContext).toBeNull();
expect(regenUnifiedExecutionContext).toBeNull();
expect(regenSsrContext).toMatchObject({
pathname: "/isr-test",
query: {},
asPath: "/isr-test",
});
expect(regenI18nContext).toBeNull();
expect(appTreeWrapCount).toBe(1);
expect(isrSetSpy).toHaveBeenCalledOnce();
expect(isrSetSpy.mock.calls[0]?.[1]).toMatchObject({
kind: "PAGES",
pageData: {
appLevel: "preserved",
pageProps: {
fromApp: true,
message: "fresh",
},
},
});
} finally {
vi.doUnmock("../packages/vinext/src/server/isr-cache.js");
vi.resetModules();
vi.restoreAllMocks();
}
});
});