mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
33526bd89c
Middleware cookies set through NextResponse only updated Set-Cookie, so cookies() in the same request could not observe values written by middleware. External middleware rewrites were normalized to pathname and search, so cross-origin destinations were routed locally instead of being proxied. Mirror middleware cookie mutations into x-middleware-set-cookie and merge that internal header back into the request cookie store. Preserve cross-origin rewrite URLs through middleware execution and proxy them across app, pages, prod, and deploy request paths, while stripping internal middleware headers from client and upstream responses.
3924 lines
146 KiB
TypeScript
3924 lines
146 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;
|
|
}
|
|
|
|
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 });
|
|
}
|
|
`,
|
|
);
|
|
}
|
|
|
|
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 "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);
|
|
}
|
|
|
|
res.on("end", 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);
|
|
}
|
|
});
|
|
});
|
|
|
|
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.");
|
|
});
|
|
|
|
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");
|
|
const firstHtml = await first.text();
|
|
expect(firstHtml).not.toContain("nonce=");
|
|
|
|
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:");
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
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("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");
|
|
});
|
|
|
|
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("keeps ISR cache-fill rerenders isolated from the streamed 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__");
|
|
});
|
|
|
|
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("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" } });
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
|
|
// --- 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("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");
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
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("SSR renders unlisted path with getStaticPaths fallback: true (on-demand)", async () => {
|
|
// In dev/SSR mode, fallback: true still renders fully (same as blocking)
|
|
// because data is always available via on-demand SSR.
|
|
const res = await fetch(`${baseUrl}/products/unknown`);
|
|
expect(res.status).toBe(200);
|
|
const html = await res.text();
|
|
expect(html).toMatch(/Product\s*(<!-- -->)?\s*unknown/);
|
|
expect(html).toMatch(/Product ID:.*unknown/);
|
|
// isFallback should be false since we always SSR fully
|
|
expect(html).toMatch(/isFallback:.*false/);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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}/_vinext/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}/_vinext/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}/_vinext/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 () => {
|
|
await server?.close();
|
|
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" },
|
|
});
|
|
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" },
|
|
});
|
|
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]+["']/);
|
|
expect(code).not.toContain(":slug+");
|
|
expect(code).not.toContain(":slug*");
|
|
} 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("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" },
|
|
);
|
|
|
|
expect(() =>
|
|
configPlugin.configResolved({
|
|
command: "serve",
|
|
configFile: false,
|
|
plugins: [
|
|
{ name: "vite:react-babel" },
|
|
{ name: "vite:react-refresh" },
|
|
{ name: "vite:react-babel" },
|
|
{ name: "vite:react-refresh" },
|
|
],
|
|
}),
|
|
).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 output exists
|
|
const assetsDir = path.join(outDir, "client", "assets");
|
|
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();
|
|
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 res = await fetch(`http://127.0.0.1:${addr.port}/docs/counter`);
|
|
expect(res.status).toBe(200);
|
|
const html = await res.text();
|
|
expect(html).toContain('href="/docs/assets/');
|
|
expect(html).toContain('src="/docs/assets/');
|
|
} 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(async (req, res) => {
|
|
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);
|
|
});
|
|
|
|
// 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);
|
|
const ssrHtml = await ssrRes.text();
|
|
expect(ssrHtml).toContain("Server-Side Rendered");
|
|
|
|
// 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: 404 for unknown route
|
|
const notFoundRes = await fetch(`${prodUrl}/nonexistent`);
|
|
expect(notFoundRes.status).toBe(404);
|
|
} 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");
|
|
});
|
|
|
|
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" nonce="pages-prod" src="\/[^"]+"/);
|
|
expect(html).toMatch(/<link rel="modulepreload" nonce="pages-prod" href="\/[^"]+"/);
|
|
});
|
|
|
|
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
|
|
// 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");
|
|
});
|
|
|
|
// 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" });
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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("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" /><script>alert(1)</script><meta x=""',
|
|
);
|
|
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("");
|
|
});
|
|
}
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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;
|
|
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) {
|
|
return element;
|
|
},
|
|
};
|
|
}
|
|
|
|
if (id === routeFile) {
|
|
return {
|
|
default() {
|
|
return null;
|
|
},
|
|
async getStaticProps() {
|
|
regenSawUnifiedScope = isInsideUnifiedScope();
|
|
regenTags = [...getRequestContext().currentRequestTags];
|
|
regenExecutionContext = getRequestExecutionContext();
|
|
regenUnifiedExecutionContext = getRequestContext().executionContext;
|
|
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",
|
|
}),
|
|
);
|
|
|
|
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(isrSetSpy).toHaveBeenCalledOnce();
|
|
} finally {
|
|
vi.doUnmock("../packages/vinext/src/server/isr-cache.js");
|
|
vi.resetModules();
|
|
vi.restoreAllMocks();
|
|
}
|
|
});
|
|
});
|